@specific.dev/spectest 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ import { type EmailEventMessage, type EmailEventSummary } from "./recorder.js";
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
4
+ * helper types can recognise it and hand the *test* back a plain `T`. */
5
+ declare const ANNOTATED: unique symbol;
6
+ /**
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.
11
+ */
12
+ export interface Annotated<T> {
13
+ readonly [ANNOTATED]: T;
14
+ }
15
+ /**
16
+ * Every annotation kind, and the options each one takes. This interface is
17
+ * the whole type contract of {@link annotate}: naming a kind picks the type
18
+ * of the options argument, so a stray field or a value that belongs to
19
+ * another kind is a compile error at the call site.
20
+ *
21
+ * A new kind is an entry here, a case in `lower`, and an arm in
22
+ * `daemon.ts::recordAnnotationChild`.
23
+ */
24
+ export interface AnnotationOptions {
25
+ /** Draw the value as an email — one message, or a list of them for a
26
+ * mailbox listing. See {@link EmailAnnotation}. */
27
+ email: EmailAnnotation | readonly EmailAnnotation[];
28
+ }
29
+ /** The kinds {@link annotate} accepts. */
30
+ export type AnnotationKind = keyof AnnotationOptions;
31
+ /**
32
+ * What an annotation lowers to: the fields of the child event the daemon
33
+ * records under the call's own `fake` one. Tagged by that event's `kind` —
34
+ * which needn't be the annotation kind's name, since an annotation names
35
+ * what the value *is* and the event names how it's drawn.
36
+ */
37
+ export type RenderAnnotation = {
38
+ kind: "email";
39
+ /** Single-message ops. */
40
+ message?: EmailEventMessage;
41
+ /** Listing ops. */
42
+ messages?: EmailEventSummary[];
43
+ /** Total matches (a listing is capped on the event, never in the value). */
44
+ count?: number;
45
+ };
46
+ /**
47
+ * The email fields to render. Every one is optional — pass what the fake
48
+ * has. Addresses take a single string or a list.
49
+ *
50
+ * The event can also carry a date, per-row previews and attachment
51
+ * metadata, which the built-in `email()` component fills from a really
52
+ * captured message; a fake's mail was never sent, so those are deliberately
53
+ * not offered here. A listing row's preview is derived from the body.
54
+ */
55
+ export interface EmailAnnotation {
56
+ from?: string;
57
+ to?: string | readonly string[];
58
+ cc?: string | readonly string[];
59
+ bcc?: string | readonly string[];
60
+ subject?: string;
61
+ /** HTML body. Rendered in a fully sandboxed iframe (scripts off). */
62
+ html?: string;
63
+ /** Plain-text body. Shown on its own when there's no HTML, folded into a
64
+ * "Plain-text version" disclosure when there is. */
65
+ text?: string;
66
+ }
67
+ /**
68
+ * Say what a fake helper's return value *is*, so the timeline can draw it
69
+ * as that on top of the JSON it always shows. The call stays an ordinary
70
+ * fake step; the rendered view nests inside it. The value is returned to
71
+ * the test unchanged (and unchanged in type).
72
+ *
73
+ * ```ts
74
+ * helpers: ({ state }) => ({
75
+ * lastReceipt() {
76
+ * const r = state.receipts.at(-1);
77
+ * return r && annotate(r, "email", {
78
+ * from: "receipts@stripe.test",
79
+ * to: r.customer,
80
+ * subject: `Receipt for ${r.description}`,
81
+ * html: r.body,
82
+ * });
83
+ * },
84
+ * })
85
+ * ```
86
+ *
87
+ * `kind` picks what the options are: `"email"` takes an
88
+ * {@link EmailAnnotation} (or a list of them, which renders a mailbox
89
+ * listing instead of one message). Every field is optional, and with no
90
+ * options at all they're read off the value itself — enough when it already
91
+ * carries `from`/`to`/`subject`/`html`/`text`. Throws if there is nothing to
92
+ * render, rather than recording an empty step.
93
+ */
94
+ export declare function annotate<T, K extends AnnotationKind>(value: T, kind: K, options?: AnnotationOptions[K]): Annotated<T>;
95
+ /** Open an annotation box. Returns `undefined` for any ordinary value, so
96
+ * call sites can treat annotation as the exception it is. */
97
+ export declare function readAnnotation(value: unknown): {
98
+ annotation: RenderAnnotation;
99
+ value: unknown;
100
+ } | undefined;
101
+ export {};
@@ -0,0 +1,196 @@
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.
32
+ import { deepUnwrap } from "./inspect.js";
33
+ import { truncateUtf8, } from "./recorder.js";
34
+ /**
35
+ * Runtime marker on the box `annotate(...)` returns. `Symbol.for` rather
36
+ * than a module-local symbol so a second copy of the SDK still recognises
37
+ * a box minted by the first — the duplicate-module-instance hazard that
38
+ * silently drops assertion events when the SDK lands in an app dir twice
39
+ * (see `sdk.rs`).
40
+ */
41
+ const ANNOTATION = Symbol.for("spectest.render-annotation");
42
+ /** Listing rows carried on the event. The returned value is never capped. */
43
+ const LIST_CAP = 50;
44
+ /** How much of a body becomes a listing row's preview. */
45
+ const SNIPPET_CHARS = 200;
46
+ const NO_FIELDS = 'annotate(value, "email"): nothing to render — no email fields found on ' +
47
+ "the value. Pass them explicitly, e.g. " +
48
+ 'annotate(value, "email", { to: value.recipient, subject: value.title, ' +
49
+ "html: value.body }).";
50
+ /**
51
+ * Say what a fake helper's return value *is*, so the timeline can draw it
52
+ * 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).
55
+ *
56
+ * ```ts
57
+ * helpers: ({ state }) => ({
58
+ * lastReceipt() {
59
+ * const r = state.receipts.at(-1);
60
+ * return r && annotate(r, "email", {
61
+ * from: "receipts@stripe.test",
62
+ * to: r.customer,
63
+ * subject: `Receipt for ${r.description}`,
64
+ * html: r.body,
65
+ * });
66
+ * },
67
+ * })
68
+ * ```
69
+ *
70
+ * `kind` picks what the options are: `"email"` takes an
71
+ * {@link EmailAnnotation} (or a list of them, which renders a mailbox
72
+ * listing instead of one message). Every field is optional, and with no
73
+ * options at all they're read off the value itself — enough when it already
74
+ * carries `from`/`to`/`subject`/`html`/`text`. Throws if there is nothing to
75
+ * render, rather than recording an empty step.
76
+ */
77
+ export function annotate(value, kind, options) {
78
+ return box(value, lower(kind, options, value));
79
+ }
80
+ /** Turn a kind + its options into the event fields to record. */
81
+ function lower(kind, options, value) {
82
+ switch (kind) {
83
+ case "email":
84
+ return lowerEmail(options, value);
85
+ default:
86
+ // Unreachable while `kind` is a key of AnnotationOptions; a kind added
87
+ // to that interface without a case here lands on this line.
88
+ throw new Error(`annotate(): unknown annotation kind ${String(kind)}`);
89
+ }
90
+ }
91
+ function lowerEmail(options, value) {
92
+ // `deepUnwrap` only where fields are *read* — a value off another
93
+ // instrumented call is a provenance carrier, and `String(carrier)` would
94
+ // otherwise be its coerced shape rather than the address it holds. The
95
+ // value handed back to the test is never touched.
96
+ const source = deepUnwrap(options ?? value);
97
+ if (Array.isArray(source)) {
98
+ const messages = source.slice(0, LIST_CAP).map((m) => summaryOf(fields(m)));
99
+ if (source.length > 0 && messages.every((m) => isEmpty(m))) {
100
+ throw new Error(NO_FIELDS);
101
+ }
102
+ return { kind: "email", messages, count: source.length };
103
+ }
104
+ const message = messageOf(fields(source));
105
+ if (isEmpty(message))
106
+ throw new Error(NO_FIELDS);
107
+ return { kind: "email", message };
108
+ }
109
+ /** Open an annotation box. Returns `undefined` for any ordinary value, so
110
+ * call sites can treat annotation as the exception it is. */
111
+ export function readAnnotation(value) {
112
+ if (typeof value !== "object" || value === null)
113
+ return undefined;
114
+ const annotation = value[ANNOTATION];
115
+ if (!annotation)
116
+ return undefined;
117
+ return { annotation, value: value.value };
118
+ }
119
+ function box(value, annotation) {
120
+ return { [ANNOTATION]: annotation, value };
121
+ }
122
+ function isEmpty(o) {
123
+ return Object.keys(o).length === 0;
124
+ }
125
+ /** The value as a field bag — anything that isn't an object contributes
126
+ * nothing, and falls through to the `NO_FIELDS` error. */
127
+ function fields(v) {
128
+ return typeof v === "object" && v !== null
129
+ ? v
130
+ : {};
131
+ }
132
+ function messageOf(src) {
133
+ const msg = {};
134
+ const from = text(src.from);
135
+ if (from)
136
+ msg.from = from;
137
+ for (const key of ["to", "cc", "bcc"]) {
138
+ const addrs = addresses(src[key]);
139
+ if (addrs.length > 0)
140
+ msg[key] = addrs;
141
+ }
142
+ const subject = text(src.subject);
143
+ if (subject)
144
+ msg.subject = subject;
145
+ const html = text(src.html);
146
+ if (html) {
147
+ const t = truncateUtf8(html);
148
+ msg.html = t.value;
149
+ if (t.truncated)
150
+ msg.htmlTruncated = true;
151
+ }
152
+ const body = text(src.text);
153
+ if (body) {
154
+ const t = truncateUtf8(body);
155
+ msg.text = t.value;
156
+ if (t.truncated)
157
+ msg.textTruncated = true;
158
+ }
159
+ return msg;
160
+ }
161
+ /** A listing row is a whole message plus its preview line — the dashboard
162
+ * expands the row you click into the full view, so the body has to ride
163
+ * along rather than being summarised away. */
164
+ function summaryOf(src) {
165
+ const row = messageOf(src);
166
+ const snippet = preview(text(src.text), text(src.html));
167
+ if (snippet)
168
+ row.snippet = snippet;
169
+ return row;
170
+ }
171
+ /** A listing row's preview: the plain-text body if there is one, else the
172
+ * HTML with its tags stripped — enough to tell two messages apart. */
173
+ function preview(body, html) {
174
+ const raw = body || html.replace(/<[^>]*>/g, " ");
175
+ const collapsed = raw.replace(/\s+/g, " ").trim();
176
+ return collapsed.length > SNIPPET_CHARS
177
+ ? `${collapsed.slice(0, SNIPPET_CHARS)}…`
178
+ : collapsed;
179
+ }
180
+ /** Scalars render as themselves; anything else (an object, a nested array)
181
+ * is not an address or a subject line and is dropped rather than shown as
182
+ * `[object Object]`. */
183
+ function text(v) {
184
+ if (typeof v === "string")
185
+ return v;
186
+ if (typeof v === "number" || typeof v === "boolean")
187
+ return String(v);
188
+ return "";
189
+ }
190
+ function addresses(v) {
191
+ if (typeof v === "string")
192
+ return v ? [v] : [];
193
+ if (!Array.isArray(v))
194
+ return [];
195
+ return v.map((a) => text(a)).filter((a) => a.length > 0);
196
+ }
package/dist/daemon.js CHANGED
@@ -44,7 +44,8 @@ import { runContainerArgs } from "./harness/container-run.js";
44
44
  import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
45
45
  import { conflict, notFound, requireString, } from "./harness/methods.js";
46
46
  import { openTerminal } from "./terminal.js";
47
- import { recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
47
+ import { readAnnotation } from "./annotate.js";
48
+ import { recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
48
49
  import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
49
50
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
50
51
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
@@ -2262,14 +2263,27 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
2262
2263
  const args = callArgs.map((a) => deepUnwrap(a));
2263
2264
  const safeArgs = args.map((a) => safeSerialize(a));
2264
2265
  const recordResult = (value) => {
2266
+ // A helper may box its return in a render annotation
2267
+ // (`annotate(v, "email", …)`). The box never reaches the test, and it
2268
+ // never replaces the step either: the call is recorded as the ordinary
2269
+ // `fake` event it is, and the annotation rides *under* it as a child
2270
+ // event (`parentSeq`), which the dashboard folds into the fake step's
2271
+ // detail panel. So the timeline reads the same as any other helper
2272
+ // call, with the richer view one click in.
2273
+ const annotated = readAnnotation(value);
2274
+ const raw = annotated ? annotated.value : value;
2275
+ const durationMs = Date.now() - t;
2265
2276
  const seq = recordFake({
2266
2277
  fake: fakeName,
2267
2278
  member,
2268
2279
  args: safeArgs,
2269
- result: safeSerialize(value),
2270
- durationMs: Date.now() - t,
2280
+ result: safeSerialize(raw),
2281
+ durationMs,
2271
2282
  }, resv);
2272
- return wrap(value, seq);
2283
+ if (annotated && seq !== undefined) {
2284
+ recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
2285
+ }
2286
+ return wrap(raw, seq);
2273
2287
  };
2274
2288
  const recordError = (err) => {
2275
2289
  recordFake({
@@ -2296,6 +2310,35 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
2296
2310
  }
2297
2311
  return recordResult(result);
2298
2312
  }
2313
+ /** Record a fake-helper call's render annotation as a child of the call's
2314
+ * own `fake` event — the same `parentSeq` grouping `ctx.poll` uses for the
2315
+ * iteration it kept, so the annotated view folds into the fake step's
2316
+ * detail panel instead of taking a timeline row of its own.
2317
+ *
2318
+ * The child is the event kind that already knows how to draw this: an
2319
+ * `email` annotation records the very event the built-in `email()`
2320
+ * component's mailbox helpers record, so it renders with no new code. It
2321
+ * carries the fake's name and member as its service/op, and the parent's
2322
+ * duration, since it describes that same call.
2323
+ *
2324
+ * Only the success path is annotated: a helper that threw returned no value
2325
+ * to annotate, so it's a plain `fake` error event. */
2326
+ function recordAnnotationChild(fakeName, member, annotation, durationMs, parentSeq) {
2327
+ switch (annotation.kind) {
2328
+ case "email":
2329
+ recordEmail({
2330
+ parentSeq,
2331
+ annotation: true,
2332
+ service: fakeName,
2333
+ op: member,
2334
+ message: annotation.message,
2335
+ messages: annotation.messages,
2336
+ count: annotation.count,
2337
+ durationMs,
2338
+ });
2339
+ break;
2340
+ }
2341
+ }
2299
2342
  function errMessage(err) {
2300
2343
  return err?.message ?? String(err);
2301
2344
  }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,9 @@ import { strict as nodeAssert } from "node:assert";
2
2
  export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedResponse, Provenanced, SpectestFetch, Unwrap, } from "./inspect.js";
3
3
  import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
4
4
  export { field } from "./inspect.js";
5
+ export { annotate } from "./annotate.js";
6
+ export type { Annotated, AnnotationKind, AnnotationOptions, EmailAnnotation, } from "./annotate.js";
7
+ import type { Annotated } from "./annotate.js";
5
8
  export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
6
9
  export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
7
10
  export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
@@ -1147,6 +1150,10 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
1147
1150
  * Every call is tracked in the test timeline: it records a `fake` step
1148
1151
  * and the return value is tagged so a later `expect(...)` on it nests
1149
1152
  * under that step in the UI (same provenance as `fetch`/db results).
1153
+ * The step renders the return value as JSON; wrap it in {@link annotate}
1154
+ * to add a richer view (an email, today) that the step's panel leads with,
1155
+ * the JSON one tab away — the test still receives the raw value, unchanged
1156
+ * and unchanged in type.
1150
1157
  *
1151
1158
  * Receives the fake's `state` plus a {@link FakeContext} `ctx`, so a
1152
1159
  * helper can provision/teardown runtime services just like the handler.
@@ -1188,8 +1195,15 @@ export type FakesMap = Record<string, FakeDefinition<any, any>>;
1188
1195
  * `void` side-effect helpers) pass through untouched. Mirrors `Tagged<T>` in
1189
1196
  * `components/k3s.ts`, extended to cover synchronous returns. */
1190
1197
  type WrappedHelpers<H> = {
1191
- [K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Wrapped<R>> : H[K] extends (...args: infer A) => infer R ? (...args: A) => [R] extends [void] ? void : Wrapped<R> : H[K];
1198
+ [K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Wrapped<Unannotated<R>>> : H[K] extends (...args: infer A) => infer R ? (...args: A) => [R] extends [void] ? void : Wrapped<Unannotated<R>> : H[K];
1192
1199
  };
1200
+ /** A helper's return type as the *test* sees it. {@link annotate} boxes the
1201
+ * value in an {@link Annotated} to pick how the step renders; the daemon
1202
+ * opens that box at the helper boundary, so the annotation never reaches
1203
+ * the caller — in the types either. Distributes over a union, so a helper
1204
+ * that annotates only when it has something (`return m && annotate(m, "email")`)
1205
+ * still reads as `T | undefined`. */
1206
+ type Unannotated<R> = R extends Annotated<infer U> ? U : R;
1193
1207
  /** Awaited return type of a fake's `helpers` factory (with each result
1194
1208
  * inspect-wrapped, see {@link WrappedHelpers}), or `{ state: S }` (the
1195
1209
  * default) when the user didn't ship one. */
package/dist/index.js CHANGED
@@ -16,6 +16,13 @@ import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
16
16
  // always wrapped (in every context), so the method is always there; there is no
17
17
  // `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
18
18
  export { field } from "./inspect.js";
19
+ // Render annotations for fake helpers: a helper returns
20
+ // `annotate(value, "email", { … })` when its value is an email, and the
21
+ // fake step it records gains a nested `email` event — the same one the
22
+ // built-in `email()` component records — so the step's panel can draw the
23
+ // mail, tabbing to the raw JSON value. The kind picks the options' type, and the test still gets
24
+ // the raw value, with the raw value's type — see `annotate.ts`.
25
+ export { annotate } from "./annotate.js";
19
26
  // Instrumented client primitives — drop-in replacements for Bun's native
20
27
  // clients that record each operation on the test event log and return their
21
28
  // results inspect-wrapped, so `expect(...)` on a result links back to the op
@@ -18,6 +18,15 @@ interface BaseEvent {
18
18
  * timelines. The parent event is identified by its `seq`.
19
19
  */
20
20
  parentSeq?: number;
21
+ /**
22
+ * Set when this event is not an op of its own but *another view of its
23
+ * parent's value* — what a fake helper's `annotate(...)` records
24
+ * (`annotate.ts`). The two kinds of `parentSeq` child read differently
25
+ * and render differently: a `ctx.poll` iteration is work the step did,
26
+ * and belongs below it; an annotation is the same value drawn better,
27
+ * and belongs in place of it (the dashboard tabs between the two).
28
+ */
29
+ annotation?: boolean;
21
30
  }
22
31
  export interface ExecEvent extends BaseEvent {
23
32
  kind: "exec";
@@ -311,14 +320,17 @@ export interface EmailEventMessage {
311
320
  size: number;
312
321
  }[];
313
322
  }
314
- /** One row of a mailbox listing embedded on an {@link EmailEvent}. */
315
- export interface EmailEventSummary {
316
- from?: string;
317
- to?: string[];
318
- subject?: string;
319
- /** Plain-text preview of the body. */
323
+ /**
324
+ * One row of a mailbox listing embedded on an {@link EmailEvent}. A row is a
325
+ * message: the dashboard lists rows as `to` + subject and expands the one you
326
+ * click into the same view a single-message op renders, so whatever body the
327
+ * producer has belongs here. Mailpit's list API returns no bodies, so
328
+ * `email()`'s own listings fill only the header fields plus `snippet` — a row
329
+ * with no body expands to its preview instead.
330
+ */
331
+ export interface EmailEventSummary extends EmailEventMessage {
332
+ /** Plain-text preview of the body, shown when the row carries no body. */
320
333
  snippet?: string;
321
- date?: string;
322
334
  }
323
335
  /**
324
336
  * One call to an email service's helpers (`ctx.svc.<name>.lastEmail(...)`
@@ -332,9 +344,13 @@ export interface EmailEventSummary {
332
344
  */
333
345
  export interface EmailEvent extends BaseEvent {
334
346
  kind: "email";
335
- /** Service key of the mail server (`ctx.svc.<service>`). */
347
+ /** Service key of the mail server (`ctx.svc.<service>`) — or the fake's
348
+ * name, when a fake helper annotated its return with `annotate(v, "email")`
349
+ * (`annotate.ts`) and this event hangs off that call's `fake` one as a
350
+ * `parentSeq` child. */
336
351
  service: string;
337
- /** Helper called, e.g. `"lastEmail"`. */
352
+ /** Helper called, e.g. `"lastEmail"` — the fake's member name for an
353
+ * annotated fake-helper call. */
338
354
  op: string;
339
355
  /** Human-readable match criteria, e.g. `to alice@example.com`. */
340
356
  query?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,292 @@
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.
32
+
33
+ import { deepUnwrap } from "./inspect.js";
34
+ import {
35
+ truncateUtf8,
36
+ type EmailEventMessage,
37
+ type EmailEventSummary,
38
+ } from "./recorder.js";
39
+
40
+ /**
41
+ * Runtime marker on the box `annotate(...)` returns. `Symbol.for` rather
42
+ * than a module-local symbol so a second copy of the SDK still recognises
43
+ * a box minted by the first — the duplicate-module-instance hazard that
44
+ * silently drops assertion events when the SDK lands in an app dir twice
45
+ * (see `sdk.rs`).
46
+ */
47
+ const ANNOTATION = Symbol.for("spectest.render-annotation");
48
+
49
+ /** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
50
+ * box can't be read as the value by accident inside the fake, and the
51
+ * helper types can recognise it and hand the *test* back a plain `T`. */
52
+ declare const ANNOTATED: unique symbol;
53
+
54
+ /**
55
+ * A value plus a rendering hint, as returned by {@link annotate}. Return it
56
+ * straight from a fake helper: the daemon unwraps it, so the test receives
57
+ * the value itself and `ctx.fakes.<name>.<fn>()` is typed as if the
58
+ * annotation weren't there.
59
+ */
60
+ export interface Annotated<T> {
61
+ readonly [ANNOTATED]: T;
62
+ }
63
+
64
+ /**
65
+ * Every annotation kind, and the options each one takes. This interface is
66
+ * the whole type contract of {@link annotate}: naming a kind picks the type
67
+ * of the options argument, so a stray field or a value that belongs to
68
+ * another kind is a compile error at the call site.
69
+ *
70
+ * A new kind is an entry here, a case in `lower`, and an arm in
71
+ * `daemon.ts::recordAnnotationChild`.
72
+ */
73
+ export interface AnnotationOptions {
74
+ /** Draw the value as an email — one message, or a list of them for a
75
+ * mailbox listing. See {@link EmailAnnotation}. */
76
+ email: EmailAnnotation | readonly EmailAnnotation[];
77
+ }
78
+
79
+ /** The kinds {@link annotate} accepts. */
80
+ export type AnnotationKind = keyof AnnotationOptions;
81
+
82
+ /**
83
+ * What an annotation lowers to: the fields of the child event the daemon
84
+ * records under the call's own `fake` one. Tagged by that event's `kind` —
85
+ * which needn't be the annotation kind's name, since an annotation names
86
+ * what the value *is* and the event names how it's drawn.
87
+ */
88
+ export type RenderAnnotation = {
89
+ kind: "email";
90
+ /** Single-message ops. */
91
+ message?: EmailEventMessage;
92
+ /** Listing ops. */
93
+ messages?: EmailEventSummary[];
94
+ /** Total matches (a listing is capped on the event, never in the value). */
95
+ count?: number;
96
+ };
97
+
98
+ /**
99
+ * The email fields to render. Every one is optional — pass what the fake
100
+ * has. Addresses take a single string or a list.
101
+ *
102
+ * The event can also carry a date, per-row previews and attachment
103
+ * metadata, which the built-in `email()` component fills from a really
104
+ * captured message; a fake's mail was never sent, so those are deliberately
105
+ * not offered here. A listing row's preview is derived from the body.
106
+ */
107
+ export interface EmailAnnotation {
108
+ from?: string;
109
+ to?: string | readonly string[];
110
+ cc?: string | readonly string[];
111
+ bcc?: string | readonly string[];
112
+ subject?: string;
113
+ /** HTML body. Rendered in a fully sandboxed iframe (scripts off). */
114
+ html?: string;
115
+ /** Plain-text body. Shown on its own when there's no HTML, folded into a
116
+ * "Plain-text version" disclosure when there is. */
117
+ text?: string;
118
+ }
119
+
120
+ /** Listing rows carried on the event. The returned value is never capped. */
121
+ const LIST_CAP = 50;
122
+
123
+ /** How much of a body becomes a listing row's preview. */
124
+ const SNIPPET_CHARS = 200;
125
+
126
+ const NO_FIELDS =
127
+ 'annotate(value, "email"): nothing to render — no email fields found on ' +
128
+ "the value. Pass them explicitly, e.g. " +
129
+ 'annotate(value, "email", { to: value.recipient, subject: value.title, ' +
130
+ "html: value.body }).";
131
+
132
+ /**
133
+ * Say what a fake helper's return value *is*, so the timeline can draw it
134
+ * as that on top of the JSON it always shows. The call stays an ordinary
135
+ * fake step; the rendered view nests inside it. The value is returned to
136
+ * the test unchanged (and unchanged in type).
137
+ *
138
+ * ```ts
139
+ * helpers: ({ state }) => ({
140
+ * lastReceipt() {
141
+ * const r = state.receipts.at(-1);
142
+ * return r && annotate(r, "email", {
143
+ * from: "receipts@stripe.test",
144
+ * to: r.customer,
145
+ * subject: `Receipt for ${r.description}`,
146
+ * html: r.body,
147
+ * });
148
+ * },
149
+ * })
150
+ * ```
151
+ *
152
+ * `kind` picks what the options are: `"email"` takes an
153
+ * {@link EmailAnnotation} (or a list of them, which renders a mailbox
154
+ * listing instead of one message). Every field is optional, and with no
155
+ * options at all they're read off the value itself — enough when it already
156
+ * carries `from`/`to`/`subject`/`html`/`text`. Throws if there is nothing to
157
+ * render, rather than recording an empty step.
158
+ */
159
+ export function annotate<T, K extends AnnotationKind>(
160
+ value: T,
161
+ kind: K,
162
+ options?: AnnotationOptions[K],
163
+ ): Annotated<T> {
164
+ return box(value, lower(kind, options, value));
165
+ }
166
+
167
+ /** Turn a kind + its options into the event fields to record. */
168
+ function lower<K extends AnnotationKind>(
169
+ kind: K,
170
+ options: AnnotationOptions[K] | undefined,
171
+ value: unknown,
172
+ ): RenderAnnotation {
173
+ switch (kind) {
174
+ case "email":
175
+ return lowerEmail(options as AnnotationOptions["email"] | undefined, value);
176
+ default:
177
+ // Unreachable while `kind` is a key of AnnotationOptions; a kind added
178
+ // to that interface without a case here lands on this line.
179
+ throw new Error(`annotate(): unknown annotation kind ${String(kind)}`);
180
+ }
181
+ }
182
+
183
+ function lowerEmail(
184
+ options: AnnotationOptions["email"] | undefined,
185
+ value: unknown,
186
+ ): RenderAnnotation {
187
+ // `deepUnwrap` only where fields are *read* — a value off another
188
+ // instrumented call is a provenance carrier, and `String(carrier)` would
189
+ // otherwise be its coerced shape rather than the address it holds. The
190
+ // value handed back to the test is never touched.
191
+ const source: unknown = deepUnwrap(options ?? value);
192
+ if (Array.isArray(source)) {
193
+ const messages = source.slice(0, LIST_CAP).map((m) => summaryOf(fields(m)));
194
+ if (source.length > 0 && messages.every((m) => isEmpty(m))) {
195
+ throw new Error(NO_FIELDS);
196
+ }
197
+ return { kind: "email", messages, count: source.length };
198
+ }
199
+ const message = messageOf(fields(source));
200
+ if (isEmpty(message)) throw new Error(NO_FIELDS);
201
+ return { kind: "email", message };
202
+ }
203
+
204
+ /** Open an annotation box. Returns `undefined` for any ordinary value, so
205
+ * call sites can treat annotation as the exception it is. */
206
+ export function readAnnotation(
207
+ value: unknown,
208
+ ): { annotation: RenderAnnotation; value: unknown } | undefined {
209
+ if (typeof value !== "object" || value === null) return undefined;
210
+ const annotation = (value as Record<symbol, unknown>)[ANNOTATION] as
211
+ | RenderAnnotation
212
+ | undefined;
213
+ if (!annotation) return undefined;
214
+ return { annotation, value: (value as { value: unknown }).value };
215
+ }
216
+
217
+ function box<T>(value: T, annotation: RenderAnnotation): Annotated<T> {
218
+ return { [ANNOTATION]: annotation, value } as unknown as Annotated<T>;
219
+ }
220
+
221
+ function isEmpty(o: object): boolean {
222
+ return Object.keys(o).length === 0;
223
+ }
224
+
225
+ /** The value as a field bag — anything that isn't an object contributes
226
+ * nothing, and falls through to the `NO_FIELDS` error. */
227
+ function fields(v: unknown): Record<string, unknown> {
228
+ return typeof v === "object" && v !== null
229
+ ? (v as Record<string, unknown>)
230
+ : {};
231
+ }
232
+
233
+ function messageOf(src: Record<string, unknown>): EmailEventMessage {
234
+ const msg: EmailEventMessage = {};
235
+ const from = text(src.from);
236
+ if (from) msg.from = from;
237
+ for (const key of ["to", "cc", "bcc"] as const) {
238
+ const addrs = addresses(src[key]);
239
+ if (addrs.length > 0) msg[key] = addrs;
240
+ }
241
+ const subject = text(src.subject);
242
+ if (subject) msg.subject = subject;
243
+ const html = text(src.html);
244
+ if (html) {
245
+ const t = truncateUtf8(html);
246
+ msg.html = t.value;
247
+ if (t.truncated) msg.htmlTruncated = true;
248
+ }
249
+ const body = text(src.text);
250
+ if (body) {
251
+ const t = truncateUtf8(body);
252
+ msg.text = t.value;
253
+ if (t.truncated) msg.textTruncated = true;
254
+ }
255
+ return msg;
256
+ }
257
+
258
+ /** A listing row is a whole message plus its preview line — the dashboard
259
+ * expands the row you click into the full view, so the body has to ride
260
+ * along rather than being summarised away. */
261
+ function summaryOf(src: Record<string, unknown>): EmailEventSummary {
262
+ const row: EmailEventSummary = messageOf(src);
263
+ const snippet = preview(text(src.text), text(src.html));
264
+ if (snippet) row.snippet = snippet;
265
+ return row;
266
+ }
267
+
268
+ /** A listing row's preview: the plain-text body if there is one, else the
269
+ * HTML with its tags stripped — enough to tell two messages apart. */
270
+ function preview(body: string, html: string): string {
271
+ const raw = body || html.replace(/<[^>]*>/g, " ");
272
+ const collapsed = raw.replace(/\s+/g, " ").trim();
273
+ return collapsed.length > SNIPPET_CHARS
274
+ ? `${collapsed.slice(0, SNIPPET_CHARS)}…`
275
+ : collapsed;
276
+ }
277
+
278
+ /** Scalars render as themselves; anything else (an object, a nested array)
279
+ * is not an address or a subject line and is dropped rather than shown as
280
+ * `[object Object]`. */
281
+ function text(v: unknown): string {
282
+ if (typeof v === "string") return v;
283
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
284
+ return "";
285
+ }
286
+
287
+ function addresses(v: unknown): string[] {
288
+ if (typeof v === "string") return v ? [v] : [];
289
+ if (!Array.isArray(v)) return [];
290
+ return v.map((a) => text(a)).filter((a) => a.length > 0);
291
+ }
292
+
package/src/daemon.ts CHANGED
@@ -112,7 +112,9 @@ import {
112
112
  } from "./harness/methods.js";
113
113
  import type { Mobile, MobileApp } from "./mobile.js";
114
114
  import { openTerminal } from "./terminal.js";
115
+ import { readAnnotation, type RenderAnnotation } from "./annotate.js";
115
116
  import {
117
+ recordEmail,
116
118
  recordEnv,
117
119
  recordExec,
118
120
  recordFake,
@@ -2728,14 +2730,27 @@ function invokeFakeHelper(
2728
2730
  const args = callArgs.map((a) => deepUnwrap(a));
2729
2731
  const safeArgs = args.map((a) => safeSerialize(a));
2730
2732
  const recordResult = (value: unknown): unknown => {
2733
+ // A helper may box its return in a render annotation
2734
+ // (`annotate(v, "email", …)`). The box never reaches the test, and it
2735
+ // never replaces the step either: the call is recorded as the ordinary
2736
+ // `fake` event it is, and the annotation rides *under* it as a child
2737
+ // event (`parentSeq`), which the dashboard folds into the fake step's
2738
+ // detail panel. So the timeline reads the same as any other helper
2739
+ // call, with the richer view one click in.
2740
+ const annotated = readAnnotation(value);
2741
+ const raw = annotated ? annotated.value : value;
2742
+ const durationMs = Date.now() - t;
2731
2743
  const seq = recordFake({
2732
2744
  fake: fakeName,
2733
2745
  member,
2734
2746
  args: safeArgs,
2735
- result: safeSerialize(value),
2736
- durationMs: Date.now() - t,
2747
+ result: safeSerialize(raw),
2748
+ durationMs,
2737
2749
  }, resv);
2738
- return wrap(value, seq);
2750
+ if (annotated && seq !== undefined) {
2751
+ recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
2752
+ }
2753
+ return wrap(raw, seq);
2739
2754
  };
2740
2755
  const recordError = (err: unknown): void => {
2741
2756
  recordFake({
@@ -2763,6 +2778,42 @@ function invokeFakeHelper(
2763
2778
  return recordResult(result);
2764
2779
  }
2765
2780
 
2781
+ /** Record a fake-helper call's render annotation as a child of the call's
2782
+ * own `fake` event — the same `parentSeq` grouping `ctx.poll` uses for the
2783
+ * iteration it kept, so the annotated view folds into the fake step's
2784
+ * detail panel instead of taking a timeline row of its own.
2785
+ *
2786
+ * The child is the event kind that already knows how to draw this: an
2787
+ * `email` annotation records the very event the built-in `email()`
2788
+ * component's mailbox helpers record, so it renders with no new code. It
2789
+ * carries the fake's name and member as its service/op, and the parent's
2790
+ * duration, since it describes that same call.
2791
+ *
2792
+ * Only the success path is annotated: a helper that threw returned no value
2793
+ * to annotate, so it's a plain `fake` error event. */
2794
+ function recordAnnotationChild(
2795
+ fakeName: string,
2796
+ member: string,
2797
+ annotation: RenderAnnotation,
2798
+ durationMs: number,
2799
+ parentSeq: number,
2800
+ ): void {
2801
+ switch (annotation.kind) {
2802
+ case "email":
2803
+ recordEmail({
2804
+ parentSeq,
2805
+ annotation: true,
2806
+ service: fakeName,
2807
+ op: member,
2808
+ message: annotation.message,
2809
+ messages: annotation.messages,
2810
+ count: annotation.count,
2811
+ durationMs,
2812
+ });
2813
+ break;
2814
+ }
2815
+ }
2816
+
2766
2817
  function errMessage(err: unknown): string {
2767
2818
  return (err as Error)?.message ?? String(err);
2768
2819
  }
package/src/index.ts CHANGED
@@ -34,6 +34,21 @@ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
34
34
  // `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
35
35
  export { field } from "./inspect.js";
36
36
 
37
+ // Render annotations for fake helpers: a helper returns
38
+ // `annotate(value, "email", { … })` when its value is an email, and the
39
+ // fake step it records gains a nested `email` event — the same one the
40
+ // built-in `email()` component records — so the step's panel can draw the
41
+ // mail, tabbing to the raw JSON value. The kind picks the options' type, and the test still gets
42
+ // the raw value, with the raw value's type — see `annotate.ts`.
43
+ export { annotate } from "./annotate.js";
44
+ export type {
45
+ Annotated,
46
+ AnnotationKind,
47
+ AnnotationOptions,
48
+ EmailAnnotation,
49
+ } from "./annotate.js";
50
+ import type { Annotated } from "./annotate.js";
51
+
37
52
  // Instrumented client primitives — drop-in replacements for Bun's native
38
53
  // clients that record each operation on the test event log and return their
39
54
  // results inspect-wrapped, so `expect(...)` on a result links back to the op
@@ -1633,6 +1648,10 @@ export interface FakeDefinition<
1633
1648
  * Every call is tracked in the test timeline: it records a `fake` step
1634
1649
  * and the return value is tagged so a later `expect(...)` on it nests
1635
1650
  * under that step in the UI (same provenance as `fetch`/db results).
1651
+ * The step renders the return value as JSON; wrap it in {@link annotate}
1652
+ * to add a richer view (an email, today) that the step's panel leads with,
1653
+ * the JSON one tab away — the test still receives the raw value, unchanged
1654
+ * and unchanged in type.
1636
1655
  *
1637
1656
  * Receives the fake's `state` plus a {@link FakeContext} `ctx`, so a
1638
1657
  * helper can provision/teardown runtime services just like the handler.
@@ -1703,12 +1722,20 @@ export type FakesMap = Record<string, FakeDefinition<any, any>>;
1703
1722
  * `components/k3s.ts`, extended to cover synchronous returns. */
1704
1723
  type WrappedHelpers<H> = {
1705
1724
  [K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R>
1706
- ? (...args: A) => Promise<Wrapped<R>>
1725
+ ? (...args: A) => Promise<Wrapped<Unannotated<R>>>
1707
1726
  : H[K] extends (...args: infer A) => infer R
1708
- ? (...args: A) => [R] extends [void] ? void : Wrapped<R>
1727
+ ? (...args: A) => [R] extends [void] ? void : Wrapped<Unannotated<R>>
1709
1728
  : H[K];
1710
1729
  };
1711
1730
 
1731
+ /** A helper's return type as the *test* sees it. {@link annotate} boxes the
1732
+ * value in an {@link Annotated} to pick how the step renders; the daemon
1733
+ * opens that box at the helper boundary, so the annotation never reaches
1734
+ * the caller — in the types either. Distributes over a union, so a helper
1735
+ * that annotates only when it has something (`return m && annotate(m, "email")`)
1736
+ * still reads as `T | undefined`. */
1737
+ type Unannotated<R> = R extends Annotated<infer U> ? U : R;
1738
+
1712
1739
  /** Awaited return type of a fake's `helpers` factory (with each result
1713
1740
  * inspect-wrapped, see {@link WrappedHelpers}), or `{ state: S }` (the
1714
1741
  * default) when the user didn't ship one. */
package/src/recorder.ts CHANGED
@@ -47,6 +47,15 @@ interface BaseEvent {
47
47
  * timelines. The parent event is identified by its `seq`.
48
48
  */
49
49
  parentSeq?: number;
50
+ /**
51
+ * Set when this event is not an op of its own but *another view of its
52
+ * parent's value* — what a fake helper's `annotate(...)` records
53
+ * (`annotate.ts`). The two kinds of `parentSeq` child read differently
54
+ * and render differently: a `ctx.poll` iteration is work the step did,
55
+ * and belongs below it; an annotation is the same value drawn better,
56
+ * and belongs in place of it (the dashboard tabs between the two).
57
+ */
58
+ annotation?: boolean;
50
59
  }
51
60
 
52
61
  export interface ExecEvent extends BaseEvent {
@@ -348,14 +357,17 @@ export interface EmailEventMessage {
348
357
  attachments?: { filename: string; contentType: string; size: number }[];
349
358
  }
350
359
 
351
- /** One row of a mailbox listing embedded on an {@link EmailEvent}. */
352
- export interface EmailEventSummary {
353
- from?: string;
354
- to?: string[];
355
- subject?: string;
356
- /** Plain-text preview of the body. */
360
+ /**
361
+ * One row of a mailbox listing embedded on an {@link EmailEvent}. A row is a
362
+ * message: the dashboard lists rows as `to` + subject and expands the one you
363
+ * click into the same view a single-message op renders, so whatever body the
364
+ * producer has belongs here. Mailpit's list API returns no bodies, so
365
+ * `email()`'s own listings fill only the header fields plus `snippet` — a row
366
+ * with no body expands to its preview instead.
367
+ */
368
+ export interface EmailEventSummary extends EmailEventMessage {
369
+ /** Plain-text preview of the body, shown when the row carries no body. */
357
370
  snippet?: string;
358
- date?: string;
359
371
  }
360
372
 
361
373
  /**
@@ -370,9 +382,13 @@ export interface EmailEventSummary {
370
382
  */
371
383
  export interface EmailEvent extends BaseEvent {
372
384
  kind: "email";
373
- /** Service key of the mail server (`ctx.svc.<service>`). */
385
+ /** Service key of the mail server (`ctx.svc.<service>`) — or the fake's
386
+ * name, when a fake helper annotated its return with `annotate(v, "email")`
387
+ * (`annotate.ts`) and this event hangs off that call's `fake` one as a
388
+ * `parentSeq` child. */
374
389
  service: string;
375
- /** Helper called, e.g. `"lastEmail"`. */
390
+ /** Helper called, e.g. `"lastEmail"` — the fake's member name for an
391
+ * annotated fake-helper call. */
376
392
  op: string;
377
393
  /** Human-readable match criteria, e.g. `to alice@example.com`. */
378
394
  query?: string;