supercov 4.24.7 → 4.25.0
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/package.json +9 -9
- package/runtime/javascript/atomic.mjs +42 -2
- package/runtime/javascript/launchSupervisor.mjs +10 -0
- package/runtime/javascript/playwright.mjs +426 -179
- package/runtime/javascript/playwrightCacheThread.mjs +6 -0
- package/runtime/javascript/register.mjs +87 -31
- package/runtime/javascript/resolve-loader.mjs +49 -3
- package/runtime/javascript/runnerEvidence.mjs +16 -1
- package/runtime/javascript/runtime.mjs +223 -22
- package/runtime/javascript/snapshots.mjs +108 -0
- package/runtime/javascript/transport.mjs +36 -0
|
@@ -18,7 +18,8 @@ import * as standardPlaywright from "@playwright/test";
|
|
|
18
18
|
import * as coverageRuntime from "./runtime.mjs";
|
|
19
19
|
import { inferTestProvenance, testTags } from "./provenance.mjs";
|
|
20
20
|
import { appendJsonLineDurableSync, appendJsonLineSync } from "./atomic.mjs";
|
|
21
|
-
import {
|
|
21
|
+
import { expandSnapshot, mergeSnapshots } from "./snapshots.mjs";
|
|
22
|
+
import { COVERAGE_DOCUMENT_PHASE_COOKIE, COVERAGE_DOCUMENT_PHASE_SITE_COOKIE, COVERAGE_PHASE_HEADER, COVERAGE_PHASE_COOKIE, COVERAGE_PHASE_SITE_COOKIE, COVERAGE_SCOPE_COOKIE, COVERAGE_SCOPE_HEADER, COVERAGE_SCOPE_SITE_COOKIE, COVERAGE_CARRIER_ENV, encodeCoverageCarrier, encodeCoverageScope, serverEvidenceDirectory, serverEvidencePath, startDocumentInPhase, } from "./transport.mjs";
|
|
22
23
|
export * from "@playwright/test";
|
|
23
24
|
const generatedTargetModule = "__SUPERCOV_PLAYWRIGHT_MODULE__";
|
|
24
25
|
const targetModule = process.env["SUPERCOV_PLAYWRIGHT_MODULE"] ??
|
|
@@ -51,6 +52,33 @@ const browserRuntimePrelude = `(() => { if (globalThis.__SUPERCOV_DIRECT_RUNTIME
|
|
|
51
52
|
const WORKER_ACTIVATION_WAIT_MS = 250;
|
|
52
53
|
const WORKER_SNAPSHOT_WAIT_MS = 5000;
|
|
53
54
|
const WORKER_TIMED_OUT = Symbol("worker did not answer");
|
|
55
|
+
/**
|
|
56
|
+
* Supercov's own Playwright calls, made as one internal API call. Each call a
|
|
57
|
+
* suite makes captures its stack, maps every frame through the runner's
|
|
58
|
+
* source maps, and reports a step and a trace event: the collector's calls at
|
|
59
|
+
* every action did all of that, and on a real suite they were most of what an
|
|
60
|
+
* action waited for, and showed up among the test's own steps. Inside one
|
|
61
|
+
* internal call the rest find its zone: they capture and report nothing. The
|
|
62
|
+
* one stack it captures names nothing anyone reads, so it is not formatted.
|
|
63
|
+
*/
|
|
64
|
+
const unformattedStack = () => "";
|
|
65
|
+
function internally(owner, work) {
|
|
66
|
+
const wrapApiCall = owner?._wrapApiCall;
|
|
67
|
+
if (typeof wrapApiCall !== "function")
|
|
68
|
+
return work();
|
|
69
|
+
const prepareStackTrace = Error.prepareStackTrace;
|
|
70
|
+
Error.prepareStackTrace = unformattedStack;
|
|
71
|
+
try {
|
|
72
|
+
// `{ internal: true }`, or `true` where older releases take a flag.
|
|
73
|
+
return wrapApiCall.call(owner, () => {
|
|
74
|
+
Error.prepareStackTrace = prepareStackTrace;
|
|
75
|
+
return work();
|
|
76
|
+
}, { internal: true });
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
Error.prepareStackTrace = prepareStackTrace;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
54
82
|
function bounded(promise, milliseconds) {
|
|
55
83
|
let timer;
|
|
56
84
|
return Promise.race([
|
|
@@ -197,6 +225,7 @@ class CoveragePhaseController {
|
|
|
197
225
|
cdpSessions = new Map();
|
|
198
226
|
newDocumentScriptIds = new Map();
|
|
199
227
|
pendingRegistrations = new Set();
|
|
228
|
+
pendingAssertion;
|
|
200
229
|
pageRegistrations = new WeakMap();
|
|
201
230
|
scriptUpdate = Promise.resolve();
|
|
202
231
|
proxyCache = new WeakMap();
|
|
@@ -257,12 +286,22 @@ class CoveragePhaseController {
|
|
|
257
286
|
allPages() {
|
|
258
287
|
return [...this.pages];
|
|
259
288
|
}
|
|
289
|
+
/** Any Playwright object of this test's, to make internal calls through. */
|
|
290
|
+
anyOwner() {
|
|
291
|
+
for (const owners of [this.contexts, this.pages, this.workers])
|
|
292
|
+
for (const owner of owners)
|
|
293
|
+
return owner;
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
260
296
|
allWorkers() {
|
|
261
297
|
return [...this.workers];
|
|
262
298
|
}
|
|
263
299
|
async collectRuntimeSnapshots() {
|
|
264
300
|
if (this.runtimeSnapshots)
|
|
265
301
|
return this.runtimeSnapshots;
|
|
302
|
+
return internally(this.anyOwner() ?? [...liveTrackedContexts()][0], () => this.collectRuntimeSnapshotsNow());
|
|
303
|
+
}
|
|
304
|
+
async collectRuntimeSnapshotsNow() {
|
|
266
305
|
timingCount("collectRuntimeSnapshots.cold");
|
|
267
306
|
// Contexts the suite created without going through any fixture the
|
|
268
307
|
// collector wraps (a raw `browser.newContext()` mid-test) are found
|
|
@@ -369,6 +408,9 @@ class CoveragePhaseController {
|
|
|
369
408
|
async snapshotBeforeClose(page) {
|
|
370
409
|
if (!this.pages.has(page) || this.snapshottedPages.has(page) || this.runtimeSnapshots)
|
|
371
410
|
return;
|
|
411
|
+
return internally(page, () => this.snapshotBeforeCloseNow(page));
|
|
412
|
+
}
|
|
413
|
+
async snapshotBeforeCloseNow(page) {
|
|
372
414
|
this.snapshottedPages.add(page);
|
|
373
415
|
this.earlySnapshots.push(...(await this.snapshotPage(page)));
|
|
374
416
|
// A dedicated worker ends with the page that owns it, so it is read
|
|
@@ -393,7 +435,7 @@ class CoveragePhaseController {
|
|
|
393
435
|
for (const context of liveTrackedContexts()) {
|
|
394
436
|
if (!this.contexts.has(context)) {
|
|
395
437
|
const headersKnown = trackedContextConfiguredHeaders.has(context);
|
|
396
|
-
await this.registerContext(context, { adopted: !headersKnown }).catch(() => undefined);
|
|
438
|
+
await internally(context, () => this.registerContext(context, { adopted: !headersKnown })).catch(() => undefined);
|
|
397
439
|
}
|
|
398
440
|
}
|
|
399
441
|
}
|
|
@@ -407,8 +449,19 @@ class CoveragePhaseController {
|
|
|
407
449
|
* already has one of this test's phases is left alone: a reset that
|
|
408
450
|
* arrives late must not drop what this test ran.
|
|
409
451
|
*/
|
|
410
|
-
|
|
411
|
-
|
|
452
|
+
/**
|
|
453
|
+
* `pages`, when given, are the pages the test starts with, read from the
|
|
454
|
+
* live contexts while those are being registered: what they carry is in
|
|
455
|
+
* their documents, which registering a context does not touch, so the
|
|
456
|
+
* test's setup need not wait for one before the other.
|
|
457
|
+
*/
|
|
458
|
+
async takeCarriedEvidence(pages) {
|
|
459
|
+
if (pages === undefined)
|
|
460
|
+
await Promise.all([...this.pendingRegistrations]);
|
|
461
|
+
const targets = pages ?? this.allPages();
|
|
462
|
+
return internally(targets[0] ?? this.anyOwner(), () => this.takeCarriedEvidenceNow(targets));
|
|
463
|
+
}
|
|
464
|
+
async takeCarriedEvidenceNow(pages) {
|
|
412
465
|
const prefix = `${this.scope.attemptId}:`;
|
|
413
466
|
const take = (target) => bounded(target
|
|
414
467
|
.evaluate((prefix) => {
|
|
@@ -416,16 +469,17 @@ class CoveragePhaseController {
|
|
|
416
469
|
if (String(coverageGlobal.__SUPERCOV_PHASE_ID__ ?? "").startsWith(prefix))
|
|
417
470
|
return undefined;
|
|
418
471
|
const getSnapshot = coverageGlobal.__SUPERCOV_COVERAGE_SNAPSHOT__;
|
|
472
|
+
const compact = coverageGlobal.__SUPERCOV_COMPACT_SNAPSHOT__;
|
|
419
473
|
const reset = coverageGlobal.__SUPERCOV_RESET__;
|
|
420
474
|
if (!getSnapshot || !reset)
|
|
421
475
|
return undefined;
|
|
422
|
-
const snapshot = JSON.stringify(getSnapshot());
|
|
476
|
+
const snapshot = typeof compact === "function" ? compact(getSnapshot()) : JSON.stringify(getSnapshot());
|
|
423
477
|
reset();
|
|
424
478
|
return snapshot;
|
|
425
479
|
}, prefix)
|
|
426
|
-
.then((text) => (typeof text === "string" ? JSON.parse(text) : undefined))
|
|
480
|
+
.then((text) => (typeof text === "string" ? expandSnapshot(JSON.parse(text)) : undefined))
|
|
427
481
|
.catch(() => undefined), WORKER_ACTIVATION_WAIT_MS);
|
|
428
|
-
const taken = await Promise.all(
|
|
482
|
+
const taken = await Promise.all(pages.flatMap((page) => [
|
|
429
483
|
...page.frames().map(take),
|
|
430
484
|
...page.workers().map(take),
|
|
431
485
|
]));
|
|
@@ -447,7 +501,7 @@ class CoveragePhaseController {
|
|
|
447
501
|
return Promise.resolve();
|
|
448
502
|
let registration = this.pageRegistrations.get(page);
|
|
449
503
|
if (!registration) {
|
|
450
|
-
registration = this.registerNewPage(page);
|
|
504
|
+
registration = internally(page, () => this.registerNewPage(page));
|
|
451
505
|
this.pageRegistrations.set(page, registration);
|
|
452
506
|
}
|
|
453
507
|
return registration;
|
|
@@ -463,7 +517,15 @@ class CoveragePhaseController {
|
|
|
463
517
|
await this.registerContext(context, {
|
|
464
518
|
adopted: !this.contexts.has(context) && tracked && !headersKnown,
|
|
465
519
|
});
|
|
466
|
-
|
|
520
|
+
// A page of a context whose headers are not known learns each phase
|
|
521
|
+
// from a new-document script replaced through a session of its own
|
|
522
|
+
// (activatePage). Every other page needs none: a session with the
|
|
523
|
+
// Page domain on has the browser send it every event of the page a
|
|
524
|
+
// second time, and the browser's own process spent more on that than
|
|
525
|
+
// the renderers spent running the instrumented app.
|
|
526
|
+
const cdp = this.adoptedContexts.has(context)
|
|
527
|
+
? await page.context().newCDPSession(page).catch(() => undefined)
|
|
528
|
+
: undefined;
|
|
467
529
|
if (cdp) {
|
|
468
530
|
// Chromium runs a session's new-document scripts only once the
|
|
469
531
|
// session has enabled the Page domain. Without it the script that
|
|
@@ -471,25 +533,13 @@ class CoveragePhaseController {
|
|
|
471
533
|
// learned its phase only from the next action, after it had
|
|
472
534
|
// loaded, and everything it ran while loading was placed by time.
|
|
473
535
|
await cdp.send("Page.enable").catch(() => undefined);
|
|
474
|
-
// A document that leaves hands its evidence over here: the
|
|
475
|
-
// next one reads what it stored only on the same origin.
|
|
476
|
-
cdp.on("Runtime.bindingCalled", ({ name, payload }) => {
|
|
477
|
-
if (name !== LEAVING_BINDING || this.runtimeSnapshots)
|
|
478
|
-
return;
|
|
479
|
-
try {
|
|
480
|
-
this.leftSnapshots.push(JSON.parse(payload));
|
|
481
|
-
}
|
|
482
|
-
catch {
|
|
483
|
-
// Not evidence.
|
|
484
|
-
}
|
|
485
|
-
});
|
|
486
|
-
await cdp.send("Runtime.enable").catch(() => undefined);
|
|
487
|
-
await cdp.send("Runtime.addBinding", { name: LEAVING_BINDING }).catch(() => undefined);
|
|
488
536
|
this.cdpSessions.set(page, cdp);
|
|
489
537
|
}
|
|
490
538
|
const phaseId = this.requestPhaseId();
|
|
491
|
-
if (phaseId)
|
|
539
|
+
if (phaseId && this.adoptedContexts.has(context))
|
|
492
540
|
await this.activatePage(page, phaseId);
|
|
541
|
+
else if (phaseId)
|
|
542
|
+
await this.activateFrames(phaseId, [page]);
|
|
493
543
|
page.on("worker", (worker) => {
|
|
494
544
|
void this.registerWorker(worker);
|
|
495
545
|
});
|
|
@@ -503,11 +553,18 @@ class CoveragePhaseController {
|
|
|
503
553
|
patchContextPrototype(context);
|
|
504
554
|
if (adopted)
|
|
505
555
|
this.adoptedContexts.add(context);
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
556
|
+
return internally(context, () => this.registerContextNow(context, adopted));
|
|
557
|
+
}
|
|
558
|
+
async registerContextNow(context, adopted) {
|
|
559
|
+
// The context's own scripts first: sent when it was made, so they
|
|
560
|
+
// are in place, or sent now for a context made some other way.
|
|
561
|
+
await prepareContext(context);
|
|
562
|
+
// Then what every new document of the context runs for this test, in
|
|
563
|
+
// this order, as one script: one call where several, each awaited,
|
|
564
|
+
// were most of what a test waited for before its page opened. Each
|
|
565
|
+
// part runs on its own, as separate scripts did, so one that throws
|
|
566
|
+
// leaves the others.
|
|
567
|
+
const parts = [];
|
|
511
568
|
// A context that outlives its test (a worker-scoped browser fixture)
|
|
512
569
|
// is registered again by every later test, so this script accumulates
|
|
513
570
|
// on it and each new document runs every copy in registration order.
|
|
@@ -515,55 +572,25 @@ class CoveragePhaseController {
|
|
|
515
572
|
// own attempt, so the newest wins; the fetch patch is installed once
|
|
516
573
|
// and reads the attempt at call time, so a stale copy can never pin an
|
|
517
574
|
// old scope onto a request the way nested wrappers would.
|
|
518
|
-
|
|
519
|
-
const coverageGlobal = globalThis;
|
|
520
|
-
coverageGlobal.__SUPERCOV_ATTEMPT__ = { attemptId, scopeValue };
|
|
521
|
-
coverageGlobal.__SUPERCOV_MCDC_TEST_ID__ = attemptId;
|
|
522
|
-
try {
|
|
523
|
-
document.cookie = `${scopeCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=Lax`;
|
|
524
|
-
// And for this document's frames on another of the app's
|
|
525
|
-
// hosts, where crossing sites is allowed.
|
|
526
|
-
if (globalThis.isSecureContext)
|
|
527
|
-
document.cookie = `${scopeSiteCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=None; Secure`;
|
|
528
|
-
if (!coverageGlobal.__SUPERCOV_BROWSER_FETCH_PATCHED__) {
|
|
529
|
-
const originalFetch = globalThis.fetch?.bind(globalThis);
|
|
530
|
-
if (originalFetch) {
|
|
531
|
-
coverageGlobal.__SUPERCOV_BROWSER_FETCH_PATCHED__ = true;
|
|
532
|
-
globalThis.fetch = ((input, init) => {
|
|
533
|
-
// Only the page's own origin hears the scope. A
|
|
534
|
-
// header on a request to another origin makes the
|
|
535
|
-
// browser ask that origin first, and one that
|
|
536
|
-
// allows only the headers it uses refuses: the
|
|
537
|
-
// page's fetch failed under measurement alone.
|
|
538
|
-
try {
|
|
539
|
-
const target = new URL(input instanceof Request ? input.url : String(input), location.href);
|
|
540
|
-
if (target.origin !== location.origin)
|
|
541
|
-
return originalFetch(input, init);
|
|
542
|
-
}
|
|
543
|
-
catch {
|
|
544
|
-
return originalFetch(input, init);
|
|
545
|
-
}
|
|
546
|
-
const headers = new Headers(init?.headers ??
|
|
547
|
-
(input instanceof Request ? input.headers : undefined));
|
|
548
|
-
headers.set(scopeHeader, coverageGlobal.__SUPERCOV_ATTEMPT__?.scopeValue ?? scopeValue);
|
|
549
|
-
const phase = coverageGlobal.__SUPERCOV_PHASE_ID__;
|
|
550
|
-
if (phase)
|
|
551
|
-
headers.set("x-supercov-phase", phase);
|
|
552
|
-
return originalFetch(input, { ...init, headers });
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
catch {
|
|
558
|
-
// Browser instrumentation must not change application behavior.
|
|
559
|
-
}
|
|
560
|
-
}, {
|
|
575
|
+
parts.push(`(${announceAttempt})(${JSON.stringify({
|
|
561
576
|
attemptId: this.scope.attemptId,
|
|
562
577
|
scopeHeader: COVERAGE_SCOPE_HEADER,
|
|
563
578
|
scopeValue: encodeCoverageScope(this.scope),
|
|
564
579
|
scopeCookie: COVERAGE_SCOPE_COOKIE,
|
|
565
580
|
scopeSiteCookie: COVERAGE_SCOPE_SITE_COOKIE,
|
|
566
|
-
})
|
|
581
|
+
})});`);
|
|
582
|
+
// A new document starts in the last action's phase, from the cookie
|
|
583
|
+
// each action sets: one script per context, where a script replaced on
|
|
584
|
+
// every page at every phase took two Playwright calls each time.
|
|
585
|
+
parts.push(`(${startDocumentInPhase})(${JSON.stringify({
|
|
586
|
+
attemptId: this.scope.attemptId,
|
|
587
|
+
phaseCookie: COVERAGE_DOCUMENT_PHASE_COOKIE,
|
|
588
|
+
phaseSiteCookie: COVERAGE_DOCUMENT_PHASE_SITE_COOKIE,
|
|
589
|
+
})});`);
|
|
590
|
+
await Promise.all([
|
|
591
|
+
context.addInitScript({ content: separately(parts) }).catch(() => undefined),
|
|
592
|
+
adopted ? undefined : this.updateContextHeaders(context, this.requestPhaseId(), this.lastActionId),
|
|
593
|
+
]);
|
|
567
594
|
const register = (page) => {
|
|
568
595
|
const pending = this.registerPage(page).finally(() => this.pendingRegistrations.delete(pending));
|
|
569
596
|
this.pendingRegistrations.add(pending);
|
|
@@ -576,8 +603,6 @@ class CoveragePhaseController {
|
|
|
576
603
|
this.contextListeners.set(context, [["page", register], ["serviceworker", registerServiceWorker]]);
|
|
577
604
|
for (const worker of context.serviceWorkers())
|
|
578
605
|
void this.registerWorker(worker);
|
|
579
|
-
if (!adopted)
|
|
580
|
-
await this.updateContextHeaders(context, this.requestPhaseId());
|
|
581
606
|
for (const page of context.pages())
|
|
582
607
|
register(page);
|
|
583
608
|
}
|
|
@@ -585,6 +610,9 @@ class CoveragePhaseController {
|
|
|
585
610
|
if (this.workers.has(worker))
|
|
586
611
|
return;
|
|
587
612
|
this.workers.add(worker);
|
|
613
|
+
return internally(worker, () => this.registerWorkerNow(worker));
|
|
614
|
+
}
|
|
615
|
+
async registerWorkerNow(worker) {
|
|
588
616
|
const phaseId = this.requestPhaseId();
|
|
589
617
|
const registration = await worker
|
|
590
618
|
.evaluate(({ attemptId, scopeHeader, scopeValue, phaseHeader, phase }) => {
|
|
@@ -636,12 +664,21 @@ class CoveragePhaseController {
|
|
|
636
664
|
const phase = this.createPhase("action", operation);
|
|
637
665
|
this.lastActionId = phase.id;
|
|
638
666
|
this.activePhaseId = phase.id;
|
|
639
|
-
await timed("action.activateInBrowser", () => this.activateInBrowser(phase.id));
|
|
667
|
+
await timed("action.activateInBrowser", () => this.activateInBrowser(phase.id, phase.id));
|
|
640
668
|
return phase;
|
|
641
669
|
}
|
|
642
|
-
beginAssertion(operation, source = callerSource()) {
|
|
670
|
+
beginAssertion(operation, source = callerSource(), reachesBrowser = true) {
|
|
643
671
|
timingCount("beginAssertion");
|
|
644
672
|
const phase = this.createPhase("assertion", operation, this.lastActionId, source);
|
|
673
|
+
// An assertion that is over before any message could reach the
|
|
674
|
+
// browser (`expect(value).toBe(1)`) gets no browser phase: telling the
|
|
675
|
+
// page and the app's cookies afterwards labelled what the page did
|
|
676
|
+
// next, the last action's work, as this assertion's, and queued two
|
|
677
|
+
// calls ahead of the test's next action.
|
|
678
|
+
if (!reachesBrowser) {
|
|
679
|
+
timingCount("assertion.offBrowser");
|
|
680
|
+
return phase;
|
|
681
|
+
}
|
|
645
682
|
this.activePhaseId = phase.id;
|
|
646
683
|
// Playwright queues browser protocol commands in order. Starting this
|
|
647
684
|
// evaluation before an async locator assertion is sufficient to tag its
|
|
@@ -649,12 +686,37 @@ class CoveragePhaseController {
|
|
|
649
686
|
void timed("assertion.activateInBrowser", () => this.activateInBrowser(phase.id));
|
|
650
687
|
return phase;
|
|
651
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* An instrumented assertion's phase, opened around its arguments as well
|
|
691
|
+
* as its matcher, which reaches the browser just before the first thing
|
|
692
|
+
* in it that can: a call on one of the test's Playwright objects, or a
|
|
693
|
+
* matcher that waits (promotePendingAssertion). Playwright sends calls in
|
|
694
|
+
* order, so the page has the phase before anything the assertion asks of
|
|
695
|
+
* it. One that asks nothing of the page sends nothing.
|
|
696
|
+
*/
|
|
697
|
+
beginBridgedAssertion(operation, source) {
|
|
698
|
+
const phase = this.beginAssertion(operation, source, false);
|
|
699
|
+
this.pendingAssertion = phase;
|
|
700
|
+
return phase;
|
|
701
|
+
}
|
|
702
|
+
promotePendingAssertion() {
|
|
703
|
+
const phase = this.pendingAssertion;
|
|
704
|
+
if (!phase)
|
|
705
|
+
return;
|
|
706
|
+
this.pendingAssertion = undefined;
|
|
707
|
+
timingCount("assertion.promoted");
|
|
708
|
+
this.activePhaseId = phase.id;
|
|
709
|
+
void timed("assertion.activateInBrowser", () => this.activateInBrowser(phase.id));
|
|
710
|
+
}
|
|
652
711
|
requestPhaseId() {
|
|
653
712
|
return this.activePhaseId;
|
|
654
713
|
}
|
|
655
714
|
async dispose() {
|
|
656
715
|
this.disposed = true;
|
|
657
716
|
await Promise.all([...this.pendingRegistrations]);
|
|
717
|
+
return internally(this.anyOwner(), () => this.disposeNow());
|
|
718
|
+
}
|
|
719
|
+
async disposeNow() {
|
|
658
720
|
await this.scriptUpdate;
|
|
659
721
|
for (const [context, listeners] of this.contextListeners) {
|
|
660
722
|
for (const [event, listener] of listeners)
|
|
@@ -662,6 +724,11 @@ class CoveragePhaseController {
|
|
|
662
724
|
}
|
|
663
725
|
this.contextListeners.clear();
|
|
664
726
|
for (const [page, cdp] of this.cdpSessions) {
|
|
727
|
+
// A closed page took its session with it: detaching it failed for
|
|
728
|
+
// every page a test closed, each failure a Playwright error with a
|
|
729
|
+
// stack, and was the only collector call on a slice that failed.
|
|
730
|
+
if (page.isClosed?.())
|
|
731
|
+
continue;
|
|
665
732
|
const identifier = this.newDocumentScriptIds.get(page);
|
|
666
733
|
if (identifier)
|
|
667
734
|
await cdp
|
|
@@ -673,6 +740,8 @@ class CoveragePhaseController {
|
|
|
673
740
|
}
|
|
674
741
|
}
|
|
675
742
|
finish(phase, error) {
|
|
743
|
+
if (this.pendingAssertion === phase)
|
|
744
|
+
this.pendingAssertion = undefined;
|
|
676
745
|
phase.endedAtMs = Date.now();
|
|
677
746
|
phase.status = error === undefined ? "passed" : "failed";
|
|
678
747
|
if (error !== undefined)
|
|
@@ -695,6 +764,7 @@ class CoveragePhaseController {
|
|
|
695
764
|
return value;
|
|
696
765
|
const method = String(property);
|
|
697
766
|
return (...args) => {
|
|
767
|
+
this.promotePendingAssertion();
|
|
698
768
|
const isRequest = REQUEST_METHODS.has(method) &&
|
|
699
769
|
isApiRequestContext(object);
|
|
700
770
|
if (ACTION_METHODS.has(method) || isRequest) {
|
|
@@ -818,19 +888,33 @@ class CoveragePhaseController {
|
|
|
818
888
|
* the 43 extra seconds, with the database worker's bounded wait the
|
|
819
889
|
* largest part).
|
|
820
890
|
*/
|
|
821
|
-
|
|
891
|
+
activateInBrowser(phaseId, documentPhaseId) {
|
|
892
|
+
return internally(this.anyOwner(), () => this.activateInBrowserNow(phaseId, documentPhaseId));
|
|
893
|
+
}
|
|
894
|
+
async activateInBrowserNow(phaseId, documentPhaseId) {
|
|
822
895
|
await Promise.all([
|
|
823
896
|
timed("activate.contextHeaders", () => Promise.all([...this.contexts]
|
|
824
897
|
.filter((context) => !this.adoptedContexts.has(context))
|
|
825
|
-
.map((context) => this.updateContextHeaders(context, phaseId)))),
|
|
898
|
+
.map((context) => this.updateContextHeaders(context, phaseId, documentPhaseId)))),
|
|
826
899
|
this.activateFrames(phaseId),
|
|
827
900
|
this.activateWorkers(phaseId),
|
|
828
|
-
|
|
901
|
+
// A context whose headers are not known gets no cookies, so its
|
|
902
|
+
// pages' new documents learn the phase from a script replaced here.
|
|
903
|
+
timed("activate.pageScript", () => Promise.all([...this.pages]
|
|
904
|
+
.filter((page) => this.adoptedContexts.has(page.context()))
|
|
905
|
+
.map((page) => this.activatePage(page, phaseId)))),
|
|
829
906
|
]);
|
|
830
907
|
}
|
|
831
|
-
async activateFrames(phaseId) {
|
|
832
|
-
|
|
833
|
-
|
|
908
|
+
async activateFrames(phaseId, pages = this.pages) {
|
|
909
|
+
// The app's own hosts have the phase from the context's cookies, set
|
|
910
|
+
// beside this; a document there writing them again was a round trip
|
|
911
|
+
// to the cookie store per cookie, on every action. A context whose
|
|
912
|
+
// headers are not known gets none, so its documents still write them.
|
|
913
|
+
const covered = (page) => this.adoptedContexts.has(page.context())
|
|
914
|
+
? []
|
|
915
|
+
: this.appHosts.map((url) => new URL(url).hostname);
|
|
916
|
+
await timed("activate.frameEvaluate", () => Promise.all([...pages].flatMap((page) => page.frames().map((frame) => [frame, covered(page)])).map(([frame, coveredHosts]) => frame
|
|
917
|
+
.evaluate(({ id, attemptId, storageKey, scopeCookie, scopeValue, phaseCookie, scopeSiteCookie, phaseSiteCookie, coveredHosts }) => {
|
|
834
918
|
globalThis.__SUPERCOV_PHASE_ID__ = id;
|
|
835
919
|
const coverageGlobal = globalThis;
|
|
836
920
|
// A document that loaded under an earlier test's attempt (a page
|
|
@@ -840,6 +924,8 @@ class CoveragePhaseController {
|
|
|
840
924
|
coverageGlobal.__SUPERCOV_ACTIVATE_PROBE_CONTEXT__?.(attemptId, id);
|
|
841
925
|
try {
|
|
842
926
|
localStorage.setItem(storageKey, id);
|
|
927
|
+
if (coveredHosts.includes(location.hostname))
|
|
928
|
+
return;
|
|
843
929
|
document.cookie = `${scopeCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=Lax`;
|
|
844
930
|
document.cookie = `${phaseCookie}=${encodeURIComponent(id)}; Path=/; SameSite=Lax`;
|
|
845
931
|
if (globalThis.isSecureContext) {
|
|
@@ -859,8 +945,9 @@ class CoveragePhaseController {
|
|
|
859
945
|
phaseCookie: COVERAGE_PHASE_COOKIE,
|
|
860
946
|
scopeSiteCookie: COVERAGE_SCOPE_SITE_COOKIE,
|
|
861
947
|
phaseSiteCookie: COVERAGE_PHASE_SITE_COOKIE,
|
|
948
|
+
coveredHosts,
|
|
862
949
|
})
|
|
863
|
-
.catch(() =>
|
|
950
|
+
.catch(() => timingCount("activate.frameRefused")))));
|
|
864
951
|
}
|
|
865
952
|
async activateWorkers(phaseId) {
|
|
866
953
|
await timed("activate.workerEvaluate", () => Promise.all([...this.workers].map(async (worker) => {
|
|
@@ -891,32 +978,49 @@ class CoveragePhaseController {
|
|
|
891
978
|
* site from the first navigation on, and, on a secure origin, to frames
|
|
892
979
|
* of the app's other hosts as well.
|
|
893
980
|
*/
|
|
894
|
-
async updateContextHeaders(context, phaseId) {
|
|
981
|
+
async updateContextHeaders(context, phaseId, documentPhaseId) {
|
|
895
982
|
const scope = encodeURIComponent(encodeCoverageScope(this.scope));
|
|
896
983
|
const phase = phaseId ? encodeURIComponent(phaseId) : undefined;
|
|
897
|
-
const
|
|
984
|
+
const documentPhase = documentPhaseId ? encodeURIComponent(documentPhaseId) : undefined;
|
|
985
|
+
const pairs = (scopeName, phaseName, documentName) => [
|
|
898
986
|
[scopeName, scope],
|
|
899
987
|
...(phase ? [[phaseName, phase]] : []),
|
|
988
|
+
...(documentPhase ? [[documentName, documentPhase]] : []),
|
|
900
989
|
];
|
|
901
990
|
// The context's own headers are left alone: they are the suite's, and
|
|
902
991
|
// a test that sets more with `setExtraHTTPHeaders` keeps them. Setting
|
|
903
992
|
// them back at every phase dropped a test's `X-Forwarded-For`.
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
993
|
+
const byHost = this.appHosts.map((url) => {
|
|
994
|
+
const host = new URL(url);
|
|
995
|
+
// For the app's own site: requests, navigations and sockets.
|
|
996
|
+
const cookies = pairs(COVERAGE_SCOPE_COOKIE, COVERAGE_PHASE_COOKIE, COVERAGE_DOCUMENT_PHASE_COOKIE).map(([name, value]) => ({ name, value, url, sameSite: "Lax" }));
|
|
997
|
+
// For its frames on another of its hosts: a cookie that
|
|
998
|
+
// crosses sites must be Secure, which needs https or a local
|
|
999
|
+
// host. Chromium keeps such a cookie only when it is given by
|
|
1000
|
+
// domain; given by an http URL it is dropped without a word.
|
|
1001
|
+
if (host.protocol === "https:" || ["localhost", "127.0.0.1"].includes(host.hostname)) {
|
|
1002
|
+
cookies.push(...pairs(COVERAGE_SCOPE_SITE_COOKIE, COVERAGE_PHASE_SITE_COOKIE, COVERAGE_DOCUMENT_PHASE_SITE_COOKIE).map(([name, value]) => ({ name, value, domain: host.hostname, path: "/", sameSite: "None", secure: true })));
|
|
1003
|
+
}
|
|
1004
|
+
return cookies;
|
|
1005
|
+
});
|
|
1006
|
+
// Every host's cookies in one call: a call per host, each its own
|
|
1007
|
+
// round trip through Playwright, was most of what an action waited
|
|
1008
|
+
// for. A cookie is kept per host, for every port of it: hosts that
|
|
1009
|
+
// differ only by port gave the same cookies twice, and each goes once.
|
|
1010
|
+
// A cookie the browser refuses fails the whole call, so then each host
|
|
1011
|
+
// is given its own and one refused leaves the others theirs.
|
|
1012
|
+
const distinct = new Map();
|
|
1013
|
+
for (const cookie of byHost.flat()) {
|
|
1014
|
+
const target = cookie.url ? new URL(cookie.url) : undefined;
|
|
1015
|
+
const where = target ? `${target.protocol}//${target.hostname}${target.pathname}` : `${cookie.domain}${cookie.path}`;
|
|
1016
|
+
const key = JSON.stringify([cookie.name, cookie.value, where, cookie.sameSite, cookie.secure ?? false]);
|
|
1017
|
+
if (!distinct.has(key))
|
|
1018
|
+
distinct.set(key, cookie);
|
|
1019
|
+
}
|
|
1020
|
+
await context.addCookies([...distinct.values()]).catch(() => {
|
|
1021
|
+
timingCount("activate.cookiesRefused");
|
|
1022
|
+
return Promise.all(byHost.map((cookies) => context.addCookies(cookies).catch(() => timingCount("activate.hostCookiesRefused"))));
|
|
1023
|
+
});
|
|
920
1024
|
}
|
|
921
1025
|
async activatePage(page, phaseId) {
|
|
922
1026
|
const cdp = this.cdpSessions.get(page);
|
|
@@ -948,55 +1052,114 @@ class CoveragePhaseController {
|
|
|
948
1052
|
* the end of every test (20,000 events: 470 ms, against 34 ms as one JSON
|
|
949
1053
|
* string), and doubled a 76-test suite's time.
|
|
950
1054
|
*/
|
|
951
|
-
const LEAVING_BINDING = "__supercovLeaving";
|
|
952
1055
|
/**
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
* restored the same evidence and added to it.
|
|
1056
|
+
* The attempt a new document belongs to, for the app's server: its cookies,
|
|
1057
|
+
* and the header its fetch sends to its own origin.
|
|
956
1058
|
*/
|
|
957
|
-
function
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1059
|
+
function announceAttempt({ attemptId, scopeHeader, scopeValue, scopeCookie, scopeSiteCookie }) {
|
|
1060
|
+
const coverageGlobal = globalThis;
|
|
1061
|
+
coverageGlobal.__SUPERCOV_ATTEMPT__ = { attemptId, scopeValue };
|
|
1062
|
+
coverageGlobal.__SUPERCOV_MCDC_TEST_ID__ = attemptId;
|
|
1063
|
+
try {
|
|
1064
|
+
document.cookie = `${scopeCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=Lax`;
|
|
1065
|
+
// And for this document's frames on another of the app's
|
|
1066
|
+
// hosts, where crossing sites is allowed.
|
|
1067
|
+
if (globalThis.isSecureContext)
|
|
1068
|
+
document.cookie = `${scopeSiteCookie}=${encodeURIComponent(scopeValue)}; Path=/; SameSite=None; Secure`;
|
|
1069
|
+
if (!coverageGlobal.__SUPERCOV_BROWSER_FETCH_PATCHED__) {
|
|
1070
|
+
const originalFetch = globalThis.fetch?.bind(globalThis);
|
|
1071
|
+
if (originalFetch) {
|
|
1072
|
+
coverageGlobal.__SUPERCOV_BROWSER_FETCH_PATCHED__ = true;
|
|
1073
|
+
globalThis.fetch = ((input, init) => {
|
|
1074
|
+
// Only the page's own origin hears the scope. A
|
|
1075
|
+
// header on a request to another origin makes the
|
|
1076
|
+
// browser ask that origin first, and one that
|
|
1077
|
+
// allows only the headers it uses refuses: the
|
|
1078
|
+
// page's fetch failed under measurement alone.
|
|
1079
|
+
try {
|
|
1080
|
+
const target = new URL(input instanceof Request ? input.url : String(input), location.href);
|
|
1081
|
+
if (target.origin !== location.origin)
|
|
1082
|
+
return originalFetch(input, init);
|
|
1083
|
+
}
|
|
1084
|
+
catch {
|
|
1085
|
+
return originalFetch(input, init);
|
|
1086
|
+
}
|
|
1087
|
+
const headers = new Headers(init?.headers ??
|
|
1088
|
+
(input instanceof Request ? input.headers : undefined));
|
|
1089
|
+
headers.set(scopeHeader, coverageGlobal.__SUPERCOV_ATTEMPT__?.scopeValue ?? scopeValue);
|
|
1090
|
+
const phase = coverageGlobal.__SUPERCOV_PHASE_ID__;
|
|
1091
|
+
if (phase)
|
|
1092
|
+
headers.set("x-supercov-phase", phase);
|
|
1093
|
+
return originalFetch(input, { ...init, headers });
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
984
1096
|
}
|
|
985
1097
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1098
|
+
catch {
|
|
1099
|
+
// Browser instrumentation must not change application behavior.
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const LEAVING_BINDING = "__supercovLeaving";
|
|
1103
|
+
/** Scripts as one, each part run on its own as separate scripts were. */
|
|
1104
|
+
function separately(parts) {
|
|
1105
|
+
return parts.map((part) => `try {\n${part}\n} catch (error) {}`).join("\n");
|
|
1106
|
+
}
|
|
1107
|
+
/** A document that leaves hands its evidence to the test that has its page. */
|
|
1108
|
+
function takeLeavingEvidence(source, payload) {
|
|
1109
|
+
const controller = source?.page ? controllerOwning(source.page) : undefined;
|
|
1110
|
+
if (!controller || controller.runtimeSnapshots)
|
|
1111
|
+
return;
|
|
1112
|
+
try {
|
|
1113
|
+
timingCount("transfer.leavingBytes", typeof payload === "string" ? payload.length : 0);
|
|
1114
|
+
controller.leftSnapshots.push(expandSnapshot(JSON.parse(payload)));
|
|
1115
|
+
}
|
|
1116
|
+
catch {
|
|
1117
|
+
// Not evidence.
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* What a context's documents run whichever test has them -- the runtime, and
|
|
1122
|
+
* the binding through which a leaving document hands its evidence over (the
|
|
1123
|
+
* next one reads what it stored only on the same origin) -- sent once, when
|
|
1124
|
+
* the context is made: Playwright opens the test's page meanwhile, and the
|
|
1125
|
+
* test's setup waits only for its own script and cookies. A context that
|
|
1126
|
+
* outlives its test keeps the one binding: the test that has the page now
|
|
1127
|
+
* takes what it hands over.
|
|
1128
|
+
*/
|
|
1129
|
+
const preparedContexts = new WeakMap();
|
|
1130
|
+
function prepareContext(context) {
|
|
1131
|
+
let prepared = preparedContexts.get(context);
|
|
1132
|
+
if (!prepared) {
|
|
1133
|
+
const parts = [];
|
|
1134
|
+
// A Rust crate compiled to wasm the run measures: pages remember
|
|
1135
|
+
// their wasm instances, so each test's work can be taken from them.
|
|
1136
|
+
if (process.env.SUPERCOV_WASM_REGIONS === "1")
|
|
1137
|
+
parts.push("globalThis.__SUPERCOV_WASM_REGIONS__ = true;");
|
|
1138
|
+
parts.push(browserRuntimePrelude);
|
|
1139
|
+
prepared = Promise.resolve(internally(context, () => Promise.all([
|
|
1140
|
+
context.addInitScript({ content: separately(parts) }).catch(() => undefined),
|
|
1141
|
+
context.exposeBinding(LEAVING_BINDING, takeLeavingEvidence).catch(() => undefined),
|
|
1142
|
+
]))).catch(() => undefined);
|
|
1143
|
+
preparedContexts.set(context, prepared);
|
|
1144
|
+
}
|
|
1145
|
+
return prepared;
|
|
992
1146
|
}
|
|
993
1147
|
function readSnapshot(target) {
|
|
994
1148
|
return target
|
|
995
1149
|
.evaluate(() => {
|
|
996
1150
|
const getSnapshot = globalThis.__SUPERCOV_COVERAGE_SNAPSHOT__;
|
|
997
|
-
|
|
1151
|
+
const compact = globalThis.__SUPERCOV_COMPACT_SNAPSHOT__;
|
|
1152
|
+
if (!getSnapshot)
|
|
1153
|
+
return undefined;
|
|
1154
|
+
return typeof compact === "function" ? compact(getSnapshot()) : JSON.stringify(getSnapshot());
|
|
998
1155
|
})
|
|
999
|
-
.then((text) =>
|
|
1156
|
+
.then((text) => {
|
|
1157
|
+
if (typeof text !== "string")
|
|
1158
|
+
return undefined;
|
|
1159
|
+
// Phase timing counts the bytes each read carried, as its "ms".
|
|
1160
|
+
timingCount("transfer.snapshotBytes", text.length);
|
|
1161
|
+
return expandSnapshot(JSON.parse(text));
|
|
1162
|
+
});
|
|
1000
1163
|
}
|
|
1001
1164
|
let activeController;
|
|
1002
1165
|
let bridgedAssertionDepth = 0;
|
|
@@ -1034,6 +1197,7 @@ function trackContext(context, configuredHeaders) {
|
|
|
1034
1197
|
trackedContexts.add(context);
|
|
1035
1198
|
context.once?.("close", () => trackedContexts.delete(context));
|
|
1036
1199
|
patchContextPrototype(context);
|
|
1200
|
+
void prepareContext(context);
|
|
1037
1201
|
}
|
|
1038
1202
|
function liveTrackedContexts() {
|
|
1039
1203
|
const contexts = new Set(trackedContexts);
|
|
@@ -1090,7 +1254,7 @@ function patchBrowserPrototype(browser) {
|
|
|
1090
1254
|
* way the page is gone by the time the test's snapshots are collected.
|
|
1091
1255
|
*/
|
|
1092
1256
|
/** The cookies Supercov gives a context to carry the test's scope. */
|
|
1093
|
-
const OWN_COOKIES = new Set([COVERAGE_SCOPE_COOKIE, COVERAGE_PHASE_COOKIE, COVERAGE_SCOPE_SITE_COOKIE, COVERAGE_PHASE_SITE_COOKIE]);
|
|
1257
|
+
const OWN_COOKIES = new Set([COVERAGE_SCOPE_COOKIE, COVERAGE_PHASE_COOKIE, COVERAGE_DOCUMENT_PHASE_COOKIE, COVERAGE_SCOPE_SITE_COOKIE, COVERAGE_PHASE_SITE_COOKIE, COVERAGE_DOCUMENT_PHASE_SITE_COOKIE]);
|
|
1094
1258
|
const suiteCookies = (cookies) => (Array.isArray(cookies) ? cookies.filter((cookie) => !OWN_COOKIES.has(cookie?.name)) : cookies);
|
|
1095
1259
|
function patchContextPrototype(context) {
|
|
1096
1260
|
// A test reads and saves the cookies it and its app set, never
|
|
@@ -1162,7 +1326,7 @@ globalThis.__SUPERCOV_ASSERTION_PHASE_BRIDGE__ = (operation, source, callback) =
|
|
|
1162
1326
|
const runtime = directRuntime();
|
|
1163
1327
|
if (!controller || !runtime)
|
|
1164
1328
|
return { handled: false };
|
|
1165
|
-
const phase = controller.
|
|
1329
|
+
const phase = controller.beginBridgedAssertion(operation, source);
|
|
1166
1330
|
bridgedAssertionDepth += 1;
|
|
1167
1331
|
try {
|
|
1168
1332
|
const value = runtime.withCoverageCarrier({ version: 1, scope: controller.scope, phaseId: phase.id }, callback);
|
|
@@ -1194,7 +1358,7 @@ globalThis.__SUPERCOV_ASSERTION_PHASE_OPENER__ = (operation, source) => {
|
|
|
1194
1358
|
const controller = activeController;
|
|
1195
1359
|
if (!controller || !directRuntime())
|
|
1196
1360
|
return undefined;
|
|
1197
|
-
const phase = controller.
|
|
1361
|
+
const phase = controller.beginBridgedAssertion(operation, source);
|
|
1198
1362
|
bridgedAssertionDepth += 1;
|
|
1199
1363
|
let held = true;
|
|
1200
1364
|
const release = () => {
|
|
@@ -1378,7 +1542,45 @@ function installChildProcessScopePropagation() {
|
|
|
1378
1542
|
syncBuiltinESMExports();
|
|
1379
1543
|
}
|
|
1380
1544
|
installChildProcessScopePropagation();
|
|
1381
|
-
|
|
1545
|
+
// expect's own matchers that settle in the call, whatever they are given, on
|
|
1546
|
+
// a value that is no Playwright object: none of them can wait for the page.
|
|
1547
|
+
// Under `expect.poll`, `.resolves` or `.rejects` the same names wait.
|
|
1548
|
+
const SETTLED_MATCHERS = new Set([
|
|
1549
|
+
"toBe",
|
|
1550
|
+
"toBeCloseTo",
|
|
1551
|
+
"toBeDefined",
|
|
1552
|
+
"toBeFalsy",
|
|
1553
|
+
"toBeGreaterThan",
|
|
1554
|
+
"toBeGreaterThanOrEqual",
|
|
1555
|
+
"toBeInstanceOf",
|
|
1556
|
+
"toBeLessThan",
|
|
1557
|
+
"toBeLessThanOrEqual",
|
|
1558
|
+
"toBeNaN",
|
|
1559
|
+
"toBeNull",
|
|
1560
|
+
"toBeTruthy",
|
|
1561
|
+
"toBeUndefined",
|
|
1562
|
+
"toContain",
|
|
1563
|
+
"toContainEqual",
|
|
1564
|
+
"toEqual",
|
|
1565
|
+
"toHaveLength",
|
|
1566
|
+
"toHaveProperty",
|
|
1567
|
+
"toMatch",
|
|
1568
|
+
"toMatchObject",
|
|
1569
|
+
"toStrictEqual",
|
|
1570
|
+
]);
|
|
1571
|
+
const PLAYWRIGHT_VALUE_TYPES = new Set(["APIResponse", "ElementHandle", "Frame", "FrameLocator", "JSHandle", "Locator", "Page"]);
|
|
1572
|
+
function isPlaywrightValue(value) {
|
|
1573
|
+
if (!value || (typeof value !== "object" && typeof value !== "function"))
|
|
1574
|
+
return false;
|
|
1575
|
+
try {
|
|
1576
|
+
return "_guid" in value || "_frame" in value || "_selector" in value ||
|
|
1577
|
+
PLAYWRIGHT_VALUE_TYPES.has(value.constructor?.name);
|
|
1578
|
+
}
|
|
1579
|
+
catch {
|
|
1580
|
+
return true;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
function wrapMatchers(matchers, path = "expect", settles = false) {
|
|
1382
1584
|
return new Proxy(matchers, {
|
|
1383
1585
|
get(target, property) {
|
|
1384
1586
|
// Matcher objects and built-ins can expose getters whose internal slots
|
|
@@ -1389,14 +1591,19 @@ function wrapMatchers(matchers, path = "expect") {
|
|
|
1389
1591
|
if (value &&
|
|
1390
1592
|
typeof value === "object" &&
|
|
1391
1593
|
(name === "not" || name === "resolves" || name === "rejects"))
|
|
1392
|
-
return wrapMatchers(value, `${path}.${name}
|
|
1594
|
+
return wrapMatchers(value, `${path}.${name}`, settles && name === "not");
|
|
1393
1595
|
if (typeof value !== "function")
|
|
1394
1596
|
return value;
|
|
1395
1597
|
return (...args) => {
|
|
1396
1598
|
const controller = activeController;
|
|
1599
|
+
const settled = settles && SETTLED_MATCHERS.has(name);
|
|
1397
1600
|
const phase = bridgedAssertionDepth === 0
|
|
1398
|
-
? controller?.beginAssertion(`${path}.${name}
|
|
1601
|
+
? controller?.beginAssertion(`${path}.${name}`, undefined, !settled)
|
|
1399
1602
|
: undefined;
|
|
1603
|
+
// Inside an instrumented assertion, a matcher that can wait
|
|
1604
|
+
// takes the assertion's phase to the browser before it starts.
|
|
1605
|
+
if (bridgedAssertionDepth > 0 && !settled)
|
|
1606
|
+
controller?.promotePendingAssertion();
|
|
1400
1607
|
try {
|
|
1401
1608
|
const result = Reflect.apply(value, target, args);
|
|
1402
1609
|
if (result instanceof Promise) {
|
|
@@ -1423,21 +1630,21 @@ function wrapMatchers(matchers, path = "expect") {
|
|
|
1423
1630
|
},
|
|
1424
1631
|
});
|
|
1425
1632
|
}
|
|
1426
|
-
function wrapExpectCallable(callable) {
|
|
1633
|
+
function wrapExpectCallable(callable, polls = false) {
|
|
1427
1634
|
return new Proxy(callable, {
|
|
1428
1635
|
apply(target, thisArg, argumentsList) {
|
|
1429
1636
|
const result = Reflect.apply(target, thisArg, argumentsList);
|
|
1430
1637
|
if (typeof result === "function")
|
|
1431
|
-
return wrapExpectCallable(result);
|
|
1638
|
+
return wrapExpectCallable(result, polls);
|
|
1432
1639
|
if (result && typeof result === "object")
|
|
1433
|
-
return wrapMatchers(result);
|
|
1640
|
+
return wrapMatchers(result, "expect", !polls && !isPlaywrightValue(argumentsList[0]));
|
|
1434
1641
|
return result;
|
|
1435
1642
|
},
|
|
1436
1643
|
get(target, property, receiver) {
|
|
1437
1644
|
const value = Reflect.get(target, property, receiver);
|
|
1438
1645
|
if (typeof value !== "function")
|
|
1439
1646
|
return value;
|
|
1440
|
-
return wrapExpectCallable(value.bind(target));
|
|
1647
|
+
return wrapExpectCallable(value.bind(target), polls || property === "poll");
|
|
1441
1648
|
},
|
|
1442
1649
|
});
|
|
1443
1650
|
}
|
|
@@ -1467,26 +1674,6 @@ function executionScope(testInfo) {
|
|
|
1467
1674
|
attemptId,
|
|
1468
1675
|
};
|
|
1469
1676
|
}
|
|
1470
|
-
function readServerRecords(scope) {
|
|
1471
|
-
try {
|
|
1472
|
-
return readFileSync(serverEvidencePath(scope), "utf8")
|
|
1473
|
-
.split("\n")
|
|
1474
|
-
.filter(Boolean)
|
|
1475
|
-
.flatMap((line) => {
|
|
1476
|
-
try {
|
|
1477
|
-
const record = JSON.parse(line);
|
|
1478
|
-
return record.scope?.attemptId === scope.attemptId ? [record] : [];
|
|
1479
|
-
}
|
|
1480
|
-
catch {
|
|
1481
|
-
// Ignore a final partial line if the server was writing during collection.
|
|
1482
|
-
return [];
|
|
1483
|
-
}
|
|
1484
|
-
});
|
|
1485
|
-
}
|
|
1486
|
-
catch {
|
|
1487
|
-
return [];
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
1677
|
function phaseSourceLine(source) {
|
|
1491
1678
|
return source?.replace(/:\d+$/, "");
|
|
1492
1679
|
}
|
|
@@ -1523,6 +1710,48 @@ function mergeCollectedPhases(controllerPhases, fallbackPhases) {
|
|
|
1523
1710
|
* written to one uniquely named, one-shot evidence file in the runner's shared
|
|
1524
1711
|
* directory as well. No test streams into this path.
|
|
1525
1712
|
*/
|
|
1713
|
+
/**
|
|
1714
|
+
* A snapshot's events as columns.
|
|
1715
|
+
* A page records each hit once per test as an event of five fields, and its
|
|
1716
|
+
* thousands of them were most of a test's record. The engine decodes the
|
|
1717
|
+
* columns into exactly these events, in this order.
|
|
1718
|
+
*/
|
|
1719
|
+
function columnarEvents(snapshot) {
|
|
1720
|
+
const events = snapshot?.events;
|
|
1721
|
+
if (!Array.isArray(events) || events.length === 0)
|
|
1722
|
+
return snapshot;
|
|
1723
|
+
const tables = { types: new Map(), phases: new Map(), environments: new Map() };
|
|
1724
|
+
const index = (table, value) => {
|
|
1725
|
+
let found = table.get(value);
|
|
1726
|
+
if (found === undefined) {
|
|
1727
|
+
found = table.size;
|
|
1728
|
+
table.set(value, found);
|
|
1729
|
+
}
|
|
1730
|
+
return found;
|
|
1731
|
+
};
|
|
1732
|
+
const columns = { id: [], type: [], phase: [], environment: [], t: [], vector: {} };
|
|
1733
|
+
let previous = 0;
|
|
1734
|
+
events.forEach((event, position) => {
|
|
1735
|
+
columns.id.push(event.id);
|
|
1736
|
+
columns.type.push(index(tables.types, event.type));
|
|
1737
|
+
columns.phase.push(event.phaseId == null ? -1 : index(tables.phases, event.phaseId));
|
|
1738
|
+
columns.environment.push(index(tables.environments, event.environment));
|
|
1739
|
+
columns.t.push(event.timestampMs - previous);
|
|
1740
|
+
previous = event.timestampMs;
|
|
1741
|
+
if (event.vector !== undefined)
|
|
1742
|
+
columns.vector[position] = event.vector;
|
|
1743
|
+
});
|
|
1744
|
+
const { events: _events, ...rest } = snapshot;
|
|
1745
|
+
return {
|
|
1746
|
+
...rest,
|
|
1747
|
+
eventColumns: {
|
|
1748
|
+
types: [...tables.types.keys()],
|
|
1749
|
+
phases: [...tables.phases.keys()],
|
|
1750
|
+
environments: [...tables.environments.keys()],
|
|
1751
|
+
...columns,
|
|
1752
|
+
},
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1526
1755
|
function appendEvidenceLine(serialized) {
|
|
1527
1756
|
const evidenceDirectory = process.env["SUPERCOV_EVIDENCE_DIR"] ??
|
|
1528
1757
|
(GENERATED_EVIDENCE_DIRECTORY.startsWith("__")
|
|
@@ -1542,7 +1771,7 @@ const instrumentedFixtures = {
|
|
|
1542
1771
|
const controller = controllers.get(scope.attemptId);
|
|
1543
1772
|
if (!controller)
|
|
1544
1773
|
throw new Error(`Supercov's automatic Playwright collector was not initialized for ${scope.attemptId}`);
|
|
1545
|
-
await controller.registerPage(page);
|
|
1774
|
+
await timed("fixture.pageSetup", () => controller.registerPage(page));
|
|
1546
1775
|
try {
|
|
1547
1776
|
await use(controller.wrap(page));
|
|
1548
1777
|
}
|
|
@@ -1550,7 +1779,7 @@ const instrumentedFixtures = {
|
|
|
1550
1779
|
// This fixture still owns a live page. The automatic test fixture tears
|
|
1551
1780
|
// down after page dependencies and therefore cannot reliably evaluate
|
|
1552
1781
|
// frames on every Playwright version.
|
|
1553
|
-
await controller.collectRuntimeSnapshots();
|
|
1782
|
+
await timed("fixture.pageTeardown", () => controller.collectRuntimeSnapshots());
|
|
1554
1783
|
}
|
|
1555
1784
|
},
|
|
1556
1785
|
browser: [
|
|
@@ -1580,12 +1809,25 @@ const instrumentedFixtures = {
|
|
|
1580
1809
|
async ({}, use, testInfo) => {
|
|
1581
1810
|
const scope = executionScope(testInfo);
|
|
1582
1811
|
const configuredHeaders = Object.fromEntries(Object.entries(testInfo.project.use.extraHTTPHeaders ?? {}).map(([name, value]) => [name, String(value)]));
|
|
1812
|
+
const setupStarted = performance.now();
|
|
1583
1813
|
const controller = new CoveragePhaseController(scope, configuredHeaders, testInfo.project.use.baseURL);
|
|
1584
1814
|
controllers.set(scope.attemptId, controller);
|
|
1585
1815
|
activeController = controller;
|
|
1586
1816
|
directRuntime()?.activateCoverageScope(scope);
|
|
1587
|
-
|
|
1588
|
-
|
|
1817
|
+
// The pages the test starts with, whose evidence is taken while
|
|
1818
|
+
// their contexts are registered.
|
|
1819
|
+
const startingPages = [...liveTrackedContexts()].flatMap((context) => {
|
|
1820
|
+
try {
|
|
1821
|
+
return context.pages();
|
|
1822
|
+
}
|
|
1823
|
+
catch {
|
|
1824
|
+
return [];
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
const [, carried] = await Promise.all([
|
|
1828
|
+
timed("fixture.adopt", () => controller.adoptTrackedContexts().catch(() => undefined)),
|
|
1829
|
+
timed("fixture.carried", () => controller.takeCarriedEvidence(startingPages).catch(() => undefined)),
|
|
1830
|
+
]);
|
|
1589
1831
|
if (carried) {
|
|
1590
1832
|
appendEvidenceLine(`${JSON.stringify({
|
|
1591
1833
|
testId: `__supercov_background__:playwright:${scope.attemptId}`,
|
|
@@ -1593,7 +1835,7 @@ const instrumentedFixtures = {
|
|
|
1593
1835
|
status: "unknown",
|
|
1594
1836
|
provenance: { runner: "background", kind: "background", source: "explicit" },
|
|
1595
1837
|
role: "background",
|
|
1596
|
-
browser: [carried],
|
|
1838
|
+
browser: [columnarEvents(carried)],
|
|
1597
1839
|
})}\n`);
|
|
1598
1840
|
}
|
|
1599
1841
|
const serverOutput = serverEvidencePath(scope);
|
|
@@ -1603,14 +1845,15 @@ const instrumentedFixtures = {
|
|
|
1603
1845
|
// no test is credited by time to the one test running then, and a
|
|
1604
1846
|
// test is running for all of this, not only during its actions.
|
|
1605
1847
|
const startedAtMs = Date.now();
|
|
1848
|
+
timingCount("fixture.testSetup", performance.now() - setupStarted);
|
|
1606
1849
|
try {
|
|
1607
1850
|
await use();
|
|
1608
1851
|
}
|
|
1609
1852
|
finally {
|
|
1853
|
+
const teardownStarted = performance.now();
|
|
1610
1854
|
const endedAtMs = Date.now();
|
|
1611
1855
|
try {
|
|
1612
1856
|
const browser = await controller.collectRuntimeSnapshots();
|
|
1613
|
-
const server = readServerRecords(scope);
|
|
1614
1857
|
// Emit an artifact even when this test touched no application source.
|
|
1615
1858
|
// A complete test-to-coverage matrix must also identify tests that are
|
|
1616
1859
|
// removable without changing coverage.
|
|
@@ -1635,14 +1878,14 @@ const instrumentedFixtures = {
|
|
|
1635
1878
|
}),
|
|
1636
1879
|
phases: mergeCollectedPhases(controller.phases, directRuntime()?.takeNodeAssertionPhases(scope) ?? []),
|
|
1637
1880
|
...(controller.workerEvidenceMissing ? { attribution: "partial" } : {}),
|
|
1638
|
-
browser,
|
|
1639
|
-
server,
|
|
1881
|
+
browser: browser.map(columnarEvents),
|
|
1640
1882
|
span: [startedAtMs, endedAtMs],
|
|
1641
1883
|
};
|
|
1642
1884
|
appendEvidenceLine(`${JSON.stringify(payload)}\n`);
|
|
1643
1885
|
}
|
|
1644
1886
|
finally {
|
|
1645
1887
|
await timed("controller.dispose", () => controller.dispose());
|
|
1888
|
+
timingCount("fixture.testTeardown", performance.now() - teardownStarted);
|
|
1646
1889
|
timingReport(testInfo.title);
|
|
1647
1890
|
directRuntime()?.activateCoverageScope();
|
|
1648
1891
|
if (activeController === controller)
|
|
@@ -1656,6 +1899,10 @@ const instrumentedFixtures = {
|
|
|
1656
1899
|
};
|
|
1657
1900
|
const instrumentedTest = base.extend(instrumentedFixtures);
|
|
1658
1901
|
export const test = instrumentedTest;
|
|
1902
|
+
// `export *` passes no default on, and `import test from "@playwright/test"`
|
|
1903
|
+
// is Playwright's own default export: such a spec found none and failed with
|
|
1904
|
+
// `(0 , _test.default) is not a function`.
|
|
1905
|
+
export default instrumentedTest;
|
|
1659
1906
|
/**
|
|
1660
1907
|
* A Playwright `test` object: callable, and carrying the API a spec drives.
|
|
1661
1908
|
* Facades export several -- one per fixture set -- and every one of them must
|