@specific.dev/spectest 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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, ChatAnnotation, ChatMessageAnnotation, 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,14 @@ 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", { … })` or `annotate(value, "chat", { … })`
21
+ // when its value is one of those, and the fake step it records gains a
22
+ // nested event drawing it — an email mail-client style, a conversation as
23
+ // bubbles — which the step's panel leads with, tabbing to the raw JSON
24
+ // value. The kind picks the options' type, and the test still gets the raw
25
+ // value, with the raw value's type — see `annotate.ts`.
26
+ export { annotate } from "./annotate.js";
19
27
  // Instrumented client primitives — drop-in replacements for Bun's native
20
28
  // clients that record each operation on the test event log and return their
21
29
  // results inspect-wrapped, so `expect(...)` on a result links back to the op
@@ -1,4 +1,4 @@
1
- export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent;
1
+ export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent | StepEvent;
2
2
  interface BaseEvent {
3
3
  /** Order of *start* within the test. Reserved when an op begins (see
4
4
  * `reserveEvent`), so an op whose nested children finish — and record —
@@ -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;
@@ -541,6 +557,74 @@ export declare function recordTerminal(ev: Omit<TerminalEvent, "seq" | "tOffsetM
541
557
  export declare function recordTerminalStep(ev: Omit<TerminalStepEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
542
558
  export declare function recordFake(ev: Omit<FakeEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
543
559
  export declare function recordEnv(ev: Omit<EnvEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
560
+ /**
561
+ * One block of step detail, in the dashboard's presentation vocabulary
562
+ * (`crates/control-plane/src/web/blocks.rs`, which owns the closed set).
563
+ * A server that predates a block type skips it rather than failing, so a
564
+ * newer SDK's step degrades to the blocks the server knows.
565
+ */
566
+ export type StepBlock = {
567
+ type: "text";
568
+ text: string;
569
+ } | {
570
+ type: "code";
571
+ code: string;
572
+ lang?: string;
573
+ label?: string;
574
+ } | {
575
+ type: "json";
576
+ value: unknown;
577
+ label?: string;
578
+ } | {
579
+ type: "kv";
580
+ rows: {
581
+ label: string;
582
+ value?: string;
583
+ error?: boolean;
584
+ }[];
585
+ } | {
586
+ type: "table";
587
+ columns: string[];
588
+ rows: unknown[][];
589
+ } | {
590
+ type: "htmlSandbox";
591
+ html: string;
592
+ label?: string;
593
+ } | {
594
+ type: "chat";
595
+ messages: ChatBlockMessage[];
596
+ label?: string;
597
+ };
598
+ /** One message of a `chat` block. */
599
+ export interface ChatBlockMessage {
600
+ /** Who sent it. The transcript is drawn as the end user would see it, so
601
+ * `self` (the end user's own messages) sits on the right and `other` on
602
+ * the left. */
603
+ side?: "self" | "other";
604
+ text?: string;
605
+ /** Arrived in *this* step: drawn with an entrance animation. Only the
606
+ * producer knows this, so only the producer sets it. */
607
+ new?: boolean;
608
+ }
609
+ /**
610
+ * A step that describes itself in presentation terms — a title plus an
611
+ * ordered list of blocks — instead of as one of the recorder's hard-coded
612
+ * kinds. This is the seam that lets a component ship a new kind of step
613
+ * with no server change: `kind` stays an opaque grouping/diff-alignment
614
+ * label and never decides how the step draws.
615
+ */
616
+ export interface StepEvent extends BaseEvent {
617
+ /** Opaque. Groups the step and aligns it across runs; never matched on
618
+ * to pick a renderer. */
619
+ kind: string;
620
+ /** The step's one-line summary in the timeline. */
621
+ title: string;
622
+ blocks?: StepBlock[];
623
+ status?: "passed" | "failed" | "error";
624
+ durationMs?: number;
625
+ error?: string;
626
+ }
627
+ export declare function recordStep(ev: Omit<StepEvent, "seq" | "tOffsetMs">, reservation?: EventReservation): number | undefined;
544
628
  export declare function recordEmail(ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
545
629
  /**
546
630
  * Recorded once per `ctx.poll(...)` call. Stands in for the suppressed
package/dist/recorder.js CHANGED
@@ -49,14 +49,24 @@ class Recorder {
49
49
  this.events.length = toLen;
50
50
  }
51
51
  }
52
- /** Stamp `parentSeq` onto every event at index >= `startIdx`. Used
53
- * by `ctx.poll` to group the kept iteration's events under the
54
- * resulting wait event. */
52
+ /** Stamp `parentSeq` onto every event at index >= `startIdx` that isn't
53
+ * already grouped under something else. Used by `ctx.poll` to group the
54
+ * kept iteration's events under the resulting wait event.
55
+ *
56
+ * An event that already carries a `parentSeq` keeps it: it belongs to a
57
+ * step *inside* this one, and re-parenting it here would flatten the
58
+ * nesting the UI renders from. The case that made this matter is a fake
59
+ * helper called in a poll predicate — its `annotate(...)` child is the
60
+ * fake call's value, and stamping the wait's seq over it made the WAIT
61
+ * render as an email/chat while the fake call showed raw JSON. Its
62
+ * ancestor is still the wait, one level up through the fake event. */
55
63
  markChildren(startIdx, parentSeq) {
56
64
  for (let i = startIdx; i < this.events.length; i++) {
57
65
  const ev = this.events[i];
58
66
  if (ev.seq === parentSeq)
59
67
  continue;
68
+ if (ev.parentSeq !== undefined)
69
+ continue;
60
70
  ev.parentSeq = parentSeq;
61
71
  }
62
72
  }
@@ -178,6 +188,9 @@ export function recordFake(ev, reservation) {
178
188
  export function recordEnv(ev, reservation) {
179
189
  return active() ? current.push({ kind: "env", ...ev }, reservation) : undefined;
180
190
  }
191
+ export function recordStep(ev, reservation) {
192
+ return active() ? current.push(ev, reservation) : undefined;
193
+ }
181
194
  export function recordEmail(ev, reservation) {
182
195
  return active() ? current.push({ kind: "email", ...ev }, reservation) : undefined;
183
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.48.0",
3
+ "version": "0.50.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,395 @@
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 ChatBlockMessage,
37
+ type EmailEventMessage,
38
+ type EmailEventSummary,
39
+ type StepBlock,
40
+ } from "./recorder.js";
41
+
42
+ /**
43
+ * Runtime marker on the box `annotate(...)` returns. `Symbol.for` rather
44
+ * than a module-local symbol so a second copy of the SDK still recognises
45
+ * a box minted by the first — the duplicate-module-instance hazard that
46
+ * silently drops assertion events when the SDK lands in an app dir twice
47
+ * (see `sdk.rs`).
48
+ */
49
+ const ANNOTATION = Symbol.for("spectest.render-annotation");
50
+
51
+ /** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
52
+ * box can't be read as the value by accident inside the fake, and the
53
+ * helper types can recognise it and hand the *test* back a plain `T`. */
54
+ declare const ANNOTATED: unique symbol;
55
+
56
+ /**
57
+ * A value plus a rendering hint, as returned by {@link annotate}. Return it
58
+ * straight from a fake helper: the daemon unwraps it, so the test receives
59
+ * the value itself and `ctx.fakes.<name>.<fn>()` is typed as if the
60
+ * annotation weren't there.
61
+ */
62
+ export interface Annotated<T> {
63
+ readonly [ANNOTATED]: T;
64
+ }
65
+
66
+ /**
67
+ * Every annotation kind, and the options each one takes. This interface is
68
+ * the whole type contract of {@link annotate}: naming a kind picks the type
69
+ * of the options argument, so a stray field or a value that belongs to
70
+ * another kind is a compile error at the call site.
71
+ *
72
+ * A new kind is an entry here, a case in `lower`, and an arm in
73
+ * `daemon.ts::recordAnnotationChild`.
74
+ */
75
+ export interface AnnotationOptions {
76
+ /** Draw the value as an email — one message, or a list of them for a
77
+ * mailbox listing. See {@link EmailAnnotation}. */
78
+ email: EmailAnnotation | readonly EmailAnnotation[];
79
+ /** Draw the value as a chat transcript — bubbles by role, with the
80
+ * messages that arrived in this call marked. See {@link ChatAnnotation}. */
81
+ chat: ChatAnnotation | readonly ChatMessageAnnotation[];
82
+ }
83
+
84
+ /** The kinds {@link annotate} accepts. */
85
+ export type AnnotationKind = keyof AnnotationOptions;
86
+
87
+ /**
88
+ * What an annotation lowers to: the fields of the child event the daemon
89
+ * records under the call's own `fake` one. Tagged by that event's `kind` —
90
+ * which needn't be the annotation kind's name, since an annotation names
91
+ * what the value *is* and the event names how it's drawn.
92
+ */
93
+ export type RenderAnnotation = EmailRenderAnnotation | ChatRenderAnnotation;
94
+
95
+ type EmailRenderAnnotation = {
96
+ kind: "email";
97
+ /** Single-message ops. */
98
+ message?: EmailEventMessage;
99
+ /** Listing ops. */
100
+ messages?: EmailEventSummary[];
101
+ /** Total matches (a listing is capped on the event, never in the value). */
102
+ count?: number;
103
+ };
104
+
105
+ /** A chat lowers to a step that describes itself in presentation terms — a
106
+ * title plus blocks — rather than to a kind of its own: `blocks.rs` owns
107
+ * the drawing vocabulary, and a transcript is one of its block types. */
108
+ type ChatRenderAnnotation = {
109
+ kind: "chat";
110
+ title?: string;
111
+ blocks: StepBlock[];
112
+ };
113
+
114
+ /**
115
+ * The email fields to render. Every one is optional — pass what the fake
116
+ * has. Addresses take a single string or a list.
117
+ *
118
+ * The event can also carry a date, per-row previews and attachment
119
+ * metadata, which the built-in `email()` component fills from a really
120
+ * captured message; a fake's mail was never sent, so those are deliberately
121
+ * not offered here. A listing row's preview is derived from the body.
122
+ */
123
+ export interface EmailAnnotation {
124
+ from?: string;
125
+ to?: string | readonly string[];
126
+ cc?: string | readonly string[];
127
+ bcc?: string | readonly string[];
128
+ subject?: string;
129
+ /** HTML body. Rendered in a fully sandboxed iframe (scripts off). */
130
+ html?: string;
131
+ /** Plain-text body. Shown on its own when there's no HTML, folded into a
132
+ * "Plain-text version" disclosure when there is. */
133
+ text?: string;
134
+ }
135
+
136
+ /**
137
+ * A conversation to draw as chat bubbles.
138
+ *
139
+ * Two sides and their text, which is all a transcript needs to read as one.
140
+ * It is drawn from the end user's point of view — their own messages on the
141
+ * right, the other party's on the left.
142
+ * Pass the messages alone (`annotate(v, "chat", messages)`), or this object
143
+ * when the thread has an identity worth showing. With no options at all the
144
+ * value itself is read as the message list.
145
+ */
146
+ export interface ChatAnnotation {
147
+ messages: readonly ChatMessageAnnotation[];
148
+ /**
149
+ * The thread's identity — a phone number, a channel, whoever is on the
150
+ * other end — shown as a header over the bubbles. Optional: a fake with
151
+ * one conversation has nothing useful to put here, and the transcript
152
+ * reads fine without it.
153
+ */
154
+ title?: string;
155
+ }
156
+
157
+ /** One turn of a {@link ChatAnnotation}. */
158
+ export interface ChatMessageAnnotation {
159
+ /**
160
+ * Who sent it. A transcript is drawn the way the **end user** would see
161
+ * it on their own screen, so `self` is the end user's own messages — the
162
+ * person using the app under test — and sits on the right; `other` is
163
+ * whoever they are talking to and sits on the left.
164
+ */
165
+ side: "self" | "other";
166
+ text: string;
167
+ /**
168
+ * This message arrived in *this* call: it slides in when the step opens,
169
+ * and again whenever it is reopened. Only the fake knows which turns are new —
170
+ * the dashboard sees one call, not the conversation's history — so it is
171
+ * the fake that marks them.
172
+ */
173
+ new?: boolean;
174
+ }
175
+
176
+ /** Listing rows carried on the event. The returned value is never capped. */
177
+ const LIST_CAP = 50;
178
+
179
+ /** How much of a body becomes a listing row's preview. */
180
+ const SNIPPET_CHARS = 200;
181
+
182
+ const NO_FIELDS =
183
+ 'annotate(value, "email"): nothing to render — no email fields found on ' +
184
+ "the value. Pass them explicitly, e.g. " +
185
+ 'annotate(value, "email", { to: value.recipient, subject: value.title, ' +
186
+ "html: value.body }).";
187
+
188
+ /**
189
+ * Say what a fake helper's return value *is*, so the timeline can draw it
190
+ * as that on top of the JSON it always shows. The call stays an ordinary
191
+ * fake step; the rendered view nests inside it. The value is returned to
192
+ * the test unchanged (and unchanged in type).
193
+ *
194
+ * ```ts
195
+ * helpers: ({ state }) => ({
196
+ * lastReceipt() {
197
+ * const r = state.receipts.at(-1);
198
+ * return r && annotate(r, "email", {
199
+ * from: "receipts@stripe.test",
200
+ * to: r.customer,
201
+ * subject: `Receipt for ${r.description}`,
202
+ * html: r.body,
203
+ * });
204
+ * },
205
+ * })
206
+ * ```
207
+ *
208
+ * `kind` picks what the options are: `"email"` takes an
209
+ * {@link EmailAnnotation} (or a list of them, which renders a mailbox
210
+ * listing instead of one message). Every field is optional, and with no
211
+ * options at all they're read off the value itself — enough when it already
212
+ * carries `from`/`to`/`subject`/`html`/`text`. Throws if there is nothing to
213
+ * render, rather than recording an empty step.
214
+ */
215
+ export function annotate<T, K extends AnnotationKind>(
216
+ value: T,
217
+ kind: K,
218
+ options?: AnnotationOptions[K],
219
+ ): Annotated<T> {
220
+ return box(value, lower(kind, options, value));
221
+ }
222
+
223
+ /** Turn a kind + its options into the event fields to record. */
224
+ function lower<K extends AnnotationKind>(
225
+ kind: K,
226
+ options: AnnotationOptions[K] | undefined,
227
+ value: unknown,
228
+ ): RenderAnnotation {
229
+ switch (kind) {
230
+ case "email":
231
+ return lowerEmail(options as AnnotationOptions["email"] | undefined, value);
232
+ case "chat":
233
+ return lowerChat(options as AnnotationOptions["chat"] | undefined, value);
234
+ default:
235
+ // Unreachable while `kind` is a key of AnnotationOptions; a kind added
236
+ // to that interface without a case here lands on this line.
237
+ throw new Error(`annotate(): unknown annotation kind ${String(kind)}`);
238
+ }
239
+ }
240
+
241
+ function lowerEmail(
242
+ options: AnnotationOptions["email"] | undefined,
243
+ value: unknown,
244
+ ): RenderAnnotation {
245
+ // `deepUnwrap` only where fields are *read* — a value off another
246
+ // instrumented call is a provenance carrier, and `String(carrier)` would
247
+ // otherwise be its coerced shape rather than the address it holds. The
248
+ // value handed back to the test is never touched.
249
+ const source: unknown = deepUnwrap(options ?? value);
250
+ if (Array.isArray(source)) {
251
+ const messages = source.slice(0, LIST_CAP).map((m) => summaryOf(fields(m)));
252
+ if (source.length > 0 && messages.every((m) => isEmpty(m))) {
253
+ throw new Error(NO_FIELDS);
254
+ }
255
+ return { kind: "email", messages, count: source.length };
256
+ }
257
+ const message = messageOf(fields(source));
258
+ if (isEmpty(message)) throw new Error(NO_FIELDS);
259
+ return { kind: "email", message };
260
+ }
261
+
262
+ /** Open an annotation box. Returns `undefined` for any ordinary value, so
263
+ * call sites can treat annotation as the exception it is. */
264
+ export function readAnnotation(
265
+ value: unknown,
266
+ ): { annotation: RenderAnnotation; value: unknown } | undefined {
267
+ if (typeof value !== "object" || value === null) return undefined;
268
+ const annotation = (value as Record<symbol, unknown>)[ANNOTATION] as
269
+ | RenderAnnotation
270
+ | undefined;
271
+ if (!annotation) return undefined;
272
+ return { annotation, value: (value as { value: unknown }).value };
273
+ }
274
+
275
+ function box<T>(value: T, annotation: RenderAnnotation): Annotated<T> {
276
+ return { [ANNOTATION]: annotation, value } as unknown as Annotated<T>;
277
+ }
278
+
279
+ function isEmpty(o: object): boolean {
280
+ return Object.keys(o).length === 0;
281
+ }
282
+
283
+ /** The value as a field bag — anything that isn't an object contributes
284
+ * nothing, and falls through to the `NO_FIELDS` error. */
285
+ function fields(v: unknown): Record<string, unknown> {
286
+ return typeof v === "object" && v !== null
287
+ ? (v as Record<string, unknown>)
288
+ : {};
289
+ }
290
+
291
+ const NO_MESSAGES =
292
+ 'annotate(value, "chat"): nothing to render — no messages found on the ' +
293
+ "value. Pass them explicitly, e.g. " +
294
+ 'annotate(value, "chat", value.turns.map((t) => ({ side: t.fromCustomer ? "self" : "other", text: t.body }))).';
295
+
296
+ function lowerChat(
297
+ options: AnnotationOptions["chat"] | undefined,
298
+ value: unknown,
299
+ ): RenderAnnotation {
300
+ const source: unknown = deepUnwrap(options ?? value);
301
+ let list: unknown;
302
+ let title: string | undefined;
303
+ if (Array.isArray(source)) {
304
+ list = source;
305
+ } else {
306
+ const bag = fields(source);
307
+ list = bag.messages;
308
+ title = text(bag.title) || undefined;
309
+ }
310
+ // An empty conversation is a real state (nothing said yet) and renders as
311
+ // one; a value with no `messages` at all is the author's mistake.
312
+ if (!Array.isArray(list)) throw new Error(NO_MESSAGES);
313
+ const messages = list.map((m) => chatMessageOf(fields(m)));
314
+ // The title travels twice on purpose: as the step's own summary, and as
315
+ // the transcript's header — the annotated view renders the blocks alone,
316
+ // so a title carried only on the step would never be seen.
317
+ return {
318
+ kind: "chat",
319
+ title,
320
+ blocks: [{ type: "chat", messages, ...(title ? { label: title } : {}) }],
321
+ };
322
+ }
323
+
324
+ function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
325
+ const msg: ChatBlockMessage = {};
326
+ // Anything that isn't the end user's own message is drawn as the other
327
+ // party's, rather than a turn being dropped for a typo.
328
+ if (text(src.side) === "self") msg.side = "self";
329
+ else msg.side = "other";
330
+ const body = text(src.text);
331
+ if (body) msg.text = truncateUtf8(body).value;
332
+ if (src.new === true) msg.new = true;
333
+ return msg;
334
+ }
335
+
336
+ function messageOf(src: Record<string, unknown>): EmailEventMessage {
337
+ const msg: EmailEventMessage = {};
338
+ const from = text(src.from);
339
+ if (from) msg.from = from;
340
+ for (const key of ["to", "cc", "bcc"] as const) {
341
+ const addrs = addresses(src[key]);
342
+ if (addrs.length > 0) msg[key] = addrs;
343
+ }
344
+ const subject = text(src.subject);
345
+ if (subject) msg.subject = subject;
346
+ const html = text(src.html);
347
+ if (html) {
348
+ const t = truncateUtf8(html);
349
+ msg.html = t.value;
350
+ if (t.truncated) msg.htmlTruncated = true;
351
+ }
352
+ const body = text(src.text);
353
+ if (body) {
354
+ const t = truncateUtf8(body);
355
+ msg.text = t.value;
356
+ if (t.truncated) msg.textTruncated = true;
357
+ }
358
+ return msg;
359
+ }
360
+
361
+ /** A listing row is a whole message plus its preview line — the dashboard
362
+ * expands the row you click into the full view, so the body has to ride
363
+ * along rather than being summarised away. */
364
+ function summaryOf(src: Record<string, unknown>): EmailEventSummary {
365
+ const row: EmailEventSummary = messageOf(src);
366
+ const snippet = preview(text(src.text), text(src.html));
367
+ if (snippet) row.snippet = snippet;
368
+ return row;
369
+ }
370
+
371
+ /** A listing row's preview: the plain-text body if there is one, else the
372
+ * HTML with its tags stripped — enough to tell two messages apart. */
373
+ function preview(body: string, html: string): string {
374
+ const raw = body || html.replace(/<[^>]*>/g, " ");
375
+ const collapsed = raw.replace(/\s+/g, " ").trim();
376
+ return collapsed.length > SNIPPET_CHARS
377
+ ? `${collapsed.slice(0, SNIPPET_CHARS)}…`
378
+ : collapsed;
379
+ }
380
+
381
+ /** Scalars render as themselves; anything else (an object, a nested array)
382
+ * is not an address or a subject line and is dropped rather than shown as
383
+ * `[object Object]`. */
384
+ function text(v: unknown): string {
385
+ if (typeof v === "string") return v;
386
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
387
+ return "";
388
+ }
389
+
390
+ function addresses(v: unknown): string[] {
391
+ if (typeof v === "string") return v ? [v] : [];
392
+ if (!Array.isArray(v)) return [];
393
+ return v.map((a) => text(a)).filter((a) => a.length > 0);
394
+ }
395
+