slicetest 0.8.0 → 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/README.md +14 -6
- package/dist/cli-run.d.ts +13 -0
- package/dist/cli-run.js +15 -0
- package/dist/cli.js +12 -0
- package/dist/config.js +45 -0
- package/dist/form.d.ts +1 -0
- package/dist/form.js +13 -4
- package/dist/global-setup.js +3 -6
- package/dist/har.js +3 -3
- package/dist/http.d.ts +29 -1
- package/dist/http.js +95 -5
- package/dist/init.js +53 -8
- package/dist/matchers.d.ts +14 -3
- package/dist/matchers.js +57 -2
- package/dist/openapi.d.ts +0 -5
- package/dist/openapi.js +13 -6
- package/dist/recording.d.ts +7 -0
- package/dist/recording.js +38 -4
- package/dist/runtime.js +12 -0
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +10 -3
- package/dist/stub.js +47 -10
- package/dist/yaml-runtime.js +46 -5
- package/dist/yaml.d.ts +8 -2
- package/dist/yaml.js +14 -2
- package/package.json +1 -1
- package/schema/scenario.schema.json +70 -4
package/dist/matchers.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Where } from "./db.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type HttpResponse } from "./http.js";
|
|
3
3
|
import { type MatchOptions } from "./stub.js";
|
|
4
4
|
/** One expected call for `toHaveReceivedInOrder`: `[stub, method, path, match?]`. */
|
|
5
5
|
export type OrderedCall = [stub: string, method: string, path: string | RegExp, match?: MatchOptions];
|
|
@@ -22,8 +22,16 @@ interface SlicetestMatchers<R = unknown> {
|
|
|
22
22
|
* relative to the working directory with an optional pointer, `openapi.yaml#/components/schemas/Poll`.
|
|
23
23
|
*/
|
|
24
24
|
toMatchSchema(schema: object | string): R;
|
|
25
|
-
/** The response has this status; the failure message shows the response body. */
|
|
26
|
-
toHaveStatus(status: number): R;
|
|
25
|
+
/** The response has this status, a status of this class (`"2xx"`) or one of a list (`[200, 204]`); the failure message shows the response body. */
|
|
26
|
+
toHaveStatus(status: number | `${1 | 2 | 3 | 4 | 5}xx` | (number | `${1 | 2 | 3 | 4 | 5}xx`)[]): R;
|
|
27
|
+
/**
|
|
28
|
+
* The response sets cookie `name`, with these attributes as a subset (`value`, `httpOnly`,
|
|
29
|
+
* `secure`, `sameSite`, `path`, `domain`, `maxAge`, `expires`, `partitioned`):
|
|
30
|
+
* `expect(res).toSetCookie("sid", { httpOnly: true, secure: true, sameSite: "Lax" })`.
|
|
31
|
+
*/
|
|
32
|
+
toSetCookie(name: string, attributes?: Record<string, unknown>): R;
|
|
33
|
+
/** The response arrived within `ms` milliseconds (measured from sending the request to reading the whole body). */
|
|
34
|
+
toRespondWithin(ms: number): R;
|
|
27
35
|
/** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`. */
|
|
28
36
|
toHaveStatuses(counts: Record<number, number>): R;
|
|
29
37
|
/** Async: the table has at least one row matching `where` (`count` for an exact number). */
|
|
@@ -33,5 +41,8 @@ declare module "vitest" {
|
|
|
33
41
|
interface Matchers<R extends void | Promise<void> = void | Promise<void>, T = unknown> extends SlicetestMatchers<R> {
|
|
34
42
|
}
|
|
35
43
|
}
|
|
44
|
+
/** A status code, a class (`"2xx"`), or a list of either: `[200, 204]`, `["2xx", 304]`. */
|
|
45
|
+
export type ExpectedStatus = number | string | (number | string)[];
|
|
46
|
+
export declare function statusMatches(expected: ExpectedStatus, actual: number): boolean;
|
|
36
47
|
export declare function statusCounts(responses: HttpResponse[]): Record<number, number>;
|
|
37
48
|
export {};
|
package/dist/matchers.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect } from "vitest";
|
|
2
2
|
import { Db } from "./db.js";
|
|
3
|
+
import { setCookies } from "./http.js";
|
|
3
4
|
import { describeGraphQL } from "./graphql.js";
|
|
4
5
|
import { schemaProblems } from "./schema.js";
|
|
5
6
|
import { Stub, subset } from "./stub.js";
|
|
@@ -22,6 +23,24 @@ function assertStub(received) {
|
|
|
22
23
|
if (!(received instanceof Stub))
|
|
23
24
|
throw new TypeError("slicetest: expected a stub, e.g. expect(stub(\"slack\"))");
|
|
24
25
|
}
|
|
26
|
+
export function statusMatches(expected, actual) {
|
|
27
|
+
if (Array.isArray(expected)) {
|
|
28
|
+
if (expected.length === 0)
|
|
29
|
+
throw new TypeError("slicetest: toHaveStatus needs at least one status in the list");
|
|
30
|
+
return expected.some((e) => statusMatches(e, actual));
|
|
31
|
+
}
|
|
32
|
+
if (typeof expected === "number")
|
|
33
|
+
return expected === actual;
|
|
34
|
+
const s = String(expected).trim().toLowerCase();
|
|
35
|
+
if (/^\d{3}$/.test(s))
|
|
36
|
+
return Number(s) === actual;
|
|
37
|
+
if (/^[1-5]xx$/.test(s))
|
|
38
|
+
return Math.floor(actual / 100) === Number(s[0]);
|
|
39
|
+
throw new TypeError(`slicetest: a status is a code (201), a class ("2xx") or a list of them, got ${JSON.stringify(expected)}`);
|
|
40
|
+
}
|
|
41
|
+
function describeStatus(expected) {
|
|
42
|
+
return Array.isArray(expected) ? `one of ${expected.join(", ")}` : String(expected);
|
|
43
|
+
}
|
|
25
44
|
export function statusCounts(responses) {
|
|
26
45
|
const out = {};
|
|
27
46
|
for (const r of responses)
|
|
@@ -140,18 +159,54 @@ expect.extend({
|
|
|
140
159
|
};
|
|
141
160
|
},
|
|
142
161
|
toHaveStatus(received, status) {
|
|
143
|
-
|
|
162
|
+
if (!received || typeof received !== "object" || typeof received.status !== "number") {
|
|
163
|
+
throw new TypeError(`slicetest: toHaveStatus expects a response from http, got ${this.utils.stringify(received)}`);
|
|
164
|
+
}
|
|
165
|
+
const pass = statusMatches(status, received.status);
|
|
144
166
|
return {
|
|
145
167
|
pass,
|
|
146
168
|
message: () => {
|
|
147
169
|
const body = received.text.length > 1000 ? `${received.text.slice(0, 1000)}…` : received.text;
|
|
148
|
-
return (`expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond ${status}, got ${received.status}\n` +
|
|
170
|
+
return (`expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond ${describeStatus(status)}, got ${received.status}\n` +
|
|
149
171
|
`Response body:\n ${body || "(empty)"}`);
|
|
150
172
|
},
|
|
151
173
|
actual: received?.status,
|
|
152
174
|
expected: status,
|
|
153
175
|
};
|
|
154
176
|
},
|
|
177
|
+
toSetCookie(received, name, attributes = {}) {
|
|
178
|
+
if (!received || typeof received !== "object" || !(received.headers instanceof Headers)) {
|
|
179
|
+
throw new TypeError(`slicetest: toSetCookie expects a response from http, got ${this.utils.stringify(received)}`);
|
|
180
|
+
}
|
|
181
|
+
const cookies = setCookies(received);
|
|
182
|
+
const cookie = Object.hasOwn(cookies, name) ? cookies[name] : undefined;
|
|
183
|
+
// SameSite values are case-insensitive: "Lax" and "lax" are the same attribute. A matcher sees the value as sent.
|
|
184
|
+
const plain = typeof attributes.sameSite === "string";
|
|
185
|
+
const want = plain ? { ...attributes, sameSite: attributes.sameSite.toLowerCase() } : attributes;
|
|
186
|
+
const pass = !!cookie && subset(want, plain ? { ...cookie, sameSite: cookie.sameSite?.toLowerCase() } : cookie);
|
|
187
|
+
const describe = (c) => `${c.name}=${c.value.length > 40 ? `${c.value.slice(0, 40)}…` : c.value} ${JSON.stringify(Object.fromEntries(Object.entries(c).filter(([k, v]) => k !== "name" && k !== "value" && v !== undefined && v !== false)))}`;
|
|
188
|
+
return {
|
|
189
|
+
pass,
|
|
190
|
+
message: () => {
|
|
191
|
+
const head = `expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to set cookie ${name}${Object.keys(attributes).length ? ` with ${this.utils.stringify(attributes, 10, { min: true })}` : ""}`;
|
|
192
|
+
const all = Object.values(cookies);
|
|
193
|
+
return `${head}\n${all.length ? `Cookies it set:\n${all.map((c) => ` ${describe(c)}`).join("\n")}` : "It set no cookies."}`;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
toRespondWithin(received, ms) {
|
|
198
|
+
if (!received || typeof received !== "object" || typeof received.durationMs !== "number") {
|
|
199
|
+
throw new TypeError(`slicetest: toRespondWithin expects a response from http, got ${this.utils.stringify(received)}`);
|
|
200
|
+
}
|
|
201
|
+
if (!(typeof ms === "number" && ms > 0))
|
|
202
|
+
throw new TypeError(`slicetest: toRespondWithin needs a positive number of milliseconds, got ${this.utils.stringify(ms)}`);
|
|
203
|
+
return {
|
|
204
|
+
pass: received.durationMs <= ms,
|
|
205
|
+
message: () => `expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond within ${ms}ms, it took ${received.durationMs}ms`,
|
|
206
|
+
actual: received.durationMs,
|
|
207
|
+
expected: ms,
|
|
208
|
+
};
|
|
209
|
+
},
|
|
155
210
|
toHaveStatuses(received, counts) {
|
|
156
211
|
if (!Array.isArray(received))
|
|
157
212
|
throw new TypeError("slicetest: toHaveStatuses expects an array of responses, e.g. from http.concurrently()");
|
package/dist/openapi.d.ts
CHANGED
|
@@ -31,11 +31,6 @@ export interface Message {
|
|
|
31
31
|
body: unknown;
|
|
32
32
|
query?: URLSearchParams;
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
35
|
-
* An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
|
|
36
|
-
* the app's responses against the app's own spec, and the app's calls to a
|
|
37
|
-
* stubbed service (and the stub's canned replies) against that service's spec.
|
|
38
|
-
*/
|
|
39
34
|
export declare class OpenApiSpec {
|
|
40
35
|
#private;
|
|
41
36
|
readonly file: string;
|
package/dist/openapi.js
CHANGED
|
@@ -5,11 +5,17 @@ import addFormatsModule from "ajv-formats";
|
|
|
5
5
|
import { parse } from "yaml";
|
|
6
6
|
const addFormats = (addFormatsModule.default ?? addFormatsModule);
|
|
7
7
|
const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
/** A server's URL with its `{variables}` filled in: every `enum` value, else the default. */
|
|
9
|
+
function serverUrls(server) {
|
|
10
|
+
let urls = [server.url ?? "/"];
|
|
11
|
+
for (const [name, v] of Object.entries(server.variables ?? {})) {
|
|
12
|
+
const values = [...new Set([...(v.default === undefined ? [] : [v.default]), ...(v.enum ?? [])].map(String))];
|
|
13
|
+
if (values.length === 0 || !urls[0].includes(`{${name}}`))
|
|
14
|
+
continue;
|
|
15
|
+
urls = urls.flatMap((u) => values.map((value) => u.replaceAll(`{${name}}`, value))).slice(0, 50);
|
|
16
|
+
}
|
|
17
|
+
return urls;
|
|
18
|
+
}
|
|
13
19
|
export class OpenApiSpec {
|
|
14
20
|
file;
|
|
15
21
|
doc;
|
|
@@ -45,7 +51,8 @@ export class OpenApiSpec {
|
|
|
45
51
|
// Paths in the spec are relative to the server URL, e.g. /v1 for https://api.example.com/v1.
|
|
46
52
|
this.#basePaths = [
|
|
47
53
|
...new Set((doc.servers ?? [])
|
|
48
|
-
.
|
|
54
|
+
.flatMap((s) => serverUrls(s))
|
|
55
|
+
.map((url) => new URL(url, "http://x").pathname.replace(/\/$/, ""))
|
|
49
56
|
.filter(Boolean)),
|
|
50
57
|
];
|
|
51
58
|
}
|
package/dist/recording.d.ts
CHANGED
|
@@ -38,6 +38,13 @@ export declare class Recorder {
|
|
|
38
38
|
/** Recordings made by this worker, to be merged into the file when the run ends. */
|
|
39
39
|
added(): Recording[];
|
|
40
40
|
}
|
|
41
|
+
export declare const REDACTED = "[redacted]";
|
|
42
|
+
/**
|
|
43
|
+
* A request with credential values replaced by `[redacted]`, so recordings can be committed and
|
|
44
|
+
* replay whatever key the test environment uses. Applied to recordings and to calls alike before
|
|
45
|
+
* they are compared.
|
|
46
|
+
*/
|
|
47
|
+
export declare function redactRequest(r: Recording["request"]): Recording["request"];
|
|
41
48
|
export declare function readRecordings(file: string): Promise<Recording[]>;
|
|
42
49
|
/** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
|
|
43
50
|
export declare function mergeRecordings(file: string, upstream: string, added: Recording[], source?: string): Promise<void>;
|
package/dist/recording.js
CHANGED
|
@@ -90,14 +90,48 @@ export class Recorder {
|
|
|
90
90
|
}
|
|
91
91
|
function requestOf(call) {
|
|
92
92
|
const query = Object.fromEntries([...call.query.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
93
|
-
return {
|
|
93
|
+
return redactRequest({
|
|
94
94
|
method: call.method,
|
|
95
95
|
path: call.path,
|
|
96
96
|
...(Object.keys(query).length ? { query } : {}),
|
|
97
97
|
...(call.json !== undefined ? { json: call.json } : call.body ? { body: call.body } : {}),
|
|
98
|
-
};
|
|
98
|
+
});
|
|
99
99
|
}
|
|
100
|
-
|
|
100
|
+
/** Credentials sent in a query, a form or JSON body: `api_key`, `key`, `access_token`, `client_secret`, `password`, `appid`, signatures. */
|
|
101
|
+
const SECRET = /^(api[-_]?key|apikey|key|(access|refresh|id|auth)[-_]?token|token|client[-_]?secret|app[-_]?secret|secret|password|passwd|appid|signature|sig|x-amz-(signature|credential|security-token))$/i;
|
|
102
|
+
/** In bodies, a bare `key` is usually data (a KV store's key, a record's key) that tells requests apart, not a credential. */
|
|
103
|
+
const bodySecret = (k) => SECRET.test(k) && k.toLowerCase() !== "key";
|
|
104
|
+
export const REDACTED = "[redacted]";
|
|
105
|
+
const FORM_BODY = /^[^\s=&]+=[^\s&]*(&[^\s=&]+=[^\s&]*)*$/;
|
|
106
|
+
/**
|
|
107
|
+
* A request with credential values replaced by `[redacted]`, so recordings can be committed and
|
|
108
|
+
* replay whatever key the test environment uses. Applied to recordings and to calls alike before
|
|
109
|
+
* they are compared.
|
|
110
|
+
*/
|
|
111
|
+
export function redactRequest(r) {
|
|
112
|
+
const out = { ...r };
|
|
113
|
+
if (r.query)
|
|
114
|
+
out.query = Object.fromEntries(Object.entries(r.query).map(([k, v]) => [k, SECRET.test(k) ? REDACTED : v]));
|
|
115
|
+
if (r.json !== undefined)
|
|
116
|
+
out.json = redactJson(r.json);
|
|
117
|
+
if (r.body && FORM_BODY.test(r.body)) {
|
|
118
|
+
const form = new URLSearchParams(r.body);
|
|
119
|
+
if ([...form.keys()].some(bodySecret)) {
|
|
120
|
+
out.body = [...form].map(([k, v]) => `${encodeURIComponent(k)}=${bodySecret(k) ? REDACTED : encodeURIComponent(v)}`).join("&");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function redactJson(v) {
|
|
126
|
+
if (Array.isArray(v))
|
|
127
|
+
return v.map(redactJson);
|
|
128
|
+
if (v && typeof v === "object")
|
|
129
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, typeof x === "string" && bodySecret(k) ? REDACTED : redactJson(x)]));
|
|
130
|
+
return v;
|
|
131
|
+
}
|
|
132
|
+
function keyOf(request) {
|
|
133
|
+
// Recordings made before redaction still match: both sides are compared redacted.
|
|
134
|
+
const r = redactRequest(request);
|
|
101
135
|
return JSON.stringify([r.method.toUpperCase(), r.path, sortKeys(r.query ?? {}), sortKeys(r.json ?? null), r.body ?? ""]);
|
|
102
136
|
}
|
|
103
137
|
function sortKeys(v) {
|
|
@@ -143,6 +177,6 @@ export async function mergeRecordings(file, upstream, added, source = `Recorded
|
|
|
143
177
|
if (!entries.some((x) => isDeepStrictEqual(x, e)))
|
|
144
178
|
entries.push(e);
|
|
145
179
|
await mkdir(path.dirname(file), { recursive: true });
|
|
146
|
-
const header = `# ${source}. Review before committing:
|
|
180
|
+
const header = `# ${source}. Review before committing: credentials in queries and bodies (api_key, access_token, client_secret, password, …) are replaced by ${REDACTED}, but other values are stored as sent.\n`;
|
|
147
181
|
await writeFile(file, header + YAML.stringify(entries, { lineWidth: 0 }));
|
|
148
182
|
}
|
package/dist/runtime.js
CHANGED
|
@@ -302,6 +302,9 @@ export class Runtime {
|
|
|
302
302
|
this.#assertAlive();
|
|
303
303
|
if (this.interceptor?.blocked.size)
|
|
304
304
|
throw new Error(blockedHint([...this.interceptor.blocked]));
|
|
305
|
+
const thrown = this.#handlerErrors();
|
|
306
|
+
if (thrown.length > 0)
|
|
307
|
+
throw new Error(`slicetest: a stub's reply threw, so the app got an error response instead of the reply:\n${thrown.join("\n")}`);
|
|
305
308
|
const unmatched = this.#unmatched();
|
|
306
309
|
if (unmatched.length > 0) {
|
|
307
310
|
throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
|
|
@@ -347,6 +350,12 @@ export class Runtime {
|
|
|
347
350
|
}
|
|
348
351
|
return out;
|
|
349
352
|
}
|
|
353
|
+
#handlerErrors() {
|
|
354
|
+
return [...this.stubs.values()].flatMap((s) => s.handlerErrors().map(({ call, error }) => {
|
|
355
|
+
const where = error?.stack?.split("\n").find((l) => /^\s+at /.test(l) && !l.includes("node_modules") && !l.includes("node:"))?.trim();
|
|
356
|
+
return ` ${s.name}: ${call.method} ${call.path}: ${error instanceof Error ? error.message : String(error)}${where ? `\n ${where}` : ""}`;
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
350
359
|
#unmatched() {
|
|
351
360
|
return [...this.stubs.values()].flatMap((s) => {
|
|
352
361
|
const calls = s.unmatched();
|
|
@@ -401,6 +410,9 @@ export class Runtime {
|
|
|
401
410
|
if (exit)
|
|
402
411
|
sections.push(`the ${p.label} exited (code ${exit.code}, signal ${exit.signal}); it will be restarted for the next scenario`);
|
|
403
412
|
}
|
|
413
|
+
const thrown = this.#handlerErrors();
|
|
414
|
+
if (thrown.length > 0)
|
|
415
|
+
sections.push(`stub replies that threw:\n${thrown.join("\n")}`);
|
|
404
416
|
const unmatched = this.#unmatched();
|
|
405
417
|
if (unmatched.length > 0)
|
|
406
418
|
sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files `migrate: { sql }` applies, in order: the file itself, or a directory's `.sql` files (and
|
|
3
|
+
* Diesel's `<version>_<name>/up.sql`) sorted by name with numbers compared as numbers. Rollback
|
|
4
|
+
* files are left out: golang-migrate / sqlx `*.down.sql`, Diesel's `down.sql` and Flyway undo
|
|
5
|
+
* migrations (`U2__…`).
|
|
6
|
+
*/
|
|
7
|
+
export declare function sqlMigrationFiles(target: string): Promise<string[]>;
|
|
8
|
+
/** The statements of a migration file that migrate up: without a goose or dbmate down section. */
|
|
9
|
+
export declare function upSection(sql: string): string;
|
|
10
|
+
/** Apply `files` in order with `exec`, naming the file that fails. */
|
|
11
|
+
export declare function applySqlFiles(files: string[], exec: (sql: string) => Promise<unknown>, root: string): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Version numbers compare as numbers: Flyway's `V2__` comes before `V10__`. */
|
|
5
|
+
const byVersion = new Intl.Collator("en", { numeric: true }).compare;
|
|
6
|
+
/**
|
|
7
|
+
* Files `migrate: { sql }` applies, in order: the file itself, or a directory's `.sql` files (and
|
|
8
|
+
* Diesel's `<version>_<name>/up.sql`) sorted by name with numbers compared as numbers. Rollback
|
|
9
|
+
* files are left out: golang-migrate / sqlx `*.down.sql`, Diesel's `down.sql` and Flyway undo
|
|
10
|
+
* migrations (`U2__…`).
|
|
11
|
+
*/
|
|
12
|
+
export async function sqlMigrationFiles(target) {
|
|
13
|
+
if (!(await stat(target)).isDirectory())
|
|
14
|
+
return [target];
|
|
15
|
+
const entries = await readdir(target, { withFileTypes: true });
|
|
16
|
+
const files = entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".sql") && !/\.down\.sql$/i.test(e.name) && !/^U\d+(\.\d+)*__/.test(e.name)).map((e) => e.name);
|
|
17
|
+
// Diesel: one directory per migration, holding up.sql and down.sql.
|
|
18
|
+
const dirs = entries.filter((e) => e.isDirectory() && existsSync(path.join(target, e.name, "up.sql"))).map((e) => path.join(e.name, "up.sql"));
|
|
19
|
+
return [...files, ...dirs].sort(byVersion).map((f) => path.join(target, f));
|
|
20
|
+
}
|
|
21
|
+
/** Where a migration's rollback section starts in goose (`-- +goose Down`) and dbmate (`-- migrate:down`) files. */
|
|
22
|
+
const DOWN = /^--\s*(\+goose\s+down|migrate:down)\b/im;
|
|
23
|
+
/** The statements of a migration file that migrate up: without a goose or dbmate down section. */
|
|
24
|
+
export function upSection(sql) {
|
|
25
|
+
const m = DOWN.exec(sql);
|
|
26
|
+
return m ? sql.slice(0, m.index) : sql;
|
|
27
|
+
}
|
|
28
|
+
/** Apply `files` in order with `exec`, naming the file that fails. */
|
|
29
|
+
export async function applySqlFiles(files, exec, root) {
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
try {
|
|
32
|
+
await exec(upSection(await readFile(file, "utf8")));
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
throw new Error(`slicetest: db.migrate.sql: ${path.relative(root, file) || file}: ${e.message}`, { cause: e });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/stub.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface ChaosOptions {
|
|
|
54
54
|
export interface StubResponse {
|
|
55
55
|
status?: number;
|
|
56
56
|
headers?: Record<string, string>;
|
|
57
|
-
/**
|
|
57
|
+
/** Strings and bytes (`Uint8Array`, `ArrayBuffer`) are sent as they are; anything else as JSON. */
|
|
58
58
|
body?: unknown;
|
|
59
59
|
}
|
|
60
60
|
/** One server-sent event: `[event, data]`, or `{ event, data, id }`. Data that isn't a string is sent as JSON. */
|
|
@@ -75,7 +75,8 @@ export type Responder = StubResponse | ((call: RecordedCall) => StubResponse | P
|
|
|
75
75
|
* `expect.*` asymmetric matchers receive the actual value.
|
|
76
76
|
*/
|
|
77
77
|
export interface MatchOptions {
|
|
78
|
-
|
|
78
|
+
/** A list matches a repeated parameter's values in order: `{ ids: ["1", "2"] }` for `?ids=1&ids=2`. */
|
|
79
|
+
query?: Record<string, Matcher | Matcher[]>;
|
|
79
80
|
headers?: Record<string, Matcher>;
|
|
80
81
|
json?: unknown;
|
|
81
82
|
/** Subset of a form-encoded body's fields (`call.form`). Numbers and booleans compare as the strings sent. */
|
|
@@ -137,6 +138,7 @@ export declare class Stub {
|
|
|
137
138
|
/**
|
|
138
139
|
* Answer `method path`. `path` may contain `:name` segments (captured into
|
|
139
140
|
* `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
|
|
141
|
+
* A query in `path` (`/search?q=tea`) is a condition on those parameters; others may come along.
|
|
140
142
|
*/
|
|
141
143
|
on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
|
|
142
144
|
/**
|
|
@@ -160,7 +162,12 @@ export declare class Stub {
|
|
|
160
162
|
/** The active `chaos()` settings and what they did, for failure output; undefined when off. */
|
|
161
163
|
describeChaos(): string | undefined;
|
|
162
164
|
/** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
|
|
163
|
-
calls(method?: string,
|
|
165
|
+
calls(method?: string, fullPath?: string | RegExp, fullMatch?: MatchOptions): RecordedCall[];
|
|
166
|
+
/** Calls whose reply function or fallback threw, with the error; the app got a 500 (502 for a fallback). */
|
|
167
|
+
handlerErrors(): {
|
|
168
|
+
call: RecordedCall;
|
|
169
|
+
error: unknown;
|
|
170
|
+
}[];
|
|
164
171
|
unmatched(): RecordedCall[];
|
|
165
172
|
/** Routes registered in this scenario that no call reached, except `optional()` ones. */
|
|
166
173
|
unusedRoutes(): string[];
|
package/dist/stub.js
CHANGED
|
@@ -30,6 +30,8 @@ export class Stub {
|
|
|
30
30
|
/** Appended to the 501 answer for a call nothing could answer. */
|
|
31
31
|
#hint;
|
|
32
32
|
#chaos;
|
|
33
|
+
/** Reply functions (and fallbacks) that threw: the app got a 500 / 502, the scenario must fail with the error. */
|
|
34
|
+
#errors = [];
|
|
33
35
|
url = "";
|
|
34
36
|
constructor(name) {
|
|
35
37
|
this.name = name;
|
|
@@ -54,6 +56,7 @@ export class Stub {
|
|
|
54
56
|
/**
|
|
55
57
|
* Answer `method path`. `path` may contain `:name` segments (captured into
|
|
56
58
|
* `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
|
|
59
|
+
* A query in `path` (`/search?q=tea`) is a condition on those parameters; others may come along.
|
|
57
60
|
*/
|
|
58
61
|
on(method, path, match = {}) {
|
|
59
62
|
return this.#on(method, path, match);
|
|
@@ -77,7 +80,8 @@ export class Stub {
|
|
|
77
80
|
};
|
|
78
81
|
return gql;
|
|
79
82
|
}
|
|
80
|
-
#on(method,
|
|
83
|
+
#on(method, fullPath, fullMatch, label) {
|
|
84
|
+
const { path, match } = splitQuery(fullPath, fullMatch);
|
|
81
85
|
const { pattern, paramNames } = compilePath(path);
|
|
82
86
|
const route = {
|
|
83
87
|
method: method.toUpperCase(),
|
|
@@ -179,12 +183,17 @@ export class Stub {
|
|
|
179
183
|
this.#send(call, res, { status: fault.status, headers: retry, body: { error: "slicetest chaos", status: fault.status } });
|
|
180
184
|
}
|
|
181
185
|
/** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
|
|
182
|
-
calls(method,
|
|
186
|
+
calls(method, fullPath, fullMatch = {}) {
|
|
187
|
+
const { path, match } = fullPath === undefined ? { path: undefined, match: fullMatch } : splitQuery(fullPath, fullMatch);
|
|
183
188
|
const compiled = path === undefined ? undefined : compilePath(path);
|
|
184
189
|
return this.#calls.filter((c) => (!method || method === "*" || c.method === method.toUpperCase()) &&
|
|
185
190
|
(!compiled || matchPath(path, compiled.pattern, c.path) !== undefined) &&
|
|
186
191
|
matchConditions(match, c));
|
|
187
192
|
}
|
|
193
|
+
/** Calls whose reply function or fallback threw, with the error; the app got a 500 (502 for a fallback). */
|
|
194
|
+
handlerErrors() {
|
|
195
|
+
return this.#errors;
|
|
196
|
+
}
|
|
188
197
|
unmatched() {
|
|
189
198
|
return this.#calls.filter((c) => !c.matched);
|
|
190
199
|
}
|
|
@@ -239,6 +248,7 @@ export class Stub {
|
|
|
239
248
|
this.#routes = [];
|
|
240
249
|
this.#calls = [];
|
|
241
250
|
this.#chaos = undefined;
|
|
251
|
+
this.#errors = [];
|
|
242
252
|
}
|
|
243
253
|
/**
|
|
244
254
|
* Answer calls that no registered route matches, instead of failing with 501.
|
|
@@ -303,6 +313,7 @@ export class Stub {
|
|
|
303
313
|
out = await this.#fallback?.(call);
|
|
304
314
|
}
|
|
305
315
|
catch (e) {
|
|
316
|
+
this.#errors.push({ call, error: e });
|
|
306
317
|
res.writeHead(502, { "content-type": "text/plain" }).end(e.message);
|
|
307
318
|
return;
|
|
308
319
|
}
|
|
@@ -330,10 +341,15 @@ export class Stub {
|
|
|
330
341
|
this.#send(call, res, typeof route.respond === "function" ? await route.respond(call) : route.respond);
|
|
331
342
|
}
|
|
332
343
|
catch (e) {
|
|
344
|
+
this.#errors.push({ call, error: e });
|
|
333
345
|
res.writeHead(500).end(`slicetest: stub handler threw: ${e}`);
|
|
334
346
|
}
|
|
335
347
|
}
|
|
336
348
|
#send(call, res, out) {
|
|
349
|
+
if (out.body instanceof ArrayBuffer || (ArrayBuffer.isView(out.body) && !(out.body instanceof Uint8Array))) {
|
|
350
|
+
const view = out.body;
|
|
351
|
+
out = { ...out, body: view instanceof ArrayBuffer ? new Uint8Array(view) : new Uint8Array(view.buffer, view.byteOffset, view.byteLength) };
|
|
352
|
+
}
|
|
337
353
|
const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
|
|
338
354
|
const headers = { ...out.headers };
|
|
339
355
|
if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
|
|
@@ -349,6 +365,15 @@ export class Stub {
|
|
|
349
365
|
res.end(payload);
|
|
350
366
|
}
|
|
351
367
|
}
|
|
368
|
+
/** `/search?q=tea` is the path `/search` with the query condition `q: "tea"`; `match.query` wins over the path's. */
|
|
369
|
+
function splitQuery(path, match) {
|
|
370
|
+
if (typeof path !== "string" || !path.includes("?"))
|
|
371
|
+
return { path, match };
|
|
372
|
+
const [bare, search] = [path.slice(0, path.indexOf("?")), path.slice(path.indexOf("?") + 1)];
|
|
373
|
+
const params = new URLSearchParams(search);
|
|
374
|
+
const query = Object.fromEntries([...new Set(params.keys())].map((k) => [k, params.getAll(k).length > 1 ? params.getAll(k) : params.get(k)]));
|
|
375
|
+
return { path: bare || "/", match: { ...match, query: { ...query, ...match.query } } };
|
|
376
|
+
}
|
|
352
377
|
function compilePath(path) {
|
|
353
378
|
if (typeof path !== "string" || !path.includes("/:"))
|
|
354
379
|
return { pattern: undefined, paramNames: [] };
|
|
@@ -388,7 +413,7 @@ function decodeSegment(segment) {
|
|
|
388
413
|
}
|
|
389
414
|
function matchConditions(match, call) {
|
|
390
415
|
for (const [k, m] of Object.entries(match.query ?? {})) {
|
|
391
|
-
if (!
|
|
416
|
+
if (!testQuery(m, call.query, k))
|
|
392
417
|
return false;
|
|
393
418
|
}
|
|
394
419
|
for (const [k, m] of Object.entries(match.headers ?? {})) {
|
|
@@ -417,9 +442,11 @@ function matchConditions(match, call) {
|
|
|
417
442
|
/** The first condition of `match` that `call` fails, described; undefined when it meets them all. */
|
|
418
443
|
function conditionMismatch(match, call) {
|
|
419
444
|
for (const [k, m] of Object.entries(match.query ?? {})) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
445
|
+
if (testQuery(m, call.query, k))
|
|
446
|
+
continue;
|
|
447
|
+
const all = call.query.getAll(k);
|
|
448
|
+
const got = all.length === 0 ? "nothing" : Array.isArray(m) || all.length > 1 ? JSON.stringify(all) : JSON.stringify(all[0]);
|
|
449
|
+
return `query ${k}: expected ${Array.isArray(m) ? `[${m.map(show).join(", ")}]` : show(m)}, got ${got}`;
|
|
423
450
|
}
|
|
424
451
|
for (const [k, m] of Object.entries(match.headers ?? {})) {
|
|
425
452
|
const raw = call.headers[k.toLowerCase()];
|
|
@@ -525,6 +552,13 @@ function distance(a, b) {
|
|
|
525
552
|
}
|
|
526
553
|
return row[b.length];
|
|
527
554
|
}
|
|
555
|
+
/** A query condition: one matcher for the first value, or a list for every value of a repeated parameter, in order. */
|
|
556
|
+
function testQuery(m, query, key) {
|
|
557
|
+
if (!Array.isArray(m))
|
|
558
|
+
return test(m, query.get(key) ?? undefined);
|
|
559
|
+
const values = query.getAll(key);
|
|
560
|
+
return values.length === m.length && m.every((x, i) => test(x, values[i]));
|
|
561
|
+
}
|
|
528
562
|
function test(m, value) {
|
|
529
563
|
if (isAsymmetric(m))
|
|
530
564
|
return m.asymmetricMatch(value);
|
|
@@ -558,6 +592,8 @@ function isAsymmetric(m) {
|
|
|
558
592
|
function isForm(contentType) {
|
|
559
593
|
return !!contentType && contentType.split(";")[0].trim().toLowerCase() === "application/x-www-form-urlencoded";
|
|
560
594
|
}
|
|
595
|
+
/** Keys kept as plain fields rather than nested into, so a body can't reach `Object.prototype`. */
|
|
596
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
561
597
|
/**
|
|
562
598
|
* A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
|
|
563
599
|
* `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
|
|
@@ -567,12 +603,13 @@ export function parseForm(body) {
|
|
|
567
603
|
const out = {};
|
|
568
604
|
for (const [key, value] of new URLSearchParams(body)) {
|
|
569
605
|
const m = /^([^[\]]+)((?:\[[^\]]*\])*)$/.exec(key);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
606
|
+
const path = m?.[2] ? [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])] : undefined;
|
|
607
|
+
// `__proto__[x]=1` is sent by whatever the app forwards; it must not reach Object.prototype.
|
|
608
|
+
if (!path || path.some((seg) => UNSAFE_KEYS.has(seg))) {
|
|
609
|
+
const prev = Object.hasOwn(out, key) ? out[key] : undefined;
|
|
610
|
+
Object.defineProperty(out, key, { value: prev === undefined ? value : Array.isArray(prev) ? [...prev, value] : [prev, value], enumerable: true, writable: true, configurable: true });
|
|
573
611
|
continue;
|
|
574
612
|
}
|
|
575
|
-
const path = [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])];
|
|
576
613
|
let node = out;
|
|
577
614
|
for (let i = 0; i < path.length; i++) {
|
|
578
615
|
const seg = path[i];
|
package/dist/yaml-runtime.js
CHANGED
|
@@ -235,7 +235,7 @@ async function runStep(step, ctx, vars, base) {
|
|
|
235
235
|
follow: step.follow,
|
|
236
236
|
});
|
|
237
237
|
verifyResponse(res, step.expect, vars, base);
|
|
238
|
-
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
|
|
238
|
+
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers), events: res.events }, vars);
|
|
239
239
|
return;
|
|
240
240
|
}
|
|
241
241
|
if ("request" in step) {
|
|
@@ -296,7 +296,7 @@ async function runStep(step, ctx, vars, base) {
|
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
verify(res);
|
|
299
|
-
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
|
|
299
|
+
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers), events: res.events }, vars);
|
|
300
300
|
return;
|
|
301
301
|
}
|
|
302
302
|
if ("insert" in step) {
|
|
@@ -551,18 +551,23 @@ const FORMATS = {
|
|
|
551
551
|
uri: /^[a-z][a-z0-9+.-]*:\/\/\S+$/i,
|
|
552
552
|
integer: /^-?\d+$/,
|
|
553
553
|
};
|
|
554
|
-
const MATCHERS = ["$type", "$regex", "$contains", "$any", "$gt", "$gte", "$lt", "$lte", "$len", "$not", "$oneOf", "$format"];
|
|
554
|
+
const MATCHERS = ["$type", "$regex", "$contains", "$any", "$gt", "$gte", "$lt", "$lte", "$closeTo", "$len", "$not", "$oneOf", "$format"];
|
|
555
555
|
/** An asymmetric matcher Vitest's `toEqual` / `toMatchObject` call, with a readable name in diffs. */
|
|
556
556
|
function matcher(name, test) {
|
|
557
557
|
return { asymmetricMatch: test, toAsymmetricMatcher: () => name, toString: () => name };
|
|
558
558
|
}
|
|
559
|
-
/**
|
|
559
|
+
/** A decimal as Postgres `numeric` and MySQL `DECIMAL` columns come back, to keep their precision: `"12.50"`. */
|
|
560
|
+
const DECIMAL = /^-?\d+(\.\d+)?$/;
|
|
561
|
+
/**
|
|
562
|
+
* Numbers compare as numbers, also with decimal strings from `numeric` / `DECIMAL` columns; strings
|
|
563
|
+
* (ISO dates, versions) compare as strings; anything else never matches.
|
|
564
|
+
*/
|
|
560
565
|
function compare(op, arg, holds) {
|
|
561
566
|
if (typeof arg !== "number" && typeof arg !== "string")
|
|
562
567
|
throw new Error(`${op} takes a number or a string, got ${JSON.stringify(arg)}`);
|
|
563
568
|
return matcher(`${op} ${JSON.stringify(arg)}`, (v) => {
|
|
564
569
|
if (typeof arg === "number")
|
|
565
|
-
return typeof v === "number" && holds(v - arg);
|
|
570
|
+
return (typeof v === "number" || (typeof v === "string" && DECIMAL.test(v))) && holds(Number(v) - arg);
|
|
566
571
|
return typeof v === "string" && holds(v < arg ? -1 : v > arg ? 1 : 0);
|
|
567
572
|
});
|
|
568
573
|
}
|
|
@@ -604,6 +609,13 @@ function single(key, arg) {
|
|
|
604
609
|
return compare(key, arg, (c) => c < 0);
|
|
605
610
|
case "$lte":
|
|
606
611
|
return compare(key, arg, (c) => c <= 0);
|
|
612
|
+
case "$closeTo": {
|
|
613
|
+
// `$closeTo: 9.99` allows ±0.005 (two decimals, like toBeCloseTo); `$closeTo: [9.99, 0.1]` sets the tolerance.
|
|
614
|
+
const [target, tolerance = 0.005] = Array.isArray(arg) ? arg : [arg];
|
|
615
|
+
if (typeof target !== "number" || typeof tolerance !== "number" || !(tolerance >= 0))
|
|
616
|
+
throw new Error(`$closeTo takes a number or [number, tolerance], got ${JSON.stringify(arg)}`);
|
|
617
|
+
return matcher(`$closeTo ${JSON.stringify(arg)}`, (v) => (typeof v === "number" || (typeof v === "string" && DECIMAL.test(v))) && Math.abs(Number(v) - target) <= tolerance + Number.EPSILON);
|
|
618
|
+
}
|
|
607
619
|
case "$len": {
|
|
608
620
|
const want = toMatchers(arg);
|
|
609
621
|
return matcher(`$len ${JSON.stringify(arg)}`, (v) => (typeof v === "string" || Array.isArray(v)) && equalsMatcher(want, v.length));
|
|
@@ -669,6 +681,35 @@ function verifyResponse(res, e, vars, base) {
|
|
|
669
681
|
check(res.json, e.json, vars, "response JSON");
|
|
670
682
|
if (e?.text !== undefined)
|
|
671
683
|
check(res.text, e.text, vars, "response text");
|
|
684
|
+
if (e?.events !== undefined) {
|
|
685
|
+
if (!res.events)
|
|
686
|
+
throw new Error(`${res.method} ${res.url}: expected server-sent events, but the response is ${res.headers.get("content-type") ?? "without a content type"}, not text/event-stream`);
|
|
687
|
+
const wanted = toMatchers(interpolate(e.events, vars));
|
|
688
|
+
let at = 0;
|
|
689
|
+
for (const [i, want] of wanted.entries()) {
|
|
690
|
+
const found = res.events.findIndex((ev, j) => j >= at && subsetEquals(want, ev));
|
|
691
|
+
if (found < 0) {
|
|
692
|
+
const shown = res.events.slice(0, 20).map((ev, j) => ` ${j}: ${JSON.stringify(ev).slice(0, 200)}`).join("\n");
|
|
693
|
+
throw new Error(`${res.method} ${res.url}: expected event ${i + 1} of ${wanted.length}, ${JSON.stringify(e.events[i])}, ${at > 0 ? `after event ${at - 1}` : "in the stream"}; ${res.events.length} event(s) arrived:\n${shown || " (none)"}${res.events.length > 20 ? "\n …" : ""}`);
|
|
694
|
+
}
|
|
695
|
+
at = found + 1;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (e?.cookies !== undefined) {
|
|
699
|
+
for (const [name, attrs] of Object.entries(e.cookies)) {
|
|
700
|
+
if (attrs === null)
|
|
701
|
+
expect(res).not.toSetCookie(name);
|
|
702
|
+
else
|
|
703
|
+
expect(res).toSetCookie(name, toMatchers(interpolate(attrs, vars)));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (e?.duration !== undefined) {
|
|
707
|
+
const want = interpolate(e.duration, vars);
|
|
708
|
+
if (typeof want === "number")
|
|
709
|
+
expect(res).toRespondWithin(want);
|
|
710
|
+
else
|
|
711
|
+
check(res.durationMs, want, vars, `${res.method} ${res.url} response time (${res.durationMs}ms)`);
|
|
712
|
+
}
|
|
672
713
|
if (e?.schema !== undefined) {
|
|
673
714
|
const problems = schemaProblems(e.schema, res.json, base);
|
|
674
715
|
if (problems.length)
|
package/dist/yaml.d.ts
CHANGED
|
@@ -132,7 +132,10 @@ export interface RequestStep {
|
|
|
132
132
|
/** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
|
|
133
133
|
/** `schema`: a JSON Schema for the response JSON, inline or `file#/pointer` relative to the scenario file. */
|
|
134
134
|
expect?: {
|
|
135
|
-
|
|
135
|
+
events?: Record<string, unknown>[];
|
|
136
|
+
cookies?: Record<string, Record<string, unknown> | null>;
|
|
137
|
+
duration?: number | Record<string, unknown>;
|
|
138
|
+
status?: number | string | (number | string)[];
|
|
136
139
|
statuses?: Record<string, number>;
|
|
137
140
|
headers?: Record<string, unknown>;
|
|
138
141
|
json?: unknown;
|
|
@@ -156,7 +159,10 @@ export interface SubmitStep {
|
|
|
156
159
|
headers?: Record<string, string>;
|
|
157
160
|
follow?: boolean;
|
|
158
161
|
expect?: {
|
|
159
|
-
|
|
162
|
+
events?: Record<string, unknown>[];
|
|
163
|
+
cookies?: Record<string, Record<string, unknown> | null>;
|
|
164
|
+
duration?: number | Record<string, unknown>;
|
|
165
|
+
status?: number | string | (number | string)[];
|
|
160
166
|
headers?: Record<string, unknown>;
|
|
161
167
|
json?: unknown;
|
|
162
168
|
text?: unknown;
|