Files
aego/tools/websmoke/audio-silence.test.cjs
sanspie d8ad92166c
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m14s
ci / release (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci / checks (push) Failing after 58m31s
GitHub / windows amd64 native GitHub-hosted native CI: success
GitHub / darwin arm64 native GitHub-hosted native CI: success
GitHub / darwin amd64 native GitHub-hosted native CI: success
GitHub / linux amd64 native GitHub-hosted native CI: success
test: diagnose browser scheduling on audio underruns
2026-11-10 14:56:15 +03:00

156 lines
8.3 KiB
JavaScript

// Exercise the real browser-audio gate with fake page state. These policy
// checks do not replace browser playback, nor do zero underruns prove audio.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {test} = require('node:test');
const vm = require('node:vm');
const file = path.join(__dirname, 'audio.mjs');
const source = fs.readFileSync(file, 'utf8');
const start = source.indexOf('async function run(browser, label, headers, wantShared, depth = 0) {');
const end = source.indexOf('\nconst browser = await chromium.launch(', start);
if (start < 0 || end < 0) throw new Error('unexpected audio smoke source');
const code = source.slice(start, end);
async function evaluate(rms, isDemo, depth = 0, overrides = {}, diagnostics = {}) {
const failures = [];
const logs = [];
const expressions = [];
let install;
const stats = {running:true, queued:2048, shared:false, rms, out:rms, muted:false,
underruns:0, pumpUnderruns:0, targetBlocks:depth || 6, blockFrames:512, rate:48000, mixMaxUs:50, ...overrides};
const page = {on(){}, goto:async()=>{}, close:async()=>{}, reload:async()=>{},
addInitScript:async fn=>{install=fn; if (diagnostics.initError) throw new Error('observer unavailable');},
mouse:{click:async()=>{}}, isHidden:async()=>false,
click:async selector=>{if (selector === '#mute') {stats.muted=true;stats.out=0;}}};
const scope = {
fs:{existsSync:()=>true}, path, site:'/diagnostic-only', seconds:10, demoSeconds:10,
sleep:async()=>{}, fail:s=>failures.push(s), console:{log:s=>logs.push(s)},
serve:async()=>({address:()=>({port:1}),close(){}}),
scriptWithoutGesture:async()=>({until:async()=>{}, js:async expression=>{
expressions.push(expression);
if (expression==='globalThis.crossOriginIsolated === true') return false;
if (expression.includes('const c = new AudioContext()')) return false;
if (expression.startsWith('globalThis.aegoAudioBlocks =')) return undefined;
if (expression==='globalThis.aegoAudio.start()') return '';
if (expression==='globalThis.aegoAudio.stats()' || expression==='globalThis.aegoAudioStats()') return JSON.stringify(stats);
if (expression==='globalThis.aegoAudio.stop()' || expression.includes('globalThis.aegoAudioMute(false)')) return undefined;
if (expression==="document.documentElement.dataset.sound === 'off'") return false;
if (expression.includes('navigator.userActivation && navigator.userActivation.hasBeenActive')) return false;
if (expression==='globalThis.aegoGame?.mixStats?.() ?? null') return {underruns:stats.pumpUnderruns};
if (expression==='JSON.stringify({now:performance.now(), timing:globalThis.__aegoAudioSmokeTiming ?? null})') {
if (diagnostics.readError) throw new Error('timing unavailable');
return JSON.stringify({now:123, timing:{available:true, count:1, maxMS:160, tasks:[{startMS:50,durationMS:160}]}});
}
throw new Error('unexpected test expression: '+expression);
}}),
};
vm.createContext(scope);
vm.runInContext(code,scope,{filename:file});
const browser={newPage:async()=>page};
if (isDemo) await scope.demo(browser,'demo',{},false);
else await scope.run(browser,'messages',{},false,depth);
return {failures, logs, install, expressions};
}
test('tone silence fails even when both underrun counters are zero',async()=>{
assert((await evaluate(0,false)).failures.some(x=>x.includes('rms 0')));
assert((await evaluate(0,false,5)).failures.some(x=>x.includes('rms 0')));
});
test('game silence fails even when both underrun counters are zero',async()=>{
assert((await evaluate(0,true)).failures.some(x=>x.includes('music is silent')));
});
test('audible samples and zero underruns do not fail the fake checks',async()=>{
assert.deepEqual((await evaluate(0.354,false)).failures,[]);
const result=await evaluate(0.09,true);
assert.deepEqual(result.failures,[]);
assert(!result.logs.some(x=>x.includes('underrun timing')));
});
test('game underruns still fail and report the browser timing diagnostic',async()=>{
const {failures,logs}=await evaluate(0.09,true,0,{underruns:1,pumpUnderruns:1});
assert(failures.some(x=>x.includes('1 underruns in the game')));
assert(failures.some(x=>x.includes('1 predicted pump underruns')));
assert(logs.some(x=>x.includes('underrun timing') && x.includes('160')));
});
test('timing marks preserve the unmute and pre-click activation checks',async()=>{
const {expressions}=await evaluate(0.09,true);
const unmute=expressions.find(x=>x.includes('globalThis.aegoAudioMute(false)'));
const activation=expressions.find(x=>x.includes('navigator.userActivation && navigator.userActivation.hasBeenActive'));
let calls=0;
const env={__aegoAudioSmokeTiming:{},performance:{now:()=>123},
aegoAudioMute:on=>{assert.equal(on,false);calls++;return 'original result';},
navigator:{userActivation:{hasBeenActive:false}}};
assert.equal(vm.runInNewContext(unmute,env),'original result');
assert.equal(calls,1);
assert.equal(env.__aegoAudioSmokeTiming.unmuteMS,123);
env.performance.now=()=>456;
assert.equal(vm.runInNewContext(activation,env),false);
assert.equal(env.__aegoAudioSmokeTiming.beforeClickSampleMS,456);
delete env.__aegoAudioSmokeTiming;
assert.equal(vm.runInNewContext(unmute,env),'original result');
assert.equal(vm.runInNewContext(activation,env),false);
assert.equal(calls,2);
});
test('unavailable timing never hides an underrun or fails a healthy game',async()=>{
const failed=await evaluate(0.09,true,0,{underruns:1},{readError:true});
assert(failed.failures.some(x=>x.includes('1 underruns in the game')));
assert(failed.logs.some(x=>x.includes('underrun timing') && x.includes('unavailable')));
assert.deepEqual((await evaluate(0.09,true,0,{}, {initError:true})).failures,[]);
});
test('long-task observer is bounded and records only the trusted first click',async()=>{
const {install}=await evaluate(0.09,true);
assert.equal(typeof install,'function');
let observe, click, callback, pulse;
class Observer {
static supportedEntryTypes=['longtask'];
constructor(fn) { callback=fn; }
observe(options) { observe=options.type; }
}
const env={PerformanceObserver:Observer, performance:{now:()=>123},
setInterval:(fn,ms)=>{assert.equal(ms,50);pulse=fn;},
window:{addEventListener:(name,fn)=>{assert.equal(name,'pointerdown');click=fn;}}};
vm.runInNewContext(`(${install.toString()})()`,env);
const timing=env.__aegoAudioSmokeTiming;
assert.equal(observe,'longtask');
assert.equal(timing.available,true);
click({isTrusted:false});
assert.equal(timing.firstClickMS,null);
click({isTrusted:true});
env.performance.now=()=>456;
click({isTrusted:true});
assert.equal(timing.firstClickMS,123);
pulse();
assert.equal(timing.heartbeatAvailable,true);
assert.equal(timing.gapCount,1);
assert.equal(timing.maxGapMS,333);
for (let i=0; i<100; i++) {
env.performance.now=()=>656+i*200;
pulse();
}
assert.equal(timing.gapCount,101);
assert.equal(timing.maxGapMS,333);
assert.equal(timing.gaps.length,32);
assert.equal(timing.gaps[0].gapMS,333);
assert.equal(timing.gaps[31].gapMS,200);
callback({getEntries:()=>Array.from({length:100},(_,i)=>({startTime:i*100,duration:50+i}))});
assert.equal(timing.count,100);
assert.equal(timing.maxMS,149);
assert.equal(timing.tasks.length,32);
assert.equal(timing.tasks[0].startMS,0);
assert.equal(timing.tasks[15].startMS,1500);
assert.equal(timing.tasks[16].startMS,8400);
assert.equal(timing.tasks[31].startMS,9900);
});
test('an unsupported or throwing long-task observer is diagnostic only',async()=>{
const {install}=await evaluate(0.09,true);
for (const Observer of [undefined, class {
static supportedEntryTypes=['longtask'];
observe() {throw new Error('unsupported');}
}]) {
const env={PerformanceObserver:Observer,performance:{now:()=>0},setInterval(){},window:{addEventListener(){}}};
vm.runInNewContext(`(${install.toString()})()`,env);
assert.equal(env.__aegoAudioSmokeTiming.available,false);
}
});