Skip to content

Commit 4b809e3

Browse files
committed
feat: support emulating additional CSS media features
This patch adds support for: - `forced-colors` - `prefers-contrast` - `prefers-reduced-motion`
1 parent 5ddb0a3 commit 4b809e3

11 files changed

Lines changed: 283 additions & 23 deletions

File tree

‎docs/tool-reference.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,11 @@
285285
- **colorScheme** (enum: "dark", "light", "auto") _(optional)_: [`Emulate`](#emulate) the dark or the light mode. Set to "auto" to reset to the default.
286286
- **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling
287287
- **extraHttpHeaders** (string) _(optional)_: Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers.
288+
- **forcedColors** (enum: "active", "none", "auto") _(optional)_: [`Emulate`](#emulate) the `forced-colors` CSS media feature. Set to "auto" to reset to the default.
288289
- **geolocation** (string) _(optional)_: Geolocation (`<latitude>,<longitude>`) to [`emulate`](#emulate). Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.
289290
- **networkConditions** (enum: "Offline", "Slow 3G", "Fast 3G", "Slow 4G", "Fast 4G") _(optional)_: Throttle network. Omit to disable throttling.
291+
- **prefersContrast** (enum: "more", "less", "custom", "no-preference", "auto") _(optional)_: [`Emulate`](#emulate) the `prefers-contrast` CSS media feature. Set to "auto" to reset to the default.
292+
- **prefersReducedMotion** (enum: "reduce", "no-preference", "auto") _(optional)_: [`Emulate`](#emulate) the `prefers-reduced-motion` CSS media feature. Set to "auto" to reset to the default.
290293
- **userAgent** (string) _(optional)_: User agent to [`emulate`](#emulate). Set to empty string to clear the user agent override.
291294
- **viewport** (string) _(optional)_: [`Emulate`](#emulate) device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to [`emulate`](#emulate) mobile devices. 'landscape' to [`emulate`](#emulate) landscape mode.
292295

‎src/McpPage.ts‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ import type {
9393
} from './tools/ToolDefinition.js';
9494
import type {
9595
EmulationSettings,
96+
ForcedColors,
9697
GeolocationOptions,
98+
PrefersContrast,
99+
PrefersReducedMotion,
97100
TextSnapshotNode,
98101
} from './types.js';
99102
import {logger} from './utils/logger.js';
@@ -550,6 +553,18 @@ export class McpPage implements ContextPage {
550553
return this.emulationSettings.colorScheme ?? null;
551554
}
552555

556+
get forcedColors(): ForcedColors | null {
557+
return this.emulationSettings.forcedColors ?? null;
558+
}
559+
560+
get prefersContrast(): PrefersContrast | null {
561+
return this.emulationSettings.prefersContrast ?? null;
562+
}
563+
564+
get prefersReducedMotion(): PrefersReducedMotion | null {
565+
return this.emulationSettings.prefersReducedMotion ?? null;
566+
}
567+
553568
// Public for testability: tests spy on this method to verify throttle multipliers.
554569
createWaitForHelper(
555570
cpuMultiplier: number,
@@ -995,12 +1010,30 @@ export class McpPage implements ContextPage {
9951010
await this.emulate(currentSetting);
9961011
}
9971012

1013+
async #emulateMediaFeatures(settings: EmulationSettings): Promise<void> {
1014+
// `page.emulateMediaFeatures` replaces all previously emulated media
1015+
// features, so every supported feature is sent on each call. An empty
1016+
// value disables the override for that feature.
1017+
await this.pptrPage.emulateMediaFeatures([
1018+
{name: 'prefers-color-scheme', value: settings.colorScheme ?? ''},
1019+
{name: 'forced-colors', value: settings.forcedColors ?? ''},
1020+
{name: 'prefers-contrast', value: settings.prefersContrast ?? ''},
1021+
{
1022+
name: 'prefers-reduced-motion',
1023+
value: settings.prefersReducedMotion ?? '',
1024+
},
1025+
]);
1026+
}
1027+
9981028
async emulate(options: {
9991029
networkConditions?: string;
10001030
cpuThrottlingRate?: number;
10011031
geolocation?: GeolocationOptions;
10021032
userAgent?: string;
10031033
colorScheme?: 'dark' | 'light' | 'auto';
1034+
forcedColors?: ForcedColors | 'auto';
1035+
prefersContrast?: PrefersContrast | 'auto';
1036+
prefersReducedMotion?: PrefersReducedMotion | 'auto';
10041037
viewport?: Viewport;
10051038
extraHttpHeaders?: Record<string, string> | undefined;
10061039
}): Promise<void> {
@@ -1070,17 +1103,34 @@ export class McpPage implements ContextPage {
10701103
}
10711104

10721105
if (!options.colorScheme || options.colorScheme === 'auto') {
1073-
await page.emulateMediaFeatures([
1074-
{name: 'prefers-color-scheme', value: ''},
1075-
]);
10761106
delete newSettings.colorScheme;
10771107
} else {
1078-
await page.emulateMediaFeatures([
1079-
{name: 'prefers-color-scheme', value: options.colorScheme},
1080-
]);
10811108
newSettings.colorScheme = options.colorScheme;
10821109
}
10831110

1111+
if (!options.forcedColors || options.forcedColors === 'auto') {
1112+
delete newSettings.forcedColors;
1113+
} else {
1114+
newSettings.forcedColors = options.forcedColors;
1115+
}
1116+
1117+
if (!options.prefersContrast || options.prefersContrast === 'auto') {
1118+
delete newSettings.prefersContrast;
1119+
} else {
1120+
newSettings.prefersContrast = options.prefersContrast;
1121+
}
1122+
1123+
if (
1124+
!options.prefersReducedMotion ||
1125+
options.prefersReducedMotion === 'auto'
1126+
) {
1127+
delete newSettings.prefersReducedMotion;
1128+
} else {
1129+
newSettings.prefersReducedMotion = options.prefersReducedMotion;
1130+
}
1131+
1132+
await this.#emulateMediaFeatures(newSettings);
1133+
10841134
if (!options.viewport) {
10851135
delete newSettings.viewport;
10861136
} else {

‎src/McpResponse.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,9 @@ export class McpResponse implements Response {
886886
userAgent?: string;
887887
cpuThrottlingRate?: number;
888888
colorScheme?: string;
889+
forcedColors?: string;
890+
prefersContrast?: string;
891+
prefersReducedMotion?: string;
889892
dialog?: {
890893
type: string;
891894
message: string;
@@ -1011,6 +1014,26 @@ export class McpResponse implements Response {
10111014
structuredContent.colorScheme = colorScheme;
10121015
}
10131016

1017+
const forcedColors = this.#page?.forcedColors;
1018+
if (forcedColors) {
1019+
response.push(`Emulating forced-colors: ${forcedColors}`);
1020+
structuredContent.forcedColors = forcedColors;
1021+
}
1022+
1023+
const prefersContrast = this.#page?.prefersContrast;
1024+
if (prefersContrast) {
1025+
response.push(`Emulating prefers-contrast: ${prefersContrast}`);
1026+
structuredContent.prefersContrast = prefersContrast;
1027+
}
1028+
1029+
const prefersReducedMotion = this.#page?.prefersReducedMotion;
1030+
if (prefersReducedMotion) {
1031+
response.push(
1032+
`Emulating prefers-reduced-motion: ${prefersReducedMotion}`,
1033+
);
1034+
structuredContent.prefersReducedMotion = prefersReducedMotion;
1035+
}
1036+
10141037
const dialog = this.#page?.getDialog();
10151038
if (dialog) {
10161039
const defaultValueIfNeeded =

‎src/config/cli-options.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,30 @@ export const commands: Commands = {
276276
required: false,
277277
enum: ['dark', 'light', 'auto'],
278278
},
279+
forcedColors: {
280+
name: 'forcedColors',
281+
type: 'string',
282+
description:
283+
'Emulate the `forced-colors` CSS media feature. Set to "auto" to reset to the default.',
284+
required: false,
285+
enum: ['active', 'none', 'auto'],
286+
},
287+
prefersContrast: {
288+
name: 'prefersContrast',
289+
type: 'string',
290+
description:
291+
'Emulate the `prefers-contrast` CSS media feature. Set to "auto" to reset to the default.',
292+
required: false,
293+
enum: ['more', 'less', 'custom', 'no-preference', 'auto'],
294+
},
295+
prefersReducedMotion: {
296+
name: 'prefersReducedMotion',
297+
type: 'string',
298+
description:
299+
'Emulate the `prefers-reduced-motion` CSS media feature. Set to "auto" to reset to the default.',
300+
required: false,
301+
enum: ['reduce', 'no-preference', 'auto'],
302+
},
279303
viewport: {
280304
name: 'viewport',
281305
type: 'string',

‎src/telemetry/tool_call_metrics.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@
105105
{
106106
"name": "page_id",
107107
"argType": "number"
108+
},
109+
{
110+
"name": "forced_colors",
111+
"argType": "string"
112+
},
113+
{
114+
"name": "prefers_contrast",
115+
"argType": "string"
116+
},
117+
{
118+
"name": "prefers_reduced_motion",
119+
"argType": "string"
108120
}
109121
]
110122
},

‎src/tools/ToolDefinition.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ import type {
4141
TextSnapshotNode,
4242
GeolocationOptions,
4343
CD4ACommentThread,
44+
ForcedColors,
45+
PrefersContrast,
46+
PrefersReducedMotion,
4447
} from '../types.js';
4548
import type {McpWorker} from '../McpWorker.js';
4649
import type {PaginationOptions} from '../types.js';
@@ -424,6 +427,9 @@ export type ContextPage = Readonly<{
424427
geolocation?: GeolocationOptions;
425428
userAgent?: string;
426429
colorScheme?: 'dark' | 'light' | 'auto';
430+
forcedColors?: ForcedColors | 'auto';
431+
prefersContrast?: PrefersContrast | 'auto';
432+
prefersReducedMotion?: PrefersReducedMotion | 'auto';
427433
viewport?: Viewport;
428434
}): Promise<void>;
429435
waitForTextOnPage(text: string[], timeout?: number): Promise<Element>;

‎src/tools/emulation.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
*/
77

88
import {zod, PredefinedNetworkConditions} from '../third_party/index.js';
9+
import {
10+
FORCED_COLORS_VALUES,
11+
PREFERS_CONTRAST_VALUES,
12+
PREFERS_REDUCED_MOTION_VALUES,
13+
} from '../types.js';
914

1015
import {ToolCategory} from './categories.js';
1116
import {
@@ -84,6 +89,24 @@ export const emulate = definePageTool(() => ({
8489
.describe(
8590
'Emulate the dark or the light mode. Set to "auto" to reset to the default.',
8691
),
92+
forcedColors: zod
93+
.enum([...FORCED_COLORS_VALUES, 'auto'])
94+
.optional()
95+
.describe(
96+
'Emulate the `forced-colors` CSS media feature. Set to "auto" to reset to the default.',
97+
),
98+
prefersContrast: zod
99+
.enum([...PREFERS_CONTRAST_VALUES, 'auto'])
100+
.optional()
101+
.describe(
102+
'Emulate the `prefers-contrast` CSS media feature. Set to "auto" to reset to the default.',
103+
),
104+
prefersReducedMotion: zod
105+
.enum([...PREFERS_REDUCED_MOTION_VALUES, 'auto'])
106+
.optional()
107+
.describe(
108+
'Emulate the `prefers-reduced-motion` CSS media feature. Set to "auto" to reset to the default.',
109+
),
87110
viewport: zod
88111
.string()
89112
.transform(viewportTransform)

‎src/types.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,33 @@ export interface GeolocationOptions {
2222
longitude: number;
2323
}
2424

25+
export const FORCED_COLORS_VALUES = ['active', 'none'] as const;
26+
export type ForcedColors = (typeof FORCED_COLORS_VALUES)[number];
27+
28+
export const PREFERS_CONTRAST_VALUES = [
29+
'more',
30+
'less',
31+
'custom',
32+
'no-preference',
33+
] as const;
34+
export type PrefersContrast = (typeof PREFERS_CONTRAST_VALUES)[number];
35+
36+
export const PREFERS_REDUCED_MOTION_VALUES = [
37+
'reduce',
38+
'no-preference',
39+
] as const;
40+
export type PrefersReducedMotion =
41+
(typeof PREFERS_REDUCED_MOTION_VALUES)[number];
42+
2543
export interface EmulationSettings {
2644
networkConditions?: string;
2745
cpuThrottlingRate?: number;
2846
geolocation?: GeolocationOptions;
2947
userAgent?: string;
3048
colorScheme?: 'dark' | 'light';
49+
forcedColors?: ForcedColors;
50+
prefersContrast?: PrefersContrast;
51+
prefersReducedMotion?: PrefersReducedMotion;
3152
viewport?: Viewport;
3253
extraHttpHeaders?: Record<string, string>;
3354
}

‎tests/McpPage.test.ts‎

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -539,24 +539,74 @@ describe('McpPage', () => {
539539
});
540540
});
541541

542-
it('calls emulateMediaFeatures with dark color scheme', async () => {
543-
const {mcpPage, pptrPage} = await createMcpPage();
544-
await mcpPage.emulate({colorScheme: 'dark'});
545-
assert.strictEqual(mcpPage.colorScheme, 'dark');
546-
sinon.assert.calledOnceWithExactly(pptrPage.emulateMediaFeatures, [
547-
{name: 'prefers-color-scheme', value: 'dark'},
548-
]);
549-
});
542+
describe('media features', () => {
543+
it('emulates all media features in a single call', async () => {
544+
const {mcpPage, pptrPage} = await createMcpPage();
545+
await mcpPage.emulate({
546+
colorScheme: 'dark',
547+
forcedColors: 'active',
548+
prefersContrast: 'more',
549+
prefersReducedMotion: 'reduce',
550+
});
551+
assert.strictEqual(mcpPage.colorScheme, 'dark');
552+
assert.strictEqual(mcpPage.forcedColors, 'active');
553+
assert.strictEqual(mcpPage.prefersContrast, 'more');
554+
assert.strictEqual(mcpPage.prefersReducedMotion, 'reduce');
555+
sinon.assert.calledOnceWithExactly(pptrPage.emulateMediaFeatures, [
556+
{name: 'prefers-color-scheme', value: 'dark'},
557+
{name: 'forced-colors', value: 'active'},
558+
{name: 'prefers-contrast', value: 'more'},
559+
{name: 'prefers-reduced-motion', value: 'reduce'},
560+
]);
561+
});
550562

551-
it('calls emulateMediaFeatures with empty string to reset color scheme', async () => {
552-
const {mcpPage, pptrPage} = await createMcpPage();
553-
await mcpPage.emulate({colorScheme: 'dark'});
554-
await mcpPage.emulate({colorScheme: 'auto'});
555-
assert.strictEqual(mcpPage.colorScheme, null);
556-
sinon.assert.calledTwice(pptrPage.emulateMediaFeatures);
557-
sinon.assert.calledWithExactly(pptrPage.emulateMediaFeatures.secondCall, [
558-
{name: 'prefers-color-scheme', value: ''},
559-
]);
563+
it('resets media features set to auto', async () => {
564+
const {mcpPage, pptrPage} = await createMcpPage();
565+
await mcpPage.emulate({
566+
colorScheme: 'dark',
567+
forcedColors: 'active',
568+
prefersContrast: 'more',
569+
prefersReducedMotion: 'reduce',
570+
});
571+
await mcpPage.emulate({
572+
colorScheme: 'auto',
573+
forcedColors: 'auto',
574+
prefersContrast: 'auto',
575+
prefersReducedMotion: 'auto',
576+
});
577+
assert.strictEqual(mcpPage.colorScheme, null);
578+
assert.strictEqual(mcpPage.forcedColors, null);
579+
assert.strictEqual(mcpPage.prefersContrast, null);
580+
assert.strictEqual(mcpPage.prefersReducedMotion, null);
581+
sinon.assert.calledTwice(pptrPage.emulateMediaFeatures);
582+
sinon.assert.calledWithExactly(
583+
pptrPage.emulateMediaFeatures.secondCall,
584+
[
585+
{name: 'prefers-color-scheme', value: ''},
586+
{name: 'forced-colors', value: ''},
587+
{name: 'prefers-contrast', value: ''},
588+
{name: 'prefers-reduced-motion', value: ''},
589+
],
590+
);
591+
});
592+
593+
it('resets omitted media features', async () => {
594+
const {mcpPage, pptrPage} = await createMcpPage();
595+
await mcpPage.emulate({forcedColors: 'active'});
596+
await mcpPage.emulate({prefersContrast: 'less'});
597+
assert.strictEqual(mcpPage.forcedColors, null);
598+
assert.strictEqual(mcpPage.prefersContrast, 'less');
599+
sinon.assert.calledTwice(pptrPage.emulateMediaFeatures);
600+
sinon.assert.calledWithExactly(
601+
pptrPage.emulateMediaFeatures.secondCall,
602+
[
603+
{name: 'prefers-color-scheme', value: ''},
604+
{name: 'forced-colors', value: ''},
605+
{name: 'prefers-contrast', value: 'less'},
606+
{name: 'prefers-reduced-motion', value: ''},
607+
],
608+
);
609+
});
560610
});
561611

562612
it('calls setViewport with the given dimensions merged with defaults', async () => {

‎tests/ToolHandler.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,6 +1284,9 @@ describe('ToolHandler', () => {
12841284
sinon.stub(mockPage, 'viewport').get(() => undefined);
12851285
sinon.stub(mockPage, 'userAgent').get(() => undefined);
12861286
sinon.stub(mockPage, 'colorScheme').get(() => undefined);
1287+
sinon.stub(mockPage, 'forcedColors').get(() => undefined);
1288+
sinon.stub(mockPage, 'prefersContrast').get(() => undefined);
1289+
sinon.stub(mockPage, 'prefersReducedMotion').get(() => undefined);
12871290
sinon.stub(mockPage, 'cpuThrottlingRate').get(() => 1);
12881291
mockContext.getSelectedMcpPage.returns(mockPage);
12891292
const canonicalFilePath = path.resolve('/canonical/output.png');

0 commit comments

Comments
 (0)