@specific.dev/spectest 0.38.0 → 0.41.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.
Files changed (103) hide show
  1. package/dist/components/k3s.js +1 -24
  2. package/dist/components/supabase.d.ts +87 -27
  3. package/dist/components/supabase.js +352 -69
  4. package/dist/daemon.d.ts +38 -0
  5. package/dist/daemon.js +405 -946
  6. package/dist/harness/build-context.d.ts +82 -0
  7. package/dist/harness/build-context.js +113 -0
  8. package/dist/harness/buildkit-progress.d.ts +37 -0
  9. package/dist/harness/buildkit-progress.js +66 -0
  10. package/dist/harness/container-run.d.ts +89 -0
  11. package/dist/harness/container-run.js +118 -0
  12. package/dist/harness/file-mounts.d.ts +91 -0
  13. package/dist/harness/file-mounts.js +119 -0
  14. package/dist/harness/hostmatch.d.ts +65 -0
  15. package/dist/harness/hostmatch.js +108 -0
  16. package/dist/harness/http-proxy.d.ts +62 -0
  17. package/dist/harness/http-proxy.js +104 -0
  18. package/dist/harness/ingress-table.d.ts +148 -0
  19. package/dist/harness/ingress-table.js +129 -0
  20. package/dist/harness/log-delta.d.ts +54 -0
  21. package/dist/harness/log-delta.js +83 -0
  22. package/dist/harness/main.d.ts +47 -0
  23. package/dist/harness/main.js +164 -0
  24. package/dist/harness/methods.d.ts +54 -0
  25. package/dist/harness/methods.js +65 -0
  26. package/dist/harness/names-registry.d.ts +63 -0
  27. package/dist/harness/names-registry.js +90 -0
  28. package/dist/harness/protocol.d.ts +88 -0
  29. package/dist/harness/protocol.js +96 -0
  30. package/dist/harness/ready-poll.d.ts +47 -0
  31. package/dist/harness/ready-poll.js +67 -0
  32. package/dist/harness/service-graph.d.ts +29 -0
  33. package/dist/harness/service-graph.js +92 -0
  34. package/dist/harness/volume-paths.d.ts +70 -0
  35. package/dist/harness/volume-paths.js +81 -0
  36. package/dist/index.d.ts +3 -3
  37. package/dist/ingress.d.ts +1 -1
  38. package/dist/inspect.d.ts +23 -0
  39. package/dist/inspect.js +65 -0
  40. package/dist/resolver.js +5 -8
  41. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  42. package/dist/vendor/rrweb-record.min.js +5061 -0
  43. package/package.json +7 -1
  44. package/src/aws-sigv4.ts +218 -0
  45. package/src/browser.ts +2040 -0
  46. package/src/components/aws.ts +554 -0
  47. package/src/components/email.ts +398 -0
  48. package/src/components/expo.ts +167 -0
  49. package/src/components/index.ts +81 -0
  50. package/src/components/k3s.ts +2061 -0
  51. package/src/components/postgres.ts +132 -0
  52. package/src/components/replayFake.ts +1015 -0
  53. package/src/components/s3.ts +132 -0
  54. package/src/components/supabase.ts +1699 -0
  55. package/src/daemon.ts +5489 -0
  56. package/src/harness/build-context.test.ts +0 -0
  57. package/src/harness/build-context.ts +146 -0
  58. package/src/harness/buildkit-progress.test.ts +98 -0
  59. package/src/harness/buildkit-progress.ts +74 -0
  60. package/src/harness/container-run.test.ts +209 -0
  61. package/src/harness/container-run.ts +158 -0
  62. package/src/harness/file-mounts.test.ts +185 -0
  63. package/src/harness/file-mounts.ts +145 -0
  64. package/src/harness/hostmatch.test.ts +148 -0
  65. package/src/harness/hostmatch.ts +109 -0
  66. package/src/harness/http-proxy.test.ts +156 -0
  67. package/src/harness/http-proxy.ts +119 -0
  68. package/src/harness/ingress-rebind.test.ts +125 -0
  69. package/src/harness/ingress-table.test.ts +172 -0
  70. package/src/harness/ingress-table.ts +186 -0
  71. package/src/harness/log-delta.test.ts +125 -0
  72. package/src/harness/log-delta.ts +100 -0
  73. package/src/harness/main.test.ts +211 -0
  74. package/src/harness/main.ts +196 -0
  75. package/src/harness/methods.test.ts +63 -0
  76. package/src/harness/methods.ts +92 -0
  77. package/src/harness/names-registry.test.ts +137 -0
  78. package/src/harness/names-registry.ts +108 -0
  79. package/src/harness/protocol.test.ts +148 -0
  80. package/src/harness/protocol.ts +163 -0
  81. package/src/harness/ready-poll.test.ts +172 -0
  82. package/src/harness/ready-poll.ts +93 -0
  83. package/src/harness/service-graph.test.ts +97 -0
  84. package/src/harness/service-graph.ts +97 -0
  85. package/src/harness/volume-paths.test.ts +102 -0
  86. package/src/harness/volume-paths.ts +112 -0
  87. package/src/ids.ts +89 -0
  88. package/src/index.ts +2725 -0
  89. package/src/ingress.ts +305 -0
  90. package/src/inspect.ts +739 -0
  91. package/src/locator.ts +716 -0
  92. package/src/mobile.ts +133 -0
  93. package/src/record-secrets.ts +41 -0
  94. package/src/recorder.ts +846 -0
  95. package/src/redis.ts +202 -0
  96. package/src/replay-bundle.ts +108 -0
  97. package/src/resolver.ts +348 -0
  98. package/src/s3.ts +333 -0
  99. package/src/sql.ts +243 -0
  100. package/src/terminal.ts +740 -0
  101. package/src/url-match.ts +67 -0
  102. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  103. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -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
+ }
@@ -0,0 +1,167 @@
1
+ // `expo()` — a ready-to-use service for an Expo / React Native app rendered
2
+ // to the web, built for `ctx.mobile(...)`.
3
+ //
4
+ // Serving model: STATIC EXPORT. The image runs `expo export -p web` at build
5
+ // time (producing `dist/`) and the container just serves those static files
6
+ // with a tiny Node server. No Metro at runtime — so the ready-check is fast
7
+ // and deterministic and the bundle rides the image-layer cache, instead of
8
+ // paying a non-deterministic first-request bundle on every boot.
9
+ //
10
+ // The service's `helpers` factory returns a `MobileApp` handle (the resolved
11
+ // in-VM URL), so a test drives it with `ctx.mobile(ctx.svc.app)` — no URL, no
12
+ // `navigate`.
13
+
14
+ import type { ServiceDefinition } from "../index.js";
15
+ import { mobileApp } from "../mobile.js";
16
+ import type { MobileApp } from "../mobile.js";
17
+
18
+ export interface ExpoOptions {
19
+ /** Port the static server listens on. Default `8081`. */
20
+ port?: number;
21
+ /**
22
+ * App directory inside the project, relative to the build context root.
23
+ * Default `"."` (the project root is the Expo app). Set this when the
24
+ * Expo app lives in a subdirectory of a larger repo.
25
+ */
26
+ appDir?: string;
27
+ /** Node base image tag. Default `"20-bookworm-slim"`. */
28
+ nodeVersion?: string;
29
+ /** Output dir `expo export` writes to (relative to the app dir). Default `"dist"`. */
30
+ outputDir?: string;
31
+ /**
32
+ * Extra environment variables. `EXPO_PUBLIC_*` keys are additionally baked
33
+ * into the image build so `expo export` inlines them into the bundle —
34
+ * Expo resolves them at export time, so runtime-only container env would be
35
+ * too late. Everything else applies to the static-server container only.
36
+ */
37
+ env?: Record<string, string>;
38
+ /**
39
+ * Script installed before each `ctx.mobile(ctx.svc.app)` session's first
40
+ * navigation, so it runs ahead of the app bundle on the very first document
41
+ * — no relaunch. Declare shims here once (reduced-motion, `Notification`, …)
42
+ * instead of calling them at the top of every test.
43
+ */
44
+ initScript?: string;
45
+ }
46
+
47
+ /** The handle `expo()` exposes on `ctx.svc.<name>` — a {@link MobileApp}. */
48
+ export type ExpoHelpers = MobileApp;
49
+
50
+ // A dependency-free static file server with SPA fallback. Bind-mounted into
51
+ // the container (so it isn't baked into the image build) and run as the
52
+ // container command. Serves `dist/`, falling back to index.html for client
53
+ // routes. Embedded as a file rather than `npx serve` so the container needs
54
+ // no extra install at runtime.
55
+ const STATIC_SERVER = String.raw`import { createServer } from "node:http";
56
+ import { stat, readFile } from "node:fs/promises";
57
+ import { join, extname, normalize, resolve } from "node:path";
58
+
59
+ const root = resolve(process.argv[2] || "dist");
60
+ const port = Number(process.argv[3] || 8081);
61
+ const TYPES = {
62
+ ".html": "text/html; charset=utf-8",
63
+ ".js": "text/javascript; charset=utf-8",
64
+ ".mjs": "text/javascript; charset=utf-8",
65
+ ".css": "text/css; charset=utf-8",
66
+ ".json": "application/json; charset=utf-8",
67
+ ".map": "application/json; charset=utf-8",
68
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
69
+ ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon",
70
+ ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2",
71
+ ".ttf": "font/ttf", ".otf": "font/otf", ".wasm": "application/wasm",
72
+ };
73
+
74
+ async function send(res, fp) {
75
+ const body = await readFile(fp);
76
+ res.writeHead(200, { "content-type": TYPES[extname(fp)] || "application/octet-stream" });
77
+ res.end(body);
78
+ }
79
+
80
+ createServer(async (req, res) => {
81
+ try {
82
+ let p = decodeURIComponent((req.url || "/").split("?")[0]);
83
+ if (p.endsWith("/")) p += "index.html";
84
+ let fp = normalize(join(root, p));
85
+ if (!fp.startsWith(root)) { res.writeHead(403); return res.end("forbidden"); }
86
+ try {
87
+ const s = await stat(fp);
88
+ if (s.isDirectory()) fp = join(fp, "index.html");
89
+ await send(res, fp);
90
+ } catch {
91
+ // SPA fallback: unknown path → index.html.
92
+ await send(res, join(root, "index.html"));
93
+ }
94
+ } catch (e) {
95
+ res.writeHead(500);
96
+ res.end(String(e));
97
+ }
98
+ }).listen(port, "0.0.0.0", () => console.log("[expo-static] serving " + root + " on :" + port));
99
+ `;
100
+
101
+ /**
102
+ * A ready-to-use Expo (web) service. Drop into `environment.services` and
103
+ * drive it with `ctx.mobile`:
104
+ *
105
+ * ```ts
106
+ * services: { app: expo() }
107
+ * // in a test:
108
+ * const m = await ctx.mobile(ctx.svc.app);
109
+ * await m.getByText("Sign in").tap();
110
+ * ```
111
+ *
112
+ * The web build is produced once at image-build time; the container serves
113
+ * the static bundle. `ctx.svc.<key>` is the app's `MobileApp` handle.
114
+ */
115
+ export function expo(opts: ExpoOptions = {}) {
116
+ const port = opts.port ?? 8081;
117
+ const appDir = opts.appDir ?? ".";
118
+ const nodeVersion = opts.nodeVersion ?? "20-bookworm-slim";
119
+ const outputDir = opts.outputDir ?? "dist";
120
+
121
+ // Expo inlines EXPO_PUBLIC_* variables into the bundle at `expo export`
122
+ // time, so they must exist during the image build — runtime container env
123
+ // is too late. Bake them as ENV lines placed after `npm install` (an env
124
+ // edit re-exports without re-installing) and before the export RUN. They
125
+ // remain in `env` too, so server-side reads in the container agree.
126
+ const publicEnv = Object.entries(opts.env ?? {}).filter(([k]) =>
127
+ k.startsWith("EXPO_PUBLIC_"),
128
+ );
129
+ for (const [k, v] of publicEnv) {
130
+ if (/[\r\n]/.test(v)) {
131
+ throw new Error(`expo(): env ${k} must not contain newlines (baked into the Dockerfile)`);
132
+ }
133
+ }
134
+ const publicEnvLines = publicEnv
135
+ .map(([k, v]) => `ENV ${k}="${v.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`)
136
+ .join("\n");
137
+
138
+ // CI=1 keeps `expo export` non-interactive. Static export resolves the web
139
+ // bundle deterministically into `outputDir`.
140
+ const dockerfile = `FROM node:${nodeVersion}
141
+ WORKDIR /app
142
+ ENV CI=1
143
+ COPY ${appDir}/ ./
144
+ RUN npm install
145
+ ${publicEnvLines ? `${publicEnvLines}\n` : ""}RUN npx expo export --platform web --output-dir ${outputDir}
146
+ `;
147
+
148
+ return {
149
+ image: { type: "dockerfile", content: dockerfile },
150
+ files: [
151
+ { path: "/spectest-expo-serve.mjs", content: STATIC_SERVER },
152
+ ],
153
+ command: `node /spectest-expo-serve.mjs /app/${outputDir} ${port}`,
154
+ env: { ...(opts.env ?? {}) },
155
+ ports: [port],
156
+ readyCheck: { type: "http" as const, port, path: "/", timeoutSecs: 60 },
157
+ helpers: ({ name }: { name: string }): ExpoHelpers => {
158
+ // Use the multi-label `<name>.internal` alias (the daemon registers it
159
+ // for every service) rather than the bare single-label `<name>`:
160
+ // headless Chromium mishandles a single-label http navigation (it tries
161
+ // TLS → net::ERR_SSL_PROTOCOL_ERROR), while a dotted host navigates as
162
+ // plain HTTP. Node-side `fetch` is unaffected, but the mobile session
163
+ // drives a real browser, so the URL must be browser-navigable.
164
+ return mobileApp(`http://${name}.internal:${port}`, opts.initScript);
165
+ },
166
+ } satisfies ServiceDefinition<ExpoHelpers>;
167
+ }
@@ -0,0 +1,81 @@
1
+ // Reusable, pre-built service definitions for common dependencies.
2
+ //
3
+ // Each component returns a fully-formed `ServiceDefinition` ready to
4
+ // drop into an environment's `services` map. Beyond the container
5
+ // boilerplate (image, env vars, volumes, ready checks) a component may
6
+ // also ship a `client` factory — that's what surfaces convenience
7
+ // handles like `ctx.svc.db.client` (a Bun SQL pool for `postgres(...)`).
8
+
9
+ export {
10
+ postgres,
11
+ type PostgresOptions,
12
+ type PostgresHelpers,
13
+ } from "./postgres.js";
14
+ export {
15
+ s3,
16
+ type S3Options,
17
+ type S3Helpers,
18
+ } from "./s3.js";
19
+ // `SQL` / `SqlClient` now live in the core SDK (`@specific.dev/spectest`),
20
+ // next to `expect` and `fetch` — re-exported here so existing
21
+ // `from ".../components"` imports keep resolving.
22
+ export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
23
+ export {
24
+ k3s,
25
+ type K3sOptions,
26
+ type K3sHelpers,
27
+ type K3sClient,
28
+ type TaggedObjectApi,
29
+ type KubernetesObjectRef,
30
+ type ApplyOptions,
31
+ type RolloutTarget,
32
+ type WaitOptions,
33
+ type LogsOptions,
34
+ // Re-exported from `@kubernetes/client-node` so a project can type its
35
+ // custom resources (`interface Cluster extends KubernetesObject { … }`)
36
+ // without depending on the kubernetes client directly.
37
+ type KubernetesObject,
38
+ type KubernetesListObject,
39
+ } from "./k3s.js";
40
+ export {
41
+ expo,
42
+ type ExpoOptions,
43
+ type ExpoHelpers,
44
+ } from "./expo.js";
45
+ export {
46
+ supabase,
47
+ type SupabaseOptions,
48
+ type SupabaseHelpers,
49
+ type SupabaseStack,
50
+ } from "./supabase.js";
51
+ export {
52
+ email,
53
+ type EmailOptions,
54
+ type EmailHelpers,
55
+ type EmailMessage,
56
+ type EmailSummary,
57
+ type EmailMatch,
58
+ type EmailAttachment,
59
+ } from "./email.js";
60
+ export {
61
+ aws,
62
+ type AwsOptions,
63
+ type LambdaOptions,
64
+ } from "./aws.js";
65
+ export {
66
+ replayFake,
67
+ type ReplayFakeOptions,
68
+ type ReplayMatch,
69
+ type InjectRule,
70
+ type InjectMatch,
71
+ type StringMatch,
72
+ type SignConfig,
73
+ type AwsSigV4Sign,
74
+ type ReplayHelpers,
75
+ type ReplaySummary,
76
+ type Cassette,
77
+ type CassetteInteraction,
78
+ type CassetteRequest,
79
+ type CassetteResponse,
80
+ type CassetteState,
81
+ } from "./replayFake.js";