/** * 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 = 'http://127.0.0.1:18743'; 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); // 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')); // 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 await page.keyboard.press('?'); await page.waitForTimeout(300); ok('键盘 ? 帮助浮层', await page.locator('#kbd-help[open]').count() > 0); await page.keyboard.press('Escape'); await page.keyboard.press('g'); await page.keyboard.press('g'); await page.waitForLoadState('networkidle'); ok('g-chords 导航到图库', page.url().includes('/gallery')); // 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);