browser-case.mjs 7.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. import {chromium} from 'playwright';
  2. import {createServer} from 'node:http';
  3. import fs from 'node:fs';
  4. import {resolve,extname} from 'node:path';
  5. import {gzipSync} from 'node:zlib';
  6. import assert from 'node:assert/strict';
  7. export async function browserHarness({functions,token,me,base,out}){
  8. const servers=[],origins={},requests={};
  9. for(const fn of functions){
  10. const root=resolve(fn.variant==='before'?out+'/baseline-browser':'dist/xiaoshu-admin/browser');
  11. const server=createServer(async(req,res)=>{
  12. const path=new URL(req.url,'http://local').pathname;
  13. try{
  14. let body,contentType='application/json';
  15. if(path.startsWith('/cloud-functions/')){
  16. let raw='';for await(const chunk of req)raw+=chunk;const input=JSON.parse(raw),op=input.params?.operation;
  17. if(op==='ops/coaches/list')input.params.refresh=true;
  18. const primary=['ops/dashboard/summary','ops/users/list','ops/stores/list','ops/schedule/calendar','ops/coaches/list','ops/payroll/overview','ops/lessons/list','ops/learning-reports/list','ops/support-requests/list','ops/vocabulary/tree','ops/vocabulary/list','ops/special-training/list'].includes(op);
  19. // This harness cannot send business mutations, even if a test accidentally clicks one.
  20. if(!primary&&!['ops/live/versions','ops/payroll/list','ops/pay-rates/list','ops/teaching-rules/list','ops/payroll/coaches'].includes(op)){res.writeHead(403);res.end('{}');return;}
  21. const r=await fetch(base+'/api/functions/'+(primary?fn.path:path.slice('/cloud-functions/'.length)),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(input),signal:AbortSignal.timeout(60000)});
  22. body=Buffer.from(await r.arrayBuffer());res.statusCode=r.status;
  23. }else{
  24. let file=resolve(root,'.'+decodeURIComponent(path));if(!file.startsWith(root+'/')||!fs.existsSync(file)||!extname(file))file=root+'/index.html';
  25. body=fs.readFileSync(file);contentType=({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.json':'application/json'})[extname(file)]||'application/octet-stream';
  26. }
  27. res.setHeader('Content-Type',contentType);res.setHeader('Content-Encoding','gzip');res.setHeader('Cache-Control','no-store');res.end(gzipSync(body));
  28. }catch{res.writeHead(502);res.end('{}');}
  29. });await new Promise(r=>server.listen(0,'127.0.0.1',r));servers.push(server);origins[fn.variant]='http://127.0.0.1:'+server.address().port;
  30. }
  31. const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
  32. const selectors={dashboard:'.overview-metrics',members:'app-operations-users tbody tr',stores:'app-operations-stores tbody tr',schedule:'.content-card[aria-busy="false"]',coaches:'app-operations-coaches tbody tr',payroll:'.payroll-summary-table tbody tr',lessons:'app-lesson-records tbody tr','learning-reports':'app-operations-learning-reports tbody tr','support-requests':'.request-list',vocabulary:'app-operations-vocabulary tbody tr','special-training':'.special-card'};
  33. return {
  34. async measure(fn,item,round){
  35. const ctx=await browser.newContext({viewport:{width:1440,height:1000}}),page=await ctx.newPage(),records=[],errors=[];
  36. await ctx.addInitScript(auth=>{localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));window.__perfLong=[];new PerformanceObserver(list=>window.__perfLong.push(...list.getEntries().map(e=>e.duration))).observe({type:'longtask',buffered:true});},{objectId:me.objectId,sessionToken:token,username:'性能验收',roles:me.roles,company:me.company,operationsRole:'ops-manager',isSuperAdmin:true});
  37. page.on('pageerror',e=>errors.push(e.message));
  38. const responseJobs=[];page.on('response',response=>{if(!response.url().includes('/cloud-functions/'))return;responseJobs.push((async()=>{const request=response.request(),p=request.postDataJSON()?.params||{};try{const data=await response.json();records.push({operation:p.operation,displayOnly:p.displayOnly===true,data:data.data,success:response.ok()&&data.success===true,ms:Math.round(performance.now()-start)});}catch{errors.push('Invalid response');}})());});
  39. const cdp=await ctx.newCDPSession(page);await cdp.send('Network.enable');await cdp.send('Network.emulateNetworkConditions',{offline:false,latency:20,downloadThroughput:4*1024*1024/8,uploadThroughput:3*1024*1024/8});
  40. const start=performance.now();
  41. const primaryResponse=page.waitForResponse(r=>(r.request().postData()?r.request().postDataJSON():null)?.params?.operation===item.params.operation,{timeout:60000});
  42. const wordResponse=item.page==='vocabulary'?page.waitForResponse(r=>(r.request().postData()?r.request().postDataJSON():null)?.params?.operation==='ops/vocabulary/list',{timeout:60000}):null;
  43. try{
  44. await page.goto(origins[fn.variant]+'/admin/'+item.page,{waitUntil:'domcontentloaded'});
  45. await (await primaryResponse).finished();if(wordResponse)await (await wordResponse).finished();await Promise.all(responseJobs);
  46. await page.locator(selectors[item.page]).first().waitFor({state:'visible',timeout:60000});
  47. // Vocabulary must have BOTH the tree and the current word page available.
  48. await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));
  49. const readyMs=Math.round(performance.now()-start);
  50. const input=page.locator('main input:not([type="file"]):not([disabled])').first();let interactive=true;
  51. if(await input.count()){const previous=await input.inputValue();await input.fill('测速');interactive=(await input.inputValue())==='测速';await input.fill(previous);}
  52. if(item.page==='payroll'&&fn.variant==='after'){
  53. assert.equal(await page.locator('.create-payroll-action').isDisabled(),true,'settlement must remain disabled during verification');
  54. if(round===0)await page.screenshot({path:out+'/payroll-verifying.png',animations:'disabled'});
  55. await page.waitForFunction(()=>!document.querySelector('.payroll-source-row')?.textContent?.includes('正在核验结算资格'),null,{timeout:60000});
  56. }
  57. const completeMs=Math.round(performance.now()-start);
  58. await Promise.all(responseJobs);
  59. assert.equal(interactive,true);assert.deepEqual(errors,[]);
  60. const primary=records.filter(r=>r.operation===item.params.operation);assert.ok(primary.length&&primary.every(r=>r.success),'primary data must succeed');
  61. if(round===0){
  62. await page.mouse.move(0,0);for(const width of [1440,1000]){await page.setViewportSize({width,height:1000});await page.waitForTimeout(150);await page.screenshot({path:out+'/'+item.page+'-'+fn.variant+'-'+width+'.png',animations:'disabled'});}await page.setViewportSize({width:1440,height:1000});
  63. }
  64. const data=primary.at(-1).data;
  65. let menuReturn;
  66. if(['lessons','support-requests'].includes(item.page)){
  67. await page.locator('#admin-sidebar a[href="/admin/special-training"]').last().click();await page.locator('.special-card').first().waitFor({timeout:30000});
  68. const count=records.filter(r=>r.operation===item.params.operation).length,at=performance.now();await page.locator('#admin-sidebar a[href="/admin/'+item.page+'"]').last().click();await page.locator(selectors[item.page]).first().waitFor({timeout:30000});await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));await Promise.all(responseJobs);
  69. menuReturn={ms:Math.round(performance.now()-at),requests:records.filter(r=>r.operation===item.params.operation).length-count};
  70. }
  71. return{data,readyMs,completeMs,menuReturn,primaryRequests:primary.length,errors,longTaskMs:Math.round(await page.evaluate(()=>window.__perfLong.reduce((a,b)=>a+b,0))),resources:await page.evaluate(()=>performance.getEntriesByType('resource').reduce((s,r)=>({requests:s.requests+1,bytes:s.bytes+r.transferSize}),{requests:0,bytes:0}))};
  72. }finally{await ctx.close();}
  73. },
  74. async close(){await browser.close();for(const server of servers)await new Promise(r=>server.close(r));}
  75. };
  76. }