Read OSS

ページの内側:セレクター、注入スクリプト、DOM 操作

上級

前提知識

  • 第1〜3回:アーキテクチャ、プロトコル、ブラウザ抽象化
  • DOM と CSS セレクターの知識
  • ブラウザ実行コンテキストの理解

ページの内側:セレクター、注入スクリプト、DOM 操作

これまで、API 呼び出しからプロトコルメッセージ、サーバーサイドのブラウザ抽象化へと至る流れを追ってきました。しかし、サーバーが要素を見つけたり、表示状態を確認したり、クリックを実行したりする際、それを Node.js 側から直接行うことはできません。コードをブラウザのページ内で実行する必要があります。この記事では、Playwright の注入スクリプトアーキテクチャの仕組み、セレクターエンジンシステムがクエリを評価する方法、Locator がクライアントサイドでセレクターを遅延的に組み立てる方法、そして自動待機が Playwright テストを安定させる仕組みを掘り下げていきます。

注入スクリプトのアーキテクチャ

Playwright は packages/injected/ という独立したパッケージを持っており、ここにはブラウザのページコンテキスト内で実行するために設計されたコードが含まれています。メインのエントリーポイントは InjectedScript で、packages/injected/src/injectedScript.ts#L1-L60 に定義されています。

このコードはビルド時に個別にコンパイルされ、サーバー側に文字列としてバンドルされます(packages/playwright-core/src/generated/injectedScriptSource 経由)。サーバーがページと対話する必要があるとき、このスクリプトをページの JavaScript コンテキストで評価し、InjectedScript インスタンスを生成します。このインスタンスが Playwright のページ内エージェントとして機能します。

InjectedScript クラスが担う処理は次のとおりです。

  • セレクター評価(セレクターに一致する要素の検索)
  • 要素の状態確認(visible、enabled、stable など)
  • ARIA スナップショットの生成(アクセシビリティテスト用)
  • ヒットターゲットの検出(クリック対象となる要素の特定)
  • セレクターの生成(レコーダー/codegen 用)
flowchart TD
    subgraph "Node.js Process"
        S["Server (frames.ts)"]
        B["Bundled injectedScriptSource"]
    end
    
    subgraph "Browser Page"
        UC["Utility Context"]
        MC["Main Context"]
        IS["InjectedScript Instance"]
    end
    
    S -->|"evaluate(injectedScriptSource)"| UC
    B -->|"compiled at build time"| S
    UC --> IS
    IS -->|"querySelector"| DOM["DOM Tree"]
    IS -->|"state checks"| DOM
    IS -->|"ARIA tree"| DOM

ここで重要なのが、utility world による分離です。Playwright は InjectedScript を、ページの JavaScript 名前空間とは切り離された独立した JavaScript コンテキスト(「utility」ワールド)で評価します。このコンテキストは DOM を共有しつつも、ページ側のスクリプトから干渉を受けません。つまり、ページが document.querySelector をオーバーライドしていても、Playwright のセレクター評価には一切影響しないということです。

ヒント: utility world による分離があるからこそ、Playwright は組み込みプロトタイプを改変するページや DOM をラップするフレームワークを使ったページでも確実に動作できます。ページが Element.prototype.click を再定義していても、テストが壊れることはありません。

セレクターエンジンシステム

Playwright は豊富なセレクターエンジンをサポートしています。サーバーサイドの Selectors クラスは packages/playwright-core/src/server/selectors.ts#L23-L56 に定義されており、組み込みエンジンとカスタムエンジンの両方を管理しています。

this._builtinEngines = new Set([
  'css', 'css:light',
  'xpath', 'xpath:light',
  '_react', '_vue',
  'text', 'text:light',
  'id', 'id:light',
  'role', 'internal:attr', 'internal:label', 'internal:text',
  'internal:role', 'internal:testid',
  'internal:has', 'internal:has-not',
  'internal:has-text', 'internal:has-not-text',
  'internal:and', 'internal:or', 'internal:chain',
  'nth', 'visible', 'internal:control',
  // ...
]);

エンジンはいくつかのカテゴリに分類されます。

カテゴリ エンジン 用途
CSS css, css:light 標準 CSS セレクター
XPath xpath, xpath:light XPath セレクター
テキスト text, internal:text, internal:has-text テキスト内容のマッチング
ロール role, internal:role ARIA ロールベースのセレクター
テスト ID data-testid, internal:testid テスト用属性セレクター
フレームワーク _react, _vue フレームワークコンポーネントのセレクター
コンビネーター internal:has, internal:and, internal:or セレクターの合成

:light サフィックスは「light DOM のみ」を意味し、デフォルトで有効になっている Shadow DOM のトラバーサルを除外します。

セレクターは >> 構文を使ってチェーンできます。たとえば次のように記述します。

role=button >> text=Submit

これは「button ロールの要素を探し、その中から 'Submit' というテキストを持つ要素を探す」という意味です。各セグメントはそれぞれのエンジンで評価され、>> 演算子がそれらを順番に合成します。

flowchart LR
    A["'role=button >> text=Submit'"] --> B["Parse selector"]
    B --> C["Part 1: role=button"]
    B --> D["Part 2: text=Submit"]
    C --> E["Role engine evaluates<br/>→ finds buttons"]
    E --> F["Scope narrows"]
    F --> D
    D --> G["Text engine evaluates<br/>within button scope"]
    G --> H["Final element"]

Locator:遅延クライアントサイド合成

packages/playwright-core/src/client/locator.ts#L40-L73 に定義された Locator クラスは、Playwright の最も重要な抽象化の一つです。サーバーサイドの参照を返す古い API(ElementHandle)とは異なり、Locator は純粋なクライアントサイドオブジェクトです。セレクター文字列を保持し、アクションが実行されるたびに要素を新たに解決します。

export class Locator implements api.Locator {
  _frame: Frame;
  _selector: string;

  constructor(frame: Frame, selector: string, options?: LocatorOptions) {
    this._frame = frame;
    this._selector = selector;

    if (options?.hasText)
      this._selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
    if (options?.hasNotText)
      this._selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`;
    if (options?.has)
      this._selector += ` >> internal:has=` + JSON.stringify(options.has._selector);
    if (options?.hasNot)
      this._selector += ` >> internal:has-not=` + JSON.stringify(options.hasNot._selector);
    if (options?.visible !== undefined)
      this._selector += ` >> visible=${options.visible ? 'true' : 'false'}`;
  }

フィルタリングメソッド(hashasTexthasNotvisible)はいずれも、>> チェーン構文を使ってセレクター文字列に追記するだけです。clickfilltextContent などのアクションが呼ばれるまで、ブラウザとの通信は一切発生しません。

この設計には大きな利点があります。

  1. 古くなった参照が発生しない — 要素はアクションのたびに新しく解決される
  2. 合成しやすいlocator.filter().locator().nth() のように連鎖させることで複合セレクター文字列を構築できる
  3. 自動待機 — アクション呼び出し時に、セレクターがマッチするまで Playwright が自動的に待機する
classDiagram
    class Locator {
        +_frame: Frame
        +_selector: string
        +click()
        +fill()
        +locator(): Locator
        +filter(): Locator
        +nth(): Locator
        +first(): Locator
        +last(): Locator
    }
    
    note for Locator "No server-side state!<br/>Just _frame + _selector string.<br/>Browser round-trip only on actions."

ヒント: page.$()ElementHandle よりも Locator を使いましょう。Locator はサーバーサイドの参照を保持しないため、「object collected」エラーが発生せず、動的なコンテンツにも自然に対応できます。

Frame の実行とアクションフロー

Frame は、サーバーサイドにおけるすべての DOM 操作の実行境界です。packages/playwright-core/src/server/frames.ts に定義されており、約 1,800 行にわたるコアファイルの中でも最大のファイルです。

各 Frame は 2 つの実行コンテキストを持ちます。

  • Main world:ページ自身の JavaScript コンテキスト。page.evaluate() の呼び出しや _react/_vue セレクターで使用されます。
  • Utility world:Playwright 専用の分離されたコンテキスト。セレクターの評価や状態確認に使用されます。

packages/playwright-core/src/server/dom.ts#L48-L57FrameExecutionContext クラスが、これら 2 つのワールドを橋渡しします。

export class FrameExecutionContext extends js.ExecutionContext {
  readonly frame: frames.Frame;
  readonly world: types.World | null;

locator.click() の呼び出しが完了するまでの流れを追ってみましょう。

sequenceDiagram
    participant User as locator.click()
    participant Client as Client Frame
    participant Proto as Protocol
    participant Server as Server Frame
    participant IS as InjectedScript
    participant PD as PageDelegate

    User->>Client: click()
    Client->>Proto: {method: "click", params: {selector}}
    Proto->>Server: Frame.click()
    
    loop Retry until timeout
        Server->>IS: querySelector(selector)
        IS-->>Server: elementHandle or null
        alt Element found
            Server->>IS: checkActionability()
            IS-->>Server: visible? stable? enabled?
            alt Actionable
                Server->>PD: rawMouse.click(x, y)
                PD-->>Server: done
                Server-->>Proto: success
            else Not actionable
                Note over Server: Wait and retry
            end
        else Not found
            Note over Server: Wait and retry
        end
    end

自動待機とリトライロジック

Playwright の自動待機は、従来の自動化ツールよりも根本的に安定性が高い理由の核心です。要素が準備できていないときに即座に失敗するのではなく、タイムアウトが切れるまでループでオペレーション全体をリトライし続けます。

リトライロジックはサーバー側の Frame クラスに実装されています。click() などのアクションメソッドでは、サーバーは次の手順を踏みます。

  1. セレクターを解決して要素を見つける
  2. アクション可能性を確認する:要素は visible か、enabled か、stable(アニメーション中でないか)か、別の要素に遮られていないか?
  3. アクションを実行する
  4. いずれかのステップが回復可能なエラーで失敗した場合、少し待機してからステップ 1 に戻る

この処理は第 3 回で解説した ProgressController と連携しています。ProgressController が全体のタイムアウトを管理し、フレームレベルのリトライループがそのウィンドウ内でオペレーションを繰り返し試みます。

ブラウザ側の InjectedScript は、アクション可能性チェックを効率的に処理します。クリック操作では次の項目を確認します。

チェック項目 意味
visible バウンディングボックスがゼロより大きく、visibility: hidden でない
stable 2 つのアニメーションフレーム間で要素の位置が変化していない
enabled 無効化されたフォーム要素でない
receives events 対象座標のクリックを横取りする要素が存在しない
flowchart TD
    A["Start: click(selector)"] --> B["Resolve selector"]
    B --> C{"Element found?"}
    C -->|No| D["Wait for DOM change"]
    D --> B
    C -->|Yes| E["Check visible"]
    E --> F{"Visible?"}
    F -->|No| D
    F -->|Yes| G["Check stable"]
    G --> H{"Stable?"}
    H -->|No| I["Wait for RAF"]
    I --> G
    H -->|Yes| J["Check enabled"]
    J --> K{"Enabled?"}
    K -->|No| D
    K -->|Yes| L["Scroll into view"]
    L --> M["Check hit target"]
    M --> N{"Receives events?"}
    N -->|No| D
    N -->|Yes| O["Perform click"]
    O --> P["Success ✓"]
    
    D --> Q{"Timeout?"}
    Q -->|Yes| R["Throw TimeoutError"]

packages/playwright-core/src/server/dom.ts#L39PerformActionResult 型を見ると、回復可能なエラー状態の全容がわかります。

type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 
  'error:notinviewport' | 'error:optionsnotfound' | 'error:optionnotenabled' | 
  { missingState: ElementState } | { hitTargetDescription: string } | 'done';

'done' 以外はすべてリトライのトリガーになります。NonRecoverableDOMError クラスは、入力要素でない要素に fill を試みるような、リトライすべきでないエラーを表します。

ヒント: クリックでタイムアウトが発生した場合、Playwright のエラーメッセージには「Call log」が含まれており、どのアクション可能性チェックで失敗していたかを正確に確認できます。「waiting for element to be visible」や「element is not stable」といったメッセージを手がかりに問題を診断しましょう。

次回予告

ユーザー向け API からプロトコル、サーバーサイドの抽象化、そしてブラウザのページ内部まで、Playwright の全体像を一通り追い終えました。次回は Playwright のテストランナーに焦点を当てます。マルチプロセスアーキテクチャ、フィクスチャシステム、タスクパイプライン、そして test.extend() が合成可能なテスト設定をどのように構築するかを解説します。