@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/README.md +1 -1
- package/dist/.build.json +1 -1
- package/dist/actions.d.ts +1 -1
- package/dist/actions.js +17 -6
- package/dist/attempt-session.d.ts +8 -0
- package/dist/attempt-session.js +21 -0
- package/dist/browser-connection.d.ts +2 -3
- package/dist/browser-connection.js +18 -5
- package/dist/browser.d.ts +7 -4
- package/dist/browser.js +65 -53
- package/dist/element-list.d.ts +28 -0
- package/dist/element-list.js +73 -0
- package/dist/evaluation.d.ts +20 -12
- package/dist/evaluation.js +107 -10
- package/dist/frame-document.d.ts +20 -0
- package/dist/frame-document.js +40 -0
- package/dist/in-page/read-semantics.js +3 -0
- package/dist/install.d.ts +2 -2
- package/dist/install.js +5 -5
- package/dist/locators.d.ts +9 -2
- package/dist/locators.js +31 -23
- package/dist/observation-capture.js +3 -3
- package/dist/observation.d.ts +12 -8
- package/dist/observation.js +40 -43
- package/dist/observe.d.ts +3 -2
- package/dist/observe.js +6 -5
- package/dist/read-node.d.ts +5 -0
- package/dist/refs.d.ts +17 -8
- package/dist/refs.js +20 -10
- package/dist/route-options.d.ts +7 -2
- package/dist/route-options.js +33 -9
- package/dist/support.d.ts +16 -3
- package/dist/support.js +31 -0
- package/dist/surface.d.ts +30 -8
- package/dist/surface.js +213 -100
- package/package.json +3 -3
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
560
|
+
return await this.locateNow(expression, currentOperation);
|
|
583
561
|
}
|
|
584
562
|
catch (cause) {
|
|
585
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
|
810
|
-
* element
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
58
|
+
"e2e": "0.23.0",
|
|
59
59
|
"pngjs": "7.0.0",
|
|
60
60
|
"typescript": "7.0.2",
|
|
61
61
|
"vitest": "5.1.0"
|