Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/human-emulate-media.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions packages/playwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions packages/playwright/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1742,3 +1742,51 @@ describe('human.clear', () => {
expect(mouseClick).not.toHaveBeenCalled();
});
});

describe('human.emulateMedia', () => {
function makeMediaPage(): { page: Page; emulateMedia: ReturnType<typeof vi.fn> } {
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();
});
});
23 changes: 23 additions & 0 deletions packages/playwright/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,26 @@ export interface Human {
* Not a humanized action: no plugin events fire.
*/
setViewportSize(size: { width: number; height: number }): Promise<void>;
/**
* 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<Page['emulateMedia']>[0]): Promise<void>;
/**
* Render the page as a PDF. Forwards to `page.pdf(options)`. Chromium
* only; works most reliably in headless mode (in headed mode, Playwright
Expand Down Expand Up @@ -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);
},
Expand Down
Loading