@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/annotate.d.ts +151 -0
- package/dist/annotate.js +242 -0
- package/dist/daemon.js +199 -15
- package/dist/index.d.ts +15 -1
- package/dist/index.js +8 -0
- package/dist/recorder.d.ts +94 -10
- package/dist/recorder.js +16 -3
- package/package.json +1 -1
- package/src/annotate.ts +395 -0
- package/src/daemon.ts +226 -19
- package/src/index.ts +32 -2
- package/src/recorder.test.ts +57 -0
- package/src/recorder.ts +95 -13
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
|
|
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
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
53
|
-
* by `ctx.poll` to group 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
package/src/annotate.ts
ADDED
|
@@ -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
|
+
|