@e2e-dev/web 0.14.1-nightly-20261013094930 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,9 @@ target, so a test can `requires: ['browser']`. Browser options:
42
42
  `getByTestId` and a node's `testId` read; default `data-testid`), `userAgent`
43
43
  (the `User-Agent` every attempt sends and `navigator.userAgent` reports),
44
44
  `locale` and `timezoneId` (the language and time zone every attempt runs in,
45
- such as `de-DE` and `Europe/Berlin`), `initScripts` (scripts every document
45
+ such as `de-DE` and `Europe/Berlin`), `colorScheme` and `reducedMotion` (the
46
+ `prefers-color-scheme` and `prefers-reduced-motion` every page reports, from
47
+ first paint; `browser.emulateMedia` changes them in a test), `initScripts` (scripts every document
46
48
  runs before the page's own), and
47
49
  `connect` — attach to a remote browser over CDP instead of launching a local
48
50
  one.
package/dist/.build.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.14.1-nightly-20261013094930"}
1
+ {"version":"0.15.0"}
@@ -5,6 +5,7 @@ import { type CdpEndpointResolver, type SessionBinding } from './cdp-recovery.ts
5
5
  import { type OperationBound } from './operation-budget.ts';
6
6
  import { RefRegistry } from './refs.ts';
7
7
  import type { LeaseRecording } from './provider.ts';
8
+ import type { WebMedia } from './media.ts';
8
9
  import type { WebScreencastOptions } from './surface.ts';
9
10
  export type StorageState = Exclude<NonNullable<BrowserContextOptions['storageState']>, string>;
10
11
  interface SessionOptions {
@@ -13,6 +14,8 @@ interface SessionOptions {
13
14
  readonly viewport: ViewportSize | null;
14
15
  /** How the engine's own screencast records, when the provider does not record. */
15
16
  readonly screencast: WebScreencastOptions;
17
+ /** The media features every page the attempt opens emulates, until `emulateMedia` changes them. */
18
+ readonly media: WebMedia;
16
19
  readonly contextOptions: BrowserContextOptions;
17
20
  readonly acquire: (signal: AbortSignal) => Promise<Browser>;
18
21
  readonly configure: (context: BrowserContext) => Promise<void>;
@@ -41,6 +44,9 @@ export declare class AttemptSession {
41
44
  /** Until the attempt's first page opens: a persistent browser's own first tab can serve as that page. */
42
45
  private firstPage;
43
46
  private requestedViewport;
47
+ private media;
48
+ /** The emulation each page got as it appeared, so the attempt's own page is ready before it shows. */
49
+ private readonly emulated;
44
50
  constructor(options: SessionOptions);
45
51
  /** Captures an immutable connection generation for publication after asynchronous reads. */
46
52
  token(): object;
@@ -76,6 +82,28 @@ export declare class AttemptSession {
76
82
  readonly width: number;
77
83
  readonly height: number;
78
84
  }): Promise<void>;
85
+ /**
86
+ * Emulates media features on every open tab and every tab opened after
87
+ * them, the app's own included; a feature `media` leaves out keeps its value.
88
+ */
89
+ emulateMedia(media: WebMedia): Promise<void>;
90
+ /**
91
+ * Runs `emulate` on every open tab but the attempt's own. A tab the app
92
+ * closes meanwhile fails its own call only; an error on a tab still open
93
+ * propagates.
94
+ */
95
+ private emulateOnOthers;
96
+ /** Sets the attempt's media features on a page before it shows anything; a page with none set keeps the browser's own. */
97
+ private applyMedia;
98
+ /**
99
+ * Emulates the attempt's media on each tab as it appears, so a tab the app
100
+ * opens itself follows a test's change too, on a persistent context as
101
+ * well, where the engine sets no context options. It paints with the
102
+ * context's configured values first: Playwright emulates per page, so a
103
+ * test's change reaches it once it exists. A tab the attempt opens awaits
104
+ * the same work in `ensurePage`; a tab the app opens is not awaited.
105
+ */
106
+ private watchPages;
79
107
  /** Restarts the document while retaining this context's storage. */
80
108
  restart(): Promise<void>;
81
109
  /** Replaces an ordinary context; persistent recovery never advertises this capability. */
@@ -16,8 +16,12 @@ export class AttemptSession {
16
16
  /** Until the attempt's first page opens: a persistent browser's own first tab can serve as that page. */
17
17
  firstPage = true;
18
18
  requestedViewport;
19
+ media;
20
+ /** The emulation each page got as it appeared, so the attempt's own page is ready before it shows. */
21
+ emulated = new WeakMap();
19
22
  constructor(options) {
20
23
  this.options = options;
24
+ this.media = options.media;
21
25
  this.video = options.record === undefined
22
26
  ? new VideoRecorder(options.artifactsDir, options.screencast)
23
27
  : new ProviderVideo(options.record, options.artifactsDir);
@@ -121,10 +125,14 @@ export class AttemptSession {
121
125
  if (budget.signal.aborted)
122
126
  throw connectionAbort(budget.signal, 'connection');
123
127
  await this.options.configure(candidate.context);
128
+ this.watchPages(candidate.context);
124
129
  if (recover && candidate.page !== null) {
125
130
  const size = previous?.page?.viewportSize() ?? this.requestedViewport ?? this.options.viewport;
126
131
  if (size !== null)
127
132
  await candidate.page.setViewportSize(size);
133
+ // Emulation belongs to the CDP session that set it, so the new transport sets it again, on the app's own tabs too.
134
+ await this.applyMedia(candidate.page);
135
+ await this.emulateOnOthers(candidate.context, candidate.page, (page) => this.applyMedia(page));
128
136
  await this.video.pageOpened(candidate.page);
129
137
  }
130
138
  if (budget.signal.aborted)
@@ -164,6 +172,7 @@ export class AttemptSession {
164
172
  const size = this.requestedViewport ?? (identity === undefined ? null : this.options.viewport);
165
173
  if (size !== null)
166
174
  await page.setViewportSize(size);
175
+ await (this.emulated.get(page) ?? this.applyMedia(page));
167
176
  if (identity !== undefined)
168
177
  identity = { ...identity, target: await targetIdentity(page) };
169
178
  this.check(token);
@@ -208,6 +217,55 @@ export class AttemptSession {
208
217
  await page.setViewportSize({ width, height });
209
218
  this.requestedViewport = { width, height };
210
219
  }
220
+ /**
221
+ * Emulates media features on every open tab and every tab opened after
222
+ * them, the app's own included; a feature `media` leaves out keeps its value.
223
+ */
224
+ async emulateMedia(media) {
225
+ // Merged before the await, so a second call made meanwhile builds on this one instead of the old value.
226
+ this.media = { ...this.media, ...media };
227
+ const { context, page } = this.current();
228
+ if (page !== null && !page.isClosed())
229
+ await page.emulateMedia(this.media);
230
+ await this.emulateOnOthers(context, page, (other) => other.emulateMedia(this.media));
231
+ }
232
+ /**
233
+ * Runs `emulate` on every open tab but the attempt's own. A tab the app
234
+ * closes meanwhile fails its own call only; an error on a tab still open
235
+ * propagates.
236
+ */
237
+ async emulateOnOthers(context, own, emulate) {
238
+ const others = context.pages().filter((page) => page !== own && !page.isClosed());
239
+ await Promise.all(others.map(async (page) => {
240
+ try {
241
+ await emulate(page);
242
+ }
243
+ catch (cause) {
244
+ if (!page.isClosed())
245
+ throw cause;
246
+ }
247
+ }));
248
+ }
249
+ /** Sets the attempt's media features on a page before it shows anything; a page with none set keeps the browser's own. */
250
+ async applyMedia(page) {
251
+ if (Object.keys(this.media).length > 0)
252
+ await page.emulateMedia(this.media);
253
+ }
254
+ /**
255
+ * Emulates the attempt's media on each tab as it appears, so a tab the app
256
+ * opens itself follows a test's change too, on a persistent context as
257
+ * well, where the engine sets no context options. It paints with the
258
+ * context's configured values first: Playwright emulates per page, so a
259
+ * test's change reaches it once it exists. A tab the attempt opens awaits
260
+ * the same work in `ensurePage`; a tab the app opens is not awaited.
261
+ */
262
+ watchPages(context) {
263
+ context.on('page', (page) => {
264
+ const applied = this.applyMedia(page);
265
+ this.emulated.set(page, applied);
266
+ applied.catch(() => undefined);
267
+ });
268
+ }
211
269
  /** Restarts the document while retaining this context's storage. */
212
270
  async restart() {
213
271
  const binding = this.current();
package/dist/browser.d.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import type { ActionOptions, Expectable, JsonValue, Locator, Screen, TextMatch } from 'e2e';
15
15
  import { type EngineFixtureContext } from 'e2e/engine';
16
16
  import type { DialogHandler } from './dialogs.ts';
17
+ import { type WebMedia } from './media.ts';
17
18
  import type { PlaywrightSurface } from './surface.ts';
18
19
  /**
19
20
  * One of `json`, `body`, or `path`, or none for an empty body. `path` names a
@@ -200,6 +201,14 @@ export interface Browser extends Expectable<BrowserExpectation> {
200
201
  width: number;
201
202
  height: number;
202
203
  }): Promise<void>;
204
+ /**
205
+ * Emulates CSS media features on every open tab and every tab opened after
206
+ * them, the app's own included, for the rest of the attempt, as Playwright's
207
+ * `page.emulateMedia` does: the page's `@media` rules and `matchMedia` both
208
+ * read them. A feature left out keeps its value. Works before the first
209
+ * navigation, so the page loads with them.
210
+ */
211
+ emulateMedia(media: WebMedia): Promise<void>;
203
212
  /** Registers an attempt-scoped dialog handler and returns an unsubscribe function. */
204
213
  onDialog(handler: DialogHandler): Promise<() => Promise<void>>;
205
214
  /** Runs a trigger and waits for its download. */
@@ -207,6 +216,7 @@ export interface Browser extends Expectable<BrowserExpectation> {
207
216
  timeout?: number;
208
217
  }): Promise<{
209
218
  path: string;
219
+ absolutePath: string;
210
220
  suggestedFilename: string;
211
221
  }>;
212
222
  /** Viewport-level keyboard, for whatever has focus. */
package/dist/browser.js CHANGED
@@ -11,12 +11,13 @@
11
11
  * never re-implemented here: `context.app.resolveUrl` is the one place that
12
12
  * says which URLs a test may open.
13
13
  */
14
- import { ConfigurationError, Deadline, describePattern, EngineError, matchesText, pollCondition, raceAbort, rejectUnknownOptions, TestError, toTextPattern, urlMatches, validateJsonValue, withTimeout, } from 'e2e/engine';
14
+ import { ConfigurationError, Deadline, describePattern, EngineError, matchesText, obj, pollCondition, raceAbort, rejectUnknownOptions, TestError, toTextPattern, urlMatches, validateJsonValue, withTimeout, } from 'e2e/engine';
15
15
  import { classifyInputError } from './actions.js';
16
16
  import { saveDownloadsTo, saveFromBrowser, saveLocally } from './downloads.js';
17
17
  import { isTestErrorCode, message as causeMessage, translatePwError } from './support.js';
18
18
  import { compileEvaluation, pageFunctionSource } from './evaluation.js';
19
19
  import { initScriptLabel, testInitScriptSource } from './init-scripts.js';
20
+ import { MEDIA_KEYS, mediaLabel, mediaProblem } from './media.js';
20
21
  import { lowercaseNames } from './protected-app.js';
21
22
  import { parseContinue, parseFulfill, requireNoArguments } from './route-options.js';
22
23
  import { routePatternMatches, routePatternsEqual } from './route-pattern.js';
@@ -286,6 +287,20 @@ export function createBrowserFixture(surface, context) {
286
287
  await surface.setViewport(size);
287
288
  context.attachViewport({ width: size.width, height: size.height, scale: 1 });
288
289
  }),
290
+ async emulateMedia(media) {
291
+ if (typeof media !== 'object' || media === null) {
292
+ throw new TestError('INVALID_ARGUMENT', 'browser.emulateMedia takes { colorScheme?, reducedMotion? }');
293
+ }
294
+ rejectUnknownOptions('browser.emulateMedia', media, MEDIA_KEYS);
295
+ for (const key of MEDIA_KEYS) {
296
+ const problem = mediaProblem((name) => `browser.emulateMedia({ ${name} })`, key, media[key]);
297
+ if (problem !== undefined)
298
+ throw new TestError('INVALID_ARGUMENT', problem);
299
+ }
300
+ // Read once, so a test that changes its object later changes nothing here.
301
+ const requested = obj({ colorScheme: media.colorScheme, reducedMotion: media.reducedMotion });
302
+ await surface.guard(context.operation(), 'emulateMedia', () => surface.emulateMedia(requested));
303
+ },
289
304
  // Async so the harness records the registration as a `browser.onDialog` step.
290
305
  async onDialog(handler) {
291
306
  const unsubscribe = surface.dialogs.add(handler);
@@ -321,7 +336,7 @@ export function createBrowserFixture(surface, context) {
321
336
  else
322
337
  await saveFromBrowser(download, remote, absolute, currentOperation.signal);
323
338
  context.attachArtifact('download', relative);
324
- return { path: relative, suggestedFilename };
339
+ return { path: relative, absolutePath: absolute, suggestedFilename };
325
340
  }, (cause, label) => {
326
341
  // The trigger is test code, so its errors keep their original classification.
327
342
  if (triggerFailure !== undefined && Object.is(cause, triggerFailure.cause))
@@ -369,6 +384,7 @@ export function createBrowserFixture(surface, context) {
369
384
  cookies: action,
370
385
  setCookies: action,
371
386
  setViewport: action,
387
+ emulateMedia: { ...action, label: (media) => mediaLabel(media) },
372
388
  onDialog: action,
373
389
  waitForDownload: { ...action, timeout: (_trigger, options) => options?.timeout },
374
390
  keyboard: { press: { ...action, label: (key) => key }, type: { ...action, label: (text) => `${text.length} chars` } },
@@ -430,13 +446,15 @@ function createBrowserExpectation(deps, negated = false) {
430
446
  let value;
431
447
  try {
432
448
  // A locator read takes the action timeout; the poll's deadline is the tighter bound.
433
- value = await withTimeout(target.getAttribute('class'), deadline.remaining(), () => new EngineError('OPERATION_TIMEOUT', 'class read timed out', { retryable: false }));
449
+ value = await withTimeout(readClass(target), deadline.remaining(), () => new EngineError('OPERATION_TIMEOUT', 'class read timed out', { retryable: false }));
434
450
  }
435
451
  catch (error) {
436
452
  if (isTestErrorCode(error, 'LOCATOR_NOT_FOUND'))
437
453
  return { matches: undefined, observed: 'no node' };
438
454
  throw error;
439
455
  }
456
+ if (value === undefined)
457
+ return { matches: undefined, observed: 'no node' };
440
458
  if (value === null)
441
459
  return { matches: false, observed: 'no class attribute' };
442
460
  return {
@@ -447,6 +465,16 @@ function createBrowserExpectation(deps, negated = false) {
447
465
  },
448
466
  };
449
467
  }
468
+ /**
469
+ * The class attribute of one match, `undefined` while nothing matches. A
470
+ * read waits for its match until the action timeout, so the poll samples
471
+ * absence first: a node that never arrives stays an assertion failure.
472
+ */
473
+ async function readClass(target) {
474
+ if ((await target.count()) === 0)
475
+ return undefined;
476
+ return target.getAttribute('class');
477
+ }
450
478
  function requireSelector(method, selector) {
451
479
  if (typeof selector !== 'string' || selector.length === 0) {
452
480
  throw new TestError('INVALID_LOCATOR', `${method}() requires a nonempty selector`);
@@ -2,7 +2,7 @@
2
2
  import type { BrowserContext, Page } from 'playwright-core';
3
3
  import { type ConnectionBudget } from './operation-budget.ts';
4
4
  /**
5
- * Playwright 1.63 seeds custom selectors only in newly created contexts, not
5
+ * Playwright 1.64 seeds custom selectors only in newly created contexts, not
6
6
  * the persistent context returned by connectOverCDP. Its public register API
7
7
  * rejects an already registered name before reaching that context. Use the
8
8
  * same context channel as selectors.register, without allocating global names
@@ -4,7 +4,7 @@ import { connectionAbort } from './operation-budget.js';
4
4
  import { CLOSED_SHADOW_ROOTS_KEY } from './closed-shadow.js';
5
5
  import { SELECTOR_ENGINES } from './selector-engines.js';
6
6
  /**
7
- * Playwright 1.63 seeds custom selectors only in newly created contexts, not
7
+ * Playwright 1.64 seeds custom selectors only in newly created contexts, not
8
8
  * the persistent context returned by connectOverCDP. Its public register API
9
9
  * rejects an already registered name before reaching that context. Use the
10
10
  * same context channel as selectors.register, without allocating global names
package/dist/engine.js CHANGED
@@ -11,6 +11,7 @@ import { asBrowserProvider } from './provider.js';
11
11
  import { PlaywrightSurface } from './surface.js';
12
12
  import { createBrowserFixture } from './browser.js';
13
13
  import { validateInitScripts } from './init-scripts.js';
14
+ import { MEDIA_KEYS, mediaProblem } from './media.js';
14
15
  import { FIELD_VALUE_CONTROL, headerProblem } from './protected-app.js';
15
16
  /** App fields only a device target reads. */
16
17
  const NATIVE_APP_KEYS = ['bundleId', 'appPath', 'launchArguments', 'permissions'];
@@ -52,6 +53,7 @@ export function web(options = {}) {
52
53
  validateLocale(options.locale, options.headers);
53
54
  if (options.timezoneId !== undefined)
54
55
  validateTimezoneId(options.timezoneId);
56
+ validateMedia(options);
55
57
  if (options.initScripts !== undefined)
56
58
  validateInitScripts(options.initScripts);
57
59
  if (options.screencast !== undefined)
@@ -134,6 +136,8 @@ const WEB_OPTION_KEYS = Object.keys({
134
136
  userAgent: true,
135
137
  locale: true,
136
138
  timezoneId: true,
139
+ colorScheme: true,
140
+ reducedMotion: true,
137
141
  initScripts: true,
138
142
  });
139
143
  /** The options applied when the engine creates a browser context, which a persistent context never is. */
@@ -238,6 +242,18 @@ function validateTimezoneId(timezoneId) {
238
242
  throw new ConfigurationError('INVALID_CONFIG', `web({ timezoneId }) must be an IANA time zone such as "Europe/Berlin", got ${JSON.stringify(timezoneId)}`);
239
243
  }
240
244
  }
245
+ /**
246
+ * Refuses a media feature value the browser does not emulate, at config load
247
+ * rather than at the first page. A persistent context takes these too: they
248
+ * are set on each page, not only on a context the engine creates.
249
+ */
250
+ function validateMedia(options) {
251
+ for (const key of MEDIA_KEYS) {
252
+ const problem = mediaProblem((name) => `web({ ${name} })`, key, options[key]);
253
+ if (problem !== undefined)
254
+ throw new ConfigurationError('INVALID_CONFIG', problem);
255
+ }
256
+ }
241
257
  /**
242
258
  * True when `timeZone` is an IANA name `Intl` resolves, spelled with its own
243
259
  * case. Node also takes an offset (`+01:00`) and any casing (`europe/berlin`),
@@ -38,6 +38,9 @@ export const readSemanticsFunction = (element, options) => {
38
38
  'frameset',
39
39
  'object',
40
40
  'embed',
41
+ // Next.js App Router's route announcer echoes the title on a hydration
42
+ // schedule of its own, which an observation must not read as an alert.
43
+ 'next-route-announcer',
41
44
  ];
42
45
  const OPAQUE_TAGS = ['svg', 'math', 'canvas', 'video', 'audio'];
43
46
  /**
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export { web, surfaceOf } from './engine.ts';
9
9
  export type { PlaywrightLiveSurface } from './engine.ts';
10
10
  export type { WebBasicAuth, WebConnectOptions, WebOptions, WebScreencastOptions } from './surface.ts';
11
11
  export type { WebInitScript } from './init-scripts.ts';
12
+ export type { WebMedia } from './media.ts';
12
13
  export type { BrowserDownloadContext, BrowserLease, BrowserProvider, BrowserProviderDownloads, BrowserProviderScope, BrowserReleaseContext, BrowserRequest, } from './provider.ts';
13
14
  export type { BrowserName } from './browser-connection.ts';
14
15
  export type { Dialog, DialogHandler } from './dialogs.ts';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The CSS media features the engine emulates: one set of values from
3
+ * `web({ colorScheme, reducedMotion })` for every attempt, and the same names
4
+ * on `browser.emulateMedia()` for the rest of one.
5
+ */
6
+ /** User preferences a page reads through `@media` rules and `matchMedia`, as Playwright's `emulateMedia` sets them. */
7
+ export interface WebMedia {
8
+ /** `prefers-color-scheme`: `'light'`, `'dark'`, or `'no-preference'`. */
9
+ readonly colorScheme?: 'light' | 'dark' | 'no-preference';
10
+ /** `prefers-reduced-motion`: `'reduce'` or `'no-preference'`. */
11
+ readonly reducedMotion?: 'reduce' | 'no-preference';
12
+ }
13
+ /** The feature names `WebMedia` takes, for unknown-key checks. */
14
+ export declare const MEDIA_KEYS: readonly (keyof WebMedia)[];
15
+ /**
16
+ * Why one feature's value is refused, naming it the way `where` does; undefined
17
+ * for a value the browser emulates, and for an unset one. Config and tests run
18
+ * as JavaScript, so the types alone are no guard.
19
+ */
20
+ export declare function mediaProblem(where: (key: keyof WebMedia) => string, key: keyof WebMedia, value: unknown): string | undefined;
21
+ /** The step label of a `browser.emulateMedia` call: the features it sets, read defensively since the label runs before validation. */
22
+ export declare function mediaLabel(media: unknown): string;
package/dist/media.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The CSS media features the engine emulates: one set of values from
3
+ * `web({ colorScheme, reducedMotion })` for every attempt, and the same names
4
+ * on `browser.emulateMedia()` for the rest of one.
5
+ */
6
+ /** Every emulated feature and the values it takes, kept equal to `WebMedia` by the compiler. */
7
+ const MEDIA_VALUES = {
8
+ colorScheme: ['light', 'dark', 'no-preference'],
9
+ reducedMotion: ['reduce', 'no-preference'],
10
+ };
11
+ /** The feature names `WebMedia` takes, for unknown-key checks. */
12
+ export const MEDIA_KEYS = Object.keys(MEDIA_VALUES);
13
+ /**
14
+ * Why one feature's value is refused, naming it the way `where` does; undefined
15
+ * for a value the browser emulates, and for an unset one. Config and tests run
16
+ * as JavaScript, so the types alone are no guard.
17
+ */
18
+ export function mediaProblem(where, key, value) {
19
+ if (value === undefined)
20
+ return undefined;
21
+ const allowed = MEDIA_VALUES[key];
22
+ if (typeof value === 'string' && allowed.includes(value))
23
+ return undefined;
24
+ const names = allowed.map((name) => JSON.stringify(name));
25
+ return `${where(key)} must be ${names.slice(0, -1).join(', ')}${names.length > 2 ? ',' : ''} or ${names.at(-1)}, got ${describe(value)}`;
26
+ }
27
+ /** The step label of a `browser.emulateMedia` call: the features it sets, read defensively since the label runs before validation. */
28
+ export function mediaLabel(media) {
29
+ if (typeof media !== 'object' || media === null)
30
+ return '';
31
+ return MEDIA_KEYS.flatMap((key) => {
32
+ const value = Reflect.get(media, key);
33
+ return typeof value === 'string' && mediaProblem(String, key, value) === undefined ? [`${key}: ${value}`] : [];
34
+ }).join(', ');
35
+ }
36
+ /** A refused value as a message shows it: a string quoted, another primitive as written, anything else by its kind, never serialized. */
37
+ function describe(value) {
38
+ if (typeof value === 'string')
39
+ return JSON.stringify(value);
40
+ if (value === null || typeof value === 'number' || typeof value === 'boolean')
41
+ return String(value);
42
+ if (Array.isArray(value))
43
+ return 'an array';
44
+ return typeof value === 'object' ? 'an object' : typeof value;
45
+ }
package/dist/surface.d.ts CHANGED
@@ -13,6 +13,7 @@ import { type BrowserProvider, type LeaseDownloads } from './provider.ts';
13
13
  import { DialogRouter } from './dialogs.ts';
14
14
  import { type OperationBound } from './operation-budget.ts';
15
15
  import { type WebInitScript } from './init-scripts.ts';
16
+ import type { WebMedia } from './media.ts';
16
17
  import { ErrorLatch } from './support.ts';
17
18
  type RoutePredicate = (url: URL) => boolean;
18
19
  type RouteHandler = (route: Route) => Promise<void>;
@@ -149,6 +150,21 @@ export interface WebOptions {
149
150
  * provider with `scope: 'attempt'`) is `INVALID_CONFIG`.
150
151
  */
151
152
  readonly timezoneId?: string;
153
+ /**
154
+ * The `prefers-color-scheme` every page reports from its first paint, to
155
+ * its `@media` rules and to `matchMedia`, as Playwright's own
156
+ * `colorScheme` context option sets it. Defaults to the browser's own.
157
+ * `browser.emulateMedia` changes it for the rest of one attempt.
158
+ */
159
+ readonly colorScheme?: WebMedia['colorScheme'];
160
+ /**
161
+ * The `prefers-reduced-motion` every page reports from its first paint, as
162
+ * Playwright's own `reducedMotion` context option sets it: `'reduce'`
163
+ * lets an app's own rule turn its animations off, so none races an
164
+ * assertion. Defaults to the browser's own. `browser.emulateMedia`
165
+ * changes it for the rest of one attempt.
166
+ */
167
+ readonly reducedMotion?: WebMedia['reducedMotion'];
152
168
  /**
153
169
  * Scripts every document runs before the page's own, in every tab and
154
170
  * frame: JavaScript source, a `{ path }` relative to the project root, or a
@@ -179,6 +195,8 @@ export declare class PlaywrightSurface {
179
195
  private readonly userAgent;
180
196
  private readonly locale;
181
197
  private readonly timezoneId;
198
+ /** The configured media features, which every attempt starts from. */
199
+ private readonly media;
182
200
  private readonly configuredInitScripts;
183
201
  /** The attempt's init scripts, configured then added, applied to each context the attempt opens. */
184
202
  private initScripts;
@@ -303,6 +321,8 @@ export declare class PlaywrightSurface {
303
321
  readonly width: number;
304
322
  readonly height: number;
305
323
  }): Promise<void>;
324
+ /** Emulates media features on the open page, or the one the next navigation opens, for the rest of the attempt. */
325
+ emulateMedia(media: WebMedia): Promise<void>;
306
326
  /**
307
327
  * The single entry of every operation: rethrows an error latched on an
308
328
  * unawaited path, refuses a cancelled operation, races `fn` against the
package/dist/surface.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { mkdirSync } from 'node:fs';
10
10
  import path from 'node:path';
11
- import { EngineError, raceAbort, TestError, withinCleanupBudget, } from 'e2e/engine';
11
+ import { EngineError, obj, raceAbort, TestError, withinCleanupBudget, } from 'e2e/engine';
12
12
  import { matchesText } from 'e2e/engine';
13
13
  import { classifyActionError, classifyInputError, dispatchLocatorAction, dispatchPointerAction } from './actions.js';
14
14
  import { BrowserConnection, connectCdp } from './browser-connection.js';
@@ -67,6 +67,8 @@ export class PlaywrightSurface {
67
67
  userAgent;
68
68
  locale;
69
69
  timezoneId;
70
+ /** The configured media features, which every attempt starts from. */
71
+ media;
70
72
  configuredInitScripts;
71
73
  /** The attempt's init scripts, configured then added, applied to each context the attempt opens. */
72
74
  initScripts = [];
@@ -98,6 +100,7 @@ export class PlaywrightSurface {
98
100
  this.userAgent = options.userAgent;
99
101
  this.locale = options.locale;
100
102
  this.timezoneId = options.timezoneId;
103
+ this.media = obj({ colorScheme: options.colorScheme, reducedMotion: options.reducedMotion });
101
104
  this.configuredInitScripts = new ConfiguredInitScripts(options.initScripts ?? []);
102
105
  }
103
106
  // --- lifecycle ---
@@ -253,6 +256,7 @@ export class PlaywrightSurface {
253
256
  artifactsDir: context.artifactsDir,
254
257
  viewport: this.viewport,
255
258
  screencast: this.screencast,
259
+ media: this.media,
256
260
  acquire: (signal) => this.acquireBrowser(signal),
257
261
  contextOptions: {
258
262
  viewport: this.viewport,
@@ -261,6 +265,8 @@ export class PlaywrightSurface {
261
265
  ...(this.userAgent === undefined ? {} : { userAgent: this.userAgent }),
262
266
  ...(this.locale === undefined ? {} : { locale: this.locale }),
263
267
  ...(this.timezoneId === undefined ? {} : { timezoneId: this.timezoneId }),
268
+ // On the context too, so a tab the app opens itself paints with the configured values before the session reaches it.
269
+ ...this.media,
264
270
  ...(this.headers === undefined ? {} : { serviceWorkers: 'block' }),
265
271
  },
266
272
  configure: async (target) => {
@@ -417,6 +423,10 @@ export class PlaywrightSurface {
417
423
  setViewport(size) {
418
424
  return this.requireSession().setViewport(size);
419
425
  }
426
+ /** Emulates media features on the open page, or the one the next navigation opens, for the rest of the attempt. */
427
+ emulateMedia(media) {
428
+ return this.requireSession().emulateMedia(media);
429
+ }
420
430
  /**
421
431
  * The single entry of every operation: rethrows an error latched on an
422
432
  * unawaited path, refuses a cancelled operation, races `fn` against the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e2e-dev/web",
3
- "version": "0.14.1-nightly-20261013094930",
3
+ "version": "0.15.0",
4
4
  "description": "Web engine for e2e agentic end-to-end testing, built on Playwright",
5
5
  "keywords": [
6
6
  "e2e",
@@ -47,14 +47,14 @@
47
47
  "NOTICE"
48
48
  ],
49
49
  "dependencies": {
50
- "playwright-core": "1.63.0"
50
+ "playwright-core": "1.64.0"
51
51
  },
52
52
  "peerDependencies": {
53
- "e2e": "0.21.0-nightly-20261013094930"
53
+ "e2e": ">=0.18.0 <1"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/node": "26.6.2",
57
- "e2e": "0.21.0-nightly-20261013094930",
57
+ "e2e": "0.21.0",
58
58
  "@types/pngjs": "6.0.5",
59
59
  "pngjs": "7.0.0",
60
60
  "typescript": "7.0.2",