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