@specific.dev/spectest 0.26.0 → 0.27.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 (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +143 -0
  12. package/dist/components/k3s.js +1067 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4223 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1183 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +516 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1819
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
@@ -0,0 +1,135 @@
1
+ import type { Wrapped } from "../inspect.js";
2
+ export interface EmailOptions {
3
+ /** TCP port the SMTP listener binds. Default `1025`. */
4
+ smtpPort?: number;
5
+ /** Override the underlying mail-server image. */
6
+ image?: string;
7
+ /** Extra environment variables forwarded to the container. */
8
+ env?: Record<string, string>;
9
+ }
10
+ export interface EmailAttachment {
11
+ filename: string;
12
+ contentType: string;
13
+ /** Decoded size in bytes. */
14
+ size: number;
15
+ }
16
+ /** A fully captured email, as returned by `lastEmail`. */
17
+ export interface EmailMessage {
18
+ id: string;
19
+ /** Sender address. */
20
+ from: string;
21
+ /** Recipient addresses. */
22
+ to: string[];
23
+ cc: string[];
24
+ bcc: string[];
25
+ subject: string;
26
+ /** Message date, ISO-formatted. */
27
+ date: string;
28
+ /** Plain-text body ("" when the mail had none). */
29
+ text: string;
30
+ /** HTML body ("" when the mail had none). */
31
+ html: string;
32
+ attachments: EmailAttachment[];
33
+ }
34
+ /** A mailbox-listing row, as returned by `emails()`. */
35
+ export interface EmailSummary {
36
+ id: string;
37
+ from: string;
38
+ to: string[];
39
+ subject: string;
40
+ /** Plain-text preview of the body. */
41
+ snippet: string;
42
+ date: string;
43
+ /** Attachment count. */
44
+ attachments: number;
45
+ }
46
+ /** Filter for `emails()` listings. All given fields must match. */
47
+ export interface EmailMatch {
48
+ /** A recipient address, compared case-insensitively. */
49
+ to?: string;
50
+ /** The sender address, compared case-insensitively. */
51
+ from?: string;
52
+ /** Subject substring (string) or pattern (RegExp). */
53
+ subject?: string | RegExp;
54
+ }
55
+ /** Helpers an `email(...)` service exposes on `ctx.svc.<name>`. */
56
+ export interface EmailHelpers {
57
+ /**
58
+ * The newest captured message, in full (or `undefined` while the mailbox
59
+ * is empty) — which makes it the natural `ctx.poll` predicate for waiting
60
+ * on delivery; assert on its fields once it returns:
61
+ *
62
+ * ```ts
63
+ * const mail = await ctx.poll("welcome email", () => ctx.svc.email.lastEmail());
64
+ * expect(mail.to).toContain("alice@example.com");
65
+ * ```
66
+ *
67
+ * To wait for a *specific* message when several are in flight, check
68
+ * fields inside the predicate:
69
+ *
70
+ * ```ts
71
+ * const mail = await ctx.poll("reset email", async () => {
72
+ * const m = await ctx.svc.email.lastEmail();
73
+ * return m && /reset/i.test(m.unwrap().subject) ? m : undefined;
74
+ * });
75
+ * ```
76
+ */
77
+ lastEmail(): Promise<Wrapped<EmailMessage> | undefined>;
78
+ /** All captured messages matching `match`, newest first. */
79
+ emails(match?: EmailMatch): Promise<Wrapped<EmailSummary[]>>;
80
+ /** Delete every captured message. */
81
+ clear(): Promise<void>;
82
+ }
83
+ /**
84
+ * A capture-everything SMTP server. Drop into `environment.services`:
85
+ *
86
+ * ```ts
87
+ * services: {
88
+ * email: email(),
89
+ * app: {
90
+ * ...,
91
+ * env: { SMTP_HOST: "email", SMTP_PORT: "1025" },
92
+ * },
93
+ * }
94
+ * ```
95
+ *
96
+ * The app sends real SMTP (any or no credentials are accepted, no TLS
97
+ * required); tests assert on what arrived, using the standard `ctx.poll`
98
+ * to wait for delivery:
99
+ *
100
+ * ```ts
101
+ * const mail = await ctx.poll("welcome email", () => ctx.svc.email.lastEmail());
102
+ * expect(mail.to).toContain("alice@example.com");
103
+ * expect(mail.subject).toBe("Welcome!");
104
+ * expect(mail.html).toContain("Alice");
105
+ * ```
106
+ */
107
+ export declare function email(opts?: EmailOptions): {
108
+ image: {
109
+ type: "registry";
110
+ reference: string;
111
+ };
112
+ env: {
113
+ MP_SMTP_BIND_ADDR?: string | undefined;
114
+ MP_SMTP_AUTH_ACCEPT_ANY: string;
115
+ MP_SMTP_AUTH_ALLOW_INSECURE: string;
116
+ };
117
+ ports: number[];
118
+ readyCheck: {
119
+ type: "http";
120
+ port: number;
121
+ path: string;
122
+ timeoutSecs: number;
123
+ };
124
+ helpers: ({ name }: {
125
+ name: string;
126
+ }) => EmailHelpers;
127
+ };
128
+ /**
129
+ * Build the mailbox helpers for an `email()` service, by its services-map
130
+ * key. For components that expose a shared mailbox on their own handle
131
+ * (e.g. `supabase({ mail: true })` surfacing `ctx.svc.supabase.mail`) —
132
+ * the recorded `email` events carry `service` so the dashboard attributes
133
+ * them to the right container.
134
+ */
135
+ export declare function emailHelpers(service: string, apiPort?: number): EmailHelpers;
@@ -0,0 +1,271 @@
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
+ import { pauseRecording, recordEmail, reserveEvent, resumeRecording, truncateUtf8, } from "../recorder.js";
20
+ import { wrap } from "../inspect.js";
21
+ const DEFAULT_IMAGE = "axllent/mailpit:v1.30";
22
+ const SMTP_PORT = 1025;
23
+ const API_PORT = 8025;
24
+ /**
25
+ * A capture-everything SMTP server. Drop into `environment.services`:
26
+ *
27
+ * ```ts
28
+ * services: {
29
+ * email: email(),
30
+ * app: {
31
+ * ...,
32
+ * env: { SMTP_HOST: "email", SMTP_PORT: "1025" },
33
+ * },
34
+ * }
35
+ * ```
36
+ *
37
+ * The app sends real SMTP (any or no credentials are accepted, no TLS
38
+ * required); tests assert on what arrived, using the standard `ctx.poll`
39
+ * to wait for delivery:
40
+ *
41
+ * ```ts
42
+ * const mail = await ctx.poll("welcome email", () => ctx.svc.email.lastEmail());
43
+ * expect(mail.to).toContain("alice@example.com");
44
+ * expect(mail.subject).toBe("Welcome!");
45
+ * expect(mail.html).toContain("Alice");
46
+ * ```
47
+ */
48
+ export function email(opts = {}) {
49
+ const smtpPort = opts.smtpPort ?? SMTP_PORT;
50
+ const reference = opts.image ?? DEFAULT_IMAGE;
51
+ // `satisfies` (not a return-type annotation) so the helpers factory's
52
+ // literal return type flows through to `ctx.svc.<name>` — see the note
53
+ // in postgres.ts.
54
+ return {
55
+ image: { type: "registry", reference },
56
+ env: {
57
+ // Accept whatever AUTH the app offers (including none, over
58
+ // plaintext), so an app configured with production-style SMTP
59
+ // credentials runs unchanged against the capture server.
60
+ MP_SMTP_AUTH_ACCEPT_ANY: "1",
61
+ MP_SMTP_AUTH_ALLOW_INSECURE: "1",
62
+ ...(smtpPort !== SMTP_PORT
63
+ ? { MP_SMTP_BIND_ADDR: `0.0.0.0:${smtpPort}` }
64
+ : {}),
65
+ ...(opts.env ?? {}),
66
+ },
67
+ ports: [smtpPort, API_PORT],
68
+ readyCheck: {
69
+ type: "http",
70
+ port: API_PORT,
71
+ path: "/livez",
72
+ timeoutSecs: 60,
73
+ },
74
+ helpers: ({ name }) => buildHelpers(name, `http://${name}:${API_PORT}`),
75
+ };
76
+ }
77
+ /**
78
+ * Build the mailbox helpers for an `email()` service, by its services-map
79
+ * key. For components that expose a shared mailbox on their own handle
80
+ * (e.g. `supabase({ mail: true })` surfacing `ctx.svc.supabase.mail`) —
81
+ * the recorded `email` events carry `service` so the dashboard attributes
82
+ * them to the right container.
83
+ */
84
+ export function emailHelpers(service, apiPort = API_PORT) {
85
+ return buildHelpers(service, `http://${service}:${apiPort}`);
86
+ }
87
+ function buildHelpers(service, base) {
88
+ return {
89
+ async lastEmail() {
90
+ return instrumented(service, "lastEmail", undefined, async () => {
91
+ const [hit] = await listAll(base); // newest first
92
+ if (!hit)
93
+ return { value: undefined, count: 0 };
94
+ const value = await getMessage(base, hit.id);
95
+ return { value, message: toEventMessage(value) };
96
+ });
97
+ },
98
+ emails(match) {
99
+ return instrumented(service, "emails", describeMatch(match), async () => {
100
+ const value = (await listAll(base)).filter((s) => matches(s, match));
101
+ return {
102
+ value,
103
+ count: value.length,
104
+ messages: value.slice(0, EVENT_LIST_CAP).map(toEventSummary),
105
+ };
106
+ });
107
+ },
108
+ async clear() {
109
+ await instrumented(service, "clear", undefined, async () => {
110
+ await api(base, "/api/v1/messages", { method: "DELETE" });
111
+ return { value: undefined };
112
+ });
113
+ },
114
+ };
115
+ }
116
+ /** Newest-first mailbox listing rows returned by `emails()` are capped at
117
+ * this many entries on the recorded event (the return value itself is
118
+ * never truncated). */
119
+ const EVENT_LIST_CAP = 50;
120
+ /** Run one helper op: reserve a timeline slot up front, record an `email`
121
+ * event when the op settles, and hand the value back inspect-wrapped
122
+ * against that event so assertions on it nest under the step. */
123
+ async function instrumented(service, op, query, body) {
124
+ const started = Date.now();
125
+ const resv = reserveEvent();
126
+ try {
127
+ const { value, message, messages, count } = await body();
128
+ const seq = recordEmail({ service, op, query, message, messages, count, durationMs: Date.now() - started }, resv);
129
+ return wrap(value, seq);
130
+ }
131
+ catch (err) {
132
+ const e = err;
133
+ recordEmail({
134
+ service,
135
+ op,
136
+ query,
137
+ durationMs: Date.now() - started,
138
+ error: e?.message ?? String(err),
139
+ }, resv);
140
+ throw err;
141
+ }
142
+ }
143
+ /** Query the mail server's HTTP API. Recording is paused around the fetch
144
+ * so these internal polls don't land as `http` events on the timeline —
145
+ * the helper records one consolidated `email` event instead. */
146
+ async function api(base, path, init) {
147
+ pauseRecording();
148
+ try {
149
+ const res = await fetch(`${base}${path}`, init);
150
+ if (!res.ok) {
151
+ throw new Error(`email server API ${path} failed: HTTP ${res.status}`);
152
+ }
153
+ const text = await res.text();
154
+ if (text === "")
155
+ return undefined;
156
+ try {
157
+ return JSON.parse(text);
158
+ }
159
+ catch {
160
+ // Mutating endpoints reply with a plain-text acknowledgement.
161
+ return text;
162
+ }
163
+ }
164
+ finally {
165
+ resumeRecording();
166
+ }
167
+ }
168
+ function addresses(v) {
169
+ if (!Array.isArray(v))
170
+ return [];
171
+ return v.map((a) => a?.Address ?? "").filter((a) => a !== "");
172
+ }
173
+ async function listAll(base) {
174
+ const data = (await api(base, "/api/v1/messages?limit=500"));
175
+ return (data?.messages ?? []).map((raw) => {
176
+ const m = raw;
177
+ return {
178
+ id: m.ID ?? "",
179
+ from: m.From?.Address ?? "",
180
+ to: addresses(m.To),
181
+ subject: m.Subject ?? "",
182
+ snippet: m.Snippet ?? "",
183
+ date: m.Created ?? "",
184
+ attachments: m.Attachments ?? 0,
185
+ };
186
+ });
187
+ }
188
+ async function getMessage(base, id) {
189
+ const m = (await api(base, `/api/v1/message/${encodeURIComponent(id)}`));
190
+ return {
191
+ id: m.ID ?? "",
192
+ from: m.From?.Address ?? "",
193
+ to: addresses(m.To),
194
+ cc: addresses(m.Cc),
195
+ bcc: addresses(m.Bcc),
196
+ subject: m.Subject ?? "",
197
+ date: m.Date ?? "",
198
+ text: m.Text ?? "",
199
+ html: m.HTML ?? "",
200
+ attachments: (Array.isArray(m.Attachments) ? m.Attachments : []).map((raw) => {
201
+ const a = raw;
202
+ return {
203
+ filename: a.FileName ?? "",
204
+ contentType: a.ContentType ?? "",
205
+ size: a.Size ?? 0,
206
+ };
207
+ }),
208
+ };
209
+ }
210
+ function matches(s, match) {
211
+ if (!match)
212
+ return true;
213
+ if (match.to !== undefined) {
214
+ const want = match.to.toLowerCase();
215
+ if (!s.to.some((a) => a.toLowerCase() === want))
216
+ return false;
217
+ }
218
+ if (match.from !== undefined && s.from.toLowerCase() !== match.from.toLowerCase()) {
219
+ return false;
220
+ }
221
+ if (match.subject !== undefined) {
222
+ if (typeof match.subject === "string") {
223
+ if (!s.subject.includes(match.subject))
224
+ return false;
225
+ }
226
+ else if (!match.subject.test(s.subject)) {
227
+ return false;
228
+ }
229
+ }
230
+ return true;
231
+ }
232
+ function describeMatch(match) {
233
+ if (!match)
234
+ return undefined;
235
+ const parts = [];
236
+ if (match.to !== undefined)
237
+ parts.push(`to ${match.to}`);
238
+ if (match.from !== undefined)
239
+ parts.push(`from ${match.from}`);
240
+ if (match.subject !== undefined)
241
+ parts.push(`subject ${String(match.subject)}`);
242
+ return parts.length > 0 ? parts.join(", ") : undefined;
243
+ }
244
+ function toEventSummary(s) {
245
+ return {
246
+ from: s.from,
247
+ to: s.to,
248
+ subject: s.subject,
249
+ snippet: s.snippet,
250
+ date: s.date,
251
+ };
252
+ }
253
+ function toEventMessage(m) {
254
+ const html = truncateUtf8(m.html);
255
+ const text = truncateUtf8(m.text);
256
+ return {
257
+ from: m.from,
258
+ to: m.to,
259
+ ...(m.cc.length > 0 ? { cc: m.cc } : {}),
260
+ ...(m.bcc.length > 0 ? { bcc: m.bcc } : {}),
261
+ subject: m.subject,
262
+ date: m.date,
263
+ ...(m.html !== ""
264
+ ? { html: html.value, ...(html.truncated ? { htmlTruncated: true } : {}) }
265
+ : {}),
266
+ ...(m.text !== ""
267
+ ? { text: text.value, ...(text.truncated ? { textTruncated: true } : {}) }
268
+ : {}),
269
+ ...(m.attachments.length > 0 ? { attachments: m.attachments } : {}),
270
+ };
271
+ }
@@ -0,0 +1,69 @@
1
+ import type { MobileApp } from "../mobile.js";
2
+ export interface ExpoOptions {
3
+ /** Port the static server listens on. Default `8081`. */
4
+ port?: number;
5
+ /**
6
+ * App directory inside the project, relative to the build context root.
7
+ * Default `"."` (the project root is the Expo app). Set this when the
8
+ * Expo app lives in a subdirectory of a larger repo.
9
+ */
10
+ appDir?: string;
11
+ /** Node base image tag. Default `"20-bookworm-slim"`. */
12
+ nodeVersion?: string;
13
+ /** Output dir `expo export` writes to (relative to the app dir). Default `"dist"`. */
14
+ outputDir?: string;
15
+ /**
16
+ * Extra environment variables. `EXPO_PUBLIC_*` keys are additionally baked
17
+ * into the image build so `expo export` inlines them into the bundle —
18
+ * Expo resolves them at export time, so runtime-only container env would be
19
+ * too late. Everything else applies to the static-server container only.
20
+ */
21
+ env?: Record<string, string>;
22
+ /**
23
+ * Script installed before each `ctx.mobile(ctx.svc.app)` session's first
24
+ * navigation, so it runs ahead of the app bundle on the very first document
25
+ * — no relaunch. Declare shims here once (reduced-motion, `Notification`, …)
26
+ * instead of calling them at the top of every test.
27
+ */
28
+ initScript?: string;
29
+ }
30
+ /** The handle `expo()` exposes on `ctx.svc.<name>` — a {@link MobileApp}. */
31
+ export type ExpoHelpers = MobileApp;
32
+ /**
33
+ * A ready-to-use Expo (web) service. Drop into `environment.services` and
34
+ * drive it with `ctx.mobile`:
35
+ *
36
+ * ```ts
37
+ * services: { app: expo() }
38
+ * // in a test:
39
+ * const m = await ctx.mobile(ctx.svc.app);
40
+ * await m.getByText("Sign in").tap();
41
+ * ```
42
+ *
43
+ * The web build is produced once at image-build time; the container serves
44
+ * the static bundle. `ctx.svc.<key>` is the app's `MobileApp` handle.
45
+ */
46
+ export declare function expo(opts?: ExpoOptions): {
47
+ image: {
48
+ type: "dockerfile";
49
+ content: string;
50
+ };
51
+ files: {
52
+ path: string;
53
+ content: string;
54
+ }[];
55
+ command: string;
56
+ env: {
57
+ [x: string]: string;
58
+ };
59
+ ports: number[];
60
+ readyCheck: {
61
+ type: "http";
62
+ port: number;
63
+ path: string;
64
+ timeoutSecs: number;
65
+ };
66
+ helpers: ({ name }: {
67
+ name: string;
68
+ }) => ExpoHelpers;
69
+ };
@@ -0,0 +1,125 @@
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
+ import { mobileApp } from "../mobile.js";
14
+ // A dependency-free static file server with SPA fallback. Bind-mounted into
15
+ // the container (so it isn't baked into the image build) and run as the
16
+ // container command. Serves `dist/`, falling back to index.html for client
17
+ // routes. Embedded as a file rather than `npx serve` so the container needs
18
+ // no extra install at runtime.
19
+ const STATIC_SERVER = String.raw `import { createServer } from "node:http";
20
+ import { stat, readFile } from "node:fs/promises";
21
+ import { join, extname, normalize, resolve } from "node:path";
22
+
23
+ const root = resolve(process.argv[2] || "dist");
24
+ const port = Number(process.argv[3] || 8081);
25
+ const TYPES = {
26
+ ".html": "text/html; charset=utf-8",
27
+ ".js": "text/javascript; charset=utf-8",
28
+ ".mjs": "text/javascript; charset=utf-8",
29
+ ".css": "text/css; charset=utf-8",
30
+ ".json": "application/json; charset=utf-8",
31
+ ".map": "application/json; charset=utf-8",
32
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
33
+ ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon",
34
+ ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2",
35
+ ".ttf": "font/ttf", ".otf": "font/otf", ".wasm": "application/wasm",
36
+ };
37
+
38
+ async function send(res, fp) {
39
+ const body = await readFile(fp);
40
+ res.writeHead(200, { "content-type": TYPES[extname(fp)] || "application/octet-stream" });
41
+ res.end(body);
42
+ }
43
+
44
+ createServer(async (req, res) => {
45
+ try {
46
+ let p = decodeURIComponent((req.url || "/").split("?")[0]);
47
+ if (p.endsWith("/")) p += "index.html";
48
+ let fp = normalize(join(root, p));
49
+ if (!fp.startsWith(root)) { res.writeHead(403); return res.end("forbidden"); }
50
+ try {
51
+ const s = await stat(fp);
52
+ if (s.isDirectory()) fp = join(fp, "index.html");
53
+ await send(res, fp);
54
+ } catch {
55
+ // SPA fallback: unknown path → index.html.
56
+ await send(res, join(root, "index.html"));
57
+ }
58
+ } catch (e) {
59
+ res.writeHead(500);
60
+ res.end(String(e));
61
+ }
62
+ }).listen(port, "0.0.0.0", () => console.log("[expo-static] serving " + root + " on :" + port));
63
+ `;
64
+ /**
65
+ * A ready-to-use Expo (web) service. Drop into `environment.services` and
66
+ * drive it with `ctx.mobile`:
67
+ *
68
+ * ```ts
69
+ * services: { app: expo() }
70
+ * // in a test:
71
+ * const m = await ctx.mobile(ctx.svc.app);
72
+ * await m.getByText("Sign in").tap();
73
+ * ```
74
+ *
75
+ * The web build is produced once at image-build time; the container serves
76
+ * the static bundle. `ctx.svc.<key>` is the app's `MobileApp` handle.
77
+ */
78
+ export function expo(opts = {}) {
79
+ const port = opts.port ?? 8081;
80
+ const appDir = opts.appDir ?? ".";
81
+ const nodeVersion = opts.nodeVersion ?? "20-bookworm-slim";
82
+ const outputDir = opts.outputDir ?? "dist";
83
+ // Expo inlines EXPO_PUBLIC_* variables into the bundle at `expo export`
84
+ // time, so they must exist during the image build — runtime container env
85
+ // is too late. Bake them as ENV lines placed after `npm install` (an env
86
+ // edit re-exports without re-installing) and before the export RUN. They
87
+ // remain in `env` too, so server-side reads in the container agree.
88
+ const publicEnv = Object.entries(opts.env ?? {}).filter(([k]) => k.startsWith("EXPO_PUBLIC_"));
89
+ for (const [k, v] of publicEnv) {
90
+ if (/[\r\n]/.test(v)) {
91
+ throw new Error(`expo(): env ${k} must not contain newlines (baked into the Dockerfile)`);
92
+ }
93
+ }
94
+ const publicEnvLines = publicEnv
95
+ .map(([k, v]) => `ENV ${k}="${v.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`)
96
+ .join("\n");
97
+ // CI=1 keeps `expo export` non-interactive. Static export resolves the web
98
+ // bundle deterministically into `outputDir`.
99
+ const dockerfile = `FROM node:${nodeVersion}
100
+ WORKDIR /app
101
+ ENV CI=1
102
+ COPY ${appDir}/ ./
103
+ RUN npm install
104
+ ${publicEnvLines ? `${publicEnvLines}\n` : ""}RUN npx expo export --platform web --output-dir ${outputDir}
105
+ `;
106
+ return {
107
+ image: { type: "dockerfile", content: dockerfile },
108
+ files: [
109
+ { path: "/spectest-expo-serve.mjs", content: STATIC_SERVER },
110
+ ],
111
+ command: `node /spectest-expo-serve.mjs /app/${outputDir} ${port}`,
112
+ env: { ...(opts.env ?? {}) },
113
+ ports: [port],
114
+ readyCheck: { type: "http", port, path: "/", timeoutSecs: 60 },
115
+ helpers: ({ name }) => {
116
+ // Use the multi-label `<name>.internal` alias (the daemon registers it
117
+ // for every service) rather than the bare single-label `<name>`:
118
+ // headless Chromium mishandles a single-label http navigation (it tries
119
+ // TLS → net::ERR_SSL_PROTOCOL_ERROR), while a dotted host navigates as
120
+ // plain HTTP. Node-side `fetch` is unaffected, but the mobile session
121
+ // drives a real browser, so the URL must be browser-navigable.
122
+ return mobileApp(`http://${name}.internal:${port}`, opts.initScript);
123
+ },
124
+ };
125
+ }
@@ -0,0 +1,8 @@
1
+ export { postgres, type PostgresOptions, type PostgresHelpers, } from "./postgres.js";
2
+ export { s3, type S3Options, type S3Helpers, } from "./s3.js";
3
+ export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
4
+ export { k3s, type K3sOptions, type K3sHelpers, type K3sClient, } from "./k3s.js";
5
+ export { expo, type ExpoOptions, type ExpoHelpers, } from "./expo.js";
6
+ export { supabase, type SupabaseOptions, type SupabaseHelpers, type SupabaseStack, } from "./supabase.js";
7
+ export { email, type EmailOptions, type EmailHelpers, type EmailMessage, type EmailSummary, type EmailMatch, type EmailAttachment, } from "./email.js";
8
+ export { replayFake, type ReplayFakeOptions, type ReplayMatch, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
@@ -0,0 +1,18 @@
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
+ export { postgres, } from "./postgres.js";
9
+ export { s3, } from "./s3.js";
10
+ // `SQL` / `SqlClient` now live in the core SDK (`@specific.dev/spectest`),
11
+ // next to `expect` and `fetch` — re-exported here so existing
12
+ // `from ".../components"` imports keep resolving.
13
+ export { SQL } from "../sql.js";
14
+ export { k3s, } from "./k3s.js";
15
+ export { expo, } from "./expo.js";
16
+ export { supabase, } from "./supabase.js";
17
+ export { email, } from "./email.js";
18
+ export { replayFake, } from "./replayFake.js";