Read OSS

Playwrightのアーキテクチャ:モノレポ構造を読み解く

中級

前提知識

  • TypeScriptの基礎知識
  • npm workspacesの概念への理解
  • ブラウザ自動化の基本的な理解

Playwrightのアーキテクチャ:モノレポ構造を読み解く

PlaywrightはChromium、Firefox、WebKitに対応したクロスブラウザのE2Eテスト・自動化フレームワークです。しかし、おなじみのpage.click() APIの裏側には、驚くほど精巧なクライアント・サーバーアーキテクチャが隠されています。複数の言語バインディング、リモート実行、そして関心の明確な分離を当初から設計に組み込んだ構造です。本記事では、モノレポの構成、クライアント・サーバー分割の基本設計、境界強制の仕組み、さらに全体をつなぐエントリーポイントについて概観します。

モノレポの構造とパッケージの役割

Playwrightのリポジトリはnpm workspaceとして構成されており、packages/配下におよそ22のパッケージが含まれています。ルートのpackage.jsonでworkspaceが定義されています:

package.json#L1-L5

主要なパッケージの関係を以下の表にまとめます:

Package npm Name 役割
playwright-core playwright-core コアライブラリ:client、server、protocol、CLIを含む
playwright playwright ブラウザダウンロード + テストfixture + テストランナー
playwright-test @playwright/test playwrightを再エクスポートする薄いCLIラッパー
protocol (内部) YAMLプロトコル定義 + 生成された型
injected (内部) ブラウザのページコンテキスト内で実行されるスクリプト
recorder (内部) レコーダーUIウェブアプリ
trace-viewer (内部) トレースビューアーウェブアプリ
html-reporter (内部) HTMLレポーターウェブアプリ
web (内部) 共有Webユーティリティ
playwright-browser-* playwright-browser-chromiumなど ブラウザ個別のダウンロードパッケージ

最も重要なパッケージはplaywright-coreです。クライアント側の公開APIと、サーバー側のブラウザ制御ロジックの両方を含んでいます。playwrightパッケージはその上にテストfixture、テストランナー、そしてブラウザダウンロード管理を重ねる形になっています。

graph TD
    AT["@playwright/test<br/>(CLI wrapper)"] --> PW["playwright<br/>(test runner + fixtures)"]
    PW --> PC["playwright-core<br/>(core library)"]
    PC --> PROTO["protocol<br/>(YAML + generated types)"]
    PC --> INJ["injected<br/>(browser-side scripts)"]
    PW --> REC["recorder<br/>(UI)"]
    PW --> TV["trace-viewer<br/>(UI)"]
    PW --> HR["html-reporter<br/>(UI)"]

Tip: npm install playwrightを実行すると、テストランナーやブラウザダウンロード機能を含むフルパッケージが手に入ります。テストランナーが不要で自動化APIだけ使いたい場合は、playwright-coreをインストールしましょう。

クライアント・サーバー分割

Playwrightの設計でもっとも重要な決断が、クライアント・サーバーアーキテクチャの採用です。単一のNode.jsスクリプトからPlaywrightを実行する場合でも、内部ではプロトコルを介して通信する2つの独立したレイヤーが存在しています:

  • Client (packages/playwright-core/src/client/):ユーザーが直接触れる公開API層です。BrowserPageLocatorなどのオブジェクトがここに属し、メソッド呼び出しをプロトコルメッセージにシリアライズする軽量なオブジェクトとして実装されています。
  • Server (packages/playwright-core/src/server/):実際のブラウザ制御ロジックを担う層です。プロセスの起動、CDP/WebSocket通信、DOMの操作、ネットワーク処理などを担当します。

なぜこの分割が必要なのでしょうか。PlaywrightはJavaScript/TypeScript、Python、Java、C#という4つの言語バインディングをサポートしているからです。JavaScript以外のバインディングはサーバーを子プロセスとして起動し、stdin/stdoutを介して通信します。プロトコル境界を明確に定めることで、すべての言語で完全に同じサーバー実装を共有できる仕組みになっています。

sequenceDiagram
    participant JS as Node.js Client
    participant Server as Playwright Server
    participant Browser as Browser Process

    JS->>Server: page.click('.button')
    Note over JS,Server: Protocol message<br/>{guid, method, params}
    Server->>Browser: CDP/Custom Protocol
    Browser-->>Server: Result
    Server-->>JS: Protocol response

クライアント側のルートオブジェクトはpackages/playwright-core/src/client/playwright.ts#L29-L61で定義されています。ChannelOwnerを継承しており、普段よく使うchromiumfirefoxwebkitプロパティを公開しています:

サーバー側の対応するオブジェクトはpackages/playwright-core/src/server/playwright.ts#L39-L66にあります。サーバー側のPlaywrightがChromiumFirefoxWebKitという具体的なブラウザ実装をインスタンス化しているほか、WebDriver BiDiサポートに向けたBidiChromiumBidiFirefoxのバリアントも生成していることが確認できます。

DEPS.listによるモジュール境界の強制

大規模なモノレポでは、NxやTurborepoといったツールで依存関係の境界を強制するのが一般的です。Playwrightはより軽量なアプローチを採用しています。ビルド時にカスタムスクリプトutils/check_deps.jsでチェックされるDEPS.listファイルです。

各ディレクトリにはDEPS.listファイルを置くことができ、そのディレクトリ内のファイルがどのモジュールをimportできるかを宣言します。形式はシンプルで、各セクションにファイル名とそこからimportを許可するパスを列挙するだけです。

トップレベルの境界ルールはpackages/playwright-core/src/DEPS.list#L1-L32に定義されています:

[inProcessFactory.ts]
**

[inprocess.ts]
utils/
server/utils

[outofprocess.ts]
client/
protocol/
utils/
utils/isomorphic
server/utils

注目すべきは[inProcessFactory.ts]に続く**という記述です。これはコードベース全体でclientとserver双方からimportできる唯一のファイルであることを意味しています。他のすべてのファイルにはimportの制限が課せられています。

clientの境界はさらに厳しく設定されています。packages/playwright-core/src/client/DEPS.list#L1-L3を見てみましょう:

[*]
../protocol/
../utils/isomorphic

clientはprotocolの型とisomorphicなユーティリティのみimportでき、serverにはアクセスできません。この制約はビルド時に強制されるため、意図しない結合を未然に防ぎます。

flowchart LR
    subgraph "Allowed Imports"
        direction TB
        C["client/"] -->|"protocol types only"| P["protocol/"]
        C -->|"shared utils"| UI["utils/isomorphic/"]
        S["server/"] --> P
        S --> UI
        S -->|"controlled"| SB["server/chromium/<br/>server/firefox/<br/>server/webkit/"]
    end

    IPF["inProcessFactory.ts"] -->|"** (everything)"| C
    IPF --> S
    
    style IPF fill:#f96,stroke:#333,color:#000

サーバーのDEPS.listpackages/playwright-core/src/server/DEPS.list#L1-L30)にはさらなる制約があります。./chromium/./firefox/./webkit/といったブラウザ固有のモジュールをimportできるのは、サーバーのルートであるplaywright.tsだけです。これにより、たとえばPage基底クラスが誤ってChromium固有のコードをimportしてしまうことを防いでいます。

Tip: Playwrightにコントリビュートしていてビルドが依存関係エラーで失敗した場合は、最も近いDEPS.listファイルを確認しましょう。そのファイルから行えるimportが明示されています。

エントリーポイント:In-Process と Out-of-Process

Playwrightには2つの動作モードがあり、それぞれ対応するエントリーポイントが存在します。

In-Process(Node.jsのデフォルト)

require('playwright-core')を実行すると、packages/playwright-core/src/inprocess.ts#L17-L19が呼び出されます:

import { createInProcessPlaywright } from './inProcessFactory';
module.exports = createInProcessPlaywright();

これによりpackages/playwright-core/src/inProcessFactory.ts#L26-L58の重要な接続関数が呼び出され、以下の処理が順に実行されます:

  1. サーバー側のPlaywrightインスタンスを生成する
  2. クライアントのConnectionとサーバーのDispatcherConnectionを生成する
  3. 初期化時は同期ディスパッチで両者を接続する
  4. Playwrightチャンネルを初期化する
  5. setImmediate()を使って非同期ディスパッチに切り替える
flowchart TD
    A["require('playwright-core')"] --> B["inprocess.ts"]
    B --> C["createInProcessPlaywright()"]
    C --> D["Create server Playwright"]
    C --> E["Create client Connection"]
    C --> F["Create DispatcherConnection"]
    D --> G["Wire sync dispatch"]
    G --> H["Initialize Playwright channel"]
    H --> I["Switch to async dispatch<br/>(setImmediate)"]
    I --> J["Return client PlaywrightAPI"]

この「同期→非同期」の切り替えは理解しておく価値があります。初期化中、clientはinitialize()を送信し、chromiumfirefoxwebkitプロパティを持つPlaywrightオブジェクトをすぐに受け取ることを期待しています。同期ディスパッチにより、このハンドシェイクがcreateInProcessPlaywright()の返却前に完了することが保証されます。その後は、深くネストされたプロトコル呼び出しによるスタックオーバーフローを防ぐために、setImmediate()を使った非同期ディスパッチに移行します。

Out-of-Process(その他の言語バインディング)

Python、Java、C#のバインディングでは、エントリーポイントはpackages/playwright-core/src/outofprocess.ts#L28-L68になります。PlaywrightドライバーをChild Processとしてフォークし、stdin/stdout上のPipeTransportを介して接続します:

this._driverProcess = childProcess.fork(
  path.join(__dirname, '..', 'cli.js'), ['run-driver'], {
    stdio: 'pipe',
    detached: true,
  });

Connectionはpipeを通じてJSONメッセージを送信し、サーバーはin-processモードとまったく同じようにそれをディスパッチします。4つの言語が1つのサーバー実装を共有できるのは、この仕組みによるものです。

sequenceDiagram
    participant Python as Python Client
    participant Pipe as stdin/stdout
    participant Driver as Node.js Driver
    participant Browser as Browser

    Python->>Pipe: JSON message
    Pipe->>Driver: PipeTransport
    Driver->>Browser: CDP/Protocol
    Browser-->>Driver: Response
    Driver-->>Pipe: JSON response
    Pipe-->>Python: Result

プロトコルYAMLとコード生成

RPC API全体の唯一の情報源(Single Source of Truth)はpackages/protocol/src/protocol.ymlです。約4,590行のYAMLファイルで、すべてのchannel(インターフェース)、メソッド、イベント、initializerが定義されています。

774行目から始まるRootPlaywrightチャンネルを例に、channel定義の構造を見てみましょう:

Root:
  type: interface
  commands:
    initialize:
      internal: true
      parameters:
        sdkLanguage: SDKLanguage
      returns:
        playwright: Playwright

Playwright:
  type: interface
  initializer:
    chromium: BrowserType
    firefox: BrowserType
    webkit: BrowserType
    android: Android
    electron: Electron

ビルド時にutils/generate_channels.jsがこのYAMLを読み込み、以下のファイルを生成します:

  • TypeScript channelインターフェース (packages/protocol/src/channels.d.ts)
  • バリデーター関数 (packages/playwright-core/src/protocol/validator.ts)
  • プロトコルメタ情報 (packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts)

バリデーターはプロトコル境界の両側で使用され、メッセージがスキーマに準拠していることを確認します。これがPlaywrightにおけるRPCレイヤーのコンパイル時安全網です。

flowchart LR
    YAML["protocol.yml<br/>(source of truth)"] --> GEN["generate_channels.js"]
    GEN --> CH["channels.d.ts<br/>(TypeScript types)"]
    GEN --> VAL["validator.ts<br/>(runtime validators)"]
    GEN --> META["protocolMetainfo.ts<br/>(method metadata)"]
    CH --> CLIENT["Client code"]
    CH --> SERVER["Server code"]
    VAL --> CLIENT
    VAL --> SERVER

次のステップ

本記事ではPlaywrightの全体像を俯瞰しました。次の記事では、プロトコル層そのものを深掘りします。page.click()の呼び出しからメッセージがどのように流れるか——クライアントのConnectionを経由し、プロトコル境界を越えて、サーバーのDispatcherConnectionを通り、実際のブラウザコマンドに至るまで——を丁寧に追っていきます。ChannelOwnerDispatcher、オブジェクトのライフサイクル管理、そしてclientとserverの同期を保つバリデーションパイプラインについて詳しく解説します。