verify-live-S1-v2-browser.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. // Anonymous real-browser acceptance; only navigates and opens evidence drawers.
  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 live=JSON.parse(fs.readFileSync('work/live-S1-v2-readback-20261102.json','utf8'));
  7. const template=JSON.parse(fs.readFileSync('work/first-live-S1-v2-template.json','utf8'));
  8. const localPatch=process.argv.includes('--local-patch');
  9. assert.ok(process.argv.slice(2).every(arg=>arg==='--local-patch'));
  10. const patchFiles=['src/core/report.js','src/core/evidence/chain.js','src/layout/kf-app.js'];
  11. const base='https://kangshifu.fmode.cn';
  12. const profile=path.resolve('work/browser-S1-v2-'+Date.now());fs.mkdirSync(profile);
  13. const browser=spawn('C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',[
  14. '--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check','--disable-background-networking',
  15. '--remote-debugging-address=127.0.0.1','--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
  16. ],{windowsHide:true,stdio:'ignore'});
  17. const sleep=ms=>new Promise(r=>setTimeout(r,ms));
  18. const checks=[],errors=[],requests=[],observations=[];
  19. let socket;
  20. const resultFile='work/live-S1-v2-browser'+(localPatch?'-local-patch':'')+'-20261102.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;await sleep(100);
  26. }
  27. assert.ok(port,'browser debugger');
  28. const tabs=await(await fetch('http://127.0.0.1:'+port+'/json/list')).json();
  29. socket=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);
  30. await new Promise((resolve,reject)=>{socket.addEventListener('open',resolve,{once:true});socket.addEventListener('error',reject,{once:true});});
  31. let seq=0;const pending=new Map();
  32. socket.addEventListener('message',({data})=>{
  33. const m=JSON.parse(data);
  34. if(m.id&&pending.has(m.id)){const p=pending.get(m.id);clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);}
  35. if(m.method==='Runtime.exceptionThrown')errors.push({runtime:m.params.exceptionDetails.text});
  36. if(m.method==='Network.loadingFailed'&&m.params.errorText!=='net::ERR_ABORTED')errors.push({network:m.params.errorText});
  37. if(m.method==='Log.entryAdded'&&m.params.entry.level==='error'&&!m.params.entry.url?.endsWith('/favicon.ico'))errors.push({console:m.params.entry.text});
  38. if(m.method==='Network.requestWillBeSent')requests.push({url:m.params.request.url,method:m.params.request.method,headerNames:Object.keys(m.params.request.headers||{})});
  39. });
  40. const cdp=(method,params={})=>new Promise((resolve,reject)=>{
  41. const id=++seq,timer=setTimeout(()=>{pending.delete(id);reject(Error(method+' timeout'));},30000);
  42. pending.set(id,{resolve,reject,timer});socket.send(JSON.stringify({id,method,params}));
  43. });
  44. if(localPatch){
  45. socket.addEventListener('message',({data})=>{
  46. const m=JSON.parse(data);if(m.method!=='Fetch.requestPaused')return;
  47. const p=m.params;const relative=new URL(p.request.url).pathname.slice(1);
  48. const action=patchFiles.includes(relative)?cdp('Fetch.fulfillRequest',{
  49. requestId:p.requestId,responseCode:200,responseHeaders:[{name:'Content-Type',value:'text/javascript; charset=utf-8'},{name:'Cache-Control',value:'no-store'}],
  50. body:fs.readFileSync(relative).toString('base64')
  51. }):cdp('Fetch.continueRequest',{requestId:p.requestId});
  52. action.catch(()=>errors.push({intercept:'LOCAL_OVERRIDE_FAILED'}));
  53. });
  54. await cdp('Fetch.enable',{patterns:patchFiles.map(file=>({urlPattern:base+'/'+file,requestStage:'Request'}))});
  55. }
  56. const evaluate=async expression=>{
  57. const r=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});
  58. if(r.exceptionDetails)throw Error(r.exceptionDetails.text);return r.result.value;
  59. };
  60. const check=(name,passed)=>{checks.push({name,passed:Boolean(passed)});assert.ok(passed,name);};
  61. const helpers=`function find(root,selector){const hit=root.querySelector(selector);if(hit)return hit;for(const n of root.querySelectorAll('*'))if(n.shadowRoot){const found=find(n.shadowRoot,selector);if(found)return found;}return null;}
  62. function text(root){let out='';for(const n of root.childNodes){if(n.nodeType===Node.TEXT_NODE)out+=n.textContent;else if(n.nodeType===Node.ELEMENT_NODE&&!['STYLE','SCRIPT','NOSCRIPT','TEMPLATE'].includes(n.tagName)){out+=text(n);if(n.shadowRoot)out+=text(n.shadowRoot);}}return out;}`;
  63. const deepText=()=>evaluate(`(()=>{${helpers}return text(document);})()`);
  64. const compact=s=>s.replace(/\s/g,'');
  65. // The existing display typography inserts U+2009 around Latin text; keep every other character.
  66. const quoteText=s=>s.replace(/\u2009/g,'');
  67. const navigate=async route=>{
  68. await cdp('Page.navigate',{url:base+route});let ready=false;
  69. for(let i=0;i<120;i++){
  70. await sleep(200);
  71. try{ready=await evaluate("import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready')");}catch{}
  72. if(ready)break;
  73. }
  74. check(route+' data loaded',ready);await sleep(250);
  75. };
  76. const dimensions=mobile=>cdp('Emulation.setDeviceMetricsOverride',mobile?
  77. {width:390,height:844,deviceScaleFactor:1,mobile:true}:{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
  78. await cdp('Page.enable');await cdp('Runtime.enable');await cdp('Network.enable');await cdp('Log.enable');
  79. for(const mobile of [false,true]){
  80. await dimensions(mobile);
  81. for(const route of ['/members','/stages','/report/'+live.artifactId]){
  82. await navigate(route);const rendered=compact(await deepText());
  83. check(route+' '+(mobile?'390px':'desktop')+' report/owner visible',rendered.includes(template.title)||rendered.includes('员工待绑定·f83edd27dda7'));
  84. check(route+' '+(mobile?'390px':'desktop')+' no page overflow',await evaluate('document.documentElement.scrollWidth<=innerWidth'));
  85. if(route.startsWith('/report/')){
  86. check('report title '+mobile,rendered.includes(template.title));
  87. check('classroom boundary '+mobile,rendered.includes('课堂过程成果')&&rendered.includes('未经员工正式确认'));
  88. check('competitor table '+mobile,rendered.includes('贵州豆豉辣酱风味面')&&rendered.includes('统一酱拌面'));
  89. check('own product and 2022 limitation '+mobile,rendered.includes('康师傅已入库资料')&&rendered.includes('2022')&&rendered.includes('不能直接按产品数量比较'));
  90. for(const question of template.pendingQuestions)check('question '+(template.pendingQuestions.indexOf(question)+1)+' '+mobile,rendered.includes(compact(question)));
  91. check('agent attribution '+mobile,rendered.includes('王刚001'));
  92. const screenshot=await cdp('Page.captureScreenshot',{format:'png'});
  93. fs.writeFileSync('work/live-S1-v2-report-'+(mobile?'mobile':'desktop')+(localPatch?'-local-patch':'')+'.png',Buffer.from(screenshot.data,'base64'));
  94. for(const [key,entry]of Object.entries(template.report.evidence)){
  95. const selector=JSON.stringify('[data-anchor="'+key+'"]');
  96. check('evidence button '+key+' '+mobile,await evaluate(`(()=>{${helpers}const button=find(document,${selector});if(!button)return false;button.scrollIntoView({block:'center'});return true;})()`));
  97. await sleep(300);
  98. const point=await evaluate(`(()=>{${helpers}const r=find(document,${selector}).getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`);
  99. await cdp('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
  100. await cdp('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
  101. await sleep(350);
  102. const drawer=await evaluate(`(()=>{${helpers}const host=find(document,'kf-drawer');const panel=host?.shadowRoot.querySelector('.panel');const body=host?.shadowRoot.querySelector('.body');const r=panel?.getBoundingClientRect();const style=panel?getComputedStyle(panel):null;return {open:host?.open===true,visible:!!r&&r.width>0&&r.height>0&&r.right>0&&r.left<innerWidth&&style.opacity==='1'&&style.pointerEvents==='auto',text:body?text(body):'',quotes:body?[...body.querySelectorAll('.quotes > li > p')].map(p=>p.textContent):[]};})()`);
  103. const quoteMatch=JSON.stringify(drawer.quotes.map(quoteText))===JSON.stringify(entry.quotes.map(q=>quoteText(q.text)));
  104. if(!quoteMatch)observations.push({evidence:key,actualQuotes:drawer.quotes,expectedQuotes:entry.quotes.map(q=>q.text)});
  105. check('visible evidence drawer '+key+' '+mobile,drawer.open&&drawer.visible);
  106. check('drawer quotes match except typography thin spaces '+key+' '+mobile,quoteMatch);
  107. for(const quote of entry.quotes)check('verbatim quote '+key+' '+entry.quotes.indexOf(quote)+' '+mobile,compact(drawer.text).includes(compact(quote.text)));
  108. check('registered source label '+key+' '+mobile,drawer.text.includes('已关联原始资料')&&!drawer.text.includes('平台原文,未落入知识库'));
  109. check('open drawer no page overflow '+key+' '+mobile,await evaluate('document.documentElement.scrollWidth<=innerWidth'));
  110. const drawerScreenshot=await cdp('Page.captureScreenshot',{format:'png'});
  111. fs.writeFileSync('work/live-S1-v2-evidence-'+key+'-'+(mobile?'mobile':'desktop')+(localPatch?'-local-patch':'')+'.png',Buffer.from(drawerScreenshot.data,'base64'));
  112. observations.push({viewport:mobile?390:1440,evidence:key,drawerVisible:drawer.open&&drawer.visible,quoteCount:drawer.quotes.length,input:'browser mouse event',unlinkedSourceLabel:drawer.text.includes('平台原文,未落入知识库')});
  113. await cdp('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});
  114. await cdp('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});
  115. await sleep(300);
  116. check('evidence drawer closed '+key+' '+mobile,await evaluate(`(()=>{${helpers}return find(document,'kf-drawer')?.open===false;})()`));
  117. }
  118. }
  119. }
  120. }
  121. await navigate('/report/'+live.previousArtifactId);
  122. check('v1 remains readable',compact(await deepText()).includes('S1真实资料首批小结'));
  123. check('no browser errors',errors.length===0);
  124. check('public cloud reads',requests.some(r=>r.url.endsWith('/api/functions')&&r.method==='POST'));
  125. check('anonymous requests',!requests.some(r=>r.headerNames.some(k=>/master|authorization|session.token|ksf.service.token/i.test(k))));
  126. const result={ok:true,at:new Date().toISOString(),base,artifactId:live.artifactId,previousArtifactId:live.previousArtifactId,
  127. browserVerified:!localPatch,localPatchBrowserVerified:localPatch,scope:localPatch?'local module overrides with live public API data':'production website',
  128. readOnly:true,businessWrites:0,quoteComparison:'all characters equal after removing display U+2009 thin spaces',checks,errors,observations};
  129. fs.writeFileSync(resultFile,JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result,null,2));
  130. }catch(e){
  131. const result={ok:false,at:new Date().toISOString(),artifactId:live.artifactId,browserVerified:false,readOnly:true,businessWrites:0,checks,errors,observations,error:e.message};
  132. fs.writeFileSync(resultFile,JSON.stringify(result,null,2)+'\n');console.error(JSON.stringify(result,null,2));process.exitCode=1;
  133. }finally{socket?.close();browser.kill();}