@specific.dev/spectest 0.39.0 → 0.43.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/browser.d.ts +21 -8
- package/dist/browser.js +78 -36
- package/dist/components/supabase.d.ts +87 -27
- package/dist/components/supabase.js +352 -69
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +464 -987
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +58 -16
- package/dist/ingress.d.ts +1 -1
- package/dist/mobile.d.ts +9 -5
- package/dist/mobile.js +7 -6
- package/dist/recorder.d.ts +10 -0
- package/dist/resolver.js +5 -8
- package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/dist/vendor/rrweb-record.min.js +5061 -0
- package/package.json +7 -1
- package/src/aws-sigv4.ts +218 -0
- package/src/browser.ts +2095 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5537 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2767 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +138 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +856 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- 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";
|