@specific.dev/spectest 0.116.1 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -78,7 +78,7 @@ import { openTerminal } from "./terminal.js";
78
78
  // client is passed to descendants as a test's return value.
79
79
  import { openMcp } from "./mcp.js";
80
80
  import { trackHelpers } from "./helper-tracking.js";
81
- import { isRecording, recordEnv, recordExec, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
81
+ import { isRecording, recordEnv, recordExec, recordStep, namedStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
82
82
  import { readRaw, wrap } from "./inspect.js";
83
83
  import { setRecordSecrets } from "./record-secrets.js";
84
84
  import { setReplayEnvironmentMode } from "./replay-mode.js";
@@ -4446,6 +4446,7 @@ async function runOne(testCase) {
4446
4446
  const phones = phoneScope(start);
4447
4447
  const ctx = {
4448
4448
  ...base,
4449
+ step: namedStep,
4449
4450
  // exec/terminal/poll wrap their results at runtime (the recorder is
4450
4451
  // active), so the ctx interface types them wrapped. The impls' own
4451
4452
  // return types stay raw — hence the bridging casts.
@@ -4942,6 +4943,7 @@ async function evalCode(code) {
4942
4943
  const phones = phoneScope(start, { record: false });
4943
4944
  const ctx = {
4944
4945
  ...base,
4946
+ step: namedStep,
4945
4947
  // Bun does not propagate the import() caller's AsyncLocalStorage into
4946
4948
  // the snippet's module evaluation. Enter the eval scope at the ctx.fetch
4947
4949
  // call itself so top-level awaits and imported helpers get wrapped values
@@ -127,18 +127,23 @@ export async function collectStickyCache(config, retention, run = exec, usage =
127
127
  }, kubernetes) {
128
128
  validateStickyConfig(config);
129
129
  const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
130
- await run("buildctl", [...buildctl, "--keep-duration", "168h"], 120_000);
131
- // Docker's until filter and Created field refer to the publisher's build
132
- // date, not our use. Never prune a just-used image merely because it is old.
130
+ // Docker 29's containerd until filter uses local image-record creation;
131
+ // inspect's Created uses the publisher's build date. Neither tracks use.
132
+ // Never prune a just-used image merely because either timestamp is old.
133
133
  const protectedImage = (id) => retention.protectedImages.has(id) || kubernetes?.protectedImages.has(id);
134
134
  const images = [...retention.images].sort((a, b) => a.lastUsed - b.lastUsed || a.id.localeCompare(b.id));
135
135
  const expired = new Set();
136
136
  for (const image of images) {
137
- if (protectedImage(image.id) || image.lastUsed >= retention.observedAt - 7 * 24 * 60 * 60 * 1000)
137
+ if (protectedImage(image.id) || image.lastUsed >= retention.observedAt - 6 * 60 * 60 * 1000)
138
138
  continue;
139
139
  await run("docker", ["image", "rm", "-f", image.id], 120_000);
140
140
  expired.add(image.id);
141
141
  }
142
+ // Drop expired image references first so they no longer pin obsolete build
143
+ // layers. Cache mounts need a longer window: a cached RUN skips mounting
144
+ // its package cache, even though a later dependency change will need it.
145
+ await run("buildctl", [...buildctl, "--keep-duration", "6h", "--filter", "type!=exec.cachemount"], 120_000);
146
+ await run("buildctl", [...buildctl, "--keep-duration", "168h", "--filter", "type==exec.cachemount"], 120_000);
142
147
  const withinTarget = (fs) => fs.freeBytes >= config.headroomBytes;
143
148
  let fs = await usage();
144
149
  if (withinTarget(fs))
package/dist/index.d.ts CHANGED
@@ -1201,6 +1201,11 @@ export type TestFn<T = void, P = undefined, S extends ServicesMap = ServicesMap,
1201
1201
  * as the factory's output (e.g. a Bun SQL pool for `postgres(...)`).
1202
1202
  */
1203
1203
  export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> extends Omit<SpectestContext<S, F>, "exec"> {
1204
+ /** Name one operation or group several operations in the test timeline.
1205
+ * Steps may nest and run concurrently. Returns the callback's value without
1206
+ * changing its type or assertion provenance. Errors propagate unchanged;
1207
+ * a step adds no retries, timeout, or snapshot boundary. */
1208
+ step<T>(name: string, callback: () => T | Promise<T>): Promise<T>;
1204
1209
  /** Run a command inside a service container — a string via `sh -lc`,
1205
1210
  * or an array as exact argv. The result is {@link Wrapped}, so
1206
1211
  * `res.stdout` is a `Carrier<string>` — assert via `expect(res.stdout)`
@@ -574,7 +574,7 @@ export interface EventReservation {
574
574
  * implementation's events and one annotated helper call. Seqs still come from
575
575
  * the test recorder, so concurrent calls and linked assertions stay unique.
576
576
  * Poll truncation/grouping inside the helper applies only to its own buffer. */
577
- export declare function captureRecording(): {
577
+ export declare function captureRecording(groupSeq?: number): {
578
578
  run<T>(fn: () => T): T;
579
579
  finish(keep: boolean): void;
580
580
  isCurrent(): boolean;
@@ -722,6 +722,8 @@ export interface StepEvent extends BaseEvent {
722
722
  kind: string;
723
723
  /** The step's one-line summary in the timeline. */
724
724
  title: string;
725
+ /** An authored ctx.step scope, displayed as a collapsible group. */
726
+ group?: boolean;
725
727
  /** Native action semantics; titles are presentation only. */
726
728
  android?: AndroidStepDetails;
727
729
  blocks?: StepBlock[];
@@ -730,6 +732,9 @@ export interface StepEvent extends BaseEvent {
730
732
  error?: string;
731
733
  }
732
734
  export declare function recordStep(ev: Omit<StepEvent, "seq" | "tOffsetMs">, reservation?: EventReservation): number | undefined;
735
+ /** A named recording scope. Values and thrown errors pass through unchanged.
736
+ * Buffering isolates concurrent scopes and preserves poll/helper grouping. */
737
+ export declare function namedStep<T>(name: string, callback: () => T | Promise<T>): Promise<T>;
733
738
  export declare function recordEmail(ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
734
739
  /**
735
740
  * Recorded once per `ctx.poll(...)` call. Stands in for the suppressed
package/dist/recorder.js CHANGED
@@ -15,6 +15,7 @@ class Recorder {
15
15
  start;
16
16
  rootEvents = [];
17
17
  pendingHelpers = new Set();
18
+ pendingSteps = new Map();
18
19
  get events() {
19
20
  const branch = recordingBranches.getStore();
20
21
  return branch?.owner === this ? branchEvents(branch) : this.rootEvents;
@@ -36,6 +37,7 @@ class Recorder {
36
37
  clearPendingNullish();
37
38
  const seq = reservation?.seq ?? this.seq++;
38
39
  this.events.push({
40
+ parentSeq: recordingBranches.getStore()?.groupSeq,
39
41
  ...ev,
40
42
  seq,
41
43
  tOffsetMs: reservation?.tOffsetMs ?? Date.now() - this.start,
@@ -75,7 +77,7 @@ class Recorder {
75
77
  const ev = this.events[i];
76
78
  if (ev.seq === parentSeq)
77
79
  continue;
78
- if (ev.parentSeq !== undefined)
80
+ if (ev.parentSeq !== undefined && ev.parentSeq !== recordingBranches.getStore()?.groupSeq)
79
81
  continue;
80
82
  ev.parentSeq = parentSeq;
81
83
  }
@@ -126,11 +128,12 @@ function branchAllowsRecording(branch) {
126
128
  * implementation's events and one annotated helper call. Seqs still come from
127
129
  * the test recorder, so concurrent calls and linked assertions stay unique.
128
130
  * Poll truncation/grouping inside the helper applies only to its own buffer. */
129
- export function captureRecording() {
131
+ export function captureRecording(groupSeq) {
130
132
  if (!active())
131
133
  return { run: (fn) => fn(), finish: () => { }, isCurrent: () => false };
132
134
  const branch = {
133
135
  owner: current, events: [], parent: recordingBranches.getStore(),
136
+ groupSeq: groupSeq ?? recordingBranches.getStore()?.groupSeq,
134
137
  destination: current.drain(), state: "open",
135
138
  };
136
139
  current.pendingHelpers.add(branch);
@@ -179,6 +182,10 @@ export function stopRecording() {
179
182
  if (!current)
180
183
  return [];
181
184
  const evs = current.drain();
185
+ for (const [event, started] of current.pendingSteps) {
186
+ event.durationMs = Date.now() - started;
187
+ }
188
+ current.pendingSteps.clear();
182
189
  // A timeout can stop the test before a helper returns. Keep its available
183
190
  // diagnostics, then fence detached work out of any subsequent recording.
184
191
  for (const branch of current.pendingHelpers) {
@@ -259,6 +266,45 @@ export function recordEnv(ev, reservation) {
259
266
  export function recordStep(ev, reservation) {
260
267
  return active() ? current.push(ev, reservation) : undefined;
261
268
  }
269
+ /** A named recording scope. Values and thrown errors pass through unchanged.
270
+ * Buffering isolates concurrent scopes and preserves poll/helper grouping. */
271
+ export async function namedStep(name, callback) {
272
+ if (typeof name !== "string" || !name.trim()) {
273
+ throw new TypeError("ctx.step requires a non-empty name");
274
+ }
275
+ if (!active())
276
+ return await callback();
277
+ const owner = current;
278
+ const started = Date.now();
279
+ const seq = recordStep({
280
+ kind: "step", title: name, group: true, status: "error",
281
+ error: "Step did not finish before the test ended.", durationMs: 0,
282
+ });
283
+ const event = owner.drain().find((event) => event.seq === seq);
284
+ owner.pendingSteps.set(event, started);
285
+ const recording = captureRecording(seq);
286
+ try {
287
+ const value = await recording.run(callback);
288
+ if (recording.isCurrent()) {
289
+ event.status = "passed";
290
+ delete event.error;
291
+ }
292
+ return value;
293
+ }
294
+ catch (error) {
295
+ if (recording.isCurrent()) {
296
+ event.status = "error";
297
+ event.error = error instanceof Error ? error.message : String(error);
298
+ }
299
+ throw error;
300
+ }
301
+ finally {
302
+ if (recording.isCurrent())
303
+ event.durationMs = Date.now() - started;
304
+ owner.pendingSteps.delete(event);
305
+ recording.finish(true);
306
+ }
307
+ }
262
308
  export function recordEmail(ev, reservation) {
263
309
  return active() ? current.push({ kind: "email", ...ev }, reservation) : undefined;
264
310
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.116.1",
3
+ "version": "0.117.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -206,6 +206,7 @@ import {
206
206
  recordEnv,
207
207
  recordExec,
208
208
  recordStep,
209
+ namedStep,
209
210
  recordTerminal,
210
211
  recordWait,
211
212
  reserveEvent,
@@ -5367,6 +5368,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5367
5368
  const phones = phoneScope(start);
5368
5369
  const ctx: TestContext<unknown> = {
5369
5370
  ...base,
5371
+ step: namedStep,
5370
5372
  // exec/terminal/poll wrap their results at runtime (the recorder is
5371
5373
  // active), so the ctx interface types them wrapped. The impls' own
5372
5374
  // return types stay raw — hence the bridging casts.
@@ -5929,6 +5931,7 @@ async function evalCode(code: string): Promise<EvalResult> {
5929
5931
  const phones = phoneScope(start, { record: false });
5930
5932
  const ctx: TestContext<undefined> = {
5931
5933
  ...base,
5934
+ step: namedStep,
5932
5935
  // Bun does not propagate the import() caller's AsyncLocalStorage into
5933
5936
  // the snippet's module evaluation. Enter the eval scope at the ctx.fetch
5934
5937
  // call itself so top-level awaits and imported helpers get wrapped values
@@ -88,18 +88,32 @@ test("healthy cache keeps recently used images and a cache that cannot shrink is
88
88
  .rejects.toThrow("cannot reclaim required write headroom");
89
89
  });
90
90
 
91
- test("seven-day expiry uses our last observation and respects image protection", async () => {
91
+ test("six-hour image expiry uses last observation and respects the boundary and image protection", async () => {
92
92
  const removed: string[] = [];
93
93
  const run = async (command: string, args: string[]) => {
94
94
  if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
95
95
  return "";
96
96
  };
97
97
  await collectStickyCache(config, retention([
98
- { id: "unused-for-eight-days", lastUsed: now - 8 * 86400_000 },
98
+ { id: "unused-for-seven-hours", lastUsed: now - 7 * 3600_000 },
99
+ { id: "exactly-six-hours", lastUsed: now - 6 * 3600_000 },
100
+ { id: "used-five-hours-ago", lastUsed: now - 5 * 3600_000 },
99
101
  { id: "used-today", lastUsed: now },
100
102
  { id: "protected", lastUsed: 0 },
101
103
  ], ["protected"]), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 220 * GiB }));
102
- expect(removed).toEqual(["unused-for-eight-days"]);
104
+ expect(removed).toEqual(["unused-for-seven-hours"]);
105
+ });
106
+
107
+ test("publication removes expired images before pruning build records and gives cache mounts seven days", async () => {
108
+ const calls: string[][] = [];
109
+ const run = async (command: string, args: string[]) => { calls.push([command, ...args]); return ""; };
110
+ await collectStickyCache(config, retention([{ id: "superseded", lastUsed: now - 7 * 3600_000 }]),
111
+ run, async () => ({ totalBytes: 251 * GiB, freeBytes: 170 * GiB }));
112
+ expect(calls).toEqual([
113
+ ["docker", "image", "rm", "-f", "superseded"],
114
+ ["buildctl", "--addr", "tcp://127.0.0.1:1234", "prune", "--keep-duration", "6h", "--filter", "type!=exec.cachemount"],
115
+ ["buildctl", "--addr", "tcp://127.0.0.1:1234", "prune", "--keep-duration", "168h", "--filter", "type==exec.cachemount"],
116
+ ]);
103
117
  });
104
118
 
105
119
  test("pressure cannot evict the images used by the environment being published", async () => {
@@ -137,17 +137,22 @@ export async function collectStickyCache(
137
137
  ): Promise<void> {
138
138
  validateStickyConfig(config);
139
139
  const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
140
- await run("buildctl", [...buildctl, "--keep-duration", "168h"], 120_000);
141
- // Docker's until filter and Created field refer to the publisher's build
142
- // date, not our use. Never prune a just-used image merely because it is old.
140
+ // Docker 29's containerd until filter uses local image-record creation;
141
+ // inspect's Created uses the publisher's build date. Neither tracks use.
142
+ // Never prune a just-used image merely because either timestamp is old.
143
143
  const protectedImage = (id: string) => retention.protectedImages.has(id) || kubernetes?.protectedImages.has(id);
144
144
  const images = [...retention.images].sort((a, b) => a.lastUsed - b.lastUsed || a.id.localeCompare(b.id));
145
145
  const expired = new Set<string>();
146
146
  for (const image of images) {
147
- if (protectedImage(image.id) || image.lastUsed >= retention.observedAt - 7 * 24 * 60 * 60 * 1000) continue;
147
+ if (protectedImage(image.id) || image.lastUsed >= retention.observedAt - 6 * 60 * 60 * 1000) continue;
148
148
  await run("docker", ["image", "rm", "-f", image.id], 120_000);
149
149
  expired.add(image.id);
150
150
  }
151
+ // Drop expired image references first so they no longer pin obsolete build
152
+ // layers. Cache mounts need a longer window: a cached RUN skips mounting
153
+ // its package cache, even though a later dependency change will need it.
154
+ await run("buildctl", [...buildctl, "--keep-duration", "6h", "--filter", "type!=exec.cachemount"], 120_000);
155
+ await run("buildctl", [...buildctl, "--keep-duration", "168h", "--filter", "type==exec.cachemount"], 120_000);
151
156
  const withinTarget = (fs: { freeBytes: number }) => fs.freeBytes >= config.headroomBytes;
152
157
  let fs = await usage();
153
158
  if (withinTarget(fs)) return;
package/src/index.ts CHANGED
@@ -1866,6 +1866,11 @@ export interface TestContext<
1866
1866
  S extends ServicesMap = ServicesMap,
1867
1867
  F extends FakesMap = FakesMap,
1868
1868
  > extends Omit<SpectestContext<S, F>, "exec"> {
1869
+ /** Name one operation or group several operations in the test timeline.
1870
+ * Steps may nest and run concurrently. Returns the callback's value without
1871
+ * changing its type or assertion provenance. Errors propagate unchanged;
1872
+ * a step adds no retries, timeout, or snapshot boundary. */
1873
+ step<T>(name: string, callback: () => T | Promise<T>): Promise<T>;
1869
1874
  /** Run a command inside a service container — a string via `sh -lc`,
1870
1875
  * or an array as exact argv. The result is {@link Wrapped}, so
1871
1876
  * `res.stdout` is a `Carrier<string>` — assert via `expect(res.stdout)`
@@ -0,0 +1,111 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { annotate } from "./annotate.js";
3
+ import { trackHelpers } from "./helper-tracking.js";
4
+ import { expect as assertValue } from "./index.js";
5
+ import { adoptNullishTag, readTag, wrap } from "./inspect.js";
6
+ import {
7
+ captureRecording, namedStep, recordStep, recordWait, recorderEventCount,
8
+ recorderMarkChildren, startRecording, stopRecording,
9
+ } from "./recorder.js";
10
+
11
+ afterEach(() => stopRecording());
12
+ const operation = (title: string) => recordStep({ kind: "example", title })!;
13
+
14
+ test("returns values unchanged and nests scopes without flattening operations", async () => {
15
+ startRecording();
16
+ const value = await namedStep("outer", () => namedStep("inner", () => {
17
+ return wrap({ total: 42 }, operation("read"));
18
+ }));
19
+ const events = stopRecording();
20
+ expect(events.map(e => [e.seq, e.parentSeq])).toEqual([[0, undefined], [1, 0], [2, 1]]);
21
+ expect(readTag(value.total)?.sourceSeq).toBe(2);
22
+ expect(value.total.unwrap()).toBe(42);
23
+ });
24
+
25
+ test("keeps nullable provenance when the callback returns", async () => {
26
+ startRecording();
27
+ const value = await namedStep("read nullable", () => wrap({ total: null }, operation("read")).total);
28
+ expect(value).toBeNull();
29
+ expect(readTag(adoptNullishTag(value))).toEqual({ sourceSeq: 1, path: ["total"] });
30
+ });
31
+
32
+ test("concurrent scopes never capture each other's operations", async () => {
33
+ startRecording();
34
+ let release!: () => void;
35
+ const gate = new Promise<void>(resolve => { release = resolve; });
36
+ await Promise.all([
37
+ namedStep("first", async () => { await gate; operation("first child"); }),
38
+ namedStep("second", () => { operation("second child"); release(); }),
39
+ ]);
40
+ const events = stopRecording();
41
+ const parent = (title: string) => events.find(e => "title" in e && e.title === title)!;
42
+ expect(parent("first child").parentSeq).toBe(parent("first").seq);
43
+ expect(parent("second child").parentSeq).toBe(parent("second").seq);
44
+ });
45
+
46
+ test("poll grouping and captured helper annotations keep their own hierarchy", async () => {
47
+ startRecording();
48
+ await namedStep("scope", () => {
49
+ const from = recorderEventCount();
50
+ const helper = captureRecording();
51
+ helper.run(() => operation("helper"));
52
+ helper.finish(true);
53
+ const wait = recordWait({ description: "ready", attempts: 1, durationMs: 1, passed: true })!;
54
+ recorderMarkChildren(from, wait);
55
+ });
56
+ const events = stopRecording();
57
+ expect(events.find(e => e.seq === 1)?.parentSeq).toBe(2);
58
+ expect(events.find(e => e.seq === 2)?.parentSeq).toBe(0);
59
+ });
60
+
61
+ test("retains diagnostics and propagates the identical thrown error", async () => {
62
+ startRecording();
63
+ const error = new Error("checkout failed");
64
+ try {
65
+ await namedStep("checkout", () => { operation("before failure"); throw error; });
66
+ throw new Error("expected callback failure");
67
+ } catch (caught) { expect(caught).toBe(error); }
68
+ const events = stopRecording();
69
+ expect(events[0]).toMatchObject({ group: true, status: "error", error: "checkout failed" });
70
+ expect(events[1]?.parentSeq).toBe(0);
71
+ });
72
+
73
+ test("a timeout retains unfinished scopes and fences late work from the next test", async () => {
74
+ startRecording();
75
+ let release!: () => void;
76
+ const gate = new Promise<void>(resolve => { release = resolve; });
77
+ const pending = namedStep("unfinished", async () => {
78
+ operation("before timeout");
79
+ await gate;
80
+ operation("late");
81
+ });
82
+ const events = stopRecording();
83
+ expect(events).toHaveLength(2);
84
+ expect(events[0]).toMatchObject({ group: true, status: "error" });
85
+ expect(events[1]?.parentSeq).toBe(0);
86
+ startRecording();
87
+ release();
88
+ await pending;
89
+ expect(stopRecording()).toEqual([]);
90
+ });
91
+
92
+ test("works without recording and rejects blank names", async () => {
93
+ const value = { key: "web" as const, total: 42 };
94
+ expect(await namedStep("plain", () => value)).toBe(value);
95
+ await expect(namedStep(" ", () => value)).rejects.toThrow("non-empty name");
96
+ });
97
+
98
+
99
+ test("annotated helper children and external assertions retain provenance inside a named scope", async () => {
100
+ startRecording();
101
+ const helpers = trackHelpers("fake", "mail", {
102
+ latest() { return annotate({ subject: "Invoice" }, "email"); },
103
+ });
104
+ const mail = await namedStep("Read email", () => helpers.latest());
105
+ assertValue(mail.subject).toBe("Invoice");
106
+ const events = stopRecording();
107
+ const helper = events.find(event => event.kind === "fake")!;
108
+ expect(helper.parentSeq).toBe(0);
109
+ expect(events.find(event => event.kind === "email")?.parentSeq).toBe(helper.seq);
110
+ expect(events.find(event => event.kind === "assertion")).toMatchObject({ sourceSeq: helper.seq });
111
+ });
package/src/recorder.ts CHANGED
@@ -643,6 +643,7 @@ export interface EventReservation {
643
643
  class Recorder {
644
644
  private rootEvents: TestEvent[] = [];
645
645
  readonly pendingHelpers = new Set<RecordingBranch>();
646
+ readonly pendingSteps = new Map<StepEvent, number>();
646
647
  private get events(): TestEvent[] {
647
648
  const branch = recordingBranches.getStore();
648
649
  return branch?.owner === this ? branchEvents(branch) : this.rootEvents;
@@ -666,6 +667,7 @@ class Recorder {
666
667
  if (ev.kind !== "assertion") clearPendingNullish();
667
668
  const seq = reservation?.seq ?? this.seq++;
668
669
  this.events.push({
670
+ parentSeq: recordingBranches.getStore()?.groupSeq,
669
671
  ...ev,
670
672
  seq,
671
673
  tOffsetMs: reservation?.tOffsetMs ?? Date.now() - this.start,
@@ -708,7 +710,7 @@ class Recorder {
708
710
  for (let i = startIdx; i < this.events.length; i++) {
709
711
  const ev = this.events[i]!;
710
712
  if (ev.seq === parentSeq) continue;
711
- if (ev.parentSeq !== undefined) continue;
713
+ if (ev.parentSeq !== undefined && ev.parentSeq !== recordingBranches.getStore()?.groupSeq) continue;
712
714
  ev.parentSeq = parentSeq;
713
715
  }
714
716
  }
@@ -749,6 +751,8 @@ class Recorder {
749
751
  let current: Recorder | null = null;
750
752
 
751
753
  interface RecordingBranch {
754
+ /** Nearest authored scope; internal poll/helper grouping may refine it. */
755
+ groupSeq?: number;
752
756
  owner: Recorder;
753
757
  events: TestEvent[];
754
758
  parent: RecordingBranch | undefined;
@@ -773,7 +777,7 @@ function branchAllowsRecording(branch: RecordingBranch | undefined): boolean {
773
777
  * implementation's events and one annotated helper call. Seqs still come from
774
778
  * the test recorder, so concurrent calls and linked assertions stay unique.
775
779
  * Poll truncation/grouping inside the helper applies only to its own buffer. */
776
- export function captureRecording(): {
780
+ export function captureRecording(groupSeq?: number): {
777
781
  run<T>(fn: () => T): T;
778
782
  finish(keep: boolean): void;
779
783
  isCurrent(): boolean;
@@ -781,6 +785,7 @@ export function captureRecording(): {
781
785
  if (!active()) return { run: (fn) => fn(), finish: () => {}, isCurrent: () => false };
782
786
  const branch: RecordingBranch = {
783
787
  owner: current!, events: [], parent: recordingBranches.getStore(),
788
+ groupSeq: groupSeq ?? recordingBranches.getStore()?.groupSeq,
784
789
  destination: current!.drain(), state: "open",
785
790
  };
786
791
  current!.pendingHelpers.add(branch);
@@ -831,6 +836,10 @@ export function startRecording(startedAt = Date.now()): void {
831
836
  export function stopRecording(): TestEvent[] {
832
837
  if (!current) return [];
833
838
  const evs = current.drain();
839
+ for (const [event, started] of current.pendingSteps) {
840
+ event.durationMs = Date.now() - started;
841
+ }
842
+ current.pendingSteps.clear();
834
843
  // A timeout can stop the test before a helper returns. Keep its available
835
844
  // diagnostics, then fence detached work out of any subsequent recording.
836
845
  for (const branch of current.pendingHelpers) {
@@ -1038,6 +1047,8 @@ export interface StepEvent extends BaseEvent {
1038
1047
  kind: string;
1039
1048
  /** The step's one-line summary in the timeline. */
1040
1049
  title: string;
1050
+ /** An authored ctx.step scope, displayed as a collapsible group. */
1051
+ group?: boolean;
1041
1052
  /** Native action semantics; titles are presentation only. */
1042
1053
  android?: AndroidStepDetails;
1043
1054
  blocks?: StepBlock[];
@@ -1053,6 +1064,42 @@ export function recordStep(
1053
1064
  return active() ? current!.push(ev, reservation) : undefined;
1054
1065
  }
1055
1066
 
1067
+ /** A named recording scope. Values and thrown errors pass through unchanged.
1068
+ * Buffering isolates concurrent scopes and preserves poll/helper grouping. */
1069
+ export async function namedStep<T>(name: string, callback: () => T | Promise<T>): Promise<T> {
1070
+ if (typeof name !== "string" || !name.trim()) {
1071
+ throw new TypeError("ctx.step requires a non-empty name");
1072
+ }
1073
+ if (!active()) return await callback();
1074
+ const owner = current!;
1075
+ const started = Date.now();
1076
+ const seq = recordStep({
1077
+ kind: "step", title: name, group: true, status: "error",
1078
+ error: "Step did not finish before the test ended.", durationMs: 0,
1079
+ })!;
1080
+ const event = owner.drain().find((event) => event.seq === seq)! as StepEvent;
1081
+ owner.pendingSteps.set(event, started);
1082
+ const recording = captureRecording(seq);
1083
+ try {
1084
+ const value = await recording.run(callback);
1085
+ if (recording.isCurrent()) {
1086
+ event.status = "passed";
1087
+ delete event.error;
1088
+ }
1089
+ return value;
1090
+ } catch (error) {
1091
+ if (recording.isCurrent()) {
1092
+ event.status = "error";
1093
+ event.error = error instanceof Error ? error.message : String(error);
1094
+ }
1095
+ throw error;
1096
+ } finally {
1097
+ if (recording.isCurrent()) event.durationMs = Date.now() - started;
1098
+ owner.pendingSteps.delete(event);
1099
+ recording.finish(true);
1100
+ }
1101
+ }
1102
+
1056
1103
  export function recordEmail(
1057
1104
  ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">,
1058
1105
  reservation?: EventReservation,