@specific.dev/spectest 0.116.2 → 0.118.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 +3 -1
- package/dist/index.d.ts +5 -0
- package/dist/recorder.d.ts +6 -1
- package/dist/recorder.js +48 -2
- package/package.json +1 -1
- package/src/daemon.ts +3 -0
- package/src/index.ts +5 -0
- package/src/named-step.test.ts +111 -0
- package/src/recorder.ts +49 -2
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
|
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)`
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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
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
|
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,
|