Files
blog/tests/e2e.mjs
T
wait4xandGLM-5.3 363cc616f7 feat(motion): 动效层三件——背景辉光/入场/心跳,页脚撤双入口
- 背景鼠标辉光:ink 色 4% 径向渐变跟随(pointer:fine×非
  reduced-motion 才有 JS,raf 节流 document 级监听 VT 存活)——
  丙灰阶语言的"活背景",触屏/减动效自动隐藏
- 正文入场 rise-in 12px/0.4s(VT 换页自然重放)+ 列表行悬停
  1px 微浮;全量 prefers-reduced-motion 豁免
- 首页"最近心跳":构建期拉 blog 仓最新 commit(gitea API 8s 超时
  静默降级),脉冲点+相对时长——本站自己的脉搏
- 页脚撤「装备/制作」入口(页面保留,用户决策:只删入口)
- E2E 加固:g-chords 修好后 location.href 与 goto 竞态(en 页弦
  导航迟到提交打断 goto)——中断重试模式
- 过程教训:str.replace 静默无操作再犯(锚点瞄错组件文件)——
  脚本未进产物却报"全落地",replace 必须 assert 配对 grep 产物

验证: --mx 鼠标双跳 15.6% 实测跟随;E2E 31/31 PASS(竞态重试后
      确定性);构建 34 页 exit 0

Spec: none (动效层+页脚撤口+测试加固,车道级交付)

Co-authored-by: GLM-5.3
2026-10-25 22:09:41 +08:00

192 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);