关于 web-visual-verification
用于编写 Playwright 脚本,检查网页界面的实际运行行为。它将脚本分为验证、截图、诊断和快速探查四类,并提供断言、错误处理、多视口及 CI 指引。
主要功能
- 01四类脚本职责划分
- 02多视口运行时检查
- 03可复用的界面断言
- 04控制台与页面错误检查
- 05截图及 CI 约定
适用场景
- 01界面更改后检查布局和交互
- 02为设计评审采集截图
- 03通过 DOM 和样式信息排查故障
- 04发布前运行回归检查
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ kevintsai1202/ teaching-site-skills --skill web-visual-verification安装后在 Claude Code 中输入/web-visual-verification确认已加载。
详细介绍
Web 视觉验证
Schema 权威来源:验证数据是否正确渲染时,字段名称以
_shared/domain-primitives.md为准。例如,断言unit.illustrations.length >= 1符合第 11 节的覆盖下限。参考实现:
d:/GitHub/ai-workshop/scripts/verify-*.mjs中包含生产级验证脚本模式(多视口、捕获控制台错误、失败时截图)。
此技能用于生成基于 Playwright 的运行时验证脚本,驱动实际渲染的网站,并回答“它的行为是否正确?”此技能源自一个教学网站项目,但适用于任何静态或动态 Web UI。
何时调用
- 任何交互层改动之后(第 4 阶段的交互、侧边栏逻辑、主题、缩放)。
- 任何视觉资源改动之后(第 5 阶段——插图、截图)。
- 任何布局 / CSS 改动之后(“侧边栏折叠得很奇怪”“模态框的背景遮罩不见了”)。
- 发布版本打标签或移交企业版之前。
- 用户询问“这次改动有没有影响其他功能?”时。
文件 / 数据一致性检查请勿调用(使用 web-content-audit)。
四种脚本职责——选择正确的一种
此技能中的每个 Playwright 脚本只能承担以下一种职责。混用职责会导致脚本难以维护。
| 职责 | 前缀 | 是否包含断言? | 输出 | 使用场景 |
|---|---|---|---|---|
| 验证 | verify-*.mjs | 是(assert.*) | 通过 / 失败 + 截图 | 默认选择——运行时回归检查 |
| 捕获 | capture-*.mjs | 否 | 仅截图 | 需要视觉证据但不做判断时(设计评审、变更日志) |
| 诊断 | diagnose-*.mjs | 否(但会大量记录日志) | 控制台转储 + DOM 树 + 截图 | 验证失败后需要查明原因时 |
| 探查 | check-*.mjs、find-*.mjs | 针对性断言,通常临时使用 | 控制台输出 | 快速命令行检查:“这个字符串是否出现?”“元素 X 在哪里?” |
原则:验证脚本必须在失败时明确报错。捕获脚本绝不能失败(它只提供证据)。诊断脚本用于探索。探查脚本则是一行命令版的浏览器开发者工具。
框架:多视口验证脚本
这是最常用的模式。可根据任何 UX 流程进行调整。
// scripts/verify-mobile.mjs
import { chromium, devices } from 'playwright';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir } from 'node:fs/promises';
import assert from 'node:assert/strict';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const SCREENSHOT_DIR = resolve(ROOT, 'data', 'mobile-verify');
const URL = process.env.URL || 'http://localhost:3000/';
const PROFILES = [
{ id: 'iphone-se', device: devices['iPhone SE'] },
{ id: 'iphone-13', device: devices['iPhone 13'] },
{ id: 'ipad-mini', device: devices['iPad Mini'] },
{ id: 'desktop', device: { viewport: { width: 1280, height: 900 }, userAgent: 'Mozilla/5.0' } },
];
async function verifyProfile(browser, profile) {
const ctx = await browser.newContext({ ...profile.device, locale: 'zh-TW' });
const page = await ctx.newPage();
// === CRITICAL: collect runtime errors ===
const errors = [];
page.on('pageerror', e => errors.push(`[pageerror] ${e.message}`));
page.on('console', m => { if (m.type() === 'error') errors.push(`[console.error] ${m.text()}`); });
await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('section.chapter', { timeout: 10000 });
// === Per-profile assertions go here ===
const isMobile = (profile.device.viewport?.width ?? 1280) <= 768;
// Example: assert horizontal overflow doesn't exist
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
assert.equal(overflow, false, `${profile.id}: 水平溢出`);
// Screenshot at key states
await mkdir(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: resolve(SCREENSHOT_DIR, `${profile.id}-home.png`), fullPage: false });
// === Fail if any console errors collected ===
assert.equal(errors.length, 0, `${profile.id} console errors:\n${errors.join('\n')}`);
await ctx.close();
}
const browser = await chromium.launch({ headless: true });
try {
for (const p of PROFILES) await verifyProfile(browser, p);
console.log('✅ All profiles passed');
} finally { await browser.close(); }
可复用断言(新增验证脚本时复制)
水平溢出(最常见的布局错误)
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
assert.ok(overflow.scrollWidth <= overflow.innerWidth, `水平溢出 ${overflow.scrollWidth} > ${overflow.innerWidth}`);
确认窗口滚动确实可用(捕获 overflow-x: hidden 导致的隐式锁定)
await page.evaluate(() => window.scrollTo(0, 1500));
const y = await page.evaluate(() => window.scrollY);
assert.ok(y > 0, 'window 無法捲動(檢查 ancestor 是否有 overflow:hidden)');
侧边栏遮罩行为(移动端)
if (isMobile) {
// Default: sidebar hidden
const before = await page.locator('.sidebar').boundingBox();
assert.ok(!before || before.x < 0, 'sidebar should be off-screen by default on mobile');
// Open via hamburger
await page.locator('.menu-toggle').click();
await page.waitForTimeout(400); // animation
const after = await page.locator('.sidebar').boundingBox();
assert.ok(after && after.x >= 0, 'sidebar should slide in after toggle');
// Backdrop click closes
await page.locator('.sidebar-backdrop').click();
await page.waitForTimeout(400);
}
localStorage 往返检查(进度持久化)
await page.locator('input[type=checkbox]').first().check();
await page.reload();
await page.waitForSelector('input[type=checkbox]');
const checked = await page.locator('input[type=checkbox]').first().isChecked();
assert.equal(checked, true, '勾選未持久化(檢查 localStorage key 與 file:// 限制)');
内容缩放仅作用于 .content(不影响侧边栏)
const sidebarRect = await page.locator('.sidebar').boundingBox();
// Trigger zoom in
await page.evaluate(() => document.documentElement.style.setProperty('--content-zoom', '1.35'));
await page.waitForTimeout(100);
const sidebarRectAfter = await page.locator('.sidebar').boundingBox();
assert.equal(sidebarRect.width, sidebarRectAfter.width, 'sidebar should not be affected by zoom');
NetworkIdle 与 domcontentloaded——谨慎选择
waitUntil: | 使用场景 |
|---|---|
'domcontentloaded' | DOM 已存在,但 JS 可能仍在运行。速度快。用于验证静态内容,或之后本来就会调用 waitForSelector 的情况。 |
'networkidle' | 所有 XHR / fetch 请求静默 500ms。用于网站依赖 DOMContentLoaded 之后加载的数据时(例如 course-data.js 注入的 COURSE 会渲染单元)。速度较慢,但更可靠。 |
'load' | 仅等待文档的 load 事件。通常会造成误导——异步数据可能仍在等待中。避免用于 SPA。 |
SPA 验证默认使用 'networkidle';只有遇到超时时,才改用 'domcontentloaded' + 显式调用 waitForSelector。
截图约定
data/
├── mobile-verify/
│ ├── iphone-se-home.png
│ ├── iphone-se-sidebar-open.png
│ ├── iphone-13-home.png
│ └── desktop-home.png
├── toolbox-verify/
│ └── ...
└── design-audit/ ← capture-*.mjs goes here (no assertions, just evidence)
每个验证领域使用一个文件夹。文件名格式:{profile-id}-{state}.png。不要把所有内容都倾倒到 data/ 中。
诊断脚本模式(验证失败时)
诊断脚本不执行断言,而是收集上下文。输出包括 DOM、控制台、计算样式和截图的转储。
// scripts/diagnose-day2-visibility.mjs
const html = await page.evaluate(() => {
const day2 = document.querySelector('#day2');
if (!day2) return 'NOT FOUND';
return {
boundingBox: day2.getBoundingClientRect().toJSON(),
computedStyle: {
display: getComputedStyle(day2).display,
visibility: getComputedStyle(day2).visibility,
opacity: getComputedStyle(day2).opacity,
transform: getComputedStyle(day2).transform,
},
innerHTML: day2.innerHTML.slice(0, 500),
childCount: day2.children.length,
};
});
console.log(JSON.stringify(html, null, 2));
当验证脚本报告“section.day2 不可见”时运行诊断脚本——它能在几分钟内显示实际的 display: none(或者淡入观察器未触发时的 transform: translateY(20px))。
探测脚本模式(临时 CLI)
这些都是一次性工具,通常只有 20 行。不要过度设计。
// scripts/check-text-absence.mjs "招生人數" "100 人"
const phrases = process.argv.slice(2);
const browser = await chromium.launch({ headless: true });
const page = await (await browser.newContext({ locale: 'zh-TW' })).newPage();
await page.goto(process.env.URL || 'http://localhost:3000/');
const body = await page.evaluate(() => document.body.innerText);
const found = phrases.filter(p => body.includes(p));
console.log(found.length ? `❌ Found: ${found.join(', ')}` : '✅ All absent');
await browser.close();
process.exit(found.length ? 1 : 0);
通过 CLI 参数传入短语列表,让脚本可重复使用。不要把它封装进 verify-* 脚本。
反模式
- 没有
assert.*的验证脚本——只有console.log("ok")不算验证,而是捕获。请如实说明你在编写哪种脚本。 - 一个包办所有任务的巨型脚本——例如,verify-mobile.mjs 还运行 audit-design 和 diagnose-day2。每个脚本只做一件事。在 package.json 层面组合(
"verify:all": "verify-mobile && verify-toolbox && ...")。 - 忽略
pageerror/console.error——如果不监听,静默的 JS 错误会通过所有断言。始终添加这两个监听器,并在末尾断言没有错误。 - 硬编码
localhost:3000——始终遵循process.env.URL。同一个脚本需要能在 CI 中针对已部署的 URL 运行。 - 不带注释的
page.waitForTimeout(N)——用于动画时没问题(注明原因,例如"// 400ms slide-in animation"),但不要把它当作“等一等,碰碰运气”的工具。优先使用waitForSelector或waitForFunction。 - 同时执行断言的诊断脚本——一旦运行中途失败,你就会丢失想要获取的上下文。诊断脚本应记录所有内容,并以 0 退出。
CI 集成
{
"scripts": {
"verify": "node scripts/verify-mobile.mjs && node scripts/verify-toolbox.mjs && node scripts/verify-instructor-card.mjs",
"capture": "node scripts/capture-overview.mjs && node scripts/capture-all-zones.mjs"
}
}
在 CI 中运行 verify(必须通过);设计评审前手动运行 capture。
交接
此技能完成时:
scripts/中应有一组 verify/capture/diagnose/probe 脚本。- 每个脚本都有一行标题注释,说明其作用并提供使用示例。
npm run verify会运行完整的 verify 测试套件。- 截图按
{domain}-verify/分类整理在data/下。
如果有任何失败,而你不知道原因 → 使用与 verify 脚本配套编写的 diagnose 脚本。不要在 verify 脚本本身中添加 console.log。