check-three-tap-reading-browser.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. // Local production build + isolated API fixtures. Never sends requests to production.
  2. import { chromium } from 'playwright';
  3. import { createServer } from 'node:http';
  4. import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
  5. import { resolve, extname } from 'node:path';
  6. import assert from 'node:assert/strict';
  7. const root=resolve('dist/xiaoshu-mobile/browser');
  8. const out=resolve(process.env.THREE_TAP_QA_DIR || '/tmp/xiaoshu-three-tap-browser');mkdirSync(out,{recursive:true});
  9. const server=createServer((req,res)=>{
  10. let file=resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://local').pathname));
  11. if(!file.startsWith(root+'/')||!existsSync(file)||!extname(file))file=root+'/index.html';
  12. 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));
  13. });
  14. await new Promise(done=>server.listen(0,'127.0.0.1',done));const origin='http://127.0.0.1:'+server.address().port;
  15. const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
  16. 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}));
  17. 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);
  18. words[0]={...words[0],Title:'characteristically',sy:'典型地;具有特征地;用于验证较长中文释义在右侧区域换行'};
  19. const report=[];
  20. 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);}
  21. try{
  22. for(const entry of ['course','coach-wordbook','student-wordbook']){
  23. const errors=[],calls=[];const teacher=entry!=='student-wordbook';
  24. const context=await browser.newContext({viewport:{width:375,height:812},hasTouch:true,reducedMotion:'reduce'});
  25. await context.addInitScript(({teacher})=>{
  26. 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}));
  27. HTMLMediaElement.prototype.play=function(){window.__lastAudio=this.src;return Promise.resolve();};
  28. HTMLMediaElement.prototype.load=function(){};
  29. },{teacher});
  30. await context.route('**/*',async route=>{
  31. const url=new URL(route.request().url());if(url.origin!==origin)return route.abort();
  32. if(url.pathname.startsWith('/fixtures/'))return route.fulfill({body:wav,contentType:'audio/wav'});
  33. if(!url.pathname.startsWith('/cloud-functions/'))return route.continue();
  34. const {params:p}=route.request().postDataJSON();calls.push(p);let result=[],page;
  35. if(p.action==='app_session_validate')result={valid:true};
  36. else if(p.action==='app_live_versions')result={version:'1',scopeVersions:{},syncStatus:{state:'current'}};
  37. else if(p.action==='e_user_list')result=[{userId:100,honeyName:'测试学员'}];
  38. else if(p.action==='e_ck_list'){result=words;page={itemCount:10,pageCount:1,passCounts:[10,0,0,0],bookPassCounts:[10,0,0,0]};}
  39. 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};}
  40. else if(p.action==='stu_record_update_v2')result={batchId:p.batchId,batchVersion:1,learned:0};
  41. else if(p.action==='content_list'&&p.orders==='NEWID()')result=Array.from({length:8},(_,i)=>({GeneralID:9000+i,Title:'external'+i,sy:'外部释义'+i}));
  42. else if(p.action==='app_word_audio_ensure')result={state:'ready',url:origin+'/fixtures/'+p.wordId+'.wav'};
  43. return route.fulfill({json:{retcode:0,result,...(page?{page}:{})}});
  44. });
  45. const page=await context.newPage();
  46. const settle=()=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
  47. page.on('pageerror',e=>errors.push(e.message));
  48. await page.goto(origin+(entry==='course'?'/pages/stu/stu_abc?nids=811&pnid=801&orderId=901':'/pages/member/starshop'+(teacher?'?wordbookStudentId=100':'')));
  49. const select=entry==='course'?'.word-select-check':'.word-select-list > button';
  50. await page.locator(select).first().waitFor();assert.equal(await page.locator(select).count(),10);
  51. for(const button of await page.locator(select).all())await button.click();
  52. await page.locator('.selection-header .primary-button').click();await page.locator('.repeat-list > button').first().waitFor();
  53. assert.equal(await page.locator('.repeat-list > button').count(),6,'existing six-word grouping');
  54. const row=()=>page.locator('.repeat-list > button').first();
  55. const firstWord=await row().locator('strong').innerText();const firstMeaning=words.find(w=>w.Title===firstWord).sy;
  56. // Actual mouse hold/left drag; generated click must not consume a reading tap.
  57. 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();
  58. assert.equal(await row().locator('.study-answer').innerText(),firstMeaning);assert.equal(await row().locator('.repeat-dots .done').count(),0);
  59. await row().click();await settle();assert.equal(await row().locator('.study-answer').count(),0);assert.equal(await row().locator('.repeat-dots .done').count(),1);
  60. // Real touch gesture via CDP, including native scroll cancellation on vertical movement.
  61. const cdp=await context.newCDPSession(page);box=await row().boundingBox();
  62. const touch=async(type,x,y)=>cdp.send('Input.dispatchTouchEvent',{type,touchPoints:type==='touchEnd'?[]:[{x,y}]});
  63. 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();
  64. assert.equal(await row().locator('.study-answer').innerText(),firstMeaning);assert.equal(await row().locator('.repeat-dots .done').count(),1);
  65. 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);
  66. await page.setViewportSize({width:375,height:420});await page.evaluate(()=>window.scrollTo(0,0));await settle();
  67. box=await row().boundingBox();
  68. const scrollPosition=()=>page.evaluate(()=>window.scrollY+Array.from(document.querySelectorAll('*')).reduce((n,e)=>n+e.scrollTop,0));
  69. const beforeScroll=await scrollPosition();
  70. await touch('touchStart',box.x+box.width/2,box.y+35);
  71. 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));}
  72. await touch('touchEnd');await settle();
  73. assert.ok(await scrollPosition()>beforeScroll,'vertical touch still scrolls');
  74. assert.equal(await page.locator('.study-answer').count(),0);assert.equal(await page.locator('.repeat-dots .done').count(),0);
  75. await page.setViewportSize({width:375,height:812});await page.evaluate(()=>window.scrollTo(0,0));await settle();
  76. let readingTaps=0;
  77. for(const groupSize of [6,4]){
  78. for(const step of [1,2,3]){
  79. assert.match(await page.locator('.stage-banner').innerText(),new RegExp('第 '+step+' / 3 轮'));
  80. assert.equal(await page.locator('.study-answer').count(),0);assert.equal(await page.locator('.study-phonetic').count(),0);
  81. const sequence=await page.locator('.study-headword').allTextContents();
  82. for(let index=0;index<groupSize;index++){
  83. 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);
  84. await target.click();await settle();readingTaps++;assert.equal(await target.locator('.study-answer').count(),0);
  85. assert.equal(await target.locator('.study-phonetic').count(),step===3?0:1);
  86. await target.click();await settle();readingTaps++;assert.equal(await target.locator('.study-answer').innerText(),step===3?word.Title:word.sy);
  87. if((index===0||word.GeneralID===701)&&groupSize===6){
  88. for(const width of [320,375,768]){
  89. await page.setViewportSize({width,height:900});
  90. 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};});
  91. assert.equal(geometry.headSize,geometry.answerSize);assert.ok(geometry.right);assert.equal(geometry.overflow,false);
  92. await page.screenshot({path:out+'/'+entry+'-round-'+step+'-'+width+(word.GeneralID===701?'-long':'')+'.png'});
  93. }
  94. await page.setViewportSize({width:375,height:812});
  95. }
  96. await target.click();await settle();readingTaps++;
  97. if(index<groupSize-1){assert.equal(await target.locator('.study-answer').count(),0);assert.equal(await target.locator('.repeat-dots .done').count(),3);}
  98. }
  99. }
  100. await page.waitForURL('**/pages/stu/stu3**');
  101. for(let i=0;i<groupSize;i++){
  102. const title=await page.locator('.match-grid > div').first().locator('button').first().innerText();const word=words.find(w=>w.Title===title);
  103. await page.locator('.match-grid > div').first().getByRole('button',{name:title,exact:true}).click();
  104. await page.locator('.match-grid > div').nth(1).getByRole('button',{name:word.sy,exact:true}).click();await settle();
  105. }
  106. await page.getByRole('button',{name:'进入听音识意'}).click();await page.waitForURL('**/pages/stu/stu4**');
  107. for(let i=0;i<groupSize;i++){
  108. await until(async()=>/\/fixtures\/7\d+\.wav$/.test(await page.evaluate(()=>window.__lastAudio||'')),'listening audio');
  109. const src=await page.evaluate(()=>window.__lastAudio),id=Number(src.match(/(\d+)\.wav$/)[1]),word=words.find(w=>w.GeneralID===id);assert.ok(word);
  110. await page.evaluate(()=>window.__lastAudio='');
  111. await page.locator('.option-list').getByRole('button',{name:word.sy,exact:true}).click();await settle();
  112. }
  113. await page.getByRole('button',{name:'进入组合拼写'}).click();await page.waitForURL(teacher?'**/pages/stu/pl_stu5**':'**/pages/stu/stu5**');
  114. for(let i=0;i<groupSize;i++){
  115. const meaning=await page.locator('.spelling-card p').innerText(),word=words.find(w=>w.sy===meaning);assert.ok(word);
  116. for(const letter of word.Title){await page.locator('.letter-grid button:enabled').filter({hasText:new RegExp('^'+letter+'$')}).first().click();await settle();}
  117. await page.locator('.sticky-actions .primary-button').click();await settle();
  118. }
  119. 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);}
  120. }
  121. assert.equal(readingTaps,90);assert.deepEqual(errors,[]);
  122. 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});
  123. await context.close();
  124. }
  125. 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));
  126. }finally{await browser.close();await new Promise(done=>server.close(done));}