// 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(); }