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));} }; }