@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/README.md CHANGED
@@ -273,3 +273,26 @@ origin allowlists and cookie relationships. Different subdomains are still
273
273
  different origins; a synthetic response must still supply the intended CORS
274
274
  headers. Permissive mode is a convenience override and should not be used to
275
275
  verify application CORS restrictions.
276
+
277
+ ## Annotated service helpers (0.108.0)
278
+
279
+ Service helpers support the same `return annotate(value, "email" | "chat", options?)`
280
+ contract as fake helpers. The SDK opens the annotation box, records one named
281
+ service call, and returns a wrapped value whose assertions link to that call.
282
+ The annotated call's internal operations are suppressed; concurrent work is
283
+ unaffected. Ordinary helper returns retain their values and underlying events.
284
+
285
+ ```ts
286
+ helpers: ({ fetch }) => ({
287
+ async latest() {
288
+ const response = await fetch("http://mail:8080/messages/latest");
289
+ const mail = (await response.json<{ subject: string; text: string }>()).unwrap();
290
+ return annotate(mail, "email");
291
+ },
292
+ })
293
+ ```
294
+
295
+ The classic viewer shows `mail.latest()` with Email/JSON tabs; JSON contains
296
+ helper arguments and the returned value. The SPA retains its nested presentation.
297
+ `annotate` always returns a box for the helper boundary; it does not attach views
298
+ to arbitrary recorded results. See `spectest docs /services` for a complete example.
@@ -1,13 +1,12 @@
1
1
  import { type EmailEventMessage, type EmailEventSummary, type StepBlock } from "./recorder.js";
2
2
  /** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
3
- * box can't be read as the value by accident inside the fake, and the
3
+ * box can't be read as the value by accident inside the helper, and the
4
4
  * helper types can recognise it and hand the *test* back a plain `T`. */
5
5
  declare const ANNOTATED: unique symbol;
6
6
  /**
7
7
  * A value plus a rendering hint, as returned by {@link annotate}. Return it
8
- * straight from a fake helper: the daemon unwraps it, so the test receives
9
- * the value itself and `ctx.fakes.<name>.<fn>()` is typed as if the
10
- * annotation weren't there.
8
+ * straight from a fake or service helper: the boundary opens it and the test
9
+ * receives the value wrapped for assertions. The box never reaches the test.
11
10
  */
12
11
  export interface Annotated<T> {
13
12
  readonly [ANNOTATED]: T;
@@ -19,7 +18,7 @@ export interface Annotated<T> {
19
18
  * another kind is a compile error at the call site.
20
19
  *
21
20
  * A new kind is an entry here, a case in `lower`, and an arm in
22
- * `daemon.ts::recordAnnotationChild`.
21
+ * `helper-tracking.ts::recordAnnotationChild`.
23
22
  */
24
23
  export interface AnnotationOptions {
25
24
  /** Draw the value as an email — one message, or a list of them for a
@@ -33,7 +32,7 @@ export interface AnnotationOptions {
33
32
  export type AnnotationKind = keyof AnnotationOptions;
34
33
  /**
35
34
  * What an annotation lowers to: the fields of the child event the daemon
36
- * records under the call's own `fake` one. Tagged by that event's `kind` —
35
+ * records under the helper call's own event. Tagged by that event's `kind` —
37
36
  * which needn't be the annotation kind's name, since an annotation names
38
37
  * what the value *is* and the event names how it's drawn.
39
38
  */
@@ -56,13 +55,13 @@ type ChatRenderAnnotation = {
56
55
  blocks: StepBlock[];
57
56
  };
58
57
  /**
59
- * The email fields to render. Every one is optional — pass what the fake
60
- * has. Addresses take a single string or a list.
58
+ * The email fields to render. Every one is optional — pass what the helper
59
+ * observed. Addresses take a single string or a list.
61
60
  *
62
61
  * The event can also carry a date, per-row previews and attachment
63
- * metadata, which the built-in `email()` component fills from a really
64
- * captured message; a fake's mail was never sent, so those are deliberately
65
- * not offered here. A listing row's preview is derived from the body.
62
+ * metadata, which the built-in `email()` component fills from a captured
63
+ * message. Annotations currently cover the header/body fields below. A
64
+ * listing row's preview is derived from the body.
66
65
  */
67
66
  export interface EmailAnnotation {
68
67
  from?: string;
@@ -175,10 +174,12 @@ export interface ChatReactionAnnotation {
175
174
  new?: boolean;
176
175
  }
177
176
  /**
178
- * Say what a fake helper's return value *is*, so the timeline can draw it
177
+ * Say what a helper's return value *is*, so the timeline can draw it
179
178
  * as that on top of the JSON it always shows. The call stays an ordinary
180
- * fake step; the rendered view nests inside it. The value is returned to
181
- * the test unchanged (and unchanged in type).
179
+ * helper step; the rendered view nests inside it. Return this box from the
180
+ * helper: the caller receives the value wrapped for assertions. An annotated
181
+ * service return suppresses the helper's internal events; an ordinary service
182
+ * return keeps them. Calling annotate without returning the box has no effect.
182
183
  *
183
184
  * ```ts
184
185
  * helpers: ({ state }) => ({
package/dist/annotate.js CHANGED
@@ -1,34 +1,10 @@
1
- // Render annotations — how a fake tells the dashboard to draw a helper's
2
- // return value as something richer than JSON.
3
- //
4
- // A fake helper call is recorded as a `fake` step and its return value is
5
- // rendered as pretty JSON, which is the right default for arbitrary data.
6
- // When the value *is* a domain object the timeline already draws well — an
7
- // email, today — the fake says so: `return annotate(msg, "email", {…})`.
8
- // The call is still recorded as the `fake` step it is; the annotation rides
9
- // *under* it as a child event (`parentSeq`, marked `annotation`) of the kind
10
- // that already knows how to draw this — for `"email"`, the very event the
11
- // built-in `email()` component's mailbox helpers record — so the dashboard
12
- // renders it mail-client style (header block + sandboxed HTML body) with no
13
- // new rendering code. It leads the step's detail panel; the raw JSON value
14
- // is a tab away.
15
- //
16
- // One function, keyed by kind: the kind is a key of `AnnotationOptions`, so
17
- // the options argument is typed against the kind that was named, and a new
18
- // kind is an entry in that interface (+ a case in `lower`, + an arm in
19
- // `daemon.ts::recordAnnotatedCall`) rather than a new export.
20
- //
21
- // The test is unaffected. `annotate` returns a box that the daemon opens at
22
- // the helper boundary (`daemon.ts::invokeFakeHelper`), so the caller gets
23
- // the raw value back, inspect-wrapped as always, and the *type* it sees is
24
- // still the raw value's (`WrappedHelpers` in index.ts unwraps `Annotated`).
25
- // Assertions keep linking to the fake step, exactly as for an unannotated
26
- // helper. The annotation is a rendering hint and nothing else.
27
- //
28
- // This is a fake-helper mechanism: those calls go through a tracking proxy
29
- // that can open the box. A *service* helper (`ctx.svc.<name>.…`) is the raw
30
- // record its factory returned, so an annotation there would reach the test
31
- // as an opaque value — don't.
1
+ // A helper returns an annotation box to choose a richer view of its result.
2
+ // Both fake and service helper boundaries open it, record one named helper
3
+ // operation with an annotation child, and wrap the value for linked assertions.
4
+ // annotate() itself does not record or attach anything to an existing operation.
5
+ // Fake helpers always hide their implementation; service helpers opt into that
6
+ // behavior by returning this box. Ordinary service returns keep their internal
7
+ // recorded operations and their existing types.
32
8
  import { deepUnwrap } from "./inspect.js";
33
9
  import { truncateUtf8, } from "./recorder.js";
34
10
  /**
@@ -48,10 +24,12 @@ const NO_FIELDS = 'annotate(value, "email"): nothing to render — no email fiel
48
24
  'annotate(value, "email", { to: value.recipient, subject: value.title, ' +
49
25
  "html: value.body }).";
50
26
  /**
51
- * Say what a fake helper's return value *is*, so the timeline can draw it
27
+ * Say what a helper's return value *is*, so the timeline can draw it
52
28
  * as that on top of the JSON it always shows. The call stays an ordinary
53
- * fake step; the rendered view nests inside it. The value is returned to
54
- * the test unchanged (and unchanged in type).
29
+ * helper step; the rendered view nests inside it. Return this box from the
30
+ * helper: the caller receives the value wrapped for assertions. An annotated
31
+ * service return suppresses the helper's internal events; an ordinary service
32
+ * return keeps them. Calling annotate without returning the box has no effect.
55
33
  *
56
34
  * ```ts
57
35
  * helpers: ({ state }) => ({
package/dist/daemon.js CHANGED
@@ -73,9 +73,9 @@ import { openTerminal } from "./terminal.js";
73
73
  // `ctx.mcp(url)`. One client per call, no registry: an authenticated
74
74
  // client is passed to descendants as a test's return value.
75
75
  import { openMcp } from "./mcp.js";
76
- import { readAnnotation } from "./annotate.js";
77
- import { isRecording, recordEmail, recordEnv, recordExec, recordFake, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
78
- import { deepUnwrap, readRaw, wrap } from "./inspect.js";
76
+ import { trackHelpers } from "./helper-tracking.js";
77
+ import { isRecording, recordEnv, recordExec, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
78
+ import { readRaw, wrap } from "./inspect.js";
79
79
  import { setRecordSecrets } from "./record-secrets.js";
80
80
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
81
81
  function namedServices(cfg) {
@@ -2623,7 +2623,7 @@ const FAKE_CTX = {
2623
2623
  /** Build (or fetch from cache) the helpers record for a fake — the
2624
2624
  * value that ends up at `ctx.fakes.<name>`. Defaults to `{}` (a fake
2625
2625
  * with no `helpers` exposes nothing — tests never touch private state
2626
- * directly). Returns a tracking proxy (see `trackFakeHelpers`) so helper
2626
+ * directly). Returns a tracking proxy (see `trackHelpers`) so helper
2627
2627
  * calls land in the test timeline. */
2628
2628
  async function ensureFakeHelpers(name) {
2629
2629
  const fake = FAKES.get(name);
@@ -2638,146 +2638,9 @@ async function ensureFakeHelpers(name) {
2638
2638
  ctx: FAKE_CTX,
2639
2639
  })))
2640
2640
  : {};
2641
- fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
2641
+ fake.trackedHelpers = trackHelpers("fake", name, fake.helpers);
2642
2642
  return fake.trackedHelpers;
2643
2643
  }
2644
- /** Wrap a fake's helpers so each call becomes a recorded `fake` event,
2645
- * its arguments are `deepUnwrap`ped, and its return value is `wrap()`ped
2646
- * for assertion provenance. Helpers
2647
- * are functions that read/mutate the fake's private state via closure;
2648
- * tests only ever see what those functions return. The proxy is built
2649
- * once and shared across tests; it consults the recorder at call time,
2650
- * so it's a transparent no-op when nothing is recording (eval / project
2651
- * setup).
2652
- *
2653
- * Only own function properties are intercepted — inherited members
2654
- * (`toString`, etc.), symbols, and any stray non-function property pass
2655
- * straight through untouched. */
2656
- function trackFakeHelpers(fakeName, helpers) {
2657
- return new Proxy(helpers, {
2658
- get(target, prop, receiver) {
2659
- if (typeof prop === "symbol")
2660
- return Reflect.get(target, prop, receiver);
2661
- const desc = Object.getOwnPropertyDescriptor(target, prop);
2662
- if (!desc || typeof desc.value !== "function") {
2663
- return Reflect.get(target, prop, receiver);
2664
- }
2665
- const fn = desc.value;
2666
- const member = String(prop);
2667
- return (...args) => invokeFakeHelper(fakeName, member, fn, target, args);
2668
- },
2669
- });
2670
- }
2671
- /** Invoke a fake helper function, recording a `fake` event and wrapping
2672
- * the return value. Handles both sync and async helpers, and records an
2673
- * error event (then rethrows) if the helper throws.
2674
- *
2675
- * Arguments are `deepUnwrap`ped on the way in, so a helper body is plain
2676
- * user code operating on plain values: a test that feeds one helper's
2677
- * return (or a `ctx.fetch` field) to another passes a provenance wrapper,
2678
- * and `msg.html.matchAll(...)` inside the fake would otherwise throw a
2679
- * `TypeError` from a stack the author can't see. Same treatment the k8s
2680
- * client's arguments get (`components/k3s.ts::withTagging`). The types
2681
- * still say the value is a handle — passing a wrapped value where the
2682
- * parameter type is known is a compile error asking for `.unwrap()`; this
2683
- * catches the case where it isn't known (an `any` off `req.json()`). */
2684
- function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
2685
- const t = Date.now();
2686
- const resv = reserveEvent();
2687
- const args = callArgs.map((a) => deepUnwrap(a));
2688
- const safeArgs = args.map((a) => safeSerialize(a));
2689
- const recordResult = (value) => {
2690
- // A helper may box its return in a render annotation
2691
- // (`annotate(v, "email", …)`). The box never reaches the test, and it
2692
- // never replaces the step either: the call is recorded as the ordinary
2693
- // `fake` event it is, and the annotation rides *under* it as a child
2694
- // event (`parentSeq`), which the dashboard folds into the fake step's
2695
- // detail panel. So the timeline reads the same as any other helper
2696
- // call, with the richer view one click in.
2697
- const annotated = readAnnotation(value);
2698
- const raw = annotated ? annotated.value : value;
2699
- const durationMs = Date.now() - t;
2700
- const seq = recordFake({
2701
- fake: fakeName,
2702
- member,
2703
- args: safeArgs,
2704
- result: safeSerialize(raw),
2705
- durationMs,
2706
- }, resv);
2707
- if (annotated && seq !== undefined) {
2708
- recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
2709
- }
2710
- return wrap(raw, seq);
2711
- };
2712
- const recordError = (err) => {
2713
- recordFake({
2714
- fake: fakeName,
2715
- member,
2716
- args: safeArgs,
2717
- durationMs: Date.now() - t,
2718
- error: errMessage(err),
2719
- }, resv);
2720
- };
2721
- // Record the public helper call in the caller's scope, while its body
2722
- // and async descendants use native fetch and emit no internal events.
2723
- let result;
2724
- try {
2725
- result = runUninstrumented(() => fn.apply(thisArg, args));
2726
- }
2727
- catch (err) {
2728
- recordError(err);
2729
- throw err;
2730
- }
2731
- if (result instanceof Promise) {
2732
- return result.then(recordResult, (err) => {
2733
- recordError(err);
2734
- throw err;
2735
- });
2736
- }
2737
- return recordResult(result);
2738
- }
2739
- /** Record a fake-helper call's render annotation as a child of the call's
2740
- * own `fake` event — the same `parentSeq` grouping `ctx.poll` uses for the
2741
- * iteration it kept, so the annotated view folds into the fake step's
2742
- * detail panel instead of taking a timeline row of its own.
2743
- *
2744
- * The child is the event kind that already knows how to draw this: an
2745
- * `email` annotation records the very event the built-in `email()`
2746
- * component's mailbox helpers record, so it renders with no new code. It
2747
- * carries the fake's name and member as its service/op, and the parent's
2748
- * duration, since it describes that same call.
2749
- *
2750
- * Only the success path is annotated: a helper that threw returned no value
2751
- * to annotate, so it's a plain `fake` error event. */
2752
- function recordAnnotationChild(fakeName, member, annotation, durationMs, parentSeq) {
2753
- switch (annotation.kind) {
2754
- case "email":
2755
- recordEmail({
2756
- parentSeq,
2757
- annotation: true,
2758
- service: fakeName,
2759
- op: member,
2760
- message: annotation.message,
2761
- messages: annotation.messages,
2762
- count: annotation.count,
2763
- durationMs,
2764
- });
2765
- break;
2766
- case "chat":
2767
- // A chat has no event kind of its own: it describes itself in
2768
- // presentation terms (title + blocks), which is the seam a new step
2769
- // type is supposed to use. `kind` stays an opaque grouping label.
2770
- recordStep({
2771
- kind: "chat",
2772
- parentSeq,
2773
- annotation: true,
2774
- title: annotation.title ?? `${fakeName}.${member}`,
2775
- blocks: annotation.blocks,
2776
- durationMs,
2777
- });
2778
- break;
2779
- }
2780
- }
2781
2644
  function errMessage(err) {
2782
2645
  return err?.message ?? String(err);
2783
2646
  }
@@ -3062,31 +2925,34 @@ async function bootstrapInner(prepared, identity) {
3062
2925
  logBootstrapTimings(result);
3063
2926
  return result;
3064
2927
  }
2928
+ // Captured as false in the shared template, true in a seeded interactive
2929
+ // snapshot. Repeated requests must not apply a successful seed twice.
2930
+ let interactiveSetupCompleted = false;
3065
2931
  /**
3066
- * Run the loaded project's `setup` hook, if any. Called by the control
3067
- * plane once between /bootstrap and the warm-template snapshot, so the
3068
- * effects (seeded DB rows, initial pods, fixture files) are captured
3069
- * exactly once and inherited by every later snapshot/fork.
2932
+ * Run a project setup hook. Normal `setup` runs before the shared warm
2933
+ * snapshot; `setupInteractive` runs only after it, in a fresh user start.
2934
+ * Both use the same context and boot-log capture.
3070
2935
  *
3071
2936
  * Unlike test runs, this is NOT instrumented — no recorder, no event
3072
2937
  * timeline, no timeout from the test runner. Setup failures abort the
3073
2938
  * env bring-up; the control plane surfaces them as a start failure.
3074
2939
  */
3075
- async function runProjectSetup() {
2940
+ async function runProjectSetup(hook = "setup") {
3076
2941
  // Same tee as bootstrap: a project `setup` hook's console output is
3077
2942
  // part of explaining the boot, not something to lose on success.
3078
2943
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
3079
2944
  try {
3080
- return await runProjectSetupInner();
2945
+ return await runProjectSetupInner(hook);
3081
2946
  }
3082
2947
  finally {
3083
2948
  restoreBootLog();
3084
2949
  }
3085
2950
  }
3086
- async function runProjectSetupInner() {
3087
- const proj = requireLoaded().project;
3088
- if (!proj.setup)
2951
+ async function runProjectSetupInner(hook) {
2952
+ const setup = requireLoaded().project[hook];
2953
+ if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
3089
2954
  return { ran: false, durationMs: 0 };
2955
+ }
3090
2956
  const start = Date.now();
3091
2957
  // Install the fetch wrapper for the duration of setup so `ctx.fetch` (and any
3092
2958
  // client routed through `globalThis.fetch`) returns a wrapped Response, same
@@ -3097,9 +2963,11 @@ async function runProjectSetupInner() {
3097
2963
  // The full context, unscoped: every service is up by project-setup time,
3098
2964
  // so `ctx.svc` is the same map tests see (and shares helper instances with
3099
2965
  // them — a Bun.SQL pool created here is reused later).
3100
- const ctx = await spectestContext();
3101
2966
  try {
3102
- await runInstrumented(fetchScope, () => proj.setup(ctx));
2967
+ const ctx = await spectestContext();
2968
+ await runInstrumented(fetchScope, () => setup(ctx));
2969
+ if (hook === "setupInteractive")
2970
+ interactiveSetupCompleted = true;
3103
2971
  }
3104
2972
  finally {
3105
2973
  fetchScope.active = false;
@@ -3969,10 +3837,10 @@ async function ensureHelpers(name, def) {
3969
3837
  }
3970
3838
  HELPERS_BUILDING.add(name);
3971
3839
  try {
3972
- HELPERS_CACHE.set(name, await def.helpers({
3840
+ HELPERS_CACHE.set(name, trackHelpers("service", name, await def.helpers({
3973
3841
  name,
3974
3842
  ...(await spectestContext({ service: name, includeSelf: false })),
3975
- }));
3843
+ })));
3976
3844
  }
3977
3845
  finally {
3978
3846
  HELPERS_BUILDING.delete(name);
@@ -3984,8 +3852,8 @@ async function ensureHelpers(name, def) {
3984
3852
  const HELPERS_BUILDING = new Set();
3985
3853
  /**
3986
3854
  * Build the `svc` map for one test/eval. The value at `svc[name]` is
3987
- * exactly the record the service's `helpers` factory returned (e.g.
3988
- * `{ client: SqlClient }` for `postgres(...)`). Services without a
3855
+ * a shallow helper proxy: annotated returns become named service steps;
3856
+ * ordinary returns and live client handles pass through. Services without a
3989
3857
  * `helpers` factory don't appear in the map at all.
3990
3858
  */
3991
3859
  async function buildServiceHandles(cfg) {
@@ -5268,7 +5136,13 @@ export function harnessMethods(state) {
5268
5136
  setRecordSecrets(secrets);
5269
5137
  };
5270
5138
  return {
5271
- health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
5139
+ health: async () => ({
5140
+ ok: true,
5141
+ capabilities: ["prepared-images-v1"],
5142
+ // Optional discovery: old SDKs omit this field and are never sent the
5143
+ // new method. Check the loaded project, including after warm restore.
5144
+ setupInteractive: typeof loaded?.project.setupInteractive === "function",
5145
+ }),
5272
5146
  // Live bootstrap progress, polled by the control plane during
5273
5147
  // `bootstrap` and streamed into the test-run row. `{}` before the
5274
5148
  // first bootstrap of this harness process.
@@ -5385,6 +5259,7 @@ export function harnessMethods(state) {
5385
5259
  return { ok: true, timings: await bootstrap(prepared) };
5386
5260
  }),
5387
5261
  projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
5262
+ interactiveSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup("setupInteractive")),
5388
5263
  eval: async (params) => {
5389
5264
  const code = requireString(params, "code");
5390
5265
  return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
@@ -23,7 +23,7 @@
23
23
  /** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
24
24
  export declare const PROTOCOL_VERSION = 1;
25
25
  /** Methods either side can send. */
26
- export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "run" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
26
+ export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "interactiveSetup" | "run" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
27
27
  /** Methods this build knows how to dispatch. A method outside this set still
28
28
  * *parses* — it is answered with an error, never dropped. */
29
29
  export declare const KNOWN_METHODS: ReadonlySet<string>;
@@ -30,6 +30,7 @@ export const KNOWN_METHODS = new Set([
30
30
  "loadTests",
31
31
  "fingerprint",
32
32
  "bootstrap",
33
+ "interactiveSetup",
33
34
  "run",
34
35
  "eval",
35
36
  "setRecordSecrets",
@@ -0,0 +1,7 @@
1
+ type HelperKind = "fake" | "service";
2
+ /** Both helper namespaces consume the same annotation box. Only direct own
3
+ * function properties are intercepted: live clients, getters and inherited
4
+ * methods pass through; callable clients retain their properties. Service functions without annotations
5
+ * retain their original results, types and underlying recorded operations. */
6
+ export declare function trackHelpers<H extends Record<string, unknown>>(kind: HelperKind, name: string, helpers: H): H;
7
+ export {};
@@ -0,0 +1,142 @@
1
+ import { readAnnotation } from "./annotate.js";
2
+ import { deepUnwrap, wrap } from "./inspect.js";
3
+ import { runUninstrumented } from "./harness/instrumentation-scope.js";
4
+ import { captureRecording, recordEmail, recordFake, recordServiceHelper, recordStep, reserveEvent, safeSerialize, } from "./recorder.js";
5
+ /** Both helper namespaces consume the same annotation box. Only direct own
6
+ * function properties are intercepted: live clients, getters and inherited
7
+ * methods pass through; callable clients retain their properties. Service functions without annotations
8
+ * retain their original results, types and underlying recorded operations. */
9
+ export function trackHelpers(kind, name, helpers) {
10
+ const methods = new Map();
11
+ // A separate, extensible target also supports frozen helper records: a proxy
12
+ // over the original cannot substitute a non-writable function property.
13
+ const proxy = new Proxy(Object.create(Object.getPrototypeOf(helpers)), {
14
+ get(_target, prop) {
15
+ if (typeof prop !== "string")
16
+ return Reflect.get(helpers, prop, helpers);
17
+ const descriptor = Object.getOwnPropertyDescriptor(helpers, prop);
18
+ if (!descriptor || typeof descriptor.value !== "function")
19
+ return Reflect.get(helpers, prop, helpers);
20
+ const fn = descriptor.value;
21
+ const cached = methods.get(prop);
22
+ if (cached?.original === fn)
23
+ return cached.tracked;
24
+ // A callable handle (e.g. tagged-template SQL) can also have methods.
25
+ // Proxy its call rather than replacing it with a bare arrow function.
26
+ const tracked = new Proxy(fn, {
27
+ apply(original, thisArg, args) {
28
+ return invokeHelper(kind, name, prop, original, kind === "fake" || thisArg === proxy ? helpers : thisArg, args);
29
+ },
30
+ });
31
+ methods.set(prop, { original: fn, tracked });
32
+ return tracked;
33
+ },
34
+ has: (_target, prop) => Reflect.has(helpers, prop),
35
+ ownKeys: () => Reflect.ownKeys(helpers),
36
+ getOwnPropertyDescriptor(_target, prop) {
37
+ const descriptor = Object.getOwnPropertyDescriptor(helpers, prop);
38
+ return descriptor && { ...descriptor, configurable: true };
39
+ },
40
+ set: (_target, prop, value) => Reflect.set(helpers, prop, value, helpers),
41
+ deleteProperty: (_target, prop) => Reflect.deleteProperty(helpers, prop),
42
+ // Virtual properties must remain configurable on this view.
43
+ defineProperty: (_target, prop, descriptor) => descriptor.configurable === true &&
44
+ Reflect.defineProperty(helpers, prop, descriptor),
45
+ preventExtensions: () => false,
46
+ });
47
+ return proxy;
48
+ }
49
+ function invokeHelper(kind, name, member, fn, receiver, callArgs) {
50
+ const start = Date.now();
51
+ const reservation = reserveEvent();
52
+ const recording = captureRecording();
53
+ // Fake bodies have always received raw args. Services keep their existing
54
+ // input contract: they may intentionally accept wrapped values or clients.
55
+ const args = kind === "fake" ? callArgs.map((arg) => deepUnwrap(arg)) : callArgs;
56
+ const safeArgs = kind === "fake" ? args.map((arg) => safeSerialize(arg)) : undefined;
57
+ const finish = (value) => {
58
+ const annotated = readAnnotation(value);
59
+ if (kind === "service" && !annotated) {
60
+ recording.finish(true);
61
+ return value;
62
+ }
63
+ recording.finish(false);
64
+ const raw = deepUnwrap(annotated ? annotated.value : value);
65
+ const durationMs = Date.now() - start;
66
+ const seq = recording.isCurrent()
67
+ ? recordHelper(kind, name, member, safeArgs ?? args.map((arg) => safeSerialize(deepUnwrap(arg))), raw, durationMs, reservation)
68
+ : undefined;
69
+ if (annotated && seq !== undefined) {
70
+ recordAnnotationChild(name, member, annotated.annotation, durationMs, seq);
71
+ }
72
+ return wrap(raw, seq);
73
+ };
74
+ const fail = (error) => {
75
+ // There was no annotated return to replace the implementation. Preserve
76
+ // service failures' underlying diagnostics; fake internals stay hidden.
77
+ recording.finish(kind === "service");
78
+ if (kind === "fake" && recording.isCurrent()) {
79
+ recordFake({ fake: name, member, args: safeArgs, durationMs: Date.now() - start,
80
+ error: error instanceof Error ? error.message : String(error) }, reservation);
81
+ }
82
+ throw error;
83
+ };
84
+ let result;
85
+ try {
86
+ result = recording.run(() => kind === "fake"
87
+ ? runUninstrumented(() => fn.apply(receiver, args))
88
+ : fn.apply(receiver, args));
89
+ }
90
+ catch (error) {
91
+ return fail(error);
92
+ }
93
+ return result instanceof Promise ? result.then(finish, fail) : finish(result);
94
+ }
95
+ function recordHelper(kind, name, member, args, result, durationMs, reservation) {
96
+ if (kind === "fake") {
97
+ return recordFake({ fake: name, member, args, result: safeSerialize(result), durationMs }, reservation);
98
+ }
99
+ return recordServiceHelper({ service: name, member, args, result: safeSerialize(result), durationMs }, reservation);
100
+ }
101
+ /** Record a helper call's render annotation as a child of the call's
102
+ * own event — the same `parentSeq` grouping `ctx.poll` uses for the
103
+ * iteration it kept, so the annotated view folds into the helper step's
104
+ * detail panel instead of taking a timeline row of its own.
105
+ *
106
+ * The child is the event kind that already knows how to draw this: an
107
+ * `email` annotation records the very event the built-in `email()`
108
+ * component's mailbox helpers record, so it renders with no new code. It
109
+ * carries the helper's name and member as its service/op, and the parent's
110
+ * duration, since it describes that same call.
111
+ *
112
+ * Only the success path is annotated: a helper that threw returned no value
113
+ * to annotate. */
114
+ function recordAnnotationChild(name, member, annotation, durationMs, parentSeq) {
115
+ switch (annotation.kind) {
116
+ case "email":
117
+ recordEmail({
118
+ parentSeq,
119
+ annotation: true,
120
+ service: name,
121
+ op: member,
122
+ message: annotation.message,
123
+ messages: annotation.messages,
124
+ count: annotation.count,
125
+ durationMs,
126
+ });
127
+ break;
128
+ case "chat":
129
+ // A chat has no event kind of its own: it describes itself in
130
+ // presentation terms (title + blocks), which is the seam a new step
131
+ // type is supposed to use. `kind` stays an opaque grouping label.
132
+ recordStep({
133
+ kind: "chat",
134
+ parentSeq,
135
+ annotation: true,
136
+ title: annotation.title ?? `${name}.${member}`,
137
+ blocks: annotation.blocks,
138
+ durationMs,
139
+ });
140
+ break;
141
+ }
142
+ }