@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.
- package/dist/annotate.d.ts +101 -0
- package/dist/annotate.js +196 -0
- package/dist/daemon.js +47 -4
- package/dist/index.d.ts +15 -1
- package/dist/index.js +7 -0
- package/dist/recorder.d.ts +25 -9
- package/package.json +1 -1
- package/src/annotate.ts +292 -0
- package/src/daemon.ts +54 -3
- package/src/index.ts +29 -2
- package/src/recorder.ts +25 -9
|
@@ -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 {};
|
package/dist/annotate.js
ADDED
|
@@ -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 {
|
|
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(
|
|
2270
|
-
durationMs
|
|
2280
|
+
result: safeSerialize(raw),
|
|
2281
|
+
durationMs,
|
|
2271
2282
|
}, resv);
|
|
2272
|
-
|
|
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
|
|
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
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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;
|
package/package.json
CHANGED
package/src/annotate.ts
ADDED
|
@@ -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(
|
|
2736
|
-
durationMs
|
|
2747
|
+
result: safeSerialize(raw),
|
|
2748
|
+
durationMs,
|
|
2737
2749
|
}, resv);
|
|
2738
|
-
|
|
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
|
-
/**
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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;
|