先に結論です。本編の「安い順のはしご」を分けているのはコストではなく、何を実行するかです。 「読むだけ」は何も実行せず、単体テストは Ruby か Node のプロセスを 1 つ動かし、撮影はブラウザを 1 枚開き、スモークはブラウザ 2 枚とサーバと WebSocket と DB を同時に動かします。実行するものが増えるほど見えるものが増え、同時に、結果を決める場所が画面から遠ざかります。 手元のスモークは合否を画面では決めず、終わった後に残ったデータを Ruby が読んで決めます。画面は「当たって見える気がする」を返し、DB は列を返すからです。そして本編がはしごに入れていない 5 つ目があります。本番です。踏んだ人のブラウザが送り、YouTube の API が決め、人は通りません。
もう 1 つの結論は、撮影前の宣言を要求する hook は、導入から 3 日で「宣言なしの実行」を 1 件も捕まえていないということです。記録(tmp/visual_guard_log.tsv)の 21 判定を 2026-09-05 に分けると、撮影の実行そのものは 9 件で全部通り、止めた 6 件は全部、コマンド文字列に screenshot.sh を含んでいただけの偽陽性でした。うち 2 件は宣言ファイルを書くコマンド自身です。さらに、hook の入口の正規表現は scripts/playwright/smoke_*.js を直接呼ぶ形だけを拾い、実際に使われる ./scripts/smoke_*.sh を拾いません(2026-09-05 実測。exit 0)。はしごの一番上が判定に入っていない。 これが、その計測器の最初の読み取りです。
この章ははしごの機構(H.1〜H.2)、宣言 hook の計測(H.3)、スモークの合否と flaky(H.4〜H.5)、E2E の間引き(H.6)、画面の状態を集める器(H.7)、本番の経路(H.8)の順に進みます。付録 C が hook の契約を、付録 F が自動テストの型を、付録 G が git とプロセスを書きました。この章はその上、ブラウザと本番が絡む検証の機構を埋めます。
H.1 はしごは何を実行しているか
手元の運用のはしごは 4 段で、所要時間が付いています。技術書の側から見ると、所要時間は結果であって原因ではありません。原因は「そのとき動いているプロセス」です。
① Read / grep ┌──────────┐
│ ソース │ 実行なし。文字列の一致だけ
└──────────┘
② lint・単体テスト ┌──────────┐
│ ruby/node │ プロセス 1 つ。DB はテスト用。ブラウザ無し
└──────────┘
③ スクリーンショット ┌──────────┐ ┌──────────┐
│ Chromium │──▶│ Rails dev │ ブラウザ 1 枚。URL と cookie で作れる状態だけ
└──────────┘ └──────────┘
④ スモーク ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────┐
│ Chromium │──▶│ Rails dev │──▶│ AnyCable │ │ DB │
│ ×2 │ │ + rake │ │ + Valkey │ │ │◀── verify(Ruby)
└──────────┘ └──────────┘ └──────────┘ └────┘
⑤ 本番 ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 利用者の │──▶│ Rails │──▶│ YouTube │ 人は通らない
│ ブラウザ │ │ + job │ │ Data API │
└──────────┘ └──────────┘ └──────────┘
| はしご | 実行するもの | ここで初めて見えるもの | ここでも見えないもの | 手元の道具 | 所要(本編) |
|---|---|---|---|---|---|
| ① 読むだけ | 何も実行しない | CSS ルール・クラス名・i18n キー・DOM の構造(ソースに書いてある) | cascade の結果・JS 適用後の DOM | Read / grep | 0 秒 |
| ② 単体テスト | Ruby / Node / Python のプロセス 1 つ | ロジックの正しさ・YAML の構文・色リテラルの件数・コントラスト比の計算値 | ブラウザの描画・イベントの配線 | scripts/design_fix.sh(check_css_syntax → css_bundle_vars → design_rules --update → contrast_context_audit)/ validate_locales.py / Minitest / node --test | 数秒〜277 秒 |
| ③ 撮影 | Chromium 1 枚 + Rails dev | computed style・viewport 3 種の実描画・console / pageerror / requestfailed / 4xx 以上の応答 | ページをまたぐ遷移・時間経過・2 人目・DB の中身 | scripts/screenshot.sh → scripts/playwright/screenshot.js | 30〜100 秒 |
| ④ スモーク | Chromium 2 枚 + Rails dev + AnyCable + Valkey + DB + rake | 通しの結線・WebSocket・2 人の同時進行・残ったデータ | 書いていない場面 | scripts/smoke_*.sh(14 本)→ scripts/playwright/smoke_*.js(20 本)+ lib/tasks/dev_smoke.rake(verify 4 本) | 数分 |
| ⑤ 本番 | 利用者のブラウザ + 本番 Rails + job + YouTube Data API | 書いていない場面(十数言語・相手のいる対戦・時間帯のあるライブ) | 主観(歌詞のズレ・音ズレ) | 再生エラーの自動報告 → 確認ジョブ / 不具合フォーム | 踏んだ人の時間 |
この表を Google の「テストの大きさ」に重ねると、単体テストが small(1 プロセス・sleep も I/O も無し)、撮影とスモークが medium(複数プロセス・localhost へのネットワークまで)、本番が large(複数マシン)に当たります(『Software Engineering at Google』第 11 章。2026-09-05 確認)。同書が勧める比率は 80 / 15 / 5 ですが、本編のはしごは比率の話ではなく順番の話です。 「前の手段で解けたら、次へ進まない」は、テストピラミッドの「上に行くほど本数を減らす」とは別の規則で、1 回の検証をはしごのどこで止めるかを決めます。ピラミッドは持っているテストの分布を、はしごは今日の 1 回の行き先を決めます。
⚠️ 単体テストと撮影の間には、本編が実例で示した抜け穴があります。data 属性のキーを 1 つ書き間違えたとき、Minitest は全部合格で、壊れたのは JS が実 DOM に当たった後だけでした。Rails のビューテストは HTML の文字列を検証しますが、Stimulus のコントローラがその HTML を読んで動く部分は Node のテストでも HTML の文字列を stub するので、「ERB が出す属性名」と「JS が読む属性名」の一致は、両方を同じプロセスで動かさない限り検証されません。 撮影が要る理由はここにあり、本編の「見つけた抜け穴は突き合わせのテストに落とし、次からは単体テストで済ます」は、この一致を単体テストに引き下ろす作業のことです。
H.2 Playwright を Docker で動かす仕組み
撮影とスモークは同じ基盤に乗っています。ホストに Node を入れず、Microsoft が配る Playwright のイメージをワンショットで走らせ、コンテナの中の Chromium からホストで動いている Rails を開きます。
ホスト(macOS)
├── Rails dev(docker-compose の web。ホストの :WEB_PORT に公開)
└── docker run --rm mcr.microsoft.com/playwright:v1.59.0-jammy
--add-host=host.docker.internal:host-gateway ← コンテナからホストへ
-v scripts/playwright:/work:ro ← JS と node_modules
-v /tmp/ss:/out ← 画像の出口
node /work/screenshot.js http://host.docker.internal:3000/...
機構は 4 つです。
1. イメージの固定。 PLAYWRIGHT_IMAGE の既定は mcr.microsoft.com/playwright:v1.59.0-jammy で、撮影もスモークも同じ値です。Playwright の公式は「Docker イメージは必ず特定の版に固定せよ」と書き(ブラウザの実行ファイルの場所が版で変わるため)、2026-09-05 時点の例示は v1.63.0-noble です。手元は 4 つ古い版に固定したままで、scripts/playwright/package.json の playwright の版と一致している必要があります(ずれると npm install したモジュールがイメージ内のブラウザを見つけられない)。
2. コンテナからホストへ。 --add-host=host.docker.internal:host-gateway の host-gateway は Docker の特別な値で、「ホストの内部 IP に解決される」と公式が書いています(docker container run リファレンス。2026-09-05 確認)。Docker Desktop はこの名前を自動で解決しますが、Linux では解決しないので、この 1 行が両方で同じ URL を使うための鍵です。撮影はこの経路で http://host.docker.internal:<port> を開きます。
3. スモークだけに付く 3 つのフラグ。 対戦のスモークは --init --ipc=host --shm-size=2g を付け、撮影は付けません。公式の説明は、--ipc=host が「無いと Chromium がメモリ不足で落ちる」、--init が「PID 1 の特別扱いを避ける。ゾンビプロセスのよくある原因」です(Playwright Docker ガイド。2026-09-05 確認)。付録 G.9 が書いたコンテナの PID 1 の話が、ここでは Node が PID 1 になる形で現れます。撮影が付けていないのは、ページ 1 枚を逐次で開くだけなら落ちなかったからで、機構的な理由ではありません。スモークがブラウザのコンテキストを 2 つ持ち、5 分のシナリオを回すようになって初めて要りました。
4. localhost に見せる TCP プロキシ。 対戦のスモークは smoke_tcp_proxy.js をコンテナ内で先に起動し、127.0.0.1:3000 → host.docker.internal:3000 と 127.0.0.1:8080 → host.docker.internal:8080 を転送してから、ページを http://localhost:3000 で開きます。理由は secure context です。crypto.subtle は secure context でしか使えず、http://host.docker.internal は secure context ではありません。対戦のコミットチェーンが crypto.subtle を使うので、localhost で開く必要があります。MDN は http://localhost と http://127.0.0.1 を「ブラウザと同じ装置にあるので安全に届いたとみなせる」potentially trustworthy origin と定義しています(Secure contexts。2026-09-05 確認)。加えて Chromium に --disable-web-security を渡し、Private Network Access を --disable-features で切り、--unsafely-treat-insecure-origin-as-secure を付けています。本番と同じ経路を通すために、本番には無い 4 つの細工が要るというのがスモークの実態で、この細工が壊れると本番と関係のない理由で失敗になります(H.5)。
ページの読み込みを「終わった」とみなす条件
Playwright の page.goto は waitUntil で終了条件を選びます。load は load イベント、domcontentloaded は DOMContentLoaded、networkidle は「500 ms 以上ネットワーク接続が無い」、commit は応答を受けて文書の読み込みが始まった時点で、既定は load です。公式は networkidle に DISCOURAGED(テストには使うな、web assertion で準備完了を判定せよ)と書いています(Page クラスのリファレンス。2026-09-05 確認)。
手元の撮影は 3 つを順に待つハイブリッドです。goto を domcontentloaded で確定させ、load を待ち、networkidle を上限 5 秒で待って諦めます(screenshot.js の waitReady)。旧方式が networkidle を上限なしで待ち、WebSocket の常時接続と広告のポーリングで 30 秒のタイムアウトに張り付いたからです。公式の DISCOURAGED は、まさにこの形の壊れ方を指しています。
もう 1 つ、2026-09-02 に実測された壊れ方があります。curl は 0.4 秒で 200 を返すのに、ブラウザは 4 viewport とも page.goto: Timeout 30000ms exceeded で落ちました。原因は dev の importmap が数十本の module script を一度に取りに行き、Rails がさばききれないこと、そして module script は DOMContentLoaded をブロックすることでした。domcontentloaded で確定させる設計でも、module script が全部届くまで DOMContentLoaded は発火しません。対処は SCREENSHOT_TIMEOUT_MS=120000 で、既定値は上げていません(本当に固まっているページで 2 分待たされるため)。⚠️ この事故は撮影でしか起きず、単体テストでは importmap が読まれません。はしごを上ると、検証したい対象とは別の理由で失敗になる確率が上がります。
viewport と、出力に何が入るか
| 出力 | viewport | 何を見るか |
|---|---|---|
{NAME}_pc.png | 1280×900 | ファーストビュー |
{NAME}_pc_full.png | 1280 幅・fullPage: true | 全体。pc と同じ context から続けて撮る(順序は pc → pc_full 固定) |
{NAME}_tablet.png | 768×1024 | タブレット |
{NAME}_mobile.png | 375×812・deviceScaleFactor: 2・isMobile・hasTouch | スマホ。isMobile は「meta viewport を考慮しタッチイベントを有効にする」(Playwright の emulation ガイド) |
{NAME}_console.log | — | console の error / warning・pageerror・requestfailed・4xx 以上の response |
fullPage は公式に「表示中の viewport ではなくスクロール可能なページ全体を撮る」で、内部でスクロールが走ります。pc より先に pc_full を撮ると、そのスクロールが pc の撮影に影響するので順序が固定されています。viewport は逐次で開きます。並列度 2〜3 で個別の goto が 30〜60 秒のタイムアウトに乗った実測があり、dev の Puma が同時リクエストに弱いためです。
_console.log の 4 種は別々の口から来ます。pageerror は「ページ内の未捕捉例外」、console は「ページ内の JS が console API を呼んだとき」、requestfailed は「ネットワーク上の失敗」で、公式は 「404 のような HTTP のエラー応答は request failure ではない。response イベントで完了する」と明記しています(2026-09-05 確認)。だから手元は response を別に取り、status >= 400 を記録します。この 4 種は H.7 の不具合フォームが集める console_logs と対応していて、フォームの側は同じ 4 種を利用者のブラウザから集めます。
H.3 宣言 hook の機構と、3 日間の計測
手元では PreToolUse hook を置き、「宣言なしの実行が何回あるか」を数え始めました。機構と、その計測器が最初に読んだ値を書きます。
判定の 4 つの条件
Bash ツールのコマンド文字列
│
├─ *SKIP_VISUAL_GUARD=* を含む ──────────────────────▶ exit 0(逃げ道)
├─ scripts/screenshot\.sh | scripts/playwright/smoke_[a-z_]+\.js を含まない ─▶ exit 0(対象外)
│
▼ tmp/visual_verification.md を見る
├─ 無い ──────────────────────────────▶ exit 2「宣言なし」+ 案内(置き換え先つき)
├─ 念のため|とりあえず|確認のため|撮らないと分からない|見ないと分からない ─▶ exit 2「禁句」
├─ Q1 か Q2 が 8 文字未満(python3 で数える)──▶ exit 2「宣言が空」
├─ mtime が 1,800 秒より古い ────────▶ exit 2「使い回し」
└─ 通る ─────────────────────────────▶ exit 0 + 記録に passed
exit 2 の契約は付録 C.3 のとおりで、PreToolUse では tool call が止まり、stderr が Claude に返ります(Hooks リファレンス。2026-09-05 確認)。案内文は「TODO の『撮影で確認』を『grep / 単体テストで確認』へ書き換えて消化する」を含み、自動テスト 9 項目の 1 つはこの「書き換えて」が stderr にあることを見ています。文字数を ${#var} ではなく python3 で数えるのは付録 G.10 の理由(Claude Code の Bash ツールには LANG が無く、バイト数が返る)で、stat -f %m(BSD)と stat -c %Y(GNU)を並べているのは macOS のホストと Linux の両方で走らせるためです。
判定のたびに tmp/visual_guard_log.tsv へ「日時・判定・コマンド先頭 120 字」を 1 行追記します。この記録が計測器です。⚠️ 宣言ファイルは hook が読むだけではなく、AI が Write で書いた時点で tool_input として会話層にも載ります(付録 A)。本編 T4 を読み直して挙げた波及で、2 文なので量は小さいものの、宣言は「書いたら消える」ものではありません。
記録の 21 判定(2026-09-03 〜 09-05)
3 日間で 21 判定でした。「撮影の実行そのものか、screenshot.sh という文字列を含んでいただけか」で分けると、こうなります。
| 判定 | 撮影の実行 | 文字列を含むだけ | 計 |
|---|---|---|---|
| passed | 9 | 6 | 15 |
| blocked:使い回し | 0 | 6 | 6 |
| blocked:宣言なし | 0 | 0 | 0 |
| blocked:禁句 | 0 | 0 | 0 |
| blocked:宣言が空 | 0 | 0 | 0 |
| 計 | 9 | 12 | 21 |
読み取れることは 3 つです。
1. 宣言なしの撮影は 0 回。 撮影の実行 9 件は全部、有効な宣言の下で走りました。本編 P1 の課題は「宣言の工程を飛ばした実行は素通りする。何回起きているかは推測のまま」でしたが、計測器を置いた 3 日間では 0 回でした。これは宣言(T4 の指示文)が守られていた証拠であると同時に、hook が「ルールを構造に移した」効果をまだ 1 度も発揮していないという読みでもあります。3 日・9 回の撮影から結論は出せません。ただ、hook の価値の見積もりは「素通りの頻度」に比例し、その頻度はまだ 0 です。
2. 止めた 6 件は全部偽陽性。 内訳は、宣言ファイルを書くヒアドキュメント 2 件(Q1 / Q2 の文中か、&& で続けた撮影コマンドに scripts/screenshot.sh が含まれていて入口の正規表現に当たった。記録の 120 字にはその箇所が残っていない)、lib/tasks を grep した 1 件、この付録を書くために scripts/screenshot.sh と scripts/playwright/smoke_vocabulary_battle.js を cat した 3 件です。理由の欄は 6 件とも「使い回し」で、これは 09-04 19:58 に書かれた宣言が残っていて 30 分を過ぎていたからです。偽陽性が、正しい理由ではなく「宣言の状態」で記録されます。 記録は「何を実行しようとして止まったか」を持たず、「宣言がどうだったか」だけを持つので、偽陽性の率は記録からは読めず、人がコマンド先頭 120 字を読んで分けるしかありません。上の表はそうやって作りました。
3. 宣言を書くコマンドが止まる。 6 件のうち 2 件は、hook を通すために宣言を書こうとしたコマンドです。案内文の「ファイル編集ツールで扱ってください」はこのために書かれていて、Write ツールは PreToolUse の matcher が Bash なので hook を通りません。P1 の「置き換え先のない禁止は検証の放棄を生む」は、ここでは「宣言を書けと言いながら宣言を書くコマンドを止める」形で 2 回起きています。
入口の正規表現が拾わないもの
入口の判定、scripts/screenshot\.sh|scripts/playwright/smoke_[a-z_]+\.js を、hook の自動テストと同じ形(入力 JSON を偽造して bash で走らせる。付録 C.9)で 5 つのコマンドに当てました(2026-09-05 実測。CLAUDE_PROJECT_DIR を空のディレクトリに向け、宣言ファイル無し)。
| コマンド | exit | 意味 |
|---|---|---|
./scripts/smoke_vocabulary_battle.sh | 0 | スモークの実際の入口。素通り |
bash scripts/smoke_cable_status.sh --rpc-mid | 0 | 同上(障害 1 の再現スモーク)。素通り |
docker run x node /work/smoke_vocabulary_battle.js | 0 | ラッパーの中でコンテナに渡る形。素通り |
node scripts/playwright/smoke_vocabulary_battle.js | 2 | 拾う。ただし JS 自身が「直接 node 実行は不可。env は sh wrapper が渡す」 |
echo SKIP_VISUAL_GUARD= ; bash scripts/screenshot.sh / | 0 | 逃げ道の部分文字列(付録 G.5 と同じ) |
hook が拾うスモークの形は、走らせても動かない形だけです。実際に走る ./scripts/smoke_*.sh 14 本は、コマンド文字列に scripts/playwright/ を含まないので対象外になります。スモークははしごの一番上で、本編 T4 は「スモーク・E2E(数分)」を宣言の対象に含めていますが、hook の側は撮影だけを見ています。自動テストの項目 3「宣言なしの smoke は止まる」は docker run x node scripts/playwright/smoke_pixel_art_editor.js で通っていて、これは偽造した入力が実際の入口と違う形です(付録 F の受け入れテスト型が「テストが写している形と実物の形の一致」を見る理由がここにあります)。直すなら正規表現に scripts/smoke_[a-z_]+\.sh を足すだけですが、その瞬間に grep smoke_ scripts/ の類が偽陽性に加わります。入口の判定は、コマンド文字列の部分一致である限り、拾う形を増やすほど偽陽性も増えます。 付録 G.5 が書いた「shlex で分解して引数列を見る」形にしない限り、この天秤は消えません。手元はまだ部分一致です。
H.4 スモークの 3 つの部分と、合否を DB で決める仕組み
3 つの部分と、その境界で渡るもの
| 部分 | ファイル | 役割 | 隣へ渡すもの |
|---|---|---|---|
| Rake | lib/tasks/dev_smoke.rake(1,640 行) | setup / teardown / verify。Rails.env.development? でなければ abort(23 箇所) | puts "[task] key=value" の行 |
| Shell | scripts/smoke_<feature>.sh(14 本) | Docker の起動、trap teardown EXIT、setup の出力を grep -oE で読んで -e で JS へ、verify の行を grep して PASS / FAIL | 環境変数 |
| Playwright | scripts/playwright/smoke_<feature>.js(20 本) | SmokeRunner がシナリオを順に走らせ、失敗したらフルページの画像を /tmp/smoke_failures/ に落とす | exit code |
境界を越えるのは文字列だけです。setup が deck=#123 と印字し、sh が grep -oE 'deck=#[0-9]+' で拾い、-e SMOKE_DECK_ID=123 で JS に渡す。verify が entry_order_match=true と印字し、sh が grep -q "entry_order_match=true" で PASS にする。判定の材料は全部、人が読める 1 行です。 本編が「結果は 2 つの数で返る」と書いた Smoke FAILED (playwright=1 verify=1) は、JS の exit code と、verify の行を grep した結果の 2 つです。
teardown を trap ... EXIT に置くのは、Playwright が途中で落ちても smoke ユーザーのデータを消すためで、--keep-data はその trap の中で早期 return します。development? のガードが 23 箇所あるのは、この rake が dev DB に直接書くからで、/dev/smoke_login のルートは Rails.env.development? || Rails.env.test? の中でしか定義されず、コントローラ側にも enforce_development_only! があります(GET と POST の両方を許すのは Playwright が page.goto でログインできるようにするため)。本番に流出しない根拠は、rake の abort、ルートの if、コントローラの before_action の 3 つです。
なぜ画面では決められないか
単語帳対戦のスモーク(smoke_vocabulary_battle.sh)が見たいのは「2 人に同じ順番で出題されたか」です。本編は「表示中の単語は簡単にずれる」と書きました。機構で言うと、2 つのクライアントは同じ列を独立に進み、1 語ごとに制限時間があり、打ち終わった側だけが次へ進みます。画面に出るのは「今の 1 語」だけなので、P1 が 5 語目、P2 が 3 語目にいる瞬間に両画面を読めば、列が同じでも違う語が見えます。列の同一性は、列全体を持っている場所でしか判定できません。
列全体は 2 箇所にあります。1 つは学習記録で、クライアントが打った順に vocabulary_entry_play_logs へ残ります(in_play_order = id 昇順)。もう 1 つはサーバの再現で、部屋に残った shuffle_seed から VocabularyBattleEntryOrder.call を呼べば同じ列が出ます。
countdown_start で配る shuffle_seed(SecureRandom.random_number(2**31))
│
├──▶ サーバ: VocabularyBattleEntryOrder::Mulberry32.new(seed) ─┐
│ │
├──▶ P1 のブラウザ: mulberry32(seed) → seededShuffle ─▶ 打つ ─▶ play_logs(P1)
│ │
└──▶ P2 のブラウザ: mulberry32(seed) → seededShuffle ─▶ 打つ ─▶ play_logs(P2)
│
verify(Ruby): orders.uniq.size == 1 ◀──┘ entry_order_match
server_ids == orders.first server_order_match
entry_order_match は 2 人の記録の一致、server_order_match は記録とサーバの再現の一致です。後者が真なら、Ruby の Mulberry32 と JS の mulberry32 が同じ seed から同じ列を出したことになります。シードを 2**31 未満に収めるのは JS の Number で安全に扱える範囲のためで、mulberry32 は 32 bit の決定論 PRNG です。
この一致は単体テストでも見ています。scripts/gen_vocabulary_shuffle_vectors.rb が seed 5 つ(0・1・42・987,654,321・2,147,483,647)× 語数 4 つ(1・3・20・100)の出力を JSON に固定し、test/services/vocabulary_battle_entry_order_test.rb と test/javascript/vocabulary_shuffle.test.mjs が両側からその JSON に一致することを見ます。同じ性質を、単体テストは共有ベクタで、スモークは実データで見ています。 スモークの server_order_match が単体テストと違って見られるのは、シードが countdown_start で本当に 2 人に配られ、JS が本当にそれを読んで、記録が本当に送られた、という結線です。列の計算そのものは単体テストで済んでいます。
逆に、DOM でしか見られないもの
単語先取モード(--race)は「2 人に同時に同じ語が出て、片方が打ち切ると両方が次へ進む」が仕様そのもので、学習記録を送りません。DB に列が残らないので、DOM を見るしかありません。ここで手元が採った判定の形は、テキストの一致ではなく因果です。P2 の残り語数を控え、P1 だけが打ち切り、P2 が 1 打も打たずに残り語数が減ることを 15 秒以内に見る。さらに、打っていない P2 に「取られた」の状態クラス(is-taken)が出ることを 12 秒のポーリングで拾う。「同じ語が見えている」は瞬間の一致で、表示の切り替わりをまたぐと一瞬ずれますが、「相手が打っていないのに進んだ」はずれません。
verify にも DOM の代わりに残ったカウンタで見る行があります。race_win_total_matches は「先取ポイントの合計 + 誰も取らなかった語数 = 全語数」です。⚠️ この式は先取が 0 語でも成立します(wins=0・unclaimed=total)。付録 F の「走査 + 下限」型が「空虚な真」と呼んだ形で、手元は race_win_total_positive(先取が 1 語以上)を別の行にして下限を固定しています。合計の一致だけを見るスモークは、先取が全く機能していなくても合格です。
H.5 スモークはなぜ揺れるか
本編(第 2 部 第 6 回を読み直したとき)は「Playwright + Docker の基盤コストと flaky(時間依存)」を波及に挙げました。揺れる原因を、公式と手元で突き合わせます。
Fowler は非決定的なテストの原因を 5 つ挙げています。隔離の欠如・非同期・遠隔サービス・時刻・資源リーク("Eradicating Non-Determinism in Tests"、2011。2026-09-05 確認)。『Software Engineering at Google』は 0.1% の揺れでも 1 日 10,000 本走らせれば毎日 10 件を調べることになり、1% に近づくとテストは価値を失う、Google は約 0.15% と書いています。
| 原因(Fowler) | 手元で起きた形 | 手元の対処 | 残っているもの |
|---|---|---|---|
| 非同期 | dev の cold start で最初のシナリオが 100 秒超 / importmap が JS を 1 本ずつ返しプレイ画面の load に 28 秒 | warm-up でメイン画面を先に 1 回開く / navigation 60〜120 秒・シナリオ 240〜300 秒 | タイムアウトは長くするほど、本当に固まったときの待ちも長い |
| 非同期(bare sleep) | 「学習記録の注記は player_finish 後の非同期送信で入る」 | _waitUntil(150 ms のポーリング)と _collectRaceStatus(120 ms) | sleep(3000) が 2 箇所、sleep(2000) が 1 箇所残る。Fowler の「bare sleep を使うな、コールバックか polling を」に対して、手元は混在 |
| 時刻 | 先取モードで遅れて合流したクライアントが数語をまとめて飛ばす(設計どおり) | 「先取 + 誰も取らなかった = 全部」の式で環境ゆらぎを吸収。割合で締めない | 緩めた分だけ、記録の欠落を見逃す(F.13 の下限の話と同じ天秤) |
| 遠隔サービス | AnyCable → Rails の gRPC / Valkey | dev の実物を使う(stub しない) | 障害 1(2026-08-23)の再現スモークは、接続してから anycable_rpc を止める --rpc-mid で組み、StimulusHealthCheck の自動リロードを跨ぐと「接続後に切れた」ではなく「一度も繋がらない」経路を測ってしまい結論が逆になった記録がある |
| 隔離の欠如 | 前回の smoke データが残る / 別の smoke が同じユーザーを使う | setup で destroy_all してから作る(冪等)/ 部屋名を 4 種に分ける | 「連続実行しない」は運用の注意のまま(付録 G.8 の並列実行の話と同根) |
もう 1 つ、ノイズの扱いがあります。IGNORED_CONSOLE_PATTERNS は preloaded using link preload but not used・[StimulusHealthCheck] Unregistered controllers・Failed to load resource・ERR_BLOCKED_BY_ORB の 4 つを console から落とし、requestfailed の net::ERR_ABORTED を context close 時の正常な中断として落とします。落とすものを増やすほど、本物の失敗も落ちます。辞書対戦のスモークは「わざと 422 を 1 回出す」ので、末尾の console に update_settings の 422 が 1 件出るのは想定どおりと JS のコメントに書いてあり、これは人が読んで区別する部分です。
手元のスモークが flaky の統計を持てない理由は、走る回数にあります。14 本のスモークは「触った領域を宣言してから」しか走らず、CI にもありません。Google の 0.15% は 1 日に何万本も走るから測れる数字で、月に数回のスモークでは、1 回の失敗が揺れなのか壊れたのかを回数で判定できません。手元の代わりの手は、失敗時のフルページ画像(/tmp/smoke_failures/<scenario>_<index>_<ms>.png。自動削除されない)と、Error.message に期待値と実際値を必ず入れる規約です。揺れを統計で吸収できない規模では、1 回の失敗を人が読める形にするしかありません。
H.6 E2E の間引きと、隠さないこと
「間引いた一覧を毎回印字する」の機構と、テスト選択の一般論との違いを書きます。
何を E2E にするか
手元のガイド(docs/guide/14_testing_guide.md §3-1)の判定基準は 5 つです。外部 HTTP 通信を伴う / 繰り返し実行を前提とする(レート制限の閾値まで本物の反復)/ 大量データを前提とする / 複数サービスを跨ぐ横断フロー / 実 I/O そのものが目的(実画像のエンコード・実ファイルの書き出し・DDL の張り替え)。「回数の閾値で判定しない」と明記されていて、600.times でも DB I/O が無ければ通常のテストです。Google の small / medium / large に重ねると、外部 HTTP は large、それ以外は medium(1 台の中で複数プロセス・ファイル・DB を使う)で、E2E という名前が付いていますが、ブラウザは 1 本も動きません。 手元の E2E は「重い Minitest」の別名で、H.1 のスモークとは別のものです。2026-09-05 時点で 23 領域・24 ファイルにあります(E2E_AREA に言及するだけのファイルがもう 1 本あり、check_test_runs.py が「定数の定義」だけを目印にする理由がそこにあります。付録 F.12)。
3 つの機構
テスト側 ガード側 出口
skip_e2e(E2E_AREA) TestExecutionGuard Minitest.after_run
│ 選ばれた領域と完全一致? │ E2E="<一部>" を include? で照合 │ SKIPPED_AREAS を sort して印字
├─ yes → 走る │ 0 件 → abort / 2 件以上 → abort │ 「[E2E] 次の領域は実行していません」
└─ no → Minitest::Skip │ all/ALL/*/全部/全量/カンマ → abort │
+ SKIPPED_AREAS << 領域 │ 直前 30 分に別領域 → E2E_AGAIN が無ければ abort
│ 記録: tmp/e2e_last_run / /tmp/typingtube_e2e_last_run / tmp/e2e_run_history(200 行)
収集は Minitest::Skip です。skip は要約の行の skips に数えられ、終了コードを変えません。付録 F.9 が書いたとおり、E2E の除外も他の理由の skip も同じ 1 数字に混ざります。
選択の材料になる領域の一覧は文書に置かず、TestExecutionGuard#e2e_areas が test/**/*_test.rb の E2E_AREA = "..." を正規表現で数えます(付録 F.9)。選択そのものは TestExecutionGuard が 1 箇所で行い、E2ETestSkippable は「選ばれた領域と完全一致するか」だけを見ます。部分一致・曖昧の解決・全量の拒否をテスト側に書かないのは、2 箇所に書くと片方を直したときに黙ってずれるからです(計画 196 §9 の轍)。「1 つずつ順番に全部」を止める 30 分の窓は、E2E=all を落としただけでは 23 回打てば全量と同じになるからで、2 領域目を走らせるには E2E_AGAIN="<なぜ要るか>" を書きます。書けるのは本当に 2 領域に跨って触ったときだけのはず、という設計です。
記録は 3 つあります。直前の 1 件(30 分の窓のため)、同じ内容をコンテナのローカルにも二重化したもの(rm 一つで消せる記録は消せば「順番に全部」が通っていたため)、追記の履歴(pre-commit が「直した E2E を走らせたか」を見るのに、直前の 1 件では 1 コミットが 2 領域に跨ったとき片方が必ず落ちるため)です。⚠️ 履歴を読むと、同じ領域を何十回も走らせた跡のほうが先に目に入ります。間引きの記録は「何を見ていないか」より「何を見続けたか」を先に語ります。
テスト選択の一般論と、この方式の違い
回帰テスト選択(regression test selection)は、変更に影響されるテストだけを選んで走らせる技術です。製品としては Microsoft の Test Impact Analysis があり、公式ドキュメント(2018-12-07 版。2026-09-05 確認)は、テストとコードの依存関係の地図から影響されるテストを選び、HTML や CSS の変更のように「理解できない」コミットでは全テストへフォールバックし、「設定した周期で全テストを走らせる」ことを推奨し、選択が正しいかは「選んだテスト(T1)と全テスト(T2)を続けて走らせて T2 が同じ失敗を出すか」で確かめよ、と書いています。
手元の方式との違いは 2 つです。
| TIA(回帰テスト選択) | 手元(T3) | |
|---|---|---|
| 誰が選ぶか | 依存関係の地図(機械) | 人(または AI)が領域名を 1 つ打つ |
| 選ばなかったものは | 黙って省く。周期的な全量で拾う | 毎回印字する。全量は用意せず、CI でも E2E は回さない(.github/workflows/ci.yml の注記「E2E: all を足して総当たりにしない。ガードが CI でも落とす」) |
| 変更から領域を導くか | 導く | 導かない。pre-commit [7/8] は「E2E のテストファイル自身をステージしたか」だけを見る |
| 安全性 | 「変更を露わにしうるテストを全部選ぶ」を目指す | 目指さない。触った領域が一覧に載っていないことを人が読む |
本編が「テスト選択の一般論と違う点は、隠さないこと」と書いたのは、この右の列です。TIA が全量へのフォールバックと周期的な全量で安全性を買うのに対し、手元は全量を用意せず(本編 T3 を読み直したときに挙げた「CI では全量にする」は、手元では採っていません。CI は既定の全量 = E2E 抜きだけを回します)、一覧を毎回会話層に積むことで買っています(付録 A の「毎ターン全文を送り直す」により、22〜23 行は毎回トークンを食います)。⚠️ もう 1 つの違いは、手元が実装の変更から領域を導かないことです。app/services/ を直して、その領域の E2E テストファイルを触らなければ、[7/8] は何も言いません。導くには依存関係の解決が要り、それは TIA が持っていて手元に無いものです。
H.7 画面の状態を集める器
本編(第 3 部 第 3 回)の不具合フォームは「人が打つのは症状の一文だけ」でした。何を、どの口から、どこまで集め、どこに置くかを書きます。
誰が知っているか
| 項目 | 知っているのは | 集める口 | 上限(クライアント) | 上限(サーバ) |
|---|---|---|---|---|
| 症状の一文 | 人 | textarea | 1,000 字(UI の表示) | truncate(5000) |
| 分類(不具合 / 要望 / 歌詞の間違い / その他) | 人 | radio | — | enum |
page_url | 画面 | location.pathname + search | — | 500 字・必須 |
| スクリーンショット | 画面 | html2canvas → canvas.toDataURL("image/jpeg", 0.7)・scale: 0.5 | 圧縮率と縮尺 | 大きさ(下の段落) |
| コンソールログ | 画面 | lib/error_log の 5 つの口 | 保持 50 件・送信 20 件・1 件 2,000 字 | 20 件・2,000 字(同じ値をもう一度) |
| 解像度 | 画面 | screen.width x screen.height(viewport ではなく画面) | — | — |
| 送信日時・環境 | サーバ | 受け取った時刻と要求ヘッダ | — | — |
「人が知っていて画面が知らないもの」は上 2 行だけです。サーバ側が同じ上限(20 件・2,000 字)をもう一度持つのは、API は呼び手を信用しないという原則で、ブラウザの JS を書き換えた呼び手が 10,000 件を送っても 20 件しか残らないためです。⚠️ 上限は項目の性質ごとに置きます。件数と字数で縛れるログと違い、画像は 1 枚の大きさで縛るしかなく、クライアント側の圧縮率と縮尺が事実上の上限になります。サーバ側にも大きさの上限を別に持たせるのが、ログと同じ「呼び手を信用しない」の形です。
html2canvas は何を描き、何を描けないか
html2canvas の公式は「実際のスクリーンショットは撮らない。ページにある情報から screenshot を組み立てる」「各 CSS プロパティは手で実装されるので、完全な CSS 対応にはならない」と書いています(README と FAQ。2026-09-05 確認)。DOM と computed style を読んで canvas に描き直すので、ブラウザが描いたピクセルとは別物です。
本編を読み直して挙げた「YouTube プレイヤーが写らない」は html2canvas の制限というより、同一生成元ポリシーの帰結です。MDN は、cross-origin の iframe について contentDocument にアクセスできないと書いています(Same-origin policy。2026-09-05 確認)。DOM を読んで描く方式は、読めない DOM を描けません。YouTube の iframe は youtube.com の文書なので、typingtube のスクリプトからはその中身が見えず、html2canvas はそこを空のまま描きます。タイピング画面の不具合報告で、肝心の再生面が写らないのはこのためです。手元は .player-overlay.active があればそれを、無ければ document.body を対象にしていて、プレイヤーの上に重なるオーバーレイは同一生成元なので写ります。
もう 1 つ、cross-origin の画像です。他生成元の画像を canvas に描くと canvas は tainted になり toDataURL が投げます。手元は useCORS: true(CORS ヘッダのある画像は取り込む)・allowTaint: false(無ければ描かない)で、失敗は catch で null にして送らない形です。だから「スクリーンショット付き」で送られたのに画像が無い報告は、この経路で生まれます。
5 つの口と、置く場所
コンソールログの収集は app/javascript/lib/error_log.js で、2026-08-23 の本番障害 2(Turbo の 500 で押しても無反応)の後、08-24 に今の形になりました。障害のとき「エラーを添付する」を付けて送っても console_logs は空でした。Turbo は if (responseHTML) が falsy のとき黙って抜け、console.error を出さないからです。
| 口 | 拾うもの | 公式の根拠 | 落とすもの |
|---|---|---|---|
E1 window.addEventListener("error") | 同期の未捕捉例外(filename:lineno:colno 付き) | MDN: 「同期的に投げられたスクリプトのエラーだけ。Promise の拒否は unhandledrejection へ」 | capture を付けていないので、img / script のリソース読み込み失敗は入らない(バブルしないため) |
E2 unhandledrejection | Promise の未処理拒否(event.reason) | MDN: 「拒否ハンドラの無い Promise が拒否されたとき」 | preventDefault を呼んでいないので console にも出る(重複ではなく併記) |
E3 console.error の差し替え | アプリが自分で出したエラー | — | console.warn は拾わない |
E4 turbo:before-fetch-response / turbo:fetch-request-error | Turbo の失敗応答(fetchResponse.succeeded が false)とネットワーク失敗 | Turbo リファレンス: 前者の detail.fetchResponse、後者の detail.request と detail.error | 素の fetch の失敗は入らない |
| E5 置き場所 | Stimulus が起動する前の例外 | ES モジュールの import は巻き上げられて本文より先に評価される | application.js の最初の import に置く。関数呼び出しの形にすると全 import の後になる |
Turbo の 2 つのイベントは公式には「それを起こした要素(turbo-frame / form)で発火する」と書かれていて、document で受けられるのは bubbles で投げられるからです(手元のコメントは turbo 2.0.23 で確認)。E5 は付録 D が書いた「読み込み時点」の JS 版で、口を開く時刻が、口の広さより先に決まります。 feedback_controller#connect に置いていた旧実装は、Stimulus より前の例外を全部落としていました。
1 行の書式は ISO 8601 の時刻 [口の名前] 本文 で、時刻を入れるのはサーバのログと突き合わせるためです。Error は JSON.stringify すると {} になる(列挙可能な自前プロパティを持たない)ので先に判定して stack を取り、Symbol はテンプレートリテラルで投げるが String() は投げないので String() で文字列化します。これらは全部、scripts/test_js/error_log.test.mjs が固定しています。
中身と保持期間 —— 上限の外にある 2 本の線引き
上限(件数・字数・大きさ)は、器の入口の線引きです。器にはもう 2 本、線引きが要ります。中身と保持期間です。
画面から集めるものには、症状と関係のないものが混ざります。page_url のクエリ文字列、スクリーンショットに写る他の利用者の表示名や入力途中の文字列、ログに含まれる URL のパラメータ。集める側は「画面が知っていること」を機械的に取るので、何が混ざるかを集める時点で選べません。だから器を作るときに決めるのは、上限のほかに 3 つです。
- いつ消すか。集めた 1 件の保持期限を、集める瞬間に決めておく。報告は直すための材料で、直したら要らない
- 写ってはいけないものをどう扱うか。送る前に利用者が外せる(添付を任意にする = 本編の「チェック 2 つ」)のと、届いた後に人が目印を付けて隔離するのとで、2 段階になる
- 誰が見られるか。閲覧を管理画面に限り、画像はその場で描いて外へ配らない
本編の第 3 の注意「集める側を厚くするほど、出す側の線引きが要る」は、上限だけでなくこの 3 つを含みます。上限は機械が守れますが、中身と保持期間は、決めていなければ誰も守りません。
AI に貼ったとき
管理画面の 1 件を丸ごと AI に貼ると、画像はコンテキストに入ります。付録 A.8 が書いたとおり、画像は枚数と総サイズの上限を超えると古いものから一括で外れます。20 件のログは 1 件 2,000 字なら最大 40,000 字で、付録 A.5 の Bash 出力の上限(30,000 字)より大きい。貼るのは人なので切り詰めは起きませんが、1 件の報告がコンテキストの相当な部分を占めることは知っておく値です。
H.8 本番の自動報告経路 —— 誰も通らない判定
本編(第 2 部 第 6 回)の矢印 5 本を、機構に置き換えます。経路の URL・件数・時刻のような固有の値は本の理解に要らないので、形だけを書きます。同じ形は、外部のサービスに依存する資産(動画・画像・API の応答)を一覧に載せているどのサービスにも当てはまります。
利用者のブラウザ サーバ
YT IFrame API onError ── 動画が利用不可のコード ──▶ 報告の API(ログイン不要)
│ 送るのは 100 / 101 / 150 だけ │ 同じ動画 × 同じ報告者は一定時間に 1 件(重複の吸収)
│ 同じ動画は端末側でも一定時間に 1 回 │ 報告者ごとに一定時間の件数の上限(超えたら 429)
│ 送信の失敗は握りつぶす(利用者に何も見せない) │ 報告を 1 行書き、確認ジョブを 1 回だけ enqueue
▼
確認ジョブ(即時)
│ 直近に確認済みなら何もしない
│ YouTube Data API videos.list?part=status に聞く
│ 利用不可と分かれば → 一覧から外す(冪等)
│ 使用中のものは外さず、保留の印だけ
│ API の失敗 → 何もしない(記録だけ)
▼
再確認ジョブ(定期)
│ 外したものを古い順に聞き直し、戻せるなら戻す。無期限に続ける
報告の削除ジョブ(定期)
│ 古い報告を消す(報告は判定の材料で、記録ではない)
発火の条件。YouTube IFrame API の onError のコードは公式にこう定義されています。2 = パラメータ不正、5 = HTML5 プレイヤーのエラー、100 = 動画が見つからない(削除または非公開)、101 = 埋め込み再生が許可されていない、150 = 101 と同じ(2026-09-05 確認)。送るのは 100・101・150 だけで、2 は開発のバグ、5 は広告ブロッカーやネットワークで誤発火しやすいので除外します。送るのは「動画自体が利用不可」を強く示すコードだけで、判定はどのみち API がやるので、ここで絞るのはリクエスト数のためです。
重複の吸収。同じ動画を同じ人が何度も踏んでも、報告は増えません。端末側(localStorage)とサーバ側(報告者 × 動画)の両方で時間の窓を持ち、確認ジョブの enqueue はキャッシュの unless_exist で 1 回に絞ります。付録 H.7 の「サーバ側が同じ上限をもう一度持つ」と同じ理由で、ブラウザ側の窓は呼び手を書き換えれば消えるからです。
二次確認。videos.list の part=status が返す uploadStatus(deleted / failed / processed / rejected / uploaded)、privacyStatus(private / public / unlisted)、embeddable を読みます(YouTube Data API v3。2026-09-05 確認)。⚠️ 「items に含まれない id は削除された動画」は実装側の解釈で、公式の videos.list のページにその明文はありません。API の失敗(クォータ切れ・タイムアウト・資格情報の不備)を「何もしない」に寄せるのは、一時的な失敗で一覧から外さないためです。この寄せ方には非対称があります。外す側の誤りは再確認ジョブが戻しますが、外さない側の誤りは、誰かがもう一度踏むまで分かりません。 次の「陽性対照」はこの非対称から来ます。
保留。本編の「対戦やライブイベントで使用中の動画は自動で外さず保留する」は、その動画をいま参照している進行中のものがあるかを確認ジョブが見る形です。使用中に外すと参加者の全員が踏むので、外さない側に倒し、保留の目印を付けた動画は次の報告が来たときに再評価します。管理画面はこの件数を別に出します。
戻す。再確認ジョブは、外したものを確認の古い順(未確認を先頭に)に聞き直し、利用可能に戻っていれば一覧に戻します。人が手で固定したものは対象から外します。API の結果で勝手に戻さないためです。「無期限に続ける」は設計の明文で、YouTube の動画は数か月後に復活することもあるので、諦めて削除に格上げしません。
陽性対照
本編の検証手順 3 は「送信先を壊して報告が 0 件になることを見る。0 のとき『壊れていない』と『経路が切れている』は数字だけでは区別が付かない」でした。機構の側から言い直すと、この経路の 0 件には陽性対照が要ります。 hook の自動テスト(付録 C.9・F.6)が「正しい入力が通ること」を必ず併置するのと同じ理由で、本番の経路にも「壊れた動画を 1 本、意図的に用意して、報告が届き、判定が記録され、一覧から外れること」が併置されて初めて、0 件の意味が決まります。API の失敗を「何もしない」に倒した経路はなおさらで、資格情報や権限の設定が抜けていても、報告だけが積まれて同じ 0 件になります。管理画面の「外した数 / 戻した数」が 7 日間 0 のとき、それが平穏なのか経路の死なのかは、陽性対照が無ければ人が別の手で確かめることになります。検証手順 12 に、その形を書きます。
最初の 1 人が踏む理由
報告は再生を試みた後にしか届きません。YouTube 側の削除を先に知る手は、全動画を定期的に API に聞くことですが、動画の数だけ毎日クォータを使うことになり、他の同期と共有する枠がそれを許しません。だから最初の 1 人は必ずエラー画面を見ます。本編が「踏ませてよいのは、踏んだ被害より放置の被害が大きいときだけ」と書いた条件のうち、「戻すことが前提なので誤検出しても被害が小さい」は再確認ジョブの無期限が、「使用中は外さず保留」は参照中の判定が担っています。条件を書かずにこの形だけ真似すると事故るのは、条件がそれぞれ別のコードに散っていて、1 つ欠けても残りは動くからです。
主観の側
「歌詞の間違い」の報告には、スクリーンショットもコンソールログも付けません。機械が真偽を決められない報告に、機械が集めた材料を付けても判定は変わらないからで、こちらは状態を人が動かす一覧に溜まります。
H.9 本編との対応
- H.1・H.2・H.3・H.6 は、本書の章に対応するものがありません。撮影前の宣言(Q1 / Q2)と安い順のはしご、宣言ファイルを実行の前提にする hook、実行していない領域の一覧を毎回出す形 —— いずれも次の連載で扱う仕組みで、ここには機構だけを置いてあります。
- 第 2 部 第 6 回(確認依頼を断る)のスモークの 3 つの部分と共有シードを DB で判定する型は H.4、flaky は H.5、本番の自動報告経路と陽性対照は H.8。
- 第 3 部 第 3 回(不具合詳細を書かない)の「誰が知っているか」、html2canvas と同一生成元、5 つの口、中身と保持期間は H.7。
H.10 本編が言っていない注意
- 宣言 hook はスモークの実際の入口(
./scripts/smoke_*.sh)を拾いません(H.3。2026-09-05 実測 exit 0)。本編 T4 の宣言の対象は「撮影・スモーク・E2E」ですが、hook が構造に移したのは撮影だけです。 - 記録の 21 判定に「宣言なし」は 0 件で、止めた 6 件は全部偽陽性、うち 2 件は宣言を書くコマンド自身です(H.3)。記録は偽陽性を「使い回し」として記録するので、偽陽性の率は記録から読めません。
- 撮影は検証対象と無関係な理由で失敗になります(H.2)。module script が
DOMContentLoadedをブロックし、dev の importmap で既定 30 秒を超える。curlが返るならSCREENSHOT_TIMEOUT_MSを疑う。 - スモークには本番に無い細工が 4 つあります(H.2)。TCP プロキシで
localhostに見せる、--disable-web-security、Private Network Access を切る、insecure origin を secure 扱いにする。壊れると本番と関係のない失敗になります。 - 合計の一致は 0 件でも真です(H.4)。
race_win_total_matchesだけを見るスモークは先取が全く動かなくても合格で、race_win_total_positiveが下限を固定しています。 - 手元の E2E にブラウザは 1 本も動きません(H.6)。「重い Minitest」の別名で、スモークとは別のものです。実装の変更から領域を導く機構は無く、pre-commit は E2E のテストファイル自身をステージしたときだけ止めます。
- YouTube プレイヤーは写りません(H.7)。同一生成元ポリシーで cross-origin の iframe の文書が読めないため、DOM から描く html2canvas には描くものがありません。
- 本番の経路には陽性対照が要ります(H.8)。API の失敗を「何もしない」に倒した経路は、資格情報の抜けでも同じ 0 件になり、0 件が平穏か経路の死かは数字だけでは決まりません。
H.11 検証手順
止まるべきものが止まり、通るべきものが通ることを、はしごの 1 つごとに 1 度ずつ見ます。
1. はしごを 1 つ上る。 ERB の data-*-target の属性名を 1 文字変えて、scripts/rails_test.sh test/controllers/<該当>_test.rb と scripts/js_test.sh が両方とも合格のままであることを見ます(単体テストは属性名の一致を見ていない)。宣言(Q1 = 属性名のずれで Stimulus の target が外れるか / Q2 = ERB の出力と JS の読みは別プロセスで検証されるため)を書き、bash scripts/screenshot.sh <そのページ> の _console.log に target の未解決が出るか、画面が壊れていることを見ます。戻して合格。
2. waitUntil の 3 つの待ち。 screenshot.js の waitReady の networkidle の timeout: 5000 を一時的に外し、WebSocket を張るページ(/vocabulary_battle など)を撮ります。30 秒で Timeout になることを見ます。戻して撮れることを見ます。次に SCREENSHOT_TIMEOUT_MS を付けずに importmap の重いページを撮り、curl が 200 を返すのに page.goto: Timeout 30000ms exceeded が出るかを見ます(環境依存。出なければ dev が軽いだけ)。
3. requestfailed は 404 を含まない。 存在しないパス(/no_such_page)を撮り、_console.log に [response 404] が出て [requestfailed] が出ないことを見ます。
4. 宣言 hook の入口。 付録 C.9 の形で入力 JSON を偽造し、CLAUDE_PROJECT_DIR を空のディレクトリに向けて、H.3 の表の 5 つのコマンドを流します。./scripts/smoke_vocabulary_battle.sh が exit 0(素通り)、node scripts/playwright/smoke_vocabulary_battle.js が exit 2 であることを見ます。
probe=$(mktemp -d); mkdir -p "$probe/tmp"
export CLAUDE_PROJECT_DIR="$probe"
for c in './scripts/smoke_vocabulary_battle.sh' 'node scripts/playwright/smoke_vocabulary_battle.js'; do
printf '{"tool_input":{"command":"%s"}}' "$c" \
| bash .claude/hooks/require_visual_verification_declaration.sh >/dev/null 2>&1
echo "exit=$? : $c"
done
cut -f2 "$probe/tmp/visual_guard_log.tsv" # blocked:宣言なし が 1 行(node の方だけ)
計測器を置いたら、記録を判定ごとに数え、止めた行のうち偽陽性を人が分けます。偽陽性が「理由」ではなく「宣言の状態」で記録されていることが、そこで見えます。
5. 宣言を書くコマンドが止まる。 宣言ファイルを消し、Q2 の文中に撮影コマンドの名前を含むヒアドキュメントで宣言を書こうとして、hook が止めることを見ます(偽陽性)。Write ツールで同じ内容を書くと通ることを見ます。
6. スモークの 3 つの部分の境界。 smoke_vocabulary_battle.sh の grep -oE 'deck=#[0-9]+' の # を消して走らせ、setup ログから deck_id を抽出できませんでした で止まることを見ます。戻します。次に dev_smoke.rake の entry_order_match=#{orders.uniq.size == 1} を != 1 に変えて走らせ、Smoke FAILED (playwright=0 verify=1) になることを見ます(本編の検証手順 1 と同じ)。戻して Smoke PASSED。
7. 共有シードを見る 2 箇所。 test/fixtures/files/vocabulary_battle_shuffle_vectors.json の 1 つの output の並びを 2 語入れ替え、scripts/rails_test.sh test/services/vocabulary_battle_entry_order_test.rb と scripts/js_test.sh が両方失敗になることを見ます(Ruby と JS が同じベクタを見ている)。戻します。次に app/javascript/battle/vocabulary_shuffle.js の mulberry32 の定数を 1 つ変えて、単体テストの JS が失敗・Ruby が合格、そしてスモークの server_order_match=false・entry_order_match=true(2 人は同じ間違った列を打つ)になることを見ます。この差が「列の計算」と「配線」の違いです。戻します。
8. 空虚な真。 dev_smoke.rake の race_win_total_positive の行をコメントアウトし、--race のスモークで先取が 1 語も成立しない状況(P1 の _typeOneEntry を呼ばない)を作って、race_win_total_matches=true のまま合格になることを見ます。行を戻して失敗。⚠️ 時間がかかるので、代わりに race_lines を wins=0, unclaimed=total の記録で単体テストから呼ぶ形でも同じことが見えます。
9. E2E の領域。 領域名を付けた重いテストが素の全量でスキップされ、E2E=<領域名> で走り、要約の「実行していません」一覧に載る・消えることを見ます(本編 T3 の 3 点)。次に E2E=all が落ちること、複数の領域に一致する指定が曖昧で落ちることを見ます。
10. 5 つの口。 dev のブラウザで不具合フォームを開く前に、コンソールで次を 1 つずつ打ち、フォームの「エラーを添付する」で送った console_logs に 5 行が入ることを管理画面で見ます。
setTimeout(() => { throw new Error("E1 probe") }, 0) // uncaught
Promise.reject(new Error("E2 probe")) // unhandledrejection
console.error("E3 probe", { a: 1 }) // console.error({} にならず {"a":1})
fetch("/no_such_page") // 入らない(Turbo を通らない素の fetch)
4 つ目が入らないことが、E4 の口の広さの限界です。Turbo の遷移で 404 を踏むと [turbo] 404 <url> が入ります。
11. cross-origin の iframe。 タイピング画面で不具合フォームを開き、プレビューの画像で YouTube プレイヤーの領域が空であることを見ます。同じ画面で .player-overlay.active が出ている状態(一時停止など)で開くと、オーバーレイの中身は写ることを見ます。
12. 本番の経路の陽性対照。 本番と同じ構成の検証環境で、削除済みと分かっている動画を 1 本一覧に入れ、その再生画面を開いて onError の 100 を出させ、報告が 1 件届き、二次確認の結果が「削除」と記録され、一覧から外れることを順に見ます。次に API の資格情報を一時的に外して同じことをし、報告は 1 件増えるが判定が「API の失敗」で止まり、何も外れず、ログに警告が 1 行出ることを見ます。この 2 つの差が、「0 件」の 2 つの意味です。 戻します。
H.12 出典(2026-09-05 確認)
- Playwright
Pageclass ——page.gotoのwaitUntil(load既定 /domcontentloaded/networkidle= 500 ms 接続なし・DISCOURAGED /commit)、page.screenshotのfullPage、pageerror・console・requestfailed(「404 のような HTTP エラー応答は request failure ではない。responseで完了する」) - Playwright Docker —— イメージの形式
mcr.microsoft.com/playwright:vX.Y.Z-<distro>、--ipc=host(「無いと Chromium がメモリ不足で落ちる」)、--init(PID 1 とゾンビ)、版の固定の推奨。手元はv1.59.0-jammy、公式の例示はv1.63.0-noble - Playwright Emulation ——
isMobile(meta viewport の考慮とタッチイベント)、viewport、deviceScaleFactor - docker container run ——
--add-hostのhost-gateway(ホストの内部 IP に解決)、--ipc=host、--shm-size - Hooks reference ——
PreToolUseの exit 2 は tool call を止め stderr を Claude に返す、tool_input.command。付録 C.3 と同じ - MDN Window: error event —— 「同期的に投げられたスクリプトのエラーだけ。Promise の拒否は
unhandledrejectionへ」、ErrorEventのfilename/lineno/colno/error - MDN Window: unhandledrejection event —— 「拒否ハンドラの無い Promise が拒否されたとき」、
PromiseRejectionEvent.reason、preventDefaultで console 出力を抑止 - MDN Same-origin policy —— cross-origin の読み取りは原則不可、cross-origin の iframe の
contentDocumentにはアクセスできない - MDN Secure contexts ——
http://localhost/http://127.0.0.1/http://*.localhostは「ブラウザと同じ装置にあるので安全に届いたとみなせる」。window.isSecureContext - Turbo Reference: Events ——
turbo:before-fetch-response(detail.fetchResponse)、turbo:fetch-request-error(detail.request/detail.error)、発火するのはそれを起こした要素 - html2canvas README / FAQ —— 「実際のスクリーンショットは撮らず、ページにある情報から組み立てる」「ブラウザのコンテンツポリシーを魔法のように回避はしない。cross-origin はプロキシが要る」「各 CSS プロパティは手で実装されるので完全な CSS 対応にはならない」「他生成元の画像は canvas を taint する」。⚠️ iframe についての明文は 2026-09-05 に取得した README / FAQ に無く、H.7 は MDN の同一生成元ポリシーから導いた
- YouTube IFrame Player API ——
onErrorの 2 / 5 / 100 / 101 / 150 の定義 - YouTube Data API v3: Videos ——
status.uploadStatus(deleted / failed / processed / rejected / uploaded)、status.privacyStatus(private / public / unlisted)、status.embeddable。⚠️ 「itemsに無い id は削除」の明文は無い - Bender & Manshreck, "Testing Overview", 『Software Engineering at Google』(O'Reilly、abseil.io の HTML 版)—— small(1 プロセス・sleep も I/O も blocking も無し)/ medium(複数プロセス・
localhostへのネットワーク)/ large(複数マシン)、80 / 15 / 5、「0.1% × 10,000 本 = 毎日 10 件」「1% に近づくと価値を失う」「Google は約 0.15%」 - Fowler, "Eradicating Non-Determinism in Tests"(2011-04-14) —— 隔離の欠如 / 非同期(「bare sleep を使うな。コールバックか polling を」)/ 遠隔サービス / 時刻(「システムクロックを必ずラップせよ」)/ 資源リーク、隔離(quarantine)
- Microsoft Learn, "Use Test Impact Analysis"(2018-12-07) —— 依存関係の地図から影響されるテストを選ぶ、HTML / CSS の変更では全テストへフォールバック、「設定した周期で全テストを走らせる」、T1(選択)と T2(全量)を続けて走らせて検証
- 手元の実測(2026-09-05)—— 宣言 hook(判定の 4 条件・入口の正規表現・記録 21 判定)と偽造入力 5 本の exit / 撮影とスモークのスクリプト(Playwright
v1.59.0-jammy・TCP プロキシ・--init --ipc=host)とdev_smoke.rakeの verify / 共有シード(Mulberry32)の Ruby と JS のベクタ / E2E のガード(23 領域・30 分の窓・記録 3 つ)と pre-commit の照合 / 不具合フォーム(html2canvas・error_log.js20 件 / 2,000 字)/ 自動報告経路(100 / 101 / 150・重複の吸収・videos.list・再確認と削除の定期ジョブ)。それぞれ本文で名指ししたファイル