#!/usr/bin/env bash # ============================================================================= # v-ui.sh —— S0 尺子③:浏览器级判据(**客户端 UI 必须过这一关**) # 规格来源:交付物/功能打包到基础插件-分步实施方案-20260926.md §3.10.1(浏览器级行)· §3.10.2 S0-4 · §5 第 5 层 # ----------------------------------------------------------------------------- # 为什么非要有这把尺子(方案 §4-A1 / §5 三条铁律第 1 条): # `node --check` +「client bundle 可组合」+「boot 本包零报错」**三条全绿也证明不了客户端能加载** # ⇒ UI 类交付的唯一有效判据 = **真壳里出现卡片 + JS 错误 0 + 无 `Failed to load plugins`** # ----------------------------------------------------------------------------- # 退出码:rc=0 绿(三条判据全过)/rc=1 红/rc=3 **未取到**(本机没有运行中的真壳;⛔ 3 不是绿) # 用法:bash v-ui.sh [--cdp http://127.0.0.1:9330] # ----------------------------------------------------------------------------- # ⛔ 本脚本**不自动启动真壳**:真壳的开发态目录属**客户端线**(另一条 lane,归属不明 ⇒ 只报告不动手, # R7-边界),且启动会写它的运行态、污染本棒刚取的基线。⇒ 未取到时**只给出启动指引**。 # ============================================================================= set -u SELF_DIR="$(cd "$(dirname "$0")" && pwd -W)" [ -d "$SELF_DIR" ] || SELF_DIR="$(cd "$(dirname "$0")" && pwd)" WS="$(cd "$SELF_DIR/../.." && pwd -W)" [ -d "$WS" ] || WS="$(cd "$SELF_DIR/../.." && pwd)" OUT="${P39_OUT:-$WS/tmp/p39}" NODE="${NODE:-E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe}" CDP="${P39_CDP:-http://127.0.0.1:9330}" MARKER_FAIL="Failed to load plugins" [ "${1:-}" = "--cdp" ] && CDP="$2" mkdir -p "$OUT" # ---- ① 先探有没有真壳在跑(不跑就不猜)-------------------------------------- if ! curl -s --max-time 4 "$CDP/json/list" > "$OUT/.cdp-list.json" 2>/dev/null; then echo "⚠️ 未取到(rc=3)|判据【浏览器级】:CDP 端 $CDP 不可达 ⇒ 本机当前**没有运行中的真壳**" echo " ⛔ 本脚本 **不自动启动真壳**(开发态目录属客户端线,另属一条 lane;且会写其运行态、污染刚取的基线)" echo " 启动指引(在**客户端线**窗口内执行,⛔ 本棒不执行):" echo " 启动器:E:/ProgramData/AIProject/dsh-ai1net-desktop/.workbuddy/_devkit/launch-desktop-dev-017.mts" echo " 环境: DSH_HOME=E:/github/dsh-desktop-0.1.7rc2/apps/desktop/.desktop-build/development/home-adapt" echo " DSH_DESKTOP_DSH_DIR=<同上>/project-adapt" echo " DSH_DESKTOP_PRIMARY_RUNTIME_DIR=E:/github/dsh-desktop-0.1.7rc2/apps/desktop/.desktop-build/targets/win-x64/runtime/primary-runtime" echo " DSH_DESKTOP_HOST_INSPECT_PORT=9330" echo ' ⇒ 壳起来后重跑本脚本即可取到读数(⛔ 不许拿「三条静态全绿」顶替本面)' exit 3 fi # ---- ② 真壳在 ⇒ 走 CDP:刷新一次页面,收 JS 错误,再读 DOM ------------------- cat > "$OUT/_ui_probe.mjs" <<'JSEOF' const cdp = process.argv[2]; const markerFail = process.argv[3]; const list = await (await fetch(cdp + '/json/list')).json(); const pages = list.filter((t) => t.type === 'page' && !String(t.url).startsWith('devtools://')); if (pages.length === 0) { console.log(JSON.stringify({ verdict: 'UNAVAILABLE', reason: 'no page target' })); process.exit(3); } const page = pages.find((t) => String(t.url).startsWith('dsh-app://')) || pages[0]; const ws = new WebSocket(page.webSocketDebuggerUrl); let id = 0; const pending = new Map(); const errors = []; const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); return; } if (m.method === 'Log.entryAdded' && m.params?.entry?.level === 'error') errors.push('log: ' + String(m.params.entry.text).slice(0, 160)); if (m.method === 'Runtime.exceptionThrown') errors.push('exception: ' + String(m.params?.exceptionDetails?.text || '').slice(0, 160)); if (m.method === 'Runtime.consoleAPICalled' && m.params?.type === 'error') errors.push('console.error: ' + (m.params.args || []).map((a) => String(a.value ?? a.description ?? '')).join(' ').slice(0, 160)); }); await new Promise((res, rej) => { ws.addEventListener('open', res); ws.addEventListener('error', () => rej(new Error('ws error'))); setTimeout(() => rej(new Error('ws timeout')), 8000); }); await send('Runtime.enable'); await send('Log.enable'); await send('Page.enable'); await send('Page.reload', { ignoreCache: false }); await new Promise((r) => setTimeout(r, 7000)); const expr = `(() => { const b = document.body ? (document.body.innerText || '') : ''; const root = document.querySelector('#root'); return JSON.stringify({ url: location.href, title: document.title, rootLen: root ? root.innerHTML.length : -1, navCount: document.querySelectorAll('nav').length, cardNodes: document.querySelectorAll('[class*=card],[data-card]').length, hasFailedText: b.includes(${JSON.stringify(markerFail)}), bodyHead: b.slice(0, 300) }); })()`; const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }); let dom = null; try { dom = JSON.parse(r.result.value); } catch (e) { dom = { parseError: String(e) }; } const checks = { '页面是真壳渲染的(dsh-app://)': String(dom.url || '').startsWith('dsh-app://'), '页面画出内容(#root 非空)': (dom.rootLen || 0) > 0, 'JS 错误 0': errors.length === 0, '无 "Failed to load plugins"': dom.hasFailedText === false, }; const bad = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k); console.log(JSON.stringify({ target: { url: page.url, title: page.title }, dom, jsErrors: errors, checks, verdict: bad.length ? 'RED' : 'GREEN' }, null, 1)); process.exit(bad.length ? 1 : 0); JSEOF TARGET_URL="$(tr ',' '\n' < "$OUT/.cdp-list.json" | sed -n 's/.*"url":"\(dsh-app:[^"]*\)".*/\1/p' | head -1)" echo "── 【浏览器级】CDP=$CDP 靶页=${TARGET_URL:-(未识别到 dsh-app:// 页)}" "$NODE" "$OUT/_ui_probe.mjs" "$CDP" "$MARKER_FAIL" 2>&1 | sed 's/^/ /' | cut -c1-200 RC=${PIPESTATUS[0]} case "$RC" in 0) echo "✅ 绿(rc=0)|判据【浏览器级】:卡片/内容出现 + JS 错误 0 + 无 Failed to load plugins"; exit 0 ;; 3) echo "⚠️ 未取到(rc=3)|判据【浏览器级】:CDP 有端点但无可用 page 靶"; exit 3 ;; *) echo "❌ 红(rc=$RC)|判据【浏览器级】:三条里至少一条不成立(原始输出见上)"; exit 1 ;; esac