From 8df9cd5b9a7cef2861a44833053f7eca640641b1 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:02:56 +0900 Subject: [PATCH] playwright --- .github/scripts/chrome.mts | 460 +++++------------- .../measure-frontend-browser-comparison.mts | 64 +-- .../frontend-browser-metrics-report.yml | 5 + 3 files changed, 165 insertions(+), 364 deletions(-) diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts index f2ca3d7326..b2bc3a55c5 100644 --- a/.github/scripts/chrome.mts +++ b/.github/scripts/chrome.mts @@ -3,17 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { writeFile } from 'node:fs/promises'; import * as util from './utility.mts'; +import type { Locator, Browser, BrowserContext, CDPSession, Page } from 'playwright'; import type { HeapSnapshotData } from './heap-snapshot-util.mts'; -type ChromeHandle = { - process: ChildProcessWithoutNullStreams; - port: number; - userDataDir: string; +type LocatorEntry = { + name: string; + locator: Locator; }; export type NetworkRequest = { @@ -126,145 +124,12 @@ export type BrowserMeasurement = { heapSnapshot: HeapSnapshotData; }; -async function waitForProcessExit(child: ChildProcessWithoutNullStreams) { - await new Promise(resolvePromise => { - if (child.exitCode != null) { - resolvePromise(); - return; - } - const killTimer = setTimeout(() => { - child.kill('SIGKILL'); - resolvePromise(); - }, 5_000).unref(); - child.once('exit', () => { - clearTimeout(killTimer); - resolvePromise(); - }); - }); -} +type PlaywrightModule = typeof import('playwright'); -async function fetchJson(url: string, options?: RequestInit) { - const response = await fetch(url, options); - if (!response.ok) { - throw new Error(`${url} returned ${response.status}: ${await response.text()}`); - } - return await response.json() as T; -} +const requireFromFrontend = createRequire(new URL('../../packages/frontend/package.json', import.meta.url)); -function findChrome() { - const envChrome = process.env.CHROME_BIN ?? process.env.GOOGLE_CHROME_BIN; - if (envChrome != null && envChrome !== '') return envChrome; - - const candidates = process.platform === 'win32' - ? [ - 'chrome.exe', - 'msedge.exe', - ] - : [ - 'google-chrome', - 'google-chrome-stable', - 'chromium', - 'chromium-browser', - ]; - - for (const candidate of candidates) { - const result = spawnSync(candidate, ['--version'], { - stdio: 'ignore', - shell: process.platform === 'win32', - }); - if (result.status === 0) return candidate; - } - - throw new Error('Could not find Chrome or Chromium. Set CHROME_BIN to the browser executable.'); -} - -async function launchChrome(label: string): Promise { - const chrome = findChrome(); - const port = label === 'base' ? 9222 : 9223; - const userDataDir = await mkdtemp(join(tmpdir(), `misskey-browser-metrics-${label}-`)); - const child = spawn(chrome, [ - '--headless=new', - '--disable-gpu', - '--disable-dev-shm-usage', - '--disable-background-networking', - '--disable-default-apps', - '--disable-extensions', - '--disable-sync', - '--metrics-recording-only', - '--no-first-run', - '--no-default-browser-check', - '--no-sandbox', - `--remote-debugging-port=${port}`, - `--user-data-dir=${userDataDir}`, - 'about:blank', - ], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - - child.stdout.on('data', data => process.stderr.write(`[chrome:${label}] ${data}`)); - child.stderr.on('data', data => process.stderr.write(`[chrome:${label}] ${data}`)); - - try { - const startedAt = Date.now(); - while (Date.now() - startedAt < 30_000) { - if (child.exitCode != null) throw new Error(`Chrome exited early with code ${child.exitCode}`); - try { - await fetchJson(`http://127.0.0.1:${port}/json/version`); - return { - process: child, - port, - userDataDir, - }; - } catch { - await util.sleep(250); - } - } - - throw new Error('Timed out waiting for Chrome DevTools Protocol'); - } catch (err) { - await closeChrome({ - process: child, - port, - userDataDir, - }); - throw err; - } -} - -async function closeChrome(handle: ChromeHandle) { - if (handle.process.exitCode == null) { - handle.process.kill(); - } - await waitForProcessExit(handle.process); - await rm(handle.userDataDir, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 200, - }); -} - -type CdpResponse = { - id?: number; - method?: string; - params?: any; - result?: T; - error?: { - code: number; - message: string; - }; -}; - -function selectorReadyExpression(selector: string, options: { visible?: boolean; enabled?: boolean } = {}) { - return `(() => { - const el = document.querySelector(${JSON.stringify(selector)}); - if (el == null) return false; - const style = window.getComputedStyle(el); - const rect = el.getBoundingClientRect(); - if (${options.visible === true ? 'true' : 'false'} && (style.visibility === 'hidden' || style.display === 'none' || rect.width === 0 || rect.height === 0)) return false; - if (${options.enabled === true ? 'true' : 'false'} && (el.disabled || el.getAttribute('aria-disabled') === 'true')) return false; - return true; - })()`; +function loadPlaywright(): PlaywrightModule { + return requireFromFrontend('playwright') as PlaywrightModule; } function normalizeHeaders(headers: Record | undefined) { @@ -282,119 +147,79 @@ function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: stri return Buffer.byteLength(frame.payloadData, 'base64'); } -class CdpClient { - private nextId = 1; - private callbacks = new Map void; - reject: (error: Error) => void; - }>(); - private eventHandlers = new Map void>>(); - private ws: WebSocket; - - private constructor(ws: WebSocket) { - this.ws = ws; - ws.addEventListener('message', event => { - const message = JSON.parse(String(event.data)) as CdpResponse; - if (message.id != null) { - const callback = this.callbacks.get(message.id); - if (callback == null) return; - this.callbacks.delete(message.id); - if (message.error != null) { - callback.reject(new Error(`${message.error.message} (${message.error.code})`)); - } else { - callback.resolve(message.result); - } - return; - } - - if (message.method != null) { - for (const handler of this.eventHandlers.get(message.method) ?? []) { - handler(message.params); - } - } - }); - - ws.addEventListener('close', () => { - for (const callback of this.callbacks.values()) { - callback.reject(new Error('CDP websocket closed')); - } - this.callbacks.clear(); - }); - } - - static async connect(wsUrl: string) { - const ws = new WebSocket(wsUrl); - await new Promise((resolvePromise, reject) => { - ws.addEventListener('open', () => resolvePromise(), { once: true }); - ws.addEventListener('error', () => reject(new Error(`Failed to connect to ${wsUrl}`)), { once: true }); - }); - return new CdpClient(ws); - } - - on(method: string, handler: (params: any) => void) { - const handlers = this.eventHandlers.get(method) ?? new Set(); - handlers.add(handler); - this.eventHandlers.set(method, handlers); - } - - send(method: string, params: Record = {}): Promise { - const id = this.nextId++; - this.ws.send(JSON.stringify({ id, method, params })); - - return new Promise((resolvePromise, reject) => { - this.callbacks.set(id, { - resolve: resolvePromise, - reject, - }); - }); - } - - close() { - this.ws.close(); - } -} - -type ChromeOptions = { +type PlaywrightBrowserOptions = { scenarioTimeoutMs: number; + baseUrl: string; }; -export class Chrome { - private handle: ChromeHandle; - public cdp: CdpClient; +export class PlaywrightBrowser { public networkRequests: NetworkRequest[] = []; public webSocketConnections: WebSocketConnection[] = []; - private scenarioTimeoutMs: number; + private readonly browser: Browser; + private readonly context: BrowserContext; + public readonly page: Page; + private readonly cdp: CDPSession; private pendingNetworkDetailReads: Promise[] = []; - constructor(handle: ChromeHandle, cdpClient: CdpClient, options: ChromeOptions) { - this.handle = handle; - this.cdp = cdpClient; - this.scenarioTimeoutMs = options.scenarioTimeoutMs; + private constructor( + browser: Browser, + context: BrowserContext, + page: Page, + cdp: CDPSession, + options: PlaywrightBrowserOptions, + ) { + this.browser = browser; + this.context = context; + this.page = page; + this.cdp = cdp; + this.page.setDefaultTimeout(options.scenarioTimeoutMs); + this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs); } - static async create(label: string, options: ChromeOptions): Promise { - const chromeHandle = await launchChrome(label); + static async create(label: string, options: PlaywrightBrowserOptions): Promise { + process.stderr.write(`[${label}] Launching Playwright Chromium\n`); + const { chromium } = loadPlaywright(); + const browser = await chromium.launch({ + headless: true, + args: [ + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-default-apps', + '--disable-extensions', + '--disable-sync', + '--metrics-recording-only', + '--no-first-run', + '--no-default-browser-check', + '--no-sandbox', + ], + }); + try { - const url = await fetchJson<{ webSocketDebuggerUrl: string }>( - `http://127.0.0.1:${chromeHandle.port}/json/new?${encodeURIComponent('about:blank')}`, - { method: 'PUT' }, - ).catch(async () => await fetchJson<{ webSocketDebuggerUrl: string }>( - `http://127.0.0.1:${chromeHandle.port}/json/new?${encodeURIComponent('about:blank')}`, - )); - const cdpClient = await CdpClient.connect(url.webSocketDebuggerUrl); - return new Chrome(chromeHandle, cdpClient, options); - } catch (err) { - await closeChrome(chromeHandle); - throw err; + const context = await browser.newContext({ + baseURL: options.baseUrl, + locale: 'en-US', + }); + await context.addInitScript(() => { + // @ts-expect-error Test-only runtime hint consumed by Misskey frontend code. + window.isPlaywright = true; + }); + + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + return new PlaywrightBrowser(browser, context, page, cdp, options); + } catch (error) { + await browser.close().catch(() => undefined); + throw error; } } - static async with(label: string, options: ChromeOptions, callback: (chrome: Chrome) => T | Promise): Promise { - const chrome = await Chrome.create(label, options); + static async with(label: string, options: PlaywrightBrowserOptions, callback: (browser: PlaywrightBrowser) => T | Promise): Promise { + const browser = await PlaywrightBrowser.create(label, options); try { - return await callback(chrome); + return await callback(browser); } finally { - await chrome.close(); + await browser.close(); } } @@ -550,94 +375,10 @@ export class Chrome { } public async evaluate(expression: string, timeoutMs = 30_000): Promise { - const result = await this.cdp.send<{ - result: { value: T }; - exceptionDetails?: unknown; - }>('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - timeout: timeoutMs, - }); - - if (result.exceptionDetails != null) { - throw new Error(`Runtime.evaluate failed: ${JSON.stringify(result.exceptionDetails)}`); - } - - return result.result.value; - } - - public async waitForSelector(selector: string, options: { timeoutMs?: number; visible?: boolean; enabled?: boolean } = {}) { - const startedAt = Date.now(); - const timeoutMs = options.timeoutMs ?? this.scenarioTimeoutMs; - while (Date.now() - startedAt < timeoutMs) { - const ready = await this.evaluate(selectorReadyExpression(selector, options), 5_000); - if (ready) return true; - await util.sleep(250); - } - return false; - } - - public async waitForAnySelector(selectors: string[], options: { timeoutMs?: number; visible?: boolean; enabled?: boolean } = {}) { - const startedAt = Date.now(); - const timeoutMs = options.timeoutMs ?? this.scenarioTimeoutMs; - while (Date.now() - startedAt < timeoutMs) { - for (const selector of selectors) { - const ready = await this.evaluate(selectorReadyExpression(selector, options), 5_000); - if (ready) return selector; - } - await util.sleep(250); - } - return null; - } - - public async click(selector: string) { - const found = await this.waitForSelector(selector, { visible: true, enabled: true }); - if (!found) throw new Error(`Selector was not clickable: ${selector}`); - await this.evaluate(`(() => { - const el = document.querySelector(${JSON.stringify(selector)}); - if (el == null) throw new Error('Element not found'); - el.scrollIntoView({ block: 'center', inline: 'center' }); - el.click(); - })()`); - } - - public async maybeClick(selector: string, timeoutMs = 3_000) { - if (await this.waitForSelector(selector, { visible: true, enabled: true, timeoutMs })) { - await this.click(selector); - return true; - } - return false; - } - - public async setValue(selector: string, value: string) { - const found = await this.waitForSelector(selector, { visible: true, enabled: true }); - if (!found) throw new Error(`Selector was not editable: ${selector}`); - await this.evaluate(`(() => { - const el = document.querySelector(${JSON.stringify(selector)}); - if (el == null) throw new Error('Element not found'); - el.scrollIntoView({ block: 'center', inline: 'center' }); - el.focus(); - const proto = Object.getPrototypeOf(el); - const descriptor = Object.getOwnPropertyDescriptor(proto, 'value'); - if (descriptor?.set != null) { - descriptor.set.call(el, ${JSON.stringify(value)}); - } else { - el.value = ${JSON.stringify(value)}; - } - el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(value)} })); - el.dispatchEvent(new Event('change', { bubbles: true })); - })()`); - } - - public async waitForText(text: string, timeoutMs = this.scenarioTimeoutMs) { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const found = await this.evaluate(`document.body?.innerText?.includes(${JSON.stringify(text)}) === true`, 5_000); - if (found) return true; - await util.sleep(250); - } - return false; + return await Promise.race([ + this.page.evaluate(expression), + new Promise((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()), + ]) as T; } public async collectPerformance(): Promise { @@ -673,7 +414,9 @@ export class Chrome { public async collectTabMemory(): Promise { const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => { - const result = await performance.measureUserAgentSpecificMemory(); + const measureMemory = performance.measureUserAgentSpecificMemory; + if (typeof measureMemory !== 'function') return {}; + const result = await measureMemory.call(performance); return { bytes: result.bytes }; })()`, 10_000); @@ -705,9 +448,20 @@ export class Chrome { return JSON.parse(content); } + public mkInput(testId: string) { + return this.page.locator(`[data-testid="${testId}"] input`); + } + + public waitApiResponse(path: string) { + return this.page.waitForResponse((response) => { + return response.url().endsWith(path) && response.request().method() === 'POST'; + }, { timeout: this.page.defaultTimeout() }); + } + public async close() { - this.cdp.close(); - await closeChrome(this.handle); + await this.cdp.detach().catch(() => undefined); + await this.context.close().catch(() => undefined); + await this.browser.close().catch(() => undefined); } } @@ -782,3 +536,41 @@ export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, })), }; } + +export async function isReady(locator: Locator, options: { visible?: boolean; enabled?: boolean }) { + const first = locator.first(); + try { + if (await locator.count() === 0) return false; + if (options.visible === true && !await first.isVisible()) return false; + if (options.enabled === true && !await first.isEnabled()) return false; + return true; + } catch { + return false; + } +} + +export async function waitForReady(locator: Locator, options: { timeoutMs: number; visible?: boolean; enabled?: boolean }) { + const startedAt = Date.now(); + while (Date.now() - startedAt < options.timeoutMs) { + if (await isReady(locator, options)) return true; + await util.sleep(250); + } + return false; +} + +export async function waitForAnyLocator(entries: LocatorEntry[], options: { timeoutMs: number; visible?: boolean; enabled?: boolean }) { + const startedAt = Date.now(); + while (Date.now() - startedAt < options.timeoutMs) { + for (const entry of entries) { + if (await isReady(entry.locator, options)) return entry.name; + } + await util.sleep(250); + } + return null; +} + +export async function maybeClick(locator: Locator, timeoutMs = 3_000) { + if (!await waitForReady(locator, { visible: true, enabled: true, timeoutMs })) return false; + await locator.click({ timeout: timeoutMs }); + return true; +} diff --git a/.github/scripts/measure-frontend-browser-comparison.mts b/.github/scripts/measure-frontend-browser-comparison.mts index 8cbaa85ad9..e272a1cdd9 100644 --- a/.github/scripts/measure-frontend-browser-comparison.mts +++ b/.github/scripts/measure-frontend-browser-comparison.mts @@ -8,7 +8,7 @@ import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import * as util from './utility.mts'; import * as heapSnapshotUtil from './heap-snapshot-util.mts'; -import { Chrome, summarizeNetwork } from './chrome.mts'; +import { maybeClick, PlaywrightBrowser, summarizeNetwork, waitForAnyLocator, waitForReady } from './chrome.mts'; import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts'; const [baseDirArg, headDirArg, baseOutputArg, headOutputArg, headHeapSnapshotOutputArg] = process.argv.slice(2); @@ -36,49 +36,53 @@ type BrowserMetricsReport = { samples: BrowserMeasurementSample[]; }; -async function runSignupAndPostScenario(chrome: Chrome) { +async function runSignupAndPostScenario(browser: PlaywrightBrowser) { + const page = browser.page; const noteText = `Frontend browser metrics ${Date.now()}`; - await chrome.cdp.send('Page.navigate', { url: `${baseUrl}/` }); - const initialSelector = await chrome.waitForAnySelector(['[data-cy-signup]', '[data-cy-open-post-form]'], { visible: true, timeoutMs: scenarioTimeoutMs }); + await page.goto(`${baseUrl}/`, { waitUntil: 'domcontentloaded', timeout: scenarioTimeoutMs }); + const initialSelector = await waitForAnyLocator([ + { name: 'signup', locator: page.getByTestId('signup') }, + { name: 'open-post-form', locator: page.getByTestId('open-post-form') }, + ], { visible: true, timeoutMs: scenarioTimeoutMs }); if (initialSelector == null) throw new Error('Timed out waiting for the signup or timeline entry point'); - if (await chrome.waitForSelector('[data-cy-signup]', { visible: true, enabled: true, timeoutMs: 5_000 })) { - await chrome.click('[data-cy-signup]'); + if (await waitForReady(page.getByTestId('signup'), { visible: true, enabled: true, timeoutMs: 5_000 })) { + await page.getByTestId('signup').click(); - if (await chrome.waitForSelector('[data-cy-signup-rules-continue]', { visible: true, timeoutMs: 5_000 })) { - await chrome.click('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]'); - await chrome.maybeClick('[data-cy-modal-dialog-ok]', 5_000); - await chrome.click('[data-cy-signup-rules-continue]'); + if (await waitForReady(page.getByTestId('signup-rules-continue'), { visible: true, timeoutMs: 5_000 })) { + await page.getByTestId('signup-rules-notes-agree').getByTestId('switch-toggle').click(); + await maybeClick(page.getByTestId('modal-dialog-ok'), 5_000); + await page.getByTestId('signup-rules-continue').click(); } - await chrome.setValue('[data-cy-signup-username] input', 'alice'); - await chrome.setValue('[data-cy-signup-password] input', 'alice1234'); - await chrome.setValue('[data-cy-signup-password-retype] input', 'alice1234'); - if (await chrome.waitForSelector('[data-cy-signup-invitation-code] input', { visible: true, enabled: true, timeoutMs: 2_000 })) { - await chrome.setValue('[data-cy-signup-invitation-code] input', 'test-invitation-code'); + await browser.mkInput('signup-username').fill('alice'); + await browser.mkInput('signup-password').fill('alice1234'); + await browser.mkInput('signup-password-retype').fill('alice1234'); + if (await waitForReady(browser.mkInput('signup-invitation-code'), { visible: true, enabled: true, timeoutMs: 2_000 })) { + await browser.mkInput('signup-invitation-code').fill('test-invitation-code'); } - await chrome.click('[data-cy-signup-submit]'); + const signupResponse = browser.waitApiResponse('/api/signup'); + await page.getByTestId('signup-submit').click(); + await signupResponse; } - const firstReadySelector = await chrome.waitForAnySelector([ - '[data-cy-user-setup] [data-cy-modal-window-close]', - '[data-cy-open-post-form]', + const setupDialogClose = page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]'); + const firstReadySelector = await waitForAnyLocator([ + { name: 'setup-dialog-close', locator: setupDialogClose }, + { name: 'open-post-form', locator: page.getByTestId('open-post-form') }, ], { visible: true, enabled: true, timeoutMs: scenarioTimeoutMs }); if (firstReadySelector == null) throw new Error('Timed out waiting for signed-in home timeline'); - if (firstReadySelector === '[data-cy-user-setup] [data-cy-modal-window-close]') { - await chrome.click('[data-cy-user-setup] [data-cy-modal-window-close]'); - await chrome.maybeClick('[data-cy-modal-dialog-ok]', 5_000); + if (firstReadySelector === 'setup-dialog-close') { + await setupDialogClose.click(); + await maybeClick(page.getByTestId('modal-dialog-ok'), 5_000); } - await chrome.click('[data-cy-open-post-form]'); - await chrome.setValue('[data-cy-post-form-text]', noteText); - await chrome.click('[data-cy-open-post-form-submit]'); - - if (!await chrome.waitForText(noteText, scenarioTimeoutMs)) { - throw new Error('The first timeline note did not appear'); - } + await page.getByTestId('open-post-form').click(); + await page.getByTestId('post-form-text').fill(noteText); + await page.getByTestId('post-form-submit').click(); + await page.getByText(noteText).waitFor({ timeout: scenarioTimeoutMs }); await util.sleep(settleMs); } @@ -231,7 +235,7 @@ function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSam async function measureSample(label: 'base' | 'head', round: number, heapSnapshotSavePath?: string) { await util.prepareInstance(baseUrl); - return await Chrome.with(label, { scenarioTimeoutMs }, async chrome => { + return await PlaywrightBrowser.with(label, { scenarioTimeoutMs, baseUrl }, async chrome => { await chrome.enableNetworkTracking(); const startedAt = Date.now(); diff --git a/.github/workflows/frontend-browser-metrics-report.yml b/.github/workflows/frontend-browser-metrics-report.yml index 992c43a4df..93b49073bf 100644 --- a/.github/workflows/frontend-browser-metrics-report.yml +++ b/.github/workflows/frontend-browser-metrics-report.yml @@ -27,6 +27,7 @@ on: - .github/scripts/frontend-browser-report.mts - .github/scripts/heap-snapshot-util.mts - .github/scripts/measure-frontend-browser-comparison.mts + - .github/scripts/chrome.mts - .github/workflows/frontend-browser-metrics-report.yml - .github/workflows/frontend-browser-metrics-report-comment.yml @@ -112,6 +113,10 @@ jobs: working-directory: after run: pnpm build + - name: Install Playwright browsers + working-directory: after/packages/frontend + run: pnpm exec playwright install --with-deps chromium + - name: Measure frontend browser metrics shell: bash env: