web-visual-verification について
Web UIの実行時の挙動を確認するPlaywrightスクリプトの作成を支援するスキルです。検証、スクリーンショット取得、診断、簡易プローブを区別し、アサーションやエラー処理、複数ビューポート、CIの指針を示します。
主な機能
- 014種類のスクリプト役割
- 02複数ビューポートの実行時検査
- 03再利用可能なUIアサーション
- 04コンソールとページエラーの確認
- 05スクリーンショットとCIの規約
活用シーン
- 01UI変更後のレイアウトと操作の確認
- 02デザインレビュー用の画像取得
- 03DOMやスタイル情報による不具合調査
- 04リリース前の回帰チェック
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ kevintsai1202/ teaching-site-skills --skill web-visual-verificationインストール後、Claude Code で/web-visual-verificationを入力して読み込まれたか確認します。
詳細
Web ビジュアル検証
スキーマの正:データが正しくレンダリングされることを検証する場合、正とするフィールド名は
_shared/domain-primitives.mdに記載されています。たとえば、unit.illustrations.length >= 1を検証すると、§11 の Coverage Floor に合致します。リファレンス実装:本番用 verify スクリプトのパターン(複数ビューポート、コンソールエラーの取得、失敗時のスクリーンショット)については、
d:/GitHub/ai-workshop/scripts/verify-*.mjsを参照してください。
このスキルでは、実際にレンダリングされたサイトを操作し、「正しく動作しているか」を確認する Playwright ベースの実行時検証スクリプトを作成します。このスキルは教育用ウェブサイトのプロジェクトから生まれましたが、静的または動的なあらゆるウェブ UI に適用できます。
使用するタイミング
- インタラクション層の変更後(Stage 4 のインタラクション、サイドバーのロジック、テーマ、ズーム)。
- ビジュアルアセットの変更後(Stage 5 — イラスト、スクリーンショット)。
- レイアウトや CSS の変更後(「サイドバーの折りたたみ方がおかしい」「モーダルの背景が消えた」など)。
- リリースにタグを付ける前、または企業向けエディションを引き渡す前。
- ユーザーから「その変更でほかの箇所が壊れていないか」と尋ねられたとき。
ファイルやデータの整合性チェックには使用しないでください(web-content-audit を使用してください)。
4 種類のスクリプトの役割 — 適切なものを使う
このスキルの Playwright スクリプトには、それぞれ 次の役割のうち 1 つだけがあります。役割を混在させると、保守しにくいスクリプトになります。
| 役割 | 接頭辞 | アサーションあり? | 出力 | 使用する場面 |
|---|---|---|---|---|
| 検証 | verify-*.mjs | はい(assert.*) | 成功/失敗 + スクリーンショット | デフォルト — 実行時のリグレッションチェック |
| キャプチャ | capture-*.mjs | いいえ | スクリーンショットのみ | 判断を加えずに視覚的な証拠が必要な場合(デザインレビュー、変更履歴) |
| 診断 | diagnose-*.mjs | いいえ(ただし詳細にログを記録) | コンソールダンプ + DOM ツリー + スクリーンショット | 検証に失敗し、原因を特定したい場合 |
| プローブ | check-*.mjs、find-*.mjs | 対象を絞ったもの。多くはアドホック | コンソール出力 | 手早い CLI 確認:「この文字列は表示されているか?」「要素 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)
検証対象のドメインごとにフォルダーを 1 つ作成します。ファイル名の形式は {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")を出すだけでは検証ではなく、キャプチャです。どの役割のスクリプトを書いているのか、正直に判断してください。- すべてをこなす巨大なスクリプト — audit-design と diagnose-day2 も実行する verify-mobile.mjs など。各スクリプトは役割を1つに絞ります。
package.jsonのレベルで組み合わせます("verify:all": "verify-mobile && verify-toolbox && ...")。 pageerror/console.errorを無視すること — リッスンしておかなければ、JavaScript のエラーが気づかれないまま、すべてのアサーションを通過してしまいます。必ず2つのリスナーを登録し、最後に空であることをアサートしてください。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 スクリプトが揃っています。- 各スクリプトに、役割と使用例を説明する1行のヘッダーコメントがあります。
npm run verifyで検証スイート全体が実行されます。- スクリーンショットは
data/{domain}-verify/に整理されています。
何かが失敗し、原因がわからない場合は、verify と併せて作成した diagnose スクリプトを使ってください。verify 自体に console.log を追加しないでください。