@specific.dev/spectest 0.131.3 → 0.131.4
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/browser-coverage.d.ts +5 -5
- package/dist/browser-coverage.js +51 -14
- package/dist/browser.js +47 -14
- package/dist/coverage.js +1 -1
- package/dist/daemon.js +9 -14
- package/dist/harness/deadline.d.ts +7 -0
- package/dist/harness/deadline.js +29 -0
- package/package.json +1 -1
- package/src/browser-coverage-deadline.test.ts +62 -0
- package/src/browser-coverage.ts +45 -13
- package/src/browser-finalization.test.ts +46 -0
- package/src/browser.ts +37 -13
- package/src/coverage.ts +1 -1
- package/src/daemon.ts +9 -12
- package/src/harness/deadline.test.ts +16 -0
- package/src/harness/deadline.ts +33 -0
|
@@ -12,16 +12,16 @@ export declare function browserCoverageActive(): boolean;
|
|
|
12
12
|
export declare function attachBrowserCoverage(cdp: CDPSession): Promise<void>;
|
|
13
13
|
/**
|
|
14
14
|
* Take the session's counters and fold them into the totals. Concurrent
|
|
15
|
-
* calls coalesce onto the in-flight one.
|
|
16
|
-
*
|
|
17
|
-
* take
|
|
15
|
+
* calls coalesce onto the in-flight one. A timed-out take is ambiguous: V8
|
|
16
|
+
* may already have reset its counters. Retain that error for service capture
|
|
17
|
+
* rather than issuing another take or reporting an apparently complete file.
|
|
18
18
|
*/
|
|
19
|
-
export declare function harvestBrowserCoverage(cdp: CDPSession): Promise<void>;
|
|
19
|
+
export declare function harvestBrowserCoverage(cdp: CDPSession, signal?: AbortSignal): Promise<void>;
|
|
20
20
|
/** Fold one script's per-file counts into a service's totals. */
|
|
21
21
|
export declare function foldBrowserCoverage(service: string, files: FileCounts): void;
|
|
22
22
|
/** Harvest every live session, so a capture sees the counters of code
|
|
23
23
|
* that ran since the last browser op. */
|
|
24
|
-
export declare function harvestAllBrowserCoverage(): Promise<void>;
|
|
24
|
+
export declare function harvestAllBrowserCoverage(signal?: AbortSignal): Promise<void>;
|
|
25
25
|
/** Take one service's lcov report and clear only its totals. Other
|
|
26
26
|
* services retain their coverage until their own capture. Each capture
|
|
27
27
|
* is a delta; after every service is captured, a forked child inherits
|
package/dist/browser-coverage.js
CHANGED
|
@@ -60,28 +60,62 @@ export async function attachBrowserCoverage(cdp) {
|
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
62
|
* Take the session's counters and fold them into the totals. Concurrent
|
|
63
|
-
* calls coalesce onto the in-flight one.
|
|
64
|
-
*
|
|
65
|
-
* take
|
|
63
|
+
* calls coalesce onto the in-flight one. A timed-out take is ambiguous: V8
|
|
64
|
+
* may already have reset its counters. Retain that error for service capture
|
|
65
|
+
* rather than issuing another take or reporting an apparently complete file.
|
|
66
66
|
*/
|
|
67
|
-
export async function harvestBrowserCoverage(cdp) {
|
|
67
|
+
export async function harvestBrowserCoverage(cdp, signal) {
|
|
68
68
|
const c = COLLECTORS.get(cdp);
|
|
69
69
|
if (!c || !CONFIG)
|
|
70
70
|
return;
|
|
71
|
-
if (c.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
c.
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
if (c.incomplete)
|
|
72
|
+
throw c.incomplete;
|
|
73
|
+
if (signal?.aborted) {
|
|
74
|
+
c.abort?.abort(signal.reason);
|
|
75
|
+
signal.throwIfAborted();
|
|
76
|
+
}
|
|
77
|
+
if (!c.inFlight) {
|
|
78
|
+
const config = CONFIG;
|
|
79
|
+
const abort = c.abort = new AbortController();
|
|
80
|
+
const interrupted = new Promise((_, reject) => {
|
|
81
|
+
abort.signal.addEventListener("abort", () => {
|
|
82
|
+
c.incomplete = new Error("Browser coverage capture was interrupted; coverage is incomplete");
|
|
83
|
+
reject(c.incomplete);
|
|
84
|
+
}, { once: true });
|
|
85
|
+
});
|
|
86
|
+
c.inFlight = Promise.race([harvestInner(c, config, abort.signal), interrupted]).catch(() => {
|
|
87
|
+
if (c.incomplete)
|
|
88
|
+
throw c.incomplete;
|
|
89
|
+
// Preserve the existing handling of closed/navigating pages.
|
|
90
|
+
}).finally(() => {
|
|
91
|
+
c.inFlight = null;
|
|
92
|
+
c.abort = undefined;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// Only the existing adapter capture budget can interrupt a take. Browser
|
|
96
|
+
// operations introduce no new deadline. A capture may join a take that was
|
|
97
|
+
// started by an earlier browser operation, so wire its signal here too.
|
|
98
|
+
const abort = c.abort;
|
|
99
|
+
const interrupt = () => abort.abort(signal?.reason);
|
|
100
|
+
signal?.addEventListener("abort", interrupt, { once: true });
|
|
101
|
+
if (signal?.aborted)
|
|
102
|
+
interrupt();
|
|
103
|
+
try {
|
|
104
|
+
await c.inFlight;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
signal?.removeEventListener("abort", interrupt);
|
|
108
|
+
}
|
|
77
109
|
}
|
|
78
|
-
async function harvestInner(c, config) {
|
|
110
|
+
async function harvestInner(c, config, signal) {
|
|
79
111
|
let result;
|
|
80
112
|
try {
|
|
81
113
|
({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")));
|
|
114
|
+
signal.throwIfAborted();
|
|
82
115
|
}
|
|
83
116
|
catch (err) {
|
|
84
|
-
|
|
117
|
+
if (!signal.aborted)
|
|
118
|
+
LIVE_COLLECTORS.delete(c); // the session is gone
|
|
85
119
|
throw err;
|
|
86
120
|
}
|
|
87
121
|
for (const script of result) {
|
|
@@ -105,9 +139,11 @@ async function harvestInner(c, config) {
|
|
|
105
139
|
const r = (await c.cdp.send("Debugger.getScriptSource", {
|
|
106
140
|
scriptId: script.scriptId,
|
|
107
141
|
}));
|
|
142
|
+
signal.throwIfAborted();
|
|
108
143
|
info.source = r.scriptSource;
|
|
109
144
|
}
|
|
110
145
|
catch {
|
|
146
|
+
signal.throwIfAborted();
|
|
111
147
|
continue; // script gone with its document
|
|
112
148
|
}
|
|
113
149
|
c.scripts.set(script.scriptId, info);
|
|
@@ -117,6 +153,7 @@ async function harvestInner(c, config) {
|
|
|
117
153
|
continue;
|
|
118
154
|
if (info.map === undefined)
|
|
119
155
|
info.map = await sourceMapFor(info, url);
|
|
156
|
+
signal.throwIfAborted();
|
|
120
157
|
if (!info.map && isDocumentScriptPath(url.pathname))
|
|
121
158
|
continue; // inline script in a page
|
|
122
159
|
const files = info.map
|
|
@@ -193,8 +230,8 @@ function parseDataUrl(ref) {
|
|
|
193
230
|
}
|
|
194
231
|
/** Harvest every live session, so a capture sees the counters of code
|
|
195
232
|
* that ran since the last browser op. */
|
|
196
|
-
export async function harvestAllBrowserCoverage() {
|
|
197
|
-
await Promise.all([...LIVE_COLLECTORS].map((c) => harvestBrowserCoverage(c.cdp)));
|
|
233
|
+
export async function harvestAllBrowserCoverage(signal) {
|
|
234
|
+
await Promise.all([...LIVE_COLLECTORS].map((c) => harvestBrowserCoverage(c.cdp, signal)));
|
|
198
235
|
}
|
|
199
236
|
/** Take one service's lcov report and clear only its totals. Other
|
|
200
237
|
* services retain their coverage until their own capture. Each capture
|
package/dist/browser.js
CHANGED
|
@@ -33,6 +33,7 @@ import path from "node:path";
|
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
34
|
import { generateId } from "./ids.js";
|
|
35
35
|
import { rawFetch } from "./harness/raw-fetch.js";
|
|
36
|
+
import { withDeadline } from "./harness/deadline.js";
|
|
36
37
|
import { prepareMicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
37
38
|
import { DESKTOP_INPUT_SYNC_SCRIPT } from "./desktop-recorder.js";
|
|
38
39
|
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
@@ -1283,6 +1284,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1283
1284
|
// `recordingEnded` stops this wrapper's recorder writes (test end);
|
|
1284
1285
|
// `viewClosed` tracks actual destruction (author called close()).
|
|
1285
1286
|
let recordingEnded = false;
|
|
1287
|
+
let recordingEnd;
|
|
1286
1288
|
let viewClosed = false;
|
|
1287
1289
|
const sessionStart = Date.now();
|
|
1288
1290
|
let stepSeq = 0;
|
|
@@ -1335,12 +1337,20 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1335
1337
|
...(recorder.sessionName ? { sessionName: recorder.sessionName } : {}),
|
|
1336
1338
|
};
|
|
1337
1339
|
}
|
|
1338
|
-
async function drain(action, timestamp = Date.now()) {
|
|
1340
|
+
async function drain(action, timestamp = Date.now(), signal) {
|
|
1339
1341
|
if (!holder.recordingInstalled || !recorder || recordingEnded)
|
|
1340
1342
|
return;
|
|
1341
1343
|
try {
|
|
1342
1344
|
const urlChanged = holder.page.url() !== lastDrainUrl;
|
|
1343
|
-
|
|
1345
|
+
// Finalization only reads the buffer. A timed-out evaluation may run
|
|
1346
|
+
// after a child/eval attaches; it must not clear that session's events.
|
|
1347
|
+
const expression = action === "close"
|
|
1348
|
+
? "window.__spectestRrwebEvents || []"
|
|
1349
|
+
: drainExpr(urlChanged, timestamp);
|
|
1350
|
+
const events = (await holder.page.evaluate(expression));
|
|
1351
|
+
signal?.throwIfAborted();
|
|
1352
|
+
if (recordingEnded)
|
|
1353
|
+
return;
|
|
1344
1354
|
lastDrainUrl = holder.page.url();
|
|
1345
1355
|
if (Array.isArray(events) && events.length > 0) {
|
|
1346
1356
|
recorder.recordStep({
|
|
@@ -1352,6 +1362,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1352
1362
|
}
|
|
1353
1363
|
}
|
|
1354
1364
|
catch {
|
|
1365
|
+
signal?.throwIfAborted();
|
|
1355
1366
|
// Page may be mid-navigation, detached, or the view is closing.
|
|
1356
1367
|
// Losing an occasional drain is acceptable — the next op picks up
|
|
1357
1368
|
// the rest of the buffer.
|
|
@@ -1369,6 +1380,8 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1369
1380
|
catch {
|
|
1370
1381
|
/* page mid-navigation or closed — keep the stale cache */
|
|
1371
1382
|
}
|
|
1383
|
+
if (recordingEnded)
|
|
1384
|
+
return result;
|
|
1372
1385
|
// `sessionTimestamp` is the post-op wall clock — that's where we
|
|
1373
1386
|
// want the dashboard's seek to land, so clicking "type 'foo'"
|
|
1374
1387
|
// shows the input *with* the text, not the empty field just
|
|
@@ -1402,12 +1415,16 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1402
1415
|
return result;
|
|
1403
1416
|
}
|
|
1404
1417
|
catch (err) {
|
|
1418
|
+
if (recordingEnded)
|
|
1419
|
+
throw err;
|
|
1405
1420
|
const e = err;
|
|
1406
1421
|
const endT = Date.now();
|
|
1407
1422
|
const error = e?.message ?? String(err);
|
|
1408
1423
|
// Captured after the clock stops, so the diagnostic is not billed to
|
|
1409
1424
|
// the step, and before the event is recorded, so it rides it.
|
|
1410
1425
|
const pageStructure = await failedPageStructure(action, error);
|
|
1426
|
+
if (recordingEnded)
|
|
1427
|
+
throw err;
|
|
1411
1428
|
recordBrowser({
|
|
1412
1429
|
action,
|
|
1413
1430
|
...fields,
|
|
@@ -1422,20 +1439,21 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1422
1439
|
throw err;
|
|
1423
1440
|
}
|
|
1424
1441
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
//
|
|
1429
|
-
|
|
1430
|
-
|
|
1442
|
+
function endRecording() {
|
|
1443
|
+
// A stuck renderer must not prevent the test result from returning. The
|
|
1444
|
+
// signal and ended guard discard late recorder writes. Missing optional
|
|
1445
|
+
// replay data is a warning, not a failed test. Concurrent close/detach
|
|
1446
|
+
// calls share the same bounded attempt.
|
|
1447
|
+
return recordingEnd ??= (async () => {
|
|
1431
1448
|
try {
|
|
1432
|
-
await
|
|
1449
|
+
await withDeadline("Browser recording finalization", 10_000, signal => drain("close", Date.now(), signal));
|
|
1450
|
+
// Coverage is collected by the service capture after recording
|
|
1451
|
+
// finalization. Do not harvest it twice on the test-end path.
|
|
1433
1452
|
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1453
|
+
finally {
|
|
1454
|
+
recordingEnded = true;
|
|
1436
1455
|
}
|
|
1437
|
-
}
|
|
1438
|
-
recordingEnded = true;
|
|
1456
|
+
})();
|
|
1439
1457
|
}
|
|
1440
1458
|
const strategy = holder.device ? mobileStrategy : desktopStrategy;
|
|
1441
1459
|
const keyboard = {
|
|
@@ -1659,9 +1677,22 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1659
1677
|
// the next `ctx.browser()`/`ctx.mobile()` creates a new one. The
|
|
1660
1678
|
// routine test-end path is `detach` (recording stops, view lives on
|
|
1661
1679
|
// into the post-test snapshot).
|
|
1662
|
-
|
|
1680
|
+
try {
|
|
1681
|
+
await endRecording();
|
|
1682
|
+
}
|
|
1683
|
+
catch (error) {
|
|
1684
|
+
console.warn("[browser finalization]", error instanceof Error ? error.message : String(error));
|
|
1685
|
+
}
|
|
1663
1686
|
if (viewClosed)
|
|
1664
1687
|
return;
|
|
1688
|
+
// An explicit close destroys the renderer, unlike detach. Harvest its
|
|
1689
|
+
// counters before destroying it; service capture handles live views.
|
|
1690
|
+
if (browserCoverageActive()) {
|
|
1691
|
+
try {
|
|
1692
|
+
await harvestBrowserCoverage(holder.cdp);
|
|
1693
|
+
}
|
|
1694
|
+
catch { /* capture reports the error */ }
|
|
1695
|
+
}
|
|
1665
1696
|
viewClosed = true;
|
|
1666
1697
|
buildOpts.onDestroy?.();
|
|
1667
1698
|
try {
|
|
@@ -1745,6 +1776,8 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1745
1776
|
// A matcher that ran out of budget is the other half of a failed
|
|
1746
1777
|
// browser step, and the page it settled on is the same evidence.
|
|
1747
1778
|
const pageStructure = error ? await failedPageStructure(action, error) : undefined;
|
|
1779
|
+
if (recordingEnded)
|
|
1780
|
+
return undefined;
|
|
1748
1781
|
const seq = recordBrowser({
|
|
1749
1782
|
action,
|
|
1750
1783
|
...fields,
|
package/dist/coverage.js
CHANGED
|
@@ -1009,7 +1009,7 @@ export function browser(options = {}) {
|
|
|
1009
1009
|
ctx.collectBrowserScripts();
|
|
1010
1010
|
},
|
|
1011
1011
|
async capture(ctx) {
|
|
1012
|
-
await harvestAllBrowserCoverage();
|
|
1012
|
+
await harvestAllBrowserCoverage(ctx.signal);
|
|
1013
1013
|
const lcov = takeBrowserCoverageReport(ctx.service);
|
|
1014
1014
|
await ctx.writeReport("browser.lcov", lcov);
|
|
1015
1015
|
},
|
package/dist/daemon.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finalizeBrowserRecordings } from "./harness/deadline.js";
|
|
1
2
|
import { ensureServiceNetwork, serviceIpv4Gateway } from "./harness/service-network.js";
|
|
2
3
|
import { collectSecretRefs } from "./fake-upstream.js";
|
|
3
4
|
import { phoneScope, phoneRecordingChunk, releasePhoneRecording } from "./phone.js";
|
|
@@ -4437,13 +4438,10 @@ async function runOne(testCase) {
|
|
|
4437
4438
|
// this test's recorder. The Chromium itself deliberately stays alive
|
|
4438
4439
|
// — it's part of the state the post-test snapshot captures for
|
|
4439
4440
|
// dependsOn children (see the acquire comment above).
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
}
|
|
4444
|
-
catch {
|
|
4445
|
-
/* ignore */
|
|
4446
|
-
}
|
|
4441
|
+
const browserErrors = await finalizeBrowserRecordings(browserDetaches);
|
|
4442
|
+
if (browserErrors.length) {
|
|
4443
|
+
const message = browserErrors.join("\n");
|
|
4444
|
+
chunks.push(`[browser finalization] ${message}\n`);
|
|
4447
4445
|
}
|
|
4448
4446
|
for (const s of sessions)
|
|
4449
4447
|
s.markClosed();
|
|
@@ -4929,13 +4927,10 @@ async function evalCode(code) {
|
|
|
4929
4927
|
process.stderr.write = origStderr;
|
|
4930
4928
|
// Detach (final rrweb drain) — the browser itself stays alive; see
|
|
4931
4929
|
// the acquire comment above.
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
}
|
|
4936
|
-
catch {
|
|
4937
|
-
/* ignore */
|
|
4938
|
-
}
|
|
4930
|
+
const browserErrors = await finalizeBrowserRecordings(browserDetaches);
|
|
4931
|
+
if (browserErrors.length) {
|
|
4932
|
+
const message = browserErrors.join("\n");
|
|
4933
|
+
chunks.push(`[browser finalization] ${message}\n`);
|
|
4939
4934
|
}
|
|
4940
4935
|
for (const s of sessions)
|
|
4941
4936
|
s.markClosed();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Bound an async operation and invalidate its late result. Work must check
|
|
2
|
+
* the signal before committing state after an await; racing alone does not
|
|
3
|
+
* cancel a Playwright/CDP request already sent to the browser. */
|
|
4
|
+
export declare function withDeadline<T>(label: string, timeoutMs: number, work: (signal: AbortSignal) => Promise<T>): Promise<T>;
|
|
5
|
+
/** Start all browser drains together. One wedged view must not consume a
|
|
6
|
+
* separate finalization allowance for every other view in the test. */
|
|
7
|
+
export declare function finalizeBrowserRecordings(detaches: Array<() => Promise<void>>): Promise<string[]>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Bound an async operation and invalidate its late result. Work must check
|
|
2
|
+
* the signal before committing state after an await; racing alone does not
|
|
3
|
+
* cancel a Playwright/CDP request already sent to the browser. */
|
|
4
|
+
export async function withDeadline(label, timeoutMs, work) {
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
let timer;
|
|
7
|
+
try {
|
|
8
|
+
return await Promise.race([
|
|
9
|
+
new Promise((_, reject) => {
|
|
10
|
+
timer = setTimeout(() => {
|
|
11
|
+
const error = new Error(`${label} did not finish within ${timeoutMs}ms`);
|
|
12
|
+
controller.abort(error);
|
|
13
|
+
reject(error);
|
|
14
|
+
}, timeoutMs);
|
|
15
|
+
}),
|
|
16
|
+
work(controller.signal),
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Start all browser drains together. One wedged view must not consume a
|
|
24
|
+
* separate finalization allowance for every other view in the test. */
|
|
25
|
+
export async function finalizeBrowserRecordings(detaches) {
|
|
26
|
+
const results = await Promise.allSettled(detaches.map(detach => Promise.resolve().then(detach)));
|
|
27
|
+
return results.flatMap(result => result.status === "rejected"
|
|
28
|
+
? [result.reason instanceof Error ? result.reason.message : String(result.reason)] : []);
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { CDPSession } from "playwright-core";
|
|
3
|
+
import { attachBrowserCoverage, browserCoverageReports, configureBrowserCoverage, harvestAllBrowserCoverage, harvestBrowserCoverage } from "./browser-coverage.js";
|
|
4
|
+
|
|
5
|
+
test("an ambiguous coverage timeout is retained and a late CDP result cannot publish counts", async () => {
|
|
6
|
+
configureBrowserCoverage({ serviceForHost: () => "coverage-deadline-test" });
|
|
7
|
+
let resolveTake!: (value: unknown) => void;
|
|
8
|
+
const take = new Promise(resolve => { resolveTake = resolve; });
|
|
9
|
+
let takes = 0;
|
|
10
|
+
let sourceReads = 0;
|
|
11
|
+
const cdp = {
|
|
12
|
+
on() {},
|
|
13
|
+
async send(method: string) {
|
|
14
|
+
if (method === "Profiler.takePreciseCoverage") { takes++; return take; }
|
|
15
|
+
if (method === "Debugger.getScriptSource") { sourceReads++; return {scriptSource:"example();"}; }
|
|
16
|
+
return {};
|
|
17
|
+
},
|
|
18
|
+
} as unknown as CDPSession;
|
|
19
|
+
try {
|
|
20
|
+
await attachBrowserCoverage(cdp);
|
|
21
|
+
const first = harvestBrowserCoverage(cdp);
|
|
22
|
+
const concurrent = harvestBrowserCoverage(cdp, AbortSignal.timeout(30));
|
|
23
|
+
const results = await Promise.allSettled([first, concurrent]);
|
|
24
|
+
expect(results.every(r => r.status === "rejected" && r.reason.message.includes("coverage is incomplete"))).toBe(true);
|
|
25
|
+
expect(takes).toBe(1);
|
|
26
|
+
resolveTake({ result: [{scriptId:"1",url:"http://app.test/main.js",functions:[{ranges:[{startOffset:0,endOffset:10,count:1}]}]}] });
|
|
27
|
+
await Bun.sleep(0);
|
|
28
|
+
expect(sourceReads).toBe(0);
|
|
29
|
+
expect(browserCoverageReports().has("coverage-deadline-test")).toBe(false);
|
|
30
|
+
await expect(harvestAllBrowserCoverage()).rejects.toThrow("Browser coverage capture was interrupted; coverage is incomplete");
|
|
31
|
+
expect(takes).toBe(1);
|
|
32
|
+
} finally { configureBrowserCoverage(null); }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("cancelling during script retrieval discards late source and coverage totals", async () => {
|
|
36
|
+
configureBrowserCoverage({ serviceForHost: () => "late-source-test" });
|
|
37
|
+
let resolveSource!: (value: unknown) => void;
|
|
38
|
+
let sourceStarted!: () => void;
|
|
39
|
+
const started = new Promise<void>(resolve => { sourceStarted = resolve; });
|
|
40
|
+
const source = new Promise(resolve => { resolveSource = resolve; });
|
|
41
|
+
const cdp = {
|
|
42
|
+
on() {},
|
|
43
|
+
async send(method: string) {
|
|
44
|
+
if (method === "Profiler.takePreciseCoverage") return {result:[{scriptId:"1",url:"http://app.test/late.js",functions:[{ranges:[{startOffset:0,endOffset:10,count:1}]}]}]};
|
|
45
|
+
if (method === "Debugger.getScriptSource") { sourceStarted(); return source; }
|
|
46
|
+
return {};
|
|
47
|
+
},
|
|
48
|
+
} as unknown as CDPSession;
|
|
49
|
+
try {
|
|
50
|
+
await attachBrowserCoverage(cdp);
|
|
51
|
+
const take = harvestBrowserCoverage(cdp);
|
|
52
|
+
const settled = Promise.allSettled([take]);
|
|
53
|
+
await started;
|
|
54
|
+
const cancelled = new AbortController();
|
|
55
|
+
cancelled.abort();
|
|
56
|
+
await expect(harvestBrowserCoverage(cdp, cancelled.signal)).rejects.toThrow();
|
|
57
|
+
expect((await settled)[0].status).toBe("rejected");
|
|
58
|
+
resolveSource({scriptSource:"example();"});
|
|
59
|
+
await Bun.sleep(0);
|
|
60
|
+
expect(browserCoverageReports().has("late-source-test")).toBe(false);
|
|
61
|
+
} finally { configureBrowserCoverage(null); }
|
|
62
|
+
});
|
package/src/browser-coverage.ts
CHANGED
|
@@ -65,6 +65,8 @@ interface Collector {
|
|
|
65
65
|
cdp: CDPSession;
|
|
66
66
|
scripts: Map<string, ScriptInfo>;
|
|
67
67
|
inFlight: Promise<void> | null;
|
|
68
|
+
incomplete?: Error;
|
|
69
|
+
abort?: AbortController;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
const COLLECTORS = new WeakMap<CDPSession, Collector>();
|
|
@@ -98,26 +100,53 @@ export async function attachBrowserCoverage(cdp: CDPSession): Promise<void> {
|
|
|
98
100
|
|
|
99
101
|
/**
|
|
100
102
|
* Take the session's counters and fold them into the totals. Concurrent
|
|
101
|
-
* calls coalesce onto the in-flight one.
|
|
102
|
-
*
|
|
103
|
-
* take
|
|
103
|
+
* calls coalesce onto the in-flight one. A timed-out take is ambiguous: V8
|
|
104
|
+
* may already have reset its counters. Retain that error for service capture
|
|
105
|
+
* rather than issuing another take or reporting an apparently complete file.
|
|
104
106
|
*/
|
|
105
|
-
export async function harvestBrowserCoverage(cdp: CDPSession): Promise<void> {
|
|
107
|
+
export async function harvestBrowserCoverage(cdp: CDPSession, signal?: AbortSignal): Promise<void> {
|
|
106
108
|
const c = COLLECTORS.get(cdp);
|
|
107
109
|
if (!c || !CONFIG) return;
|
|
108
|
-
if (c.
|
|
109
|
-
|
|
110
|
-
c.
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
if (c.incomplete) throw c.incomplete;
|
|
111
|
+
if (signal?.aborted) {
|
|
112
|
+
c.abort?.abort(signal.reason);
|
|
113
|
+
signal.throwIfAborted();
|
|
114
|
+
}
|
|
115
|
+
if (!c.inFlight) {
|
|
116
|
+
const config = CONFIG;
|
|
117
|
+
const abort = c.abort = new AbortController();
|
|
118
|
+
const interrupted = new Promise<never>((_, reject) => {
|
|
119
|
+
abort.signal.addEventListener("abort", () => {
|
|
120
|
+
c.incomplete = new Error("Browser coverage capture was interrupted; coverage is incomplete");
|
|
121
|
+
reject(c.incomplete);
|
|
122
|
+
}, { once: true });
|
|
123
|
+
});
|
|
124
|
+
c.inFlight = Promise.race([harvestInner(c, config, abort.signal), interrupted]).catch(() => {
|
|
125
|
+
if (c.incomplete) throw c.incomplete;
|
|
126
|
+
// Preserve the existing handling of closed/navigating pages.
|
|
127
|
+
}).finally(() => {
|
|
128
|
+
c.inFlight = null;
|
|
129
|
+
c.abort = undefined;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// Only the existing adapter capture budget can interrupt a take. Browser
|
|
133
|
+
// operations introduce no new deadline. A capture may join a take that was
|
|
134
|
+
// started by an earlier browser operation, so wire its signal here too.
|
|
135
|
+
const abort = c.abort!;
|
|
136
|
+
const interrupt = () => abort.abort(signal?.reason);
|
|
137
|
+
signal?.addEventListener("abort", interrupt, { once: true });
|
|
138
|
+
if (signal?.aborted) interrupt();
|
|
139
|
+
try { await c.inFlight; }
|
|
140
|
+
finally { signal?.removeEventListener("abort", interrupt); }
|
|
113
141
|
}
|
|
114
142
|
|
|
115
|
-
async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promise<void> {
|
|
143
|
+
async function harvestInner(c: Collector, config: BrowserCoverageConfig, signal: AbortSignal): Promise<void> {
|
|
116
144
|
let result: { scriptId: string; url: string; functions: V8FunctionCoverage[] }[];
|
|
117
145
|
try {
|
|
118
146
|
({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")) as { result: typeof result });
|
|
147
|
+
signal.throwIfAborted();
|
|
119
148
|
} catch (err) {
|
|
120
|
-
LIVE_COLLECTORS.delete(c); // the session is gone
|
|
149
|
+
if (!signal.aborted) LIVE_COLLECTORS.delete(c); // the session is gone
|
|
121
150
|
throw err;
|
|
122
151
|
}
|
|
123
152
|
for (const script of result) {
|
|
@@ -137,8 +166,10 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promis
|
|
|
137
166
|
const r = (await c.cdp.send("Debugger.getScriptSource", {
|
|
138
167
|
scriptId: script.scriptId,
|
|
139
168
|
})) as { scriptSource: string };
|
|
169
|
+
signal.throwIfAborted();
|
|
140
170
|
info.source = r.scriptSource;
|
|
141
171
|
} catch {
|
|
172
|
+
signal.throwIfAborted();
|
|
142
173
|
continue; // script gone with its document
|
|
143
174
|
}
|
|
144
175
|
c.scripts.set(script.scriptId, info);
|
|
@@ -146,6 +177,7 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig): Promis
|
|
|
146
177
|
const generated = rangesToLineCounts(info.source, script.functions);
|
|
147
178
|
if (generated.size === 0) continue;
|
|
148
179
|
if (info.map === undefined) info.map = await sourceMapFor(info, url);
|
|
180
|
+
signal.throwIfAborted();
|
|
149
181
|
if (!info.map && isDocumentScriptPath(url.pathname)) continue; // inline script in a page
|
|
150
182
|
const files: FileCounts = info.map
|
|
151
183
|
? mapToOriginal(info.map, generated)
|
|
@@ -216,8 +248,8 @@ function parseDataUrl(ref: string): unknown {
|
|
|
216
248
|
|
|
217
249
|
/** Harvest every live session, so a capture sees the counters of code
|
|
218
250
|
* that ran since the last browser op. */
|
|
219
|
-
export async function harvestAllBrowserCoverage(): Promise<void> {
|
|
220
|
-
await Promise.all([...LIVE_COLLECTORS].map((c) => harvestBrowserCoverage(c.cdp)));
|
|
251
|
+
export async function harvestAllBrowserCoverage(signal?: AbortSignal): Promise<void> {
|
|
252
|
+
await Promise.all([...LIVE_COLLECTORS].map((c) => harvestBrowserCoverage(c.cdp, signal)));
|
|
221
253
|
}
|
|
222
254
|
|
|
223
255
|
/** Take one service's lcov report and clear only its totals. Other
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { acquirePersistentBrowser } from "./browser.js";
|
|
3
|
+
import { finalizeBrowserRecordings } from "./harness/deadline.js";
|
|
4
|
+
|
|
5
|
+
const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"));
|
|
6
|
+
|
|
7
|
+
test.skipIf(!available)("stuck replay drains are bounded warnings and late replies cannot change the recording or clear a later buffer", async () => {
|
|
8
|
+
const chunks: unknown[] = [];
|
|
9
|
+
const session = await acquirePersistentBrowser("stuck-finalization", {
|
|
10
|
+
url: "data:text/html,<title>finalization</title><p>retained page</p>",
|
|
11
|
+
recorder: { sessionId: "stuck-finalization", recordStep: step => { chunks.push(step); } },
|
|
12
|
+
});
|
|
13
|
+
const healthyChunks: unknown[] = [];
|
|
14
|
+
const healthy = await acquirePersistentBrowser("healthy-finalization", {
|
|
15
|
+
url: "data:text/html,<p>healthy</p>",
|
|
16
|
+
recorder: { sessionId: "healthy-finalization", recordStep: step => { healthyChunks.push(step); } },
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
await session.browser.probe(`setTimeout(() => {
|
|
20
|
+
const until = performance.now() + 12500;
|
|
21
|
+
while (performance.now() < until) {}
|
|
22
|
+
window.__spectestRrwebEvents = [{ type: 5, data: { tag: "later-session" } }];
|
|
23
|
+
}, 50)`);
|
|
24
|
+
await Bun.sleep(100);
|
|
25
|
+
const before = chunks.length;
|
|
26
|
+
const started = performance.now();
|
|
27
|
+
const warnings = await finalizeBrowserRecordings([session.detach, healthy.detach]);
|
|
28
|
+
expect(warnings).toEqual(["Browser recording finalization did not finish within 10000ms"]);
|
|
29
|
+
expect(performance.now() - started).toBeLessThan(12000);
|
|
30
|
+
expect(healthyChunks.length).toBeGreaterThan(0);
|
|
31
|
+
expect(chunks.length).toBe(before);
|
|
32
|
+
// The old evaluation resolves once the renderer wakes. It must neither
|
|
33
|
+
// append to the finalized recorder nor consume a later session's buffer.
|
|
34
|
+
await Bun.sleep(3000);
|
|
35
|
+
expect(chunks.length).toBe(before);
|
|
36
|
+
expect(await session.browser.probe("window.__spectestRrwebEvents.some(e => e.data?.tag === 'later-session')")).toBe(true);
|
|
37
|
+
const attached = await acquirePersistentBrowser("stuck-finalization", {
|
|
38
|
+
recorder: { sessionId: "next-finalization", recordStep() {} },
|
|
39
|
+
});
|
|
40
|
+
expect(attached.attached).toBe(true);
|
|
41
|
+
expect(await attached.browser.probe("document.querySelector('p').textContent")).toBe("retained page");
|
|
42
|
+
await attached.detach();
|
|
43
|
+
} finally {
|
|
44
|
+
await Promise.all([session.browser.close(), healthy.browser.close()]);
|
|
45
|
+
}
|
|
46
|
+
}, 25000);
|
package/src/browser.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { fileURLToPath } from "node:url";
|
|
|
35
35
|
|
|
36
36
|
import { generateId } from "./ids.js";
|
|
37
37
|
import { rawFetch } from "./harness/raw-fetch.js";
|
|
38
|
+
import { withDeadline } from "./harness/deadline.js";
|
|
38
39
|
import { prepareMicrophoneFixture, type MicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
39
40
|
export type { MicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
40
41
|
import { DESKTOP_INPUT_SYNC_SCRIPT } from "./desktop-recorder.js";
|
|
@@ -1783,6 +1784,7 @@ function buildBackend(
|
|
|
1783
1784
|
// `recordingEnded` stops this wrapper's recorder writes (test end);
|
|
1784
1785
|
// `viewClosed` tracks actual destruction (author called close()).
|
|
1785
1786
|
let recordingEnded = false;
|
|
1787
|
+
let recordingEnd: Promise<void> | undefined;
|
|
1786
1788
|
let viewClosed = false;
|
|
1787
1789
|
const sessionStart = Date.now();
|
|
1788
1790
|
let stepSeq = 0;
|
|
@@ -1837,11 +1839,18 @@ function buildBackend(
|
|
|
1837
1839
|
};
|
|
1838
1840
|
}
|
|
1839
1841
|
|
|
1840
|
-
async function drain(action: BrowserAction | "close", timestamp = Date.now()): Promise<void> {
|
|
1842
|
+
async function drain(action: BrowserAction | "close", timestamp = Date.now(), signal?: AbortSignal): Promise<void> {
|
|
1841
1843
|
if (!holder.recordingInstalled || !recorder || recordingEnded) return;
|
|
1842
1844
|
try {
|
|
1843
1845
|
const urlChanged = holder.page.url() !== lastDrainUrl;
|
|
1844
|
-
|
|
1846
|
+
// Finalization only reads the buffer. A timed-out evaluation may run
|
|
1847
|
+
// after a child/eval attaches; it must not clear that session's events.
|
|
1848
|
+
const expression = action === "close"
|
|
1849
|
+
? "window.__spectestRrwebEvents || []"
|
|
1850
|
+
: drainExpr(urlChanged, timestamp);
|
|
1851
|
+
const events = (await holder.page.evaluate(expression)) as unknown[];
|
|
1852
|
+
signal?.throwIfAborted();
|
|
1853
|
+
if (recordingEnded) return;
|
|
1845
1854
|
lastDrainUrl = holder.page.url();
|
|
1846
1855
|
if (Array.isArray(events) && events.length > 0) {
|
|
1847
1856
|
recorder.recordStep({
|
|
@@ -1852,6 +1861,7 @@ function buildBackend(
|
|
|
1852
1861
|
});
|
|
1853
1862
|
}
|
|
1854
1863
|
} catch {
|
|
1864
|
+
signal?.throwIfAborted();
|
|
1855
1865
|
// Page may be mid-navigation, detached, or the view is closing.
|
|
1856
1866
|
// Losing an occasional drain is acceptable — the next op picks up
|
|
1857
1867
|
// the rest of the buffer.
|
|
@@ -1874,6 +1884,7 @@ function buildBackend(
|
|
|
1874
1884
|
} catch {
|
|
1875
1885
|
/* page mid-navigation or closed — keep the stale cache */
|
|
1876
1886
|
}
|
|
1887
|
+
if (recordingEnded) return result;
|
|
1877
1888
|
// `sessionTimestamp` is the post-op wall clock — that's where we
|
|
1878
1889
|
// want the dashboard's seek to land, so clicking "type 'foo'"
|
|
1879
1890
|
// shows the input *with* the text, not the empty field just
|
|
@@ -1905,12 +1916,14 @@ function buildBackend(
|
|
|
1905
1916
|
}
|
|
1906
1917
|
return result;
|
|
1907
1918
|
} catch (err) {
|
|
1919
|
+
if (recordingEnded) throw err;
|
|
1908
1920
|
const e = err as Error;
|
|
1909
1921
|
const endT = Date.now();
|
|
1910
1922
|
const error = e?.message ?? String(err);
|
|
1911
1923
|
// Captured after the clock stops, so the diagnostic is not billed to
|
|
1912
1924
|
// the step, and before the event is recorded, so it rides it.
|
|
1913
1925
|
const pageStructure = await failedPageStructure(action, error);
|
|
1926
|
+
if (recordingEnded) throw err;
|
|
1914
1927
|
recordBrowser({
|
|
1915
1928
|
action,
|
|
1916
1929
|
...fields,
|
|
@@ -1926,18 +1939,21 @@ function buildBackend(
|
|
|
1926
1939
|
}
|
|
1927
1940
|
}
|
|
1928
1941
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
//
|
|
1932
|
-
|
|
1933
|
-
|
|
1942
|
+
function endRecording(): Promise<void> {
|
|
1943
|
+
// A stuck renderer must not prevent the test result from returning. The
|
|
1944
|
+
// signal and ended guard discard late recorder writes. Missing optional
|
|
1945
|
+
// replay data is a warning, not a failed test. Concurrent close/detach
|
|
1946
|
+
// calls share the same bounded attempt.
|
|
1947
|
+
return recordingEnd ??= (async () => {
|
|
1934
1948
|
try {
|
|
1935
|
-
await
|
|
1936
|
-
|
|
1937
|
-
|
|
1949
|
+
await withDeadline("Browser recording finalization", 10_000,
|
|
1950
|
+
signal => drain("close", Date.now(), signal));
|
|
1951
|
+
// Coverage is collected by the service capture after recording
|
|
1952
|
+
// finalization. Do not harvest it twice on the test-end path.
|
|
1953
|
+
} finally {
|
|
1954
|
+
recordingEnded = true;
|
|
1938
1955
|
}
|
|
1939
|
-
}
|
|
1940
|
-
recordingEnded = true;
|
|
1956
|
+
})();
|
|
1941
1957
|
}
|
|
1942
1958
|
|
|
1943
1959
|
const strategy: ActionStrategy = holder.device ? mobileStrategy : desktopStrategy;
|
|
@@ -2209,8 +2225,15 @@ function buildBackend(
|
|
|
2209
2225
|
// the next `ctx.browser()`/`ctx.mobile()` creates a new one. The
|
|
2210
2226
|
// routine test-end path is `detach` (recording stops, view lives on
|
|
2211
2227
|
// into the post-test snapshot).
|
|
2212
|
-
await endRecording();
|
|
2228
|
+
try { await endRecording(); } catch (error) {
|
|
2229
|
+
console.warn("[browser finalization]", error instanceof Error ? error.message : String(error));
|
|
2230
|
+
}
|
|
2213
2231
|
if (viewClosed) return;
|
|
2232
|
+
// An explicit close destroys the renderer, unlike detach. Harvest its
|
|
2233
|
+
// counters before destroying it; service capture handles live views.
|
|
2234
|
+
if (browserCoverageActive()) {
|
|
2235
|
+
try { await harvestBrowserCoverage(holder.cdp); } catch { /* capture reports the error */ }
|
|
2236
|
+
}
|
|
2214
2237
|
viewClosed = true;
|
|
2215
2238
|
buildOpts.onDestroy?.();
|
|
2216
2239
|
try {
|
|
@@ -2293,6 +2316,7 @@ function buildBackend(
|
|
|
2293
2316
|
// A matcher that ran out of budget is the other half of a failed
|
|
2294
2317
|
// browser step, and the page it settled on is the same evidence.
|
|
2295
2318
|
const pageStructure = error ? await failedPageStructure(action, error) : undefined;
|
|
2319
|
+
if (recordingEnded) return undefined;
|
|
2296
2320
|
const seq = recordBrowser({
|
|
2297
2321
|
action,
|
|
2298
2322
|
...fields,
|
package/src/coverage.ts
CHANGED
|
@@ -1205,7 +1205,7 @@ export function browser(options: CoverageSourceOptions = {}): CoverageAdapter {
|
|
|
1205
1205
|
ctx.collectBrowserScripts();
|
|
1206
1206
|
},
|
|
1207
1207
|
async capture(ctx) {
|
|
1208
|
-
await harvestAllBrowserCoverage();
|
|
1208
|
+
await harvestAllBrowserCoverage(ctx.signal);
|
|
1209
1209
|
const lcov = takeBrowserCoverageReport(ctx.service);
|
|
1210
1210
|
await ctx.writeReport("browser.lcov", lcov);
|
|
1211
1211
|
},
|
package/src/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finalizeBrowserRecordings } from "./harness/deadline.js";
|
|
1
2
|
import { ensureServiceNetwork, serviceIpv4Gateway } from "./harness/service-network.js";
|
|
2
3
|
import { collectSecretRefs } from "./fake-upstream.js";
|
|
3
4
|
import { phoneScope, phoneRecordingChunk, releasePhoneRecording, type PhoneRecording } from "./phone.js";
|
|
@@ -5309,12 +5310,10 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5309
5310
|
// this test's recorder. The Chromium itself deliberately stays alive
|
|
5310
5311
|
// — it's part of the state the post-test snapshot captures for
|
|
5311
5312
|
// dependsOn children (see the acquire comment above).
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
/* ignore */
|
|
5317
|
-
}
|
|
5313
|
+
const browserErrors = await finalizeBrowserRecordings(browserDetaches);
|
|
5314
|
+
if (browserErrors.length) {
|
|
5315
|
+
const message = browserErrors.join("\n");
|
|
5316
|
+
chunks.push(`[browser finalization] ${message}\n`);
|
|
5318
5317
|
}
|
|
5319
5318
|
for (const s of sessions) s.markClosed();
|
|
5320
5319
|
androidRecordings = await nativeAndroid.finish();
|
|
@@ -5871,12 +5870,10 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
5871
5870
|
(process.stderr as any).write = origStderr;
|
|
5872
5871
|
// Detach (final rrweb drain) — the browser itself stays alive; see
|
|
5873
5872
|
// the acquire comment above.
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
/* ignore */
|
|
5879
|
-
}
|
|
5873
|
+
const browserErrors = await finalizeBrowserRecordings(browserDetaches);
|
|
5874
|
+
if (browserErrors.length) {
|
|
5875
|
+
const message = browserErrors.join("\n");
|
|
5876
|
+
chunks.push(`[browser finalization] ${message}\n`);
|
|
5880
5877
|
}
|
|
5881
5878
|
for (const s of sessions) s.markClosed();
|
|
5882
5879
|
await nativeAndroid.finish();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { finalizeBrowserRecordings } from "./deadline.js";
|
|
3
|
+
|
|
4
|
+
test("all recording finalizers start before waiting and failures are returned as warnings", async () => {
|
|
5
|
+
let release!: () => void;
|
|
6
|
+
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
7
|
+
const started: string[] = [];
|
|
8
|
+
const done = finalizeBrowserRecordings([
|
|
9
|
+
async () => { started.push("first"); await gate; throw new Error("optional replay unavailable"); },
|
|
10
|
+
async () => { started.push("second"); await gate; },
|
|
11
|
+
]);
|
|
12
|
+
await Promise.resolve();
|
|
13
|
+
expect(started).toEqual(["first", "second"]);
|
|
14
|
+
release();
|
|
15
|
+
expect(await done).toEqual(["optional replay unavailable"]);
|
|
16
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Bound an async operation and invalidate its late result. Work must check
|
|
2
|
+
* the signal before committing state after an await; racing alone does not
|
|
3
|
+
* cancel a Playwright/CDP request already sent to the browser. */
|
|
4
|
+
export async function withDeadline<T>(
|
|
5
|
+
label: string,
|
|
6
|
+
timeoutMs: number,
|
|
7
|
+
work: (signal: AbortSignal) => Promise<T>,
|
|
8
|
+
): Promise<T> {
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
try {
|
|
12
|
+
return await Promise.race([
|
|
13
|
+
new Promise<never>((_, reject) => {
|
|
14
|
+
timer = setTimeout(() => {
|
|
15
|
+
const error = new Error(`${label} did not finish within ${timeoutMs}ms`);
|
|
16
|
+
controller.abort(error);
|
|
17
|
+
reject(error);
|
|
18
|
+
}, timeoutMs);
|
|
19
|
+
}),
|
|
20
|
+
work(controller.signal),
|
|
21
|
+
]);
|
|
22
|
+
} finally {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Start all browser drains together. One wedged view must not consume a
|
|
28
|
+
* separate finalization allowance for every other view in the test. */
|
|
29
|
+
export async function finalizeBrowserRecordings(detaches: Array<() => Promise<void>>): Promise<string[]> {
|
|
30
|
+
const results = await Promise.allSettled(detaches.map(detach => Promise.resolve().then(detach)));
|
|
31
|
+
return results.flatMap(result => result.status === "rejected"
|
|
32
|
+
? [result.reason instanceof Error ? result.reason.message : String(result.reason)] : []);
|
|
33
|
+
}
|