@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.
package/src/daemon.ts CHANGED
@@ -196,13 +196,11 @@ import { openTerminal } from "./terminal.js";
196
196
  // `ctx.mcp(url)`. One client per call, no registry: an authenticated
197
197
  // client is passed to descendants as a test's return value.
198
198
  import { openMcp } from "./mcp.js";
199
- import { readAnnotation, type RenderAnnotation } from "./annotate.js";
199
+ import { trackHelpers } from "./helper-tracking.js";
200
200
  import {
201
201
  isRecording,
202
- recordEmail,
203
202
  recordEnv,
204
203
  recordExec,
205
- recordFake,
206
204
  recordStep,
207
205
  recordTerminal,
208
206
  recordWait,
@@ -216,7 +214,7 @@ import {
216
214
  type StepBlock,
217
215
  type TestEvent,
218
216
  } from "./recorder.js";
219
- import { deepUnwrap, readRaw, wrap } from "./inspect.js";
217
+ import { readRaw, wrap } from "./inspect.js";
220
218
  import type { Wrapped } from "./inspect.js";
221
219
  import { setRecordSecrets } from "./record-secrets.js";
222
220
  import { generateId } from "./ids.js";
@@ -3141,7 +3139,7 @@ const FAKE_CTX: FakeContext = {
3141
3139
  /** Build (or fetch from cache) the helpers record for a fake — the
3142
3140
  * value that ends up at `ctx.fakes.<name>`. Defaults to `{}` (a fake
3143
3141
  * with no `helpers` exposes nothing — tests never touch private state
3144
- * directly). Returns a tracking proxy (see `trackFakeHelpers`) so helper
3142
+ * directly). Returns a tracking proxy (see `trackHelpers`) so helper
3145
3143
  * calls land in the test timeline. */
3146
3144
  async function ensureFakeHelpers(name: string): Promise<Record<string, unknown>> {
3147
3145
  const fake = FAKES.get(name);
@@ -3154,168 +3152,10 @@ async function ensureFakeHelpers(name: string): Promise<Record<string, unknown>>
3154
3152
  ctx: FAKE_CTX,
3155
3153
  }))) as Record<string, unknown>)
3156
3154
  : {};
3157
- fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
3155
+ fake.trackedHelpers = trackHelpers("fake", name, fake.helpers);
3158
3156
  return fake.trackedHelpers;
3159
3157
  }
3160
3158
 
3161
- /** Wrap a fake's helpers so each call becomes a recorded `fake` event,
3162
- * its arguments are `deepUnwrap`ped, and its return value is `wrap()`ped
3163
- * for assertion provenance. Helpers
3164
- * are functions that read/mutate the fake's private state via closure;
3165
- * tests only ever see what those functions return. The proxy is built
3166
- * once and shared across tests; it consults the recorder at call time,
3167
- * so it's a transparent no-op when nothing is recording (eval / project
3168
- * setup).
3169
- *
3170
- * Only own function properties are intercepted — inherited members
3171
- * (`toString`, etc.), symbols, and any stray non-function property pass
3172
- * straight through untouched. */
3173
- function trackFakeHelpers(
3174
- fakeName: string,
3175
- helpers: Record<string, unknown>,
3176
- ): Record<string, unknown> {
3177
- return new Proxy(helpers, {
3178
- get(target, prop, receiver) {
3179
- if (typeof prop === "symbol") return Reflect.get(target, prop, receiver);
3180
- const desc = Object.getOwnPropertyDescriptor(target, prop);
3181
- if (!desc || typeof desc.value !== "function") {
3182
- return Reflect.get(target, prop, receiver);
3183
- }
3184
- const fn = desc.value as (...a: unknown[]) => unknown;
3185
- const member = String(prop);
3186
- return (...args: unknown[]) =>
3187
- invokeFakeHelper(fakeName, member, fn, target, args);
3188
- },
3189
- });
3190
- }
3191
-
3192
- /** Invoke a fake helper function, recording a `fake` event and wrapping
3193
- * the return value. Handles both sync and async helpers, and records an
3194
- * error event (then rethrows) if the helper throws.
3195
- *
3196
- * Arguments are `deepUnwrap`ped on the way in, so a helper body is plain
3197
- * user code operating on plain values: a test that feeds one helper's
3198
- * return (or a `ctx.fetch` field) to another passes a provenance wrapper,
3199
- * and `msg.html.matchAll(...)` inside the fake would otherwise throw a
3200
- * `TypeError` from a stack the author can't see. Same treatment the k8s
3201
- * client's arguments get (`components/k3s.ts::withTagging`). The types
3202
- * still say the value is a handle — passing a wrapped value where the
3203
- * parameter type is known is a compile error asking for `.unwrap()`; this
3204
- * catches the case where it isn't known (an `any` off `req.json()`). */
3205
- function invokeFakeHelper(
3206
- fakeName: string,
3207
- member: string,
3208
- fn: (...a: unknown[]) => unknown,
3209
- thisArg: unknown,
3210
- callArgs: unknown[],
3211
- ): unknown {
3212
- const t = Date.now();
3213
- const resv = reserveEvent();
3214
- const args = callArgs.map((a) => deepUnwrap(a));
3215
- const safeArgs = args.map((a) => safeSerialize(a));
3216
- const recordResult = (value: unknown): unknown => {
3217
- // A helper may box its return in a render annotation
3218
- // (`annotate(v, "email", …)`). The box never reaches the test, and it
3219
- // never replaces the step either: the call is recorded as the ordinary
3220
- // `fake` event it is, and the annotation rides *under* it as a child
3221
- // event (`parentSeq`), which the dashboard folds into the fake step's
3222
- // detail panel. So the timeline reads the same as any other helper
3223
- // call, with the richer view one click in.
3224
- const annotated = readAnnotation(value);
3225
- const raw = annotated ? annotated.value : value;
3226
- const durationMs = Date.now() - t;
3227
- const seq = recordFake({
3228
- fake: fakeName,
3229
- member,
3230
- args: safeArgs,
3231
- result: safeSerialize(raw),
3232
- durationMs,
3233
- }, resv);
3234
- if (annotated && seq !== undefined) {
3235
- recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
3236
- }
3237
- return wrap(raw, seq);
3238
- };
3239
- const recordError = (err: unknown): void => {
3240
- recordFake({
3241
- fake: fakeName,
3242
- member,
3243
- args: safeArgs,
3244
- durationMs: Date.now() - t,
3245
- error: errMessage(err),
3246
- }, resv);
3247
- };
3248
-
3249
- // Record the public helper call in the caller's scope, while its body
3250
- // and async descendants use native fetch and emit no internal events.
3251
- let result: unknown;
3252
- try {
3253
- result = runUninstrumented(() => fn.apply(thisArg, args));
3254
- } catch (err) {
3255
- recordError(err);
3256
- throw err;
3257
- }
3258
- if (result instanceof Promise) {
3259
- return result.then(
3260
- recordResult,
3261
- (err) => {
3262
- recordError(err);
3263
- throw err;
3264
- },
3265
- );
3266
- }
3267
- return recordResult(result);
3268
- }
3269
-
3270
- /** Record a fake-helper call's render annotation as a child of the call's
3271
- * own `fake` event — the same `parentSeq` grouping `ctx.poll` uses for the
3272
- * iteration it kept, so the annotated view folds into the fake step's
3273
- * detail panel instead of taking a timeline row of its own.
3274
- *
3275
- * The child is the event kind that already knows how to draw this: an
3276
- * `email` annotation records the very event the built-in `email()`
3277
- * component's mailbox helpers record, so it renders with no new code. It
3278
- * carries the fake's name and member as its service/op, and the parent's
3279
- * duration, since it describes that same call.
3280
- *
3281
- * Only the success path is annotated: a helper that threw returned no value
3282
- * to annotate, so it's a plain `fake` error event. */
3283
- function recordAnnotationChild(
3284
- fakeName: string,
3285
- member: string,
3286
- annotation: RenderAnnotation,
3287
- durationMs: number,
3288
- parentSeq: number,
3289
- ): void {
3290
- switch (annotation.kind) {
3291
- case "email":
3292
- recordEmail({
3293
- parentSeq,
3294
- annotation: true,
3295
- service: fakeName,
3296
- op: member,
3297
- message: annotation.message,
3298
- messages: annotation.messages,
3299
- count: annotation.count,
3300
- durationMs,
3301
- });
3302
- break;
3303
- case "chat":
3304
- // A chat has no event kind of its own: it describes itself in
3305
- // presentation terms (title + blocks), which is the seam a new step
3306
- // type is supposed to use. `kind` stays an opaque grouping label.
3307
- recordStep({
3308
- kind: "chat",
3309
- parentSeq,
3310
- annotation: true,
3311
- title: annotation.title ?? `${fakeName}.${member}`,
3312
- blocks: annotation.blocks,
3313
- durationMs,
3314
- });
3315
- break;
3316
- }
3317
- }
3318
-
3319
3159
  function errMessage(err: unknown): string {
3320
3160
  return (err as Error)?.message ?? String(err);
3321
3161
  }
@@ -3633,30 +3473,35 @@ interface ProjectSetupResult {
3633
3473
  durationMs: number;
3634
3474
  }
3635
3475
 
3476
+ // Captured as false in the shared template, true in a seeded interactive
3477
+ // snapshot. Repeated requests must not apply a successful seed twice.
3478
+ let interactiveSetupCompleted = false;
3479
+
3636
3480
  /**
3637
- * Run the loaded project's `setup` hook, if any. Called by the control
3638
- * plane once between /bootstrap and the warm-template snapshot, so the
3639
- * effects (seeded DB rows, initial pods, fixture files) are captured
3640
- * exactly once and inherited by every later snapshot/fork.
3481
+ * Run a project setup hook. Normal `setup` runs before the shared warm
3482
+ * snapshot; `setupInteractive` runs only after it, in a fresh user start.
3483
+ * Both use the same context and boot-log capture.
3641
3484
  *
3642
3485
  * Unlike test runs, this is NOT instrumented — no recorder, no event
3643
3486
  * timeline, no timeout from the test runner. Setup failures abort the
3644
3487
  * env bring-up; the control plane surfaces them as a start failure.
3645
3488
  */
3646
- async function runProjectSetup(): Promise<ProjectSetupResult> {
3489
+ async function runProjectSetup(hook: "setup" | "setupInteractive" = "setup"): Promise<ProjectSetupResult> {
3647
3490
  // Same tee as bootstrap: a project `setup` hook's console output is
3648
3491
  // part of explaining the boot, not something to lose on success.
3649
3492
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
3650
3493
  try {
3651
- return await runProjectSetupInner();
3494
+ return await runProjectSetupInner(hook);
3652
3495
  } finally {
3653
3496
  restoreBootLog();
3654
3497
  }
3655
3498
  }
3656
3499
 
3657
- async function runProjectSetupInner(): Promise<ProjectSetupResult> {
3658
- const proj = requireLoaded().project;
3659
- if (!proj.setup) return { ran: false, durationMs: 0 };
3500
+ async function runProjectSetupInner(hook: "setup" | "setupInteractive"): Promise<ProjectSetupResult> {
3501
+ const setup = requireLoaded().project[hook];
3502
+ if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
3503
+ return { ran: false, durationMs: 0 };
3504
+ }
3660
3505
  const start = Date.now();
3661
3506
  // Install the fetch wrapper for the duration of setup so `ctx.fetch` (and any
3662
3507
  // client routed through `globalThis.fetch`) returns a wrapped Response, same
@@ -3667,9 +3512,10 @@ async function runProjectSetupInner(): Promise<ProjectSetupResult> {
3667
3512
  // The full context, unscoped: every service is up by project-setup time,
3668
3513
  // so `ctx.svc` is the same map tests see (and shares helper instances with
3669
3514
  // them — a Bun.SQL pool created here is reused later).
3670
- const ctx: ProjectSetupContext = await spectestContext();
3671
3515
  try {
3672
- await runInstrumented(fetchScope, () => proj.setup!(ctx));
3516
+ const ctx: ProjectSetupContext = await spectestContext();
3517
+ await runInstrumented(fetchScope, () => setup(ctx));
3518
+ if (hook === "setupInteractive") interactiveSetupCompleted = true;
3673
3519
  } finally {
3674
3520
  fetchScope.active = false;
3675
3521
  restoreFetch();
@@ -4805,10 +4651,10 @@ async function ensureHelpers(
4805
4651
  try {
4806
4652
  HELPERS_CACHE.set(
4807
4653
  name,
4808
- await def.helpers({
4654
+ trackHelpers("service", name, await def.helpers({
4809
4655
  name,
4810
4656
  ...(await spectestContext({ service: name, includeSelf: false })),
4811
- }),
4657
+ })),
4812
4658
  );
4813
4659
  } finally {
4814
4660
  HELPERS_BUILDING.delete(name);
@@ -4822,8 +4668,8 @@ const HELPERS_BUILDING = new Set<string>();
4822
4668
 
4823
4669
  /**
4824
4670
  * Build the `svc` map for one test/eval. The value at `svc[name]` is
4825
- * exactly the record the service's `helpers` factory returned (e.g.
4826
- * `{ client: SqlClient }` for `postgres(...)`). Services without a
4671
+ * a shallow helper proxy: annotated returns become named service steps;
4672
+ * ordinary returns and live client handles pass through. Services without a
4827
4673
  * `helpers` factory don't appear in the map at all.
4828
4674
  */
4829
4675
  async function buildServiceHandles(cfg: EnvironmentConfig): Promise<ServiceHandles> {
@@ -6308,7 +6154,13 @@ export function harnessMethods(state: RouteState): MethodTable {
6308
6154
  setRecordSecrets(secrets as Record<string, string>);
6309
6155
  };
6310
6156
  return {
6311
- health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
6157
+ health: async () => ({
6158
+ ok: true,
6159
+ capabilities: ["prepared-images-v1"],
6160
+ // Optional discovery: old SDKs omit this field and are never sent the
6161
+ // new method. Check the loaded project, including after warm restore.
6162
+ setupInteractive: typeof loaded?.project.setupInteractive === "function",
6163
+ }),
6312
6164
 
6313
6165
  // Live bootstrap progress, polled by the control plane during
6314
6166
  // `bootstrap` and streamed into the test-run row. `{}` before the
@@ -6445,6 +6297,14 @@ export function harnessMethods(state: RouteState): MethodTable {
6445
6297
  () => runProjectSetup(),
6446
6298
  ),
6447
6299
 
6300
+ interactiveSetup: async () =>
6301
+ exclusive(
6302
+ state,
6303
+ "inFlightProjectSetup",
6304
+ "project-setup already in progress",
6305
+ () => runProjectSetup("setupInteractive"),
6306
+ ),
6307
+
6448
6308
  eval: async (params) => {
6449
6309
  const code = requireString(params, "code");
6450
6310
  return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
@@ -32,6 +32,7 @@ export type Method =
32
32
  | "loadTests"
33
33
  | "fingerprint"
34
34
  | "bootstrap"
35
+ | "interactiveSetup"
35
36
  | "run"
36
37
  | "eval"
37
38
  | "setRecordSecrets"
@@ -51,6 +52,7 @@ export const KNOWN_METHODS: ReadonlySet<string> = new Set<Method>([
51
52
  "loadTests",
52
53
  "fingerprint",
53
54
  "bootstrap",
55
+ "interactiveSetup",
54
56
  "run",
55
57
  "eval",
56
58
  "setRecordSecrets",
@@ -0,0 +1,289 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { annotate } from "./annotate.js";
3
+ import { expect as assertValue, type ServiceHandlesFor, type Wrapped } from "./index.js";
4
+ import { readRaw, readTag, wrap } from "./inspect.js";
5
+ import { trackHelpers } from "./helper-tracking.js";
6
+ import {
7
+ recordHttp, recordWait, recorderEventCount, recorderMarkChildren, recorderTruncate,
8
+ startRecording, stopRecording,
9
+ } from "./recorder.js";
10
+ import { createInstrumentationScope, runInstrumented } from "./harness/instrumentation-scope.js";
11
+ import { installFetchWrapper } from "./harness/fetch.js";
12
+
13
+ function observe(subject = "Invoice") {
14
+ const raw = { subject, to: "owner@test", text: "Please pay" };
15
+ const seq = recordHttp({ method: "GET", url: "https://mail.test/latest", status: 200,
16
+ responseBody: JSON.stringify(raw), durationMs: 2 });
17
+ return wrap(raw, seq) as unknown as Wrapped<typeof raw>;
18
+ }
19
+ function deferred() {
20
+ let resolve!: () => void;
21
+ const promise = new Promise<void>((r) => { resolve = r; });
22
+ return { promise, resolve };
23
+ }
24
+ afterEach(() => stopRecording());
25
+
26
+ for (const kind of ["fake", "service"] as const) {
27
+ test(`${kind} consumes the same annotation box and links assertions to one helper step`, async () => {
28
+ startRecording();
29
+ const helper = trackHelpers(kind, "mail", {
30
+ async latest() {
31
+ const value = observe();
32
+ await Promise.resolve();
33
+ recordWait({ description: "internal", attempts: 1, durationMs: 2, passed: true });
34
+ return annotate(value, "email");
35
+ },
36
+ });
37
+ const mail = await helper.latest() as unknown as Wrapped<{ subject: string }>;
38
+ assertValue(mail.subject).toBe("Invoice");
39
+ const events = stopRecording();
40
+ expect(events.map((e) => e.kind)).toEqual([kind, "email", "assertion"]);
41
+ expect(events[1]).toMatchObject({ parentSeq: events[0]!.seq, annotation: true,
42
+ message: { subject: "Invoice", to: ["owner@test"], text: "Please pay" } });
43
+ expect(events[2]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["subject"] });
44
+ expect<unknown>(readRaw(mail)).toEqual({ subject: "Invoice", to: "owner@test", text: "Please pay" });
45
+ expect(events[0]).toMatchObject({ [kind === "fake" ? "fake" : "service"]: "mail",
46
+ member: "latest", args: [], result: readRaw(mail) });
47
+ });
48
+ }
49
+
50
+ test("ordinary service returns preserve value identity and operation provenance", async () => {
51
+ startRecording();
52
+ let observed: unknown;
53
+ const helper = trackHelpers("service", "mail", {
54
+ async latest() { return observed = observe(); },
55
+ count: () => 42,
56
+ });
57
+ const mail = await helper.latest() as Wrapped<{subject: string}>;
58
+ expect<unknown>(mail).toBe(observed);
59
+ expect(helper.count()).toBe(42);
60
+ assertValue(mail.subject).toBe("Invoice");
61
+ const events = stopRecording();
62
+ expect(events.map((e) => e.kind)).toEqual(["http", "assertion"]);
63
+ expect(events[1]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["subject"] });
64
+ });
65
+
66
+ test("a helper can choose an annotated or ordinary return per call", () => {
67
+ startRecording();
68
+ const helper = trackHelpers("service", "mail", {
69
+ latest(render: boolean) { const value = observe(); return render ? annotate(value, "email") : value; },
70
+ });
71
+ helper.latest(false);
72
+ helper.latest(true);
73
+ expect(stopRecording().map((e) => e.kind)).toEqual(["http", "service", "email"]);
74
+ });
75
+
76
+ test("concurrent test work and sibling helpers survive an annotated return", async () => {
77
+ startRecording();
78
+ const gate = deferred();
79
+ const helper = trackHelpers("service", "mail", {
80
+ async annotated() { observe("hidden"); await gate.promise; observe("also hidden"); return annotate({subject: "shown"}, "email"); },
81
+ async plain() { await Promise.resolve(); return observe("sibling"); },
82
+ });
83
+ const pending = helper.annotated();
84
+ const outside = observe("outside");
85
+ const sibling = await helper.plain();
86
+ gate.resolve();
87
+ await pending;
88
+ assertValue(outside.subject).toBe("outside");
89
+ assertValue(sibling.subject).toBe("sibling");
90
+ const events = stopRecording();
91
+ expect(events.filter((e) => e.kind === "http").map((e: any) => JSON.parse(e.responseBody).subject)).toEqual(["outside", "sibling"]);
92
+ expect(events.filter((e) => e.kind === "service")).toHaveLength(1);
93
+ expect(new Set(events.map((e) => e.seq)).size).toBe(events.length);
94
+ const seqs = new Set(events.map((e) => e.seq));
95
+ for (const e of events.filter((e) => e.kind === "assertion") as any[]) expect(seqs.has(e.sourceSeq)).toBe(true);
96
+ });
97
+
98
+ test("nested annotated helpers collapse only when the outer helper annotates", async () => {
99
+ startRecording();
100
+ const inner = trackHelpers("service", "inner", { latest: () => annotate(observe(), "email") });
101
+ const outer = trackHelpers("service", "outer", {
102
+ async latest(render: boolean) { const value = await inner.latest(); return render ? annotate(value, "email") : value; },
103
+ });
104
+ await outer.latest(false);
105
+ await outer.latest(true);
106
+ const events = stopRecording();
107
+ expect(events.map((e) => e.kind)).toEqual(["service", "email", "service", "email"]);
108
+ expect(events[0]).toMatchObject({ service: "inner", member: "latest", args: [] });
109
+ expect(events[2]).toMatchObject({ service: "outer", member: "latest", args: [true] });
110
+ });
111
+
112
+ test("polling inside a helper cannot truncate unrelated concurrent events", async () => {
113
+ startRecording();
114
+ const gate = deferred();
115
+ const helper = trackHelpers("service", "mail", {
116
+ async latest() {
117
+ const start = recorderEventCount();
118
+ observe("discarded attempt");
119
+ await gate.promise;
120
+ recorderTruncate(start);
121
+ const result = observe("kept attempt");
122
+ const wait = recordWait({ description: "ready", attempts: 2, passed: true, durationMs: 3 })!;
123
+ recorderMarkChildren(start, wait);
124
+ return result;
125
+ },
126
+ });
127
+ const pending = helper.latest();
128
+ observe("outside");
129
+ gate.resolve();
130
+ await pending;
131
+ const events = stopRecording();
132
+ expect(events.filter((e) => e.kind === "http").map((e: any) => JSON.parse(e.responseBody).subject)).toEqual(["outside", "kept attempt"]);
133
+ });
134
+
135
+ test("an annotated service helper is one child of a poll's kept attempt", () => {
136
+ startRecording();
137
+ const helper = trackHelpers("service", "mail", { latest: () => annotate(observe(), "email") });
138
+ const start = recorderEventCount();
139
+ helper.latest();
140
+ recorderTruncate(start);
141
+ helper.latest();
142
+ const wait = recordWait({ description: "mail arrives", attempts: 2, passed: true, durationMs: 10 })!;
143
+ recorderMarkChildren(start, wait);
144
+ const events = stopRecording();
145
+ expect(events.map((e) => e.kind)).toEqual(["service", "email", "wait"]);
146
+ expect(events[0]!.parentSeq).toBe(wait);
147
+ expect(events[1]!.parentSeq).toBe(events[0]!.seq);
148
+ });
149
+
150
+ test("errors retain service diagnostics and fake error behavior", async () => {
151
+ for (const kind of ["fake", "service"] as const) {
152
+ startRecording();
153
+ const failure = new Error("mail unavailable");
154
+ const helper = trackHelpers(kind, "mail", {
155
+ async latest() { observe(); await Promise.resolve(); throw failure; },
156
+ });
157
+ await expect(helper.latest()).rejects.toBe(failure);
158
+ expect(stopRecording().map((e) => e.kind)).toEqual([kind === "fake" ? "fake" : "http"]);
159
+ }
160
+ });
161
+
162
+ test("live objects, callable clients, function properties and receivers survive", async () => {
163
+ startRecording();
164
+ const client = { query: () => 1 };
165
+ const sql = Object.assign((input: string) => observe(input), { unsafe: () => 2 });
166
+ const raw = { client, sql, subject: "Invoice", latest() { return annotate({subject: this.subject}, "email"); } };
167
+ const helper = trackHelpers("service", "mail", raw);
168
+ expect(helper.client).toBe(client);
169
+ expect(helper.sql.unsafe).toBe(sql.unsafe);
170
+ expect(helper.sql.unsafe()).toBe(2);
171
+ expect(helper.sql).toBe(helper.sql);
172
+ expect(helper.latest).toBe(helper.latest);
173
+ expect(helper.sql("query").subject.unwrap()).toBe("query");
174
+ expect<unknown>(readRaw(helper.latest())).toEqual({subject: "Invoice"});
175
+ expect<unknown>(readRaw(helper.latest.call({...raw, subject: "Other"}))).toEqual({subject: "Other"});
176
+ });
177
+
178
+ test("setup/eval unwrap annotated returns without recording", async () => {
179
+ const scope = createInstrumentationScope(false);
180
+ const helper = trackHelpers("service", "mail", { latest: () => annotate({subject: "Invoice"}, "email") });
181
+ const value = runInstrumented(scope, () => helper.latest());
182
+ expect<unknown>(readRaw(value)).toEqual({subject: "Invoice"});
183
+ expect(readTag(value)?.sourceSeq).toBeUndefined();
184
+ expect(stopRecording()).toEqual([]);
185
+ });
186
+
187
+ test("frozen helper records retain enumeration and can return chat annotations", () => {
188
+ startRecording();
189
+ const client = { query: () => 1 };
190
+ const helper = trackHelpers("service", "support", Object.freeze({
191
+ client,
192
+ conversation: () => annotate([{ side: "other", text: "Hello" }], "chat"),
193
+ }));
194
+ expect(Object.keys(helper)).toEqual(["client", "conversation"]);
195
+ expect("client" in helper).toBe(true);
196
+ expect(helper.client).toBe(client);
197
+ helper.conversation();
198
+ const events = stopRecording();
199
+ expect(events.map((e) => e.kind)).toEqual(["service", "chat"]);
200
+ expect(events[1]).toMatchObject({ parentSeq: events[0]!.seq, annotation: true });
201
+ });
202
+
203
+ test("timed-out helpers retain diagnostics and cannot write into the next test", async () => {
204
+ startRecording();
205
+ const gate = deferred();
206
+ const helper = trackHelpers("service", "mail", {
207
+ async latest() { observe("before timeout"); await gate.promise; observe("late"); return annotate({subject: "late"}, "email"); },
208
+ });
209
+ const pending = helper.latest();
210
+ expect(stopRecording().map((e) => e.kind)).toEqual(["http"]);
211
+ startRecording();
212
+ observe("next test");
213
+ gate.resolve();
214
+ await pending;
215
+ expect(stopRecording().map((e) => e.kind)).toEqual(["http"]);
216
+ });
217
+
218
+ test("detached work from an annotated helper stays suppressed after return", async () => {
219
+ startRecording();
220
+ const gate = deferred();
221
+ let detached!: Promise<unknown>;
222
+ const helper = trackHelpers("service", "mail", {
223
+ latest() { detached = gate.promise.then(() => observe("late")); return annotate({subject: "Invoice"}, "email"); },
224
+ });
225
+ helper.latest();
226
+ gate.resolve();
227
+ await detached;
228
+ expect(stopRecording().map((e) => e.kind)).toEqual(["service", "email"]);
229
+ });
230
+
231
+ test("stopping a test does not resurrect pending descendants of a collapsed helper", async () => {
232
+ startRecording();
233
+ const gate = deferred();
234
+ const inner = trackHelpers("service", "inner", {
235
+ async latest() { observe("hidden"); await gate.promise; return observe("also hidden"); },
236
+ });
237
+ let pending!: Promise<unknown>;
238
+ const outer = trackHelpers("service", "outer", {
239
+ latest() { pending = inner.latest(); return annotate({subject: "Invoice"}, "email"); },
240
+ });
241
+ outer.latest();
242
+ expect(stopRecording().map((e) => e.kind)).toEqual(["service", "email"]);
243
+ gate.resolve();
244
+ await pending;
245
+ });
246
+
247
+ for (const annotated of [false, true]) test(`real HTTP service helper preserves internal recording unless annotated (${annotated})`, async () => {
248
+ let requests = 0;
249
+ const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch() { requests++; return Response.json({subject: "Invoice"}); } });
250
+ const restore = installFetchWrapper();
251
+ const scope = createInstrumentationScope();
252
+ startRecording();
253
+ try {
254
+ const helper = trackHelpers("service", "mail", {
255
+ async latest() {
256
+ const response = await fetch(`http://127.0.0.1:${server.port}/mail`);
257
+ const value = await response.json();
258
+ return annotated ? annotate(value, "email") : value;
259
+ },
260
+ });
261
+ await runInstrumented(scope, async () => {
262
+ const value = await helper.latest() as unknown as Wrapped<{subject:string}>;
263
+ assertValue(value.subject).toBe("Invoice");
264
+ });
265
+ expect(requests).toBe(1);
266
+ const events = stopRecording();
267
+ expect(events.map((e) => e.kind)).toEqual(annotated ? ["service", "email", "assertion"] : ["http", "assertion"]);
268
+ expect(events.at(-1)).toMatchObject({ sourceSeq: events[0]!.seq });
269
+ } finally { scope.active = false; restore(); server.stop(true); }
270
+ });
271
+
272
+ // Compile-time contract, including inferred annotated unions and generic live clients.
273
+ function types(ctx: ServiceHandlesFor<{ mail: { image: string; helpers: () => {
274
+ plain(): number;
275
+ latest(): ReturnType<typeof annotation>;
276
+ maybe(): ReturnType<typeof annotation> | undefined;
277
+ asyncLatest(): Promise<ReturnType<typeof annotation>>;
278
+ generic<T>(value: T): T;
279
+ } } }>) {
280
+ const plain: number = ctx.mail.plain();
281
+ assertValue(ctx.mail.latest().subject).toBe("Invoice");
282
+ const maybe: Wrapped<{subject:string}> | undefined = ctx.mail.maybe();
283
+ const promise: Promise<Wrapped<{subject:string}>> = ctx.mail.asyncLatest();
284
+ const literal: "kept" = ctx.mail.generic("kept" as const);
285
+ // @ts-expect-error annotated leaves remain wrapped at the caller
286
+ const invalid: string = ctx.mail.latest().subject;
287
+ return [plain, maybe, promise, literal, invalid];
288
+ }
289
+ function annotation() { return annotate({subject: "Invoice"}, "email"); }