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
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/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/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/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[];
/**
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/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/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);
+ }
}
}
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');
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');
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);