194 lines
9.4 KiB
JavaScript
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);
|