playwright-screenshot-inspector について
Web ページの自動ビジュアル検証を行う開発者向けの Playwright スキルです。React の描画完了を待ってから、ピクセル比較だけでなくマルチモーダル AI でスクリーンショットを意味的に検査します。
主な機能
- 01ヘッドレス Playwright によるスクリーンショット撮影
- 02React コンテンツの描画待ち
- 03コンテンツの読み込みとレイアウト状態の確認
- 04ダーク・ライトテーマの表示検証
- 05スクリーンショット比較と差分評価
- 06可読性と視覚的アクセシビリティの確認
活用シーン
- 01ページの撮影結果が空白や読み込み中でないか確認
- 02ダーク・ライトテーマが正しく適用されるか検証
- 03UI の変更が意図的か不具合かを判別
MCP 対応
対話的なブラウザー操作には Playwright MCP、自動テストスイートには標準の Python Playwright を推奨しています。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ curiositech/ some_claude_skills --skill playwright-screenshot-inspectorインストール後、Claude Code で/playwright-screenshot-inspectorを入力して読み込まれたか確認します。
同梱ファイル.claude-plugin/plugin.json
詳細
Playwright スクリーンショットインスペクター
Playwright とマルチモーダル AI による検査を使用して、スクリーンショットの自動取得、分析、UI 検証を行う LLM 搭載のビジュアルテストエキスパートです。
アクティベーションのトリガー
次の場合にアクティベートします:
- 「screenshot test」、「visual test」、「screenshot inspection」
- 「playwright headless」、「playwright screenshot」
- 「UI verification」、「visual regression」
- 「theme compliance test」、「dark mode test」、「light mode test」
- 「automated screenshot」、「capture and analyze」
- 「compare screenshots」、「visual diff」
次の場合には使用しません:
- 単発の簡単なスクリーンショット(ブラウザーの DevTools を使用してください)
- AI を使わないピクセル単位の比較(Playwright 標準の
toHaveScreenshotを使用してください) - Web 以外の UI テスト(プラットフォーム固有のツールを使用してください)
- パフォーマンステスト(Lighthouse/WebPageTest を使用してください)
基本方針
従来のビジュアルテストはピクセルを比較します。LLM 搭載のビジュアルテストは意味を理解します。
「この 50 ピクセルが変わった」ではなく、LLM による検査では次のような質問に答えます:
- 「コンテンツは実際にレンダリングされていますか?」
- 「テーマは正しく切り替わりますか?」
- 「操作可能な要素は表示され、適切にスタイル設定されていますか?」
- 「何が壊れていて、何が単に異なっているだけですか?」
スクリーンショット検査のループ
┌─────────────────────────────────────────────────────────────┐
│ LLM SCREENSHOT INSPECTION │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. CAPTURE (Playwright) │
│ └─► Wait for React hydration, not just network │
│ │
│ 2. READ (Claude vision) │
│ └─► Pass screenshot to LLM with specific questions │
│ │
│ 3. ANALYZE (Structured response) │
│ └─► Extract: content present? theme correct? errors? │
│ │
│ 4. ACT (Conditional logic) │
│ └─► Pass/fail based on semantic understanding │
│ │
└─────────────────────────────────────────────────────────────┘
重要:React コンテンツの表示を待つ
最もよくある失敗:React のハイドレーション前にスクリーンショットを撮影することです。
アンチパターン:ネットワークアイドル状態だけを待つ
# ❌ WRONG - React may not have rendered yet
page.goto(url)
page.wait_for_load_state('networkidle')
page.screenshot(path='broken.png') # Often blank!
正しいパターン:実際のコンテンツを待つ
# ✅ CORRECT - Wait for React to mount
page.goto(url, wait_until='domcontentloaded')
page.wait_for_load_state('networkidle')
# Give React time to hydrate
import time
time.sleep(0.5)
# Wait for actual content selector
page.wait_for_selector('.main-content, h1, [data-testid="app"]',
state='visible',
timeout=10000)
# Verify content exists
body_text = page.locator('body').inner_text()
if len(body_text) < 50:
time.sleep(2) # Extra wait for slow hydration
page.screenshot(path='good.png', full_page=True)
コンテンツ検証関数
def wait_for_react_content(page, selectors, timeout=10000):
"""Wait for React to hydrate by checking for actual content."""
page.wait_for_load_state('domcontentloaded')
page.wait_for_load_state('networkidle')
time.sleep(0.5) # React hydration buffer
for selector in selectors.split(','):
try:
locator = page.locator(selector.strip())
if locator.count() > 0:
locator.first.wait_for(state='visible', timeout=timeout)
return True
except:
continue
# Fallback: wait for substantial body content
try:
page.wait_for_function(
'document.body.innerText.length > 100',
timeout=timeout
)
return True
except:
return False
ヘッドレスモード:ウィンドウの大量起動を防ぐ
ブラウザーウィンドウが起動しないように、常に headless=True を使用してください:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
# CRITICAL: headless=True prevents visible browser windows
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={'width': 1280, 'height': 800},
color_scheme='dark' # Initial theme
)
page = context.new_page()
# ... your test logic ...
browser.close() # Always clean up
テーマテストのパターン
# Dark mode screenshot
page.emulate_media(color_scheme='dark') # Note: on PAGE, not context
page.goto(url)
wait_for_react_content(page, '.app-container, main, h1')
page.screenshot(path='dark.png', full_page=True)
# Light mode screenshot
page.emulate_media(color_scheme='light')
page.reload()
wait_for_react_content(page, '.app-container, main, h1')
page.screenshot(path='light.png', full_page=True)
LLM によるスクリーンショット分析のパターン
パターン 1:コンテンツの検証
Prompt: "Analyze this screenshot. Answer:
1. Is the main content rendered (not blank/loading)?
2. What major UI elements are visible?
3. Are there any error states or broken layouts?
4. Rate content completeness: FULL / PARTIAL / EMPTY"
パターン 2:テーマ準拠の確認
Prompt: "This is a {dark/light} mode screenshot. Verify:
1. Background color matches expected theme (dark bg for dark mode)
2. Text has sufficient contrast against background
3. Interactive elements are visible and styled correctly
4. No theme leakage (dark elements on light bg or vice versa)"
パターン 3:比較分析
Prompt: "Compare these two screenshots (before/after). Identify:
1. What changed between them?
2. Are changes intentional (theme switch) or bugs?
3. Is any content missing in the 'after' version?
4. Rate similarity: IDENTICAL / MINOR_DIFF / MAJOR_DIFF / BROKEN"
パターン 4:アクセシビリティチェック
Prompt: "Evaluate this screenshot for visual accessibility:
1. Is text readable (sufficient size and contrast)?
2. Are interactive elements clearly identifiable?
3. Is there visual hierarchy (headings, sections)?
4. Any elements that would fail WCAG contrast requirements?"
テストスクリプトの完全なテンプレート
#!/usr/bin/env python3
"""
LLM-Powered Screenshot Test Suite
Captures screenshots and uses Claude vision for semantic analysis.
"""
from playwright.sync_api import sync_playwright
import os
import time
PAGES_TO_TEST = [
# (path, name, content_selectors)
('/', 'Home', '.hero, main, h1'),
('/about', 'About', '.about-content, main, h1'),
('/dashboard', 'Dashboard', '.dashboard, .stats, h1'),
]
BASE_URL = 'http://localhost:5173'
SCREENSHOT_DIR = '/tmp/visual-tests'
def wait_for_content(page, selectors, timeout=10000):
"""Wait for React/Vue/Svelte to hydrate."""
page.wait_for_load_state('domcontentloaded')
page.wait_for_load_state('networkidle')
time.sleep(0.5)
for selector in selectors.split(','):
try:
loc = page.locator(selector.strip())
if loc.count() > 0:
loc.first.wait_for(state='visible', timeout=timeout)
return True
except:
continue
try:
page.wait_for_function('document.body.innerText.length > 100', timeout=timeout)
return True
except:
return False
def capture_themed_screenshots(page, url, name, selectors):
"""Capture both dark and light mode screenshots."""
safe_name = name.lower().replace(' ', '-')
results = {'name': name, 'url': url}
for theme in ['dark', 'light']:
page.emulate_media(color_scheme=theme)
if theme == 'dark':
page.goto(url, wait_until='domcontentloaded')
else:
page.reload(wait_until='domcontentloaded')
content_loaded = wait_for_content(page, selectors)
if not content_loaded:
print(f" ⚠️ {theme} mode: Content slow to load, waiting...")
time.sleep(2)
screenshot_path = f'{SCREENSHOT_DIR}/{safe_name}-{theme}.png'
page.screenshot(path=screenshot_path, full_page=True)
# Check content length
body_text = page.locator('body').inner_text().strip()
results[f'{theme}_screenshot'] = screenshot_path
results[f'{theme}_content_length'] = len(body_text)
results[f'{theme}_has_content'] = len(body_text) > 50
print(f" {theme}: {'✅' if results[f'{theme}_has_content'] else '❌'} ({len(body_text)} chars)")
return results
def run_tests():
"""Run visual tests on all pages."""
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={'width': 1280, 'height': 800},
color_scheme='dark'
)
page = context.new_page()
# Capture console errors
errors = []
page.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None)
results = []
for path, name, selectors in PAGES_TO_TEST:
print(f"Testing {name}...")
url = f'{BASE_URL}{path}'
result = capture_themed_screenshots(page, url, name, selectors)
result['errors'] = list(errors)
errors.clear()
results.append(result)
browser.close()
# Summary
print("\n" + "=" * 50)
print("VISUAL TEST SUMMARY")
print("=" * 50)
passed = sum(1 for r in results
if r.get('dark_has_content') and r.get('light_has_content'))
print(f"\nPassed: {passed}/{len(results)}")
print(f"Screenshots: {SCREENSHOT_DIR}")
return results
if __name__ == '__main__':
run_tests()
MCP と Playwright 標準機能の選択フローチャート
What are you doing?
│
├─ Interactive debugging / exploring
│ └─► Playwright MCP (see live browser)
│
├─ Automated test suite
│ └─► Native Python Playwright (headless)
│
├─ CI/CD pipeline
│ └─► Native Python Playwright (headless)
│
├─ Screenshot capture for LLM analysis
│ └─► Native Python Playwright (headless)
│
└─ One-off inspection
└─► Either works, MCP is convenient
よくある失敗と修正方法
失敗:スクリーンショットが真っ白
原因:React のハイドレーション前にスクリーンショットを撮影しています 修正方法:コンテンツセレクターを待ち、ハイドレーション用の待機時間を追加します
失敗:「Reconnecting...」バッジが表示される
原因:HMR/WebSocket が接続されていません(テストでは見た目だけの問題です) 修正方法:問題ない場合が多いため、実際のコンテンツに注目します
失敗:テーマが適用されない
原因:emulate_media をページではなくコンテキストで呼び出しています
修正方法:page.emulate_media(color_scheme='dark') を使用します
失敗:ブラウザーウィンドウが起動する
原因:headless=False を指定しているか、標準機能の代わりに MCP を使用しています
修正方法:p.chromium.launch(headless=True) を使用します
失敗:コンテンツの表示がタイムアウトする
原因:セレクターが間違っているか、ページ自体に問題があります 修正方法:セレクターが存在することを確認し、コンソールエラーを確認します
Claude Code との統合
Claude がこのパターンで取得したスクリーンショットを読み取る場合:
- 具体的な分析を依頼する:スクリーンショットを見せるだけでなく、対象を絞った質問をします
- コンテキストを伝える:「これはダークモードです」や「これはログインページです」
- 体系的に比較する:変更前と変更後、ダークとライト、デスクトップとモバイル
- 意味的な分析を信頼する:LLM は「空白のページ」と「コンテンツが読み込まれたページ」を見分けられます
参考資料
研究論文
- UI テストにおける Vision LLM の活用 - ワシントン大学
- ビジョン駆動型の自動モバイル GUI テスト - マルチモーダル LLM のアプローチ
- ScreenLLM:状態を持つ画面スキーマ - UI 理解フレームワーク
ツールと統合
公式ドキュメント
バージョン履歴
- 2026-01-23:スキルを初めて作成
- マルチモーダル LLM によるスクリーンショット分析のベストプラクティスを調査
- React のハイドレーション完了を待つパターンを文書化
- ヘッドレスモードの要件を追加
- テストスクリプトの完全なテンプレートを作成
主なポイント:役に立たないスクリーンショットテストと役に立つスクリーンショットテストの違いは、ネットワークだけでなくコンテンツを待つことです。LLM はセマンティクスを分析できますが、分析するコンテンツが実際に存在する場合に限ります。