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); },