From e51e5b5a3d4e0186cf5cce791f0e0187e8d0ac08 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:06:49 +0200 Subject: [PATCH 1/6] test(connect): unblock hung launchServer teardown with SIGKILL fallback (#42148) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Simon Knott --- tests/config/remoteServer.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/config/remoteServer.ts b/tests/config/remoteServer.ts index b541628da09c7..fc334af94d9d2 100644 --- a/tests/config/remoteServer.ts +++ b/tests/config/remoteServer.ts @@ -165,7 +165,12 @@ export class RemoteServer implements PlaywrightServer { await this._browser.close(); this._browser = undefined; } - await this._process.kill('SIGINT'); - await this.childExitCode(); + void this._process.kill('SIGINT'); + const killTimer = setTimeout(() => void this._process.kill('SIGINT'), 30000); + try { + await this.childExitCode(); + } finally { + clearTimeout(killTimer); + } } } From bcb3563aa73d7ac71ac8cb877433201b1b97b7da Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 12 Aug 2026 15:19:12 +0200 Subject: [PATCH 2/6] devops: gate PR CI triage on write access (#42215) --- .github/workflows/create_test_report.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/create_test_report.yml b/.github/workflows/create_test_report.yml index e47c9933963ff..2716035797788 100644 --- a/.github/workflows/create_test_report.yml +++ b/.github/workflows/create_test_report.yml @@ -74,11 +74,18 @@ jobs: echo "number=$NUMBER" >> "$GITHUB_OUTPUT" AUTHOR=$(gh pr view --repo "${{ github.repository }}" "$HEAD_REF" --json author --jq '.author.login' 2>/dev/null || true) - ALLOWED="github-actions[bot] pavelfeldman yury-s dgozman Skn0tt dcrousso" TRIAGE_ALLOWED=false - for a in $ALLOWED; do - if [ "$a" = "$AUTHOR" ]; then TRIAGE_ALLOWED=true; break; fi - done + case "$AUTHOR" in + app/github-actions|app/microsoft-playwright-automation) + TRIAGE_ALLOWED=true + ;; + *) + if [ -n "$AUTHOR" ]; then + PERM=$(gh api "repos/${{ github.repository }}/collaborators/$AUTHOR/permission" --jq .permission 2>/dev/null || echo none) + case "$PERM" in write|admin) TRIAGE_ALLOWED=true ;; esac + fi + ;; + esac echo "triage_allowed=$TRIAGE_ALLOWED" >> "$GITHUB_OUTPUT" - name: Post report comment to PR From b9ab6ff948b4b5c9420f29d79760618dcd480324 Mon Sep 17 00:00:00 2001 From: John Hill Date: Wed, 12 Aug 2026 10:59:04 -0700 Subject: [PATCH 3/6] feat(trace): add --grep filter to trace console (#42217) --- .../src/tools/skills/playwright-trace/SKILL.md | 3 +++ packages/playwright-core/src/tools/trace/traceCli.ts | 3 ++- .../playwright-core/src/tools/trace/traceConsole.ts | 12 +++++++++--- tests/mcp/trace-cli.spec.ts | 8 ++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/tools/skills/playwright-trace/SKILL.md b/packages/playwright-core/src/tools/skills/playwright-trace/SKILL.md index c1da2ef591e7f..c92e2a5d83f3f 100644 --- a/packages/playwright-core/src/tools/skills/playwright-trace/SKILL.md +++ b/packages/playwright-core/src/tools/skills/playwright-trace/SKILL.md @@ -94,6 +94,9 @@ npx playwright trace console --browser # Only stdout/stderr (no browser console) npx playwright trace console --stdio + +# Filter by message text pattern +npx playwright trace console --grep "failed to fetch" ``` ### Errors diff --git a/packages/playwright-core/src/tools/trace/traceCli.ts b/packages/playwright-core/src/tools/trace/traceCli.ts index 629f65595e401..91a64b03040ec 100644 --- a/packages/playwright-core/src/tools/trace/traceCli.ts +++ b/packages/playwright-core/src/tools/trace/traceCli.ts @@ -86,11 +86,12 @@ export function addTraceCommands(program: Command, logErrorAndExit: (e: Error) = traceCommand .command('console') .description('show console messages') + .option('--grep ', 'filter by message text pattern') .option('--errors-only', 'only show errors') .option('--warnings', 'show errors and warnings') .option('--browser', 'only browser console messages') .option('--stdio', 'only stdout/stderr') - .action(async (options: { errorsOnly?: boolean, warnings?: boolean, browser?: boolean, stdio?: boolean }) => { + .action(async (options: { grep?: string, errorsOnly?: boolean, warnings?: boolean, browser?: boolean, stdio?: boolean }) => { traceConsole(options).catch(logErrorAndExit); }); diff --git a/packages/playwright-core/src/tools/trace/traceConsole.ts b/packages/playwright-core/src/tools/trace/traceConsole.ts index d90879d86db70..6da27338919c6 100644 --- a/packages/playwright-core/src/tools/trace/traceConsole.ts +++ b/packages/playwright-core/src/tools/trace/traceConsole.ts @@ -18,7 +18,7 @@ import { loadTrace, formatTimestamp } from './traceUtils'; -export async function traceConsole(options: { errorsOnly?: boolean, warnings?: boolean, browser?: boolean, stdio?: boolean }) { +export async function traceConsole(options: { grep?: string, errorsOnly?: boolean, warnings?: boolean, browser?: boolean, stdio?: boolean }) { const trace = await loadTrace(); const model = trace.model; @@ -88,12 +88,18 @@ export async function traceConsole(options: { errorsOnly?: boolean, warnings?: b items.sort((a, b) => a.timestamp - b.timestamp); - if (!items.length) { + let filtered = items; + if (options.grep) { + const pattern = new RegExp(options.grep, 'i'); + filtered = filtered.filter(item => pattern.test(item.text)); + } + + if (!filtered.length) { console.log(' No console entries'); return; } - for (const item of items) { + for (const item of filtered) { const ts = formatTimestamp(item.timestamp, model.startTime); const source = item.type === 'browser' ? '[browser]' : `[${item.type}]`; const level = item.level.padEnd(8); diff --git a/tests/mcp/trace-cli.spec.ts b/tests/mcp/trace-cli.spec.ts index 49d14c5af67a6..32385694ca58d 100644 --- a/tests/mcp/trace-cli.spec.ts +++ b/tests/mcp/trace-cli.spec.ts @@ -161,6 +161,14 @@ test('trace console --errors-only', async ({ runTraceCli }) => { expect(stdout).not.toContain('info message'); }); +test('trace console --grep filters by message text', async ({ runTraceCli }) => { + const { stdout, exitCode } = await runTraceCli(['console', '--grep', 'warning']); + expect(exitCode).toBe(0); + expect(stdout).toContain('warning message'); + expect(stdout).not.toContain('info message'); + expect(stdout).not.toContain('error message'); +}); + test('trace errors', async ({ runTraceCli }) => { const { stdout, exitCode } = await runTraceCli(['errors']); expect(exitCode).toBe(0); From 680e5ad5894a54bba9e4ed8a311fd2aee388137d Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:35:39 -0700 Subject: [PATCH 4/6] feat(webkit): roll to r2346 (#42210) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- .../src/server/webkit/protocol.d.ts | 53 +++++++++++-------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 4a52755745aed..e2d18c60b4859 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -24,7 +24,7 @@ }, { "name": "webkit", - "revision": "2342", + "revision": "2346", "installByDefault": true, "revisionOverrides": { "mac14": "2251", diff --git a/packages/playwright-core/src/server/webkit/protocol.d.ts b/packages/playwright-core/src/server/webkit/protocol.d.ts index f7ccf4086bc13..5955ff74828c0 100644 --- a/packages/playwright-core/src/server/webkit/protocol.d.ts +++ b/packages/playwright-core/src/server/webkit/protocol.d.ts @@ -1108,8 +1108,8 @@ export namespace Protocol { /** * The type of rendering context backing the canvas element. */ - export type ContextType = "canvas-2d"|"offscreen-canvas-2d"|"bitmaprenderer"|"offscreen-bitmaprenderer"|"webgl"|"offscreen-webgl"|"webgl2"|"offscreen-webgl2"; - export type ProgramType = "compute"|"render"; + export type ContextType = "canvas-2d"|"offscreen-canvas-2d"|"bitmaprenderer"|"offscreen-bitmaprenderer"|"webgl"|"offscreen-webgl"|"webgl2"|"offscreen-webgl2"|"webgpu"; + export type ProgramType = "compute"|"render"|"vertex"; export type ShaderType = "compute"|"fragment"|"vertex"; /** * Drawing surface attributes. @@ -1172,26 +1172,23 @@ export namespace Protocol { * The type of rendering context backing the canvas. */ contextType: ContextType; - /** - * Width of the canvas in pixels. - */ - width: number; - /** - * Height of the canvas in pixels. - */ - height: number; + sizes?: GenericTypes.Size[]; /** * The corresponding DOM node id. */ nodeId?: DOM.NodeId; /** - * The CSS canvas identifier, for canvases created with document.getCSSCanvasContext. + * The CSS canvas identifiers, for canvases created with document.getCSSCanvasContext. */ - cssCanvasName?: string; + cssCanvasNames?: string[]; /** * Context attributes for rendering contexts. */ contextAttributes?: ContextAttributes; + /** + * Enabled WebGPU device features. + */ + features?: string[]; /** * Memory usage of the canvas in bytes. */ @@ -1200,14 +1197,20 @@ export namespace Protocol { * Backtrace that was captured when this canvas context was created. */ stackTrace?: Console.StackTrace; + name?: string; } /** - * Information about a WebGL/WebGL2 shader program. + * Information about a WebGL/WebGL2 shader program or WebGPU shader pipeline. */ export interface ShaderProgram { programId: ProgramId; programType: ProgramType; canvasId: CanvasId; + /** + * Indicates whether the vertex and fragment shader modules are the same object for a WebGPU render pipeline. + */ + sharesVertexFragmentShader?: boolean; + name?: string; } export type canvasAddedPayload = { @@ -1227,14 +1230,7 @@ export namespace Protocol { * Identifier of canvas that changed. */ canvasId: CanvasId; - /** - * Width of the canvas in pixels. - */ - width: number; - /** - * Height of the canvas in pixels. - */ - height: number; + sizes?: GenericTypes.Size[]; } export type canvasMemoryChangedPayload = { /** @@ -1334,6 +1330,10 @@ export namespace Protocol { } export type requestClientNodesReturnValue = { clientNodeIds: DOM.NodeId[]; + /** + * The CSS canvas identifiers, for canvases created with document.getCSSCanvasContext. + */ + cssCanvasNames: string[]; } /** * Resolves JavaScript canvas/device context object for given canvasId. @@ -4481,6 +4481,13 @@ might return multiple quads for inline nodes. */ lineContent: string; } + /** + * A two-dimensional size. + */ + export interface Size { + width: number; + height: number; + } } @@ -7640,7 +7647,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the /** * The type of the recording. */ - export type Type = "canvas-2d"|"offscreen-canvas-2d"|"canvas-bitmaprenderer"|"offscreen-canvas-bitmaprenderer"|"canvas-webgl"|"offscreen-canvas-webgl"|"canvas-webgl2"|"offscreen-canvas-webgl2"; + export type Type = "canvas-2d"|"offscreen-canvas-2d"|"canvas-bitmaprenderer"|"offscreen-canvas-bitmaprenderer"|"canvas-webgl"|"offscreen-canvas-webgl"|"canvas-webgl2"|"offscreen-canvas-webgl2"|"canvas-webgpu"; export type Initiator = "frontend"|"console"|"auto-capture"; /** * Information about the initial state of the recorded object. @@ -7668,7 +7675,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the */ export interface Frame { /** - * Information about an action made to the recorded object. Follows the structure [name, parameters, swizzleTypes, stackTrace, snapshot], where name is a string, parameters is an array, swizzleTypes is an array, stackTrace is a Console.StackTrace, and snapshot is a data URL image of the current contents after this action. + * Information about an action made to the recorded object. Follows the structure [name, parameters, swizzleTypes, stackTrace, receiver, snapshot], where name is a string, parameters is an array, swizzleTypes is an array, stackTrace is a Console.StackTrace, receiver follows the structure [identifier, swizzleType] for the object that received the action, and snapshot is a data URL image of the current contents after this action. */ actions: any[]; /** From c27d0621f163dc29f4c7e4b93c62f4d1e977f389 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 12 Aug 2026 14:35:58 -0700 Subject: [PATCH 5/6] fix(extension): do not treat orphaned preferences entries as an installed extension (#42224) --- .../src/tools/utils/extension.ts | 20 ++++++++--- tests/extension/extension.spec.ts | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/tools/utils/extension.ts b/packages/playwright-core/src/tools/utils/extension.ts index 94a459766cffe..9e2ed64d6a7e9 100644 --- a/packages/playwright-core/src/tools/utils/extension.ts +++ b/packages/playwright-core/src/tools/utils/extension.ts @@ -66,16 +66,28 @@ export async function isPlaywrightExtensionInstalled(userDataDir: string): Promi } async function isExtensionInstalledInProfile(profileDir: string): Promise { - // Covers two install shapes: web store drops the extension into /Extensions/; - // `--load-extension` does not, and only shows up as the id inside /Preferences. + // Web store installs unpack into /Extensions/; `--load-extension` only + // leaves a settings record in the preferences. if (await pathExists(path.join(profileDir, 'Extensions', playwrightExtensionId))) return true; + // `extensions.settings` lives in Preferences or Secure Preferences depending on the platform. + for (const fileName of ['Preferences', 'Secure Preferences']) { + if (await hasExtensionSettingsRecord(path.join(profileDir, fileName))) + return true; + } + return false; +} + +async function hasExtensionSettingsRecord(prefsPath: string): Promise { + let prefs: any; try { - const prefs = await fs.promises.readFile(path.join(profileDir, 'Preferences'), 'utf-8'); - return prefs.includes(`"${playwrightExtensionId}"`); + prefs = JSON.parse(await fs.promises.readFile(prefsPath, 'utf-8')); } catch { return false; } + // Uninstalling leaves an orphaned empty settings record behind, so require a populated one. + const record = prefs?.extensions?.settings?.[playwrightExtensionId]; + return !!record && typeof record === 'object' && Object.keys(record).length > 0; } async function pathExists(p: string): Promise { diff --git a/tests/extension/extension.spec.ts b/tests/extension/extension.spec.ts index ded650508afc1..d0145b7a1d1fa 100644 --- a/tests/extension/extension.spec.ts +++ b/tests/extension/extension.spec.ts @@ -307,6 +307,42 @@ test(`launches the profile that has the extension`, { }).toPass(); }); +test(`ignores orphaned preferences entries of an uninstalled extension`, { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1712' }, +}, async ({ startClient, server }, testInfo) => { + // Default has orphaned entries of an uninstalled extension; they must not win over the + // actual installation in Profile 1. + const userDataDir = testInfo.outputPath('multi-profile'); + await fs.mkdir(path.join(userDataDir, 'Default'), { recursive: true }); + await fs.writeFile(path.join(userDataDir, 'Default', 'Preferences'), JSON.stringify({ + extensions: { settings: { [extensionId]: {} } }, + protection: { macs: { extensions: { settings: { [extensionId]: 'DEADBEEF' } } } }, + updateclientdata: { apps: { [extensionId]: { pv: '0.3.0' } } }, + })); + await fs.mkdir(path.join(userDataDir, 'Profile 1'), { recursive: true }); + await fs.writeFile(path.join(userDataDir, 'Profile 1', 'Preferences'), JSON.stringify({ + extensions: { settings: { [extensionId]: { path: '/tmp/extension', location: 4 } } }, + })); + // Make the profile with the orphaned entries the preferred, last used one. + await fs.writeFile(path.join(userDataDir, 'Local State'), JSON.stringify({ + profile: { last_used: 'Default' }, + })); + + const executablePath = testInfo.outputPath('echo.sh'); + await fs.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 }); + + const { client } = await startClient({ + args: [`--extension`, `--executable-path=${executablePath}`], + env: { PWTEST_EXTENSION_USER_DATA_DIR: userDataDir }, + }); + + client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }).catch(() => {}); + await expect(async () => { + const output = await fs.readFile(testInfo.outputPath('output.txt'), 'utf8'); + expect(output).toContain(`--profile-directory=Profile 1`); + }).toPass(); +}); + test(`fails when extension is missing in custom userDataDir`, async ({ startClient, server }) => { const userDataDir = test.info().outputPath('empty-profile'); From 07730b7a9dab34d163d34a32e15a81218b345c88 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 12 Aug 2026 14:41:45 -0700 Subject: [PATCH 6/6] feat(install): add --no-remove option to keep unused browsers (#42222) --- docs/src/browsers.md | 18 +++++++++++++++++- docs/src/test-cli-js.md | 1 + .../playwright-core/src/cli/installActions.ts | 4 ++-- packages/playwright-core/src/cli/program.ts | 3 ++- .../playwright-cli-install-should-work.spec.ts | 15 +++++++++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/src/browsers.md b/docs/src/browsers.md index 41d1c81c58ae1..843f294ab522b 100644 --- a/docs/src/browsers.md +++ b/docs/src/browsers.md @@ -1146,7 +1146,23 @@ mvn test Playwright keeps track of the clients that use its browsers. When there are no more clients that require a particular version of the browser, that version is deleted from the system. That way you can safely use Playwright instances of different versions and at the same time, you don't waste disk space for the browsers that are no longer in use. -To opt-out from the unused browser removal, you can set the `PLAYWRIGHT_SKIP_BROWSER_GC=1` environment variable. +To opt-out from the unused browser removal, you can set the `PLAYWRIGHT_SKIP_BROWSER_GC=1` environment variable, or pass the `--no-remove` option to the install command: + +```bash js +npx playwright install --no-remove +``` + +```bash java +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install --no-remove" +``` + +```bash python +playwright install --no-remove +``` + +```bash csharp +pwsh bin/Debug/netX/playwright.ps1 install --no-remove +``` ### List all installed browsers: diff --git a/docs/src/test-cli-js.md b/docs/src/test-cli-js.md index 4fea276f3b685..7c187b1433d82 100644 --- a/docs/src/test-cli-js.md +++ b/docs/src/test-cli-js.md @@ -220,6 +220,7 @@ npx playwright install --with-deps | `--dry-run` | Don't perform installation, just print information | | `--only-shell` | Only install chromium-headless-shell instead of full Chromium | | `--no-shell` | Don't install chromium-headless-shell | +| `--no-remove` | Don't remove unused browsers | #### Install Deps Options diff --git a/packages/playwright-core/src/cli/installActions.ts b/packages/playwright-core/src/cli/installActions.ts index 18258da128dc4..b2aefc2121f4c 100644 --- a/packages/playwright-core/src/cli/installActions.ts +++ b/packages/playwright-core/src/cli/installActions.ts @@ -87,7 +87,7 @@ export async function markDockerImage(dockerImageNameTemplate: string) { await writeDockerVersion(dockerImageNameTemplate); } -export async function installBrowsers(args: string[], options: { withDeps?: boolean, force?: boolean, dryRun?: boolean, list?: boolean, shell?: boolean, noShell?: boolean, onlyShell?: boolean, progress?: boolean }) { +export async function installBrowsers(args: string[], options: { withDeps?: boolean, force?: boolean, dryRun?: boolean, list?: boolean, shell?: boolean, noShell?: boolean, onlyShell?: boolean, progress?: boolean, remove?: boolean }) { if (options.progress === false) process.env.PLAYWRIGHT_DOWNLOAD_NO_PROGRESS = '1'; if (isLikelyNpxGlobal()) { @@ -134,7 +134,7 @@ export async function installBrowsers(args: string[], options: { withDeps?: bool const browsers = await registry.listInstalledBrowsers(); printGroupedByPlaywrightVersion(browsers); } else { - await registry.install(executables, { force: options.force }); + await registry.install(executables, { force: options.force, gc: options.remove }); await registry.validateHostRequirementsForExecutablesIfNeeded(executables, process.env.PW_LANG_NAME || 'javascript').catch((e: Error) => { e.name = 'Playwright Host validation warning'; console.error(e); diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 64ea909101883..93a2323ba1d55 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -82,7 +82,8 @@ export function decorateProgram(program: Command) { .option('--only-shell', 'only install headless shell when installing chromium') .option('--no-shell', 'do not install chromium headless shell') .option('--no-progress', 'do not show download progress bars') - .action(async function(args: string[], options: { withDeps?: boolean, force?: boolean, dryRun?: boolean, list?: boolean, shell?: boolean, noShell?: boolean, onlyShell?: boolean, progress?: boolean }) { + .option('--no-remove', 'do not remove unused browsers') + .action(async function(args: string[], options: { withDeps?: boolean, force?: boolean, dryRun?: boolean, list?: boolean, shell?: boolean, noShell?: boolean, onlyShell?: boolean, progress?: boolean, remove?: boolean }) { try { await installBrowsers(args, options); } catch (e) { diff --git a/tests/installation/playwright-cli-install-should-work.spec.ts b/tests/installation/playwright-cli-install-should-work.spec.ts index 20bbfc1aa5c78..57444c25f2bd7 100755 --- a/tests/installation/playwright-cli-install-should-work.spec.ts +++ b/tests/installation/playwright-cli-install-should-work.spec.ts @@ -15,6 +15,7 @@ */ import { test, expect } from './npmTest'; import { chromium } from '@playwright/test'; +import fs from 'fs'; import path from 'path'; import http from 'http'; import https from 'https'; @@ -164,6 +165,20 @@ test('install command should work with HTTPS proxy for HTTP downloads', async ({ httpsProxyServer.close(); }); +test('install --no-remove should keep unused browsers', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42214' } }, async ({ exec, _browsersPath }) => { + await exec('npm i playwright'); + const staleDirectory = path.join(_browsersPath, 'webkit-1000'); + await fs.promises.mkdir(staleDirectory, { recursive: true }); + + const result = await exec('npx playwright install ffmpeg --no-remove'); + expect(result).not.toContain('Removing unused browser'); + expect(fs.existsSync(staleDirectory)).toBe(true); + + const result2 = await exec('npx playwright install ffmpeg'); + expect(result2).toContain('Removing unused browser'); + expect(fs.existsSync(staleDirectory)).toBe(false); +}); + test('should be able to remove browsers', async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); await exec('npx playwright install chromium');