【1:25-2:10】
インストールはこれだけです。marketplace add は初回だけ、あとは install。
入るものは3種類。スラッシュで呼ぶスキル、裏で働くサブエージェント、それとフック。
@@ -1482,7 +1482,7 @@
【12:50-13:40】
CodexとKiroです。ここは「同じものが使える」ということだけ持って帰ってください。
-Codexはマーケットプレイス方式で、Claudeとほぼ同じ2コマンド。セッションに入るとndfコロン付きでスキルが並びます。実際に叩いて30個読み込まれているのを確認済みです。
+Codexはマーケットプレイス方式で、Claudeとほぼ同じ2コマンド。セッションに入るとndfコロン付きでスキルが並びます。実際に叩いて23個読み込まれているのを確認済みです。
Kiroだけ方式が違って、リポジトリをcloneしてinstall.shを叩きます。.kiro/skills/ 以下にスキルが並んで、エージェント定義も一緒に作られます。何度実行しても壊れないので、更新したら叩き直せばいいです。
【13:40-14:30】
まとめです。
今日いろいろ見てきましたが、共通しているのは、どのスキルにも「何をやるか」の前に「何を重視するか」が書いてあることです。レビューなら観点の優先順位、文章なら誰に向けて書くか、調査なら何を根拠とするか。
diff --git a/docs/presentations/2026-08-06-ai-plugins-intro.md b/docs/presentations/2026-08-06-ai-plugins-intro.md
index 36d19f46..9dc067fd 100644
--- a/docs/presentations/2026-08-06-ai-plugins-intro.md
+++ b/docs/presentations/2026-08-06-ai-plugins-intro.md
@@ -73,7 +73,7 @@ NDFが何なのかは前回話したので、今日は「で、結局どのス
【0:35-1:25】
構成はシンプルです。スキルの本体は ndf-shared の1か所。ここを直すと、Claude用・Codex用・Kiro用の配布物がビルドで生成されます。
なので「Claudeでは直ってるけどCodexでは古い」が起きない。地味ですがこれが一番効いてます。
-数が違うのは、ランタイムごとに意味のないスキルを外しているからです。Claudeは29、Codexは30、Kiroは28。
+数が違うのは、ランタイムごとに意味のないスキルを外しているからです。Claudeは25、Codexは23、Kiroは24。
MCPプラグインが10個。今日は時間の都合で名前だけにします。
-->
@@ -93,7 +93,7 @@ MCPプラグインが10個。今日は時間の都合で名前だけにします
| | |
|---|---|
-| **スキル 29個** | `/ndf:pr`, `/ndf:review` … スラッシュで直接呼べる |
+| **スキル 25個** | `/ndf:pr`, `/ndf:review` … スラッシュで直接呼べる |
| **エージェント 8個** | director / corder / qa / debugger / devops-engineer など |
| **フック 2種** | SessionStart(transcript保持を90日に維持)/ Stop(AI要約+Slack通知) |
@@ -498,11 +498,11 @@ codex plugin add ndf@ai-plugins
```
セッション内では `ndf:` 接頭辞で
-**30個**のスキルが使えます。
+**23個**のスキルが使えます。
-Claude版との差: エージェント8個とSessionStart/Stopフックは無し。代わりに Playwright 系スキル5個が入ります。
+Claude版との差: エージェント8個とSessionStart/Stopフックは無し。Playwright 系スキル4個は Claude 版にも入ります。
@@ -518,7 +518,7 @@ bash plugins/ndf-kiro/install.sh
kiro-cli chat --agent ndf
```
-`.kiro/skills/` に**20個**、
+`.kiro/skills/` に**23個**(配布 24 個のうち `ndf-policies` は steering へ)、
`.kiro/agents/ndf.json` と
`.kiro/steering/ndf-policies.md` を生成。
@@ -534,7 +534,7 @@ kiro-cli chat --agent ndf
diff --git a/docs/presentations/2026-08-06-ai-plugins-intro.pdf b/docs/presentations/2026-08-06-ai-plugins-intro.pdf
index de4fa1de..1038ef35 100644
Binary files a/docs/presentations/2026-08-06-ai-plugins-intro.pdf and b/docs/presentations/2026-08-06-ai-plugins-intro.pdf differ
diff --git a/docs/presentations/diagrams/overview.mmd b/docs/presentations/diagrams/overview.mmd
index 046ef4d2..696a3e65 100644
--- a/docs/presentations/diagrams/overview.mmd
+++ b/docs/presentations/diagrams/overview.mmd
@@ -1,9 +1,9 @@
flowchart TB
S["plugins/ndf-shared/
skills 29 個
ここだけを編集する"]
- S --> C["ndf-claude
skills 29 + agents 8
+ SessionStart/Stop hook"]
- S --> X["ndf-codex
skills 30 + hook"]
- S --> K["ndf-kiro
skills 28 + install.sh"]
+ S --> C["ndf-claude
skills 25 + agents 8
+ SessionStart/Stop hook"]
+ S --> X["ndf-codex
skills 23 + hook"]
+ S --> K["ndf-kiro
skills 24 + install.sh"]
C --> C2(["Claude Code"])
X --> X2(["Codex CLI"])
diff --git a/docs/presentations/images/overview.png b/docs/presentations/images/overview.png
index 4fb2944e..e6a581cd 100644
Binary files a/docs/presentations/images/overview.png and b/docs/presentations/images/overview.png differ
diff --git a/plugins/ndf-claude/.claude-plugin/plugin.json b/plugins/ndf-claude/.claude-plugin/plugin.json
index f04dbfc8..bf4ac7b8 100644
--- a/plugins/ndf-claude/.claude-plugin/plugin.json
+++ b/plugins/ndf-claude/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "ndf",
"version": "5.0.0",
- "description": "Claude Code plugin (v5.0.0): 8 specialized agents and 22 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, browser automation, Docker container access, statusline, external AI delegation (Codex/Gemini), transcript retention guard, and optional Slack notifications.",
+ "description": "Claude Code plugin (v5.0.0): 8 specialized agents and 25 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, browser automation, Docker container access, statusline, external AI delegation (Codex/Gemini), transcript retention guard, and optional Slack notifications.",
"author": {
"name": "takemi-ohama",
"url": "https://github.com/takemi-ohama"
@@ -43,7 +43,10 @@
"./skills/logging-guidelines",
"./skills/cherry-pick-pr",
"./skills/deploy",
+ "./skills/playwright-planning",
"./skills/playwright-authoring",
+ "./skills/playwright-evidence",
+ "./skills/playwright-kit-ops",
"./skills/external-ai",
"./skills/statusline",
"./skills/issue-plan-strategy",
diff --git a/plugins/ndf-claude/skills/ndf-policies/SKILL.md b/plugins/ndf-claude/skills/ndf-policies/SKILL.md
index 775032d3..176736e2 100644
--- a/plugins/ndf-claude/skills/ndf-policies/SKILL.md
+++ b/plugins/ndf-claude/skills/ndf-policies/SKILL.md
@@ -32,9 +32,9 @@ Skill の棚卸で 49 個を 29 個へ整理した。旧コマンドは存在し
| `/ndf:sync-main` | `/ndf:merged` |
| `/ndf:branch-fix-strategy` | `/ndf:cherry-pick-pr`(原則は本 Skill の「ブランチ運用の原則」) |
| `/ndf:codex` `/ndf:gemini` | `/ndf:external-ai` |
-| `/ndf:playwright-test-planning` `/ndf:playwright-scenario-test` | `/ndf:playwright-planning`(Codex のみ配布) |
+| `/ndf:playwright-test-planning` `/ndf:playwright-scenario-test` | `/ndf:playwright-planning` |
| `/ndf:playwright-script-creation` `/ndf:playwright-execution` `/ndf:browser-test` `/ndf:playwright-browser-connect` | `/ndf:playwright-authoring` |
-| `/ndf:playwright-report` `/ndf:playwright-evidence-drive` | `/ndf:playwright-evidence`(Codex のみ配布) |
+| `/ndf:playwright-report` `/ndf:playwright-evidence-drive` | `/ndf:playwright-evidence` |
移行先を用意せず削除したもの(いずれも起動実績がなく、現在のモデルの標準能力か
汎用コマンドで足りる):
diff --git a/plugins/ndf-claude/skills/playwright-authoring/SKILL.md b/plugins/ndf-claude/skills/playwright-authoring/SKILL.md
index fd67b896..85dd5c09 100644
--- a/plugins/ndf-claude/skills/playwright-authoring/SKILL.md
+++ b/plugins/ndf-claude/skills/playwright-authoring/SKILL.md
@@ -245,6 +245,6 @@ Chrome DevTools MCP の利用可能な方を自動選択する。どちらも使
- `/ndf:review --branch` — 変更差分のコードレビュー
- `/ndf:pr-tests` — PR Test Plan の自動実行
-> `playwright-planning` / `playwright-evidence` / `playwright-kit-ops` は Codex 公開セットにのみ同梱される。
-> Claude Code / Kiro CLI のプラグインには含まれないため、必要な場合はリポジトリ
-> [devbasex/ai-plugins](https://github.com/devbasex/ai-plugins) の `plugins/ndf-shared/skills/` を参照する。
+> `playwright-planning` / `playwright-evidence` / `playwright-kit-ops` は v5.0.0 から
+> Claude Code / Codex / Kiro CLI の 3 ランタイムすべてに同梱される。工程の前段・後段が
+> どのランタイムでも揃うため、上記の参照はそのまま使える。
diff --git a/plugins/ndf-claude/skills/playwright-evidence/SKILL.md b/plugins/ndf-claude/skills/playwright-evidence/SKILL.md
new file mode 100644
index 00000000..ecc4e079
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-evidence/SKILL.md
@@ -0,0 +1,174 @@
+---
+name: playwright-evidence
+description: "Generate the Playwright test report and store its evidence on Google Drive. Use when producing report.md, sharing E2E results, or archiving video / trace / HAR evidence. Triggers: 'report.md', 'テスト報告書', 'エビデンスをDriveへ保管', 'trace viewer'"
+allowed-tools:
+ - Read
+ - Bash(python *)
+ - Bash(uv *)
+ - Bash(pytest *)
+---
+
+# Playwright 証跡とレポート
+
+テスト実行後に Markdown レポートを生成し、エビデンス一式を Google Drive に保管して共有可能にする。
+
+## 前提条件
+
+- テスト実行済みで `reports/
/` にエビデンスが存在すること (`/ndf:playwright-authoring`)
+- Drive へ保管する場合のみ、`google-auth` skill で OAuth2 認証が完了していること (drive.file スコープ)。
+ 同 skill は既定の配布セットに含まれないため、`plugins/ndf-shared/skills/google-auth/` を利用先へ
+ 導入するか、`GOOGLE_AUTH_SCRIPTS` 環境変数で認証スクリプトの場所を指す
+
+## レポート生成
+
+`pytest_terminal_summary` hook で `reports//report.md` が自動生成される。特別な設定は不要。
+
+```bash
+./scenario-test/run.sh
+# → reports//report.md が生成される
+```
+
+| セクション | 内容 |
+|---|---|
+| サマリ表 | nodeid, role, page_role, 結果, 実行時間, エラー数 |
+| 失敗詳細 | FAIL/ERROR のテストごとの詳細情報 |
+| body_check 違反 | PHP/SSR エラー検出の詳細 (URL, パターン, スニペット) |
+| エビデンスリンク | video, trace, HAR, screenshot へのパス |
+
+`scenario.config.yaml` の `report` セクションで表題等を制御する。
+
+```yaml
+report:
+ title: "シナリオ E2E テスト 実施報告書"
+ test_plan_link: "./test-plan.md"
+ phase_labels: {}
+```
+
+## アップロード対象とセキュリティ
+
+| ファイル | 種別 | セキュリティ考慮 |
+|---|---|---|
+| `video.mp4` | テスト動画 | 画面に表示された情報が含まれる |
+| `trace.zip` | Playwright Trace | DOM snapshot + 操作ログ + Cookie/localStorage |
+| `request.har` | ネットワーク通信ログ | HTTP request/response body を含む場合あり |
+| `report.md` | テスト結果サマリ | URL + テスト名程度 |
+| `body_check.jsonl` | PHP/SSR エラー詳細 | ソースコード片を含む場合あり |
+| `screenshot-*.png` | 失敗時スクリーンショット | 画面に表示された情報 |
+
+**セキュリティ注意**: trace.zip / HAR には認証情報 (Cookie, localStorage, Basic Auth) や
+入力内容が含まれる可能性がある。アップロード先は **private folder** + **信頼できる共有相手** に限定すること。
+
+## 方法 1: テスト実行時の自動アップロード
+
+`--pwk-drive-folder` を指定すると `pytest_sessionfinish` hook で自動アップロードされる。
+
+```bash
+./scenario-test/run.sh --pwk-drive-folder=
+```
+
+`report.md` と各テストの `trace.zip` / `*.har` / `*.mp4` / `body_check.jsonl` が、
+すべて非公開 (private) でアップロードされる。
+
+## 方法 2: 手動アップロード (scripts)
+
+スクリプトは `playwright-kit-ops/scripts/` に配置されている。
+
+```bash
+cd scenario-test
+
+# 単一ファイル
+uv run python scripts/upload_evidence.py reports//test_login/trace.zip \
+ --kind trace --parent-folder-id
+
+# ディレクトリ一括 (構造を保ったまま Drive にミラー。サブフォルダも再帰作成)
+uv run python scripts/gdrive_upload_dir.py --local reports// --parent
+
+# report.md → Google Docs 変換 (表・リスト・見出しを Docs ネイティブ形式へ)
+uv run python scripts/upload_md_as_gdoc.py --md reports//report.md \
+ --parent --name "E2E テスト報告書"
+
+# Google Docs にエビデンスの Drive リンクを埋め込み
+uv run python scripts/build_gdoc_with_drive_links.py --md reports//report.md \
+ --folder --run-id --name "E2E テスト報告書"
+```
+
+`upload_evidence.py` の主なオプション:
+
+- `--kind {trace|har|video|any}` — ファイル種別 (拡張子から自動判定も可)
+- `--parent-folder-id ` — Drive 上のアップロード先フォルダ ID
+- `--public` — anyone/read 権限を付与 (trace viewer URL 生成に必要)
+
+`build_gdoc_with_drive_links.py` は、Drive 上の `` フォルダのファイル一覧を取得し、
+`report.md` 内の相対パスリンク (`./TC-XX/trace.zip`) を Drive URL に書き換えてから Docs 化する。
+チームメンバーは Docs 上で report を読みながらエビデンスへのリンクをクリックできる。
+
+## 推奨ワークフロー
+
+```
+[テスト実行] ./scenario-test/run.sh --pwk-overlay
+ ▼
+[ローカル確認] reports//report.md で結果確認
+ ▼
+[Drive 一括保管] gdrive_upload_dir.py --local reports// --parent
+ ▼
+[Docs 化 + リンク埋め込み] build_gdoc_with_drive_links.py ...
+ ▼
+[共有] Docs URL をチーム (Slack / Google Chat) に共有
+```
+
+ワンコマンドで済ませる場合: `./scenario-test/run.sh --pwk-drive-folder= --pwk-overlay`
+
+### Drive フォルダ構成の推奨
+
+```
+E2E テスト証跡/ ← 共有ドライブ or チームフォルダ
+├── <プロジェクト名>/
+│ ├── 20260526-134500/ ← run-id (自動生成)
+│ │ ├── report.md
+│ │ └── test_login/
+│ │ ├── video.mp4
+│ │ ├── trace.zip
+│ │ └── request.har
+│ └── report (Google Docs) ← build_gdoc_with_drive_links で生成
+```
+
+## Trace Viewer 連携
+
+`--public` でアップロードした trace.zip は Playwright Trace Viewer で直接開ける。
+
+```bash
+uv run python scripts/upload_evidence.py reports/.../trace.zip \
+ --kind trace --public --parent-folder-id
+# → playwright_trace_viewer: https://trace.playwright.dev/?trace=...
+```
+
+この URL を共有すると、インストール不要でブラウザ上から trace を再生できる。
+
+**注意**: `--public` は anyone/read を付与するため、trace 内の機密情報 (Cookie, 入力値) が
+公開される。社内限定の場合は private のまま `playwright show-trace` をローカルで使うこと。
+
+## 環境変数
+
+| 変数 | 用途 | 例 |
+|---|---|---|
+| `GOOGLE_AUTH_SCRIPTS` | google-auth skill の scripts/ パス | `~/.claude/skills/google-auth/scripts` |
+| `PWK_DRIVE_FOLDER` | デフォルトの Drive アップロード先 (将来対応予定) | `1ABCxyz...` |
+
+## トラブルシュート
+
+| 症状 | 原因 | 対策 |
+|---|---|---|
+| `Google Drive 連携には optional skill google-auth が必要です` | `google-auth` 未導入 | `GOOGLE_AUTH_SCRIPTS` を `google-auth/scripts` へ設定するか、同 skill を利用先へ導入する |
+| `HttpError 403: insufficient permissions` | drive.file スコープ不足 | `google-auth` skill で再認証 (`drive.file` スコープ指定) |
+| `HttpError 404: File not found` | FOLDER_ID が間違っている / アクセス権なし | Drive で共有フォルダ ID を確認 |
+| `resumable upload failed` | ファイルサイズが大きい / ネットワーク不安定 | 再試行。動画は mp4 (H.264) で容量を抑える |
+| pytest 後に自動アップロードされない | `--pwk-drive-folder` 未指定 | CLI 引数を確認 |
+
+## 関連 Skill
+
+- `/ndf:playwright-authoring` — スクリプト作成と実行 (前段)
+- `/ndf:playwright-planning` — テスト計画
+- `/ndf:playwright-kit-ops` — 実行環境の運用 (アップロードスクリプトの配置元)
+- `google-auth` / `google-drive` — Google API の認証と Drive 操作。どちらも既定の配布セットには
+ 含まれない(`plugins/ndf-shared/skills/` にはあるが manifest には載せていない)。Drive 連携を
+ 使う場合だけ利用先へ導入する
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/.gitignore b/plugins/ndf-claude/skills/playwright-kit-ops/.gitignore
new file mode 100644
index 00000000..f9cf1ad0
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/.gitignore
@@ -0,0 +1,11 @@
+.venv/
+__pycache__/
+*.pyc
+*.egg-info/
+
+# pytest 実行時に生成される NDF report 出力 (run-id 別)
+reports/
+.pytest_cache/
+
+# uv lock は共有可
+!uv.lock
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/SKILL.md b/plugins/ndf-claude/skills/playwright-kit-ops/SKILL.md
new file mode 100644
index 00000000..76f89f4d
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/SKILL.md
@@ -0,0 +1,124 @@
+---
+name: playwright-kit-ops
+description: "Run the playwright_kit scripts: project init, page-role classification, one-off a11y / CWV scans, and Drive upload helpers. Use when a playwright_kit script has to be run directly. Triggers: 'init_project.sh', 'classify_page_role.py', 'run_a11y_scan.py', 'upload_evidence.py'"
+allowed-tools:
+ - Read
+ - Bash(python *)
+ - Bash(uv *)
+ - Bash(pytest *)
+ - Bash(playwright *)
+ - Bash(./scripts/*)
+ - Bash(bash *)
+ - Bash(chmod *)
+---
+
+# playwright_kit 操作エージェント
+
+playwright_kit のスクリプト群を実行してテスト環境のセットアップ・テスト実行・エビデンス管理を行う。
+
+## スクリプト一覧
+
+| スクリプト | 用途 | カテゴリ |
+|---|---|---|
+| `scripts/init_project.sh` | 利用者プロジェクトに scenario-test ランタイムを埋め込む | セットアップ |
+| `scripts/init_project.bat` | 同 (Windows) | セットアップ |
+| `scripts/classify_page_role.py` | URL の a11y tree + パターンから page role を自動推定 | テスト計画 |
+| `scripts/record_scenario.py` | Playwright codegen で操作を記録しテストコード化 | テスト計画 |
+| `scripts/run_a11y_scan.py` | axe-core による単発 accessibility スキャン | 品質 |
+| `scripts/check_cwv.py` | Core Web Vitals (LCP/CLS/TTFB) 単発計測 | 品質 |
+| `scripts/upload_evidence.py` | エビデンスファイルを Google Drive にアップロード | レポート |
+| `scripts/gdrive_upload_dir.py` | ディレクトリごと Drive にバッチアップロード | レポート |
+| `scripts/upload_md_as_gdoc.py` | Markdown を Google Doc に変換・アップロード | レポート |
+| `scripts/build_gdoc_with_drive_links.py` | Google Doc にエビデンスの Drive リンクを埋め込み | レポート |
+
+## セットアップ
+
+### プロジェクト初期化
+
+```bash
+# SKILL_DIR はこの skill のパス
+./scripts/init_project.sh /path/to/your-app
+
+# ディレクトリ名をカスタマイズ
+./scripts/init_project.sh /path/to/your-app --runtime-dir e2e
+
+# Windows
+scripts\init_project.bat C:\path\to\your-app
+```
+
+→ `your-app/scenario-test/` に all-in-one ランタイムが作成され、Skill 非依存で動作する。
+
+### テスト実行
+
+```bash
+cd /path/to/your-app
+./scenario-test/run.sh # 全テスト
+./scenario-test/run.sh -k test_admin # フィルタ
+./scenario-test/run.sh --pwk-overlay # 字幕 + カーソル付き動画
+./scenario-test/run.sh --pwk-drive-folder= # Drive 自動アップロード
+```
+
+Drive 連携は optional dependency として扱う。`google-auth` skill はどのランタイムの
+配布物にも同梱していないため、Drive 系コマンドや `--pwk-drive-folder` を使う場合は
+リポジトリ [devbasex/ai-plugins](https://github.com/devbasex/ai-plugins) を clone し、
+`GOOGLE_AUTH_SCRIPTS` をその clone 先の `google-auth/scripts` へ設定する。
+
+```bash
+# を実パスに置き換える
+export GOOGLE_AUTH_SCRIPTS=/plugins/ndf-shared/skills/google-auth/scripts
+cd scenario-test
+uv sync --extra drive
+```
+
+## テスト計画ツール
+
+```bash
+# page role を自動推定
+python scripts/classify_page_role.py --url https://example.com/products
+
+# Playwright codegen で操作を記録
+python scripts/record_scenario.py https://example.com/login
+```
+
+## 品質スキャンツール
+
+```bash
+# axe-core 単発スキャン
+python scripts/run_a11y_scan.py --url https://example.com
+
+# Core Web Vitals 単発計測
+python scripts/check_cwv.py --url https://example.com
+```
+
+## エビデンスアップロードツール
+
+```bash
+# 単一ファイルを Drive にアップロード
+python scripts/upload_evidence.py reports/run-001/test_login/trace.zip \
+ --kind trace --parent-folder-id FOLDER_ID
+
+# ディレクトリごとアップロード
+python scripts/gdrive_upload_dir.py --local reports/run-001/ --parent FOLDER_ID
+
+# Markdown を Google Doc へ変換
+python scripts/upload_md_as_gdoc.py --md reports/run-001/report.md --parent FOLDER_ID
+
+# エビデンスの Drive リンクを埋め込んだ Google Doc を作る
+python scripts/build_gdoc_with_drive_links.py \
+ --md reports/run-001/report.md --folder FOLDER_ID \
+ --run-id run-001 --name "run-001 レポート"
+```
+
+`--parent` / `--parent-folder-id` / `--folder` に渡すのは Drive のフォルダ ID。
+`upload_evidence.py` の `--parent-folder-id` だけは省略でき、その場合はマイドライブ
+直下へ置く。
+
+## パッケージ参照
+
+playwright_kit Python パッケージ本体・templates・tests はこの skill ディレクトリ内に配置されている。
+
+## 関連 Skill
+
+- `/ndf:playwright-planning` — テスト計画 (方法論 + チェックリスト + ワークフロー全体像)
+- `/ndf:playwright-authoring` — スクリプト作成と実行 (テストコード / エビデンス / ブラウザ接続)
+- `/ndf:playwright-evidence` — 証跡とレポート (report.md / Google Drive 保管)
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/__init__.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/__init__.py
new file mode 100644
index 00000000..bd275e61
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/__init__.py
@@ -0,0 +1,11 @@
+"""pytest-playwright 上で動く Web E2E シナリオテストパッケージ。
+
+利用方法:
+- pytest plugin として ``--pwk-config=...`` で読み込む (entry-point 経由で auto-load)
+- ``pwk_config`` / ``pwk_role_`` / ``pwk_evidence`` 等の fixture を test に注入
+- ``@pytest.mark.page_role(...)`` で accessibility / web vitals を autouse
+
+詳細は SKILL.md を参照。
+"""
+
+__version__ = "0.5.0"
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/accessibility.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/accessibility.py
new file mode 100644
index 00000000..588e3cd5
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/accessibility.py
@@ -0,0 +1,90 @@
+"""runner 内蔵の Web アクセシビリティ (axe-core) スキャンモジュール。
+
+Web アクセシビリティ (旧称 a11y) は WCAG 準拠の機械検査を指し、本モジュールは
+axe-core (axe-playwright-python) で WCAG 2.0/2.1/2.2 AA レベルの違反を検出する。
+
+`scripts/run_a11y_scan.py` (CLI) はこのモジュールの薄いラッパで、
+runner からは `scan_page(page, ...)` を直接呼び出して `EvidenceCollectors`
+の `axe_violations` に格納する。
+
+page_role が `lp / list / form / dashboard / cart / checkout / settings / auth`
+のとき runner が自動実行する (config.accessibility.auto_roles で上書き可能)。
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from playwright.sync_api import Page
+
+
+# WCAG 2.0/2.1/2.2 AA 準拠を最低基準として走査する。
+# WCAG 2.0 AAA は適合義務がない (一般的に過剰) ため除外。
+DEFAULT_TAGS: tuple[str, ...] = ("wcag2a", "wcag2aa", "wcag21aa", "wcag22aa")
+
+# page_role × a11y 自動実行のデフォルト対象。フォーム / 商取引 / 認証系は a11y 影響大。
+DEFAULT_AUTO_ROLES: frozenset[str] = frozenset({
+ "lp", "list", "form", "dashboard", "cart", "checkout", "settings", "auth",
+})
+
+
+def is_available() -> bool:
+ """axe-playwright-python がインストール済かを確認する (Maj-9: silent fail 対策)。"""
+ try:
+ import axe_playwright_python.sync_playwright # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+def scan_page(
+ page: Page,
+ *,
+ tags: tuple[str, ...] | list[str] = DEFAULT_TAGS,
+) -> list[dict[str, Any]]:
+ """既にロード済みの Page に対し axe-core を実行し violations の list を返す。
+
+ axe-playwright-python が未インストールなら空 list を返す。呼出側は事前に
+ `is_available()` で判定し、未インストールならスキップを明示すること。
+ """
+ try:
+ from axe_playwright_python.sync_playwright import Axe
+ except ImportError:
+ return []
+
+ axe = Axe()
+ try:
+ results = axe.run(
+ page, options={"runOnly": {"type": "tag", "values": list(tags)}},
+ )
+ except TypeError:
+ # axe-playwright-python の旧版は options 非対応
+ results = axe.run(page)
+
+ violations: list[dict[str, Any]] = []
+ for v in results.response.get("violations", []):
+ violations.append({
+ "id": v.get("id"),
+ "impact": v.get("impact"),
+ "tags": v.get("tags", []),
+ "help": v.get("help"),
+ "helpUrl": v.get("helpUrl"),
+ "nodes": [
+ {
+ "html": n.get("html", "")[:200],
+ "target": n.get("target", []),
+ "failureSummary": n.get("failureSummary", "")[:300],
+ }
+ for n in v.get("nodes", [])
+ ],
+ })
+ return violations
+
+
+def should_auto_scan(
+ page_roles: list[str],
+ *,
+ auto_roles: frozenset[str] = DEFAULT_AUTO_ROLES,
+) -> bool:
+ """testcase の page_role に基づき axe-core を自動実行すべきか判定する。"""
+ return any(r in auto_roles for r in page_roles)
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/body_check.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/body_check.py
new file mode 100644
index 00000000..37a8c4c0
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/body_check.py
@@ -0,0 +1,118 @@
+"""ページ本文の文字列マッチ検出 (PHP / SSR エラー検出, v0.4.0)。
+
+JavaScript ランタイム由来の console.error / pageerror では拾えない、
+サーバ側で HTML 本文に直接出力された "Fatal error" / "Warning:" 等の
+エラー文字列を、Playwright の ``page.on("response", ...)`` で拾った
+HTML 本文に対して substring match で検出する純粋関数群。
+
+旧 v0.2.x の自前 YAML runner にあった ``body_check`` 機能の再実装で、
+PHP プロジェクトのフロント漏れ ``Fatal error`` / ``STRICT:`` 等を
+テスト失敗として捕捉する。
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Iterable
+
+
+@dataclass(frozen=True)
+class BodyViolation:
+ """1 件の body_check ヒット。"""
+
+ url: str
+ category: str # "fatal" / "warning" / "not_found"
+ pattern: str
+ snippet: str
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "url": self.url,
+ "category": self.category,
+ "pattern": self.pattern,
+ "snippet": self.snippet,
+ }
+
+
+def _snippet(body: str, pattern: str, *, around: int = 60) -> str:
+ """body 中の pattern 周辺 ``around`` 文字を取り出して表示用に整形する。"""
+ idx = body.find(pattern)
+ if idx < 0:
+ return pattern
+ start = max(0, idx - around)
+ end = min(len(body), idx + len(pattern) + around)
+ snippet = body[start:end].replace("\n", " ").replace("\r", " ").replace("\t", " ")
+ prefix = "..." if start > 0 else ""
+ suffix = "..." if end < len(body) else ""
+ return prefix + snippet + suffix
+
+
+def scan_body(
+ body: str,
+ url: str,
+ *,
+ fatal_patterns: Iterable[str] = (),
+ warning_patterns: Iterable[str] = (),
+ warning_head_chars: int = 300,
+ not_found_patterns: Iterable[str] = (),
+) -> list[BodyViolation]:
+ """``body`` に該当パターンが出現すれば ``BodyViolation`` のリストで返す。
+
+ - ``fatal_patterns`` / ``not_found_patterns`` は body 全体を substring で走査
+ - ``warning_patterns`` は ``body[:warning_head_chars]`` (= 先頭 N 文字 /
+ code points) のみを走査。本文中の説明文や入力例の "Notice:" 等を許容する
+ ための head 検索。bytes ではなく code points で切るのは、日本語ページで
+ 300 bytes ≒ 100 字相当となり実用にならないため。
+
+ パターン文字列は **substring 比較** (正規表現ではない)。空文字列は無視する。
+ """
+ violations: list[BodyViolation] = []
+
+ if not body:
+ return violations
+
+ head_size = max(0, int(warning_head_chars))
+ head = body[:head_size] if head_size else ""
+
+ for pat in fatal_patterns:
+ if pat and pat in body:
+ violations.append(
+ BodyViolation(
+ url=url,
+ category="fatal",
+ pattern=pat,
+ snippet=_snippet(body, pat),
+ )
+ )
+
+ if head:
+ for pat in warning_patterns:
+ if pat and pat in head:
+ violations.append(
+ BodyViolation(
+ url=url,
+ category="warning",
+ pattern=pat,
+ snippet=_snippet(head, pat),
+ )
+ )
+
+ for pat in not_found_patterns:
+ if pat and pat in body:
+ violations.append(
+ BodyViolation(
+ url=url,
+ category="not_found",
+ pattern=pat,
+ snippet=_snippet(body, pat),
+ )
+ )
+
+ return violations
+
+
+def is_html_response(content_type: str | None) -> bool:
+ """``Content-Type`` ヘッダから HTML レスポンスか判定する。"""
+ if not content_type:
+ return False
+ return "html" in content_type.lower()
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/config.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/config.py
new file mode 100644
index 00000000..18436b4d
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/config.py
@@ -0,0 +1,461 @@
+"""共通設定 (config.yaml) のロードとデータクラス。
+
+テストケース YAML ではなく、対象環境・ロール別ログイン・Playwright/Runner 設定、
+およびページ検査・スラッグ正規化・レポート生成のプロジェクト固有パラメータを保持する。
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal
+
+import yaml
+
+
+# ---------------------------------------------------------------------------
+# 環境変数展開 (Codex Major 4)
+# ---------------------------------------------------------------------------
+
+_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
+
+
+def _expand_env_in_str(s: str) -> str:
+ """文字列中の ${VAR} / ${VAR:-default} を環境変数で展開する。"""
+ def repl(m: re.Match) -> str:
+ name, default = m.group(1), m.group(2)
+ val = os.environ.get(name)
+ if val is None:
+ if default is None:
+ raise ValueError(
+ f"環境変数 ${{{name}}} が未定義です "
+ "(default 指定 ${VAR:-default} または env を設定してください)"
+ )
+ return default
+ return val
+ return _ENV_RE.sub(repl, s)
+
+
+def _expand_env(value: Any) -> Any:
+ """dict / list / str を再帰的に走査して ${VAR} を展開する。"""
+ if isinstance(value, str):
+ return _expand_env_in_str(value)
+ if isinstance(value, list):
+ return [_expand_env(v) for v in value]
+ if isinstance(value, dict):
+ return {k: _expand_env(v) for k, v in value.items()}
+ return value
+
+
+# --- ブラウザ接続 ---------------------------------------------------
+
+BrowserMode = Literal["local", "cdp-remote"]
+BROWSER_MODES: tuple[BrowserMode, ...] = ("local", "cdp-remote")
+
+
+@dataclass
+class BrowserConfig:
+ """ブラウザ接続設定。
+
+ cdp_endpoint が空文字列や空白のみの場合はデフォルト値
+ ``http://localhost:9222`` にフォールバックする。
+ """
+
+ mode: BrowserMode = "local"
+ cdp_endpoint: str = "http://localhost:9222"
+
+ @classmethod
+ def from_raw(cls, raw: dict[str, Any]) -> "BrowserConfig":
+ base = cls()
+ mode_raw = str(raw.get("mode") or base.mode).lower()
+ if mode_raw not in BROWSER_MODES:
+ raise ValueError(
+ f"browser.mode は {BROWSER_MODES} のいずれかを指定してください "
+ f"(指定値: {mode_raw!r})"
+ )
+ mode: BrowserMode = mode_raw # type: ignore[assignment]
+ # cdp_endpoint: 空文字列・空白のみの場合はデフォルト値にフォールバック
+ cdp_raw = raw.get("cdp_endpoint")
+ cdp_endpoint = str(cdp_raw).strip() if cdp_raw else ""
+ if not cdp_endpoint:
+ cdp_endpoint = base.cdp_endpoint
+ return cls(
+ mode=mode,
+ cdp_endpoint=cdp_endpoint,
+ )
+
+
+# --- 接続/認証 -------------------------------------------------------
+
+@dataclass
+class BasicAuth:
+ user: str
+ password: str
+
+
+@dataclass
+class Login:
+ path: str
+ requires_basic_auth: bool
+ fields: dict[str, str]
+ fail_if_url_contains: str
+ # ログイン送信ボタンを特定するためのプロジェクト固有セレクタ (CSS / role / text)。
+ # auth fixture の _submit_login_form が「これ → role/type=submit フォールバック
+ # → Password で Enter」の順で試す。空のままでも汎用フォールバックで通常はログインできる。
+ submit_selectors: list[str] = field(default_factory=list)
+
+
+@dataclass
+class Role:
+ id: str
+ label: str
+ login: Login
+
+
+# --- レポート設定 ---------------------------------------------------
+
+@dataclass
+class ReportConfig:
+ title: str = "シナリオ E2E テスト 実施報告書"
+ test_plan_link: str = "./test-plan.md"
+ phase_labels: dict[int, str] = field(default_factory=dict)
+
+
+# --- Playwright / Runner -------------------------------------------
+
+# Playwright ``record_har_mode`` に直接渡す値。"minimal" は request/response の
+# メタデータのみ記録し、Basic 認証 + redirect が連続するページで navigation を
+# abort させる race を回避する (Issue #62)。"full" は body も含めた完全な HAR、
+# "none" は HAR を出力しない (= ``record_har_path`` を inject しない)。
+HarMode = Literal["minimal", "full", "none"]
+HAR_MODES: tuple[HarMode, ...] = ("minimal", "full", "none")
+
+
+@dataclass
+class PlaywrightConfig:
+ headless: bool = True
+ viewport_width: int = 1280
+ viewport_height: int = 720
+ slow_mo_ms: int = 0
+ video_width: int = 1280
+ video_height: int = 720
+ navigation_timeout_ms: int = 30000
+ # 各ステップ遷移後の表示維持時間 (動画でじっくり見せるため)
+ step_delay_ms: int = 1800
+ # 動画にカーソル+字幕オーバーレイを焼き込む (true 推奨)
+ enable_overlay: bool = True
+ # Playwright Trace (trace.zip) を出力する。クリック箇所のハイライト・
+ # DOM スナップショット・コンソール・ネットワークなどを `playwright show-trace`
+ # で対話的に確認できる。生成物が大きく (数MB〜) なるので必要時のみ。
+ enable_trace: bool = True
+ # 録画後の動画フォーマット: "webm" (Playwright 既定) | "mp4" (H.264 変換)
+ # mp4 は Google Drive プレビュアで再生互換性が高い。
+ video_format: str = "mp4"
+ # HAR 録画モード (Issue #62)。Playwright >= 1.30 で導入された
+ # ``record_har_mode`` に対応する。
+ # - "minimal" (default): メタデータのみ記録。Basic 認証 + redirect が混在
+ # するページで ``record_har_path`` 起因の ERR_ABORTED race を回避する。
+ # - "full": Playwright 既定の full HAR (body + content)。
+ # - "none": HAR を一切出力しない (= ``record_har_path`` を inject しない)。
+ har_mode: HarMode = "minimal"
+
+ @classmethod
+ def from_raw(cls, raw: dict[str, Any]) -> "PlaywrightConfig":
+ # dataclass の default を真実の源 (single source of truth) とする。
+ # fallback 値を base = cls() から参照することで、dataclass default と
+ # from_raw() の fallback が乖離するバグを防ぐ (Codex Minor 6)。
+ base = cls()
+ viewport = raw.get("viewport") or {}
+ video_size = raw.get("video_size") or {}
+ har_mode_raw = str(raw.get("har_mode", base.har_mode)).lower()
+ if har_mode_raw not in HAR_MODES:
+ raise ValueError(
+ f"playwright.har_mode は {HAR_MODES} のいずれかを指定してください "
+ f"(指定値: {har_mode_raw!r})"
+ )
+ har_mode: HarMode = har_mode_raw # type: ignore[assignment]
+ return cls(
+ headless=bool(raw.get("headless", base.headless)),
+ viewport_width=int(viewport.get("width", base.viewport_width)),
+ viewport_height=int(viewport.get("height", base.viewport_height)),
+ slow_mo_ms=int(raw.get("slow_mo_ms", base.slow_mo_ms)),
+ video_width=int(video_size.get("width", base.video_width)),
+ video_height=int(video_size.get("height", base.video_height)),
+ navigation_timeout_ms=int(raw.get("navigation_timeout_ms", base.navigation_timeout_ms)),
+ step_delay_ms=int(raw.get("step_delay_ms", base.step_delay_ms)),
+ enable_overlay=bool(raw.get("enable_overlay", base.enable_overlay)),
+ enable_trace=bool(raw.get("enable_trace", base.enable_trace)),
+ video_format=str(raw.get("video_format", base.video_format)).lower(),
+ har_mode=har_mode,
+ )
+
+ @classmethod
+ def defaults(cls) -> "PlaywrightConfig":
+ """設定が完全に省略された場合の defaults。viewport=video_size=1280x720 で揃える。"""
+ return cls()
+
+
+@dataclass
+class RunnerConfig:
+ workers: int = 4
+ testcases_dir: str = "./testcases"
+
+ @classmethod
+ def from_raw(cls, raw: dict[str, Any]) -> "RunnerConfig":
+ return cls(
+ workers=int(raw.get("workers", 4)),
+ testcases_dir=str(raw.get("testcases_dir", "./testcases")),
+ )
+
+
+# --- accessibility / web vitals (v0.3.0) -----------------------------
+
+@dataclass
+class AccessibilityConfig:
+ """axe-core 自動スキャンの設定 (page_role に応じて runner が自動実行)。"""
+ enabled: bool = True
+ auto_roles: list[str] = field(default_factory=lambda: [
+ "lp", "list", "form", "dashboard", "cart", "checkout", "settings", "auth",
+ ])
+ tags: list[str] = field(default_factory=lambda: [
+ "wcag2a", "wcag2aa", "wcag21aa", "wcag22aa",
+ ])
+ # 検出した violations を testcase の FAIL 要因として扱うか (false なら情報出力のみ)
+ fail_on_violations: bool = True
+
+
+@dataclass
+class WebVitalsConfig:
+ """Core Web Vitals 自動計測の設定 (page_role に応じて runner が自動実行)。"""
+ enabled: bool = True
+ auto_roles: list[str] = field(default_factory=lambda: [
+ "lp", "list", "dashboard", "search",
+ ])
+ observe_ms: int = 5000
+ # poor 判定が 1 件でもあれば testcase を FAIL とするか
+ fail_on_poor: bool = True
+
+
+# --- body_check (PHP / SSR エラー検出, v0.4.0) ----------------------
+
+@dataclass
+class BodyCheckConfig:
+ """ページ本文の文字列マッチ検出 (PHP / SSR プロジェクト向け)。
+
+ JavaScript ランタイム由来の console.error / pageerror では拾えない、
+ サーバ側で HTML 本文に直接出力された "Fatal error" / "Warning:" 等の
+ エラー文字列を、Playwright の ``page.on("response", ...)`` を介して
+ 検出する。
+
+ - ``fatal_patterns``: HTML 本文全体に対する substring match。1 つでも
+ 含まれれば violation。
+ - ``warning_patterns``: 本文の **先頭 ``warning_head_chars`` 文字** に
+ 対する substring match。本文中の説明文に含まれる "Notice:" 等は許容し、
+ ページ最上段への漏れだけを拾う。
+ (PLAN18 の説明文は「先頭 300 文字」、code point ベース。日本語ページで
+ 300 bytes だと先頭 1〜2 行しか見えず実用にならないため、文字数を採用。)
+ - ``not_found_patterns``: 本文全体への substring match。
+ - ``fail_on_match``: True なら violation 検出時に ``pytest.fail``。
+ False なら情報収集のみ (report.md / body_check.jsonl には記録)。
+
+ default は ``enabled=True`` + PHP 系のフロント漏れ検出パターンを内蔵。
+ config.yaml を書かなくてもまず PHP プロジェクトで素直に動く。
+ """
+
+ enabled: bool = True
+ fatal_patterns: list[str] = field(default_factory=lambda: [
+ "Fatal error",
+ "Uncaught",
+ "Parse error",
+ ])
+ warning_patterns: list[str] = field(default_factory=lambda: [
+ "STRICT:",
+ "Warning:",
+ "Notice:",
+ "Deprecated:",
+ ])
+ # 文字数ベースの head 切り出し閾値 (code points)。PLAN18 のフィールド名は
+ # ``warning_head_bytes`` だったが、説明文は「先頭 300 文字」と書かれており
+ # 矛盾していた。実用上は文字数の方が日本語ページで安定するため採用。
+ warning_head_chars: int = 300
+ not_found_patterns: list[str] = field(default_factory=lambda: [
+ "File not found",
+ ])
+ fail_on_match: bool = True
+
+
+# --- ルート ---------------------------------------------------------
+
+@dataclass
+class Config:
+ base_url: str
+ basic_auth: BasicAuth
+ verify_tls: bool
+ roles: dict[str, Role]
+ playwright: PlaywrightConfig
+ runner: RunnerConfig
+ report: ReportConfig
+ config_path: Path # 設定ファイルの絶対パス(testcases_dir の解決基点)
+ browser: BrowserConfig = field(default_factory=BrowserConfig)
+ # docs/checklists/checklist-common.md C8/C9 の境界曖昧さに対応する「除外」設定。
+ # console.error / pageerror の本文がいずれかの正規表現にマッチした場合は
+ # 集計から除外し FAIL を抑制する。3rd party の既知 warning などを許容するための
+ # 抜け穴。空 (デフォルト) なら従来どおり 1 件で FAIL。
+ tolerated_console_errors: list[str] = field(default_factory=list)
+ tolerated_page_errors: list[str] = field(default_factory=list)
+ # accessibility / web_vitals 自動実行 (page_role に応じて runner が判定)
+ accessibility: AccessibilityConfig = field(default_factory=AccessibilityConfig)
+ web_vitals: WebVitalsConfig = field(default_factory=WebVitalsConfig)
+ # PHP / SSR ページ本文エラー検出 (v0.4.0, opt-in)
+ body_check: BodyCheckConfig = field(default_factory=BodyCheckConfig)
+
+ @property
+ def testcases_dir(self) -> Path:
+ d = Path(self.runner.testcases_dir)
+ if not d.is_absolute():
+ d = self.config_path.parent / d
+ return d.resolve()
+
+ def role(self, role_id: str) -> Role:
+ if role_id not in self.roles:
+ raise KeyError(f"未定義のロール: {role_id}. roles 設定を確認してください。")
+ return self.roles[role_id]
+
+ @classmethod
+ def load(cls, path: Path) -> "Config":
+ if not path.exists():
+ raise FileNotFoundError(
+ f"設定ファイルが見つかりません: {path}\n"
+ "templates/scenario.config.yaml をコピーして作成してください。"
+ )
+ with path.open("r", encoding="utf-8") as fp:
+ raw = yaml.safe_load(fp)
+ if not isinstance(raw, dict):
+ raise ValueError(
+ f"scenario.config.yaml の中身が空または辞書ではありません: {path}\n"
+ "templates/scenario.config.yaml をコピーして必要項目を埋めてください。"
+ )
+ raw = _expand_env(raw)
+ return cls._from_dict(raw, config_path=path.resolve())
+
+ @classmethod
+ def _from_dict(cls, raw: dict[str, Any], *, config_path: Path) -> "Config":
+ target = raw["target"]
+ # basic_auth は省略可能 (サイトに Basic 認証が掛かっていない場合)。
+ # 省略時は空 BasicAuth を使い、role 側で `requires_basic_auth: true` を
+ # 指定したテストケースだけが basic_auth ヘッダを要求する設計。
+ ba_raw = target.get("basic_auth") or {}
+ basic_auth = BasicAuth(
+ user=str(ba_raw.get("user", "")),
+ password=str(ba_raw.get("password", "")),
+ )
+ roles = {rid: _role_from_raw(rid, r) for rid, r in (raw.get("roles") or {}).items()}
+
+ cfg = cls(
+ base_url=target["base_url"].rstrip("/"),
+ basic_auth=basic_auth,
+ verify_tls=bool(raw.get("verify_tls", False)),
+ roles=roles,
+ playwright=PlaywrightConfig.from_raw(raw.get("playwright") or {}),
+ runner=RunnerConfig.from_raw(raw.get("runner") or {}),
+ report=_report_from_raw(raw.get("report") or {}),
+ config_path=config_path,
+ browser=BrowserConfig.from_raw(raw.get("browser") or {}),
+ tolerated_console_errors=list(raw.get("tolerated_console_errors") or []),
+ tolerated_page_errors=list(raw.get("tolerated_page_errors") or []),
+ accessibility=_accessibility_from_raw(raw.get("accessibility") or {}),
+ web_vitals=_web_vitals_from_raw(raw.get("web_vitals") or {}),
+ body_check=_body_check_from_raw(raw.get("body_check") or {}),
+ )
+
+ # fail-fast: requires_basic_auth=True なロールが宣言されているのに
+ # basic_auth.user が空ならば実行時に HTTP 401 で必ず落ちる。先に検出して
+ # 設定不備として ValueError を投げる (Maj-4)。
+ for role in cfg.roles.values():
+ if role.login.requires_basic_auth and not basic_auth.user:
+ raise ValueError(
+ f"role '{role.id}' は requires_basic_auth=True だが、"
+ f"target.basic_auth.user が空 (config.yaml を確認してください)"
+ )
+
+ return cfg
+
+
+def _role_from_raw(rid: str, raw: dict[str, Any]) -> Role:
+ login = raw["login"]
+ return Role(
+ id=rid,
+ label=str(raw.get("label", rid)),
+ login=Login(
+ path=login["path"],
+ requires_basic_auth=bool(login.get("requires_basic_auth", False)),
+ fields=dict(login["fields"]),
+ fail_if_url_contains=login["fail_if_url_contains"],
+ submit_selectors=list(login.get("submit_selectors") or []),
+ ),
+ )
+
+
+def _report_from_raw(raw: dict[str, Any]) -> ReportConfig:
+ labels_raw = raw.get("phase_labels") or {}
+ return ReportConfig(
+ title=str(raw.get("title", "シナリオ E2E テスト 実施報告書")),
+ test_plan_link=str(raw.get("test_plan_link", "./test-plan.md")),
+ phase_labels={int(k): str(v) for k, v in labels_raw.items()},
+ )
+
+
+def _accessibility_from_raw(raw: dict[str, Any]) -> AccessibilityConfig:
+ base = AccessibilityConfig()
+ return AccessibilityConfig(
+ enabled=bool(raw.get("enabled", base.enabled)),
+ auto_roles=list(raw.get("auto_roles") or base.auto_roles),
+ tags=list(raw.get("tags") or base.tags),
+ fail_on_violations=bool(raw.get("fail_on_violations", base.fail_on_violations)),
+ )
+
+
+def _web_vitals_from_raw(raw: dict[str, Any]) -> WebVitalsConfig:
+ base = WebVitalsConfig()
+ return WebVitalsConfig(
+ enabled=bool(raw.get("enabled", base.enabled)),
+ auto_roles=list(raw.get("auto_roles") or base.auto_roles),
+ observe_ms=int(raw.get("observe_ms", base.observe_ms)),
+ fail_on_poor=bool(raw.get("fail_on_poor", base.fail_on_poor)),
+ )
+
+
+def _body_check_from_raw(raw: dict[str, Any]) -> BodyCheckConfig:
+ """``body_check`` セクションを ``BodyCheckConfig`` に変換する。
+
+ - キーが **省略** されている場合は dataclass の default 値を採用する
+ (config を書かなくても PHP 系のデフォルトパターンが効くようにするため)。
+ - キーが **明示的に空リスト** で書かれている場合はそのまま空リストにする
+ (default を上書きしてカテゴリを無効化したい場合の挙動)。
+ """
+ base = BodyCheckConfig()
+
+ def _patterns(key: str, default: list[str]) -> list[str]:
+ if key not in raw:
+ return list(default)
+ value = raw.get(key)
+ if value is None:
+ return list(default)
+ return [str(s) for s in value]
+
+ # ``warning_head_chars`` を新フィールド名として採用。旧名 ``warning_head_bytes``
+ # も alias として受理する (PLAN18 がフィールド名と説明文で矛盾していた経緯)。
+ head_chars = raw.get("warning_head_chars")
+ if head_chars is None:
+ head_chars = raw.get("warning_head_bytes", base.warning_head_chars)
+
+ return BodyCheckConfig(
+ enabled=bool(raw.get("enabled", base.enabled)),
+ fatal_patterns=_patterns("fatal_patterns", base.fatal_patterns),
+ warning_patterns=_patterns("warning_patterns", base.warning_patterns),
+ warning_head_chars=int(head_chars),
+ not_found_patterns=_patterns("not_found_patterns", base.not_found_patterns),
+ fail_on_match=bool(raw.get("fail_on_match", base.fail_on_match)),
+ )
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py
new file mode 100644
index 00000000..dc5e6685
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py
@@ -0,0 +1,19 @@
+"""playwright_kit pytest fixtures (E2E シナリオテスト)。
+
+利用者は通常の pytest テストを書き、`pwk_config` / `pwk_role_` 等の
+fixture をパラメタ宣言するだけで NDF の機能 (config / 認証 / evidence /
+accessibility / web vitals / overlay / Drive) を享受できる。
+
+各 fixture の実体はサブモジュールに分離する:
+- ``auth`` : ``pwk_config`` / ``pwk_role_`` (login 済 storage_state)
+- ``evidence`` : ``pwk_evidence`` (HAR / trace / console listeners)
+- ``accessibility`` : autouse hook で page_role marker に応じ axe-core を実行
+- ``web_vitals`` : autouse hook で page_role marker に応じ Core Web Vitals 計測
+
+pytest plugin (``playwright_kit.pytest_plugin``) から ``pytest_plugins`` で
+読み込まれる想定。利用者プロジェクトの ``conftest.py`` で個別 import する必要は無い。
+"""
+
+from __future__ import annotations
+
+__all__ = ["auth"]
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py
new file mode 100644
index 00000000..eafc0a7c
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py
@@ -0,0 +1,143 @@
+"""accessibility fixture: ``page_role`` marker に応じた axe-core 自動スキャン。
+
+Web アクセシビリティ (WCAG 準拠) を ``axe-core`` で機械検査する fixture。
+``@pytest.mark.page_role("form")`` 等が付与された test 関数の終了直前に
+axe-core を自動実行する。
+
+利用方法:
+- ``@pytest.mark.page_role("form")`` を test に付与すれば autouse 経由で
+ axe-core が走る (config.accessibility.auto_roles に該当する場合のみ)
+- 違反があれば ``config.accessibility.fail_on_violations`` (default True) に従い
+ ``pytest.fail`` する
+- 明示的に scan したい場合は ``pwk_accessibility_scan`` fixture を直接呼ぶ
+"""
+
+from __future__ import annotations
+
+from collections import Counter
+from typing import Iterator
+
+import pytest
+
+from playwright_kit import accessibility as accessibility_mod
+from playwright_kit.config import Config
+from playwright_kit.fixtures.evidence import PwkEvidence
+
+
+def _page_roles_from_marker(item) -> list[str]:
+ """test item から ``page_role`` marker の引数 (役割名 list) を集約する。"""
+ roles: list[str] = []
+ for marker in item.iter_markers(name="page_role"):
+ for arg in marker.args:
+ if isinstance(arg, str):
+ roles.append(arg)
+ elif isinstance(arg, (list, tuple)):
+ roles.extend(str(a) for a in arg)
+ return roles
+
+
+@pytest.fixture()
+def pwk_accessibility_scan(page, pwk_evidence: PwkEvidence, _pwk_config_optional):
+ """明示呼び出し用: ``violations = pwk_accessibility_scan()`` で 1 度スキャン。"""
+ config: Config | None = _pwk_config_optional
+
+ def _scan(*, tags: tuple[str, ...] | None = None) -> list[dict]:
+ if not accessibility_mod.is_available():
+ pwk_evidence.log_lines.append(
+ "[accessibility] axe-playwright-python 未インストール — SKIP "
+ "(`uv sync --extra a11y` で有効化)"
+ )
+ return []
+ actual_tags = (
+ tuple(tags)
+ if tags is not None
+ else (
+ tuple(config.accessibility.tags)
+ if config is not None
+ else accessibility_mod.DEFAULT_TAGS
+ )
+ )
+ violations = accessibility_mod.scan_page(page, tags=actual_tags)
+ pwk_evidence.axe_violations.extend(violations)
+ return violations
+
+ return _scan
+
+
+@pytest.fixture(autouse=True)
+def _pwk_accessibility_autouse(request) -> Iterator[None]:
+ """``page_role`` marker が付いた test の終了直前に axe-core を実行する。
+
+ ``page`` fixture を **要求している test のみ** 対象。autouse fixture が
+ 無条件に ``page`` を要求すると、pytest-playwright が全 test を browser
+ parametrize してしまうため、ここでは ``request.fixturenames`` を見て
+ 必要な test だけ取得する。
+
+ Issue #60 fix: 旧版の ``"pwk_evidence" not in request.fixturenames`` ガードを
+ 廃止。test 引数に ``pwk_evidence`` を書いていなくても ``getfixturevalue``
+ 経由で lazy 取得し、accessibility autouse が走るようにする。
+
+ teardown order 対策 (Issue #61): pytest fixture の teardown は LIFO のため、
+ ``yield`` 後に ``getfixturevalue("pwk_evidence")`` を呼ぶと「既に解放済」
+ AssertionError が発生する。setup phase で ``ev`` / ``page`` を取得して
+ closure に保持し、teardown phase はその参照のみを使う。
+ """
+
+ # ``page`` を要求していない (= browser を使わない) test では何もしない。
+ # これにより pure pytest test の挙動に影響を与えない。
+ if "page" not in request.fixturenames:
+ yield
+ return
+
+ config: Config | None = request.getfixturevalue("_pwk_config_optional")
+ if config is None or not config.accessibility.enabled:
+ yield
+ return
+ page_roles = _page_roles_from_marker(request.node)
+ if not page_roles:
+ yield
+ return
+ if not accessibility_mod.should_auto_scan(
+ page_roles, auto_roles=frozenset(config.accessibility.auto_roles)
+ ):
+ yield
+ return
+
+ # setup phase: closure に必要なオブジェクトを束ねる。
+ pwk_evidence: PwkEvidence = request.getfixturevalue("pwk_evidence")
+ page = request.getfixturevalue("page")
+
+ yield
+
+ # teardown phase: closure に保持した ev / page のみを参照する。
+ if not accessibility_mod.is_available():
+ pwk_evidence.log_lines.append(
+ "[accessibility autouse] axe-playwright-python 未インストール — SKIP"
+ )
+ return
+
+ try:
+ if page.is_closed():
+ return
+ except Exception:
+ return
+
+ violations = accessibility_mod.scan_page(page, tags=tuple(config.accessibility.tags))
+ pwk_evidence.axe_violations.extend(violations)
+ if not violations:
+ return
+
+ impacts = Counter(v.get("impact") or "unknown" for v in violations)
+ impact_summary = ", ".join(f"{k}={n}" for k, n in impacts.most_common())
+ pwk_evidence.log_lines.append(
+ f"[accessibility autouse] {len(violations)} violations: {impact_summary}"
+ )
+
+ if config.accessibility.fail_on_violations:
+ pytest.fail(
+ f"[accessibility] {len(violations)} 件の axe-core 違反 "
+ f"[{impact_summary}]: "
+ + ", ".join(
+ f"{v.get('id')}({v.get('impact', '?')})" for v in violations[:5]
+ )
+ )
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py
new file mode 100644
index 00000000..964f1e19
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py
@@ -0,0 +1,318 @@
+"""認証 fixture: ``pwk_config`` と動的に生成する ``pwk_role_``。
+
+pytest-playwright が提供する ``page`` / ``context`` / ``browser_context_args``
+fixture と協調して動作する。
+
+設計方針:
+- ``pwk_config`` は session scope。``--pwk-config`` で指定された YAML を
+ 1 度だけ読み込む。利用者プロジェクトの ``conftest.py`` から override 可能。
+- 各 role に対し ``pwk_role_`` fixture を *動的* に生成する。
+ 実体は ``_login_and_get_storage_state`` で session 内 1 回だけ login し、
+ storage_state を session-scoped cache (`_StorageStateCache`) に保管。
+ 以降の test では同じ role の cache を ``context.add_cookies`` 等で再利用する
+ ことで login の再実行を避ける。
+- function scope で ``page.context.storage_state(...)`` を inject し、
+ ``page`` は既に該当 role でログイン済みの状態で test 関数に渡される。
+
+fail_if_url_contains による失敗判定もここで行い、test 開始前に明示的に
+``pytest.fail`` する。
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Callable
+from urllib.parse import urlsplit
+
+import pytest
+
+from playwright_kit.config import Config, Login, Role
+
+
+def _same_origin(origin_url: str, base_url: str) -> bool:
+ """``origin_url`` が ``base_url`` と同一 origin (scheme + host + port) かを返す。
+
+ storage_state には認証対象以外のサードパーティ origin (広告 / 計測タグ等) が
+ 含まれることがある。restore 時にそれらへ ``page.goto(url)`` するのは意図しない
+ 外部送信になるため、本関数で base_url の origin に厳格一致するもののみ許可する。
+ """
+ try:
+ a = urlsplit(origin_url)
+ b = urlsplit(base_url)
+ except ValueError:
+ return False
+ return (
+ bool(a.scheme)
+ and bool(a.hostname)
+ and a.scheme == b.scheme
+ and a.hostname == b.hostname
+ and (a.port or None) == (b.port or None)
+ )
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class _StorageStateCache:
+ """session 内で role ごとの storage_state を 1 回だけ作る簡易 cache。"""
+
+ states: dict[str, dict[str, Any]]
+
+ @classmethod
+ def empty(cls) -> "_StorageStateCache":
+ return cls(states={})
+
+ def get(self, role_id: str) -> dict[str, Any] | None:
+ return self.states.get(role_id)
+
+ def put(self, role_id: str, state: dict[str, Any]) -> None:
+ self.states[role_id] = state
+
+
+def _submit_login_form(page, login: Login) -> None:
+ """ログインフォームの submit を行う。
+
+ 優先順位は ``login.submit_selectors`` → role/type=submit → password Enter。
+ """
+ for sel in login.submit_selectors:
+ try:
+ page.locator(sel).first.click(timeout=2000)
+ return
+ except Exception:
+ continue
+ for fallback in (
+ "role=button[name=/login|sign.?in|ログイン/i]",
+ 'button[type="submit"]',
+ 'input[type="submit"]',
+ ):
+ try:
+ page.locator(fallback).first.click(timeout=2000)
+ return
+ except Exception:
+ continue
+ pw_field = next(
+ (n for n in login.fields if "pass" in n.lower() or "pwd" in n.lower()),
+ None,
+ )
+ if pw_field:
+ page.locator(f'input[name="{pw_field}"]').press("Enter")
+ return
+ raise RuntimeError(
+ "ログイン送信ボタンが見つかりません (submit_selectors を設定してください)"
+ )
+
+
+def _login_and_get_storage_state(
+ *,
+ playwright,
+ base_url: str,
+ role: Role,
+ basic_auth_user: str,
+ basic_auth_password: str,
+ verify_tls: bool,
+ nav_timeout_ms: int = 30_000,
+) -> dict[str, Any]:
+ """role の login flow を実行し storage_state を返す。
+
+ 1 度だけ呼ばれることを想定。失敗時は ``pytest.fail`` を投げる。
+
+ AQ Critical-2 完遂: 関数全体を browser try/finally で囲み、
+ page.goto() / fill() / expect_navigation() / fail_if_url_contains で
+ pytest.fail() が発生した場合も含め、全ての failure path で
+ browser.close() が必ず呼ばれることを保証する。
+ pytest.fail() は内部的に例外を raise するため finally は確実に動く。
+ """
+ browser = playwright.chromium.launch(headless=True)
+ try:
+ ctx_kwargs: dict[str, Any] = {
+ "ignore_https_errors": not verify_tls,
+ }
+ if role.login.requires_basic_auth:
+ ctx_kwargs["http_credentials"] = {
+ "username": basic_auth_user,
+ "password": basic_auth_password,
+ }
+ context = browser.new_context(**ctx_kwargs)
+ context.set_default_navigation_timeout(nav_timeout_ms)
+ context.set_default_timeout(nav_timeout_ms)
+
+ try:
+ page = context.new_page()
+ url = f"{base_url}{role.login.path}"
+ try:
+ page.goto(url, wait_until="domcontentloaded", timeout=nav_timeout_ms)
+ except Exception as exc: # pragma: no cover - depends on remote target
+ pytest.fail(
+ f"[pwk_role_{role.id}] login page open failed: {url} ({exc})"
+ )
+
+ for name, value in role.login.fields.items():
+ try:
+ page.locator(f'input[name="{name}"]').fill(
+ value, timeout=nav_timeout_ms
+ )
+ except Exception as exc: # pragma: no cover
+ pytest.fail(
+ f"[pwk_role_{role.id}] fill {name!r} failed: {exc}"
+ )
+
+ try:
+ with page.expect_navigation(
+ wait_until="domcontentloaded", timeout=nav_timeout_ms
+ ):
+ _submit_login_form(page, role.login)
+ except Exception as exc: # pragma: no cover
+ pytest.fail(
+ f"[pwk_role_{role.id}] navigation 失敗: "
+ f"{type(exc).__name__}: {exc}"
+ )
+
+ final_url = page.url
+ # Amazon Q Critical-1: fail_if_url_contains が空文字列の場合、空文字列は
+ # あらゆる文字列に含まれるため常に True になり全 login が失敗する。
+ # 空文字列 (= 未設定) の場合はチェックをスキップする。
+ if role.login.fail_if_url_contains and role.login.fail_if_url_contains in final_url:
+ pytest.fail(
+ f"[pwk_role_{role.id}] login 失敗: "
+ f"final_url={final_url} に '{role.login.fail_if_url_contains}' を含む"
+ )
+
+ state = context.storage_state()
+ return state
+ finally:
+ try:
+ context.close()
+ except Exception:
+ pass
+ finally:
+ try:
+ browser.close()
+ except Exception:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Public fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="session")
+def pwk_config(pytestconfig) -> Config:
+ """``--pwk-config`` で指定された YAML をロードして ``Config`` を返す。
+
+ 指定が無い場合は CWD 直下の ``scenario.config.yaml`` を試し、
+ それも無ければ ``pytest.skip`` する (NDF 機能が要らない test と共存可能にする)。
+ """
+ raw_path: str | None = pytestconfig.getoption("pwk_config", default=None)
+ if not raw_path:
+ env = os.environ.get("PWK_CONFIG")
+ if env:
+ raw_path = env
+ if not raw_path:
+ candidate = Path.cwd() / "scenario.config.yaml"
+ if candidate.exists():
+ raw_path = str(candidate)
+ if not raw_path:
+ pytest.skip(
+ "pwk_config 未指定: --pwk-config もしくは PWK_CONFIG env、"
+ "または ./scenario.config.yaml を用意してください。"
+ )
+
+ path = Path(raw_path).resolve()
+ return Config.load(path)
+
+
+@pytest.fixture(scope="session")
+def _pwk_storage_state_cache() -> _StorageStateCache:
+ return _StorageStateCache.empty()
+
+
+def _make_role_fixture(role_id: str) -> Callable:
+ """role_id ごとに ``pwk_role_`` fixture の実装関数を生成する。"""
+
+ def _fixture(
+ pwk_config: Config,
+ playwright,
+ context,
+ _pwk_storage_state_cache: _StorageStateCache,
+ ) -> Role:
+ """login 済の storage_state を ``context`` に注入し、Role を返す。
+
+ - ``playwright`` / ``context`` は ``pytest-playwright`` 提供
+ - 既に同 role の storage_state が cache 済なら login をスキップ
+ """
+ role = pwk_config.role(role_id)
+
+ state = _pwk_storage_state_cache.get(role_id)
+ if state is None:
+ state = _login_and_get_storage_state(
+ playwright=playwright,
+ base_url=pwk_config.base_url,
+ role=role,
+ basic_auth_user=pwk_config.basic_auth.user,
+ basic_auth_password=pwk_config.basic_auth.password,
+ verify_tls=pwk_config.verify_tls,
+ nav_timeout_ms=pwk_config.playwright.navigation_timeout_ms,
+ )
+ _pwk_storage_state_cache.put(role_id, state)
+
+ # cookies / origins (localStorage 等) を新しい context に注入する。
+ cookies = state.get("cookies") or []
+ if cookies:
+ context.add_cookies(cookies)
+ # storage_state に含まれる広告/計測タグ等の third-party origin に対して
+ # 不用意に goto するのを避けるため、base_url と同一 origin のみ復元する。
+ for origin in state.get("origins") or []:
+ url = origin.get("origin")
+ items = origin.get("localStorage") or []
+ if not url or not items:
+ continue
+ if not _same_origin(url, pwk_config.base_url):
+ continue
+ try:
+ page = context.new_page()
+ page.goto(url, wait_until="domcontentloaded")
+ for it in items:
+ page.evaluate(
+ "([k, v]) => window.localStorage.setItem(k, v)",
+ [it.get("name"), it.get("value")],
+ )
+ page.close()
+ except Exception:
+ # localStorage 注入失敗は致命的ではない (cookie ベースの認証なら OK)。
+ pass
+
+ return role
+
+ _fixture.__name__ = f"pwk_role_{role_id}"
+ _fixture.__doc__ = (
+ f"role={role_id!r} で login 済の storage_state を context に注入する。"
+ )
+ return _fixture
+
+
+def register_role_fixtures(plugin_module, config: Config) -> list[str]:
+ """plugin module に ``pwk_role_`` fixture を動的登録する。
+
+ ``pytest_configure`` から呼ばれる。pytest は modules の attribute を
+ fixture として discover するため、setattr で十分。
+
+ Returns:
+ 登録した fixture 名のリスト。
+ """
+ registered: list[str] = []
+ for role_id in config.roles:
+ name = f"pwk_role_{role_id}"
+ if hasattr(plugin_module, name):
+ continue
+ impl = _make_role_fixture(role_id)
+ # function scope (default) で wrap してから plugin module に attach
+ wrapped = pytest.fixture(name=name)(impl)
+ setattr(plugin_module, name, wrapped)
+ registered.append(name)
+ return registered
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py
new file mode 100644
index 00000000..d6437c2e
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py
@@ -0,0 +1,194 @@
+"""body_check fixture: ``page.on("response", ...)`` でレスポンス本文を監視し、
+PHP / SSR が HTML 本文に出力したエラー文字列を検出する (v0.4.0)。
+
+config.yaml の ``body_check.enabled`` の default は ``True`` (PHP 系パターン
+内蔵)。``page`` fixture を要求している test に限り autouse で listener を
+attach する (accessibility autouse と同じ guard 戦略)。
+
+opt-out:
+- 全体無効化: ``body_check.enabled: false`` を config.yaml に明示
+- カテゴリ単位: ``fatal_patterns: []`` などで明示空指定
+- 個別 test: ``@pytest.mark.no_body_check`` を付与
+
+注意:
+- 同じ page を ``pwk_body_check_scan`` helper で手動スキャンしつつ autouse
+ listener も走らせると、同一 violation を重複加算する。helper は autouse の
+ listener が拾えないタイミング (例: SPA 内のローカル DOM 更新後) でのみ使う。
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Iterator
+
+import pytest
+
+from playwright_kit.body_check import (
+ is_html_response,
+ scan_body,
+)
+from playwright_kit.config import BodyCheckConfig, Config
+from playwright_kit.fixtures.evidence import PwkEvidence
+
+
+def _build_response_handler(cfg: BodyCheckConfig, ev: PwkEvidence):
+ """``page.on("response", ...)`` 用の handler を closure として作る。
+
+ listener 内で発生する例外は test 失敗には伝播させず ``ev.log_lines`` に
+ 記録するに留める (accessibility / pageerror listener と同じ防御方針)。
+ """
+ fatal = tuple(cfg.fatal_patterns)
+ warn = tuple(cfg.warning_patterns)
+ not_found = tuple(cfg.not_found_patterns)
+ head_chars = int(cfg.warning_head_chars)
+
+ def _on_response(response) -> None:
+ try:
+ headers = response.headers or {}
+ if not is_html_response(headers.get("content-type") or headers.get("Content-Type")):
+ return
+ try:
+ body = response.text()
+ except Exception:
+ # navigation 中の中断 / closed context などで text() が失敗するケース
+ return
+ violations = scan_body(
+ body,
+ response.url,
+ fatal_patterns=fatal,
+ warning_patterns=warn,
+ warning_head_chars=head_chars,
+ not_found_patterns=not_found,
+ )
+ for v in violations:
+ ev.body_check_violations.append(v.to_dict())
+ except Exception as exc: # pragma: no cover - listener 内で test を落とさない
+ ev.log_lines.append(f"[body_check listener] {exc}")
+
+ return _on_response
+
+
+def _write_jsonl(ev: PwkEvidence) -> None:
+ """1 violation = 1 行で ``case_dir/body_check.jsonl`` に書き出す。"""
+ if not ev.body_check_violations:
+ return
+ try:
+ path = ev.case_dir / "body_check.jsonl"
+ with path.open("w", encoding="utf-8") as fp:
+ for v in ev.body_check_violations:
+ fp.write(json.dumps(v, ensure_ascii=False) + "\n")
+ except Exception as exc: # pragma: no cover
+ ev.log_lines.append(f"[body_check] jsonl write failed: {exc}")
+
+
+def _format_violation_summary(violations: list[dict[str, Any]], limit: int = 5) -> str:
+ """``pytest.fail`` メッセージ用の短いサマリ文字列。"""
+ parts: list[str] = []
+ for v in violations[:limit]:
+ url = v.get("url", "?")
+ cat = v.get("category", "?")
+ pat = v.get("pattern", "?")
+ parts.append(f"{cat}:{pat!r}@{url}")
+ if len(violations) > limit:
+ parts.append(f"... (+{len(violations) - limit} more)")
+ return "; ".join(parts)
+
+
+@pytest.fixture()
+def pwk_body_check_scan(page, pwk_evidence: PwkEvidence, _pwk_config_optional):
+ """明示呼び出し用: ``violations = pwk_body_check_scan()`` で現在の page 本文を 1 度スキャン。
+
+ autouse 経路を使わず、特定タイミング (例: フォーム送信後の 200 応答) で
+ 本文を再評価したい場合の helper。
+ """
+ config: Config | None = _pwk_config_optional
+
+ def _scan() -> list[dict[str, Any]]:
+ if config is None or not config.body_check.enabled:
+ return []
+ try:
+ body = page.content()
+ except Exception as exc:
+ pwk_evidence.log_lines.append(f"[body_check] page.content() failed: {exc}")
+ return []
+ violations = scan_body(
+ body,
+ page.url,
+ fatal_patterns=config.body_check.fatal_patterns,
+ warning_patterns=config.body_check.warning_patterns,
+ warning_head_chars=config.body_check.warning_head_chars,
+ not_found_patterns=config.body_check.not_found_patterns,
+ )
+ as_dicts = [v.to_dict() for v in violations]
+ pwk_evidence.body_check_violations.extend(as_dicts)
+ return as_dicts
+
+ return _scan
+
+
+@pytest.fixture(autouse=True)
+def _pwk_body_check_autouse(request) -> Iterator[None]:
+ """``page`` を要求する test に限り、HTML response への body_check を自動実行する。
+
+ ガード戦略:
+ - ``page`` を fixturename に持たない test は対象外 (browser を起動させない)
+ - config.body_check.enabled が False なら何もしない
+ - ``@pytest.mark.no_body_check`` が付いている test は skip
+
+ ``pwk_evidence`` は ``getfixturevalue`` で setup phase に lazy resolve する。
+ test 関数の引数に ``pwk_evidence`` を書いていなくても listener が attach される
+ (Issue #60)。
+
+ teardown 時に違反があれば ``case_dir/body_check.jsonl`` に書き出し、
+ ``fail_on_match`` が True なら ``pytest.fail`` で test を失敗させる。
+ """
+ if "page" not in request.fixturenames:
+ yield
+ return
+
+ config: Config | None = request.getfixturevalue("_pwk_config_optional")
+ if config is None or not config.body_check.enabled:
+ yield
+ return
+
+ if request.node.get_closest_marker("no_body_check") is not None:
+ yield
+ return
+
+ page = request.getfixturevalue("page")
+ # setup phase で fetch して closure に保持する (Issue #61 と同じ teardown
+ # order 問題を防ぐ)。
+ ev: PwkEvidence = request.getfixturevalue("pwk_evidence")
+ handler = _build_response_handler(config.body_check, ev)
+
+ try:
+ page.on("response", handler)
+ except Exception as exc:
+ ev.log_lines.append(f"[body_check] page.on attach failed: {exc}")
+ yield
+ return
+
+ try:
+ yield
+ finally:
+ try:
+ page.remove_listener("response", handler)
+ except Exception:
+ # page が既に閉じられている場合などは黙殺
+ pass
+
+ if not ev.body_check_violations:
+ return
+
+ _write_jsonl(ev)
+
+ ev.log_lines.append(
+ f"[body_check] {len(ev.body_check_violations)} 件の違反: "
+ + _format_violation_summary(ev.body_check_violations)
+ )
+
+ if config.body_check.fail_on_match:
+ pytest.fail(
+ f"[body_check] {len(ev.body_check_violations)} 件の本文エラーを検出: "
+ + _format_violation_summary(ev.body_check_violations)
+ )
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py
new file mode 100644
index 00000000..c5ab4846
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py
@@ -0,0 +1,353 @@
+"""evidence fixture: HAR / trace / console / pageerror の収集。
+
+pytest-playwright が提供する ``browser_context_args`` / ``context`` / ``page``
+fixture と組み合わせて、test 関数ごとに以下を自動収集する:
+
+- HAR: ``browser_context_args`` に ``record_har_path`` を inject
+- trace: ``context.tracing.start`` / ``stop`` (``--pwk-no-evidence`` で無効化)
+- console.error / pageerror: page listener として attach
+ (``tolerated_console_errors`` / ``tolerated_page_errors`` でフィルタ)
+
+artifact の出力先は ``--pwk-out-dir`` (default: ``./reports//``)。
+test 関数 ID から sub-dir を作って 1 test = 1 dir で隔離する。
+"""
+
+from __future__ import annotations
+
+import datetime as _dt
+import hashlib
+import os
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Iterator
+
+import pytest
+
+from playwright_kit.config import Config, PlaywrightConfig
+
+
+# ---------------------------------------------------------------------------
+# Output directory resolver (session scope)
+# ---------------------------------------------------------------------------
+
+
+def _resolve_out_dir(pytestconfig) -> Path:
+ """``--pwk-out-dir`` が指定されればそれを、なければ ``reports//``。
+
+ run_id は session 開始時に 1 度だけ決定し、``pytestconfig._pwk_out_dir`` に
+ キャッシュする。これにより ``pwk_out_dir`` fixture と
+ ``pytest_terminal_summary`` が別々に ``datetime.now()`` を呼んで
+ 秒またぎでディレクトリがズレる問題を防ぐ (新規 Major 対応)。
+
+ ``--pwk-out-dir`` が明示指定されている場合はキャッシュ不要のため
+ 常にその値を返す(複数回呼ばれても同じ値)。
+ """
+ raw: str | None = pytestconfig.getoption("pwk_out_dir", default=None)
+ if raw:
+ return Path(raw).resolve()
+
+ # --pwk-out-dir 未指定時のみキャッシュで run_id の秒またぎを防ぐ。
+ # hasattr で厳密にチェックし、MagicMock 等が偽の属性を返さないようにする。
+ if "_pwk_out_dir" in vars(pytestconfig):
+ return pytestconfig._pwk_out_dir # type: ignore[attr-defined]
+
+ run_id = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
+ out = (Path.cwd() / "reports" / run_id).resolve()
+ # session-scoped キャッシュとして保存
+ pytestconfig._pwk_out_dir = out # type: ignore[attr-defined]
+ return out
+
+
+@pytest.fixture(scope="session")
+def pwk_out_dir(pytestconfig) -> Path:
+ """session 全体で共有する成果物ルート。session 開始時に作成する。
+
+ ``_resolve_out_dir`` を通じて ``pytestconfig._pwk_out_dir`` にキャッシュし、
+ ``pytest_terminal_summary`` と同じ out_dir を参照する。
+ """
+ out = _resolve_out_dir(pytestconfig)
+ out.mkdir(parents=True, exist_ok=True)
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Per-test evidence (function scope)
+# ---------------------------------------------------------------------------
+
+
+_FILENAME_SAFE_RE = re.compile(r"[^\w\-]+")
+
+
+def _safe_slug(name: str, fallback: str = "test") -> str:
+ """文字列から安全なファイル名スラグを生成する (後方互換維持)。"""
+ s = _FILENAME_SAFE_RE.sub("-", name).strip("-").lower()
+ return s[:80] or fallback
+
+
+def _safe_case_slug(node: Any) -> str:
+ """nodeid + xdist worker + sha1[:6] suffix で衝突しない slug を生成する (Codex Major 2)。
+
+ - parametrize / 同名関数 / xdist 並列で trace.zip / request.har の上書きを防止。
+ - 既存の _safe_slug(name, fallback) 仕様は変えず、evidence fixture 内のみ本関数を使う。
+ """
+ nodeid = getattr(node, "nodeid", getattr(node, "name", "test"))
+ worker = os.environ.get("PYTEST_XDIST_WORKER", "")
+ raw = f"{nodeid}@{worker}" if worker else nodeid
+ slug = _FILENAME_SAFE_RE.sub("-", raw).strip("-").lower()
+ digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:6]
+ # 60 文字 + "-" + sha1[:6] = 最大 67 文字程度に圧縮
+ return f"{slug[:60]}-{digest}".strip("-") or "test"
+
+
+@dataclass
+class PwkEvidence:
+ """1 test 関数分の証跡コレクタ。"""
+
+ case_dir: Path
+ config: Config | None
+ enabled: bool
+
+ har_path: Path | None = None
+ trace_path: Path | None = None
+ har_relpath: str | None = None
+ trace_relpath: str | None = None
+
+ console_errors: list[str] = field(default_factory=list)
+ page_errors: list[str] = field(default_factory=list)
+ axe_violations: list[dict[str, Any]] = field(default_factory=list)
+ web_vitals_metrics: dict[str, float] = field(default_factory=dict)
+ web_vitals_passed: bool = True
+ # PHP / SSR ページ本文エラー (body_check) 違反 (v0.4.0)。1 件 = 1 dict
+ # ({url, category, pattern, snippet})。
+ body_check_violations: list[dict[str, Any]] = field(default_factory=list)
+
+ log_lines: list[str] = field(default_factory=list)
+
+ _trace_started: bool = field(default=False, init=False, repr=False)
+ _tolerated_console_re: list[re.Pattern[str]] = field(
+ default_factory=list, init=False, repr=False
+ )
+ _tolerated_page_re: list[re.Pattern[str]] = field(
+ default_factory=list, init=False, repr=False
+ )
+
+ def __post_init__(self) -> None:
+ if self.config is not None:
+ self._tolerated_console_re = [
+ re.compile(p) for p in self.config.tolerated_console_errors
+ ]
+ self._tolerated_page_re = [
+ re.compile(p) for p in self.config.tolerated_page_errors
+ ]
+
+ # --- listener ------------------------------------------------------
+
+ def attach_listeners(self, page) -> None:
+ page.on("console", self._on_console)
+ page.on("pageerror", self._on_pageerror)
+
+ def _on_console(self, msg) -> None:
+ try:
+ if msg.type != "error":
+ return
+ loc = getattr(msg, "location", None) or {}
+ text = msg.text[:500]
+ for rx in self._tolerated_console_re:
+ if rx.search(text):
+ return
+ self.console_errors.append(f"{loc.get('url', '?')}: {text}")
+ except Exception as exc: # pragma: no cover
+ self.log_lines.append(f"[console listener] {exc}")
+
+ def _on_pageerror(self, exc) -> None:
+ try:
+ text = str(exc)[:1000]
+ for rx in self._tolerated_page_re:
+ if rx.search(text):
+ return
+ self.page_errors.append(text)
+ except Exception as listener_exc: # pragma: no cover
+ self.log_lines.append(f"[pageerror listener] {listener_exc}")
+
+ # --- trace lifecycle (context scope) -------------------------------
+
+ def start_tracing(self, context) -> None:
+ if not self.enabled or self.trace_path is None:
+ return
+ try:
+ context.tracing.start(
+ name=self.case_dir.name,
+ title=self.case_dir.name,
+ snapshots=True,
+ screenshots=True,
+ sources=False,
+ )
+ self._trace_started = True
+ except Exception as exc: # pragma: no cover
+ self.log_lines.append(f"[trace] start 失敗: {exc}")
+
+ def stop_tracing(self, context) -> None:
+ if not self._trace_started or self.trace_path is None:
+ return
+ try:
+ context.tracing.stop(path=str(self.trace_path))
+ self.trace_relpath = self.trace_path.name
+ except Exception as exc: # pragma: no cover
+ self.log_lines.append(f"[trace] stop 失敗: {exc}")
+
+ def confirm_har(self) -> None:
+ if self.har_path and self.har_path.exists():
+ self.har_relpath = self.har_path.name
+
+ # --- summary -------------------------------------------------------
+
+ @property
+ def has_runtime_errors(self) -> bool:
+ return bool(self.console_errors or self.page_errors)
+
+ def runtime_error_summary(self) -> str:
+ parts: list[str] = []
+ if self.page_errors:
+ parts.append(f"pageerror {len(self.page_errors)} 件")
+ if self.console_errors:
+ parts.append(f"console.error {len(self.console_errors)} 件")
+ return "Runtime errors detected: " + ", ".join(parts) if parts else ""
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="session")
+def _pwk_config_optional(pytestconfig) -> Config | None:
+ """``pwk_config`` を session に 1 度だけ load する (失敗時は None)。
+
+ evidence fixture は pwk_config が無くても動くように optional にしてある。
+ """
+ cached = getattr(pytestconfig, "_pwk_config", None)
+ if cached is not None:
+ return cached # type: ignore[no-any-return]
+ return None
+
+
+def _resolve_har_mode(pytestconfig, pwk_config) -> str:
+ """``--pwk-har-mode`` CLI > ``playwright.har_mode`` config > ``PlaywrightConfig`` default。
+
+ Issue #62 対策で default は ``minimal``。``--pwk-no-evidence`` が True の場合
+ 呼び出し側で HAR を一切 inject しない (本関数の戻り値は使われない)。
+
+ default 値は ``PlaywrightConfig().har_mode`` から引くことで dataclass 側との
+ 二重管理を防ぐ。
+ """
+ cli = pytestconfig.getoption("pwk_har_mode", default=None)
+ if cli:
+ # ``pytest_plugin.py`` の argparse ``choices`` で値は担保済だが、
+ # API 経由 (``--config`` 等) で大文字が来た場合に備える defensive normalize。
+ return str(cli).lower()
+ if pwk_config is not None:
+ return str(pwk_config.playwright.har_mode).lower()
+ return PlaywrightConfig().har_mode
+
+
+@pytest.fixture()
+def browser_context_args(
+ browser_context_args, request, pytestconfig, pwk_out_dir, _pwk_config_optional
+) -> dict[str, Any]:
+ """pytest-playwright の ``browser_context_args`` を function scope で override し、
+ 1 test = 1 HAR を実現する (Codex Major 1)。
+
+ - scope を function に変更し、``request.node`` ごとに ``case_dir/request.har``
+ を ``record_har_path`` に inject する。
+ - session 共通 HAR (``session.har``) は廃止。これにより
+ ``PwkEvidence.confirm_har()`` が常に None を返す不整合を解消。
+ - ``--pwk-no-evidence`` が True なら HAR 収集を OFF。
+ - HAR mode (Issue #62):
+ - ``--pwk-har-mode none`` (または config ``playwright.har_mode: none``):
+ ``record_har_path`` を inject しない。
+ - ``minimal`` (default): ``record_har_mode="minimal"`` でメタデータのみ
+ 記録。Basic 認証 + redirect 連続時の ``ERR_ABORTED`` race を回避する。
+ - ``full``: Playwright 既定の full HAR (body + content) を記録。
+ """
+ no_evidence = bool(pytestconfig.getoption("pwk_no_evidence", default=False))
+ args = dict(browser_context_args or {})
+ if no_evidence:
+ return args
+
+ har_mode = _resolve_har_mode(pytestconfig, _pwk_config_optional)
+ if har_mode == "none":
+ return args
+
+ case_dir = pwk_out_dir / _safe_case_slug(request.node)
+ case_dir.mkdir(parents=True, exist_ok=True)
+ args.setdefault("record_har_path", str(case_dir / "request.har"))
+ if har_mode == "minimal":
+ # Playwright の record_har_mode="minimal" (Playwright >= 1.30) は
+ # request/response の主要メタデータのみ。content は記録されないので
+ # omit 指定は不要。
+ args.setdefault("record_har_mode", "minimal")
+ else: # "full"
+ # 既存挙動 (body 含む) を維持しつつ content だけは省略する。
+ args.setdefault("record_har_content", "omit")
+ return args
+
+
+@pytest.fixture()
+def pwk_evidence(
+ request,
+ pytestconfig,
+ pwk_out_dir: Path,
+ _pwk_config_optional,
+ context,
+ page,
+) -> Iterator[PwkEvidence]:
+ """1 test 関数分の evidence collector を返す。
+
+ - ``--pwk-no-evidence`` が True なら trace/HAR を OFF にし、listener のみ動かす
+ - ``page`` fixture から console / pageerror listener を attach
+ - ``context.tracing.start/stop`` を裏で実行 (有効時)
+ - ``--pwk-overlay`` 指定時は ``overlay.OVERLAY_INIT_SCRIPT`` を ``context.add_init_script``
+ で全 page に inject する
+ - ``pytest_runtest_makereport`` から FAIL 時に ``pwk_evidence`` の状態を確認可能
+ """
+ enabled = not bool(pytestconfig.getoption("pwk_no_evidence", default=False))
+ overlay_enabled = bool(pytestconfig.getoption("pwk_overlay", default=False))
+ # _safe_case_slug で nodeid + xdist worker + sha1[:6] の衝突しない slug を使用 (Codex Major 2)
+ case_dir = pwk_out_dir / _safe_case_slug(request.node)
+ case_dir.mkdir(parents=True, exist_ok=True)
+
+ # HAR mode が "none" のときは ``request.har`` を期待しないようにする
+ # (browser_context_args で record_har_path 自体を inject していない: Issue #62)。
+ har_mode = _resolve_har_mode(pytestconfig, _pwk_config_optional)
+ har_enabled = enabled and har_mode != "none"
+
+ ev = PwkEvidence(
+ case_dir=case_dir,
+ config=_pwk_config_optional,
+ enabled=enabled,
+ har_path=(case_dir / "request.har") if har_enabled else None,
+ trace_path=(case_dir / "trace.zip") if enabled else None,
+ )
+ ev.attach_listeners(page)
+ ev.start_tracing(context)
+
+ # overlay (赤丸カーソル + 字幕、旧名 HUD) を init_script で inject。
+ if overlay_enabled:
+ try:
+ from playwright_kit.overlay import OVERLAY_INIT_SCRIPT
+
+ context.add_init_script(OVERLAY_INIT_SCRIPT)
+ except Exception as exc: # pragma: no cover
+ ev.log_lines.append(f"[overlay] add_init_script 失敗: {exc}")
+
+ # request.node に ev を保持して makereport hook から参照可能にする
+ request.node._pwk_evidence = ev # type: ignore[attr-defined]
+
+ try:
+ yield ev
+ finally:
+ try:
+ ev.stop_tracing(context)
+ finally:
+ ev.confirm_har()
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py
new file mode 100644
index 00000000..924e382a
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py
@@ -0,0 +1,109 @@
+"""Web Vitals fixture: ``page_role`` marker に応じた Core Web Vitals 自動計測。
+
+``@pytest.mark.page_role("dashboard")`` 等が付与された test の終了直前に
+LCP (Largest Contentful Paint) / CLS (Cumulative Layout Shift) /
+TTFB (Time To First Byte) / longest_task (Long Tasks API) を計測する。
+"""
+
+from __future__ import annotations
+
+from typing import Iterator
+
+import pytest
+
+from playwright_kit import web_vitals as web_vitals_mod
+from playwright_kit.config import Config
+from playwright_kit.fixtures.evidence import PwkEvidence
+
+
+def _page_roles_from_marker(item) -> list[str]:
+ roles: list[str] = []
+ for marker in item.iter_markers(name="page_role"):
+ for arg in marker.args:
+ if isinstance(arg, str):
+ roles.append(arg)
+ elif isinstance(arg, (list, tuple)):
+ roles.extend(str(a) for a in arg)
+ return roles
+
+
+@pytest.fixture()
+def pwk_web_vitals_measure(page, pwk_evidence: PwkEvidence, _pwk_config_optional):
+ """明示呼び出し用: ``metrics = pwk_web_vitals_measure()`` で 1 度計測。"""
+ config: Config | None = _pwk_config_optional
+
+ def _measure(*, observe_ms: int | None = None) -> dict[str, float]:
+ ms = (
+ int(observe_ms)
+ if observe_ms is not None
+ else (
+ int(config.web_vitals.observe_ms)
+ if config is not None
+ else 5000
+ )
+ )
+ metrics = web_vitals_mod.measure_page(page, observe_ms=ms)
+ pwk_evidence.web_vitals_metrics.update(metrics)
+ pwk_evidence.web_vitals_passed = web_vitals_mod.passed(pwk_evidence.web_vitals_metrics)
+ return metrics
+
+ return _measure
+
+
+@pytest.fixture(autouse=True)
+def _pwk_web_vitals_autouse(request) -> Iterator[None]:
+ """``page_role`` marker が付いた test の終了直前に Web Vitals 計測を行う。
+
+ accessibility autouse と同じく ``page`` fixture を要求している test のみ対象。
+
+ Issue #60 fix: 旧版の ``"pwk_evidence" not in request.fixturenames`` ガードを
+ 廃止。test 引数に ``pwk_evidence`` を書いていなくても ``getfixturevalue``
+ 経由で lazy 取得し、Web Vitals autouse が走るようにする。
+
+ teardown order 対策 (Issue #61): ``yield`` 後に ``pwk_evidence`` を fetch
+ しようとすると LIFO 解放済の AssertionError になるため、setup phase で
+ ``ev`` / ``page`` を取得して closure に保持する。
+ """
+
+ if "page" not in request.fixturenames:
+ yield
+ return
+
+ config: Config | None = request.getfixturevalue("_pwk_config_optional")
+ if config is None or not config.web_vitals.enabled:
+ yield
+ return
+ page_roles = _page_roles_from_marker(request.node)
+ if not page_roles:
+ yield
+ return
+ if not web_vitals_mod.should_auto_measure(
+ page_roles, auto_roles=frozenset(config.web_vitals.auto_roles)
+ ):
+ yield
+ return
+
+ # setup phase: closure に保持。
+ pwk_evidence: PwkEvidence = request.getfixturevalue("pwk_evidence")
+ page = request.getfixturevalue("page")
+
+ yield
+
+ # teardown phase: closure 経由でアクセス。
+ try:
+ if page.is_closed():
+ return
+ except Exception:
+ return
+
+ metrics = web_vitals_mod.measure_page(page, observe_ms=int(config.web_vitals.observe_ms))
+ pwk_evidence.web_vitals_metrics.update(metrics)
+ pwk_evidence.web_vitals_passed = web_vitals_mod.passed(pwk_evidence.web_vitals_metrics)
+
+ detail = ", ".join(
+ f"{k}={v:.1f}({web_vitals_mod.judge(k, v)})" for k, v in metrics.items()
+ ) or "no metrics collected"
+ pwk_evidence.log_lines.append(f"[web_vitals autouse] {detail}")
+
+ if not pwk_evidence.web_vitals_passed and config.web_vitals.fail_on_poor:
+ pytest.fail(f"[web_vitals] poor metric を検出: {detail}")
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/overlay.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/overlay.py
new file mode 100644
index 00000000..21e9844a
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/overlay.py
@@ -0,0 +1,220 @@
+"""動画録画用 HUD オーバーレイ (赤丸カーソル + 2 行字幕) の制御。
+
+ブラウザ標準のカーソルは録画に焼き付かないため、JS で `` を 2 つ inject する:
+ - `#__hud_cursor` — mousemove/down/up を追う赤丸(クリックで黄色リップル)
+ - `#__hud_caption` — 画面上部の 2 行字幕(`直前 │ … / 次へ │ …`)
+
+OVERLAY_INIT_SCRIPT は context.add_init_script() に渡す。
+他の関数は Page を受け取り、HUD の状態を JS evaluate 越しに更新する。
+"""
+
+from __future__ import annotations
+
+from playwright.sync_api import Page
+
+
+OVERLAY_INIT_SCRIPT = r"""
+(() => {
+ if (window.__hudInited) return;
+ window.__hudInited = true;
+ function setup() {
+ if (!document.body) return false;
+ const cursor = document.createElement('div');
+ cursor.id = '__hud_cursor';
+ cursor.style.cssText =
+ 'position:fixed;width:24px;height:24px;border-radius:50%;' +
+ 'background:rgba(255,80,80,0.55);border:2px solid #f33;' +
+ 'pointer-events:none;z-index:2147483647;' +
+ 'transform:translate(-50%,-50%);' +
+ 'box-shadow:0 0 10px rgba(255,0,0,0.7);transition:background 0.1s;';
+ document.documentElement.appendChild(cursor);
+ // 前のページから引き継いだ最終カーソル位置と表示状態を復元
+ try {
+ const cx = sessionStorage.getItem('__hudCursorX');
+ const cy = sessionStorage.getItem('__hudCursorY');
+ const cv = sessionStorage.getItem('__hudCursorVisible');
+ if (cx !== null) cursor.style.left = cx + 'px';
+ if (cy !== null) cursor.style.top = cy + 'px';
+ // 既定は「非表示」 (擬似クリック対象がないステップは消す方針)
+ if (cv === '1') {
+ cursor.style.opacity = '1';
+ cursor.style.visibility = 'visible';
+ } else {
+ cursor.style.opacity = '0';
+ cursor.style.visibility = 'hidden';
+ }
+ } catch (e) {}
+ document.addEventListener('mousemove', (e) => {
+ cursor.style.left = e.clientX + 'px';
+ cursor.style.top = e.clientY + 'px';
+ try {
+ sessionStorage.setItem('__hudCursorX', String(e.clientX));
+ sessionStorage.setItem('__hudCursorY', String(e.clientY));
+ } catch (err) {}
+ }, true);
+ // 任意座標でクリックリップルを発火させる外部 API
+ window.__hudFlash = function(x, y) {
+ cursor.style.left = x + 'px';
+ cursor.style.top = y + 'px';
+ cursor.style.opacity = '1';
+ cursor.style.visibility = 'visible';
+ try { sessionStorage.setItem('__hudCursorVisible', '1'); } catch (e) {}
+ spawnRipple(x, y);
+ cursor.style.background = 'rgba(0,255,200,0.95)';
+ cursor.style.transform = 'translate(-50%,-50%) scale(1.6)';
+ cursor.style.boxShadow = '0 0 22px rgba(255,224,0,0.95)';
+ cursor.style.borderColor = '#ffe000';
+ setTimeout(() => {
+ cursor.style.background = 'rgba(255,80,80,0.55)';
+ cursor.style.transform = 'translate(-50%,-50%) scale(1)';
+ cursor.style.boxShadow = '0 0 10px rgba(255,0,0,0.7)';
+ cursor.style.borderColor = '#f33';
+ }, 400);
+ };
+
+ // 擬似クリック対象が見つからないステップで非表示にする
+ window.__hudHideCursor = function() {
+ cursor.style.opacity = '0';
+ cursor.style.visibility = 'hidden';
+ try { sessionStorage.setItem('__hudCursorVisible', '0'); } catch (e) {}
+ };
+
+ function spawnRipple(x, y) {
+ // 3 重リングのリップルでクリック箇所を強調
+ for (let i = 0; i < 3; i++) {
+ const ring = document.createElement('div');
+ ring.style.cssText =
+ 'position:fixed;pointer-events:none;z-index:2147483645;' +
+ 'left:' + x + 'px;top:' + y + 'px;' +
+ 'width:0;height:0;border:4px solid #ffe000;' +
+ 'border-radius:50%;transform:translate(-50%,-50%);' +
+ 'box-shadow:0 0 12px rgba(255,224,0,0.8);';
+ document.documentElement.appendChild(ring);
+ ring.animate(
+ [
+ { width: '24px', height: '24px', opacity: 1, borderWidth: '5px',
+ borderColor: '#ffe000' },
+ { width: '110px', height: '110px', opacity: 0, borderWidth: '2px',
+ borderColor: '#ff6600' }
+ ],
+ { duration: 800, delay: i * 130, easing: 'ease-out',
+ fill: 'forwards' }
+ );
+ setTimeout(() => { try { ring.remove(); } catch(e) {} },
+ 850 + i * 130);
+ }
+ const cross = document.createElement('div');
+ cross.style.cssText =
+ 'position:fixed;pointer-events:none;z-index:2147483647;' +
+ 'left:' + x + 'px;top:' + y + 'px;' +
+ 'width:48px;height:48px;transform:translate(-50%,-50%);' +
+ 'background:radial-gradient(circle,rgba(255,224,0,0.55) 0%,rgba(255,224,0,0) 70%);';
+ document.documentElement.appendChild(cross);
+ cross.animate(
+ [{ opacity: 1 }, { opacity: 0 }],
+ { duration: 600, easing: 'ease-out', fill: 'forwards' }
+ );
+ setTimeout(() => { try { cross.remove(); } catch(e) {} }, 650);
+ }
+
+ document.addEventListener('mousedown', (e) => {
+ cursor.style.background = 'rgba(0,255,200,0.95)';
+ cursor.style.transform = 'translate(-50%,-50%) scale(1.6)';
+ cursor.style.boxShadow = '0 0 22px rgba(255,224,0,0.95)';
+ cursor.style.borderColor = '#ffe000';
+ spawnRipple(e.clientX, e.clientY);
+ }, true);
+ document.addEventListener('mouseup', () => {
+ cursor.style.background = 'rgba(255,80,80,0.55)';
+ cursor.style.transform = 'translate(-50%,-50%) scale(1)';
+ cursor.style.boxShadow = '0 0 10px rgba(255,0,0,0.7)';
+ cursor.style.borderColor = '#f33';
+ }, true);
+
+ const HUD_HEIGHT = 60; // 字幕 2 行分の固定高
+ const cap = document.createElement('div');
+ cap.id = '__hud_caption';
+ cap.style.cssText =
+ 'position:fixed;top:0;left:0;right:0;' +
+ 'height:' + HUD_HEIGHT + 'px;' +
+ 'background:rgba(0,0,0,0.88);color:#fff;' +
+ 'font:14px/1.5 "Noto Sans CJK JP","Noto Sans JP",' +
+ '"Hiragino Sans","Yu Gothic","Meiryo",IPAGothic,sans-serif;' +
+ 'font-feature-settings:"palt";' +
+ 'padding:8px 16px;pointer-events:none;z-index:2147483646;' +
+ 'box-sizing:border-box;border-bottom:3px solid #fa0;' +
+ 'white-space:pre-wrap;word-break:break-all;';
+ document.documentElement.appendChild(cap);
+
+ // 本文が字幕で隠れないよう body を下にずらす (border-bottom 3px を含めて +3)
+ try {
+ const padTop = (HUD_HEIGHT + 3) + 'px';
+ document.body.style.paddingTop = padTop;
+ document.documentElement.style.scrollPaddingTop = padTop;
+ } catch (e) {}
+ let stored = '';
+ try { stored = sessionStorage.getItem('__hudCaption') || ''; } catch (e) {}
+ cap.textContent = window.__pendingCaption || stored || '';
+ return true;
+ }
+ if (!setup()) {
+ document.addEventListener('DOMContentLoaded', setup);
+ }
+})();
+"""
+
+
+_SET_CAPTION_JS = """(text) => {
+ window.__pendingCaption = text;
+ try { sessionStorage.setItem('__hudCaption', text); } catch (e) {}
+ const cap = document.getElementById('__hud_caption');
+ if (cap) cap.textContent = text;
+}"""
+
+
+def set_caption(page: Page, *, previous: str = "", next_action: str = "") -> None:
+ """現ページの HUD 字幕を「直前 / 次へ」の 2 行で更新する。
+
+ sessionStorage にも書き込むので、次の navigation 後に init script が拾い直す。
+ """
+ parts = [
+ f"直前 │ {previous}" if previous else None,
+ f"次へ │ {next_action}" if next_action else None,
+ ]
+ text = "\n".join(p for p in parts if p)
+ try:
+ page.evaluate(_SET_CAPTION_JS, text)
+ except Exception:
+ pass
+
+
+def flash_click(page: Page, x: int, y: int, *, settle_ms: int = 250) -> None:
+ """指定座標 (viewport 内) にカーソルを移動し、HUD リップルを発火させる。
+
+ DOM 要素はクリックしない (`page.mouse.click` は呼ばない)。HUD オーバーレイの
+ `__hudFlash(x, y)` を JS evaluate で呼び出すだけ。
+ """
+ try:
+ page.mouse.move(x, y, steps=10)
+ except Exception:
+ pass
+ try:
+ page.evaluate(
+ "(c) => { if (window.__hudFlash) window.__hudFlash(c[0], c[1]); }",
+ [x, y],
+ )
+ except Exception:
+ return
+ if settle_ms > 0:
+ try:
+ page.wait_for_timeout(settle_ms)
+ except Exception:
+ pass
+
+
+def hide_cursor(page: Page) -> None:
+ """擬似クリック対象が見つからないとき、HUD カーソルを非表示にする。"""
+ try:
+ page.evaluate("() => { if (window.__hudHideCursor) window.__hudHideCursor(); }")
+ except Exception:
+ pass
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py
new file mode 100644
index 00000000..d8e46208
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py
@@ -0,0 +1,491 @@
+"""playwright_kit の pytest plugin (E2E シナリオテスト)。
+
+CLI options:
+- ``--pwk-config
``: scenario.config.yaml を指定
+- ``--pwk-out-dir ``: 成果物 (HAR / trace / 動画 / report) の出力先
+- ``--pwk-no-evidence``: evidence 収集を OFF
+- ``--pwk-overlay``: overlay (赤丸カーソル + 字幕、旧名 HUD) を ON
+- ``--pwk-drive-folder ``: Drive 連携
+
+markers:
+- ``page_role(*roles)``: accessibility / web vitals autouse の判定材料
+- ``role(role_id)``: login する role を明示 (`pwk_role_` fixture と並用可)
+- ``phase(num)``: report.md のフェーズ集計用
+- ``priority(level)``: report.md のソート用
+"""
+
+from __future__ import annotations
+
+import datetime as _dt
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from playwright_kit.pytest_report import PwkTestEntry, write_report
+
+# 配下の fixture モジュールを pytest_plugins として読み込む
+# (こうすると entry-point 経由で plugin がロードされた瞬間に fixture が
+# 全 test に対して discover される)。
+pytest_plugins = [
+ "playwright_kit.fixtures.auth",
+ "playwright_kit.fixtures.evidence",
+ "playwright_kit.fixtures.accessibility",
+ "playwright_kit.fixtures.web_vitals",
+ "playwright_kit.fixtures.body_check",
+]
+
+
+# ---------------------------------------------------------------------------
+# CLI options
+# ---------------------------------------------------------------------------
+
+
+def pytest_addoption(parser: pytest.Parser) -> None:
+ group = parser.getgroup("pwk", "playwright E2E scenario test (playwright_kit)")
+ group.addoption(
+ "--pwk-config",
+ action="store",
+ default=None,
+ help="scenario.config.yaml へのパス (env PWK_CONFIG, または ./scenario.config.yaml も可)",
+ )
+ group.addoption(
+ "--pwk-out-dir",
+ action="store",
+ default=None,
+ help="成果物出力先ディレクトリ (default: ./reports//)",
+ )
+ group.addoption(
+ "--pwk-no-evidence",
+ action="store_true",
+ default=False,
+ help="HAR / trace / video の収集を OFF にする",
+ )
+ group.addoption(
+ "--pwk-har-mode",
+ action="store",
+ default=None,
+ choices=["minimal", "full", "none"],
+ help=(
+ "HAR 録画モード (Issue #62)。"
+ "minimal=メタデータのみ (default; Basic 認証 + redirect race を回避), "
+ "full=Playwright 既定の full HAR, "
+ "none=HAR を出力しない。"
+ "config の playwright.har_mode より優先。"
+ ),
+ )
+ group.addoption(
+ "--pwk-no-video",
+ action="store_true",
+ default=False,
+ help="動画収集を明示的に OFF にする (デフォルトは全テストで動画 ON)",
+ )
+ group.addoption(
+ "--pwk-overlay",
+ action="store_true",
+ default=False,
+ help="overlay (赤丸カーソル + 字幕、旧名 HUD) を全 page に inject する",
+ )
+ group.addoption(
+ "--pwk-drive-folder",
+ action="store",
+ default=None,
+ help=(
+ "Drive アップロード先フォルダ ID (terminal_summary 後に upload 実行)。"
+ "trace.zip / *.har / 動画には機微情報 (URL / Cookie / localStorage / 操作履歴) "
+ "が含まれる可能性があります。private folder + 信頼できる共有相手のみに限定してください。"
+ " (Codex Minor 8)"
+ ),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Markers / Config
+# ---------------------------------------------------------------------------
+
+
+_PWK_MARKERS: list[tuple[str, str]] = [
+ ("page_role", "page_role(*roles): accessibility / web vitals autouse の判定 (例: form, list, dashboard)"),
+ ("role", "role(role_id): test がどの login role を要求するか (`pwk_role_` 経由でも可)"),
+ ("phase", "phase(num): report.md のフェーズ集計用 (1〜N の整数)"),
+ ("priority", "priority(level): report.md のソート用 (high/mid/low など任意文字列)"),
+ ("no_body_check", "no_body_check: body_check autouse をこの test では skip する"),
+]
+
+
+def pytest_configure(config: pytest.Config) -> None:
+ """marker 登録 + config の早期 load を試みる。
+
+ config 読み込みは ``pwk_config`` fixture でも遅延ロードされるが、
+ ``pwk_role_`` fixture を *動的登録* するためには
+ ``pytest_configure`` で 1 度 Config をロードしておく必要がある。
+ failure は警告にとどめ、利用者が playwright_kit 機能を使わない場合に test 全体を
+ 潰さないようにする。
+ """
+ for name, doc in _PWK_MARKERS:
+ config.addinivalue_line("markers", f"{name}: {doc}")
+
+ # 動的 fixture 登録のため、可能なら Config を early load する。
+ cfg = _try_load_config_silently(config)
+ if cfg is not None:
+ from playwright_kit.fixtures import auth as auth_module
+
+ registered = auth_module.register_role_fixtures(auth_module, cfg)
+ if registered:
+ # plugin 自体にも公開しておく (ユーザが import 元を調整しなくて良いように)。
+ import playwright_kit.pytest_plugin as plugin_self
+
+ for name in registered:
+ fn = getattr(auth_module, name, None)
+ if fn is not None:
+ setattr(plugin_self, name, fn)
+ # session 中で再利用するためにキャッシュする。
+ config._pwk_config = cfg # type: ignore[attr-defined]
+
+ # 動画デフォルト ON (大原則: エビデンス動画を常に取得)
+ # ユーザーが --video を CLI で明示指定した場合はそちらを優先する。
+ # --pwk-no-video 指定時は video='off' に設定する。
+ # --pwk-no-evidence 指定時も video='off' に設定する (全エビデンス OFF)。
+ # pytest-playwright の --video デフォルト値は 'off' であるため、
+ # getoption() の返り値では明示指定の有無を判別できない。
+ # invocation_params.args を走査して明示指定を検出する。
+ try:
+ cli_args = list(config.invocation_params.args)
+ video_explicitly_set = any(
+ a == "--video" or a.startswith("--video=") for a in cli_args
+ )
+ no_video = config.getoption("pwk_no_video", default=False)
+ no_evidence = config.getoption("pwk_no_evidence", default=False)
+ if not video_explicitly_set:
+ if no_video or no_evidence:
+ config.option.video = "off"
+ else:
+ config.option.video = "on"
+ except (ValueError, AttributeError):
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Reports / hooks
+# ---------------------------------------------------------------------------
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_runtest_makereport(item, call):
+ """test の各 phase 終了時に ``pwk_evidence`` の状態をレポートに紐付ける。
+
+ FAIL 時には evidence の trace/HAR path を log に追記し、
+ 成果物 path / marker を rep.user_properties に保存して
+ ``pytest_terminal_summary`` で report.md に集約する。
+
+ HAR lifecycle 修正 (Codex Major-1 完遂 / 3回目):
+ Playwright は HAR を ``context.close()`` 時に flush する。
+ ``pwk_evidence`` の finalizer は ``context`` の finalizer より先に動くため、
+ call phase の時点では ``har_relpath`` / ``trace_relpath`` がまだ未確定の場合がある。
+
+ teardown phase の makereport は pytest-playwright の ``context`` finalizer が
+ teardown 中に完了した後に走るため、ここで再度 ``confirm_har()`` を呼んで
+ HAR の存在を確認し直し、確定した path を teardown report の user_properties に
+ 積む。``_collect_entries()`` が teardown report の pwk_har/pwk_trace を call
+ entry に merge することで、report.md に artifact path が反映される。
+ """
+ outcome = yield
+ rep = outcome.get_result()
+
+ ev = getattr(item, "_pwk_evidence", None)
+
+ # teardown phase: context.close() 後に HAR が flush されるため confirm_har() 再呼び出し。
+ # 確定した har_relpath / trace_relpath を teardown report の user_properties に積む。
+ # body_check_violations もこの phase で確定する (autouse fixture finalizer が
+ # pytest.fail 直前まで populate してから走る)。
+ # _collect_entries() がこれらの値を call entry に merge する。
+ if rep.when == "teardown" and ev is not None:
+ ev.confirm_har()
+ if ev.har_relpath:
+ rep.user_properties.append(("pwk_har", str(ev.case_dir / ev.har_relpath)))
+ if ev.trace_relpath:
+ rep.user_properties.append(
+ ("pwk_trace", str(ev.case_dir / ev.trace_relpath))
+ )
+ rep.user_properties.append(
+ ("pwk_body_check_violations", len(ev.body_check_violations))
+ )
+ if ev.body_check_violations:
+ rep.user_properties.append(
+ ("pwk_body_check_detail", list(ev.body_check_violations))
+ )
+ return
+
+ if rep.when != "call":
+ return
+
+ # pwk_evidence fixture が attach した状態を直接参照
+ if ev is not None:
+ if ev.har_relpath:
+ rep.user_properties.append(("pwk_har", str(ev.case_dir / ev.har_relpath)))
+ if ev.trace_relpath:
+ rep.user_properties.append(
+ ("pwk_trace", str(ev.case_dir / ev.trace_relpath))
+ )
+ rep.user_properties.append(("pwk_console_errors", len(ev.console_errors)))
+ rep.user_properties.append(("pwk_page_errors", len(ev.page_errors)))
+
+ # markers を user_properties に転写
+ page_roles: list[str] = []
+ for marker in item.iter_markers(name="page_role"):
+ for arg in marker.args:
+ if isinstance(arg, str):
+ page_roles.append(arg)
+ elif isinstance(arg, (list, tuple)):
+ page_roles.extend(str(a) for a in arg)
+ if page_roles:
+ rep.user_properties.append(("pwk_page_role", page_roles))
+
+ role_marker = item.get_closest_marker("role")
+ if role_marker is not None and role_marker.args:
+ rep.user_properties.append(("pwk_role", str(role_marker.args[0])))
+
+ phase_marker = item.get_closest_marker("phase")
+ if phase_marker is not None and phase_marker.args:
+ try:
+ rep.user_properties.append(("pwk_phase", int(phase_marker.args[0])))
+ except (TypeError, ValueError):
+ pass
+
+ priority_marker = item.get_closest_marker("priority")
+ if priority_marker is not None and priority_marker.args:
+ rep.user_properties.append(("pwk_priority", str(priority_marker.args[0])))
+
+
+# ---------------------------------------------------------------------------
+# Terminal summary / session finish
+# ---------------------------------------------------------------------------
+
+
+def _collect_entries(terminalreporter) -> list[PwkTestEntry]:
+ """terminalreporter から ``PwkTestEntry`` のリストを構築する。
+
+ xfailed / xpassed も集約する (Codex Major 3)。
+ pytest 内部では xfailed の rep は stats["xfailed"] に直接入るため、
+ "xfailed" / "xpassed" キーを明示的に走査する。
+
+ artifact 伝搬 (Codex Major-1 / 3回目):
+ HAR は context.close() 時に flush されるため、call phase 時点では
+ har_relpath / trace_relpath が未確定の場合がある。
+ teardown phase の makereport で確定した pwk_har / pwk_trace を
+ call entry に merge することで、report.md に artifact path を反映する。
+ """
+ # Step 1: call/setup phase の entry を nodeid でインデックス化
+ call_entries: dict[str, PwkTestEntry] = {}
+ for outcome_key in ("passed", "failed", "skipped", "error", "xfailed", "xpassed"):
+ for rep in terminalreporter.stats.get(outcome_key, []):
+ if getattr(rep, "when", "call") not in ("call", "setup"):
+ continue
+ props = dict(rep.user_properties or [])
+ nodeid = getattr(rep, "nodeid", "?")
+ entry = PwkTestEntry(
+ nodeid=nodeid,
+ name=getattr(rep, "head_line", nodeid),
+ outcome=outcome_key,
+ duration_s=float(getattr(rep, "duration", 0.0) or 0.0),
+ page_role=list(props.get("pwk_page_role") or []),
+ role=props.get("pwk_role"),
+ phase=int(props.get("pwk_phase") or 0),
+ priority=props.get("pwk_priority"),
+ har_path=props.get("pwk_har"),
+ trace_path=props.get("pwk_trace"),
+ console_errors=int(props.get("pwk_console_errors") or 0),
+ page_errors=int(props.get("pwk_page_errors") or 0),
+ # Amazon Q Critical-3: skipped 時の longrepr は tuple 形式のため
+ # failed / error のときのみ str() 化する。他 outcome は None のまま。
+ error_message=(
+ str(rep.longrepr)
+ if outcome_key in ("failed", "error") and rep.longrepr
+ else None
+ ),
+ )
+ call_entries[nodeid] = entry
+
+ # Step 2: teardown report の pwk_har / pwk_trace / body_check を call entry に merge する。
+ # teardown 時点で context.close() 後の確定値や body_check の violation 集計が
+ # 積まれているため、call phase で未確定だった値をここで埋める。
+ # pytest は setup/teardown の rep を stats[""] (空文字キー) に格納するため、
+ # "" キーも含めて全キーを走査する。
+ for outcome_key in terminalreporter.stats:
+ for rep in terminalreporter.stats[outcome_key]:
+ if getattr(rep, "when", None) != "teardown":
+ continue
+ nodeid = getattr(rep, "nodeid", "?")
+ if nodeid not in call_entries:
+ continue
+ entry = call_entries[nodeid]
+ props = dict(rep.user_properties or [])
+ if not entry.har_path and props.get("pwk_har"):
+ entry.har_path = props["pwk_har"]
+ if not entry.trace_path and props.get("pwk_trace"):
+ entry.trace_path = props["pwk_trace"]
+ if "pwk_body_check_violations" in props:
+ entry.body_check_violations = int(
+ props.get("pwk_body_check_violations") or 0
+ )
+ detail = props.get("pwk_body_check_detail")
+ if detail:
+ entry.body_check_detail = list(detail)
+ # body_check が teardown で pytest.fail を起こした場合、call phase
+ # は passed / xfailed / xpassed / skipped のまま teardown report
+ # のみ failed/error になる。call phase の本物の failure は上書き
+ # しないが、それ以外の outcome は teardown 失敗を反映させる
+ # (xfail テストでも teardown の body_check fail は実バグ扱い)。
+ teardown_outcome = getattr(rep, "outcome", None)
+ if (
+ teardown_outcome in ("failed", "error")
+ and entry.outcome not in ("failed", "error")
+ ):
+ entry.outcome = (
+ "error" if teardown_outcome == "error" else "failed"
+ )
+ if rep.longrepr and not entry.error_message:
+ entry.error_message = str(rep.longrepr)
+
+ return list(call_entries.values())
+
+
+def pytest_terminal_summary(terminalreporter, exitstatus, config):
+ """``reports//report.md`` を生成する。
+
+ ``--pwk-out-dir`` 指定があればそこに、なければ ``pwk_out_dir`` fixture と
+ 同一の ``pytestconfig._pwk_out_dir`` キャッシュを参照する。
+ キャッシュが無い場合 (pwk_out_dir fixture が一度も呼ばれていない) は
+ ``_resolve_out_dir`` 経由でセットする。これにより evidence と report.md の
+ 出力先が秒またぎでズレる問題を防ぐ (新規 Major 対応)。
+ """
+ # session 中で 1 件も test を回していない (collect-only など) は先に entries で判断。
+ # xfailed / xpassed のみの session でも report を生成するため、
+ # early return は _collect_entries() の結果で判断する (新規 Minor 対応)。
+ entries = _collect_entries(terminalreporter)
+ if not entries:
+ return
+
+ cached_cfg = getattr(config, "_pwk_config", None)
+ base_url = cached_cfg.base_url if cached_cfg is not None else None
+ title = (
+ cached_cfg.report.title
+ if cached_cfg is not None
+ else "シナリオ E2E テスト 実施報告書"
+ )
+
+ # evidence.py の _resolve_out_dir と同一キャッシュ (_pwk_out_dir) を参照する。
+ # これにより両者が独立に datetime.now() を呼んで別ディレクトリを作る問題を解消。
+ from playwright_kit.fixtures.evidence import _resolve_out_dir
+
+ out_dir = _resolve_out_dir(config)
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ # Amazon Q Critical-4: xdist 並列実行時の session 開始時刻計算が不正確な問題を修正。
+ # terminalreporter._sessionstarttime (pytest 内部 float) を優先利用し、
+ # 無ければ従来の逐次実行前提の計算にフォールバックする。
+ session_start_ts = getattr(terminalreporter, "_sessionstarttime", None)
+ if session_start_ts is not None:
+ started = _dt.datetime.fromtimestamp(session_start_ts)
+ else:
+ started = _dt.datetime.now() - _dt.timedelta(
+ seconds=sum(e.duration_s for e in entries)
+ )
+ finished = _dt.datetime.now()
+ path = write_report(
+ entries,
+ out_dir=out_dir,
+ started_at=started,
+ finished_at=finished,
+ title=title,
+ base_url=base_url,
+ )
+ terminalreporter.write_sep("-", "pwk report")
+ terminalreporter.write_line(f"report.md generated: {path}")
+
+ # session 後の Drive アップロードに使うため pickle 不要な情報を保存
+ config._pwk_report_path = path # type: ignore[attr-defined]
+ config._pwk_out_dir = out_dir # type: ignore[attr-defined]
+
+
+def pytest_sessionfinish(session, exitstatus):
+ """``--pwk-drive-folder`` 指定時、生成済 report.md と evidence を Drive アップ。
+
+ ``upload_evidence.upload`` を直接呼ぶ。失敗時は警告のみで test 結果には影響しない。
+ """
+ folder_id: str | None = session.config.getoption(
+ "pwk_drive_folder", default=None
+ )
+ if not folder_id:
+ return
+
+ report_path: Path | None = getattr(session.config, "_pwk_report_path", None)
+ out_dir: Path | None = getattr(session.config, "_pwk_out_dir", None)
+ if report_path is None or out_dir is None:
+ return
+
+ try:
+ # Amazon Q Critical-5: sys.path への動的 inject を廃止し、
+ # playwright_kit.uploaders パッケージ経由で安全に import する。
+ # scripts/upload_evidence.py は CLI スタンドアロン用途として残す。
+ from playwright_kit.uploaders import upload, detect_kind
+
+ # report.md は kind=any でアップ
+ if report_path.exists():
+ upload(
+ report_path, kind="any", parent_folder_id=folder_id, public=False
+ )
+
+ # trace.zip / *.har / *.mp4 / body_check.jsonl を 1 階層下から拾い上げる
+ for sub in out_dir.iterdir():
+ if not sub.is_dir():
+ continue
+ for f in sub.iterdir():
+ suffix = f.suffix
+ if suffix not in (".zip", ".har", ".mp4", ".webm", ".jsonl"):
+ continue
+ # detect_kind は body_check.jsonl 等の任意ファイルを未知の kind
+ # と扱うため、jsonl は ``any`` に固定する。
+ kind = "any" if suffix == ".jsonl" else detect_kind(f)
+ upload(
+ f, kind=kind, parent_folder_id=folder_id, public=False
+ )
+ except Exception as exc: # pragma: no cover - depends on Drive auth
+ import warnings
+
+ warnings.warn(
+ f"[pwk] Drive upload 失敗 (session continues): {exc}",
+ stacklevel=1,
+ )
+
+
+def _try_load_config_silently(config: pytest.Config) -> Any | None:
+ """``--pwk-config`` 等から Config を試行ロードする。失敗時は None。"""
+ import os
+ from pathlib import Path
+
+ raw_path: str | None = config.getoption("pwk_config", default=None)
+ if not raw_path:
+ env = os.environ.get("PWK_CONFIG")
+ if env:
+ raw_path = env
+ if not raw_path:
+ candidate = Path.cwd() / "scenario.config.yaml"
+ if candidate.exists():
+ raw_path = str(candidate)
+ if not raw_path:
+ return None
+
+ try:
+ from playwright_kit.config import Config
+
+ return Config.load(Path(raw_path).resolve())
+ except Exception as exc: # pragma: no cover - depends on user config
+ import warnings
+
+ warnings.warn(
+ f"[pwk] config load 失敗 ({raw_path}): {exc}. "
+ "pwk_role_ fixture は動的登録されません。",
+ stacklevel=2,
+ )
+ return None
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/pytest_report.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/pytest_report.py
new file mode 100644
index 00000000..2d67a89f
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/pytest_report.py
@@ -0,0 +1,212 @@
+"""pytest hook で集めた test result から Markdown レポートを生成する。
+
+``pytest_terminal_summary`` から呼ばれ、``reports//report.md`` を生成する。
+``--pwk-drive-folder`` 指定時は Drive アップロードと URL 差し込みも担当。
+"""
+
+from __future__ import annotations
+
+import datetime as _dt
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Iterable
+
+
+@dataclass
+class PwkTestEntry:
+ """1 test 関数分のレポート用エントリ。
+
+ pytest の ``TestReport`` から要点だけを抽出して保持する
+ (``user_properties`` 経由で ``pwk_evidence`` の状態が紐付く)。
+ """
+
+ nodeid: str
+ name: str
+ outcome: str # passed / failed / skipped / xfailed / xpassed / error
+ duration_s: float
+ page_role: list[str] = field(default_factory=list)
+ role: str | None = None
+ phase: int = 0
+ priority: str | None = None
+ har_path: str | None = None
+ trace_path: str | None = None
+ console_errors: int = 0
+ page_errors: int = 0
+ error_message: str | None = None
+ # body_check (PHP / SSR エラー検出, v0.4.0)
+ body_check_violations: int = 0
+ body_check_detail: list[dict] = field(default_factory=list)
+
+ @property
+ def ok(self) -> bool:
+ return self.outcome in ("passed", "xfailed")
+
+ @property
+ def status_label(self) -> str:
+ return {
+ "passed": "OK",
+ "failed": "FAIL",
+ "skipped": "SKIP",
+ "xfailed": "XFAIL",
+ "xpassed": "XPASS",
+ "error": "ERROR",
+ }.get(self.outcome, self.outcome.upper())
+
+
+def render_markdown(
+ entries: Iterable[PwkTestEntry],
+ *,
+ started_at: _dt.datetime,
+ finished_at: _dt.datetime,
+ title: str = "シナリオ E2E テスト 実施報告書",
+ base_url: str | None = None,
+) -> str:
+ """test entries から Markdown 文字列を生成する。"""
+ entries_list = list(entries)
+ total = len(entries_list)
+ passed = sum(1 for e in entries_list if e.outcome == "passed")
+ failed = sum(1 for e in entries_list if e.outcome == "failed")
+ skipped = sum(1 for e in entries_list if e.outcome == "skipped")
+ errors = sum(1 for e in entries_list if e.outcome == "error")
+ xfailed = sum(1 for e in entries_list if e.outcome == "xfailed")
+ xpassed = sum(1 for e in entries_list if e.outcome == "xpassed")
+ duration = (finished_at - started_at).total_seconds()
+ # xfailed は期待通りの失敗なので OK 扱い (PwkTestEntry.ok と同じ方針)
+ # xpassed は意図せず pass したため注意喚起 (全PASS とはしない)
+ all_pass = total > 0 and (passed + xfailed) == total and xpassed == 0
+
+ lines: list[str] = [
+ f"# {title}",
+ "",
+ f"- 実行開始: {started_at:%Y-%m-%d %H:%M:%S}",
+ f"- 実行終了: {finished_at:%Y-%m-%d %H:%M:%S}",
+ f"- 所要時間: {duration:.1f} 秒",
+ ]
+ if base_url:
+ lines.append(f"- 対象URL : {base_url}")
+
+ # 集計サマリ行を構築
+ extra_parts: list[str] = []
+ if failed:
+ extra_parts.append(f"FAIL {failed}")
+ if skipped:
+ extra_parts.append(f"SKIP {skipped}")
+ if errors:
+ extra_parts.append(f"ERROR {errors}")
+ if xfailed:
+ extra_parts.append(f"XFAIL {xfailed}")
+ if xpassed:
+ extra_parts.append(f"XPASS {xpassed}")
+ # 全PASS でも xfailed / xpassed があれば内訳を明示する
+ if all_pass:
+ if extra_parts:
+ result_suffix = " (全PASS) / " + " / ".join(extra_parts)
+ else:
+ result_suffix = " (全PASS)"
+ else:
+ result_suffix = " / " + " / ".join(extra_parts) if extra_parts else ""
+
+ lines.extend([
+ f"- **結果: {passed}/{total} test PASS{result_suffix}**",
+ "",
+ "## サマリ",
+ "",
+ "| nodeid | role | page_role | status | duration | console.error | pageerror | body_check |",
+ "|---|---|---|---|---|---|---|---|",
+ ])
+
+ # phase / priority / nodeid の順でソート
+ sorted_entries = sorted(
+ entries_list,
+ key=lambda e: (e.phase, e.priority or "", e.nodeid),
+ )
+ for e in sorted_entries:
+ page_role = ",".join(e.page_role) if e.page_role else "-"
+ lines.append(
+ f"| `{e.nodeid}` | {e.role or '-'} | {page_role} | "
+ f"{e.status_label} | {e.duration_s:.2f}s | "
+ f"{e.console_errors} | {e.page_errors} | "
+ f"{e.body_check_violations} |"
+ )
+
+ failures = [e for e in sorted_entries if e.outcome in ("failed", "error")]
+ if failures:
+ lines.extend(["", "## FAIL / ERROR の詳細", ""])
+ for e in failures:
+ lines.append(f"### `{e.nodeid}` — {e.status_label}")
+ lines.append("")
+ if e.error_message:
+ lines.append("```")
+ lines.append(e.error_message[:2000])
+ lines.append("```")
+ if e.trace_path:
+ lines.append(f"- trace: `{e.trace_path}`")
+ if e.har_path:
+ lines.append(f"- HAR: `{e.har_path}`")
+ lines.append("")
+
+ body_check_hits = [e for e in sorted_entries if e.body_check_violations > 0]
+ if body_check_hits:
+ lines.extend(["", "## body_check 違反の詳細", ""])
+ for e in body_check_hits:
+ lines.append(
+ f"### `{e.nodeid}` — body_check {e.body_check_violations} 件 "
+ f"({e.status_label})"
+ )
+ lines.append("")
+ lines.append("| # | URL | category | pattern | snippet |")
+ lines.append("|---:|---|---|---|---|")
+ for i, v in enumerate(e.body_check_detail[:20], start=1):
+ url = _escape_table_cell(str(v.get("url", "?")))
+ cat = _escape_table_cell(str(v.get("category", "?")))
+ pat = _escape_table_cell(str(v.get("pattern", "?")))
+ snippet = _escape_table_cell(str(v.get("snippet", "")))
+ if len(snippet) > 200:
+ snippet = snippet[:200] + "..."
+ lines.append(f"| {i} | `{url}` | {cat} | `{pat}` | {snippet} |")
+ if len(e.body_check_detail) > 20:
+ lines.append(
+ f"\n_(表示は先頭 20 件のみ。詳細は ``body_check.jsonl`` を参照)_"
+ )
+ lines.append("")
+
+ return "\n".join(lines) + "\n"
+
+
+def _escape_table_cell(text: str) -> str:
+ """Markdown 表のセル値を 1 行に潰してエスケープする。
+
+ 改行・タブが残ると行が分割されて表が崩れるので空白に置換する。
+ ``|`` と backtick もエスケープして表構造とコード span を破壊しないようにする。
+ """
+ return (
+ text.replace("\r\n", " ")
+ .replace("\r", " ")
+ .replace("\n", " ")
+ .replace("\t", " ")
+ .replace("|", "\\|")
+ .replace("`", "\\`")
+ )
+
+
+def write_report(
+ entries: Iterable[PwkTestEntry],
+ *,
+ out_dir: Path,
+ started_at: _dt.datetime,
+ finished_at: _dt.datetime,
+ title: str = "シナリオ E2E テスト 実施報告書",
+ base_url: str | None = None,
+) -> Path:
+ """``out_dir/report.md`` を書き出してそのパスを返す。"""
+ out_dir.mkdir(parents=True, exist_ok=True)
+ md = render_markdown(
+ entries,
+ started_at=started_at,
+ finished_at=finished_at,
+ title=title,
+ base_url=base_url,
+ )
+ path = out_dir / "report.md"
+ path.write_text(md, encoding="utf-8")
+ return path
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py
new file mode 100644
index 00000000..a27b555f
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py
@@ -0,0 +1,127 @@
+"""Drive アップロード機能を playwright_kit パッケージから直接 import するためのラッパー。
+
+scripts/upload_evidence.py の CLI スタンドアロン用途 (利用者が
+``python upload_evidence.py ...`` で叩く) を壊さずに、pytest_sessionfinish から
+安全に import できるようにする (Amazon Q Critical-5: sys.path 廃止)。
+
+使い方 (pytest_plugin.py から):
+ from playwright_kit.uploaders import upload, detect_kind
+
+この module は google-auth スキルが存在しない環境でも import できる。
+実際のアップロード時のみ google-auth を必要とする (遅延 import)。
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from urllib.parse import quote
+
+
+_HERE = Path(__file__).resolve()
+# Drive 認証の候補探索は scripts/_drive_auth.py を唯一の実装とする。
+# パッケージ側からも同じ探索を使うため、skill 直下の scripts/ を sys.path へ入れて読む。
+_SCRIPTS_DIR = _HERE.parent.parent.parent / "scripts"
+
+
+def _drive_service(scopes: list[str]):
+ if str(_SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(_SCRIPTS_DIR))
+ from _drive_auth import drive_service # type: ignore # noqa: E402
+ return drive_service(scopes)
+
+
+# 拡張子 → kind の自動判定
+_EXT_KIND: dict[str, str] = {
+ ".zip": "trace",
+ ".har": "har",
+ ".mp4": "video",
+ ".webm": "video",
+}
+
+_MIME_BY_KIND: dict[str, str] = {
+ "trace": "application/zip",
+ "har": "application/json",
+ "video": "video/mp4",
+ "any": "application/octet-stream",
+}
+
+_MIME_BY_EXT: dict[str, str] = {
+ ".webm": "video/webm",
+ ".mp4": "video/mp4",
+ ".har": "application/json",
+ ".zip": "application/zip",
+}
+
+ALLOWED_KINDS: frozenset[str] = frozenset(_MIME_BY_KIND)
+
+
+def detect_kind(path: Path) -> str:
+ """拡張子から evidence kind を自動判定する。"""
+ return _EXT_KIND.get(path.suffix.lower(), "any")
+
+
+def detect_mime(path: Path, kind: str) -> str:
+ """拡張子優先で MIME を決定し、未知拡張子は kind の既定値にフォールバック。"""
+ return _MIME_BY_EXT.get(
+ path.suffix.lower(),
+ _MIME_BY_KIND.get(kind, "application/octet-stream"),
+ )
+
+
+def upload(
+ file_path: Path,
+ *,
+ kind: str = "any",
+ parent_folder_id: str | None = None,
+ public: bool = False,
+) -> dict:
+ """ファイルを Drive にアップして metadata + 補助 URL を返す。
+
+ ⚠️ trace.zip / HAR / video には DOM snapshot や入力痕跡・HTTP request body が含まれる。
+ 既定では非公開アップロード。``public=True`` のときだけ anyone/read を付与する。
+ ``parent_folder_id`` には **private folder** の ID を指定し、
+ 共有相手を信頼できるメンバーに限定してください (Amazon Q Critical-5 / Codex Minor 8)。
+ """
+ if kind not in ALLOWED_KINDS:
+ raise ValueError(
+ f"未対応の kind: {kind!r} (allowed: {sorted(ALLOWED_KINDS)})"
+ )
+
+ from googleapiclient.http import MediaFileUpload # noqa: E402
+
+ service = _drive_service(["drive.file"])
+
+ metadata: dict = {"name": file_path.name}
+ if parent_folder_id:
+ metadata["parents"] = [parent_folder_id]
+ media = MediaFileUpload(
+ str(file_path), mimetype=detect_mime(file_path, kind),
+ )
+ f = service.files().create(
+ body=metadata, media_body=media, fields="id,webViewLink",
+ ).execute()
+ file_id = f["id"]
+
+ if public:
+ service.permissions().create(
+ fileId=file_id, body={"type": "anyone", "role": "reader"},
+ ).execute()
+
+ direct_url: str | None = None
+ viewer_url: str | None = None
+ if public:
+ direct_url = f"https://drive.google.com/uc?export=download&id={file_id}"
+ if kind == "trace":
+ viewer_url = (
+ f"https://trace.playwright.dev/?trace={quote(direct_url, safe='')}"
+ )
+
+ return {
+ "file_id": file_id,
+ "drive_view": f.get("webViewLink"),
+ "direct_download": direct_url,
+ "playwright_trace_viewer": viewer_url,
+ "is_public": public,
+ "kind": kind,
+ }
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/video.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/video.py
new file mode 100644
index 00000000..e1a018ea
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/video.py
@@ -0,0 +1,71 @@
+"""Playwright 録画 webm → Google Drive 互換 mp4 への変換。
+
+Drive のストリーミング再生で「処理中」になりにくい設定:
+ - H.264 High profile / Level 4.0 / yuv420p / bt709
+ - 30 fps CFR (Constant Frame Rate)
+ - 60 frames keyframe interval (2 sec at 30fps)
+ - AAC LC stereo 48kHz 128kbps (無音でも音声トラックは必須)
+ - +faststart で moov atom を先頭配置 → プログレッシブ再生
+
+依存: imageio-ffmpeg (静的 ffmpeg バイナリ同梱の Python パッケージ)
+"""
+
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+
+
+def _ffmpeg_args(webm: Path, mp4: Path) -> list[str]:
+ """Drive 互換 mp4 への変換用 ffmpeg 引数を組み立てる。"""
+ return [
+ "-y",
+ "-i", str(webm),
+ # 無音 AAC を映像と並行して生成 (一部プレイヤは音声トラック必須)
+ "-f", "lavfi", "-i",
+ "anullsrc=channel_layout=stereo:sample_rate=48000",
+ # --- 映像 ---
+ "-c:v", "libx264",
+ "-profile:v", "high",
+ "-level", "4.0",
+ "-pix_fmt", "yuv420p",
+ "-preset", "medium",
+ "-crf", "23",
+ "-r", "30", # 固定フレームレート
+ "-fps_mode", "cfr", # 旧 -vsync の後継
+ "-g", "60", # キーフレーム間隔 2秒
+ "-keyint_min", "30",
+ "-sc_threshold", "0", # シーン検出キーフレーム無効
+ "-color_primaries", "bt709",
+ "-color_trc", "bt709",
+ "-colorspace", "bt709",
+ # --- 音声 (無音) ---
+ "-c:a", "aac",
+ "-b:a", "128k",
+ "-ar", "48000",
+ "-ac", "2",
+ "-shortest", # 映像終了で打ち切り
+ # --- ストリーミング向け ---
+ "-movflags", "+faststart",
+ str(mp4),
+ ]
+
+
+def convert_webm_to_mp4(webm: Path, mp4: Path, *, timeout_sec: int = 300) -> Path | None:
+ """webm を Drive 互換 mp4 に変換。成功時は mp4 のパス、失敗時は None。"""
+ try:
+ import imageio_ffmpeg
+ ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
+ except Exception:
+ return None
+
+ try:
+ subprocess.run(
+ [ffmpeg, *_ffmpeg_args(webm, mp4)],
+ check=True, capture_output=True, timeout=timeout_sec,
+ )
+ except Exception:
+ return None
+ if not mp4.exists() or mp4.stat().st_size == 0:
+ return None
+ return mp4
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/web_vitals.py b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/web_vitals.py
new file mode 100644
index 00000000..034247ab
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/web_vitals.py
@@ -0,0 +1,142 @@
+"""runner 内蔵の Core Web Vitals (Web Vitals) 計測モジュール。
+
+Core Web Vitals は Google が定義する「ユーザ体感パフォーマンス指標」群で、
+本モジュールは以下の 4 指標を計測する:
+
+- **LCP** (Largest Contentful Paint): 最大コンテンツ描画時間 (体感ロード速度)
+- **CLS** (Cumulative Layout Shift): 累積レイアウトずれ量 (視覚的安定性)
+- **TTFB** (Time To First Byte): 初バイト到達時間 (サーバ応答速さ)
+- **longest_task** (Long Tasks API): 観測した最長タスクのミリ秒値 (応答性代理指標)
+
+`scripts/check_cwv.py` (CLI) はこのモジュールの薄いラッパで、runner からは
+`measure_page(page)` を直接呼び出して `EvidenceCollectors.web_vitals_metrics` に
+格納する。
+
+page_role が `lp / list / dashboard` のとき runner が自動実行する
+(config.web_vitals.auto_roles で上書き可能)。
+
+注意:
+- INP は実 user 入力ベースの指標であり Playwright で完全再現は不可能。
+ `longest_task_ms` (50ms 超を 1 件以上検出) を「応答性低下の代理指標」として記録
+ するが、INP の代わりにはならない。
+- 計測は page.evaluate で 5 秒間 PerformanceObserver を回す ため、testcase の
+ 最後 (全 step 実行後) に呼ぶこと。
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from playwright.sync_api import Page
+
+
+# web.dev 公式閾値 (75 percentile 基準)
+THRESHOLDS: dict[str, dict[str, float]] = {
+ "lcp_ms": {"good": 2500, "poor": 4000},
+ "cls": {"good": 0.1, "poor": 0.25},
+ "ttfb_ms": {"good": 800, "poor": 1800},
+ "longest_task_ms": {"good": 50, "poor": 200},
+}
+
+# page_role × Web Vitals 自動実行のデフォルト対象。インタラクション主体 (form / cart) は
+# 過度な負荷になるため除外し、初回表示性能が UX に直結する role に限定する。
+DEFAULT_AUTO_ROLES: frozenset[str] = frozenset({"lp", "list", "dashboard", "search"})
+
+
+# Crit-1 (amazon-q-developer 指摘): JS 内の "5000" を `str.replace` する旧実装は
+# JS 中に同じ数値が他出現すると壊れる fragile な置換だった。`page.evaluate` の
+# arg 経由で `observeMs` を渡し、JS 関数引数として明示的に bind する。
+_PERF_JS = r"""
+(observeMs) => new Promise((resolve) => {
+ const result = {lcp: null, cls: 0, longest_task: 0, ttfb: null};
+
+ try {
+ const lcpObs = new PerformanceObserver((list) => {
+ const entries = list.getEntries();
+ const last = entries.at(-1);
+ if (last) result.lcp = last.startTime;
+ });
+ lcpObs.observe({type: 'largest-contentful-paint', buffered: true});
+ } catch (e) {}
+
+ try {
+ const clsObs = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ if (!entry.hadRecentInput) {
+ result.cls += entry.value;
+ }
+ }
+ });
+ clsObs.observe({type: 'layout-shift', buffered: true});
+ } catch (e) {}
+
+ try {
+ const ltObs = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ if (entry.duration > result.longest_task) {
+ result.longest_task = entry.duration;
+ }
+ }
+ });
+ ltObs.observe({type: 'longtask', buffered: true});
+ } catch (e) {}
+
+ try {
+ const nav = performance.getEntriesByType('navigation')[0];
+ if (nav) result.ttfb = nav.responseStart - nav.requestStart;
+ } catch (e) {}
+
+ setTimeout(() => resolve(result), observeMs);
+});
+"""
+
+
+def measure_page(page: Page, *, observe_ms: int = 5000) -> dict[str, float]:
+ """既にロード済みの Page で Core Web Vitals を `observe_ms` ミリ秒観察し、metrics dict を返す。
+
+ Returns: `{"lcp_ms": float, "cls": float, "ttfb_ms": float, "longest_task_ms": float}`
+ 計測失敗した metric は dict から除外される。
+ """
+ try:
+ raw: dict[str, Any] = page.evaluate(_PERF_JS, int(observe_ms))
+ except Exception:
+ return {}
+
+ out: dict[str, float] = {}
+ if raw.get("lcp") is not None:
+ out["lcp_ms"] = float(raw["lcp"])
+ if raw.get("cls") is not None:
+ out["cls"] = float(raw["cls"])
+ if raw.get("ttfb") is not None:
+ out["ttfb_ms"] = float(raw["ttfb"])
+ if raw.get("longest_task") is not None:
+ out["longest_task_ms"] = float(raw["longest_task"])
+ return out
+
+
+def judge(metric: str, value: float | None) -> str:
+ """値を `good` / `needs-improvement` / `poor` / `unknown` に分類する。"""
+ if value is None:
+ return "unknown"
+ th = THRESHOLDS.get(metric)
+ if not th:
+ return "unknown"
+ if value <= th["good"]:
+ return "good"
+ if value <= th["poor"]:
+ return "needs-improvement"
+ return "poor"
+
+
+def passed(metrics: dict[str, float]) -> bool:
+ """すべての metric が good または needs-improvement なら True (poor が 1 件でも あれば False)。"""
+ return all(judge(k, v) != "poor" for k, v in metrics.items())
+
+
+def should_auto_measure(
+ page_roles: list[str],
+ *,
+ auto_roles: frozenset[str] = DEFAULT_AUTO_ROLES,
+) -> bool:
+ """testcase の page_role に基づき Web Vitals を自動計測すべきか判定する。"""
+ return any(r in auto_roles for r in page_roles)
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/pyproject.toml b/plugins/ndf-claude/skills/playwright-kit-ops/pyproject.toml
new file mode 100644
index 00000000..14e535b1
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/pyproject.toml
@@ -0,0 +1,50 @@
+[project]
+name = "playwright_kit"
+version = "0.5.0"
+description = "pytest-playwright 上で動く Web シナリオ E2E テストの理論ベース実施フレームワーク (Claude Code skill)。v0.3.0: 自前 YAML DSL を全廃し、pytest plugin / fixture / marker / accessibility autouse / web vitals autouse / overlay / report.md / Google Drive 連携を提供。v0.4.0: PHP / SSR がフロント漏れさせる Fatal error / STRICT: 等を検出する body_check autouse を default 有効で復活 (PHP 系パターン内蔵)。v0.5.0: パッケージ名を playwright_kit に rename し、利用者プロジェクト直下で自己完結 (Skill 非依存) に動作させる構成へ移行。"
+requires-python = ">=3.11"
+dependencies = [
+ "playwright>=1.50,<2.0",
+ "pyyaml>=6.0",
+ # webm → mp4 変換用。Playwright 録画は webm のみ生成するが、
+ # Google Drive のプレビュアでは mp4 (H.264) の方が安定するため変換する。
+ "imageio-ffmpeg>=0.4",
+ # v0.3.0: pure pytest-playwright 移行
+ # pytest 本体と pytest-playwright fixture (page / context / browser_context_args)、
+ # pytest-xdist による並列実行を main 依存として要求する。
+ "pytest>=8.0",
+ "pytest-playwright>=0.5",
+ "pytest-xdist>=3.0",
+ # accessibility autouse は scenario.config.yaml で default 有効。
+ # runtime テンプレート (templates/pyproject.toml.runtime) と揃えて main dep に含める。
+ "axe-playwright-python>=0.1.4",
+]
+
+[project.optional-dependencies]
+# Drive 連携: scripts/trace_link.py / build_gdoc_with_drive_links.py 等が使用
+drive = [
+ "google-api-python-client>=2.0",
+ "google-auth>=2.0",
+ "google-auth-oauthlib>=1.0",
+]
+# 開発用: pure function の単体テスト (uv run --extra dev pytest)
+# pytest 本体は main deps に移行済 (v0.3.0)。dev には開発時のみの拡張を入れる余地。
+dev = [
+ "pytest>=8.0",
+]
+
+[project.entry-points."pytest11"]
+# pytest が auto-discover する plugin entry-point。
+# ``uv pip install -e .`` 後は ``pytest`` 起動だけで playwright_kit plugin が有効になる。
+playwright-kit = "playwright_kit.pytest_plugin"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["playwright_kit"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-q"
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/scripts/_drive_auth.py b/plugins/ndf-claude/skills/playwright-kit-ops/scripts/_drive_auth.py
new file mode 100644
index 00000000..60dc75ea
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/scripts/_drive_auth.py
@@ -0,0 +1,64 @@
+"""google-auth スキル経由で Drive API クレデンシャルを取得する共通ヘルパ。
+
+3 つの uploader スクリプト (gdrive_upload_dir / build_gdoc_with_drive_links /
+upload_md_as_gdoc) はいずれも同じ手順で `google_auth.get_credentials()` を
+sys.path から発見する。本モジュールにロジックを集約する。
+
+Drive 連携は optional dependency。`GOOGLE_AUTH_SCRIPTS` 環境変数が設定されて
+いればそれを使い、それ以外は標準インストール先と sibling の google-auth
+スキルを探す。google-auth はどの公開セットにも含めていないため、Drive 系
+コマンドを使う場合は `GOOGLE_AUTH_SCRIPTS` を明示するか、同スキルを利用先へ
+導入する。
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+
+_HERE = Path(__file__).resolve()
+_CANDIDATES: tuple[Path, ...] = tuple(
+ Path(p).expanduser()
+ for p in (
+ os.environ.get("GOOGLE_AUTH_SCRIPTS"),
+ "~/.claude/skills/google-auth/scripts",
+ "~/.codex/skills/google-auth/scripts",
+ "~/.kiro/skills/google-auth/scripts",
+ str(_HERE.parent.parent.parent / "google-auth" / "scripts"),
+ )
+ if p
+)
+
+
+def _ensure_google_auth_on_path() -> None:
+ """`from google_auth import get_credentials` できるよう sys.path を整える。
+
+ ディレクトリの存在だけで採用すると、`google_auth.py` を含まない別の
+ `scripts/` を先に拾って後続の候補を見ないまま import に失敗する。
+ 実体の有無まで確かめてから sys.path へ入れる。
+ """
+ for p in _CANDIDATES:
+ if (p / "google_auth.py").is_file():
+ path = str(p)
+ if path not in sys.path:
+ sys.path.insert(0, path)
+ return
+ searched = "\n - ".join(str(p) for p in _CANDIDATES)
+ raise RuntimeError(
+ "Google Drive 連携には optional skill `google-auth` が必要です。\n"
+ "どの公開セットにも同梱していないため、Drive 系コマンドを使う前に "
+ "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n"
+ "例: export GOOGLE_AUTH_SCRIPTS=/plugins/ndf-shared/skills/google-auth/scripts\n"
+ "google_auth.py を探した候補:\n - "
+ f"{searched}"
+ )
+
+
+def drive_service(scopes: list[str]):
+ """認証済み Drive API v3 service を返す。"""
+ _ensure_google_auth_on_path()
+ from google_auth import get_credentials # type: ignore
+ from googleapiclient.discovery import build
+ return build("drive", "v3", credentials=get_credentials(scopes))
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py b/plugins/ndf-claude/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py
new file mode 100644
index 00000000..47990c64
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py
@@ -0,0 +1,126 @@
+"""report.md の相対リンクを Google Drive URL に置換し、Google Docs として再アップロードする。
+
+事前に対象ディレクトリを Drive にアップロード済みである前提。
+このスクリプトは:
+ 1. Drive 上の フォルダから {相対パス: file_id} mapping を構築
+ 2. report.md 中の `(./TC-XX/foo.ext)` 形式リンクを Drive URL に書き換え
+ 3. text/markdown としてアップロードし mimeType=Google Docs 指定で自動変換
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from _drive_auth import drive_service # noqa: E402
+
+from googleapiclient.http import MediaFileUpload # noqa: E402
+
+SCOPES = ["drive.file", "drive.readonly"]
+FOLDER_MIME = "application/vnd.google-apps.folder"
+DOC_MIME = "application/vnd.google-apps.document"
+LINK_PATTERN = re.compile(r"\(\.?\/?(TC-[\w-]+/[^\)\s]+)\)")
+
+
+def list_folder_files(service, folder_id: str, prefix: str = "") -> dict[str, str]:
+ """folder_id 配下のファイルを再帰的に列挙し、{相対パス: file_id} を返す。"""
+ out: dict[str, str] = {}
+ page_token: str | None = None
+ while True:
+ resp = service.files().list(
+ q=f"'{folder_id}' in parents and trashed=false",
+ fields="nextPageToken, files(id,name,mimeType)",
+ pageSize=200, pageToken=page_token,
+ supportsAllDrives=True, includeItemsFromAllDrives=True,
+ ).execute()
+ for f in resp.get("files", []):
+ rel = f"{prefix}/{f['name']}".lstrip("/")
+ if f["mimeType"] == FOLDER_MIME:
+ out.update(list_folder_files(service, f["id"], rel))
+ else:
+ out[rel] = f["id"]
+ page_token = resp.get("nextPageToken")
+ if not page_token:
+ return out
+
+
+def find_run_folder_id(service, parent_id: str, run_id: str) -> str:
+ """parent 配下の run_id 名フォルダの ID を返す。なければ例外。"""
+ files = service.files().list(
+ q=(
+ f"'{parent_id}' in parents and name='{run_id}' "
+ f"and mimeType='{FOLDER_MIME}' and trashed=false"
+ ),
+ fields="files(id,name)",
+ supportsAllDrives=True, includeItemsFromAllDrives=True,
+ ).execute().get("files", [])
+ if not files:
+ raise SystemExit(f"ERROR: run-id folder '{run_id}' not found under {parent_id}")
+ return files[0]["id"]
+
+
+def _drive_url_for(rel: str, fid: str) -> str:
+ # PNG は uc?id (画像直接表示)、その他 (動画/zip/etc) は file/d//view
+ if rel.endswith(".png"):
+ return f"https://drive.google.com/uc?id={fid}"
+ return f"https://drive.google.com/file/d/{fid}/view"
+
+
+def rewrite_links(md: str, mapping: dict[str, str]) -> tuple[str, int]:
+ """`(./TC-XX/foo.ext)` 形式リンクを Drive URL に置換し、(新md, 置換件数) を返す。"""
+ replaced = 0
+
+ def rep(m: re.Match[str]) -> str:
+ nonlocal replaced
+ rel = m.group(1)
+ fid = mapping.get(rel)
+ if fid is None:
+ return m.group(0) # 未マップは原文のまま
+ replaced += 1
+ return f"({_drive_url_for(rel, fid)})"
+
+ return LINK_PATTERN.sub(rep, md), replaced
+
+
+def main() -> int:
+ p = argparse.ArgumentParser()
+ p.add_argument("--md", required=True, type=Path)
+ p.add_argument("--folder", required=True,
+ help="Drive folder containing the run-id subfolder")
+ p.add_argument("--run-id", required=True,
+ help="Run id subfolder name (= local report dir name)")
+ p.add_argument("--name", required=True)
+ args = p.parse_args()
+
+ service = drive_service(SCOPES)
+ run_folder_id = find_run_folder_id(service, args.folder, args.run_id)
+ print(f"run folder: {run_folder_id}")
+
+ mapping = list_folder_files(service, run_folder_id)
+ print(f"Indexed {len(mapping)} files")
+
+ md_new, replaced = rewrite_links(args.md.read_text(encoding="utf-8"), mapping)
+ print(f"Replaced links: {replaced} matches")
+
+ tmp_md = Path("/tmp/report_with_drive_links.md")
+ tmp_md.write_text(md_new, encoding="utf-8")
+
+ media = MediaFileUpload(str(tmp_md), mimetype="text/markdown", resumable=True)
+ file = service.files().create(
+ body={"name": args.name, "mimeType": DOC_MIME, "parents": [args.folder]},
+ media_body=media,
+ fields="id,name,webViewLink,mimeType",
+ supportsAllDrives=True,
+ ).execute()
+ print(f"OK: created {file['name']} ({file['mimeType']})")
+ print(f" id: {file['id']}")
+ print(f" url: {file['webViewLink']}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/scripts/check_cwv.py b/plugins/ndf-claude/skills/playwright-kit-ops/scripts/check_cwv.py
new file mode 100644
index 00000000..679554de
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/scripts/check_cwv.py
@@ -0,0 +1,113 @@
+"""Core Web Vitals (LCP/CLS/TTFB/longest_task) を 1 URL に対して計測する CLI。
+
+`playwright_kit.web_vitals` モジュールの薄いラッパ。runner は testcase 内蔵で同 module
+を呼ぶため、本 CLI は外部 URL の単発計測専用。
+
+Usage:
+ python check_cwv.py --url https://example.com
+ python check_cwv.py --url-list urls.txt --output web_vitals.json
+ python check_cwv.py --url https://example.com --device "Pixel 5"
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+from playwright.sync_api import sync_playwright
+
+_SKILL_ROOT = Path(__file__).resolve().parent.parent
+if str(_SKILL_ROOT) not in sys.path:
+ sys.path.insert(0, str(_SKILL_ROOT))
+
+from playwright_kit.web_vitals import THRESHOLDS, judge, measure_page # noqa: E402
+
+
+def measure(
+ url: str,
+ *,
+ storage_state: str | None = None,
+ device_name: str | None = None,
+ timeout_ms: int = 30_000,
+ headless: bool = True,
+ observe_ms: int = 5000,
+) -> dict[str, Any]:
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=headless)
+ ctx_kwargs: dict[str, Any] = {"ignore_https_errors": True}
+ if storage_state:
+ ctx_kwargs["storage_state"] = storage_state
+ if device_name:
+ device = p.devices.get(device_name)
+ if not device:
+ browser.close()
+ return {"url": url, "error": f"unknown device: {device_name}"}
+ ctx_kwargs.update(device)
+
+ context = browser.new_context(**ctx_kwargs)
+ page = context.new_page()
+ try:
+ page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded")
+ except Exception as exc:
+ browser.close()
+ return {"url": url, "error": str(exc)}
+
+ metrics = measure_page(page, observe_ms=observe_ms)
+ browser.close()
+ return {
+ "url": url,
+ "device": device_name or "desktop",
+ "metrics": metrics,
+ "judgement": {k: judge(k, v) for k, v in metrics.items()},
+ "thresholds": THRESHOLDS,
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Core Web Vitals を計測")
+ parser.add_argument("--url", help="計測対象 URL")
+ parser.add_argument("--url-list", type=Path, help="URL を 1 行 1 件で書いたファイル")
+ parser.add_argument("--storage-state", default=None)
+ parser.add_argument("--device", default=None,
+ help="Playwright device 名 (例: 'Pixel 5')")
+ parser.add_argument("--observe-ms", type=int, default=5000,
+ help="PerformanceObserver 観測時間 (ms)")
+ parser.add_argument("--output", type=Path, default=None)
+ parser.add_argument("--fail-on-poor", action="store_true",
+ help="poor 判定 1 件以上で exit 1")
+ parser.add_argument("--headed", action="store_true")
+ args = parser.parse_args()
+
+ if not args.url and not args.url_list:
+ parser.error("--url または --url-list が必要です")
+
+ urls = (
+ [line.strip() for line in args.url_list.read_text().splitlines() if line.strip()]
+ if args.url_list else [args.url]
+ )
+
+ results = [
+ measure(u, storage_state=args.storage_state, device_name=args.device,
+ headless=not args.headed, observe_ms=args.observe_ms)
+ for u in urls
+ ]
+
+ text = json.dumps(results, indent=2, ensure_ascii=False)
+ if args.output:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(text, encoding="utf-8")
+ print(f"OK: web_vitals → {args.output}", file=sys.stderr)
+ else:
+ sys.stdout.write(text + "\n")
+
+ has_poor = any(
+ any(v == "poor" for v in r.get("judgement", {}).values()) for r in results
+ )
+ return 1 if args.fail_on_poor and has_poor else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/plugins/ndf-claude/skills/playwright-kit-ops/scripts/classify_page_role.py b/plugins/ndf-claude/skills/playwright-kit-ops/scripts/classify_page_role.py
new file mode 100644
index 00000000..2943843a
--- /dev/null
+++ b/plugins/ndf-claude/skills/playwright-kit-ops/scripts/classify_page_role.py
@@ -0,0 +1,238 @@
+"""URL の page role を DOM の (implicit + explicit) role 集計から判定する。
+
+実装はブラウザ DOM クエリで `