// Read-only mock page. No account creation, report writes or Face++ call. 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 browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true }); page.setDefaultTimeout(60000); // Cold public-site font downloads can exceed 30s. const errors = []; page.on('pageerror', e => errors.push(e.message)); await page.goto(base + '?mock=1&screen=report'); await page.waitForFunction(() => document.querySelector('#stat-score b').textContent !== '--' && [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth)); const cdp = await page.context().newCDPSession(page); async function swipe(card, dx, dy = 0) { const frame = card.locator('.dpanel.active .viz-frame'); await frame.scrollIntoViewIfNeeded(); const box = await frame.boundingBox(), x = box.x + box.width / 2, y = Math.min(500, box.y + box.height / 2); await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] }); for (let i = 1; i <= 8; i++) { await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: x + dx * i / 8, y: y + dy * i / 8 }] }); } await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await page.waitForTimeout(300); } const cards = page.locator('.card-detail'); await swipe(cards.nth(0), -85); assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '1'); assert.equal(await page.locator('#p-zoom.show').count(), 0); await swipe(cards.nth(0), 85); assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '0'); await swipe(cards.nth(0), 0, -85); assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '0', 'vertical scrolling must not change tab'); await swipe(cards.nth(1), -85); assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '1'); await swipe(cards.nth(1), -85); assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '2'); await swipe(cards.nth(1), 85); await cards.nth(1).locator('.dtab').first().click(); assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '0'); await cards.nth(1).locator('.dtab').first().focus(); await page.keyboard.press('ArrowRight'); assert.equal(await cards.nth(1).locator('.dtab.active').getAttribute('data-tab'), '1'); await swipe(cards.nth(2), -85); assert.equal(await cards.nth(2).locator('.dtab.active').getAttribute('data-tab'), '1'); assert.equal(await cards.nth(0).locator('.dtab.active').getAttribute('data-tab'), '0'); const image = cards.nth(0).locator('.dpanel.active .ditem-img'); await image.click(); await page.waitForSelector('#p-zoom.show'); assert.equal(await page.locator('#zoom-img').getAttribute('src'), await image.getAttribute('src')); await page.click('#p-zoom'); await page.waitForSelector('#p-zoom.show', { state: 'hidden' }); await page.waitForTimeout(300); const wrinkleSummary = await page.locator('#ditem-para2').textContent(); assert(wrinkleSummary.includes('面中、口周、眼周') || wrinkleSummary.includes('面部整体平整')); assert(!wrinkleSummary.includes('分区明细') && !wrinkleSummary.includes('标准化长度')); assert((await page.locator('#smooth-map-note').textContent()).includes('毛孔')); const focused = await page.locator('.ditem-img').evaluateAll(images => images.map(i => ({ focused: i.dataset.crop, ratio: i.naturalWidth / i.naturalHeight }))); assert(focused.every(i => i.focused === 'face' && Math.abs(i.ratio - 215 / 278) < .005)); const fontSizes = []; for (const width of [320, 375, 390, 414, 480]) { await page.setViewportSize({ width, height: 844 }); await page.waitForTimeout(150); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'page overflow ' + width); fontSizes.push(await page.locator('#tx-tone').evaluate(e => getComputedStyle(e).fontSize)); assert.equal(await page.locator('#product-name .product-line').count(), 2); for (const line of await page.locator('#product-name .product-line').all()) { assert.equal(await line.evaluate(e => getComputedStyle(e).whiteSpace), 'nowrap'); assert(await line.evaluate(e => e.scrollWidth <= e.clientWidth), 'product line overflow ' + width); } } assert(fontSizes.every(size => size === '12px'), JSON.stringify(fontSizes)); assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--yellow').trim()), '#F5F078'); if (process.env.H5_ARTIFACT_DIR) { fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true }); await page.setViewportSize({ width: 375, height: 726 }); await image.scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '01-face-focused.png'), animations: 'disabled' }); await cards.nth(0).locator('.dtab').nth(1).click(); await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '02-wrinkle-regions.png'), animations: 'disabled' }); await cards.nth(1).locator('.dtab').nth(1).click(); await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '03-smooth-maps.png'), animations: 'disabled' }); await page.locator('.card-product').scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, '04-product-lines.png'), animations: 'disabled' }); } // Change only the in-browser fixture. Exercise the actual renderer and age-only // recommendation outside boundary windows, without production report writes. for (const [age, name, tier] of [[40, '以我自在有势挺括', 'II'], [60, '以我从容有态构塑', 'III']]) { 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 + ' };\ndelete MOCK_FIXTURE.result.face_maps.texture_enhanced_blackheads;' }); }); await page.goto(base + '?mock=1&screen=report'); await page.waitForFunction(() => document.querySelector('#product-name .product-line')); assert.equal(await page.locator('#product-title').textContent(), "1'me 主场精华 " + tier); assert.equal(await page.locator('#product-name .product-line').first().textContent(), name); assert(!(await page.locator('#smooth-map-note').textContent()).includes('红色'), 'missing blackhead map must not be claimed'); for (const width of [320, 375, 390, 414, 480]) { await page.setViewportSize({ width, height: 844 }); for (const line of await page.locator('#product-name .product-line').all()) { assert(await line.evaluate(e => e.scrollWidth <= e.clientWidth), 'tier ' + tier + ' product line overflow ' + width); } assert(await page.locator('#product-title').evaluate(e => e.scrollWidth <= e.clientWidth), 'tier ' + tier + ' title overflow ' + width); } await page.unroute('**/assets/mock/report-fixture.js'); } assert.deepEqual(errors, []); console.log('PASS: real mobile swipes in three independent groups, vertical scroll vs swipe, click/keyboard tabs, no zoom after swipe, zoom still works, seven face-focused maps, approved wrinkle summary without technical appendix, fixed fonts/all three product lines in five widths, partial map caption, PSD yellow, no JS errors'); } finally { await browser.close(); }