@specific.dev/spectest 0.107.2 → 0.109.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.
@@ -0,0 +1,162 @@
1
+ import { readAnnotation } from "./annotate.js";
2
+ import type { RenderAnnotation } from "./annotate.js";
3
+ import { deepUnwrap, wrap } from "./inspect.js";
4
+ import { runUninstrumented } from "./harness/instrumentation-scope.js";
5
+ import {
6
+ captureRecording, recordEmail, recordFake, recordServiceHelper, recordStep, reserveEvent, safeSerialize,
7
+ type EventReservation,
8
+ } from "./recorder.js";
9
+
10
+ type HelperKind = "fake" | "service";
11
+
12
+ /** Both helper namespaces consume the same annotation box. Only direct own
13
+ * function properties are intercepted: live clients, getters and inherited
14
+ * methods pass through; callable clients retain their properties. Service functions without annotations
15
+ * retain their original results, types and underlying recorded operations. */
16
+ export function trackHelpers<H extends Record<string, unknown>>(kind: HelperKind, name: string, helpers: H): H {
17
+ const methods = new Map<string, { original: Function; tracked: Function }>();
18
+ // A separate, extensible target also supports frozen helper records: a proxy
19
+ // over the original cannot substitute a non-writable function property.
20
+ const proxy: H = new Proxy(Object.create(Object.getPrototypeOf(helpers)), {
21
+ get(_target, prop) {
22
+ if (typeof prop !== "string") return Reflect.get(helpers, prop, helpers);
23
+ const descriptor = Object.getOwnPropertyDescriptor(helpers, prop);
24
+ if (!descriptor || typeof descriptor.value !== "function") return Reflect.get(helpers, prop, helpers);
25
+ const fn = descriptor.value as (...args: unknown[]) => unknown;
26
+ const cached = methods.get(prop);
27
+ if (cached?.original === fn) return cached.tracked;
28
+ // A callable handle (e.g. tagged-template SQL) can also have methods.
29
+ // Proxy its call rather than replacing it with a bare arrow function.
30
+ const tracked = new Proxy(fn, {
31
+ apply(original, thisArg, args) {
32
+ return invokeHelper(kind, name, prop, original,
33
+ kind === "fake" || thisArg === proxy ? helpers : thisArg, args);
34
+ },
35
+ });
36
+ methods.set(prop, { original: fn, tracked });
37
+ return tracked;
38
+ },
39
+ has: (_target, prop) => Reflect.has(helpers, prop),
40
+ ownKeys: () => Reflect.ownKeys(helpers),
41
+ getOwnPropertyDescriptor(_target, prop) {
42
+ const descriptor = Object.getOwnPropertyDescriptor(helpers, prop);
43
+ return descriptor && { ...descriptor, configurable: true };
44
+ },
45
+ set: (_target, prop, value) => Reflect.set(helpers, prop, value, helpers),
46
+ deleteProperty: (_target, prop) => Reflect.deleteProperty(helpers, prop),
47
+ // Virtual properties must remain configurable on this view.
48
+ defineProperty: (_target, prop, descriptor) => descriptor.configurable === true &&
49
+ Reflect.defineProperty(helpers, prop, descriptor),
50
+ preventExtensions: () => false,
51
+ });
52
+ return proxy;
53
+ }
54
+
55
+ function invokeHelper(
56
+ kind: HelperKind, name: string, member: string,
57
+ fn: (...args: unknown[]) => unknown, receiver: unknown, callArgs: unknown[],
58
+ ): unknown {
59
+ const start = Date.now();
60
+ const reservation = reserveEvent();
61
+ const recording = captureRecording();
62
+ // Fake bodies have always received raw args. Services keep their existing
63
+ // input contract: they may intentionally accept wrapped values or clients.
64
+ const args = kind === "fake" ? callArgs.map((arg) => deepUnwrap(arg)) : callArgs;
65
+ const safeArgs = kind === "fake" ? args.map((arg) => safeSerialize(arg)) : undefined;
66
+ const finish = (value: unknown): unknown => {
67
+ const annotated = readAnnotation(value);
68
+ if (kind === "service" && !annotated) {
69
+ recording.finish(true);
70
+ return value;
71
+ }
72
+ recording.finish(false);
73
+ const raw = deepUnwrap(annotated ? annotated.value : value);
74
+ const durationMs = Date.now() - start;
75
+ const seq = recording.isCurrent()
76
+ ? recordHelper(kind, name, member,
77
+ safeArgs ?? args.map((arg) => safeSerialize(deepUnwrap(arg))), raw, durationMs, reservation)
78
+ : undefined;
79
+ if (annotated && seq !== undefined) {
80
+ recordAnnotationChild(name, member, annotated.annotation, durationMs, seq);
81
+ }
82
+ return wrap(raw, seq);
83
+ };
84
+ const fail = (error: unknown): never => {
85
+ // There was no annotated return to replace the implementation. Preserve
86
+ // service failures' underlying diagnostics; fake internals stay hidden.
87
+ recording.finish(kind === "service");
88
+ if (kind === "fake" && recording.isCurrent()) {
89
+ recordFake({ fake: name, member, args: safeArgs!, durationMs: Date.now() - start,
90
+ error: error instanceof Error ? error.message : String(error) }, reservation);
91
+ }
92
+ throw error;
93
+ };
94
+ let result: unknown;
95
+ try {
96
+ result = recording.run(() => kind === "fake"
97
+ ? runUninstrumented(() => fn.apply(receiver, args))
98
+ : fn.apply(receiver, args));
99
+ } catch (error) {
100
+ return fail(error);
101
+ }
102
+ return result instanceof Promise ? result.then(finish, fail) : finish(result);
103
+ }
104
+
105
+ function recordHelper(
106
+ kind: HelperKind, name: string, member: string, args: unknown[], result: unknown,
107
+ durationMs: number, reservation: EventReservation | undefined,
108
+ ): number | undefined {
109
+ if (kind === "fake") {
110
+ return recordFake({ fake: name, member, args, result: safeSerialize(result), durationMs }, reservation);
111
+ }
112
+ return recordServiceHelper({ service: name, member, args, result: safeSerialize(result), durationMs }, reservation);
113
+ }
114
+
115
+ /** Record a helper call's render annotation as a child of the call's
116
+ * own event — the same `parentSeq` grouping `ctx.poll` uses for the
117
+ * iteration it kept, so the annotated view folds into the helper step's
118
+ * detail panel instead of taking a timeline row of its own.
119
+ *
120
+ * The child is the event kind that already knows how to draw this: an
121
+ * `email` annotation records the very event the built-in `email()`
122
+ * component's mailbox helpers record, so it renders with no new code. It
123
+ * carries the helper's name and member as its service/op, and the parent's
124
+ * duration, since it describes that same call.
125
+ *
126
+ * Only the success path is annotated: a helper that threw returned no value
127
+ * to annotate. */
128
+ function recordAnnotationChild(
129
+ name: string,
130
+ member: string,
131
+ annotation: RenderAnnotation,
132
+ durationMs: number,
133
+ parentSeq: number,
134
+ ): void {
135
+ switch (annotation.kind) {
136
+ case "email":
137
+ recordEmail({
138
+ parentSeq,
139
+ annotation: true,
140
+ service: name,
141
+ op: member,
142
+ message: annotation.message,
143
+ messages: annotation.messages,
144
+ count: annotation.count,
145
+ durationMs,
146
+ });
147
+ break;
148
+ case "chat":
149
+ // A chat has no event kind of its own: it describes itself in
150
+ // presentation terms (title + blocks), which is the seam a new step
151
+ // type is supposed to use. `kind` stays an opaque grouping label.
152
+ recordStep({
153
+ kind: "chat",
154
+ parentSeq,
155
+ annotation: true,
156
+ title: annotation.title ?? `${name}.${member}`,
157
+ blocks: annotation.blocks,
158
+ durationMs,
159
+ });
160
+ break;
161
+ }
162
+ }
package/src/index.ts CHANGED
@@ -36,7 +36,7 @@ export type {
36
36
  SpectestRequestInit,
37
37
  Unwrap,
38
38
  } from "./inspect.js";
39
- import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
39
+ import type { OpTag, Provenanced, SpectestFetch, Wrapped, Unwrap } from "./inspect.js";
40
40
 
41
41
  // `field` — a provenance-preserving, null-safe selector: a `null`/`undefined`
42
42
  // leaf read off a wrapped op result is raw and untagged, so an `expect(...)` on
@@ -49,13 +49,9 @@ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
49
49
  // `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
50
50
  export { field } from "./inspect.js";
51
51
 
52
- // Render annotations for fake helpers: a helper returns
53
- // `annotate(value, "email", { … })` or `annotate(value, "chat", { … })`
54
- // when its value is one of those, and the fake step it records gains a
55
- // nested event drawing it — an email mail-client style, a conversation as
56
- // bubbles — which the step's panel leads with, tabbing to the raw JSON
57
- // value. The kind picks the options' type, and the test still gets the raw
58
- // value, with the raw value's type — see `annotate.ts`.
52
+ // Fake and service helpers return annotate(value, kind) to render their own
53
+ // result as email/chat. Their boundary opens the box and links assertions to
54
+ // the helper step. Annotated service returns suppress internal operation events.
59
55
  export { annotate } from "./annotate.js";
60
56
  export type {
61
57
  Annotated,
@@ -768,6 +764,11 @@ export interface ServiceDefinition<
768
764
  * user chose in the services map and matches the in-VM DNS name; the
769
765
  * rest of the {@link ServiceHelpersContext} (exec, projectRoot) lets a
770
766
  * component reach into its containers without daemon internals.
767
+ *
768
+ * Direct helper functions may return `annotate(value, "email" | "chat")`.
769
+ * That call becomes one named service step, its internal events are hidden,
770
+ * and the caller receives `Wrapped<T>` linked to the helper. Ordinary returns
771
+ * retain their existing value/type and underlying operation events.
771
772
  */
772
773
  helpers?: (args: ServiceHelpersContext) => H | Promise<H>;
773
774
  /**
@@ -816,13 +817,26 @@ type HelpersOf<D> = "helpers" extends keyof D
816
817
  /**
817
818
  * Per-service handles derived from a concrete services map. Only
818
819
  * services that ship a `helpers` factory appear here; `ctx.svc.<name>`
819
- * is exactly the record the factory returned (e.g. `{ client: SqlClient }`
820
- * for `postgres(...)`). Services without helpers don't show up at all,
820
+ * preserves the factory's live handles and ordinary function results. A
821
+ * function returning `annotate(value, kind)` instead returns a wrapped value
822
+ * linked to one named service-helper step. Services without helpers don't show up,
821
823
  * so plain `services: { api: { image: ... } }` adds no noise.
822
824
  */
823
825
  export type ServiceHandlesFor<S extends ServicesMap> = {
824
- [K in keyof S as HelpersOf<S[K]> extends never ? never : K]: HelpersOf<S[K]>;
826
+ [K in keyof S as HelpersOf<S[K]> extends never ? never : K]: ServiceHelpers<HelpersOf<S[K]>>;
827
+ };
828
+
829
+ /** Only annotated returns change type. Keeping other function types verbatim
830
+ * preserves generics, overloads, and callable clients with attached methods. */
831
+ type ServiceHelpers<H> = {
832
+ [K in keyof H]: H[K] extends (...args: infer A) => infer R
833
+ ? Extract<Awaited<R>, Annotated<unknown>> extends never
834
+ ? H[K]
835
+ : (...args: A) => R extends Promise<infer V>
836
+ ? Promise<ServiceHelperResult<V>> : ServiceHelperResult<R>
837
+ : H[K];
825
838
  };
839
+ type ServiceHelperResult<R> = R extends Annotated<infer V> ? Wrapped<Unwrap<V>> : R;
826
840
 
827
841
  /** Loose, runtime-friendly shape of `ctx.svc` for code that doesn't
828
842
  * know the concrete services map (the daemon, generic helpers). The
@@ -2265,7 +2279,7 @@ export function defineFake<
2265
2279
  export type FakesMap = Record<string, FakeDefinition<any, any>>;
2266
2280
 
2267
2281
  /** Rewrite a helpers record so each function's result is inspect-wrapped
2268
- * ({@link Wrapped}) — mirroring the runtime, where `trackFakeHelpers` wraps
2282
+ * ({@link Wrapped}) — mirroring the runtime, where `trackHelpers` wraps
2269
2283
  * every helper return value for assertion provenance. Without this the raw
2270
2284
  * return type (e.g. `Charge[]`) reaches `expect`, which the `Provenanced`
2271
2285
  * gate rejects. Handles sync and async helpers; non-function members (and
@@ -2273,9 +2287,9 @@ export type FakesMap = Record<string, FakeDefinition<any, any>>;
2273
2287
  * `components/k3s.ts`, extended to cover synchronous returns. */
2274
2288
  type WrappedHelpers<H> = {
2275
2289
  [K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R>
2276
- ? (...args: A) => Promise<Wrapped<Unannotated<R>>>
2290
+ ? (...args: A) => Promise<Wrapped<Unwrap<Unannotated<R>>>>
2277
2291
  : H[K] extends (...args: infer A) => infer R
2278
- ? (...args: A) => [R] extends [void] ? void : Wrapped<Unannotated<R>>
2292
+ ? (...args: A) => [R] extends [void] ? void : Wrapped<Unwrap<Unannotated<R>>>
2279
2293
  : H[K];
2280
2294
  };
2281
2295
 
@@ -2363,18 +2377,26 @@ export interface Project<
2363
2377
  * appear in the timeline.
2364
2378
  */
2365
2379
  setup?: ProjectSetupFn<S, F>;
2380
+ /**
2381
+ * Prepare a new interactive environment, after normal setup and the shared
2382
+ * warm-template snapshot. Runs on both cold starts and warm restores from
2383
+ * the dashboard or `spectest env start`, before the environment is ready.
2384
+ * Never runs for tests, snapshot forks, or resuming a stopped environment.
2385
+ * Uses the same context as `setup`; throw to fail the start.
2386
+ */
2387
+ setupInteractive?: ProjectSetupFn<S, F>;
2366
2388
  tests?: TestSuite<S, F>;
2367
2389
  }
2368
2390
 
2369
2391
  /**
2370
- * Context handed to the project-level `setup` hook: the full
2392
+ * Context handed to the project-level `setup` and `setupInteractive` hooks: the full
2371
2393
  * {@link SpectestContext}, minus only what a timeline gives a test
2372
2394
  * (testName, parent, browser/mobile/terminal). Everything a test can do
2373
2395
  * to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
2374
2396
  * `dnsName`, `certificate`, `startService`, and project-file access via
2375
- * `projectRoot`/`readProjectFile` — is available here too, and whatever it
2376
- * produces is captured into the warm-template snapshot, so every test
2377
- * inherits it without re-running.
2397
+ * `projectRoot`/`readProjectFile` — is available here too. Normal `setup`
2398
+ * state is captured into the shared warm template; `setupInteractive`
2399
+ * state belongs only to the new interactive environment.
2378
2400
  */
2379
2401
  export interface ProjectSetupContext<
2380
2402
  S extends ServicesMap = ServicesMap,
@@ -2423,6 +2445,8 @@ export interface ProjectOpts<
2423
2445
  > {
2424
2446
  /** Optional project-level setup. See `Project.setup`. */
2425
2447
  setup?: ProjectSetupFn<S, F>;
2448
+ /** Optional interactive-only setup. See `Project.setupInteractive`. */
2449
+ setupInteractive?: ProjectSetupFn<S, F>;
2426
2450
  /** Test cases for this project's suite. */
2427
2451
  tests?: TestCase<unknown, S, F>[];
2428
2452
  }
@@ -2487,7 +2511,7 @@ export interface DefinedEnvironment<
2487
2511
  ): TestGroup<P, S, F>;
2488
2512
  /** Bundle this environment with a test suite into the project default
2489
2513
  * export. Pass tests as a plain array for the common case, or an
2490
- * options bag to attach project-level `setup`. (Fakes are declared on
2514
+ * options bag to attach `setup` or `setupInteractive`. (Fakes are declared on
2491
2515
  * `defineEnvironment`, not here.) */
2492
2516
  project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
2493
2517
  project(opts: ProjectOpts<S, F>): Project<S, F>;
@@ -2639,6 +2663,7 @@ export function defineEnvironment<
2639
2663
  : (arg ?? {});
2640
2664
  const proj: Project<S, F> = { environment: config };
2641
2665
  if (opts.setup) proj.setup = opts.setup;
2666
+ if (opts.setupInteractive) proj.setupInteractive = opts.setupInteractive;
2642
2667
  if (fakes) proj.fakes = fakes;
2643
2668
  if (opts.tests && opts.tests.length > 0) {
2644
2669
  // Explicit suite (single-file / legacy layout): the tests are listed
@@ -0,0 +1,89 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // The daemon owns globals and snapshot-resident state. Use a fresh process,
7
+ // just as each freshly built environment has its own harness.
8
+ for (const withHook of [false, true]) {
9
+ test(`interactive setup: ${withHook ? "context, failure, exclusion and one-shot state" : "optional hook"}`, async () => {
10
+ const root = await mkdtemp(join(tmpdir(), "spectest-interactive-setup-"));
11
+ try {
12
+ await mkdir(join(root, "spectest"));
13
+ await mkdir(join(root, "bin"));
14
+ await writeFile(join(root, "bin/docker"), '#!/bin/sh\nprintf "%s\\n" "$*"\n', { mode: 0o755 });
15
+ const entry = join(root, "spectest/index.ts");
16
+ await writeFile(entry, `
17
+ import { defineEnvironment } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
18
+ export const calls = [];
19
+ const env = defineEnvironment({ name: "seed", services: {
20
+ app: { image: { type: "registry", reference: "busybox" },
21
+ helpers: () => ({ label: "typed helper" }) },
22
+ }});
23
+ export default env.project({
24
+ setup: () => { calls.push("baseline"); },
25
+ ${withHook ? `setupInteractive: async (ctx) => {
26
+ calls.push("interactive");
27
+ console.log("seeding demo data");
28
+ await globalThis.seedGate;
29
+ if (globalThis.failSeed) throw new Error("seed rejected");
30
+ const response = await ctx.fetch("data:application/json,%7B%22ok%22%3Atrue%7D");
31
+ if (response.status.unwrap() !== 200) throw new Error("fetch not wrapped");
32
+ if (ctx.svc.app.label !== "typed helper") throw new Error("missing helpers");
33
+ if ("browser" in ctx || "testName" in ctx) throw new Error("test context leaked");
34
+ const result = await ctx.exec("app", ["seed", "demo"]);
35
+ if (typeof result.exitCode !== "number" || !result.stdout.includes("seed demo")) {
36
+ throw new Error("exec result not plain: " + JSON.stringify(result));
37
+ }
38
+ },` : ""}
39
+ });
40
+ `);
41
+ const script = `
42
+ import { strict as assert } from "node:assert";
43
+ import { METHODS } from ${JSON.stringify(new URL("./daemon.ts", import.meta.url).href)};
44
+ import { calls } from ${JSON.stringify(entry)};
45
+ const nativeFetch = globalThis.fetch;
46
+ await METHODS.load({});
47
+ assert.equal((await METHODS.health({})).setupInteractive, ${withHook});
48
+ await METHODS.projectSetup({});
49
+ assert.deepEqual(calls, ["baseline"]);
50
+ ${withHook ? `
51
+ let release;
52
+ globalThis.seedGate = new Promise(r => { release = r; });
53
+ globalThis.failSeed = true;
54
+ const failed = METHODS.interactiveSetup({});
55
+ await assert.rejects(METHODS.interactiveSetup({}), /already in progress/);
56
+ await assert.rejects(METHODS.projectSetup({}), /already in progress/);
57
+ release();
58
+ await assert.rejects(failed, /seed rejected/);
59
+ assert.equal(globalThis.fetch, nativeFetch);
60
+ globalThis.failSeed = false;
61
+ assert.equal((await METHODS.interactiveSetup({})).ran, true);
62
+ assert.deepEqual(calls, ["baseline", "interactive", "interactive"]);
63
+ // A repeated request (or a resumed seeded snapshot) cannot reseed.
64
+ assert.equal((await METHODS.interactiveSetup({})).ran, false);
65
+ assert.deepEqual(calls, ["baseline", "interactive", "interactive"]);
66
+ const logs = await METHODS.envLogs({});
67
+ assert.ok(logs.bootLog.includes("seeding demo data"));
68
+ ` : `
69
+ assert.equal((await METHODS.interactiveSetup({})).ran, false);
70
+ assert.deepEqual(calls, ["baseline"]);
71
+ `}
72
+ assert.equal(globalThis.fetch, nativeFetch);
73
+ // envLogs' subprocess timeout keeps a timer alive after it returns;
74
+ // this fixture tests hook behavior, not daemon process lifetime.
75
+ process.exit(0);
76
+ `;
77
+ const child = Bun.spawn([process.execPath, "--eval", script], {
78
+ env: { ...process.env, SPECTEST_APP_DIR: root, PATH: `${root}/bin:${process.env.PATH}` },
79
+ stdout: "pipe", stderr: "pipe",
80
+ });
81
+ const [code, stdout, stderr] = await Promise.all([
82
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
83
+ ]);
84
+ expect(code, stdout + stderr).toBe(0);
85
+ } finally {
86
+ await rm(root, { recursive: true, force: true });
87
+ }
88
+ }, 20_000);
89
+ }
package/src/recorder.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // current recorder is `null` and the `record*` helpers are no-ops, so
9
9
  // callers can invoke them unconditionally.
10
10
 
11
+ import { AsyncLocalStorage } from "node:async_hooks";
11
12
  import type { AndroidStepDetails } from "./android-step.js";
12
13
  import { clearPendingNullish } from "./inspect.js";
13
14
  import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
@@ -27,6 +28,7 @@ export type TestEvent =
27
28
  | TerminalStepEvent
28
29
  | WaitEvent
29
30
  | FakeEvent
31
+ | ServiceHelperEvent
30
32
  | EnvEvent
31
33
  | EmailEvent
32
34
  // Open `kind`, so it goes last: narrowing the union by a literal kind
@@ -339,6 +341,14 @@ export interface FakeEvent extends BaseEvent {
339
341
  error?: string;
340
342
  }
341
343
 
344
+ /** An annotated service helper has the same call/return shape as a fake.
345
+ * Only helpers returning an annotation emit this event. */
346
+ export interface ServiceHelperEvent extends Omit<FakeEvent, "kind" | "fake"> {
347
+ kind: "service";
348
+ /** Service name — the key in `ctx.svc`. */
349
+ service: string;
350
+ }
351
+
342
352
  /**
343
353
  * One captured email, as embedded on an {@link EmailEvent}. The HTML and
344
354
  * text bodies ride along (truncated to the output cap) so the dashboard
@@ -631,7 +641,12 @@ export interface EventReservation {
631
641
  }
632
642
 
633
643
  class Recorder {
634
- private events: TestEvent[] = [];
644
+ private rootEvents: TestEvent[] = [];
645
+ readonly pendingHelpers = new Set<RecordingBranch>();
646
+ private get events(): TestEvent[] {
647
+ const branch = recordingBranches.getStore();
648
+ return branch?.owner === this ? branchEvents(branch) : this.rootEvents;
649
+ }
635
650
  private seq = 0;
636
651
  constructor(private readonly start = Date.now()) {}
637
652
 
@@ -733,6 +748,60 @@ class Recorder {
733
748
 
734
749
  let current: Recorder | null = null;
735
750
 
751
+ interface RecordingBranch {
752
+ owner: Recorder;
753
+ events: TestEvent[];
754
+ parent: RecordingBranch | undefined;
755
+ destination: TestEvent[];
756
+ state: "open" | "kept" | "discarded";
757
+ }
758
+
759
+ const recordingBranches = new AsyncLocalStorage<RecordingBranch>();
760
+
761
+ function branchEvents(branch: RecordingBranch): TestEvent[] {
762
+ return branch.state === "kept"
763
+ ? branch.parent ? branchEvents(branch.parent) : branch.destination
764
+ : branch.events;
765
+ }
766
+
767
+ function branchAllowsRecording(branch: RecordingBranch | undefined): boolean {
768
+ return !branch || (branch.owner === current && branch.state !== "discarded" &&
769
+ branchAllowsRecording(branch.parent));
770
+ }
771
+
772
+ /** Buffer a helper's async descendants until its return chooses between the
773
+ * implementation's events and one annotated helper call. Seqs still come from
774
+ * the test recorder, so concurrent calls and linked assertions stay unique.
775
+ * Poll truncation/grouping inside the helper applies only to its own buffer. */
776
+ export function captureRecording(): {
777
+ run<T>(fn: () => T): T;
778
+ finish(keep: boolean): void;
779
+ isCurrent(): boolean;
780
+ } {
781
+ if (!active()) return { run: (fn) => fn(), finish: () => {}, isCurrent: () => false };
782
+ const branch: RecordingBranch = {
783
+ owner: current!, events: [], parent: recordingBranches.getStore(),
784
+ destination: current!.drain(), state: "open",
785
+ };
786
+ current!.pendingHelpers.add(branch);
787
+ return {
788
+ run: (fn) => recordingBranches.run(branch, fn),
789
+ isCurrent: () => branch.owner === current && active(),
790
+ finish(keep) {
791
+ if (branch.state !== "open") return;
792
+ branch.owner.pendingHelpers.delete(branch);
793
+ if (keep && branchAllowsRecording(branch)) {
794
+ const destination = branch.parent ? branchEvents(branch.parent) : branch.destination;
795
+ for (const event of branch.events) destination.push(event);
796
+ branch.state = "kept";
797
+ } else {
798
+ branch.state = "discarded";
799
+ }
800
+ branch.events.length = 0;
801
+ },
802
+ };
803
+ }
804
+
736
805
  // Counter-based pause: `pauseRecording`/`resumeRecording` nest safely
737
806
  // (a nested `ctx.poll` inside another `ctx.poll`'s predicate stays
738
807
  // suppressed until its own resume balances out). Active record* sites
@@ -751,7 +820,7 @@ export function resumeRecording(): void {
751
820
  }
752
821
 
753
822
  function active(): boolean {
754
- return current !== null && paused === 0 && scopeAllowsRecording();
823
+ return current !== null && paused === 0 && scopeAllowsRecording() && branchAllowsRecording(recordingBranches.getStore());
755
824
  }
756
825
 
757
826
  export function startRecording(startedAt = Date.now()): void {
@@ -762,6 +831,18 @@ export function startRecording(startedAt = Date.now()): void {
762
831
  export function stopRecording(): TestEvent[] {
763
832
  if (!current) return [];
764
833
  const evs = current.drain();
834
+ // A timeout can stop the test before a helper returns. Keep its available
835
+ // diagnostics, then fence detached work out of any subsequent recording.
836
+ for (const branch of current.pendingHelpers) {
837
+ if (branchAllowsRecording(branch)) {
838
+ for (const event of branch.events) evs.push(event);
839
+ }
840
+ }
841
+ for (const branch of current.pendingHelpers) {
842
+ branch.events.length = 0;
843
+ branch.state = "discarded";
844
+ }
845
+ current.pendingHelpers.clear();
765
846
  current = null;
766
847
  paused = 0;
767
848
  return evs;
@@ -862,6 +943,13 @@ export function recordFake(
862
943
  return active() ? current!.push({ kind: "fake", ...ev }, reservation) : undefined;
863
944
  }
864
945
 
946
+ export function recordServiceHelper(
947
+ ev: Omit<ServiceHelperEvent, "seq" | "tOffsetMs" | "kind">,
948
+ reservation?: EventReservation,
949
+ ): number | undefined {
950
+ return active() ? current!.push({ kind: "service", ...ev }, reservation) : undefined;
951
+ }
952
+
865
953
  export function recordEnv(
866
954
  ev: Omit<EnvEvent, "seq" | "tOffsetMs" | "kind">,
867
955
  reservation?: EventReservation,
@@ -992,11 +1080,7 @@ export function recordWait(
992
1080
  ev: Omit<WaitEvent, "seq" | "tOffsetMs" | "kind">,
993
1081
  reservation?: EventReservation,
994
1082
  ): number | undefined {
995
- // Use `current` directly (not `active()`) so a `recordWait` at the
996
- // end of a `ctx.poll` block lands even though the surrounding code
997
- // just resumed from `paused`. Pushing this event is the whole point
998
- // of the poll primitive.
999
- return current?.push({ kind: "wait", ...ev }, reservation);
1083
+ return active() ? current!.push({ kind: "wait", ...ev }, reservation) : undefined;
1000
1084
  }
1001
1085
 
1002
1086
  /** Number of events the active recorder has accumulated. Returns 0