@kedge/testing 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/package.json +34 -3
- package/src/budgets.ts +476 -0
- package/src/http.ts +435 -0
- package/src/index.ts +30 -0
- package/src/invariants.ts +308 -0
- package/src/ports.ts +207 -0
- package/src/requests.ts +68 -0
- package/src/scenario.ts +438 -0
- package/src/vitest.ts +11 -0
- package/README.md +0 -3
package/src/http.ts
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// The scenario DSL over HTTP (docs/proposals/p0.8-cli-agent-kit.md §5.4): a
|
|
2
|
+
// store's acceptance scenarios against a deployed preview, through its public
|
|
3
|
+
// routes only, for Node. It isn't the workerd DSL behind one interface: that
|
|
4
|
+
// one calls stores directly with a clock it controls, this one meets the real
|
|
5
|
+
// relay and cron, so it polls. The method names match, so a scenario moves
|
|
6
|
+
// between them with small edits.
|
|
7
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
8
|
+
import { INVARIANTS } from "./invariants";
|
|
9
|
+
|
|
10
|
+
export interface OwnerCredentials {
|
|
11
|
+
email: string;
|
|
12
|
+
password: string;
|
|
13
|
+
/** The TOTP secret, base32, as the enrolment's otpauth URI gave it. */
|
|
14
|
+
totp: string;
|
|
15
|
+
/**
|
|
16
|
+
* The last TOTP step this factor accepted. A code from it, or an earlier one, is refused
|
|
17
|
+
* exactly as a wrong code is, and counts toward the lockout, so the next sign-in waits past it.
|
|
18
|
+
*/
|
|
19
|
+
lastStep?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StoreCredentials {
|
|
23
|
+
url: string;
|
|
24
|
+
publishableKey: string;
|
|
25
|
+
secretKey: string;
|
|
26
|
+
owner?: OwnerCredentials;
|
|
27
|
+
/** An origin in the store's ADMIN_ORIGINS, for the browser-shaped ceremonies. */
|
|
28
|
+
adminOrigin?: string;
|
|
29
|
+
/** An origin the publishable key's storefront allows, for a customer's sign-in (a session-creating ceremony). */
|
|
30
|
+
storefrontOrigin?: string;
|
|
31
|
+
/** The preview's D1 database, for expectInvariants. */
|
|
32
|
+
databaseId?: string;
|
|
33
|
+
/** A strong Owner session `kedge preview` signed in once for all its scenario files; never cached. */
|
|
34
|
+
ownerSession?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What `kedge preview` puts in a scenario's environment. */
|
|
38
|
+
export function credentialsFromEnv(env: NodeJS.ProcessEnv = process.env): StoreCredentials {
|
|
39
|
+
const raw = env.KEDGE_PREVIEW_CREDENTIALS;
|
|
40
|
+
if (!raw) throw new Error("KEDGE_PREVIEW_CREDENTIALS isn't set: run scenarios through kedge preview");
|
|
41
|
+
return JSON.parse(raw) as StoreCredentials;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- TOTP, as an authenticator app computes it (RFC 6238 over RFC 4226) ------------------------------
|
|
45
|
+
export function base32Decode(text: string): Buffer {
|
|
46
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
47
|
+
let bits = "";
|
|
48
|
+
for (const ch of text.replace(/=+$/, "").toUpperCase()) {
|
|
49
|
+
const i = alphabet.indexOf(ch);
|
|
50
|
+
if (i < 0) throw new Error("not base32");
|
|
51
|
+
bits += i.toString(2).padStart(5, "0");
|
|
52
|
+
}
|
|
53
|
+
const out: number[] = [];
|
|
54
|
+
for (let i = 0; i + 8 <= bits.length; i += 8) out.push(Number.parseInt(bits.slice(i, i + 8), 2));
|
|
55
|
+
return Buffer.from(out);
|
|
56
|
+
}
|
|
57
|
+
export function hotp(secret: Buffer, step: number): string {
|
|
58
|
+
const counter = Buffer.alloc(8);
|
|
59
|
+
counter.writeBigUInt64BE(BigInt(step));
|
|
60
|
+
const mac = createHmac("sha1", secret).update(counter).digest();
|
|
61
|
+
const o = (mac[19] as number) & 0xf;
|
|
62
|
+
return String((mac.readUInt32BE(o) & 0x7fffffff) % 1_000_000).padStart(6, "0");
|
|
63
|
+
}
|
|
64
|
+
export const totpStep = (at = Date.now()) => Math.floor(at / 30_000);
|
|
65
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
66
|
+
|
|
67
|
+
/** A refusal's code and message: the API answers `{ error: { code, message } }` (docs/06). */
|
|
68
|
+
export function errorOf(body: unknown): { code: string; message: string } {
|
|
69
|
+
const e = (body as { error?: { code?: unknown; message?: unknown } })?.error;
|
|
70
|
+
return { code: String(e?.code ?? "UNKNOWN"), message: String(e?.message ?? "") };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Answer<T = Record<string, unknown>> {
|
|
74
|
+
status: number;
|
|
75
|
+
body: T;
|
|
76
|
+
/** The request as sent, so retry() resends it with the same idempotency key. */
|
|
77
|
+
request: { method: string; path: string; headers: Record<string, string>; body?: unknown };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function send<T>(f: typeof fetch, base: string, req: Answer["request"]): Promise<Answer<T>> {
|
|
81
|
+
const res = await f(new URL(req.path, base), {
|
|
82
|
+
method: req.method,
|
|
83
|
+
headers: { ...(req.body === undefined ? {} : { "content-type": "application/json" }), ...req.headers },
|
|
84
|
+
...(req.body === undefined ? {} : { body: JSON.stringify(req.body) }),
|
|
85
|
+
signal: AbortSignal.timeout(30_000),
|
|
86
|
+
});
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
let body: unknown;
|
|
89
|
+
try {
|
|
90
|
+
body = JSON.parse(text);
|
|
91
|
+
} catch {
|
|
92
|
+
body = { raw: text.slice(0, 500) };
|
|
93
|
+
}
|
|
94
|
+
return { status: res.status, body: body as T, request: req };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The first Owner, from an invite token: accept with a password, enrol and
|
|
99
|
+
* confirm TOTP, then a strong sign-in. A TOTP code works once, so the
|
|
100
|
+
* sign-in waits past the confirm's step (as the remote runs learned): `lastStep`.
|
|
101
|
+
*/
|
|
102
|
+
export async function enrolOwner(
|
|
103
|
+
url: string,
|
|
104
|
+
invite: string,
|
|
105
|
+
email: string,
|
|
106
|
+
origin: string,
|
|
107
|
+
f: typeof fetch = fetch,
|
|
108
|
+
wait: (ms: number) => Promise<void> = sleep,
|
|
109
|
+
): Promise<{ owner: OwnerCredentials; session: string }> {
|
|
110
|
+
const first = { origin, "x-kedge-csrf": "1", "x-kedge-session-mode": "bearer" };
|
|
111
|
+
const password = Buffer.from(crypto.getRandomValues(new Uint8Array(18))).toString("base64url");
|
|
112
|
+
const accept = await send<{ session_token?: string }>(f, url, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
path: "/auth/admin/invites/accept",
|
|
115
|
+
headers: first,
|
|
116
|
+
body: {
|
|
117
|
+
token: invite,
|
|
118
|
+
password,
|
|
119
|
+
resume_nonce: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url"),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
if (accept.status !== 200 || !accept.body.session_token) throw new Error(`invite accept answered ${accept.status}`);
|
|
123
|
+
const bearer = { authorization: `Bearer ${accept.body.session_token}` };
|
|
124
|
+
const enrol = await send<{ otpauth_uri?: string }>(f, url, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
path: "/auth/admin/totp/enrol",
|
|
127
|
+
headers: bearer,
|
|
128
|
+
body: {},
|
|
129
|
+
});
|
|
130
|
+
const totp = new URL(enrol.body.otpauth_uri ?? "otpauth://x").searchParams.get("secret");
|
|
131
|
+
if (!totp) throw new Error(`TOTP enrolment answered ${enrol.status}`);
|
|
132
|
+
const confirmStep = totpStep();
|
|
133
|
+
const confirm = await send(f, url, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
path: "/auth/admin/totp/confirm",
|
|
136
|
+
headers: bearer,
|
|
137
|
+
body: { code: hotp(base32Decode(totp), confirmStep) },
|
|
138
|
+
});
|
|
139
|
+
if (confirm.status !== 200) throw new Error(`TOTP confirm answered ${confirm.status}`);
|
|
140
|
+
const owner: OwnerCredentials = { email, password, totp, lastStep: confirmStep };
|
|
141
|
+
return { owner, session: await signInOwner(url, owner, origin, f, wait) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const turns = new WeakMap<OwnerCredentials, Promise<unknown>>();
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A strong Owner session: password, then a TOTP code from a step not used before. Sign-ins for one
|
|
148
|
+
* Owner in a process take turns, each after the last one's step: run together they would all send
|
|
149
|
+
* one step's code, and a retry together would collide again (codex code r29).
|
|
150
|
+
*/
|
|
151
|
+
export function signInOwner(
|
|
152
|
+
url: string,
|
|
153
|
+
owner: OwnerCredentials,
|
|
154
|
+
origin: string,
|
|
155
|
+
f: typeof fetch = fetch,
|
|
156
|
+
wait: (ms: number) => Promise<void> = sleep,
|
|
157
|
+
): Promise<string> {
|
|
158
|
+
const mine = (turns.get(owner) ?? Promise.resolve())
|
|
159
|
+
.catch(() => {})
|
|
160
|
+
.then(() => signInOnce(url, owner, origin, f, wait));
|
|
161
|
+
turns.set(owner, mine);
|
|
162
|
+
return mine;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function signInOnce(
|
|
166
|
+
url: string,
|
|
167
|
+
owner: OwnerCredentials,
|
|
168
|
+
origin: string,
|
|
169
|
+
f: typeof fetch,
|
|
170
|
+
wait: (ms: number) => Promise<void>,
|
|
171
|
+
): Promise<string> {
|
|
172
|
+
const first = { origin, "x-kedge-csrf": "1", "x-kedge-session-mode": "bearer" };
|
|
173
|
+
if (owner.lastStep !== undefined && totpStep() <= owner.lastStep)
|
|
174
|
+
await wait((owner.lastStep + 1) * 30_000 - Date.now() + 500);
|
|
175
|
+
const signIn = await send<{ challenge?: { token: string } }>(f, url, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
path: "/auth/admin/sign-in",
|
|
178
|
+
headers: first,
|
|
179
|
+
body: { email: owner.email, password: owner.password },
|
|
180
|
+
});
|
|
181
|
+
if (!signIn.body.challenge?.token) throw new Error(`sign-in answered ${signIn.status}`);
|
|
182
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
183
|
+
const step = totpStep();
|
|
184
|
+
const strong = await send<{ session_token?: string }>(f, url, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
path: "/auth/admin/totp/challenge",
|
|
187
|
+
headers: first,
|
|
188
|
+
body: { challenge_token: signIn.body.challenge.token, code: hotp(base32Decode(owner.totp), step) },
|
|
189
|
+
});
|
|
190
|
+
if (strong.status === 200 && strong.body.session_token) {
|
|
191
|
+
owner.lastStep = step;
|
|
192
|
+
return strong.body.session_token;
|
|
193
|
+
}
|
|
194
|
+
// A code already used this step, by a sign-in in another process, is refused as a wrong code is (the
|
|
195
|
+
// server doesn't say which): once, the next step's. A success clears the count, so collisions don't
|
|
196
|
+
// add up to a lockout (codex code r28). `kedge preview` signs in once for all its scenario files.
|
|
197
|
+
const code = errorOf(strong.body).code;
|
|
198
|
+
if (code === "AUTH_CEREMONY_STALE" || code === "UNAUTHORIZED") await wait(30_000 - (Date.now() % 30_000) + 500);
|
|
199
|
+
else throw new Error(`TOTP challenge answered ${strong.status}`);
|
|
200
|
+
}
|
|
201
|
+
throw new Error("TOTP challenge failed twice");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface CheckoutResult {
|
|
205
|
+
status: "placed" | "pending" | "refused";
|
|
206
|
+
order?: { id: string; total?: number };
|
|
207
|
+
error?: { code: string; message?: string };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface Line {
|
|
211
|
+
variantId: string;
|
|
212
|
+
quantity?: number;
|
|
213
|
+
custom?: Record<string, unknown>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const owners = new Map<string, Promise<string>>();
|
|
217
|
+
|
|
218
|
+
export function httpScenario(
|
|
219
|
+
c: StoreCredentials,
|
|
220
|
+
opts: { fetch?: typeof fetch; cloudflare?: { token: string; account: string } } = {},
|
|
221
|
+
) {
|
|
222
|
+
const f = opts.fetch ?? fetch;
|
|
223
|
+
const origin = c.adminOrigin ?? "https://kedge-preview.invalid";
|
|
224
|
+
let ownerSession: Promise<string> | null = null;
|
|
225
|
+
const auth = (as: "store" | "secret" | "none" | { bearer: string }): Record<string, string> =>
|
|
226
|
+
as === "store"
|
|
227
|
+
? { "x-kedge-key": c.publishableKey }
|
|
228
|
+
: as === "secret"
|
|
229
|
+
? { authorization: `Bearer ${c.secretKey}` }
|
|
230
|
+
: as === "none"
|
|
231
|
+
? {}
|
|
232
|
+
: { authorization: `Bearer ${as.bearer}` };
|
|
233
|
+
|
|
234
|
+
const s = {
|
|
235
|
+
/** One request; mutations carry a fresh idempotency key unless given one. */
|
|
236
|
+
async request<T = Record<string, unknown>>(
|
|
237
|
+
method: string,
|
|
238
|
+
path: string,
|
|
239
|
+
o: {
|
|
240
|
+
as?: "store" | "secret" | "none" | { bearer: string };
|
|
241
|
+
body?: unknown;
|
|
242
|
+
key?: string;
|
|
243
|
+
headers?: Record<string, string>;
|
|
244
|
+
} = {},
|
|
245
|
+
): Promise<Answer<T>> {
|
|
246
|
+
const mutating = method !== "GET";
|
|
247
|
+
return send<T>(f, c.url, {
|
|
248
|
+
method,
|
|
249
|
+
path,
|
|
250
|
+
headers: {
|
|
251
|
+
...auth(o.as ?? "secret"),
|
|
252
|
+
...(mutating ? { "idempotency-key": o.key ?? `scn-${randomUUID()}` } : {}),
|
|
253
|
+
...o.headers,
|
|
254
|
+
},
|
|
255
|
+
...(o.body === undefined ? {} : { body: o.body }),
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
/** Resends the same request with the same idempotency key. */
|
|
259
|
+
retry<T>(a: Answer<T>): Promise<Answer<T>> {
|
|
260
|
+
return send<T>(f, c.url, a.request);
|
|
261
|
+
},
|
|
262
|
+
async product(p: { prices?: Record<string, number>; stock?: number; title?: string; kinds?: string[] } = {}) {
|
|
263
|
+
const handle = `scn-${randomUUID().slice(0, 8)}`;
|
|
264
|
+
const r = await s.request<{ id?: string; variantIds?: string[] }>("POST", "/admin/products", {
|
|
265
|
+
body: {
|
|
266
|
+
title: p.title ?? "Scenario product",
|
|
267
|
+
handle,
|
|
268
|
+
variants: [
|
|
269
|
+
{
|
|
270
|
+
sku: handle.toUpperCase(),
|
|
271
|
+
title: "One",
|
|
272
|
+
prices: p.prices ?? { USD: 1000 },
|
|
273
|
+
stock: { loc_default: p.stock ?? 10 },
|
|
274
|
+
...(p.kinds ? { allowed_kinds: p.kinds } : {}),
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const variantId = r.body.variantIds?.[0];
|
|
280
|
+
if (r.status !== 200 || !variantId)
|
|
281
|
+
throw new Error(`creating a product answered ${r.status} ${JSON.stringify(r.body)}`);
|
|
282
|
+
return { productId: String(r.body.id), variantId, handle };
|
|
283
|
+
},
|
|
284
|
+
/** A shopper: a cart, lines, completion, and the staff placement that stands in for payment. */
|
|
285
|
+
buyer(b: { email?: string; customer?: string; currency?: string } = {}) {
|
|
286
|
+
const email = b.email ?? `buyer-${randomUUID().slice(0, 8)}@example.com`;
|
|
287
|
+
const headers = { ...auth("store"), ...(b.customer ? { authorization: `Bearer ${b.customer}` } : {}) };
|
|
288
|
+
return {
|
|
289
|
+
async checkout(
|
|
290
|
+
lines: readonly Line[],
|
|
291
|
+
o: { place?: boolean; expectedTotal?: number } = {},
|
|
292
|
+
): Promise<CheckoutResult> {
|
|
293
|
+
const cart = await send<{ id: string; token: string }>(f, c.url, {
|
|
294
|
+
method: "POST",
|
|
295
|
+
path: "/store/carts",
|
|
296
|
+
headers,
|
|
297
|
+
body: { currency_code: b.currency ?? "USD", email },
|
|
298
|
+
});
|
|
299
|
+
if (cart.status !== 200) return { status: "refused", error: errorOf(cart.body) };
|
|
300
|
+
const h = { ...headers, "x-cart-token": cart.body.token };
|
|
301
|
+
for (const l of lines) {
|
|
302
|
+
const r = await send(f, c.url, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
path: `/store/carts/${cart.body.id}/lines`,
|
|
305
|
+
headers: h,
|
|
306
|
+
body: { variant_id: l.variantId, quantity: l.quantity ?? 1, ...(l.custom ? { custom: l.custom } : {}) },
|
|
307
|
+
});
|
|
308
|
+
if (r.status !== 200) return { status: "refused", error: errorOf(r.body) };
|
|
309
|
+
}
|
|
310
|
+
// Money is `{ amount, currency_code }` in the API (docs/06); expected_total is the minor units the shopper saw.
|
|
311
|
+
const view = await send<{ totals?: { total?: { amount: number } } }>(f, c.url, {
|
|
312
|
+
method: "GET",
|
|
313
|
+
path: `/store/carts/${cart.body.id}`,
|
|
314
|
+
headers: h,
|
|
315
|
+
});
|
|
316
|
+
const total = o.expectedTotal ?? view.body.totals?.total?.amount ?? 0;
|
|
317
|
+
const done = await send<{ id?: string }>(f, c.url, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
path: `/store/carts/${cart.body.id}/complete`,
|
|
320
|
+
headers: { ...h, "idempotency-key": `scn-${randomUUID()}` },
|
|
321
|
+
body: { expected_total: total },
|
|
322
|
+
});
|
|
323
|
+
if (done.status !== 200 || !done.body.id) return { status: "refused", error: errorOf(done.body) };
|
|
324
|
+
const order = { id: done.body.id, total };
|
|
325
|
+
if (o.place === false) return { status: "pending", order };
|
|
326
|
+
const placed = await s.request("POST", `/admin/orders/${done.body.id}/place`, { body: {} });
|
|
327
|
+
if (placed.status !== 200) return { status: "refused", order, error: errorOf(placed.body) };
|
|
328
|
+
return { status: "placed", order };
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
/** The Owner's strong session, signed in once per scenario. */
|
|
333
|
+
owner(): Promise<string> {
|
|
334
|
+
if (!c.owner) throw new Error("these credentials have no Owner");
|
|
335
|
+
// The runner's session when it gave one, so parallel scenario files spend no step; else one sign-in
|
|
336
|
+
// per Owner in this process, however many scenarios ask.
|
|
337
|
+
if (c.ownerSession) return Promise.resolve(c.ownerSession);
|
|
338
|
+
const key = `${c.url} ${c.owner.email}`;
|
|
339
|
+
ownerSession ??= owners.get(key) ?? signInOwner(c.url, c.owner, origin, f);
|
|
340
|
+
owners.set(key, ownerSession);
|
|
341
|
+
return ownerSession;
|
|
342
|
+
},
|
|
343
|
+
/** The preview's capture log: calls, webhooks, and sign-in emails in clear for a strong Owner. */
|
|
344
|
+
async captures(port?: string): Promise<{ kind: string; port: string; envelope: Record<string, unknown> }[]> {
|
|
345
|
+
const r = await s.request<{ captures?: { kind: string; port: string; envelope: Record<string, unknown> }[] }>(
|
|
346
|
+
"GET",
|
|
347
|
+
`/admin/preview/captures${port ? `?port=${encodeURIComponent(port)}` : ""}`,
|
|
348
|
+
{ as: { bearer: await s.owner() } },
|
|
349
|
+
);
|
|
350
|
+
if (r.status !== 200) throw new Error(`reading captures answered ${r.status}`);
|
|
351
|
+
return r.body.captures ?? [];
|
|
352
|
+
},
|
|
353
|
+
/** A customer session, signed in with the code the preview captured. */
|
|
354
|
+
async customer(email: string): Promise<string> {
|
|
355
|
+
// Creating a session takes CSRF and an allowed Origin even in bearer mode, so a foreign page can't sign someone in.
|
|
356
|
+
const first = { origin: c.storefrontOrigin ?? origin, "x-kedge-csrf": "1" };
|
|
357
|
+
const asked = await s.request("POST", "/auth/store/code", { as: "store", body: { email }, headers: first });
|
|
358
|
+
if (asked.status !== 200) throw new Error(`asking a code answered ${asked.status}`);
|
|
359
|
+
const code = await s.until(async () => {
|
|
360
|
+
const found = (await s.captures("auth-email")).filter((x) => x.envelope.to === email).at(-1);
|
|
361
|
+
return /Your code is (\S+)\./.exec(String(found?.envelope.text ?? ""))?.[1];
|
|
362
|
+
});
|
|
363
|
+
const v = await s.request<{ session_token?: string }>("POST", "/auth/store/verify", {
|
|
364
|
+
as: "store",
|
|
365
|
+
body: { email, code },
|
|
366
|
+
headers: { ...first, "x-kedge-session-mode": "bearer" },
|
|
367
|
+
});
|
|
368
|
+
if (!v.body.session_token) throw new Error(`verifying the code answered ${v.status}`);
|
|
369
|
+
return v.body.session_token;
|
|
370
|
+
},
|
|
371
|
+
/** Polls until fn returns something other than undefined, null or false: a preview's relay and cron are real. */
|
|
372
|
+
async until<T>(
|
|
373
|
+
fn: () => Promise<T | undefined | null | false>,
|
|
374
|
+
o: { ms?: number; every?: number } = {},
|
|
375
|
+
): Promise<T> {
|
|
376
|
+
const end = Date.now() + (o.ms ?? 120_000);
|
|
377
|
+
for (;;) {
|
|
378
|
+
const v = await fn();
|
|
379
|
+
if (v !== undefined && v !== null && v !== false) return v;
|
|
380
|
+
if (Date.now() > end) throw new Error("until: timed out");
|
|
381
|
+
await sleep(o.every ?? 2_000);
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
expectExactlyOne(results: readonly CheckoutResult[], status: CheckoutResult["status"]): CheckoutResult {
|
|
385
|
+
const hits = results.filter((r) => r.status === status);
|
|
386
|
+
if (hits.length !== 1)
|
|
387
|
+
throw new Error(`expected exactly one ${status}, got ${hits.length}: ${JSON.stringify(results)}`);
|
|
388
|
+
return hits[0] as CheckoutResult;
|
|
389
|
+
},
|
|
390
|
+
expectError(results: readonly CheckoutResult[], code: string): CheckoutResult {
|
|
391
|
+
const hit = results.find((r) => r.error?.code === code);
|
|
392
|
+
if (!hit) throw new Error(`expected an error ${code}: ${JSON.stringify(results)}`);
|
|
393
|
+
return hit;
|
|
394
|
+
},
|
|
395
|
+
/**
|
|
396
|
+
* Every invariant, through D1's query API with the runner's own token. The
|
|
397
|
+
* SQL is the workerd suite's, read-only. Without a token it fails rather
|
|
398
|
+
* than passing silently.
|
|
399
|
+
*/
|
|
400
|
+
async expectInvariants(): Promise<void> {
|
|
401
|
+
const cf =
|
|
402
|
+
opts.cloudflare ??
|
|
403
|
+
(process.env.CLOUDFLARE_API_TOKEN && process.env.CLOUDFLARE_ACCOUNT_ID
|
|
404
|
+
? { token: process.env.CLOUDFLARE_API_TOKEN, account: process.env.CLOUDFLARE_ACCOUNT_ID }
|
|
405
|
+
: null);
|
|
406
|
+
if (!cf || !c.databaseId)
|
|
407
|
+
throw new Error(
|
|
408
|
+
"expectInvariants needs CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID and the preview's database id",
|
|
409
|
+
);
|
|
410
|
+
const failures: string[] = [];
|
|
411
|
+
for (const check of INVARIANTS) {
|
|
412
|
+
const r = await f(
|
|
413
|
+
`https://api.cloudflare.com/client/v4/accounts/${cf.account}/d1/database/${c.databaseId}/query`,
|
|
414
|
+
{
|
|
415
|
+
method: "POST",
|
|
416
|
+
headers: { authorization: `Bearer ${cf.token}`, "content-type": "application/json" },
|
|
417
|
+
body: JSON.stringify({ sql: check.sql }),
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
const j = (await r.json()) as { success?: boolean; result?: { results?: unknown[] }[] };
|
|
421
|
+
if (!j.success) throw new Error(`invariant ${check.name}: the query failed (${r.status})`);
|
|
422
|
+
const rows = j.result?.[0]?.results ?? [];
|
|
423
|
+
if (rows.length)
|
|
424
|
+
failures.push(`${check.name}: ${check.rule} (${rows.length} rows, e.g. ${JSON.stringify(rows[0])})`);
|
|
425
|
+
}
|
|
426
|
+
if (failures.length) throw new Error(`invariants broken:\n ${failures.join("\n ")}`);
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
return s;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Runs each at once: the concurrency helper for last-unit races. */
|
|
433
|
+
export function concurrently<T>(...fns: (() => Promise<T>)[]): Promise<T[]> {
|
|
434
|
+
return Promise.all(fns.map((fn) => fn()));
|
|
435
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { type TestRuntime, testRuntime } from "@kedge/core";
|
|
2
|
+
// Plugin tests write their setup through core's marker (p0.5 §7.1) without depending on @kedge/db.
|
|
3
|
+
export { runMarked } from "@kedge/db";
|
|
4
|
+
export {
|
|
5
|
+
type BudgetViolation,
|
|
6
|
+
CHECKOUT_BUDGETS,
|
|
7
|
+
type CheckoutShape,
|
|
8
|
+
EXPIRY_BUDGETS,
|
|
9
|
+
type Measured,
|
|
10
|
+
measureRows,
|
|
11
|
+
overBudget,
|
|
12
|
+
PLACEMENT_BUDGETS,
|
|
13
|
+
RELAY_BUDGETS,
|
|
14
|
+
type StatementBudget,
|
|
15
|
+
TIMER_BUDGETS,
|
|
16
|
+
} from "./budgets";
|
|
17
|
+
export { checkInvariants, INVARIANTS, type Violation } from "./invariants";
|
|
18
|
+
export { type CapturedCall, type Fault, ProviderDouble, testEmailPort, testPaymentPort } from "./ports";
|
|
19
|
+
export { countRequests, type RequestCounter } from "./requests";
|
|
20
|
+
export {
|
|
21
|
+
type CheckoutOutcome,
|
|
22
|
+
cartLines,
|
|
23
|
+
concurrently,
|
|
24
|
+
type RelayPass,
|
|
25
|
+
type Scenario,
|
|
26
|
+
type ScenarioLine,
|
|
27
|
+
type ScenarioOptions,
|
|
28
|
+
scenario,
|
|
29
|
+
} from "./scenario";
|
|
30
|
+
export { describeKedgeInvariants } from "./vitest";
|