mcpField
W
web-visual-verification

web-visual-verification

github.com/kevintsai1202测试与质量5264 次安装24 周前更新
代码仓库

关于 web-visual-verification

用于编写 Playwright 脚本,检查网页界面的实际运行行为。它将脚本分为验证、截图、诊断和快速探查四类,并提供断言、错误处理、多视口及 CI 指引。

主要功能

  1. 01四类脚本职责划分
  2. 02多视口运行时检查
  3. 03可复用的界面断言
  4. 04控制台与页面错误检查
  5. 05截图及 CI 约定

适用场景

  1. 01界面更改后检查布局和交互
  2. 02为设计评审采集截图
  3. 03通过 DOM 和样式信息排查故障
  4. 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。

评论

支持 Markdown