| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- #!/usr/bin/env node
- /**
- * scripts/verify-render.mjs —— 无浏览器渲染冒烟(jsdom)。
- *
- * 为什么需要:真实浏览器验收(verify-browser.mjs)依赖 Playwright + Chromium;
- * CI / 无头环境没有浏览器时,用 jsdom 把**真实的组件树**跑起来:
- * 1. 按 index.html 的 importmap 解析裸标识符(与浏览器同一套映射)
- * 2. 导入 src/app.js → 21 个自定义元素全部注册
- * 3. 真实连 Parse(或不联网用 --offline 跳过取数)→ 三视角 / 池子 / 台账 / 报告 全部拿到数据后渲染
- * 4. 抓未捕获异常、页面错误(jsdom 不支持的能力只记录,不算失败)
- *
- * 注意:jsdom 没有 layout / backdrop-filter / IntersectionObserver,**视觉与滚动行为必须用真实浏览器验**。
- * 本脚本只回答「逻辑闭环 + 渲染不抛错」。
- *
- * 用法:node scripts/verify-render.mjs [--offline]
- * 需要 jsdom:优先用 node_modules/jsdom,其次 ../testing/node_modules/jsdom(本机验收机)
- */
- import { createRequire } from 'node:module';
- import { register } from 'node:module';
- import { pathToFileURL } from 'node:url';
- import { readFileSync, existsSync } from 'node:fs';
- import { join, dirname } from 'node:path';
- import { fileURLToPath } from 'node:url';
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
- const require = createRequire(import.meta.url);
- function findJsdom() {
- for (const p of [join(ROOT, 'node_modules/jsdom'), '/opt/data/work/kangshifu/testing/node_modules/jsdom']) {
- if (existsSync(p)) return p;
- }
- return null;
- }
- const jsdomPath = findJsdom();
- if (!jsdomPath) {
- console.error('需要 jsdom:npm i -D jsdom(或放在 ../testing/node_modules)');
- process.exit(2);
- }
- // 1) importmap → Node 解析器(file:// 路径)
- const html = readFileSync(join(ROOT, 'index.html'), 'utf8');
- const im = JSON.parse(html.match(/<script type="importmap">([\s\S]*?)<\/script>/)[1]).imports;
- const map = Object.fromEntries(Object.entries(im).map(([k, v]) => [k, v.startsWith('/') ? pathToFileURL(ROOT + v).href : pathToFileURL(join(ROOT, v)).href]));
- process.env.KF_IM = JSON.stringify(map);
- register('data:text/javascript,' + encodeURIComponent(`
- export async function resolve(spec, ctx, next) {
- const M = JSON.parse(process.env.KF_IM || '{}');
- if (M[spec]) return { url: M[spec], shortCircuit: true };
- const p = Object.keys(M).filter((k) => k.endsWith('/') && spec.startsWith(k)).sort((a, b) => b.length - a.length)[0];
- if (p) return { url: M[p] + spec.slice(p.length), shortCircuit: true };
- return next(spec, ctx);
- }`));
- const { JSDOM } = require(join(jsdomPath, 'lib/api.js'));
- let dom;
- try {
- // 与真实 index.html 相同的 <base href>(jsdom 里 document.baseURI 不会自动跟随 <base>,
- // router.basePath 优先读 <base> 元素,所以这里必须像浏览器一样带上它)
- dom = new JSDOM('<!doctype html><html><head><base href="/kangshifu/dev/"></head><body><kf-app></kf-app></body></html>', {
- url: 'https://verify.local/kangshifu/dev/',
- pretendToBeVisual: true,
- });
- } catch (e) {
- console.error('JSDOM init failed:', e?.stack || e);
- process.exit(3);
- }
- const browserPath = '/kangshifu/dev/';
- const errors = [];
- const notes = [];
- process.on('SIGUSR2', () => { console.log('=== active handles ==='); for (const h of process._getActiveHandles()) console.log(h.constructor.name, h.remoteAddress || '', h.fd || ''); });
- process.on('unhandledRejection', (e) => errors.push(`unhandledRejection: ${e?.stack || e}`));
- process.on('uncaughtException', (e) => errors.push(`uncaughtException: ${e?.stack || e}`));
- dom.window.addEventListener('error', (e) => {
- const msg = e.error?.stack || e.message;
- if (/Not implemented/.test(msg)) notes.push(msg);
- else errors.push(`window.error: ${msg}`);
- });
- for (const k of ['window', 'document', 'HTMLElement', 'customElements', 'CSSStyleSheet', 'CustomEvent', 'Event', 'Node', 'ShadowRoot',
- 'DocumentFragment', 'Document', 'MutationObserver', 'localStorage', 'location', 'history', 'PopStateEvent']) {
- globalThis[k] = dom.window[k];
- }
- globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
- globalThis.getComputedStyle = dom.window.getComputedStyle?.bind(dom.window) || (() => ({}));
- dom.window.scrollTo = () => {};
- const offline = process.argv.includes('--offline');
- // --url 指向本地快照回放服务器(src/dev/fixture-server.mjs):/parse 请求直接打到它,不起 jsdom 的网络栈
- const FIXTURE = (() => { const a = process.argv.indexOf('--url'); return a !== -1 ? String(process.argv[a + 1] || '').replace(/\/+$/, '') : ''; })();
- const realFetch = globalThis.fetch;
- let calls = 0;
- globalThis.fetch = async (url, opts) => {
- let u = String(url);
- if (u.startsWith('/parse')) {
- if (offline) throw new Error('offline');
- u = (FIXTURE || 'https://kangshifu.fmode.cn') + u;
- }
- calls++;
- return realFetch(u, opts);
- };
- async function main() {
- await import(pathToFileURL(join(ROOT, 'src/app.js')).href);
- const TAGS = ['kf-app', 'kf-glass-card', 'kf-stat', 'kf-badge', 'kf-empty', 'kf-table', 'kf-drawer', 'kf-opportunity-card',
- 'kf-score-panel', 'kf-evidence-chain', 'kf-pipeline', 'kf-toast', 'kf-nav', 'kf-topbar',
- 'view-workbench', 'view-pool', 'view-stages', 'view-reports', 'view-report-type', 'view-report', 'view-ledger', 'view-members'];
- const unregistered = TAGS.filter((t) => !customElements.get(t));
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
- const { boardService } = await import(pathToFileURL(join(ROOT, 'src/services/board-service.js')).href);
- let waited = 0;
- while (boardService.status.get() === 'loading' && waited < 30000) { await wait(250); waited += 250; }
- const db = boardService.db.get();
- /** 深查 shadow DOM 的文本 */
- function textOf(node) {
- let out = '';
- const walk = (n) => {
- if (!n) return;
- if (n.nodeType === 3) { out += n.textContent + ' '; return; }
- for (const c of n.childNodes || []) if (c.nodeType !== 8) walk(c);
- if (n.shadowRoot) for (const c of n.shadowRoot.childNodes) walk(c);
- if (n.tagName === 'STYLE') return;
- };
- walk(node);
- return out.replace(/\s+/g, ' ').trim();
- }
- const appRoot = document.querySelector('kf-app');
- const app = appRoot;
- const waitFor = async (pred, ms = 3000) => {
- const t0 = Date.now();
- while (Date.now() - t0 < ms) {
- try { if (pred()) return true; } catch { /* keep waiting */ }
- await wait(30);
- }
- return false;
- };
- // 与用户点导航等价:经 router.navigate(保留部署前缀)、派发 popstate
- const { navigate, currentPath } = await import(pathToFileURL(join(ROOT, 'src/utils/router.js')).href);
- const routeState = () => {
- const m = app.shadowRoot.querySelector('main#view [data-view]');
- return m ? `${m.dataset.view} ${m.dataset.path}` : '';
- };
- process.on('exit',(c)=>console.log('EXIT',c));
- setTimeout(()=>{console.log('STACK'); process._getActiveHandles?.().forEach(h=>console.log(h.constructor.name)); }, 20000).unref?.();
- /** 等路由真的落到目标应用内路径(视图按路由重建,所以断言必须等路由状态,不是 sleep) */
- const nav = async (path) => {
- navigate(path);
- const want = path.split('?')[0].replace(/\/+$/, '') || '/';
- const reached = await waitFor(() => (routeState().split(' ')[1] || '') === want, 5000);
- if (process.env.KF_DEBUG) console.log('[nav]', path, 'reached=', reached);
- await wait(80);
- };
- const __nav0 = nav;
- const out = [];
- const ok = (name, cond, detail = '') => { out.push({ name, ok: Boolean(cond), detail }); };
- ok(`21 个自定义元素全部注册`, unregistered.length === 0, unregistered.join(', '));
- ok('数据加载完成(status=ready)', boardService.status.get() === 'ready', String(boardService.status.get()) + ' ' + (boardService.error.get()?.message || ''));
- if (db) {
- ok('机会卡 ≥ 6', db.opportunities.length >= 6, `${db.opportunities.length}`);
- ok('有完整报告(6 章)', db.artifacts.some((a) => a.report?.chapters?.length >= 6));
- ok('库里有卡住的人', db.members.some((m) => m.stuck), db.members.filter((m) => m.stuck).map((m) => m.name).join('、'));
- ok('每个人都有上传四要素字段(缺失标出而非编造)', db.opportunities.every((o) => Array.isArray(o.upload.missing)), '');
- }
- // 各路由渲染
- const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/category', '/reports/competitor', '/reports/voc', '/reports/pain', '/reports/concept', '/report', '/ledger', '/members', '/nope'];
- for (const r of ROUTES) {
- console.log('[dbg] route', r);
- await nav(r);
- const main = app.shadowRoot.querySelector('main#view');
- const t = textOf(main);
- ok(`渲染 ${r}`, t.length > 20 && !/undefined|NaN|\[object Object\]/.test(t), t.slice(0, 160));
- }
- // 类型页:阶段二次筛选写进 URL,列表变化
- await nav('/reports/competitor');
- const typeAll = app.shadowRoot.querySelector('main#view').querySelector('view-report-type')?.shadowRoot.querySelectorAll('[data-cards] > kf-glass-card').length;
- await nav('/reports/competitor?stage=S1');
- const typeS1 = app.shadowRoot.querySelector('main#view').querySelector('view-report-type')?.shadowRoot.querySelectorAll('[data-cards] > kf-glass-card').length;
- ok('类型页按阶段二次筛选(?stage=S1 后条数变化)', typeAll > 0 && typeS1 != null && typeS1 <= typeAll, `全部=${typeAll} S1=${typeS1}`);
- // 阶段页:只看有证据
- await nav('/stages');
- const stAll = app.shadowRoot.querySelector('main#view').querySelector('view-stages')?.shadowRoot.querySelector('[data-count]')?.textContent;
- await nav('/stages?ev=1');
- const stEv = app.shadowRoot.querySelector('main#view').querySelector('view-stages')?.shadowRoot.querySelector('[data-count]')?.textContent;
- ok('阶段页:只看有证据(?ev=1 后条数变化)', Boolean(stAll) && Boolean(stEv) && stAll !== stEv, `全部=${stAll} 有证据=${stEv}`);
- const rep = db?.artifacts.find((a) => a.report?.chapters?.length >= 6);
- if (rep) {
- await nav(`/report/${rep.id}`);
- const t = textOf(app.shadowRoot.querySelector('main#view'));
- ok('报告页:章节 + 结论 + 证据锚点', t.includes('这章在回答') && t.includes('证据链'), t.slice(0, 200));
- ok('报告页:数据管线 2,727 → 1,190 → 730', t.includes('2,727') && t.includes('1,190') && t.includes('730'), '');
- }
- // 写操作的闭环:改状态 / 评分 走真实事件 → service → Parse(离线模式 / 快照回放模式跳过,不写库)
- if (!offline && !FIXTURE) {
- await nav('/pool');
- await waitFor(() => idsIn().length > 0);
- const victim = db.opportunities.find((o) => o.status === 'candidate');
- boardService.setMe('张伟');
- const before = await boardService.setOpportunityStatus(victim.id, 'approved', { by: '张伟', reason: '验收:确认改状态可持久' });
- const afterLoad = await boardService.load({ force: true });
- const persisted = afterLoad.opportunityById[victim.id];
- ok('18 改状态写入 Parse 后刷新仍在(且记下操作人)', before.changed && persisted.status === 'approved' && persisted.decision?.by === '张伟',
- `status=${persisted.status} decision.by=${persisted.decision?.by} log=${persisted.decisionLog?.length}`);
- const s0 = persisted.stats;
- const st = await boardService.rateOpportunity(victim.id, { user: '张伟', score: 77, comment: '验收:评分写入' });
- const after2 = (await boardService.load({ force: true })).opportunityById[victim.id];
- ok('17 评分写入后平均分随之变化(同一人重评覆盖)', st.n >= s0.n && after2.stats.byUser['张伟']?.score === 77, `n ${s0.n}→${after2.stats.n} avg ${s0.avg}→${after2.stats.avg}`);
- // 还原,避免污染演示数据
- await boardService.setOpportunityStatus(victim.id, 'candidate', { by: '张伟', reason: '验收后还原' });
- }
- // 机会卡池:排序与筛选让真实列表顺序变化(用真实 DOM 上的点击 / URL 驱动,不用内部对象)
- await nav('/pool');
- const pool = () => app.shadowRoot.querySelector('main#view')?.querySelector('view-pool');
- const idsIn = () => [...(pool()?.shadowRoot?.querySelectorAll('kf-opportunity-card') || [])].map((c) => c.opp?.id);
- await waitFor(() => idsIn().length > 0);
- const byPrio = idsIn();
- await nav('/pool?sort=score');
- await waitFor(() => { const ids = idsIn(); return ids.length > 0 && ids.join() !== byPrio.join(); });
- const byScore = idsIn();
- ok('排序切换(优先级 → 评分)后顺序真实变化', byPrio.length > 1 && byPrio.join() !== byScore.join(), `priority=[${byPrio.join(',')}] score=[${byScore.join(',')}]`);
- await nav('/pool?priority=S&category=%E6%8B%8C%E9%9D%A2');
- await waitFor(() => { const ids = idsIn(); return ids.length > 0 && ids.length !== byScore.length; });
- const filtered = idsIn();
- const wantS = byPrio.filter((id) => { const o = db?.opportunityById?.[id]; return o && o.priority === 'S' && o.category === '拌面'; });
- ok('筛选叠加(S 级 + 拌面)与 core 结果一致', filtered.length > 0 && filtered.join() === wantS.join() && filtered.length < byPrio.length, `页面=${filtered.length} core=${wantS.length}`);
- // 点卡片状态按钮:改状态(需要先选「我是谁」)
- await nav('/pool');
- await waitFor(() => idsIn().length > 0);
- // 抽屉:/pool/:id 深链接 → 抽屉里有人评分面板;点证据锚点 → 证据链抽屉;抽屉内按钮事件能回到应用
- await nav('/pool');
- await waitFor(() => idsIn().length > 0);
- const anyId = idsIn()[0];
- await nav(`/pool/${anyId}`);
- const drawerOf = () => app.shadowRoot.querySelector('kf-drawer');
- await waitFor(() => drawerOf()?.shadowRoot?.querySelector('[part=body] kf-score-panel'), 4000);
- const dbody = drawerOf()?.shadowRoot?.querySelector('[part=body]');
- ok('深链接 /pool/:id 打开抽屉,内含评分面板与来源四要素', Boolean(dbody) && /人评分|暂无评分/.test(dbody.textContent) && /Agent/.test(dbody.textContent), (dbody?.textContent || '').slice(0, 120));
- // 异常汇总
- ok('无未捕获异常', errors.length === 0, errors.join('\n'));
- console.log(FIXTURE ? `(数据源:快照回放 ${FIXTURE};写库闭环本次跳过)` : '(数据源:真实 Parse;含写库闭环)');
- for (const r of out) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.detail ? ` — ${r.detail.replace(/\n/g, '\n ')}` : ''}`);
- if (notes.length) console.log(`\n(jsdom 不支持、已忽略:${[...new Set(notes)].join('; ')})`);
- const fails = out.filter((r) => !r.ok).length;
- console.log(`\n${fails ? `RENDER SMOKE FAILED(${fails})` : 'RENDER SMOKE OK'} · fetch=${calls} · ${waited}ms`);
- process.exit(fails ? 1 : 0);
- }
- main().then(
- (code) => process.exit(code ?? 0),
- (err) => { console.error('VERIFY-RENDER ERROR', err?.stack || err); process.exit(1); },
- );
|