slicetest 0.8.0 → 0.10.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 +131 -35
- package/dist/app.js +10 -2
- package/dist/auth.js +2 -1
- package/dist/cli-run.d.ts +53 -0
- package/dist/cli-run.js +80 -0
- package/dist/cli.js +27 -29
- package/dist/config.d.ts +33 -2
- package/dist/config.js +121 -2
- package/dist/container-runtime.d.ts +4 -0
- package/dist/container-runtime.js +16 -0
- package/dist/containers.js +4 -3
- package/dist/db.d.ts +3 -1
- package/dist/db.js +45 -8
- package/dist/deadline.d.ts +7 -0
- package/dist/deadline.js +24 -0
- package/dist/doctor.js +27 -5
- package/dist/drivers/driver.d.ts +6 -0
- package/dist/drivers/mysql.js +5 -1
- package/dist/drivers/postgres.d.ts +2 -0
- package/dist/drivers/postgres.js +20 -1
- package/dist/exit.d.ts +1 -0
- package/dist/exit.js +28 -0
- package/dist/form.d.ts +2 -0
- package/dist/form.js +62 -9
- package/dist/global-setup.js +3 -6
- package/dist/har.js +4 -4
- package/dist/http.d.ts +38 -4
- package/dist/http.js +135 -10
- package/dist/init.js +64 -10
- package/dist/list.d.ts +1 -0
- package/dist/list.js +16 -3
- package/dist/mail.d.ts +1 -1
- package/dist/mail.js +7 -7
- package/dist/matchers.d.ts +18 -6
- package/dist/matchers.js +75 -8
- package/dist/openapi.d.ts +2 -5
- package/dist/openapi.js +146 -20
- package/dist/record-cli.js +2 -1
- package/dist/record.js +2 -1
- package/dist/recording.d.ts +11 -1
- package/dist/recording.js +51 -6
- package/dist/request-url.d.ts +1 -0
- package/dist/request-url.js +5 -0
- package/dist/runtime.d.ts +3 -3
- package/dist/runtime.js +144 -18
- package/dist/scenario.js +11 -2
- 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 +64 -16
- package/dist/vitest.d.ts +4 -0
- package/dist/vitest.js +6 -2
- package/dist/webhook.d.ts +3 -3
- package/dist/webhook.js +10 -2
- package/dist/yaml-runtime.d.ts +4 -4
- package/dist/yaml-runtime.js +141 -38
- package/dist/yaml.d.ts +12 -2
- package/dist/yaml.js +47 -4
- package/docs/guarantees.md +92 -0
- package/docs/state-isolation.md +94 -0
- package/package.json +4 -1
- package/schema/config.schema.json +675 -0
- package/schema/scenario.schema.json +97 -7
package/dist/init.js
CHANGED
|
@@ -74,6 +74,37 @@ function runnable(dir) {
|
|
|
74
74
|
return false;
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
/** Where plain SQL migrations usually live (golang-migrate, goose, sqlx, Diesel, dbmate). */
|
|
78
|
+
const SQL_MIGRATION_DIRS = ["migrations", "db/migrations", "sql/migrations", "database/migrations"];
|
|
79
|
+
/** `.sql` files, or Diesel's directories with an `up.sql`. */
|
|
80
|
+
function hasSqlMigrations(dir) {
|
|
81
|
+
if (!existsSync(dir))
|
|
82
|
+
return false;
|
|
83
|
+
return readdirSync(dir, { withFileTypes: true }).some((e) => (e.isFile() && e.name.endsWith(".sql")) || (e.isDirectory() && existsSync(path.join(dir, e.name, "up.sql"))));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The Go package to run: `.` when the root is package main, else the one command in `cmd/*`
|
|
87
|
+
* (or the one named server, api, app, web or like the module). Undefined when there is none.
|
|
88
|
+
*/
|
|
89
|
+
function goMain(root, goMod) {
|
|
90
|
+
const isMain = (dir) => {
|
|
91
|
+
try {
|
|
92
|
+
return readdirSync(dir).some((f) => f.endsWith(".go") && !f.endsWith("_test.go") && /^\s*package\s+main\b/m.test(readFileSync(path.join(dir, f), "utf8")));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (isMain(root))
|
|
99
|
+
return ".";
|
|
100
|
+
const cmd = path.join(root, "cmd");
|
|
101
|
+
if (!existsSync(cmd))
|
|
102
|
+
return undefined;
|
|
103
|
+
const mains = readdirSync(cmd, { withFileTypes: true }).filter((e) => e.isDirectory() && isMain(path.join(cmd, e.name))).map((e) => e.name).sort();
|
|
104
|
+
const module = /^module\s+(\S+)/m.exec(goMod)?.[1]?.split("/").pop();
|
|
105
|
+
const pick = mains.length === 1 ? mains[0] : ["server", "api", "app", "web", "http", module].find((n) => n && mains.includes(n));
|
|
106
|
+
return pick ? `./cmd/${pick}` : undefined;
|
|
107
|
+
}
|
|
77
108
|
export async function detect(root) {
|
|
78
109
|
const notes = [];
|
|
79
110
|
// The app may live in a subdirectory (backend/, server/, …); everything about the app is read there.
|
|
@@ -83,11 +114,19 @@ export async function detect(root) {
|
|
|
83
114
|
const appRoot = path.join(root, appDir);
|
|
84
115
|
const has = (p) => existsSync(path.join(appRoot, p));
|
|
85
116
|
const read = async (p) => (has(p) ? readFile(path.join(appRoot, p), "utf8") : "");
|
|
86
|
-
const
|
|
117
|
+
const readJson = async (name) => {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(await read(name));
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
throw new Error(`slicetest init: ${name} isn't valid JSON (${e.message}); fix it, or run init from the folder of the app`);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const pkg = has("package.json") ? (await readJson("package.json")) : undefined;
|
|
87
126
|
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
88
127
|
const python = `${await read("requirements.txt")}\n${await read("pyproject.toml")}`.toLowerCase();
|
|
89
128
|
const gemfile = await read("Gemfile");
|
|
90
|
-
const composer = has("composer.json") ?
|
|
129
|
+
const composer = has("composer.json") ? (await readJson("composer.json")) : undefined;
|
|
91
130
|
const php = { ...composer?.require, ...composer?.["require-dev"] };
|
|
92
131
|
const laravel = !!php["laravel/framework"] && has("artisan");
|
|
93
132
|
const symfony = !laravel && !!php["symfony/framework-bundle"] && has("bin/console");
|
|
@@ -238,19 +277,33 @@ export async function detect(root) {
|
|
|
238
277
|
}
|
|
239
278
|
}
|
|
240
279
|
else if (has("go.mod")) {
|
|
241
|
-
|
|
242
|
-
|
|
280
|
+
const main = goMain(appRoot, await read("go.mod"));
|
|
281
|
+
// Built once up front, so the workers' `go run` find everything compiled instead of compiling at once.
|
|
282
|
+
build = "go build ./...";
|
|
283
|
+
command = `go run ${main ?? "."}`;
|
|
284
|
+
readyTimeout = 60_000;
|
|
285
|
+
notes.push(main && main !== "."
|
|
286
|
+
? `app: Go (go.mod), the main package in ${main.slice(2)}/. It must listen on $PORT.`
|
|
287
|
+
: main
|
|
288
|
+
? "app: Go (go.mod). It must listen on $PORT."
|
|
289
|
+
: "app: Go (go.mod), but no main package at the root or in cmd/*/: set the package in app.command (`go run ./cmd/server`). It must listen on $PORT.");
|
|
243
290
|
}
|
|
244
291
|
else if (has("Cargo.toml")) {
|
|
245
|
-
|
|
246
|
-
|
|
292
|
+
const cargo = await read("Cargo.toml");
|
|
293
|
+
// Compiled once before the workers start; `cargo run` then only checks that nothing changed.
|
|
294
|
+
build = "cargo build -q";
|
|
295
|
+
command = "cargo run -q";
|
|
296
|
+
readyTimeout = 120_000;
|
|
297
|
+
notes.push(/^\[workspace\]/m.test(cargo) && !/^\[package\]/m.test(cargo)
|
|
298
|
+
? "app: Rust workspace (Cargo.toml): add `-p <crate>` to app.command for the server's crate. It must listen on $PORT."
|
|
299
|
+
: "app: Rust (Cargo.toml), built once with `cargo build`. It must listen on $PORT.");
|
|
247
300
|
}
|
|
248
301
|
else {
|
|
249
302
|
notes.push("app: couldn't tell how to start the app. Set app.command.");
|
|
250
303
|
}
|
|
251
304
|
// --- migrations ---
|
|
252
305
|
let migrate;
|
|
253
|
-
const
|
|
306
|
+
const migrationsDir = SQL_MIGRATION_DIRS.find((d) => hasSqlMigrations(path.join(appRoot, d)));
|
|
254
307
|
const atlas = await findAtlas(root, appDir);
|
|
255
308
|
if (atlas) {
|
|
256
309
|
migrate = { atlas: { dir: `file://${atlas}` } };
|
|
@@ -304,9 +357,9 @@ export async function detect(root) {
|
|
|
304
357
|
migrate = { command: "npx knex migrate:latest", inputs: ["migrations"] };
|
|
305
358
|
notes.push("db: Knex migrations");
|
|
306
359
|
}
|
|
307
|
-
else if (
|
|
308
|
-
migrate = { sql:
|
|
309
|
-
notes.push(
|
|
360
|
+
else if (migrationsDir) {
|
|
361
|
+
migrate = { sql: migrationsDir };
|
|
362
|
+
notes.push(`db: SQL migrations in ${migrationsDir}/, applied in version order (down migrations left out)`);
|
|
310
363
|
}
|
|
311
364
|
else if (has("schema.sql")) {
|
|
312
365
|
migrate = { sql: "schema.sql" };
|
|
@@ -564,6 +617,7 @@ export async function init(root, { force = false } = {}) {
|
|
|
564
617
|
}
|
|
565
618
|
const { config, notes } = await detect(root);
|
|
566
619
|
const header = [
|
|
620
|
+
"# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/config.schema.json",
|
|
567
621
|
"# slicetest config, generated by `npx slicetest init`. Paths are relative to this file.",
|
|
568
622
|
"# Everything the Vitest plugin accepts works here: https://github.com/revo1290/slicetest#configuration-reference",
|
|
569
623
|
"#",
|
package/dist/list.d.ts
CHANGED
package/dist/list.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { tagsSelected } from "./scenario.js";
|
|
4
|
-
import { parseScenarioFile } from "./yaml.js";
|
|
4
|
+
import { parseScenarioFile, scenarioTitle } from "./yaml.js";
|
|
5
|
+
function matchesGlob(file, glob) {
|
|
6
|
+
const match = path.matchesGlob;
|
|
7
|
+
if (!match)
|
|
8
|
+
throw new Error("slicetest list: the config's include needs Node.js 20.17 or later (path.matchesGlob)");
|
|
9
|
+
return match(file, glob.replace(/^\.\//, ""));
|
|
10
|
+
}
|
|
5
11
|
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "target", "vendor", ".venv", "venv"]);
|
|
6
12
|
async function scenarioFiles(dir, out = []) {
|
|
7
13
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
@@ -25,20 +31,27 @@ export async function listScenarios(root, opts = {}) {
|
|
|
25
31
|
const pattern = opts.name ? new RegExp(opts.name) : undefined;
|
|
26
32
|
for (const file of await scenarioFiles(root)) {
|
|
27
33
|
const rel = path.relative(root, file).split(path.sep).join("/");
|
|
34
|
+
if (opts.include && !opts.include.some((glob) => matchesGlob(rel, glob)))
|
|
35
|
+
continue;
|
|
28
36
|
if (opts.filters?.length && !opts.filters.some((f) => rel.includes(f)))
|
|
29
37
|
continue;
|
|
30
38
|
try {
|
|
31
39
|
const doc = parseScenarioFile(await readFile(file, "utf8"), rel);
|
|
40
|
+
// Vitest's `.only` runs only the focused tests of that file; one the tag filter leaves out is registered as skipped.
|
|
41
|
+
const focused = doc.scenarios.some((sc) => sc.only && !sc.skip && tagsSelected(sc.tags ?? [], opts.tags));
|
|
32
42
|
for (const sc of doc.scenarios) {
|
|
33
43
|
const tags = sc.tags ?? [];
|
|
34
|
-
|
|
44
|
+
// `-t` is matched against the title each row runs under (`vote {{c}}` → `vote a`).
|
|
45
|
+
const titles = sc.each ? sc.each.map((row, i) => scenarioTitle(sc.name, row, i)) : [sc.name];
|
|
46
|
+
const rows = pattern ? titles.filter((t) => pattern.test(t)).length : titles.length;
|
|
47
|
+
const selected = tagsSelected(tags, opts.tags) && rows > 0 && (!focused || !!sc.only);
|
|
35
48
|
scenarios.push({
|
|
36
49
|
file: rel,
|
|
37
50
|
line: sc.line,
|
|
38
51
|
name: sc.name,
|
|
39
52
|
tags,
|
|
40
53
|
status: sc.skip ? "skip" : !selected ? "filtered" : sc.only ? "only" : "run",
|
|
41
|
-
rows:
|
|
54
|
+
rows: selected ? rows : titles.length,
|
|
42
55
|
steps: doc.setup.length + sc.steps.length,
|
|
43
56
|
});
|
|
44
57
|
}
|
package/dist/mail.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export declare class Mailbox {
|
|
|
47
47
|
* The first message matching `filter`, waiting up to `within` ms for the app
|
|
48
48
|
* to send it. Fails with the messages that did arrive.
|
|
49
49
|
*/
|
|
50
|
-
waitFor(filter?: MailFilter, { within }?: {
|
|
50
|
+
waitFor(filter?: MailFilter, { within: wanted }?: {
|
|
51
51
|
within?: number | undefined;
|
|
52
52
|
}): Promise<Mail>;
|
|
53
53
|
/** One line per message received this scenario, for failure output. */
|
package/dist/mail.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { waitBudget } from "./deadline.js";
|
|
2
|
+
import { decodeEntities } from "./form.js";
|
|
1
3
|
import net from "node:net";
|
|
2
4
|
export class Mailbox {
|
|
3
5
|
server;
|
|
@@ -37,7 +39,8 @@ export class Mailbox {
|
|
|
37
39
|
* The first message matching `filter`, waiting up to `within` ms for the app
|
|
38
40
|
* to send it. Fails with the messages that did arrive.
|
|
39
41
|
*/
|
|
40
|
-
async waitFor(filter = {}, { within = 5000 } = {}) {
|
|
42
|
+
async waitFor(filter = {}, { within: wanted = 5000 } = {}) {
|
|
43
|
+
const { ms: within, note } = waitBudget(wanted);
|
|
41
44
|
const deadline = Date.now() + within;
|
|
42
45
|
for (;;) {
|
|
43
46
|
const found = this.messages(filter)[0];
|
|
@@ -45,7 +48,7 @@ export class Mailbox {
|
|
|
45
48
|
return found;
|
|
46
49
|
const left = deadline - Date.now();
|
|
47
50
|
if (left <= 0)
|
|
48
|
-
throw new Error(`slicetest: no mail matching ${describeFilter(filter)} within ${within}ms\n${this.describe()}`);
|
|
51
|
+
throw new Error(`slicetest: no mail matching ${describeFilter(filter)} within ${within}ms${note}\n${this.describe()}`);
|
|
49
52
|
await new Promise((resolve) => {
|
|
50
53
|
const done = () => {
|
|
51
54
|
clearTimeout(timer);
|
|
@@ -283,11 +286,8 @@ function htmlToText(html) {
|
|
|
283
286
|
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, "")
|
|
284
287
|
.replace(/<br\s*\/?>|<\/p>/gi, "\n")
|
|
285
288
|
.replace(/<[^>]+>/g, "")
|
|
286
|
-
|
|
287
|
-
.replace(/&
|
|
288
|
-
.replace(/>/g, ">")
|
|
289
|
-
.replace(/"/g, '"')
|
|
290
|
-
.replace(/'/g, "'")
|
|
289
|
+
// One pass: replacing `&` first turned the text `<` (sent as `&lt;`) into `<`.
|
|
290
|
+
.replace(/&[^;\s]+;/g, (e) => decodeEntities(e))
|
|
291
291
|
.trim();
|
|
292
292
|
}
|
|
293
293
|
function links(s) {
|
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,10 +22,18 @@ 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;
|
|
27
|
-
/**
|
|
28
|
-
|
|
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;
|
|
35
|
+
/** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`; classes count the rest: `{ 201: 1, "4xx": 9 }`. */
|
|
36
|
+
toHaveStatuses(counts: Partial<Record<number | `${1 | 2 | 3 | 4 | 5}xx`, number>>): R;
|
|
29
37
|
/** Async: the table has at least one row matching `where` (`count` for an exact number). */
|
|
30
38
|
toHaveRow(table: string, where?: Where, count?: number): Promise<void>;
|
|
31
39
|
}
|
|
@@ -33,5 +41,9 @@ declare module "vitest" {
|
|
|
33
41
|
interface Matchers<R extends void | Promise<void> = void | Promise<void>, T = unknown> extends SlicetestMatchers<R> {
|
|
34
42
|
}
|
|
35
43
|
}
|
|
36
|
-
|
|
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;
|
|
47
|
+
/** Responses counted under their code when `keys` lists it, else under their class (`4xx`) when that is listed. */
|
|
48
|
+
export declare function statusCounts(responses: HttpResponse[], keys?: string[]): Record<string, number>;
|
|
37
49
|
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,14 +23,38 @@ 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
|
}
|
|
25
|
-
export function
|
|
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
|
+
}
|
|
44
|
+
/** Responses counted under their code when `keys` lists it, else under their class (`4xx`) when that is listed. */
|
|
45
|
+
export function statusCounts(responses, keys = []) {
|
|
26
46
|
const out = {};
|
|
27
|
-
for (const r of responses)
|
|
28
|
-
|
|
47
|
+
for (const r of responses) {
|
|
48
|
+
const code = String(r.status);
|
|
49
|
+
const key = keys.includes(code) ? code : keys.includes(`${code[0]}xx`) ? `${code[0]}xx` : code;
|
|
50
|
+
out[key] = (out[key] ?? 0) + 1;
|
|
51
|
+
}
|
|
29
52
|
return out;
|
|
30
53
|
}
|
|
31
54
|
function fmtCounts(c) {
|
|
32
|
-
|
|
55
|
+
// In status order, a class before its codes; object order would put every code before every class.
|
|
56
|
+
const rank = (k) => (/xx$/.test(k) ? Number(k[0]) * 100 - 0.5 : Number(k));
|
|
57
|
+
return `{ ${Object.entries(c).sort(([a], [b]) => rank(a) - rank(b)).map(([k, n]) => `${k}: ${n}`).join(", ")} }`;
|
|
33
58
|
}
|
|
34
59
|
/** One example response per status, which is usually what explains the odd one out. */
|
|
35
60
|
function describeResponses(responses) {
|
|
@@ -140,23 +165,65 @@ expect.extend({
|
|
|
140
165
|
};
|
|
141
166
|
},
|
|
142
167
|
toHaveStatus(received, status) {
|
|
143
|
-
|
|
168
|
+
if (!received || typeof received !== "object" || typeof received.status !== "number") {
|
|
169
|
+
throw new TypeError(`slicetest: toHaveStatus expects a response from http, got ${this.utils.stringify(received)}`);
|
|
170
|
+
}
|
|
171
|
+
const pass = statusMatches(status, received.status);
|
|
144
172
|
return {
|
|
145
173
|
pass,
|
|
146
174
|
message: () => {
|
|
147
175
|
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` +
|
|
176
|
+
return (`expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond ${describeStatus(status)}, got ${received.status}\n` +
|
|
149
177
|
`Response body:\n ${body || "(empty)"}`);
|
|
150
178
|
},
|
|
151
179
|
actual: received?.status,
|
|
152
180
|
expected: status,
|
|
153
181
|
};
|
|
154
182
|
},
|
|
183
|
+
toSetCookie(received, name, attributes = {}) {
|
|
184
|
+
if (!received || typeof received !== "object" || !(received.headers instanceof Headers)) {
|
|
185
|
+
throw new TypeError(`slicetest: toSetCookie expects a response from http, got ${this.utils.stringify(received)}`);
|
|
186
|
+
}
|
|
187
|
+
const cookies = setCookies(received);
|
|
188
|
+
const cookie = Object.hasOwn(cookies, name) ? cookies[name] : undefined;
|
|
189
|
+
// SameSite values are case-insensitive: "Lax" and "lax" are the same attribute. A matcher sees the value as sent.
|
|
190
|
+
const plain = typeof attributes.sameSite === "string";
|
|
191
|
+
const want = plain ? { ...attributes, sameSite: attributes.sameSite.toLowerCase() } : attributes;
|
|
192
|
+
const pass = !!cookie && subset(want, plain ? { ...cookie, sameSite: cookie.sameSite?.toLowerCase() } : cookie);
|
|
193
|
+
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)))}`;
|
|
194
|
+
return {
|
|
195
|
+
pass,
|
|
196
|
+
message: () => {
|
|
197
|
+
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 })}` : ""}`;
|
|
198
|
+
const all = Object.values(cookies);
|
|
199
|
+
return `${head}\n${all.length ? `Cookies it set:\n${all.map((c) => ` ${describe(c)}`).join("\n")}` : "It set no cookies."}`;
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
toRespondWithin(received, ms) {
|
|
204
|
+
if (!received || typeof received !== "object" || typeof received.durationMs !== "number") {
|
|
205
|
+
throw new TypeError(`slicetest: toRespondWithin expects a response from http, got ${this.utils.stringify(received)}`);
|
|
206
|
+
}
|
|
207
|
+
if (!(typeof ms === "number" && ms > 0))
|
|
208
|
+
throw new TypeError(`slicetest: toRespondWithin needs a positive number of milliseconds, got ${this.utils.stringify(ms)}`);
|
|
209
|
+
return {
|
|
210
|
+
pass: received.durationMs <= ms,
|
|
211
|
+
message: () => `expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond within ${ms}ms, it took ${received.durationMs}ms`,
|
|
212
|
+
actual: received.durationMs,
|
|
213
|
+
expected: ms,
|
|
214
|
+
};
|
|
215
|
+
},
|
|
155
216
|
toHaveStatuses(received, counts) {
|
|
156
217
|
if (!Array.isArray(received))
|
|
157
218
|
throw new TypeError("slicetest: toHaveStatuses expects an array of responses, e.g. from http.concurrently()");
|
|
158
|
-
const
|
|
159
|
-
|
|
219
|
+
const keys = Object.keys(counts).map((k) => {
|
|
220
|
+
const key = k.trim().toLowerCase();
|
|
221
|
+
if (!/^\d{3}$|^[1-5]xx$/.test(key))
|
|
222
|
+
throw new TypeError(`slicetest: a status is a code (201) or a class ("2xx"), got ${JSON.stringify(k)}`);
|
|
223
|
+
return key;
|
|
224
|
+
});
|
|
225
|
+
const actual = statusCounts(received, keys);
|
|
226
|
+
const expected = Object.fromEntries(Object.entries(counts).filter((entry) => (entry[1] ?? 0) > 0).map(([k, n]) => [k.trim().toLowerCase(), n]));
|
|
160
227
|
const pass = this.equals(actual, expected);
|
|
161
228
|
return {
|
|
162
229
|
pass,
|
package/dist/openapi.d.ts
CHANGED
|
@@ -30,12 +30,9 @@ export interface Message {
|
|
|
30
30
|
/** Parsed JSON when the body is JSON, else the raw text. */
|
|
31
31
|
body: unknown;
|
|
32
32
|
query?: URLSearchParams;
|
|
33
|
+
/** Header names in lower case, as Node delivers them; undefined skips the check for required header parameters. */
|
|
34
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
33
35
|
}
|
|
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
36
|
export declare class OpenApiSpec {
|
|
40
37
|
#private;
|
|
41
38
|
readonly file: string;
|