verify-opportunity-links.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. // Fresh anonymous browser regression for opportunity card details and linked reports.
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import assert from 'node:assert/strict';
  5. import { spawn } from 'node:child_process';
  6. const base = process.argv[2] || 'http://127.0.0.1:4178';
  7. const profile = fs.mkdtempSync(path.resolve('work/opportunity-browser-'));
  8. const browser = spawn('C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', [
  9. '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
  10. '--disable-background-networking', '--remote-debugging-address=127.0.0.1',
  11. '--remote-debugging-port=0', '--user-data-dir=' + profile, 'about:blank',
  12. ], { windowsHide: true, stdio: 'ignore' });
  13. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  14. let socket;
  15. const requests = [];
  16. const checks = [];
  17. const errors = [];
  18. const screenshots = [];
  19. const runId = Date.now();
  20. const output = process.env.KF_OPPORTUNITY_RESULT || path.join(profile, 'result.json');
  21. try {
  22. let port;
  23. for (let i = 0; i < 100; i++) {
  24. try { port = Number(fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split('\n')[0]); } catch {}
  25. if (port) break;
  26. await sleep(100);
  27. }
  28. assert.ok(port, 'Edge CDP unavailable');
  29. const tabs = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
  30. socket = new WebSocket(tabs.find((tab) => tab.type === 'page').webSocketDebuggerUrl);
  31. await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }); });
  32. let seq = 0;
  33. const pending = new Map();
  34. socket.addEventListener('message', ({ data }) => {
  35. const msg = JSON.parse(data);
  36. if (msg.id && pending.has(msg.id)) {
  37. const call = pending.get(msg.id);
  38. clearTimeout(call.timer);
  39. pending.delete(msg.id);
  40. msg.error ? call.reject(Error(msg.error.message)) : call.resolve(msg.result);
  41. }
  42. if (msg.method === 'Network.requestWillBeSent') requests.push({ method: msg.params.request.method, url: msg.params.request.url, body: msg.params.request.postData, headerNames: Object.keys(msg.params.request.headers || {}) });
  43. if (msg.method === 'Runtime.exceptionThrown') errors.push({ kind: 'runtime', text: msg.params.exceptionDetails.text });
  44. if (msg.method === 'Log.entryAdded' && msg.params.entry.level === 'error' && !msg.params.entry.url?.endsWith('/favicon.ico')) errors.push({ kind: 'console', text: msg.params.entry.text });
  45. if (msg.method === 'Network.loadingFailed' && msg.params.errorText !== 'net::ERR_ABORTED') errors.push({ kind: 'network', text: msg.params.errorText });
  46. });
  47. const cdp = (method, params = {}) => new Promise((resolve, reject) => {
  48. const id = ++seq;
  49. const timer = setTimeout(() => { pending.delete(id); reject(Error(`${method} timed out`)); }, 30000);
  50. pending.set(id, { resolve, reject, timer });
  51. socket.send(JSON.stringify({ id, method, params }));
  52. });
  53. const evaluate = async (expression) => {
  54. const result = await cdp('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
  55. if (result.exceptionDetails) throw Error(result.exceptionDetails.text);
  56. return result.result.value;
  57. };
  58. const find = `function find(root, selector) { const hit=root.querySelector(selector); if(hit)return hit; for(const node of root.querySelectorAll('*')) if(node.shadowRoot){const nested=find(node.shadowRoot,selector);if(nested)return nested;} return null; }`;
  59. const shot = async (label) => {
  60. const result = await cdp('Page.captureScreenshot', { format: 'png' });
  61. const file = `work/live-opportunity-ui-${label}-${runId}.png`;
  62. fs.writeFileSync(file, Buffer.from(result.data, 'base64'), { flag: 'wx' }); screenshots.push(file);
  63. };
  64. const waitFor = async (expression) => {
  65. for (let i = 0; i < 80; i++) {
  66. const value = await evaluate(expression).catch(() => false);
  67. if (value) return value;
  68. await sleep(150);
  69. }
  70. throw Error(`Condition not reached: ${expression.slice(0, 80)}`);
  71. };
  72. await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable'); await cdp('Log.enable');
  73. await cdp('Network.setCacheDisabled', { cacheDisabled: true });
  74. await cdp('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false });
  75. await cdp('Page.navigate', { url: base + '/pool' });
  76. await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready')`);
  77. const card = await waitFor(`(()=>{${find}const c=find(document,'kf-opportunity-card[key=\"7TnUg76JOI\"]');return c?.shadowRoot.querySelector('a.report')?.href})()`);
  78. assert.equal(new URL(card).pathname, '/report/mX1FapeOVB');
  79. await evaluate(`(()=>{${find}find(document,'kf-opportunity-card[key=\"7TnUg76JOI\"]').shadowRoot.querySelector('a.report').click();return true})()`);
  80. await waitFor(`location.pathname==='/report/mX1FapeOVB'`);
  81. const report = await waitFor(`(()=>{${find}const v=find(document,'view-report');return v?.shadowRoot?.textContent.includes('【样例】')||v?.shadowRoot?.textContent.includes('拌面')})()`);
  82. assert.ok(report);
  83. await shot('report-desktop');
  84. await cdp('Page.navigate', { url: base + '/pool/mX1FapeOVB' });
  85. await waitFor(`location.pathname==='/report/mX1FapeOVB'`);
  86. checks.push('旧的 /pool/:报告编号 链接可恢复为正确报告');
  87. await cdp('Page.navigate', { url: base + '/pool' });
  88. await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready')`);
  89. await evaluate(`(()=>{${find}find(document,'kf-opportunity-card[key=\"7TnUg76JOI\"]').shadowRoot.querySelector('.acts .kf-btn').click();return true})()`);
  90. await waitFor(`location.pathname==='/pool/7TnUg76JOI'`);
  91. const heading = await waitFor(`(()=>{${find}const d=find(document,'kf-drawer');return d?.open&&d.heading})()`);
  92. assert.ok(heading.includes('拌面方向待验证'));
  93. await shot('drawer-desktop');
  94. checks.push('关联报告路由正确,真实报告可读', '详情使用机会卡编号,标题匹配');
  95. const cards = await evaluate(`import('/src/services/board-service.js').then(m=>m.boardService.db.get().opportunities.map(o=>({id:o.id,title:o.title,reportId:o.reportArtifact?.id})))`);
  96. for (const card of cards) {
  97. await evaluate(`import('/src/utils/router.js').then(m=>m.navigate('/pool/${card.id}'))`);
  98. await waitFor(`(()=>{${find}const d=find(document,'kf-drawer');return d?.open&&d.heading===${JSON.stringify(card.title)}})()`);
  99. if (card.reportId) {
  100. await evaluate(`(()=>{${find}find(document,'kf-opportunity-card[key="${card.id}"]').shadowRoot.querySelector('a.report').click();return true})()`);
  101. await waitFor(`location.pathname==='/report/${card.reportId}'`);
  102. await waitFor(`(()=>{${find}const v=find(document,'view-report');return v?.artifactId==='${card.reportId}'&&Boolean(v?.shadowRoot?.querySelector('.cover h1'))})()`);
  103. }
  104. checks.push(`机会卡 ${card.id} 详情${card.reportId ? '/关联报告' : ''}可读`);
  105. }
  106. if (process.env.KF_ACTION_TEST_ID) {
  107. const id = process.env.KF_ACTION_TEST_ID;
  108. for (const width of [1440, 390]) {
  109. await cdp('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: width === 390 });
  110. const signature = width === 1440 ? '' : '公开操作验收样例';
  111. await evaluate(`import('/src/services/board-service.js').then(m=>m.boardService.setMe(${JSON.stringify(signature)}))`);
  112. for (const status of ['approved', 'rejected', 'candidate']) {
  113. await cdp('Page.navigate', { url: base + '/pool' });
  114. await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready')`);
  115. const index = ['candidate', 'approved', 'rejected'].indexOf(status);
  116. const before = requests.length;
  117. await evaluate(`(()=>{${find}const card=find(document,'kf-opportunity-card[key="${id}"]');if(!card)throw Error('Test card absent');card.scrollIntoView();card.shadowRoot.querySelectorAll('.st button')[${index}].click();return true})()`);
  118. await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.db.get().opportunityById['${id}']?.status==='${status}')`);
  119. await sleep(150);
  120. const writes = requests.slice(before).filter((r) => r.url.endsWith('/api/functions') && JSON.parse(r.body || '{}').path === '/ksfSetPublicOpportunityStatus');
  121. assert.equal(writes.length, 1, 'One click must send exactly one state action');
  122. assert.equal(JSON.parse(writes[0].body).params.opportunityId, id);
  123. await cdp('Page.reload', { ignoreCache: true });
  124. await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready'&&m.boardService.db.get().opportunityById['${id}']?.status==='${status}')`);
  125. const persistedBy = await evaluate(`import('/src/services/board-service.js').then(m=>m.boardService.db.get().opportunityById['${id}']?.decision?.by)`);
  126. assert.equal(persistedBy, signature || '匿名访客');
  127. checks.push(`${width}px ${status} 单次点击/真实保存/刷新一致`);
  128. }
  129. await evaluate(`import('/src/utils/router.js').then(m=>m.navigate('/pool/${id}'))`);
  130. await waitFor(`(()=>{${find}return find(document,'kf-drawer')?.open})()`);
  131. assert.ok(await evaluate('document.documentElement.scrollWidth<=innerWidth'), 'Viewport overflow');
  132. await shot('state-drawer-' + width);
  133. }
  134. await evaluate(`import('/src/utils/router.js').then(m=>m.navigate('/pool/${id}'))`);
  135. await waitFor(`(()=>{${find}return find(document,'kf-drawer')?.open})()`);
  136. await evaluate(`import('/src/services/board-service.js').then(m=>m.boardService.setMe('公开操作验收样例'))`);
  137. await sleep(150);
  138. const before = requests.length;
  139. await evaluate(`(()=>{${find}const panel=find(document,'kf-score-panel');const input=panel.shadowRoot.querySelector('input[type=number]');for(let i=0;i<2;i++){input.value='76';input.dispatchEvent(new Event('input',{bubbles:true}));panel.shadowRoot.querySelector('form').requestSubmit();}return true})()`);
  140. await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.db.get().opportunityById['${id}']?.scores.some(s=>s.user==='公开操作验收样例'&&s.score===76))`);
  141. assert.equal(requests.slice(before).filter((r) => r.url.endsWith('/api/functions') && JSON.parse(r.body || '{}').path === '/ksfRatePublicOpportunity').length, 1);
  142. await cdp('Page.reload', { ignoreCache: true });
  143. await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready'&&m.boardService.db.get().opportunityById['${id}']?.scores.some(s=>s.user==='公开操作验收样例'&&s.score===76))`);
  144. checks.push('抽屉评分快速双提交只有一次写入,刷新回读一致');
  145. await shot('score-mobile');
  146. }
  147. assert.ok(!requests.some((r) => r.method === 'PUT' || r.method === 'DELETE'), 'Browser must not write raw tables');
  148. assert.ok(!requests.some((r) => r.headerNames.some((key) => /master|authorization|session.token|ksf.service.token/i.test(key))), 'Browser must remain anonymous');
  149. assert.ok(!requests.some((r) => r.url.includes('/parse/classes/')), 'Browser must use public projection/actions only');
  150. assert.equal(errors.length, 0, 'Browser runtime/network errors');
  151. checks.push('浏览器无凭据、无直接业务表访问、无运行或网络错误');
  152. const result = { ok: true, at: new Date().toISOString(), base, checks, errors, screenshots, productionFrontendBrowserVerified: base === 'https://kangshifu.fmode.cn', moduleOverrides: false, reportPath: '/report/mX1FapeOVB', opportunityPath: '/pool/7TnUg76JOI', drawerHeading: heading, rawTableWrites: 0, testOpportunityId: process.env.KF_ACTION_TEST_ID || null };
  153. fs.writeFileSync(output, JSON.stringify(result, null, 2));
  154. console.log(JSON.stringify(result));
  155. } catch (error) {
  156. const failure = { ok: false, checks, errors, screenshots, error: error.message };
  157. fs.writeFileSync(output, JSON.stringify(failure, null, 2));
  158. console.error(JSON.stringify(failure));
  159. process.exitCode = 1;
  160. } finally {
  161. socket?.close();
  162. browser.kill();
  163. }