verify-live-S3-browser-complete.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. // Anonymous production browser acceptance. No local overrides or business write calls.
  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 verified=JSON.parse(fs.readFileSync('work/live-S3-readback-20261102.json','utf8'));
  7. const template=JSON.parse(fs.readFileSync('work/first-live-S3-template.json','utf8'));
  8. assert.ok(verified.ok);assert.equal(verified.artifactId,'oiNxX7o27j');
  9. const base='https://kangshifu.fmode.cn',output='work/live-S3-browser-partial-20261103.json';
  10. const runId=Date.now();
  11. assert.equal(fs.existsSync(output),false,'Preserve prior browser acceptance');
  12. const profile=path.resolve('work/browser-S3-'+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(resolve=>setTimeout(resolve,ms));
  18. const checks=[],errors=[],requests=[],observations=[],screenshots=[],missingQuestions=[];
  19. let socket;
  20. try{
  21. let port;
  22. for(let i=0;i<100;i++){try{port=Number(fs.readFileSync(path.join(profile,'DevToolsActivePort'),'utf8').split('\n')[0]);}catch{}if(port)break;await sleep(100);}
  23. assert.ok(port,'browser debugger');
  24. const tabs=await(await fetch('http://127.0.0.1:'+port+'/json/list')).json();
  25. socket=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);
  26. await new Promise((resolve,reject)=>{socket.addEventListener('open',resolve,{once:true});socket.addEventListener('error',reject,{once:true});});
  27. let seq=0;const pending=new Map();
  28. socket.addEventListener('message',({data})=>{
  29. const message=JSON.parse(data);
  30. if(message.id&&pending.has(message.id)){const p=pending.get(message.id);clearTimeout(p.timer);pending.delete(message.id);message.error?p.reject(Error(message.error.message)):p.resolve(message.result);}
  31. if(message.method==='Runtime.exceptionThrown')errors.push({runtime:message.params.exceptionDetails.text});
  32. if(message.method==='Network.loadingFailed'&&message.params.errorText!=='net::ERR_ABORTED')errors.push({network:message.params.errorText});
  33. if(message.method==='Log.entryAdded'&&message.params.entry.level==='error'&&!message.params.entry.url?.endsWith('/favicon.ico'))errors.push({console:message.params.entry.text});
  34. if(message.method==='Network.requestWillBeSent'){
  35. const r=message.params.request;let cloudPath=null;
  36. if(r.url.endsWith('/api/functions')){try{cloudPath=JSON.parse(r.postData||'{}').path??null;}catch{}}
  37. requests.push({url:r.url,method:r.method,headerNames:Object.keys(r.headers||{}),cloudPath});
  38. }
  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. const evaluate=async expression=>{
  45. const r=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});
  46. if(r.exceptionDetails)throw Error(r.exceptionDetails.text);return r.result.value;
  47. };
  48. const check=(name,passed)=>{checks.push({name,passed:Boolean(passed)});assert.ok(passed,name);};
  49. 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;}
  50. 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;}`;
  51. const deepText=()=>evaluate(`(()=>{${helpers}return text(document);})()`);
  52. const compact=value=>value.replace(/\s/g,'');
  53. const quoteText=value=>value.replace(/\u2009/g,'');
  54. const screenshot=async label=>{
  55. const shot=await cdp('Page.captureScreenshot',{format:'png'}),file='work/live-S3-'+label+'-'+runId+'.png';
  56. fs.writeFileSync(file,Buffer.from(shot.data,'base64'),{flag:'wx'});screenshots.push(file);
  57. };
  58. const navigate=async route=>{
  59. await cdp('Page.navigate',{url:base+route});let ready=false;
  60. for(let i=0;i<120;i++){await sleep(200);try{ready=await evaluate("import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready')");}catch{}if(ready)break;}
  61. check(route+' public data loaded',ready);await sleep(250);
  62. };
  63. await cdp('Page.enable');await cdp('Runtime.enable');await cdp('Network.enable');await cdp('Log.enable');
  64. for(const mobile of [false,true]){
  65. const viewport=mobile?'390px':'desktop';
  66. await cdp('Emulation.setDeviceMetricsOverride',mobile?{width:390,height:844,deviceScaleFactor:1,mobile:true}:{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
  67. for(const route of ['/members','/stages','/reports','/report/'+verified.artifactId]){
  68. await navigate(route);const content=compact(await deepText());
  69. // The members route presents the employee, while report routes present the artifact.
  70. const expectedVisible=route==='/members'?content.includes('员工待绑定·f83edd27dda7'):
  71. route==='/stages'?content.includes('S3')&&content.includes('用户需求与痛点'):
  72. content.includes(compact(template.title));
  73. check(route+' '+viewport+' relevant content visible',expectedVisible);
  74. check(route+' '+viewport+' no page overflow',await evaluate('document.documentElement.scrollWidth<=innerWidth'));
  75. if(!route.startsWith('/report/'))continue;
  76. check('S3 title '+viewport,content.includes(compact(template.title)));
  77. check('classroom boundary '+viewport,content.includes('课堂过程成果')&&content.includes('未经员工正式确认'));
  78. check('historical sample boundary '+viewport,content.includes('2023—2024年')&&content.includes('不能代表当前市场'));
  79. check('series separated '+viewport,content.includes('燃魂')&&content.includes('香辣')&&content.includes('不跨产品推论'));
  80. check('hypotheses and priority '+viewport,content.includes('三项待验证改进方向')&&content.includes('先调查燃魂份量'));
  81. check('independent opportunity and project absent '+viewport,content.includes('独立机会卡和项目均不建立'));
  82. check('unknowns retained '+viewport,content.includes('未知')&&content.includes('支付意愿')&&content.includes('KANO'));
  83. for(const [index,question]of template.pendingQuestions.entries()){
  84. const visible=content.includes(compact(question));
  85. checks.push({name:'pending question '+(index+1)+' '+viewport,passed:visible});
  86. if(!visible)missingQuestions.push({viewport,index:index+1,question});
  87. }
  88. await screenshot('report-'+(mobile?'mobile':'desktop'));
  89. for(const [key,entry]of Object.entries(template.report.evidence)){
  90. const selector=JSON.stringify('[data-anchor="'+key+'"]');
  91. check('evidence button '+key+' '+viewport,await evaluate(`(()=>{${helpers}const button=find(document,${selector});if(!button)return false;button.scrollIntoView({block:'center'});return true;})()`));
  92. await sleep(300);
  93. const point=await evaluate(`(()=>{${helpers}const r=find(document,${selector}).getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`);
  94. await cdp('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
  95. await cdp('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
  96. await sleep(350);
  97. 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):[]};})()`);
  98. check('drawer visible '+key+' '+viewport,drawer.open&&drawer.visible);
  99. check('verbatim quote '+key+' '+viewport,JSON.stringify(drawer.quotes.map(quoteText))===JSON.stringify(entry.quotes.map(q=>quoteText(q.text))));
  100. check('registered source '+key+' '+viewport,drawer.text.includes('已关联原始资料')&&!drawer.text.includes('未关联来源记录'));
  101. check('open drawer no overflow '+key+' '+viewport,await evaluate('document.documentElement.scrollWidth<=innerWidth'));
  102. await screenshot('evidence-'+key+'-'+(mobile?'mobile':'desktop'));
  103. observations.push({viewport:mobile?390:1440,evidence:key,sourceId:entry.sourceId,drawerVisible:true,quoteCount:drawer.quotes.length,input:'browser mouse event'});
  104. await cdp('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});
  105. await cdp('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await sleep(300);
  106. check('drawer closed '+key+' '+viewport,await evaluate(`(()=>{${helpers}return find(document,'kf-drawer')?.open===false;})()`));
  107. }
  108. }
  109. }
  110. await navigate('/report/G8IrkkDG7X');check('S1 still readable',compact(await deepText()).includes('S1资料小结'));
  111. await navigate('/report/b7NOby5ys0');check('S2 still readable',compact(await deepText()).includes('S2用户需求与痛点'));
  112. check('no browser errors',errors.length===0);
  113. const cloudRequests=requests.filter(r=>r.url.endsWith('/api/functions'));
  114. check('public cloud reads observed',cloudRequests.length>0);
  115. check('only public board cloud reads',cloudRequests.every(r=>r.method==='POST'&&r.cloudPath==='/ksfGetPublicBoard'));
  116. check('anonymous browser requests',!requests.some(r=>r.headerNames.some(k=>/master|authorization|session.token|ksf.service.token/i.test(k))));
  117. const result={ok:missingQuestions.length===0,operation:'live-S3-production-browser-partial',at:new Date().toISOString(),base,artifactId:verified.artifactId,
  118. browserVerified:missingQuestions.length===0,interactionVerified:true,missingQuestions,scope:'production website, fresh anonymous Edge browser, no module overrides',readOnly:true,businessWrites:0,
  119. viewports:[1440,390],evidenceDrawersChecked:observations.length,quoteComparison:'All characters equal except display U+2009 thin spaces',
  120. checks,errors,observations,screenshots};
  121. fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});
  122. console.log(JSON.stringify({ok:missingQuestions.length===0,checks:checks.length,passed:checks.filter(c=>c.passed).length,missingQuestionChecks:missingQuestions.length,evidenceDrawersChecked:observations.length,screenshots:screenshots.length,browserVerified:missingQuestions.length===0,interactionVerified:true,businessWrites:0},null,2));
  123. }catch(error){
  124. const result={ok:false,at:new Date().toISOString(),artifactId:verified.artifactId,browserVerified:false,readOnly:true,businessWrites:0,
  125. checks,errors,observations,screenshots,error:error.message};
  126. fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});console.error(JSON.stringify(result,null,2));process.exitCode=1;
  127. }finally{socket?.close();browser.kill();}