|
|
@@ -0,0 +1,71 @@
|
|
|
+// Actual built Angular pages + actual HTMLAudioElement. Isolated API/audio fixtures, not a production speed claim.
|
|
|
+import {chromium} from 'playwright';
|
|
|
+import {createServer} from 'node:http';
|
|
|
+import fs from 'node:fs';
|
|
|
+import {resolve,extname} from 'node:path';
|
|
|
+import assert from 'node:assert/strict';
|
|
|
+import {fileURLToPath} from 'node:url';
|
|
|
+const baseline=process.argv[2];if(!baseline)throw Error('Pass baseline browser build directory');
|
|
|
+const runs=Number(process.env.PERF_RUNS||10),out=new URL('./',import.meta.url);
|
|
|
+const wav=Buffer.alloc(44+19200);wav.write('RIFF');wav.writeUInt32LE(wav.length-8,4);wav.write('WAVEfmt ',8);wav.writeUInt32LE(16,16);wav.writeUInt16LE(1,20);wav.writeUInt16LE(1,22);wav.writeUInt32LE(16000,24);wav.writeUInt32LE(32000,28);wav.writeUInt16LE(2,32);wav.writeUInt16LE(16,34);wav.write('data',36);wav.writeUInt32LE(19200,40);for(let i=0;i<9600;i++)wav.writeInt16LE(Math.round(Math.sin(i/16000*440*2*Math.PI)*1500),44+i*2);
|
|
|
+const extras=Array.from({length:60},(_,i)=>({GeneralID:3000+i,Title:'extra-'+i,sy:'拓展释义'+i}));
|
|
|
+const servers=[],origins={};
|
|
|
+for(const [variant,root] of Object.entries({before:resolve(baseline),after:resolve('dist/xiaoshu-mobile/browser')})){
|
|
|
+ const server=createServer(async(req,res)=>{
|
|
|
+ const path=new URL(req.url,'http://local').pathname;
|
|
|
+ if(path.startsWith('/cloud-functions/')){
|
|
|
+ let body='';for await(const chunk of req)body+=chunk;const p=JSON.parse(body).params;
|
|
|
+ if(p.action==='content_list'&&p.orders==='NEWID()'){
|
|
|
+ await new Promise(r=>setTimeout(r,1600));res.setHeader('Content-Type','application/json');res.end(JSON.stringify({retcode:1,result:extras}));return;
|
|
|
+ }
|
|
|
+ // This harness never permits a teaching write or a real credential.
|
|
|
+ if(p.action==='app_support_unread'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({retcode:1,result:{unread:0}}));return;}
|
|
|
+ res.statusCode=400;res.end(JSON.stringify({retcode:-1,retmsg:'Unexpected fixture request '+p.action}));return;
|
|
|
+ }
|
|
|
+ if(path.startsWith('/audio/')){res.setHeader('Content-Type','audio/wav');res.setHeader('Cache-Control','no-store');res.end(wav);return;}
|
|
|
+ let file=resolve(root,'.'+decodeURIComponent(path));if(!file.startsWith(root+'/')||!fs.existsSync(file)||!extname(file))file=root+'/index.html';
|
|
|
+ try{res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.wav':'audio/wav'})[extname(file)]||'application/octet-stream');res.end(fs.readFileSync(file));}catch{res.statusCode=404;res.end();}
|
|
|
+ });await new Promise(r=>server.listen(0,'127.0.0.1',r));servers.push(server);origins[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'}),samples={before:[],after:[]};
|
|
|
+try{
|
|
|
+ for(let i=0;i<runs;i++)for(const variant of i%2?['after','before']:['before','after']){
|
|
|
+ const origin=origins[variant],context=await browser.newContext({viewport:{width:390,height:844}}),page=await context.newPage(),errors=[];
|
|
|
+ page.on('pageerror',e=>errors.push(e.message));
|
|
|
+ const requests=[];page.on('request',r=>{if(r.url().includes('/audio/')||r.url().includes('/cloud-functions/'))requests.push({path:new URL(r.url()).pathname,action:r.postDataJSON()?.params?.action});});
|
|
|
+ await context.addInitScript(({origin})=>{
|
|
|
+ localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:fixture-only',user:{userId:300,groupId:3,honeyName:'测试老师'},student:{userId:100,groupId:1}}));
|
|
|
+ const words=['apple','banana','orange','pear'].map((word,i)=>({GeneralID:1001+i,Title:word,sy:['苹果','香蕉','橙子','梨'][i],yp:origin+'/audio/'+i+'.wav'}));
|
|
|
+ const prefix='xiaoshu.learning.300.100.practice.';
|
|
|
+ localStorage.setItem(prefix+'learn_mode','word');localStorage.setItem(prefix+'_page_word_orilist',JSON.stringify([words]));localStorage.setItem(prefix+'_page_current_learn','1');
|
|
|
+ window.__audio=[];window.__click=0;document.addEventListener('click',()=>window.__click=performance.now(),true);
|
|
|
+ const Original=window.Audio;window.Audio=function(url){const audio=new Original(url);audio.addEventListener('playing',()=>window.__audio.push({url,ms:performance.now()-window.__click}));return audio;};window.Audio.prototype=Original.prototype;
|
|
|
+ // Make order identical for visual/data comparison; media and rendering stay real.
|
|
|
+ Math.random=()=>0.5;
|
|
|
+ },{origin});
|
|
|
+ await context.route('**/*',route=>new URL(route.request().url()).origin===origin?route.continue():route.abort());
|
|
|
+ const cdp=await context.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});
|
|
|
+ await page.goto(origin+'/pages/stu/stu_con');await page.locator('.repeat-list button').first().waitFor();
|
|
|
+ // Same one-second reading pause for A and B; include all background requests in totals.
|
|
|
+ await page.waitForTimeout(1000);
|
|
|
+ const audioTimes=[];
|
|
|
+ for(let n=0;n<2;n++){
|
|
|
+ const count=await page.evaluate(()=>window.__audio.length);await page.locator('.repeat-list button').first().click();
|
|
|
+ await page.waitForFunction(count=>window.__audio.length>count,count);audioTimes.push(await page.evaluate(()=>window.__audio.at(-1).ms));
|
|
|
+ await page.waitForTimeout(650);
|
|
|
+ }
|
|
|
+ await page.getByRole('button',{name:'跳过这一关',exact:false}).click();await page.locator('.match-grid').waitFor();
|
|
|
+ await page.waitForTimeout(1800); // The same short matching-stage dwell for both versions.
|
|
|
+ const at=await page.evaluate(()=>performance.now());
|
|
|
+ await page.getByRole('button',{name:'跳过这一关',exact:true}).click();
|
|
|
+ await page.waitForFunction(()=>document.querySelectorAll('.option-list button').length===4);
|
|
|
+ const readyMs=await page.evaluate(at=>performance.now()-at,at);
|
|
|
+ assert.equal(await page.locator('.option-list button').count(),4);assert.deepEqual(errors,[]);
|
|
|
+ if(i===0){await page.mouse.move(0,0);await page.waitForTimeout(250);for(const width of [390,1000]){await page.setViewportSize({width,height:844});await page.waitForTimeout(350);assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));await page.screenshot({path:fileURLToPath(new URL(variant+'-'+width+'.png',out)),fullPage:true,animations:'disabled'});}}
|
|
|
+ const row={run:i,firstAudioMs:audioTimes[0],repeatAudioMs:audioTimes[1],listeningReadyMs:readyMs,audioRequests:requests.filter(r=>r.path.startsWith('/audio/')).length,poolRequests:requests.filter(r=>r.action==='content_list').length,errors};samples[variant].push(row);console.log(JSON.stringify({variant,...row}));
|
|
|
+ await context.close();
|
|
|
+ }
|
|
|
+ const q=(rows,key,p)=>rows.map(r=>r[key]).sort((a,b)=>a-b)[Math.ceil(rows.length*p)-1];
|
|
|
+ const summary=Object.fromEntries(Object.entries(samples).map(([variant,rows])=>[variant,Object.fromEntries(['firstAudioMs','repeatAudioMs','listeningReadyMs'].map(key=>[key,{p50:q(rows,key,.5),p75:q(rows,key,.75),p95:q(rows,key,.95)}]))]));
|
|
|
+ fs.writeFileSync(new URL(process.env.PERF_OUTPUT || 'browser-paired.json',out),JSON.stringify({scope:'isolated actual Angular + HTMLAudioElement; 20ms/4Mbps; 19KB generated WAV no-store; fixed 1600ms fixture API; 1s reading/1.8s matching dwell; not real-user production measurements',runs,summary,samples},null,2));console.log(JSON.stringify(summary,null,2));
|
|
|
+}finally{await browser.close();for(const server of servers)await new Promise(r=>server.close(r));}
|