@specific.dev/spectest 0.47.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ import { type EmailEventMessage, type EmailEventSummary } from "./recorder.js";
2
+ /** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
3
+ * box can't be read as the value by accident inside the fake, and the
4
+ * helper types can recognise it and hand the *test* back a plain `T`. */
5
+ declare const ANNOTATED: unique symbol;
6
+ /**
7
+ * A value plus a rendering hint, as returned by {@link annotate}. Return it
8
+ * straight from a fake helper: the daemon unwraps it, so the test receives
9
+ * the value itself and `ctx.fakes.<name>.<fn>()` is typed as if the
10
+ * annotation weren't there.
11
+ */
12
+ export interface Annotated<T> {
13
+ readonly [ANNOTATED]: T;
14
+ }
15
+ /**
16
+ * Every annotation kind, and the options each one takes. This interface is
17
+ * the whole type contract of {@link annotate}: naming a kind picks the type
18
+ * of the options argument, so a stray field or a value that belongs to
19
+ * another kind is a compile error at the call site.
20
+ *
21
+ * A new kind is an entry here, a case in `lower`, and an arm in
22
+ * `daemon.ts::recordAnnotationChild`.
23
+ */
24
+ export interface AnnotationOptions {
25
+ /** Draw the value as an email — one message, or a list of them for a
26
+ * mailbox listing. See {@link EmailAnnotation}. */
27
+ email: EmailAnnotation | readonly EmailAnnotation[];
28
+ }
29
+ /** The kinds {@link annotate} accepts. */
30
+ export type AnnotationKind = keyof AnnotationOptions;
31
+ /**
32
+ * What an annotation lowers to: the fields of the child event the daemon
33
+ * records under the call's own `fake` one. Tagged by that event's `kind` —
34
+ * which needn't be the annotation kind's name, since an annotation names
35
+ * what the value *is* and the event names how it's drawn.
36
+ */
37
+ export type RenderAnnotation = {
38
+ kind: "email";
39
+ /** Single-message ops. */
40
+ message?: EmailEventMessage;
41
+ /** Listing ops. */
42
+ messages?: EmailEventSummary[];
43
+ /** Total matches (a listing is capped on the event, never in the value). */
44
+ count?: number;
45
+ };
46
+ /**
47
+ * The email fields to render. Every one is optional — pass what the fake
48
+ * has. Addresses take a single string or a list.
49
+ *
50
+ * The event can also carry a date, per-row previews and attachment
51
+ * metadata, which the built-in `email()` component fills from a really
52
+ * captured message; a fake's mail was never sent, so those are deliberately
53
+ * not offered here. A listing row's preview is derived from the body.
54
+ */
55
+ export interface EmailAnnotation {
56
+ from?: string;
57
+ to?: string | readonly string[];
58
+ cc?: string | readonly string[];
59
+ bcc?: string | readonly string[];
60
+ subject?: string;
61
+ /** HTML body. Rendered in a fully sandboxed iframe (scripts off). */
62
+ html?: string;
63
+ /** Plain-text body. Shown on its own when there's no HTML, folded into a
64
+ * "Plain-text version" disclosure when there is. */
65
+ text?: string;
66
+ }
67
+ /**
68
+ * Say what a fake helper's return value *is*, so the timeline can draw it
69
+ * as that on top of the JSON it always shows. The call stays an ordinary
70
+ * fake step; the rendered view nests inside it. The value is returned to
71
+ * the test unchanged (and unchanged in type).
72
+ *
73
+ * ```ts
74
+ * helpers: ({ state }) => ({
75
+ * lastReceipt() {
76
+ * const r = state.receipts.at(-1);
77
+ * return r && annotate(r, "email", {
78
+ * from: "receipts@stripe.test",
79
+ * to: r.customer,
80
+ * subject: `Receipt for ${r.description}`,
81
+ * html: r.body,
82
+ * });
83
+ * },
84
+ * })
85
+ * ```
86
+ *
87
+ * `kind` picks what the options are: `"email"` takes an
88
+ * {@link EmailAnnotation} (or a list of them, which renders a mailbox
89
+ * listing instead of one message). Every field is optional, and with no
90
+ * options at all they're read off the value itself — enough when it already
91
+ * carries `from`/`to`/`subject`/`html`/`text`. Throws if there is nothing to
92
+ * render, rather than recording an empty step.
93
+ */
94
+ export declare function annotate<T, K extends AnnotationKind>(value: T, kind: K, options?: AnnotationOptions[K]): Annotated<T>;
95
+ /** Open an annotation box. Returns `undefined` for any ordinary value, so
96
+ * call sites can treat annotation as the exception it is. */
97
+ export declare function readAnnotation(value: unknown): {
98
+ annotation: RenderAnnotation;
99
+ value: unknown;
100
+ } | undefined;
101
+ export {};
@@ -0,0 +1,196 @@
1
+ // Render annotations — how a fake tells the dashboard to draw a helper's
2
+ // return value as something richer than JSON.
3
+ //
4
+ // A fake helper call is recorded as a `fake` step and its return value is
5
+ // rendered as pretty JSON, which is the right default for arbitrary data.
6
+ // When the value *is* a domain object the timeline already draws well — an
7
+ // email, today — the fake says so: `return annotate(msg, "email", {…})`.
8
+ // The call is still recorded as the `fake` step it is; the annotation rides
9
+ // *under* it as a child event (`parentSeq`, marked `annotation`) of the kind
10
+ // that already knows how to draw this — for `"email"`, the very event the
11
+ // built-in `email()` component's mailbox helpers record — so the dashboard
12
+ // renders it mail-client style (header block + sandboxed HTML body) with no
13
+ // new rendering code. It leads the step's detail panel; the raw JSON value
14
+ // is a tab away.
15
+ //
16
+ // One function, keyed by kind: the kind is a key of `AnnotationOptions`, so
17
+ // the options argument is typed against the kind that was named, and a new
18
+ // kind is an entry in that interface (+ a case in `lower`, + an arm in
19
+ // `daemon.ts::recordAnnotatedCall`) rather than a new export.
20
+ //
21
+ // The test is unaffected. `annotate` returns a box that the daemon opens at
22
+ // the helper boundary (`daemon.ts::invokeFakeHelper`), so the caller gets
23
+ // the raw value back, inspect-wrapped as always, and the *type* it sees is
24
+ // still the raw value's (`WrappedHelpers` in index.ts unwraps `Annotated`).
25
+ // Assertions keep linking to the fake step, exactly as for an unannotated
26
+ // helper. The annotation is a rendering hint and nothing else.
27
+ //
28
+ // This is a fake-helper mechanism: those calls go through a tracking proxy
29
+ // that can open the box. A *service* helper (`ctx.svc.<name>.…`) is the raw
30
+ // record its factory returned, so an annotation there would reach the test
31
+ // as an opaque value — don't.
32
+ import { deepUnwrap } from "./inspect.js";
33
+ import { truncateUtf8, } from "./recorder.js";
34
+ /**
35
+ * Runtime marker on the box `annotate(...)` returns. `Symbol.for` rather
36
+ * than a module-local symbol so a second copy of the SDK still recognises
37
+ * a box minted by the first — the duplicate-module-instance hazard that
38
+ * silently drops assertion events when the SDK lands in an app dir twice
39
+ * (see `sdk.rs`).
40
+ */
41
+ const ANNOTATION = Symbol.for("spectest.render-annotation");
42
+ /** Listing rows carried on the event. The returned value is never capped. */
43
+ const LIST_CAP = 50;
44
+ /** How much of a body becomes a listing row's preview. */
45
+ const SNIPPET_CHARS = 200;
46
+ const NO_FIELDS = 'annotate(value, "email"): nothing to render — no email fields found on ' +
47
+ "the value. Pass them explicitly, e.g. " +
48
+ 'annotate(value, "email", { to: value.recipient, subject: value.title, ' +
49
+ "html: value.body }).";
50
+ /**
51
+ * Say what a fake helper's return value *is*, so the timeline can draw it
52
+ * as that on top of the JSON it always shows. The call stays an ordinary
53
+ * fake step; the rendered view nests inside it. The value is returned to
54
+ * the test unchanged (and unchanged in type).
55
+ *
56
+ * ```ts
57
+ * helpers: ({ state }) => ({
58
+ * lastReceipt() {
59
+ * const r = state.receipts.at(-1);
60
+ * return r && annotate(r, "email", {
61
+ * from: "receipts@stripe.test",
62
+ * to: r.customer,
63
+ * subject: `Receipt for ${r.description}`,
64
+ * html: r.body,
65
+ * });
66
+ * },
67
+ * })
68
+ * ```
69
+ *
70
+ * `kind` picks what the options are: `"email"` takes an
71
+ * {@link EmailAnnotation} (or a list of them, which renders a mailbox
72
+ * listing instead of one message). Every field is optional, and with no
73
+ * options at all they're read off the value itself — enough when it already
74
+ * carries `from`/`to`/`subject`/`html`/`text`. Throws if there is nothing to
75
+ * render, rather than recording an empty step.
76
+ */
77
+ export function annotate(value, kind, options) {
78
+ return box(value, lower(kind, options, value));
79
+ }
80
+ /** Turn a kind + its options into the event fields to record. */
81
+ function lower(kind, options, value) {
82
+ switch (kind) {
83
+ case "email":
84
+ return lowerEmail(options, value);
85
+ default:
86
+ // Unreachable while `kind` is a key of AnnotationOptions; a kind added
87
+ // to that interface without a case here lands on this line.
88
+ throw new Error(`annotate(): unknown annotation kind ${String(kind)}`);
89
+ }
90
+ }
91
+ function lowerEmail(options, value) {
92
+ // `deepUnwrap` only where fields are *read* — a value off another
93
+ // instrumented call is a provenance carrier, and `String(carrier)` would
94
+ // otherwise be its coerced shape rather than the address it holds. The
95
+ // value handed back to the test is never touched.
96
+ const source = deepUnwrap(options ?? value);
97
+ if (Array.isArray(source)) {
98
+ const messages = source.slice(0, LIST_CAP).map((m) => summaryOf(fields(m)));
99
+ if (source.length > 0 && messages.every((m) => isEmpty(m))) {
100
+ throw new Error(NO_FIELDS);
101
+ }
102
+ return { kind: "email", messages, count: source.length };
103
+ }
104
+ const message = messageOf(fields(source));
105
+ if (isEmpty(message))
106
+ throw new Error(NO_FIELDS);
107
+ return { kind: "email", message };
108
+ }
109
+ /** Open an annotation box. Returns `undefined` for any ordinary value, so
110
+ * call sites can treat annotation as the exception it is. */
111
+ export function readAnnotation(value) {
112
+ if (typeof value !== "object" || value === null)
113
+ return undefined;
114
+ const annotation = value[ANNOTATION];
115
+ if (!annotation)
116
+ return undefined;
117
+ return { annotation, value: value.value };
118
+ }
119
+ function box(value, annotation) {
120
+ return { [ANNOTATION]: annotation, value };
121
+ }
122
+ function isEmpty(o) {
123
+ return Object.keys(o).length === 0;
124
+ }
125
+ /** The value as a field bag — anything that isn't an object contributes
126
+ * nothing, and falls through to the `NO_FIELDS` error. */
127
+ function fields(v) {
128
+ return typeof v === "object" && v !== null
129
+ ? v
130
+ : {};
131
+ }
132
+ function messageOf(src) {
133
+ const msg = {};
134
+ const from = text(src.from);
135
+ if (from)
136
+ msg.from = from;
137
+ for (const key of ["to", "cc", "bcc"]) {
138
+ const addrs = addresses(src[key]);
139
+ if (addrs.length > 0)
140
+ msg[key] = addrs;
141
+ }
142
+ const subject = text(src.subject);
143
+ if (subject)
144
+ msg.subject = subject;
145
+ const html = text(src.html);
146
+ if (html) {
147
+ const t = truncateUtf8(html);
148
+ msg.html = t.value;
149
+ if (t.truncated)
150
+ msg.htmlTruncated = true;
151
+ }
152
+ const body = text(src.text);
153
+ if (body) {
154
+ const t = truncateUtf8(body);
155
+ msg.text = t.value;
156
+ if (t.truncated)
157
+ msg.textTruncated = true;
158
+ }
159
+ return msg;
160
+ }
161
+ /** A listing row is a whole message plus its preview line — the dashboard
162
+ * expands the row you click into the full view, so the body has to ride
163
+ * along rather than being summarised away. */
164
+ function summaryOf(src) {
165
+ const row = messageOf(src);
166
+ const snippet = preview(text(src.text), text(src.html));
167
+ if (snippet)
168
+ row.snippet = snippet;
169
+ return row;
170
+ }
171
+ /** A listing row's preview: the plain-text body if there is one, else the
172
+ * HTML with its tags stripped — enough to tell two messages apart. */
173
+ function preview(body, html) {
174
+ const raw = body || html.replace(/<[^>]*>/g, " ");
175
+ const collapsed = raw.replace(/\s+/g, " ").trim();
176
+ return collapsed.length > SNIPPET_CHARS
177
+ ? `${collapsed.slice(0, SNIPPET_CHARS)}…`
178
+ : collapsed;
179
+ }
180
+ /** Scalars render as themselves; anything else (an object, a nested array)
181
+ * is not an address or a subject line and is dropped rather than shown as
182
+ * `[object Object]`. */
183
+ function text(v) {
184
+ if (typeof v === "string")
185
+ return v;
186
+ if (typeof v === "number" || typeof v === "boolean")
187
+ return String(v);
188
+ return "";
189
+ }
190
+ function addresses(v) {
191
+ if (typeof v === "string")
192
+ return v ? [v] : [];
193
+ if (!Array.isArray(v))
194
+ return [];
195
+ return v.map((a) => text(a)).filter((a) => a.length > 0);
196
+ }
package/dist/browser.d.ts CHANGED
@@ -359,4 +359,5 @@ export interface RecordableFields {
359
359
  attempts: number;
360
360
  artifactId: string;
361
361
  attribute: string;
362
+ files: string[];
362
363
  }
package/dist/daemon.js CHANGED
@@ -34,6 +34,7 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
34
34
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
35
35
  import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
36
36
  import { pollUntilReady } from "./harness/ready-poll.js";
37
+ import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
37
38
  import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
38
39
  import { encodeRegistry } from "./harness/names-registry.js";
39
40
  import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
@@ -43,7 +44,8 @@ import { runContainerArgs } from "./harness/container-run.js";
43
44
  import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
44
45
  import { conflict, notFound, requireString, } from "./harness/methods.js";
45
46
  import { openTerminal } from "./terminal.js";
46
- import { recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
47
+ import { readAnnotation } from "./annotate.js";
48
+ import { recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
47
49
  import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
48
50
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
49
51
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
@@ -52,7 +54,6 @@ function namedServices(cfg) {
52
54
  }
53
55
  const DEFAULT_TEST_TIMEOUT_MS = 60_000;
54
56
  const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
55
- const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
56
57
  // Stable hostname every service container resolves to the host (the
57
58
  // `spectest-br0` gateway) — so apps that build or pull images at runtime
58
59
  // can point a builder at `spectest-host:5000` (the zot Docker Hub mirror)
@@ -77,7 +78,9 @@ function hostCacheGateway() {
77
78
  }
78
79
  return _hostCacheGateway;
79
80
  }
80
- const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
81
+ // WORKSPACE (/workspace) and APP_DIR (/opt/spectest/app) both live in
82
+ // project-files.ts, next to the rule that decides which copy of a project
83
+ // file is the current one.
81
84
  // The bun the base snapshot installs (base.rs::BASE_SETUP_SH). The daemon
82
85
  // runs under it, and eval's dependency install shells out to it.
83
86
  const BUN_BIN = "/usr/local/bin/bun";
@@ -1028,7 +1031,7 @@ async function probeTcp(host, port) {
1028
1031
  return new Promise((resolve) => {
1029
1032
  const sock = net.createConnection({ host, port });
1030
1033
  let settled = false;
1031
- const finish = (v) => {
1034
+ const finish = (ok, detail) => {
1032
1035
  if (settled)
1033
1036
  return;
1034
1037
  settled = true;
@@ -1038,26 +1041,30 @@ async function probeTcp(host, port) {
1038
1041
  catch {
1039
1042
  /* ignore */
1040
1043
  }
1041
- resolve(v);
1044
+ resolve({ ok, detail });
1042
1045
  };
1043
1046
  sock.setTimeout(2000);
1044
- sock.once("connect", () => finish(true));
1045
- sock.once("error", () => finish(false));
1046
- sock.once("timeout", () => finish(false));
1047
+ sock.once("connect", () => finish(true, `connected to ${host}:${port}`));
1048
+ sock.once("error", (err) => finish(false, `connect to ${host}:${port} failed: ${err.message}`));
1049
+ sock.once("timeout", () => finish(false, `connect to ${host}:${port} got no reply within 2000ms`));
1047
1050
  });
1048
1051
  }
1049
1052
  async function probeHttp(host, port, urlPath, headers, expectStatus) {
1053
+ const url = `http://${host}:${port}${urlPath}`;
1050
1054
  const ctrl = new AbortController();
1051
1055
  const to = setTimeout(() => ctrl.abort(), 5000);
1052
1056
  try {
1053
- const res = await fetch(`http://${host}:${port}${urlPath}`, {
1054
- signal: ctrl.signal,
1055
- headers,
1056
- });
1057
- return expectStatus !== undefined ? res.status === expectStatus : res.ok;
1057
+ const res = await fetch(url, { signal: ctrl.signal, headers });
1058
+ const ok = expectStatus !== undefined ? res.status === expectStatus : res.ok;
1059
+ const want = expectStatus !== undefined ? String(expectStatus) : "2xx";
1060
+ return { ok, detail: `GET ${url} → ${res.status} (wanted ${want})` };
1058
1061
  }
1059
- catch {
1060
- return false;
1062
+ catch (err) {
1063
+ const e = err;
1064
+ const detail = e?.name === "AbortError"
1065
+ ? `GET ${url} got no reply within 5000ms`
1066
+ : `GET ${url} failed: ${e?.message ?? String(err)}`;
1067
+ return { ok: false, detail };
1061
1068
  }
1062
1069
  finally {
1063
1070
  clearTimeout(to);
@@ -1065,28 +1072,64 @@ async function probeHttp(host, port, urlPath, headers, expectStatus) {
1065
1072
  }
1066
1073
  async function probeExec(name, command) {
1067
1074
  const r = await docker(["exec", name, "sh", "-c", command], 10_000);
1068
- return r.code === 0;
1075
+ if (r.code === 0)
1076
+ return { ok: true, detail: `\`${command}\` exited 0` };
1077
+ // 124 is shx's own kill (see `shx`): the exec never answered, which says
1078
+ // the docker daemon is wedged or starved rather than anything about the
1079
+ // command's verdict.
1080
+ const why = r.code === 124
1081
+ ? `was killed after 10000ms with no reply`
1082
+ : `exited ${r.code}`;
1083
+ const output = firstLine(r.stderr) || firstLine(r.stdout);
1084
+ return {
1085
+ ok: false,
1086
+ detail: `\`${command}\` ${why}${output ? `: ${output}` : ""}`,
1087
+ };
1088
+ }
1089
+ /** First non-empty line, trimmed and capped — probe output goes into an
1090
+ * error message, not a log file. */
1091
+ function firstLine(s) {
1092
+ const line = s.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
1093
+ return line.length > 200 ? `${line.slice(0, 200)}…` : line;
1069
1094
  }
1070
1095
  async function waitForReady(svc) {
1071
1096
  const check = svc.readyCheck;
1072
1097
  if (!check)
1073
1098
  return;
1074
1099
  const timeoutSecs = check.timeoutSecs ?? 60;
1100
+ let last;
1075
1101
  const probe = async () => {
1076
1102
  if (check.type === "tcp")
1077
- return probeTcp(svc.name, check.port);
1078
- if (check.type === "http") {
1079
- return probeHttp(svc.name, check.port, check.path ?? "/", check.headers, check.expectStatus);
1103
+ last = await probeTcp(svc.name, check.port);
1104
+ else if (check.type === "http") {
1105
+ last = await probeHttp(svc.name, check.port, check.path ?? "/", check.headers, check.expectStatus);
1080
1106
  }
1081
- return probeExec(svc.name, check.command);
1107
+ else
1108
+ last = await probeExec(svc.name, check.command);
1109
+ return last.ok;
1082
1110
  };
1083
1111
  // Scheduling (the ramp, and not sleeping past the deadline) lives in
1084
1112
  // `harness/ready-poll.ts`; this supplies the probe and the diagnosis.
1085
- const { ready } = await pollUntilReady(probe, { kind: check.type, timeoutSecs });
1113
+ const { ready, attempts, elapsedMs } = await pollUntilReady(probe, {
1114
+ kind: check.type,
1115
+ timeoutSecs,
1116
+ });
1086
1117
  if (ready)
1087
1118
  return;
1119
+ // The attempt count is half the diagnosis: a probe with a 10s timeout of
1120
+ // its own can only run a handful of times in 60s, so "6 attempts" says the
1121
+ // probes were hanging where "80 attempts" says they ran and kept saying no.
1122
+ let msg = `service ${svc.name} not ready within ${timeoutSecs}s ` +
1123
+ `(${attempts} ${check.type} probe${attempts === 1 ? "" : "s"} over ${Math.round(elapsedMs / 100) / 10}s).`;
1124
+ if (last)
1125
+ msg += `\nLast probe: ${last.detail}`;
1088
1126
  const logs = await docker(["logs", "--tail=200", svc.name], 30_000);
1089
- throw new Error(`service ${svc.name} not ready within ${timeoutSecs}s. Recent container logs:\n${logs.stdout}\n${logs.stderr}`);
1127
+ const output = `${logs.stdout}\n${logs.stderr}`.trim();
1128
+ // A service that logs nothing (`sleep infinity`, a quiet daemon) used to
1129
+ // get an empty "Recent container logs:" heading, which reads as if the
1130
+ // logs were the evidence and there simply weren't any.
1131
+ msg += output ? `\nRecent container logs:\n${output}` : `\n(the container logged nothing)`;
1132
+ throw new Error(msg);
1090
1133
  }
1091
1134
  /** Validate the `dependsOn` graph and return the name→service map used to
1092
1135
  * walk it. Rules live in `harness/service-graph.ts`. */
@@ -2220,14 +2263,27 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
2220
2263
  const args = callArgs.map((a) => deepUnwrap(a));
2221
2264
  const safeArgs = args.map((a) => safeSerialize(a));
2222
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;
2223
2276
  const seq = recordFake({
2224
2277
  fake: fakeName,
2225
2278
  member,
2226
2279
  args: safeArgs,
2227
- result: safeSerialize(value),
2228
- durationMs: Date.now() - t,
2280
+ result: safeSerialize(raw),
2281
+ durationMs,
2229
2282
  }, resv);
2230
- return wrap(value, seq);
2283
+ if (annotated && seq !== undefined) {
2284
+ recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
2285
+ }
2286
+ return wrap(raw, seq);
2231
2287
  };
2232
2288
  const recordError = (err) => {
2233
2289
  recordFake({
@@ -2254,6 +2310,35 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
2254
2310
  }
2255
2311
  return recordResult(result);
2256
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
+ }
2257
2342
  function errMessage(err) {
2258
2343
  return err?.message ?? String(err);
2259
2344
  }
@@ -2926,7 +3011,8 @@ async function spectestContext(scope = {}) {
2926
3011
  const execTimeoutMs = scope.service ? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS : undefined;
2927
3012
  return {
2928
3013
  projectRoot: WORKSPACE,
2929
- readProjectFile: (p) => fs.readFile(path.isAbsolute(p) ? p : path.join(WORKSPACE, p), "utf8"),
3014
+ readProjectFile: (p) => fs.readFile(resolveProjectPath(p), "utf8"),
3015
+ readProjectFileBytes: async (p) => new Uint8Array(await fs.readFile(resolveProjectPath(p))),
2930
3016
  exec: (service, command, opts) => componentExec(service, command, opts, execTimeoutMs),
2931
3017
  // Read `globalThis.fetch` at call time: the wrapper is installed for
2932
3018
  // the duration of a test / eval / project setup, so a context built
@@ -3342,22 +3428,31 @@ async function pollCall(description, fn, opts) {
3342
3428
  let value;
3343
3429
  let success = false;
3344
3430
  let predicateError;
3345
- // Record all iterations normally. Falsy iterations get truncated
3346
- // from the recorder so the timeline doesn't fill with polling
3347
- // noise; the LAST iteration's events stay, then get marked as
3348
- // children of the wait event so the UI can render them nested.
3431
+ // Record all iterations normally, but keep only the newest one: a new
3432
+ // attempt drops the events the previous attempt emitted, so the timeline
3433
+ // never fills with polling noise. Whichever attempt is last when the loop
3434
+ // ends — the winning one, or the final failed one — stays, and gets marked
3435
+ // as a child of the wait event so the UI can render it nested.
3436
+ //
3437
+ // The failed attempt is kept deliberately. A poll that times out reports
3438
+ // only "timed out after 120000ms (60 attempts)", which says nothing about
3439
+ // WHY: an ingress answering an instant 404 for two minutes and a backend
3440
+ // that never returns look identical in that message. The last attempt's
3441
+ // events carry the status, the body and the duration, which is the whole
3442
+ // difference between "the route was never programmed" and "the app hung".
3349
3443
  const beforePollIdx = recorderEventCount();
3350
3444
  let lastIterStartIdx = beforePollIdx;
3351
- let keptIterStartIdx = beforePollIdx;
3352
3445
  while (Date.now() - start < timeoutMs) {
3353
3446
  attempts += 1;
3447
+ // Drop the *previous* attempt's events, not this one's — its events are
3448
+ // the ones worth keeping until a newer attempt replaces them.
3449
+ recorderTruncate(lastIterStartIdx);
3354
3450
  lastIterStartIdx = recorderEventCount();
3355
3451
  try {
3356
3452
  const v = await fn();
3357
3453
  if (v !== null && v !== undefined && v !== false) {
3358
3454
  value = v;
3359
3455
  success = true;
3360
- keptIterStartIdx = lastIterStartIdx;
3361
3456
  break;
3362
3457
  }
3363
3458
  }
@@ -3365,17 +3460,10 @@ async function pollCall(description, fn, opts) {
3365
3460
  predicateError = err;
3366
3461
  break;
3367
3462
  }
3368
- // Failed iteration — drop the events it emitted.
3369
- recorderTruncate(lastIterStartIdx);
3370
3463
  if (Date.now() - start + intervalMs > timeoutMs)
3371
3464
  break;
3372
3465
  await new Promise((r) => setTimeout(r, intervalMs));
3373
3466
  }
3374
- if (!success) {
3375
- // Timeout or predicate error: drop every attempt's events. The
3376
- // wait event we emit below is the only trace.
3377
- recorderTruncate(beforePollIdx);
3378
- }
3379
3467
  const errMsg = predicateError !== undefined
3380
3468
  ? (predicateError?.message ?? String(predicateError))
3381
3469
  : success
@@ -3388,11 +3476,11 @@ async function pollCall(description, fn, opts) {
3388
3476
  passed: success,
3389
3477
  ...(errMsg !== undefined ? { error: errMsg } : {}),
3390
3478
  }, resv);
3391
- if (success && seq !== undefined) {
3479
+ if (seq !== undefined) {
3392
3480
  // Group the kept iteration's events under the wait so the UI can
3393
3481
  // render them inside the wait card. The wait event itself is the
3394
3482
  // very last entry; markChildren skips it via the seq match.
3395
- recorderMarkChildren(keptIterStartIdx, seq);
3483
+ recorderMarkChildren(lastIterStartIdx, seq);
3396
3484
  }
3397
3485
  if (predicateError !== undefined)
3398
3486
  throw predicateError;
package/dist/index.d.ts CHANGED
@@ -2,12 +2,15 @@ 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";
8
11
  export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
9
12
  import type { Browser, BrowserOptions } from "./browser.js";
10
- export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, } from "./locator.js";
13
+ export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, FilePayload, InputFiles, } from "./locator.js";
11
14
  import type { Locator } from "./locator.js";
12
15
  export type { UrlPattern } from "./url-match.js";
13
16
  import type { UrlPattern } from "./url-match.js";
@@ -215,6 +218,10 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
215
218
  /** Read a project file as UTF-8. Relative paths resolve against
216
219
  * {@link projectRoot}; absolute paths are read as-is. */
217
220
  readProjectFile(path: string): Promise<string>;
221
+ /** Read a project file as bytes — a fixture to post, hash, or compare
222
+ * against what the app under test received. Same path rules as
223
+ * {@link readProjectFile}. */
224
+ readProjectFileBytes(path: string): Promise<Uint8Array>;
218
225
  /**
219
226
  * Run a command inside a service container. Pass an **array** for exact
220
227
  * argv with no shell (`["psql", "-f", "-"]`), or a **string** to run via
@@ -1143,6 +1150,10 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
1143
1150
  * Every call is tracked in the test timeline: it records a `fake` step
1144
1151
  * and the return value is tagged so a later `expect(...)` on it nests
1145
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.
1146
1157
  *
1147
1158
  * Receives the fake's `state` plus a {@link FakeContext} `ctx`, so a
1148
1159
  * helper can provision/teardown runtime services just like the handler.
@@ -1184,8 +1195,15 @@ export type FakesMap = Record<string, FakeDefinition<any, any>>;
1184
1195
  * `void` side-effect helpers) pass through untouched. Mirrors `Tagged<T>` in
1185
1196
  * `components/k3s.ts`, extended to cover synchronous returns. */
1186
1197
  type WrappedHelpers<H> = {
1187
- [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];
1188
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;
1189
1207
  /** Awaited return type of a fake's `helpers` factory (with each result
1190
1208
  * inspect-wrapped, see {@link WrappedHelpers}), or `{ state: S }` (the
1191
1209
  * default) when the user didn't ship one. */