// Local UI + in-memory API fixtures only. No account/report writes or paid calls. import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { execSync } from 'node:child_process'; import { fileURLToPath, 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 root = fileURLToPath(new URL('../../', import.meta.url)); const artifacts = process.env.H5_ARTIFACT_DIR; const widths = [320, 375, 390, 414, 480]; const consent = '我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。'; const guideLines = ['为使测试结果更准确,请关闭美颜/滤镜、并确', '保光线充足均匀。']; const manifest = JSON.parse(fs.readFileSync(path.join(root, 'design/ui-20261012/manifest.json'))); for (const asset of manifest.assets) { const bytes = fs.readFileSync(path.join(root, asset.file)); assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), asset.sha256, asset.file); } const styleOf = key => manifest.text_styles.find(row => row.path === key); assert.equal(styleOf('0/4').runs[0].font, 'MiSans-Demibold'); assert.equal(styleOf('0/5').runs[0].font, 'MiSans-Regular'); assert.equal(styleOf('8/1/0/5').runs[0].font, 'MiSans-Semibold'); assert.equal(styleOf('1/3/1').text, consent); const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true }); page.setDefaultTimeout(60000); const errors = [], requests = [], writes = []; page.on('pageerror', error => errors.push(error.message)); page.on('request', request => { requests.push(new URL(request.url()).pathname); if (request.method() === 'POST') writes.push(request.url()); }); await page.route('**/api/auth/session', route => route.fulfill({ json: { ok: true, user: { id: 'third-batch-fixture', kind: 'wechat' } } })); await page.route('**/vendor/faceapi/face-api.js', route => route.abort()); await page.addInitScript(() => { navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException('fixture denial', 'NotAllowedError')); }); async function screenshot(name, selector) { if (!artifacts) return; fs.mkdirSync(artifacts, { recursive: true }); await (selector ? page.locator(selector) : page).screenshot({ path: path.join(artifacts, name + '.png'), animations: 'disabled' }); } async function loadFonts() { await page.waitForFunction(() => [...document.fonts].some(font => /MiSans Demibold/.test(font.family))); await page.evaluate(async () => { await Promise.all([document.fonts.load('400 12px MiSans'), document.fonts.load('600 14px MiSans'), document.fonts.load('700 23px "MiSans Demibold"')]); }); } async function rect(selector) { return page.locator(selector).evaluate(e => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }); } async function actualLines(selector) { return page.locator(selector).evaluate(el => { const lines = [], walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) for (let i = 0; i < node.length; i++) { const range = document.createRange(); range.setStart(node, i); range.setEnd(node, i + 1); const r = range.getBoundingClientRect(); if (!r.width || !r.height) continue; let row = lines.find(row => Math.abs(row.y - r.y) < 1); if (!row) { row = { y: r.y, text: '' }; lines.push(row); } row.text += node.textContent[i]; } return lines.map(row => row.text); }); } try { await page.goto(base); await page.waitForSelector('#s-splash.active'); await page.waitForLoadState('networkidle'); assert(!requests.some(p => /\.woff2$|faceapi|scan-(waiting|base|face)|product-\d+\.(webp|jpg)/.test(p)), 'heavy/scanning assets must not compete with entry'); await page.waitForFunction(() => document.querySelector('#s-splash .splash-slogan').naturalWidth === 718 && document.querySelector('.brand-lockup').naturalWidth === 517); await screenshot('01-home'); await page.click('[data-action="open-privacy"]'); await loadFonts(); await page.waitForTimeout(350); assert.equal(await page.locator('#p-privacy .popup-body').textContent(), consent); const bodyLines = await actualLines('#p-privacy .popup-body'); assert.deepEqual(bodyLines, ['我们将采集您的面部影像,为您生成专属肌肤分', '析与状态管理方案。所有数据严格保密,仅用于', '本次检测分析。']); for (const width of widths) { await page.setViewportSize({ width, height: 726 }); const popup = await rect('#p-privacy .popup'); assert(popup.x >= 0 && popup.right <= width); assert.deepEqual(await actualLines('#p-privacy .popup-body'), bodyLines, 'consent wrapping at ' + width); } const dialogRadius = await page.locator('#p-privacy .popup').evaluate(e => parseFloat(getComputedStyle(e).borderRadius)); assert(Math.abs(dialogRadius - manifest.shape_metrics.find(row => row.path === '1/1').corner_radius_psd_px / 2.88) < .001); await page.setViewportSize({ width: 375, height: 726 }); await screenshot('02-consent'); await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]'); await page.waitForSelector('#s-profile.active'); assert(requests.some(p => p.endsWith('scan-base.webp')), 'preload waiting image only after profile entry'); for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) { await page.click(selector); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(400); } await page.click('[data-action="submit-profile"]'); await page.waitForTimeout(350); for (const width of widths) { await page.setViewportSize({ width, height: 726 }); assert.deepEqual(await actualLines('.guide-description'), guideLines, 'exact PSD guide lines at ' + width); const popup = await rect('#p-photoguide .popup'), description = await rect('.guide-description'); assert(popup.x >= 0 && popup.right <= width && description.right <= popup.right); assert.equal(await page.locator('.guide-description').evaluate(e => e.scrollWidth <= e.clientWidth), true); await screenshot('03-guide-' + width); } // The real capture/upload progression with held, intercepted analysis response. await page.setViewportSize({ width: 375, height: 726 }); let pending; await page.route('**/api/skin-analyze', route => { pending = route; }); async function captureThree() { await page.click('[data-action="start-camera"]'); for (let i = 1; i <= 3; i++) { await page.waitForFunction(n => document.querySelector('#cam-step').textContent.includes(n + '/3'), i); await page.waitForTimeout(1600); await page.setInputFiles('#cam-file', path.join(root, 'reference/facepp-skinanalyze/examples/images/C3_180.jpg')); } await page.waitForSelector('#p-analyzing.show'); await page.waitForFunction(() => document.querySelector('#scan-artwork').naturalWidth === 1080); const deadline = Date.now() + 5000; while (!pending && Date.now() < deadline) await page.waitForTimeout(20); assert(pending, 'analysis request must be intercepted within 5 seconds'); } await captureThree(); assert.equal(await page.locator('.scan-status').evaluate(e => getComputedStyle(e).clipPath), 'inset(50%)', 'loaded PSD text must not be duplicated'); const scanPixels = await page.locator('#scan-artwork').evaluate(image => { const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const context = canvas.getContext('2d'); context.drawImage(image, 0, 0); return [[540, 720], [540, 1200]].map(([x, y]) => Array.from(context.getImageData(x, y, 1, 1).data)); }); assert(scanPixels[0].slice(0, 3).every(value => value > 245), 'original PSD bitmap is unchanged'); assert(scanPixels[1].slice(0, 3).every(value => value > 245), 'original PSD bitmap is unchanged'); assert.equal(await page.locator('#scan-photo').count(), 0, 'white PSD head is not covered by user photograph'); assert.equal(await page.locator('.scan-beam').evaluate(e => getComputedStyle(e).animationName), 'report-scan'); assert.equal(await page.locator('#p-analyzing [data-action]').count(), 1, 'only cancel is actionable; PSD icons decorative'); for (const [width, height] of [[320, 726], [375, 726], [390, 844], [414, 896], [480, 726], [375, 440]]) { await page.setViewportSize({ width, height }); const art = await rect('#scan-artwork'); assert(art.width <= width + .1 && art.height <= height + .1); assert(Math.abs(art.width / art.height - 1080 / 2091) < .001); assert.equal(await page.locator('#scan-artwork').evaluate(e => getComputedStyle(e).objectFit), 'contain'); assert(await page.locator('.scan-cancel').isVisible()); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); } await page.setViewportSize({ width: 375, height: 726 }); await screenshot('04-waiting'); for (const point of [[70, 655], [306, 655], [180, 350]]) await page.mouse.click(...point); assert(await page.locator('#p-analyzing').isVisible(), 'icons and face must not cancel/open camera'); await page.keyboard.press('Tab'); assert.equal(await page.evaluate(() => document.activeElement.dataset.action), 'cancel-analysis'); await page.keyboard.press('Escape'); assert(await page.locator('#p-analyzing').isVisible(), 'Escape must retain existing waiting behavior'); await page.click('[data-action="cancel-analysis"]'); await page.waitForSelector('#s-profile.active'); await pending.fulfill({ json: { ok: false, message: '迟到的失败不应影响页面' } }); pending = null; await page.waitForTimeout(1400); assert.equal(await page.locator('#p-analyzing.show').count(), 0); assert(!(await page.locator('#toast').textContent()).includes('迟到')); assert(await page.locator('#f-birth').inputValue()); // Re-entry/failure must still dismiss the new artwork and retain the profile. await page.click('[data-action="submit-profile"]'); await captureThree(); await pending.fulfill({ json: { ok: false, message: '未检测到人脸' } }); pending = null; await page.waitForSelector('#s-profile.active'); assert.equal(await page.locator('#p-analyzing.show').count(), 0); assert((await page.locator('#toast').textContent()).includes('未检测到人脸')); // Successful completion follows the same request callback into the report. await page.click('[data-action="submit-profile"]'); await captureThree(); const success = await page.evaluate(() => ({ ok: true, result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE, report_saved: true })); await pending.fulfill({ json: success }); pending = null; await page.waitForSelector('#s-report.active'); assert.equal(await page.locator('#p-analyzing.show').count(), 0); assert.notEqual(await page.locator('#stat-age b').textContent(), '--'); await page.unroute('**/api/skin-analyze'); assert.equal(writes.length, 3, 'only three intercepted analysis POSTs; no auth/report mutations'); for (const [age, tier] of [[24, 1], [40, 2], [60, 3]]) { await page.route('**/assets/mock/report-fixture.js', async route => { const response = await route.fetch(); await route.fulfill({ response, body: await response.text() + '\nMOCK_FIXTURE.result.skin_age = { value: ' + age + ' };' }); }); await page.goto(base + '?mock=1&screen=report'); await loadFonts(); await page.waitForFunction(() => document.querySelector('#product-image').naturalWidth === 481); assert((await page.locator('#product-image').getAttribute('src')).endsWith('product-' + ({ 1: 25, 2: 35, 3: 50 }[tier]) + '.png')); for (const width of widths) { await page.setViewportSize({ width, height: 844 }); await page.locator('.card-product').scrollIntoViewIfNeeded(); const info = await rect('.prod-info'), name = await rect('#product-name'), title = await rect('#product-title'); assert(title.right <= info.right && name.right <= info.right, 'product text overlaps art at ' + width + ': ' + JSON.stringify({ info, name, title })); assert(Math.abs(title.x - info.x - 81 / 1080 * width) < .1); assert.equal(await page.locator('#product-name').evaluate(e => getComputedStyle(e).fontWeight), '700'); assert.equal((await actualLines('#product-name')).length, 1); const card = await rect('.card-product'); assert(Math.abs(card.width - 945 / 1080 * width) < .1, 'exact current Figma card width'); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); } await page.setViewportSize({ width: 375, height: 726 }); await screenshot('05-product-' + tier, '.card-product'); await page.unroute('**/assets/mock/report-fixture.js'); } // Font downloads may fail on a phone: explicit guide lines still must not // reflow or escape the dialog, and short viewports must keep controls visible. const fallback = await browser.newPage({ viewport: { width: 375, height: 726 } }); await fallback.route('**/*.woff2', route => route.abort()); await fallback.route('**/scan-base.webp', route => route.abort()); await fallback.route('**/api/auth/session', route => route.fulfill({ json: { ok: true, user: { id: 'font-fallback-fixture', kind: 'browser' } } })); await fallback.goto(base); await fallback.click('[data-action="open-privacy"]'); await fallback.waitForLoadState('networkidle'); await fallback.evaluate(async () => { await document.fonts.ready; document.querySelector('#p-privacy').classList.remove('show'); document.querySelector('#p-photoguide').classList.add('show'); }); for (const width of widths) { await fallback.setViewportSize({ width, height: 440 }); assert(await fallback.locator('#p-photoguide').evaluate(el => { const box = el.querySelector('.popup').getBoundingClientRect(); return box.top >= 0 && box.bottom <= innerHeight && box.left >= 0 && box.right <= innerWidth && [...el.querySelectorAll('.guide-description span')].every(span => span.getBoundingClientRect().right <= box.right); }), 'font fallback/short viewport overflow at ' + width); assert(await fallback.locator('[data-action="start-camera"]').isVisible()); } await fallback.evaluate(() => { document.querySelector('#p-photoguide').classList.remove('show'); document.querySelector('#p-privacy').classList.add('show'); }); await fallback.click('#privacy-check'); await fallback.click('[data-action="agree-continue"]'); await fallback.waitForLoadState('networkidle'); await fallback.evaluate(() => document.querySelector('#p-analyzing').classList.add('show')); assert.equal(await fallback.locator('#p-analyzing.scan-artwork-ready').count(), 0); assert.equal(await fallback.locator('.scan-status').evaluate(e => getComputedStyle(e).clipPath), 'none', 'failed image must leave a visible text status'); assert.equal(await fallback.locator('#scan-artwork').evaluate(e => getComputedStyle(e).visibility), 'hidden'); await fallback.click('[data-action="cancel-analysis"]'); assert.equal(await fallback.locator('#p-analyzing.show').count(), 0, 'image failure must not disable cancel'); await fallback.close(); assert.deepEqual(errors, []); console.log('PASS: PSD source hashes/fonts/copy; no heavy entry assets; fixed consent/guide wrapping in five widths; exact product crops/padding/Semibold/all tiers; actual three-photo waiting transition; visible clipped face; decorative icons/focus; cancel/stale/failure/retry/success; six viewport sizes; no backend writes or JS errors'); } finally { await browser.close(); }