slicetest 0.1.0 → 0.3.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 +237 -14
- package/dist/app.d.ts +26 -3
- package/dist/app.js +80 -18
- package/dist/cli.js +44 -6
- package/dist/config.d.ts +114 -11
- package/dist/config.js +97 -22
- package/dist/containers.d.ts +22 -0
- package/dist/containers.js +59 -0
- package/dist/db.d.ts +37 -5
- package/dist/db.js +149 -81
- package/dist/drivers/driver.d.ts +65 -0
- package/dist/drivers/driver.js +6 -0
- package/dist/drivers/index.d.ts +5 -0
- package/dist/drivers/index.js +14 -0
- package/dist/drivers/mysql.d.ts +29 -0
- package/dist/drivers/mysql.js +270 -0
- package/dist/drivers/postgres.d.ts +19 -0
- package/dist/drivers/postgres.js +164 -0
- package/dist/gen.d.ts +24 -0
- package/dist/gen.js +116 -0
- package/dist/global-setup.d.ts +7 -1
- package/dist/global-setup.js +159 -30
- package/dist/http.d.ts +3 -0
- package/dist/http.js +7 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +1 -0
- package/dist/init.d.ts +20 -0
- package/dist/init.js +236 -0
- package/dist/openapi.d.ts +74 -0
- package/dist/openapi.js +337 -0
- package/dist/provided.d.ts +2 -0
- package/dist/recording.d.ts +43 -0
- package/dist/recording.js +148 -0
- package/dist/runtime.d.ts +20 -1
- package/dist/runtime.js +207 -28
- package/dist/scenario.js +2 -2
- package/dist/stub.d.ts +14 -0
- package/dist/stub.js +45 -9
- package/dist/trace.d.ts +35 -0
- package/dist/trace.js +57 -0
- package/dist/yaml-runtime.js +67 -1
- package/dist/yaml.d.ts +36 -1
- package/dist/yaml.js +46 -4
- package/package.json +22 -5
- package/schema/scenario.schema.json +156 -1
package/dist/cli.js
CHANGED
|
@@ -11,21 +11,33 @@ import { parseArgs } from "node:util";
|
|
|
11
11
|
import { parse } from "yaml";
|
|
12
12
|
const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetest.config.json"];
|
|
13
13
|
const HELP = `Usage: slicetest [filters...] [options]
|
|
14
|
+
slicetest init [--force]
|
|
15
|
+
slicetest gen [--spec <file>] [--out <dir>] [--uncovered] [--force]
|
|
14
16
|
|
|
15
17
|
Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
|
|
18
|
+
\`slicetest init\` looks at the project and writes a starting config and scenario.
|
|
19
|
+
\`slicetest gen\` writes scenario skeletons for the documented responses of the
|
|
20
|
+
app's OpenAPI spec; with --uncovered, only for those the last run didn't produce.
|
|
16
21
|
|
|
17
22
|
Options:
|
|
18
23
|
-c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
|
|
19
24
|
-w, --watch Re-run on changes
|
|
20
25
|
-t, --name <pattern> Only run scenarios whose name matches
|
|
26
|
+
--spec <file> gen: OpenAPI file (default: \`openapi\` from the config)
|
|
27
|
+
--out <dir> gen: where to write scenarios (default: scenarios)
|
|
28
|
+
--uncovered gen: only responses the last run didn't cover
|
|
29
|
+
--force init, gen: overwrite existing files
|
|
21
30
|
-h, --help Show this help
|
|
22
31
|
|
|
23
32
|
Config (paths are relative to the config file):
|
|
24
|
-
app:
|
|
25
|
-
db:
|
|
26
|
-
stubs:
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
|
|
34
|
+
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse }
|
|
35
|
+
stubs: [name | { name, openapi, autoReply, upstream, recordings }]
|
|
36
|
+
services: { name: { command, env, cwd, ready } }
|
|
37
|
+
containers: { name: { image, port, env, command, ready: { log }, reset } }
|
|
38
|
+
openapi: file | { spec, minCoverage }
|
|
39
|
+
http: { headers, query }
|
|
40
|
+
include: [globs] default ["**/*.scenario.{yaml,yml}"]
|
|
29
41
|
`;
|
|
30
42
|
export async function main(argv = process.argv.slice(2)) {
|
|
31
43
|
const { values, positionals } = parseArgs({
|
|
@@ -36,17 +48,43 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
36
48
|
watch: { type: "boolean", short: "w" },
|
|
37
49
|
name: { type: "string", short: "t" },
|
|
38
50
|
help: { type: "boolean", short: "h" },
|
|
51
|
+
force: { type: "boolean" },
|
|
52
|
+
spec: { type: "string" },
|
|
53
|
+
out: { type: "string" },
|
|
54
|
+
uncovered: { type: "boolean" },
|
|
39
55
|
},
|
|
40
56
|
});
|
|
41
57
|
if (values.help) {
|
|
42
58
|
process.stdout.write(HELP);
|
|
43
59
|
return;
|
|
44
60
|
}
|
|
61
|
+
if (positionals[0] === "init") {
|
|
62
|
+
const { init } = await import("./init.js");
|
|
63
|
+
const { files, notes } = await init(process.cwd(), { force: values.force });
|
|
64
|
+
process.stdout.write(`Created ${files.join(" and ")}.\n\nWhat was detected (check these in the config):\n${notes.map((n) => ` - ${n}`).join("\n")}\n\nNext: npx slicetest\n`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
45
67
|
const configPath = values.config
|
|
46
68
|
? path.resolve(values.config)
|
|
47
69
|
: CONFIG_NAMES.map((n) => path.resolve(n)).find((p) => existsSync(p));
|
|
70
|
+
if (positionals[0] === "gen") {
|
|
71
|
+
const configOpenapi = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}).openapi : undefined;
|
|
72
|
+
const spec = values.spec ?? (typeof configOpenapi === "object" ? configOpenapi.spec : configOpenapi);
|
|
73
|
+
if (!spec)
|
|
74
|
+
throw new Error("slicetest gen: no OpenAPI spec. Pass --spec openapi.yaml, or set `openapi` in the config.");
|
|
75
|
+
const root = values.spec || !configPath ? process.cwd() : path.dirname(configPath);
|
|
76
|
+
const { gen } = await import("./gen.js");
|
|
77
|
+
const { written, skipped, count } = await gen(root, { spec, out: values.out, uncovered: values.uncovered, force: values.force });
|
|
78
|
+
const lines = [
|
|
79
|
+
count === 0 ? "Every documented response is already covered; nothing to generate." : `${count} scenario(s) for the responses in ${spec}.`,
|
|
80
|
+
...written.map((f) => ` wrote ${f}`),
|
|
81
|
+
...skipped.map((f) => ` skipped ${f} (exists; --force to overwrite)`),
|
|
82
|
+
];
|
|
83
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
48
86
|
if (!configPath || !existsSync(configPath)) {
|
|
49
|
-
process.stderr.write(`slicetest: no config found.
|
|
87
|
+
process.stderr.write(`slicetest: no config found. Run "npx slicetest init" to create ${CONFIG_NAMES[0]} (see --help).\n`);
|
|
50
88
|
process.exitCode = 1;
|
|
51
89
|
return;
|
|
52
90
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -2,11 +2,85 @@ import type { RequestOptions } from "./http.js";
|
|
|
2
2
|
export interface SlicetestOptions {
|
|
3
3
|
app: AppOptions;
|
|
4
4
|
db?: DbOptions;
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Outbound HTTP services to stub. Each gets its own server, referenced as `{{stub.<name>}}` in `app.env`.
|
|
7
|
+
* With `{ name, openapi }`, the app's calls to it and the stub's replies are checked against that service's spec.
|
|
8
|
+
* With `autoReply: true` as well, calls no route matches are answered from the spec (its examples, or values
|
|
9
|
+
* built from its schemas) instead of failing, so you only register the routes a scenario cares about.
|
|
10
|
+
*/
|
|
11
|
+
stubs?: (string | StubOptions)[];
|
|
12
|
+
/**
|
|
13
|
+
* The app's own OpenAPI 3 spec (YAML or JSON, relative to the root). Every
|
|
14
|
+
* response the app gives during a scenario must be documented and match its
|
|
15
|
+
* schema, or the scenario fails. At the end of the run, slicetest prints which
|
|
16
|
+
* documented responses the scenarios produced; with `minCoverage` (percent),
|
|
17
|
+
* a lower coverage fails the run.
|
|
18
|
+
*/
|
|
19
|
+
openapi?: string | {
|
|
20
|
+
spec: string;
|
|
21
|
+
minCoverage?: number;
|
|
22
|
+
};
|
|
7
23
|
/** Defaults for every request made with `http`, e.g. `{ headers: { accept: "application/json" } }`. */
|
|
8
24
|
http?: RequestOptions;
|
|
25
|
+
/**
|
|
26
|
+
* Other processes the app needs: a queue worker, another microservice, a
|
|
27
|
+
* mock written in another language. They start before the app, in order, and
|
|
28
|
+
* are watched like the app: a crash fails the scenario and they're restarted.
|
|
29
|
+
* Their URL is `{{service.<name>}}` and their port `{{service.<name>.port}}`,
|
|
30
|
+
* usable in `app.env` and in the env of services declared after them.
|
|
31
|
+
*/
|
|
32
|
+
services?: Record<string, ServiceOptions>;
|
|
33
|
+
/**
|
|
34
|
+
* Containers the app depends on besides the database: Redis, Elasticsearch,
|
|
35
|
+
* MinIO, LocalStack. Each test file gets its own, reachable at
|
|
36
|
+
* `{{container.<name>}}` (`host:port`), `{{container.<name>.host}}` and
|
|
37
|
+
* `{{container.<name>.port}}`. `reset` runs inside it before every scenario.
|
|
38
|
+
*/
|
|
39
|
+
containers?: Record<string, ContainerOptions>;
|
|
9
40
|
}
|
|
41
|
+
export interface ContainerOptions {
|
|
42
|
+
image: string;
|
|
43
|
+
/** The port the service listens on inside the container. */
|
|
44
|
+
port: number;
|
|
45
|
+
env?: Record<string, string>;
|
|
46
|
+
command?: string[];
|
|
47
|
+
/** Wait for this log line instead of the port accepting connections. */
|
|
48
|
+
ready?: {
|
|
49
|
+
log: string;
|
|
50
|
+
};
|
|
51
|
+
/** Command run inside the container before each scenario, e.g. `["redis-cli", "FLUSHALL"]`. */
|
|
52
|
+
reset?: string[];
|
|
53
|
+
}
|
|
54
|
+
export interface StubOptions {
|
|
55
|
+
name: string;
|
|
56
|
+
/** The provider's OpenAPI spec: the app's calls and the stub's replies are checked against it. */
|
|
57
|
+
openapi?: string;
|
|
58
|
+
/** Answer calls no route matches from the spec's examples or schemas. Needs `openapi`. */
|
|
59
|
+
autoReply?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The real service's base URL, e.g. `https://api.github.com`. Calls no route
|
|
62
|
+
* matches are answered from `recordings`; run with `SLICETEST_RECORD=<name>`
|
|
63
|
+
* (or `=1` for every stub) to forward the ones without a recording to this
|
|
64
|
+
* URL and record the answers.
|
|
65
|
+
*/
|
|
66
|
+
upstream?: string;
|
|
67
|
+
/** Recordings file, relative to the root. Default `recordings/<name>.yaml`. */
|
|
68
|
+
recordings?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface ServiceOptions extends Omit<AppOptions, "ready"> {
|
|
71
|
+
/** Default: no wait (for workers that don't listen). `{ path }` polls the service's own port. */
|
|
72
|
+
ready?: AppOptions["ready"];
|
|
73
|
+
}
|
|
74
|
+
type ResolvedReady = {
|
|
75
|
+
path: string;
|
|
76
|
+
} | {
|
|
77
|
+
log: string;
|
|
78
|
+
flags: string;
|
|
79
|
+
};
|
|
80
|
+
/** A process to start, with `ready` made JSON-serializable. The app always has `ready`; services may not. */
|
|
81
|
+
export type ResolvedProcess = Omit<AppOptions, "ready"> & {
|
|
82
|
+
ready?: ResolvedReady;
|
|
83
|
+
};
|
|
10
84
|
export interface AppOptions {
|
|
11
85
|
/** Command that starts the app, run through the shell. */
|
|
12
86
|
command: string;
|
|
@@ -27,10 +101,15 @@ export interface AppOptions {
|
|
|
27
101
|
readyTimeout?: number;
|
|
28
102
|
}
|
|
29
103
|
export interface DbOptions {
|
|
30
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* `postgres` (default) or `mysql`. Inferred from `url` when it starts with `mysql://`.
|
|
106
|
+
* MySQL needs the `mysql2` package, and `@testcontainers/mysql` unless `url` is given.
|
|
107
|
+
*/
|
|
108
|
+
engine?: "postgres" | "mysql";
|
|
109
|
+
/** Image used when no `url` is given. Default `postgres:17-alpine`, or `mysql:8.4` for MySQL. */
|
|
31
110
|
image?: string;
|
|
32
111
|
/**
|
|
33
|
-
* Use an existing
|
|
112
|
+
* Use an existing server instead of starting a container. Must point at a superuser (root) connection.
|
|
34
113
|
* Defaults to the `SLICETEST_DATABASE_URL` environment variable, which is handy in CI.
|
|
35
114
|
*/
|
|
36
115
|
url?: string;
|
|
@@ -41,6 +120,13 @@ export interface DbOptions {
|
|
|
41
120
|
schemas?: string[];
|
|
42
121
|
/** Extra tables kept across resets, in addition to known migration bookkeeping tables. */
|
|
43
122
|
keep?: string[];
|
|
123
|
+
/**
|
|
124
|
+
* Keep the Postgres container running between runs and cache the migrated
|
|
125
|
+
* template by the contents of the migrations, so a run with unchanged
|
|
126
|
+
* migrations skips both container start-up and migrating.
|
|
127
|
+
* Default: on, except when `CI` is set or `url` is given.
|
|
128
|
+
*/
|
|
129
|
+
reuse?: boolean;
|
|
44
130
|
}
|
|
45
131
|
export type MigrateOptions = {
|
|
46
132
|
atlas: {
|
|
@@ -50,20 +136,37 @@ export type MigrateOptions = {
|
|
|
50
136
|
sql: string;
|
|
51
137
|
} | {
|
|
52
138
|
command: string;
|
|
139
|
+
/**
|
|
140
|
+
* Files or directories the command reads (e.g. `["prisma/migrations"]`).
|
|
141
|
+
* With `reuse`, the migrated template is cached until one of them changes;
|
|
142
|
+
* without `inputs`, the command runs on every run.
|
|
143
|
+
*/
|
|
144
|
+
inputs?: string[];
|
|
53
145
|
};
|
|
54
146
|
/** Normalized shape passed from the plugin to globalSetup and workers. Must stay JSON-serializable. */
|
|
55
147
|
export interface ResolvedOptions {
|
|
56
148
|
root: string;
|
|
57
149
|
app: Omit<AppOptions, "ready"> & {
|
|
58
|
-
ready:
|
|
59
|
-
path: string;
|
|
60
|
-
} | {
|
|
61
|
-
log: string;
|
|
62
|
-
flags: string;
|
|
63
|
-
};
|
|
150
|
+
ready: ResolvedReady;
|
|
64
151
|
};
|
|
65
|
-
|
|
152
|
+
services: Record<string, ResolvedProcess>;
|
|
153
|
+
containers: Record<string, ContainerOptions>;
|
|
154
|
+
db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">;
|
|
66
155
|
stubs: string[];
|
|
156
|
+
/** Spec files, resolved against the root: the app's, and per stub name. */
|
|
157
|
+
openapi: {
|
|
158
|
+
app?: string;
|
|
159
|
+
minCoverage?: number;
|
|
160
|
+
stubs: Record<string, string>;
|
|
161
|
+
autoReply: string[];
|
|
162
|
+
};
|
|
163
|
+
/** Stubs backed by recordings of a real service; `record` when SLICETEST_RECORD selects them. */
|
|
164
|
+
recordings: Record<string, {
|
|
165
|
+
file: string;
|
|
166
|
+
upstream: string;
|
|
167
|
+
record: boolean;
|
|
168
|
+
}>;
|
|
67
169
|
http?: RequestOptions;
|
|
68
170
|
}
|
|
69
171
|
export declare function resolveOptions(opts: SlicetestOptions, root: string): ResolvedOptions;
|
|
172
|
+
export {};
|
package/dist/config.js
CHANGED
|
@@ -1,27 +1,55 @@
|
|
|
1
1
|
export function resolveOptions(opts, root) {
|
|
2
2
|
validate(opts);
|
|
3
|
-
const ready = opts.app.ready ?? { path: "/" };
|
|
4
3
|
return {
|
|
5
4
|
root,
|
|
6
|
-
app: {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
app: { ...opts.app, ready: resolveReady(opts.app.ready ?? { path: "/" }) },
|
|
6
|
+
services: Object.fromEntries(Object.entries(opts.services ?? {}).map(([name, s]) => [name, { ...s, ready: s.ready && resolveReady(s.ready) }])),
|
|
7
|
+
containers: opts.containers ?? {},
|
|
8
|
+
db: resolveDb(opts.db ?? {}),
|
|
9
|
+
stubs: (opts.stubs ?? []).map(stubName),
|
|
10
|
+
openapi: {
|
|
11
|
+
app: typeof opts.openapi === "object" ? opts.openapi.spec : opts.openapi,
|
|
12
|
+
minCoverage: typeof opts.openapi === "object" ? opts.openapi.minCoverage : undefined,
|
|
13
|
+
stubs: Object.fromEntries((opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.openapi ? [[s.name, s.openapi]] : []))),
|
|
14
|
+
autoReply: (opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.autoReply ? [s.name] : [])),
|
|
13
15
|
},
|
|
14
|
-
|
|
15
|
-
image: "postgres:17-alpine",
|
|
16
|
-
schemas: ["public"],
|
|
17
|
-
keep: [],
|
|
18
|
-
...opts.db,
|
|
19
|
-
url: opts.db?.url ?? (process.env.SLICETEST_DATABASE_URL || undefined),
|
|
20
|
-
},
|
|
21
|
-
stubs: opts.stubs ?? [],
|
|
16
|
+
recordings: resolveRecordings(opts.stubs ?? []),
|
|
22
17
|
http: opts.http,
|
|
23
18
|
};
|
|
24
19
|
}
|
|
20
|
+
function resolveRecordings(stubs) {
|
|
21
|
+
const withUpstream = stubs.filter((s) => typeof s === "object" && !!s.upstream);
|
|
22
|
+
const env = (process.env.SLICETEST_RECORD ?? "").trim();
|
|
23
|
+
const all = ["1", "true", "all", "*"].includes(env.toLowerCase());
|
|
24
|
+
const names = all || !env ? [] : env.split(",").map((n) => n.trim()).filter(Boolean);
|
|
25
|
+
for (const n of names) {
|
|
26
|
+
if (!withUpstream.some((s) => s.name === n)) {
|
|
27
|
+
throw new Error(`slicetest: SLICETEST_RECORD names "${n}", but no stub of that name has an upstream. Stubs with one: ${withUpstream.map((s) => s.name).join(", ") || "(none)"}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return Object.fromEntries(withUpstream.map((s) => [s.name, { file: s.recordings ?? `recordings/${s.name}.yaml`, upstream: s.upstream, record: all || names.includes(s.name) }]));
|
|
31
|
+
}
|
|
32
|
+
function resolveReady(ready) {
|
|
33
|
+
if (!("log" in ready))
|
|
34
|
+
return ready;
|
|
35
|
+
return typeof ready.log === "string" ? { log: escapeRegExp(ready.log), flags: "" } : { log: ready.log.source, flags: ready.log.flags };
|
|
36
|
+
}
|
|
37
|
+
function resolveDb(db) {
|
|
38
|
+
const isMysql = (u) => /^mysql:/i.test(u);
|
|
39
|
+
const env = process.env.SLICETEST_DATABASE_URL || undefined;
|
|
40
|
+
const engine = db.engine ?? (isMysql(db.url ?? env ?? "") ? "mysql" : "postgres");
|
|
41
|
+
// The environment variable names one server for the whole CI job; a project on the other engine starts its own.
|
|
42
|
+
const url = db.url ?? (env && isMysql(env) === (engine === "mysql") ? env : undefined);
|
|
43
|
+
return {
|
|
44
|
+
image: engine === "mysql" ? "mysql:8.4" : "postgres:17-alpine",
|
|
45
|
+
schemas: ["public"],
|
|
46
|
+
keep: [],
|
|
47
|
+
...db,
|
|
48
|
+
engine,
|
|
49
|
+
url,
|
|
50
|
+
reuse: db.reuse ?? (!url && !process.env.CI),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
25
53
|
function validate(opts) {
|
|
26
54
|
const fail = (msg) => {
|
|
27
55
|
throw new Error(`slicetest: invalid config: ${msg}`);
|
|
@@ -29,18 +57,62 @@ function validate(opts) {
|
|
|
29
57
|
if (!opts?.app || typeof opts.app.command !== "string" || !opts.app.command.trim()) {
|
|
30
58
|
fail("app.command is required, e.g. { app: { command: \"node server.js\" } }");
|
|
31
59
|
}
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
60
|
+
const checkReady = (ready, where) => {
|
|
61
|
+
if (ready !== undefined && !("path" in ready) && !("log" in ready))
|
|
62
|
+
fail(`${where}.ready must be { path } or { log }`);
|
|
63
|
+
if (ready && "path" in ready && !ready.path.startsWith("/"))
|
|
64
|
+
fail(`${where}.ready.path must start with "/", got "${ready.path}"`);
|
|
65
|
+
};
|
|
66
|
+
checkReady(opts.app.ready, "app");
|
|
67
|
+
for (const [name, s] of Object.entries(opts.services ?? {})) {
|
|
68
|
+
if (!/^[\w-]+$/.test(name))
|
|
69
|
+
fail(`service name "${name}" may only contain letters, digits, "_" and "-"`);
|
|
70
|
+
if (!s || typeof s.command !== "string" || !s.command.trim())
|
|
71
|
+
fail(`services.${name}.command is required`);
|
|
72
|
+
checkReady(s.ready, `services.${name}`);
|
|
73
|
+
}
|
|
74
|
+
for (const [name, c] of Object.entries(opts.containers ?? {})) {
|
|
75
|
+
if (!/^[\w-]+$/.test(name))
|
|
76
|
+
fail(`container name "${name}" may only contain letters, digits, "_" and "-"`);
|
|
77
|
+
if (!c || typeof c.image !== "string" || !c.image)
|
|
78
|
+
fail(`containers.${name}.image is required, e.g. "redis:7-alpine"`);
|
|
79
|
+
if (!Number.isInteger(c.port) || c.port <= 0)
|
|
80
|
+
fail(`containers.${name}.port must be the port the service listens on inside the container, e.g. 6379`);
|
|
81
|
+
for (const key of ["command", "reset"]) {
|
|
82
|
+
const v = c[key];
|
|
83
|
+
if (v !== undefined && !(Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === "string")))
|
|
84
|
+
fail(`containers.${name}.${key} must be a list of strings, e.g. ["redis-cli", "FLUSHALL"]`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const engine = opts.db?.engine;
|
|
88
|
+
if (engine !== undefined && engine !== "postgres" && engine !== "mysql")
|
|
89
|
+
fail(`db.engine must be "postgres" or "mysql", got ${JSON.stringify(engine)}`);
|
|
37
90
|
const migrate = opts.db?.migrate;
|
|
38
91
|
if (migrate) {
|
|
39
92
|
const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
|
|
40
93
|
if (keys.length !== 1)
|
|
41
94
|
fail(`db.migrate takes exactly one of atlas / sql / command, got ${keys.join(", ") || "none"}`);
|
|
42
95
|
}
|
|
43
|
-
const
|
|
96
|
+
const oas = opts.openapi;
|
|
97
|
+
if (oas !== undefined) {
|
|
98
|
+
const spec = typeof oas === "object" && oas ? oas.spec : oas;
|
|
99
|
+
if (typeof spec !== "string" || !spec)
|
|
100
|
+
fail('openapi must be the path of an OpenAPI file, or { spec, minCoverage }');
|
|
101
|
+
const min = typeof oas === "object" ? oas.minCoverage : undefined;
|
|
102
|
+
if (min !== undefined && !(typeof min === "number" && min >= 0 && min <= 100))
|
|
103
|
+
fail("openapi.minCoverage must be a percentage between 0 and 100");
|
|
104
|
+
}
|
|
105
|
+
for (const s of opts.stubs ?? []) {
|
|
106
|
+
if (typeof s !== "string" && (!s || typeof s.name !== "string"))
|
|
107
|
+
fail(`each stub must be a name or { name, openapi }, got ${JSON.stringify(s)}`);
|
|
108
|
+
if (typeof s === "object" && s.autoReply && !s.openapi)
|
|
109
|
+
fail(`stub "${s.name}": autoReply needs an openapi spec to answer from`);
|
|
110
|
+
if (typeof s === "object" && s.upstream !== undefined && !/^https?:\/\/[^/]/.test(s.upstream))
|
|
111
|
+
fail(`stub "${s.name}": upstream must be an http(s) URL, got ${JSON.stringify(s.upstream)}`);
|
|
112
|
+
if (typeof s === "object" && s.recordings !== undefined && !s.upstream)
|
|
113
|
+
fail(`stub "${s.name}": recordings needs an upstream to record from`);
|
|
114
|
+
}
|
|
115
|
+
const stubs = (opts.stubs ?? []).map(stubName);
|
|
44
116
|
for (const name of stubs) {
|
|
45
117
|
if (!/^[\w-]+$/.test(name))
|
|
46
118
|
fail(`stub name "${name}" may only contain letters, digits, "_" and "-"`);
|
|
@@ -49,6 +121,9 @@ function validate(opts) {
|
|
|
49
121
|
if (dup)
|
|
50
122
|
fail(`stub "${dup}" is declared twice`);
|
|
51
123
|
}
|
|
124
|
+
function stubName(s) {
|
|
125
|
+
return typeof s === "string" ? s : s.name;
|
|
126
|
+
}
|
|
52
127
|
function escapeRegExp(s) {
|
|
53
128
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
54
129
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ContainerOptions } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* A container the app depends on (Redis, a search engine, an S3 emulator),
|
|
4
|
+
* started for one test file and emptied between scenarios with `reset`.
|
|
5
|
+
*/
|
|
6
|
+
export declare class Dependency {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
private readonly container;
|
|
9
|
+
private readonly opts;
|
|
10
|
+
private constructor();
|
|
11
|
+
static start(name: string, opts: ContainerOptions): Promise<Dependency>;
|
|
12
|
+
get host(): string;
|
|
13
|
+
/** The host port mapped to the container's `port`. */
|
|
14
|
+
get port(): number;
|
|
15
|
+
/** `host:port`, to put after a scheme: `redis://{{container.cache}}`. */
|
|
16
|
+
get address(): string;
|
|
17
|
+
/** Run a command inside the container; throws with its output unless it exits with 0. */
|
|
18
|
+
exec(command: string[]): Promise<string>;
|
|
19
|
+
/** Start of a scenario: run the `reset` command, if any. */
|
|
20
|
+
reset(): Promise<void>;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { configureContainerRuntime } from "./container-runtime.js";
|
|
2
|
+
/**
|
|
3
|
+
* A container the app depends on (Redis, a search engine, an S3 emulator),
|
|
4
|
+
* started for one test file and emptied between scenarios with `reset`.
|
|
5
|
+
*/
|
|
6
|
+
export class Dependency {
|
|
7
|
+
name;
|
|
8
|
+
container;
|
|
9
|
+
opts;
|
|
10
|
+
constructor(name, container, opts) {
|
|
11
|
+
this.name = name;
|
|
12
|
+
this.container = container;
|
|
13
|
+
this.opts = opts;
|
|
14
|
+
}
|
|
15
|
+
static async start(name, opts) {
|
|
16
|
+
configureContainerRuntime();
|
|
17
|
+
const { GenericContainer, Wait } = await import("testcontainers");
|
|
18
|
+
let definition = new GenericContainer(opts.image).withExposedPorts(opts.port);
|
|
19
|
+
if (opts.env)
|
|
20
|
+
definition = definition.withEnvironment(opts.env);
|
|
21
|
+
if (opts.command)
|
|
22
|
+
definition = definition.withCommand(opts.command);
|
|
23
|
+
if (opts.ready)
|
|
24
|
+
definition = definition.withWaitStrategy(Wait.forLogMessage(opts.ready.log));
|
|
25
|
+
try {
|
|
26
|
+
return new Dependency(name, await definition.start(), opts);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
throw new Error(`slicetest: container "${name}" (${opts.image}) didn't start: ${e.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
get host() {
|
|
33
|
+
return this.container.getHost();
|
|
34
|
+
}
|
|
35
|
+
/** The host port mapped to the container's `port`. */
|
|
36
|
+
get port() {
|
|
37
|
+
return this.container.getMappedPort(this.opts.port);
|
|
38
|
+
}
|
|
39
|
+
/** `host:port`, to put after a scheme: `redis://{{container.cache}}`. */
|
|
40
|
+
get address() {
|
|
41
|
+
return `${this.host}:${this.port}`;
|
|
42
|
+
}
|
|
43
|
+
/** Run a command inside the container; throws with its output unless it exits with 0. */
|
|
44
|
+
async exec(command) {
|
|
45
|
+
const res = await this.container.exec(command);
|
|
46
|
+
if (res.exitCode !== 0) {
|
|
47
|
+
throw new Error(`slicetest: \`${command.join(" ")}\` in container "${this.name}" exited with ${res.exitCode}:\n${res.output.trim()}`);
|
|
48
|
+
}
|
|
49
|
+
return res.stdout;
|
|
50
|
+
}
|
|
51
|
+
/** Start of a scenario: run the `reset` command, if any. */
|
|
52
|
+
async reset() {
|
|
53
|
+
if (this.opts.reset)
|
|
54
|
+
await this.exec(this.opts.reset);
|
|
55
|
+
}
|
|
56
|
+
async stop() {
|
|
57
|
+
await this.container.stop();
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/db.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
[column: string]: unknown;
|
|
4
|
-
}
|
|
1
|
+
import type { Driver, Row } from "./drivers/driver.js";
|
|
2
|
+
export type { Row } from "./drivers/driver.js";
|
|
5
3
|
/**
|
|
6
4
|
* Column filters. `null` means IS NULL and an array means IN (...);
|
|
7
5
|
* every other value is compared with `=`.
|
|
@@ -12,13 +10,30 @@ export interface RowsOptions {
|
|
|
12
10
|
orderBy?: string | string[];
|
|
13
11
|
limit?: number;
|
|
14
12
|
}
|
|
13
|
+
/** Rows added, changed and removed in one table. */
|
|
14
|
+
export interface TableChanges<T extends Row = Row> {
|
|
15
|
+
inserted: T[];
|
|
16
|
+
/**
|
|
17
|
+
* Matched by primary key (`key`); `changed` lists the columns that differ.
|
|
18
|
+
* Tables without a primary key only report inserts and deletes.
|
|
19
|
+
*/
|
|
20
|
+
updated: {
|
|
21
|
+
key: Row;
|
|
22
|
+
before: T;
|
|
23
|
+
after: T;
|
|
24
|
+
changed: string[];
|
|
25
|
+
}[];
|
|
26
|
+
deleted: T[];
|
|
27
|
+
}
|
|
28
|
+
/** Changed tables only, keyed by table name (`schema.table` outside `public`). */
|
|
29
|
+
export type Changes = Record<string, TableChanges>;
|
|
15
30
|
/** Test-side handle to the database the app under test is using. */
|
|
16
31
|
export declare class Db {
|
|
17
32
|
#private;
|
|
18
33
|
readonly url: string;
|
|
19
34
|
private readonly opts;
|
|
20
35
|
private constructor();
|
|
21
|
-
static connect(url: string, opts: {
|
|
36
|
+
static connect(driver: Driver, url: string, opts: {
|
|
22
37
|
schemas: string[];
|
|
23
38
|
keep: string[];
|
|
24
39
|
seedFile?: string;
|
|
@@ -35,5 +50,22 @@ export declare class Db {
|
|
|
35
50
|
insert<T extends Row = Row>(table: string, rows: Row | Row[]): Promise<T[]>;
|
|
36
51
|
/** Empty every data table without dropping the app's connections, then re-apply the seed. */
|
|
37
52
|
reset(): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* What changed in the database since the scenario started (after the seed),
|
|
55
|
+
* or since the last `checkpoint()`: inserted, updated and deleted rows per table.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* await db.checkpoint(); // ignore the rows the test arranged
|
|
59
|
+
* await http.post("/polls", { ... });
|
|
60
|
+
* expect(await db.changes()).toEqual({ polls: { inserted: [expect.objectContaining({ title: "x" })], updated: [], deleted: [] } });
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
changes(): Promise<Changes>;
|
|
64
|
+
/** Make `changes()` report only what happens from now on. */
|
|
65
|
+
checkpoint(): Promise<void>;
|
|
66
|
+
/** Changes since the scenario started, regardless of checkpoints. Used for failure output. */
|
|
67
|
+
changesSinceStart(): Promise<Changes>;
|
|
38
68
|
close(): Promise<void>;
|
|
39
69
|
}
|
|
70
|
+
/** Short summary of `changes()` for failure output. */
|
|
71
|
+
export declare function formatChanges(changes: Changes, maxRows?: number): string;
|