- 车道 D 三件收编(op-gitea):①index.astro 首两图 eager+首图
fetchpriority=high(Image 默认 lazy 曾压 LCP 至 6.4s,好模式现
1.3s)②inline-home-css.mjs:BaseLayout 11KB 内联首页 head(省
发现+下载跳)③build.sh 守卫挂钩
- 诊断结论入档:home 中位漂移根因=测试床伪影(python server 不压
缩,裸传 67KB vs 生产 gzip 9.2KB;lantern 对未压缩 HTML 流式归因
双峰 99↔165ms 翻转出两个世界)——gzip 等价口径中位 100
(FCP 0.9-1.1s/LCP 1.3-1.4s/TBT 0/CLS 0)
- 测试床转正:gz-server.py 泛化入仓(ThreadingHTTPServer+gzip
[端口 目录] 参数化)——兼治 SW 预缓存风暴竞速(单线程 server
曾把 E2E /gallery/ networkidle 饿死,实锤于收编验收)
- E2E serviceWorkers:'block':SW 有专属测试,站点 E2E 求确定性
- 三连否定留档:SW✗/css链✗/子集内联 v3✗(6×63 反例钉死,已
回滚)——残尾 20% 离群属 lantern trace 抖动,采中位+多轮
车道:op-gitea(过程三连否定+测试床移交);收编验收:内联 10KB
实出+首图 eager 实证+E2E 31/31(SW 屏蔽后确定性复现)
验证: gz 测试床 18743 gzip 10KB(生产等价口径);node tests/e2e.mjs
31/31 PASS;构建+守卫链复跑
Spec: none (性能三件+测试床转正,车道级交付)
Co-authored-by: GLM-5.3
187 lines
9.6 KiB
JavaScript
187 lines
9.6 KiB
JavaScript
/**
|
||
* 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) 图库:网格可见(灯箱不再自动打开)+ 灯箱交互
|
||
await page.goto(BASE + '/gallery/', { waitUntil: 'networkidle' });
|
||
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);
|