@specific.dev/spectest 0.13.0 → 0.15.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,387 @@
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
+ function buildHelpers(service: string, base: string): EmailHelpers {
180
+ return {
181
+ async lastEmail() {
182
+ return instrumented(service, "lastEmail", undefined, async () => {
183
+ const [hit] = await listAll(base); // newest first
184
+ if (!hit) return { value: undefined, count: 0 };
185
+ const value = await getMessage(base, hit.id);
186
+ return { value, message: toEventMessage(value) };
187
+ });
188
+ },
189
+
190
+ emails(match) {
191
+ return instrumented(service, "emails", describeMatch(match), async () => {
192
+ const value = (await listAll(base)).filter((s) => matches(s, match));
193
+ return {
194
+ value,
195
+ count: value.length,
196
+ messages: value.slice(0, EVENT_LIST_CAP).map(toEventSummary),
197
+ };
198
+ });
199
+ },
200
+
201
+ async clear() {
202
+ await instrumented(service, "clear", undefined, async () => {
203
+ await api(base, "/api/v1/messages", { method: "DELETE" });
204
+ return { value: undefined };
205
+ });
206
+ },
207
+ };
208
+ }
209
+
210
+ /** Newest-first mailbox listing rows returned by `emails()` are capped at
211
+ * this many entries on the recorded event (the return value itself is
212
+ * never truncated). */
213
+ const EVENT_LIST_CAP = 50;
214
+
215
+ /** Run one helper op: reserve a timeline slot up front, record an `email`
216
+ * event when the op settles, and hand the value back inspect-wrapped
217
+ * against that event so assertions on it nest under the step. */
218
+ async function instrumented<T>(
219
+ service: string,
220
+ op: string,
221
+ query: string | undefined,
222
+ body: () => Promise<{
223
+ value: T;
224
+ message?: EmailEventMessage;
225
+ messages?: EmailEventSummary[];
226
+ count?: number;
227
+ }>,
228
+ ): Promise<Wrapped<T>> {
229
+ const started = Date.now();
230
+ const resv = reserveEvent();
231
+ try {
232
+ const { value, message, messages, count } = await body();
233
+ const seq = recordEmail(
234
+ { service, op, query, message, messages, count, durationMs: Date.now() - started },
235
+ resv,
236
+ );
237
+ return wrap(value, seq) as Wrapped<T>;
238
+ } catch (err) {
239
+ const e = err as Error;
240
+ recordEmail(
241
+ {
242
+ service,
243
+ op,
244
+ query,
245
+ durationMs: Date.now() - started,
246
+ error: e?.message ?? String(err),
247
+ },
248
+ resv,
249
+ );
250
+ throw err;
251
+ }
252
+ }
253
+
254
+ /** Query the mail server's HTTP API. Recording is paused around the fetch
255
+ * so these internal polls don't land as `http` events on the timeline —
256
+ * the helper records one consolidated `email` event instead. */
257
+ async function api(base: string, path: string, init?: RequestInit): Promise<unknown> {
258
+ pauseRecording();
259
+ try {
260
+ const res = await fetch(`${base}${path}`, init);
261
+ if (!res.ok) {
262
+ throw new Error(`email server API ${path} failed: HTTP ${res.status}`);
263
+ }
264
+ const text = await res.text();
265
+ if (text === "") return undefined;
266
+ try {
267
+ return JSON.parse(text);
268
+ } catch {
269
+ // Mutating endpoints reply with a plain-text acknowledgement.
270
+ return text;
271
+ }
272
+ } finally {
273
+ resumeRecording();
274
+ }
275
+ }
276
+
277
+ interface RawAddress {
278
+ Name?: string;
279
+ Address?: string;
280
+ }
281
+
282
+ function addresses(v: unknown): string[] {
283
+ if (!Array.isArray(v)) return [];
284
+ return v.map((a) => (a as RawAddress)?.Address ?? "").filter((a) => a !== "");
285
+ }
286
+
287
+ async function listAll(base: string): Promise<EmailSummary[]> {
288
+ const data = (await api(base, "/api/v1/messages?limit=500")) as {
289
+ messages?: unknown[];
290
+ };
291
+ return (data?.messages ?? []).map((raw) => {
292
+ const m = raw as Record<string, unknown>;
293
+ return {
294
+ id: (m.ID as string) ?? "",
295
+ from: (m.From as RawAddress)?.Address ?? "",
296
+ to: addresses(m.To),
297
+ subject: (m.Subject as string) ?? "",
298
+ snippet: (m.Snippet as string) ?? "",
299
+ date: (m.Created as string) ?? "",
300
+ attachments: (m.Attachments as number) ?? 0,
301
+ };
302
+ });
303
+ }
304
+
305
+ async function getMessage(base: string, id: string): Promise<EmailMessage> {
306
+ const m = (await api(base, `/api/v1/message/${encodeURIComponent(id)}`)) as Record<
307
+ string,
308
+ unknown
309
+ >;
310
+ return {
311
+ id: (m.ID as string) ?? "",
312
+ from: (m.From as RawAddress)?.Address ?? "",
313
+ to: addresses(m.To),
314
+ cc: addresses(m.Cc),
315
+ bcc: addresses(m.Bcc),
316
+ subject: (m.Subject as string) ?? "",
317
+ date: (m.Date as string) ?? "",
318
+ text: (m.Text as string) ?? "",
319
+ html: (m.HTML as string) ?? "",
320
+ attachments: (Array.isArray(m.Attachments) ? m.Attachments : []).map((raw) => {
321
+ const a = raw as Record<string, unknown>;
322
+ return {
323
+ filename: (a.FileName as string) ?? "",
324
+ contentType: (a.ContentType as string) ?? "",
325
+ size: (a.Size as number) ?? 0,
326
+ };
327
+ }),
328
+ };
329
+ }
330
+
331
+ function matches(s: EmailSummary, match?: EmailMatch): boolean {
332
+ if (!match) return true;
333
+ if (match.to !== undefined) {
334
+ const want = match.to.toLowerCase();
335
+ if (!s.to.some((a) => a.toLowerCase() === want)) return false;
336
+ }
337
+ if (match.from !== undefined && s.from.toLowerCase() !== match.from.toLowerCase()) {
338
+ return false;
339
+ }
340
+ if (match.subject !== undefined) {
341
+ if (typeof match.subject === "string") {
342
+ if (!s.subject.includes(match.subject)) return false;
343
+ } else if (!match.subject.test(s.subject)) {
344
+ return false;
345
+ }
346
+ }
347
+ return true;
348
+ }
349
+
350
+ function describeMatch(match?: EmailMatch): string | undefined {
351
+ if (!match) return undefined;
352
+ const parts: string[] = [];
353
+ if (match.to !== undefined) parts.push(`to ${match.to}`);
354
+ if (match.from !== undefined) parts.push(`from ${match.from}`);
355
+ if (match.subject !== undefined) parts.push(`subject ${String(match.subject)}`);
356
+ return parts.length > 0 ? parts.join(", ") : undefined;
357
+ }
358
+
359
+ function toEventSummary(s: EmailSummary): EmailEventSummary {
360
+ return {
361
+ from: s.from,
362
+ to: s.to,
363
+ subject: s.subject,
364
+ snippet: s.snippet,
365
+ date: s.date,
366
+ };
367
+ }
368
+
369
+ function toEventMessage(m: EmailMessage): EmailEventMessage {
370
+ const html = truncateUtf8(m.html);
371
+ const text = truncateUtf8(m.text);
372
+ return {
373
+ from: m.from,
374
+ to: m.to,
375
+ ...(m.cc.length > 0 ? { cc: m.cc } : {}),
376
+ ...(m.bcc.length > 0 ? { bcc: m.bcc } : {}),
377
+ subject: m.subject,
378
+ date: m.date,
379
+ ...(m.html !== ""
380
+ ? { html: html.value, ...(html.truncated ? { htmlTruncated: true } : {}) }
381
+ : {}),
382
+ ...(m.text !== ""
383
+ ? { text: text.value, ...(text.truncated ? { textTruncated: true } : {}) }
384
+ : {}),
385
+ ...(m.attachments.length > 0 ? { attachments: m.attachments } : {}),
386
+ };
387
+ }
@@ -31,6 +31,21 @@ export {
31
31
  type ExpoOptions,
32
32
  type ExpoHelpers,
33
33
  } from "./expo.js";
34
+ export {
35
+ supabase,
36
+ type SupabaseOptions,
37
+ type SupabaseHelpers,
38
+ type SupabaseStack,
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";
34
49
  export {
35
50
  replayFake,
36
51
  type ReplayFakeOptions,
@@ -18,7 +18,7 @@ import {
18
18
  } from "@kubernetes/client-node";
19
19
  import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
20
20
 
21
- import type { ServiceDefinition } from "../index.js";
21
+ import type { ServiceDefinition, ServiceHelpersContext } from "../index.js";
22
22
  import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
23
23
  import { readRaw, readTag, wrap } from "../inspect.js";
24
24
  import type { Wrapped } from "../inspect.js";
@@ -180,13 +180,6 @@ function runProcess(
180
180
  });
181
181
  }
182
182
 
183
- function runDocker(
184
- args: string[],
185
- timeoutMs = 30_000,
186
- ): Promise<DockerExecResult> {
187
- return runProcess("docker", args, timeoutMs);
188
- }
189
-
190
183
  // In-VM root CA, generated once into the base snapshot (see
191
184
  // control-plane `base.rs`). Trusted everywhere the test framework runs —
192
185
  // Node (`NODE_EXTRA_CA_CERTS`), Chromium (NSS DB), Python, the system
@@ -1242,19 +1235,14 @@ export function k3s(opts: K3sOptions = {}) {
1242
1235
  ingressDomains,
1243
1236
  });
1244
1237
  },
1245
- helpers: async ({ name }: { name: string }): Promise<K3sHelpers> => {
1238
+ helpers: async ({ name, exec }: ServiceHelpersContext): Promise<K3sHelpers> => {
1246
1239
  // Read the cluster's kubeconfig and address the API server by its
1247
1240
  // auto-assigned `<name>.internal` hostname on spectest-net. TLS
1248
1241
  // verification is off (see the K3sHelpers docstring), so the
1249
1242
  // server's cert SAN list doesn't need to include the .internal
1250
1243
  // name.
1251
- const kcRead = await runDocker([
1252
- "exec",
1253
- name,
1254
- "cat",
1255
- "/etc/rancher/k3s/k3s.yaml",
1256
- ]);
1257
- if (kcRead.code !== 0) {
1244
+ const kcRead = await exec(name, ["cat", "/etc/rancher/k3s/k3s.yaml"]);
1245
+ if (kcRead.exitCode !== 0) {
1258
1246
  throw new Error(
1259
1247
  `k3s(${name}): failed to read kubeconfig from container: ${kcRead.stderr.trim()}`,
1260
1248
  );