fix(camera): add 30-second scan waiting watchdog

This commit is contained in:
xsl
2026-10-16 16:07:53 +08:00
parent 5e07985b19
commit b19811f974
4 changed files with 96 additions and 24 deletions
+15 -9
View File
@@ -8,9 +8,9 @@ const source = fs.readFileSync(path.join(__dirname, '../js/app.js'), 'utf8');
const start = source.indexOf(' var ANALYZE_TIMEOUT_MS =');
const end = source.indexOf(' /* ─── SkinScore viewModel', start);
assert(start >= 0 && end > start);
function setup() {
let timer, complete, signal, result;
const ctx = { IS_MOCK: false, camSeq: 1, AbortController, CaptureQuality: require('../js/capture-quality'), captureDiagnostics: [],
function setup(withAbort = true) {
let timer, complete, signal, result, calls = 0;
const ctx = { IS_MOCK: false, camSeq: 1, AbortController: withAbort ? AbortController : undefined, CaptureQuality: require('../js/capture-quality'), captureDiagnostics: [],
setTimeout(fn, delay) { timer = { fn, delay }; return 1; },
clearTimeout() { timer = null; },
fetch(url, options) {
@@ -18,21 +18,20 @@ function setup() {
signal = options.signal;
return new Promise((resolve, reject) => {
complete = () => resolve({ json: async () => ({ ok: true, report_saved: true }) });
signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); });
if (signal) signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); });
});
}
};
vm.createContext(ctx);
vm.runInContext(source.slice(start, end), ctx);
ctx.requestAnalyze(['front', 'left', 'right'], 1, {}, (...args) => { result = args; });
const cancel = ctx.requestAnalyze(['front', 'left', 'right'], 1, {}, (...args) => { result = args; calls++; });
return { ctx, get timer() { return timer; }, get signal() { return signal; },
get result() { return result; }, finish() { complete(); } };
get result() { return result; }, get calls() { return calls; }, cancel, finish() { complete(); } };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
(async () => {
const delayed = setup();
assert.equal(delayed.timer.delay, 150000);
assert(delayed.timer.delay > 60000 * 2 + 4000, 'Allow both backend attempts plus retry wait');
assert.equal(delayed.timer.delay, 30000);
delayed.finish();
await flush();
assert.equal(delayed.result[0], null);
@@ -44,11 +43,18 @@ const flush = () => new Promise(resolve => setImmediate(resolve));
await flush();
assert(expired.signal.aborted);
assert.match(expired.result[0], /检测服务开小差/);
assert.equal(expired.calls, 1);
const legacy = setup(false);
legacy.timer.fn();
assert.equal(legacy.result[0], '检测服务开小差了,请重试');
legacy.finish();await flush();assert.equal(legacy.calls,1,'late success never replaces timeout');
const aborted = setup();aborted.cancel();await flush();
assert(aborted.signal.aborted);assert.equal(aborted.result,undefined);assert.equal(aborted.timer,null);
const canceled = setup();
canceled.ctx.camSeq++;
canceled.finish();
await flush();
assert.equal(canceled.result, undefined);
assert.equal(canceled.timer, null);
console.log('PASS: frontend timeout covers backend retry budget; success, timeout, stale response, timer cleanup');
console.log('PASS: 30s request timeout, legacy no-AbortController fallback, single completion, cancellation, late response suppression and timer cleanup');
})().catch(e => { console.error(e); process.exitCode = 1; });
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
const source=fs.readFileSync(require('node:path').join(__dirname,'../js/app.js'),'utf8');
const helpers=source.slice(source.indexOf(' var SCAN_WAIT_TIMEOUT_MS'),source.indexOf(' function restartScanCycle('));
const submit=source.slice(source.indexOf(' function submitPhotos('),source.indexOf(' function shutter()'));
function setup() {
let now=0, next=0, done, resolveAnimation, aborted=0, stopped=0, rendered=0;
const timers=new Map(),messages=[],events=[];
const nodes={'#f-age':{value:30},'#f-gender':{value:'女'},'#p-analyzing':{show:false},'#s-report':{classList:{contains:()=>ctx.currentScreen==='s-report'}}};
const ctx={Date:{now:()=>now},Promise,console,camSeq:1,camStep:2,photos:['a','b','c'],captureDiagnostics:[],shutterLock:true,currentScreen:'s-cam',
setTimeout(fn,ms){const id=++next;timers.set(id,{fn,ms});return id;},clearTimeout(id){timers.delete(id);},
$:s=>nodes[s],photosByView:p=>p,openOverlay(){nodes['#p-analyzing'].show=true;ctx.scanCycle={promise:new Promise(r=>{resolveAnimation=r;})};},
closeOverlay(){nodes['#p-analyzing'].show=false;ctx.stopAnalysisWait();resolveAnimation();},
voice:{play(){},stop(){}},VOICE:{analyzing:'a',fail:'f'},regionSel:{value:'上海'},SKINS:[{name:'中性'}],skinIndex:0,
trackCapture:s=>events.push(s),stopCamera(){stopped++;},toast:s=>messages.push(s),showScreen:s=>{ctx.currentScreen=s;},popHistorySilently(){},
requestAnalyze(p,s,profile,cb){done=cb;return ()=>{aborted++;};},
SkinScore:{buildViewModel:()=>({})},renderReport(){rendered++;},IS_MOCK:false,AccountClient:{user:()=>({kind:'wechat'})}};
vm.createContext(ctx);vm.runInContext(helpers+submit,ctx);ctx.submitPhotos(1);
return {ctx,timers,messages,events,advance(ms,fire=true){now=ms;if(fire){const jobs=[...timers.values()];jobs.forEach(j=>j.fn());}},respond(error=null){done(error,{vm:{},report_saved:true});},animation(){resolveAnimation();},get aborted(){return aborted;},get rendered(){return rendered;},get stopped(){return stopped;}};
}
const flush=()=>new Promise(r=>setImmediate(r));
(async()=>{
const pending=setup();assert.equal([...pending.timers.values()][0].ms,30000);
pending.advance(30000);await flush();assert.equal(pending.ctx.currentScreen,'s-profile');assert.equal(pending.messages[0],'检测服务开小差了,请重试');assert.equal(pending.ctx.shutterLock,false);assert.equal(pending.ctx.photos.length,0);assert.equal(pending.aborted,1);assert.equal(pending.timers.size,0);
pending.respond();await flush();assert.equal(pending.rendered,0);assert.equal(pending.messages.length,1);
const stuck=setup();stuck.respond();await flush();assert.equal(stuck.ctx.currentScreen,'s-cam');stuck.advance(30000);await flush();assert.equal(stuck.ctx.currentScreen,'s-profile');assert.equal(stuck.rendered,0);
const success=setup();success.advance(29999,false);success.respond();success.animation();await flush();assert.equal(success.ctx.currentScreen,'s-report');assert.equal(success.timers.size,0);assert.equal(success.messages.length,0);assert.equal(success.rendered,1);
const late=setup();late.advance(31000,false);late.respond();await flush();assert.equal(late.ctx.currentScreen,'s-profile');assert.equal(late.rendered,0,'deadline checked even before delayed timer executes');
const cancel=setup();cancel.ctx.camSeq++;cancel.ctx.closeOverlay();cancel.advance(30000);cancel.respond();await flush();assert.equal(cancel.messages.length,0);assert.equal(cancel.timers.size,0);assert.equal(cancel.rendered,0);
console.log('PASS: scan page 30s watchdog covers pending network and stuck animation, success cleanup, late callbacks, legacy timing and cancellation');
})().catch(e=>{console.error(e);process.exitCode=1;});