| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576 |
- import {chromium} from 'playwright';
- import {createServer} from 'node:http';
- import fs from 'node:fs';
- import {resolve,extname} from 'node:path';
- import {gzipSync} from 'node:zlib';
- import assert from 'node:assert/strict';
- export async function browserHarness({functions,token,me,base,out}){
- const servers=[],origins={},requests={};
- for(const fn of functions){
- const root=resolve(fn.variant==='before'?out+'/baseline-browser':'dist/xiaoshu-admin/browser');
- const server=createServer(async(req,res)=>{
- const path=new URL(req.url,'http://local').pathname;
- try{
- let body,contentType='application/json';
- if(path.startsWith('/cloud-functions/')){
- let raw='';for await(const chunk of req)raw+=chunk;const input=JSON.parse(raw),op=input.params?.operation;
- if(op==='ops/coaches/list')input.params.refresh=true;
- 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);
- // This harness cannot send business mutations, even if a test accidentally clicks one.
- 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;}
- 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)});
- body=Buffer.from(await r.arrayBuffer());res.statusCode=r.status;
- }else{
- let file=resolve(root,'.'+decodeURIComponent(path));if(!file.startsWith(root+'/')||!fs.existsSync(file)||!extname(file))file=root+'/index.html';
- 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';
- }
- res.setHeader('Content-Type',contentType);res.setHeader('Content-Encoding','gzip');res.setHeader('Cache-Control','no-store');res.end(gzipSync(body));
- }catch{res.writeHead(502);res.end('{}');}
- });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;
- }
- const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
- 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'};
- return {
- async measure(fn,item,round){
- const ctx=await browser.newContext({viewport:{width:1440,height:1000}}),page=await ctx.newPage(),records=[],errors=[];
- 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});
- page.on('pageerror',e=>errors.push(e.message));
- 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');}})());});
- 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});
- const start=performance.now();
- const primaryResponse=page.waitForResponse(r=>(r.request().postData()?r.request().postDataJSON():null)?.params?.operation===item.params.operation,{timeout:60000});
- const wordResponse=item.page==='vocabulary'?page.waitForResponse(r=>(r.request().postData()?r.request().postDataJSON():null)?.params?.operation==='ops/vocabulary/list',{timeout:60000}):null;
- try{
- await page.goto(origins[fn.variant]+'/admin/'+item.page,{waitUntil:'domcontentloaded'});
- await (await primaryResponse).finished();if(wordResponse)await (await wordResponse).finished();await Promise.all(responseJobs);
- await page.locator(selectors[item.page]).first().waitFor({state:'visible',timeout:60000});
- // Vocabulary must have BOTH the tree and the current word page available.
- await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));
- const readyMs=Math.round(performance.now()-start);
- const input=page.locator('main input:not([type="file"]):not([disabled])').first();let interactive=true;
- if(await input.count()){const previous=await input.inputValue();await input.fill('测速');interactive=(await input.inputValue())==='测速';await input.fill(previous);}
- if(item.page==='payroll'&&fn.variant==='after'){
- assert.equal(await page.locator('.create-payroll-action').isDisabled(),true,'settlement must remain disabled during verification');
- if(round===0)await page.screenshot({path:out+'/payroll-verifying.png',animations:'disabled'});
- await page.waitForFunction(()=>!document.querySelector('.payroll-source-row')?.textContent?.includes('正在核验结算资格'),null,{timeout:60000});
- }
- const completeMs=Math.round(performance.now()-start);
- await Promise.all(responseJobs);
- assert.equal(interactive,true);assert.deepEqual(errors,[]);
- const primary=records.filter(r=>r.operation===item.params.operation);assert.ok(primary.length&&primary.every(r=>r.success),'primary data must succeed');
- if(round===0){
- 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});
- }
- const data=primary.at(-1).data;
- let menuReturn;
- if(['lessons','support-requests'].includes(item.page)){
- await page.locator('#admin-sidebar a[href="/admin/special-training"]').last().click();await page.locator('.special-card').first().waitFor({timeout:30000});
- 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);
- menuReturn={ms:Math.round(performance.now()-at),requests:records.filter(r=>r.operation===item.params.operation).length-count};
- }
- 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}))};
- }finally{await ctx.close();}
- },
- async close(){await browser.close();for(const server of servers)await new Promise(r=>server.close(r));}
- };
- }
|