/** * 无头 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);