fix(h5): sync latest report copy and deployment verification

This commit is contained in:
xsl
2026-10-11 22:59:21 +08:00
parent 3dda4ec7ff
commit 7518dc2307
14 changed files with 1981 additions and 863 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
"优秀": "肌肤质感表现优异,表面细腻平滑,角质层排列整齐,无明显粗大毛孔与黑头。"
},
"water": {
"改善": "缺水面积 {water_area}%、肌肤缺水比较严重 {water_sev},肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。",
"改善": "水分分 {water_score};缺水面积 {water_area}%、肌肤缺水比较严重 {water_sev},肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。",
"良好": "水分分 {water_score};缺水面积 {water_area}%、肌肤含水量处于健康水平,整体水润度良好。",
"优秀": "水分分 {water_score};角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。"
},
+54
View File
@@ -0,0 +1,54 @@
// Exercise the actual browser request function with a deterministic fake clock.
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
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,
setTimeout(fn, delay) { timer = { fn, delay }; return 1; },
clearTimeout() { timer = null; },
fetch(url, options) {
assert.equal(url, 'api/skin-analyze');
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); });
});
}
};
vm.createContext(ctx);
vm.runInContext(source.slice(start, end), ctx);
ctx.requestAnalyze(['front', 'left', 'right'], 1, {}, (...args) => { result = args; });
return { ctx, get timer() { return timer; }, get signal() { return signal; },
get result() { return result; }, 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');
delayed.finish();
await flush();
assert.equal(delayed.result[0], null);
assert.equal(delayed.result[1].report_saved, true);
assert.equal(delayed.timer, null);
assert.equal(delayed.signal.aborted, false);
const expired = setup();
expired.timer.fn();
await flush();
assert(expired.signal.aborted);
assert.match(expired.result[0], /检测服务开小差/);
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');
})().catch(e => { console.error(e); process.exitCode = 1; });
+79
View File
@@ -0,0 +1,79 @@
// Public-site error recovery and real model loading. Analysis requests are intercepted;
// no paid upstream call or report is created. A separate empty browser identity is used.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
const samplePath = fileURLToPath(new URL('../../reference/facepp-skinanalyze/examples/images/C3_180.jpg', import.meta.url));
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 375, height: 812 } });
page.setDefaultTimeout(60000);
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.addInitScript(() => {
navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException('test denial', 'NotAllowedError'));
const original = window.setTimeout;
window.setTimeout = function (fn, delay, ...args) {
// Accelerate only the actual analyze deadline for the timeout branch.
return original(fn, window.__fastAnalyzeTimeout && delay === 150000 ? 150 : delay, ...args);
};
});
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
// Real models from production paths, actual landmark inference on the sample image.
const detected = await page.evaluate(async data => {
await Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri('vendor/faceapi/models'),
faceapi.nets.faceLandmark68Net.loadFromUri('vendor/faceapi/models')
]);
const img = await faceapi.fetchImage(data);
const d = await faceapi.detectSingleFace(img, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.3 })).withFaceLandmarks();
return d ? { score: d.detection.score, landmarks: d.landmarks.positions.length } : null;
}, 'data:image/jpeg;base64,' + fs.readFileSync(samplePath).toString('base64'));
assert(detected && detected.landmarks === 68 && detected.score > 0.3);
console.log('PASS: real production models load and detect sample face with 68 landmarks');
// Keep subsequent error-flow uploads independent of angle validation.
await page.evaluate(() => { window.faceapi = undefined; });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="open-privacy"]');
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.click(selector);
await page.click('.ms-mobile-select.ms-show .ms-ensure');
await page.waitForTimeout(400);
}
const cases = [
['business', '未检测到人脸', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: false, message: '未检测到人脸' }) })],
['network', '网络异常', route => route.abort('failed')],
['timeout', '检测服务开小差', async route => { await new Promise(resolve => setTimeout(resolve, 500)); await route.abort().catch(() => {}); }]
];
for (const [name, message, handler] of cases) {
await page.route('**/api/skin-analyze', handler);
await page.evaluate(fast => { window.__fastAnalyzeTimeout = fast; }, name === 'timeout');
await page.click('[data-action="submit-profile"]');
await page.click('[data-action="start-camera"]');
for (let i = 1; i <= 3; i++) {
await page.waitForFunction(n => document.querySelector('#cam-step').textContent.includes(n + '/3'), i);
await page.waitForTimeout(1600);
await page.setInputFiles('#cam-file', samplePath);
}
await page.waitForSelector('#s-profile.active');
assert((await page.locator('#toast').textContent()).includes(message), name);
assert.equal(await page.locator('#p-analyzing.show').count(), 0);
assert(await page.locator('#f-birth').inputValue());
await page.unroute('**/api/skin-analyze', handler);
console.log('PASS: ' + name + ' error → dismiss loading → retain profile → allow retry');
}
const reports = await (await page.context().request.get(base + 'api/reports')).json();
assert.equal(reports.total, 0);
assert.deepEqual(errors, []);
console.log('PASS: no spurious reports and no JS errors');
} finally {
await browser.close();
}
+144
View File
@@ -0,0 +1,144 @@
// Explicit opt-in: one real Face++ call with the repository's three-view sample.
// Creates a separate browser identity/report; never alters existing user data.
// RUN_LIVE_ANALYZE=1 H5_BASE_URL=https://host/state/ node h5/test/production-loop.mjs
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
assert.equal(process.env.RUN_LIVE_ANALYZE, '1', 'Requires explicit RUN_LIVE_ANALYZE=1 (paid upstream call)');
const base = process.env.H5_BASE_URL;
assert(base && base.endsWith('/'), 'Set H5_BASE_URL with trailing slash');
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const samples = fileURLToPath(new URL('../../reference/facepp-skinanalyze/examples/images/', import.meta.url));
const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-production-loop-'));
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await context.newPage();
page.setDefaultTimeout(60000);
const errors = [];
page.on('pageerror', e => errors.push(e.message));
// Exercise the denied-camera fallback; hardware camera behavior needs a phone.
await context.addInitScript(() => {
navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException('test denial', 'NotAllowedError'));
window.__analyzeTimeouts = [];
const original = window.setTimeout;
window.setTimeout = function (fn, delay, ...args) {
if (delay >= 45000) window.__analyzeTimeouts.push(delay);
return original(fn, delay, ...args);
};
});
// Test model-unavailable fallback as well, without relying on machine vision.
await page.route('**/models/**', route => route.abort());
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
const api = context.request;
assert.equal((await api.get(base + 'api/reports')).status(), 200);
const cookie = (await context.cookies()).find(c => c.httpOnly);
assert(cookie && cookie.secure && cookie.sameSite === 'Lax' && cookie.path === new URL(base).pathname);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {} })).status(), 400);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {}, headers: { Origin: 'https://invalid.example' } })).status(), 403);
console.log('PASS: public HTTPS, secure scoped cookie, missing-image validation, cross-origin denial');
await page.click('[data-action="open-privacy"]');
await page.click('[data-action="agree-continue"]');
assert.equal(await page.locator('#s-profile.active').count(), 0);
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
await page.click('[data-action="submit-profile"]');
assert.equal(await page.locator('#s-profile.active').count(), 1);
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.click(selector);
await page.click('.ms-mobile-select.ms-show .ms-ensure');
await page.waitForTimeout(400);
}
await page.click('[data-action="submit-profile"]');
await page.click('[data-action="start-camera"]');
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('系统相机'));
// Bad file must keep us on the same step and allow selecting a valid photo.
await page.setInputFiles('#cam-file', { name: 'broken.jpg', mimeType: 'image/jpeg', buffer: Buffer.from('not an image') });
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('读取失败'));
assert((await page.locator('#cam-step').textContent()).includes('1/3'));
console.log('PASS: consent/profile guards, denied camera + missing model fallback, invalid-image recovery');
let analyzeCalls = 0;
page.on('request', req => { if (req.url() === base + 'api/skin-analyze') analyzeCalls++; });
const responsePromise = page.waitForResponse(res => res.url() === base + 'api/skin-analyze', { timeout: 165000 });
// Capture sequence front → right → left; app maps to API front/left/right fields.
for (const [i, name] of ['C3_180.jpg', 'C3_-135.jpg', 'C3_135.jpg'].entries()) {
await page.waitForFunction(n => document.querySelector('#cam-step').textContent.includes(n + '/3'), i + 1);
await page.waitForTimeout(1600);
await page.setInputFiles('#cam-file', path.join(samples, name));
}
const response = await responsePromise;
assert.equal(response.status(), 200);
const result = await response.json();
assert.equal(result.ok, true, JSON.stringify({ code: result.code, message: result.message }));
assert.equal(result.report_saved, true);
assert.equal(analyzeCalls, 1);
assert((await page.evaluate(() => window.__analyzeTimeouts)).includes(150000));
await page.waitForSelector('#s-report.active');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth > 0));
const snap = (await (await api.get(base + 'api/reports/' + result.record_id)).json()).report;
assert.equal(snap.photos.filter(Boolean).length, 3);
assert.deepEqual(snap.presentation, result.presentation);
assert.deepEqual(snap.payload.result.face_maps, result.result.face_maps);
const images = await page.locator('.ditem-img').evaluateAll(nodes => nodes.map(i => i.src));
const copy = await page.locator('.ditem-para').allTextContents();
for (const card of await page.locator('.card-detail').all()) {
for (const tab of await card.locator('.dtab').all()) {
await tab.click();
assert.equal(await card.locator('.dpanel.active').count(), 1);
}
}
await page.locator('.ditem-img:visible').first().click();
await page.waitForSelector('#p-zoom.show');
await page.goBack();
await page.waitForSelector('#p-zoom.show', { state: 'hidden' });
assert.equal(await page.locator('#s-report.active').count(), 1);
await page.screenshot({ path: path.join(artifacts, 'real-report.png'), fullPage: true });
console.log('PASS: three sample views → REAL Face++ → scoring → seven images/tabs → zoom/back → backend snapshot');
// Cookie loss is restored using this browser's stored random identity, not a new account.
await context.clearCookies();
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]');
await page.click('[data-report-id="' + result.record_id + '"]');
await page.waitForSelector('#s-report.active');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth > 0));
assert.deepEqual(await page.locator('.ditem-img').evaluateAll(nodes => nodes.map(i => i.src)), images);
assert.deepEqual(await page.locator('.ditem-para').allTextContents(), copy);
await page.goBack();
await page.waitForSelector('#s-history.active');
// Read failure + explicit retry must recover without losing the report.
await page.route('**/api/reports', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ ok: false, message: '测试临时不可用' }) }));
await page.click('[data-action="history-retry"]');
await page.waitForFunction(() => document.querySelector('#history-list').textContent.includes('测试临时不可用'));
await page.unroute('**/api/reports');
await page.click('[data-action="history-retry"]');
await page.waitForSelector('[data-report-id="' + result.record_id + '"]');
await page.screenshot({ path: path.join(artifacts, 'real-history.png') });
await page.goBack();
await page.waitForSelector('#s-splash.active');
console.log('PASS: cookie restoration, report reload with identical seven images/copy, history error/retry, browser back');
const other = await browser.newContext();
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 401);
await other.request.post(base + 'api/auth/browser', { data: { consent: true } });
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 404);
for (const route of ['api/admin/stats', 'admin.html', 'data/accounts/identities.json', '%2fserver.js', 'lib/accounts.js', 'test/accounts.js']) {
assert([401,403,404].includes((await other.request.get(base + route)).status()), route);
}
await other.close();
assert.deepEqual(errors, []);
fs.writeFileSync(path.join(artifacts, 'summary.json'), JSON.stringify({ record_id: result.record_id, analyzeCalls, errors, tested_at: new Date().toISOString() }, null, 2));
console.log('PASS: cross-account isolation, admin/source/data protection, zero JS errors');
console.log('Test report (dedicated identity): ' + result.record_id + '; artifacts: ' + artifacts);
} finally {
await browser.close();
}
+3 -1
View File
@@ -3,7 +3,9 @@ import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
import fs from 'node:fs';
import assert from 'node:assert/strict';
const root = new URL('../../design/ui-20261009/verification/', import.meta.url);
const root = process.env.H5_ARTIFACT_DIR
? pathToFileURL(process.env.H5_ARTIFACT_DIR.replace(/\/$/, '') + '/')
: new URL('../../design/ui-20261009/verification/', import.meta.url);
fs.mkdirSync(root, { recursive: true });
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });