| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126 |
- // Local production build + isolated API fixtures. Never sends requests to production.
- import { chromium } from 'playwright';
- import { createServer } from 'node:http';
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
- import { resolve, extname } from 'node:path';
- import assert from 'node:assert/strict';
- const root=resolve('dist/xiaoshu-mobile/browser');
- const out=resolve(process.env.THREE_TAP_QA_DIR || '/tmp/xiaoshu-three-tap-browser');mkdirSync(out,{recursive:true});
- const server=createServer((req,res)=>{
- let file=resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://local').pathname));
- if(!file.startsWith(root+'/')||!existsSync(file)||!extname(file))file=root+'/index.html';
- res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml'})[extname(file)]||'application/octet-stream');res.end(readFileSync(file));
- });
- await new Promise(done=>server.listen(0,'127.0.0.1',done));const origin='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 words=['apple','banana','orange','pear','grape','peach','melon','lemon','mango','plum'].map((Title,i)=>({GeneralID:701+i,NodeID:811,Title,sy:['苹果','香蕉','橙子','梨','葡萄','桃子','甜瓜','柠檬','芒果','李子'][i],yb:'/wɜːd/',yp:origin+'/fixtures/'+(701+i)+'.wav',w_learned:0}));
- const wav=Buffer.alloc(44+3200);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(3200,40);
- words[0]={...words[0],Title:'characteristically',sy:'典型地;具有特征地;用于验证较长中文释义在右侧区域换行'};
- const report=[];
- async function until(check,message){for(let i=0;i<100;i++){if(await check())return;await new Promise(r=>setTimeout(r,50));}throw Error(message);}
- try{
- for(const entry of ['course','coach-wordbook','student-wordbook']){
- const errors=[],calls=[];const teacher=entry!=='student-wordbook';
- const context=await browser.newContext({viewport:{width:375,height:812},hasTouch:true,reducedMotion:'reduce'});
- await context.addInitScript(({teacher})=>{
- localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:local-three-tap-fixture',user:{userId:teacher?300:100,groupId:teacher?3:1,honeyName:'本地测试'},student:teacher?{userId:100,groupId:1,honeyName:'测试学员'}:null}));
- HTMLMediaElement.prototype.play=function(){window.__lastAudio=this.src;return Promise.resolve();};
- HTMLMediaElement.prototype.load=function(){};
- },{teacher});
- await context.route('**/*',async route=>{
- const url=new URL(route.request().url());if(url.origin!==origin)return route.abort();
- if(url.pathname.startsWith('/fixtures/'))return route.fulfill({body:wav,contentType:'audio/wav'});
- if(!url.pathname.startsWith('/cloud-functions/'))return route.continue();
- const {params:p}=route.request().postDataJSON();calls.push(p);let result=[],page;
- if(p.action==='app_session_validate')result={valid:true};
- else if(p.action==='app_live_versions')result={version:'1',scopeVersions:{},syncStatus:{state:'current'}};
- else if(p.action==='e_user_list')result=[{userId:100,honeyName:'测试学员'}];
- else if(p.action==='e_ck_list'){result=words;page={itemCount:10,pageCount:1,passCounts:[10,0,0,0],bookPassCounts:[10,0,0,0]};}
- else if(p.action==='e_words_list'){result=words.map((w,i)=>({GeneralID:601+i,xxcs:1,detail:[w]}));page={itemCount:10,pageCount:1,pendingCount:10,completedCount:0};}
- else if(p.action==='stu_record_update_v2')result={batchId:p.batchId,batchVersion:1,learned:0};
- else if(p.action==='content_list'&&p.orders==='NEWID()')result=Array.from({length:8},(_,i)=>({GeneralID:9000+i,Title:'external'+i,sy:'外部释义'+i}));
- else if(p.action==='app_word_audio_ensure')result={state:'ready',url:origin+'/fixtures/'+p.wordId+'.wav'};
- return route.fulfill({json:{retcode:0,result,...(page?{page}:{})}});
- });
- const page=await context.newPage();
- const settle=()=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
- page.on('pageerror',e=>errors.push(e.message));
- await page.goto(origin+(entry==='course'?'/pages/stu/stu_abc?nids=811&pnid=801&orderId=901':'/pages/member/starshop'+(teacher?'?wordbookStudentId=100':'')));
- const select=entry==='course'?'.word-select-check':'.word-select-list > button';
- await page.locator(select).first().waitFor();assert.equal(await page.locator(select).count(),10);
- for(const button of await page.locator(select).all())await button.click();
- await page.locator('.selection-header .primary-button').click();await page.locator('.repeat-list > button').first().waitFor();
- assert.equal(await page.locator('.repeat-list > button').count(),6,'existing six-word grouping');
- const row=()=>page.locator('.repeat-list > button').first();
- const firstWord=await row().locator('strong').innerText();const firstMeaning=words.find(w=>w.Title===firstWord).sy;
- // Actual mouse hold/left drag; generated click must not consume a reading tap.
- let box=await row().boundingBox();await page.mouse.move(box.x+box.width-30,box.y+25);await page.mouse.down();await new Promise(r=>setTimeout(r,480));await page.mouse.move(box.x+box.width-90,box.y+25,{steps:4});await page.mouse.up();
- assert.equal(await row().locator('.study-answer').innerText(),firstMeaning);assert.equal(await row().locator('.repeat-dots .done').count(),0);
- await row().click();await settle();assert.equal(await row().locator('.study-answer').count(),0);assert.equal(await row().locator('.repeat-dots .done').count(),1);
- // Real touch gesture via CDP, including native scroll cancellation on vertical movement.
- const cdp=await context.newCDPSession(page);box=await row().boundingBox();
- const touch=async(type,x,y)=>cdp.send('Input.dispatchTouchEvent',{type,touchPoints:type==='touchEnd'?[]:[{x,y}]});
- await touch('touchStart',box.x+box.width-30,box.y+25);await new Promise(r=>setTimeout(r,480));await touch('touchMove',box.x+box.width-90,box.y+25);await touch('touchEnd');await settle();
- assert.equal(await row().locator('.study-answer').innerText(),firstMeaning);assert.equal(await row().locator('.repeat-dots .done').count(),1);
- await page.getByRole('button',{name:'重新乱序',exact:true}).click();await settle();assert.equal(await page.locator('.study-answer').count(),0);assert.equal(await page.locator('.repeat-dots .done').count(),0);
- await page.setViewportSize({width:375,height:420});await page.evaluate(()=>window.scrollTo(0,0));await settle();
- box=await row().boundingBox();
- const scrollPosition=()=>page.evaluate(()=>window.scrollY+Array.from(document.querySelectorAll('*')).reduce((n,e)=>n+e.scrollTop,0));
- const beforeScroll=await scrollPosition();
- await touch('touchStart',box.x+box.width/2,box.y+35);
- for(let offset=15;offset<=100;offset+=15){await touch('touchMove',box.x+box.width/2,box.y+35-offset);await new Promise(r=>setTimeout(r,20));}
- await touch('touchEnd');await settle();
- assert.ok(await scrollPosition()>beforeScroll,'vertical touch still scrolls');
- assert.equal(await page.locator('.study-answer').count(),0);assert.equal(await page.locator('.repeat-dots .done').count(),0);
- await page.setViewportSize({width:375,height:812});await page.evaluate(()=>window.scrollTo(0,0));await settle();
- let readingTaps=0;
- for(const groupSize of [6,4]){
- for(const step of [1,2,3]){
- assert.match(await page.locator('.stage-banner').innerText(),new RegExp('第 '+step+' / 3 轮'));
- assert.equal(await page.locator('.study-answer').count(),0);assert.equal(await page.locator('.study-phonetic').count(),0);
- const sequence=await page.locator('.study-headword').allTextContents();
- for(let index=0;index<groupSize;index++){
- const target=page.locator('.repeat-list > button').nth(index);const word=words.find(w=>(step===3?w.sy:w.Title)===sequence[index]);assert.ok(word);
- await target.click();await settle();readingTaps++;assert.equal(await target.locator('.study-answer').count(),0);
- assert.equal(await target.locator('.study-phonetic').count(),step===3?0:1);
- await target.click();await settle();readingTaps++;assert.equal(await target.locator('.study-answer').innerText(),step===3?word.Title:word.sy);
- if((index===0||word.GeneralID===701)&&groupSize===6){
- for(const width of [320,375,768]){
- await page.setViewportSize({width,height:900});
- const geometry=await target.evaluate(el=>{const head=el.querySelector('.study-headword'),answer=el.querySelector('.study-answer'),hints=el.querySelector('.study-hints');const a=head.getBoundingClientRect(),b=hints.getBoundingClientRect();return {headSize:getComputedStyle(head).fontSize,answerSize:getComputedStyle(answer).fontSize,right:b.x>=a.right,overflow:document.documentElement.scrollWidth>innerWidth};});
- assert.equal(geometry.headSize,geometry.answerSize);assert.ok(geometry.right);assert.equal(geometry.overflow,false);
- await page.screenshot({path:out+'/'+entry+'-round-'+step+'-'+width+(word.GeneralID===701?'-long':'')+'.png'});
- }
- await page.setViewportSize({width:375,height:812});
- }
- await target.click();await settle();readingTaps++;
- if(index<groupSize-1){assert.equal(await target.locator('.study-answer').count(),0);assert.equal(await target.locator('.repeat-dots .done').count(),3);}
- }
- }
- await page.waitForURL('**/pages/stu/stu3**');
- for(let i=0;i<groupSize;i++){
- const title=await page.locator('.match-grid > div').first().locator('button').first().innerText();const word=words.find(w=>w.Title===title);
- await page.locator('.match-grid > div').first().getByRole('button',{name:title,exact:true}).click();
- await page.locator('.match-grid > div').nth(1).getByRole('button',{name:word.sy,exact:true}).click();await settle();
- }
- await page.getByRole('button',{name:'进入听音识意'}).click();await page.waitForURL('**/pages/stu/stu4**');
- for(let i=0;i<groupSize;i++){
- await until(async()=>/\/fixtures\/7\d+\.wav$/.test(await page.evaluate(()=>window.__lastAudio||'')),'listening audio');
- const src=await page.evaluate(()=>window.__lastAudio),id=Number(src.match(/(\d+)\.wav$/)[1]),word=words.find(w=>w.GeneralID===id);assert.ok(word);
- await page.evaluate(()=>window.__lastAudio='');
- await page.locator('.option-list').getByRole('button',{name:word.sy,exact:true}).click();await settle();
- }
- await page.getByRole('button',{name:'进入组合拼写'}).click();await page.waitForURL(teacher?'**/pages/stu/pl_stu5**':'**/pages/stu/stu5**');
- for(let i=0;i<groupSize;i++){
- const meaning=await page.locator('.spelling-card p').innerText(),word=words.find(w=>w.sy===meaning);assert.ok(word);
- for(const letter of word.Title){await page.locator('.letter-grid button:enabled').filter({hasText:new RegExp('^'+letter+'$')}).first().click();await settle();}
- await page.locator('.sticky-actions .primary-button').click();await settle();
- }
- if(groupSize===6){await page.getByRole('button',{name:'学习下一组单词'}).click();await page.locator('.repeat-list > button').first().waitFor();assert.equal(await page.locator('.repeat-list > button').count(),4);}
- }
- assert.equal(readingTaps,90);assert.deepEqual(errors,[]);
- report.push({entry,passed:true,readingTaps,groups:[6,4],roundsPerGroup:3,stages:['point reading','matching','listening','spelling'],mouseAndTouchSwipe:true,verticalScroll:true,longLabels:true,widths:[320,375,768],productionRequests:0});
- await context.close();
- }
- writeFileSync(out+'/results.json',JSON.stringify({environment:'local production build with isolated API and audio fixtures',report},null,2));console.log(JSON.stringify(report,null,2));
- }finally{await browser.close();await new Promise(done=>server.close(done));}
|