Loading...
Loading...
Use this skill whenever you need to verify that a web UI actually works the way it's supposed to — clicking through flows, asserting state, catching console errors, taking screenshots across multiple viewports for visual review. Triggers on phrases like "驗證網頁", "verify the site", "Playwright tests", "visual regression", "RWD verification", "screenshot comparison", "responsive check", "console error check", "看看手機版有沒有壞", "視覺驗證", "Playwright 測試", "截圖比對", "RWD 驗證", "驗 sidebar", "看 console 有沒有錯", or any post-change moment where the user wants to know "did I break anything?". This is the runtime-behaviour verifier — use `web-content-audit` instead if the question is about file/data consistency rather than rendered behaviour.
npx skill4agent add kevintsai1202/teaching-site-skills web-visual-verificationSchema 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
web-content-audit| 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?" |
// 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: hiddenawait 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);
}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:// 限制)');.contentconst 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'); | 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 |
'networkidle''domcontentloaded'waitForSelectordata/
├── 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}.pngdata/// 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));display: nonetransform: translateY(20px)// 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);assert.*console.log("ok")"verify:all": "verify-mobile && verify-toolbox && ..."pageerrorconsole.errorlocalhost:3000process.env.URLpage.waitForTimeout(N)waitForSelectorwaitForFunction{
"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"
}
}verifycapturescripts/npm run verifydata/{domain}-verify/console.log