@e2e-dev/web 0.15.5-nightly-20261104030231 → 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,9 +19,10 @@ 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
+ import { SHOWN_SELECTOR } from './shown-selector.js';
25
26
  import { withHiddenCaret } from './screenshot-caret.js';
26
27
  import { connectionAbort, withOperationDeadline } from './operation-budget.js';
27
28
  import { CLOSED_SHADOW_ROOTS_INIT_SCRIPT, CLOSED_SHADOW_ROOTS_KEY } from './closed-shadow.js';
@@ -30,7 +31,7 @@ import { SECURE_FIELD_SELECTOR } from './read-node.js';
30
31
  import { readSelector, takeReadsFunction } from './read-selector.js';
31
32
  import { httpCredentials, installSiteHeaders, lowercaseNames, siteHeadersFor } from './protected-app.js';
32
33
  import { RefRegistry } from './refs.js';
33
- 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';
34
35
  /** Browser launch budget when the harness init budget is not otherwise expressed. */
35
36
  const BROWSER_LAUNCH_TIMEOUT_MS = 60_000;
36
37
  const STATE_FORMAT = 'playwright-storage-state';
@@ -79,6 +80,8 @@ export class PlaywrightSurface {
79
80
  artifactsDir = '';
80
81
  /** Navigations the engine has in flight, which the app log leaves to the step that started them. */
81
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();
82
85
  /** What the attempt tells its trace beside the app log; absent between attempts. */
83
86
  trace;
84
87
  artifactCounter = 0;
@@ -131,7 +134,7 @@ export class PlaywrightSurface {
131
134
  return [...scripts, { id: 'browser', status: 'skip', message: `browsers come from the "${this.leases.name}" provider` }];
132
135
  if (this.connect !== undefined)
133
136
  return [...scripts, { id: 'browser', status: 'skip', message: 'the browser is attached over CDP' }];
134
- return [...scripts, ...browserCheck(this.browserName, info.env, info.headed)];
137
+ return [...scripts, ...(await browserCheck(this.browserName, info.env, info.headed))];
135
138
  }
136
139
  /** Releases the browsers `prepare` leased; a local launch or a `connect` has nothing to release. */
137
140
  async finish(info) {
@@ -462,6 +465,9 @@ export class PlaywrightSurface {
462
465
  : await this.session.run(operation, label, fn, bound);
463
466
  }
464
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();
465
471
  throw translate(cause, label);
466
472
  }
467
473
  }
@@ -482,18 +488,44 @@ export class PlaywrightSurface {
482
488
  resize(size, operation) {
483
489
  return this.acted(operation, this.guard(operation, 'resize', () => this.setViewport(size)));
484
490
  }
485
- /** 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
+ */
486
497
  async navigating(work) {
487
498
  this.ownNavigations += 1;
499
+ const watch = {};
500
+ this.navigationAborts.add(watch);
488
501
  try {
489
502
  const result = await work();
490
503
  this.trace?.tellEnvironment();
491
504
  return result;
492
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
+ }
493
511
  finally {
512
+ this.navigationAborts.delete(watch);
494
513
  this.ownNavigations -= 1;
495
514
  }
496
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
+ }
497
529
  /** Resolves with what an action resolved with once the trace has the screen it left (`TraceFeed.screenAfterAction`); an action that failed leaves none. */
498
530
  async acted(operation, work) {
499
531
  const result = await work;
@@ -524,86 +556,104 @@ export class PlaywrightSurface {
524
556
  */
525
557
  locate(expression, operation) {
526
558
  return this.guard(operation, 'locate', async (currentOperation) => {
527
- const session = this.requireSession();
528
- const token = session.token();
529
- const refs = session.refs;
530
- const page = this.requirePage();
531
- const frame = await this.frameBox(expression);
532
- const projected = projectExpression(page, expression, this.testIdAttribute);
533
- const { displayValue, name, steps } = projected;
534
- // Every match is read by the `e2e-read` selector engine in the task that finds it, so a
535
- // page that replaces it a frame later cannot detach it before the read. A
536
- // predicate-filtered match is one element among many candidates (every input with a
537
- // value, every control the label engine kept). Re-resolving it by position at action
538
- // time would act on a neighbor whenever the page inserted or removed an element in
539
- // between, so such matches are pinned to the handles of the elements the query returned,
540
- // and their reads are taken back from those very handles: what was read and what is
541
- // acted on are one set of elements by construction.
542
- const read = readSelector({ testIdAttribute: this.testIdAttribute, secureFieldSelector: SECURE_FIELD_SELECTOR });
543
- const reading = projected.locator.locator(read.selector);
544
- const handles = displayValue !== null || name !== null
545
- ? (await reading.elementHandles())
546
- : null;
547
- /** Disposes every handle this locate took, on the paths that hand none of them out. */
548
- const releaseHandles = () => {
549
- for (const handle of handles ?? [])
550
- void handle.dispose().catch(() => undefined);
551
- };
552
- const first = handles?.[0];
553
- const taken = handles === null
554
- ? await reading.evaluateAll(takeReadsFunction, { token: read.token })
555
- : first === undefined
556
- ? []
557
- : await first.evaluate(takeReadsFunction, { token: read.token, elements: handles });
558
- const reads = taken.filter((raw) => raw !== null);
559
- if (reads.length !== taken.length) {
560
- releaseHandles();
561
- throw new TestError('INVALID_LOCATOR', 'a selector that captures with * (*css=article >> text=Hello) is not supported: use filter({ has }) instead');
562
- }
563
- const candidates = reads.flatMap((raw, index) => (projected.visible && raw.states.hidden ? [] : [{ raw, index }]));
564
- // An exact label query matches any of the control's labels as the engine's reader names
565
- // them, so text a label marks aria-hidden (a required-field marker) never hides a field,
566
- // and an aria-label override or a second label does not either.
567
- const predicate = displayValue !== null
568
- ? (raw) => matchesText(raw.value ?? '', displayValue)
569
- : name !== null
570
- ? (raw) => raw.labels !== null && raw.labels.some((label) => matchesText(label, name))
571
- : null;
572
- const matches = predicate === null
573
- ? candidates
574
- : await applyPostSteps(candidates.filter(({ raw }) => predicate(raw)), steps,
575
- // A filter after a position runs on that one element alone.
576
- async ({ index }, options) => (await projected.locator.nth(index).filter(options).count()) > 0);
577
- if (currentOperation.signal.aborted) {
578
- releaseHandles();
579
- throw cancelled('locate cancelled');
580
- }
581
559
  try {
582
- session.check(token);
560
+ return await this.locateNow(expression, currentOperation);
583
561
  }
584
562
  catch (cause) {
585
- releaseHandles();
586
- throw cause;
587
- }
588
- if (handles !== null) {
589
- // Only the matches keep their handles; the rest would otherwise live until the page goes.
590
- const kept = new Set(matches.map(({ index }) => index));
591
- for (const [index, handle] of handles.entries()) {
592
- if (!kept.has(index))
593
- void handle.dispose().catch(() => undefined);
594
- }
563
+ throw (await pageScriptFailure(this.frameScope(expression), cause, 'locate', currentOperation.timeoutMs)) ?? cause;
595
564
  }
596
- return matches.map(({ raw, index }) => {
597
- const pinned = handles?.[index];
598
- // A single match keeps the strict locator, so a ref that turns
599
- // ambiguous between locate and perform is stale instead of acting
600
- // on whichever element is first.
601
- const locator = reads.length === 1 ? projected.locator : projected.locator.nth(index);
602
- const id = refs.storeLocated(pinned === undefined ? { kind: 'locator', locator } : { kind: 'element', element: pinned });
603
- return inFrame(toSemanticNode({ id, revision: '' }, raw), frame);
604
- });
605
565
  }, staleOr);
606
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
+ }
607
657
  /**
608
658
  * One action on a located or observed node. A `swipe` on the observation
609
659
  * root is the viewport swipe: a wheel gesture sized by the viewport, with no
@@ -617,7 +667,11 @@ export class PlaywrightSurface {
617
667
  this.requireSession().requireObservation();
618
668
  return performViewportSwipe(page, action.direction, action.momentum ?? 'none');
619
669
  }
620
- 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
+ });
621
675
  }, (cause) => classifyActionError(cause, action)));
622
676
  }
623
677
  /** One pointer action at a viewport point in CSS pixels, with nothing resolved behind it; see `dispatchPointerAction`. */
@@ -708,16 +762,22 @@ export class PlaywrightSurface {
708
762
  * the same chain; a frame-scoped `count` is 0 until that frame's document
709
763
  * has loaded, which is what makes `FRAME_NOT_FOUND` worth retrying. Returns
710
764
  * the innermost frame's content box in the top-level viewport, where its
711
- * document's viewport starts: undefined for an expression in the main
712
- * document, null for a frame with no box.
765
+ * document's viewport starts (undefined for an expression in the main
766
+ * document, null for a frame with no box), and
767
+ * whether a frame along the chain is hidden: a frame element's own `hidden`
768
+ * state, read in the document around it, so a cross-origin frame reads too.
769
+ * Every node in a hidden frame is hidden, and so are the frames under it.
713
770
  */
714
- async frameBox(expression) {
771
+ async frameOf(expression) {
715
772
  let scope = this.requirePage();
716
773
  let element;
774
+ let hidden = false;
717
775
  for (const selector of frameSelectors(expression)) {
718
776
  let count;
719
777
  try {
720
778
  count = await scope.locator(selector).count();
779
+ if (count === 1 && !hidden)
780
+ hidden = (await scope.locator(selector).locator(SHOWN_SELECTOR).count()) === 0;
721
781
  }
722
782
  catch (cause) {
723
783
  throw translatePwError(cause, 'frame resolution');
@@ -736,10 +796,10 @@ export class PlaywrightSurface {
736
796
  scope = scope.frameLocator(selector);
737
797
  }
738
798
  if (element === undefined)
739
- return undefined;
799
+ return { box: undefined, hidden };
740
800
  try {
741
801
  const border = await element.boundingBox();
742
- return border === null ? null : contentBox(border, await element.evaluate(frameContentInset));
802
+ return { box: border === null ? null : contentBox(border, await element.evaluate(frameContentInset)), hidden };
743
803
  }
744
804
  catch (cause) {
745
805
  throw translatePwError(cause, 'frame resolution');
@@ -761,15 +821,39 @@ export class PlaywrightSurface {
761
821
  /** Redacted at the source: secure fields in every frame are masked, and the caret is hidden without editing styles, as in observation pixels. */
762
822
  screenshot(label, operation) {
763
823
  return this.guard(operation, 'screenshot', async (currentOperation) => {
764
- const page = this.requirePage();
765
824
  const { relative, absolute } = this.artifactPath('screenshots', label, '.png');
766
- await withHiddenCaret(page, () => page.screenshot({
767
- path: absolute,
768
- timeout: currentOperation.timeoutMs,
769
- caret: 'initial',
770
- ...maskOptions(secureFieldMasks(page)),
771
- }));
772
- 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
+ }
773
857
  });
774
858
  }
775
859
  /** Starts the attempt's video. */
@@ -806,8 +890,9 @@ export class PlaywrightSurface {
806
890
  // --- observation ---
807
891
  /**
808
892
  * Captures one atomic semantic observation. Secure fields are masked in the
809
- * page before the tree leaves the engine, and every node keeps a live
810
- * 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.
811
896
  *
812
897
  * With `options.pixels`, masked viewport pixels are captured alongside the
813
898
  * tree rather than after it, so the image and the node geometry describe the
@@ -819,9 +904,13 @@ export class PlaywrightSurface {
819
904
  // within the same deadline instead of failing the call.
820
905
  return this.guard(operation, 'observe', (currentOperation) => this.captureObservation(currentOperation, options), navigationStaleOr);
821
906
  }
822
- /** Captures privately, then publishes only into the page and ref generation that requested it. */
823
- async captureObservation(operation, options) {
907
+ /** Captures privately in the session's turn, then publishes only into the page and ref generation that requested it. */
908
+ captureObservation(operation, options) {
824
909
  const session = this.requireSession();
910
+ return session.observeInTurn(operation, (turn) => this.captureInTurn(session, turn, options));
911
+ }
912
+ /** One observation that holds the session's turn. */
913
+ async captureInTurn(session, operation, options) {
825
914
  const token = session.token();
826
915
  const refs = session.refs;
827
916
  const page = this.requirePage();
@@ -847,6 +936,31 @@ export class PlaywrightSurface {
847
936
  }
848
937
  }
849
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
+ }
850
964
  /**
851
965
  * A located node in the top-level viewport's space, as the observation tree
852
966
  * reports it: its box shifted by the frame's and clipped to it, and dropped
@@ -855,7 +969,6 @@ export class PlaywrightSurface {
855
969
  function inFrame(node, frame) {
856
970
  if (frame === undefined || node.rect === undefined)
857
971
  return node;
858
- const { rect, ...rest } = node;
859
- const placed = frame === null ? undefined : placeRectInFrame(rect, frame);
860
- return placed === undefined ? rest : { ...rest, rect: placed };
972
+ const { rect, origin, ...rest } = node;
973
+ return frame === null ? rest : { ...rest, ...placeBoxInFrame(rect, origin, frame) };
861
974
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e2e-dev/web",
3
- "version": "0.15.5-nightly-20261104030231",
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-20261104030231"
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-20261104030231",
58
+ "e2e": "0.23.0",
59
59
  "pngjs": "7.0.0",
60
60
  "typescript": "7.0.2",
61
61
  "vitest": "5.1.0"