// 真跑 render():把 board.html 的页面脚本放进 vm 的**全局作用域**执行, // 这样顶层 `function render(){}` 会挂到 sandbox 对象上,能真调到。 // ⛔ 上次用 `new Function()` 是假绿 —— 函数关在局部,取到 undefined,等于没跑。 const fs = require('fs'), http = require('http'), vm = require('vm'); const path = require('path'); const P = process.env.BOARD_HTML || path.join(__dirname, 'board.html'); const SNAP_URL = process.env.BOARD_SNAP || 'http://127.0.0.1:20099/board.json'; // 🔴 2026-10-05:支持**盘上快照**(自测/离线用)—— ⛔ 不必起服务,⛔ 不依赖网络。 const SNAP_FILE = process.env.BOARD_SNAP_FILE || ''; // 分母口径断言可被外部指定(自测会传期望值;不传则只报数不判) const WANT_PASS = process.env.BOARD_WANT_PASS || ''; const html = fs.readFileSync(P, 'utf8'); const blocks = [...html.matchAll(/]*>([\s\S]*?)<\/script>/gi)].map(m => m[1]); const code = blocks.join('\n;\n'); const els = {}; function mkEl(id) { const e = { id, hidden: false, textContent: '', innerHTML: '', style: {}, children: [], dataset: {}, // ← 页面用 dataset.base 存静态文案 classList: { toggle(){}, add(){}, remove(){}, contains(){ return false; } }, appendChild(c){ this.children.push(c); return c; }, insertBefore(c){ this.children.push(c); return c; }, setAttribute(){}, getAttribute(){ return null; }, addEventListener(){}, removeAttribute(){}, focus(){}, blur(){}, click(){}, querySelector(){ return null; }, querySelectorAll(){ return []; }, getBoundingClientRect(){ return {width:900,height:500,top:0,left:0}; }, getContext(){ const noop=()=>{}; return {clearRect:noop,fillRect:noop,beginPath:noop,moveTo:noop,lineTo:noop, stroke:noop,fill:noop,arc:noop,fillText:noop,save:noop,restore:noop, translate:noop,scale:noop,closePath:noop,setLineDash:noop,bezierCurveTo:noop, measureText:()=>({width:10})}; }, parentNode: null, offsetWidth: 900, offsetHeight: 500, offsetTop: 0, }; return e; } const document = { getElementById(id){ return els[id] || (els[id] = mkEl(id)); }, createElement(t){ return mkEl('__'+t); }, createElementNS(){ return mkEl('__ns'); }, querySelector(){ return null; }, querySelectorAll(){ return []; }, addEventListener(){}, removeEventListener(){}, body: mkEl('body'), documentElement: mkEl('html'), head: mkEl('head'), createTextNode(t){ return {textContent:t}; }, }; let rafQ = []; const sandbox = { document, console, window: null, navigator: { userAgent: 'node' }, location: { protocol: 'http:', hash: '', search: '', href: 'http://127.0.0.1:20099/' }, requestAnimationFrame: fn => { rafQ.push(fn); return rafQ.length; }, cancelAnimationFrame: () => {}, setTimeout: () => 0, // ⛔ 不开轮询 clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, fetch: () => Promise.reject(new Error('probe: fetch disabled')), localStorage: { getItem(){return null;}, setItem(){}, removeItem(){} }, matchMedia: () => ({ matches:false, addEventListener(){} }), addEventListener(){}, removeEventListener(){}, alert(){}, getComputedStyle: () => ({ getPropertyValue: () => '' }), }; sandbox.window = sandbox; sandbox.globalThis = sandbox; sandbox.self = sandbox; let bootErr = null; try { vm.createContext(sandbox); vm.runInContext(code, sandbox, { filename: 'board.html', timeout: 15000 }); } catch (e) { bootErr = e; } console.log('脚本装载 :', bootErr ? ('❌ ' + bootErr.message) : '✅ OK'); console.log('render 类型 :', typeof sandbox.render); console.log('renderProject :', typeof sandbox.renderProject); console.log('paintScope :', typeof sandbox.paintScope); console.log(''); function run(snap) { console.log('快照顶层键数 :', Object.keys(snap).length); console.log('顶层有 acc? :', Object.prototype.hasOwnProperty.call(snap,'acc')); console.log('goal.acceptance 条数:', Object.keys((snap.goal||{}).acceptance||{}).length); console.log(''); let err=null, out=''; // 清掉桩里 fetch reject 留下的残留(那是**测试桩**造成的,⛔ 不是页面 bug) if (els['err']) els['err'].textContent = ''; try { const rp = sandbox.renderProject || sandbox.render; out = rp(snap) || ''; // ← 原来炸的就是这条 } catch(e){ err = e; } const errEl = els['err']; // 🔴 renderProject 直接写 `#proj`(⛔ 不是返回值、⛔ 也不是 #project) // ⚠️ 2026-10-05:**目标 id / 归属判据 / 工作区**那几项落在**右上角 `?` 的 `#projTip`** 里, // ⛔ 不在 `#proj` ⇒ 只读 `#proj` 会漏掉它们(实测"目标 id 没渲染"就是这么假红的)。 const _tip = (els['projTip'] && els['projTip'].innerHTML) || ''; const outHtml = (out || (els['proj'] ? (els['proj'].innerHTML||'') : '')) + '\n' + _tip; console.log('render() 抛异常:', err ? ('❌ ' + err.message) : '✅ 无'); if (err) console.log(' 位置:', (err.stack||'').split('\n')[1]); console.log('#err 文案 :', errEl && errEl.textContent ? ('❌ ' + errEl.textContent.slice(0,160)) : '✅ 空(未报错)'); console.log('#proj 产物长度 :', outHtml.length); console.log('产物含 accRow :', outHtml.indexOf('accRow') >= 0 ? '✅' : '❌'); console.log('产物含 完成情况:', outHtml.indexOf('完成情况') >= 0 ? '✅' : '❌'); const m = outHtml.match(/(\d+)\s*(?:\/|/)\s*(\d+)\s*通过/); console.log('通过率数值 :', m ? m[0] : '(未渲染出)'); /* 🔴🔴 2026-10-05 加:**静默错文案**这一族(⛔ 不抛异常,只画错值 ⇒ 上面那些断言全绿漏过)。 用户报障逐字:「看板中目标还是空的 本项目 目标(未声明主题)(未声明目标)」。 真因=目标数据在 `d.project` / `d.goal` 下,而代码裸写 `d.short` / `d.criteria` / `d.id` … ⇒ 取到 `undefined` ⇒ **悄悄**显示兜底文案与 '—',⛔ 一句异常都不抛。 🔴 判据=**拿快照里的真值去比对渲染产物**("没炸" ≠ "画对了")。 */ let silent = 0; ['(未声明主题)', '(未声明目标)'].forEach((t) => { if (outHtml.indexOf(t) >= 0) { console.log('兜底文案 : ❌ 出现了 ' + t + ' ⇒ 取值层级错'); silent++; } }); if (!silent) console.log('兜底文案 : ✅ 未出现'); const _pj = snap.project || {}, _gj = snap.goal || {}; const _title = _gj.title || _pj.title; /* 🔴🔴 2026-10-05 **「主题真值」口径已改**(用户逐字:「目标 vibe-product 提取 … 改为 vibe-product 目标:提取 …」)⇒ 目标行**不再**渲染 `goal.short`(主题简称), 改为渲染【所属工作区真实目录名 `ws_name`】+【目标全称 `goal.title`】。 ⚠️ 旧断言查的是 `goal.short` ⇒ 本区格(`goal.short='本机协作'` ≠ `ws_name='ai1net-dsh-server'`) 会**假红**;而 peer 格恰好两者同字面 ⇒ **假绿**。⇒ 旧断言已失效,⛔ 不许留。 ✅ 新判据=**双查**:pill 必须画出 `ws_name`,标题必须画出 `title`(各查一次,互不遮掩)。 */ const _wsn = String(snap.ws_name || '').trim(); if (_wsn) { if (outHtml.indexOf(_wsn) >= 0) console.log('工作区真值 : ✅ 渲染出「' + _wsn + '」'); else { console.log('工作区真值 : ❌ 快照有 ws_name「' + _wsn + '」却没渲染出来'); silent++; } } else { console.log('工作区真值 : ⚠️ 快照没有 ws_name(后端没给?目标行 pill 会退化成「—」)'); } if (_title) { if (outHtml.indexOf(_title) >= 0) console.log('目标真值 : ✅ 渲染出(' + _title.slice(0, 24) + '…)'); else { console.log('目标真值 : ❌ 快照有 title 却没渲染出来'); silent++; } } /* 🔴🔴 2026-10-05 加:**目标行必须是「目标 [工作区] 标题」这个形态**(用户逐字: 「vibe-product 目标:提取 …」)。 ⛔ 判据是**产物形态**,不是"两个值分别出现过" —— 两个值都渲染了,但顺序/标签错了 (比如 pill 跑到标题后面、或「目标」标签丢了)同样不合用户口径。 ✅ 真跑:拿**真产物**,去标签后**按令牌顺序**咬住 `目标` → `ws_name` → 标题。 🔴🔴 坑(本轮实测):⛔ **不能**用 `/<\/?[^>]*>/g` 去标签 —— 标题里带 `https://…` 时,正则会把 `<` 到下一个 `>` 之间整段吃掉(`//www.tiaoyue.com/ 的设计风格…` 全没了) ⇒ `indexOf(标题)` 恒 -1 ⇒ **假红**("标题没渲染"其实渲染了)。 ⇒ ✅ 只剥**本文件已知的真实标签**(``/``/`
` 之类), ⛔ 不用宽泛的 `<[^>]*>`。 */ if (_wsn && _title) { const _flat = outHtml .replace(/<\/?(?:b|i|u|em|strong|span|div|code|ul|li|a|br|p|small|sup|sub|svg|path|canvas)(?:\s[^<>]*)?\/?>/gi, ' ') .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') .replace(/\s+/g, ''); const _needle = _title.replace(/\s+/g, ''); const _iLead = _flat.indexOf('目标'), _iWs = _flat.indexOf(_wsn), _iTi = _flat.indexOf(_needle); if (_iLead >= 0 && _iWs > _iLead && _iTi > _iWs) { console.log('目标行形态 : ✅ 目标 → ' + _wsn + ' → 标题(顺序正确)'); } else { console.log('目标行形态 : ❌ 期望「目标 → 工作区 → 标题」,实得 idx(目标)=' + _iLead + ' idx(工作区)=' + _iWs + ' idx(标题)=' + _iTi); silent++; } } if (_pj.id) { if (outHtml.indexOf(String(_pj.id)) >= 0) console.log('目标 id : ✅ ' + _pj.id); else { console.log('目标 id : ❌ 没渲染(criteria/id 取错层级)'); silent++; } } /* 🔴🔴 2026-10-05 加:**「任务类别」整行必须不在版面上**(用户逐字: 「任务类别(声明任务类别)这个没用就删除 看着烦」)。 ⚠️ 判据是「**版面上没有**」而不是「函数里没有」—— 所以拿 `renderProject()` 的**真产物**判, ⛔ 不 grep 源码(源码里 `'任务类别'` 字样还在:`?` 提示里那句「任务类别的来源」**必须保留**)。 ✅ 同时钉住「`?` 提示里那条来源仍在」⇒ 防**删过头**(把该留的信息一起删了)。 */ let dashOk = true; if (outHtml.indexOf('>任务类别<') >= 0 || outHtml.indexOf('(未声明任务类别)') >= 0 || outHtml.indexOf('class="topics"') >= 0) { console.log('任务类别行 : ❌ 版面上还有(用户已要求删除)'); dashOk = false; } else { console.log('任务类别行 : ✅ 版面已无该行(按用户口径删除)'); } if (_tip.indexOf('任务类别的来源') >= 0) { console.log('类别信息留存 : ✅ `?` 提示里仍有「任务类别的来源」(⛔ 没删过头)'); } else { console.log('类别信息留存 : ❌ `?` 提示里也没有了 ⇒ 删过头(类别来源无处可查)'); dashOk = false; } /* 🔴🔴🔴 2026-10-05 加:**主会话 chip 不许画成「⚠ 无主会话」**(用户报障逐字: 「peer 工作区的主会话还是找不到对应的吗」)。 ⛔ 判据不是"源码里有没有这个词" —— 源码**必然**有那句兜底文案(`board.html:1353`), 所以只能查**渲染产物**:快照的 `project.main_by_topic` 有值 ⇒ 产物里就该有 id8; 只有**取不到**时才允许出现「无主会话」。 ✅ 双向断言:**快照有值 ⇒ 产物不许说"无主会话"**;**快照确实空 ⇒ 必须说"无主会话"** (⛔ 否则把兜底整个删掉也能"绿",那是假绿)。 */ const _mbt = ((snap.project || {}).main_by_topic) || {}; const _hasMain = Object.keys(_mbt).some(k => { const v = _mbt[k]; return (typeof v === 'object' && v && v.sid) || (typeof v === 'string' && v); }); const _said = outHtml.indexOf('无主会话') >= 0; if (_hasMain && _said) { console.log('主会话渲染 : ❌ 快照有 `main_by_topic` 却仍画「无主会话」'); dashOk = false; } else if (!_hasMain && !_said) { console.log('主会话渲染 : ❌ 快照无主会话,产物也没提「无主会话」(兜底被删?)'); dashOk = false; } else { console.log('主会话渲染 : ✅ ' + (_hasMain ? '快照有主会话 ⇒ 产物未画兜底文案' : '快照确无主会话 ⇒ 如实画了兜底文案')); } console.log('静默错文案 :', silent ? ('❌ ' + silent + ' 项') : '✅ 无'); let ok = !err && !(errEl && errEl.textContent) && outHtml.indexOf('accRow') >= 0 && outHtml.indexOf('完成情况') >= 0 && (typeof sandbox.renderProject==='function') && silent === 0 && dashOk; if (WANT_PASS && (!m || m[0].replace(/\s/g,'') !== WANT_PASS.replace(/\s/g,''))) { console.log('期望通过率 : ❌ 应得 ' + WANT_PASS + ',实得 ' + (m?m[0]:'—')); ok = false; } else if (WANT_PASS) { console.log('期望通过率 : ✅ ' + WANT_PASS); } console.log('\n' + (ok ? '✅ 真跑通了(render 真被调用、零异常、#proj 真渲染出完成情况、无静默错文案)' : '❌ 仍有问题')); process.exit(ok ? 0 : 1); } // 快照来源两选一:盘上文件(自测/离线)优先,否则打活服务。 if (SNAP_FILE) { let raw; try { raw = fs.readFileSync(SNAP_FILE, 'utf8'); } catch (e) { console.log('读盘上快照失败:', e.message); process.exit(1); } run(JSON.parse(raw)); } else { http.get(SNAP_URL, res => { let buf = ''; res.on('data', c => buf += c); res.on('end', () => { run(JSON.parse(buf)); }); }).on('error', e => { console.log('取快照失败:', e.message); process.exit(1); }); }