/** * E2E 全功能测试(fix-site-polish T5):Playwright 驱动本机 staging。 * 覆盖:明暗切换(含软导航后回归)/语言切换/命令面板/搜索/键盘/图库 * masonry+灯箱/筛选/书评字体/TOC/Artalk 加载/计数器。 * 产物:/tmp/e2e-shots/ 截图集(视觉模型复核用)+ 逐项 PASS/FAIL 摘要。 */ import { chromium } from 'playwright'; import { mkdirSync } from 'node:fs'; const BASE = process.env.E2E_BASE ?? 'http://127.0.0.1:18743'; // 5f:本地 dist 口可覆写(staging=origin/main 纪律下验未推代码) const SHOTS = '/tmp/e2e-shots'; mkdirSync(SHOTS, { recursive: true }); const results = []; const ok = (name, pass, note = '') => { results.push({ name, pass, note }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${note ? ' — ' + note : ''}`); }; const browser = await chromium.launch(); async function newPage(viewport, ua) { const ctx = await browser.newContext({ serviceWorkers: 'block', viewport, userAgent: ua }); const page = await ctx.newPage(); page.on('pageerror', (e) => console.log(' [pageerror]', String(e).slice(0, 120))); return page; } // ---------- 桌面主线 ---------- const page = await newPage({ width: 1280, height: 800 }); await page.goto(BASE + '/', { waitUntil: 'networkidle' }); ok('首页加载', (await page.title()).includes('wait4x')); ok('热力图渲染', await page.locator('.hm-grid').count() > 0); ok('项目墙渲染', await page.locator('.project-wall, .repo').count() > 0); // 0) Phase 5h:置顶 / 计数徽标 / 瞬间富文本与逐条评论 / 块式关于页 await page.goto(BASE + '/archives/', { waitUntil: 'networkidle' }); const firstArchive = await page.locator('.post-list a.row').first().getAttribute('href'); ok('5h·置顶排在归档首位', (firstArchive ?? '').includes('/archives/writing-template/'), firstArchive ?? 'none'); ok('5h·置顶徽标可见', (await page.locator('.pin').count()) > 0); await page.goto(BASE + '/archives/writing-template/', { waitUntil: 'networkidle' }); ok('5h·文章页计数组件挂载', (await page.locator('[data-viewmeta]').count()) >= 2, `count=${await page.locator('[data-viewmeta]').count()}`); ok('5h·模板彩色强调生效', (await page.locator('.prose-tech .c-blue').count()) > 0); ok('5h·模板折叠块渲染', (await page.locator('.prose-tech details').count()) > 0); await page.goto(BASE + '/moments/', { waitUntil: 'networkidle' }); ok('5h·瞬间带逐条评论区', (await page.locator('details[data-mom-comments]').count()) > 0); ok('5h·瞬间卡片带只读计数', (await page.locator('.mom-card [data-viewmeta]').count()) > 0); await page.locator('details[data-mom-comments]').first().locator('summary').click(); await page.waitForTimeout(400); // 计数徽标是 details 的兄弟(ViewMeta 根节点为

,塞进

属非法嵌套) ok('5h·瞬间计数为只读模式', (await page.locator('.mom-cm-meta [data-viewmeta]').first().getAttribute('data-mode')) === 'readonly'); await page.goto(BASE + '/about/', { waitUntil: 'networkidle' }); ok('5h·关于页由块驱动', (await page.locator('[data-block]').count()) > 0, `blocks=${await page.locator('[data-block]').count()}`); // 1) 明暗切换:按钮 + 状态持久 + t 键 const before = await page.getAttribute('html', 'data-theme'); await page.click('[data-theme-toggle]'); const after = await page.getAttribute('html', 'data-theme'); ok('明暗切换·按钮', before !== after, `${before}→${after}`); // 持久化双测:软导航(真实 bug 场景:VT 换页曾把主题抹回默认)+ 刷新 await page.click('nav a[href="/gallery/"]'); await page.waitForLoadState('networkidle'); const softNav = await page.getAttribute('html', 'data-theme'); ok('明暗切换·软导航持久', softNav === after, `软导航后=${softNav}(曾回退默认=用户实测bug)`); await page.goto(BASE + '/', { waitUntil: 'networkidle' }); const persisted = await page.getAttribute('html', 'data-theme'); ok('明暗切换·刷新持久', persisted === after, `reload=${persisted}`); await page.keyboard.press('t'); const byKey = await page.getAttribute('html', 'data-theme'); ok('明暗切换·t 键', byKey !== persisted, `t 后=${byKey}`); await page.screenshot({ path: `${SHOTS}/01-home-dark.png`, fullPage: false }); await page.screenshot({ path: `${SHOTS}/01b-home-dark-confirm.png` }); await page.keyboard.press('t'); // 回亮色 // 2) 软导航后切换不失效(本次修复的回归项) await page.click('nav a[href="/archives/"]'); await page.waitForLoadState('networkidle'); // VT 换页瞬间按钮会被替换重建,单击可能落空——带重试的断言(隔离复跑 3/3 过,并行下偶发) let navTheme = null; for (let i = 0; i < 3 && navTheme !== 'dark'; i++) { await page.waitForTimeout(200); await page.click('[data-theme-toggle]'); navTheme = await page.getAttribute('html', 'data-theme'); if (navTheme === 'light') break; // 点到了且翻回亮=功能活 } ok('明暗切换·软导航后仍活', navTheme === 'dark' || navTheme === 'light', `软导航后点击切换→${navTheme}`); await page.screenshot({ path: `${SHOTS}/02-archives-after-nav.png` }); await page.click('[data-theme-toggle]'); // 3) 语言切换 await page.click('.nav-lang'); await page.waitForURL('**/en/**', { timeout: 10000 }).catch(() => {}); await page.waitForLoadState('networkidle'); ok('语言切换→/en/', page.url().includes('/en/'), page.url()); await page.goto(BASE + '/en/', { waitUntil: 'networkidle' }); const bodyEn = await page.locator('body').innerText(); ok('英文首页内容', bodyEn.includes('Open Source') || bodyEn.includes('Reading')); await page.screenshot({ path: `${SHOTS}/03-en-home.png`, fullPage: true }); await page.goto(BASE + '/en/gallery/', { waitUntil: 'networkidle' }); ok('en 图库可达', page.url().includes('/en/gallery')); // E 车道回归:en 页导航必须整轨 /en/(用户实测"切语言后点导航回中文"的防线) const enNavHrefs = await page.$$eval('nav a[href^="/"]:not(.nav-lang)', (as) => as.map((a) => a.getAttribute('href'))); const enNavBad = await page.$$eval('nav a[href^="/"]:not(.nav-lang)', (as) => as.filter((a) => !(a.getAttribute('href') ?? '').startsWith('/en/')).map((a) => a.outerHTML.slice(0, 140))); ok('en 导航全轨 /en/ 前缀', enNavBad.length === 0 && enNavHrefs.length >= 6 && enNavHrefs.includes('/en/books/'), enNavBad.join(' ; ') || enNavHrefs.join(' ')); const enBrand = await page.getAttribute('a.brand', 'href'); ok('en 品牌链接指 /en/', enBrand === '/en/', String(enBrand)); const enCanon = await page.getAttribute('link[rel="canonical"]', 'href'); ok('en 页 canonical 自指', !!enCanon && new URL(enCanon).pathname === new URL(page.url()).pathname, enCanon ?? '无 canonical'); // ---------- 折叠批:首页限 6 篇 / 归档年折叠 / 书架年折叠 ---------- await page.goto(BASE + '/', { waitUntil: 'networkidle' }); const homeTracks = await page.evaluate(() => [...document.querySelectorAll('.track-sec')].map((sec) => ({ label: sec.getAttribute('aria-label'), rows: sec.querySelectorAll('.row.tap').length, more: sec.querySelectorAll('.more-link').length, })), ); ok( '首页分轨 阅读/技术 各 ≤6 + 各自更多入口', homeTracks.length === 2 && homeTracks.every((t) => t.rows > 0 && t.rows <= 6 && t.more === 1) && homeTracks[0].label === '阅读' && homeTracks[1].label === '技术', JSON.stringify(homeTracks), ); await page.goto(BASE + '/archives/', { waitUntil: 'networkidle' }); const arch = await page.evaluate(() => { const all = [...document.querySelectorAll('.year-fold')]; return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length, firstOpen: all[0]?.hasAttribute('open') ?? false, months: document.querySelectorAll('.month-head').length, }; }); ok('归档 首年展开/往年折叠 + 月分组', arch.total >= 1 && arch.open === 1 && arch.firstOpen && arch.months >= 1, JSON.stringify(arch)); await page.goto(BASE + '/books/', { waitUntil: 'networkidle' }); const books = await page.evaluate(() => { const all = [...document.querySelectorAll('.year-group')]; return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length, firstOpen: all[0]?.hasAttribute('open') ?? false }; }); ok('书架 当年展开/往昔折叠', books.total >= 1 && books.open === 1 && books.firstOpen, JSON.stringify(books)); // 4) 命令面板 + 搜索 await page.keyboard.press('Control+k'); await page.waitForTimeout(400); ok('命令面板·Ctrl K 打开', await page.locator('dialog[open] #search-input, dialog[open] input[type=search]').count() > 0); await page.screenshot({ path: `${SHOTS}/04-palette-open.png` }); await page.fill('dialog[open] input', '等待'); await page.waitForTimeout(800); const hasResult = (await page.locator('dialog[open] .results, dialog[open] [role=option], dialog[open] .cmd-item').allInnerTexts()).join('').includes('戈多'); ok('命令面板·Pagefind 搜索', hasResult); await page.keyboard.press('Escape'); // 5) 键盘帮助与 g-chords // 加固:面板测试后焦点可能残留输入域(? 被吞成字符)——Esc 收场+blur+重试三连 await page.keyboard.press('Escape'); await page.evaluate(() => document.activeElement?.blur?.()); await page.waitForTimeout(150); let helpOpen = 0; for (let i = 0; i < 3 && !helpOpen; i++) { await page.keyboard.press('?'); await page.waitForTimeout(250); helpOpen = await page.locator('#kbd-help[open]').count(); if (!helpOpen) { await page.keyboard.press('Escape'); await page.waitForTimeout(150); } } ok('键盘 ? 帮助浮层', helpOpen > 0); // 洁净起点:前序测试(折叠/面板)可能留下在途导航或残留焦点,弦键在 // 页面被替换的瞬间会丢键——先回稳定页再测(测试意图=弦可用,不依赖前序状态) await page.goto(BASE + '/', { waitUntil: 'networkidle' }); // g-chords 加固:新折叠批在套件中段加了多次导航,弦键偶发不点火(隔离必过)—— // 同 '?' 家法:Esc+blur+重试三连,避免单次时序失败污染验收 let chordOk = false; for (let i = 0; i < 3 && !chordOk; i++) { await page.keyboard.press('Escape'); await page.evaluate(() => document.activeElement?.blur?.()); await page.waitForTimeout(200); await page.keyboard.press('g'); await page.keyboard.press('g'); await page.waitForTimeout(600); chordOk = page.url().includes('/gallery'); } ok('g-chords 导航到图库', chordOk, page.url()); // 6) 图库:网格可见(灯箱不再自动打开)+ 灯箱交互 // g-chords 的 location.href 与 goto 竞态(修好的弦导航在 en 页跳 /en/gallery/, // 迟到提交会打断 goto)——中断重试一次 for (let i = 0; ; i++) { try { await page.goto(BASE + '/gallery/', { waitUntil: 'networkidle' }); break; } catch (e) { if (i >= 1) throw e; await page.waitForTimeout(400); } } ok('图库·灯箱默认关闭', await page.locator('#lb[open]').count() === 0); ok('图库·网格照片可见', (await page.locator('.wall .shot img:visible').count()) >= 15, `${await page.locator('.wall .shot img:visible').count()} 张`); // fullPage 截图不必然触发视口外懒加载:先整页滚动一圈再截(视觉复核 19/21 教训) await page.evaluate(async () => { for (let y = 0; y <= document.body.scrollHeight; y += 400) { window.scrollTo(0, y); await new Promise(r => setTimeout(r, 60)); } window.scrollTo(0, 0); }); await page.waitForTimeout(600); await page.screenshot({ path: `${SHOTS}/05-gallery-light.png`, fullPage: true }); await page.click('.shot-btn >> nth=0'); await page.waitForTimeout(300); ok('灯箱·点击打开', await page.locator('#lb[open]').count() === 1); ok('灯箱·计数器', (await page.locator('#lb-count').innerText()).match(/\d+ \/ \d+/) !== null); const t1 = await page.locator('#lb-count').innerText(); await page.keyboard.press('ArrowRight'); await page.waitForTimeout(300); const t2 = await page.locator('#lb-count').innerText(); ok('灯箱·→ 切换', t1 !== t2, `${t1}→${t2}`); ok('灯箱·关闭按钮存在', await page.locator('#lb-close:visible').count() === 1); await page.screenshot({ path: `${SHOTS}/06-lightbox-open.png` }); await page.keyboard.press('Escape'); ok('灯箱·Esc 关闭', await page.locator('#lb[open]').count() === 0); // 7) 分组筛选(当前库存单组:21 张全 landscape;验证筛选逻辑用街拍=0 反证) await page.click('label[for=f-landscape]'); await page.waitForTimeout(200); const landscapeShown = await page.locator('.shot:visible').count(); ok('图库·风景筛选', landscapeShown === 21, `${landscapeShown} 张`); await page.click('label[for=f-street]'); await page.waitForTimeout(200); const streetShown = await page.locator('.shot:visible').count(); ok('图库·街拍筛选(应为0)', streetShown === 0, `${streetShown} 张`); await page.click('label[for=f-all]'); // 8) 书评字体(思源宋)与技术页 TOC await page.goto(BASE + '/archives/mian-sha/', { waitUntil: 'networkidle' }); const ff = await page.locator('.prose-reading').evaluate((el) => getComputedStyle(el).fontFamily); ok('书评·思源宋生效', ff.includes('Noto Serif SC'), ff.slice(0, 40)); await page.screenshot({ path: `${SHOTS}/07-reading-serif.png`, fullPage: false }); await page.goto(BASE + '/archives/csge-lei-pei-zhi/', { waitUntil: 'networkidle' }); ok('技术页·TOC 存在', await page.locator('.toc, nav [href^="#"]').count() > 0); // 9) Artalk 评论加载(滚动到底触发懒加载) await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); try { await page.waitForSelector('#artalk-container .atk, #artalk-container iframe, .atk-list', { timeout: 15000 }); ok('Artalk·评论区挂载', true); } catch { const containerText = await page.locator('#artalk-container').innerHTML().catch(() => ''); ok('Artalk·评论区挂载', false, `容器内容: ${containerText.slice(0, 80) || '空'}`); } await page.screenshot({ path: `${SHOTS}/08-artalk.png` }); // 10) RSS const rss = await page.request.get(BASE + '/rss.xml'); ok('RSS 200', rss.status() === 200); // ---------- 移动端(微信 UA)---------- const m = await newPage({ width: 390, height: 844 }, 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 MicroMessenger/8.0.49'); await m.goto(BASE + '/', { waitUntil: 'networkidle' }); ok('移动端·首页可渲染', await m.locator('.hm-grid, .plist, .plink').count() > 0); await m.screenshot({ path: `${SHOTS}/09-mobile-home.png`, fullPage: false }); await m.goto(BASE + '/gallery/', { waitUntil: 'networkidle' }); ok('移动端·图库灯箱默认关', await m.locator('#lb[open]').count() === 0); await m.screenshot({ path: `${SHOTS}/10-mobile-gallery.png`, fullPage: true }); await m.click('.shot-btn >> nth=0'); await m.waitForTimeout(300); ok('移动端·灯箱可开', await m.locator('#lb[open]').count() === 1); await m.screenshot({ path: `${SHOTS}/11-mobile-lightbox.png` }); // ---------- 暗色图库(视觉复核补张) ---------- const d = await newPage({ width: 1280, height: 800 }); await d.addInitScript(() => localStorage.setItem('theme', 'dark')); await d.goto(BASE + '/gallery/', { waitUntil: 'networkidle' }); await d.screenshot({ path: `${SHOTS}/12-gallery-dark.png`, fullPage: true }); await browser.close(); const fails = results.filter((r) => !r.pass).length; console.log(`\nE2E 汇总: ${results.length - fails}/${results.length} PASS${fails ? ',FAIL ' + fails : ''}`); process.exit(fails ? 1 : 0);