92 lines
4.6 KiB
JavaScript

// Only the explicit numeric projection below can leave the browser. Never
// pass a receipt, device object, error string, frame, PCM or DOM value to fetch.
export function reportMetrics(receipt) {
const finite=value=>typeof value==='number'&&Number.isFinite(value)&&value>=0 ? value : null;
const v=receipt.video??{},a=receipt.audio??{},s=receipt.settings??{};
return Object.fromEntries(Object.entries({
elapsed_ms:receipt.elapsedMs,
video_width:s.video?.width,video_height:s.video?.height,video_fps:s.video?.frameRate,
audio_rate:s.audio?.sampleRate,audio_channels:s.audio?.channelCount,
video_callbacks:v.callbacks,video_unobserved:v.unobservedPresentedFrames,
video_resets:v.clockOrCounterResets,video_interval_p95_ms:v.callbackIntervalP95Ms,
video_gap_max_ms:v.callbackGapMaxMs,audio_input_frames:a.inputFrames,
audio_processed_frames:a.processedFrames,audio_zero_frames:a.exactZeroFrames,
audio_rms:a.rms,audio_peak:a.peak,audio_context_rate:a.sampleRate,
}).map(([key,value])=>[key,finite(value)]));
}
export function stopState(reason) {
if(reason==='120 seconds complete')return 'completed';
if(reason==='page hidden; timing invalid')return 'hidden';
if(reason==='page closed')return 'closed';
if(reason==='device ended'||reason==='device selection changed')return 'device-change';
if(reason==='operator'||reason==='selecting devices')return 'stopped';
return 'failed';
}
export class RunReporter {
constructor(status,request=globalThis.fetch.bind(globalThis)) {
this.status=status;this.request=request;this.pending=null;this.busy=false;
this.terminal=false;this.sequence=0;this.lastProgress=-Infinity;this.available=true;
status('Reporting: connecting…');
this.ready=this.open();
}
async open() {
try {
const r=await this.request('/av-test/api/start',{method:'POST',credentials:'omit',
headers:{'Content-Type':'application/json'},body:'{}',signal:AbortSignal.timeout(5000)});
if(!r.ok)throw new Error('start rejected');
const data=await r.json();
if(!/^[a-f0-9]{32}$/.test(data.run_id)||!/^[A-Za-z0-9_-]{43}$/.test(data.write_token))throw new Error('bad receipt');
this.runId=data.run_id;this.token=data.write_token;
this.status(`Reporting enabled · test ${this.runId.slice(0,8)}`);
} catch {
this.available=false;this.status('Reporting unavailable · Save metrics remains available');
}
}
observe(receipt) {
if(this.terminal||receipt.elapsedMs-this.lastProgress<10000)return;
this.lastProgress=receipt.elapsedMs;this.queue('running',receipt);
}
finish(receipt) {
if(this.terminal)return;
this.terminal=true;this.queue(stopState(receipt.reason),receipt);
}
queue(state,receipt) {
// One latest snapshot, not a growing queue. Terminal state supersedes
// pending progress; a stopped run never becomes running again.
this.pending={state,metrics:reportMetrics(receipt)};
void this.flush();
}
async flush() {
if(this.busy)return;
this.busy=true;
try {
await this.ready;
if(!this.available){this.pending=null;return;}
while(this.pending) {
const report=this.pending;this.pending=null;
const payload={run_id:this.runId,sequence:++this.sequence,...report};
let delivered=false;
for(let attempt=0;attempt<2&&!delivered;attempt++) {
try {
const response=await this.request('/av-test/api/report',{
method:'POST',credentials:'omit',keepalive:true,
headers:{'Content-Type':'application/json',Authorization:`Bearer ${this.token}`},
body:JSON.stringify(payload),signal:AbortSignal.timeout(5000),
});
delivered=response.ok;
if(response.status>=400&&response.status<500)break;
} catch { /* No raw exception or endpoint response is displayed. */ }
}
if(report.state!=='running') {
this.status(delivered ? `Result reported · test ${this.runId.slice(0,8)}` :
'Result not reported · use Save metrics');
} else if(!delivered) {
this.status('Reporting interrupted · local test continues');
}
}
} finally {this.busy=false;}
}
}