Files
juzi/h5/test/e2e-browser.mjs
T
xslandCursor 531a7ddd14 fix: 拍照页对齐 Figma(正→右→左/镜像预览/WebView 口径定位/85%遮罩) + 语音调度重写 + 正脸照不镜像
- 拍照顺序改为正脸→右脸→左脸(对齐 Figma),左右以用户本人为准;送检前按 target 重排为 [正,左,右]
- 三步预览均镜像(Figma 侧脸轮廓按镜像绘制),后置镜头侧脸轮廓自动翻转;抓帧恒为原始视角
- 蒙版 viewBox 取 Figma 帧 y86–812(扣除微信导航栏)+ meet 缩放,文案/引导/提示按 726 高定位;框外 85% 黑,静态态白底
- 语音:流程音排队不截断、姿态提示挂起补播、倒计时等播报收尾、拍摄完成打断过时引导、出报告/退出清队列;去重仅限姿态提示
- 语音文件改名 step2-rightface / step3-leftface,同步清单、生成脚本与设计文档
- 精华卡去重复正文、推荐依据写明具体原因;年龄仅数字;toast 按字数延时;备注去内部质量码
- 新增无头浏览器冒烟测试 h5/test/e2e-browser.mjs

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 02:22:37 +08:00

184 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 无头 Chromium 冒烟:mock 三连拍走到报告页,并核对语音清单是否真的被拉取、解码、起播。
*
* 依赖:全局 playwright + `npx playwright install chromium`
* 服务:node h5/server.js(默认 http://127.0.0.1:8080)
*
* node h5/test/e2e-browser.mjs
*/
import { execSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const pwRoot = execSync('npm root -g', { encoding: 'utf8' }).trim();
const { chromium } = await import(pathToFileURL(pwRoot + '/playwright/index.mjs').href);
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
const EXPECTED = [
'step1-front', 'step2-rightface', 'step3-leftface',
'hint-noface', 'hint-near', 'hint-far', 'hint-front',
'hint-turn-left', 'hint-turn-right', 'hint-hold',
'countdown-321', 'shot-done', 'analyzing', 'analyze-fail',
];
const fails = [];
function check(name, ok, detail) {
const mark = ok ? 'PASS' : 'FAIL';
console.log(`${mark} ${name}${detail ? ' ' + detail : ''}`);
if (!ok) fails.push(name + (detail ? ': ' + detail : ''));
}
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'],
});
// 语音解锁后整批预加载,请求顺序不代表播放顺序:按 ArrayBuffer → AudioBuffer 反查条目名,记录真实起播与截断
await context.addInitScript(() => {
window.__voice = [];
const abId = new WeakMap(), bufId = new WeakMap();
const origFetch = window.fetch;
window.fetch = function (url, opts) {
const m = String(url).match(/voice\/([a-z0-9-]+)\.mp3/);
return origFetch.call(this, url, opts).then((res) => {
if (!m) return res;
const origAb = res.arrayBuffer.bind(res);
res.arrayBuffer = () => origAb().then((ab) => { abId.set(ab, m[1]); return ab; });
return res;
});
};
const Orig = window.AudioContext || window.webkitAudioContext;
function WrappedAudioContext(...args) {
const ctx = new Orig(...args);
const origDecode = ctx.decodeAudioData.bind(ctx);
ctx.decodeAudioData = function (ab, ok, err) {
const id = abId.get(ab);
return origDecode(ab, (buf) => { bufId.set(buf, id); if (ok) ok(buf); }, err);
};
const origCreate = ctx.createBufferSource.bind(ctx);
ctx.createBufferSource = function () {
const src = origCreate();
const origStart = src.start.bind(src), origStop = src.stop.bind(src);
let rec = null;
src.start = function (...a) {
rec = { id: bufId.get(src.buffer) || '?', t: Date.now(), dur: Math.round(src.buffer.duration * 1000), cutAt: null };
window.__voice.push(rec);
return origStart(...a);
};
src.stop = function (...a) {
if (rec && rec.cutAt == null) rec.cutAt = Date.now() - rec.t;
return origStop(...a);
};
return src;
};
return ctx;
}
WrappedAudioContext.prototype = Orig.prototype;
window.AudioContext = WrappedAudioContext;
window.webkitAudioContext = WrappedAudioContext;
});
const page = await context.newPage();
const pageErrors = [];
page.on('pageerror', (err) => pageErrors.push(String(err)));
try {
// 1) 14 条语音文件与清单都在,且 HTTP 200
const manifest = await (await page.request.get(BASE + '/assets/voice/manifest.json')).json();
const listed = manifest.files || [];
check('清单含设计文档 14 条', EXPECTED.every((id) => listed.includes(id)) && listed.length === EXPECTED.length,
'listed=' + listed.join(','));
for (const id of EXPECTED) {
const res = await page.request.get(BASE + '/assets/voice/' + id + '.mp3');
const len = (await res.body()).length;
const ctype = res.headers()['content-type'] || '';
check('音频 ' + id, res.ok() && len > 1000 && /audio|mpeg|octet/.test(ctype),
`status=${res.status()} bytes=${len} type=${ctype}`);
}
// 2) mock 直达报告页:评分与精华卡有内容
await page.goto(BASE + '/?mock=1&screen=report', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const b = document.querySelector('#stat-score b');
return b && b.textContent && b.textContent !== '--';
}, null, { timeout: 8000 });
const score = await page.locator('#stat-score b').innerText();
const essence = await page.locator('#essence-name').innerText();
const reportOn = await page.locator('#s-report.active').count();
check('直达报告页', reportOn === 1 && /^\d/.test(score) && essence && essence !== '--',
`score=${score} essence=${essence}`);
// 3) 完整 mock 流程:启动 → 档案 → 三连拍 → 报告,并记录语音起播
await page.goto(BASE + '/?mock=1', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: '开始检测' }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续' }).click();
await page.locator('#f-age').fill('28');
await page.locator('#f-gender').selectOption('女');
await page.locator('#rs-current').click();
await page.locator('#rs-list li[data-v="北京"]').click();
await page.getByRole('button', { name: '提交' }).click();
await page.getByRole('button', { name: '开始' }).click();
await page.waitForFunction(() => {
const v = document.querySelector('#cam-video');
return v && v.videoWidth > 0 && document.querySelector('#s-cam').classList.contains('cam-live');
}, null, { timeout: 12000 });
const step1 = (await page.locator('#cam-step').innerText()).replace(/\s+/g, '');
check('进入拍照 1/3', step1.includes('1/3') && step1.includes('正脸'), step1);
// 听完每步引导再按快门(最长 step2 4.3s):引导、「拍摄完成」、「分析中」都应完整播出
// 步骤顺序与轮廓对齐 Figma:正脸 → 右脸(耳在画面右,mask 2)→ 左脸(耳在画面左,mask 1)
const FIGMA_STEPS = [['正脸', '0'], ['右脸', '2'], ['左脸', '1']];
for (let i = 0; i < 3; i++) {
const n = i + 1;
await page.waitForFunction((num) => {
const t = document.querySelector('#cam-step');
return t && t.textContent.includes(num + '/3');
}, n, { timeout: 8000 });
const st = await page.evaluate(() => ({ text: document.querySelector('#cam-step').textContent, mask: document.querySelector('#cam-mask').getAttribute('data-step') }));
check(`步骤 ${n}/3 文案与轮廓对齐 Figma`, st.text.includes(FIGMA_STEPS[i][0]) && st.mask === FIGMA_STEPS[i][1], `${st.text} mask=${st.mask}`);
await page.waitForFunction((id) => (window.__voice || []).some((v) => v.id === id), `step${n}-${['front', 'rightface', 'leftface'][i]}`, { timeout: 8000 });
await page.waitForTimeout(4600);
await page.locator('[data-action="shutter"]').click();
}
await page.waitForFunction(() => document.querySelector('#s-report').classList.contains('active'), null, { timeout: 15000 });
const score2 = await page.locator('#stat-score b').innerText();
const age = await page.locator('#stat-age b').innerText();
check('三连拍进入报告', /^\d/.test(score2), `score=${score2} skinAge=${age}`);
await page.waitForTimeout(3200); // 等「分析中」播完
const played = await page.evaluate(() => window.__voice || []);
const ids = played.map((v) => v.id);
console.log('VOICE 起播: ' + played.map((v) => `${v.id}(${v.dur}ms${v.cutAt != null ? ' 截断@' + v.cutAt : ''})`).join(' → '));
for (const id of ['step1-front', 'step2-rightface', 'step3-leftface', 'shot-done', 'analyzing']) {
check('流程播报 ' + id, ids.includes(id));
}
check('步骤引导顺序', ids.indexOf('step1-front') < ids.indexOf('step2-rightface')
&& ids.indexOf('step2-rightface') < ids.indexOf('step3-leftface'));
check('拍摄完成每步各一次', ids.filter((id) => id === 'shot-done').length === 3,
'count=' + ids.filter((id) => id === 'shot-done').length);
const cut = played.filter((v) => v.cutAt != null && v.cutAt < v.dur - 80);
check('流程语音无截断', cut.length === 0, cut.map((v) => `${v.id}@${v.cutAt}/${v.dur}`).join(','));
check('页面无脚本异常', pageErrors.length === 0, pageErrors.join(' | '));
await page.screenshot({ path: '/tmp/juzi-e2e-report.png', fullPage: true });
console.log('截图 /tmp/juzi-e2e-report.png');
} catch (err) {
console.error('RUN ERROR', err);
fails.push(String(err && err.message || err));
try { await page.screenshot({ path: '/tmp/juzi-e2e-fail.png', fullPage: true }); } catch (e) {}
} finally {
await browser.close();
}
console.log(fails.length ? `\n${fails.length} 项失败` : '\n全部通过');
process.exit(fails.length ? 1 : 0);