// Browser-local session/preview fixtures. No account/report writes or paid calls.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
const artifacts = process.env.H5_ARTIFACT_DIR;
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
page.setDefaultTimeout(60000); // Match the production-facing third-round suite.
const errors = [], requests = [];
page.on('pageerror', e => errors.push(e.message));
page.on('request', r => requests.push(new URL(r.url()).pathname));
await page.route('**/api/auth/session', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, user: { id: 'second-batch-fixture', kind: 'browser' } }) }));
await page.goto(base);
await page.waitForSelector('#s-splash.active');
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /\.woff2$|faceapi/.test(p)), 'entry must not download complete fonts or face engine');
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 726 });
const home = await page.evaluate(() => {
const lockup = document.querySelector('#s-splash .brand-lockup');
const logo = lockup.getBoundingClientRect();
const row = document.querySelector('#s-splash .brand-row').getBoundingClientRect();
const sloganImage = document.querySelector('#s-splash .splash-slogan');
const slogan = sloganImage.getBoundingClientRect();
const badge = document.querySelector('.account-status-home').getBoundingClientRect();
return { logoHeight: logo.height, source: lockup.getAttribute('src'), alt: lockup.alt,
logoRatio: logo.width / logo.height, rowX: row.x, rowRight: row.right,
badgeBottom: badge.bottom, sloganTop: slogan.top,
sloganRatio: slogan.width / slogan.height, sloganAlt: sloganImage.alt,
overflow: document.documentElement.scrollWidth > innerWidth };
});
// Third-round PSD replaces platform-dependent glyphs with the exact lockup.
assert(home.source.endsWith('ui-20261012/brand-lockup.png'));
assert.equal(home.alt, "1'me 肌肤状态检测");
assert(Math.abs(home.logoHeight - 59 / 2.88) < .02);
assert(Math.abs(home.logoRatio - 517 / 59) < .01, 'lockup must retain PSD aspect ratio');
assert(Math.abs(home.sloganRatio - 718 / 217) < .01, 'three-line PSD slogan must retain its aspect ratio');
assert(home.sloganAlt.includes('以我为始 自有表现'));
assert(home.rowX >= 0 && home.rowRight <= width && !home.overflow, 'brand row overflows at ' + width);
assert(home.badgeBottom <= home.sloganTop, 'status badge overlaps slogan at ' + width);
}
await page.setViewportSize({ width: 375, height: 726 });
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await page.screenshot({ path: path.join(artifacts, '00-home-clarified.png'), animations: 'disabled' });
}
await page.waitForLoadState('networkidle');
assert(!requests.some(p => /\.woff2$|faceapi/.test(p)), 'home typography must not add full fonts or face engine');
await page.click('[data-action="open-privacy"]');
await page.waitForSelector('#p-privacy.show');
await page.waitForSelector('link[href="css/fonts.css"]', { state: 'attached' });
// A link being attached does not mean its font faces are registered yet,
// especially through the public reverse proxy rather than localhost.
await page.waitForFunction(() => [...document.fonts].some(font => /MiSans Demibold/.test(font.family)));
await page.evaluate(async () => { await document.fonts.load('700 23px "MiSans Demibold"', "欢迎体验肌肤状态评估服务"); });
await page.waitForLoadState('networkidle');
assert(requests.some(p => /MiSans-Demibold\.woff2$/.test(p)), 'consent must load PSD Demibold before profile');
assert.equal(await page.locator('#p-privacy .popup-title').evaluate(e => getComputedStyle(e).fontWeight), '700');
const cdp = await page.context().newCDPSession(page);
await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
async function actualFont(selector, expected) {
const { root } = await cdp.send('DOM.getDocument');
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector });
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId });
assert(fonts.some(f => f.isCustomFont && new RegExp(expected, 'i').test(f.postScriptName || f.familyName)), selector + ': ' + JSON.stringify(fonts));
}
await actualFont('#p-privacy .popup-title', 'Demibold');
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await page.screenshot({ path: path.join(artifacts, '01-consent-demibold.png'), animations: 'disabled' });
}
await page.click('[data-action="close-privacy"]');
// Synthetic local stream only, to inspect full-bleed framing without camera permission.
await page.evaluate(async () => {
document.querySelectorAll('.screen').forEach(s => s.classList.toggle('active', s.id === 's-cam'));
document.querySelector('#s-cam').classList.add('cam-live');
const canvas = document.createElement('canvas'); canvas.width = 375; canvas.height = 726;
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#587581'; ctx.fillRect(0, 0, canvas.width, canvas.height);
const video = document.querySelector('#cam-video'); video.srcObject = canvas.captureStream(5); await video.play();
});
await page.waitForTimeout(400); // Let the existing screen-entry translate animation finish.
await page.evaluate(async () => { await document.fonts.load('700 23px "MiSans Demibold"', '请正脸出镜'); });
await actualFont('#cam-step', 'Demibold');
for (const [step, label] of [['0', '1/3
请正脸出镜'], ['2', '2/3
请右脸出镜'], ['1', '3/3
请左脸出镜']]) {
await page.evaluate(({ step, label }) => { document.querySelector('#cam-mask').dataset.step = step; document.querySelector('#cam-step').innerHTML = label; }, { step, label });
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 726 });
const box = await page.locator('#cam-video').boundingBox();
assert(Math.abs(box.width - width) < 1 && Math.abs(box.height - 726) < 1, 'preview must fill viewport');
assert(Math.abs(box.x) < 1 && Math.abs(box.y) < 1);
assert.equal(await page.locator('#cam-video').evaluate(e => getComputedStyle(e).borderWidth), '0px');
for (const state of ['', 'no-face', 'ok']) {
await page.locator('#cam-mask').evaluate((e, state) => { e.classList.remove('no-face', 'ok'); if (state) e.classList.add(state); }, state);
assert.equal(await page.locator('.cam-line').evaluate(e => getComputedStyle(e).display), 'none');
}
assert(await page.locator('#cam-cut path[data-shape="' + step + '"]').evaluate(e => e.getAttribute('d').includes('Q')));
assert.equal(await page.locator('#cam-step').evaluate(e => getComputedStyle(e).fontSize), '21.5px');
assert.equal(await page.locator('.cam-feature-lines[data-shape="' + step + '"]').evaluate(e => getComputedStyle(e).display), 'block');
}
if (artifacts) {
await page.setViewportSize({ width: 375, height: 726 });
await page.screenshot({ path: path.join(artifacts, '02-camera-' + step + '.png'), animations: 'disabled' });
}
}
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#stat-score b').textContent !== '--');
await page.waitForFunction(() => [...document.fonts].some(font => /MiSans Demibold/.test(font.family)));
await page.evaluate(async () => { await document.fonts.load('700 40px "MiSans Demibold"', '0123456789'); await document.fonts.load('600 23px MiSans', '护理建议您的肌肤状态表现紧塑力'); });
await page.waitForLoadState('networkidle');
for (const selector of ['#stat-score b', '#stat-age b', '.dim-score b']) await actualFont(selector, 'Demibold');
for (const selector of ['#s-report .report-title', '.dimension-title', '.card-heading']) await actualFont(selector, 'Semibold');
for (const width of [320, 375, 390, 414, 480]) {
await page.setViewportSize({ width, height: 844 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
assert.equal(await page.locator('.card-heading').first().evaluate(e => getComputedStyle(e).fontSize), '23px');
}
if (artifacts) {
await page.setViewportSize({ width: 375, height: 726 });
await page.locator('#s-report').evaluate(e => { e.scrollTop = 0; });
await page.screenshot({ path: path.join(artifacts, '03-report-demibold-numbers.png'), animations: 'disabled' });
await page.locator('.card-care').scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(artifacts, '04-care-semibold.png'), animations: 'disabled' });
}
assert.deepEqual(errors, []);
console.log('PASS: heavier Chinese slogan, matched logo/text optical height, three lines/no overlap in five widths, no fonts/engine at entry; early consent Demibold; actual PSD font glyphs for headings/numbers; full-bleed camera, no outline in any state, three rounded contours/retained guides; fixed fonts/no overflow in five widths; no JS errors');
} finally { await browser.close(); }