- 拍照顺序改为正脸→右脸→左脸(对齐 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>
184 lines
8.6 KiB
JavaScript
184 lines
8.6 KiB
JavaScript
/**
|
||
* 无头 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);
|