@e2e-dev/web 0.13.0 → 0.14.0-nightly-20261008205824

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/dist/surface.js CHANGED
@@ -14,14 +14,16 @@ import { classifyActionError, classifyInputError, dispatchLocatorAction, dispatc
14
14
  import { BrowserConnection, connectCdp } from './browser-connection.js';
15
15
  import { AttemptSession } from './attempt-session.js';
16
16
  import { LeasedBrowsers } from './provider.js';
17
+ import { installAppLog } from './app-log.js';
18
+ import { TraceFeed } from './trace-feed.js';
17
19
  import { DialogRouter } from './dialogs.js';
18
20
  import { ensureBrowsersInstalled } from './install.js';
19
21
  import { applyPostSteps, frameSelectors, projectExpression } from './locators.js';
20
- import { ROOT_NODE_ID, toSemanticNode } from './observation.js';
22
+ import { contentBox, frameContentInset, placeRectInFrame, ROOT_NODE_ID, toSemanticNode } from './observation.js';
21
23
  import { captureObservation } from './observation-capture.js';
22
24
  import { maskOptions, secureFieldMasks } from './observe.js';
23
25
  import { connectionAbort, withOperationDeadline } from './operation-budget.js';
24
- import { CLOSED_SHADOW_ROOTS_INIT_SCRIPT } from './closed-shadow.js';
26
+ import { CLOSED_SHADOW_ROOTS_INIT_SCRIPT, CLOSED_SHADOW_ROOTS_KEY } from './closed-shadow.js';
25
27
  import { ConfiguredInitScripts } from './init-scripts.js';
26
28
  import { SECURE_FIELD_SELECTOR } from './read-node.js';
27
29
  import { readSelector, takeReadsFunction } from './read-selector.js';
@@ -72,6 +74,10 @@ export class PlaywrightSurface {
72
74
  projectRoot = '';
73
75
  headed = false;
74
76
  artifactsDir = '';
77
+ /** Navigations the engine has in flight, which the app log leaves to the step that started them. */
78
+ ownNavigations = 0;
79
+ /** What the attempt tells its trace beside the app log; absent between attempts. */
80
+ trace;
75
81
  artifactCounter = 0;
76
82
  /**
77
83
  * Attempt-scoped network routes. Registered on the
@@ -235,6 +241,7 @@ export class PlaywrightSurface {
235
241
  const persistent = await this.persistentBinding(context);
236
242
  this.artifactsDir = context.artifactsDir;
237
243
  this.artifactCounter = 0;
244
+ this.trace = new TraceFeed(context, () => this.openPage(), () => this.session?.refs, { testIdAttribute: this.testIdAttribute, site: this.app.site });
238
245
  const routes = [];
239
246
  const initScripts = this.configuredInitScripts.forAttempt();
240
247
  this.latch = new ErrorLatch();
@@ -262,6 +269,11 @@ export class PlaywrightSurface {
262
269
  await target.addInitScript(script);
263
270
  target.setDefaultTimeout(CONTEXT_DEFAULT_TIMEOUT_MS);
264
271
  target.on('dialog', (dialog) => { void dialogs.dispatch(dialog); });
272
+ installAppLog(target, context.appLog, {
273
+ navigated: context.navigation,
274
+ ownNavigation: () => this.ownNavigations > 0,
275
+ testPage: () => this.openPage(),
276
+ });
265
277
  await installSiteHeaders(target, this.app.site, this.headers);
266
278
  for (const stored of routes)
267
279
  await target.route(stored.predicate, stored.handler);
@@ -301,8 +313,8 @@ export class PlaywrightSurface {
301
313
  * Releases attempt resources, the worker's shared browser process, and a
302
314
  * lease the worker acquired for itself: three independent tasks, in that
303
315
  * order, every one attempted whatever the earlier ones did, and the first
304
- * failure reported once all ran. A billed browser outlives a failed trace
305
- * flush otherwise.
316
+ * failure reported once all ran. A billed browser outlives a failed
317
+ * recording flush otherwise.
306
318
  */
307
319
  async dispose(context) {
308
320
  const tasks = [
@@ -358,6 +370,20 @@ export class PlaywrightSurface {
358
370
  await context.unroute(predicate, handler);
359
371
  }
360
372
  // --- page access shared with the browser fixture ---
373
+ /**
374
+ * The attempt's open page, or undefined, for a reader beside the test (the
375
+ * trace): unlike `requirePage`, it leaves an error a handler latched for
376
+ * the test's next operation to throw.
377
+ */
378
+ openPage() {
379
+ try {
380
+ const page = this.session?.current().page ?? null;
381
+ return page === null || page.isClosed() ? undefined : page;
382
+ }
383
+ catch {
384
+ return undefined;
385
+ }
386
+ }
361
387
  requirePage() {
362
388
  this.latch.throwPending();
363
389
  const page = this.requireSession().current().page;
@@ -415,15 +441,33 @@ export class PlaywrightSurface {
415
441
  // --- steering: the session hooks ---
416
442
  /** Opens one URL the harness resolved; the attempt's page is created on first use. */
417
443
  open(url, operation) {
418
- return this.guard(operation, 'navigation', async (currentOperation) => {
444
+ return this.acted(operation, this.guard(operation, 'navigation', (currentOperation) => this.navigating(async () => {
419
445
  const page = await this.ensurePage();
420
446
  await page.goto(url, { waitUntil: 'load', timeout: currentOperation.timeoutMs });
421
- });
447
+ })));
422
448
  }
423
449
  back(operation) {
424
- return this.guard(operation, 'navigation', async (currentOperation) => {
450
+ return this.acted(operation, this.guard(operation, 'navigation', (currentOperation) => this.navigating(async () => {
425
451
  await this.requirePage().goBack({ waitUntil: 'load', timeout: currentOperation.timeoutMs });
426
- });
452
+ })));
453
+ }
454
+ /** Runs one navigation the engine itself makes, so the app log leaves it to the step that asked for it. */
455
+ async navigating(work) {
456
+ this.ownNavigations += 1;
457
+ try {
458
+ const result = await work();
459
+ this.trace?.tellEnvironment();
460
+ return result;
461
+ }
462
+ finally {
463
+ this.ownNavigations -= 1;
464
+ }
465
+ }
466
+ /** Resolves with what an action resolved with once the trace has the screen it left (`TraceFeed.screenAfterAction`); an action that failed leaves none. */
467
+ async acted(operation, work) {
468
+ const result = await work;
469
+ await this.trace?.screenAfterAction(operation);
470
+ return result;
427
471
  }
428
472
  /** Restarts the document while retaining this attempt's context and storage. */
429
473
  restart(operation) {
@@ -453,7 +497,7 @@ export class PlaywrightSurface {
453
497
  const token = session.token();
454
498
  const refs = session.refs;
455
499
  const page = this.requirePage();
456
- await this.validateFrames(expression);
500
+ const frame = await this.frameBox(expression);
457
501
  const projected = projectExpression(page, expression, this.testIdAttribute);
458
502
  const { displayValue, name, steps } = projected;
459
503
  // Every match is read by the `e2e-read` selector engine in the task that finds it, so a
@@ -525,7 +569,7 @@ export class PlaywrightSurface {
525
569
  // on whichever element is first.
526
570
  const locator = reads.length === 1 ? projected.locator : projected.locator.nth(index);
527
571
  const id = refs.storeLocated(pinned === undefined ? { kind: 'locator', locator } : { kind: 'element', element: pinned });
528
- return toSemanticNode({ id, revision: '' }, raw);
572
+ return inFrame(toSemanticNode({ id, revision: '' }, raw), frame);
529
573
  });
530
574
  }, staleOr);
531
575
  }
@@ -536,21 +580,21 @@ export class PlaywrightSurface {
536
580
  * on the root acts on the document element the root stands for.
537
581
  */
538
582
  perform(ref, action, operation) {
539
- return this.guard(operation, action.kind, (currentOperation) => {
583
+ return this.acted(operation, this.guard(operation, action.kind, (currentOperation) => {
540
584
  const page = this.requirePage();
541
585
  if (action.kind === 'swipe' && ref.id === ROOT_NODE_ID) {
542
586
  this.requireSession().requireObservation();
543
587
  return performViewportSwipe(page, action.direction, action.momentum ?? 'none');
544
588
  }
545
- return dispatchLocatorAction(this.requireSession().refs.lookup(ref), action, currentOperation.timeoutMs, (other) => this.requireSession().refs.lookup(other));
546
- }, (cause) => classifyActionError(cause, action));
589
+ return dispatchLocatorAction(this.requireSession().refs.lookup(ref), action, currentOperation, (other) => this.requireSession().refs.lookup(other));
590
+ }, (cause) => classifyActionError(cause, action)));
547
591
  }
548
592
  /** One pointer action at a viewport point in CSS pixels, with nothing resolved behind it; see `dispatchPointerAction`. */
549
593
  performAt(point, action, operation) {
550
- return this.guard(operation, `${action.kind} at point`, (currentOperation) => {
594
+ return this.acted(operation, this.guard(operation, `${action.kind} at point`, (currentOperation) => {
551
595
  this.requireSession().requireObservation();
552
596
  return dispatchPointerAction(this.requirePage(), point, action, currentOperation.signal);
553
- }, classifyInputError);
597
+ }, classifyInputError));
554
598
  }
555
599
  /**
556
600
  * Types into whatever holds focus. Keystrokes into a document body are
@@ -560,7 +604,7 @@ export class PlaywrightSurface {
560
604
  * scoped by the browser to the whole editing host, so it is opt-in.
561
605
  */
562
606
  typeText(text, options, operation) {
563
- return this.guard(operation, 'keyboard.type', async (currentOperation) => {
607
+ return this.acted(operation, this.guard(operation, 'keyboard.type', async (currentOperation) => {
564
608
  const session = this.requireSession();
565
609
  session.requireObservation();
566
610
  const token = session.token();
@@ -580,14 +624,14 @@ export class PlaywrightSurface {
580
624
  checkpoint();
581
625
  }
582
626
  await page.keyboard.type(text);
583
- }, classifyInputError);
627
+ }, classifyInputError));
584
628
  }
585
629
  /** Sends one key to whatever holds focus, in the contract's key grammar Playwright shares. */
586
630
  pressKey(key, operation) {
587
- return this.guard(operation, 'keyboard.press', () => {
631
+ return this.acted(operation, this.guard(operation, 'keyboard.press', () => {
588
632
  this.requireSession().requireObservation();
589
633
  return this.requirePage().keyboard.press(key);
590
- }, classifyInputError);
634
+ }, classifyInputError));
591
635
  }
592
636
  /**
593
637
  * Refuses focused typing when nothing that takes keystrokes has focus, in
@@ -599,8 +643,16 @@ export class PlaywrightSurface {
599
643
  */
600
644
  async requireEditableFocus(page) {
601
645
  const editable = await Promise.all(page.frames().map((frame) => frame
602
- .evaluate(() => {
603
- const active = document.activeElement;
646
+ .evaluate((key) => {
647
+ const recorded = Reflect.get(globalThis, Symbol.for(key));
648
+ let active = document.activeElement;
649
+ while (active !== null) {
650
+ const root = active.shadowRoot ?? (recorded instanceof WeakMap ? recorded.get(active) : undefined);
651
+ const leaf = root?.activeElement;
652
+ if (leaf === undefined || leaf === null)
653
+ break;
654
+ active = leaf;
655
+ }
604
656
  if (active === null || active === document.body || active === document.documentElement)
605
657
  return false;
606
658
  if (active instanceof HTMLInputElement) {
@@ -608,11 +660,12 @@ export class PlaywrightSurface {
608
660
  }
609
661
  if (active instanceof HTMLTextAreaElement)
610
662
  return !active.disabled && !active.readOnly;
611
- if (active instanceof HTMLSelectElement || active instanceof HTMLButtonElement || active instanceof HTMLAnchorElement) {
663
+ if (active instanceof HTMLSelectElement || active instanceof HTMLButtonElement || active instanceof HTMLAnchorElement ||
664
+ active instanceof HTMLIFrameElement || active instanceof HTMLFrameElement) {
612
665
  return false;
613
666
  }
614
667
  return true;
615
- })
668
+ }, CLOSED_SHADOW_ROOTS_KEY)
616
669
  .catch(() => false)));
617
670
  if (!editable.some(Boolean)) {
618
671
  throw new EngineError('NOT_ACTIONABLE', 'nothing that takes keystrokes has focus: the typed text would reach no field. Tap the field first, or type into a listed input by id.', { retryable: false });
@@ -622,10 +675,14 @@ export class PlaywrightSurface {
622
675
  * Checks that every frame selector along the expression matches exactly one
623
676
  * element, each counted inside the frame before it, the way `project` walks
624
677
  * the same chain; a frame-scoped `count` is 0 until that frame's document
625
- * has loaded, which is what makes `FRAME_NOT_FOUND` worth retrying.
678
+ * has loaded, which is what makes `FRAME_NOT_FOUND` worth retrying. Returns
679
+ * the innermost frame's content box in the top-level viewport, where its
680
+ * document's viewport starts: undefined for an expression in the main
681
+ * document, null for a frame with no box.
626
682
  */
627
- async validateFrames(expression) {
683
+ async frameBox(expression) {
628
684
  let scope = this.requirePage();
685
+ let element;
629
686
  for (const selector of frameSelectors(expression)) {
630
687
  let count;
631
688
  try {
@@ -644,8 +701,18 @@ export class PlaywrightSurface {
644
701
  retryable: false,
645
702
  });
646
703
  }
704
+ element = scope.locator(selector);
647
705
  scope = scope.frameLocator(selector);
648
706
  }
707
+ if (element === undefined)
708
+ return undefined;
709
+ try {
710
+ const border = await element.boundingBox();
711
+ return border === null ? null : contentBox(border, await element.evaluate(frameContentInset));
712
+ }
713
+ catch (cause) {
714
+ throw translatePwError(cause, 'frame resolution');
715
+ }
649
716
  }
650
717
  // --- artifacts ---
651
718
  /**
@@ -673,15 +740,7 @@ export class PlaywrightSurface {
673
740
  return relative;
674
741
  });
675
742
  }
676
- /** Starts tracing the current attempt. */
677
- startTrace(operation) {
678
- return this.guard(operation, 'trace', () => this.requireSession().startTrace());
679
- }
680
- /** Returns every trace segment finalized by the current attempt. */
681
- stopTrace(operation) {
682
- return this.requireSession().collectTrace(operation);
683
- }
684
- /** Starts the attempt's video before a trace chooses its screencast dimensions. */
743
+ /** Starts the attempt's video. */
685
744
  startVideo(operation) {
686
745
  return this.guard(operation, 'video', (current) => this.requireSession().startVideo(current.signal));
687
746
  }
@@ -756,3 +815,15 @@ export class PlaywrightSurface {
756
815
  }
757
816
  }
758
817
  }
818
+ /**
819
+ * A located node in the top-level viewport's space, as the observation tree
820
+ * reports it: its box shifted by the frame's and clipped to it, and dropped
821
+ * when the frame has no box or the node lies outside it.
822
+ */
823
+ function inFrame(node, frame) {
824
+ if (frame === undefined || node.rect === undefined)
825
+ return node;
826
+ const { rect, ...rest } = node;
827
+ const placed = frame === null ? undefined : placeRectInFrame(rect, frame);
828
+ return placed === undefined ? rest : { ...rest, rect: placed };
829
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What the web engine tells the harness for an attempt's trace, apart from
3
+ * the app log: the screen an action left (`EngineAttemptContext.screen`),
4
+ * and the browser and user agent the attempt runs on (`environment`). One
5
+ * feed per attempt; it never fails what the test does.
6
+ */
7
+ import type { Page } from 'playwright-core';
8
+ import type { EngineAttemptContext, OperationContext } from 'e2e/engine';
9
+ import { type CaptureSettings } from './observation-capture.ts';
10
+ import { RefRegistry } from './refs.ts';
11
+ export declare class TraceFeed {
12
+ private readonly context;
13
+ private readonly page;
14
+ private readonly refs;
15
+ private readonly settings;
16
+ /** Whether the feed told the harness what the attempt runs on. */
17
+ private environmentTold;
18
+ /**
19
+ * `page` reads the attempt's open page without touching the error latch;
20
+ * `refs` is the registry a screen is captured against, then let go.
21
+ */
22
+ constructor(context: Pick<EngineAttemptContext, 'screen' | 'environment'>, page: () => Page | undefined, refs: () => RefRegistry | undefined, settings: CaptureSettings);
23
+ /**
24
+ * Tells the harness, once an attempt, the browser and the user agent its
25
+ * page reports. Best effort and not awaited: nothing the test does waits
26
+ * on it.
27
+ */
28
+ tellEnvironment(): void;
29
+ /** The browser and the user agent the page reports, within a second; undefined without a page or a browser to ask. */
30
+ private environmentFacts;
31
+ /**
32
+ * Hands the harness the screen an action left. The read is on the action's
33
+ * path, as the `screen` contract allows, and only when the attempt keeps a
34
+ * trace (the harness hands no `screen` otherwise). The screen is captured
35
+ * like an observation but never published, so every ref a test or model
36
+ * holds stays valid; a page that cannot answer within a second leaves the
37
+ * step without one.
38
+ */
39
+ screenAfterAction(operation: OperationContext): Promise<void>;
40
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What the web engine tells the harness for an attempt's trace, apart from
3
+ * the app log: the screen an action left (`EngineAttemptContext.screen`),
4
+ * and the browser and user agent the attempt runs on (`environment`). One
5
+ * feed per attempt; it never fails what the test does.
6
+ */
7
+ import { captureObservation } from './observation-capture.js';
8
+ import { withOperationDeadline } from './operation-budget.js';
9
+ import { RefRegistry } from './refs.js';
10
+ /** How long the screen an action left may take for the trace before the step goes without one. */
11
+ const TRACE_SCREEN_TIMEOUT_MS = 1_000;
12
+ /** How long the page may take to report its user agent before the trace names the browser alone. */
13
+ const ENVIRONMENT_TIMEOUT_MS = 1_000;
14
+ export class TraceFeed {
15
+ context;
16
+ page;
17
+ refs;
18
+ settings;
19
+ /** Whether the feed told the harness what the attempt runs on. */
20
+ environmentTold = false;
21
+ /**
22
+ * `page` reads the attempt's open page without touching the error latch;
23
+ * `refs` is the registry a screen is captured against, then let go.
24
+ */
25
+ constructor(context, page, refs, settings) {
26
+ this.context = context;
27
+ this.page = page;
28
+ this.refs = refs;
29
+ this.settings = settings;
30
+ }
31
+ /**
32
+ * Tells the harness, once an attempt, the browser and the user agent its
33
+ * page reports. Best effort and not awaited: nothing the test does waits
34
+ * on it.
35
+ */
36
+ tellEnvironment() {
37
+ if (this.environmentTold)
38
+ return;
39
+ const sink = this.context.environment;
40
+ void this.environmentFacts().then((facts) => {
41
+ try {
42
+ if (facts !== undefined)
43
+ sink(facts);
44
+ }
45
+ catch {
46
+ // The harness's sink never fails the attempt.
47
+ }
48
+ });
49
+ }
50
+ /** The browser and the user agent the page reports, within a second; undefined without a page or a browser to ask. */
51
+ async environmentFacts() {
52
+ try {
53
+ const page = this.page();
54
+ if (page === undefined)
55
+ return undefined;
56
+ this.environmentTold = true;
57
+ const browser = page.context().browser();
58
+ const facts = browser === null ? {} : { browser: `${browser.browserType().name()} ${browser.version()}` };
59
+ const budget = { signal: AbortSignal.timeout(ENVIRONMENT_TIMEOUT_MS), timeoutMs: ENVIRONMENT_TIMEOUT_MS };
60
+ const userAgent = await withOperationDeadline(budget, 'user agent', () => page.evaluate(() => navigator.userAgent)).catch(() => undefined);
61
+ return typeof userAgent === 'string' ? { ...facts, 'user agent': userAgent } : facts;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ /**
68
+ * Hands the harness the screen an action left. The read is on the action's
69
+ * path, as the `screen` contract allows, and only when the attempt keeps a
70
+ * trace (the harness hands no `screen` otherwise). The screen is captured
71
+ * like an observation but never published, so every ref a test or model
72
+ * holds stays valid; a page that cannot answer within a second leaves the
73
+ * step without one.
74
+ */
75
+ async screenAfterAction(operation) {
76
+ const sink = this.context.screen;
77
+ const refs = this.refs();
78
+ if (sink === undefined || operation.signal.aborted || refs === undefined)
79
+ return;
80
+ try {
81
+ const page = this.page();
82
+ if (page === undefined)
83
+ return;
84
+ const budget = { signal: operation.signal, timeoutMs: TRACE_SCREEN_TIMEOUT_MS };
85
+ const captured = await withOperationDeadline(budget, 'trace screen', (remaining) => captureObservation(page, refs, this.settings, { ...operation, ...remaining() }, undefined));
86
+ RefRegistry.dispose(captured.generation);
87
+ sink(captured.snapshot);
88
+ }
89
+ catch {
90
+ // A screen the page could not give in time is no evidence the trace claims.
91
+ }
92
+ }
93
+ }
package/dist/video.d.ts CHANGED
@@ -26,8 +26,6 @@ import type { WebScreencastOptions } from './surface.ts';
26
26
  export interface AttemptVideo {
27
27
  /** True while pages the attempt opens start segments of their own. */
28
28
  readonly isArmed: boolean;
29
- /** True when `arm` would start a screencast on the page, which a running trace sized for itself. */
30
- readonly startsScreencast: boolean;
31
29
  arm(page: Page, signal: AbortSignal): Promise<void>;
32
30
  pageOpened(page: Page): Promise<void>;
33
31
  pageClosing(): Promise<void>;
@@ -50,8 +48,6 @@ export declare class VideoRecorder implements AttemptVideo {
50
48
  constructor(artifactsDir: string, options?: WebScreencastOptions);
51
49
  /** True between `arm` and `stop`: a page the attempt opens then starts a segment. */
52
50
  get isArmed(): boolean;
53
- /** True while no segment records: `arm` then starts one on the page it is given. */
54
- get startsScreencast(): boolean;
55
51
  /** Starts the recording on `page`, unless a segment already records, and arms it. */
56
52
  arm(page: Page): Promise<void>;
57
53
  /** A page the attempt opened: the next segment, when armed. */
package/dist/video.js CHANGED
@@ -17,6 +17,14 @@ import { existsSync, mkdirSync } from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { EngineError } from 'e2e/engine';
19
19
  import { currentViewport, message } from './support.js';
20
+ /**
21
+ * How long a segment that has not received a frame yet waits for one before
22
+ * it ends. A screencast sends a frame only when the page repaints, and on
23
+ * Linux the frame of a page that just painted arrives after the paint: a
24
+ * segment stopped right after a navigation would otherwise end with no frame,
25
+ * which Playwright writes as a white video.
26
+ */
27
+ const FIRST_FRAME_WAIT_MS = 1_000;
20
28
  export class VideoRecorder {
21
29
  artifactsDir;
22
30
  options;
@@ -37,10 +45,6 @@ export class VideoRecorder {
37
45
  get isArmed() {
38
46
  return this.armed;
39
47
  }
40
- /** True while no segment records: `arm` then starts one on the page it is given. */
41
- get startsScreencast() {
42
- return this.current === null;
43
- }
44
48
  /** Starts the recording on `page`, unless a segment already records, and arms it. */
45
49
  async arm(page) {
46
50
  if (this.current === null)
@@ -90,12 +94,15 @@ export class VideoRecorder {
90
94
  mkdirSync(path.dirname(absolute), { recursive: true });
91
95
  const { quality } = this.options;
92
96
  const size = this.options.size ?? await currentViewport(page);
97
+ let framedOnce = () => undefined;
98
+ const framed = new Promise((resolve) => (framedOnce = resolve));
93
99
  await page.screencast.start({
94
100
  path: absolute,
95
101
  size: { width: size.width, height: size.height },
96
102
  ...(quality === undefined ? {} : { quality }),
103
+ onFrame: () => framedOnce(),
97
104
  });
98
- this.current = { page, relative, absolute, startedAt: new Date().toISOString() };
105
+ this.current = { page, relative, absolute, startedAt: new Date().toISOString(), framed };
99
106
  }
100
107
  /**
101
108
  * Ends the segment in progress. A stop that failed but left a file keeps
@@ -108,6 +115,7 @@ export class VideoRecorder {
108
115
  if (segment === null)
109
116
  return;
110
117
  this.current = null;
118
+ await waitForFrame(segment, FIRST_FRAME_WAIT_MS);
111
119
  try {
112
120
  await segment.page.screencast.stop();
113
121
  }
@@ -122,3 +130,13 @@ export class VideoRecorder {
122
130
  }
123
131
  }
124
132
  }
133
+ /** Waits up to `timeoutMs` for the segment's first frame. */
134
+ async function waitForFrame(segment, timeoutMs) {
135
+ let timer;
136
+ try {
137
+ await Promise.race([segment.framed, new Promise((resolve) => (timer = setTimeout(resolve, timeoutMs)))]);
138
+ }
139
+ finally {
140
+ clearTimeout(timer);
141
+ }
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e2e-dev/web",
3
- "version": "0.13.0",
3
+ "version": "0.14.0-nightly-20261008205824",
4
4
  "description": "Web engine for e2e agentic end-to-end testing, built on Playwright",
5
5
  "keywords": [
6
6
  "e2e",
@@ -50,11 +50,11 @@
50
50
  "playwright-core": "1.63.0"
51
51
  },
52
52
  "peerDependencies": {
53
- "e2e": ">=0.18.0 <1"
53
+ "e2e": "0.19.0-nightly-20261008205824"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/node": "26.6.2",
57
- "e2e": "0.18.0",
57
+ "e2e": "0.19.0-nightly-20261008205824",
58
58
  "@types/pngjs": "6.0.5",
59
59
  "pngjs": "7.0.0",
60
60
  "typescript": "7.0.2",