Files
blog/tests/e2e.mjs
T
wait4xandGLM-5.3 7e0167a8bb feat(opensource): 热力图五方案预览 + 开源页叙述合一(5j T1 + 预设)
用户两项新需求:
①「开源页的叙述为什么要区分链接前和链接后?完全可以用 markdown 富文本统一替代」
②「热力图可以有更多的动效,而不是静态的一张图,探索相关方案供我审阅」

T1 叙述合一:
- opensource.yaml 新增 story(双语 markdown,链接内联),blurb/blurb_after 保留为
  legacy(story 为空时才用)→ 旧 yaml 零回归
- site-content 访问器取 story,GithubProfile 走 renderRich 行内模式;markdown 生成的
  裸 <a> 补 .pf-bio :global(a) 样式,与 .pf-link 观感一致
- 实测产物:pf-bio 内联 <a> 渲染成功、pf-link 拼接痕迹 0 处

热力图五方案预览(/heatmap-preview/,供用户选型):
- 新增 geek/HeatGrid.astro:可复用网格渲染件(371 格带 --c/--r 供错峰延迟),
  沿用线上同款 ink 灰阶(accent 只做高亮,不参与数据编码)
- A 入场波纹(视口内一次性斜向漫过)、B 光标光晕(radial-gradient 跟随 + 日期读数)、
  C 年度回放(滑块 scrub + 累计计数 + 自动播放)、D 连续电流(最近连续段流光 +
  连续天数大数)、E 悬停周详情(整列抬起 + 周合计/最好一天面板)
- 五案均守灰阶红线、均在 prefers-reduced-motion 下静态化、零第三方依赖、无常驻 rAF
- **两处真坑**:①网格是子组件渲染,页面级 scoped 样式够不到其内部元素 → 与格内元素
  相关的选择器必须 :global()(否则 A 的动画与 E 的抬列静默不生效)②「当前连续」按
  今天起算会得 0(今天尚未贡献)→ 改为以最后一个有贡献的日子为终点往回数,并标注
  「截至 <日期>」

另修 E2E 过时断言:书架改版为抽书阅读后,原「年份折叠组」断言必然失败,
改为「抽书行渲染 + 点击可抽开」(56/56 PASS)

验证: 构建 51 页 exit 0;content-lint 52 页 0/0/0;E2E 56/56;
      Playwright 逐案实测:A 动画 hmRise 0.42s 且播完 opacity=1、B 光晕 opacity=1
      并显示「日期 · 次数」、C 拖到第 10 周读数与淡出正确、D 12 格流光 hmFlow +
      连续 5 天(截至 2026-10-24)、E 整列 lift matrix(…,-2);390 零横向溢出;
      reduced-motion 下动画 none、光晕 display:none

Spec: 2026-11-03_blog-5j-content-refine (T1 + 热力图预览)

Co-authored-by: GLM-5.3
2026-11-04 20:09:10 +08:00

330 lines
18 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 = 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 根节点为 <p>,塞进 <summary> 属非法嵌套)
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);