diff --git a/browsers.json b/browsers.json index a51c10b4f7..69a0d1a603 100644 --- a/browsers.json +++ b/browsers.json @@ -8,12 +8,12 @@ }, { "name": "firefox", - "revision": "1265", + "revision": "1266", "installByDefault": true }, { "name": "firefox-stable", - "revision": "1255", + "revision": "1256", "installByDefault": false }, { diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 2f873a75ed..df63f1cc1a 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -970,6 +970,11 @@ Passing `null` disables CSS media emulation. Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. Passing `null` disables color scheme emulation. +### option: Page.emulateMedia.reducedMotion +- `reducedMotion` > + +Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. Passing `null` disables reduced motion emulation. + ## async method: Page.evalOnSelector * langs: - alias-python: eval_on_selector diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 0879764270..61ec647b65 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -366,6 +366,12 @@ Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/W Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See [`method: Page.emulateMedia`] for more details. Defaults to `'light'`. +## context-option-reducedMotion +- `reducedMotion` <[ReducedMotion]<"reduce"|"no-preference">> + +Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. See [`method: Page.emulateMedia`] for more details. Defaults +to `'no-preference'`. + ## context-option-logger * langs: js - `logger` <[Logger]> @@ -578,6 +584,7 @@ using the [`method: AndroidDevice.setDefaultTimeout`] method. - %%-context-option-offline-%% - %%-context-option-httpcredentials-%% - %%-context-option-colorscheme-%% +- %%-context-option-reducedMotion-%% - %%-context-option-logger-%% - %%-context-option-videospath-%% - %%-context-option-videosize-%% diff --git a/src/client/page.ts b/src/client/page.ts index 1e2843d803..f32724f5f4 100644 --- a/src/client/page.ts +++ b/src/client/page.ts @@ -423,11 +423,12 @@ export class Page extends ChannelOwner { await channel.emulateMedia({ media: options.media === null ? 'null' : options.media, colorScheme: options.colorScheme === null ? 'null' : options.colorScheme, + reducedMotion: options.reducedMotion === null ? 'null' : options.reducedMotion, }); }); } diff --git a/src/dispatchers/pageDispatcher.ts b/src/dispatchers/pageDispatcher.ts index c14bb6015f..150fa49326 100644 --- a/src/dispatchers/pageDispatcher.ts +++ b/src/dispatchers/pageDispatcher.ts @@ -121,6 +121,7 @@ export class PageDispatcher extends Dispatcher i await this._page.emulateMedia({ media: params.media === 'null' ? null : params.media, colorScheme: params.colorScheme === 'null' ? null : params.colorScheme, + reducedMotion: params.reducedMotion === 'null' ? null : params.reducedMotion, }); } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index a4a34d7ebd..f41565db65 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -313,6 +313,7 @@ export type BrowserTypeLaunchPersistentContextParams = { isMobile?: boolean, hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', + reducedMotion?: 'reduce' | 'no-preference', acceptDownloads?: boolean, _debugName?: string, recordVideo?: { @@ -382,6 +383,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { isMobile?: boolean, hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', + reducedMotion?: 'reduce' | 'no-preference', acceptDownloads?: boolean, _debugName?: string, recordVideo?: { @@ -471,6 +473,7 @@ export type BrowserNewContextParams = { isMobile?: boolean, hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', + reducedMotion?: 'reduce' | 'no-preference', acceptDownloads?: boolean, _debugName?: string, recordVideo?: { @@ -527,6 +530,7 @@ export type BrowserNewContextOptions = { isMobile?: boolean, hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', + reducedMotion?: 'reduce' | 'no-preference', acceptDownloads?: boolean, _debugName?: string, recordVideo?: { @@ -973,10 +977,12 @@ export type PageCloseResult = void; export type PageEmulateMediaParams = { media?: 'screen' | 'print' | 'null', colorScheme?: 'dark' | 'light' | 'no-preference' | 'null', + reducedMotion?: 'reduce' | 'no-preference' | 'null', }; export type PageEmulateMediaOptions = { media?: 'screen' | 'print' | 'null', colorScheme?: 'dark' | 'light' | 'no-preference' | 'null', + reducedMotion?: 'reduce' | 'no-preference' | 'null', }; export type PageEmulateMediaResult = void; export type PageExposeBindingParams = { @@ -2934,6 +2940,7 @@ export type AndroidDeviceLaunchBrowserParams = { isMobile?: boolean, hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', + reducedMotion?: 'reduce' | 'no-preference', acceptDownloads?: boolean, _debugName?: string, recordVideo?: { @@ -2978,6 +2985,7 @@ export type AndroidDeviceLaunchBrowserOptions = { isMobile?: boolean, hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', + reducedMotion?: 'reduce' | 'no-preference', acceptDownloads?: boolean, _debugName?: string, recordVideo?: { diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 2711a97713..19cfd16efd 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -312,6 +312,11 @@ ContextOptions: - dark - light - no-preference + reducedMotion: + type: enum? + literals: + - reduce + - no-preference acceptDownloads: boolean? _debugName: string? recordVideo: @@ -717,6 +722,13 @@ Page: - no-preference # Reset emulated value to the system default. - null + reducedMotion: + type: enum? + literals: + - reduce + - no-preference + # Reset emulated value to the system default. + - null exposeBinding: parameters: @@ -2374,6 +2386,11 @@ AndroidDevice: - dark - light - no-preference + reducedMotion: + type: enum? + literals: + - reduce + - no-preference acceptDownloads: boolean? _debugName: string? recordVideo: diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index d70b2d7727..c34e3533b0 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -231,6 +231,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { isMobile: tOptional(tBoolean), hasTouch: tOptional(tBoolean), colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference'])), + reducedMotion: tOptional(tEnum(['reduce', 'no-preference'])), acceptDownloads: tOptional(tBoolean), _debugName: tOptional(tString), recordVideo: tOptional(tObject({ @@ -289,6 +290,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { isMobile: tOptional(tBoolean), hasTouch: tOptional(tBoolean), colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference'])), + reducedMotion: tOptional(tEnum(['reduce', 'no-preference'])), acceptDownloads: tOptional(tBoolean), _debugName: tOptional(tString), recordVideo: tOptional(tObject({ @@ -410,6 +412,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scheme.PageEmulateMediaParams = tObject({ media: tOptional(tEnum(['screen', 'print', 'null'])), colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference', 'null'])), + reducedMotion: tOptional(tEnum(['reduce', 'no-preference', 'null'])), }); scheme.PageExposeBindingParams = tObject({ name: tString, @@ -1128,6 +1131,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { isMobile: tOptional(tBoolean), hasTouch: tOptional(tBoolean), colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference'])), + reducedMotion: tOptional(tEnum(['reduce', 'no-preference'])), acceptDownloads: tOptional(tBoolean), _debugName: tOptional(tString), recordVideo: tOptional(tObject({ diff --git a/src/server/chromium/crPage.ts b/src/server/chromium/crPage.ts index dbfb2761cc..f4672059ce 100644 --- a/src/server/chromium/crPage.ts +++ b/src/server/chromium/crPage.ts @@ -1006,8 +1006,13 @@ class FrameSession { async _updateEmulateMedia(initial: boolean): Promise { if (this._crPage._browserContext._browser.isClank()) return; - const colorScheme = this._page._state.colorScheme || this._crPage._browserContext._options.colorScheme || 'light'; - const features = colorScheme ? [{ name: 'prefers-color-scheme', value: colorScheme }] : []; + const colorScheme = this._page._state.colorScheme === null ? '' : this._page._state.colorScheme; + const reducedMotion = this._page._state.reducedMotion === null ? '' : this._page._state.reducedMotion; + const features = [ + { name: 'prefers-color-scheme', value: colorScheme }, + { name: 'prefers-reduced-motion', value: reducedMotion }, + ]; + // Empty string disables the override. await this._client.send('Emulation.setEmulatedMedia', { media: this._page._state.mediaType || '', features }); } diff --git a/src/server/firefox/ffBrowser.ts b/src/server/firefox/ffBrowser.ts index 064c6ea404..1fb6f87fee 100644 --- a/src/server/firefox/ffBrowser.ts +++ b/src/server/firefox/ffBrowser.ts @@ -198,8 +198,14 @@ export class FFBrowserContext extends BrowserContext { promises.push(this.setGeolocation(this._options.geolocation)); if (this._options.offline) promises.push(this.setOffline(this._options.offline)); - if (this._options.colorScheme) - promises.push(this._browser._connection.send('Browser.setColorScheme', { browserContextId, colorScheme: this._options.colorScheme })); + promises.push(this._browser._connection.send('Browser.setColorScheme', { + browserContextId, + colorScheme: this._options.colorScheme !== undefined ? this._options.colorScheme : 'light', + })); + promises.push(this._browser._connection.send('Browser.setReducedMotion', { + browserContextId, + reducedMotion: this._options.reducedMotion !== undefined ? this._options.reducedMotion : 'no-preference', + })); if (this._options.recordVideo) { promises.push(this._ensureVideosPath().then(() => { return this._browser._connection.send('Browser.setVideoRecordingOptions', { diff --git a/src/server/firefox/ffPage.ts b/src/server/firefox/ffPage.ts index f571fcd0cf..65ca35e69e 100644 --- a/src/server/firefox/ffPage.ts +++ b/src/server/firefox/ffPage.ts @@ -352,11 +352,13 @@ export class FFPage implements PageDelegate { } async updateEmulateMedia(): Promise { - const colorScheme = this._page._state.colorScheme || this._browserContext._options.colorScheme || 'light'; + const colorScheme = this._page._state.colorScheme === null ? undefined : this._page._state.colorScheme; + const reducedMotion = this._page._state.reducedMotion === null ? undefined : this._page._state.reducedMotion; await this._session.send('Page.setEmulatedMedia', { // Empty string means reset. type: this._page._state.mediaType === null ? '' : this._page._state.mediaType, - colorScheme + colorScheme, + reducedMotion, }); } diff --git a/src/server/page.ts b/src/server/page.ts index 06fb106a53..4c1ebb0827 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -88,6 +88,7 @@ type PageState = { emulatedSize: { screen: types.Size, viewport: types.Size } | null; mediaType: types.MediaType | null; colorScheme: types.ColorScheme | null; + reducedMotion: types.ReducedMotion | null; extraHTTPHeaders: types.HeadersArray | null; }; @@ -159,7 +160,8 @@ export class Page extends SdkObject { this._state = { emulatedSize: browserContext._options.viewport ? { viewport: browserContext._options.viewport, screen: browserContext._options.screen || browserContext._options.viewport } : null, mediaType: null, - colorScheme: null, + colorScheme: browserContext._options.colorScheme !== undefined ? browserContext._options.colorScheme : 'light', + reducedMotion: browserContext._options.reducedMotion !== undefined ? browserContext._options.reducedMotion : 'no-preference', extraHTTPHeaders: null, }; this.accessibility = new accessibility.Accessibility(delegate.getAccessibilityTree.bind(delegate)); @@ -359,15 +361,13 @@ export class Page extends SdkObject { }), this._timeoutSettings.navigationTimeout(options)); } - async emulateMedia(options: { media?: types.MediaType | null, colorScheme?: types.ColorScheme | null }) { - if (options.media !== undefined) - assert(options.media === null || types.mediaTypes.has(options.media), 'media: expected one of (screen|print|null)'); - if (options.colorScheme !== undefined) - assert(options.colorScheme === null || types.colorSchemes.has(options.colorScheme), 'colorScheme: expected one of (dark|light|no-preference|null)'); + async emulateMedia(options: { media?: types.MediaType | null, colorScheme?: types.ColorScheme | null, reducedMotion?: types.ReducedMotion | null }) { if (options.media !== undefined) this._state.mediaType = options.media; if (options.colorScheme !== undefined) this._state.colorScheme = options.colorScheme; + if (options.reducedMotion !== undefined) + this._state.reducedMotion = options.reducedMotion; await this._delegate.updateEmulateMedia(); await this._doSlowMo(); } diff --git a/src/server/types.ts b/src/server/types.ts index e7de7320d8..89145fee3a 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -84,6 +84,9 @@ export const mediaTypes: Set = new Set(['screen', 'print']); export type ColorScheme = 'dark' | 'light' | 'no-preference'; export const colorSchemes: Set = new Set(['dark', 'light', 'no-preference']); +export type ReducedMotion = 'no-preference' | 'reduce'; +export const reducedMotions: Set = new Set(['no-preference', 'reduce']); + export type DeviceDescriptor = { userAgent: string, viewport: Size, @@ -237,6 +240,7 @@ export type BrowserContextOptions = { isMobile?: boolean, hasTouch?: boolean, colorScheme?: ColorScheme, + reducedMotion?: ReducedMotion, acceptDownloads?: boolean, recordVideo?: { dir: string, diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index cb5e86f9fc..b3d7d74737 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -180,8 +180,8 @@ export class WKPage implements PageDelegate { const contextOptions = this._browserContext._options; if (contextOptions.userAgent) promises.push(session.send('Page.overrideUserAgent', { value: contextOptions.userAgent })); - if (this._page._state.mediaType || this._page._state.colorScheme) - promises.push(WKPage._setEmulateMedia(session, this._page._state.mediaType, this._page._state.colorScheme)); + if (this._page._state.mediaType || this._page._state.colorScheme || this._page._state.reducedMotion) + promises.push(WKPage._setEmulateMedia(session, this._page._state.mediaType, this._page._state.colorScheme, this._page._state.reducedMotion)); for (const world of ['main', 'utility'] as const) { const bootstrapScript = this._calculateBootstrapScript(world); if (bootstrapScript.length) @@ -580,17 +580,21 @@ export class WKPage implements PageDelegate { await this._page._onFileChooserOpened(handle); } - private static async _setEmulateMedia(session: WKSession, mediaType: types.MediaType | null, colorScheme: types.ColorScheme | null): Promise { + private static async _setEmulateMedia(session: WKSession, mediaType: types.MediaType | null, colorScheme: types.ColorScheme | null, reducedMotion: types.ReducedMotion | null): Promise { const promises = []; promises.push(session.send('Page.setEmulatedMedia', { media: mediaType || '' })); - if (colorScheme !== null) { - let appearance: any = ''; - switch (colorScheme) { - case 'light': appearance = 'Light'; break; - case 'dark': appearance = 'Dark'; break; - } - promises.push(session.send('Page.setForcedAppearance', { appearance })); + let appearance: any = undefined; + switch (colorScheme) { + case 'light': appearance = 'Light'; break; + case 'dark': appearance = 'Dark'; break; } + promises.push(session.send('Page.setForcedAppearance', { appearance })); + let reducedMotionWk: any = undefined; + switch (reducedMotion) { + case 'reduce': reducedMotionWk = 'Reduce'; break; + case 'no-preference': reducedMotionWk = 'NoPreference'; break; + } + promises.push(session.send('Page.setForcedReducedMotion', { reducedMotion: reducedMotionWk })); await Promise.all(promises); } @@ -609,8 +613,9 @@ export class WKPage implements PageDelegate { } async updateEmulateMedia(): Promise { - const colorScheme = this._page._state.colorScheme || this._browserContext._options.colorScheme || 'light'; - await this._forAllSessions(session => WKPage._setEmulateMedia(session, this._page._state.mediaType, colorScheme)); + const colorScheme = this._page._state.colorScheme; + const reducedMotion = this._page._state.reducedMotion; + await this._forAllSessions(session => WKPage._setEmulateMedia(session, this._page._state.mediaType, colorScheme, reducedMotion)); } async setEmulatedSize(emulatedSize: types.EmulatedSize): Promise { diff --git a/tests/defaultbrowsercontext-2.spec.ts b/tests/defaultbrowsercontext-2.spec.ts index b9f9c15288..4c32746a69 100644 --- a/tests/defaultbrowsercontext-2.spec.ts +++ b/tests/defaultbrowsercontext-2.spec.ts @@ -38,6 +38,12 @@ it('should support colorScheme option', async ({launchPersistent}) => { expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches)).toBe(true); }); +it('should support reducedMotion option', async ({launchPersistent}) => { + const {page} = await launchPersistent({reducedMotion: 'reduce'}); + expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(true); + expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: no-preference)').matches)).toBe(false); +}); + it('should support timezoneId option', async ({launchPersistent}) => { 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 0638bc557e..32c29df85c 100644 --- a/tests/page/page-emulate-media.spec.ts +++ b/tests/page/page-emulate-media.spec.ts @@ -114,3 +114,14 @@ it('should change the actual colors in css', async ({page}) => { await page.emulateMedia({ colorScheme: 'light' }); expect(await getBackgroundColor()).toBe('rgb(255, 255, 255)'); }); + +it('should emulate reduced motion', async ({page}) => { + expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: no-preference)').matches)).toBe(true); + await page.emulateMedia({ reducedMotion: 'reduce' }); + expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(true); + expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: no-preference)').matches)).toBe(false); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false); + expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: no-preference)').matches)).toBe(true); + await page.emulateMedia({ reducedMotion: null }); +}); diff --git a/types/types.d.ts b/types/types.d.ts index 6bfed713b8..67656ab3da 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -1679,6 +1679,12 @@ export interface Page { * disables CSS media emulation. */ media?: null|"screen"|"print"; + + /** + * Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. Passing `null` + * disables reduced motion emulation. + */ + reducedMotion?: null|"reduce"|"no-preference"; }): Promise; /** @@ -7174,6 +7180,13 @@ export interface BrowserType { }; }; + /** + * Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#pageemulatemediaoptions) for more details. + * Defaults to `'no-preference'`. + */ + reducedMotion?: "reduce"|"no-preference"; + /** * Emulates consistent window screen size available inside web page via `window.screen`. Is only used when the `viewport` * is set. @@ -8197,6 +8210,13 @@ export interface AndroidDevice { }; }; + /** + * Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#pageemulatemediaoptions) for more details. + * Defaults to `'no-preference'`. + */ + reducedMotion?: "reduce"|"no-preference"; + /** * Emulates consistent window screen size available inside web page via `window.screen`. Is only used when the `viewport` * is set. @@ -8974,6 +8994,13 @@ export interface Browser extends EventEmitter { }; }; + /** + * Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#pageemulatemediaoptions) for more details. + * Defaults to `'no-preference'`. + */ + reducedMotion?: "reduce"|"no-preference"; + /** * Emulates consistent window screen size available inside web page via `window.screen`. Is only used when the `viewport` * is set. @@ -11019,6 +11046,13 @@ export interface BrowserContextOptions { }; }; + /** + * Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#pageemulatemediaoptions) for more details. + * Defaults to `'no-preference'`. + */ + reducedMotion?: "reduce"|"no-preference"; + /** * Emulates consistent window screen size available inside web page via `window.screen`. Is only used when the `viewport` * is set.