From 83229711acb172d898e9ff8f2324a48632e38cd9 Mon Sep 17 00:00:00 2001 From: Toti Date: Tue, 1 Sep 2026 10:10:25 -0300 Subject: [PATCH] feat(playwright): add human.emulateMedia for accessibility and theming checks Brings the library to parity with the human_emulate_media MCP tool added in #112. A primitive available to agents but not to library users is a half-feature, and this is the direction CLAUDE.md asks the surfaces to stay aligned in. The reduced-motion branch is the motivating case: it cannot be exercised without changing an OS setting, so it ships unverified even where it was written carefully -- and it fails silently, because the users who depend on it are the least likely to report it. Documented under its own README section rather than in the Primitives table, which covers humanized actions; this is configuration, like setViewportSize and pdf. --- .changeset/human-emulate-media.md | 9 +++++ packages/playwright/README.md | 20 +++++++++++ packages/playwright/src/index.test.ts | 48 +++++++++++++++++++++++++++ packages/playwright/src/index.ts | 23 +++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 .changeset/human-emulate-media.md diff --git a/.changeset/human-emulate-media.md b/.changeset/human-emulate-media.md new file mode 100644 index 0000000..c1ffbbe --- /dev/null +++ b/.changeset/human-emulate-media.md @@ -0,0 +1,9 @@ +--- +"@humanjs/playwright": minor +--- + +Add `human.emulateMedia(options)`, forwarding to Playwright's `page.emulateMedia`. + +Covers `prefers-reduced-motion`, `prefers-color-scheme`, `forced-colors`, `prefers-contrast` and print media. The reduced-motion path is the motivating case: it cannot normally be exercised without changing an OS setting, so it tends to ship unverified even where it was written carefully — and it fails silently, because the users who depend on it are the least likely to report it. + +Not a humanized action; no plugin events fire and `speed` does not affect it. This brings the library to parity with the `human_emulate_media` MCP tool. diff --git a/packages/playwright/README.md b/packages/playwright/README.md index e88bb03..f0f3eb8 100644 --- a/packages/playwright/README.md +++ b/packages/playwright/README.md @@ -100,6 +100,26 @@ Targets accept a CSS selector string or a Playwright `Locator`. `move` and `drag Near-miss (cursor wobble before committing — see `personality.mouse.misclickProbability`) applies to the primitives that commit a button event at the resolved coordinates: `click`, `rightClick`, both `drag` endpoints, and the implicit focus-acquiring click inside `type` / `paste` (the keystrokes themselves are unaffected). `hover`, `move`, `press`, `read`, and `scroll` never misclick, by design — a wobble would trigger handlers on the wrong element for `hover`, and would contradict the explicit-coordinate contract for `move`. The misclick is also skipped when the cursor is already on the target (no approach means no overshoot). +### Media emulation + +`emulateMedia` forwards to Playwright and covers the CSS media features a real user's machine would set: `prefers-reduced-motion`, `prefers-color-scheme`, `forced-colors`, `prefers-contrast`, and print media. + +```ts +await human.emulateMedia({ reducedMotion: 'reduce' }); +await human.goto(url); // now renders the reduced-motion path +``` + +The reduced-motion branch is the one worth calling out. It is normally impossible to exercise without changing an OS setting, so it ships unverified even in projects that wrote it carefully — and it is exactly the branch that breaks silently, because the people who rely on it are the least likely to file a bug. Checking it costs one line. + +Settings persist across navigations until changed. Pass `null` for a feature to stop emulating it and fall back to the host's own setting: + +```ts +await human.emulateMedia({ colorScheme: 'dark' }); // check the dark theme +await human.emulateMedia({ colorScheme: null }); // back to the host setting +``` + +Not a humanized action — no plugin events fire, and it is unaffected by `speed`. + ### Keyboard ```ts diff --git a/packages/playwright/src/index.test.ts b/packages/playwright/src/index.test.ts index 7d2c798..bce6920 100644 --- a/packages/playwright/src/index.test.ts +++ b/packages/playwright/src/index.test.ts @@ -1742,3 +1742,51 @@ describe('human.clear', () => { expect(mouseClick).not.toHaveBeenCalled(); }); }); + +describe('human.emulateMedia', () => { + function makeMediaPage(): { page: Page; emulateMedia: ReturnType } { + const emulateMedia = vi.fn().mockResolvedValue(undefined); + const page = { emulateMedia } as unknown as Page; + return { page, emulateMedia }; + } + + it('forwards the options straight through to Playwright', async () => { + const { page, emulateMedia } = makeMediaPage(); + const human = await createHuman(page, { speed: 'instant' }); + await human.emulateMedia({ reducedMotion: 'reduce' }); + expect(emulateMedia).toHaveBeenCalledWith({ reducedMotion: 'reduce' }); + }); + + it('passes null through so a feature can stop being emulated', async () => { + const { page, emulateMedia } = makeMediaPage(); + const human = await createHuman(page, { speed: 'instant' }); + await human.emulateMedia({ colorScheme: null }); + expect(emulateMedia).toHaveBeenCalledWith({ colorScheme: null }); + }); + + it('accepts several features in one call', async () => { + const { page, emulateMedia } = makeMediaPage(); + const human = await createHuman(page, { speed: 'instant' }); + await human.emulateMedia({ + reducedMotion: 'reduce', + colorScheme: 'dark', + forcedColors: 'active', + }); + expect(emulateMedia).toHaveBeenCalledWith({ + reducedMotion: 'reduce', + colorScheme: 'dark', + forcedColors: 'active', + }); + }); + + it('does not fire plugin actions (configuration, not a humanized action)', async () => { + const { page } = makeMediaPage(); + const before = vi.fn(); + const human = await createHuman(page, { + speed: 'instant', + plugins: [{ name: 't', beforeAction: before }], + }); + await human.emulateMedia({ reducedMotion: 'reduce' }); + expect(before).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index ee808e4..09d204a 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -609,6 +609,26 @@ export interface Human { * Not a humanized action: no plugin events fire. */ setViewportSize(size: { width: number; height: number }): Promise; + /** + * Emulate CSS media features — `prefers-reduced-motion`, + * `prefers-color-scheme`, `forced-colors`, `prefers-contrast`, and print + * media. Forwards to `page.emulateMedia(options)`. + * + * The reduced-motion branch of a UI is normally impossible to exercise + * without changing an OS setting, so it tends to ship unverified even in + * projects that wrote it carefully. This makes it one call: + * + * ```ts + * await human.emulateMedia({ reducedMotion: 'reduce' }); + * await human.goto(url); // now renders the reduced-motion path + * ``` + * + * Settings persist across navigations until changed. Pass `null` for a + * feature to stop emulating it and fall back to the host's own setting. + * + * Not a humanized action: no plugin events fire. + */ + emulateMedia(options: Parameters[0]): Promise; /** * Render the page as a PDF. Forwards to `page.pdf(options)`. Chromium * only; works most reliably in headless mode (in headed mode, Playwright @@ -1153,6 +1173,9 @@ export async function createHuman(page: Page, options: CreateHumanOptions = {}): setViewportSize(size) { return page.setViewportSize(size); }, + emulateMedia(options) { + return page.emulateMedia(options); + }, pdf(opts) { return page.pdf(opts); },