@specific.dev/spectest 0.131.2 → 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.
@@ -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. Errors (a page mid-navigation,
16
- * a closed view) are swallowed: the counters then stay in V8 for the next
17
- * take, nothing is lost.
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
@@ -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. Errors (a page mid-navigation,
64
- * a closed view) are swallowed: the counters then stay in V8 for the next
65
- * take, nothing is lost.
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.inFlight)
72
- return c.inFlight;
73
- c.inFlight = harvestInner(c, CONFIG).catch(() => { }).finally(() => {
74
- c.inFlight = null;
75
- });
76
- return c.inFlight;
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
- LIVE_COLLECTORS.delete(c); // the session is gone
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
- const events = (await holder.page.evaluate(drainExpr(urlChanged, timestamp)));
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
- async function endRecording() {
1426
- if (recordingEnded)
1427
- return;
1428
- // Final drain before we stop writing to this recorder.
1429
- await drain("close");
1430
- if (browserCoverageActive()) {
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 harvestBrowserCoverage(holder.cdp);
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
- catch {
1435
- /* best-effort */
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
- await endRecording();
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
- for (const detach of browserDetaches) {
4441
- try {
4442
- await detach();
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
- for (const detach of browserDetaches) {
4933
- try {
4934
- await detach();
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
+ }
@@ -48,6 +48,10 @@ export function validateStickyConfig(config) {
48
48
  export function stickyBuildkitConfig(config) {
49
49
  validateStickyConfig(config);
50
50
  return `root = "${config.path}/buildkit"
51
+ # This standalone BuildKit does not inherit dockerd's registry mirrors.
52
+ # Keep Docker Hub routing aligned with the guest daemon and k3s.
53
+ [registry."docker.io"]
54
+ mirrors = ["mirror.gcr.io"]
51
55
  [grpc]
52
56
  address = ["tcp://127.0.0.1:1234"]
53
57
  [worker.oci]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.2",
3
+ "version": "0.131.4",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
+ });
@@ -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. Errors (a page mid-navigation,
102
- * a closed view) are swallowed: the counters then stay in V8 for the next
103
- * take, nothing is lost.
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.inFlight) return c.inFlight;
109
- c.inFlight = harvestInner(c, CONFIG).catch(() => {}).finally(() => {
110
- c.inFlight = null;
111
- });
112
- return c.inFlight;
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
- const events = (await holder.page.evaluate(drainExpr(urlChanged, timestamp))) as unknown[];
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
- async function endRecording(): Promise<void> {
1930
- if (recordingEnded) return;
1931
- // Final drain before we stop writing to this recorder.
1932
- await drain("close");
1933
- if (browserCoverageActive()) {
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 harvestBrowserCoverage(holder.cdp);
1936
- } catch {
1937
- /* best-effort */
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
- for (const detach of browserDetaches) {
5313
- try {
5314
- await detach();
5315
- } catch {
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
- for (const detach of browserDetaches) {
5875
- try {
5876
- await detach();
5877
- } catch {
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
+ }
@@ -23,6 +23,11 @@ test("sticky consumer validates disk capacity and required free space", () => {
23
23
  }
24
24
  });
25
25
 
26
+ test("standalone BuildKit routes Docker Hub through Google's mirror", () => {
27
+ const buildkit = Bun.TOML.parse(stickyBuildkitConfig(config)) as any;
28
+ expect(buildkit.registry["docker.io"].mirrors).toEqual(["mirror.gcr.io"]);
29
+ });
30
+
26
31
  test("automatic BuildKit GC has one free-space policy without default cache-size caps", () => {
27
32
  const worker = (Bun.TOML.parse(stickyBuildkitConfig(config)) as any).worker.containerd;
28
33
  expect(worker.gc).toBe(true);
@@ -53,6 +53,10 @@ export function validateStickyConfig(config: StickyConsumerConfig): void {
53
53
  export function stickyBuildkitConfig(config: StickyConsumerConfig): string {
54
54
  validateStickyConfig(config);
55
55
  return `root = "${config.path}/buildkit"
56
+ # This standalone BuildKit does not inherit dockerd's registry mirrors.
57
+ # Keep Docker Hub routing aligned with the guest daemon and k3s.
58
+ [registry."docker.io"]
59
+ mirrors = ["mirror.gcr.io"]
56
60
  [grpc]
57
61
  address = ["tcp://127.0.0.1:1234"]
58
62
  [worker.oci]