225 lines
16 KiB
JavaScript
225 lines
16 KiB
JavaScript
// 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/.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), 'visible PSD face must not be lost to clipping');
|
|
assert(scanPixels[1].slice(0, 3).every(value => value > 245), 'base face stays white; yellow scan is now a separate animated layer');
|
|
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 === 504);
|
|
assert((await page.locator('#product-image').getAttribute('src')).endsWith('product-' + ({ 1: 25, 2: 35, 3: 50 }[tier]) + '.webp'));
|
|
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 - 26.736111) < .1);
|
|
assert.equal(await page.locator('#product-name').evaluate(e => getComputedStyle(e).fontWeight), '600');
|
|
assert.equal((await actualLines('#product-name')).length, 2);
|
|
const card = await rect('.card-product');
|
|
assert(Math.abs(card.width - 984 / 1080 * width) < .1, 'exact PSD 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(); }
|