/* 验证脚本:逐路由截图 + 断言(控制台 0 error / 无横向溢出 / 配色与暗色能切换) 用法:node verify.mjs (产物在 ./shots/) */ import { chromium } from 'playwright'; import fs from 'fs'; import path from 'path'; const dir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')); const out = path.join(dir, 'shots'); fs.mkdirSync(out, { recursive: true }); const ROUTES = [ ['01-messages', '#/messages'], ['04-contacts', '#/contacts'], ['05-agent', '#/agent/a-nova'], ['06-projects', '#/projects'], ['07-project-detail', '#/projects/pr-1'], ['08-tasks', '#/tasks'], ['09-task-blocked', '#/tasks/t-1'], ['10-deliverables', '#/deliverables'], ['11-events', '#/events'], ['12-workbench', '#/workbench'], ['13-mine', '#/mine'], ]; const browser = await chromium.launch({ channel: 'msedge' }); const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 }); const errors = []; page.on('pageerror', e => errors.push('pageerror: ' + e.message)); page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); const url = 'file:///' + path.join(dir, 'index.html').replace(/\\/g, '/'); await page.goto(url + '#/messages', { waitUntil: 'load' }); await page.waitForTimeout(400); const report = { routes: [], overflow: [], palettes: {} }; const wx = {}; // ---- 登录流程:未登录先落到 #/login,手机号 → 验证码 → 同意 → 登录 ---- await page.evaluate(() => localStorage.clear()); await page.goto(url, { waitUntil: 'load' }); await page.waitForTimeout(300); wx.loginRedirect = await page.evaluate(() => location.hash); await page.screenshot({ path: path.join(out, '00-login-phone.png') }); wx.loginBtnDisabledBeforePhone = await page.locator('[data-act="login-next"][data-step="code"]').isDisabled(); await page.locator('[data-act="login-phone"]').fill('13800000000'); await page.waitForTimeout(150); wx.loginPhoneValid = await page.locator('[data-act="login-next"][data-step="code"]').isEnabled(); await page.locator('[data-act="login-next"][data-step="code"]').click(); await page.waitForTimeout(200); await page.screenshot({ path: path.join(out, '00-login-code.png') }); await page.locator('[data-act="login-code"]').fill('123456'); await page.waitForTimeout(150); wx.loginCodeValid = await page.locator('[data-act="login-submit"]').isEnabled(); await page.locator('[data-act="login-agree"]').click(); await page.waitForTimeout(150); await page.locator('[data-act="login-submit"]').click(); await page.waitForTimeout(400); wx.afterLogin = await page.evaluate(() => location.hash); // ---- 底部输入坞不随内容滚动 ---- await page.evaluate(() => { location.hash = '#/chat/s-1'; }); await page.waitForTimeout(250); const posA = await page.locator('.dock').boundingBox(); await page.evaluate(() => { const b = document.querySelector('.page-body'); b.scrollTop = b.scrollHeight; }); await page.waitForTimeout(200); const posB = await page.locator('.dock').boundingBox(); wx.dockFixedDuringScroll = !!posA && !!posB && Math.abs(posA.y - posB.y) < 1; wx.dockInsideViewport = await page.evaluate(() => { const el = document.querySelector('.dock'); if (!el) return true; // 该页没有输入坞 return Math.round(el.getBoundingClientRect().bottom) <= window.innerHeight; }); // 有 TabBar 的页面:输入坞底边不得压住 TabBar await page.evaluate(() => { location.hash = '#/messages'; }); await page.waitForTimeout(250); wx.homeDockAboveTabbar = await page.evaluate(() => { const d = document.querySelector('.dock'); const t = document.querySelector('.tabbar'); if (!d || !t) return true; return getComputedStyle(t).display === 'flex' && d.getBoundingClientRect().bottom <= t.getBoundingClientRect().top + 1; }); for (const [name, hash] of ROUTES) { await page.evaluate(h => { location.hash = h; }, hash); await page.waitForTimeout(180); await page.screenshot({ path: path.join(out, name + '.png') }); const info = await page.evaluate(() => ({ hasView: !!document.querySelector('.view.active .page'), hOverflow: document.documentElement.scrollWidth > window.innerWidth + 1, title: (document.querySelector('.navbar .title') || {}).textContent?.trim() || '', })); if (info.hOverflow) report.overflow.push(name); report.routes.push([name, info.hasView, info.title]); } // 抽屉 + 会话内成果抽屉 + 预览层 await page.evaluate(() => { location.hash = '#/messages'; }); await page.waitForTimeout(150); await page.locator('[data-act="drawer"]').click(); await page.waitForTimeout(350); await page.screenshot({ path: path.join(out, '14-drawer.png') }); const drawerOk = await page.locator('.drawer.show').count(); await page.evaluate(() => { document.querySelectorAll('#layers > div').forEach(w => w.remove()); document.querySelectorAll('#layers > .preview').forEach(x => x.remove()); }); await page.waitForTimeout(300); await page.evaluate(() => { location.hash = '#/chat/s-2'; }); await page.waitForTimeout(150); await page.locator('[data-act="sheet-chat"]').click(); await page.waitForTimeout(350); await page.screenshot({ path: path.join(out, '15-chat-sheet.png') }); await page.locator('[data-tab="deliver"]').click(); await page.waitForTimeout(150); await page.locator('[data-sheet-body] .row').first().click(); await page.waitForTimeout(300); await page.screenshot({ path: path.join(out, '16-preview.png') }); const previewOk = await page.locator('.preview').count(); await page.locator('.preview [data-act="close"]').click(); await page.waitForTimeout(300); // 配色 × 明暗(先回首页,避免在预览层级上操作) await page.evaluate(() => { location.hash = '#/messages'; document.querySelectorAll('#layers > *').forEach(x => x.remove()); }); await page.waitForTimeout(300); for (const p of ['platinum', 'ice', 'clay', 'slate']) { for (const dark of [false, true]) { await page.evaluate(([p, dark]) => { document.documentElement.dataset.palette = p; document.documentElement.classList.toggle('dark', dark); }, [p, dark]); const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); report.palettes[p + (dark ? '-dark' : '-light')] = bg; } await page.evaluate(p => { document.documentElement.dataset.palette = p; document.documentElement.classList.remove('dark'); location.hash = '#/messages'; }, p); await page.waitForTimeout(150); await page.screenshot({ path: path.join(out, `17-palette-${p}.png`) }); } // 用页面自身入口切一次,验证持久化路径 await page.evaluate(() => { location.hash = '#/mine'; }); await page.waitForTimeout(150); await page.locator('[data-act="palette"][data-key="ice"]').click(); await page.waitForTimeout(150); report.paletteAfterClick = await page.evaluate(() => document.documentElement.dataset.palette); await page.locator('[data-act="toggle-dark"]').click(); await page.waitForTimeout(150); report.darkAfterClick = await page.evaluate(() => document.documentElement.classList.contains('dark')); await page.evaluate(() => { localStorage.clear(); }); // 设置页截图(深色 + ice) await page.evaluate(() => { document.documentElement.dataset.palette = 'ice'; document.documentElement.classList.add('dark'); location.hash = '#/mine'; }); await page.waitForTimeout(200); await page.screenshot({ path: path.join(out, '18-mine-dark-ice.png') }); const distinct = new Set(Object.values(report.palettes)).size; console.log(JSON.stringify({ ...wx, routes: report.routes, overflow: report.overflow, drawerOk, previewOk, paletteColors: report.palettes, distinctPaletteColors: distinct, paletteAfterClick: report.paletteAfterClick, darkAfterClick: report.darkAfterClick, errors, }, null, 1)); await browser.close(); process.exit(errors.length || report.overflow.length || distinct < 8 ? 1 : 0);