@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/annotate.d.ts +101 -0
- package/dist/annotate.js +196 -0
- package/dist/browser.d.ts +1 -0
- package/dist/daemon.js +129 -41
- package/dist/index.d.ts +20 -2
- package/dist/index.js +7 -0
- package/dist/locator.d.ts +32 -0
- package/dist/locator.js +28 -0
- package/dist/project-files.d.ts +18 -0
- package/dist/project-files.js +97 -0
- package/dist/recorder.d.ts +28 -9
- package/package.json +1 -1
- package/src/annotate.ts +292 -0
- package/src/browser.ts +1 -0
- package/src/daemon.ts +159 -48
- package/src/index.ts +35 -2
- package/src/locator.ts +70 -0
- package/src/project-files.ts +103 -0
- package/src/recorder.ts +28 -9
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
|
+
}
|
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;
|
|
@@ -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
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
|
+
|