// 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 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 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/\/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;iw.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));}