Skip to content

Instantly share code, notes, and snippets.

@mizchi
Created October 3, 2024 12:26
Show Gist options
  • Save mizchi/3d963f82c79a7593105c7ad966bcf839 to your computer and use it in GitHub Desktop.
Save mizchi/3d963f82c79a7593105c7ad966bcf839 to your computer and use it in GitHub Desktop.

playwright を読んだメモ

package.json

    "ctest": "playwright test --config=tests/library/playwright.config.ts --project=chromium-*",
    "ftest": "playwright test --config=tests/library/playwright.config.ts --project=firefox-*",
    "wtest": "playwright test --config=tests/library/playwright.config.ts --project=webkit-*",
    "atest": "playwright test --config=tests/android/playwright.config.ts",
    "etest": "playwright test --config=tests/electron/playwright.config.ts",
npm run build
npx playwright install
npm run ctest

packages/playwright-core/index.mjs

/**
 * Copyright (c) Microsoft Corporation.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import playwright from './index.js';

export const chromium = playwright.chromium;
export const firefox = playwright.firefox;
export const webkit = playwright.webkit;
export const selectors = playwright.selectors;
export const devices = playwright.devices;
export const errors = playwright.errors;
export const request = playwright.request;
export const _electron = playwright._electron;
export const _android = playwright._android;
export default playwright;
// packages/playwright-core/index.js
// ...
module.exports = require('./lib/inprocess');

これは .gitignore されていて src/inprocess を見る。

// packages/playwright-core/src/inprocess.ts
import { createInProcessPlaywright } from './inProcessFactory';
module.exports = createInProcessPlaywright();

実体っぽい packages/playwright-core/src/inProcessFactory.ts

/**
 * Copyright (c) Microsoft Corporation.
 *
 * Licensed under the Apache License, Version 2.0 (the 'License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import type { Playwright as PlaywrightAPI } from './client/playwright';
import { createPlaywright, DispatcherConnection, RootDispatcher, PlaywrightDispatcher } from './server';
import { Connection } from './client/connection';
import { BrowserServerLauncherImpl } from './browserServerImpl';
import { AndroidServerLauncherImpl } from './androidServerImpl';
import type { Language } from './utils';

export function createInProcessPlaywright(): PlaywrightAPI {
  const playwright = createPlaywright({ sdkLanguage: (process.env.PW_LANG_NAME as Language | undefined) || 'javascript' });

  const clientConnection = new Connection(undefined, undefined);
  clientConnection.useRawBuffers();
  const dispatcherConnection = new DispatcherConnection(true /* local */);

  // Dispatch synchronously at first.
  dispatcherConnection.onmessage = message => clientConnection.dispatch(message);
  clientConnection.onmessage = message => dispatcherConnection.dispatch(message);

  const rootScope = new RootDispatcher(dispatcherConnection);

  // Initialize Playwright channel.
  new PlaywrightDispatcher(rootScope, playwright);
  const playwrightAPI = clientConnection.getObjectWithKnownName('Playwright') as PlaywrightAPI;
  playwrightAPI.chromium._serverLauncher = new BrowserServerLauncherImpl('chromium');
  playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox');
  playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit');
  playwrightAPI._android._serverLauncher = new AndroidServerLauncherImpl();

  // Switch to async dispatch after we got Playwright object.
  dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message));
  clientConnection.onmessage = message => setImmediate(() => dispatcherConnection.dispatch(message));

  clientConnection.toImpl = (x: any) => x ? dispatcherConnection._dispatchers.get(x._guid)!._object : dispatcherConnection._dispatchers.get('');
  (playwrightAPI as any)._toImpl = clientConnection.toImpl;
  return playwrightAPI;
}

まずAPI命名だけみて勘で読む。

WebDriver と接続する Connection があって、 DispatcherConnection があって、 抽象度高そうな RootDispatcher があって、さらに抽象度が高そうな PlaywrightDispatcher がある。

レシーバがないタスククラスである PlaywrightDispatcher は、 dispatcher と playwright を結びつけてる。

ファイル名からの類推だが、InProcess でない Playwright もたぶん存在し、それは別の形で rootScope と playwright が結びついているのだろう。あくまで予想だが。

あとで整理したい

  • ブラウザのDOMと話すための clientConnection: Connection
  • WebDriver プロトコルと話すための RootDispatcher
  • これを紐づける PlaywrightDispatcher
  • これらをラップした PlaywrightAPI(ユーザーが使う playright インスタンス)

おそらくMSのことだから Provider/Adapter パターンになっていて、API の実体はスカスカで、それぞれのAPI定義がみっちり定義してあるはず。

自分は WebDriver 側は興味がないから(4227だかの適当なポートで繋ぐと、コマンドを受け付けるサーバー程度のイメージ)、 Connection の実体を追う。

使い方からインターフェースに入る

使い方のイメージを確認

const playwright = require('playwright-core');

playwright.chromium.launch({channel: 'chrome', headless: false}).then(async browser => {
  const page = await browser.newPage();
  await page.goto('https://google.com');
  await page.type('input[name="q"]', 'playwright');
  await Promise.all([
  	page.waitForNavigation(),
  	page.keyboard.press('Enter'),
  ]);
  await page.waitForTimeout(3000);
  await browser.close();
});

https://zenn.dev/yusukeiwaki/articles/90bf05c2cf9a90

つまり、playwright[browser].launch() でインスタンスが立ち上がるから、 browser.launch のインターフェースをみる。

// packages/playwright-core/src/client/browserType.ts
export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> implements api.BrowserType {
  _serverLauncher?: BrowserServerLauncher;
  _contexts = new Set<BrowserContext>();
  _playwright!: Playwright;

  /// ここ
  async launch(options: LaunchOptions = {}): Promise<Browser> {
    assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead');
    assert(!(options as any).port, 'Cannot specify a port without launching as a server.');

    const logger = options.logger || this._defaultLaunchOptions?.logger;
    options = { ...this._defaultLaunchOptions, ...options };
    const launchOptions: channels.BrowserTypeLaunchParams = {
      ...options,
      ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined,
      ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
      env: options.env ? envObjectToArray(options.env) : undefined,
    };
    return await this._wrapApiCall(async () => {
      const browser = Browser.from((await this._channel.launch(launchOptions)).browser);
      this._didLaunchBrowser(browser, options, logger);
      return browser;
    });
  }

ここで Browser インスタンスが手に入る。

///packages/playwright-core/src/client/browser.ts
export class Browser extends ChannelOwner<channels.BrowserChannel> implements api.Browser {
  readonly _contexts = new Set<BrowserContext>();
  private _isConnected = true;
  private _closedPromise: Promise<void>;
  _shouldCloseConnectionOnClose = false;
  _browserType!: BrowserType;
  _options: LaunchOptions = {};
  readonly _name: string;
  private _path: string | undefined;

  // Used from @playwright/test fixtures.
  _connectHeaders?: HeadersArray;
  _closeReason: string | undefined;

  static from(browser: channels.BrowserChannel): Browser {
    return (browser as any)._object;
  }

これに対して browser.newPage などができるはず。

///packages/playwright-core/src/client/browser.ts
  async newPage(options: BrowserContextOptions = {}): Promise<Page> {
    return await this._wrapApiCall(async () => {
      const context = await this.newContext(options);
      const page = await context.newPage();
      page._ownedContext = context;
      context._ownerPage = page;
      return page;
    });
  }

Context を作って、Context が実際の newPage を持っていて、オーナー設定して、Page インスタンスを返す。

(ここから vscode.dev リンクが使えることに気づいたので使う)

/// https://vscode.dev/github/microsoft/playwright/blob/main/packages/playwright-core/src/client/browser.ts#L60
  async newContext(options: BrowserContextOptions = {}): Promise<BrowserContext> {
    return await this._innerNewContext(options, false);
  }
/// https://vscode.dev/github/microsoft/playwright/blob/main/packages/playwright-core/src/client/browser.ts#L82-L91
  async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise<BrowserContext> {
    options = { ...this._browserType._defaultContextOptions, ...options };
    const contextOptions = await prepareBrowserContextParams(options);
    const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions);
    const context = BrowserContext.from(response.context);
    await this._browserType._didCreateContext(context, contextOptions, this._options, options.logger || this._logger);
    return context;
  }

///https://vscode.dev/github/microsoft/playwright/blob/main/packages/playwright-core/src/client/browser.ts#L19-L20
import { BrowserContext, prepareBrowserContextParams } from './browserContext';

過去のリソースと比較して再利用判定して、最終的には BrowserContext.from(response.context) するっぽい。

/// https://vscode.dev/github/microsoft/playwright/blob/main/packages/playwright-core/src/client/browserContext.ts#L71-L74
  static from(context: channels.BrowserContextChannel): BrowserContext {
    return (context as any)._object;
  }

_objectが何度も出てくる。とにかくこれが実体なのはわかった。

これはこのインスタンスに対して型を無視して注入している

///https://vscode.dev/github/microsoft/playwright/blob/main/packages/protocol/src/channels.ts#L1504-L1507
export interface BrowserContextChannel extends BrowserContextEventTarget, EventTargetChannel {
  _type_BrowserContext: boolean;
  addCookies(params: BrowserContextAddCookiesParams, metadata?: CallMetadata): Promise<BrowserContextAddCookiesResult>;

そもそもこの Context どう初期化するのかを追ったが、いろいろな経路がある。(Find Reference All で型、そのインスタンスで追って、一つが BrowserTypeDispatcher::connectOverCDP() の defaultContext

export class BrowserTypeDispatcher extends Dispatcher<BrowserType, channels.BrowserTypeChannel, RootDispatcher> implements channels.BrowserTypeChannel {
  _type_BrowserType = true;
  constructor(scope: RootDispatcher, browserType: BrowserType) {
    super(scope, browserType, 'BrowserType', {
      executablePath: browserType.executablePath(),
      name: browserType.name()
    });
  }

  async launch(params: channels.BrowserTypeLaunchParams, metadata: CallMetadata): Promise<channels.BrowserTypeLaunchResult> {
    const browser = await this._object.launch(metadata, params);
    return { browser: new BrowserDispatcher(this, browser) };
  }

  async launchPersistentContext(params: channels.BrowserTypeLaunchPersistentContextParams, metadata: CallMetadata): Promise<channels.BrowserTypeLaunchPersistentContextResult> {
    const browserContext = await this._object.launchPersistentContext(metadata, params.userDataDir, params);
    return { context: new BrowserContextDispatcher(this, browserContext) };
  }

  async connectOverCDP(params: channels.BrowserTypeConnectOverCDPParams, metadata: CallMetadata): Promise<channels.BrowserTypeConnectOverCDPResult> {
    const browser = await this._object.connectOverCDP(metadata, params.endpointURL, params, params.timeout);
    const browserDispatcher = new BrowserDispatcher(this, browser);
    return {
      browser: browserDispatcher,
      defaultContext: browser._defaultContext ? new BrowserContextDispatcher(browserDispatcher, browser._defaultContext) : undefined,
    };
  }
}

とにかく色んな人が Context を持ってて、隙あらば上書きする。ここで BrowseContextDispatcher が Dispatch 先ということがわかる。

そもそも BrowseContextDispatcher って何?

/// https://vscode.dev/github/microsoft/playwright/blob/main/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts#L46
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
  _type_EventTarget = true;
  _type_BrowserContext = true;
  private _context: BrowserContext;
  private _subscriptions = new Set<channels.BrowserContextUpdateSubscriptionParams['event']>();

  constructor(parentScope: DispatcherScope, context: BrowserContext) {
    // We will reparent these to the context below.
    const requestContext = APIRequestContextDispatcher.from(parentScope as BrowserContextDispatcher, context.fetchRequest);
    const tracing = TracingDispatcher.from(parentScope as BrowserContextDispatcher, context.tracing);

    super(parentScope, context, 'BrowserContext', {
      isChromium: context._browser.options.isChromium,
      isLocalBrowserOnServer: context._browser._isCollocatedWithServer,
      requestContext,
      tracing,
    });

    this.adopt(requestContext);
    this.adopt(tracing);

    this._context = context;
    // Note: when launching persistent context, dispatcher is created very late,
    // so we can already have pages, videos and everything else.

    const onVideo = (artifact: Artifact) => {
      // Note: Video must outlive Page and BrowserContext, so that client can saveAs it
      // after closing the context. We use |scope| for it.
      const artifactDispatcher = ArtifactDispatcher.from(parentScope, artifact);
      this._dispatchEvent('video', { artifact: artifactDispatcher });
    };
    this.addObjectListener(BrowserContext.Events.VideoStarted, onVideo);
    for (const video of context._browser._idToVideo.values()) {
      if (video.context === context)
        onVideo(video.artifact);
    }

    for (const page of context.pages())
      this._dispatchEvent('page', { page: PageDispatcher.from(this, page) });
    this.addObjectListener(BrowserContext.Events.Page, page => {
      this._dispatchEvent('page', { page: PageDispatcher.from(this, page) });
    });
    this.addObjectListener(BrowserContext.Events.Close, () => {
      this._dispatchEvent('close');
      this._dispose();
    });
    this.addObjectListener(BrowserContext.Events.PageError, (error: Error, page: Page) => {
      this._dispatchEvent('pageError', { error: serializeError(error), page: PageDispatcher.from(this, page) });
    });
    this.addObjectListener(BrowserContext.Events.Console, (message: ConsoleMessage) => {
      const page = message.page()!;
      if (this._shouldDispatchEvent(page, 'console')) {
        const pageDispatcher = PageDispatcher.from(this, page);
        this._dispatchEvent('console', {
          page: pageDispatcher,
          type: message.type(),
          text: message.text(),
          args: message.args().map(a => ElementHandleDispatcher.fromJSHandle(pageDispatcher, a)),
          location: message.location(),
        });
      }
    });
    this.addObjectListener(BrowserContext.Events.Dialog, (dialog: Dialog) => {
      if (this._shouldDispatchEvent(dialog.page(), 'dialog'))
        this._dispatchEvent('dialog', { dialog: new DialogDispatcher(this, dialog) });
      else
        dialog.close().catch(() => {});
    });

    if (context._browser.options.name === 'chromium') {
      for (const page of (context as CRBrowserContext).backgroundPages())
        this._dispatchEvent('backgroundPage', { page: PageDispatcher.from(this, page) });
      this.addObjectListener(CRBrowserContext.CREvents.BackgroundPage, page => this._dispatchEvent('backgroundPage', { page: PageDispatcher.from(this, page) }));
      for (const serviceWorker of (context as CRBrowserContext).serviceWorkers())
        this._dispatchEvent('serviceWorker', { worker: new WorkerDispatcher(this, serviceWorker) });
      this.addObjectListener(CRBrowserContext.CREvents.ServiceWorker, serviceWorker => this._dispatchEvent('serviceWorker', { worker: new WorkerDispatcher(this, serviceWorker) }));
    }
    this.addObjectListener(BrowserContext.Events.Request, (request: Request) =>  {
      // Create dispatcher, if:
      // - There are listeners to the requests.
      // - We are redirected from a reported request so that redirectedTo was updated on client.
      // - We are a navigation request and dispatcher will be reported as a part of the goto return value and newDocument param anyways.
      //   By the time requestFinished is triggered to update the request, we should have a request on the client already.
      const redirectFromDispatcher = request.redirectedFrom() && existingDispatcher(request.redirectedFrom());
      if (!redirectFromDispatcher && !this._shouldDispatchNetworkEvent(request, 'request') && !request.isNavigationRequest())
        return;
      const requestDispatcher = RequestDispatcher.from(this, request);
      this._dispatchEvent('request', {
        request: requestDispatcher,
        page: PageDispatcher.fromNullable(this, request.frame()?._page.initializedOrUndefined())
      });
    });
    this.addObjectListener(BrowserContext.Events.Response, (response: Response) => {
      const requestDispatcher = existingDispatcher<RequestDispatcher>(response.request());
      if (!requestDispatcher && !this._shouldDispatchNetworkEvent(response.request(), 'response'))
        return;
      this._dispatchEvent('response', {
        response: ResponseDispatcher.from(this, response),
        page: PageDispatcher.fromNullable(this, response.frame()?._page.initializedOrUndefined())
      });
    });
    this.addObjectListener(BrowserContext.Events.RequestFailed, (request: Request) => {
      const requestDispatcher = existingDispatcher<RequestDispatcher>(request);
      if (!requestDispatcher && !this._shouldDispatchNetworkEvent(request, 'requestFailed'))
        return;
      this._dispatchEvent('requestFailed', {
        request: RequestDispatcher.from(this, request),
        failureText: request._failureText || undefined,
        responseEndTiming: request._responseEndTiming,
        page: PageDispatcher.fromNullable(this, request.frame()?._page.initializedOrUndefined())
      });
    });
    this.addObjectListener(BrowserContext.Events.RequestFinished, ({ request, response }: { request: Request, response: Response | null }) => {
      const requestDispatcher = existingDispatcher<RequestDispatcher>(request);
      if (!requestDispatcher && !this._shouldDispatchNetworkEvent(request, 'requestFinished'))
        return;
      this._dispatchEvent('requestFinished', {
        request: RequestDispatcher.from(this, request),
        response: ResponseDispatcher.fromNullable(this, response),
        responseEndTiming: request._responseEndTiming,
        page: PageDispatcher.fromNullable(this, request.frame()?._page.initializedOrUndefined()),
      });
    });
  }

いきなり複数ページあいだのVideoの再生制御をしていて、自分がPlaywrightに期待してた泥臭さが表出してきた。個人の感想。

複数ページを管理して、イベントをページ間で横流ししたりしている。 つまり Playwright 自体が一つのタブブラウザみたいになっているのか。

ここまでのまとめ

Playwright の Context を読んで理解してきたんだけど、Playwright自体が WebDriver プロトコルを喋る一つのヘッドレスなタブブラウザの実装になってて、概念的にはPlaywrightはブラウザ実装の高水準な実装の一つだと思うほうが良さそう。

続き

adopt って何

/// https://vscode.dev/github/microsoft/playwright/blob/main/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts#L52
  constructor(parentScope: DispatcherScope, context: BrowserContext) {
    // We will reparent these to the context below.
    const requestContext = APIRequestContextDispatcher.from(parentScope as BrowserContextDispatcher, context.fetchRequest);
    const tracing = TracingDispatcher.from(parentScope as BrowserContextDispatcher, context.tracing);

    super(parentScope, context, 'BrowserContext', {
      isChromium: context._browser.options.isChromium,
      isLocalBrowserOnServer: context._browser._isCollocatedWithServer,
      requestContext,
      tracing,
    });

    this.adopt(requestContext);
    this.adopt(tracing);

    this._context = context;
  adopt(child: DispatcherScope) {
    if (child._parent === this)
      return;
    const oldParent = child._parent!;
    oldParent._dispatchers.delete(child._guid);
    this._dispatchers.set(child._guid, child);
    child._parent = this;
    this._connection.sendAdopt(this, child);
  }

子 dispatcher を自身に登録する。

つまり、 browserContext に対して fetch や tracing のイベントハンドラを登録している感じ。 (これってブラウザ内のスタンドアロンじゃないんだ)

あとは pages に対して自身のコンテキストを引数に 'page' イベントを送っている。コンテキストが生成されたよ、という通知かな。

結構泥臭い。モバイルアプリの WebView 組み込みのもう一つ低階層なやつみたいだ。

newPage を見たい!

たぶんここまでで .launch() したときに空のページのブラウザコンテキストが生成される世界を追ったと思う。 なので、次は context.newPage() されたらどう pages に追加されて、親にハンドルされるかを見たい。

/// https://vscode.dev/github/microsoft/playwright/blob/main/packages/playwright-core/src/server/browserContext.ts#L482-L494
  async newPage(metadata: CallMetadata): Promise<Page> {
    const pageDelegate = await this.newPageDelegate();
    if (metadata.isServerSide)
      pageDelegate.potentiallyUninitializedPage().markAsServerSideOnly();
    const pageOrError = await pageDelegate.pageOrError();
    if (pageOrError instanceof Page) {
      if (pageOrError.isClosed())
        throw new Error('Page has been closed.');
      return pageOrError;
    }
    throw pageOrError;
  }

pageDelegate というオブジェクトに委託し、その結果 pageOrError が返ってくる。

このインターフェースを実装してる一つ。Chrome 側。

/// https://vscode.dev/github/microsoft/playwright/blob/main/packages/playwright-core/src/server/chromium/crBrowser.ts#L371
  async newPageDelegate(): Promise<PageDelegate> {
    assertBrowserContextIsNotOwned(this);

    const oldKeys = this._browser.isClank() ? new Set(this._browser._crPages.keys()) : undefined;

    let { targetId } = await this._browser._session.send('Target.createTarget', { url: 'about:blank', browserContextId: this._browserContextId });

    if (oldKeys) {
      // Chrome for Android returns tab ids (1, 2, 3, 4, 5) instead of content target ids here, work around it via the
      // heuristic assuming that there is only one page created at a time.
      const newKeys = new Set(this._browser._crPages.keys());
      // Remove old keys.
      for (const key of oldKeys)
        newKeys.delete(key);
      // Remove potential concurrent popups.
      for (const key of newKeys) {
        const page = this._browser._crPages.get(key)!;
        if (page._opener)
          newKeys.delete(key);
      }
      assert(newKeys.size === 1);
      [targetId] = [...newKeys];
    }
    return this._browser._crPages.get(targetId)!;
  }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment