verify-live-S5-completion-browser.mjs 11 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
  1. // Fresh anonymous production browser; public reads only, no module 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-completion-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.progressState,'completed');
  9. const base='https://kangshifu.fmode.cn',output='work/live-S5-completion-browser-verified-20261103.json',runId=Date.now();
  10. assert.equal(fs.existsSync(output),false);
  11. const profile=path.resolve('work/browser-S5-completion-'+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(r=>setTimeout(r,ms));
  17. const checks=[],errors=[],requests=[],observations=[],screenshots=[];let socket;
  18. try{
  19. let port;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);}assert.ok(port);
  20. const tabs=await(await fetch('http://127.0.0.1:'+port+'/json/list')).json();socket=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);
  21. await new Promise((resolve,reject)=>{socket.addEventListener('open',resolve,{once:true});socket.addEventListener('error',reject,{once:true});});
  22. let seq=0;const pending=new Map();
  23. socket.addEventListener('message',({data})=>{const m=JSON.parse(data);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);}
  24. if(m.method==='Runtime.exceptionThrown')errors.push({runtime:m.params.exceptionDetails.text});
  25. if(m.method==='Network.loadingFailed'&&m.params.errorText!=='net::ERR_ABORTED')errors.push({network:m.params.errorText});
  26. if(m.method==='Log.entryAdded'&&m.params.entry.level==='error'&&!m.params.entry.url?.endsWith('/favicon.ico'))errors.push({console:m.params.entry.text});
  27. if(m.method==='Network.requestWillBeSent'){const r=m.params.request;let cloudPath=null;if(r.url.endsWith('/api/functions'))try{cloudPath=JSON.parse(r.postData||'{}').path??null;}catch{}requests.push({url:r.url,method:r.method,headerNames:Object.keys(r.headers||{}),cloudPath});}
  28. });
  29. const cdp=(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>{pending.delete(id);reject(Error(method+' timeout'));},30000);pending.set(id,{resolve,reject,timer});socket.send(JSON.stringify({id,method,params}));});
  30. const evaluate=async expression=>{const r=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(r.exceptionDetails.text);return r.result.value;};
  31. const check=(name,passed)=>{checks.push({name,passed:Boolean(passed)});assert.ok(passed,name);};
  32. 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;}
  33. 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;}`;
  34. const deepText=()=>evaluate(`(()=>{${helpers}return text(document);})()`),compact=x=>x.replace(/\s/g,'');
  35. const shot=async label=>{const r=await cdp('Page.captureScreenshot',{format:'png'}),file='work/live-S5-completion-'+label+'-'+runId+'.png';fs.writeFileSync(file,Buffer.from(r.data,'base64'),{flag:'wx'});screenshots.push(file);};
  36. const navigate=async route=>{await cdp('Page.navigate',{url:base+route});let ready=false;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;}check(route+' public data loaded',ready);await sleep(250);};
  37. await cdp('Page.enable');await cdp('Runtime.enable');await cdp('Network.enable');await cdp('Log.enable');
  38. for(const mobile of [false,true]){
  39. const label=mobile?'mobile':'desktop';await cdp('Emulation.setDeviceMetricsOverride',mobile?{width:390,height:844,deviceScaleFactor:1,mobile:true}:{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
  40. await navigate('/members');
  41. const row=await evaluate(`(()=>{${helpers}const row=find(document,'[data-project="S_CikxZeBs"]');if(!row)return null;row.scrollIntoView({block:'center'});return {cells:[...row.children].map(n=>text(n).replace(/\s/g,'')),stageVisible:row.children[2]?.getBoundingClientRect().width>0};})()`);
  42. check('exact project row S5 classroom completed, three reports, concept saved '+label,row?.cells[2]?.includes('S5')&&row.cells[2].includes('课堂完成')&&row.cells[4]==='3份'&&row.cells[5]==='已保存'&&row.cells[7]==='课堂完成');
  43. check('responsive project stage cell is visible '+label,row.stageVisible);
  44. const model=await evaluate("import('/src/services/board-service.js').then(({boardService})=>{const p=boardService.db.get().projects.find(p=>p.id==='S_CikxZeBs');return p?{id:p.id,stageCode:p.stageCode,progressState:p.progressState,currentConceptId:p.currentConceptId,artifactIds:p.artifactIds}:null;})");
  45. check('public project model completed and existing concept/report IDs '+label,model?.progressState==='completed'&&model.currentConceptId==='QO0uo8nisk'&&JSON.stringify(model.artifactIds.toSorted())===JSON.stringify(verified.projectArtifactIds.toSorted()));
  46. check('members no page overflow '+label,await evaluate('document.documentElement.scrollWidth<=innerWidth'));observations.push({viewport:mobile?390:1440,project:model,rowCells:row.cells});await shot('project-'+label);
  47. for(const route of ['/stages','/reports']){await navigate(route);const content=compact(await deepText());check(route+' existing S5 stage/report remains visible '+label,route==='/stages'?content.includes('S5'):content.includes(compact(template.title)));check(route+' no page overflow '+label,await evaluate('document.documentElement.scrollWidth<=innerWidth'));}
  48. await navigate('/report/'+verified.artifactId);const content=compact(await deepText());
  49. check('same S5 title '+label,content.includes(compact(template.title)));check('classroom/unconfirmed boundaries remain '+label,content.includes('课堂过程成果')&&content.includes('未经员工正式确认')&&content.includes('非试吃、市场或量产结果'));
  50. check('historical submission text retained without rewriting report '+label,content.includes('S5/in_progress')&&content.includes('不包含completed'));
  51. for(const [i,q]of template.pendingQuestions.entries())check('unresolved question '+(i+1)+' retained '+label,content.includes(compact(q)));
  52. check('report no page overflow '+label,await evaluate('document.documentElement.scrollWidth<=innerWidth'));await shot('report-'+label);
  53. const pending=await evaluate(`(()=>{${helpers}const view=find(document,'view-report'),s=[...view.shadowRoot.querySelectorAll('section.sec')].find(s=>s.querySelector('h4')?.textContent.trim()==='待补问题');if(!s)return null;s.scrollIntoView({block:'start'});return [...s.querySelectorAll('tbody tr')].map(r=>r.children[1]?.textContent.replace(/\u2009/g,''));})()`);
  54. check('visible pending table has all thirteen exact questions '+label,JSON.stringify(pending)===JSON.stringify(template.pendingQuestions));await shot('pending-'+label);
  55. const point=await evaluate(`(()=>{${helpers}const b=find(document,'[data-anchor="public-p1"]');if(!b)return null;b.scrollIntoView({block:'center'});const r=b.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`);check('source evidence button remains reachable '+label,Boolean(point));
  56. await sleep(200);await cdp('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});await cdp('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});await sleep(350);
  57. const drawer=await evaluate(`(()=>{${helpers}const h=find(document,'kf-drawer'),b=h?.shadowRoot.querySelector('.body');return {open:h?.open===true,text:b?text(b):'',quotes:b?[...b.querySelectorAll('.quotes > li > p')].map(p=>p.textContent.replace(/\u2009/g,'')):[]};})()`);
  58. check('retained source drawer opens with exact original quote '+label,drawer.open&&JSON.stringify(drawer.quotes)===JSON.stringify(template.report.evidence['public-p1'].quotes.map(q=>q.text.replace(/\u2009/g,''))));check('original source still linked '+label,drawer.text.includes('已关联原始资料'));await shot('evidence-'+label);
  59. await cdp('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await cdp('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await sleep(250);check('drawer closes '+label,await evaluate(`(()=>{${helpers}return find(document,'kf-drawer')?.open===false;})()`));
  60. await navigate('/report/U3_W7WDliP');check('previous S4 report still readable '+label,compact(await deepText()).includes('公开资料桌面评估'));
  61. }
  62. check('no browser runtime/network errors',errors.length===0);const cloud=requests.filter(r=>r.url.endsWith('/api/functions'));
  63. check('public board requests observed',cloud.length>0);check('only public board cloud reads',cloud.every(r=>r.method==='POST'&&r.cloudPath==='/ksfGetPublicBoard'));check('no direct private table scans',!requests.some(r=>r.url.includes('/parse/classes/')));check('anonymous browser requests',!requests.some(r=>r.headerNames.some(k=>/master|authorization|session.token|ksf.service.token/i.test(k))));
  64. const r={ok:true,at:new Date().toISOString(),operation:'live-S5-classroom-completion-production-browser',scope:'Fresh anonymous production Edge browser, no module overrides',base,projectId:verified.projectId,artifactId:verified.artifactId,conceptId:verified.conceptId,progressState:'completed',browserVerified:true,readOnly:true,businessWrites:0,viewports:[1440,390],checks,errors,observations,screenshots};
  65. fs.writeFileSync(output,JSON.stringify(r,null,2)+'\n',{flag:'wx'});console.log(JSON.stringify({ok:true,checks:checks.length,browserVerified:true,screenshots:screenshots.length,businessWrites:0}));
  66. }catch(error){const r={ok:false,at:new Date().toISOString(),readOnly:true,businessWrites:0,browserVerified:false,checks,errors,observations,screenshots,error:error.message};fs.writeFileSync(output,JSON.stringify(r,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}));process.exitCode=1;}
  67. finally{socket?.close();browser.kill();}