diff --git a/docs/侧脸皱纹浮层异步加载-2026-10-16.md b/docs/侧脸皱纹浮层异步加载-2026-10-16.md new file mode 100644 index 0000000..bba4ecc --- /dev/null +++ b/docs/侧脸皱纹浮层异步加载-2026-10-16.md @@ -0,0 +1,13 @@ +# 侧脸皱纹浮层异步加载 + +主测肤请求只等待主接口、评分及报告保存,不再同步等待左右侧脸的皱纹浮层。正面浮层随主接口返回。前端在报告切屏并经过两帧绘制后,才调用 `POST api/wrinkle-views`,获取该报告所属任务的左右侧脸标注。 + +加载期间保留两张侧脸原图并显示「侧脸皱纹标注加载中…」。完成后只更新侧脸图片,不重算分数、不改结论或护理建议。失败/超时仅退回原图提示,报告继续使用。扫描等待页 30 秒兜底只针对主报告,不受浮层任务影响;可选浮层请求前端预算 65 秒,服务端两次顺序请求仍各最多 20 秒、间隔 4 秒。 + +使用随机任务令牌并绑定服务器会话所属账号;不同账号无法获取他人的标注。重复/并发请求共用同一任务,成功或失败结果都缓存,不自动重新收费调用。微信任务元数据及完成结果持久化,补写原报告 payload、后台标注状态和原始结果,不修改 vm/presentation。服务重启后可继续尚未启动的任务;已标记运行但被重启打断的任务不自动重跑,避免重复调用。老报告无任务令牌时不自动补检。 + +游客任务、照片及结果仅存内存,五分钟后清除,不写游客身份或报告到磁盘。任务从未被报告页请求时,不调用额外侧脸检测。微信任务可从已有报告快照恢复照片,无需用户重新上传。离开报告或切换账号后,迟到结果不修改当前页面;返回同一报告可显示已加载数据。 + +后台 `cost_ms` 现在代表主报告检测链路,额外浮层耗时记录为 `wrinkle_cost_ms`。正常案例从进入报告的等待中移走固定的两次 4 秒间隔及两次侧脸请求耗时,但不承诺固定秒数,不减少已实际执行的接口调用数量。 + +验证使用隔离数据目录和模拟上游,不产生付费检测:主响应只有一次上游调用、报告显示后才请求侧脸、总共三次模拟调用、重复并发与重启缓存、不同账号隔离、游客无磁盘写入、离开/返回和失败降级,以及评分和报告结论不变。 diff --git a/h5/js/app.js b/h5/js/app.js index d4604df..9f54da9 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -133,6 +133,10 @@ currentScreen = id; renderAccountStatus(); if (id === 's-report') resetReport(); // 每次进入报告页都还原初始状态 + if (id === 's-report') { + // Let the report paint before initiating optional side annotation work. + requestAnimationFrame(function(){requestAnimationFrame(loadReportWrinkles);}); + } if (nav === 'push') history.pushState({ screen: id }, ''); else if (nav === 'replace') history.replaceState({ screen: id }, ''); } @@ -1602,9 +1606,37 @@ refreshHeight(); } var wrinklePhotoEpoch = 0; + var activeWrinkleReport = null; + function loadReportWrinkles() { + var report=activeWrinkleReport, job=report && report.payload && report.payload.wrinkle_job; + if(report && job && report.started && job.status!=='pending' && currentScreen==='s-report' && report.owner===(AccountClient.user()&&AccountClient.user().id)) { + renderWrinkleSides(report.photos,report.payload);return; + } + if (!report || !job || job.status!=='pending' || report.started || IS_MOCK || currentScreen!=='s-report' || + report.owner!==(AccountClient.user() && AccountClient.user().id)) return; + report.started=true; + var controller=typeof AbortController!=='undefined'?new AbortController():null; + var timer=setTimeout(function(){if(controller)controller.abort();fail();},65000); + var finished=false; + function visible(){return report===activeWrinkleReport && currentScreen==='s-report' && report.owner===(AccountClient.user()&&AccountClient.user().id);} + function fail(){ + if(finished)return;finished=true;clearTimeout(timer);job.status='unavailable'; + if(visible())renderWrinkleSides(report.photos,report.payload); + } + fetch('api/wrinkle-views',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({token:job.token}),signal:controller?controller.signal:undefined}) + .then(function(res){if(!res.ok)throw Error('annotation unavailable');return res.json();}) + .then(function(data){ + if(finished)return;if(!data.ok||!data.wrinkle_views){fail();return;} + finished=true;clearTimeout(timer);job.status='complete';report.payload.wrinkle_views=data.wrinkle_views; + if(visible())renderWrinkleSides(report.photos,report.payload); // Images only: never re-score/rewrite conclusions. + }).catch(fail); + } function renderWrinkleSides(reportPhotos, payload) { var epoch = ++wrinklePhotoEpoch, wrap = $('#wrinkle-sides'), note = $('#wrinkle-layer-note'); function refresh() { + note.textContent=payload && payload.wrinkle_job && payload.wrinkle_job.status==='pending' ? + '侧脸皱纹标注加载中…' : '部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。'; var visible = Array.from(wrap.querySelectorAll('figure:not([hidden])')); wrap.hidden = !visible.length; note.hidden = !visible.some(function(f) { return f.querySelector('img').dataset.hasAnnotation !== 'true'; }); @@ -1633,6 +1665,7 @@ refresh(); } function renderReport(vm, frontPhoto, sidePhoto, payload, reportPhotos) { + activeWrinkleReport={payload:payload,photos:reportPhotos || [frontPhoto,sidePhoto,null],owner:AccountClient.user()&&AccountClient.user().id,started:false}; renderContourSides(reportPhotos || [frontPhoto, sidePhoto, null]); reportOwner = AccountClient.user() && AccountClient.user().id; // Historical snapshots also use their saved dimensions, never the old API total. diff --git a/h5/lib/accounts.js b/h5/lib/accounts.js index 52855a7..9cc8f6c 100644 --- a/h5/lib/accounts.js +++ b/h5/lib/accounts.js @@ -185,5 +185,16 @@ module.exports = function createAccounts(root, env = process.env) { try { return JSON.parse(fs.readFileSync(path.join(snapshots, user.id, id + '.json'), 'utf8')).presentation || null; } catch (_) { return null; } } - return { route, session, storeReport, reportPresentation, sameOrigin, wechatAvailable: available }; + function reportSnapshot(ownerId,id) { + if (!db.users.some(u=>u.id===ownerId&&u.kind==='wechat') || !/^R[\w-]+$/.test(id)) return null; + try {return JSON.parse(fs.readFileSync(path.join(snapshots,ownerId,id+'.json'),'utf8'));} catch (_) {return null;} + } + function updateWrinkleViews(ownerId,id,views) { + const snapshot=reportSnapshot(ownerId,id); + if (!snapshot) return; + snapshot.payload.wrinkle_views=views; + if (snapshot.payload.wrinkle_job) snapshot.payload.wrinkle_job.status='complete'; + atomic(path.join(snapshots,ownerId,id+'.json'),snapshot); + } + return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available }; }; diff --git a/h5/lib/wrinkle-jobs.js b/h5/lib/wrinkle-jobs.js new file mode 100644 index 0000000..a6f8fab --- /dev/null +++ b/h5/lib/wrinkle-jobs.js @@ -0,0 +1,55 @@ +'use strict'; +const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'); +// Lazy, owner-bound, single-flight tasks. Only WeChat jobs persist. Guest photos +// remain in RAM for at most five minutes; no paid work starts until requested. +module.exports = function({dir, collect, load, complete, now = Date.now}) { + const jobs = new Map(); + function remember(job) { + jobs.set(job.token,job); + const timer=setTimeout(()=>{jobs.delete(job.token);job.context=null;},300000);timer.unref(); + } + function save(job) { + if (!job.persist) return; + fs.mkdirSync(dir, {recursive:true, mode:0o700}); + const file=path.join(dir,job.token+'.json'),tmp=file+'.tmp'; + fs.writeFileSync(tmp,JSON.stringify({token:job.token,owner:job.owner,record:job.record,persist:true,status:job.status,views:job.views}),{mode:0o600}); + fs.renameSync(tmp,file); + } + function register(owner,record,context) { + const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:owner.kind==='wechat',status:'pending',context}; + save(job);remember(job); + return {token:job.token,status:'pending'}; + } + function unavailable(code) {return {version:'independent-side-wrinkles-v1',left:{status:'unavailable',error_code:code},right:{status:'unavailable',error_code:code}};} + async function run(owner,token) { + if (!/^[a-f0-9]{48}$/.test(token||'')) return null; + let job=jobs.get(token); + if (!job) { + try {job=JSON.parse(fs.readFileSync(path.join(dir,token+'.json'),'utf8'));} catch (_) {return null;} + if (job.owner!==owner.id || owner.kind!=='wechat') return null; + remember(job); + } + if (job.owner!==owner.id) return null; + if (job.promise) return job.promise; + if (job.status==='complete') return job.views; + // A persisted running marker means a process restart interrupted the task. + // Never silently repeat potentially charged requests after a restart. + const interrupted=job.status==='running'; + job.status='running';save(job); + job.promise=(async()=>{ + const started=now();let views; + try { + const context=job.context || (job.persist && load(job.owner,job.record)); + views=interrupted?unavailable('WRINKLE_JOB_INTERRUPTED'):context?await collect(context):unavailable('WRINKLE_JOB_EXPIRED'); + } catch (_) {views=unavailable('WRINKLE_JOB_FAILED');} + job.context=null;job.views=views;job.status='complete'; + // Save the completed cache BEFORE patching snapshots; duplicates never + // re-run paid work, even if a snapshot or admin update fails. + save(job); + if (job.persist) {try {complete(job.owner,job.record,views,now()-started);} catch (_) {}} + return views; + })(); + return job.promise; + } + return {register,run}; +}; diff --git a/h5/server.js b/h5/server.js index e90fd20..fd1cbf3 100644 --- a/h5/server.js +++ b/h5/server.js @@ -31,6 +31,32 @@ const CaptureDiagnostics = require('./lib/capture-diagnostics.js'); const WrinkleViews = require('./lib/wrinkle-views.js'); const captureFunnel = require('./lib/capture-funnel.js')(path.join(DATA_ROOT, 'capture-funnel.jsonl')); const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts')); +const wrinkleJobs = require('./lib/wrinkle-jobs.js')({ + dir:path.join(DATA_ROOT,'wrinkle-jobs'), + collect:async context=>WrinkleViews.forReport(await WrinkleViews.collect(context.primary,context.photos,{ + key:CRED.key,secret:CRED.secret,url:FACEPP_URL,gapMs:process.env.NODE_ENV==='test'?0:4000,deadline:Date.now()+60000 + })), + load(owner,id) { + const snapshot=accounts.reportSnapshot(owner,id); + if (!snapshot) return null; + return {primary:{left_side_result:snapshot.payload.side?.left,right_side_result:snapshot.payload.side?.right}, + photos:{left:stripDataPrefix(snapshot.photos[1]||''),right:stripDataPrefix(snapshot.photos[2]||'')}}; + }, + complete(owner,id,views,cost) { + accounts.updateWrinkleViews(owner,id,views); + const rec=findRecord(id); + if (!rec || rec.owner_id!==owner) return; + rec.wrinkle_view_status=Object.fromEntries(['left','right'].map(v=>[v,{status:views[v]?.status||'unavailable',error_code:views[v]?.error_code||null}])); + rec.wrinkle_cost_ms=cost;rec.updated_at=nowIso();updateRecord(rec); + if (rec.raw_file) { + const file=path.join(DATA_DIR,path.basename(rec.raw_file)); + try { + const raw=JSON.parse(fs.readFileSync(file,'utf8'));raw.wrinkle_views=views; + fs.writeFileSync(file+'.wrinkle.tmp',JSON.stringify(raw),{mode:0o600});fs.renameSync(file+'.wrinkle.tmp',file); + } catch (_) {} + } + } +}); /* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */ const SkinScore = require('./js/skin-score.js'); @@ -326,17 +352,17 @@ function fillFailure(rec, code) { } /* 执行一次检测:计时调用 Face++,按结果补全记录(成功/失败都落库)。返回 Face++ 原始返回 */ -async function runDetect(rec, front, left, right, persist = true) { +async function runDetect(rec, front, left, right, persist = true, deferWrinkles = false) { rec.detect_status = '检测中'; rec.updated_at = nowIso(); const t0 = Date.now(); const fpJson = await callFacepp(front, left, right); - if (fpJson.result && !fpJson.error_message) { + if (fpJson.result && !fpJson.error_message && !deferWrinkles) { fpJson.wrinkle_views = await WrinkleViews.collect(fpJson,{left,right},{ key:CRED.key,secret:CRED.secret,url:FACEPP_URL, gapMs:process.env.NODE_ENV==='test'?0:4000, - // Keep within the existing 150-second browser timeout, including - // upload/serialization headroom. Missing side maps never fail a report. + // Legacy admin retries may still collect inline; customer reports use + // deferred tasks instead. Missing side maps never fail a report. deadline:t0+120000 }); } @@ -452,7 +478,7 @@ async function handleAnalyze(req, res) { console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`); } - const fpJson = await runDetect(rec, front, left, right, persist); + const fpJson = await runDetect(rec, front, left, right, persist, true); if (persist) updateRecord(rec); if (fpJson.error_message) { @@ -484,6 +510,15 @@ async function handleAnalyze(req, res) { side: { left: fpJson.left_side_result || nested.left_side_result || null, right: fpJson.right_side_result || nested.right_side_result || null } }; + // The main report never waits for display-only side wrinkle requests. + if (left || right) { + try { + payload.wrinkle_job=wrinkleJobs.register(owner,recordId,{primary:{left_side_result:payload.side.left,right_side_result:payload.side.right},photos:{left,right}}); + payload.wrinkle_views={version:WrinkleViews.VERSION,left:{status:left?'pending':'unavailable'},right:{status:right?'pending':'unavailable'}}; + rec.wrinkle_view_status={left:{status:left?'pending':'unavailable',error_code:null},right:{status:right?'pending':'unavailable',error_code:null}}; + if(persist)updateRecord(rec); + } catch (_) { /* Optional artwork must never block the main report. */ } + } let reportSaved = false, presentation = null, snapshotVm = null; try { snapshotVm = SkinScore.buildViewModel(result, payload.side, scoreProfile(rec)); @@ -501,6 +536,7 @@ async function handleAnalyze(req, res) { record_id: recordId, report_saved: reportSaved, presentation, vm: snapshotVm, result, wrinkle_views: payload.wrinkle_views, + wrinkle_job: payload.wrinkle_job || null, face_rectangle: fpJson.face_rectangle || null, side: { left: fpJson.left_side_result || nested.left_side_result || null, @@ -876,6 +912,16 @@ async function handleAdmin(req, res, pathname, url) { http.createServer(async (req, res) => { const pathname = req.url.split('?')[0]; if (await accounts.route(req, res, pathname)) return; + if (req.method==='POST' && pathname==='/api/wrinkle-views') { + const owner=accounts.session(req); + if(!owner)return sendJson(res,401,{ok:false,message:'请先登录'}); + if(!accounts.sameOrigin(req))return sendJson(res,403,{ok:false}); + try { + const body=JSON.parse((await readBody(req,4096)).toString('utf8')); + const views=await wrinkleJobs.run(owner,body.token); + return sendJson(res,views?200:404,views?{ok:true,wrinkle_views:views}:{ok:false,message:'标注任务已失效'}); + } catch (_) {return sendJson(res,500,{ok:false,message:'侧脸标注暂不可用'});} + } if (req.method === 'POST' && pathname === '/api/capture-funnel') { if (!accounts.sameOrigin(req) || !accounts.session(req)) return sendJson(res, 403, { ok: false }); try { diff --git a/h5/test/report-persistence.mjs b/h5/test/report-persistence.mjs index 6a3d3f9..8bb93aa 100644 --- a/h5/test/report-persistence.mjs +++ b/h5/test/report-persistence.mjs @@ -61,7 +61,12 @@ try { assert.equal(result.report_saved, true); assert(result.vm && result.presentation); assert.equal(result.wrinkle_views.version,'independent-side-wrinkles-v1'); - assert.equal(result.wrinkle_views.left.status,'ready');assert.equal(result.wrinkle_views.right.status,'ready'); + assert.equal(result.wrinkle_views.left.status,'pending');assert.equal(result.wrinkle_views.right.status,'pending'); + assert(result.wrinkle_job.token); + const layers = await (await api.post(base+'api/wrinkle-views',{data:{token:result.wrinkle_job.token}})).json(); + assert.equal(layers.ok,true);assert.equal(layers.wrinkle_views.left.status,'ready');assert.equal(layers.wrinkle_views.right.status,'ready'); + const cachedLayers = await (await api.post(base+'api/wrinkle-views',{data:{token:result.wrinkle_job.token}})).json(); + assert.deepEqual(cachedLayers,layers); const expectedTotal = Math.round(result.vm.dims.firm.S * 4 + result.vm.dims.vita.S * 3 + result.vm.dims.stab.S * 3); assert.equal(result.vm.total, expectedTotal); assert.notEqual(expectedTotal, result.result.score_info.total_score, 'fixture distinguishes weighted score from API total'); @@ -104,7 +109,8 @@ try { const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json(); assert.equal(snapshot.report.photos.length, 3); assert.deepEqual(snapshot.report.presentation, result.presentation); - assert.deepEqual(snapshot.report.payload.wrinkle_views,result.wrinkle_views); + assert.deepEqual(snapshot.report.payload.wrinkle_views,layers.wrinkle_views); + assert.equal(snapshot.report.payload.wrinkle_job.status,'complete'); assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps); assert.equal(snapshot.report.photos[0], fixture.photos[0]); assert.equal(snapshot.report.photos[1],fixture.photos[0]); // This HTTP test submits the same fixture for all three views. diff --git a/h5/test/wrinkle-async-browser.mjs b/h5/test/wrinkle-async-browser.mjs new file mode 100644 index 0000000..bb874c8 --- /dev/null +++ b/h5/test/wrinkle-async-browser.mjs @@ -0,0 +1,55 @@ +// Real HTTP + report UI. All provider calls are delayed in-memory fixtures. +import assert from 'node:assert/strict'; +import fs from 'node:fs';import os from 'node:os';import path from 'node:path'; +import {spawn,execSync} from 'node:child_process'; +import {createRequire} from 'node:module';import {fileURLToPath,pathToFileURL} from 'node:url'; +const require=createRequire(import.meta.url),h5=fileURLToPath(new URL('../',import.meta.url)); +const dir=fs.mkdtempSync(path.join(os.tmpdir(),'juzi-wrinkle-async-')); +const owner=require('./wechat-fixture.cjs')(path.join(dir,'accounts'))[0]; +const {chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href); +const child=spawn(process.execPath,['-e',` + const fs=require('fs'),vm=require('vm'),path=require('path'),w={};vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});const f=w.MOCK_FIXTURE; + global.fetch=async(url,o)=>{const side=o.body.get('return_maps')==='texture_enhanced_lines'; + fs.appendFileSync(path.join(process.env.JUZI_DATA_ROOT,'provider-calls.jsonl'),JSON.stringify({side})+'\\n'); + if(side)await new Promise(r=>setTimeout(r,1000)); + return{ok:true,status:200,json:async()=>({result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right})};};require('./server.js'); +`],{cwd:h5,env:{...process.env,HOST:'127.0.0.1',PORT:'18142',JUZI_DATA_ROOT:dir,NODE_ENV:'test',SKIN_API_KEY:'fixture',SKIN_API_SECRET:'fixture',PUBLIC_BASE_URL:'',WECHAT_APP_ID:'',WECHAT_APP_SECRET:''},stdio:['ignore','pipe','pipe']}); +let browser; +try { + await new Promise((resolve,reject)=>{const t=setTimeout(()=>reject(Error('startup timeout')),10000);child.stdout.on('data',b=>{if(b.toString().includes('已启动')){clearTimeout(t);resolve();}});child.once('exit',()=>{clearTimeout(t);reject(Error('server exit'));});}); + const base='http://127.0.0.1:18142/';browser=await chromium.launch({headless:true});const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.context().addCookies([{name:'juzi_session',value:owner.token,url:base}]); + const source=fs.readFileSync(path.join(h5,'js/app.js'),'utf8'); + await page.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,` + window.__asyncScreen=showScreen; + window.__startAsyncReport=function(){beginCaptureJourney();['front','right','left'].forEach(trackCapture);photos=Array(3).fill(window.MOCK_FIXTURE.photos[0]);camSeq++;showScreen('s-cam');submitPhotos(camSeq);};})();`)})); + await page.addInitScript(()=>{const f=window.fetch;window.__annotationStarts=[];window.fetch=function(url,options){if(String(url).includes('api/wrinkle-views'))window.__annotationStarts.push({report:!!document.querySelector('#s-report.active'),scan:!!document.querySelector('#p-analyzing.show')});return f.call(this,url,options);};}); + await page.goto(base);await page.waitForFunction(()=>AccountClient.user());await page.addScriptTag({url:base+'assets/mock/report-fixture.js'}); + const primary=page.waitForResponse(r=>r.url().endsWith('/api/skin-analyze'));await page.evaluate(()=>window.__startAsyncReport()); + const result=await(await primary).json();assert.equal(result.ok,true);assert.equal(result.wrinkle_views.left.status,'pending'); + assert.equal(fs.readFileSync(path.join(dir,'provider-calls.jsonl'),'utf8').trim().split('\n').length,1,'main response cannot wait for side paid calls'); + await page.waitForSelector('#s-report.active');const conclusion=await page.locator('#ditem-para2').textContent(); + await page.waitForFunction(()=>document.querySelector('#wrinkle-layer-note').textContent.includes('加载中')); + await page.waitForFunction(()=>window.__annotationStarts.length===1); + await page.evaluate(()=>window.__asyncScreen('s-splash')); + const api=page.context().request; + const cached=await(await api.post(base+'api/wrinkle-views',{data:{token:result.wrinkle_job.token}})).json();assert.equal(cached.ok,true); + await page.waitForTimeout(100); + assert.equal(await page.locator('#s-splash.active').count(),1,'late annotation response cannot navigate'); + assert.equal(await page.locator('#wrinkle-left').getAttribute('data-has-annotation'),'false','hidden report is not patched by stale response'); + await page.evaluate(()=>window.__asyncScreen('s-report')); + await page.waitForFunction(()=>['left','right'].every(v=>document.querySelector('#wrinkle-'+v).dataset.hasAnnotation==='true')); + assert.equal(await page.locator('#ditem-para2').textContent(),conclusion); + assert.deepEqual(await page.evaluate(()=>window.__annotationStarts),[{report:true,scan:false}]); + const stored=await(await api.get(base+'api/reports/'+result.record_id)).json();assert.equal(stored.report.payload.wrinkle_job.status,'complete');assert.deepEqual(stored.report.vm,result.vm);assert.deepEqual(stored.report.presentation,result.presentation); + const other=await browser.newContext();await other.request.post(base+'api/auth/browser',{data:{consent:true}});assert.equal((await other.request.post(base+'api/wrinkle-views',{data:{token:result.wrinkle_job.token}})).status(),404);await other.close(); + assert.equal(fs.readFileSync(path.join(dir,'provider-calls.jsonl'),'utf8').trim().split('\n').length,3,'main + exactly two side calls, cache/access denial never repeat'); + assert.equal((await api.get(base+'data/wrinkle-jobs/'+result.wrinkle_job.token+'.json')).status(),403); + await page.route('**/api/wrinkle-views',r=>r.fulfill({status:503,json:{ok:false}})); + await page.evaluate(()=>window.__startAsyncReport());await page.waitForSelector('#s-report.active'); + await page.waitForFunction(()=>document.querySelector('#wrinkle-layer-note').textContent.includes('部分侧脸未获取')); + assert.equal(await page.locator('#s-report.active').count(),1,'optional annotation failure keeps report accessible'); + assert.equal(await page.locator('#ditem-para2').textContent(),conclusion); + assert.equal(fs.readFileSync(path.join(dir,'provider-calls.jsonl'),'utf8').trim().split('\n').length,4,'failed optional endpoint adds no provider retry'); + assert.deepEqual(errors,[]);console.log('PASS: fast main report, side requests only after active report, pending/ready UI, unchanged conclusions/scores, owner isolation, snapshot cache, exactly 3 fixture calls'); +} finally {if(browser)await browser.close();child.kill();} diff --git a/h5/test/wrinkle-jobs.js b/h5/test/wrinkle-jobs.js new file mode 100644 index 0000000..02d4488 --- /dev/null +++ b/h5/test/wrinkle-jobs.js @@ -0,0 +1,29 @@ +'use strict'; +const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path'); +const create=require('../lib/wrinkle-jobs'); +const dir=fs.mkdtempSync(path.join(os.tmpdir(),'juzi-wrinkle-jobs-')); +const owner={id:'owner',kind:'wechat'},guest={id:'guest',kind:'browser'}; +const ready={version:'independent-side-wrinkles-v1',left:{status:'ready'},right:{status:'ready'}}; +(async()=>{ + let calls=0,finish,completed=0; + const deps={dir,collect:()=>{calls++;return new Promise(r=>{finish=r;});},load:()=>({}),complete:()=>{completed++;}}; + const jobs=create(deps),job=jobs.register(owner,'Rtest',{});assert.equal(calls,0,'registration cannot start paid work'); + assert.equal(await jobs.run({id:'intruder',kind:'wechat'},job.token),null); + const a=jobs.run(owner,job.token),b=jobs.run(owner,job.token);assert.equal(calls,1);finish(ready); + assert.deepEqual(await a,ready);assert.deepEqual(await b,ready);assert.equal(completed,1); + assert.deepEqual(await jobs.run(owner,job.token),ready);assert.equal(calls,1); + const restarted=create({...deps,collect:()=>{throw Error('must use persistent cache');}}); + assert.deepEqual(await restarted.run(owner,job.token),ready); + const before=fs.readdirSync(dir);const g=jobs.register(guest,'Rguest',{}); + const gr=jobs.run(guest,g.token);finish(ready);await gr; + assert.deepEqual(fs.readdirSync(dir),before,'guest jobs/photos/results never persist'); + const pending=jobs.register(owner,'Rpending',{}); + const reload=create({...deps,collect:async()=>ready});assert.deepEqual(await reload.run(owner,pending.token),ready,'pending restart reloads own saved snapshot'); + const running=jobs.register(owner,'Rrunning',{});const old=jobs.run(owner,running.token); + const afterCrash=create({...deps,collect:()=>{throw Error('must not charge twice');}}); + assert.equal((await afterCrash.run(owner,running.token)).left.error_code,'WRINKLE_JOB_INTERRUPTED');finish(ready);await old; + const failed=create({...deps,collect:async()=>{throw Error('provider failed');}});const f=failed.register(owner,'Rfailed',{}); + assert.equal((await failed.run(owner,f.token)).left.error_code,'WRINKLE_JOB_FAILED'); + assert.equal(await failed.run(owner,'../bad'),null); + console.log('PASS: lazy owner-bound single-flight jobs, durable success/failure cache, crash no-duplicate-charge policy, pending recovery, guest RAM-only'); +})().catch(e=>{console.error(e);process.exitCode=1;});