Files
juzi/h5/test/e2e-browser.mjs

194 lines
9.4 KiB
JavaScript

/**
* 无头 Chromium 冒烟:mock 三连拍走到报告页,并核对语音清单是否真的被拉取、解码、起播。
*
* 依赖:全局 playwright + `npx playwright install chromium`
* 服务:node h5/server.js(默认 http://127.0.0.1:8080,可用 BASE_URL 覆盖)
*
* 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('#product-title').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.locator('#login-consent').check();
await page.locator('[data-action="guest-experience"]').click();
await page.waitForSelector('#s-splash.active');
await page.getByRole('button', { name: '开始检测' }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续' }).click();
// 三个滚轮选择器:打开后直接点「确定」取默认项(出生年月默认 30 年前 1 月、女、北京市)
for (const sel of ['#pick-birth', '#pick-gender', '#pick-region']) {
await page.locator(sel).click();
await page.locator('.ms-mobile-select.ms-show .ms-ensure').click();
await page.waitForTimeout(400);
}
const prof = await page.evaluate(() => ({
birth: document.querySelector('#f-birth').value, age: document.querySelector('#f-age').value,
gender: document.querySelector('#f-gender').value, region: document.querySelector('#f-region').value,
}));
check('档案滚轮选择', /^\d{4}-\d{2}$/.test(prof.birth) && +prof.age > 0 && prof.gender && prof.region, JSON.stringify(prof));
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);
// mock 无人脸检测,快门作为调试入口保留;听完每步引导再按(最长 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);