@specific.dev/spectest 0.93.0 → 0.93.1
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/components/email.js +14 -39
- package/dist/components/s3.js +2 -1
- package/dist/components/supabase.js +2 -1
- package/package.json +1 -1
- package/src/components/email.ts +12 -20
- package/src/components/internal-fetch.test.ts +109 -0
- package/src/components/s3.ts +2 -1
- package/src/components/supabase.ts +2 -1
package/dist/components/email.js
CHANGED
|
@@ -1,22 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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";
|
|
1
|
+
import { rawFetch } from "../harness/raw-fetch.js";
|
|
2
|
+
import { recordEmail, reserveEvent, truncateUtf8, } from "../recorder.js";
|
|
20
3
|
import { wrap } from "../inspect.js";
|
|
21
4
|
const DEFAULT_IMAGE = "axllent/mailpit:v1.30";
|
|
22
5
|
const SMTP_PORT = 1025;
|
|
@@ -140,29 +123,21 @@ async function instrumented(service, op, query, body) {
|
|
|
140
123
|
throw err;
|
|
141
124
|
}
|
|
142
125
|
}
|
|
143
|
-
/**
|
|
144
|
-
* so these internal polls don't land as `http` events on the timeline —
|
|
145
|
-
* the helper records one consolidated `email` event instead. */
|
|
126
|
+
/** Internal HTTP stays native; the public helper records one email event. */
|
|
146
127
|
async function api(base, path, init) {
|
|
147
|
-
|
|
128
|
+
const res = await rawFetch(`${base}${path}`, init);
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
throw new Error(`email server API ${path} failed: HTTP ${res.status}`);
|
|
131
|
+
}
|
|
132
|
+
const text = await res.text();
|
|
133
|
+
if (text === "")
|
|
134
|
+
return undefined;
|
|
148
135
|
try {
|
|
149
|
-
|
|
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
|
-
}
|
|
136
|
+
return JSON.parse(text);
|
|
163
137
|
}
|
|
164
|
-
|
|
165
|
-
|
|
138
|
+
catch {
|
|
139
|
+
// Mutating endpoints reply with a plain-text acknowledgement.
|
|
140
|
+
return text;
|
|
166
141
|
}
|
|
167
142
|
}
|
|
168
143
|
function addresses(v) {
|
package/dist/components/s3.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rawFetch } from "../harness/raw-fetch.js";
|
|
1
2
|
import { S3Client } from "../s3.js";
|
|
2
3
|
// Backing image, pinned internally (not user-configurable). adobe/s3mock 5.1.0
|
|
3
4
|
// is the current release; avoid exactly 5.0.0, which crashed at startup on
|
|
@@ -75,7 +76,7 @@ export function s3(opts = {}) {
|
|
|
75
76
|
// CreateBucket is a plain `PUT` on the bucket root; the store
|
|
76
77
|
// returns 200 (or 409 if it already exists — idempotent enough to
|
|
77
78
|
// ignore).
|
|
78
|
-
const res = await
|
|
79
|
+
const res = await rawFetch(`http://${name}:${S3_PORT}/${bucket}`, {
|
|
79
80
|
method: "PUT",
|
|
80
81
|
});
|
|
81
82
|
if (!res.ok && res.status !== 409) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rawFetch } from "../harness/raw-fetch.js";
|
|
1
2
|
// A ready-to-use, self-hosted **Supabase** stack, expressed as a set of
|
|
2
3
|
// spectest services (one container each) wired together on spectest-net.
|
|
3
4
|
//
|
|
@@ -1807,7 +1808,7 @@ export function supabase(opts = {}) {
|
|
|
1807
1808
|
// snapshot and a test's diff is what its requests mutate.
|
|
1808
1809
|
setup: async () => {
|
|
1809
1810
|
const t0 = Date.now();
|
|
1810
|
-
const res = await
|
|
1811
|
+
const res = await rawFetch(`http://${g.key("functions")}:9000/_spectest/warm`);
|
|
1811
1812
|
if (!res.ok) {
|
|
1812
1813
|
console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
|
|
1813
1814
|
return;
|
package/package.json
CHANGED
package/src/components/email.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rawFetch } from "../harness/raw-fetch.js";
|
|
1
2
|
// `email()` — a real SMTP server as a spectest service. The app under test
|
|
2
3
|
// points its SMTP transport at `<key>:1025` and sends mail exactly as it
|
|
3
4
|
// would in production; every message is captured instead of delivered.
|
|
@@ -19,10 +20,8 @@
|
|
|
19
20
|
|
|
20
21
|
import type { ServiceDefinition } from "../index.js";
|
|
21
22
|
import {
|
|
22
|
-
pauseRecording,
|
|
23
23
|
recordEmail,
|
|
24
24
|
reserveEvent,
|
|
25
|
-
resumeRecording,
|
|
26
25
|
truncateUtf8,
|
|
27
26
|
type EmailEventMessage,
|
|
28
27
|
type EmailEventSummary,
|
|
@@ -262,26 +261,19 @@ async function instrumented<T>(
|
|
|
262
261
|
}
|
|
263
262
|
}
|
|
264
263
|
|
|
265
|
-
/**
|
|
266
|
-
* so these internal polls don't land as `http` events on the timeline —
|
|
267
|
-
* the helper records one consolidated `email` event instead. */
|
|
264
|
+
/** Internal HTTP stays native; the public helper records one email event. */
|
|
268
265
|
async function api(base: string, path: string, init?: RequestInit): Promise<unknown> {
|
|
269
|
-
|
|
266
|
+
const res = await rawFetch(`${base}${path}`, init);
|
|
267
|
+
if (!res.ok) {
|
|
268
|
+
throw new Error(`email server API ${path} failed: HTTP ${res.status}`);
|
|
269
|
+
}
|
|
270
|
+
const text = await res.text();
|
|
271
|
+
if (text === "") return undefined;
|
|
270
272
|
try {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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();
|
|
273
|
+
return JSON.parse(text);
|
|
274
|
+
} catch {
|
|
275
|
+
// Mutating endpoints reply with a plain-text acknowledgement.
|
|
276
|
+
return text;
|
|
285
277
|
}
|
|
286
278
|
}
|
|
287
279
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Exercise public component hooks with the same dispatcher and async scope as
|
|
7
|
+
// bootstrap. A child process isolates global fetch and recorder state.
|
|
8
|
+
async function check(code: string, workspace?: string): Promise<void> {
|
|
9
|
+
const moduleUrl = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
|
|
10
|
+
const script = `
|
|
11
|
+
import { strict as assert } from "node:assert";
|
|
12
|
+
import { s3 } from ${moduleUrl("./s3.ts")};
|
|
13
|
+
import { supabase } from ${moduleUrl("./supabase.ts")};
|
|
14
|
+
import { emailHelpers } from ${moduleUrl("./email.ts")};
|
|
15
|
+
import { installFetchWrapper } from ${moduleUrl("../harness/fetch.ts")};
|
|
16
|
+
import { createInstrumentationScope, runInstrumented } from ${moduleUrl("../harness/instrumentation-scope.ts")};
|
|
17
|
+
import { startRecording, stopRecording } from ${moduleUrl("../recorder.ts")};
|
|
18
|
+
let respond = () => Response.json({});
|
|
19
|
+
const requests = [];
|
|
20
|
+
globalThis.fetch = async (input, init) => {
|
|
21
|
+
requests.push({ url: String(input), method: init?.method ?? "GET" });
|
|
22
|
+
return respond();
|
|
23
|
+
};
|
|
24
|
+
const restore = installFetchWrapper();
|
|
25
|
+
const scope = createInstrumentationScope(false);
|
|
26
|
+
try {
|
|
27
|
+
await runInstrumented(scope, async () => {
|
|
28
|
+
assert.equal((await fetch("http://probe/")).status.unwrap(), 200);
|
|
29
|
+
requests.length = 0;
|
|
30
|
+
${code}
|
|
31
|
+
});
|
|
32
|
+
} finally { scope.active = false; restore(); }
|
|
33
|
+
`;
|
|
34
|
+
const child = Bun.spawn([process.execPath, "--eval", script], {
|
|
35
|
+
env: { ...process.env, ...(workspace ? { SPECTEST_WORKSPACE: workspace } : {}) },
|
|
36
|
+
stdout: "pipe", stderr: "pipe",
|
|
37
|
+
});
|
|
38
|
+
const [exit, stdout, stderr] = await Promise.all([
|
|
39
|
+
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
|
|
40
|
+
]);
|
|
41
|
+
expect(exit, stdout + stderr).toBe(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test.each([200, 201, 409, 403, 500])("S3 setup handles HTTP %i inside a wrapped setup scope", async (status) => {
|
|
45
|
+
await check(`
|
|
46
|
+
respond = () => new Response("bucket response", { status: ${status} });
|
|
47
|
+
const setup = s3({ buckets: ["uploads", "assets"] }).setup;
|
|
48
|
+
${status === 403 || status === 500
|
|
49
|
+
? `await assert.rejects(() => setup({ name: "storage" }), /failed to create bucket uploads: ${status}/);
|
|
50
|
+
assert.equal(requests.length, 1);`
|
|
51
|
+
: `await setup({ name: "storage" });
|
|
52
|
+
assert.deepEqual(requests, [
|
|
53
|
+
{ url: "http://storage:9090/uploads", method: "PUT" },
|
|
54
|
+
{ url: "http://storage:9090/assets", method: "PUT" },
|
|
55
|
+
]);`}
|
|
56
|
+
`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("S3 setup propagates transport failures", async () => {
|
|
60
|
+
await check(`
|
|
61
|
+
respond = () => { throw new Error("connection refused"); };
|
|
62
|
+
await assert.rejects(() => s3({ buckets: ["uploads"] }).setup({ name: "storage" }), /connection refused/);
|
|
63
|
+
`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("Supabase warm-up handles errors and reads native JSON in a wrapped setup scope", async () => {
|
|
67
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-component-fetch-"));
|
|
68
|
+
try {
|
|
69
|
+
await mkdir(join(root, "supabase/functions/hello"), { recursive: true });
|
|
70
|
+
await writeFile(join(root, "supabase/functions/hello/index.ts"), "export default {};");
|
|
71
|
+
await check(`
|
|
72
|
+
const parts = supabase({ dir: "supabase" }).group.services({
|
|
73
|
+
name: "supabase", key: part => part === "gateway" ? "supabase" : "supabase-" + part,
|
|
74
|
+
});
|
|
75
|
+
const warnings = [], logs = [];
|
|
76
|
+
console.warn = (...args) => warnings.push(args.join(" "));
|
|
77
|
+
console.log = (...args) => logs.push(args.join(" "));
|
|
78
|
+
respond = () => new Response("unavailable", { status: 503 });
|
|
79
|
+
await parts.functions.setup();
|
|
80
|
+
assert.equal(warnings.length, 1);
|
|
81
|
+
assert.match(warnings[0], /warm-up answered 503/);
|
|
82
|
+
respond = () => Response.json({ mode: "shared", warmed: ["hello"], failed: {} });
|
|
83
|
+
await parts.functions.setup();
|
|
84
|
+
assert.equal(logs.length, 1);
|
|
85
|
+
assert.match(logs[0], /warmed 1 edge function in one isolate/);
|
|
86
|
+
`, root);
|
|
87
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("email helpers reject HTTP errors and retain their public event without internal HTTP events", async () => {
|
|
91
|
+
await check(`
|
|
92
|
+
const mail = emailHelpers("mail");
|
|
93
|
+
scope.recording = true;
|
|
94
|
+
startRecording();
|
|
95
|
+
respond = () => new Response("unavailable", { status: 503 });
|
|
96
|
+
await assert.rejects(() => mail.clear(), /email server API .* failed: HTTP 503/);
|
|
97
|
+
let events = stopRecording();
|
|
98
|
+
assert.deepEqual(events.map(e => e.kind), ["email"]);
|
|
99
|
+
assert.match(events[0].error, /HTTP 503/);
|
|
100
|
+
startRecording();
|
|
101
|
+
respond = () => Response.json({ messages: [] });
|
|
102
|
+
assert.deepEqual((await mail.emails()).unwrap(), []);
|
|
103
|
+
respond = () => new Response(null, { status: 204 });
|
|
104
|
+
await mail.clear();
|
|
105
|
+
events = stopRecording();
|
|
106
|
+
assert.deepEqual(events.map(e => e.kind), ["email", "email"]);
|
|
107
|
+
assert.equal(events.some(e => e.error), false);
|
|
108
|
+
`);
|
|
109
|
+
});
|
package/src/components/s3.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rawFetch } from "../harness/raw-fetch.js";
|
|
1
2
|
import type { ServiceDefinition } from "../index.js";
|
|
2
3
|
import { S3Client, type S3ClientLike } from "../s3.js";
|
|
3
4
|
|
|
@@ -123,7 +124,7 @@ export function s3(opts: S3Options = {}) {
|
|
|
123
124
|
// CreateBucket is a plain `PUT` on the bucket root; the store
|
|
124
125
|
// returns 200 (or 409 if it already exists — idempotent enough to
|
|
125
126
|
// ignore).
|
|
126
|
-
const res = await
|
|
127
|
+
const res = await rawFetch(`http://${name}:${S3_PORT}/${bucket}`, {
|
|
127
128
|
method: "PUT",
|
|
128
129
|
});
|
|
129
130
|
if (!res.ok && res.status !== 409) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rawFetch } from "../harness/raw-fetch.js";
|
|
1
2
|
// A ready-to-use, self-hosted **Supabase** stack, expressed as a set of
|
|
2
3
|
// spectest services (one container each) wired together on spectest-net.
|
|
3
4
|
//
|
|
@@ -2300,7 +2301,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
2300
2301
|
// snapshot and a test's diff is what its requests mutate.
|
|
2301
2302
|
setup: async () => {
|
|
2302
2303
|
const t0 = Date.now();
|
|
2303
|
-
const res = await
|
|
2304
|
+
const res = await rawFetch(`http://${g.key("functions")}:9000/_spectest/warm`);
|
|
2304
2305
|
if (!res.ok) {
|
|
2305
2306
|
console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
|
|
2306
2307
|
return;
|