92 lines
4.6 KiB
JavaScript
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;}
|
|
}
|
|
}
|