/** 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(); }