256 lines
14 KiB
JavaScript
256 lines
14 KiB
JavaScript
/** UI 反馈验收:真实交互 + 可控检测输入;拦截分析请求,不调用 Face++。 */
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { execSync } from 'node:child_process';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
const modulePath = process.env.PLAYWRIGHT_MODULE || path.join(execSync('npm root -g', { encoding: 'utf8' }).trim(), 'playwright/index.mjs');
|
|
const { chromium } = await import(pathToFileURL(modulePath).href);
|
|
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
|
|
const OUT = process.env.E2E_OUTPUT_DIR || path.join(os.tmpdir(), 'juzi-feedback');
|
|
fs.mkdirSync(OUT, { recursive: true });
|
|
const browser = await chromium.launch({ headless: true, args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
|
|
const context = await browser.newContext({ viewport: { width: 375, height: 812 }, permissions: ['camera'], reducedMotion: 'reduce' });
|
|
let page;
|
|
const errors = [], requests = [];
|
|
const pass = name => console.log('PASS ' + name);
|
|
|
|
// 只替换检测输入和亮度采样;实际 HeadPose、质量判断、相机抓帧、步骤锁与送检仍运行产品代码。
|
|
const detector = `
|
|
window.scene = { pose: 'front', luma: 130, count: 1, ratio: .45, offX: 0, offY: 0, modelFail: false, pattern: false, calls: 0 };
|
|
var origPixels = CanvasRenderingContext2D.prototype.getImageData;
|
|
CanvasRenderingContext2D.prototype.getImageData = function(...args) {
|
|
if (this.canvas.width !== 16 || this.canvas.height !== 16) return origPixels.apply(this,args);
|
|
var a = new Uint8ClampedArray(16*16*4); a.fill(window.scene.luma); return { data: a };
|
|
};
|
|
window.faceapi = {
|
|
nets: { tinyFaceDetector: { loadFromUri: () => scene.modelFail ? Promise.reject(new Error('offline')) : Promise.resolve() }, faceLandmark68Net: { loadFromUri: () => Promise.resolve() } },
|
|
TinyFaceDetectorOptions: function() {},
|
|
detectAllFaces: source => ({ withFaceLandmarks: () => {
|
|
var s = {...scene}; scene.calls++;
|
|
var w = source.videoWidth || source.naturalWidth, h = source.videoHeight || source.naturalHeight;
|
|
var pts = Array.from({length:68},()=>({x:200,y:150}));
|
|
pts[36]={x:150,y:100}; pts[45]={x:250,y:100}; pts[19]={x:170,y:70}; pts[24]={x:230,y:70}; pts[8]={x:200,y:240};
|
|
pts[30]={x: s.pose==='right'?217.7:s.pose==='left'?182.3:200,y:140};
|
|
pts[33]={x:200,y: s.pose==='down'?100:s.pose==='up'?200:161.5};
|
|
var box={x:w*(.5+s.offX)-h*s.ratio/2,y:h*(.5+s.offY-s.ratio/2),width:h*s.ratio,height:h*s.ratio};
|
|
var count=s.pattern && scene.calls%3===0 ? 0 : s.count;
|
|
var result=Array.from({length:count},()=>({detection:{box},landmarks:{positions:pts}}));
|
|
return new Promise(resolve=>setTimeout(()=>resolve(result),s.delay||0));
|
|
} })
|
|
};`;
|
|
await context.route('**/vendor/faceapi/face-api.js', route => route.fulfill({ contentType: 'application/javascript', body: detector }));
|
|
const fixture = JSON.parse(fs.readFileSync('h5/test/fixtures/case1.json', 'utf8'));
|
|
await context.route('**/api/skin-analyze', async route => {
|
|
requests.push(route.request().postDataJSON());
|
|
await route.fulfill({ json: { ok: true, result: fixture.result || fixture, side: {} } });
|
|
});
|
|
|
|
async function chooseDefaults() {
|
|
for (const id of ['birth', 'gender', 'region']) {
|
|
await page.locator('#pick-' + id).click();
|
|
await page.waitForTimeout(450);
|
|
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
|
|
await page.waitForTimeout(450); // 插件内部关闭动画锁
|
|
}
|
|
}
|
|
async function profile() {
|
|
await page.goto(BASE + '/');
|
|
await page.getByRole('button', { name: '开始检测', exact: true }).click();
|
|
await page.locator('#privacy-check').click();
|
|
await page.getByRole('button', { name: '继续', exact: true }).click();
|
|
}
|
|
async function start() {
|
|
await chooseDefaults();
|
|
await page.getByRole('button', { name: '提交', exact: true }).click();
|
|
await page.getByRole('button', { name: '开始', exact: true }).click();
|
|
await page.waitForFunction(() => document.querySelector('#cam-video').videoWidth > 0 && document.querySelector('#s-cam').classList.contains('cam-live'));
|
|
}
|
|
async function scene(values) { await page.evaluate(v => Object.assign(window.scene, v), values); }
|
|
async function hint(text) { await page.waitForFunction(t => document.querySelector('#cam-hint').textContent === t, text); }
|
|
async function step(n) { await page.waitForFunction(n => document.querySelector('#cam-step').textContent.startsWith(n + '/3'), n); }
|
|
async function shot(name) { await page.screenshot({ path: path.join(OUT, name + '.png') }); }
|
|
|
|
try {
|
|
page = await context.newPage();
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
await page.goto(BASE + '/');
|
|
await shot('01-splash');
|
|
await profile();
|
|
assert.equal(await page.locator('.pick-value.placeholder').count(), 3);
|
|
await chooseDefaults();
|
|
const data = await page.evaluate(() => ({ birth: document.querySelector('#f-birth').value, age: +document.querySelector('#f-age').value, region: document.querySelector('#f-region').value }));
|
|
assert.equal(data.age, 30);
|
|
assert.equal(data.region, '北京市');
|
|
assert.match(await page.locator('#profile-age').innerText(), /30岁/);
|
|
assert.equal(await page.evaluate(() => document.body.getBoundingClientRect().top), 0);
|
|
assert.ok(await page.locator('.page-title').evaluate(el => el.getBoundingClientRect().top >= 0));
|
|
await shot('02-profile');
|
|
pass('出生年月自动计算年龄、控件占位和省市默认值');
|
|
|
|
await page.locator('#pick-birth').click();
|
|
await page.waitForTimeout(450);
|
|
for (let i = 0; i < 3; i++) {
|
|
const wheel = await page.locator('.ms-mobile-select.ms-show .ms-wheel').nth(1).boundingBox();
|
|
await page.mouse.move(wheel.x + wheel.width / 2, wheel.y + wheel.height - 22);
|
|
await page.mouse.down();
|
|
await page.mouse.move(wheel.x + wheel.width / 2, wheel.y + 22, { steps: 12 });
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(200);
|
|
}
|
|
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
|
|
await page.waitForTimeout(450);
|
|
const december = await page.evaluate(() => ({ birth: document.querySelector('#f-birth').value, age: +document.querySelector('#f-age').value, month: new Date().getMonth() + 1 }));
|
|
assert.ok(december.birth.endsWith('-12'));
|
|
assert.equal(december.age, december.month < 12 ? 29 : 30);
|
|
assert.ok(await page.locator('#pick-birth .pick-value').evaluate(el => el.scrollWidth <= el.clientWidth));
|
|
await shot('02-profile');
|
|
pass('滚动选择12月、未过生日月份减一岁,完整年月不被截断');
|
|
|
|
// 省份滚轮点击相邻可见项,市级选项必须跟随更新。
|
|
await page.locator('#pick-region').click();
|
|
await page.waitForTimeout(450);
|
|
await page.locator('.ms-mobile-select.ms-show .ms-select-container').first().getByText('河北省', { exact: true }).click();
|
|
await page.waitForTimeout(350);
|
|
assert.ok((await page.locator('.ms-mobile-select.ms-show .ms-select-container').nth(1).innerText()).includes('石家庄市'));
|
|
await shot('03-region');
|
|
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
|
|
await page.waitForTimeout(450);
|
|
assert.match(await page.locator('#pick-region').innerText(), /河北省.*石家庄市/);
|
|
pass('省市联动滚轮实际选择');
|
|
|
|
await page.getByRole('button', { name: '提交', exact: true }).click();
|
|
await page.locator('#p-photoguide .popup').waitFor({ state: 'visible' });
|
|
await page.waitForTimeout(150);
|
|
await shot('04-guide');
|
|
assert.equal(await page.locator('#p-photoguide .profile-body').count(), 0);
|
|
await scene({ luma: 20 });
|
|
await page.getByRole('button', { name: '开始', exact: true }).click();
|
|
await hint('请到光线充足处');
|
|
assert.equal(await page.locator('.cam-shutter').isVisible(), false);
|
|
await scene({ luma: 250 }); await hint('光线过强,请避开强光');
|
|
await scene({ luma: 130, ratio: .2 }); await hint('请靠近一点');
|
|
await scene({ ratio: .8 }); await hint('请远一点');
|
|
await scene({ ratio: .45, offY: .45 }); await hint('请将脸部移入取景框');
|
|
await scene({ offY: 0, count: 2 }); await hint('请保持单人出镜');
|
|
await scene({ count: 1, pose: 'down' }); await hint('请略微抬头');
|
|
await scene({ pose: 'up' }); await hint('请略微低头');
|
|
await scene({ pose: 'front', pattern: true });
|
|
await page.waitForTimeout(2200);
|
|
assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
|
|
assert.equal(requests.length, 0);
|
|
pass('光线、距离、居中、多人、抬低头与丢帧均阻止误拍;正式页面无手动快门');
|
|
|
|
await scene({ pattern: false });
|
|
await step(2);
|
|
assert.equal(await page.locator('.cam-progress .done').count(), 1);
|
|
await scene({ pose: 'left' }); await hint('请向左转头');
|
|
// 持续错误侧脸超过旧版 30 帧自校准阈值,仍不允许交换左右。
|
|
const calls = await page.evaluate(() => scene.calls);
|
|
await page.waitForFunction(n => scene.calls > n + 32, calls);
|
|
assert.match(await page.locator('#cam-step').innerText(), /^2\/3/);
|
|
await shot('05-camera');
|
|
await scene({ pose: 'right' }); await step(3);
|
|
assert.equal(await page.locator('.cam-progress .done').count(), 2);
|
|
await scene({ pose: 'left' });
|
|
await page.locator('#s-report.active').waitFor();
|
|
assert.equal(requests.length, 1);
|
|
assert.ok(requests[0].front_image && requests[0].left_image && requests[0].right_image);
|
|
pass('条件合格自动抓拍,正→右→左严格顺序,三张齐备后只送检一次');
|
|
|
|
await page.goto(BASE + '/?mock=1&screen=report');
|
|
await page.waitForFunction(() => document.querySelector('#ditem-img1').naturalWidth > 0);
|
|
await shot('06-report');
|
|
await page.locator('.card-detail').first().scrollIntoViewIfNeeded();
|
|
await shot('07-report-detail');
|
|
await page.locator('#ditem-img1').click();
|
|
await page.locator('#p-zoom.show').waitFor();
|
|
await shot('08-zoom');
|
|
await page.locator('#zoom-img').click();
|
|
await page.waitForFunction(() => !history.state?.overlay);
|
|
assert.equal(await page.locator('#p-zoom').isVisible(), false);
|
|
await page.locator('#ditem-img1').press('Enter');
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForFunction(() => !history.state?.overlay);
|
|
assert.equal(await page.locator('#p-zoom').isVisible(), false);
|
|
pass('图片点击/键盘放大,再点或 Escape 返回,历史栈正确');
|
|
|
|
for (const width of [320, 375, 430]) {
|
|
await page.setViewportSize({ width, height: 812 });
|
|
assert.ok(await page.evaluate(() => document.querySelector('#s-report').scrollWidth <= document.body.clientWidth));
|
|
assert.ok(await page.locator('.ditem-para').first().evaluate(el => parseFloat(getComputedStyle(el).fontSize) >= 14));
|
|
}
|
|
pass('320/375/430px 报告无横向溢出,正文不小于14px');
|
|
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await profile();
|
|
await scene({ modelFail: true });
|
|
await start();
|
|
await page.locator('.cam-retry:visible').waitFor();
|
|
assert.equal(await page.locator('.cam-shutter').isVisible(), false);
|
|
await shot('09-retry');
|
|
await scene({ modelFail: false, luma: 20 });
|
|
await page.locator('.cam-retry').click();
|
|
await hint('请到光线充足处');
|
|
assert.equal(await page.locator('.cam-retry').isVisible(), false);
|
|
pass('模型失败可重试,恢复后继续当前步且不会降级手动快门');
|
|
|
|
// 相册错误侧脸/光线不合格必须留在同一步;失败后仍可以再次选择。
|
|
await scene({ pose: 'left', luma: 130 });
|
|
const photo = path.resolve('reference/facepp-skinanalyze/examples/images/C3_180.jpg');
|
|
await page.locator('#album-file').setInputFiles(photo);
|
|
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('请按顺序选择正脸'));
|
|
assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
|
|
await scene({ pose: 'front', luma: 20 });
|
|
await page.locator('#album-file').setInputFiles(photo);
|
|
await page.waitForFunction(() => document.querySelector('#toast').textContent.includes('光线充足'));
|
|
assert.match(await page.locator('#cam-step').innerText(), /^1\/3/);
|
|
pass('相册照片同样校验视角和光线,不合格不推进');
|
|
|
|
await page.close();
|
|
page = await context.newPage();
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
await page.addInitScript(() => {
|
|
const original = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
|
let first = true;
|
|
navigator.mediaDevices.getUserMedia = options => {
|
|
if (first) { first = false; return Promise.reject(new DOMException('Denied', 'NotAllowedError')); }
|
|
return original(options);
|
|
};
|
|
});
|
|
await profile();
|
|
await chooseDefaults();
|
|
await scene({ luma: 20 });
|
|
await page.getByRole('button', { name: '提交', exact: true }).click();
|
|
await page.getByRole('button', { name: '开始', exact: true }).click();
|
|
await page.locator('.cam-retry:visible').waitFor();
|
|
assert.equal(await page.locator('.cam-shutter').isVisible(), false);
|
|
await page.locator('.cam-retry').click();
|
|
await hint('请到光线充足处');
|
|
pass('相机拒绝授权后重试恢复,仍停留正脸步骤');
|
|
|
|
const native = await browser.newPage(); // 新 context 使用仓库实际 face-api 和模型
|
|
await native.goto(BASE + '/?mock=1&screen=report');
|
|
await native.locator('#ditem-img1').scrollIntoViewIfNeeded(); // lazy 图片须进入视口才会加载
|
|
const faceCount = await native.evaluate(async () => {
|
|
await Promise.all([faceapi.nets.tinyFaceDetector.loadFromUri('vendor/faceapi/models'), faceapi.nets.faceLandmark68Net.loadFromUri('vendor/faceapi/models')]);
|
|
const img = document.querySelector('#ditem-img1');
|
|
await img.decode();
|
|
const result = await faceapi.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: .3 })).withFaceLandmarks();
|
|
return result.filter(d => d.landmarks.positions.length === 68).length;
|
|
});
|
|
assert.ok(faceCount > 0);
|
|
await native.close();
|
|
pass('实际本地模型可加载并返回人脸与68个特征点');
|
|
assert.deepEqual(errors, []);
|
|
pass('页面无脚本异常');
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (page) await shot('FAIL');
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await browser.close();
|
|
}
|