verify-render.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-render.mjs —— 无浏览器渲染冒烟(jsdom)。
  4. *
  5. * 为什么需要:真实浏览器验收(verify-browser.mjs)依赖 Playwright + Chromium;
  6. * CI / 无头环境没有浏览器时,用 jsdom 把**真实的组件树**跑起来:
  7. * 1. 按 index.html 的 importmap 解析裸标识符(与浏览器同一套映射)
  8. * 2. 导入 src/app.js → 21 个自定义元素全部注册
  9. * 3. 真实连 Parse(或不联网用 --offline 跳过取数)→ 三视角 / 池子 / 台账 / 报告 全部拿到数据后渲染
  10. * 4. 抓未捕获异常、页面错误(jsdom 不支持的能力只记录,不算失败)
  11. *
  12. * 注意:jsdom 没有 layout / backdrop-filter / IntersectionObserver,**视觉与滚动行为必须用真实浏览器验**。
  13. * 本脚本只回答「逻辑闭环 + 渲染不抛错」。
  14. *
  15. * 用法:node scripts/verify-render.mjs [--offline]
  16. * 需要 jsdom:优先用 node_modules/jsdom,其次 ../testing/node_modules/jsdom(本机验收机)
  17. */
  18. import { createRequire } from 'node:module';
  19. import { register } from 'node:module';
  20. import { pathToFileURL } from 'node:url';
  21. import { readFileSync, writeFileSync, appendFileSync, existsSync } from 'node:fs';
  22. import { join, dirname } from 'node:path';
  23. import { fileURLToPath } from 'node:url';
  24. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  25. const require = createRequire(import.meta.url);
  26. function findJsdom() {
  27. for (const p of [join(ROOT, 'node_modules/jsdom'), '/opt/data/work/kangshifu/testing/node_modules/jsdom']) {
  28. if (existsSync(p)) return p;
  29. }
  30. return null;
  31. }
  32. const jsdomPath = findJsdom();
  33. if (!jsdomPath) {
  34. console.error('需要 jsdom:npm i -D jsdom(或放在 ../testing/node_modules)');
  35. process.exit(2);
  36. }
  37. // 1) importmap → Node 解析器(file:// 路径)
  38. const html = readFileSync(join(ROOT, 'index.html'), 'utf8');
  39. const im = JSON.parse(html.match(/<script type="importmap">([\s\S]*?)<\/script>/)[1]).imports;
  40. const map = Object.fromEntries(Object.entries(im).map(([k, v]) => [k, v.startsWith('/') ? pathToFileURL(ROOT + v).href : pathToFileURL(join(ROOT, v)).href]));
  41. process.env.KF_IM = JSON.stringify(map);
  42. register('data:text/javascript,' + encodeURIComponent(`
  43. export async function resolve(spec, ctx, next) {
  44. const M = JSON.parse(process.env.KF_IM || '{}');
  45. if (M[spec]) return { url: M[spec], shortCircuit: true };
  46. const p = Object.keys(M).filter((k) => k.endsWith('/') && spec.startsWith(k)).sort((a, b) => b.length - a.length)[0];
  47. if (p) return { url: M[p] + spec.slice(p.length), shortCircuit: true };
  48. return next(spec, ctx);
  49. }`), { data: { name: 'kf-verify-render', parentURL: 'file://' + ROOT + '/' } });
  50. const { JSDOM } = require(join(jsdomPath, 'lib/api.js'));
  51. let dom;
  52. try {
  53. // 与真实 index.html 相同的 <base href>(jsdom 里 document.baseURI 不会自动跟随 <base>,
  54. // router.basePath 优先读 <base> 元素,所以这里必须像浏览器一样带上它)
  55. dom = new JSDOM('<!doctype html><html><head><base href="/kangshifu/dev/"></head><body><kf-app></kf-app></body></html>', {
  56. url: 'https://verify.local/kangshifu/dev/',
  57. pretendToBeVisual: true,
  58. });
  59. } catch (e) {
  60. console.error('JSDOM init failed:', e?.stack || e);
  61. process.exit(3);
  62. }
  63. const browserPath = '/kangshifu/dev/';
  64. const errors = [];
  65. const notes = [];
  66. process.on('unhandledRejection', (e) => errors.push(`unhandledRejection: ${e?.stack || e}`));
  67. process.on('uncaughtException', (e) => errors.push(`uncaughtException: ${e?.stack || e}`));
  68. dom.window.addEventListener('error', (e) => {
  69. const msg = e.error?.stack || e.message;
  70. if (/Not implemented/.test(msg)) notes.push(msg);
  71. else errors.push(`window.error: ${msg}`);
  72. });
  73. for (const k of ['window', 'document', 'HTMLElement', 'customElements', 'CSSStyleSheet', 'CustomEvent', 'Event', 'Node', 'ShadowRoot',
  74. 'DocumentFragment', 'Document', 'MutationObserver', 'localStorage', 'location', 'history', 'PopStateEvent']) {
  75. globalThis[k] = dom.window[k];
  76. }
  77. globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
  78. globalThis.getComputedStyle = dom.window.getComputedStyle?.bind(dom.window) || (() => ({}));
  79. dom.window.scrollTo = () => {};
  80. const offline = process.argv.includes('--offline');
  81. // --url 指向本地快照回放服务器(src/dev/fixture-server.mjs):/parse 请求直接打到它,不起 jsdom 的网络栈
  82. const FIXTURE = (() => { const a = process.argv.indexOf('--url'); return a !== -1 ? String(process.argv[a + 1] || '').replace(/\/+$/, '') : ''; })();
  83. const realFetch = globalThis.fetch;
  84. let calls = 0;
  85. globalThis.fetch = async (url, opts) => {
  86. let u = String(url);
  87. if (u.startsWith('/parse')) {
  88. if (offline) throw new Error('offline');
  89. u = (FIXTURE || 'https://kangshifu.fmode.cn') + u;
  90. }
  91. calls++;
  92. return realFetch(u, opts);
  93. };
  94. async function main() {
  95. // FIXTURE 模式:取数只打一次快照服务器,之后应用发出的取数直接返回同一份快照(不让 jsdom 反复走网络)
  96. const FROZEN = {};
  97. globalThis.fetch = async (url, opts) => {
  98. const u = String(url);
  99. if (FIXTURE && u.startsWith('/parse')) {
  100. if (FROZEN[u]) return new Response(FROZEN[u], { headers: { 'Content-Type': 'application/json' } });
  101. const r = await realFetch(FIXTURE + u, opts);
  102. const text = await r.text();
  103. FROZEN[u] = text;
  104. calls++;
  105. return new Response(text, { headers: { 'Content-Type': 'application/json' } });
  106. }
  107. calls++;
  108. return realFetch(u, opts);
  109. };
  110. await import(pathToFileURL(join(ROOT, 'src/app.js')).href);
  111. const TAGS = ['kf-app', 'kf-glass-card', 'kf-stat', 'kf-badge', 'kf-empty', 'kf-table', 'kf-drawer', 'kf-opportunity-card',
  112. 'kf-score-panel', 'kf-evidence-chain', 'kf-pipeline', 'kf-toast', 'kf-nav', 'kf-topbar',
  113. 'view-workbench', 'view-pool', 'view-stages', 'view-reports', 'view-report-type', 'view-report', 'view-ledger', 'view-members'];
  114. const unregistered = TAGS.filter((t) => !customElements.get(t));
  115. /** 浏览器里用 MessageChannel 排一轮,不依赖定时器(jsdom 长时间跑时会话定时器会卡住) */
  116. const tick = () => new Promise((r) => { const ch = new MessageChannel(); ch.port1.onmessage = () => r(); ch.port2.postMessage(0); });
  117. const wait = (ms) => Promise.race([new Promise((r) => setTimeout(r, ms)), tick()]);
  118. const { boardService } = await import(pathToFileURL(join(ROOT, 'src/services/board-service.js')).href);
  119. let waited = 0;
  120. while (boardService.status.get() === 'loading' && waited < 30000) { await wait(250); waited += 250; }
  121. const db = boardService.db.get();
  122. /** 深查 shadow DOM 的文本 */
  123. function textOf(node) {
  124. let out = '';
  125. const walk = (n) => {
  126. if (!n) return;
  127. if (n.nodeType === 3) { out += n.textContent + ' '; return; }
  128. for (const c of n.childNodes || []) if (c.nodeType !== 8) walk(c);
  129. if (n.shadowRoot) for (const c of n.shadowRoot.childNodes) walk(c);
  130. if (n.tagName === 'STYLE') return;
  131. };
  132. walk(node);
  133. return out.replace(/\s+/g, ' ').trim();
  134. }
  135. const appRoot = document.querySelector('kf-app');
  136. const app = appRoot;
  137. const waitFor = async (pred, ms = 3000) => {
  138. const t0 = Date.now();
  139. while (Date.now() - t0 < ms) {
  140. try { if (pred()) return true; } catch { /* keep waiting */ }
  141. await wait(30);
  142. }
  143. return false;
  144. };
  145. const { navigate, currentPath } = await import(pathToFileURL(join(ROOT, 'src/utils/router.js')).href);
  146. const routeState = () => {
  147. const m = app.shadowRoot.querySelector('main#view [data-view]');
  148. return m ? `${m.dataset.view} ${m.dataset.path}` : '';
  149. };
  150. /** 等路由真的落到目标应用内路径(视图按路由重建,所以断言必须等路由状态,不是 sleep) */
  151. const nav = async (path) => {
  152. navigate(path);
  153. const want = path.split('?')[0].replace(/\/+$/, '') || '/';
  154. // 每步导航硬上限 3s(jsdom 下会话偶尔不回调,不让它拖死整个脚本)
  155. const reached = await Promise.race([waitFor(() => (routeState().split(' ')[1] || '') === want, 3000), wait(3000).then(() => false)]);
  156. if (process.env.KF_DEBUG) console.log('[nav]', path, 'reached=', reached);
  157. // jsdom 环境下 wait(80) 偶发不回调:给每个导航一个硬上限,超时即判「未到达」继续跑(不挂死整个脚本)
  158. await Promise.race([wait(80), new Promise((r) => setTimeout(r, 80))]);
  159. };
  160. const __nav0 = nav;
  161. const out = [];
  162. const ok = (name, cond, detail = '') => {
  163. out.push({ name, ok: Boolean(cond), detail });
  164. if (process.env.KF_TRACE) {
  165. writeFileSync(join(ROOT, 'docs/render-trace.txt'), out.map((r) => `${r.ok ? 'PASS' : 'FAIL'} ${r.name} — ${r.detail}`).join('\n') + '\n');
  166. appendFileSync(join(ROOT, 'docs/render-progress.txt'), `${out.length} ${Date.now() % 100000} ${name.slice(0, 50)}\n`);
  167. }
  168. };
  169. ok(`21 个自定义元素全部注册`, unregistered.length === 0, unregistered.join(', '));
  170. ok('数据加载完成(status=ready)', boardService.status.get() === 'ready', String(boardService.status.get()) + ' ' + (boardService.error.get()?.message || ''));
  171. if (db) {
  172. ok('机会卡 ≥ 6', db.opportunities.length >= 6, `${db.opportunities.length}`);
  173. ok('有完整报告(6 章)', db.artifacts.some((a) => a.report?.chapters?.length >= 6));
  174. ok('库里有卡住的人', db.members.some((m) => m.stuck), db.members.filter((m) => m.stuck).map((m) => m.name).join('、'));
  175. ok('每个人都有上传四要素字段(缺失标出而非编造)', db.opportunities.every((o) => Array.isArray(o.upload.missing)), '');
  176. }
  177. // 各路由渲染
  178. const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/category', '/reports/competitor', '/reports/voc', '/reports/pain', '/reports/concept', '/report', '/ledger', '/members', '/nope'];
  179. for (const r of ROUTES) {
  180. await nav(r);
  181. const main = app.shadowRoot.querySelector('main#view');
  182. const t = textOf(main);
  183. ok(`渲染 ${r}`, t.length > 20 && !/undefined|NaN|\[object Object\]/.test(t), t.slice(0, 160));
  184. }
  185. // 类型页:阶段二次筛选写进 URL,列表变化
  186. await nav('/reports/competitor');
  187. const typeAll = app.shadowRoot.querySelector('main#view').querySelector('view-report-type')?.shadowRoot.querySelectorAll('[data-cards] > kf-glass-card').length;
  188. await nav('/reports/competitor?stage=S1');
  189. const typeS1 = app.shadowRoot.querySelector('main#view').querySelector('view-report-type')?.shadowRoot.querySelectorAll('[data-cards] > kf-glass-card').length;
  190. ok('类型页按阶段二次筛选(?stage=S1 后条数变化)', typeAll > 0 && typeS1 != null && typeS1 <= typeAll, `全部=${typeAll} S1=${typeS1}`);
  191. // 阶段页:只看有证据
  192. await nav('/stages');
  193. const stAll = app.shadowRoot.querySelector('main#view').querySelector('view-stages')?.shadowRoot.querySelector('[data-count]')?.textContent;
  194. await nav('/stages?ev=1');
  195. const stEv = app.shadowRoot.querySelector('main#view').querySelector('view-stages')?.shadowRoot.querySelector('[data-count]')?.textContent;
  196. ok('阶段页:只看有证据(?ev=1 后条数变化)', Boolean(stAll) && Boolean(stEv) && stAll !== stEv, `全部=${stAll} 有证据=${stEv}`);
  197. const rep = db?.artifacts.find((a) => a.report?.chapters?.length >= 6);
  198. if (rep) {
  199. await nav(`/report/${rep.id}`);
  200. const t = textOf(app.shadowRoot.querySelector('main#view'));
  201. ok('报告页:章节 + 结论 + 证据锚点', t.includes('这章在回答') && t.includes('证据链'), t.slice(0, 200));
  202. ok('报告页:数据管线 2,727 → 1,190 → 730', t.includes('2,727') && t.includes('1,190') && t.includes('730'), '');
  203. }
  204. // 写操作的闭环:改状态 / 评分 走真实事件 → service → Parse(离线模式 / 快照回放模式跳过,不写库)
  205. if (!offline && !FIXTURE) {
  206. await nav('/pool');
  207. await waitFor(() => idsIn().length > 0);
  208. const victim = db.opportunities.find((o) => o.status === 'candidate');
  209. boardService.setMe('张伟');
  210. const before = await boardService.setOpportunityStatus(victim.id, 'approved', { by: '张伟', reason: '验收:确认改状态可持久' });
  211. const afterLoad = await boardService.load({ force: true });
  212. const persisted = afterLoad.opportunityById[victim.id];
  213. ok('18 改状态写入 Parse 后刷新仍在(且记下操作人)', before.changed && persisted.status === 'approved' && persisted.decision?.by === '张伟',
  214. `status=${persisted.status} decision.by=${persisted.decision?.by} log=${persisted.decisionLog?.length}`);
  215. const s0 = persisted.stats;
  216. const st = await boardService.rateOpportunity(victim.id, { user: '张伟', score: 77, comment: '验收:评分写入' });
  217. const after2 = (await boardService.load({ force: true })).opportunityById[victim.id];
  218. ok('17 评分写入后平均分随之变化(同一人重评覆盖)', st.n >= s0.n && after2.stats.byUser['张伟']?.score === 77, `n ${s0.n}→${after2.stats.n} avg ${s0.avg}→${after2.stats.avg}`);
  219. // 还原,避免污染演示数据
  220. await boardService.setOpportunityStatus(victim.id, 'candidate', { by: '张伟', reason: '验收后还原' });
  221. }
  222. // 机会卡池:排序与筛选让真实列表顺序变化(用真实 DOM 上的点击 / URL 驱动,不用内部对象)
  223. await nav('/pool');
  224. const pool = () => app.shadowRoot.querySelector('main#view')?.querySelector('view-pool');
  225. const idsIn = () => [...(pool()?.shadowRoot?.querySelectorAll('kf-opportunity-card') || [])].map((c) => c.opp?.id);
  226. await waitFor(() => idsIn().length > 0);
  227. const byPrio = idsIn();
  228. await nav('/pool?sort=score');
  229. await waitFor(() => { const ids = idsIn(); return ids.length > 0 && ids.join() !== byPrio.join(); });
  230. const byScore = idsIn();
  231. ok('排序切换(优先级 → 评分)后顺序真实变化', byPrio.length > 1 && byPrio.join() !== byScore.join(), `priority=[${byPrio.join(',')}] score=[${byScore.join(',')}]`);
  232. await nav('/pool?priority=S&category=%E6%8B%8C%E9%9D%A2');
  233. await waitFor(() => { const ids = idsIn(); return ids.length > 0 && ids.length !== byScore.length; });
  234. const filtered = idsIn();
  235. const wantS = byPrio.filter((id) => { const o = db?.opportunityById?.[id]; return o && o.priority === 'S' && o.category === '拌面'; });
  236. ok('筛选叠加(S 级 + 拌面)与 core 结果一致', filtered.length > 0 && filtered.join() === wantS.join() && filtered.length < byPrio.length, `页面=${filtered.length} core=${wantS.length}`);
  237. // 点卡片状态按钮:改状态(需要先选「我是谁」)
  238. await nav('/pool');
  239. await waitFor(() => idsIn().length > 0);
  240. // 抽屉:/pool/:id 深链接 → 抽屉里有人评分面板;点证据锚点 → 证据链抽屉;抽屉内按钮事件能回到应用
  241. await nav('/pool');
  242. await waitFor(() => idsIn().length > 0);
  243. const anyId = idsIn()[0];
  244. await nav(`/pool/${anyId}`);
  245. const drawerOf = () => app.shadowRoot.querySelector('kf-drawer');
  246. await waitFor(() => drawerOf()?.shadowRoot?.querySelector('[part=body] kf-score-panel'), 4000);
  247. const dbody = drawerOf()?.shadowRoot?.querySelector('[part=body]');
  248. ok('深链接 /pool/:id 打开抽屉,内含评分面板与来源四要素', Boolean(dbody) && /人评分|暂无评分/.test(dbody.textContent) && /Agent/.test(dbody.textContent), (dbody?.textContent || '').slice(0, 120));
  249. // 异常汇总
  250. ok('无未捕获异常', errors.length === 0, errors.join('\n'));
  251. console.log(FIXTURE ? `(数据源:快照回放 ${FIXTURE};写库闭环本次跳过)` : '(数据源:真实 Parse;含写库闭环)');
  252. for (const r of out) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.detail ? ` — ${r.detail.replace(/\n/g, '\n ')}` : ''}`);
  253. if (notes.length) console.log(`\n(jsdom 不支持、已忽略:${[...new Set(notes)].join('; ')})`);
  254. const fails = out.filter((r) => !r.ok).length;
  255. const summaryLine = `${fails ? `RENDER SMOKE FAILED(${fails})` : 'RENDER SMOKE OK'} · fetch=${calls} · ${waited}ms`;
  256. console.log(summaryLine);
  257. // 结果先落盘再退出:jsdom 的定时器偶发在大量路由切换后不再回调,不能让异常退出吞掉结果
  258. writeFileSync(join(ROOT, 'docs/render-smoke-result.txt'), out.map((r) => `${r.ok ? 'PASS' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`).join('\n') + `\n\n${fails ? `RENDER SMOKE FAILED(${fails})` : 'RENDER SMOKE OK'} · fetch=${calls} · ${waited}ms\n`);
  259. process.exit(fails ? 1 : 0);
  260. }
  261. main().then(
  262. (code) => process.exit(code ?? 0),
  263. (err) => { console.error('VERIFY-RENDER ERROR', err?.stack || err); process.exit(1); },
  264. );