From 2a31eea2eec7d9cda953e36d7306aa860c594179 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 9 Mar 2026 08:54:37 -0700 Subject: [PATCH 1/2] feat(cdp): add 'event' and 'close' events to CDPSession (#39552) --- .claude/skills/playwright-dev/api.md | 4 +- docs/src/api/class-cdpsession.md | 24 +++ packages/playwright-client/types/types.d.ts | 160 +++++++++++++++++- .../playwright-core/src/client/cdpSession.ts | 5 + .../playwright-core/src/protocol/validator.ts | 1 + .../dispatchers/cdpSessionDispatcher.ts | 5 +- packages/playwright-core/types/types.d.ts | 160 +++++++++++++++++- packages/protocol/src/channels.d.ts | 3 + packages/protocol/src/protocol.yml | 2 + tests/library/chromium/session.spec.ts | 30 ++++ utils/generate_types/overrides.d.ts | 10 +- 11 files changed, 387 insertions(+), 17 deletions(-) diff --git a/.claude/skills/playwright-dev/api.md b/.claude/skills/playwright-dev/api.md index 06431bbe61c62..2ef9ecb7d43ab 100644 --- a/.claude/skills/playwright-dev/api.md +++ b/.claude/skills/playwright-dev/api.md @@ -30,7 +30,7 @@ Description of the option. ``` **Key syntax rules:** -- `* since: v1.XX` — version from package.json (without -next) +- `* since: v1.XX` — always take the version from package.json (without -next) - `* langs: js, python` — language filter (optional) - `* langs: alias-java: navigate` — language-specific method name - `* deprecated: v1.XX` — deprecation marker @@ -60,6 +60,8 @@ Description. Description. ``` +Keep methods, events and property definitions sorted alphabetically within the file. + Watch will kick in and auto-generate: - `packages/playwright-core/types/types.d.ts` — public API types - `packages/playwright/types/test.d.ts` — test API types diff --git a/docs/src/api/class-cdpsession.md b/docs/src/api/class-cdpsession.md index a14dd58fb4ffc..8efa8e1da71ad 100644 --- a/docs/src/api/class-cdpsession.md +++ b/docs/src/api/class-cdpsession.md @@ -67,6 +67,30 @@ params.addProperty("playbackRate", playbackRate / 2); client.send("Animation.setPlaybackRate", params); ``` +## event: CDPSession.close +* since: v1.59 +* langs: js + +Emitted when the session is closed, either because the target was closed or `session.detach()` was called. + +## event: CDPSession.event +* since: v1.59 +* langs: js +- argument: <[Object]> + - `name` <[string]> CDP event name. + - `params` ?<[Object]> CDP event parameters. + +Emitted for every CDP event received from the session. Allows subscribing to all CDP events at once without knowing +their names ahead of time. + +**Usage** + +```js +session.on('event', ({ name, params }) => { + console.log(`CDP event: ${name}`, params); +}); +``` + ## async method: CDPSession.detach * since: v1.8 diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 815eb462afc7f..2048ef36657b7 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -16023,11 +16023,11 @@ export interface BrowserType { * */ export interface CDPSession { - on: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - addListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - off: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - removeListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - once: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + on(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + addListener(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + off(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + removeListener(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + once(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; /** * @param method Protocol method name. * @param params Optional method parameters. @@ -16036,6 +16036,156 @@ export interface CDPSession { method: T, params?: Protocol.CommandParameters[T] ): Promise; + /** + * Emitted when the session is closed, either because the target was closed or `session.detach()` was called. + */ + on(event: 'close', listener: () => any): this; + + /** + * Emitted for every CDP event received from the session. Allows subscribing to all CDP events at once without knowing + * their names ahead of time. + * + * **Usage** + * + * ```js + * session.on('event', ({ name, params }) => { + * console.log(`CDP event: ${name}`, params); + * }); + * ``` + * + */ + on(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'close', listener: () => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + + /** + * Emitted when the session is closed, either because the target was closed or `session.detach()` was called. + */ + addListener(event: 'close', listener: () => any): this; + + /** + * Emitted for every CDP event received from the session. Allows subscribing to all CDP events at once without knowing + * their names ahead of time. + * + * **Usage** + * + * ```js + * session.on('event', ({ name, params }) => { + * console.log(`CDP event: ${name}`, params); + * }); + * ``` + * + */ + addListener(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'close', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'close', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + + /** + * Emitted when the session is closed, either because the target was closed or `session.detach()` was called. + */ + prependListener(event: 'close', listener: () => any): this; + + /** + * Emitted for every CDP event received from the session. Allows subscribing to all CDP events at once without knowing + * their names ahead of time. + * + * **Usage** + * + * ```js + * session.on('event', ({ name, params }) => { + * console.log(`CDP event: ${name}`, params); + * }); + * ``` + * + */ + prependListener(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + /** * Detaches the CDPSession from the target. Once detached, the CDPSession object won't emit any events and can't be * used to send messages. diff --git a/packages/playwright-core/src/client/cdpSession.ts b/packages/playwright-core/src/client/cdpSession.ts index 0a58f79d27bba..25f374af79209 100644 --- a/packages/playwright-core/src/client/cdpSession.ts +++ b/packages/playwright-core/src/client/cdpSession.ts @@ -30,6 +30,11 @@ export class CDPSession extends ChannelOwner impleme this._channel.on('event', ({ method, params }) => { this.emit(method, params); + this.emit('event', { name: method, params }); + }); + + this._channel.on('close', () => { + this.emit('close'); }); this.on = super.on; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 1a64a527bc56c..2eea4cb028581 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -2609,6 +2609,7 @@ scheme.CDPSessionEventEvent = tObject({ method: tString, params: tOptional(tAny), }); +scheme.CDPSessionCloseEvent = tOptional(tObject({})); scheme.CDPSessionSendParams = tObject({ method: tString, params: tOptional(tAny), diff --git a/packages/playwright-core/src/server/dispatchers/cdpSessionDispatcher.ts b/packages/playwright-core/src/server/dispatchers/cdpSessionDispatcher.ts index 58d785705ef3f..8e16795e24259 100644 --- a/packages/playwright-core/src/server/dispatchers/cdpSessionDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/cdpSessionDispatcher.ts @@ -28,7 +28,10 @@ export class CDPSessionDispatcher extends Dispatcher this._dispatchEvent('event', { method, params })); - this.addObjectListener(CDPSession.Events.Closed, () => this._dispose()); + this.addObjectListener(CDPSession.Events.Closed, () => { + this._dispatchEvent('close'); + this._dispose(); + }); } async send(params: channels.CDPSessionSendParams, progress: Progress): Promise { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 815eb462afc7f..2048ef36657b7 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -16023,11 +16023,11 @@ export interface BrowserType { * */ export interface CDPSession { - on: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - addListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - off: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - removeListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - once: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + on(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + addListener(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + off(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + removeListener(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + once(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; /** * @param method Protocol method name. * @param params Optional method parameters. @@ -16036,6 +16036,156 @@ export interface CDPSession { method: T, params?: Protocol.CommandParameters[T] ): Promise; + /** + * Emitted when the session is closed, either because the target was closed or `session.detach()` was called. + */ + on(event: 'close', listener: () => any): this; + + /** + * Emitted for every CDP event received from the session. Allows subscribing to all CDP events at once without knowing + * their names ahead of time. + * + * **Usage** + * + * ```js + * session.on('event', ({ name, params }) => { + * console.log(`CDP event: ${name}`, params); + * }); + * ``` + * + */ + on(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'close', listener: () => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + + /** + * Emitted when the session is closed, either because the target was closed or `session.detach()` was called. + */ + addListener(event: 'close', listener: () => any): this; + + /** + * Emitted for every CDP event received from the session. Allows subscribing to all CDP events at once without knowing + * their names ahead of time. + * + * **Usage** + * + * ```js + * session.on('event', ({ name, params }) => { + * console.log(`CDP event: ${name}`, params); + * }); + * ``` + * + */ + addListener(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'close', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'close', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + + /** + * Emitted when the session is closed, either because the target was closed or `session.detach()` was called. + */ + prependListener(event: 'close', listener: () => any): this; + + /** + * Emitted for every CDP event received from the session. Allows subscribing to all CDP events at once without knowing + * their names ahead of time. + * + * **Usage** + * + * ```js + * session.on('event', ({ name, params }) => { + * console.log(`CDP event: ${name}`, params); + * }); + * ``` + * + */ + prependListener(event: 'event', listener: (data: { + /** + * CDP event name. + */ + name: string; + + /** + * CDP event parameters. + */ + params?: Object; + }) => any): this; + /** * Detaches the CDPSession from the target. Once detached, the CDPSession object won't emit any events and can't be * used to send messages. diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index b5b4c0407c12e..4773a2875eb79 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -4550,6 +4550,7 @@ export interface WritableStreamEvents { export type CDPSessionInitializer = {}; export interface CDPSessionEventTarget { on(event: 'event', callback: (params: CDPSessionEventEvent) => void): this; + on(event: 'close', callback: (params: CDPSessionCloseEvent) => void): this; } export interface CDPSessionChannel extends CDPSessionEventTarget, Channel { _type_CDPSession: boolean; @@ -4560,6 +4561,7 @@ export type CDPSessionEventEvent = { method: string, params?: any, }; +export type CDPSessionCloseEvent = {}; export type CDPSessionSendParams = { method: string, params?: any, @@ -4576,6 +4578,7 @@ export type CDPSessionDetachResult = void; export interface CDPSessionEvents { 'event': CDPSessionEventEvent; + 'close': CDPSessionCloseEvent; } // ----------- Electron ----------- diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 8114c4099d729..d5254a0af52a1 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -3989,6 +3989,8 @@ CDPSession: method: string params: json? + close: + Electron: type: interface diff --git a/tests/library/chromium/session.spec.ts b/tests/library/chromium/session.spec.ts index c2069a7a3ca11..5e7789b7bd7b9 100644 --- a/tests/library/chromium/session.spec.ts +++ b/tests/library/chromium/session.spec.ts @@ -146,6 +146,36 @@ browserTest('should reject protocol calls when page closes', async function({ br await context.close(); }); +it('should emit event for each CDP event', async function({ page, server }) { + const client = await page.context().newCDPSession(page); + await client.send('Network.enable'); + const events = []; + client.on('event', event => events.push(event)); + await page.goto(server.EMPTY_PAGE); + expect(events.length).toBeGreaterThan(0); + const requestEvent = events.find(e => e.name === 'Network.requestWillBeSent'); + expect(requestEvent).toBeTruthy(); + expect(requestEvent.params.request.url).toBe(server.EMPTY_PAGE); +}); + +it('should emit close event when session is detached', async function({ page }) { + const client = await page.context().newCDPSession(page); + let closeFired = false; + client.on('close', () => closeFired = true); + await client.detach(); + expect(closeFired).toBe(true); +}); + +browserTest('should emit close event when page closes', async function({ browser }) { + const context = await browser.newContext(); + const page = await context.newPage(); + const session = await context.newCDPSession(page); + const closePromise = new Promise(f => session.on('close', f)); + await page.close(); + await closePromise; + await context.close(); +}); + browserTest('should work with newBrowserCDPSession', async function({ browser }) { const session = await browser.newBrowserCDPSession(); diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index d34403fb5d731..f5677d8f6150d 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -229,11 +229,11 @@ export interface BrowserType { } export interface CDPSession { - on: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - addListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - off: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - removeListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - once: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + on(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + addListener(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + off(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + removeListener(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; + once(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void): this; send( method: T, params?: Protocol.CommandParameters[T] From 9605ceae3d614c431744d02657d09e5dadcbf224 Mon Sep 17 00:00:00 2001 From: Chris <57954026+cpAdm@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:31:00 +0100 Subject: [PATCH 2/2] feat: Add global config option for `/children` property of aria snapshots (#39466) --- docs/src/aria-snapshots.md | 19 ++ docs/src/test-api/class-testconfig.md | 1 + docs/src/test-api/class-testproject.md | 1 + .../src/matchers/toMatchAriaSnapshot.ts | 5 + packages/playwright/types/test.d.ts | 14 ++ .../aria-snapshot-file.spec.ts | 179 ++++++++++++++++++ 6 files changed, 219 insertions(+) diff --git a/docs/src/aria-snapshots.md b/docs/src/aria-snapshots.md index 00321151aadb1..ad0ee92d74701 100644 --- a/docs/src/aria-snapshots.md +++ b/docs/src/aria-snapshots.md @@ -307,6 +307,25 @@ Following snapshot will fail due to Feature C not being in the template: - listitem: Feature B ``` +#### Setting `children` mode globally + +Instead of adding a `/children` property to every snapshot, you can set the default children matching mode for all +`toMatchAriaSnapshot` calls in the configuration file: + +```js title="playwright.config.ts" +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + expect: { + toMatchAriaSnapshot: { + children: 'equal', + }, + }, +}); +``` + +Individual snapshots can still override the global setting by including an explicit `/children` property in the template. + ### Matching with regular expressions Regular expressions allow flexible matching for elements with dynamic or variable text. Accessible names and text can diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index a01babad1f23b..fb642f79d6090 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -83,6 +83,7 @@ The structure of the git commit metadata is subject to change. - `pathTemplate` ?<[string]> A template controlling location of the screenshots. See [`property: TestConfig.snapshotPathTemplate`] for details. - `toMatchAriaSnapshot` ?<[Object]> Configuration for the [`method: LocatorAssertions.toMatchAriaSnapshot#2`] method. - `pathTemplate` ?<[string]> A template controlling location of the aria snapshots. See [`property: TestConfig.snapshotPathTemplate`] for details. + - `children` ?<["contain" | "equal" | "deep-equal"]> Controls how children of the snapshot root are matched against the actual accessibility tree. This is equivalent to adding a `/children` property at the top of every aria snapshot template. Individual snapshots can override this by including an explicit `/children` property. - `toMatchSnapshot` ?<[Object]> Configuration for the [`method: SnapshotAssertions.toMatchSnapshot#1`] method. - `maxDiffPixels` ?<[int]> An acceptable amount of pixels that could be different, unset by default. - `maxDiffPixelRatio` ?<[float]> An acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1` , unset by default. diff --git a/docs/src/test-api/class-testproject.md b/docs/src/test-api/class-testproject.md index 30f316c82b151..d301719c7c5eb 100644 --- a/docs/src/test-api/class-testproject.md +++ b/docs/src/test-api/class-testproject.md @@ -101,6 +101,7 @@ export default defineConfig({ - `pathTemplate` ?<[string]> A template controlling location of the screenshots. See [`property: TestProject.snapshotPathTemplate`] for details. - `toMatchAriaSnapshot` ?<[Object]> Configuration for the [`method: LocatorAssertions.toMatchAriaSnapshot#2`] method. - `pathTemplate` ?<[string]> A template controlling location of the aria snapshots. See [`property: TestProject.snapshotPathTemplate`] for details. + - `children` ?<["contain" | "equal" | "deep-equal"]> Controls how children of the snapshot root are matched against the actual accessibility tree. This is equivalent to adding a `/children` property at the top of every aria snapshot template. Individual snapshots can override this by including an explicit `/children` property. - `toMatchSnapshot` ?<[Object]> Configuration for the [`method: SnapshotAssertions.toMatchSnapshot#1`] method. - `threshold` ?<[float]> an acceptable perceived color difference between the same pixel in compared images, ranging from `0` (strict) and `1` (lax). `"pixelmatch"` comparator computes color difference in [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`. - `maxDiffPixels` ?<[int]> an acceptable amount of pixels that could be different, unset by default. diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 15e828f70bf37..8212ad6623bff 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -80,6 +80,11 @@ export async function toMatchAriaSnapshot( } expected = unshift(expected); + + const globalChildren = testInfo._projectInternal.expect?.toMatchAriaSnapshot?.children; + if (globalChildren && !expected.match(/^- \/children:/m)) + expected = `- /children: ${globalChildren}\n` + expected; + const { matches: pass, received, log, timedOut, errorMessage } = await locator._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout }); const typedReceived = received as MatcherReceived; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 0553609ebdeea..c30f7bca04dab 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -250,6 +250,13 @@ interface TestProject { * for details. */ pathTemplate?: string; + + /** + * Controls how children of the snapshot root are matched against the actual accessibility tree. This is equivalent to + * adding a `/children` property at the top of every aria snapshot template. Individual snapshots can override this by + * including an explicit `/children` property. + */ + children?: "contain"|"equal"|"deep-equal"; }; /** @@ -1181,6 +1188,13 @@ interface TestConfig { * for details. */ pathTemplate?: string; + + /** + * Controls how children of the snapshot root are matched against the actual accessibility tree. This is equivalent to + * adding a `/children` property at the top of every aria snapshot template. Individual snapshots can override this by + * including an explicit `/children` property. + */ + children?: "contain"|"equal"|"deep-equal"; }; /** diff --git a/tests/playwright-test/aria-snapshot-file.spec.ts b/tests/playwright-test/aria-snapshot-file.spec.ts index f568dc5c310d5..ded73bcc3c06d 100644 --- a/tests/playwright-test/aria-snapshot-file.spec.ts +++ b/tests/playwright-test/aria-snapshot-file.spec.ts @@ -251,3 +251,182 @@ test('should respect config.expect.toMatchAriaSnapshot.pathTemplate', async ({ r expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); }); + +test('should fail with config.expect.toMatchAriaSnapshot.children=equal when root children mismatch', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + expect: { + toMatchAriaSnapshot: { + children: 'equal', + }, + }, + }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page }) => { + await page.setContent(\`

Title

Paragraph

\`); + await expect(page.locator('body')).toMatchAriaSnapshot(\` + - heading "Title" [level=1] + \`, { timeout: 1000 }); + }); + ` + }); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain('+ - paragraph: Paragraph'); +}); + +test('should respect config.expect.toMatchAriaSnapshot.children=equal with file snapshot', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + expect: { + toMatchAriaSnapshot: { + children: 'equal', + }, + }, + }; + `, + 'a.spec.ts-snapshots/test.aria.yml': ` + - list: + - listitem: "One" + - listitem: "Two" + - listitem: "Three" + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page }) => { + await page.setContent(\`
  • One
  • Two
  • Three
\`); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.aria.yml' }); + }); + ` + }); + + expect(result.exitCode).toBe(0); +}); + +test('should respect config.expect.toMatchAriaSnapshot.children=deep-equal', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + expect: { + toMatchAriaSnapshot: { + children: 'deep-equal', + }, + }, + }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page }) => { + await page.setContent(\`
    • 1.1
    • 1.2
\`); + await expect(page.locator('body')).toMatchAriaSnapshot(\` + - list: + - listitem: + - list: + - listitem: "1.1" + \`, { timeout: 1000 }); + }); + ` + }); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain('+ - listitem: "1.2"'); +}); + +test('inline /children property should override global children config', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + expect: { + toMatchAriaSnapshot: { + children: 'equal', + }, + }, + }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page }) => { + await page.setContent(\`
  • One
  • Two
  • Three
\`); + await expect(page.locator('body')).toMatchAriaSnapshot(\` + - list: + - /children: contain + - listitem: "One" + - listitem: "Three" + \`); + }); + ` + }); + + expect(result.exitCode).toBe(0); +}); + +test('should respect project-level expect.toMatchAriaSnapshot.children=equal', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + projects: [ + { + name: 'strict', + expect: { + toMatchAriaSnapshot: { + children: 'equal', + }, + }, + }, + ], + }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('matching passes', async ({ page }) => { + await page.setContent(\`

Title

\`); + await expect(page.locator('body')).toMatchAriaSnapshot(\` + - heading "Title" [level=1] + \`); + }); + test('extra root child fails', async ({ page }) => { + await page.setContent(\`

Title

Paragraph

\`); + await expect(page.locator('body')).toMatchAriaSnapshot(\` + - heading "Title" [level=1] + \`, { timeout: 1000 }); + }); + ` + }); + + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(1); + expect(result.failed).toBe(1); + expect(result.output).toContain('+ - paragraph: Paragraph'); +}); + + +test('top-level equal should overwrite deep-equal children config', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + expect: { + toMatchAriaSnapshot: { + children: 'deep-equal', + }, + }, + }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('equal matches part of tree', async ({ page }) => { + await page.setContent(\`
  • One
  • Two
\`); + await expect(page.locator('body')).toMatchAriaSnapshot(\` + - /children: equal + - list: + - listitem: "One" + \`); + }); + ` + }); + + expect(result.exitCode).toBe(0); +});