From f8b1f4f8477763fec3fc416f61b1f9d80b283178 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:35:27 -0700 Subject: [PATCH 1/8] feat(webkit): roll to r2270 (#39722) --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index d994050724c3e..a9295b5e930f9 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -45,7 +45,7 @@ }, { "name": "webkit", - "revision": "2269", + "revision": "2270", "installByDefault": true, "revisionOverrides": { "mac14": "2251", From 1ed53ac99ec225a5fc0aacb762a18e3195c61a6c Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 17 Mar 2026 10:58:00 -0700 Subject: [PATCH 2/8] feat(api): return Disposable from Video.start and Screencast.start (#39712) --- docs/src/api/class-screencast.md | 1 + docs/src/api/class-video.md | 53 ++++++++++--------- packages/playwright-client/types/types.d.ts | 33 ++++++------ .../playwright-core/src/client/screencast.ts | 4 +- packages/playwright-core/src/client/video.ts | 14 +++-- packages/playwright-core/types/types.d.ts | 33 ++++++------ tests/library/screencast.spec.ts | 18 +++++++ tests/library/video.spec.ts | 25 ++++++--- 8 files changed, 109 insertions(+), 72 deletions(-) diff --git a/docs/src/api/class-screencast.md b/docs/src/api/class-screencast.md index c5aab7e1af774..c6df3fcd3fb3a 100644 --- a/docs/src/api/class-screencast.md +++ b/docs/src/api/class-screencast.md @@ -26,6 +26,7 @@ await screencast.stop(); ## async method: Screencast.start * since: v1.59 +- returns: <[Disposable]> Starts capturing screencast frames. Frames are emitted as [`event: Screencast.screencastFrame`] events. diff --git a/docs/src/api/class-video.md b/docs/src/api/class-video.md index 294478f29373b..945419c3f68c5 100644 --- a/docs/src/api/class-video.md +++ b/docs/src/api/class-video.md @@ -26,33 +26,33 @@ Console.WriteLine(await page.Video.GetPathAsync()); Alternatively, you can use [`method: Video.start`] and [`method: Video.stop`] to record video manually. This approach is mutually exclusive with the `recordVideo` option. ```js -await page.video().start(); +await page.video().start({ path: 'video.webm' }); // ... perform actions ... -await page.video().stop({ path: 'video.webm' }); +await page.video().stop(); ``` ```java -page.video().start(); +page.video().start(new Video.StartOptions().setPath(Paths.get("video.webm"))); // ... perform actions ... -page.video().stop(new Video.StopOptions().setPath(Paths.get("video.webm"))); +page.video().stop(); ``` ```python async -await page.video.start() +await page.video.start(path="video.webm") # ... perform actions ... -await page.video.stop(path="video.webm") +await page.video.stop() ``` ```python sync -page.video.start() +page.video.start(path="video.webm") # ... perform actions ... -page.video.stop(path="video.webm") +page.video.stop() ``` ```csharp -await page.Video.StartAsync(); +await page.Video.StartAsync(new() { Path = "video.webm" }); // ... perform actions ... -await page.Video.StopAsync(new() { Path = "video.webm" }); +await page.Video.StopAsync(); ``` ## async method: Video.delete @@ -94,41 +94,48 @@ Path where the video should be saved. ## async method: Video.start * since: v1.59 +- returns: <[Disposable]> Starts video recording. This method is mutually exclusive with the `recordVideo` context option. **Usage** ```js -await page.video().start(); +await page.video().start({ path: 'video.webm' }); // ... perform actions ... -await page.video().stop({ path: 'video.webm' }); +await page.video().stop(); ``` ```java -page.video().start(); +page.video().start(new Video.StartOptions().setPath(Paths.get("video.webm"))); // ... perform actions ... -page.video().stop(new Video.StopOptions().setPath(Paths.get("video.webm"))); +page.video().stop(); ``` ```python async -await page.video.start() +await page.video.start(path="video.webm") # ... perform actions ... -await page.video.stop(path="video.webm") +await page.video.stop() ``` ```python sync -page.video.start() +page.video.start(path="video.webm") # ... perform actions ... -page.video.stop(path="video.webm") +page.video.stop() ``` ```csharp -await page.Video.StartAsync(); +await page.Video.StartAsync(new() { Path = "video.webm" }); // ... perform actions ... -await page.Video.StopAsync(new() { Path = "video.webm" }); +await page.Video.StopAsync(); ``` +### option: Video.start.path +* since: v1.59 +- `path` <[path]> + +Path where the video should be saved when the recording is stopped. + ### option: Video.start.size * since: v1.59 - `size` ?<[Object]> @@ -141,9 +148,3 @@ Optional dimensions of the recorded video. If not specified the size will be equ * since: v1.59 Stops video recording started with [`method: Video.start`]. - -### option: Video.stop.path -* since: v1.59 -- `path` <[path]> - -Path where the video should be saved. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 10be6cbb19f2e..572435010d35c 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -4805,8 +4805,8 @@ export interface Page { /** * Video object associated with this page. Can be used to control video recording with * [video.start([options])](https://playwright.dev/docs/api/class-video#video-start) and - * [video.stop([options])](https://playwright.dev/docs/api/class-video#video-stop), or to access the video file when - * using the `recordVideo` context option. + * [video.stop()](https://playwright.dev/docs/api/class-video#video-stop), or to access the video file when using the + * `recordVideo` context option. */ video(): Video; @@ -21778,7 +21778,7 @@ export interface Screencast { */ height: number; }; - }): Promise; + }): Promise; /** * Stops the screencast started with @@ -22110,13 +22110,13 @@ export interface Tracing { * ``` * * Alternatively, you can use [video.start([options])](https://playwright.dev/docs/api/class-video#video-start) and - * [video.stop([options])](https://playwright.dev/docs/api/class-video#video-stop) to record video manually. This - * approach is mutually exclusive with the `recordVideo` option. + * [video.stop()](https://playwright.dev/docs/api/class-video#video-stop) to record video manually. This approach is + * mutually exclusive with the `recordVideo` option. * * ```js - * await page.video().start(); + * await page.video().start({ path: 'video.webm' }); * // ... perform actions ... - * await page.video().stop({ path: 'video.webm' }); + * await page.video().stop(); * ``` * */ @@ -22145,14 +22145,19 @@ export interface Video { * **Usage** * * ```js - * await page.video().start(); + * await page.video().start({ path: 'video.webm' }); * // ... perform actions ... - * await page.video().stop({ path: 'video.webm' }); + * await page.video().stop(); * ``` * * @param options */ start(options?: { + /** + * Path where the video should be saved when the recording is stopped. + */ + path?: string; + /** * Optional dimensions of the recorded video. If not specified the size will be equal to page viewport scaled down to * fit into 800x800. Actual picture of the page will be scaled down if necessary to fit the specified size. @@ -22168,19 +22173,13 @@ export interface Video { */ height: number; }; - }): Promise; + }): Promise; /** * Stops video recording started with * [video.start([options])](https://playwright.dev/docs/api/class-video#video-start). - * @param options */ - stop(options?: { - /** - * Path where the video should be saved. - */ - path?: string; - }): Promise; + stop(): Promise; } /** diff --git a/packages/playwright-core/src/client/screencast.ts b/packages/playwright-core/src/client/screencast.ts index 618e54e8c71cd..bd1ac3dd82daa 100644 --- a/packages/playwright-core/src/client/screencast.ts +++ b/packages/playwright-core/src/client/screencast.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { DisposableStub } from './disposable'; import { EventEmitter } from './eventEmitter'; import type * as api from '../../types/types'; @@ -28,8 +29,9 @@ export class Screencast extends EventEmitter implements api.Screencast { this._page._channel.on('screencastFrame', ({ data }) => this.emit('screencastframe', { data })); } - async start(options: { maxSize?: { width: number, height: number } } = {}): Promise { + async start(options: { maxSize?: { width: number, height: number } } = {}) { await this._page._channel.startScreencast(options); + return new DisposableStub(() => this.stop()); } async stop(): Promise { diff --git a/packages/playwright-core/src/client/video.ts b/packages/playwright-core/src/client/video.ts index 47e594ae536a7..8b3a026d4b069 100644 --- a/packages/playwright-core/src/client/video.ts +++ b/packages/playwright-core/src/client/video.ts @@ -15,6 +15,7 @@ */ import { Artifact } from './artifact'; +import { DisposableStub } from './disposable'; import { EventEmitter } from './eventEmitter'; import type { Connection } from './connection'; @@ -25,6 +26,7 @@ export class Video extends EventEmitter implements api.Video { private _artifact: Artifact | undefined; private _isRemote = false; private _page: Page; + private _savePath: string | undefined; constructor(page: Page, connection: Connection, artifact: Artifact | undefined) { super(page._platform); @@ -33,16 +35,18 @@ export class Video extends EventEmitter implements api.Video { this._artifact = artifact; } - async start(options: { size?: { width: number, height: number } } = {}): Promise { - const result = await this._page._channel.videoStart(options); + async start(options: { path?: string, size?: { width: number, height: number } } = {}) { + const result = await this._page._channel.videoStart({ size: options.size }); this._artifact = Artifact.from(result.artifact); + this._savePath = options.path; + return new DisposableStub(() => this.stop()); } - async stop(options: { path?: string } = {}): Promise { + async stop(): Promise { await this._page._wrapApiCall(async () => { await this._page._channel.videoStop(); - if (options.path) - await this.saveAs(options.path); + if (this._savePath) + await this.saveAs(this._savePath); }); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 10be6cbb19f2e..572435010d35c 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -4805,8 +4805,8 @@ export interface Page { /** * Video object associated with this page. Can be used to control video recording with * [video.start([options])](https://playwright.dev/docs/api/class-video#video-start) and - * [video.stop([options])](https://playwright.dev/docs/api/class-video#video-stop), or to access the video file when - * using the `recordVideo` context option. + * [video.stop()](https://playwright.dev/docs/api/class-video#video-stop), or to access the video file when using the + * `recordVideo` context option. */ video(): Video; @@ -21778,7 +21778,7 @@ export interface Screencast { */ height: number; }; - }): Promise; + }): Promise; /** * Stops the screencast started with @@ -22110,13 +22110,13 @@ export interface Tracing { * ``` * * Alternatively, you can use [video.start([options])](https://playwright.dev/docs/api/class-video#video-start) and - * [video.stop([options])](https://playwright.dev/docs/api/class-video#video-stop) to record video manually. This - * approach is mutually exclusive with the `recordVideo` option. + * [video.stop()](https://playwright.dev/docs/api/class-video#video-stop) to record video manually. This approach is + * mutually exclusive with the `recordVideo` option. * * ```js - * await page.video().start(); + * await page.video().start({ path: 'video.webm' }); * // ... perform actions ... - * await page.video().stop({ path: 'video.webm' }); + * await page.video().stop(); * ``` * */ @@ -22145,14 +22145,19 @@ export interface Video { * **Usage** * * ```js - * await page.video().start(); + * await page.video().start({ path: 'video.webm' }); * // ... perform actions ... - * await page.video().stop({ path: 'video.webm' }); + * await page.video().stop(); * ``` * * @param options */ start(options?: { + /** + * Path where the video should be saved when the recording is stopped. + */ + path?: string; + /** * Optional dimensions of the recorded video. If not specified the size will be equal to page viewport scaled down to * fit into 800x800. Actual picture of the page will be scaled down if necessary to fit the specified size. @@ -22168,19 +22173,13 @@ export interface Video { */ height: number; }; - }): Promise; + }): Promise; /** * Stops video recording started with * [video.start([options])](https://playwright.dev/docs/api/class-video#video-start). - * @param options */ - stop(options?: { - /** - * Path where the video should be saved. - */ - path?: string; - }): Promise; + stop(): Promise; } /** diff --git a/tests/library/screencast.spec.ts b/tests/library/screencast.spec.ts index ade493c914b27..69bdfbe150615 100644 --- a/tests/library/screencast.spec.ts +++ b/tests/library/screencast.spec.ts @@ -91,6 +91,24 @@ test('start throws when video recording is running with different params', async await context.close(); }); +test('screencast.start dispose stops screencast', async ({ browser, server, trace }) => { + test.skip(trace === 'on', 'trace=on has different screencast image configuration'); + const context = await browser.newContext({ viewport: { width: 1000, height: 400 } }); + const page = await context.newPage(); + + const frames: { data: Buffer }[] = []; + page.screencast().on('screencastframe', frame => frames.push(frame)); + + const disposable = await page.screencast().start({ maxSize: { width: 500, height: 400 } }); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => document.body.style.backgroundColor = 'red'); + await rafraf(page, 100); + await disposable.dispose(); + + expect(frames.length).toBeGreaterThan(0); + await context.close(); +}); + test('video.start does not emit screencastframe events', async ({ page, server, trace }) => { test.skip(trace === 'on', 'trace=on enables screencast frame events'); diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index c373c1f38e51b..138345bccf884 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -826,14 +826,14 @@ it.describe('screencast', () => { await page.video().stop(); expectRedFrames(videoPath1, size); - await page.video().start({ size }); + const videoPath3 = testInfo.outputPath('video3.webm'); + await page.video().start({ size, path: videoPath3 }); await page.evaluate(() => document.body.style.backgroundColor = 'rgb(100,100,100)'); await rafraf(page, 100); const videoPath2 = await page.video().path(); expect(videoPath2).toBeDefined(); expect(videoPath2).not.toEqual(videoPath1); - const videoPath3 = testInfo.outputPath('video3.webm'); - await page.video().stop({ path: videoPath3 }); + await page.video().stop(); const contents2 = fs.readFileSync(videoPath2).toString('base64'); const contents3 = fs.readFileSync(videoPath3).toString('base64'); expect(contents2 === contents3).toBeTruthy(); @@ -851,7 +851,7 @@ it.describe('screencast', () => { const page = await context.newPage(); const error = await page.video().start().catch(e => e); expect(error.message).toContain('Video is already being recorded'); - await page.video().stop({ path: testInfo.outputPath('video.webm') }); + await page.video().stop(); await context.close(); }); @@ -891,13 +891,26 @@ it.describe('screencast', () => { const size = { width: 800, height: 800 }; const context = await browser.newContext({ viewport: size }); const page = await context.newPage(); - await page.video().start({ size }); const videoPath = testInfo.outputPath('empty-video.webm'); - await page.video().stop({ path: videoPath }); + await page.video().start({ size, path: videoPath }); + await page.video().stop(); await context.close(); expectFrames(videoPath, size, isAlmostWhite); }); + it('video.start dispose stops recording', async ({ browser }, testInfo) => { + const size = { width: 800, height: 800 }; + const context = await browser.newContext({ viewport: size }); + const page = await context.newPage(); + const videoPath = testInfo.outputPath('dispose-video.webm'); + const disposable = await page.video().start({ size, path: videoPath }); + await page.evaluate(() => document.body.style.backgroundColor = 'red'); + await rafraf(page, 100); + await disposable.dispose(); + expectRedFrames(videoPath, size); + await context.close(); + }); + }); it('should saveAs video', async ({ browser }, testInfo) => { From 7a4bfa1ae6afb2c2b30fe0d4107256793dde8a3a Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 17 Mar 2026 11:26:27 -0700 Subject: [PATCH 3/8] fix(api): make Tracing.group return Disposable (#39729) --- CLAUDE.md | 2 ++ docs/src/api/class-tracing.md | 1 + packages/playwright-client/types/types.d.ts | 2 +- packages/playwright-core/src/client/tracing.ts | 2 ++ packages/playwright-core/types/types.d.ts | 2 +- tests/library/screencast.spec.ts | 4 ++-- 6 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b85a3d23fa0e..d5bdcd3a62372 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,8 @@ When creating or moving files, update the relevant `DEPS.list` to declare allowe ## Commit Convention +Before committing, run `npm run flint` and fix errors. + Semantic commit messages: `label(scope): description` Labels: `fix`, `feat`, `chore`, `docs`, `test`, `devops` diff --git a/docs/src/api/class-tracing.md b/docs/src/api/class-tracing.md index 704409a1705da..9c4b5221d0e62 100644 --- a/docs/src/api/class-tracing.md +++ b/docs/src/api/class-tracing.md @@ -305,6 +305,7 @@ To specify the final trace zip file name, you need to pass `path` option to ## async method: Tracing.group * since: v1.49 +- returns: <[Disposable]> :::caution Use `test.step` instead when available. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 572435010d35c..3f8e9d677c7cd 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -21957,7 +21957,7 @@ export interface Tracing { column?: number; }; - }): Promise; + }): Promise; /** * Closes the last group created by diff --git a/packages/playwright-core/src/client/tracing.ts b/packages/playwright-core/src/client/tracing.ts index cd5e06ff5d042..5fed143e0a805 100644 --- a/packages/playwright-core/src/client/tracing.ts +++ b/packages/playwright-core/src/client/tracing.ts @@ -16,6 +16,7 @@ import { Artifact } from './artifact'; import { ChannelOwner } from './channelOwner'; +import { DisposableStub } from './disposable'; import type * as api from '../../types/types'; import type * as channels from '@protocol/channels'; @@ -62,6 +63,7 @@ export class Tracing extends ChannelOwner implements ap if (options.location) this._additionalSources.add(options.location.file); await this._channel.tracingGroup({ name, location: options.location }); + return new DisposableStub(() => this.groupEnd()); } async groupEnd() { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 572435010d35c..3f8e9d677c7cd 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -21957,7 +21957,7 @@ export interface Tracing { column?: number; }; - }): Promise; + }): Promise; /** * Closes the last group created by diff --git a/tests/library/screencast.spec.ts b/tests/library/screencast.spec.ts index 69bdfbe150615..7ab98d300b008 100644 --- a/tests/library/screencast.spec.ts +++ b/tests/library/screencast.spec.ts @@ -97,9 +97,9 @@ test('screencast.start dispose stops screencast', async ({ browser, server, trac const page = await context.newPage(); const frames: { data: Buffer }[] = []; - page.screencast().on('screencastframe', frame => frames.push(frame)); + page.screencast.on('screencastframe', frame => frames.push(frame)); - const disposable = await page.screencast().start({ maxSize: { width: 500, height: 400 } }); + const disposable = await page.screencast.start({ maxSize: { width: 500, height: 400 } }); await page.goto(server.EMPTY_PAGE); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await rafraf(page, 100); From b5d02856e2dbe720c231da752875b8f5c06c05d5 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 17 Mar 2026 15:39:05 -0700 Subject: [PATCH 4/8] docs(skill): fix video recording command order and syntax (#39730) --- .../src/skill/references/video-recording.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/playwright-core/src/skill/references/video-recording.md b/packages/playwright-core/src/skill/references/video-recording.md index 38391b37ada54..2501cace5c828 100644 --- a/packages/playwright-core/src/skill/references/video-recording.md +++ b/packages/playwright-core/src/skill/references/video-recording.md @@ -5,17 +5,20 @@ Capture browser automation sessions as video for debugging, documentation, or ve ## Basic Recording ```bash +# Open browser first +playwright-cli open + # Start recording playwright-cli video-start -# Perform actions -playwright-cli open https://example.com +# Navigate and perform actions +playwright-cli goto https://example.com playwright-cli snapshot playwright-cli click e1 playwright-cli fill e2 "test input" # Stop and save -playwright-cli video-stop demo.webm +playwright-cli video-stop --filename=demo.webm ``` ## Best Practices @@ -24,8 +27,8 @@ playwright-cli video-stop demo.webm ```bash # Include context in filename -playwright-cli video-stop recordings/login-flow-2024-01-15.webm -playwright-cli video-stop recordings/checkout-test-run-42.webm +playwright-cli video-stop --filename=recordings/login-flow-2024-01-15.webm +playwright-cli video-stop --filename=recordings/checkout-test-run-42.webm ``` ## Tracing vs Video From 66f55cbbf872353969f8af53143a9ade55a6e58e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 17 Mar 2026 15:40:08 -0700 Subject: [PATCH 5/8] docs(skill): add eval examples for inspecting element attributes (#39728) --- packages/playwright-core/src/skill/SKILL.md | 4 ++++ .../skill/references/element-attributes.md | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 packages/playwright-core/src/skill/references/element-attributes.md diff --git a/packages/playwright-core/src/skill/SKILL.md b/packages/playwright-core/src/skill/SKILL.md index 4865d3337e43d..22060877694d0 100644 --- a/packages/playwright-core/src/skill/SKILL.md +++ b/packages/playwright-core/src/skill/SKILL.md @@ -46,6 +46,9 @@ playwright-cli snapshot playwright-cli snapshot --filename=after-click.yaml playwright-cli eval "document.title" playwright-cli eval "el => el.textContent" e5 +# get element id, class, or any attribute not visible in the snapshot +playwright-cli eval "el => el.id" e5 +playwright-cli eval "el => el.getAttribute('data-testid')" e5 playwright-cli dialog-accept playwright-cli dialog-accept "confirmation text" playwright-cli dialog-dismiss @@ -307,3 +310,4 @@ playwright-cli close * **Test generation** [references/test-generation.md](references/test-generation.md) * **Tracing** [references/tracing.md](references/tracing.md) * **Video recording** [references/video-recording.md](references/video-recording.md) +* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md) diff --git a/packages/playwright-core/src/skill/references/element-attributes.md b/packages/playwright-core/src/skill/references/element-attributes.md new file mode 100644 index 0000000000000..4e9fa6b99103b --- /dev/null +++ b/packages/playwright-core/src/skill/references/element-attributes.md @@ -0,0 +1,23 @@ +# Inspecting Element Attributes + +When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them. + +## Examples + +```bash +playwright-cli snapshot +# snapshot shows a button as e7 but doesn't reveal its id or data attributes + +# get the element's id +playwright-cli eval "el => el.id" e7 + +# get all CSS classes +playwright-cli eval "el => el.className" e7 + +# get a specific attribute +playwright-cli eval "el => el.getAttribute('data-testid')" e7 +playwright-cli eval "el => el.getAttribute('aria-label')" e7 + +# get a computed style property +playwright-cli eval "el => getComputedStyle(el).display" e7 +``` From 998f35dccb1de560350765493073dec5ec2c811c Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 17 Mar 2026 22:48:27 +0000 Subject: [PATCH 6/8] feat: expose BrowserContext.options() (#39725) --- docs/src/api/class-browsercontext.md | 8 +++++++ packages/playwright-client/types/types.d.ts | 18 +++++++++----- .../src/client/browserContext.ts | 24 ++++++++++++++++++- .../playwright-core/src/client/selectors.ts | 4 +++- packages/playwright-core/types/types.d.ts | 18 +++++++++----- tests/library/browser.spec.ts | 4 ++++ tests/library/browsercontext-basic.spec.ts | 14 +++++++++++ utils/generate_types/overrides.d.ts | 4 ++++ 8 files changed, 80 insertions(+), 14 deletions(-) diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index ea7afa54fc259..806aaee33b283 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1004,6 +1004,14 @@ named `page`, but it can be a `Page` or `Frame` type. Creates a new page in the browser context. +## method: BrowserContext.contextOptions +* since: v1.59 +* langs: js +- returns: <[Object]> + +Returns the context options that were used to create this browser context. The return type matches the options +accepted by [`method: Browser.newContext`]. + ## method: BrowserContext.pages * since: v1.8 - returns: <[Array]<[Page]>> diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 3f8e9d677c7cd..a8796454b58f2 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -8323,6 +8323,12 @@ export interface BrowserContext { */ behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + + /** + * Returns the context options that were used to create this browser context. The return type matches the options + * accepted by [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context). + */ + contextOptions(): BrowserContextOptions; /** * This event is not emitted. */ @@ -9750,6 +9756,12 @@ export interface Browser { */ behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + + /** + * Returns the launch options that were used to launch this browser. The return type matches the options accepted by + * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch). + */ + launchOptions(): LaunchOptions; /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: @@ -9841,12 +9853,6 @@ export interface Browser { */ isConnected(): boolean; - /** - * Returns the launch options that were used to launch this browser. The return type matches the options accepted by - * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch). - */ - launchOptions(): Object; - /** * **NOTE** CDP Sessions are only supported on Chromium-based browsers. * diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 3a06890167776..7212dec579286 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -38,7 +38,7 @@ import { WebError } from './webError'; import { Worker } from './worker'; import { TimeoutSettings } from './timeoutSettings'; import { mkdirIfNeeded } from './fileUtils'; -import { headersObjectToArray } from '../utils/isomorphic/headers'; +import { headersArrayToObject, headersObjectToArray } from '../utils/isomorphic/headers'; import { urlMatchesEqual } from '../utils/isomorphic/urlMatch'; import { isRegExp, isString } from '../utils/isomorphic/rtti'; import { rewriteErrorMessage } from '../utils/isomorphic/stackTrace'; @@ -290,6 +290,10 @@ export class BrowserContext extends ChannelOwner return this._browser; } + contextOptions() { + return contextParamsToPublicOptions(this._options); + } + pages(): Page[] { return [...this._pages]; } @@ -603,6 +607,24 @@ export async function prepareBrowserContextParams(platform: Platform, options: B return contextParams; } +function contextParamsToPublicOptions(params: channels.BrowserNewContextParams): api.BrowserContextOptions { + const result = { + ...params, + viewport: params.noDefaultViewport ? null : params.viewport, + extraHTTPHeaders: params.extraHTTPHeaders ? headersArrayToObject(params.extraHTTPHeaders, false) : undefined, + colorScheme: params.colorScheme === 'no-override' ? null : params.colorScheme, + reducedMotion: params.reducedMotion === 'no-override' ? null : params.reducedMotion, + forcedColors: params.forcedColors === 'no-override' ? null : params.forcedColors, + contrast: params.contrast === 'no-override' ? null : params.contrast, + acceptDownloads: params.acceptDownloads === 'accept' ? true : params.acceptDownloads === 'deny' ? false : undefined, + storageState: undefined, + }; + delete result.clientCertificates; + delete result.noDefaultViewport; + delete result.selectorEngines; + return result; +} + function toAcceptDownloadsProtocol(acceptDownloads?: boolean) { if (acceptDownloads === undefined) return undefined; diff --git a/packages/playwright-core/src/client/selectors.ts b/packages/playwright-core/src/client/selectors.ts index 3a34cef5a96ff..3e6fc905c4802 100644 --- a/packages/playwright-core/src/client/selectors.ts +++ b/packages/playwright-core/src/client/selectors.ts @@ -47,8 +47,10 @@ export class Selectors implements api.Selectors { setTestIdAttribute(attributeName: string) { this._testIdAttributeName = attributeName; setTestIdAttribute(attributeName); - for (const context of this._contextsForSelectors) + for (const context of this._contextsForSelectors) { + context._options.testIdAttributeName = attributeName; context._channel.setTestIdAttributeName({ testIdAttributeName: attributeName }).catch(() => {}); + } } _withSelectorOptions(options: T) { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 3f8e9d677c7cd..a8796454b58f2 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -8323,6 +8323,12 @@ export interface BrowserContext { */ behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + + /** + * Returns the context options that were used to create this browser context. The return type matches the options + * accepted by [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context). + */ + contextOptions(): BrowserContextOptions; /** * This event is not emitted. */ @@ -9750,6 +9756,12 @@ export interface Browser { */ behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + + /** + * Returns the launch options that were used to launch this browser. The return type matches the options accepted by + * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch). + */ + launchOptions(): LaunchOptions; /** * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the * following: @@ -9841,12 +9853,6 @@ export interface Browser { */ isConnected(): boolean; - /** - * Returns the launch options that were used to launch this browser. The return type matches the options accepted by - * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch). - */ - launchOptions(): Object; - /** * **NOTE** CDP Sessions are only supported on Chromium-based browsers. * diff --git a/tests/library/browser.spec.ts b/tests/library/browser.spec.ts index 19d47f871a4ea..a2911f4ad65ef 100644 --- a/tests/library/browser.spec.ts +++ b/tests/library/browser.spec.ts @@ -51,6 +51,10 @@ test('version should work', async function({ browser, browserName }) { expect(version.match(/^\d+\.\d+/)).toBeTruthy(); }); +test('launchOptions() should work', async function({ browser, headless }) { + expect(!!browser.launchOptions().headless).toBe(!!headless); +}); + test('should dispatch page.on(close) upon browser.close and reject evaluate', async ({ browserType }) => { const browser = await browserType.launch(); const page = await browser.newPage(); diff --git a/tests/library/browsercontext-basic.spec.ts b/tests/library/browsercontext-basic.spec.ts index 795e8d1932dae..050d7c36ebcbc 100644 --- a/tests/library/browsercontext-basic.spec.ts +++ b/tests/library/browsercontext-basic.spec.ts @@ -33,6 +33,20 @@ it('should create new context @smoke', async function({ browser }) { await context2.close(); }); +it('should return context options', async function({ browser }) { + const context = await browser.newContext({ + userAgent: 'custom-ua', + locale: 'fr-FR', + extraHTTPHeaders: { 'foo': 'bar' }, + }); + const options = context.contextOptions(); + expect(options.userAgent).toBe('custom-ua'); + expect(options.locale).toBe('fr-FR'); + expect(options.extraHTTPHeaders).toEqual({ 'foo': 'bar' }); + expect((options as any).noDefaultViewport).toBeUndefined(); // internal option should not leak + await context.close(); +}); + it('should be able to click across browser contexts', async function({ browser }) { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29096' }); expect(browser.contexts().length).toBe(0); diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index 3a0c13eed56ea..35f10f96f5e8e 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -133,6 +133,8 @@ export interface BrowserContext { */ behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + + contextOptions(): BrowserContextOptions; } export interface Browser { @@ -146,6 +148,8 @@ export interface Browser { */ behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + + launchOptions(): LaunchOptions; } export interface Worker { From 4df350b1c81b61ea471ff12767efd8e05f773377 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 17 Mar 2026 15:49:14 -0700 Subject: [PATCH 7/8] feat(mcp): filtering and optional headers/body in browser_network_requests (#39672) --- .github/copilot-instructions.md | 9 ++ .../src/tools/backend/network.ts | 34 +++++-- .../src/tools/cli-daemon/commands.ts | 5 +- tests/mcp/cli-devtools.spec.ts | 53 ++++++++++ tests/mcp/network.spec.ts | 97 ++++++++++++++++++- 5 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000000..231189577e90c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,9 @@ +## PR Review Guidelines + +When reviewing pull requests: + +- Only comment on semantically meaningful issues: bugs, incorrect logic, security problems, or API contract violations. +- Skip style, formatting, naming, and whitespace observations unless they cause functional problems. +- Keep each comment short — one or two sentences maximum. +- Do not write long descriptions or summaries of what the code does. +- Do not suggest refactors or improvements unrelated to the PR's stated goal. diff --git a/packages/playwright-core/src/tools/backend/network.ts b/packages/playwright-core/src/tools/backend/network.ts index 2e3040528678e..4097422b63357 100644 --- a/packages/playwright-core/src/tools/backend/network.ts +++ b/packages/playwright-core/src/tools/backend/network.ts @@ -27,7 +27,10 @@ const requests = defineTabTool({ title: 'List network requests', description: 'Returns all network requests since loading the page', inputSchema: z.object({ - includeStatic: z.boolean().default(false).describe('Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.'), + static: z.boolean().default(false).describe('Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.'), + requestBody: z.boolean().default(false).describe('Whether to include request body. Defaults to false.'), + requestHeaders: z.boolean().default(false).describe('Whether to include request headers. Defaults to false.'), + filter: z.string().optional().describe('Only return requests whose URL matches this regexp (e.g. "/api/.*user").'), filename: z.string().optional().describe('Filename to save the network requests to. If not provided, requests are returned as text.'), }), type: 'readOnly', @@ -35,11 +38,17 @@ const requests = defineTabTool({ handle: async (tab, params, response) => { const requests = await tab.requests(); + const filter = params.filter ? new RegExp(params.filter) : undefined; const text: string[] = []; for (const request of requests) { - if (!params.includeStatic && !isFetch(request) && isSuccessfulResponse(request)) + if (!params.static && !isFetch(request) && isSuccessfulResponse(request)) continue; - text.push(await renderRequest(request)); + if (filter) { + filter.lastIndex = 0; + if (!filter.test(request.url())) + continue; + } + text.push(await renderRequest(request, params.requestBody, params.requestHeaders)); } await response.addResult('Network', text.join('\n'), { prefix: 'network', ext: 'log', suggestedFilename: params.filename }); }, @@ -71,16 +80,27 @@ export function isFetch(request: playwright.Request): boolean { return ['fetch', 'xhr'].includes(request.resourceType()); } -export async function renderRequest(request: playwright.Request): Promise { +export async function renderRequest(request: playwright.Request, includeBody = false, includeHeaders = false): Promise { const response = request.existingResponse(); const result: string[] = []; result.push(`[${request.method().toUpperCase()}] ${request.url()}`); if (response) - result.push(`=> [${response.status()}] ${response.statusText()}`); + result.push(` => [${response.status()}] ${response.statusText()}`); else if (request.failure()) - result.push(`=> [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`); - return result.join(' '); + result.push(` => [FAILED] ${request.failure()?.errorText ?? 'Unknown error'}`); + if (includeHeaders) { + const headers = request.headers(); + const headerLines = Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`).join('\n'); + if (headerLines) + result.push(`\n Request headers:\n${headerLines}`); + } + if (includeBody) { + const postData = request.postData(); + if (postData) + result.push(`\n Request body: ${postData}`); + } + return result.join(''); } const networkStateSet = defineTool({ diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 7f7d1f9cc0102..f2325e36a5014 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -759,10 +759,13 @@ const networkRequests = declareCommand({ args: z.object({}), options: z.object({ static: z.boolean().optional().describe('Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.'), + ['request-body']: z.boolean().optional().describe('Whether to include request body. Defaults to false.'), + ['request-headers']: z.boolean().optional().describe('Whether to include request headers. Defaults to false.'), + filter: z.string().optional().describe('Only return requests whose URL matches this regexp (e.g. "/api/.*user").'), clear: z.boolean().optional().describe('Whether to clear the network list'), }), toolName: ({ clear }) => clear ? 'browser_network_clear' : 'browser_network_requests', - toolParams: ({ static: includeStatic, clear }) => clear ? ({}) : ({ includeStatic }), + toolParams: ({ static: s, 'request-body': requestBody, 'request-headers': requestHeaders, filter, clear }) => clear ? ({}) : ({ static: s, requestBody, requestHeaders, filter }), }); const tracingStart = declareCommand({ diff --git a/tests/mcp/cli-devtools.spec.ts b/tests/mcp/cli-devtools.spec.ts index 8bdd8e3397004..12086d8d26be9 100644 --- a/tests/mcp/cli-devtools.spec.ts +++ b/tests/mcp/cli-devtools.spec.ts @@ -64,6 +64,59 @@ test('network --static', async ({ cli, server }) => { expect(attachments[0].data.toString()).toContain(`[GET] ${`${server.PREFIX}/`} => [200] OK`); }); +test('network --filter', async ({ cli, server }) => { + server.setContent('/', ``, 'text/html'); + await cli('open', server.PREFIX); + + const { attachments } = await cli('network', '--filter=/api/', '--static'); + expect(attachments[0].data.toString()).toContain(`${server.PREFIX}/api/users`); + expect(attachments[0].data.toString()).toContain(`${server.PREFIX}/api/orders`); + expect(attachments[0].data.toString()).not.toContain(`${server.PREFIX}/static/image.png`); +}); + +test('network --request-body', async ({ cli, server }) => { + server.setContent('/', ` + + `, 'text/html'); + server.setContent('/api', '{}', 'application/json'); + await cli('open', server.PREFIX); + await cli('click', 'e2'); + + { + const { attachments } = await cli('network'); + expect(attachments[0].data.toString()).not.toContain('Request body:'); + } + + { + const { attachments } = await cli('network', '--request-body'); + expect(attachments[0].data.toString()).toContain(`[POST] ${server.PREFIX}/api => [200] OK`); + expect(attachments[0].data.toString()).toContain('Request body: {"key":"value"}'); + } +}); + +test('network --request-headers', async ({ cli, server }) => { + server.setContent('/', ` + + `, 'text/html'); + server.setContent('/api', '{}', 'application/json'); + await cli('open', server.PREFIX); + await cli('click', 'e2'); + + { + const { attachments } = await cli('network'); + expect(attachments[0].data.toString()).not.toContain('Request headers:'); + } + + { + const { attachments } = await cli('network', '--request-headers'); + expect(attachments[0].data.toString()).toContain(`[GET] ${server.PREFIX}/api => [200] OK`); + expect(attachments[0].data.toString()).toContain('Request headers:'); + expect(attachments[0].data.toString()).toContain('x-custom-header: test-value'); + } +}); + test('network --clear', async ({ cli, server }) => { await cli('open', server.PREFIX); await cli('eval', '() => fetch("/hello-world")'); diff --git a/tests/mcp/network.spec.ts b/tests/mcp/network.spec.ts index 9bfdbe337beb9..94ee4cad6e06f 100644 --- a/tests/mcp/network.spec.ts +++ b/tests/mcp/network.spec.ts @@ -52,7 +52,7 @@ test('browser_network_requests', async ({ client, server }) => { const response = parseResponse(await client.callTool({ name: 'browser_network_requests', arguments: { - includeStatic: true, + static: true, }, })); expect(response.result).toContain(`[GET] ${`${server.PREFIX}/`} => [200] OK`); @@ -60,3 +60,98 @@ test('browser_network_requests', async ({ client, server }) => { expect(response.result).toContain(`[GET] ${`${server.PREFIX}/image.png`} => [404]`); } }); + +test('browser_network_requests filter', async ({ client, server }) => { + server.setContent('/', ``, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + arguments: { filter: '/api/', static: true }, + })); + expect(response.result).toContain(`${server.PREFIX}/api/users`); + expect(response.result).toContain(`${server.PREFIX}/api/orders`); + expect(response.result).not.toContain(`${server.PREFIX}/static/image.png`); + } +}); + +test('browser_network_requests includes request headers', async ({ client, server }) => { + server.setContent('/', ` + + `, 'text/html'); + server.setContent('/api', '{}', 'application/json'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + await client.callTool({ + name: 'browser_click', + arguments: { element: 'Click me button', ref: 'e2' }, + }); + + { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + })); + expect(response.result).not.toContain('Request headers:'); + } + + { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + arguments: { requestHeaders: true }, + })); + expect(response.result).toContain(`[GET] ${server.PREFIX}/api => [200] OK`); + expect(response.result).toContain('Request headers:'); + expect(response.result).toContain('x-custom-header: test-value'); + } +}); + +test('browser_network_requests includes request payload', async ({ client, server }) => { + server.setContent('/', ` + + `, 'text/html'); + + server.setContent('/api', '{}', 'application/json'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + }); + + await client.callTool({ + name: 'browser_click', + arguments: { + element: 'Click me button', + ref: 'e2', + }, + }); + + { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + })); + expect(response.result).toContain(`[POST] ${server.PREFIX}/api => [200] OK`); + expect(response.result).not.toContain(`Request body:`); + } + + { + const response = parseResponse(await client.callTool({ + name: 'browser_network_requests', + arguments: { requestBody: true }, + })); + expect(response.result).toContain(`[POST] ${server.PREFIX}/api => [200] OK`); + expect(response.result).toContain(`Request body: {"key":"value"}`); + } +}); From 61afb37f4e6af73ae536c7d05ee1a926328b6d80 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 17 Mar 2026 16:24:19 -0700 Subject: [PATCH 8/8] chore(cli-client): vendor and simplify minimist as TypeScript (#39734) --- CLAUDE.md | 1 + package-lock.json | 8 - package.json | 1 - .../src/tools/cli-client/DEPS.list | 5 + .../src/tools/cli-client/minimist.ts | 164 ++++++++++++++++++ .../src/tools/cli-client/program.ts | 29 +--- .../src/tools/cli-client/session.ts | 10 +- 7 files changed, 179 insertions(+), 39 deletions(-) create mode 100644 packages/playwright-core/src/tools/cli-client/minimist.ts diff --git a/CLAUDE.md b/CLAUDE.md index d5bdcd3a62372..15e5d82a420e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,7 @@ EOF ``` Never add Co-Authored-By agents in commit message. +Never add "Generated with" in commit message. Branch naming for issue fixes: `fix-` ## Development Guides diff --git a/package-lock.json b/package-lock.json index 94f09f6995ab9..79c1b9fda82ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "@types/codemirror": "^5.60.7", "@types/formidable": "^2.0.4", "@types/mdast": "^4.0.4", - "@types/minimist": "^1.2.5", "@types/node": "18.19.76", "@types/react": "^19.2.1", "@types/react-dom": "^19.2.1", @@ -1853,13 +1852,6 @@ "@types/unist": "*" } }, - "node_modules/@types/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "18.19.76", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.76.tgz", diff --git a/package.json b/package.json index df5fe7fe4af65..e209868709e1f 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ "@types/codemirror": "^5.60.7", "@types/formidable": "^2.0.4", "@types/mdast": "^4.0.4", - "@types/minimist": "^1.2.5", "@types/node": "18.19.76", "@types/react": "^19.2.1", "@types/react-dom": "^19.2.1", diff --git a/packages/playwright-core/src/tools/cli-client/DEPS.list b/packages/playwright-core/src/tools/cli-client/DEPS.list index a780566006ba7..7901dac1293e6 100644 --- a/packages/playwright-core/src/tools/cli-client/DEPS.list +++ b/packages/playwright-core/src/tools/cli-client/DEPS.list @@ -1,11 +1,13 @@ [program.ts] "strict" +./minimist.ts ./session.ts ./registry.ts ../../serverRegistry.ts [session.ts] "strict" +./minimist.ts ../utils/socketConnection.ts ./registry.ts @@ -14,3 +16,6 @@ [registry.ts] "strict" + +[minimist.ts] +"strict" diff --git a/packages/playwright-core/src/tools/cli-client/minimist.ts b/packages/playwright-core/src/tools/cli-client/minimist.ts new file mode 100644 index 0000000000000..28e6f75257b22 --- /dev/null +++ b/packages/playwright-core/src/tools/cli-client/minimist.ts @@ -0,0 +1,164 @@ +/** + * MIT License + * + * Copyright (c) 2013 James Halliday and contributors + * Modifications copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export interface MinimistOptions { + string?: string | string[]; + boolean?: string | string[]; +} + +export interface MinimistArgs { + _: string[]; + [key: string]: string | boolean | string[] | undefined; +} + +export function minimist(args: string[], opts?: MinimistOptions): MinimistArgs { + if (!opts) + opts = {}; + + const bools: Record = {}; + const strings: Record = {}; + + for (const key of toArray(opts.boolean)) + bools[key] = true; + + for (const key of toArray(opts.string)) + strings[key] = true; + + const argv: MinimistArgs = { _: [] }; + + function setArg(key: string, val: string | boolean): void { + if (argv[key] === undefined || bools[key] || typeof argv[key] === 'boolean') + argv[key] = val; + else if (Array.isArray(argv[key])) + (argv[key] as string[]).push(val as string); + else + argv[key] = [argv[key] as string, val as string]; + } + + let notFlags: string[] = []; + const doubleDashIndex = args.indexOf('--'); + if (doubleDashIndex !== -1) { + notFlags = args.slice(doubleDashIndex + 1); + args = args.slice(0, doubleDashIndex); + } + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + let key: string; + let next: string; + + if ((/^--.+=/).test(arg)) { + const m = arg.match(/^--([^=]+)=([\s\S]*)$/)!; + key = m[1]; + if (bools[key]) + throw new Error(`boolean option '--${key}' should not be passed with '=value', use '--${key}' or '--no-${key}' instead`); + setArg(key, m[2]); + } else if ((/^--no-.+/).test(arg)) { + key = arg.match(/^--no-(.+)/)![1]; + setArg(key, false); + } else if ((/^--.+/).test(arg)) { + key = arg.match(/^--(.+)/)![1]; + next = args[i + 1]; + if ( + next !== undefined + && !(/^(-|--)[^-]/).test(next) + && !bools[key] + ) { + setArg(key, next); + i += 1; + } else if ((/^(true|false)$/).test(next)) { + setArg(key, next === 'true'); + i += 1; + } else { + setArg(key, strings[key] ? '' : true); + } + } else if ((/^-[^-]+/).test(arg)) { + const letters = arg.slice(1, -1).split(''); + + let broken = false; + for (let j = 0; j < letters.length; j++) { + next = arg.slice(j + 2); + + if (next === '-') { + setArg(letters[j], next); + continue; + } + + if ((/[A-Za-z]/).test(letters[j]) && next[0] === '=') { + setArg(letters[j], next.slice(1)); + broken = true; + break; + } + + if ( + (/[A-Za-z]/).test(letters[j]) + && (/-?\d+(\.\d*)?(e-?\d+)?$/).test(next) + ) { + setArg(letters[j], next); + broken = true; + break; + } + + if (letters[j + 1] && letters[j + 1].match(/\W/)) { + setArg(letters[j], arg.slice(j + 2)); + broken = true; + break; + } else { + setArg(letters[j], strings[letters[j]] ? '' : true); + } + } + + key = arg.slice(-1)[0]; + if (!broken && key !== '-') { + if ( + args[i + 1] + && !(/^(-|--)[^-]/).test(args[i + 1]) + && !bools[key] + ) { + setArg(key, args[i + 1]); + i += 1; + } else if (args[i + 1] && (/^(true|false)$/).test(args[i + 1])) { + setArg(key, args[i + 1] === 'true'); + i += 1; + } else { + setArg(key, strings[key] ? '' : true); + } + } + } else { + argv._.push(arg); + } + } + + for (const k of notFlags) + argv._.push(k); + + return argv; +} + +function toArray(value: string | string[] | undefined): string[] { + if (!value) + return []; + return Array.isArray(value) ? value : [value]; +} diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts index cba03b08ee44d..7996c5b48b0d6 100644 --- a/packages/playwright-core/src/tools/cli-client/program.ts +++ b/packages/playwright-core/src/tools/cli-client/program.ts @@ -26,15 +26,12 @@ import path from 'path'; import { createClientInfo, explicitSessionName, Registry, resolveSessionName } from './registry'; import { Session, renderResolvedConfig } from './session'; import { serverRegistry } from '../../serverRegistry'; +import { minimist } from './minimist'; import type { Config } from '../mcp/config.d'; import type { ClientInfo, SessionFile } from './registry'; import type { BrowserDescriptor } from '../../serverRegistry'; - -type MinimistArgs = { - _: string[]; - [key: string]: any; -}; +import type { MinimistArgs } from './minimist'; type GlobalOptions = { help?: boolean; @@ -77,21 +74,7 @@ export async function program(options?: { embedderVersion?: string}) { const argv = process.argv.slice(2); const boolean = [...help.booleanOptions, ...booleanOptions]; - const args: MinimistArgs = require('minimist')(argv, { boolean, string: ['_'] }); - for (const [key, value] of Object.entries(args)) { - if (key !== '_' && typeof value !== 'boolean') - args[key] = String(value); - } - for (let index = 0; index < args._.length; index++) - args._[index] = String(args._[index]); - for (const option of boolean) { - if (!argv.includes(`--${option}`) && !argv.includes(`--no-${option}`)) - delete args[option]; - if (argv.some(arg => arg.startsWith(`--${option}=`) || arg.startsWith(`--no-${option}=`))) { - console.error(`boolean option '--${option}' should not be passed with '=value', use '--${option}' or '--no-${option}' instead`); - process.exit(1); - } - } + const args: MinimistArgs = minimist(argv, { boolean, string: ['_'] }); // Normalize -s alias to --session if (args.s) { args.session = args.s; @@ -123,11 +106,11 @@ export async function program(options?: { embedderVersion?: string}) { } const registry = await Registry.load(); - const sessionName = resolveSessionName(args.session); + const sessionName = resolveSessionName(args.session as string); switch (commandName) { case 'list': { - await listSessions(registry, clientInfo, args.all); + await listSessions(registry, clientInfo, !!args.all); return; } case 'close-all': { @@ -155,7 +138,7 @@ export async function program(options?: { embedderVersion?: string}) { } case 'attach': { const attachTarget = args._[1]; - const attachSessionName = explicitSessionName(args.session) ?? attachTarget; + const attachSessionName = explicitSessionName(args.session as string) ?? attachTarget; args.attach = attachTarget; args.session = attachSessionName; await startSession(attachSessionName, registry, clientInfo, args); diff --git a/packages/playwright-core/src/tools/cli-client/session.ts b/packages/playwright-core/src/tools/cli-client/session.ts index 2702c346efd17..577b665a61245 100644 --- a/packages/playwright-core/src/tools/cli-client/session.ts +++ b/packages/playwright-core/src/tools/cli-client/session.ts @@ -26,11 +26,7 @@ import { compareSemver, SocketConnection } from '../utils/socketConnection'; import { resolveSessionName } from './registry'; import type { SessionConfig, ClientInfo, SessionFile } from './registry'; - -type MinimistArgs = { - _: string[]; - [key: string]: any; -}; +import type { MinimistArgs } from './minimist'; export class Session { readonly name: string; @@ -47,7 +43,7 @@ export class Session { return compareSemver(clientInfo.version, this.config.version) >= 0; } - async run(clientInfo: ClientInfo, args: MinimistArgs, cwd?: string): Promise<{ text: string }> { + async run(clientInfo: ClientInfo, args: MinimistArgs): Promise<{ text: string }> { if (!this.isCompatible(clientInfo)) throw new Error(`Client is v${clientInfo.version}, session '${this.name}' is v${this.config.version}. Run\n\n playwright-cli${this.name !== 'default' ? ` -s=${this.name}` : ''} open\n\nto restart the browser session.`); @@ -127,7 +123,7 @@ export class Session { await fs.promises.mkdir(clientInfo.daemonProfilesDir, { recursive: true }); const cliPath = require.resolve('../cli-daemon/program.js'); - const sessionName = resolveSessionName(cliArgs.session); + const sessionName = resolveSessionName(cliArgs.session as string); const errLog = path.join(clientInfo.daemonProfilesDir, sessionName + '.err'); const err = fs.openSync(errLog, 'w');