// 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??{},p=receipt.paired??{}; const estimate=typeof p.estimatedMedianSignedMs==='number'&&Number.isFinite(p.estimatedMedianSignedMs)?p.estimatedMedianSignedMs:null; 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, paired_audio_signals:p.audioSignals,paired_video_signals:p.videoSignals, paired_matches:p.pairs,paired_ambiguous:p.ambiguous,paired_overflow:p.overflow, paired_estimate_audio_late_ms:estimate===null?null:Math.max(0,estimate), paired_estimate_audio_early_ms:estimate===null?null:Math.max(0,-estimate), }).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;} } }