From 5d825673465644b2911f2788f10b41abe4962320 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 4 Feb 2025 11:15:51 +0100 Subject: [PATCH] feat: emulate `prefers-contrast` (#34494) --- docs/src/api/class-page.md | 12 +++++++ docs/src/api/params.md | 14 ++++++++ .../src/client/browserContext.ts | 1 + packages/playwright-core/src/client/page.ts | 3 +- packages/playwright-core/src/client/types.ts | 3 +- .../playwright-core/src/protocol/validator.ts | 5 +++ .../src/server/browserContext.ts | 1 + .../src/server/chromium/crPage.ts | 2 ++ .../src/server/dispatchers/pageDispatcher.ts | 1 + .../src/server/firefox/ffBrowser.ts | 6 ++++ .../src/server/firefox/ffPage.ts | 2 ++ packages/playwright-core/src/server/page.ts | 4 +++ packages/playwright-core/src/server/types.ts | 2 ++ .../src/server/webkit/wkPage.ts | 16 ++++++--- packages/playwright-core/types/types.d.ts | 34 +++++++++++++++++++ packages/protocol/src/channels.d.ts | 10 ++++++ packages/protocol/src/protocol.yml | 12 +++++++ tests/library/defaultbrowsercontext-2.spec.ts | 6 ++++ tests/page/page-emulate-media.spec.ts | 31 ++++++++++++++++- 19 files changed, 158 insertions(+), 7 deletions(-) diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index ec58c027b3..b59f2cb3fe 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -1309,6 +1309,18 @@ Emulates `'forced-colors'` media feature, supported values are `'active'` and `' * langs: csharp, python - `forcedColors` <[ForcedColors]<"active"|"none"|"null">> +### option: Page.emulateMedia.contrast +* since: v1.51 +* langs: js, java +- `contrast` > + +Emulates `'prefers-contrast'` media feature, supported values are `'no-preference'`, `'more'`. Passing `null` disables contrast emulation. + +### option: Page.emulateMedia.contrast +* since: v1.51 +* langs: csharp, python +- `contrast` <[Contrast]<"no-preference"|"more"|"null">> + ## async method: Page.evalOnSelector * since: v1.9 * discouraged: This method does not wait for the element to pass actionability diff --git a/docs/src/api/params.md b/docs/src/api/params.md index d161f91066..f7e1f8a909 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -673,6 +673,18 @@ Emulates `'forced-colors'` media feature, supported values are `'active'`, `'non Emulates `'forced-colors'` media feature, supported values are `'active'`, `'none'`. See [`method: Page.emulateMedia`] for more details. Passing `'null'` resets emulation to system defaults. Defaults to `'none'`. +## context-option-contrast +* langs: js, java +- `contrast` > + +Emulates `'prefers-contrast'` media feature, supported values are `'no-preference'`, `'more'`. See [`method: Page.emulateMedia`] for more details. Passing `null` resets emulation to system defaults. Defaults to `'no-preference'`. + +## context-option-contrast-csharp-python +* langs: csharp, python +- `contrast` <[ForcedColors]<"no-preference"|"more"|"null">> + +Emulates `'prefers-contrast'` media feature, supported values are `'no-preference'`, `'more'`. See [`method: Page.emulateMedia`] for more details. Passing `'null'` resets emulation to system defaults. Defaults to `'no-preference'`. + ## context-option-logger * langs: js - `logger` <[Logger]> @@ -973,6 +985,8 @@ between the same pixel in compared images, between zero (strict) and one (lax), - %%-context-option-reducedMotion-csharp-python-%% - %%-context-option-forcedColors-%% - %%-context-option-forcedColors-csharp-python-%% +- %%-context-option-contrast-%% +- %%-context-option-contrast-csharp-python-%% - %%-context-option-logger-%% - %%-context-option-videospath-%% - %%-context-option-videosize-%% diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 5ff432ec60..528f1b1a27 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -537,6 +537,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions colorScheme: options.colorScheme === null ? 'no-override' : options.colorScheme, reducedMotion: options.reducedMotion === null ? 'no-override' : options.reducedMotion, forcedColors: options.forcedColors === null ? 'no-override' : options.forcedColors, + contrast: options.contrast === null ? 'no-override' : options.contrast, acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads), clientCertificates: await toClientCertificatesProtocol(options.clientCertificates), }; diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index f1d90fece2..d0db2cbae9 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -483,12 +483,13 @@ export class Page extends ChannelOwner implements api.Page await this._channel.requestGC(); } - async emulateMedia(options: { media?: 'screen' | 'print' | null, colorScheme?: 'dark' | 'light' | 'no-preference' | null, reducedMotion?: 'reduce' | 'no-preference' | null, forcedColors?: 'active' | 'none' | null } = {}) { + async emulateMedia(options: { media?: 'screen' | 'print' | null, colorScheme?: 'dark' | 'light' | 'no-preference' | null, reducedMotion?: 'reduce' | 'no-preference' | null, forcedColors?: 'active' | 'none' | null, contrast?: 'no-preference' | 'more' | null } = {}) { await this._channel.emulateMedia({ media: options.media === null ? 'no-override' : options.media, colorScheme: options.colorScheme === null ? 'no-override' : options.colorScheme, reducedMotion: options.reducedMotion === null ? 'no-override' : options.reducedMotion, forcedColors: options.forcedColors === null ? 'no-override' : options.forcedColors, + contrast: options.contrast === null ? 'no-override' : options.contrast, }); } diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 11049b2111..795b24bde3 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -58,7 +58,7 @@ export type ClientCertificate = { passphrase?: string; }; -export type BrowserContextOptions = Omit & { +export type BrowserContextOptions = Omit & { viewport?: Size | null; extraHTTPHeaders?: Headers; logger?: Logger; @@ -80,6 +80,7 @@ export type BrowserContextOptions = Omit { return this._browser.session.send('Browser.setVideoRecordingOptions', { diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 68559d7c7e..71199dc8d5 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -347,12 +347,14 @@ export class FFPage implements PageDelegate { const colorScheme = emulatedMedia.colorScheme === 'no-override' ? undefined : emulatedMedia.colorScheme; const reducedMotion = emulatedMedia.reducedMotion === 'no-override' ? undefined : emulatedMedia.reducedMotion; const forcedColors = emulatedMedia.forcedColors === 'no-override' ? undefined : emulatedMedia.forcedColors; + const contrast = emulatedMedia.contrast === 'no-override' ? undefined : emulatedMedia.contrast; await this._session.send('Page.setEmulatedMedia', { // Empty string means reset. type: emulatedMedia.media === 'no-override' ? '' : emulatedMedia.media, colorScheme, reducedMotion, forcedColors, + contrast, }); } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 9b85837b65..b8fdae2808 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -107,6 +107,7 @@ type EmulatedMedia = { colorScheme: types.ColorScheme; reducedMotion: types.ReducedMotion; forcedColors: types.ForcedColors; + contrast: types.Contrast; }; type ExpectScreenshotOptions = ImageComparatorOptions & ScreenshotOptions & { @@ -530,6 +531,8 @@ export class Page extends SdkObject { this._emulatedMedia.reducedMotion = options.reducedMotion; if (options.forcedColors !== undefined) this._emulatedMedia.forcedColors = options.forcedColors; + if (options.contrast !== undefined) + this._emulatedMedia.contrast = options.contrast; await this._delegate.updateEmulateMedia(); } @@ -541,6 +544,7 @@ export class Page extends SdkObject { colorScheme: this._emulatedMedia.colorScheme !== undefined ? this._emulatedMedia.colorScheme : contextOptions.colorScheme ?? 'light', reducedMotion: this._emulatedMedia.reducedMotion !== undefined ? this._emulatedMedia.reducedMotion : contextOptions.reducedMotion ?? 'no-preference', forcedColors: this._emulatedMedia.forcedColors !== undefined ? this._emulatedMedia.forcedColors : contextOptions.forcedColors ?? 'none', + contrast: this._emulatedMedia.contrast !== undefined ? this._emulatedMedia.contrast : contextOptions.contrast ?? 'no-preference', }; } diff --git a/packages/playwright-core/src/server/types.ts b/packages/playwright-core/src/server/types.ts index b58ea5af83..b5603f19e6 100644 --- a/packages/playwright-core/src/server/types.ts +++ b/packages/playwright-core/src/server/types.ts @@ -84,6 +84,8 @@ export type ReducedMotion = 'no-preference' | 'reduce' | 'no-override'; export type ForcedColors = 'active' | 'none' | 'no-override'; +export type Contrast = 'no-preference' | 'more' | 'no-override'; + export type DeviceDescriptor = { userAgent: string, viewport: Size, diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 15005f589b..4a78642668 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -191,8 +191,8 @@ export class WKPage implements PageDelegate { if (contextOptions.userAgent) promises.push(this.updateUserAgent()); const emulatedMedia = this._page.emulatedMedia(); - if (emulatedMedia.media || emulatedMedia.colorScheme || emulatedMedia.reducedMotion || emulatedMedia.forcedColors) - promises.push(WKPage._setEmulateMedia(session, emulatedMedia.media, emulatedMedia.colorScheme, emulatedMedia.reducedMotion, emulatedMedia.forcedColors)); + if (emulatedMedia.media || emulatedMedia.colorScheme || emulatedMedia.reducedMotion || emulatedMedia.forcedColors || emulatedMedia.contrast) + promises.push(WKPage._setEmulateMedia(session, emulatedMedia.media, emulatedMedia.colorScheme, emulatedMedia.reducedMotion, emulatedMedia.forcedColors, emulatedMedia.contrast)); const bootstrapScript = this._calculateBootstrapScript(); if (bootstrapScript.length) promises.push(session.send('Page.setBootstrapScript', { source: bootstrapScript })); @@ -615,7 +615,7 @@ export class WKPage implements PageDelegate { await this._page._onFileChooserOpened(handle); } - private static async _setEmulateMedia(session: WKSession, mediaType: types.MediaType, colorScheme: types.ColorScheme, reducedMotion: types.ReducedMotion, forcedColors: types.ForcedColors): Promise { + private static async _setEmulateMedia(session: WKSession, mediaType: types.MediaType, colorScheme: types.ColorScheme, reducedMotion: types.ReducedMotion, forcedColors: types.ForcedColors, contrast: types.Contrast): Promise { const promises = []; promises.push(session.send('Page.setEmulatedMedia', { media: mediaType === 'no-override' ? '' : mediaType })); let appearance: any = undefined; @@ -639,6 +639,13 @@ export class WKPage implements PageDelegate { case 'no-override': forcedColorsWk = undefined; break; } promises.push(session.send('Page.setForcedColors', { forcedColors: forcedColorsWk })); + let contrastWk: any = undefined; + switch (contrast) { + case 'more': contrastWk = 'More'; break; + case 'no-preference': contrastWk = 'NoPreference'; break; + case 'no-override': contrastWk = undefined; break; + } + promises.push(session.send('Page.overrideUserPreference', { name: 'PrefersContrast', value: contrastWk })); await Promise.all(promises); } @@ -661,7 +668,8 @@ export class WKPage implements PageDelegate { const colorScheme = emulatedMedia.colorScheme; const reducedMotion = emulatedMedia.reducedMotion; const forcedColors = emulatedMedia.forcedColors; - await this._forAllSessions(session => WKPage._setEmulateMedia(session, emulatedMedia.media, colorScheme, reducedMotion, forcedColors)); + const contrast = emulatedMedia.contrast; + await this._forAllSessions(session => WKPage._setEmulateMedia(session, emulatedMedia.media, colorScheme, reducedMotion, forcedColors, contrast)); } async updateEmulatedViewportSize(): Promise { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 7d4c4b09b2..c4b409ae0d 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2565,6 +2565,12 @@ export interface Page { */ colorScheme?: null|"light"|"dark"|"no-preference"; + /** + * Emulates `'prefers-contrast'` media feature, supported values are `'no-preference'`, `'more'`. Passing `null` + * disables contrast emulation. + */ + contrast?: null|"no-preference"|"more"; + /** * Emulates `'forced-colors'` media feature, supported values are `'active'` and `'none'`. Passing `null` disables * forced colors emulation. @@ -9770,6 +9776,13 @@ export interface Browser { */ colorScheme?: null|"light"|"dark"|"no-preference"; + /** + * Emulates `'prefers-contrast'` media feature, supported values are `'no-preference'`, `'more'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'no-preference'`. + */ + contrast?: null|"no-preference"|"more"; + /** * Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about * [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices). @@ -14797,6 +14810,13 @@ export interface BrowserType { */ colorScheme?: null|"light"|"dark"|"no-preference"; + /** + * Emulates `'prefers-contrast'` media feature, supported values are `'no-preference'`, `'more'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'no-preference'`. + */ + contrast?: null|"no-preference"|"more"; + /** * Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about * [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices). @@ -16609,6 +16629,13 @@ export interface AndroidDevice { */ colorScheme?: null|"light"|"dark"|"no-preference"; + /** + * Emulates `'prefers-contrast'` media feature, supported values are `'no-preference'`, `'more'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'no-preference'`. + */ + contrast?: null|"no-preference"|"more"; + /** * Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about * [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices). @@ -21969,6 +21996,13 @@ export interface BrowserContextOptions { */ colorScheme?: null|"light"|"dark"|"no-preference"; + /** + * Emulates `'prefers-contrast'` media feature, supported values are `'no-preference'`, `'more'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'no-preference'`. + */ + contrast?: null|"no-preference"|"more"; + /** * Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about * [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices). diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 30f8c03088..0ab6e37266 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1006,6 +1006,7 @@ export type BrowserTypeLaunchPersistentContextParams = { reducedMotion?: 'reduce' | 'no-preference' | 'no-override', forcedColors?: 'active' | 'none' | 'no-override', acceptDownloads?: 'accept' | 'deny' | 'internal-browser-default', + contrast?: 'no-preference' | 'more' | 'no-override', baseURL?: string, recordVideo?: { dir: string, @@ -1086,6 +1087,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { reducedMotion?: 'reduce' | 'no-preference' | 'no-override', forcedColors?: 'active' | 'none' | 'no-override', acceptDownloads?: 'accept' | 'deny' | 'internal-browser-default', + contrast?: 'no-preference' | 'more' | 'no-override', baseURL?: string, recordVideo?: { dir: string, @@ -1201,6 +1203,7 @@ export type BrowserNewContextParams = { reducedMotion?: 'reduce' | 'no-preference' | 'no-override', forcedColors?: 'active' | 'none' | 'no-override', acceptDownloads?: 'accept' | 'deny' | 'internal-browser-default', + contrast?: 'no-preference' | 'more' | 'no-override', baseURL?: string, recordVideo?: { dir: string, @@ -1267,6 +1270,7 @@ export type BrowserNewContextOptions = { reducedMotion?: 'reduce' | 'no-preference' | 'no-override', forcedColors?: 'active' | 'none' | 'no-override', acceptDownloads?: 'accept' | 'deny' | 'internal-browser-default', + contrast?: 'no-preference' | 'more' | 'no-override', baseURL?: string, recordVideo?: { dir: string, @@ -1336,6 +1340,7 @@ export type BrowserNewContextForReuseParams = { reducedMotion?: 'reduce' | 'no-preference' | 'no-override', forcedColors?: 'active' | 'none' | 'no-override', acceptDownloads?: 'accept' | 'deny' | 'internal-browser-default', + contrast?: 'no-preference' | 'more' | 'no-override', baseURL?: string, recordVideo?: { dir: string, @@ -1402,6 +1407,7 @@ export type BrowserNewContextForReuseOptions = { reducedMotion?: 'reduce' | 'no-preference' | 'no-override', forcedColors?: 'active' | 'none' | 'no-override', acceptDownloads?: 'accept' | 'deny' | 'internal-browser-default', + contrast?: 'no-preference' | 'more' | 'no-override', baseURL?: string, recordVideo?: { dir: string, @@ -2063,12 +2069,14 @@ export type PageEmulateMediaParams = { colorScheme?: 'dark' | 'light' | 'no-preference' | 'no-override', reducedMotion?: 'reduce' | 'no-preference' | 'no-override', forcedColors?: 'active' | 'none' | 'no-override', + contrast?: 'no-preference' | 'more' | 'no-override', }; export type PageEmulateMediaOptions = { media?: 'screen' | 'print' | 'no-override', colorScheme?: 'dark' | 'light' | 'no-preference' | 'no-override', reducedMotion?: 'reduce' | 'no-preference' | 'no-override', forcedColors?: 'active' | 'none' | 'no-override', + contrast?: 'no-preference' | 'more' | 'no-override', }; export type PageEmulateMediaResult = void; export type PageExposeBindingParams = { @@ -4761,6 +4769,7 @@ export type AndroidDeviceLaunchBrowserParams = { reducedMotion?: 'reduce' | 'no-preference' | 'no-override', forcedColors?: 'active' | 'none' | 'no-override', acceptDownloads?: 'accept' | 'deny' | 'internal-browser-default', + contrast?: 'no-preference' | 'more' | 'no-override', baseURL?: string, recordVideo?: { dir: string, @@ -4825,6 +4834,7 @@ export type AndroidDeviceLaunchBrowserOptions = { reducedMotion?: 'reduce' | 'no-preference' | 'no-override', forcedColors?: 'active' | 'none' | 'no-override', acceptDownloads?: 'accept' | 'deny' | 'internal-browser-default', + contrast?: 'no-preference' | 'more' | 'no-override', baseURL?: string, recordVideo?: { dir: string, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 77501efa9b..8569eef8ff 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -508,6 +508,12 @@ ContextOptions: - accept - deny - internal-browser-default + contrast: + type: enum? + literals: + - no-preference + - more + - no-override baseURL: string? recordVideo: type: object? @@ -1420,6 +1426,12 @@ Page: - active - none - no-override + contrast: + type: enum? + literals: + - no-preference + - more + - no-override flags: snapshot: true diff --git a/tests/library/defaultbrowsercontext-2.spec.ts b/tests/library/defaultbrowsercontext-2.spec.ts index a54f4ee15a..af244f6e78 100644 --- a/tests/library/defaultbrowsercontext-2.spec.ts +++ b/tests/library/defaultbrowsercontext-2.spec.ts @@ -51,6 +51,12 @@ it('should support forcedColors option', async ({ launchPersistent, browserName expect(await page.evaluate(() => matchMedia('(forced-colors: none)').matches)).toBe(false); }); +it('should support contrast option', async ({ launchPersistent }) => { + const { page } = await launchPersistent({ contrast: 'more' }); + expect.soft(await page.evaluate(() => matchMedia('(prefers-contrast: more)').matches)).toBe(true); + expect.soft(await page.evaluate(() => matchMedia('(prefers-contrast: no-preference)').matches)).toBe(false); +}); + it('should support timezoneId option', async ({ launchPersistent, browserName }) => { const { page } = await launchPersistent({ locale: 'en-US', timezoneId: 'America/Jamaica' }); expect(await page.evaluate(() => new Date(1479579154987).toString())).toBe('Sat Nov 19 2016 13:12:34 GMT-0500 (Eastern Standard Time)'); diff --git a/tests/page/page-emulate-media.spec.ts b/tests/page/page-emulate-media.spec.ts index c895d64eff..734bda766b 100644 --- a/tests/page/page-emulate-media.spec.ts +++ b/tests/page/page-emulate-media.spec.ts @@ -15,7 +15,24 @@ * limitations under the License. */ -import { test as it, expect } from './pageTest'; +import type { Page } from 'packages/playwright-test'; +import { test as it, expect as baseExpect } from './pageTest'; + +const expect = baseExpect.extend({ + async toMatchMedia(page: Page, mediaQuery: string) { + const pass = await page.evaluate(mediaQuery => matchMedia(mediaQuery).matches, mediaQuery).catch(() => false); + return { + message() { + if (pass) + return `Expected "${mediaQuery}" not to match, but it did`; + else + return `Expected "${mediaQuery}" to match, but it did not`; + }, + pass, + name: 'toMatchMedia', + }; + }, +}); it('should emulate type @smoke', async ({ page }) => { expect(await page.evaluate(() => matchMedia('screen').matches)).toBe(true); @@ -158,3 +175,15 @@ it('should emulate forcedColors ', async ({ page, browserName }) => { await page.emulateMedia({ forcedColors: null }); expect(await page.evaluate(() => matchMedia('(forced-colors: none)').matches)).toBe(true); }); + +it('should emulate contrast ', async ({ page }) => { + await expect(page).toMatchMedia('(prefers-contrast: no-preference)'); + await page.emulateMedia({ contrast: 'no-preference' }); + await expect(page).toMatchMedia('(prefers-contrast: no-preference)'); + await expect(page).not.toMatchMedia('(prefers-contrast: more)'); + await page.emulateMedia({ contrast: 'more' }); + await expect(page).not.toMatchMedia('(prefers-contrast: no-preference)'); + await expect(page).toMatchMedia('(prefers-contrast: more)'); + await page.emulateMedia({ contrast: null }); + await expect(page).toMatchMedia('(prefers-contrast: no-preference)'); +});