コンテンツへスキップ

同じセレクタが WordPress 管理画面の上下に出ていた話 — Playwright の strict mode と「.first」を癖にする運用

SSH が使えないサイトでメンテナンスを回す経路として、Playwright で WordPress 管理画面をブラウザ自動操作する仕組みを持っています。プラグイン更新もテーマ更新も翻訳更新も、それぞれ問題なく動いていた ── けれど 本体(Core)更新だけが、ある日からエラーで止まるようになりました。

エラーは Locator.click: strict mode violation。Playwright 経験者なら見たことがあるはずの、locator が複数要素にマッチした時に出るやつです。原因を辿ると、WordPress 管理画面の構造的な特徴がそのまま罠になっていた話に着地しました。今回はこのバグを、Playwright の strict mode と .first を癖にする運用の観点でまとめておきます。

strict mode 違反は「セレクタが優しすぎる」のサイン

Playwright の locator は既定で strict モードで動きます。page.locator('input[name="upgrade"]').click() のようなコードが、もしページ上に該当要素が 2 つ以上あると、こんなエラーで死にます。

Locator.click: strict mode violation:
locator('input[name="upgrade"]') resolved to 2 elements:
  1) <input name="upgrade" value="今すぐ更新" ...>  (上部のボタン)
  2) <input name="upgrade" value="今すぐ更新" ...>  (下部のボタン)

Playwright がこういう設計になっているのは合理的で、「クリックしたい要素が 1 つに絞れていないのは、コードがバグの素」だからです。複数マッチを許す挙動だと、後から DOM が変わって意図しない要素を押す事故が静かに起こる。strict mode は「今のセレクタは曖昧だぞ」と早めに教えてくれる安全装置です。

WordPress 管理画面が同一セレクタを 2 箇所に描画する

問題は、WordPress の update-core.php(本体・プラグイン・テーマ・翻訳をまとめて更新する画面)が ページ上部と下部に同じ更新ボタンを 2 つ並べる UX 設計になっていることでした。具体的にはこういう DOM:

<!-- ページ上部 -->
<form action="update-core.php?action=do-core-upgrade" method="post">
  <input name="upgrade" type="submit" value="今すぐ更新">
</form>

<!-- ...各種チェックリスト... -->

<!-- ページ下部(同じセレクタの別 input) -->
<form action="update-core.php?action=do-core-upgrade" method="post">
  <input name="upgrade" type="submit" value="今すぐ更新">
</form>

長いリストの管理画面で「上にも下にも更新ボタンがある」のは UX として親切ですが、自動操作する側から見ると 同じ form 構造を持つ要素が 2 つマッチする

core_update_btn = page.locator(
    'form[action*="do-core-upgrade"] input[name="upgrade"]'
)
core_update_btn.click()   # ← strict mode violation

WordPress 開発者は「これを自動操作する人がいる」前提で書いているわけではないので、これ自体は責められません。けれど自動操作する側は、WordPress 管理画面では同じセレクタが上下に重複して出る可能性を常に頭に置く必要があります。

修正は .first を付けるだけ

Playwright で「複数マッチしても先頭でいい」と明示するには .first を付けます。

# 修正前: strict mode violation
core_update_btn.click()

# 修正後: 先頭の要素(上部のボタン)を明示クリック
core_update_btn.first.click()

たった 1 行の追加で直りますが、この 1 行を書く判断には設計上の意味があります。「先頭を取る = 上部のボタンを使う」と固定することで、将来 WordPress が 3 番目のボタンを下部に追加しても挙動は変わりません。後方互換性込みの選択です。

WordPress 管理画面で上下のボタンが機能的に同一であることは保証されているので、どちらを押しても結果は同じ。にもかかわらず .first で固定するのは「毎回同じ要素を押す」という再現性のためです。

プラグイン・テーマ・翻訳ではもう .first を使っていた

掘ってみると、プラグイン更新・テーマ更新・翻訳更新の経路では既に .first が適用済みでした。update-core.php の上下重複問題はとっくに知られていて、対応されていた。

run_browser_update_flow() 関数の中で、Core 更新のロジックだけが 昔のままで取り残されていたわけです。リファクタや機能追加の過程で「プラグインは .first 化」「テーマも .first 化」と入った時、Core にも同じ修正を入れるべきところを忘れていた、典型的な「横展開し忘れ」のパターン。

このパターンの教訓は、V12 で書いた csh 横断バグV40 の PHP Deprecated ノイズ 3 段防御 と同じです。「ある関数で .first を入れる時、他の同様のクリック箇所も同じ修正が必要ないか」を grep で確認する。今回は事前にそれをやらずに済ませて、後で踏みました。

回帰テストで「素の .click() 」を機械検出する

直し方の選択肢には 2 つあって、ひとつは「Core 更新だけ .first を足す」、もうひとつは「update-core.php 系の click は全部 .first 経由でなければ失敗するテストを入れる」。

採用したのは後者です。tests/test_browser_core_update_strict_mode.py を新規追加して、browser_utils.py の AST を走査し、update-core.php 関連の locator に対する素の .click() を検出したら CI で落ちる仕組みにしました(概念図)。

def test_core_update_locator_uses_first():
    """update-core.php 系の locator が .click() を直接呼んでいないか検証。
    Playwright strict mode 違反の再発を機械的に防ぐ。"""
    tree = ast.parse(BROWSER_UTILS_PY.read_text())
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        # core_update_btn.click() のような直接呼び出しを検出
        if (isinstance(node.func, ast.Attribute)
                and node.func.attr == 'click'
                and is_update_core_locator(node.func.value)):
            assert is_first_chained(node.func.value), \
                f"strict mode 違反のリスク: line {node.lineno}"

ポイントは、テストが「ボタンを押せる」という機能ではなく、「.first という防御が外れていない」という構造をチェックすることです。Playwright のページモックを書いて統合テストするより、AST で文法レベルを検証する方がずっと軽くて早い。同じ AST テストパターンは paramiko の look_for_keys を守るテスト でも使いました。

学び — 「WordPress 管理画面の自動操作」で覚えておく癖

このラウンドから取り出せる原則は次の 3 つでした。

  1. WordPress 管理画面のクリック対象は、まず .first を癖にする — 上下に同じ要素が出る UX は WordPress 全般で頻出する(更新画面・一括操作のドロップダウン・モーダルの「保存」と「閉じる」など)。最初から .first を付けるテンプレートで書く方が、後で strict mode 違反を踏むより早い
  2. 「ある場所に修正を入れたら、同じパターンが他にないか」を必ず grep する — Core 更新だけ .first 化が取り残された原因は、過去に他の更新フローを直した時に横展開しなかったこと。1 箇所修正の度に類似コードを grep で洗い出すのを習慣化する
  3. 回帰防止は AST 静的解析で「禁じ手の混入」を検出する — Playwright のモックを書いてランタイムで再現するより、AST で文法レベルの規律を機械検証する方が軽くて速い。.first が外れている click を CI で落とすテンプレートは、再発防止の最終ラインとして信頼できる

ブラウザ自動操作で WordPress を扱うコードを書いている方は、「同じセレクタが上下に出ていないか」を疑ってかかるのと、最初から .first を癖にする運用が、長期的にはハマる時間を減らしてくれると思います。