web-visual-verification
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWeb Visual Verification
Web视觉验证
Schema authority: when verifying that data renders correctly, the source-of-truth field names come from. E.g. asserting_shared/domain-primitives.mdmatches §11's Coverage Floor.unit.illustrations.length >= 1Reference implementation:for production verify-script patterns (multi-viewport, console-error capture, screenshot-on-fail).d:/GitHub/ai-workshop/scripts/verify-*.mjs
This skill produces Playwright-based runtime verification scripts that drive the actual rendered site and answer "does it behave correctly?". The skill emerged from a teaching-website project but applies to any static or dynamic web UI.
Schema权威来源:验证数据渲染是否正确时,基准字段名称来自。例如,断言_shared/domain-primitives.md符合第11节的覆盖要求。unit.illustrations.length >= 1参考实现:生产环境验证脚本的模式可参考(多视口、控制台错误捕获、失败时截图)。d:/GitHub/ai-workshop/scripts/verify-*.mjs
此技能会生成基于Playwright的运行时验证脚本,驱动实际渲染的网站并验证「它是否正常工作?」。该技能源自一个教学网站项目,但适用于任何静态或动态Web UI。
When to Invoke
调用时机
- After any interaction-layer change (Stage 4 interactions, sidebar logic, theme, zoom).
- After any visual asset change (Stage 5 — illustrations, screenshots).
- After any layout / CSS change ("the sidebar collapsed weirdly", "the modal lost its backdrop").
- Before tagging a release or handing off a corporate edition.
- When the user asks "did that change break anything else?".
Do NOT invoke for file/data consistency checks (use ).
web-content-audit- 任何交互层修改后(第4阶段交互、侧边栏逻辑、主题、缩放)。
- 任何视觉资源修改后(第5阶段——插图、截图)。
- 任何布局/CSS修改后(如「侧边栏折叠异常」、「模态框丢失背景遮罩」)。
- 发布版本或交付企业版之前。
- 用户询问「那次修改有没有弄坏其他东西?」时。
请勿调用此技能进行文件/数据一致性检查(请使用)。
web-content-auditThe Four Script Roles — Use the Right One
四种脚本角色——选择合适的类型
Every Playwright script in this skill has exactly one of these jobs. Mixing roles produces unmaintainable scripts.
| Role | Prefix | Has assertions? | Outputs | When to use |
|---|---|---|---|---|
| Verify | | Yes ( | Pass/fail + screenshots | Default — runtime regression check |
| Capture | | No | Screenshots only | Need visual evidence without judgement (design review, change log) |
| Diagnose | | No (but logs heavily) | Console dump + DOM tree + screenshots | When a verify failed and you need to figure out why |
| Probe | | Targeted, often ad-hoc | Console output | Quick CLI: "does this string appear?", "where is element X?" |
The discipline: a verify script must fail loudly. A capture script must never fail (it's just evidence). A diagnose script is exploratory. A probe is a one-liner replacement for browser devtools.
此技能中的每个Playwright脚本都仅承担一项任务。混合角色会导致脚本难以维护。
| 角色 | 前缀 | 是否包含断言? | 输出内容 | 使用场景 |
|---|---|---|---|---|
| 验证 | | 是( | 成功/失败结果 + 截图 | 默认场景——运行时回归检查 |
| 捕获 | | 否 | 仅截图 | 需要视觉证据但无需判断时(设计评审、变更日志) |
| 诊断 | | 否(但会大量日志) | 控制台输出 + DOM树 + 截图 | 验证脚本失败后,需要排查原因时 |
| 探测 | | 针对性强,通常为临时脚本 | 控制台输出 | 快速CLI工具:「这个字符串是否存在?」、「元素X在哪里?」 |
原则:验证脚本必须在失败时明确提示;捕获脚本绝对不能失败(仅作为证据);诊断脚本用于探索问题;探测脚本是浏览器开发者工具的单行替代方案。
Skeleton: Multi-Viewport Verify Script
骨架:多视口验证脚本
This is the workhorse pattern. Adapt for any UX flow.
js
// 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(); }这是核心模式,可适配任何用户交互流程。
js
// 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();
// === 关键:收集运行时错误 ===
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 });
// === 此处添加针对不同设备的断言 ===
const isMobile = (profile.device.viewport?.width ?? 1280) <= 768;
// 示例:断言不存在水平溢出
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
assert.equal(overflow, false, `${profile.id}: 水平溢出`);
// 在关键状态下截图
await mkdir(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: resolve(SCREENSHOT_DIR, `${profile.id}-home.png`), fullPage: false });
// === 如果收集到控制台错误则失败 ===
assert.equal(errors.length, 0, `${profile.id} console errors:\
${errors.join('\
')}`);
await ctx.close();
}
const browser = await chromium.launch({ headless: true });
try {
for (const p of PROFILES) await verifyProfile(browser, p);
console.log('✅ 所有设备验证通过');
} finally { await browser.close(); }Reusable Assertions (Copy When Adding New Verify Scripts)
可复用断言(添加新验证脚本时可复制)
Horizontal overflow (the most common layout bug)
水平溢出(最常见的布局问题)
js
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
assert.ok(overflow.scrollWidth <= overflow.innerWidth, `水平溢出 ${overflow.scrollWidth} > ${overflow.innerWidth}`);js
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
assert.ok(overflow.scrollWidth <= overflow.innerWidth, `水平溢出 ${overflow.scrollWidth} > ${overflow.innerWidth}`);Window scroll actually works (catches overflow-x: hidden
implicit lock)
overflow-x: hidden窗口滚动功能正常(捕获overflow-x: hidden
导致的滚动锁定)
overflow-x: hiddenjs
await page.evaluate(() => window.scrollTo(0, 1500));
const y = await page.evaluate(() => window.scrollY);
assert.ok(y > 0, 'window 無法捲動(檢查 ancestor 是否有 overflow:hidden)');js
await page.evaluate(() => window.scrollTo(0, 1500));
const y = await page.evaluate(() => window.scrollY);
assert.ok(y > 0, 'window 無法捲動(檢查 ancestor 是否有 overflow:hidden)');Sidebar overlay behaviour (mobile)
侧边栏覆盖行为(移动端)
js
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);
}js
if (isMobile) {
// 默认状态:侧边栏隐藏
const before = await page.locator('.sidebar').boundingBox();
assert.ok(!before || before.x < 0, 'sidebar should be off-screen by default on mobile');
// 点击汉堡菜单打开
await page.locator('.menu-toggle').click();
await page.waitForTimeout(400); // 动画等待
const after = await page.locator('.sidebar').boundingBox();
assert.ok(after && after.x >= 0, 'sidebar should slide in after toggle');
// 点击背景遮罩关闭
await page.locator('.sidebar-backdrop').click();
await page.waitForTimeout(400);
}localStorage round-trip (progress persistence)
localStorage往返测试(进度持久化)
js
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:// 限制)');js
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 zoom isolated to .content
(not sidebar)
.content内容缩放仅作用于.content
(不影响侧边栏)
.contentjs
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');js
const sidebarRect = await page.locator('.sidebar').boundingBox();
// 触发放大
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 vs domcontentloaded — Choose Carefully
NetworkIdle vs domcontentloaded——谨慎选择
| When to use |
|---|---|
| DOM exists but JS may still be running. Fast. Use when verifying static content or when you |
| All XHR/fetch quiet for 500ms. Use when site depends on data loaded post-DOMContentLoaded (e.g. |
| Only the document's |
Default to for SPA verification; switch to + explicit only if you hit timeouts.
'networkidle''domcontentloaded'waitForSelector | 使用场景 |
|---|---|
| DOM已存在但JS可能仍在运行。速度快。验证静态内容或后续会使用 |
| 所有XHR/fetch请求静默500ms后。当网站依赖DOMContentLoaded之后加载的数据时使用(例如注入的 |
| 仅等待文档的 |
验证SPA时默认使用;仅在遇到超时问题时,切换为 + 显式。
'networkidle''domcontentloaded'waitForSelectorScreenshot Conventions
截图命名规范
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)One folder per verify domain. Filename pattern: . Don't dump everything into .
{profile-id}-{state}.pngdata/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 的截图存放于此(无断言,仅作为证据)每个验证领域对应一个文件夹。文件名格式:。不要将所有截图都放在根目录下。
{设备标识}-{状态}.pngdata/Diagnose Script Pattern (When Verify Fails)
诊断脚本模式(验证失败时使用)
A diagnose script does NOT assert — it gathers context. Output is a dump of DOM, console, computed styles, screenshots.
js
// 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));Run when a verify script reports "section.day2 not visible" — the diagnose gives you the actual (or if fade-in observer didn't fire) within minutes.
display: nonetransform: translateY(20px)诊断脚本不包含断言——它仅收集上下文信息。输出内容包括DOM、控制台、计算样式和截图。
js
// 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: nonetransform: translateY(20px)Probe Script Pattern (Ad-hoc CLI)
探测脚本模式(临时CLI工具)
These are 20-line one-shot tools. Don't over-engineer.
js
// 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 args for the phrase list keeps it reusable. Don't wrap in a verify-* script.
这些是20行左右的一次性工具,无需过度设计。
js
// 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-*脚本。
Anti-Patterns
反模式
- Verify scripts without — just a
assert.*is not a verify, it's a capture. Be honest about which role you're writing.console.log("ok") - One mega-script that does everything — verify-mobile.mjs that also runs audit-design and diagnose-day2. Each script has one job. Compose at the package.json level ().
"verify:all": "verify-mobile && verify-toolbox && ..." - Ignoring /
pageerror— silent JS errors will sail through every assertion if you don't listen for them. ALWAYS attach the two listeners and assert empty at end.console.error - Hardcoded — always honour
localhost:3000. The same script needs to run against deployed URLs in CI.process.env.URL - without comment — fine for animations (note why, e.g. "// 400ms slide-in animation"), bad as a "just wait and hope" tool. Prefer
page.waitForTimeout(N)orwaitForSelector.waitForFunction - Diagnose scripts that also assert — the moment they fail mid-run you lose the context you came for. Diagnose = log everything, exit 0.
- 无的验证脚本——仅输出
assert.*不是验证脚本,而是捕获脚本。请明确脚本的角色。console.log("ok") - 包揽所有任务的巨型脚本——比如verify-mobile.mjs同时运行audit-design和diagnose-day2。每个脚本只能承担一项任务。可在package.json层面组合()。
"verify:all": "verify-mobile && verify-toolbox && ..." - 忽略/
pageerror——如果不监听这些事件,静默的JS错误会绕过所有断言。务必添加这两个监听器,并在最后断言错误列表为空。console.error - 硬编码——始终遵循
localhost:3000。同一脚本需要在CI环境中针对已部署的URL运行。process.env.URL - 无注释的——用于等待动画时没问题(需注明原因,如「// 400ms滑入动画」),但作为「单纯等待碰运气」的工具则不可取。优先使用
page.waitForTimeout(N)或waitForSelector。waitForFunction - 包含断言的诊断脚本——一旦脚本中途失败,你就会丢失所需的上下文信息。诊断脚本=记录所有信息,正常退出。
CI Integration
CI集成
json
{
"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"
}
}Run in CI (must pass); run manually before design reviews.
verifycapturejson
{
"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中运行(必须通过);在设计评审前手动运行。
verifycaptureHand-off
交付标准
When this skill finishes:
- A set of verify/capture/diagnose/probe scripts is in .
scripts/ - Each has a one-line header comment explaining its role and a usage example.
- runs the full verify suite.
npm run verify - Screenshots organised under .
data/{domain}-verify/
If anything failed and you don't know why → reach for the diagnose script you wrote alongside the verify. Don't add to the verify itself.
console.log当此技能完成后:
- 目录下会有一组验证/捕获/诊断/探测脚本。
scripts/ - 每个脚本都有一行头部注释,说明其角色和使用示例。
- 可运行完整的验证套件。
npm run verify - 截图按的结构组织。
data/{领域}-verify/
如果验证失败且你不知道原因,请使用与验证脚本配套编写的诊断脚本。不要在验证脚本中添加。",
console.log