@e2e-dev/web 0.12.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
@@ -10,18 +10,20 @@ import { mkdirSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { EngineError, raceAbort, TestError, withinCleanupBudget, } from 'e2e/engine';
12
12
  import { matchesText } from 'e2e/engine';
13
- import { classifyActionError, dispatchLocatorAction, dispatchPointerAction } from './actions.js';
13
+ import { classifyActionError, classifyInputError, dispatchLocatorAction, dispatchPointerAction } from './actions.js';
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
- import { connectionAbort } from './operation-budget.js';
24
- import { CLOSED_SHADOW_ROOTS_INIT_SCRIPT } from './closed-shadow.js';
25
+ import { connectionAbort, withOperationDeadline } from './operation-budget.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
@@ -109,7 +115,7 @@ export class PlaywrightSurface {
109
115
  return this.leases.prepare(info);
110
116
  if (this.connect !== undefined)
111
117
  return;
112
- await ensureBrowsersInstalled([this.browserName], { env: info.env, signal: info.signal, log: info.log });
118
+ await ensureBrowsersInstalled([this.browserName], { env: info.env, signal: info.signal, log: info.log, headed: info.headed });
113
119
  }
114
120
  /** Releases the browsers `prepare` leased; a local launch or a `connect` has nothing to release. */
115
121
  async finish(info) {
@@ -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;
@@ -395,16 +421,18 @@ export class PlaywrightSurface {
395
421
  * The single entry of every operation: rethrows an error latched on an
396
422
  * unawaited path, refuses a cancelled operation, races `fn` against the
397
423
  * operation signal so an abort mid-call surfaces as `CANCELLED` instead of
398
- * waiting out Playwright, and translates raw errors at the contract
424
+ * waiting out Playwright, bounds it by the operation's budget so a call a
425
+ * hung page never answers is `OPERATION_TIMEOUT`, and translates raw errors at the contract
399
426
  * boundary. `translate` overrides the default translation for operations
400
- * with a documented retryable failure mode.
427
+ * with a documented retryable failure mode; `bound` is `test-code` for an
428
+ * operation that runs the test's own code, which the deadline never cuts.
401
429
  */
402
- async guard(operation, label, fn, translate = translatePwError) {
430
+ async guard(operation, label, fn, translate = translatePwError, bound = 'deadline') {
403
431
  this.latch.throwPending();
404
432
  try {
405
433
  return this.session === undefined
406
- ? await raceAbort(() => fn(operation), operation.signal, label)
407
- : await this.session.run(operation, label, fn);
434
+ ? await withOperationDeadline(operation, label, (remaining) => fn({ ...operation, ...remaining() }), bound)
435
+ : await this.session.run(operation, label, fn, bound);
408
436
  }
409
437
  catch (cause) {
410
438
  throw translate(cause, label);
@@ -413,15 +441,33 @@ export class PlaywrightSurface {
413
441
  // --- steering: the session hooks ---
414
442
  /** Opens one URL the harness resolved; the attempt's page is created on first use. */
415
443
  open(url, operation) {
416
- return this.guard(operation, 'navigation', async (currentOperation) => {
444
+ return this.acted(operation, this.guard(operation, 'navigation', (currentOperation) => this.navigating(async () => {
417
445
  const page = await this.ensurePage();
418
446
  await page.goto(url, { waitUntil: 'load', timeout: currentOperation.timeoutMs });
419
- });
447
+ })));
420
448
  }
421
449
  back(operation) {
422
- return this.guard(operation, 'navigation', async (currentOperation) => {
450
+ return this.acted(operation, this.guard(operation, 'navigation', (currentOperation) => this.navigating(async () => {
423
451
  await this.requirePage().goBack({ waitUntil: 'load', timeout: currentOperation.timeoutMs });
424
- });
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;
425
471
  }
426
472
  /** Restarts the document while retaining this attempt's context and storage. */
427
473
  restart(operation) {
@@ -451,7 +497,7 @@ export class PlaywrightSurface {
451
497
  const token = session.token();
452
498
  const refs = session.refs;
453
499
  const page = this.requirePage();
454
- await this.validateFrames(expression);
500
+ const frame = await this.frameBox(expression);
455
501
  const projected = projectExpression(page, expression, this.testIdAttribute);
456
502
  const { displayValue, name, steps } = projected;
457
503
  // Every match is read by the `e2e-read` selector engine in the task that finds it, so a
@@ -519,11 +565,11 @@ export class PlaywrightSurface {
519
565
  return matches.map(({ raw, index }) => {
520
566
  const pinned = handles?.[index];
521
567
  // A single match keeps the strict locator, so a ref that turns
522
- // ambiguous between locate and perform fails loud instead of acting
568
+ // ambiguous between locate and perform is stale instead of acting
523
569
  // on whichever element is first.
524
570
  const locator = reads.length === 1 ? projected.locator : projected.locator.nth(index);
525
571
  const id = refs.storeLocated(pinned === undefined ? { kind: 'locator', locator } : { kind: 'element', element: pinned });
526
- return toSemanticNode({ id, revision: '' }, raw);
572
+ return inFrame(toSemanticNode({ id, revision: '' }, raw), frame);
527
573
  });
528
574
  }, staleOr);
529
575
  }
@@ -534,21 +580,21 @@ export class PlaywrightSurface {
534
580
  * on the root acts on the document element the root stands for.
535
581
  */
536
582
  perform(ref, action, operation) {
537
- return this.guard(operation, action.kind, (currentOperation) => {
583
+ return this.acted(operation, this.guard(operation, action.kind, (currentOperation) => {
538
584
  const page = this.requirePage();
539
585
  if (action.kind === 'swipe' && ref.id === ROOT_NODE_ID) {
540
586
  this.requireSession().requireObservation();
541
587
  return performViewportSwipe(page, action.direction, action.momentum ?? 'none');
542
588
  }
543
- return dispatchLocatorAction(this.requireSession().refs.lookup(ref), action, currentOperation.timeoutMs, (other) => this.requireSession().refs.lookup(other));
544
- }, (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)));
545
591
  }
546
592
  /** One pointer action at a viewport point in CSS pixels, with nothing resolved behind it; see `dispatchPointerAction`. */
547
593
  performAt(point, action, operation) {
548
- return this.guard(operation, `${action.kind} at point`, () => {
594
+ return this.acted(operation, this.guard(operation, `${action.kind} at point`, (currentOperation) => {
549
595
  this.requireSession().requireObservation();
550
- return dispatchPointerAction(this.requirePage(), point, action);
551
- });
596
+ return dispatchPointerAction(this.requirePage(), point, action, currentOperation.signal);
597
+ }, classifyInputError));
552
598
  }
553
599
  /**
554
600
  * Types into whatever holds focus. Keystrokes into a document body are
@@ -558,7 +604,7 @@ export class PlaywrightSurface {
558
604
  * scoped by the browser to the whole editing host, so it is opt-in.
559
605
  */
560
606
  typeText(text, options, operation) {
561
- return this.guard(operation, 'keyboard.type', async (currentOperation) => {
607
+ return this.acted(operation, this.guard(operation, 'keyboard.type', async (currentOperation) => {
562
608
  const session = this.requireSession();
563
609
  session.requireObservation();
564
610
  const token = session.token();
@@ -578,14 +624,14 @@ export class PlaywrightSurface {
578
624
  checkpoint();
579
625
  }
580
626
  await page.keyboard.type(text);
581
- });
627
+ }, classifyInputError));
582
628
  }
583
629
  /** Sends one key to whatever holds focus, in the contract's key grammar Playwright shares. */
584
630
  pressKey(key, operation) {
585
- return this.guard(operation, 'keyboard.press', () => {
631
+ return this.acted(operation, this.guard(operation, 'keyboard.press', () => {
586
632
  this.requireSession().requireObservation();
587
633
  return this.requirePage().keyboard.press(key);
588
- });
634
+ }, classifyInputError));
589
635
  }
590
636
  /**
591
637
  * Refuses focused typing when nothing that takes keystrokes has focus, in
@@ -597,8 +643,16 @@ export class PlaywrightSurface {
597
643
  */
598
644
  async requireEditableFocus(page) {
599
645
  const editable = await Promise.all(page.frames().map((frame) => frame
600
- .evaluate(() => {
601
- 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
+ }
602
656
  if (active === null || active === document.body || active === document.documentElement)
603
657
  return false;
604
658
  if (active instanceof HTMLInputElement) {
@@ -606,11 +660,12 @@ export class PlaywrightSurface {
606
660
  }
607
661
  if (active instanceof HTMLTextAreaElement)
608
662
  return !active.disabled && !active.readOnly;
609
- 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) {
610
665
  return false;
611
666
  }
612
667
  return true;
613
- })
668
+ }, CLOSED_SHADOW_ROOTS_KEY)
614
669
  .catch(() => false)));
615
670
  if (!editable.some(Boolean)) {
616
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 });
@@ -620,10 +675,14 @@ export class PlaywrightSurface {
620
675
  * Checks that every frame selector along the expression matches exactly one
621
676
  * element, each counted inside the frame before it, the way `project` walks
622
677
  * the same chain; a frame-scoped `count` is 0 until that frame's document
623
- * 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.
624
682
  */
625
- async validateFrames(expression) {
683
+ async frameBox(expression) {
626
684
  let scope = this.requirePage();
685
+ let element;
627
686
  for (const selector of frameSelectors(expression)) {
628
687
  let count;
629
688
  try {
@@ -642,8 +701,18 @@ export class PlaywrightSurface {
642
701
  retryable: false,
643
702
  });
644
703
  }
704
+ element = scope.locator(selector);
645
705
  scope = scope.frameLocator(selector);
646
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
+ }
647
716
  }
648
717
  // --- artifacts ---
649
718
  /**
@@ -671,15 +740,7 @@ export class PlaywrightSurface {
671
740
  return relative;
672
741
  });
673
742
  }
674
- /** Starts tracing the current attempt. */
675
- startTrace(operation) {
676
- return this.guard(operation, 'trace', () => this.requireSession().startTrace());
677
- }
678
- /** Returns every trace segment finalized by the current attempt. */
679
- stopTrace(operation) {
680
- return this.requireSession().collectTrace(operation);
681
- }
682
- /** Starts the attempt's video before a trace chooses its screencast dimensions. */
743
+ /** Starts the attempt's video. */
683
744
  startVideo(operation) {
684
745
  return this.guard(operation, 'video', (current) => this.requireSession().startVideo(current.signal));
685
746
  }
@@ -754,3 +815,15 @@ export class PlaywrightSurface {
754
815
  }
755
816
  }
756
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.12.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,18 +50,18 @@
50
50
  "playwright-core": "1.63.0"
51
51
  },
52
52
  "peerDependencies": {
53
- "e2e": ">=0.15.0 <1"
53
+ "e2e": "0.19.0-nightly-20261008205824"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/node": "26.6.2",
57
- "e2e": "0.17.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",
61
61
  "vitest": "5.0.1"
62
62
  },
63
63
  "engines": {
64
- "node": ">=22.12.0"
64
+ "node": "^22.22.3 || >=24.8.0"
65
65
  },
66
66
  "scripts": {
67
67
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc --project tsconfig.build.json && node ../../scripts/stamp-dist.ts",