verify-showcase-browser.mjs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. /** Local Edge acceptance. Non-local browser requests are intercepted, never sent. */
  2. import assert from 'node:assert/strict';
  3. import { createServer } from 'node:http';
  4. import { spawn } from 'node:child_process';
  5. import { readFile, stat, mkdir, writeFile } from 'node:fs/promises';
  6. import { existsSync, readFileSync } from 'node:fs';
  7. import path from 'node:path';
  8. import { fileURLToPath } from 'node:url';
  9. import { indexHtml } from './config.mjs';
  10. const root = fileURLToPath(new URL('../', import.meta.url));
  11. const run = path.join(root, 'work', `showcase-browser-${Date.now()}`);
  12. await mkdir(run, { recursive: true });
  13. const checks = [], errors = [], requests = [], forbidden = [], observations = [];
  14. const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };
  15. const mount = '/preview-test/';
  16. let seedUnavailable = false;
  17. const server = createServer(async (req, res) => {
  18. try {
  19. const url = new URL(req.url, 'http://localhost');
  20. const sub = url.pathname.startsWith(mount);
  21. const rel = decodeURIComponent(sub ? url.pathname.slice(mount.length) : url.pathname.slice(1));
  22. if (/^(?:api|parse)\//.test(rel)) { res.writeHead(451); res.end('No business proxy'); return; }
  23. if (seedUnavailable && rel === 'src/assets/showcase/preview-seed.json') { res.writeHead(503); res.end('sample unavailable'); return; }
  24. const file = path.resolve(root, rel || 'index.html');
  25. if (!file.startsWith(path.resolve(root) + path.sep)) { res.writeHead(403); res.end(); return; }
  26. let body;
  27. let ext = path.extname(file);
  28. if (ext === '.html' || !ext) { body = indexHtml(sub ? mount : '/', { dev: false }); ext = '.html'; }
  29. else { assert.ok((await stat(file)).isFile()); body = await readFile(file); }
  30. res.writeHead(200, { 'content-type': mime[ext] || 'application/octet-stream', 'cache-control': 'no-store' });
  31. res.end(body);
  32. } catch { res.writeHead(404); res.end('missing resource'); }
  33. });
  34. await new Promise((done) => server.listen(0, '127.0.0.1', done));
  35. const origin = `http://127.0.0.1:${server.address().port}`;
  36. const profile = path.join(run, 'profile');
  37. await mkdir(profile);
  38. const executable = process.env.CHROME_PATH || 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe';
  39. assert.ok(existsSync(executable), 'Edge/Chromium executable required');
  40. const browser = spawn(executable, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { windowsHide: true, stdio: 'ignore' });
  41. const pause = (ms) => new Promise((done) => setTimeout(done, ms));
  42. let socket;
  43. const check = (name, passed, detail) => { checks.push({ name, passed: Boolean(passed), ...(detail ? { detail } : {}) }); assert.ok(passed, name); };
  44. try {
  45. let port;
  46. for (let i = 0; i < 100; i++) {
  47. try { port = Number(readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split('\n')[0]); } catch {}
  48. if (port) break;
  49. await pause(100);
  50. }
  51. assert.ok(port);
  52. const tabs = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
  53. socket = new WebSocket(tabs.find((x) => x.type === 'page').webSocketDebuggerUrl);
  54. await new Promise((done, reject) => { socket.addEventListener('open', done, { once: true }); socket.addEventListener('error', reject, { once: true }); });
  55. let seq = 0;
  56. const pending = new Map();
  57. const cdp = (method, params = {}) => new Promise((resolve, reject) => {
  58. const id = ++seq;
  59. const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${method} timeout`)); }, 15000);
  60. pending.set(id, { resolve, reject, timer });
  61. socket.send(JSON.stringify({ id, method, params }));
  62. });
  63. const evaluate = async (expression) => {
  64. const value = await cdp('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
  65. if (value.exceptionDetails) throw new Error(value.exceptionDetails.exception?.description || value.exceptionDetails.text);
  66. return value.result.value;
  67. };
  68. const fixture = { result: { ok: true, data: { project: [], artifact: [], insight: [], source: [], session: [], opportunity: [], asOf: '2026-11-03T00:00:00.000Z', nextCursor: null } } };
  69. fixture.result.data.artifact.push({ objectId: 'fixture-report', title: '原有报告回归', stageCode: 'S4', artifactType: 'competitor_report', report: { title: '原有报告回归', chapters: [{ id: 'scope', title: '核对原文', sections: [{ kind: 'para', text: '公开报告阅读回归正文。' }] }], evidence: {} } });
  70. for (const type of ['category_report', 'voc_report', 'pain_list', 'concept']) fixture.result.data.artifact.push({ objectId: 'fixture-' + type, artifactType: type, title: '中文标题完整显示与多行排版验证', stageCode: 'S1', rawText: '本机回放排版测试资料。', createdAt: '2026-11-03T00:00:00.000Z' });
  71. fixture.result.data.opportunity.push({ objectId: 'fixture-opportunity', title: '原有机会卡回归', status: 'candidate', linkedArtifacts: ['fixture-report'], scores: [], updatedAt: '2026-11-03T00:00:00.000Z' });
  72. const fixtureEmployee = { key: 'employee:' + 'a'.repeat(64), name: '测试成员甲', displayName: '测试昵称', organizationName: '测试企业', departmentNames: ['测试产品部'], position: '产品经理', source: 'wecom_directory', nameVerified: true, verifiedAt: '2026-11-03T00:00:00Z' };
  73. fixture.result.data.session.push(...['one', 'two'].map((id) => ({ objectId: 'identity-session-' + id, studentName: '旧占位身份', employee: fixtureEmployee, meta: { participantKey: 'workshop-participant-' + id } })));
  74. Object.assign(fixture.result.data.artifact[0], { ownerSession: 'identity-session-one', ownerName: '旧占位身份' });
  75. socket.addEventListener('message', ({ data }) => {
  76. const m = JSON.parse(data);
  77. if (m.id && pending.has(m.id)) {
  78. const p = pending.get(m.id); clearTimeout(p.timer); pending.delete(m.id);
  79. if (m.error) p.reject(new Error(m.error.message)); else p.resolve(m.result);
  80. }
  81. if (m.method === 'Runtime.exceptionThrown') errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
  82. if (m.method === 'Network.requestWillBeSent') requests.push({ url: m.params.request.url, method: m.params.request.method, body: m.params.request.postData || '' });
  83. if (m.method === 'Fetch.requestPaused') {
  84. const { requestId, request } = m.params;
  85. if (request.url.startsWith(origin + '/')) cdp('Fetch.continueRequest', { requestId }).catch((e) => errors.push(e.message));
  86. else {
  87. forbidden.push({ url: request.url, method: request.method, body: request.postData || '' });
  88. const isPublicRead = request.postData?.includes('ksfGetPublicBoard');
  89. const isPreflight = request.method === 'OPTIONS';
  90. const action = request.postData ? JSON.parse(request.postData) : {};
  91. const isPublicAction = ['/ksfSetPublicOpportunityStatus', '/ksfRatePublicOpportunity'].includes(action.path);
  92. if (isPublicAction) {
  93. const opp = fixture.result.data.opportunity[0], params = action.params;
  94. opp.updatedAt = new Date().toISOString();
  95. if (action.path.includes('SetPublic')) opp.status = params.status;
  96. else opp.scores = [{ user: params.by, score: params.score, at: opp.updatedAt }];
  97. }
  98. cdp('Fetch.fulfillRequest', { requestId, responseCode: isPreflight ? 204 : isPublicRead || isPublicAction ? 200 : 451,
  99. responseHeaders: [{ name: 'Content-Type', value: 'application/json' }, { name: 'Access-Control-Allow-Origin', value: '*' }, { name: 'Access-Control-Allow-Headers', value: 'content-type,x-parse-application-id' }, { name: 'Access-Control-Allow-Methods', value: 'GET,POST,OPTIONS' }],
  100. body: Buffer.from(JSON.stringify(isPublicRead ? fixture : isPublicAction ? { result: { ok: true, data: { opportunity: fixture.result.data.opportunity[0] } } } : { error: 'No external traffic in acceptance' })).toString('base64'),
  101. }).catch((e) => errors.push(e.message));
  102. }
  103. }
  104. });
  105. await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable');
  106. await cdp('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
  107. await cdp('Page.addScriptToEvaluateOnNewDocument', { source: `const originalInterval=window.setInterval;window.setInterval=(fn,ms,...args)=>originalInterval(fn,ms===30000?100:ms,...args);` });
  108. const host = `document.querySelector('kf-app')?.shadowRoot?.querySelector('view-showcase')`;
  109. const shadow = `${host}?.shadowRoot`;
  110. const ready = async () => {
  111. for (let i = 0; i < 100; i++) { if (await evaluate(`Boolean(${host}?._model && !${host}?._busy)`)) return; await pause(100); }
  112. throw new Error('showcase did not load');
  113. };
  114. const go = async (route) => { await cdp('Page.navigate', { url: origin + route }); await ready(); await pause(150); };
  115. const shot = async (name, full = false) => {
  116. const params = { format: 'png', captureBeyondViewport: full };
  117. if (full) {
  118. const metrics = await cdp('Page.getLayoutMetrics');
  119. params.clip = { x: 0, y: 0, width: metrics.cssContentSize.width, height: metrics.cssContentSize.height, scale: 1 };
  120. }
  121. const result = await cdp('Page.captureScreenshot', params);
  122. await writeFile(path.join(run, name + '.png'), Buffer.from(result.data, 'base64'));
  123. };
  124. await cdp('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false });
  125. for (const key of ['overview', 'leadership', 'research', 'collaboration', 'validation', 'classroom']) {
  126. const route = key === 'overview' ? '/showcase' : '/showcase/' + key;
  127. await go(route);
  128. const info = await evaluate(`(()=>{const el=${host},root=el.shadowRoot;return {system:el.system,total:el._model.total,title:root.querySelector('h1')?.textContent,counts:[...root.querySelectorAll('.count-grid dd')].map(x=>Number(x.textContent)),width:document.documentElement.scrollWidth,viewport:innerWidth,images:[...root.images||root.querySelectorAll('img')].map(x=>({loaded:x.complete&&x.naturalWidth>0,src:x.src})),pending:root.querySelector('.pending')?.textContent};})()`);
  129. check(`${key}: correct route, real totals and loaded image`, info.system === key && info.total === 334 && info.counts.length === 12 && info.images.every((x) => x.loaded));
  130. check(`${key}: no page horizontal overflow`, info.width <= info.viewport + 1);
  131. observations.push(info);
  132. await shot(`desktop-${key}`, key === 'overview' || key === 'classroom');
  133. await cdp('Page.reload', { ignoreCache: true }); await ready();
  134. check(`${key}: survives direct refresh`, await evaluate(`${host}.system===${JSON.stringify(key)}`));
  135. }
  136. await go('/showcase');
  137. await evaluate(`${shadow}.querySelector('a[href$="/showcase/research"]').click()`); await pause(200);
  138. check('Shadow DOM links navigate to the correct page', await evaluate(`location.pathname==='/showcase/research'&&${host}.system==='research'`));
  139. await evaluate(`${shadow}.querySelector('.image-button').click()`); await pause(100);
  140. check('preview opens in a real modal dialog', await evaluate(`${shadow}.querySelector('dialog').open`));
  141. await cdp('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
  142. await cdp('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await pause(100);
  143. check('Escape closes preview', await evaluate(`!${shadow}.querySelector('dialog').open`));
  144. await go('/showcase?case=P01');
  145. for (let i = 0; i < 7; i++) {
  146. const observation = await evaluate(`(()=>{const el=${host};return {step:el._step,text:el.shadowRoot.querySelector('.story-body').textContent};})()`);
  147. check(`P01 step ${i + 1} displays its real record`, observation.step === i && observation.text.trim().length > 10);
  148. if (i < 6) await evaluate(`${shadow}.querySelector('.story-controls .primary').click()`);
  149. await pause(60);
  150. }
  151. check('P01 retains six questions after classroom completion', await evaluate(`${shadow}.querySelectorAll('.question-list li').length===6`));
  152. await go('/showcase/validation?case=P03');
  153. await evaluate(`${shadow}.querySelectorAll('.story-steps button')[1].click()`); await pause(80);
  154. check('P03 shows preparation conflict', await evaluate(`${shadow}.querySelector('.story-body').textContent.includes('无灶具')`));
  155. await evaluate(`${shadow}.querySelectorAll('.story-steps button')[2].click()`); await pause(80);
  156. check('P03 questions retain owners', await evaluate(`${shadow}.querySelectorAll('.question-list li').length===6&&${shadow}.querySelector('.question-list').textContent.includes('演示学员')`));
  157. await go('/showcase/validation?case=P10');
  158. await evaluate(`${shadow}.querySelectorAll('.story-steps button')[3].click()`); await pause(80);
  159. check('P10 does not fabricate missing validation tasks', await evaluate(`${shadow}.querySelector('.empty-inline').textContent.includes('尚无验证任务')`));
  160. for (const key of ['overview', 'leadership', 'research', 'collaboration', 'validation', 'classroom']) {
  161. await cdp('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
  162. await go(key === 'overview' ? '/showcase' : '/showcase/' + key);
  163. check(`${key}: mobile has no horizontal page overflow`, await evaluate('document.documentElement.scrollWidth<=innerWidth+1'));
  164. if (key === 'overview' || key === 'research') await shot('mobile-' + key, true);
  165. }
  166. await go(mount + 'showcase/collaboration?case=P01');
  167. check('nested deployment preserves route and image URLs', await evaluate(`${host}.system==='collaboration'&&${shadow}.querySelector('img').src.includes('/preview-test/src/assets/showcase/')`));
  168. await evaluate(`${shadow}.querySelector('a[href$="/showcase/validation"]').click()`); await pause(150);
  169. check('nested navigation preserves base path', await evaluate(`location.pathname==='/preview-test/showcase/validation'`));
  170. const before = forbidden.length;
  171. await evaluate(`(()=>{const app=document.querySelector('kf-app');for(const type of ['refresh','status-change','score-submit','artifact-open'])app.dispatchEvent(new CustomEvent(type,{detail:{id:'DEMO-O01',status:'approved',by:'模拟'}}));})()`);
  172. await pause(500);
  173. check('sample shell ignores global live business events', forbidden.length === before);
  174. check('all introduction interactions made zero external business requests', forbidden.length === 0);
  175. const sampleHost = `document.querySelector('kf-app').shadowRoot.querySelector('sample-shell')`;
  176. const viewHost = `${sampleHost}.shadowRoot.querySelector('main').firstElementChild`;
  177. const viewRoot = `${viewHost}.shadowRoot`;
  178. const detailHost = `${sampleHost}.shadowRoot.querySelector('sample-detail')`;
  179. const detailRoot = `${detailHost}.shadowRoot`;
  180. const sampleReady = async () => {
  181. for (let i = 0; i < 160; i++) { if (await evaluate(`(()=>{const shell=document.querySelector('kf-app')?.shadowRoot?.querySelector('sample-shell');return Boolean(shell?._model && shell.shadowRoot?.querySelector('main')?.firstElementChild?.shadowRoot?.querySelector('h1'));})()`)) { await pause(100); return; } await pause(100); }
  182. throw new Error('sample did not load');
  183. };
  184. const sampleGo = async (route) => { await cdp('Page.navigate', { url: origin + route }); await sampleReady(); };
  185. const clickRecord = async (id) => { await evaluate(`${viewRoot}.querySelector('button[data-record="${id}"]').click()`); await pause(150); };
  186. const submit = async (action, fields) => {
  187. await evaluate(`(()=>{const form=${detailRoot}.querySelector('[data-action="${action}"]').closest('form');for(const [key,value] of Object.entries(${JSON.stringify(fields)}))form.elements.namedItem(key).value=value;form.requestSubmit();})()`);
  188. await pause(200);
  189. };
  190. await cdp('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false });
  191. for (const name of ['cockpit', 'intel', 'coop', 'review', 'class']) {
  192. await sampleGo('/' + name);
  193. check(`${name}: independent sample shell and actual extension`, await evaluate(`${sampleHost}._model.baselineEntityCount===334&&${sampleHost}._model.extension.voices.length===12000`));
  194. check(`${name}: no page horizontal overflow`, await evaluate('document.documentElement.scrollWidth<=innerWidth+1'));
  195. await shot('demo-' + name);
  196. await cdp('Page.reload', { ignoreCache: true }); await sampleReady();
  197. check(`${name}: refresh preserves route`, await evaluate(`${sampleHost}.view===${JSON.stringify(name)}`));
  198. }
  199. await sampleGo('/cockpit?group=DEMO-G2');
  200. check('group filter scopes both project and report counts', await evaluate(`${viewHost}.filtered.projects.length===2&&${viewHost}.filtered.reports.every(r=>r.groupId==='DEMO-G2')`));
  201. await evaluate(`${viewRoot}.querySelector('[data-filter="group"]').value='';${viewRoot}.querySelector('[data-filter="group"]').dispatchEvent(new Event('change',{bubbles:true}));`); await pause(100);
  202. await evaluate(`${viewRoot}.querySelector('[data-filter="member"]').value='DEMO-M31';${viewRoot}.querySelector('[data-filter="member"]').dispatchEvent(new Event('change',{bubbles:true}));`); await pause(150);
  203. check('member selection filters owned and collaborating projects', await evaluate(`${viewHost}.filtered.projects.length<12&&${viewHost}.filtered.projects.some(p=>p.id==='DEMO-P10')`));
  204. await sampleGo('/intel');
  205. await evaluate(`${viewRoot}.querySelector('[data-filter="search"]').value='完全无此关键词XYZ';${viewRoot}.querySelector('[data-filter="search"]').dispatchEvent(new Event('change',{bubbles:true}));`); await pause(150);
  206. check('search has a real zero-result state', await evaluate(`${viewHost}.filtered.voices.length===0&&${viewRoot}.textContent.includes('没有符合条件')`));
  207. await cdp('Page.reload', { ignoreCache: true }); await sampleReady();
  208. check('search is preserved in URL after refresh', await evaluate(`${viewHost}.filters.search==='完全无此关键词XYZ'`));
  209. await sampleGo('/intel');
  210. await evaluate(`${viewRoot}.querySelector('[data-page="voicePage-next"]').click()`); await pause(150);
  211. check('pagination changes the visible voice records', await evaluate(`location.search.includes('voicePage=2')&&${viewRoot}.querySelector('[data-record^="SIM-VOICE"]').dataset.record==='SIM-VOICE-1-0011'`));
  212. await clickRecord('SIM-OBS-0001');
  213. check('product detail shows four distinct price snapshots', await evaluate(`${detailRoot}.querySelectorAll('tbody tr').length===4`));
  214. await cdp('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
  215. await cdp('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await pause(150);
  216. check('Escape closes record detail and removes deep-link query', await evaluate(`!${detailRoot}.querySelector('dialog').open&&!location.search.includes('record=')`));
  217. await sampleGo('/coop');
  218. await evaluate(`localStorage.setItem('unrelated-demo-sentinel','preserve')`);
  219. await clickRecord('DEMO-O01');
  220. check('opportunity opens local dialog', await evaluate(`${detailRoot}.querySelector('dialog').open&&${detailHost}.recordId==='DEMO-O01'`));
  221. await submit('status', { user: '模拟甲', status: 'candidate' });
  222. check('status action mutates only sample state', await evaluate(`${sampleHost}._model.opportunitiesById['DEMO-O01'].status==='candidate'`));
  223. await submit('score', { user: '模拟甲', score: '80', comment: '先核制备条件' });
  224. await submit('score', { user: '模拟乙', score: '60' });
  225. await submit('score', { user: '模拟甲', score: '100' });
  226. check('two voters and same-name replacement produce correct average', await evaluate(`${sampleHost}._model.opportunitiesById['DEMO-O01'].stats.avg===80&&${sampleHost}._model.opportunitiesById['DEMO-O01'].stats.n===2`));
  227. await cdp('Page.reload', { ignoreCache: true }); await sampleReady();
  228. check('status, scores and open record survive refresh', await evaluate(`${sampleHost}._model.opportunitiesById['DEMO-O01'].status==='candidate'&&${sampleHost}._model.opportunitiesById['DEMO-O01'].stats.avg===80&&${detailRoot}.querySelector('dialog').open`));
  229. await evaluate(`${detailRoot}.querySelector('[data-record="DEMO-P01"]').click()`); await pause(150);
  230. await submit('comment', { user: '模拟甲', body: '请补复水与沥水条件。' });
  231. check('project comment is retained with its project', await evaluate(`${sampleHost}._model.comments['DEMO-P01'].some(c=>c.body==='请补复水与沥水条件。')`));
  232. await evaluate(`${detailRoot}.querySelector('[data-record="DEMO-R-P01-S3"]').click()`); await pause(150);
  233. check('report detail opens actual stage record and pending questions', await evaluate(`${detailHost}.recordId==='DEMO-R-P01-S3'&&${detailRoot}.textContent.includes('依据与适用边界')&&${detailRoot}.querySelectorAll('[data-record^="DEMO-Q01"]').length===6`));
  234. await evaluate(`${detailRoot}.querySelector('[data-record="DEMO-S01"]').click()`); await pause(150);
  235. check('report source opens original sample text', await evaluate(`${detailRoot}.querySelector('.raw').textContent.includes('中午只有热水')`));
  236. await sampleGo('/coop?detail=source&record=UNKNOWN');
  237. check('unknown sample detail shows local error with no production lookup', await evaluate(`${detailRoot}.textContent.includes('不会向线上接口补查')`));
  238. await sampleGo('/review?stage=S3');
  239. check('S3 does not fabricate later validation tasks', await evaluate(`${viewHost}.filtered.validationTasks.length===0&&${viewHost}.filtered.questions.length===12`));
  240. await sampleGo('/class'); await clickRecord('DEMO-A-DEMO-G1-2');
  241. check('class assignment resolves its own report references', await evaluate(`${detailRoot}.querySelectorAll('[data-record^="DEMO-R-"]').length===4`));
  242. for (const [key, count] of [['P01', 7], ['P03', 4], ['P10', 4]]) {
  243. await sampleGo(`/review?case=${key}&step=${count - 1}`);
  244. check(`${key}: clickable guided path resolves to local project records`, await evaluate(`${viewRoot}.querySelectorAll('.steps button').length===${count}&&${viewRoot}.querySelectorAll('.story-records [data-record]').length>0`));
  245. }
  246. await sampleGo('/coop');
  247. await evaluate(`${sampleHost}.shadowRoot.querySelector('.sample-strip button').click()`); await pause(200);
  248. check('reset restores statuses and removes local votes while preserving unrelated storage', await evaluate(`${sampleHost}._model.opportunitiesById['DEMO-O01'].status==='approved'&&${sampleHost}._model.opportunitiesById['DEMO-O01'].stats===null&&localStorage.getItem('unrelated-demo-sentinel')==='preserve'`));
  249. for (const name of ['cockpit', 'intel', 'coop', 'review', 'class']) {
  250. await cdp('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
  251. await sampleGo('/' + name);
  252. check(`${name}: mobile layout permits table scrolling without page overflow`, await evaluate('document.documentElement.scrollWidth<=innerWidth+1'));
  253. if (name === 'coop') await shot('demo-mobile-coop');
  254. }
  255. await sampleGo(mount + 'coop?detail=opportunity&record=DEMO-O01');
  256. check('nested demo detail loads without production routing', await evaluate(`${detailHost}.recordId==='DEMO-O01'&&${detailRoot}.querySelector('dialog').open`));
  257. await evaluate(`${detailHost}.close()`); await pause(100);
  258. await evaluate(`(()=>{const app=document.querySelector('kf-app');for(const type of ['refresh','status-change','score-submit'])app.dispatchEvent(new CustomEvent(type,{detail:{id:'DEMO-O01',status:'approved'}}));})()`); await pause(350);
  259. check('five views and local operations made zero external business requests', forbidden.length === 0);
  260. // Existing live route regression uses a local interception fixture, never production.
  261. await evaluate(`import(${JSON.stringify(origin + mount + 'src/utils/router.js')}).then(m=>m.navigate('/'))`); await pause(600);
  262. check('existing live route resumes loading', forbidden.some((r) => r.body.includes('ksfGetPublicBoard')));
  263. check('existing navigation preserves its original scope without an extra demonstration entry', await evaluate(`(()=>{const root=document.querySelector('kf-app').shadowRoot.querySelector('kf-nav').shadowRoot;return !root.querySelector('a[data-path="/showcase"]')&&!root.querySelector('.showcase-entry')&&root.querySelectorAll('.tabbar a').length===5&&[...root.querySelectorAll('a[data-path]')].every(a=>a.href.includes('/preview-test/'));})()`));
  264. const legacyNavigate = async (route) => { await evaluate(`import(${JSON.stringify(origin + mount + 'src/utils/router.js')}).then(m=>m.navigate(${JSON.stringify(route)}))`); await pause(450); };
  265. for (const width of [1440, 390]) {
  266. await cdp('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: width < 768 });
  267. await legacyNavigate('/members');
  268. const identityInfo = await evaluate(`(()=>{const page=document.querySelector('kf-app').shadowRoot.querySelector('view-members');const rows=[...page.shadowRoot.querySelectorAll('[data-member]')];return {names:rows.map(r=>r.dataset.member),text:page.shadowRoot.textContent,width:document.documentElement.scrollWidth,viewport:innerWidth};})()`);
  269. check(`employee ${width}px: same member appears once across workshops, with available directory fields`, identityInfo.names.length === 1 && identityInfo.names[0] === '测试成员甲' && ['测试产品部', '产品经理', '企业微信通讯录姓名', '姓名同步于'].every((s) => identityInfo.text.includes(s)));
  270. check(`employee ${width}px: no page overflow or old identity label`, identityInfo.width <= identityInfo.viewport + 1 && !identityInfo.text.includes('旧占位身份'));
  271. await shot('employee-profile-' + width);
  272. }
  273. const measureTitles = async () => evaluate(`(()=>{
  274. const root=document.querySelector('kf-app').shadowRoot;
  275. const headings=[root.querySelector('kf-topbar').shadowRoot.querySelector('h1'),...root.querySelector('main').firstElementChild.shadowRoot.querySelectorAll('[data-type-card] h3,.head h2')];
  276. const canvas=document.createElement('canvas'),ctx=canvas.getContext('2d');
  277. return headings.map(el=>{const s=getComputedStyle(el),r=el.getBoundingClientRect();ctx.font=s.fontWeight+' '+s.fontSize+' '+s.fontFamily;const glyph=ctx.measureText(el.textContent);return {text:el.textContent,height:r.height,glyphHeight:glyph.actualBoundingBoxAscent+glyph.actualBoundingBoxDescent,trim:s.textBoxTrim,clipped:r.height+0.5<glyph.actualBoundingBoxAscent+glyph.actualBoundingBoxDescent};});
  278. })()`);
  279. for (const width of [2048, 1280, 1024, 768, 390]) {
  280. await cdp('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: width < 768 });
  281. await legacyNavigate('/reports');
  282. await evaluate('document.fonts.ready');
  283. const headings = await measureTitles(); observations.push({ reportWidth: width, headings });
  284. if (width === 2048 || width === 390) await shot('reports-typography-' + width);
  285. check(`reports ${width}px: Chinese heading boxes retain the complete glyph height`, headings.length === 6 && headings.every(x => !x.clipped), headings);
  286. check(`reports ${width}px: no page horizontal overflow`, await evaluate('document.documentElement.scrollWidth<=innerWidth+1'));
  287. await evaluate(`document.querySelector('kf-app').shadowRoot.querySelector('view-reports').shadowRoot.querySelector('[data-type-card="voc"]').click()`); await pause(250);
  288. const typeHeadings = await measureTitles();
  289. check(`report category ${width}px: heading and classification retain full glyphs`, typeHeadings.length === 2 && typeHeadings.every(x => !x.clipped), typeHeadings);
  290. check(`report category ${width}px: active tab is exposed correctly`, await evaluate(`document.querySelector('kf-app').shadowRoot.querySelector('view-report-type').shadowRoot.querySelector('.tab.cur').getAttribute('aria-current')==='page'`));
  291. }
  292. await cdp('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false });
  293. await legacyNavigate('/pool/fixture-report');
  294. check('legacy wrong report link recovers to report route', await evaluate(`location.pathname==='/preview-test/report/fixture-report'&&Boolean(document.querySelector('kf-app').shadowRoot.querySelector('view-report'))`));
  295. await legacyNavigate('/pool/fixture-opportunity');
  296. const legacyDrawer = `document.querySelector('kf-app').shadowRoot.querySelector('kf-drawer').shadowRoot`;
  297. check('existing public opportunity drawer resolves its record', await evaluate(`${legacyDrawer}.textContent.includes('决策与状态')&&!${legacyDrawer}.textContent.includes('未找到这张机会卡')`));
  298. await evaluate(`${legacyDrawer}.querySelectorAll('.st-row button')[1].click()`); await pause(250);
  299. check('existing state button retains public function contract', forbidden.some((r) => r.body.includes('ksfSetPublicOpportunityStatus') && r.body.includes('fixture-opportunity')));
  300. await evaluate(`document.querySelector('kf-app').dispatchEvent(new CustomEvent('score-submit',{detail:{user:'回归访客',score:85,comment:'本机拦截回放'}}))`); await pause(250);
  301. check('existing rating retains public function contract', forbidden.some((r) => r.body.includes('ksfRatePublicOpportunity') && r.body.includes('fixture-opportunity')));
  302. await evaluate(`import(${JSON.stringify(origin + mount + 'src/utils/router.js')}).then(m=>m.navigate('/showcase'))`); await ready(); await pause(200);
  303. const afterLive = forbidden.length;
  304. await pause(500);
  305. check('returning to introduction stops accelerated live polling', forbidden.length === afterLive);
  306. seedUnavailable = true;
  307. await cdp('Page.navigate', { url: origin + '/showcase' });
  308. for (let i = 0; i < 60; i++) { if (await evaluate(`Boolean(${host}?._error)`)) break; await pause(100); }
  309. check('failed sample fetch shows error instead of live fallback', await evaluate(`${host}._error.includes('503')`));
  310. seedUnavailable = false;
  311. await evaluate(`${shadow}.querySelector('.loading button').click()`); await ready();
  312. check('retry recovers the local sample', await evaluate(`${host}._model.total===334`));
  313. check('no uncaught browser exceptions', errors.length === 0, errors);
  314. await writeFile(path.join(run, 'acceptance.json'), JSON.stringify({ ok: true, checks, observations, requests, interceptedExternalRequests: forbidden, errors, productionRequestsSent: 0, deployed: false }, null, 2));
  315. console.log(JSON.stringify({ ok: true, checks: checks.length, output: run, productionRequestsSent: 0 }));
  316. } catch (error) {
  317. await writeFile(path.join(run, 'acceptance.json'), JSON.stringify({ ok: false, error: error.stack, checks, observations, requests, interceptedExternalRequests: forbidden, errors, deployed: false }, null, 2));
  318. console.error(error);
  319. console.error(`Evidence: ${run}`);
  320. process.exitCode = 1;
  321. } finally {
  322. socket?.close(); browser.kill(); server.closeAllConnections(); server.close();
  323. }