Playwrightのアーキテクチャ:モノレポ構造を読み解く
前提知識
- ›TypeScriptの基礎知識
- ›npm workspacesの概念への理解
- ›ブラウザ自動化の基本的な理解
Playwrightのアーキテクチャ:モノレポ構造を読み解く
PlaywrightはChromium、Firefox、WebKitに対応したクロスブラウザのE2Eテスト・自動化フレームワークです。しかし、おなじみのpage.click() APIの裏側には、驚くほど精巧なクライアント・サーバーアーキテクチャが隠されています。複数の言語バインディング、リモート実行、そして関心の明確な分離を当初から設計に組み込んだ構造です。本記事では、モノレポの構成、クライアント・サーバー分割の基本設計、境界強制の仕組み、さらに全体をつなぐエントリーポイントについて概観します。
モノレポの構造とパッケージの役割
Playwrightのリポジトリはnpm workspaceとして構成されており、packages/配下におよそ22のパッケージが含まれています。ルートのpackage.jsonでworkspaceが定義されています:
主要なパッケージの関係を以下の表にまとめます:
| 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層です。Browser、Page、Locatorなどのオブジェクトがここに属し、メソッド呼び出しをプロトコルメッセージにシリアライズする軽量なオブジェクトとして実装されています。 - 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を継承しており、普段よく使うchromium、firefox、webkitプロパティを公開しています:
サーバー側の対応するオブジェクトはpackages/playwright-core/src/server/playwright.ts#L39-L66にあります。サーバー側のPlaywrightがChromium、Firefox、WebKitという具体的なブラウザ実装をインスタンス化しているほか、WebDriver BiDiサポートに向けたBidiChromiumやBidiFirefoxのバリアントも生成していることが確認できます。
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.list(packages/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の重要な接続関数が呼び出され、以下の処理が順に実行されます:
- サーバー側の
Playwrightインスタンスを生成する - クライアントの
ConnectionとサーバーのDispatcherConnectionを生成する - 初期化時は同期ディスパッチで両者を接続する
- Playwrightチャンネルを初期化する
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()を送信し、chromium、firefox、webkitプロパティを持つ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行目から始まるRootとPlaywrightチャンネルを例に、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を通り、実際のブラウザコマンドに至るまで——を丁寧に追っていきます。ChannelOwner、Dispatcher、オブジェクトのライフサイクル管理、そしてclientとserverの同期を保つバリデーションパイプラインについて詳しく解説します。