107 lines
5.7 KiB
JavaScript
107 lines
5.7 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??{},p=receipt.paired??{};
|
|
const c=p.continuity??{},pilot=p.pilot??{};
|
|
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),
|
|
marker_frames:c.frames,marker_bad:c.badMarkers,marker_ambiguous:c.ambiguousGaps,
|
|
marker_missing_fraction:c.missingFraction,marker_duplicate_fraction:c.duplicateFraction,
|
|
pilot_windows:pilot.windows,pilot_gap_max_ms:pilot.gapMaxMs,
|
|
pilot_missing_fraction:pilot.missingFraction,pilot_trailing_gap_ms:pilot.trailingGapMs,
|
|
pilot_discontinuities:pilot.discontinuities,
|
|
}).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)return;
|
|
// Paired tests reset elapsed time when the known source first arrives.
|
|
// A long signal wait must not suppress the next minute of progress.
|
|
if(receipt.elapsedMs<this.lastProgress)this.lastProgress=-Infinity;
|
|
if(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;}
|
|
}
|
|
}
|