@specific.dev/spectest 0.14.0 → 0.16.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/package.json +1 -1
- package/src/browser.ts +408 -48
- package/src/components/email.ts +398 -0
- package/src/components/index.ts +9 -0
- package/src/components/supabase.ts +216 -26
- package/src/daemon.ts +222 -34
- package/src/index.ts +21 -5
- package/src/mobile.ts +47 -4
- package/src/recorder.ts +73 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// `email()` — a real SMTP server as a spectest service. The app under test
|
|
2
|
+
// points its SMTP transport at `<key>:1025` and sends mail exactly as it
|
|
3
|
+
// would in production; every message is captured instead of delivered.
|
|
4
|
+
// Tests read the mailbox through typed helpers on `ctx.svc.<key>`
|
|
5
|
+
// (`lastEmail`, `emails`, `clear`), each recorded as an `email` event on
|
|
6
|
+
// the test timeline — single-message ops embed the full captured message
|
|
7
|
+
// (HTML body included) so the dashboard renders the actual email the test
|
|
8
|
+
// asserted against, and returns come back inspect-wrapped so
|
|
9
|
+
// `expect(mail.subject)` links under the step. To wait for a message to
|
|
10
|
+
// *arrive*, use the standard `ctx.poll` with `lastEmail` as the predicate
|
|
11
|
+
// (it returns `undefined` while the mailbox is empty); the winning
|
|
12
|
+
// iteration's `email` event survives poll truncation, so the message still
|
|
13
|
+
// renders nested under the wait step.
|
|
14
|
+
//
|
|
15
|
+
// Captured mail lives in the server's process memory, so it snapshots and
|
|
16
|
+
// forks with the rest of the environment: a `dependsOn` child inherits the
|
|
17
|
+
// parent's mailbox, sibling forks never see each other's messages — the
|
|
18
|
+
// same isolation contract as fake state.
|
|
19
|
+
|
|
20
|
+
import type { ServiceDefinition } from "../index.js";
|
|
21
|
+
import {
|
|
22
|
+
pauseRecording,
|
|
23
|
+
recordEmail,
|
|
24
|
+
reserveEvent,
|
|
25
|
+
resumeRecording,
|
|
26
|
+
truncateUtf8,
|
|
27
|
+
type EmailEventMessage,
|
|
28
|
+
type EmailEventSummary,
|
|
29
|
+
} from "../recorder.js";
|
|
30
|
+
import { wrap } from "../inspect.js";
|
|
31
|
+
import type { Wrapped } from "../inspect.js";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_IMAGE = "axllent/mailpit:v1.30";
|
|
34
|
+
const SMTP_PORT = 1025;
|
|
35
|
+
const API_PORT = 8025;
|
|
36
|
+
|
|
37
|
+
export interface EmailOptions {
|
|
38
|
+
/** TCP port the SMTP listener binds. Default `1025`. */
|
|
39
|
+
smtpPort?: number;
|
|
40
|
+
/** Override the underlying mail-server image. */
|
|
41
|
+
image?: string;
|
|
42
|
+
/** Extra environment variables forwarded to the container. */
|
|
43
|
+
env?: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EmailAttachment {
|
|
47
|
+
filename: string;
|
|
48
|
+
contentType: string;
|
|
49
|
+
/** Decoded size in bytes. */
|
|
50
|
+
size: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A fully captured email, as returned by `lastEmail`. */
|
|
54
|
+
export interface EmailMessage {
|
|
55
|
+
id: string;
|
|
56
|
+
/** Sender address. */
|
|
57
|
+
from: string;
|
|
58
|
+
/** Recipient addresses. */
|
|
59
|
+
to: string[];
|
|
60
|
+
cc: string[];
|
|
61
|
+
bcc: string[];
|
|
62
|
+
subject: string;
|
|
63
|
+
/** Message date, ISO-formatted. */
|
|
64
|
+
date: string;
|
|
65
|
+
/** Plain-text body ("" when the mail had none). */
|
|
66
|
+
text: string;
|
|
67
|
+
/** HTML body ("" when the mail had none). */
|
|
68
|
+
html: string;
|
|
69
|
+
attachments: EmailAttachment[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A mailbox-listing row, as returned by `emails()`. */
|
|
73
|
+
export interface EmailSummary {
|
|
74
|
+
id: string;
|
|
75
|
+
from: string;
|
|
76
|
+
to: string[];
|
|
77
|
+
subject: string;
|
|
78
|
+
/** Plain-text preview of the body. */
|
|
79
|
+
snippet: string;
|
|
80
|
+
date: string;
|
|
81
|
+
/** Attachment count. */
|
|
82
|
+
attachments: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Filter for `emails()` listings. All given fields must match. */
|
|
86
|
+
export interface EmailMatch {
|
|
87
|
+
/** A recipient address, compared case-insensitively. */
|
|
88
|
+
to?: string;
|
|
89
|
+
/** The sender address, compared case-insensitively. */
|
|
90
|
+
from?: string;
|
|
91
|
+
/** Subject substring (string) or pattern (RegExp). */
|
|
92
|
+
subject?: string | RegExp;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Helpers an `email(...)` service exposes on `ctx.svc.<name>`. */
|
|
96
|
+
export interface EmailHelpers {
|
|
97
|
+
/**
|
|
98
|
+
* The newest captured message, in full (or `undefined` while the mailbox
|
|
99
|
+
* is empty) — which makes it the natural `ctx.poll` predicate for waiting
|
|
100
|
+
* on delivery; assert on its fields once it returns:
|
|
101
|
+
*
|
|
102
|
+
* ```ts
|
|
103
|
+
* const mail = await ctx.poll("welcome email", () => ctx.svc.email.lastEmail());
|
|
104
|
+
* expect(mail.to).toContain("alice@example.com");
|
|
105
|
+
* ```
|
|
106
|
+
*
|
|
107
|
+
* To wait for a *specific* message when several are in flight, check
|
|
108
|
+
* fields inside the predicate:
|
|
109
|
+
*
|
|
110
|
+
* ```ts
|
|
111
|
+
* const mail = await ctx.poll("reset email", async () => {
|
|
112
|
+
* const m = await ctx.svc.email.lastEmail();
|
|
113
|
+
* return m && /reset/i.test(m.unwrap().subject) ? m : undefined;
|
|
114
|
+
* });
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
lastEmail(): Promise<Wrapped<EmailMessage> | undefined>;
|
|
118
|
+
/** All captured messages matching `match`, newest first. */
|
|
119
|
+
emails(match?: EmailMatch): Promise<Wrapped<EmailSummary[]>>;
|
|
120
|
+
/** Delete every captured message. */
|
|
121
|
+
clear(): Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A capture-everything SMTP server. Drop into `environment.services`:
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* services: {
|
|
129
|
+
* email: email(),
|
|
130
|
+
* app: {
|
|
131
|
+
* ...,
|
|
132
|
+
* env: { SMTP_HOST: "email", SMTP_PORT: "1025" },
|
|
133
|
+
* },
|
|
134
|
+
* }
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* The app sends real SMTP (any or no credentials are accepted, no TLS
|
|
138
|
+
* required); tests assert on what arrived, using the standard `ctx.poll`
|
|
139
|
+
* to wait for delivery:
|
|
140
|
+
*
|
|
141
|
+
* ```ts
|
|
142
|
+
* const mail = await ctx.poll("welcome email", () => ctx.svc.email.lastEmail());
|
|
143
|
+
* expect(mail.to).toContain("alice@example.com");
|
|
144
|
+
* expect(mail.subject).toBe("Welcome!");
|
|
145
|
+
* expect(mail.html).toContain("Alice");
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export function email(opts: EmailOptions = {}) {
|
|
149
|
+
const smtpPort = opts.smtpPort ?? SMTP_PORT;
|
|
150
|
+
const reference = opts.image ?? DEFAULT_IMAGE;
|
|
151
|
+
// `satisfies` (not a return-type annotation) so the helpers factory's
|
|
152
|
+
// literal return type flows through to `ctx.svc.<name>` — see the note
|
|
153
|
+
// in postgres.ts.
|
|
154
|
+
return {
|
|
155
|
+
image: { type: "registry" as const, reference },
|
|
156
|
+
env: {
|
|
157
|
+
// Accept whatever AUTH the app offers (including none, over
|
|
158
|
+
// plaintext), so an app configured with production-style SMTP
|
|
159
|
+
// credentials runs unchanged against the capture server.
|
|
160
|
+
MP_SMTP_AUTH_ACCEPT_ANY: "1",
|
|
161
|
+
MP_SMTP_AUTH_ALLOW_INSECURE: "1",
|
|
162
|
+
...(smtpPort !== SMTP_PORT
|
|
163
|
+
? { MP_SMTP_BIND_ADDR: `0.0.0.0:${smtpPort}` }
|
|
164
|
+
: {}),
|
|
165
|
+
...(opts.env ?? {}),
|
|
166
|
+
},
|
|
167
|
+
ports: [smtpPort, API_PORT],
|
|
168
|
+
readyCheck: {
|
|
169
|
+
type: "http" as const,
|
|
170
|
+
port: API_PORT,
|
|
171
|
+
path: "/livez",
|
|
172
|
+
timeoutSecs: 60,
|
|
173
|
+
},
|
|
174
|
+
helpers: ({ name }: { name: string }): EmailHelpers =>
|
|
175
|
+
buildHelpers(name, `http://${name}:${API_PORT}`),
|
|
176
|
+
} satisfies ServiceDefinition<EmailHelpers>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build the mailbox helpers for an `email()` service, by its services-map
|
|
181
|
+
* key. For components that expose a shared mailbox on their own handle
|
|
182
|
+
* (e.g. `supabase({ mail: true })` surfacing `ctx.svc.supabase.mail`) —
|
|
183
|
+
* the recorded `email` events carry `service` so the dashboard attributes
|
|
184
|
+
* them to the right container.
|
|
185
|
+
*/
|
|
186
|
+
export function emailHelpers(service: string, apiPort: number = API_PORT): EmailHelpers {
|
|
187
|
+
return buildHelpers(service, `http://${service}:${apiPort}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildHelpers(service: string, base: string): EmailHelpers {
|
|
191
|
+
return {
|
|
192
|
+
async lastEmail() {
|
|
193
|
+
return instrumented(service, "lastEmail", undefined, async () => {
|
|
194
|
+
const [hit] = await listAll(base); // newest first
|
|
195
|
+
if (!hit) return { value: undefined, count: 0 };
|
|
196
|
+
const value = await getMessage(base, hit.id);
|
|
197
|
+
return { value, message: toEventMessage(value) };
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
emails(match) {
|
|
202
|
+
return instrumented(service, "emails", describeMatch(match), async () => {
|
|
203
|
+
const value = (await listAll(base)).filter((s) => matches(s, match));
|
|
204
|
+
return {
|
|
205
|
+
value,
|
|
206
|
+
count: value.length,
|
|
207
|
+
messages: value.slice(0, EVENT_LIST_CAP).map(toEventSummary),
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
async clear() {
|
|
213
|
+
await instrumented(service, "clear", undefined, async () => {
|
|
214
|
+
await api(base, "/api/v1/messages", { method: "DELETE" });
|
|
215
|
+
return { value: undefined };
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Newest-first mailbox listing rows returned by `emails()` are capped at
|
|
222
|
+
* this many entries on the recorded event (the return value itself is
|
|
223
|
+
* never truncated). */
|
|
224
|
+
const EVENT_LIST_CAP = 50;
|
|
225
|
+
|
|
226
|
+
/** Run one helper op: reserve a timeline slot up front, record an `email`
|
|
227
|
+
* event when the op settles, and hand the value back inspect-wrapped
|
|
228
|
+
* against that event so assertions on it nest under the step. */
|
|
229
|
+
async function instrumented<T>(
|
|
230
|
+
service: string,
|
|
231
|
+
op: string,
|
|
232
|
+
query: string | undefined,
|
|
233
|
+
body: () => Promise<{
|
|
234
|
+
value: T;
|
|
235
|
+
message?: EmailEventMessage;
|
|
236
|
+
messages?: EmailEventSummary[];
|
|
237
|
+
count?: number;
|
|
238
|
+
}>,
|
|
239
|
+
): Promise<Wrapped<T>> {
|
|
240
|
+
const started = Date.now();
|
|
241
|
+
const resv = reserveEvent();
|
|
242
|
+
try {
|
|
243
|
+
const { value, message, messages, count } = await body();
|
|
244
|
+
const seq = recordEmail(
|
|
245
|
+
{ service, op, query, message, messages, count, durationMs: Date.now() - started },
|
|
246
|
+
resv,
|
|
247
|
+
);
|
|
248
|
+
return wrap(value, seq) as Wrapped<T>;
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const e = err as Error;
|
|
251
|
+
recordEmail(
|
|
252
|
+
{
|
|
253
|
+
service,
|
|
254
|
+
op,
|
|
255
|
+
query,
|
|
256
|
+
durationMs: Date.now() - started,
|
|
257
|
+
error: e?.message ?? String(err),
|
|
258
|
+
},
|
|
259
|
+
resv,
|
|
260
|
+
);
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Query the mail server's HTTP API. Recording is paused around the fetch
|
|
266
|
+
* so these internal polls don't land as `http` events on the timeline —
|
|
267
|
+
* the helper records one consolidated `email` event instead. */
|
|
268
|
+
async function api(base: string, path: string, init?: RequestInit): Promise<unknown> {
|
|
269
|
+
pauseRecording();
|
|
270
|
+
try {
|
|
271
|
+
const res = await fetch(`${base}${path}`, init);
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
throw new Error(`email server API ${path} failed: HTTP ${res.status}`);
|
|
274
|
+
}
|
|
275
|
+
const text = await res.text();
|
|
276
|
+
if (text === "") return undefined;
|
|
277
|
+
try {
|
|
278
|
+
return JSON.parse(text);
|
|
279
|
+
} catch {
|
|
280
|
+
// Mutating endpoints reply with a plain-text acknowledgement.
|
|
281
|
+
return text;
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
resumeRecording();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
interface RawAddress {
|
|
289
|
+
Name?: string;
|
|
290
|
+
Address?: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function addresses(v: unknown): string[] {
|
|
294
|
+
if (!Array.isArray(v)) return [];
|
|
295
|
+
return v.map((a) => (a as RawAddress)?.Address ?? "").filter((a) => a !== "");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function listAll(base: string): Promise<EmailSummary[]> {
|
|
299
|
+
const data = (await api(base, "/api/v1/messages?limit=500")) as {
|
|
300
|
+
messages?: unknown[];
|
|
301
|
+
};
|
|
302
|
+
return (data?.messages ?? []).map((raw) => {
|
|
303
|
+
const m = raw as Record<string, unknown>;
|
|
304
|
+
return {
|
|
305
|
+
id: (m.ID as string) ?? "",
|
|
306
|
+
from: (m.From as RawAddress)?.Address ?? "",
|
|
307
|
+
to: addresses(m.To),
|
|
308
|
+
subject: (m.Subject as string) ?? "",
|
|
309
|
+
snippet: (m.Snippet as string) ?? "",
|
|
310
|
+
date: (m.Created as string) ?? "",
|
|
311
|
+
attachments: (m.Attachments as number) ?? 0,
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function getMessage(base: string, id: string): Promise<EmailMessage> {
|
|
317
|
+
const m = (await api(base, `/api/v1/message/${encodeURIComponent(id)}`)) as Record<
|
|
318
|
+
string,
|
|
319
|
+
unknown
|
|
320
|
+
>;
|
|
321
|
+
return {
|
|
322
|
+
id: (m.ID as string) ?? "",
|
|
323
|
+
from: (m.From as RawAddress)?.Address ?? "",
|
|
324
|
+
to: addresses(m.To),
|
|
325
|
+
cc: addresses(m.Cc),
|
|
326
|
+
bcc: addresses(m.Bcc),
|
|
327
|
+
subject: (m.Subject as string) ?? "",
|
|
328
|
+
date: (m.Date as string) ?? "",
|
|
329
|
+
text: (m.Text as string) ?? "",
|
|
330
|
+
html: (m.HTML as string) ?? "",
|
|
331
|
+
attachments: (Array.isArray(m.Attachments) ? m.Attachments : []).map((raw) => {
|
|
332
|
+
const a = raw as Record<string, unknown>;
|
|
333
|
+
return {
|
|
334
|
+
filename: (a.FileName as string) ?? "",
|
|
335
|
+
contentType: (a.ContentType as string) ?? "",
|
|
336
|
+
size: (a.Size as number) ?? 0,
|
|
337
|
+
};
|
|
338
|
+
}),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function matches(s: EmailSummary, match?: EmailMatch): boolean {
|
|
343
|
+
if (!match) return true;
|
|
344
|
+
if (match.to !== undefined) {
|
|
345
|
+
const want = match.to.toLowerCase();
|
|
346
|
+
if (!s.to.some((a) => a.toLowerCase() === want)) return false;
|
|
347
|
+
}
|
|
348
|
+
if (match.from !== undefined && s.from.toLowerCase() !== match.from.toLowerCase()) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
if (match.subject !== undefined) {
|
|
352
|
+
if (typeof match.subject === "string") {
|
|
353
|
+
if (!s.subject.includes(match.subject)) return false;
|
|
354
|
+
} else if (!match.subject.test(s.subject)) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function describeMatch(match?: EmailMatch): string | undefined {
|
|
362
|
+
if (!match) return undefined;
|
|
363
|
+
const parts: string[] = [];
|
|
364
|
+
if (match.to !== undefined) parts.push(`to ${match.to}`);
|
|
365
|
+
if (match.from !== undefined) parts.push(`from ${match.from}`);
|
|
366
|
+
if (match.subject !== undefined) parts.push(`subject ${String(match.subject)}`);
|
|
367
|
+
return parts.length > 0 ? parts.join(", ") : undefined;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function toEventSummary(s: EmailSummary): EmailEventSummary {
|
|
371
|
+
return {
|
|
372
|
+
from: s.from,
|
|
373
|
+
to: s.to,
|
|
374
|
+
subject: s.subject,
|
|
375
|
+
snippet: s.snippet,
|
|
376
|
+
date: s.date,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function toEventMessage(m: EmailMessage): EmailEventMessage {
|
|
381
|
+
const html = truncateUtf8(m.html);
|
|
382
|
+
const text = truncateUtf8(m.text);
|
|
383
|
+
return {
|
|
384
|
+
from: m.from,
|
|
385
|
+
to: m.to,
|
|
386
|
+
...(m.cc.length > 0 ? { cc: m.cc } : {}),
|
|
387
|
+
...(m.bcc.length > 0 ? { bcc: m.bcc } : {}),
|
|
388
|
+
subject: m.subject,
|
|
389
|
+
date: m.date,
|
|
390
|
+
...(m.html !== ""
|
|
391
|
+
? { html: html.value, ...(html.truncated ? { htmlTruncated: true } : {}) }
|
|
392
|
+
: {}),
|
|
393
|
+
...(m.text !== ""
|
|
394
|
+
? { text: text.value, ...(text.truncated ? { textTruncated: true } : {}) }
|
|
395
|
+
: {}),
|
|
396
|
+
...(m.attachments.length > 0 ? { attachments: m.attachments } : {}),
|
|
397
|
+
};
|
|
398
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -37,6 +37,15 @@ export {
|
|
|
37
37
|
type SupabaseHelpers,
|
|
38
38
|
type SupabaseStack,
|
|
39
39
|
} from "./supabase.js";
|
|
40
|
+
export {
|
|
41
|
+
email,
|
|
42
|
+
type EmailOptions,
|
|
43
|
+
type EmailHelpers,
|
|
44
|
+
type EmailMessage,
|
|
45
|
+
type EmailSummary,
|
|
46
|
+
type EmailMatch,
|
|
47
|
+
type EmailAttachment,
|
|
48
|
+
} from "./email.js";
|
|
40
49
|
export {
|
|
41
50
|
replayFake,
|
|
42
51
|
type ReplayFakeOptions,
|