slicetest 0.3.0 → 0.5.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 +211 -7
- package/dist/auth.d.ts +45 -0
- package/dist/auth.js +128 -0
- package/dist/ci.d.ts +29 -0
- package/dist/ci.js +57 -0
- package/dist/cli.js +31 -3
- package/dist/config.d.ts +25 -2
- package/dist/config.js +25 -4
- package/dist/db.d.ts +26 -0
- package/dist/db.js +50 -0
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +190 -0
- package/dist/drivers/driver.d.ts +29 -0
- package/dist/drivers/index.js +2 -0
- package/dist/drivers/mysql.d.ts +2 -1
- package/dist/drivers/mysql.js +38 -0
- package/dist/drivers/postgres.d.ts +2 -1
- package/dist/drivers/postgres.js +28 -0
- package/dist/drivers/sqlite.d.ts +24 -0
- package/dist/drivers/sqlite.js +255 -0
- package/dist/factory.d.ts +21 -0
- package/dist/factory.js +129 -0
- package/dist/gen.d.ts +1 -0
- package/dist/gen.js +9 -4
- package/dist/global-setup.js +24 -3
- package/dist/http.d.ts +13 -0
- package/dist/http.js +24 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/init.js +123 -5
- package/dist/mail.d.ts +58 -0
- package/dist/mail.js +299 -0
- package/dist/matchers.d.ts +4 -0
- package/dist/matchers.js +33 -0
- package/dist/openapi.d.ts +9 -0
- package/dist/openapi.js +29 -1
- package/dist/provided.d.ts +1 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -0
- package/dist/record-cli.d.ts +6 -0
- package/dist/record-cli.js +93 -0
- package/dist/record-session.d.ts +1 -0
- package/dist/record-session.js +10 -0
- package/dist/record.d.ts +46 -0
- package/dist/record.js +201 -0
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.js +62 -3
- package/dist/stub.d.ts +32 -0
- package/dist/stub.js +87 -0
- package/dist/trace.d.ts +9 -1
- package/dist/trace.js +3 -1
- package/dist/vitest.js +5 -2
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/yaml-runtime.d.ts +1 -0
- package/dist/yaml-runtime.js +92 -15
- package/dist/yaml.d.ts +59 -1
- package/dist/yaml.js +88 -5
- package/package.json +11 -4
- package/schema/scenario.schema.json +329 -1
package/dist/cli.js
CHANGED
|
@@ -13,11 +13,17 @@ const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetes
|
|
|
13
13
|
const HELP = `Usage: slicetest [filters...] [options]
|
|
14
14
|
slicetest init [--force]
|
|
15
15
|
slicetest gen [--spec <file>] [--out <dir>] [--uncovered] [--force]
|
|
16
|
+
slicetest doctor [--config <file>]
|
|
17
|
+
slicetest record [--out <file>] [--port <n>]
|
|
16
18
|
|
|
17
19
|
Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
|
|
18
20
|
\`slicetest init\` looks at the project and writes a starting config and scenario.
|
|
19
21
|
\`slicetest gen\` writes scenario skeletons for the documented responses of the
|
|
20
22
|
app's OpenAPI spec; with --uncovered, only for those the last run didn't produce.
|
|
23
|
+
\`slicetest doctor\` checks the config, the container runtime or database server,
|
|
24
|
+
migrations, commands and spec files, and says what to fix.
|
|
25
|
+
\`slicetest record\` starts everything and a proxy in front of the app: use the app
|
|
26
|
+
through it (a browser, curl), press Enter, and get the session as a YAML scenario.
|
|
21
27
|
|
|
22
28
|
Options:
|
|
23
29
|
-c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
|
|
@@ -25,16 +31,20 @@ Options:
|
|
|
25
31
|
-t, --name <pattern> Only run scenarios whose name matches
|
|
26
32
|
--spec <file> gen: OpenAPI file (default: \`openapi\` from the config)
|
|
27
33
|
--out <dir> gen: where to write scenarios (default: scenarios)
|
|
34
|
+
record: the scenario file (default: scenarios/recorded-<time>.scenario.yaml)
|
|
35
|
+
--port <n> record: the proxy's port (default: any free port)
|
|
28
36
|
--uncovered gen: only responses the last run didn't cover
|
|
29
37
|
--force init, gen: overwrite existing files
|
|
30
38
|
-h, --help Show this help
|
|
31
39
|
|
|
32
40
|
Config (paths are relative to the config file):
|
|
33
41
|
app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
|
|
34
|
-
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse }
|
|
42
|
+
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse, queries }
|
|
35
43
|
stubs: [name | { name, openapi, autoReply, upstream, recordings }]
|
|
36
44
|
services: { name: { command, env, cwd, ready } }
|
|
37
45
|
containers: { name: { image, port, env, command, ready: { log }, reset } }
|
|
46
|
+
mail: true SMTP server at {{mail.host}} / {{mail.port}}
|
|
47
|
+
auth: true | { audience, claims } OpenID issuer at {{auth.issuer}} / {{auth.jwks}}
|
|
38
48
|
openapi: file | { spec, minCoverage }
|
|
39
49
|
http: { headers, query }
|
|
40
50
|
include: [globs] default ["**/*.scenario.{yaml,yml}"]
|
|
@@ -52,6 +62,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
52
62
|
spec: { type: "string" },
|
|
53
63
|
out: { type: "string" },
|
|
54
64
|
uncovered: { type: "boolean" },
|
|
65
|
+
port: { type: "string" },
|
|
55
66
|
},
|
|
56
67
|
});
|
|
57
68
|
if (values.help) {
|
|
@@ -67,18 +78,30 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
67
78
|
const configPath = values.config
|
|
68
79
|
? path.resolve(values.config)
|
|
69
80
|
: CONFIG_NAMES.map((n) => path.resolve(n)).find((p) => existsSync(p));
|
|
81
|
+
if (positionals[0] === "doctor") {
|
|
82
|
+
const { doctor, formatChecks } = await import("./doctor.js");
|
|
83
|
+
const checks = await doctor(configPath);
|
|
84
|
+
process.stdout.write(formatChecks(checks));
|
|
85
|
+
if (checks.some((c) => c.status === "fail"))
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
70
89
|
if (positionals[0] === "gen") {
|
|
71
|
-
const
|
|
90
|
+
const config = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}) : undefined;
|
|
91
|
+
const configOpenapi = config?.openapi;
|
|
72
92
|
const spec = values.spec ?? (typeof configOpenapi === "object" ? configOpenapi.spec : configOpenapi);
|
|
73
93
|
if (!spec)
|
|
74
94
|
throw new Error("slicetest gen: no OpenAPI spec. Pass --spec openapi.yaml, or set `openapi` in the config.");
|
|
75
95
|
const root = values.spec || !configPath ? process.cwd() : path.dirname(configPath);
|
|
76
96
|
const { gen } = await import("./gen.js");
|
|
77
|
-
const { written, skipped, count } = await gen(root, { spec, out: values.out, uncovered: values.uncovered, force: values.force });
|
|
97
|
+
const { written, skipped, count, needsAuth } = await gen(root, { spec, out: values.out, uncovered: values.uncovered, force: values.force });
|
|
78
98
|
const lines = [
|
|
79
99
|
count === 0 ? "Every documented response is already covered; nothing to generate." : `${count} scenario(s) for the responses in ${spec}.`,
|
|
80
100
|
...written.map((f) => ` wrote ${f}`),
|
|
81
101
|
...skipped.map((f) => ` skipped ${f} (exists; --force to overwrite)`),
|
|
102
|
+
...(needsAuth && count > 0 && !config?.auth
|
|
103
|
+
? ["", "The spec requires bearer tokens: requests carry `auth:`. Add `auth: true` to the config and point the app's JWT settings at {{auth.issuer}} / {{auth.jwks}}."]
|
|
104
|
+
: []),
|
|
82
105
|
];
|
|
83
106
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
84
107
|
return;
|
|
@@ -89,6 +112,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
89
112
|
return;
|
|
90
113
|
}
|
|
91
114
|
const { include, ...options } = (parse(await readFile(configPath, "utf8")) ?? {});
|
|
115
|
+
if (positionals[0] === "record") {
|
|
116
|
+
const { record } = await import("./record-cli.js");
|
|
117
|
+
await record(configPath, options, { out: values.out, port: values.port ? Number(values.port) : 0 });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
92
120
|
const { startVitest } = await import("vitest/node");
|
|
93
121
|
const { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
|
|
94
122
|
const vitest = await startVitest(positionals, {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthOptions } from "./auth.js";
|
|
1
2
|
import type { RequestOptions } from "./http.js";
|
|
2
3
|
export interface SlicetestOptions {
|
|
3
4
|
app: AppOptions;
|
|
@@ -37,6 +38,18 @@ export interface SlicetestOptions {
|
|
|
37
38
|
* `{{container.<name>.port}}`. `reset` runs inside it before every scenario.
|
|
38
39
|
*/
|
|
39
40
|
containers?: Record<string, ContainerOptions>;
|
|
41
|
+
/**
|
|
42
|
+
* Catch the mail the app sends. An SMTP server (no TLS, any credentials)
|
|
43
|
+
* listens at `{{mail.host}}` / `{{mail.port}}` (`{{mail.url}}` is `smtp://host:port`);
|
|
44
|
+
* scenarios read what arrived with `mail.messages()` / `mail.waitFor()`.
|
|
45
|
+
*/
|
|
46
|
+
mail?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* An OpenID Connect issuer for apps that verify JWTs. The app gets
|
|
49
|
+
* `{{auth.issuer}}`, `{{auth.jwks}}` and `{{auth.audience}}`; scenarios mint
|
|
50
|
+
* tokens with `auth.token({ sub, roles })`. `true`, or `{ audience, claims }`.
|
|
51
|
+
*/
|
|
52
|
+
auth?: boolean | AuthOptions;
|
|
40
53
|
}
|
|
41
54
|
export interface ContainerOptions {
|
|
42
55
|
image: string;
|
|
@@ -102,10 +115,12 @@ export interface AppOptions {
|
|
|
102
115
|
}
|
|
103
116
|
export interface DbOptions {
|
|
104
117
|
/**
|
|
105
|
-
* `postgres` (default) or `
|
|
118
|
+
* `postgres` (default), `mysql` or `sqlite`. Inferred from `url` when it starts with `mysql://`.
|
|
106
119
|
* MySQL needs the `mysql2` package, and `@testcontainers/mysql` unless `url` is given.
|
|
120
|
+
* SQLite needs Node.js 22.5+ and nothing else: no server, no container. The app gets
|
|
121
|
+
* `{{db.url}}` as `sqlite:///path/to/file.db` and `{{db.path}}` as the file path.
|
|
107
122
|
*/
|
|
108
|
-
engine?: "postgres" | "mysql";
|
|
123
|
+
engine?: "postgres" | "mysql" | "sqlite";
|
|
109
124
|
/** Image used when no `url` is given. Default `postgres:17-alpine`, or `mysql:8.4` for MySQL. */
|
|
110
125
|
image?: string;
|
|
111
126
|
/**
|
|
@@ -127,6 +142,12 @@ export interface DbOptions {
|
|
|
127
142
|
* Default: on, except when `CI` is set or `url` is given.
|
|
128
143
|
*/
|
|
129
144
|
reuse?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Record the SQL the app runs: `{{db.url}}` points the app at a proxy that
|
|
147
|
+
* reads the wire protocol (Postgres and MySQL), so `db.queries()` lists every
|
|
148
|
+
* statement, whatever the app's language or driver. Off by default.
|
|
149
|
+
*/
|
|
150
|
+
queries?: boolean;
|
|
130
151
|
}
|
|
131
152
|
export type MigrateOptions = {
|
|
132
153
|
atlas: {
|
|
@@ -151,6 +172,8 @@ export interface ResolvedOptions {
|
|
|
151
172
|
};
|
|
152
173
|
services: Record<string, ResolvedProcess>;
|
|
153
174
|
containers: Record<string, ContainerOptions>;
|
|
175
|
+
mail: boolean;
|
|
176
|
+
auth: AuthOptions | false;
|
|
154
177
|
db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">;
|
|
155
178
|
stubs: string[];
|
|
156
179
|
/** Spec files, resolved against the root: the app's, and per stub name. */
|
package/dist/config.js
CHANGED
|
@@ -5,6 +5,8 @@ export function resolveOptions(opts, root) {
|
|
|
5
5
|
app: { ...opts.app, ready: resolveReady(opts.app.ready ?? { path: "/" }) },
|
|
6
6
|
services: Object.fromEntries(Object.entries(opts.services ?? {}).map(([name, s]) => [name, { ...s, ready: s.ready && resolveReady(s.ready) }])),
|
|
7
7
|
containers: opts.containers ?? {},
|
|
8
|
+
mail: opts.mail ?? false,
|
|
9
|
+
auth: opts.auth === true ? {} : (opts.auth ?? false),
|
|
8
10
|
db: resolveDb(opts.db ?? {}),
|
|
9
11
|
stubs: (opts.stubs ?? []).map(stubName),
|
|
10
12
|
openapi: {
|
|
@@ -39,9 +41,9 @@ function resolveDb(db) {
|
|
|
39
41
|
const env = process.env.SLICETEST_DATABASE_URL || undefined;
|
|
40
42
|
const engine = db.engine ?? (isMysql(db.url ?? env ?? "") ? "mysql" : "postgres");
|
|
41
43
|
// 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);
|
|
44
|
+
const url = engine === "sqlite" ? undefined : (db.url ?? (env && isMysql(env) === (engine === "mysql") ? env : undefined));
|
|
43
45
|
return {
|
|
44
|
-
image: engine === "mysql" ? "mysql:8.4" : "postgres:17-alpine",
|
|
46
|
+
image: engine === "mysql" ? "mysql:8.4" : engine === "sqlite" ? "" : "postgres:17-alpine",
|
|
45
47
|
schemas: ["public"],
|
|
46
48
|
keep: [],
|
|
47
49
|
...db,
|
|
@@ -84,9 +86,28 @@ function validate(opts) {
|
|
|
84
86
|
fail(`containers.${name}.${key} must be a list of strings, e.g. ["redis-cli", "FLUSHALL"]`);
|
|
85
87
|
}
|
|
86
88
|
}
|
|
89
|
+
if (opts.mail !== undefined && typeof opts.mail !== "boolean")
|
|
90
|
+
fail(`mail must be true or false, got ${JSON.stringify(opts.mail)}`);
|
|
91
|
+
if (opts.auth !== undefined && typeof opts.auth !== "boolean") {
|
|
92
|
+
if (!opts.auth || typeof opts.auth !== "object" || Array.isArray(opts.auth))
|
|
93
|
+
fail(`auth must be true or { audience, claims }, got ${JSON.stringify(opts.auth)}`);
|
|
94
|
+
for (const key of Object.keys(opts.auth))
|
|
95
|
+
if (key !== "audience" && key !== "claims")
|
|
96
|
+
fail(`unknown key auth.${key} (expected audience, claims)`);
|
|
97
|
+
if (opts.auth.audience !== undefined && typeof opts.auth.audience !== "string")
|
|
98
|
+
fail("auth.audience must be a string");
|
|
99
|
+
if (opts.auth.claims !== undefined && (!opts.auth.claims || typeof opts.auth.claims !== "object" || Array.isArray(opts.auth.claims)))
|
|
100
|
+
fail("auth.claims must be a mapping of claim names to values");
|
|
101
|
+
}
|
|
87
102
|
const engine = opts.db?.engine;
|
|
88
|
-
if (engine !== undefined && engine !== "postgres" && engine !== "mysql")
|
|
89
|
-
fail(`db.engine must be "postgres" or "
|
|
103
|
+
if (engine !== undefined && engine !== "postgres" && engine !== "mysql" && engine !== "sqlite")
|
|
104
|
+
fail(`db.engine must be "postgres", "mysql" or "sqlite", got ${JSON.stringify(engine)}`);
|
|
105
|
+
if (opts.db?.queries !== undefined && typeof opts.db.queries !== "boolean")
|
|
106
|
+
fail(`db.queries must be true or false, got ${JSON.stringify(opts.db.queries)}`);
|
|
107
|
+
if (engine === "sqlite" && opts.db?.queries)
|
|
108
|
+
fail("db.queries needs a database server (postgres or mysql): an SQLite app opens the file directly, so there is no connection to read");
|
|
109
|
+
if (engine === "sqlite" && opts.db?.url)
|
|
110
|
+
fail("db.url doesn't apply to sqlite: slicetest creates the database files itself and passes them to the app as {{db.url}} / {{db.path}}");
|
|
90
111
|
const migrate = opts.db?.migrate;
|
|
91
112
|
if (migrate) {
|
|
92
113
|
const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
|
package/dist/db.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Driver, Row } from "./drivers/driver.js";
|
|
2
|
+
import type { QueryList, QueryLog } from "./query-log.js";
|
|
2
3
|
export type { Row } from "./drivers/driver.js";
|
|
3
4
|
/**
|
|
4
5
|
* Column filters. `null` means IS NULL and an array means IN (...);
|
|
@@ -48,6 +49,31 @@ export declare class Db {
|
|
|
48
49
|
count(table: string, where?: Where): Promise<number>;
|
|
49
50
|
/** Insert rows and return them as stored (with defaults and generated ids). */
|
|
50
51
|
insert<T extends Row = Row>(table: string, rows: Row | Row[]): Promise<T[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Insert a row that satisfies the schema, giving only the columns the test cares about.
|
|
54
|
+
* Required columns get a value of their type (enums and `CHECK (... IN (...))` their first
|
|
55
|
+
* allowed value) and required foreign keys a parent row made the same way.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* const order = await db.make("orders", { status: "paid" }); // also creates the customer it needs
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
make<T extends Row = Row>(table: string, overrides?: Row): Promise<T>;
|
|
62
|
+
/** `count` rows made like `make()`; `overrides` may depend on the index. */
|
|
63
|
+
makeMany<T extends Row = Row>(table: string, count: number, overrides?: Row | ((i: number) => Row)): Promise<T[]>;
|
|
64
|
+
/** @internal Set by the runtime when `db.queries` is on. */
|
|
65
|
+
attachQueryLog(log: QueryLog): void;
|
|
66
|
+
/**
|
|
67
|
+
* SQL the app ran during this scenario, or only while `fn` ran. Needs `db: { queries: true }`.
|
|
68
|
+
* The test's own `db` calls aren't included.
|
|
69
|
+
*
|
|
70
|
+
* ```ts
|
|
71
|
+
* const queries = await db.queries(() => http.get("/posts"));
|
|
72
|
+
* expect(queries.repeated()).toEqual([]); // no statement shape ran 3+ times: no N+1
|
|
73
|
+
* expect(queries.length).toBeLessThanOrEqual(3);
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
queries(fn?: () => unknown): Promise<QueryList>;
|
|
51
77
|
/** Empty every data table without dropping the app's connections, then re-apply the seed. */
|
|
52
78
|
reset(): Promise<void>;
|
|
53
79
|
/**
|
package/dist/db.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { Factory } from "./factory.js";
|
|
2
3
|
/** Tables that record applied migrations. Truncating them would make tools re-run migrations. */
|
|
3
4
|
const MIGRATION_TABLES = [
|
|
4
5
|
"atlas_schema_revisions",
|
|
@@ -29,10 +30,13 @@ export class Db {
|
|
|
29
30
|
/** Contents right after the reset (and seed); undefined means every table was empty. */
|
|
30
31
|
#start;
|
|
31
32
|
#checkpoint;
|
|
33
|
+
#factory;
|
|
34
|
+
#queryLog;
|
|
32
35
|
constructor(driver, url, opts) {
|
|
33
36
|
this.url = url;
|
|
34
37
|
this.opts = opts;
|
|
35
38
|
this.#driver = driver;
|
|
39
|
+
this.#factory = new Factory((table) => driver.describe(table), (table, row) => driver.insert(table, row));
|
|
36
40
|
}
|
|
37
41
|
static async connect(driver, url, opts) {
|
|
38
42
|
const db = new Db(driver, url, opts);
|
|
@@ -80,10 +84,56 @@ export class Db {
|
|
|
80
84
|
out.push(...(await this.#driver.insert(table, row)));
|
|
81
85
|
return out;
|
|
82
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Insert a row that satisfies the schema, giving only the columns the test cares about.
|
|
89
|
+
* Required columns get a value of their type (enums and `CHECK (... IN (...))` their first
|
|
90
|
+
* allowed value) and required foreign keys a parent row made the same way.
|
|
91
|
+
*
|
|
92
|
+
* ```ts
|
|
93
|
+
* const order = await db.make("orders", { status: "paid" }); // also creates the customer it needs
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
async make(table, overrides = {}) {
|
|
97
|
+
return (await this.#factory.make(table, overrides));
|
|
98
|
+
}
|
|
99
|
+
/** `count` rows made like `make()`; `overrides` may depend on the index. */
|
|
100
|
+
async makeMany(table, count, overrides = {}) {
|
|
101
|
+
const out = [];
|
|
102
|
+
for (let i = 0; i < count; i++)
|
|
103
|
+
out.push(await this.make(table, typeof overrides === "function" ? overrides(i) : overrides));
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/** @internal Set by the runtime when `db.queries` is on. */
|
|
107
|
+
attachQueryLog(log) {
|
|
108
|
+
this.#queryLog = log;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* SQL the app ran during this scenario, or only while `fn` ran. Needs `db: { queries: true }`.
|
|
112
|
+
* The test's own `db` calls aren't included.
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* const queries = await db.queries(() => http.get("/posts"));
|
|
116
|
+
* expect(queries.repeated()).toEqual([]); // no statement shape ran 3+ times: no N+1
|
|
117
|
+
* expect(queries.length).toBeLessThanOrEqual(3);
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
async queries(fn) {
|
|
121
|
+
const log = this.#queryLog;
|
|
122
|
+
if (!log)
|
|
123
|
+
throw new Error("slicetest: db.queries() needs `db: { queries: true }` in the config (Postgres or MySQL); the app then connects through a proxy that records its SQL.");
|
|
124
|
+
if (!fn)
|
|
125
|
+
return log.queries();
|
|
126
|
+
const mark = log.mark();
|
|
127
|
+
await fn();
|
|
128
|
+
// Let statements already on the wire arrive.
|
|
129
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
130
|
+
return log.queries(mark);
|
|
131
|
+
}
|
|
83
132
|
/** Empty every data table without dropping the app's connections, then re-apply the seed. */
|
|
84
133
|
async reset() {
|
|
85
134
|
this.#tables ??= await this.#listTables();
|
|
86
135
|
await this.#driver.truncate(this.#tables);
|
|
136
|
+
this.#factory.reset();
|
|
87
137
|
this.#start = undefined;
|
|
88
138
|
this.#checkpoint = undefined;
|
|
89
139
|
if (this.#seed) {
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ResolvedOptions } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* `slicetest doctor`: everything a run needs, checked up front, each problem
|
|
4
|
+
* with what to do about it. Meant for a first setup and for CI logs, where a
|
|
5
|
+
* container runtime that isn't there otherwise shows up as a timeout.
|
|
6
|
+
*/
|
|
7
|
+
export interface Check {
|
|
8
|
+
status: "ok" | "warn" | "fail";
|
|
9
|
+
label: string;
|
|
10
|
+
/** What was found, or what to do. */
|
|
11
|
+
detail?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Things that touch the machine, replaceable in tests. */
|
|
14
|
+
export interface Probes {
|
|
15
|
+
containerRuntime(): Promise<string>;
|
|
16
|
+
database(opts: ResolvedOptions, url: string): Promise<void>;
|
|
17
|
+
command(name: string, args: string[]): Promise<string>;
|
|
18
|
+
resolvePackage(name: string, from: string): boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare const machine: Probes;
|
|
21
|
+
export declare function doctor(configPath: string | undefined, probes?: Probes, env?: NodeJS.ProcessEnv): Promise<Check[]>;
|
|
22
|
+
export declare function formatChecks(checks: Check[]): string;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { parse } from "yaml";
|
|
8
|
+
import { resolveOptions } from "./config.js";
|
|
9
|
+
import { configureContainerRuntime } from "./container-runtime.js";
|
|
10
|
+
import { engineFor } from "./drivers/index.js";
|
|
11
|
+
import { OpenApiSpec } from "./openapi.js";
|
|
12
|
+
const exec = promisify(execFile);
|
|
13
|
+
export const machine = {
|
|
14
|
+
async containerRuntime() {
|
|
15
|
+
configureContainerRuntime();
|
|
16
|
+
const { getContainerRuntimeClient } = await import("testcontainers");
|
|
17
|
+
const client = await withTimeout(getContainerRuntimeClient(), 15_000, "no answer from the container runtime");
|
|
18
|
+
const { containerRuntime: rt } = client.info;
|
|
19
|
+
return `${rt.operatingSystem} ${rt.serverVersion} at ${rt.host}`;
|
|
20
|
+
},
|
|
21
|
+
async database(opts, url) {
|
|
22
|
+
const admin = await withTimeout((await engineFor(opts)).admin(url), 10_000, "connection timed out");
|
|
23
|
+
await admin.close();
|
|
24
|
+
},
|
|
25
|
+
async command(name, args) {
|
|
26
|
+
const { stdout, stderr } = await exec(name, args, { windowsHide: true, timeout: 10_000, shell: process.platform === "win32" });
|
|
27
|
+
return (stdout || stderr).trim().split(/\r?\n/)[0];
|
|
28
|
+
},
|
|
29
|
+
resolvePackage(name, from) {
|
|
30
|
+
try {
|
|
31
|
+
createRequire(path.join(from, "noop.js")).resolve(`${name}/package.json`);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
try {
|
|
36
|
+
createRequire(import.meta.url).resolve(name);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
export async function doctor(configPath, probes = machine, env = process.env) {
|
|
46
|
+
const checks = [];
|
|
47
|
+
const add = (status, label, detail) => checks.push({ status, label, ...(detail ? { detail } : {}) });
|
|
48
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
49
|
+
add(major >= 20 ? "ok" : "fail", `Node.js ${process.versions.node}`, major >= 20 ? undefined : "slicetest needs Node.js 20 or later");
|
|
50
|
+
if (!configPath || !existsSync(configPath)) {
|
|
51
|
+
add("warn", "no slicetest.config.yaml", "checked the machine only. `npx slicetest init` writes a config; with the Vitest plugin, pass the same options to --config as YAML to check them");
|
|
52
|
+
await checkContainerRuntime(add, probes);
|
|
53
|
+
return checks;
|
|
54
|
+
}
|
|
55
|
+
const root = path.dirname(configPath);
|
|
56
|
+
const rel = (p) => path.relative(process.cwd(), path.resolve(root, p)) || ".";
|
|
57
|
+
let opts;
|
|
58
|
+
try {
|
|
59
|
+
const raw = (parse(await readFile(configPath, "utf8")) ?? {});
|
|
60
|
+
delete raw.include;
|
|
61
|
+
opts = resolveOptions(raw, root);
|
|
62
|
+
add("ok", `config ${rel(configPath)}`);
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
add("fail", `config ${rel(configPath)}`, e.message.replace(/^slicetest: /, ""));
|
|
66
|
+
return checks;
|
|
67
|
+
}
|
|
68
|
+
// Database: a server that's there already, or a container runtime to start one in.
|
|
69
|
+
const url = opts.db.engine === "sqlite" ? undefined : (opts.db.url ?? env.SLICETEST_DATABASE_URL);
|
|
70
|
+
if (opts.db.engine === "sqlite") {
|
|
71
|
+
const [maj = 0, min = 0] = process.versions.node.split(".").map(Number);
|
|
72
|
+
if (maj > 22 || (maj === 22 && min >= 5))
|
|
73
|
+
add("ok", "sqlite (node:sqlite, no server needed)");
|
|
74
|
+
else
|
|
75
|
+
add("fail", "sqlite needs Node.js 22.5 or later", `this is ${process.versions.node}; slicetest uses the built-in node:sqlite`);
|
|
76
|
+
if (Object.keys(opts.containers).length)
|
|
77
|
+
await checkContainerRuntime(add, probes, `runs ${Object.values(opts.containers).map((c) => c.image).join(", ")}`);
|
|
78
|
+
}
|
|
79
|
+
else if (url) {
|
|
80
|
+
try {
|
|
81
|
+
await probes.database(opts, url);
|
|
82
|
+
add("ok", `${opts.db.engine} at ${redact(url)}`);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
add("fail", `${opts.db.engine} at ${redact(url)}`, `can't connect: ${e.message}. It must be a superuser (root) connection that can create databases`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
await checkContainerRuntime(add, probes, `runs ${[opts.db.image, ...Object.values(opts.containers).map((c) => c.image)].join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
if (opts.db.engine === "mysql") {
|
|
92
|
+
for (const pkg of ["mysql2", ...(url ? [] : ["@testcontainers/mysql"])]) {
|
|
93
|
+
if (probes.resolvePackage(pkg, root))
|
|
94
|
+
add("ok", `${pkg} installed`);
|
|
95
|
+
else
|
|
96
|
+
add("fail", `${pkg} not installed`, `db.engine "mysql" needs it: npm i -D ${pkg}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const m = opts.db.migrate;
|
|
100
|
+
if (!m)
|
|
101
|
+
add("warn", "db.migrate not set", "scenarios run against an empty database unless the app creates its own tables");
|
|
102
|
+
else if ("atlas" in m) {
|
|
103
|
+
await checkPath(add, "migrations", m.atlas.dir.replace(/^file:\/\//, ""), root, rel);
|
|
104
|
+
try {
|
|
105
|
+
add("ok", `atlas CLI (${await probes.command("atlas", ["version"])})`);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
add("fail", "atlas CLI not found", "db.migrate.atlas runs `atlas migrate apply`. Install it: https://atlasgo.io/getting-started");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if ("sql" in m)
|
|
112
|
+
await checkPath(add, "migrations", m.sql, root, rel);
|
|
113
|
+
else
|
|
114
|
+
for (const input of m.inputs ?? [])
|
|
115
|
+
await checkPath(add, "migration input", input, root, rel);
|
|
116
|
+
if (opts.db.seed)
|
|
117
|
+
await checkPath(add, "seed", opts.db.seed, root, rel);
|
|
118
|
+
for (const [label, p] of [["app", opts.app], ...Object.entries(opts.services).map(([n, s]) => [`service ${n}`, s])]) {
|
|
119
|
+
if (p.cwd)
|
|
120
|
+
await checkPath(add, `${label} cwd`, p.cwd, root, rel);
|
|
121
|
+
const program = firstWord(p.command);
|
|
122
|
+
if (program && !/^[.\\/]|\{\{|\$/.test(program)) {
|
|
123
|
+
if (await onPath(program, env))
|
|
124
|
+
add("ok", `${label}: ${program} found`);
|
|
125
|
+
else
|
|
126
|
+
add("warn", `${label}: ${program} not found on PATH`, `\`${p.command}\` may fail to start. Fine if a shell alias or a relative script provides it`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const specs = [...(opts.openapi.app ? [["app OpenAPI", opts.openapi.app]] : []), ...Object.entries(opts.openapi.stubs).map(([n, f]) => [`stub ${n} OpenAPI`, f])];
|
|
130
|
+
for (const [label, file] of specs) {
|
|
131
|
+
try {
|
|
132
|
+
const spec = await OpenApiSpec.load(path.resolve(root, file), file);
|
|
133
|
+
add("ok", `${label} ${rel(file)}`, `${spec.operations().length} operation(s)`);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
add("fail", `${label} ${rel(file)}`, e.message.replace(/^slicetest: /, ""));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const [name, r] of Object.entries(opts.recordings)) {
|
|
140
|
+
if (existsSync(r.file))
|
|
141
|
+
add("ok", `stub ${name}: recordings ${rel(r.file)}`);
|
|
142
|
+
else
|
|
143
|
+
add("warn", `stub ${name}: no recordings yet (${rel(r.file)})`, `run once with SLICETEST_RECORD=${name} to record ${r.upstream}`);
|
|
144
|
+
}
|
|
145
|
+
return checks;
|
|
146
|
+
}
|
|
147
|
+
async function checkContainerRuntime(add, probes, forWhat) {
|
|
148
|
+
try {
|
|
149
|
+
add("ok", `container runtime: ${await probes.containerRuntime()}`, forWhat);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
add("fail", "no container runtime", `${e.message.split("\n")[0]}. Start Docker or a Podman machine (\`podman machine start\`), or set SLICETEST_DATABASE_URL / db.url to an existing database server`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function checkPath(add, label, p, root, rel) {
|
|
156
|
+
try {
|
|
157
|
+
await stat(path.resolve(root, p));
|
|
158
|
+
add("ok", `${label} ${rel(p)}`);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
add("fail", `${label} ${rel(p)} not found`, `paths in the config are relative to the config file's directory (${rel(".")})`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function firstWord(command) {
|
|
165
|
+
return /^\s*(?:"([^"]+)"|(\S+))/.exec(command)?.slice(1).find(Boolean);
|
|
166
|
+
}
|
|
167
|
+
async function onPath(program, env) {
|
|
168
|
+
const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
169
|
+
for (const dir of (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean)) {
|
|
170
|
+
for (const ext of exts) {
|
|
171
|
+
if (existsSync(path.join(dir, program + ext)) || existsSync(path.join(dir, program)))
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
function redact(url) {
|
|
178
|
+
return url.replace(/\/\/([^:/@]+):[^@]*@/, "//$1:***@");
|
|
179
|
+
}
|
|
180
|
+
function withTimeout(p, ms, message) {
|
|
181
|
+
return Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms).unref())]);
|
|
182
|
+
}
|
|
183
|
+
export function formatChecks(checks) {
|
|
184
|
+
const icon = { ok: "✓", warn: "!", fail: "✗" };
|
|
185
|
+
const lines = checks.map((c) => ` ${icon[c.status]} ${c.label}${c.detail ? `\n ${c.detail}` : ""}`);
|
|
186
|
+
const fails = checks.filter((c) => c.status === "fail").length;
|
|
187
|
+
const warns = checks.filter((c) => c.status === "warn").length;
|
|
188
|
+
const summary = fails ? `${fails} problem(s) to fix before running.` : warns ? `Ready to run, with ${warns} warning(s).` : "Ready to run.";
|
|
189
|
+
return `${lines.join("\n")}\n\n${summary}\n`;
|
|
190
|
+
}
|
package/dist/drivers/driver.d.ts
CHANGED
|
@@ -13,6 +13,31 @@ export interface Table {
|
|
|
13
13
|
/** Primary-key columns, in order; empty when there is none. */
|
|
14
14
|
key: string[];
|
|
15
15
|
}
|
|
16
|
+
export interface Column {
|
|
17
|
+
name: string;
|
|
18
|
+
/** The declared type as the engine reports it, e.g. `character varying(20)`, `int`, `TEXT`. */
|
|
19
|
+
type: string;
|
|
20
|
+
nullable: boolean;
|
|
21
|
+
/** Has a default, is generated, an identity or auto-increment column: inserts may leave it out. */
|
|
22
|
+
hasDefault: boolean;
|
|
23
|
+
maxLength?: number;
|
|
24
|
+
/** Labels of an enum type. */
|
|
25
|
+
values?: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface ForeignKey {
|
|
28
|
+
columns: string[];
|
|
29
|
+
/** Referenced table, named like `Table.name`. */
|
|
30
|
+
table: string;
|
|
31
|
+
/** Referenced columns; empty means the referenced table's primary key (SQLite). */
|
|
32
|
+
references: string[];
|
|
33
|
+
}
|
|
34
|
+
/** What `db.make()` needs to build a valid row. */
|
|
35
|
+
export interface TableShape {
|
|
36
|
+
columns: Column[];
|
|
37
|
+
foreignKeys: ForeignKey[];
|
|
38
|
+
/** Bodies of CHECK constraints, as the engine prints them. */
|
|
39
|
+
checks: string[];
|
|
40
|
+
}
|
|
16
41
|
export interface Driver {
|
|
17
42
|
query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
18
43
|
/** Several parameterless SELECTs in one round trip. */
|
|
@@ -33,6 +58,8 @@ export interface Driver {
|
|
|
33
58
|
truncate(tables: Table[]): Promise<void>;
|
|
34
59
|
/** Insert one row and return it as stored (defaults and generated ids filled in). */
|
|
35
60
|
insert(table: string, row: Row): Promise<Row[]>;
|
|
61
|
+
/** Columns, foreign keys and checks of `table`. Throws when there is no such table. */
|
|
62
|
+
describe(table: string): Promise<TableShape>;
|
|
36
63
|
close(): Promise<void>;
|
|
37
64
|
}
|
|
38
65
|
/** Server-level operations: the databases slicetest creates for templates and workers. */
|
|
@@ -54,6 +81,8 @@ export interface Engine {
|
|
|
54
81
|
name: string;
|
|
55
82
|
/** Container image used when no `db.url` is given. */
|
|
56
83
|
defaultImage: string;
|
|
84
|
+
/** Runs in-process (SQLite): no container runtime needed. */
|
|
85
|
+
local?: boolean;
|
|
57
86
|
startContainer(image: string, reuse: boolean): Promise<{
|
|
58
87
|
url: string;
|
|
59
88
|
stop(): Promise<unknown>;
|
package/dist/drivers/index.js
CHANGED
package/dist/drivers/mysql.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Driver, Engine, Row, Table } from "./driver.js";
|
|
1
|
+
import type { Driver, Engine, Row, Table, TableShape } from "./driver.js";
|
|
2
2
|
export declare class MysqlDriver implements Driver {
|
|
3
3
|
#private;
|
|
4
4
|
private readonly conn;
|
|
@@ -24,6 +24,7 @@ export declare class MysqlDriver implements Driver {
|
|
|
24
24
|
truncate(tables: Table[]): Promise<void>;
|
|
25
25
|
/** MySQL has no RETURNING: insert, then read the row back by its primary key. */
|
|
26
26
|
insert(table: string, row: Row): Promise<Row[]>;
|
|
27
|
+
describe(table: string): Promise<TableShape>;
|
|
27
28
|
close(): Promise<void>;
|
|
28
29
|
}
|
|
29
30
|
export declare const mysqlEngine: Engine;
|
package/dist/drivers/mysql.js
CHANGED
|
@@ -134,6 +134,44 @@ export class MysqlDriver {
|
|
|
134
134
|
const conds = Object.keys(where).map((k) => `${this.column(k)} = ?`);
|
|
135
135
|
return this.query(`SELECT * FROM ${this.ident(table)} WHERE ${conds.join(" AND ")}`, Object.values(where));
|
|
136
136
|
}
|
|
137
|
+
async describe(table) {
|
|
138
|
+
const [schema, name] = table.includes(".") ? table.split(".", 2) : [null, table];
|
|
139
|
+
const columns = await this.query(`SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS dflt, EXTRA AS extra,
|
|
140
|
+
CHARACTER_MAXIMUM_LENGTH AS max
|
|
141
|
+
FROM information_schema.COLUMNS
|
|
142
|
+
WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ?
|
|
143
|
+
ORDER BY ORDINAL_POSITION`, [schema, name]);
|
|
144
|
+
if (columns.length === 0)
|
|
145
|
+
throw new Error(`slicetest: there is no table "${table}"`);
|
|
146
|
+
const keys = await this.query(`SELECT CONSTRAINT_NAME AS id, COLUMN_NAME AS \`column\`, REFERENCED_TABLE_SCHEMA AS \`schema\`,
|
|
147
|
+
REFERENCED_TABLE_NAME AS \`table\`, REFERENCED_COLUMN_NAME AS ref, DATABASE() AS current
|
|
148
|
+
FROM information_schema.KEY_COLUMN_USAGE
|
|
149
|
+
WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL
|
|
150
|
+
ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION`, [schema, name]);
|
|
151
|
+
const foreignKeys = new Map();
|
|
152
|
+
for (const k of keys) {
|
|
153
|
+
const fk = foreignKeys.get(k.id) ?? { columns: [], table: k.schema === k.current ? k.table : `${k.schema}.${k.table}`, references: [] };
|
|
154
|
+
fk.columns.push(k.column);
|
|
155
|
+
fk.references.push(k.ref);
|
|
156
|
+
foreignKeys.set(k.id, fk);
|
|
157
|
+
}
|
|
158
|
+
const checks = await this.query(`SELECT c.CHECK_CLAUSE AS def
|
|
159
|
+
FROM information_schema.CHECK_CONSTRAINTS c
|
|
160
|
+
JOIN information_schema.TABLE_CONSTRAINTS t ON t.CONSTRAINT_SCHEMA = c.CONSTRAINT_SCHEMA AND t.CONSTRAINT_NAME = c.CONSTRAINT_NAME
|
|
161
|
+
WHERE t.TABLE_SCHEMA = COALESCE(?, DATABASE()) AND t.TABLE_NAME = ?`, [schema, name]);
|
|
162
|
+
return {
|
|
163
|
+
columns: columns.map((c) => ({
|
|
164
|
+
name: c.name,
|
|
165
|
+
type: c.type,
|
|
166
|
+
nullable: c.nullable === "YES",
|
|
167
|
+
hasDefault: c.dflt !== null || /auto_increment|generated/i.test(c.extra),
|
|
168
|
+
maxLength: c.max === null ? undefined : Number(c.max),
|
|
169
|
+
values: /^enum\(/i.test(c.type) ? [...c.type.matchAll(/'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'")) : undefined,
|
|
170
|
+
})),
|
|
171
|
+
foreignKeys: [...foreignKeys.values()],
|
|
172
|
+
checks: checks.map((c) => c.def),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
137
175
|
async #primaryKey(table) {
|
|
138
176
|
let key = this.#keys.get(table);
|
|
139
177
|
if (!key) {
|