verify-live-S5-browser.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. // Fresh anonymous production browser. Public reads only; no application overrides.
  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-S5-readback-20261103-02.json','utf8'));
  7. const template=JSON.parse(fs.readFileSync('work/S5-classroom-next-20261103/03-project-S5-save.template.json','utf8'));
  8. assert.ok(verified.ok);assert.equal(verified.artifactId,'tLkz2MAoIw');
  9. const base='https://kangshifu.fmode.cn',output='work/live-S5-browser-verified-20261103.json',runId=Date.now();
  10. assert.equal(fs.existsSync(output),false,'Preserve earlier browser acceptance');
  11. const profile=path.resolve('work/browser-S5-'+runId);fs.mkdirSync(profile);
  12. const browser=spawn('C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',[
  13. '--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check','--disable-background-networking',
  14. '--remote-debugging-address=127.0.0.1','--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
  15. ],{windowsHide:true,stdio:'ignore'});
  16. const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
  17. const checks=[],errors=[],requests=[],observations=[],screenshots=[];
  18. let socket;
  19. try{
  20. let port;
  21. 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);}
  22. assert.ok(port,'browser debugger');
  23. const tabs=await(await fetch('http://127.0.0.1:'+port+'/json/list')).json();
  24. socket=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);
  25. await new Promise((resolve,reject)=>{socket.addEventListener('open',resolve,{once:true});socket.addEventListener('error',reject,{once:true});});
  26. let seq=0;const pending=new Map();
  27. socket.addEventListener('message',({data})=>{
  28. const m=JSON.parse(data);
  29. 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);}
  30. if(m.method==='Runtime.exceptionThrown')errors.push({runtime:m.params.exceptionDetails.text});
  31. if(m.method==='Network.loadingFailed'&&m.params.errorText!=='net::ERR_ABORTED')errors.push({network:m.params.errorText});
  32. if(m.method==='Log.entryAdded'&&m.params.entry.level==='error'&&!m.params.entry.url?.endsWith('/favicon.ico'))errors.push({console:m.params.entry.text});
  33. if(m.method==='Network.requestWillBeSent'){
  34. const r=m.params.request;let cloudPath=null;
  35. if(r.url.endsWith('/api/functions')){try{cloudPath=JSON.parse(r.postData||'{}').path??null;}catch{}}
  36. requests.push({url:r.url,method:r.method,headerNames:Object.keys(r.headers||{}),cloudPath});
  37. }
  38. });
  39. const cdp=(method,params={})=>new Promise((resolve,reject)=>{
  40. const id=++seq,timer=setTimeout(()=>{pending.delete(id);reject(Error(method+' timeout'));},30000);
  41. pending.set(id,{resolve,reject,timer});socket.send(JSON.stringify({id,method,params}));
  42. });
  43. const evaluate=async expression=>{
  44. const r=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});
  45. if(r.exceptionDetails)throw Error(r.exceptionDetails.text);return r.result.value;
  46. };
  47. const check=(name,passed)=>{checks.push({name,passed:Boolean(passed)});assert.ok(passed,name);};
  48. 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;}
  49. 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;}
  50. function tableSection(title){const view=find(document,'view-report');return [...view.shadowRoot.querySelectorAll('section.sec')].find(s=>s.querySelector('h4')?.textContent.trim()===title);}`;
  51. const deepText=()=>evaluate(`(()=>{${helpers}return text(document);})()`);
  52. const compact=x=>x.replace(/\s/g,'');
  53. const displayText=x=>x.replace(/\u2009/g,'');
  54. const screenshot=async label=>{
  55. const shot=await cdp('Page.captureScreenshot',{format:'png'}),file='work/live-S5-'+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. const click=async point=>{
  64. await cdp('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
  65. await cdp('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
  66. };
  67. await cdp('Page.enable');await cdp('Runtime.enable');await cdp('Network.enable');await cdp('Log.enable');
  68. for(const mobile of [false,true]){
  69. const viewport=mobile?'390px':'desktop';
  70. await cdp('Emulation.setDeviceMetricsOverride',mobile?{width:390,height:844,deviceScaleFactor:1,mobile:true}:{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
  71. await navigate('/members');
  72. const project=await evaluate(`(()=>{${helpers}const row=find(document,'[data-project="S_CikxZeBs"]');if(!row)return null;row.scrollIntoView({block:'center'});return [...row.children].map(n=>text(n).replace(/\s/g,''));})()`);
  73. check('project row S5, three artifacts, concept saved and in progress '+viewport,project?.[2]?.includes('S5')&&project[2].includes('进行中')&&project[4]==='3份'&&project[5]==='已保存'&&project[7]==='进行中');
  74. check('members no page overflow '+viewport,await evaluate('document.documentElement.scrollWidth<=innerWidth'));
  75. check('project model has exact concept pointer and three report IDs '+viewport,await evaluate("import('/src/services/board-service.js').then(({boardService})=>{const p=boardService.db.get().projects.find(p=>p.id==='S_CikxZeBs');return p?.currentConceptId==='QO0uo8nisk'&&p?.artifactIds?.length===3&&p.progressState==='in_progress';})"));
  76. await screenshot('project-'+(mobile?'mobile':'desktop'));
  77. for(const route of ['/stages','/reports']){
  78. await navigate(route);const content=compact(await deepText());
  79. check(route+' current stage or S5 report visible '+viewport,route==='/stages'?content.includes('S5'):content.includes(compact(template.title)));
  80. check(route+' no page overflow '+viewport,await evaluate('document.documentElement.scrollWidth<=innerWidth'));
  81. }
  82. await navigate('/report/'+verified.artifactId);
  83. const content=compact(await deepText());
  84. check('S5 title '+viewport,content.includes(compact(template.title)));
  85. check('classroom and assessment boundaries '+viewport,content.includes('课堂过程成果')&&content.includes('未经员工正式确认')&&content.includes('非试吃、市场或量产结果'));
  86. check('current SKU and production unknowns retained '+viewport,content.includes('规格、口味、配方、工艺与成本尚未确定')&&content.includes('不拼成同SKU'));
  87. check('S5 in progress boundary '+viewport,content.includes('S5/in_progress')&&content.includes('不包含completed')&&content.includes('企业审批或正式发布'));
  88. check('five chapter titles '+viewport,template.report.chapters.every(c=>content.includes(compact(c.title))));
  89. for(const [i,q]of template.pendingQuestions.entries())check('pending question '+(i+1)+' visible in DOM '+viewport,content.includes(compact(q)));
  90. check('report no page overflow '+viewport,await evaluate('document.documentElement.scrollWidth<=innerWidth'));
  91. await screenshot('report-'+(mobile?'mobile':'desktop'));
  92. const tables=template.report.chapters.flatMap(c=>c.sections).filter(s=>s.kind==='table');
  93. for(const [ti,t]of tables.entries()){
  94. const title=JSON.stringify(t.title);
  95. const saved=await evaluate(`(()=>{${helpers}const s=tableSection(${title});if(!s)return null;return {cols:[...s.querySelectorAll('thead th')].map(c=>c.textContent.replace(/\u2009/g,'')),rows:[...s.querySelectorAll('tbody tr')].map(r=>[...r.querySelectorAll('td')].map(c=>c.textContent.replace(/\u2009/g,'')))};})()`);
  96. check(t.title+' columns and all rows exactly equal template '+viewport,JSON.stringify(saved)===JSON.stringify(t.data));
  97. for(let ri=0;ri<t.data.rows.length;ri++){
  98. const visible=await evaluate(`(()=>{${helpers}const row=tableSection(${title}).querySelectorAll('tbody tr')[${ri}];row.scrollIntoView({block:'center'});const r=row.getBoundingClientRect(),style=getComputedStyle(row);return style.visibility!=='hidden'&&style.display!=='none'&&r.width>0&&r.height>0&&r.top>=60&&r.bottom<=innerHeight-40;})()`);
  99. check(t.title+' row '+(ri+1)+' can be scrolled vertically into view '+viewport,visible);
  100. }
  101. // Check horizontal access inside each table, including its final column on phones.
  102. for(let ci=0;ci<t.data.cols.length;ci++){
  103. check(t.title+' column '+(ci+1)+' readable within viewport '+viewport,await evaluate(`(()=>{${helpers}const s=tableSection(${title}),cell=s.querySelector('tbody tr').querySelectorAll('td')[${ci}];cell.scrollIntoView({block:'center',inline:'center'});const r=cell.getBoundingClientRect();return r.width>0&&r.left>=-1&&r.right<=innerWidth+1&&document.documentElement.scrollWidth<=innerWidth;})()`));
  104. }
  105. await evaluate(`(()=>{${helpers}const s=tableSection(${title}),wrap=s.querySelector('.tbl-wrap');wrap.scrollLeft=0;s.querySelector('h4').scrollIntoView({block:'start'});})()`);
  106. await sleep(200);await screenshot('table-'+ti+'-'+(mobile?'mobile':'desktop'));
  107. observations.push({viewport:mobile?390:1440,table:t.title,columns:t.data.cols.length,rows:t.data.rows.length,allRowsExact:true,allColumnsReachable:true});
  108. }
  109. for(const [key,entry]of Object.entries(template.report.evidence)){
  110. const selector=JSON.stringify('[data-anchor="'+key+'"]');
  111. check('evidence button '+key+' '+viewport,await evaluate(`(()=>{${helpers}const b=find(document,${selector});if(!b)return false;b.scrollIntoView({block:'center'});return true;})()`));
  112. await sleep(250);
  113. const point=await evaluate(`(()=>{${helpers}const r=find(document,${selector}).getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`);
  114. await click(point);await sleep(350);
  115. const drawer=await evaluate(`(()=>{${helpers}const host=find(document,'kf-drawer'),panel=host?.shadowRoot.querySelector('.panel'),body=host?.shadowRoot.querySelector('.body'),r=panel?.getBoundingClientRect(),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):[]};})()`);
  116. check('drawer opens '+key+' '+viewport,drawer.open&&drawer.visible);
  117. check('verbatim quote including original line breaks '+key+' '+viewport,JSON.stringify(drawer.quotes.map(displayText))===JSON.stringify(entry.quotes.map(q=>displayText(q.text))));
  118. check('registered original source '+key+' '+viewport,drawer.text.includes('已关联原始资料')&&!drawer.text.includes('未关联来源记录'));
  119. check('drawer no page overflow '+key+' '+viewport,await evaluate('document.documentElement.scrollWidth<=innerWidth'));
  120. await screenshot('evidence-'+key+'-'+(mobile?'mobile':'desktop'));
  121. observations.push({viewport:mobile?390:1440,evidence:key,sourceId:entry.sourceId,drawerVisible:true,input:'browser mouse event',quoteCount:drawer.quotes.length});
  122. await cdp('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});
  123. await cdp('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await sleep(250);
  124. check('drawer closes '+key+' '+viewport,await evaluate(`(()=>{${helpers}return find(document,'kf-drawer')?.open===false;})()`));
  125. }
  126. await navigate('/report/U3_W7WDliP');
  127. check('previous S4 report still readable '+viewport,compact(await deepText()).includes('公开资料桌面评估'));
  128. check('previous S4 report no overflow '+viewport,await evaluate('document.documentElement.scrollWidth<=innerWidth'));
  129. }
  130. for(const [id,title]of [['G8IrkkDG7X','S1资料小结'],['b7NOby5ys0','S2用户需求与痛点'],['ZW1FB5zeHr','三项待验证改进方向']]){
  131. await navigate('/report/'+id);check('previous report still readable '+id,compact(await deepText()).includes(compact(title)));
  132. }
  133. check('no browser errors',errors.length===0);
  134. const cloudRequests=requests.filter(r=>r.url.endsWith('/api/functions'));
  135. check('public cloud reads observed',cloudRequests.length>0);
  136. check('only public board cloud reads',cloudRequests.every(r=>r.method==='POST'&&r.cloudPath==='/ksfGetPublicBoard'));
  137. check('no direct private table scans',!requests.some(r=>r.url.includes('/parse/classes/')));
  138. check('anonymous browser requests',!requests.some(r=>r.headerNames.some(k=>/master|authorization|session.token|ksf.service.token/i.test(k))));
  139. const result={ok:true,operation:'live-S5-production-browser-verified',at:new Date().toISOString(),base,artifactId:verified.artifactId,projectId:verified.projectId,conceptId:verified.conceptId,version:1,
  140. browserVerified:true,scope:'production website, fresh anonymous Edge browser, no module overrides',readOnly:true,businessWrites:0,
  141. viewports:[1440,390],evidenceDrawersChecked:observations.filter(o=>o.evidence).length,tablesChecked:observations.filter(o=>o.table).length,
  142. quoteComparison:'All characters including original newlines equal except display-inserted U+2009 thin spaces',checks,errors,observations,screenshots};
  143. fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});
  144. console.log(JSON.stringify({ok:true,checks:checks.length,evidenceDrawersChecked:result.evidenceDrawersChecked,tablesChecked:result.tablesChecked,screenshots:screenshots.length,browserVerified:true,businessWrites:0},null,2));
  145. }catch(error){
  146. const result={ok:false,at:new Date().toISOString(),artifactId:verified.artifactId,browserVerified:false,readOnly:true,businessWrites:0,checks,errors,observations,screenshots,error:error.message};
  147. fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});console.error(JSON.stringify({ok:false,checksPassed:checks.filter(c=>c.passed).length,failedCheck:checks.find(c=>!c.passed),error:error.message,output},null,2));process.exitCode=1;
  148. }finally{socket?.close();browser.kill();}