web-visual-verification

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Web Visual Verification

Web视觉验证

Schema authority: when verifying that data renders correctly, the source-of-truth field names come from
_shared/domain-primitives.md
. E.g. asserting
unit.illustrations.length >= 1
matches §11's Coverage Floor.
Reference implementation:
d:/GitHub/ai-workshop/scripts/verify-*.mjs
for production verify-script patterns (multi-viewport, console-error capture, screenshot-on-fail).
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
。例如,断言
unit.illustrations.length >= 1
符合第11节的覆盖要求。
参考实现:生产环境验证脚本的模式可参考
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-audit
)。

The Four Script Roles — Use the Right One

四种脚本角色——选择合适的类型

Every Playwright script in this skill has exactly one of these jobs. Mixing roles produces unmaintainable scripts.
RolePrefixHas assertions?OutputsWhen to use
Verify
verify-*.mjs
Yes (
assert.*
)
Pass/fail + screenshotsDefault — runtime regression check
Capture
capture-*.mjs
NoScreenshots onlyNeed visual evidence without judgement (design review, change log)
Diagnose
diagnose-*.mjs
No (but logs heavily)Console dump + DOM tree + screenshotsWhen a verify failed and you need to figure out why
Probe
check-*.mjs
,
find-*.mjs
Targeted, often ad-hocConsole outputQuick 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脚本都仅承担一项任务。混合角色会导致脚本难以维护。
角色前缀是否包含断言?输出内容使用场景
验证
verify-*.mjs
是(
assert.*
成功/失败结果 + 截图默认场景——运行时回归检查
捕获
capture-*.mjs
仅截图需要视觉证据但无需判断时(设计评审、变更日志)
诊断
diagnose-*.mjs
否(但会大量日志)控制台输出 + DOM树 + 截图验证脚本失败后,需要排查原因时
探测
check-*.mjs
,
find-*.mjs
针对性强,通常为临时脚本控制台输出快速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
导致的滚动锁定)

js
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
(不影响侧边栏)

js
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——谨慎选择

waitUntil:
When to use
'domcontentloaded'
DOM exists but JS may still be running. Fast. Use when verifying static content or when you
waitForSelector
afterward anyway.
'networkidle'
All XHR/fetch quiet for 500ms. Use when site depends on data loaded post-DOMContentLoaded (e.g.
course-data.js
injected COURSE renders units). Slower but robust.
'load'
Only the document's
load
event. Often misleading — async data may still be pending. Avoid for SPAs.
Default to
'networkidle'
for SPA verification; switch to
'domcontentloaded'
+ explicit
waitForSelector
only if you hit timeouts.
waitUntil:
使用场景
'domcontentloaded'
DOM已存在但JS可能仍在运行。速度快。验证静态内容或后续会使用
waitForSelector
时使用。
'networkidle'
所有XHR/fetch请求静默500ms后。当网站依赖DOMContentLoaded之后加载的数据时使用(例如注入的
course-data.js
渲染课程单元)。速度较慢但更可靠。
'load'
仅等待文档的
load
事件。通常具有误导性——异步数据可能仍在加载中。单页应用(SPA)避免使用。
验证SPA时默认使用
'networkidle'
;仅在遇到超时问题时,切换为
'domcontentloaded'
+ 显式
waitForSelector

Screenshot 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:
{profile-id}-{state}.png
. Don't dump everything into
data/
.
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 的截图存放于此(无断言,仅作为证据)
每个验证领域对应一个文件夹。文件名格式:
{设备标识}-{状态}.png
。不要将所有截图都放在
data/
根目录下。

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
display: none
(or
transform: translateY(20px)
if fade-in observer didn't fire) within minutes.
诊断脚本不包含断言——它仅收集上下文信息。输出内容包括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: none
,或者淡入观察者未触发导致
transform: 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
    assert.*
    — just a
    console.log("ok")
    is not a verify, it's a capture. Be honest about which role you're writing.
  • 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
    /
    console.error
    — 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.
  • Hardcoded
    localhost:3000
    — always honour
    process.env.URL
    . The same script needs to run against deployed URLs in CI.
  • page.waitForTimeout(N)
    without comment
    — fine for animations (note why, e.g. "// 400ms slide-in animation"), bad as a "just wait and hope" tool. Prefer
    waitForSelector
    or
    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
    /
    console.error
    ——如果不监听这些事件,静默的JS错误会绕过所有断言。务必添加这两个监听器,并在最后断言错误列表为空
  • 硬编码
    localhost:3000
    ——始终遵循
    process.env.URL
    。同一脚本需要在CI环境中针对已部署的URL运行。
  • 无注释的
    page.waitForTimeout(N)
    ——用于等待动画时没问题(需注明原因,如「// 400ms滑入动画」),但作为「单纯等待碰运气」的工具则不可取。优先使用
    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
verify
in CI (must pass); run
capture
manually before design reviews.
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"
  }
}
在CI中运行
verify
(必须通过);在设计评审前手动运行
capture

Hand-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.
  • npm run verify
    runs the full verify suite.
  • 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
console.log
to the verify itself.
当此技能完成后:
  • scripts/
    目录下会有一组验证/捕获/诊断/探测脚本。
  • 每个脚本都有一行头部注释,说明其角色和使用示例。
  • npm run verify
    可运行完整的验证套件。
  • 截图按
    data/{领域}-verify/
    的结构组织。
如果验证失败且你不知道原因,请使用与验证脚本配套编写的诊断脚本。不要在验证脚本中添加
console.log
。",