From 991617e8ce1383ba432750fd112c097fbc578c75 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 8 Aug 2026 07:12:06 +0000 Subject: [PATCH 1/8] =?UTF-8?q?Fix:=20=E3=83=A9=E3=83=B3=E3=82=BF=E3=82=A4?= =?UTF-8?q?=E3=83=A0=E3=81=94=E3=81=A8=E3=81=AE=E9=85=8D=E5=B8=83=E7=AF=84?= =?UTF-8?q?=E5=9B=B2=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=81=A6=E5=85=B1?= =?UTF-8?q?=E6=9C=89=20Skill=20=E3=81=A8=E8=B3=87=E6=96=99=E3=82=92?= =?UTF-8?q?=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release ブランチ全体の cross-review で見つかった 4 件。 - issue-plan-strategy が Codex にしか配布していない /ndf:playwright-planning を 案内していた。3 ランタイム共通の /ndf:playwright-authoring へ置き換えた。 共有 Skill から Codex 限定 Skill を参照すると Claude / Kiro で存在しない コマンドを起動しようとする - plugins/ndf-kiro/README.md と install.sh の --with-codex の説明が、実際には 行っている ndf.json への Codex MCP サーバ設定の追加に触れていなかった - プレゼン資料に旧配布数(Claude 29 / Codex 30 / Playwright 系 5 個)が残って いた。現行 manifest(Claude 22 / Codex 23)へ直し、HTML と PDF を再生成した --- .../2026-08-06-ai-plugins-intro.html | 8 ++++---- .../2026-08-06-ai-plugins-intro.md | 8 ++++---- .../2026-08-06-ai-plugins-intro.pdf | Bin 1054967 -> 1054782 bytes .../skills/issue-plan-strategy/SKILL.md | 4 ++-- .../skills/issue-plan-strategy/SKILL.md | 4 ++-- plugins/ndf-kiro/README.md | 2 +- plugins/ndf-kiro/install.sh | 2 +- .../skills/issue-plan-strategy/SKILL.md | 4 ++-- .../skills/issue-plan-strategy/SKILL.md | 4 ++-- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/presentations/2026-08-06-ai-plugins-intro.html b/docs/presentations/2026-08-06-ai-plugins-intro.html index d90670a4..8059337b 100644 --- a/docs/presentations/2026-08-06-ai-plugins-intro.html +++ b/docs/presentations/2026-08-06-ai-plugins-intro.html @@ -228,7 +228,7 @@

Codex CLI

codex plugin add ndf@ai-plugins

セッション内では ndf: 接頭辞で
-30個のスキルが使えます。

+23個のスキルが使えます。

-

Claude版との差: エージェント8個とSessionStart/Stopフックは無し。代わりに Playwright 系スキル5個が入ります。

+

Claude版との差: エージェント8個とSessionStart/Stopフックは無し。代わりに Playwright 系スキル4個が入ります。

@@ -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..a2189ff3 100644 --- a/docs/presentations/2026-08-06-ai-plugins-intro.md +++ b/docs/presentations/2026-08-06-ai-plugins-intro.md @@ -93,7 +93,7 @@ MCPプラグインが10個。今日は時間の都合で名前だけにします | | | |---|---| -| **スキル 29個** | `/ndf:pr`, `/ndf:review` … スラッシュで直接呼べる | +| **スキル 22個** | `/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個が入ります。
@@ -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 de4fa1de8a2b4921b0df95025c109d5c107697a5..1060121b78ffe83facdadc151f8c1eb8fe74567f 100644 GIT binary patch delta 31519 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%0AM4T}$JZ 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 diff --git a/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md b/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md index 26e07fdf..c9e286f0 100644 --- a/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md +++ b/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md @@ -250,7 +250,7 @@ release ブランチへの merge が一通り進んだ段階で: - PR 間の API / 型 / スキーマ整合 - 設定値の重複・矛盾 - migration の順序依存 - - E2E シナリオ (`/ndf:playwright-planning` の活用) + - E2E シナリオ (`/ndf:playwright-authoring` の活用) - ここで **新たに** 個別 PR 範囲のバグが見つかった場合は、**release PR にコメントせず**、該当の個別 PR (既に merge 済みなら修正差分を載せた新しい修正 PR を release 配下に作成) 側に指摘を書き込み、修正ループを回す。この場合レビュー対象は **修正差分** であり新規 PR でレビューできる(元の差分がそもそも cross-review 未実施だったケースは扱いが異なるため Step 8 のフォールバック参照) - release PR には integration 観点の指摘のみ残す @@ -353,4 +353,4 @@ git checkout release/ - `/ndf:cherry-pick-pr` — 検証ブランチへの cherry-pick PR とブランチ汚染を避ける原則 - `/ndf:review` / `/ndf:cross-review` — レビュー(`--branch` で PR 前のセルフレビュー) - `/ndf:fix` — コメントの分類・修正・返信・Resolve -- `/ndf:playwright-planning` — release ブランチでの E2E 結合テスト +- `/ndf:playwright-authoring` — release ブランチでの E2E 結合テスト diff --git a/plugins/ndf-codex/skills/issue-plan-strategy/SKILL.md b/plugins/ndf-codex/skills/issue-plan-strategy/SKILL.md index 26e07fdf..c9e286f0 100644 --- a/plugins/ndf-codex/skills/issue-plan-strategy/SKILL.md +++ b/plugins/ndf-codex/skills/issue-plan-strategy/SKILL.md @@ -250,7 +250,7 @@ release ブランチへの merge が一通り進んだ段階で: - PR 間の API / 型 / スキーマ整合 - 設定値の重複・矛盾 - migration の順序依存 - - E2E シナリオ (`/ndf:playwright-planning` の活用) + - E2E シナリオ (`/ndf:playwright-authoring` の活用) - ここで **新たに** 個別 PR 範囲のバグが見つかった場合は、**release PR にコメントせず**、該当の個別 PR (既に merge 済みなら修正差分を載せた新しい修正 PR を release 配下に作成) 側に指摘を書き込み、修正ループを回す。この場合レビュー対象は **修正差分** であり新規 PR でレビューできる(元の差分がそもそも cross-review 未実施だったケースは扱いが異なるため Step 8 のフォールバック参照) - release PR には integration 観点の指摘のみ残す @@ -353,4 +353,4 @@ git checkout release/ - `/ndf:cherry-pick-pr` — 検証ブランチへの cherry-pick PR とブランチ汚染を避ける原則 - `/ndf:review` / `/ndf:cross-review` — レビュー(`--branch` で PR 前のセルフレビュー) - `/ndf:fix` — コメントの分類・修正・返信・Resolve -- `/ndf:playwright-planning` — release ブランチでの E2E 結合テスト +- `/ndf:playwright-authoring` — release ブランチでの E2E 結合テスト diff --git a/plugins/ndf-kiro/README.md b/plugins/ndf-kiro/README.md index 453e7c92..f5839fc4 100644 --- a/plugins/ndf-kiro/README.md +++ b/plugins/ndf-kiro/README.md @@ -40,7 +40,7 @@ installer は `.claude-plugin/plugin.json` を読みません。公開 Skill は | `--set-default` | `kiro-cli` の既定エージェントを `ndf` に切り替える(オプトイン) | | `-y`, `--yes` | `--set-default` の確認プロンプトを省略する | | `--with-slack` | stop フックに Slack 通知を追加する | -| `--with-codex` | Codex CLI 直接実行用プロンプトを追加する | +| `--with-codex` | Codex MCP サーバ設定(`ndf.json` の `mcpServers.codex`)と、Codex CLI 直接実行用プロンプトを追加する | | `--dry-run` | 書き込みを行わず実行内容を表示する | ### 既定エージェントの切り替え diff --git a/plugins/ndf-kiro/install.sh b/plugins/ndf-kiro/install.sh index d3124f8f..38352ef3 100755 --- a/plugins/ndf-kiro/install.sh +++ b/plugins/ndf-kiro/install.sh @@ -51,7 +51,7 @@ while [ "$#" -gt 0 ]; do echo " --set-default kiro-cli の既定エージェントを ndf に切り替える(オプトイン)" echo " -y, --yes --set-default の確認プロンプトを省略する" echo " --with-slack stopフックにSlack通知を追加" - echo " --with-codex Codex CLI直接実行用プロンプトを追加" + echo " --with-codex Codex MCPサーバ設定と直接実行用プロンプトを追加" echo " --dry-run 書き込みを行わず実行内容を表示" echo " -h, --help このヘルプを表示" exit 0 diff --git a/plugins/ndf-kiro/skills/issue-plan-strategy/SKILL.md b/plugins/ndf-kiro/skills/issue-plan-strategy/SKILL.md index 26e07fdf..c9e286f0 100644 --- a/plugins/ndf-kiro/skills/issue-plan-strategy/SKILL.md +++ b/plugins/ndf-kiro/skills/issue-plan-strategy/SKILL.md @@ -250,7 +250,7 @@ release ブランチへの merge が一通り進んだ段階で: - PR 間の API / 型 / スキーマ整合 - 設定値の重複・矛盾 - migration の順序依存 - - E2E シナリオ (`/ndf:playwright-planning` の活用) + - E2E シナリオ (`/ndf:playwright-authoring` の活用) - ここで **新たに** 個別 PR 範囲のバグが見つかった場合は、**release PR にコメントせず**、該当の個別 PR (既に merge 済みなら修正差分を載せた新しい修正 PR を release 配下に作成) 側に指摘を書き込み、修正ループを回す。この場合レビュー対象は **修正差分** であり新規 PR でレビューできる(元の差分がそもそも cross-review 未実施だったケースは扱いが異なるため Step 8 のフォールバック参照) - release PR には integration 観点の指摘のみ残す @@ -353,4 +353,4 @@ git checkout release/ - `/ndf:cherry-pick-pr` — 検証ブランチへの cherry-pick PR とブランチ汚染を避ける原則 - `/ndf:review` / `/ndf:cross-review` — レビュー(`--branch` で PR 前のセルフレビュー) - `/ndf:fix` — コメントの分類・修正・返信・Resolve -- `/ndf:playwright-planning` — release ブランチでの E2E 結合テスト +- `/ndf:playwright-authoring` — release ブランチでの E2E 結合テスト diff --git a/plugins/ndf-shared/skills/issue-plan-strategy/SKILL.md b/plugins/ndf-shared/skills/issue-plan-strategy/SKILL.md index 26e07fdf..c9e286f0 100644 --- a/plugins/ndf-shared/skills/issue-plan-strategy/SKILL.md +++ b/plugins/ndf-shared/skills/issue-plan-strategy/SKILL.md @@ -250,7 +250,7 @@ release ブランチへの merge が一通り進んだ段階で: - PR 間の API / 型 / スキーマ整合 - 設定値の重複・矛盾 - migration の順序依存 - - E2E シナリオ (`/ndf:playwright-planning` の活用) + - E2E シナリオ (`/ndf:playwright-authoring` の活用) - ここで **新たに** 個別 PR 範囲のバグが見つかった場合は、**release PR にコメントせず**、該当の個別 PR (既に merge 済みなら修正差分を載せた新しい修正 PR を release 配下に作成) 側に指摘を書き込み、修正ループを回す。この場合レビュー対象は **修正差分** であり新規 PR でレビューできる(元の差分がそもそも cross-review 未実施だったケースは扱いが異なるため Step 8 のフォールバック参照) - release PR には integration 観点の指摘のみ残す @@ -353,4 +353,4 @@ git checkout release/ - `/ndf:cherry-pick-pr` — 検証ブランチへの cherry-pick PR とブランチ汚染を避ける原則 - `/ndf:review` / `/ndf:cross-review` — レビュー(`--branch` で PR 前のセルフレビュー) - `/ndf:fix` — コメントの分類・修正・返信・Resolve -- `/ndf:playwright-planning` — release ブランチでの E2E 結合テスト +- `/ndf:playwright-authoring` — release ブランチでの E2E 結合テスト From b6edd2b59a2d0da922155acb43cccf0fb3ab764e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 8 Aug 2026 07:27:26 +0000 Subject: [PATCH 2/8] =?UTF-8?q?Fix:=20=E3=83=96=E3=83=A9=E3=82=A6=E3=82=B6?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E3=83=86=E3=82=B9=E3=83=88=204=20=E5=80=8B?= =?UTF-8?q?=E3=82=92=203=20=E3=83=A9=E3=83=B3=E3=82=BF=E3=82=A4=E3=83=A0?= =?UTF-8?q?=E3=81=B8=E6=8F=83=E3=81=88=E3=81=A6=E9=85=8D=E5=B8=83=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共有 Skill から、そのランタイムに配布していない Skill を案内している箇所が 残っていた。issue-plan-strategy → playwright-planning だけでなく、 playwright-authoring 自身が playwright-planning / evidence / kit-ops を 前提にしており、Claude / Kiro では前段・後段が存在しない状態だった。 4 個で 1 つのワークフローを構成しているため、配布範囲を揃えて解消する。 - manifests の claude と kiro に playwright-planning / evidence / kit-ops を 追加(claude 22 → 25、kiro 21 → 24)。plugin.json と marketplace.json の description も追随 - 初期一覧の予算内であることを確認(Claude 6,911 / Codex 6,473、上限 8,000) - ndf-policies の「Codex のみ配布」注記が不要になったので削除 - playwright-evidence が参照する google-auth / google-drive は、どの manifest にも載せていない任意導入の Skill なので、コマンド形式の案内をやめ、導入が 必要な旨と GOOGLE_AUTH_SCRIPTS による代替を明記した - README / CLAUDE.md / プレゼン資料の個数を実数へ更新し、HTML と PDF を再生成 検査: 共有 Skill 内の /ndf: 参照のうち、参照元が配布されているランタイム で参照先が配布されていないものが 0 件になった。 --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 2 +- README.md | 4 +- .../2026-08-06-ai-plugins-intro.html | 6 +- .../2026-08-06-ai-plugins-intro.md | 6 +- .../2026-08-06-ai-plugins-intro.pdf | Bin 1054782 -> 1054322 bytes plugins/ndf-claude/.claude-plugin/plugin.json | 5 +- .../skills/issue-plan-strategy/SKILL.md | 4 +- .../ndf-claude/skills/ndf-policies/SKILL.md | 4 +- .../skills/playwright-evidence/SKILL.md | 174 ++++ .../skills/playwright-kit-ops/.gitignore | 11 + .../skills/playwright-kit-ops/SKILL.md | 117 +++ .../playwright_kit/__init__.py | 11 + .../playwright_kit/accessibility.py | 90 ++ .../playwright_kit/body_check.py | 118 +++ .../playwright_kit/config.py | 461 ++++++++++ .../playwright_kit/fixtures/__init__.py | 19 + .../playwright_kit/fixtures/accessibility.py | 143 ++++ .../playwright_kit/fixtures/auth.py | 318 +++++++ .../playwright_kit/fixtures/body_check.py | 194 +++++ .../playwright_kit/fixtures/evidence.py | 353 ++++++++ .../playwright_kit/fixtures/web_vitals.py | 109 +++ .../playwright_kit/overlay.py | 220 +++++ .../playwright_kit/pytest_plugin.py | 491 +++++++++++ .../playwright_kit/pytest_report.py | 212 +++++ .../playwright_kit/uploaders/__init__.py | 151 ++++ .../playwright_kit/video.py | 71 ++ .../playwright_kit/web_vitals.py | 142 +++ .../skills/playwright-kit-ops/pyproject.toml | 50 ++ .../playwright-kit-ops/scripts/_drive_auth.py | 57 ++ .../scripts/build_gdoc_with_drive_links.py | 126 +++ .../playwright-kit-ops/scripts/check_cwv.py | 113 +++ .../scripts/classify_page_role.py | 238 ++++++ .../scripts/gdrive_upload_dir.py | 75 ++ .../scripts/init_project.bat | 196 +++++ .../scripts/init_project.sh | 184 ++++ .../scripts/record_scenario.py | 104 +++ .../scripts/run_a11y_scan.py | 104 +++ .../scripts/upload_evidence.py | 225 +++++ .../scripts/upload_md_as_gdoc.py | 47 + .../templates/conftest.py.template | 127 +++ .../templates/pyproject.toml.runtime | 51 ++ .../playwright-kit-ops/templates/run.bat | 95 ++ .../playwright-kit-ops/templates/run.sh | 101 +++ .../templates/runtime-README.md | 133 +++ .../templates/runtime-gitignore | 28 + .../templates/scenario.config.yaml | 158 ++++ .../templates/test_auth.py.template | 35 + .../templates/test_dashboard.py.template | 25 + .../templates/test_form.py.template | 33 + .../templates/test_list.py.template | 28 + .../playwright-kit-ops/tests/__init__.py | 0 .../playwright-kit-ops/tests/conftest.py | 23 + .../tests/test_a11y_cwv_routing.py | 80 ++ .../tests/test_auth_cache.py | 242 ++++++ .../tests/test_body_check.py | 394 +++++++++ .../tests/test_config_basic_auth.py | 163 ++++ .../tests/test_evidence_fixture.py | 264 ++++++ .../tests/test_makereport_user_properties.py | 147 ++++ .../tests/test_pytest_plugin_bootstrap.py | 135 +++ .../tests/test_pytest_report.py | 328 +++++++ .../tests/test_pytest_terminal_summary.py | 372 ++++++++ .../tests/test_pytester_integration.py | 410 +++++++++ .../tests/test_same_origin.py | 45 + .../tests/test_upload_evidence.py | 57 ++ .../tests/test_video_default.py | 63 ++ .../skills/playwright-kit-ops/uv.lock | 809 ++++++++++++++++++ .../skills/playwright-planning/SKILL.md | 123 +++ .../docs/01-methodology.md | 148 ++++ .../playwright-planning/docs/02-page-roles.md | 207 +++++ .../docs/03-test-techniques.md | 284 ++++++ .../docs/04-playwright-mapping.md | 249 ++++++ .../playwright-planning/docs/05-bug-report.md | 207 +++++ .../docs/06-pytest-playwright.md | 248 ++++++ .../skills/playwright-planning/docs/README.md | 79 ++ .../docs/checklists/checklist-auth.md | 148 ++++ .../checklists/checklist-cart-checkout.md | 158 ++++ .../docs/checklists/checklist-common.md | 129 +++ .../docs/checklists/checklist-dashboard.md | 124 +++ .../docs/checklists/checklist-edit.md | 156 ++++ .../docs/checklists/checklist-form.md | 160 ++++ .../docs/checklists/checklist-item.md | 120 +++ .../docs/checklists/checklist-list.md | 138 +++ .../docs/checklists/checklist-lp.md | 97 +++ .../docs/checklists/checklist-modal-wizard.md | 161 ++++ .../docs/checklists/checklist-search.md | 134 +++ .../skills/issue-plan-strategy/SKILL.md | 4 +- .../ndf-codex/skills/ndf-policies/SKILL.md | 4 +- .../skills/playwright-evidence/SKILL.md | 13 +- .../skills/issue-plan-strategy/SKILL.md | 4 +- plugins/ndf-kiro/skills/ndf-policies/SKILL.md | 4 +- .../skills/playwright-evidence/SKILL.md | 174 ++++ .../skills/playwright-kit-ops/.gitignore | 11 + .../skills/playwright-kit-ops/SKILL.md | 117 +++ .../playwright_kit/__init__.py | 11 + .../playwright_kit/accessibility.py | 90 ++ .../playwright_kit/body_check.py | 118 +++ .../playwright_kit/config.py | 461 ++++++++++ .../playwright_kit/fixtures/__init__.py | 19 + .../playwright_kit/fixtures/accessibility.py | 143 ++++ .../playwright_kit/fixtures/auth.py | 318 +++++++ .../playwright_kit/fixtures/body_check.py | 194 +++++ .../playwright_kit/fixtures/evidence.py | 353 ++++++++ .../playwright_kit/fixtures/web_vitals.py | 109 +++ .../playwright_kit/overlay.py | 220 +++++ .../playwright_kit/pytest_plugin.py | 491 +++++++++++ .../playwright_kit/pytest_report.py | 212 +++++ .../playwright_kit/uploaders/__init__.py | 151 ++++ .../playwright_kit/video.py | 71 ++ .../playwright_kit/web_vitals.py | 142 +++ .../skills/playwright-kit-ops/pyproject.toml | 50 ++ .../playwright-kit-ops/scripts/_drive_auth.py | 57 ++ .../scripts/build_gdoc_with_drive_links.py | 126 +++ .../playwright-kit-ops/scripts/check_cwv.py | 113 +++ .../scripts/classify_page_role.py | 238 ++++++ .../scripts/gdrive_upload_dir.py | 75 ++ .../scripts/init_project.bat | 196 +++++ .../scripts/init_project.sh | 184 ++++ .../scripts/record_scenario.py | 104 +++ .../scripts/run_a11y_scan.py | 104 +++ .../scripts/upload_evidence.py | 225 +++++ .../scripts/upload_md_as_gdoc.py | 47 + .../templates/conftest.py.template | 127 +++ .../templates/pyproject.toml.runtime | 51 ++ .../playwright-kit-ops/templates/run.bat | 95 ++ .../playwright-kit-ops/templates/run.sh | 101 +++ .../templates/runtime-README.md | 133 +++ .../templates/runtime-gitignore | 28 + .../templates/scenario.config.yaml | 158 ++++ .../templates/test_auth.py.template | 35 + .../templates/test_dashboard.py.template | 25 + .../templates/test_form.py.template | 33 + .../templates/test_list.py.template | 28 + .../playwright-kit-ops/tests/__init__.py | 0 .../playwright-kit-ops/tests/conftest.py | 23 + .../tests/test_a11y_cwv_routing.py | 80 ++ .../tests/test_auth_cache.py | 242 ++++++ .../tests/test_body_check.py | 394 +++++++++ .../tests/test_config_basic_auth.py | 163 ++++ .../tests/test_evidence_fixture.py | 264 ++++++ .../tests/test_makereport_user_properties.py | 147 ++++ .../tests/test_pytest_plugin_bootstrap.py | 135 +++ .../tests/test_pytest_report.py | 328 +++++++ .../tests/test_pytest_terminal_summary.py | 372 ++++++++ .../tests/test_pytester_integration.py | 410 +++++++++ .../tests/test_same_origin.py | 45 + .../tests/test_upload_evidence.py | 57 ++ .../tests/test_video_default.py | 63 ++ .../skills/playwright-kit-ops/uv.lock | 809 ++++++++++++++++++ .../skills/playwright-planning/SKILL.md | 123 +++ .../docs/01-methodology.md | 148 ++++ .../playwright-planning/docs/02-page-roles.md | 207 +++++ .../docs/03-test-techniques.md | 284 ++++++ .../docs/04-playwright-mapping.md | 249 ++++++ .../playwright-planning/docs/05-bug-report.md | 207 +++++ .../docs/06-pytest-playwright.md | 248 ++++++ .../skills/playwright-planning/docs/README.md | 79 ++ .../docs/checklists/checklist-auth.md | 148 ++++ .../checklists/checklist-cart-checkout.md | 158 ++++ .../docs/checklists/checklist-common.md | 129 +++ .../docs/checklists/checklist-dashboard.md | 124 +++ .../docs/checklists/checklist-edit.md | 156 ++++ .../docs/checklists/checklist-form.md | 160 ++++ .../docs/checklists/checklist-item.md | 120 +++ .../docs/checklists/checklist-list.md | 138 +++ .../docs/checklists/checklist-lp.md | 97 +++ .../docs/checklists/checklist-modal-wizard.md | 161 ++++ .../docs/checklists/checklist-search.md | 134 +++ .../ndf-shared/manifests/claude-skills.txt | 3 + plugins/ndf-shared/manifests/kiro-skills.txt | 3 + .../skills/issue-plan-strategy/SKILL.md | 4 +- .../ndf-shared/skills/ndf-policies/SKILL.md | 4 +- .../skills/playwright-evidence/SKILL.md | 13 +- 173 files changed, 24732 insertions(+), 37 deletions(-) create mode 100644 plugins/ndf-claude/skills/playwright-evidence/SKILL.md create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/.gitignore create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/SKILL.md create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/__init__.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/accessibility.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/body_check.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/config.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/overlay.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/pytest_report.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/video.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/web_vitals.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/pyproject.toml create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/scripts/_drive_auth.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/scripts/check_cwv.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/scripts/classify_page_role.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/scripts/init_project.bat create mode 100755 plugins/ndf-claude/skills/playwright-kit-ops/scripts/init_project.sh create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/scripts/record_scenario.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/scripts/run_a11y_scan.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/scripts/upload_evidence.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/templates/conftest.py.template create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/templates/pyproject.toml.runtime create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/templates/run.bat create mode 100755 plugins/ndf-claude/skills/playwright-kit-ops/templates/run.sh create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/templates/runtime-README.md create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/templates/runtime-gitignore create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/templates/scenario.config.yaml create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/templates/test_auth.py.template create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/templates/test_dashboard.py.template create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/templates/test_form.py.template create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/templates/test_list.py.template create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/__init__.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/conftest.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_auth_cache.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_body_check.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_config_basic_auth.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_evidence_fixture.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_makereport_user_properties.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_pytest_report.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_pytester_integration.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_same_origin.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_upload_evidence.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/tests/test_video_default.py create mode 100644 plugins/ndf-claude/skills/playwright-kit-ops/uv.lock create mode 100644 plugins/ndf-claude/skills/playwright-planning/SKILL.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/01-methodology.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/02-page-roles.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/03-test-techniques.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/04-playwright-mapping.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/05-bug-report.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/06-pytest-playwright.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/README.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-auth.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-cart-checkout.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-common.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-dashboard.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-edit.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-form.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-item.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-list.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-lp.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-modal-wizard.md create mode 100644 plugins/ndf-claude/skills/playwright-planning/docs/checklists/checklist-search.md create mode 100644 plugins/ndf-kiro/skills/playwright-evidence/SKILL.md create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/.gitignore create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/SKILL.md create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/__init__.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/accessibility.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/body_check.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/config.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/overlay.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/pytest_report.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/video.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/playwright_kit/web_vitals.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/pyproject.toml create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/_drive_auth.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/check_cwv.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/classify_page_role.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/init_project.bat create mode 100755 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/init_project.sh create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/record_scenario.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/run_a11y_scan.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/upload_evidence.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/templates/conftest.py.template create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/templates/pyproject.toml.runtime create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/templates/run.bat create mode 100755 plugins/ndf-kiro/skills/playwright-kit-ops/templates/run.sh create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/templates/runtime-README.md create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/templates/runtime-gitignore create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/templates/scenario.config.yaml create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/templates/test_auth.py.template create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/templates/test_dashboard.py.template create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/templates/test_form.py.template create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/templates/test_list.py.template create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/__init__.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/conftest.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_auth_cache.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_body_check.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_config_basic_auth.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_evidence_fixture.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_makereport_user_properties.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_pytest_report.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_pytester_integration.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_same_origin.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_upload_evidence.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/tests/test_video_default.py create mode 100644 plugins/ndf-kiro/skills/playwright-kit-ops/uv.lock create mode 100644 plugins/ndf-kiro/skills/playwright-planning/SKILL.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/01-methodology.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/02-page-roles.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/03-test-techniques.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/04-playwright-mapping.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/05-bug-report.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/06-pytest-playwright.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/README.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-auth.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-cart-checkout.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-common.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-dashboard.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-edit.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-form.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-item.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-list.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-lp.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-modal-wizard.md create mode 100644 plugins/ndf-kiro/skills/playwright-planning/docs/checklists/checklist-search.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4aafde82..df4020ef 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf-claude", - "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." }, { "name": "mcp-playwright", diff --git a/CLAUDE.md b/CLAUDE.md index 4deb1993..7c5b2b4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ skills/ → 実行可能なワークフロー ## NDF v5.0.0 の Skill 構成 -Skill は 29 個で、配布は `plugins/ndf-shared/manifests/` が唯一の基準(Claude Code 22 / Codex 23 / Kiro 21)。frontmatter の書き方は `plugins/ndf-shared/skills/README.md` の規約に従い、`python3 scripts/check-skill-frontmatter.py` で検査する。利用実績と維持・統合・削除の判定は `docs/specifications/ndf-skill-inventory.md` に記録する。 +Skill は 29 個で、配布は `plugins/ndf-shared/manifests/` が唯一の基準(Claude Code 25 / Codex 23 / Kiro 24)。frontmatter の書き方は `plugins/ndf-shared/skills/README.md` の規約に従い、`python3 scripts/check-skill-frontmatter.py` で検査する。利用実績と維持・統合・削除の判定は `docs/specifications/ndf-skill-inventory.md` に記録する。 v4.20.1 から統合・削除で消えたコマンド名の対応表は `ndf-policies` skill にある(v6.0.0 で削除)。 diff --git a/README.md b/README.md index a7d20dd7..de93389d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Claude Code / Codex / Kiro CLI向けのスキル・MCP設定を共有するた **NDFプラグイン v5.0.0** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI へ配布されるランタイム別プラグインです。共通ソースは `plugins/ndf-shared/` に集約し、利用者が install する配布物は `plugins/ndf-claude/` / `plugins/ndf-codex/` / `plugins/ndf-kiro/` に分かれています。 -- **公開Skills**: Claude Code向け core 22個、Kiro向け core 21個、Codex向け core 23個に分離。 +- **公開Skills**: Claude Code向け core 25個、Kiro向け core 24個、Codex向け core 23個に分離。 - **元Skills(29個)**: - PR/レビューワークフロー (7): pr, pr-tests, fix, review, cherry-pick-pr, deploy, merged - 原則・ガイドライン (9): ndf-policies, implementation-plan, plan-to-spec, investigation-rules, problem-solving, logging-guidelines, markdown-writing, issue-plan-strategy, ml-model-structure @@ -102,7 +102,7 @@ kiro-cli chat --agent ndf | プラグイン名 | バージョン | 説明 | 詳細 | |------------|----------|------|------| -| **ndf** | 5.0.0 | Claude Code / Codex / Kiro CLI 向けに runtime 別配布物を提供する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 22個、Kiro向け core 21個、Codex向け core 23個)、Claude SessionStart/Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [Claude](./plugins/ndf-claude/README.md) / [Codex](./plugins/ndf-codex/README.md) / [Kiro](./plugins/ndf-kiro/README.md) | +| **ndf** | 5.0.0 | Claude Code / Codex / Kiro CLI 向けに runtime 別配布物を提供する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 25個、Kiro向け core 24個、Codex向け core 23個)、Claude SessionStart/Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [Claude](./plugins/ndf-claude/README.md) / [Codex](./plugins/ndf-codex/README.md) / [Kiro](./plugins/ndf-kiro/README.md) | ### NDF v5.0.0 の主な変更(非互換) diff --git a/docs/presentations/2026-08-06-ai-plugins-intro.html b/docs/presentations/2026-08-06-ai-plugins-intro.html index 8059337b..4b071935 100644 --- a/docs/presentations/2026-08-06-ai-plugins-intro.html +++ b/docs/presentations/2026-08-06-ai-plugins-intro.html @@ -228,7 +228,7 @@

Codex CLI

セッション内では ndf: 接頭辞で
23個のスキルが使えます。

-

Claude版との差: エージェント8個とSessionStart/Stopフックは無し。代わりに Playwright 系スキル4個が入ります。

+

Claude版との差: エージェント8個とSessionStart/Stopフックは無し。Playwright 系スキル4個は Claude 版にも入ります。

@@ -1321,7 +1321,7 @@

Kiro CLI

bash plugins/ndf-kiro/install.sh kiro-cli chat --agent ndf -

.kiro/skills/20個
+

.kiro/skills/23個
.kiro/agents/ndf.json
.kiro/steering/ndf-policies.md を生成。

diff --git a/docs/presentations/2026-08-06-ai-plugins-intro.md b/docs/presentations/2026-08-06-ai-plugins-intro.md index a2189ff3..ad01c463 100644 --- a/docs/presentations/2026-08-06-ai-plugins-intro.md +++ b/docs/presentations/2026-08-06-ai-plugins-intro.md @@ -93,7 +93,7 @@ MCPプラグインが10個。今日は時間の都合で名前だけにします | | | |---|---| -| **スキル 22個** | `/ndf:pr`, `/ndf:review` … スラッシュで直接呼べる | +| **スキル 25個** | `/ndf:pr`, `/ndf:review` … スラッシュで直接呼べる | | **エージェント 8個** | director / corder / qa / debugger / devops-engineer など | | **フック 2種** | SessionStart(transcript保持を90日に維持)/ Stop(AI要約+Slack通知) | @@ -502,7 +502,7 @@ codex plugin add ndf@ai-plugins
-Claude版との差: エージェント8個とSessionStart/Stopフックは無し。代わりに Playwright 系スキル4個が入ります。 +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個**、 `.kiro/agents/ndf.json` と `.kiro/steering/ndf-policies.md` を生成。 diff --git a/docs/presentations/2026-08-06-ai-plugins-intro.pdf b/docs/presentations/2026-08-06-ai-plugins-intro.pdf index 1060121b78ffe83facdadc151f8c1eb8fe74567f..65b0eb2aec73f3d61b5a1dd6fbfc887f112f7018 100644 GIT binary patch delta 47946 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

AIX>v4Uu}5bYKsm(*SGe!p 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 delta 48427 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


UP?`WL#oX#rS1u&WJb-wf@N7#~Kn%sN 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 G_1^$Lr1r!B 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/issue-plan-strategy/SKILL.md b/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md index c9e286f0..26e07fdf 100644 --- a/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md +++ b/plugins/ndf-claude/skills/issue-plan-strategy/SKILL.md @@ -250,7 +250,7 @@ release ブランチへの merge が一通り進んだ段階で: - PR 間の API / 型 / スキーマ整合 - 設定値の重複・矛盾 - migration の順序依存 - - E2E シナリオ (`/ndf:playwright-authoring` の活用) + - E2E シナリオ (`/ndf:playwright-planning` の活用) - ここで **新たに** 個別 PR 範囲のバグが見つかった場合は、**release PR にコメントせず**、該当の個別 PR (既に merge 済みなら修正差分を載せた新しい修正 PR を release 配下に作成) 側に指摘を書き込み、修正ループを回す。この場合レビュー対象は **修正差分** であり新規 PR でレビューできる(元の差分がそもそも cross-review 未実施だったケースは扱いが異なるため Step 8 のフォールバック参照) - release PR には integration 観点の指摘のみ残す @@ -353,4 +353,4 @@ git checkout release/ - `/ndf:cherry-pick-pr` — 検証ブランチへの cherry-pick PR とブランチ汚染を避ける原則 - `/ndf:review` / `/ndf:cross-review` — レビュー(`--branch` で PR 前のセルフレビュー) - `/ndf:fix` — コメントの分類・修正・返信・Resolve -- `/ndf:playwright-authoring` — release ブランチでの E2E 結合テスト +- `/ndf:playwright-planning` — release ブランチでの E2E 結合テスト 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-evidence/SKILL.md b/plugins/ndf-claude/skills/playwright-evidence/SKILL.md new file mode 100644 index 00000000..569a0a55 --- /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_auth スキルが見つかりません` | google-auth skill 未導入 | `GOOGLE_AUTH_SCRIPTS` env を設定、または `google-auth` 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..595f3cd1 --- /dev/null +++ b/plugins/ndf-claude/skills/playwright-kit-ops/SKILL.md @@ -0,0 +1,117 @@ +--- +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 + +# ディレクトリごとアップロード +python scripts/gdrive_upload_dir.py reports/run-001/ --folder-id + +# Markdown → Google Doc 変換 +python scripts/upload_md_as_gdoc.py reports/run-001/report.md + +# Google Doc にエビデンス Drive リンクを埋め込み +python scripts/build_gdoc_with_drive_links.py reports/run-001/ +``` + +## パッケージ参照 + +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..d7bdb900 --- /dev/null +++ b/plugins/ndf-claude/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py @@ -0,0 +1,151 @@ +"""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 os +import sys +from pathlib import Path +from urllib.parse import quote + + +_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", + str(_HERE.parent.parent.parent / "scripts"), + str(_HERE.parent.parent.parent.parent / "google-auth" / "scripts"), + ) + if p +) + + +def _ensure_google_auth_on_path() -> None: + for p in _CANDIDATES: + if p.is_dir(): + 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" + "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " + "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" + "例: export GOOGLE_AUTH_SCRIPTS=/plugins/ndf-shared/skills/google-auth/scripts\n" + "検索した候補:\n - " + f"{searched}" + ) + + +# 拡張子 → 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)})" + ) + + _ensure_google_auth_on_path() + from google_auth import get_credentials # type: ignore # noqa: E402 + from googleapiclient.discovery import build # noqa: E402 + from googleapiclient.http import MediaFileUpload # noqa: E402 + + creds = get_credentials(["drive.file"]) + service = build("drive", "v3", credentials=creds) + + 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..bb40055d --- /dev/null +++ b/plugins/ndf-claude/skills/playwright-kit-ops/scripts/_drive_auth.py @@ -0,0 +1,57 @@ +"""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 +スキルを探す。Codex 公開セットには google-auth を含めないため、Codex で +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", + 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 を整える。""" + for p in _CANDIDATES: + if p.is_dir(): + 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" + "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " + "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" + "例: export GOOGLE_AUTH_SCRIPTS=/plugins/ndf-shared/skills/google-auth/scripts\n" + "検索した候補:\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 クエリで `