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 d90670a4..2a5a83c8 100644 --- a/docs/presentations/2026-08-06-ai-plugins-intro.html +++ b/docs/presentations/2026-08-06-ai-plugins-intro.html @@ -141,7 +141,7 @@

ai-plugins / NDF v5.0.0

まず全体像を1枚で

-

+

編集するのは plugins/ndf-shared/1か所だけ。そこから3ランタイム分の配布物が生成されます。
ほかに MCP プラグインが 10個(Serena / BigQuery / Playwright / Chrome DevTools / Redash など)。

@@ -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個は Claude 版にも入ります。

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

Kiro CLI

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

.kiro/skills/20個
+

.kiro/skills/23個(配布 24 個のうち ndf-policies は steering へ)、
.kiro/agents/ndf.json
.kiro/steering/ndf-policies.md を生成。

@@ -1431,7 +1431,7 @@

【0:35-1:25】 構成はシンプルです。スキルの本体は ndf-shared の1か所。ここを直すと、Claude用・Codex用・Kiro用の配布物がビルドで生成されます。 なので「Claudeでは直ってるけどCodexでは古い」が起きない。地味ですがこれが一番効いてます。 -数が違うのは、ランタイムごとに意味のないスキルを外しているからです。Claudeは29、Codexは30、Kiroは28。 +数が違うのは、ランタイムごとに意味のないスキルを外しているからです。Claudeは25、Codexは23、Kiroは24。 MCPプラグインが10個。今日は時間の都合で名前だけにします。

【1:25-2:10】 インストールはこれだけです。marketplace add は初回だけ、あとは install。 入るものは3種類。スラッシュで呼ぶスキル、裏で働くサブエージェント、それとフック。 @@ -1482,7 +1482,7 @@

【12:50-13:40】 CodexとKiroです。ここは「同じものが使える」ということだけ持って帰ってください。 -Codexはマーケットプレイス方式で、Claudeとほぼ同じ2コマンド。セッションに入るとndfコロン付きでスキルが並びます。実際に叩いて30個読み込まれているのを確認済みです。 +Codexはマーケットプレイス方式で、Claudeとほぼ同じ2コマンド。セッションに入るとndfコロン付きでスキルが並びます。実際に叩いて23個読み込まれているのを確認済みです。 Kiroだけ方式が違って、リポジトリをcloneしてinstall.shを叩きます。.kiro/skills/ 以下にスキルが並んで、エージェント定義も一緒に作られます。何度実行しても壊れないので、更新したら叩き直せばいいです。

【13:40-14:30】 まとめです。 今日いろいろ見てきましたが、共通しているのは、どのスキルにも「何をやるか」の前に「何を重視するか」が書いてあることです。レビューなら観点の優先順位、文章なら誰に向けて書くか、調査なら何を根拠とするか。 diff --git a/docs/presentations/2026-08-06-ai-plugins-intro.md b/docs/presentations/2026-08-06-ai-plugins-intro.md index 36d19f46..9dc067fd 100644 --- a/docs/presentations/2026-08-06-ai-plugins-intro.md +++ b/docs/presentations/2026-08-06-ai-plugins-intro.md @@ -73,7 +73,7 @@ NDFが何なのかは前回話したので、今日は「で、結局どのス 【0:35-1:25】 構成はシンプルです。スキルの本体は ndf-shared の1か所。ここを直すと、Claude用・Codex用・Kiro用の配布物がビルドで生成されます。 なので「Claudeでは直ってるけどCodexでは古い」が起きない。地味ですがこれが一番効いてます。 -数が違うのは、ランタイムごとに意味のないスキルを外しているからです。Claudeは29、Codexは30、Kiroは28。 +数が違うのは、ランタイムごとに意味のないスキルを外しているからです。Claudeは25、Codexは23、Kiroは24。 MCPプラグインが10個。今日は時間の都合で名前だけにします。 --> @@ -93,7 +93,7 @@ MCPプラグインが10個。今日は時間の都合で名前だけにします | | | |---|---| -| **スキル 29個** | `/ndf:pr`, `/ndf:review` … スラッシュで直接呼べる | +| **スキル 25個** | `/ndf:pr`, `/ndf:review` … スラッシュで直接呼べる | | **エージェント 8個** | director / corder / qa / debugger / devops-engineer など | | **フック 2種** | SessionStart(transcript保持を90日に維持)/ Stop(AI要約+Slack通知) | @@ -498,11 +498,11 @@ codex plugin add ndf@ai-plugins ``` セッション内では `ndf:` 接頭辞で -**30個**のスキルが使えます。 +**23個**のスキルが使えます。

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