Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/create_test_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion docs/src/browsers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions docs/src/test-cli-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/browsers.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
{
"name": "webkit",
"revision": "2342",
"revision": "2346",
"installByDefault": true,
"revisionOverrides": {
"mac14": "2251",
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/cli/installActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion packages/playwright-core/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
53 changes: 30 additions & 23 deletions packages/playwright-core/src/server/webkit/protocol.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <code>document.getCSSCanvasContext</code>.
* The CSS canvas identifiers, for canvases created with <code>document.getCSSCanvasContext</code>.
*/
cssCanvasName?: string;
cssCanvasNames?: string[];
/**
* Context attributes for rendering contexts.
*/
contextAttributes?: ContextAttributes;
/**
* Enabled WebGPU device features.
*/
features?: string[];
/**
* Memory usage of the canvas in bytes.
*/
Expand All @@ -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 = {
Expand All @@ -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 = {
/**
Expand Down Expand Up @@ -1334,6 +1330,10 @@ export namespace Protocol {
}
export type requestClientNodesReturnValue = {
clientNodeIds: DOM.NodeId[];
/**
* The CSS canvas identifiers, for canvases created with <code>document.getCSSCanvasContext</code>.
*/
cssCanvasNames: string[];
}
/**
* Resolves JavaScript canvas/device context object for given canvasId.
Expand Down Expand Up @@ -4481,6 +4481,13 @@ might return multiple quads for inline nodes.
*/
lineContent: string;
}
/**
* A two-dimensional size.
*/
export interface Size {
width: number;
height: number;
}


}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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[];
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/playwright-core/src/tools/trace/traceCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,12 @@ export function addTraceCommands(program: Command, logErrorAndExit: (e: Error) =
traceCommand
.command('console')
.description('show console messages')
.option('--grep <pattern>', '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);
});

Expand Down
12 changes: 9 additions & 3 deletions packages/playwright-core/src/tools/trace/traceConsole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
20 changes: 16 additions & 4 deletions packages/playwright-core/src/tools/utils/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,28 @@ export async function isPlaywrightExtensionInstalled(userDataDir: string): Promi
}

async function isExtensionInstalledInProfile(profileDir: string): Promise<boolean> {
// Covers two install shapes: web store drops the extension into <profile>/Extensions/<id>;
// `--load-extension` does not, and only shows up as the id inside <profile>/Preferences.
// Web store installs unpack into <profile>/Extensions/<id>; `--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<boolean> {
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<boolean> {
Expand Down
9 changes: 7 additions & 2 deletions tests/config/remoteServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
36 changes: 36 additions & 0 deletions tests/extension/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
15 changes: 15 additions & 0 deletions tests/installation/playwright-cli-install-should-work.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading