perf(report): load side wrinkle layers after report entry
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# 侧脸皱纹浮层异步加载
|
||||
|
||||
主测肤请求只等待主接口、评分及报告保存,不再同步等待左右侧脸的皱纹浮层。正面浮层随主接口返回。前端在报告切屏并经过两帧绘制后,才调用 `POST api/wrinkle-views`,获取该报告所属任务的左右侧脸标注。
|
||||
|
||||
加载期间保留两张侧脸原图并显示「侧脸皱纹标注加载中…」。完成后只更新侧脸图片,不重算分数、不改结论或护理建议。失败/超时仅退回原图提示,报告继续使用。扫描等待页 30 秒兜底只针对主报告,不受浮层任务影响;可选浮层请求前端预算 65 秒,服务端两次顺序请求仍各最多 20 秒、间隔 4 秒。
|
||||
|
||||
使用随机任务令牌并绑定服务器会话所属账号;不同账号无法获取他人的标注。重复/并发请求共用同一任务,成功或失败结果都缓存,不自动重新收费调用。微信任务元数据及完成结果持久化,补写原报告 payload、后台标注状态和原始结果,不修改 vm/presentation。服务重启后可继续尚未启动的任务;已标记运行但被重启打断的任务不自动重跑,避免重复调用。老报告无任务令牌时不自动补检。
|
||||
|
||||
游客任务、照片及结果仅存内存,五分钟后清除,不写游客身份或报告到磁盘。任务从未被报告页请求时,不调用额外侧脸检测。微信任务可从已有报告快照恢复照片,无需用户重新上传。离开报告或切换账号后,迟到结果不修改当前页面;返回同一报告可显示已加载数据。
|
||||
|
||||
后台 `cost_ms` 现在代表主报告检测链路,额外浮层耗时记录为 `wrinkle_cost_ms`。正常案例从进入报告的等待中移走固定的两次 4 秒间隔及两次侧脸请求耗时,但不承诺固定秒数,不减少已实际执行的接口调用数量。
|
||||
|
||||
验证使用隔离数据目录和模拟上游,不产生付费检测:主响应只有一次上游调用、报告显示后才请求侧脸、总共三次模拟调用、重复并发与重启缓存、不同账号隔离、游客无磁盘写入、离开/返回和失败降级,以及评分和报告结论不变。
|
||||
@@ -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.
|
||||
|
||||
+12
-1
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
+51
-5
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();}
|
||||
@@ -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;});
|
||||
Reference in New Issue
Block a user