/**
* 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()}`);
// 0b) Phase 5i:字体真加载 / 评论不误报 / 灯箱关闭 / 开源页动效与数据板
await page.goto(BASE + '/archives/writing-template/', { waitUntil: 'networkidle' });
await page.waitForTimeout(2500);
const fontState = await page.evaluate(() => {
const fams = [...new Set([...document.fonts].map((f) => f.family.replace(/"/g, '')))];
return {
kai: fams.includes('LXGW WenKai Screen'),
huiwen: fams.includes('WF-Hwmc'),
kaiFamily: getComputedStyle(document.querySelector('.font-kai')).fontFamily.split(',')[0].trim(),
};
});
ok('5i·字体真加载(楷体在字体集内)', fontState.kai, fontState.kaiFamily);
ok('5i·汇文明朝按需加载', fontState.huiwen);
await page.evaluate(() => {
const el = document.getElementById('artalk-container');
el?.scrollIntoView();
});
await page.waitForTimeout(3500);
ok('5i·评论不误报(失败提示保持隐藏)', (await page.locator('#artalk-retry').getAttribute('hidden')) !== null);
ok('5i·评论区已挂载', (await page.locator('#artalk-container[data-artalk-mounted="1"]').count()) === 1);
await page.goto(BASE + '/gallery/', { waitUntil: 'networkidle' });
await page.locator('.shot-btn').first().click();
await page.waitForTimeout(400);
ok('5i·灯箱可开', (await page.locator('#lb[open]').count()) === 1);
await page.locator('#lb-close').click({ timeout: 5000 }).catch(() => {});
await page.waitForTimeout(300);
ok('5i·灯箱右上关闭真的能关', (await page.locator('#lb[open]').count()) === 0);
await page.goto(BASE + '/opensource/', { waitUntil: 'networkidle' });
const osState = await page.evaluate(() => {
const html = document.documentElement.outerHTML;
return {
thirdParty: ['img.shields.io', 'readme-typing-svg', 'github-readme-stats', 'streak-stats', 'komarev']
.some((u) => html.includes(u)),
typing: !!document.querySelector('[class*=typing]'),
board: document.querySelectorAll('.pf-card').length,
localAssets: [...document.querySelectorAll('img')].filter((i) => i.src.includes('/github-assets/')).length,
};
});
ok('5i·开源页零第三方外链图', !osState.thirdParty);
ok('5i·打字机动效存在', osState.typing);
ok('5i·数据板渲染', osState.board > 0 && osState.localAssets > 0, `cards=${osState.board} assets=${osState.localAssets}`);
// 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(() => {
// 书架已改版为「抽书阅读」(Phase 5i T7):不再有年份折叠组,
// 改为每本书一个 details 行;断言 = 至少一行、首行收起、点击能展开出正文。
const all = [...document.querySelectorAll('.book')];
return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length };
});
ok('书架 抽书行渲染', books.total >= 1, JSON.stringify(books));
await page.locator('.book').first().locator('summary').click();
await page.waitForTimeout(250);
ok('书架 点击可抽开', (await page.locator('.book[open] .body').count()) === 1);
await page.locator('.book').first().locator('summary').click();
await page.waitForTimeout(200);
// 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);