@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.
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/locator.d.ts CHANGED
@@ -40,6 +40,19 @@ export interface ClickOptions {
40
40
  };
41
41
  modifiers?: Array<"Alt" | "Control" | "Meta" | "Shift">;
42
42
  }
43
+ /** A file built in the test rather than read from the repo — the argument
44
+ * shape playwright's `setInputFiles` takes, with `buffer` widened to a plain
45
+ * `Uint8Array`/string and `mimeType` optional. */
46
+ export interface FilePayload {
47
+ name: string;
48
+ /** Defaults to `application/octet-stream`. */
49
+ mimeType?: string;
50
+ /** Bytes, or text (encoded UTF-8). */
51
+ buffer: Uint8Array | string;
52
+ }
53
+ /** What `setInputFiles` accepts: repo-relative (or absolute) paths, built
54
+ * files, or `[]` to clear the input. */
55
+ export type InputFiles = string | string[] | FilePayload | FilePayload[];
43
56
  export interface TimeoutOption {
44
57
  timeout?: number;
45
58
  }
@@ -240,6 +253,25 @@ export interface Locator {
240
253
  value?: string;
241
254
  index?: number;
242
255
  }, opts?: TimeoutOption): Promise<Wrapped<string[]>>;
256
+ /**
257
+ * Give an `<input type="file">` its files — the upload primitive, since a
258
+ * browser refuses a script-set value on a file input. Targets the control a
259
+ * `<label>` points at, and does **not** require the input to be visible, so
260
+ * the hidden input behind a drop zone or a styled "Choose file" button is
261
+ * the thing to select.
262
+ *
263
+ * A string is a path in your repo, relative to `ctx.projectRoot` (absolute
264
+ * paths are taken as-is) — keep fixtures in `spectest/tests/fixtures/`, which
265
+ * is out of the environment cache key, so adding one still gives a warm
266
+ * start. Pass a {@link FilePayload} instead for a file the test builds, and
267
+ * `[]` to clear the input.
268
+ *
269
+ * ```ts
270
+ * await b.locator("#file").setInputFiles("spectest/tests/fixtures/invoice.pdf");
271
+ * await b.getByLabel("Avatar").setInputFiles({ name: "a.txt", buffer: "hi" });
272
+ * ```
273
+ */
274
+ setInputFiles(files: InputFiles, opts?: TimeoutOption): Promise<void>;
243
275
  hover(opts?: TimeoutOption): Promise<void>;
244
276
  focus(opts?: TimeoutOption): Promise<void>;
245
277
  blur(opts?: TimeoutOption): Promise<void>;
package/dist/locator.js CHANGED
@@ -21,6 +21,8 @@
21
21
  // (actions, reads, `waitFor`) run inside `backend.pageOp`, so each
22
22
  // author-facing call is exactly one recorded browser event (with its rrweb
23
23
  // drain), whatever playwright work it composes underneath.
24
+ import { Buffer } from "node:buffer";
25
+ import { resolveExistingProjectPath } from "./project-files.js";
24
26
  import { truncateUtf8 } from "./recorder.js";
25
27
  /** Default deadline for a locator action/read's target to become actionable.
26
28
  * Playwright's own default is 30s — far too slow-failing for tests; 5s
@@ -264,6 +266,28 @@ async function stampActionPoint(loc, rec, position) {
264
266
  /* Element not ready / gone / strict violation — the action reports it. */
265
267
  }
266
268
  }
269
+ /** Fold a {@link InputFiles} argument into the one playwright takes: repo
270
+ * paths resolved to their in-VM location (see project-files.ts), built files
271
+ * given a default mime type and a real `Buffer`. The names come back too —
272
+ * they are what the timeline step shows, and the bytes never go near it. */
273
+ function lowerInputFiles(files) {
274
+ const many = Array.isArray(files) ? files : [files];
275
+ // `[]` clears the input, and every() is true for it — so an empty call
276
+ // lands here and lowers to an empty path list, which is what clears.
277
+ if (many.every((f) => typeof f === "string")) {
278
+ const paths = many.map((f) => resolveExistingProjectPath(f, "fixture"));
279
+ return { arg: paths, names: paths.map((p) => p.split("/").pop() ?? p) };
280
+ }
281
+ if (many.some((f) => typeof f === "string")) {
282
+ throw new Error("setInputFiles: pass either repo paths or built files — not both in one call");
283
+ }
284
+ const payloads = many.map((f) => ({
285
+ name: f.name,
286
+ mimeType: f.mimeType ?? "application/octet-stream",
287
+ buffer: typeof f.buffer === "string" ? Buffer.from(f.buffer, "utf8") : Buffer.from(f.buffer),
288
+ }));
289
+ return { arg: payloads, names: payloads.map((f) => f.name) };
290
+ }
267
291
  // ────────────────────────────────────────────────────────────────────────
268
292
  // Factory
269
293
  // ────────────────────────────────────────────────────────────────────────
@@ -340,6 +364,10 @@ export function makeLocator(backend, strategy, chain) {
340
364
  uncheck: (opts) => act("uncheck", {}, (l) => l.uncheck({ timeout: opts?.timeout })),
341
365
  setChecked: (checked, opts) => act("setChecked", {}, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
342
366
  selectOption: (values, opts) => read("selectOption", (l) => l.selectOption(values, { timeout: opts?.timeout })),
367
+ setInputFiles: (files, opts) => {
368
+ const { arg, names } = lowerInputFiles(files);
369
+ return act("setInputFiles", { files: names }, (l) => l.setInputFiles(arg, { timeout: opts?.timeout }));
370
+ },
343
371
  hover: (opts) => act("hover", {}, (l) => l.hover({ timeout: opts?.timeout })),
344
372
  focus: (opts) => act("focus", {}, (l) => l.focus({ timeout: opts?.timeout })),
345
373
  blur: (opts) => act("blur", {}, (l) => l.blur({ timeout: opts?.timeout })),
@@ -0,0 +1,18 @@
1
+ /** The extracted repo — `ctx.projectRoot`. */
2
+ export declare const WORKSPACE: string;
3
+ /** The app dir; the user's `spectest/` sits directly under it. */
4
+ export declare const APP_DIR: string;
5
+ /**
6
+ * Absolute in-VM path for a path in the user's repo. Absolute input is
7
+ * returned as-is. A relative path resolves against the project, preferring the
8
+ * copy that a warm start refreshes (see the header).
9
+ *
10
+ * A file that is missing everywhere resolves to its /workspace candidate, so
11
+ * the caller's own `fs` error names a real path — `readProjectFile` keeps
12
+ * reporting ENOENT the way it always has.
13
+ */
14
+ export declare function resolveProjectPath(p: string): string;
15
+ /** {@link resolveProjectPath}, but a missing file is an error that names every
16
+ * place we looked. For inputs a user hands us by name — a fixture — where an
17
+ * ENOENT on one guessed path reads as a spectest bug rather than a typo. */
18
+ export declare function resolveExistingProjectPath(p: string, what?: string): string;
@@ -0,0 +1,97 @@
1
+ // Where a path in the user's repo lands inside the VM.
2
+ //
3
+ // The project exists in the guest TWICE, and the two copies are not refreshed
4
+ // at the same time (see `env.rs`):
5
+ //
6
+ // /workspace the whole repo. Written by a cold or delta
7
+ // start only — a warm start never re-uploads it.
8
+ // /opt/spectest/app/spectest the user's `spectest/` directory. Re-uploaded
9
+ // on EVERY start, warm ones included.
10
+ //
11
+ // A warm start happens only when the warm-template hash matches, so /workspace
12
+ // is correct for every file that is IN that hash. It can be behind for the two
13
+ // kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
14
+ // edit keeps the fast start) and anything a project lists in
15
+ // `spectest/.envignore`.
16
+ //
17
+ // Hence the rule below: a path under `spectest/` resolves against the app copy
18
+ // first, because that copy is always current — this is what lets a fixture live
19
+ // in `spectest/tests/fixtures/` and still be found after it was added. Any
20
+ // other path resolves against /workspace, which the hash keeps current.
21
+ //
22
+ // The remaining hole is a file that `.envignore` excludes AND that sits outside
23
+ // `spectest/`: /workspace holds whatever the cold start uploaded, so a later
24
+ // edit is invisible. Nothing can repair those bytes at read time, so we refuse
25
+ // to read them instead of returning stale content. The control plane writes the
26
+ // exact path list it excluded (env.rs) into ENV_IGNORED_FILE.
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import path from "node:path";
29
+ /** The extracted repo — `ctx.projectRoot`. */
30
+ export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
31
+ /** The app dir; the user's `spectest/` sits directly under it. */
32
+ export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
33
+ /** Paths (repo-relative) the warm-template hash skipped because of
34
+ * `spectest/.envignore`. Written by the control plane at env start; absent
35
+ * when the project ships no `.envignore`. */
36
+ const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
37
+ let _envIgnored;
38
+ function envIgnored() {
39
+ if (_envIgnored)
40
+ return _envIgnored;
41
+ let paths = [];
42
+ try {
43
+ const raw = JSON.parse(readFileSync(ENV_IGNORED_FILE, "utf8"));
44
+ paths = raw.paths ?? [];
45
+ }
46
+ catch {
47
+ /* No file (no .envignore, or an older env) — nothing to refuse. */
48
+ }
49
+ _envIgnored = new Set(paths);
50
+ return _envIgnored;
51
+ }
52
+ /** Repo-relative form of `p`: no leading `./`, POSIX separators. */
53
+ function relative(p) {
54
+ return path.normalize(p).replace(/^\.\//, "").replace(/^\/+/, "");
55
+ }
56
+ /**
57
+ * Absolute in-VM path for a path in the user's repo. Absolute input is
58
+ * returned as-is. A relative path resolves against the project, preferring the
59
+ * copy that a warm start refreshes (see the header).
60
+ *
61
+ * A file that is missing everywhere resolves to its /workspace candidate, so
62
+ * the caller's own `fs` error names a real path — `readProjectFile` keeps
63
+ * reporting ENOENT the way it always has.
64
+ */
65
+ export function resolveProjectPath(p) {
66
+ if (path.isAbsolute(p))
67
+ return p;
68
+ const rel = relative(p);
69
+ if (rel.startsWith("spectest/")) {
70
+ const app = path.join(APP_DIR, rel);
71
+ if (existsSync(app))
72
+ return app;
73
+ }
74
+ if (envIgnored().has(rel)) {
75
+ throw new Error(`project file "${p}" is excluded by spectest/.envignore, so the copy in the VM ` +
76
+ `is whatever a cold start uploaded and can be out of date. Move it under ` +
77
+ `spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`);
78
+ }
79
+ return path.join(WORKSPACE, rel);
80
+ }
81
+ /** {@link resolveProjectPath}, but a missing file is an error that names every
82
+ * place we looked. For inputs a user hands us by name — a fixture — where an
83
+ * ENOENT on one guessed path reads as a spectest bug rather than a typo. */
84
+ export function resolveExistingProjectPath(p, what = "file") {
85
+ const resolved = resolveProjectPath(p);
86
+ if (existsSync(resolved))
87
+ return resolved;
88
+ const rel = relative(p);
89
+ const looked = path.isAbsolute(p)
90
+ ? [p]
91
+ : rel.startsWith("spectest/")
92
+ ? [path.join(APP_DIR, rel), path.join(WORKSPACE, rel)]
93
+ : [path.join(WORKSPACE, rel)];
94
+ throw new Error(`${what} "${p}" is not in the VM (looked in ${looked.join(", ")}). Paths are ` +
95
+ `relative to your repo root. Check that the file is committed and not ` +
96
+ `excluded by .gitignore/.spectestignore.`);
97
+ }
@@ -18,6 +18,15 @@ interface BaseEvent {
18
18
  * timelines. The parent event is identified by its `seq`.
19
19
  */
20
20
  parentSeq?: number;
21
+ /**
22
+ * Set when this event is not an op of its own but *another view of its
23
+ * parent's value* — what a fake helper's `annotate(...)` records
24
+ * (`annotate.ts`). The two kinds of `parentSeq` child read differently
25
+ * and render differently: a `ctx.poll` iteration is work the step did,
26
+ * and belongs below it; an annotation is the same value drawn better,
27
+ * and belongs in place of it (the dashboard tabs between the two).
28
+ */
29
+ annotation?: boolean;
21
30
  }
22
31
  export interface ExecEvent extends BaseEvent {
23
32
  kind: "exec";
@@ -311,14 +320,17 @@ export interface EmailEventMessage {
311
320
  size: number;
312
321
  }[];
313
322
  }
314
- /** One row of a mailbox listing embedded on an {@link EmailEvent}. */
315
- export interface EmailEventSummary {
316
- from?: string;
317
- to?: string[];
318
- subject?: string;
319
- /** Plain-text preview of the body. */
323
+ /**
324
+ * One row of a mailbox listing embedded on an {@link EmailEvent}. A row is a
325
+ * message: the dashboard lists rows as `to` + subject and expands the one you
326
+ * click into the same view a single-message op renders, so whatever body the
327
+ * producer has belongs here. Mailpit's list API returns no bodies, so
328
+ * `email()`'s own listings fill only the header fields plus `snippet` — a row
329
+ * with no body expands to its preview instead.
330
+ */
331
+ export interface EmailEventSummary extends EmailEventMessage {
332
+ /** Plain-text preview of the body, shown when the row carries no body. */
320
333
  snippet?: string;
321
- date?: string;
322
334
  }
323
335
  /**
324
336
  * One call to an email service's helpers (`ctx.svc.<name>.lastEmail(...)`
@@ -332,9 +344,13 @@ export interface EmailEventSummary {
332
344
  */
333
345
  export interface EmailEvent extends BaseEvent {
334
346
  kind: "email";
335
- /** Service key of the mail server (`ctx.svc.<service>`). */
347
+ /** Service key of the mail server (`ctx.svc.<service>`) — or the fake's
348
+ * name, when a fake helper annotated its return with `annotate(v, "email")`
349
+ * (`annotate.ts`) and this event hangs off that call's `fake` one as a
350
+ * `parentSeq` child. */
336
351
  service: string;
337
- /** Helper called, e.g. `"lastEmail"`. */
352
+ /** Helper called, e.g. `"lastEmail"` — the fake's member name for an
353
+ * annotated fake-helper call. */
338
354
  op: string;
339
355
  /** Human-readable match criteria, e.g. `to alice@example.com`. */
340
356
  query?: string;
@@ -394,6 +410,9 @@ export interface BrowserEvent extends BaseEvent {
394
410
  key?: string;
395
411
  /** Attribute name (getAttribute). */
396
412
  attribute?: string;
413
+ /** File names given to an `<input type="file">` (setInputFiles). Names
414
+ * only — a built file's bytes stay out of the timeline. */
415
+ files?: string[];
397
416
  /** Scroll/click coordinates. */
398
417
  dx?: number;
399
418
  dy?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,292 @@
1
+ // Render annotations — how a fake tells the dashboard to draw a helper's
2
+ // return value as something richer than JSON.
3
+ //
4
+ // A fake helper call is recorded as a `fake` step and its return value is
5
+ // rendered as pretty JSON, which is the right default for arbitrary data.
6
+ // When the value *is* a domain object the timeline already draws well — an
7
+ // email, today — the fake says so: `return annotate(msg, "email", {…})`.
8
+ // The call is still recorded as the `fake` step it is; the annotation rides
9
+ // *under* it as a child event (`parentSeq`, marked `annotation`) of the kind
10
+ // that already knows how to draw this — for `"email"`, the very event the
11
+ // built-in `email()` component's mailbox helpers record — so the dashboard
12
+ // renders it mail-client style (header block + sandboxed HTML body) with no
13
+ // new rendering code. It leads the step's detail panel; the raw JSON value
14
+ // is a tab away.
15
+ //
16
+ // One function, keyed by kind: the kind is a key of `AnnotationOptions`, so
17
+ // the options argument is typed against the kind that was named, and a new
18
+ // kind is an entry in that interface (+ a case in `lower`, + an arm in
19
+ // `daemon.ts::recordAnnotatedCall`) rather than a new export.
20
+ //
21
+ // The test is unaffected. `annotate` returns a box that the daemon opens at
22
+ // the helper boundary (`daemon.ts::invokeFakeHelper`), so the caller gets
23
+ // the raw value back, inspect-wrapped as always, and the *type* it sees is
24
+ // still the raw value's (`WrappedHelpers` in index.ts unwraps `Annotated`).
25
+ // Assertions keep linking to the fake step, exactly as for an unannotated
26
+ // helper. The annotation is a rendering hint and nothing else.
27
+ //
28
+ // This is a fake-helper mechanism: those calls go through a tracking proxy
29
+ // that can open the box. A *service* helper (`ctx.svc.<name>.…`) is the raw
30
+ // record its factory returned, so an annotation there would reach the test
31
+ // as an opaque value — don't.
32
+
33
+ import { deepUnwrap } from "./inspect.js";
34
+ import {
35
+ truncateUtf8,
36
+ type EmailEventMessage,
37
+ type EmailEventSummary,
38
+ } from "./recorder.js";
39
+
40
+ /**
41
+ * Runtime marker on the box `annotate(...)` returns. `Symbol.for` rather
42
+ * than a module-local symbol so a second copy of the SDK still recognises
43
+ * a box minted by the first — the duplicate-module-instance hazard that
44
+ * silently drops assertion events when the SDK lands in an app dir twice
45
+ * (see `sdk.rs`).
46
+ */
47
+ const ANNOTATION = Symbol.for("spectest.render-annotation");
48
+
49
+ /** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
50
+ * box can't be read as the value by accident inside the fake, and the
51
+ * helper types can recognise it and hand the *test* back a plain `T`. */
52
+ declare const ANNOTATED: unique symbol;
53
+
54
+ /**
55
+ * A value plus a rendering hint, as returned by {@link annotate}. Return it
56
+ * straight from a fake helper: the daemon unwraps it, so the test receives
57
+ * the value itself and `ctx.fakes.<name>.<fn>()` is typed as if the
58
+ * annotation weren't there.
59
+ */
60
+ export interface Annotated<T> {
61
+ readonly [ANNOTATED]: T;
62
+ }
63
+
64
+ /**
65
+ * Every annotation kind, and the options each one takes. This interface is
66
+ * the whole type contract of {@link annotate}: naming a kind picks the type
67
+ * of the options argument, so a stray field or a value that belongs to
68
+ * another kind is a compile error at the call site.
69
+ *
70
+ * A new kind is an entry here, a case in `lower`, and an arm in
71
+ * `daemon.ts::recordAnnotationChild`.
72
+ */
73
+ export interface AnnotationOptions {
74
+ /** Draw the value as an email — one message, or a list of them for a
75
+ * mailbox listing. See {@link EmailAnnotation}. */
76
+ email: EmailAnnotation | readonly EmailAnnotation[];
77
+ }
78
+
79
+ /** The kinds {@link annotate} accepts. */
80
+ export type AnnotationKind = keyof AnnotationOptions;
81
+
82
+ /**
83
+ * What an annotation lowers to: the fields of the child event the daemon
84
+ * records under the call's own `fake` one. Tagged by that event's `kind` —
85
+ * which needn't be the annotation kind's name, since an annotation names
86
+ * what the value *is* and the event names how it's drawn.
87
+ */
88
+ export type RenderAnnotation = {
89
+ kind: "email";
90
+ /** Single-message ops. */
91
+ message?: EmailEventMessage;
92
+ /** Listing ops. */
93
+ messages?: EmailEventSummary[];
94
+ /** Total matches (a listing is capped on the event, never in the value). */
95
+ count?: number;
96
+ };
97
+
98
+ /**
99
+ * The email fields to render. Every one is optional — pass what the fake
100
+ * has. Addresses take a single string or a list.
101
+ *
102
+ * The event can also carry a date, per-row previews and attachment
103
+ * metadata, which the built-in `email()` component fills from a really
104
+ * captured message; a fake's mail was never sent, so those are deliberately
105
+ * not offered here. A listing row's preview is derived from the body.
106
+ */
107
+ export interface EmailAnnotation {
108
+ from?: string;
109
+ to?: string | readonly string[];
110
+ cc?: string | readonly string[];
111
+ bcc?: string | readonly string[];
112
+ subject?: string;
113
+ /** HTML body. Rendered in a fully sandboxed iframe (scripts off). */
114
+ html?: string;
115
+ /** Plain-text body. Shown on its own when there's no HTML, folded into a
116
+ * "Plain-text version" disclosure when there is. */
117
+ text?: string;
118
+ }
119
+
120
+ /** Listing rows carried on the event. The returned value is never capped. */
121
+ const LIST_CAP = 50;
122
+
123
+ /** How much of a body becomes a listing row's preview. */
124
+ const SNIPPET_CHARS = 200;
125
+
126
+ const NO_FIELDS =
127
+ 'annotate(value, "email"): nothing to render — no email fields found on ' +
128
+ "the value. Pass them explicitly, e.g. " +
129
+ 'annotate(value, "email", { to: value.recipient, subject: value.title, ' +
130
+ "html: value.body }).";
131
+
132
+ /**
133
+ * Say what a fake helper's return value *is*, so the timeline can draw it
134
+ * as that on top of the JSON it always shows. The call stays an ordinary
135
+ * fake step; the rendered view nests inside it. The value is returned to
136
+ * the test unchanged (and unchanged in type).
137
+ *
138
+ * ```ts
139
+ * helpers: ({ state }) => ({
140
+ * lastReceipt() {
141
+ * const r = state.receipts.at(-1);
142
+ * return r && annotate(r, "email", {
143
+ * from: "receipts@stripe.test",
144
+ * to: r.customer,
145
+ * subject: `Receipt for ${r.description}`,
146
+ * html: r.body,
147
+ * });
148
+ * },
149
+ * })
150
+ * ```
151
+ *
152
+ * `kind` picks what the options are: `"email"` takes an
153
+ * {@link EmailAnnotation} (or a list of them, which renders a mailbox
154
+ * listing instead of one message). Every field is optional, and with no
155
+ * options at all they're read off the value itself — enough when it already
156
+ * carries `from`/`to`/`subject`/`html`/`text`. Throws if there is nothing to
157
+ * render, rather than recording an empty step.
158
+ */
159
+ export function annotate<T, K extends AnnotationKind>(
160
+ value: T,
161
+ kind: K,
162
+ options?: AnnotationOptions[K],
163
+ ): Annotated<T> {
164
+ return box(value, lower(kind, options, value));
165
+ }
166
+
167
+ /** Turn a kind + its options into the event fields to record. */
168
+ function lower<K extends AnnotationKind>(
169
+ kind: K,
170
+ options: AnnotationOptions[K] | undefined,
171
+ value: unknown,
172
+ ): RenderAnnotation {
173
+ switch (kind) {
174
+ case "email":
175
+ return lowerEmail(options as AnnotationOptions["email"] | undefined, value);
176
+ default:
177
+ // Unreachable while `kind` is a key of AnnotationOptions; a kind added
178
+ // to that interface without a case here lands on this line.
179
+ throw new Error(`annotate(): unknown annotation kind ${String(kind)}`);
180
+ }
181
+ }
182
+
183
+ function lowerEmail(
184
+ options: AnnotationOptions["email"] | undefined,
185
+ value: unknown,
186
+ ): RenderAnnotation {
187
+ // `deepUnwrap` only where fields are *read* — a value off another
188
+ // instrumented call is a provenance carrier, and `String(carrier)` would
189
+ // otherwise be its coerced shape rather than the address it holds. The
190
+ // value handed back to the test is never touched.
191
+ const source: unknown = deepUnwrap(options ?? value);
192
+ if (Array.isArray(source)) {
193
+ const messages = source.slice(0, LIST_CAP).map((m) => summaryOf(fields(m)));
194
+ if (source.length > 0 && messages.every((m) => isEmpty(m))) {
195
+ throw new Error(NO_FIELDS);
196
+ }
197
+ return { kind: "email", messages, count: source.length };
198
+ }
199
+ const message = messageOf(fields(source));
200
+ if (isEmpty(message)) throw new Error(NO_FIELDS);
201
+ return { kind: "email", message };
202
+ }
203
+
204
+ /** Open an annotation box. Returns `undefined` for any ordinary value, so
205
+ * call sites can treat annotation as the exception it is. */
206
+ export function readAnnotation(
207
+ value: unknown,
208
+ ): { annotation: RenderAnnotation; value: unknown } | undefined {
209
+ if (typeof value !== "object" || value === null) return undefined;
210
+ const annotation = (value as Record<symbol, unknown>)[ANNOTATION] as
211
+ | RenderAnnotation
212
+ | undefined;
213
+ if (!annotation) return undefined;
214
+ return { annotation, value: (value as { value: unknown }).value };
215
+ }
216
+
217
+ function box<T>(value: T, annotation: RenderAnnotation): Annotated<T> {
218
+ return { [ANNOTATION]: annotation, value } as unknown as Annotated<T>;
219
+ }
220
+
221
+ function isEmpty(o: object): boolean {
222
+ return Object.keys(o).length === 0;
223
+ }
224
+
225
+ /** The value as a field bag — anything that isn't an object contributes
226
+ * nothing, and falls through to the `NO_FIELDS` error. */
227
+ function fields(v: unknown): Record<string, unknown> {
228
+ return typeof v === "object" && v !== null
229
+ ? (v as Record<string, unknown>)
230
+ : {};
231
+ }
232
+
233
+ function messageOf(src: Record<string, unknown>): EmailEventMessage {
234
+ const msg: EmailEventMessage = {};
235
+ const from = text(src.from);
236
+ if (from) msg.from = from;
237
+ for (const key of ["to", "cc", "bcc"] as const) {
238
+ const addrs = addresses(src[key]);
239
+ if (addrs.length > 0) msg[key] = addrs;
240
+ }
241
+ const subject = text(src.subject);
242
+ if (subject) msg.subject = subject;
243
+ const html = text(src.html);
244
+ if (html) {
245
+ const t = truncateUtf8(html);
246
+ msg.html = t.value;
247
+ if (t.truncated) msg.htmlTruncated = true;
248
+ }
249
+ const body = text(src.text);
250
+ if (body) {
251
+ const t = truncateUtf8(body);
252
+ msg.text = t.value;
253
+ if (t.truncated) msg.textTruncated = true;
254
+ }
255
+ return msg;
256
+ }
257
+
258
+ /** A listing row is a whole message plus its preview line — the dashboard
259
+ * expands the row you click into the full view, so the body has to ride
260
+ * along rather than being summarised away. */
261
+ function summaryOf(src: Record<string, unknown>): EmailEventSummary {
262
+ const row: EmailEventSummary = messageOf(src);
263
+ const snippet = preview(text(src.text), text(src.html));
264
+ if (snippet) row.snippet = snippet;
265
+ return row;
266
+ }
267
+
268
+ /** A listing row's preview: the plain-text body if there is one, else the
269
+ * HTML with its tags stripped — enough to tell two messages apart. */
270
+ function preview(body: string, html: string): string {
271
+ const raw = body || html.replace(/<[^>]*>/g, " ");
272
+ const collapsed = raw.replace(/\s+/g, " ").trim();
273
+ return collapsed.length > SNIPPET_CHARS
274
+ ? `${collapsed.slice(0, SNIPPET_CHARS)}…`
275
+ : collapsed;
276
+ }
277
+
278
+ /** Scalars render as themselves; anything else (an object, a nested array)
279
+ * is not an address or a subject line and is dropped rather than shown as
280
+ * `[object Object]`. */
281
+ function text(v: unknown): string {
282
+ if (typeof v === "string") return v;
283
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
284
+ return "";
285
+ }
286
+
287
+ function addresses(v: unknown): string[] {
288
+ if (typeof v === "string") return v ? [v] : [];
289
+ if (!Array.isArray(v)) return [];
290
+ return v.map((a) => text(a)).filter((a) => a.length > 0);
291
+ }
292
+
package/src/browser.ts CHANGED
@@ -2092,4 +2092,5 @@ export interface RecordableFields {
2092
2092
  attempts: number;
2093
2093
  artifactId: string;
2094
2094
  attribute: string;
2095
+ files: string[];
2095
2096
  }