@e2e-dev/web 0.15.5-nightly-20261105030216 → 0.15.5

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
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { mkdirSync } from 'node:fs';
10
10
  import path from 'node:path';
11
- import { EngineError, obj, raceAbort, TestError, withinCleanupBudget, } from 'e2e/engine';
11
+ import { Deadline, 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';
@@ -19,7 +19,7 @@ import { TraceFeed } from './trace-feed.js';
19
19
  import { DialogRouter } from './dialogs.js';
20
20
  import { browserCheck, ensureBrowsersInstalled } from './install.js';
21
21
  import { applyPostSteps, frameSelectors, projectExpression } from './locators.js';
22
- import { contentBox, frameContentInset, placeRectInFrame, ROOT_NODE_ID, toSemanticNode } from './observation.js';
22
+ import { contentBox, frameContentInset, placeBoxInFrame, ROOT_NODE_ID, toSemanticNode } from './observation.js';
23
23
  import { captureObservation } from './observation-capture.js';
24
24
  import { maskOptions, secureFieldMasks } from './observe.js';
25
25
  import { SHOWN_SELECTOR } from './shown-selector.js';
@@ -31,7 +31,7 @@ import { SECURE_FIELD_SELECTOR } from './read-node.js';
31
31
  import { readSelector, takeReadsFunction } from './read-selector.js';
32
32
  import { httpCredentials, installSiteHeaders, lowercaseNames, siteHeadersFor } from './protected-app.js';
33
33
  import { RefRegistry } from './refs.js';
34
- import { cancelled, DEFAULT_VIEWPORT, ErrorLatch, invalidState, message, navigationStaleOr, performViewportSwipe, sanitizeFilename, staleOr, translatePwError, } from './support.js';
34
+ import { cancelled, DEFAULT_VIEWPORT, ErrorLatch, invalidState, isClassified, isPwTimeout, message, navigationStaleOr, performViewportSwipe, sanitizeFilename, pageScriptFailure, staleOr, translatePwError, } from './support.js';
35
35
  /** Browser launch budget when the harness init budget is not otherwise expressed. */
36
36
  const BROWSER_LAUNCH_TIMEOUT_MS = 60_000;
37
37
  const STATE_FORMAT = 'playwright-storage-state';
@@ -80,6 +80,8 @@ export class PlaywrightSurface {
80
80
  artifactsDir = '';
81
81
  /** Navigations the engine has in flight, which the app log leaves to the step that started them. */
82
82
  ownNavigations = 0;
83
+ /** One entry per navigation in flight, set to the URL of a document request a test route aborted during it. */
84
+ navigationAborts = new Set();
83
85
  /** What the attempt tells its trace beside the app log; absent between attempts. */
84
86
  trace;
85
87
  artifactCounter = 0;
@@ -132,7 +134,7 @@ export class PlaywrightSurface {
132
134
  return [...scripts, { id: 'browser', status: 'skip', message: `browsers come from the "${this.leases.name}" provider` }];
133
135
  if (this.connect !== undefined)
134
136
  return [...scripts, { id: 'browser', status: 'skip', message: 'the browser is attached over CDP' }];
135
- return [...scripts, ...browserCheck(this.browserName, info.env, info.headed)];
137
+ return [...scripts, ...(await browserCheck(this.browserName, info.env, info.headed))];
136
138
  }
137
139
  /** Releases the browsers `prepare` leased; a local launch or a `connect` has nothing to release. */
138
140
  async finish(info) {
@@ -463,6 +465,9 @@ export class PlaywrightSurface {
463
465
  : await this.session.run(operation, label, fn, bound);
464
466
  }
465
467
  catch (cause) {
468
+ // A handler error latched while the operation ran is its cause: a route
469
+ // that refused its own arguments aborts the request the step waited on.
470
+ this.latch.throwPending();
466
471
  throw translate(cause, label);
467
472
  }
468
473
  }
@@ -483,18 +488,44 @@ export class PlaywrightSurface {
483
488
  resize(size, operation) {
484
489
  return this.acted(operation, this.guard(operation, 'resize', () => this.setViewport(size)));
485
490
  }
486
- /** Runs one navigation the engine itself makes, so the app log leaves it to the step that asked for it. */
491
+ /**
492
+ * Runs one navigation the engine itself makes, so the app log leaves it to
493
+ * the step that asked for it. A navigation that fails after a test route
494
+ * aborted its document request failed because the test aborted it: an
495
+ * action failure, not an engine fault.
496
+ */
487
497
  async navigating(work) {
488
498
  this.ownNavigations += 1;
499
+ const watch = {};
500
+ this.navigationAborts.add(watch);
489
501
  try {
490
502
  const result = await work();
491
503
  this.trace?.tellEnvironment();
492
504
  return result;
493
505
  }
506
+ catch (cause) {
507
+ if (watch.url === undefined || isClassified(cause) || isPwTimeout(cause))
508
+ throw cause;
509
+ throw new TestError('ACTION_FAILED', `navigation failed: a route aborted the request for ${watch.url}: ${message(cause)}`, { cause });
510
+ }
494
511
  finally {
512
+ this.navigationAborts.delete(watch);
495
513
  this.ownNavigations -= 1;
496
514
  }
497
515
  }
516
+ /**
517
+ * Records a request a test route is about to abort, when it loads the
518
+ * document of the attempt's page. Never throws: the abort must follow.
519
+ */
520
+ noteRouteAbort(request) {
521
+ if (this.navigationAborts.size === 0 || !request.isNavigationRequest())
522
+ return;
523
+ const main = this.openPage()?.mainFrame();
524
+ if (main === undefined || frameOf(request) !== main)
525
+ return;
526
+ for (const watch of this.navigationAborts)
527
+ watch.url ??= request.url();
528
+ }
498
529
  /** Resolves with what an action resolved with once the trace has the screen it left (`TraceFeed.screenAfterAction`); an action that failed leaves none. */
499
530
  async acted(operation, work) {
500
531
  const result = await work;
@@ -525,88 +556,104 @@ export class PlaywrightSurface {
525
556
  */
526
557
  locate(expression, operation) {
527
558
  return this.guard(operation, 'locate', async (currentOperation) => {
528
- const session = this.requireSession();
529
- const token = session.token();
530
- const refs = session.refs;
531
- const page = this.requirePage();
532
- const frame = await this.frameOf(expression);
533
- const projected = projectExpression(page, expression, { testIdAttribute: this.testIdAttribute, frameHidden: frame.hidden });
534
- const { displayValue, name, steps } = projected;
535
- // Every match is read by the `e2e-read` selector engine in the task that finds it, so a
536
- // page that replaces it a frame later cannot detach it before the read. A
537
- // predicate-filtered match is one element among many candidates (every input with a
538
- // value, every control the label engine kept). Re-resolving it by position at action
539
- // time would act on a neighbor whenever the page inserted or removed an element in
540
- // between, so such matches are pinned to the handles of the elements the query returned,
541
- // and their reads are taken back from those very handles: what was read and what is
542
- // acted on are one set of elements by construction.
543
- const read = readSelector({ testIdAttribute: this.testIdAttribute, secureFieldSelector: SECURE_FIELD_SELECTOR });
544
- const reading = projected.locator.locator(read.selector);
545
- const handles = displayValue !== null || name !== null
546
- ? (await reading.elementHandles())
547
- : null;
548
- /** Disposes every handle this locate took, on the paths that hand none of them out. */
549
- const releaseHandles = () => {
550
- for (const handle of handles ?? [])
551
- void handle.dispose().catch(() => undefined);
552
- };
553
- const first = handles?.[0];
554
- const taken = handles === null
555
- ? await reading.evaluateAll(takeReadsFunction, { token: read.token })
556
- : first === undefined
557
- ? []
558
- : await first.evaluate(takeReadsFunction, { token: read.token, elements: handles });
559
- const reads = taken
560
- .filter((raw) => raw !== null)
561
- .map((raw) => (frame.hidden ? { ...raw, states: { ...raw.states, hidden: true } } : raw));
562
- if (reads.length !== taken.length) {
563
- releaseHandles();
564
- throw new TestError('INVALID_LOCATOR', 'a selector that captures with * (*css=article >> text=Hello) is not supported: use filter({ has }) instead');
565
- }
566
- const candidates = reads.flatMap((raw, index) => (projected.visible && raw.states.hidden ? [] : [{ raw, index }]));
567
- // An exact label query matches any of the control's labels as the engine's reader names
568
- // them, so text a label marks aria-hidden (a required-field marker) never hides a field,
569
- // and an aria-label override or a second label does not either.
570
- const predicate = displayValue !== null
571
- ? (raw) => matchesText(raw.value ?? '', displayValue)
572
- : name !== null
573
- ? (raw) => raw.labels !== null && raw.labels.some((label) => matchesText(label, name))
574
- : null;
575
- const matches = predicate === null
576
- ? candidates
577
- : await applyPostSteps(candidates.filter(({ raw }) => predicate(raw)), steps,
578
- // A filter after a position runs on that one element alone.
579
- async ({ index }, options) => (await projected.locator.nth(index).filter(options).count()) > 0);
580
- if (currentOperation.signal.aborted) {
581
- releaseHandles();
582
- throw cancelled('locate cancelled');
583
- }
584
559
  try {
585
- session.check(token);
560
+ return await this.locateNow(expression, currentOperation);
586
561
  }
587
562
  catch (cause) {
588
- releaseHandles();
589
- throw cause;
590
- }
591
- if (handles !== null) {
592
- // Only the matches keep their handles; the rest would otherwise live until the page goes.
593
- const kept = new Set(matches.map(({ index }) => index));
594
- for (const [index, handle] of handles.entries()) {
595
- if (!kept.has(index))
596
- void handle.dispose().catch(() => undefined);
597
- }
563
+ throw (await pageScriptFailure(this.frameScope(expression), cause, 'locate', currentOperation.timeoutMs)) ?? cause;
598
564
  }
599
- return matches.map(({ raw, index }) => {
600
- const pinned = handles?.[index];
601
- // A single match keeps the strict locator, so a ref that turns
602
- // ambiguous between locate and perform is stale instead of acting
603
- // on whichever element is first.
604
- const locator = reads.length === 1 ? projected.locator : projected.locator.nth(index);
605
- const id = refs.storeLocated(pinned === undefined ? { kind: 'locator', locator } : { kind: 'element', element: pinned });
606
- return inFrame(toSemanticNode({ id, revision: '' }, raw), frame.box);
607
- });
608
565
  }, staleOr);
609
566
  }
567
+ /** The body of `locate`, inside its guard. */
568
+ async locateNow(expression, currentOperation) {
569
+ const session = this.requireSession();
570
+ const token = session.token();
571
+ const refs = session.refs;
572
+ const page = this.requirePage();
573
+ const frame = await this.frameOf(expression);
574
+ const projected = projectExpression(page, expression, { testIdAttribute: this.testIdAttribute, frameHidden: frame.hidden });
575
+ const { displayValue, name, steps } = projected;
576
+ // Every match is read by the `e2e-read` selector engine in the task that finds it, so a
577
+ // page that replaces it a frame later cannot detach it before the read. A
578
+ // predicate-filtered match is one element among many candidates (every input with a
579
+ // value, every control the label engine kept). Re-resolving it by position at action
580
+ // time would act on a neighbor whenever the page inserted or removed an element in
581
+ // between, so such matches are pinned to the handles of the elements the query returned,
582
+ // and their reads are taken back from those very handles: what was read and what is
583
+ // acted on are one set of elements by construction.
584
+ const read = readSelector({ testIdAttribute: this.testIdAttribute, secureFieldSelector: SECURE_FIELD_SELECTOR });
585
+ const reading = projected.locator.locator(read.selector);
586
+ const handles = displayValue !== null || name !== null
587
+ ? (await reading.elementHandles())
588
+ : null;
589
+ /** Disposes every handle this locate took, on the paths that hand none of them out. */
590
+ const releaseHandles = () => {
591
+ for (const handle of handles ?? [])
592
+ void handle.dispose().catch(() => undefined);
593
+ };
594
+ const first = handles?.[0];
595
+ const taken = handles === null
596
+ ? await reading.evaluateAll(takeReadsFunction, { token: read.token })
597
+ : first === undefined
598
+ ? []
599
+ : await first.evaluate(takeReadsFunction, { token: read.token, elements: handles });
600
+ const reads = taken
601
+ .filter((raw) => raw !== null)
602
+ .map((raw) => (frame.hidden ? { ...raw, states: { ...raw.states, hidden: true } } : raw));
603
+ if (reads.length !== taken.length) {
604
+ releaseHandles();
605
+ throw new TestError('INVALID_LOCATOR', 'a selector that captures with * (*css=article >> text=Hello) is not supported: use filter({ has }) instead');
606
+ }
607
+ const candidates = reads.flatMap((raw, index) => (projected.visible && raw.states.hidden ? [] : [{ raw, index }]));
608
+ // An exact label query matches any of the control's labels as the engine's reader names
609
+ // them, so text a label marks aria-hidden (a required-field marker) never hides a field,
610
+ // and an aria-label override or a second label does not either.
611
+ const predicate = displayValue !== null
612
+ ? (raw) => matchesText(raw.value ?? '', displayValue)
613
+ : name !== null
614
+ ? (raw) => raw.labels !== null && raw.labels.some((label) => matchesText(label, name))
615
+ : null;
616
+ const matches = predicate === null
617
+ ? candidates
618
+ : await applyPostSteps(candidates.filter(({ raw }) => predicate(raw)), steps,
619
+ // A filter after a position runs on that one element alone.
620
+ async ({ index }, options) => (await projected.locator.nth(index).filter(options).count()) > 0);
621
+ if (currentOperation.signal.aborted) {
622
+ releaseHandles();
623
+ throw cancelled('locate cancelled');
624
+ }
625
+ try {
626
+ session.check(token);
627
+ }
628
+ catch (cause) {
629
+ releaseHandles();
630
+ throw cause;
631
+ }
632
+ if (handles !== null) {
633
+ // Only the matches keep their handles; the rest would otherwise live until the page goes.
634
+ const kept = new Set(matches.map(({ index }) => index));
635
+ for (const [index, handle] of handles.entries()) {
636
+ if (!kept.has(index))
637
+ void handle.dispose().catch(() => undefined);
638
+ }
639
+ }
640
+ return matches.map(({ raw, index }) => {
641
+ const pinned = handles?.[index];
642
+ // A single match keeps the strict locator, so a ref that turns
643
+ // ambiguous between locate and perform is stale instead of acting
644
+ // on whichever element is first.
645
+ const locator = reads.length === 1 ? projected.locator : projected.locator.nth(index);
646
+ const id = refs.storeLocated(pinned === undefined ? { kind: 'locator', locator } : { kind: 'element', element: pinned });
647
+ return inFrame(toSemanticNode({ id, revision: '' }, raw), frame.box);
648
+ });
649
+ }
650
+ /** The page or frame an expression's frame selectors lead to, unresolved. */
651
+ frameScope(expression) {
652
+ let scope = this.requirePage();
653
+ for (const selector of frameSelectors(expression))
654
+ scope = scope.frameLocator(selector);
655
+ return scope;
656
+ }
610
657
  /**
611
658
  * One action on a located or observed node. A `swipe` on the observation
612
659
  * root is the viewport swipe: a wheel gesture sized by the viewport, with no
@@ -620,7 +667,11 @@ export class PlaywrightSurface {
620
667
  this.requireSession().requireObservation();
621
668
  return performViewportSwipe(page, action.direction, action.momentum ?? 'none');
622
669
  }
623
- return dispatchLocatorAction(this.requireSession().refs.lookup(ref), action, currentOperation, (other) => this.requireSession().refs.lookup(other));
670
+ const { refs } = this.requireSession();
671
+ return refs.lookup(ref).then((target) => {
672
+ currentOperation.signal.throwIfAborted();
673
+ return dispatchLocatorAction(target, action, currentOperation, (other) => refs.lookup(other));
674
+ });
624
675
  }, (cause) => classifyActionError(cause, action)));
625
676
  }
626
677
  /** One pointer action at a viewport point in CSS pixels, with nothing resolved behind it; see `dispatchPointerAction`. */
@@ -770,17 +821,39 @@ export class PlaywrightSurface {
770
821
  /** Redacted at the source: secure fields in every frame are masked, and the caret is hidden without editing styles, as in observation pixels. */
771
822
  screenshot(label, operation) {
772
823
  return this.guard(operation, 'screenshot', async (currentOperation) => {
773
- const page = this.requirePage();
774
824
  const { relative, absolute } = this.artifactPath('screenshots', label, '.png');
775
- const deadline = Date.now() + currentOperation.timeoutMs;
776
- const masks = await secureFieldMasks(page);
777
- await withHiddenCaret(page, () => page.screenshot({
778
- path: absolute,
779
- timeout: Math.max(1, deadline - Date.now()),
780
- caret: 'initial',
781
- ...maskOptions(masks),
782
- }));
783
- return relative;
825
+ const deadline = new Deadline(currentOperation.timeoutMs);
826
+ // The masks are resolved in the document showing when the capture
827
+ // starts. A capture that fails while the page replaces that document
828
+ // is taken of the replacing one, masked by the same rule, within the
829
+ // step's own deadline.
830
+ for (;;) {
831
+ const page = this.requirePage();
832
+ const documents = watchDocuments(page);
833
+ try {
834
+ const masks = await secureFieldMasks(page);
835
+ await withHiddenCaret(page, () => page.screenshot({
836
+ path: absolute,
837
+ timeout: Math.max(1, deadline.remaining()),
838
+ caret: 'initial',
839
+ ...maskOptions(masks),
840
+ }));
841
+ return relative;
842
+ }
843
+ catch (cause) {
844
+ if (!documents.replaced() || isPwTimeout(cause) || currentOperation.signal.aborted)
845
+ throw cause;
846
+ if (deadline.expired()) {
847
+ throw new EngineError('OPERATION_TIMEOUT', `screenshot timed out: the page replaced its document during every capture for ${currentOperation.timeoutMs}ms`, {
848
+ retryable: false,
849
+ cause,
850
+ });
851
+ }
852
+ }
853
+ finally {
854
+ documents.stop();
855
+ }
856
+ }
784
857
  });
785
858
  }
786
859
  /** Starts the attempt's video. */
@@ -817,8 +890,9 @@ export class PlaywrightSurface {
817
890
  // --- observation ---
818
891
  /**
819
892
  * Captures one atomic semantic observation. Secure fields are masked in the
820
- * page before the tree leaves the engine, and every node keeps a live
821
- * element handle valid until the next observation replaces the generation.
893
+ * page before the tree leaves the engine, and every node stays reachable
894
+ * through its document's element list until the next observation replaces
895
+ * the generation; an action makes the node's handle on first use.
822
896
  *
823
897
  * With `options.pixels`, masked viewport pixels are captured alongside the
824
898
  * tree rather than after it, so the image and the node geometry describe the
@@ -862,6 +936,31 @@ export class PlaywrightSurface {
862
936
  }
863
937
  }
864
938
  }
939
+ /** Watches `page` until `stop` for a frame that commits a new document or detaches, either of which replaces a document a capture is reading. */
940
+ function watchDocuments(page) {
941
+ let replaced = false;
942
+ const mark = () => {
943
+ replaced = true;
944
+ };
945
+ page.on('framenavigated', mark);
946
+ page.on('framedetached', mark);
947
+ return {
948
+ replaced: () => replaced,
949
+ stop: () => {
950
+ page.off('framenavigated', mark);
951
+ page.off('framedetached', mark);
952
+ },
953
+ };
954
+ }
955
+ /** The frame a request belongs to; undefined for a popup's first navigation, which Playwright issues before the popup's frame exists. */
956
+ function frameOf(request) {
957
+ try {
958
+ return request.frame();
959
+ }
960
+ catch {
961
+ return undefined;
962
+ }
963
+ }
865
964
  /**
866
965
  * A located node in the top-level viewport's space, as the observation tree
867
966
  * reports it: its box shifted by the frame's and clipped to it, and dropped
@@ -870,7 +969,6 @@ export class PlaywrightSurface {
870
969
  function inFrame(node, frame) {
871
970
  if (frame === undefined || node.rect === undefined)
872
971
  return node;
873
- const { rect, ...rest } = node;
874
- const placed = frame === null ? undefined : placeRectInFrame(rect, frame);
875
- return placed === undefined ? rest : { ...rest, rect: placed };
972
+ const { rect, origin, ...rest } = node;
973
+ return frame === null ? rest : { ...rest, ...placeBoxInFrame(rect, origin, frame) };
876
974
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e2e-dev/web",
3
- "version": "0.15.5-nightly-20261105030216",
3
+ "version": "0.15.5",
4
4
  "description": "Web engine for e2e agentic end-to-end testing, built on Playwright",
5
5
  "keywords": [
6
6
  "e2e",
@@ -50,12 +50,12 @@
50
50
  "playwright-core": "1.64.1"
51
51
  },
52
52
  "peerDependencies": {
53
- "e2e": "0.22.1-nightly-20261105030216"
53
+ "e2e": ">=0.22.1 <1"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/node": "26.6.6",
57
57
  "@types/pngjs": "6.0.5",
58
- "e2e": "0.22.1-nightly-20261105030216",
58
+ "e2e": "0.23.0",
59
59
  "pngjs": "7.0.0",
60
60
  "typescript": "7.0.2",
61
61
  "vitest": "5.1.0"