_vr_dbg.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  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, 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. }`));
  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('SIGUSR2', () => { console.log('=== active handles ==='); for (const h of process._getActiveHandles()) console.log(h.constructor.name, h.remoteAddress || '', h.fd || ''); });
  67. process.on('unhandledRejection', (e) => errors.push(`unhandledRejection: ${e?.stack || e}`));
  68. process.on('uncaughtException', (e) => errors.push(`uncaughtException: ${e?.stack || e}`));
  69. dom.window.addEventListener('error', (e) => {
  70. const msg = e.error?.stack || e.message;
  71. if (/Not implemented/.test(msg)) notes.push(msg);
  72. else errors.push(`window.error: ${msg}`);
  73. });
  74. for (const k of ['window', 'document', 'HTMLElement', 'customElements', 'CSSStyleSheet', 'CustomEvent', 'Event', 'Node', 'ShadowRoot',
  75. 'DocumentFragment', 'Document', 'MutationObserver', 'localStorage', 'location', 'history', 'PopStateEvent']) {
  76. globalThis[k] = dom.window[k];
  77. }
  78. globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
  79. globalThis.getComputedStyle = dom.window.getComputedStyle?.bind(dom.window) || (() => ({}));
  80. dom.window.scrollTo = () => {};
  81. const offline = process.argv.includes('--offline');
  82. // --url 指向本地快照回放服务器(src/dev/fixture-server.mjs):/parse 请求直接打到它,不起 jsdom 的网络栈
  83. const FIXTURE = (() => { const a = process.argv.indexOf('--url'); return a !== -1 ? String(process.argv[a + 1] || '').replace(/\/+$/, '') : ''; })();
  84. const realFetch = globalThis.fetch;
  85. let calls = 0;
  86. globalThis.fetch = async (url, opts) => {
  87. let u = String(url);
  88. if (u.startsWith('/parse')) {
  89. if (offline) throw new Error('offline');
  90. u = (FIXTURE || 'https://kangshifu.fmode.cn') + u;
  91. }
  92. calls++;
  93. return realFetch(u, opts);
  94. };
  95. async function main() {
  96. await import(pathToFileURL(join(ROOT, 'src/app.js')).href);
  97. const TAGS = ['kf-app', 'kf-glass-card', 'kf-stat', 'kf-badge', 'kf-empty', 'kf-table', 'kf-drawer', 'kf-opportunity-card',
  98. 'kf-score-panel', 'kf-evidence-chain', 'kf-pipeline', 'kf-toast', 'kf-nav', 'kf-topbar',
  99. 'view-workbench', 'view-pool', 'view-stages', 'view-reports', 'view-report-type', 'view-report', 'view-ledger', 'view-members'];
  100. const unregistered = TAGS.filter((t) => !customElements.get(t));
  101. const wait = (ms) => new Promise((r) => setTimeout(r, ms));
  102. const { boardService } = await import(pathToFileURL(join(ROOT, 'src/services/board-service.js')).href);
  103. let waited = 0;
  104. while (boardService.status.get() === 'loading' && waited < 30000) { await wait(250); waited += 250; }
  105. const db = boardService.db.get();
  106. /** 深查 shadow DOM 的文本 */
  107. function textOf(node) {
  108. let out = '';
  109. const walk = (n) => {
  110. if (!n) return;
  111. if (n.nodeType === 3) { out += n.textContent + ' '; return; }
  112. for (const c of n.childNodes || []) if (c.nodeType !== 8) walk(c);
  113. if (n.shadowRoot) for (const c of n.shadowRoot.childNodes) walk(c);
  114. if (n.tagName === 'STYLE') return;
  115. };
  116. walk(node);
  117. return out.replace(/\s+/g, ' ').trim();
  118. }
  119. const appRoot = document.querySelector('kf-app');
  120. const app = appRoot;
  121. const waitFor = async (pred, ms = 3000) => {
  122. const t0 = Date.now();
  123. while (Date.now() - t0 < ms) {
  124. try { if (pred()) return true; } catch { /* keep waiting */ }
  125. await wait(30);
  126. }
  127. return false;
  128. };
  129. // 与用户点导航等价:经 router.navigate(保留部署前缀)、派发 popstate
  130. const { navigate, currentPath } = await import(pathToFileURL(join(ROOT, 'src/utils/router.js')).href);
  131. const routeState = () => {
  132. const m = app.shadowRoot.querySelector('main#view [data-view]');
  133. return m ? `${m.dataset.view} ${m.dataset.path}` : '';
  134. };
  135. process.on('exit',(c)=>console.log('EXIT',c));
  136. setTimeout(()=>{console.log('STACK'); process._getActiveHandles?.().forEach(h=>console.log(h.constructor.name)); }, 20000).unref?.();
  137. /** 等路由真的落到目标应用内路径(视图按路由重建,所以断言必须等路由状态,不是 sleep) */
  138. const nav = async (path) => {
  139. navigate(path);
  140. const want = path.split('?')[0].replace(/\/+$/, '') || '/';
  141. const reached = await waitFor(() => (routeState().split(' ')[1] || '') === want, 5000);
  142. if (process.env.KF_DEBUG) console.log('[nav]', path, 'reached=', reached);
  143. await wait(80);
  144. };
  145. const __nav0 = nav;
  146. const out = [];
  147. const ok = (name, cond, detail = '') => { out.push({ name, ok: Boolean(cond), detail }); };
  148. ok(`21 个自定义元素全部注册`, unregistered.length === 0, unregistered.join(', '));
  149. ok('数据加载完成(status=ready)', boardService.status.get() === 'ready', String(boardService.status.get()) + ' ' + (boardService.error.get()?.message || ''));
  150. if (db) {
  151. ok('机会卡 ≥ 6', db.opportunities.length >= 6, `${db.opportunities.length}`);
  152. ok('有完整报告(6 章)', db.artifacts.some((a) => a.report?.chapters?.length >= 6));
  153. ok('库里有卡住的人', db.members.some((m) => m.stuck), db.members.filter((m) => m.stuck).map((m) => m.name).join('、'));
  154. ok('每个人都有上传四要素字段(缺失标出而非编造)', db.opportunities.every((o) => Array.isArray(o.upload.missing)), '');
  155. }
  156. // 各路由渲染
  157. const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/category', '/reports/competitor', '/reports/voc', '/reports/pain', '/reports/concept', '/report', '/ledger', '/members', '/nope'];
  158. for (const r of ROUTES) {
  159. console.log('[dbg] route', r);
  160. await nav(r);
  161. const main = app.shadowRoot.querySelector('main#view');
  162. const t = textOf(main);
  163. ok(`渲染 ${r}`, t.length > 20 && !/undefined|NaN|\[object Object\]/.test(t), t.slice(0, 160));
  164. }
  165. // 类型页:阶段二次筛选写进 URL,列表变化
  166. await nav('/reports/competitor');
  167. const typeAll = app.shadowRoot.querySelector('main#view').querySelector('view-report-type')?.shadowRoot.querySelectorAll('[data-cards] > kf-glass-card').length;
  168. await nav('/reports/competitor?stage=S1');
  169. const typeS1 = app.shadowRoot.querySelector('main#view').querySelector('view-report-type')?.shadowRoot.querySelectorAll('[data-cards] > kf-glass-card').length;
  170. ok('类型页按阶段二次筛选(?stage=S1 后条数变化)', typeAll > 0 && typeS1 != null && typeS1 <= typeAll, `全部=${typeAll} S1=${typeS1}`);
  171. // 阶段页:只看有证据
  172. await nav('/stages');
  173. const stAll = app.shadowRoot.querySelector('main#view').querySelector('view-stages')?.shadowRoot.querySelector('[data-count]')?.textContent;
  174. await nav('/stages?ev=1');
  175. const stEv = app.shadowRoot.querySelector('main#view').querySelector('view-stages')?.shadowRoot.querySelector('[data-count]')?.textContent;
  176. ok('阶段页:只看有证据(?ev=1 后条数变化)', Boolean(stAll) && Boolean(stEv) && stAll !== stEv, `全部=${stAll} 有证据=${stEv}`);
  177. const rep = db?.artifacts.find((a) => a.report?.chapters?.length >= 6);
  178. if (rep) {
  179. await nav(`/report/${rep.id}`);
  180. const t = textOf(app.shadowRoot.querySelector('main#view'));
  181. ok('报告页:章节 + 结论 + 证据锚点', t.includes('这章在回答') && t.includes('证据链'), t.slice(0, 200));
  182. ok('报告页:数据管线 2,727 → 1,190 → 730', t.includes('2,727') && t.includes('1,190') && t.includes('730'), '');
  183. }
  184. // 写操作的闭环:改状态 / 评分 走真实事件 → service → Parse(离线模式 / 快照回放模式跳过,不写库)
  185. if (!offline && !FIXTURE) {
  186. await nav('/pool');
  187. await waitFor(() => idsIn().length > 0);
  188. const victim = db.opportunities.find((o) => o.status === 'candidate');
  189. boardService.setMe('张伟');
  190. const before = await boardService.setOpportunityStatus(victim.id, 'approved', { by: '张伟', reason: '验收:确认改状态可持久' });
  191. const afterLoad = await boardService.load({ force: true });
  192. const persisted = afterLoad.opportunityById[victim.id];
  193. ok('18 改状态写入 Parse 后刷新仍在(且记下操作人)', before.changed && persisted.status === 'approved' && persisted.decision?.by === '张伟',
  194. `status=${persisted.status} decision.by=${persisted.decision?.by} log=${persisted.decisionLog?.length}`);
  195. const s0 = persisted.stats;
  196. const st = await boardService.rateOpportunity(victim.id, { user: '张伟', score: 77, comment: '验收:评分写入' });
  197. const after2 = (await boardService.load({ force: true })).opportunityById[victim.id];
  198. ok('17 评分写入后平均分随之变化(同一人重评覆盖)', st.n >= s0.n && after2.stats.byUser['张伟']?.score === 77, `n ${s0.n}→${after2.stats.n} avg ${s0.avg}→${after2.stats.avg}`);
  199. // 还原,避免污染演示数据
  200. await boardService.setOpportunityStatus(victim.id, 'candidate', { by: '张伟', reason: '验收后还原' });
  201. }
  202. // 机会卡池:排序与筛选让真实列表顺序变化(用真实 DOM 上的点击 / URL 驱动,不用内部对象)
  203. await nav('/pool');
  204. const pool = () => app.shadowRoot.querySelector('main#view')?.querySelector('view-pool');
  205. const idsIn = () => [...(pool()?.shadowRoot?.querySelectorAll('kf-opportunity-card') || [])].map((c) => c.opp?.id);
  206. await waitFor(() => idsIn().length > 0);
  207. const byPrio = idsIn();
  208. await nav('/pool?sort=score');
  209. await waitFor(() => { const ids = idsIn(); return ids.length > 0 && ids.join() !== byPrio.join(); });
  210. const byScore = idsIn();
  211. ok('排序切换(优先级 → 评分)后顺序真实变化', byPrio.length > 1 && byPrio.join() !== byScore.join(), `priority=[${byPrio.join(',')}] score=[${byScore.join(',')}]`);
  212. await nav('/pool?priority=S&category=%E6%8B%8C%E9%9D%A2');
  213. await waitFor(() => { const ids = idsIn(); return ids.length > 0 && ids.length !== byScore.length; });
  214. const filtered = idsIn();
  215. const wantS = byPrio.filter((id) => { const o = db?.opportunityById?.[id]; return o && o.priority === 'S' && o.category === '拌面'; });
  216. ok('筛选叠加(S 级 + 拌面)与 core 结果一致', filtered.length > 0 && filtered.join() === wantS.join() && filtered.length < byPrio.length, `页面=${filtered.length} core=${wantS.length}`);
  217. // 点卡片状态按钮:改状态(需要先选「我是谁」)
  218. await nav('/pool');
  219. await waitFor(() => idsIn().length > 0);
  220. // 抽屉:/pool/:id 深链接 → 抽屉里有人评分面板;点证据锚点 → 证据链抽屉;抽屉内按钮事件能回到应用
  221. await nav('/pool');
  222. await waitFor(() => idsIn().length > 0);
  223. const anyId = idsIn()[0];
  224. await nav(`/pool/${anyId}`);
  225. const drawerOf = () => app.shadowRoot.querySelector('kf-drawer');
  226. await waitFor(() => drawerOf()?.shadowRoot?.querySelector('[part=body] kf-score-panel'), 4000);
  227. const dbody = drawerOf()?.shadowRoot?.querySelector('[part=body]');
  228. ok('深链接 /pool/:id 打开抽屉,内含评分面板与来源四要素', Boolean(dbody) && /人评分|暂无评分/.test(dbody.textContent) && /Agent/.test(dbody.textContent), (dbody?.textContent || '').slice(0, 120));
  229. // 异常汇总
  230. ok('无未捕获异常', errors.length === 0, errors.join('\n'));
  231. console.log(FIXTURE ? `(数据源:快照回放 ${FIXTURE};写库闭环本次跳过)` : '(数据源:真实 Parse;含写库闭环)');
  232. for (const r of out) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.detail ? ` — ${r.detail.replace(/\n/g, '\n ')}` : ''}`);
  233. if (notes.length) console.log(`\n(jsdom 不支持、已忽略:${[...new Set(notes)].join('; ')})`);
  234. const fails = out.filter((r) => !r.ok).length;
  235. console.log(`\n${fails ? `RENDER SMOKE FAILED(${fails})` : 'RENDER SMOKE OK'} · fetch=${calls} · ${waited}ms`);
  236. process.exit(fails ? 1 : 0);
  237. }
  238. main().then(
  239. (code) => process.exit(code ?? 0),
  240. (err) => { console.error('VERIFY-RENDER ERROR', err?.stack || err); process.exit(1); },
  241. );