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