165 lines
12 KiB
JavaScript
165 lines
12 KiB
JavaScript
// Real local detector + synthetic video containing existing three-view photos.
|
|
// Auth/analysis are in-memory fixtures: no account/report writes or paid calls.
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { execSync } from 'node:child_process';
|
|
import { pathToFileURL, fileURLToPath } 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 browser = await chromium.launch({ headless: true, args: ['--autoplay-policy=no-user-gesture-required'] });
|
|
try {
|
|
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
|
|
page.setDefaultTimeout(60000);
|
|
const errors = [], failedRequests = [];
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
page.on('requestfailed', request => failedRequests.push({ path: new URL(request.url()).pathname, error: request.failure().errorText }));
|
|
await page.route('**/api/auth/session', route => route.fulfill({ json: { ok: true, user: { id: 'local-camera-fixture', kind: 'browser' } } }));
|
|
for (const name of ['C3_180.jpg', 'C3_135.jpg', 'C3_-135.jpg']) {
|
|
const body = fs.readFileSync(fileURLToPath(new URL('../../reference/facepp-skinanalyze/examples/images/' + name, import.meta.url)));
|
|
await page.route('**/sample-' + name, route => route.fulfill({ contentType: 'image/jpeg', body }));
|
|
}
|
|
await page.addInitScript(() => {
|
|
// Record actual decoded audio starts, not preload requests. Include the
|
|
// visible capture step so an early "turn left" instruction is detectable.
|
|
const ids = new WeakMap(), buffers = new WeakMap(), originalFetch = window.fetch;
|
|
window.__voice = [];
|
|
window.fetch = function(url, options) {
|
|
const match = String(url).match(/voice\/([a-z0-9-]+)\.mp3/);
|
|
return originalFetch.call(this, url, options).then(response => {
|
|
if (match) {
|
|
const original = response.arrayBuffer.bind(response);
|
|
response.arrayBuffer = () => original().then(data => { ids.set(data, match[1]); return data; });
|
|
}
|
|
return response;
|
|
});
|
|
};
|
|
const OriginalAudioContext = window.AudioContext || window.webkitAudioContext;
|
|
function AudioRecorder(...args) {
|
|
const context = new OriginalAudioContext(...args), decode = context.decodeAudioData.bind(context);
|
|
context.decodeAudioData = (data, ok, error) => decode(data, buffer => { buffers.set(buffer, ids.get(data)); if (ok) ok(buffer); }, error);
|
|
const create = context.createBufferSource.bind(context);
|
|
context.createBufferSource = () => {
|
|
const source = create(), start = source.start.bind(source);
|
|
source.start = (...args) => {
|
|
window.__voice.push({ id: buffers.get(source.buffer), at: Date.now(), duration: source.buffer.duration * 1000, step: document.querySelector('#cam-step').textContent });
|
|
return start(...args);
|
|
};
|
|
return source;
|
|
};
|
|
return context;
|
|
}
|
|
AudioRecorder.prototype = OriginalAudioContext.prototype;
|
|
window.AudioContext = window.webkitAudioContext = AudioRecorder;
|
|
const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600;
|
|
let currentImage, offsetX = 0, lightGain = 1;
|
|
function draw() {
|
|
if (!currentImage) return;
|
|
const c = canvas.getContext('2d'); c.fillStyle = '#111'; c.fillRect(0, 0, canvas.width, canvas.height);
|
|
c.filter='brightness('+lightGain+')';
|
|
c.drawImage(currentImage, offsetX, 0, canvas.width, canvas.height);
|
|
c.filter='none';
|
|
}
|
|
setInterval(draw, 100);
|
|
window.__cameraFixture = { canvas, light:gain=>{lightGain=gain;draw();}, set: async name => {
|
|
const image = new Image(); image.src = 'sample-' + name; await image.decode();
|
|
// Simulate a person following the centering prompt, not an off-center
|
|
// landscape source stretched into a portrait camera frame.
|
|
let nextOffset = 0;
|
|
if (window.faceapi && faceapi.nets.faceLandmark68Net.isLoaded) {
|
|
const faces = await faceapi.detectAllFaces(image, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .5 }));
|
|
if (faces.length === 1) nextOffset = canvas.width / 2 - (faces[0].box.x + faces[0].box.width / 2) * canvas.width / image.naturalWidth;
|
|
}
|
|
currentImage = image; offsetX = nextOffset; draw();
|
|
} };
|
|
navigator.mediaDevices.getUserMedia = async () => {
|
|
await window.__cameraFixture.set('C3_135.jpg'); // invalid front: must not prompt a next-step turn
|
|
return canvas.captureStream(10);
|
|
};
|
|
});
|
|
await page.route('**/api/skin-analyze', async route => {
|
|
const fixture = await page.evaluate(() => ({ ok: true, result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE, report_saved: true }));
|
|
await route.fulfill({ json: fixture });
|
|
});
|
|
await page.goto(base); await page.click('[data-action="open-privacy"]');
|
|
assert.equal(await page.title(), "1'me肌肤状态检测");
|
|
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);
|
|
}
|
|
await page.click('[data-action="submit-profile"]'); await page.click('[data-action="start-camera"]');
|
|
try {
|
|
await page.waitForFunction(() => window.faceapi && faceapi.nets.faceLandmark68Net.isLoaded && document.querySelector('#s-cam').classList.contains('cam-live'));
|
|
} catch (error) {
|
|
console.log(await page.evaluate(() => ({ status: document.querySelector('#cam-status').textContent, hint: document.querySelector('#cam-hint').textContent, toast: document.querySelector('#toast').textContent, width: document.querySelector('#cam-video').videoWidth, classes: document.querySelector('#s-cam').className, engine: !!window.faceapi })), errors, failedRequests);
|
|
throw error;
|
|
}
|
|
assert.equal(await page.locator('#s-cam.cam-manual').count(), 0, 'real automatic pipeline, not manual bypass');
|
|
await page.evaluate(() => {
|
|
window.__holdSamples = []; window.__hints = []; window.__evaluations = [];
|
|
const evaluate = CaptureQuality.evaluate;
|
|
CaptureQuality.evaluate = function(d, options) {
|
|
const result = evaluate(d, options);
|
|
window.__evaluations.push({ target: options.target, ok: result.ok, hint: result.hint });
|
|
return result;
|
|
};
|
|
const hold = CaptureQuality.hold;
|
|
CaptureQuality.hold = function(state, obs, now) {
|
|
const result = hold(state, obs, now);
|
|
window.__holdSamples.push({ gap: state ? now - state.lastAt : null, frames: result.state && result.state.frames, ready: result.ready, obs });
|
|
if (window.__holdSamples.length > 8) window.__holdSamples.shift();
|
|
return result;
|
|
};
|
|
new MutationObserver(() => { window.__hints.push(document.querySelector('#cam-hint').textContent); if (window.__hints.length > 20) window.__hints.shift(); }).observe(document.querySelector('#cam-hint'), { childList: true, subtree: true });
|
|
});
|
|
await page.evaluate(() => window.__cameraFixture.set('C3_135.jpg'));
|
|
await page.waitForFunction(() => window.__evaluations.some(row => row.target === 'front' && !row.ok && row.hint === '请正对屏幕'));
|
|
await page.waitForFunction(() => window.__voice.some(row => row.id === 'hint-front'));
|
|
assert((await page.locator('#cam-step').textContent()).includes('1/3'), 'invalid frontal angle never advances');
|
|
assert.equal(await page.evaluate(() => window.__voice.some(row => /^hint-turn-|^step[23]-|^shot-done$/.test(row.id))), false, 'no turn/next-step/completion audio before a successful front photo');
|
|
await page.evaluate(() => { window.__cameraFixture.light(.6); return window.__cameraFixture.set('C3_180.jpg'); });
|
|
await page.waitForFunction(()=>window.__evaluations.some(row=>row.target==='front'&&!row.ok&&row.hint==='请到光线充足处'));
|
|
assert((await page.locator('#cam-step').textContent()).includes('1/3'),'dim sample cannot advance');
|
|
// Simulate improving room lighting, not bypassing the actual quality gate.
|
|
await page.evaluate(()=>window.__cameraFixture.light(1.3));
|
|
try { await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('2/3')); }
|
|
catch (error) {
|
|
console.log(await page.evaluate(async () => {
|
|
const v = document.querySelector('#cam-video');
|
|
const faces = await faceapi.detectAllFaces(v, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .5 })).withFaceLandmarks();
|
|
return { status: document.querySelector('#cam-status').textContent, hint: document.querySelector('#cam-hint').textContent,
|
|
step: document.querySelector('#cam-step').textContent, size: [v.videoWidth, v.videoHeight, v.clientWidth, v.clientHeight],
|
|
ratio: window.__dbgRatio, faces: faces.map(d => ({ score: d.detection.score, pose: HeadPose.estimate(d.landmarks.positions, false), box: d.detection.box })), holds: window.__holdSamples, hints: window.__hints };
|
|
}).then(value => JSON.stringify(value, null, 2)), errors); throw error;
|
|
}
|
|
await page.waitForFunction(() => window.__voice.some(row => row.id === 'step2-rightface'));
|
|
const firstVoice = await page.evaluate(() => window.__voice);
|
|
const completion = firstVoice.find(row => row.id === 'shot-done'), turn = firstVoice.find(row => row.id === 'step2-rightface');
|
|
assert(completion && turn && completion.step.includes('1/3') && turn.step.includes('2/3'));
|
|
assert(turn.at >= completion.at + completion.duration - 100, 'turn instruction waits for captured-front completion speech to finish');
|
|
await page.waitForFunction(() => window.__evaluations.some(row => row.target === 'right' && !row.ok));
|
|
assert((await page.locator('#cam-step').textContent()).includes('2/3'), 'front photo cannot pass the side gate');
|
|
await page.evaluate(() => window.__cameraFixture.set('C3_135.jpg'));
|
|
await page.waitForFunction(() => document.querySelector('#cam-step').textContent.includes('3/3'));
|
|
await page.waitForFunction(() => window.__evaluations.some(row => row.target === 'left' && !row.ok));
|
|
assert((await page.locator('#cam-step').textContent()).includes('3/3'), 'wrong-direction side cannot pass');
|
|
const sent = page.waitForRequest(r => r.url().endsWith('/api/skin-analyze'));
|
|
await page.evaluate(() => window.__cameraFixture.set('C3_-135.jpg'));
|
|
const body = (await sent).postDataJSON();
|
|
assert.equal(await page.locator('#scan-photo').count(), 0, 'live waiting does not display the captured face');
|
|
assert.equal(await page.locator('#scan-artwork').count(), 1, 'waiting retains original PSD head');
|
|
await page.waitForSelector('#s-report.active');
|
|
assert.equal(await page.locator('#p-analyzing.show').count(), 0, 'waiting is cleared after success');
|
|
for (const key of ['front', 'left', 'right']) {
|
|
const row = body.capture_diagnostics.views[key];
|
|
assert.equal(row.mode, 'live'); assert.equal(row.direction_checked, true);
|
|
assert(row.score >= (key==='front'?.3:.4) && row.luma >= 110 && row.luma <= 220);
|
|
assert(Math.abs(row.roll) <= 22);
|
|
}
|
|
assert(Math.abs(body.capture_diagnostics.views.front.yaw) <= 18);
|
|
assert(body.capture_diagnostics.views.left.yaw <= -24 && body.capture_diagnostics.views.left.yaw >= -84);
|
|
assert(body.capture_diagnostics.views.right.yaw >= 24 && body.capture_diagnostics.views.right.yaw <= 84);
|
|
assert(await page.evaluate(() => document.querySelector('#cam-video').srcObject === null), 'camera released after success');
|
|
assert.deepEqual(errors, []);
|
|
console.log('PASS: actual local models + automatic three-view capture + final JPEG revalidation; invalid front stays on step 1 with neutral audio, no early turn; actual decoded shot-done finishes before side guidance; wrong-direction frames blocked; approved samples/camera cleanup; no paid call/backend writes');
|
|
} finally { await browser.close(); }
|