// Controlled audio ended events; real capture function, no camera/API writes. import assert from 'node:assert/strict'; import fs from 'node:fs'; import {execSync} from 'node:child_process'; import {fileURLToPath,pathToFileURL} from 'node:url'; const {chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href); const browser=await chromium.launch(); try { const page=await browser.newPage();const errors=[]; page.on('pageerror',e=>errors.push(e.message)); const source=fs.readFileSync(fileURLToPath(new URL('../js/app.js',import.meta.url)),'utf8'); await page.route('**/api/**',r=>{assert.equal(r.request().method(),'GET');return r.fulfill({json:{ok:true,user:{kind:'browser'}}});}); await page.route('**/assets/voice/*.mp3',r=>r.fulfill({body:r.request().url().split('/').pop()})); await page.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,` window.__countdown = { start: function(step) { camStep=step; currentScreen='s-cam'; camLive=true; shutterLock=false; captureFromVideo=function(){window.__shots.push(step);return 'fixture';}; checkStill=function(){return Promise.resolve(null);}; onPhotoCaptured=function(){window.__completed.push(step);}; doCapture(); }, again:doCapture, cancel:leaveCamera, busy:function(){return shutterLock;} }; })();`)})); await page.addInitScript(()=>{ window.__audio=[];window.__shots=[];window.__completed=[]; window.AudioContext=class { constructor(){this.destination={};} get state(){return window.__mute?'suspended':'running';} decodeAudioData(ab,resolve){const b={id:new TextDecoder().decode(ab),duration:.9};resolve(b);return Promise.resolve(b);} createBufferSource(){return {connect(){},stop(){},start(){window.__audio.push(this);}};} }; }); await page.goto(process.env.H5_BASE_URL||'http://127.0.0.1:8080/'); await page.locator('body').click({position:{x:2,y:2}}); await page.waitForTimeout(300); for(let step=0;step<3;step++){ await page.evaluate(n=>window.__countdown.start(n),step); await page.waitForFunction(n=>window.__audio.length===n,step+1); assert.equal(await page.evaluate(()=>window.__audio.at(-1).buffer.id),'countdown-321.mp3'); assert.equal(await page.locator('#cam-countdown').textContent(),'3'); assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),false); await page.evaluate(()=>window.__countdown.again()); await page.waitForTimeout(350); assert.equal(await page.evaluate(()=>window.__shots.length),step,'no capture before audio ended'); assert(await page.evaluate(()=>window.__countdown.busy())); assert.equal(await page.locator('#cam-countdown').textContent(),'2'); await page.waitForFunction(()=>document.querySelector('#cam-countdown').textContent==='1'); await page.evaluate(()=>window.__audio.at(-1).onended()); await page.waitForFunction(n=>window.__completed.length===n,step+1); assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true); } await page.evaluate(()=>window.__countdown.start(0)); await page.waitForFunction(()=>window.__audio.length===4); await page.evaluate(()=>window.__countdown.cancel()); assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true,'cancel immediately clears countdown'); await page.waitForTimeout(700); assert.equal(await page.evaluate(()=>window.__shots.length),3,'cancelled countdown never captures'); await page.evaluate(()=>{window.__mute=true;window.__countdown.start(0);}); assert.equal(await page.locator('#cam-countdown').textContent(),'3'); await page.waitForTimeout(350); assert.equal(await page.evaluate(()=>window.__shots.length),3,'silent devices still wait for visual countdown'); await page.waitForFunction(()=>window.__completed.length===4); assert.equal(await page.locator('#cam-countdown').evaluate(e=>e.hidden),true); assert.deepEqual(errors,[]); console.log('PASS: all 3 shots show animated 3/2/1, wait for voice and visual completion; duplicate shutter ignored; cancellation clears digits; silent-device countdown'); }finally{await browser.close();}