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/global-setup.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { promisify } from "node:util";
|
|
6
|
-
import pg from "pg";
|
|
7
7
|
import { configureContainerRuntime } from "./container-runtime.js";
|
|
8
|
-
import {
|
|
8
|
+
import { engineFor } from "./drivers/index.js";
|
|
9
|
+
import { coverageCacheFile } from "./gen.js";
|
|
10
|
+
import { formatCoverage, OpenApiSpec } from "./openapi.js";
|
|
11
|
+
import { mergeRecordings } from "./recording.js";
|
|
9
12
|
import "./provided.js";
|
|
10
13
|
const exec = promisify(execFile);
|
|
11
|
-
/** Runs once per vitest run: start
|
|
14
|
+
/** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
|
|
12
15
|
export default async function setup(project) {
|
|
13
16
|
const opts = project.getProvidedContext().slicetestOptions;
|
|
17
|
+
const engine = await engineFor(opts);
|
|
14
18
|
let adminUrl;
|
|
15
19
|
let stopContainer;
|
|
16
20
|
if (opts.db.url) {
|
|
@@ -18,65 +22,190 @@ export default async function setup(project) {
|
|
|
18
22
|
}
|
|
19
23
|
else {
|
|
20
24
|
configureContainerRuntime();
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
const container = await engine.startContainer(opts.db.image, opts.db.reuse);
|
|
26
|
+
adminUrl = container.url;
|
|
27
|
+
if (!opts.db.reuse)
|
|
28
|
+
stopContainer = container.stop;
|
|
25
29
|
}
|
|
26
30
|
// Unique per run and project, so parallel runs and projects sharing one server never collide.
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
31
|
+
// The timestamp lets a later run recognise databases left behind by a run that was killed.
|
|
32
|
+
const prefix = `${RUN_PREFIX}${Date.now().toString(36)}_${randomBytes(3).toString("hex")}`;
|
|
33
|
+
let admin;
|
|
34
|
+
let template;
|
|
30
35
|
try {
|
|
31
|
-
await admin
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
admin = await engine.admin(adminUrl);
|
|
37
|
+
if (!stopContainer)
|
|
38
|
+
await dropStale(admin);
|
|
39
|
+
const key = opts.db.reuse ? await migrationKey(opts) : undefined;
|
|
40
|
+
template = key ? await cachedTemplate(admin, engine, opts, key) : await freshTemplate(admin, engine, opts, prefix);
|
|
34
41
|
}
|
|
35
42
|
catch (e) {
|
|
36
|
-
await admin
|
|
43
|
+
await admin?.close().catch(() => { });
|
|
37
44
|
await stopContainer?.();
|
|
38
45
|
throw e;
|
|
39
46
|
}
|
|
40
|
-
|
|
47
|
+
const server = admin;
|
|
48
|
+
// Each worker writes the documented responses it saw here; they are merged when the run ends.
|
|
49
|
+
const coverageDir = opts.openapi.app ? await mkdtemp(path.join(os.tmpdir(), "slicetest-coverage-")) : undefined;
|
|
50
|
+
// Likewise for recordings made against real services, merged into the recordings files at the end.
|
|
51
|
+
const recording = Object.values(opts.recordings).some((r) => r.record);
|
|
52
|
+
const recordDir = recording ? await mkdtemp(path.join(os.tmpdir(), "slicetest-recordings-")) : undefined;
|
|
53
|
+
project.provide("slicetestDb", { adminUrl, template, prefix, coverageDir, recordDir });
|
|
41
54
|
return async () => {
|
|
42
55
|
try {
|
|
43
56
|
if (!stopContainer) {
|
|
44
57
|
// Shared server: drop only the databases this run created.
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
await admin.query(`DROP DATABASE "${datname}" WITH (FORCE)`);
|
|
58
|
+
for (const name of await server.databases(`${prefix}_`))
|
|
59
|
+
await server.drop(name);
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
62
|
finally {
|
|
51
|
-
await
|
|
63
|
+
await server.close();
|
|
52
64
|
await stopContainer?.();
|
|
65
|
+
if (coverageDir)
|
|
66
|
+
await reportCoverage(opts, coverageDir);
|
|
67
|
+
if (recordDir)
|
|
68
|
+
await saveRecordings(opts, recordDir);
|
|
53
69
|
}
|
|
54
70
|
};
|
|
55
71
|
}
|
|
56
|
-
async function
|
|
72
|
+
async function reportCoverage(opts, dir) {
|
|
73
|
+
try {
|
|
74
|
+
const hits = new Set();
|
|
75
|
+
for (const file of await readdir(dir)) {
|
|
76
|
+
for (const key of JSON.parse(await readFile(path.join(dir, file), "utf8")))
|
|
77
|
+
hits.add(key);
|
|
78
|
+
}
|
|
79
|
+
// No scenario ran (e.g. everything filtered out): nothing to report.
|
|
80
|
+
if (hits.size === 0)
|
|
81
|
+
return;
|
|
82
|
+
const spec = await OpenApiSpec.load(path.resolve(opts.root, opts.openapi.app), opts.openapi.app);
|
|
83
|
+
const report = formatCoverage(spec, hits);
|
|
84
|
+
// For `slicetest gen --uncovered`.
|
|
85
|
+
const cache = coverageCacheFile(opts.root);
|
|
86
|
+
await mkdir(path.dirname(cache), { recursive: true }).then(() => writeFile(cache, JSON.stringify([...hits]))).catch(() => { });
|
|
87
|
+
console.log(`\n${report.text}\n`);
|
|
88
|
+
const min = opts.openapi.minCoverage;
|
|
89
|
+
if (min !== undefined && report.percent < min) {
|
|
90
|
+
// Not thrown: Vitest reports teardown errors as a crash. The failing exit code is what CI needs.
|
|
91
|
+
console.error(`slicetest: OpenAPI coverage ${report.percent}% is below openapi.minCoverage (${min}%)\n`);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await rm(dir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function saveRecordings(opts, dir) {
|
|
100
|
+
try {
|
|
101
|
+
const files = await readdir(dir);
|
|
102
|
+
for (const [name, r] of Object.entries(opts.recordings)) {
|
|
103
|
+
const added = [];
|
|
104
|
+
for (const f of files.filter((f) => f.startsWith(`${name}.`)).sort())
|
|
105
|
+
added.push(...JSON.parse(await readFile(path.join(dir, f), "utf8")));
|
|
106
|
+
if (added.length === 0)
|
|
107
|
+
continue;
|
|
108
|
+
await mergeRecordings(path.resolve(opts.root, r.file), r.upstream, added);
|
|
109
|
+
console.log(`slicetest: recorded ${added.length} call(s) to ${r.upstream} in ${r.file}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await rm(dir, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const RUN_PREFIX = "slicetest_r";
|
|
117
|
+
const TEMPLATE_PREFIX = "slicetest_tpl_";
|
|
118
|
+
const STALE_MS = 24 * 60 * 60 * 1000;
|
|
119
|
+
async function freshTemplate(admin, engine, opts, prefix) {
|
|
120
|
+
const template = `${prefix}_template`;
|
|
121
|
+
await admin.create(template);
|
|
122
|
+
await migrate(opts, engine, admin.urlFor(template));
|
|
123
|
+
return template;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The template for these migrations, built once and kept on the server. The
|
|
127
|
+
* lock makes concurrent runs wait for the first one instead of building their own.
|
|
128
|
+
*/
|
|
129
|
+
async function cachedTemplate(admin, engine, opts, key) {
|
|
130
|
+
const name = `${TEMPLATE_PREFIX}${key}`;
|
|
131
|
+
return admin.withLock(name, async () => {
|
|
132
|
+
if ((await admin.databases(name)).includes(name))
|
|
133
|
+
return name;
|
|
134
|
+
await admin.create(name);
|
|
135
|
+
try {
|
|
136
|
+
await migrate(opts, engine, admin.urlFor(name));
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
// Never leave a half-migrated template behind for later runs to reuse.
|
|
140
|
+
await admin.drop(name).catch(() => { });
|
|
141
|
+
throw e;
|
|
142
|
+
}
|
|
143
|
+
return name;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A hash of everything that determines the migrated schema, or undefined when
|
|
148
|
+
* that can't be known (a migration command without `inputs`).
|
|
149
|
+
*/
|
|
150
|
+
export async function migrationKey(opts) {
|
|
151
|
+
const m = opts.db.migrate;
|
|
152
|
+
const hash = createHash("sha256").update(`v1\0${opts.db.image}\0${JSON.stringify(m ?? null)}\0`);
|
|
153
|
+
const inputs = !m ? [] : "atlas" in m ? [atlasDirPath(m.atlas.dir, opts.root)] : "sql" in m ? [path.resolve(opts.root, m.sql)] : m.inputs?.map((p) => path.resolve(opts.root, p));
|
|
154
|
+
if (!inputs)
|
|
155
|
+
return undefined;
|
|
156
|
+
for (const input of inputs) {
|
|
157
|
+
for (const file of await filesUnder(input)) {
|
|
158
|
+
hash.update(`${path.relative(opts.root, file).replace(/\\/g, "/")}\0`);
|
|
159
|
+
hash.update(await readFile(file));
|
|
160
|
+
hash.update("\0");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return hash.digest("hex").slice(0, 20);
|
|
164
|
+
}
|
|
165
|
+
async function filesUnder(target) {
|
|
166
|
+
const info = await stat(target).catch(() => undefined);
|
|
167
|
+
if (!info)
|
|
168
|
+
throw new Error(`slicetest: migration input not found: ${target}`);
|
|
169
|
+
if (!info.isDirectory())
|
|
170
|
+
return [target];
|
|
171
|
+
const entries = await readdir(target, { recursive: true, withFileTypes: true });
|
|
172
|
+
return entries
|
|
173
|
+
.filter((e) => e.isFile())
|
|
174
|
+
.map((e) => path.join(e.parentPath, e.name))
|
|
175
|
+
.sort();
|
|
176
|
+
}
|
|
177
|
+
function atlasDirPath(dir, root) {
|
|
178
|
+
return path.resolve(root, dir.replace(/^file:\/\//, ""));
|
|
179
|
+
}
|
|
180
|
+
/** Databases from runs that were killed before cleaning up, recognised by the timestamp in their name. */
|
|
181
|
+
async function dropStale(admin) {
|
|
182
|
+
for (const datname of await admin.databases(RUN_PREFIX)) {
|
|
183
|
+
const started = parseInt(datname.slice(RUN_PREFIX.length).split("_")[0], 36);
|
|
184
|
+
if (Number.isFinite(started) && Date.now() - started > STALE_MS) {
|
|
185
|
+
await admin.drop(datname).catch(() => { });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function migrate(opts, engine, url) {
|
|
57
190
|
const m = opts.db.migrate;
|
|
58
191
|
if (!m)
|
|
59
192
|
return;
|
|
60
193
|
if ("atlas" in m) {
|
|
61
194
|
const dir = atlasDirUrl(m.atlas.dir, opts.root);
|
|
62
|
-
|
|
63
|
-
if (!u.searchParams.has("sslmode"))
|
|
64
|
-
u.searchParams.set("sslmode", "disable");
|
|
65
|
-
await run("atlas", ["migrate", "apply", "--url", u.toString(), "--dir", dir], opts.root);
|
|
195
|
+
await run("atlas", ["migrate", "apply", "--url", engine.atlasUrl(url), "--dir", dir], opts.root);
|
|
66
196
|
}
|
|
67
197
|
else if ("sql" in m) {
|
|
68
198
|
const target = path.resolve(opts.root, m.sql);
|
|
69
199
|
const files = (await stat(target)).isDirectory()
|
|
70
200
|
? (await readdir(target)).filter((f) => f.endsWith(".sql")).sort().map((f) => path.join(target, f))
|
|
71
201
|
: [target];
|
|
72
|
-
const
|
|
73
|
-
await client.connect();
|
|
202
|
+
const driver = await engine.driver(url);
|
|
74
203
|
try {
|
|
75
204
|
for (const file of files)
|
|
76
|
-
await
|
|
205
|
+
await driver.exec(await readFile(file, "utf8"));
|
|
77
206
|
}
|
|
78
207
|
finally {
|
|
79
|
-
await
|
|
208
|
+
await driver.close();
|
|
80
209
|
}
|
|
81
210
|
}
|
|
82
211
|
else {
|
package/dist/http.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface RequestOptions {
|
|
|
19
19
|
declare class Session {
|
|
20
20
|
cookies: Map<string, string>;
|
|
21
21
|
history: HttpResponse[];
|
|
22
|
+
listeners: ((res: HttpResponse) => void)[];
|
|
22
23
|
}
|
|
23
24
|
/** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
|
|
24
25
|
export declare class HttpClient {
|
|
@@ -38,6 +39,8 @@ export declare class HttpClient {
|
|
|
38
39
|
patch(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
|
|
39
40
|
/** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
|
|
40
41
|
request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<HttpResponse>;
|
|
42
|
+
/** Call `fn` with every response this client (or one derived with `with()`) receives. */
|
|
43
|
+
onResponse(fn: (res: HttpResponse) => void): void;
|
|
41
44
|
/** Cookies the app has set during this scenario. Mutations are sent with later requests. */
|
|
42
45
|
get cookies(): Map<string, string>;
|
|
43
46
|
clearCookies(): void;
|
package/dist/http.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
class Session {
|
|
3
3
|
cookies = new Map();
|
|
4
4
|
history = [];
|
|
5
|
+
listeners = [];
|
|
5
6
|
}
|
|
6
7
|
const HISTORY = 20;
|
|
7
8
|
/** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
|
|
@@ -96,8 +97,14 @@ export class HttpClient {
|
|
|
96
97
|
durationMs: Math.round(performance.now() - started),
|
|
97
98
|
};
|
|
98
99
|
this.#record(out);
|
|
100
|
+
for (const listener of this.#session.listeners)
|
|
101
|
+
listener(out);
|
|
99
102
|
return out;
|
|
100
103
|
}
|
|
104
|
+
/** Call `fn` with every response this client (or one derived with `with()`) receives. */
|
|
105
|
+
onResponse(fn) {
|
|
106
|
+
this.#session.listeners.push(fn);
|
|
107
|
+
}
|
|
101
108
|
#record(res) {
|
|
102
109
|
this.#session.history.push(res);
|
|
103
110
|
if (this.#session.history.length > HISTORY)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export { scenario } from "./scenario.js";
|
|
2
|
+
export { mask } from "./trace.js";
|
|
3
|
+
export type { Trace, MaskOptions } from "./trace.js";
|
|
2
4
|
export type { ScenarioContext } from "./runtime.js";
|
|
3
|
-
export type { Db, Row, Where, RowsOptions } from "./db.js";
|
|
5
|
+
export type { Db, Row, Where, RowsOptions, Changes, TableChanges } from "./db.js";
|
|
4
6
|
export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder } from "./stub.js";
|
|
5
7
|
export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
|
|
6
8
|
export type { App } from "./app.js";
|
|
7
|
-
export type {
|
|
9
|
+
export type { Dependency } from "./containers.js";
|
|
10
|
+
export type { SlicetestOptions, ContainerOptions } from "./config.js";
|
|
11
|
+
export type {} from "./matchers.js";
|
package/dist/index.js
CHANGED
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CliConfig } from "./cli.js";
|
|
2
|
+
/**
|
|
3
|
+
* `npx slicetest init`: look at a project and write a starting
|
|
4
|
+
* slicetest.config.yaml plus one scenario. Detection is best effort; every
|
|
5
|
+
* guess is listed so the user knows what to check.
|
|
6
|
+
*/
|
|
7
|
+
export interface Detected {
|
|
8
|
+
config: CliConfig;
|
|
9
|
+
/** One line per guess, e.g. "app: package.json has a start script". */
|
|
10
|
+
notes: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function detect(root: string): Promise<Detected>;
|
|
13
|
+
export declare function init(root: string, { force }?: {
|
|
14
|
+
force?: boolean | undefined;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
files: string[];
|
|
17
|
+
notes: string[];
|
|
18
|
+
}>;
|
|
19
|
+
/** `"6379:6379"`, `"127.0.0.1:5432:5432/tcp"`, `9000`, `{ target: 6379 }` → the port inside the container. */
|
|
20
|
+
export declare function containerPort(spec: unknown): number | undefined;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parse, stringify } from "yaml";
|
|
5
|
+
const TODO_COMMAND = "echo 'TODO: the command that starts your app' && exit 1";
|
|
6
|
+
export async function detect(root) {
|
|
7
|
+
const notes = [];
|
|
8
|
+
const has = (p) => existsSync(path.join(root, p));
|
|
9
|
+
const read = async (p) => (has(p) ? readFile(path.join(root, p), "utf8") : "");
|
|
10
|
+
const pkg = has("package.json") ? JSON.parse(await read("package.json")) : undefined;
|
|
11
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
12
|
+
const python = `${await read("requirements.txt")}\n${await read("pyproject.toml")}`.toLowerCase();
|
|
13
|
+
const gemfile = await read("Gemfile");
|
|
14
|
+
// --- app ---
|
|
15
|
+
let command = TODO_COMMAND;
|
|
16
|
+
const env = { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}" };
|
|
17
|
+
if (pkg?.scripts?.start) {
|
|
18
|
+
command = "npm start";
|
|
19
|
+
notes.push("app: `npm start` (package.json start script). It must listen on $PORT.");
|
|
20
|
+
}
|
|
21
|
+
else if (pkg?.scripts?.dev) {
|
|
22
|
+
command = "npm run dev";
|
|
23
|
+
notes.push("app: `npm run dev` (no start script). A production start command is usually faster to boot.");
|
|
24
|
+
}
|
|
25
|
+
else if (has("manage.py")) {
|
|
26
|
+
command = "python manage.py runserver 127.0.0.1:{{app.port}} --noreload";
|
|
27
|
+
notes.push("app: Django (manage.py)");
|
|
28
|
+
}
|
|
29
|
+
else if (python.includes("uvicorn") || python.includes("fastapi")) {
|
|
30
|
+
command = "uvicorn main:app --port {{app.port}}";
|
|
31
|
+
notes.push("app: FastAPI/uvicorn. Adjust `main:app` to your module.");
|
|
32
|
+
}
|
|
33
|
+
else if (python.includes("flask")) {
|
|
34
|
+
command = "flask run --port {{app.port}}";
|
|
35
|
+
notes.push("app: Flask");
|
|
36
|
+
}
|
|
37
|
+
else if (/\brails\b/.test(gemfile)) {
|
|
38
|
+
command = "bin/rails server -p {{app.port}}";
|
|
39
|
+
notes.push("app: Rails");
|
|
40
|
+
}
|
|
41
|
+
else if (has("go.mod")) {
|
|
42
|
+
command = "go run .";
|
|
43
|
+
notes.push("app: Go (go.mod). It must listen on $PORT.");
|
|
44
|
+
}
|
|
45
|
+
else if (has("Cargo.toml")) {
|
|
46
|
+
command = "cargo run";
|
|
47
|
+
notes.push("app: Rust (Cargo.toml). It must listen on $PORT.");
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
notes.push("app: couldn't tell how to start the app. Set app.command.");
|
|
51
|
+
}
|
|
52
|
+
// --- migrations ---
|
|
53
|
+
let migrate;
|
|
54
|
+
const migrationsSql = has("migrations") && (await readdir(path.join(root, "migrations"))).some((f) => f.endsWith(".sql"));
|
|
55
|
+
if (has("atlas.hcl") || has("migrations/atlas.sum")) {
|
|
56
|
+
migrate = { atlas: { dir: "file://migrations" } };
|
|
57
|
+
notes.push("db: Atlas migrations in migrations/");
|
|
58
|
+
}
|
|
59
|
+
else if (has("prisma/schema.prisma")) {
|
|
60
|
+
migrate = { command: "npx prisma migrate deploy", inputs: ["prisma/migrations"] };
|
|
61
|
+
notes.push("db: Prisma (prisma migrate deploy)");
|
|
62
|
+
}
|
|
63
|
+
else if (has("alembic.ini")) {
|
|
64
|
+
const location = /^\s*script_location\s*=\s*(\S+)/m.exec(await read("alembic.ini"))?.[1] ?? "alembic";
|
|
65
|
+
migrate = { command: "alembic upgrade head", inputs: [path.posix.join(location.replace("%(here)s/", ""), "versions")] };
|
|
66
|
+
notes.push("db: Alembic (alembic upgrade head)");
|
|
67
|
+
}
|
|
68
|
+
else if (has("manage.py")) {
|
|
69
|
+
migrate = { command: "python manage.py migrate" };
|
|
70
|
+
notes.push("db: Django migrations. Add `inputs` (your apps' migrations dirs) to cache them between runs.");
|
|
71
|
+
}
|
|
72
|
+
else if (/\brails\b/.test(gemfile) && has("db/migrate")) {
|
|
73
|
+
migrate = { command: "bin/rails db:migrate", inputs: ["db/migrate"] };
|
|
74
|
+
notes.push("db: Rails migrations");
|
|
75
|
+
}
|
|
76
|
+
else if (deps["drizzle-kit"]) {
|
|
77
|
+
migrate = { command: "npx drizzle-kit migrate", inputs: ["drizzle"] };
|
|
78
|
+
notes.push("db: Drizzle (drizzle-kit migrate). Check that `inputs` points at your migrations folder.");
|
|
79
|
+
}
|
|
80
|
+
else if (deps.knex) {
|
|
81
|
+
migrate = { command: "npx knex migrate:latest", inputs: ["migrations"] };
|
|
82
|
+
notes.push("db: Knex migrations");
|
|
83
|
+
}
|
|
84
|
+
else if (migrationsSql) {
|
|
85
|
+
migrate = { sql: "migrations" };
|
|
86
|
+
notes.push("db: plain SQL files in migrations/, applied in name order");
|
|
87
|
+
}
|
|
88
|
+
else if (has("schema.sql")) {
|
|
89
|
+
migrate = { sql: "schema.sql" };
|
|
90
|
+
notes.push("db: schema.sql");
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
notes.push("db: no migrations found; the database starts empty. Set db.migrate.");
|
|
94
|
+
}
|
|
95
|
+
// --- docker compose: the database image and other dependencies ---
|
|
96
|
+
const { db: composeDb, containers, appEnv } = await fromCompose(root, notes);
|
|
97
|
+
Object.assign(env, appEnv);
|
|
98
|
+
const mysqlDeps = !!deps.mysql2 || !!deps.mysql || /\b(pymysql|mysqlclient|aiomysql)\b/.test(python) || /\bgem ['"]mysql2['"]/.test(gemfile);
|
|
99
|
+
if (!composeDb.engine && mysqlDeps) {
|
|
100
|
+
composeDb.engine = "mysql";
|
|
101
|
+
notes.push("db: MySQL (a MySQL driver is a dependency). Install mysql2 and @testcontainers/mysql next to slicetest.");
|
|
102
|
+
}
|
|
103
|
+
// --- OpenAPI ---
|
|
104
|
+
const openapi = ["openapi.yaml", "openapi.yml", "openapi.json", "docs/openapi.yaml", "docs/openapi.yml", "docs/openapi.json"].find(has);
|
|
105
|
+
if (openapi)
|
|
106
|
+
notes.push(`openapi: ${openapi}. Every response will be checked against it.`);
|
|
107
|
+
const config = {
|
|
108
|
+
app: { command, env, ready: { path: "/" } },
|
|
109
|
+
...(migrate || Object.keys(composeDb).length ? { db: { ...composeDb, ...(migrate ? { migrate } : {}) } } : {}),
|
|
110
|
+
...(Object.keys(containers).length ? { containers } : {}),
|
|
111
|
+
stubs: [],
|
|
112
|
+
...(openapi ? { openapi } : {}),
|
|
113
|
+
};
|
|
114
|
+
return { config, notes };
|
|
115
|
+
}
|
|
116
|
+
const SCENARIO = `# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json
|
|
117
|
+
# A first scenario. Run it with: npx slicetest
|
|
118
|
+
scenarios:
|
|
119
|
+
- name: the app answers
|
|
120
|
+
steps:
|
|
121
|
+
- request: GET /
|
|
122
|
+
expect: { status: 200 }
|
|
123
|
+
`;
|
|
124
|
+
export async function init(root, { force = false } = {}) {
|
|
125
|
+
const configFile = path.join(root, "slicetest.config.yaml");
|
|
126
|
+
const scenarioFile = path.join(root, "scenarios", "smoke.scenario.yaml");
|
|
127
|
+
const existing = [configFile, scenarioFile].filter((f) => existsSync(f));
|
|
128
|
+
if (existing.length && !force) {
|
|
129
|
+
throw new Error(`slicetest: ${existing.map((f) => path.relative(root, f)).join(", ")} already exists. Use --force to overwrite.`);
|
|
130
|
+
}
|
|
131
|
+
const { config, notes } = await detect(root);
|
|
132
|
+
const header = [
|
|
133
|
+
"# slicetest config, generated by `npx slicetest init`. Paths are relative to this file.",
|
|
134
|
+
"# Everything the Vitest plugin accepts works here: https://github.com/revo1290/slicetest#configuration-reference",
|
|
135
|
+
"#",
|
|
136
|
+
...notes.map((n) => `# - ${n}`),
|
|
137
|
+
"",
|
|
138
|
+
].join("\n");
|
|
139
|
+
await writeFile(configFile, `${header}${stringify(config)}`);
|
|
140
|
+
await mkdir(path.dirname(scenarioFile), { recursive: true });
|
|
141
|
+
await writeFile(scenarioFile, SCENARIO);
|
|
142
|
+
return { files: [configFile, scenarioFile].map((f) => path.relative(root, f)), notes };
|
|
143
|
+
}
|
|
144
|
+
const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
|
|
145
|
+
/** Known images: the port they listen on, how to empty them, and the variable apps usually read. */
|
|
146
|
+
const KNOWN = [
|
|
147
|
+
{ match: /(^|\/)(redis|redis-stack|keydb)(:|$)/, port: 6379, reset: ["redis-cli", "FLUSHALL"], env: (n) => ["REDIS_URL", `redis://{{container.${n}}}`] },
|
|
148
|
+
{ match: /(^|\/)valkey(:|$)/, port: 6379, reset: ["valkey-cli", "FLUSHALL"], env: (n) => ["REDIS_URL", `redis://{{container.${n}}}`] },
|
|
149
|
+
{ match: /(^|\/)memcached(:|$)/, port: 11211 },
|
|
150
|
+
{
|
|
151
|
+
match: /(^|\/)mongo(:|$)/,
|
|
152
|
+
port: 27017,
|
|
153
|
+
reset: ["mongosh", "--quiet", "--eval", "db.getMongo().getDBNames().filter((n) => !['admin', 'config', 'local'].includes(n)).forEach((n) => db.getSiblingDB(n).dropDatabase())"],
|
|
154
|
+
env: (n) => ["MONGODB_URL", `mongodb://{{container.${n}}}`],
|
|
155
|
+
},
|
|
156
|
+
{ match: /(^|\/)(elasticsearch|opensearch)(:|$)/, port: 9200, env: (n) => ["ELASTICSEARCH_URL", `http://{{container.${n}}}`] },
|
|
157
|
+
{ match: /(^|\/)minio(:|$)/, port: 9000, env: (n) => ["S3_ENDPOINT", `http://{{container.${n}}}`] },
|
|
158
|
+
{ match: /(^|\/)rabbitmq(:|$)/, port: 5672, env: (n) => ["AMQP_URL", `amqp://guest:guest@{{container.${n}}}`] },
|
|
159
|
+
];
|
|
160
|
+
/**
|
|
161
|
+
* Reads docker compose: a postgres / mysql service sets the database engine
|
|
162
|
+
* and image; Redis, Mongo, MinIO and other images become `containers`.
|
|
163
|
+
*/
|
|
164
|
+
async function fromCompose(root, notes) {
|
|
165
|
+
const db = {};
|
|
166
|
+
const containers = {};
|
|
167
|
+
const appEnv = {};
|
|
168
|
+
const file = COMPOSE_FILES.find((f) => existsSync(path.join(root, f)));
|
|
169
|
+
if (!file)
|
|
170
|
+
return { db, containers, appEnv };
|
|
171
|
+
let doc;
|
|
172
|
+
try {
|
|
173
|
+
doc = (parse(await readFile(path.join(root, file), "utf8")) ?? {});
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
notes.push(`${file}: couldn't parse it (${e.message}); skipped`);
|
|
177
|
+
return { db, containers, appEnv };
|
|
178
|
+
}
|
|
179
|
+
for (const [name, svc] of Object.entries(doc.services ?? {})) {
|
|
180
|
+
const image = typeof svc?.image === "string" ? svc.image : undefined;
|
|
181
|
+
if (!image) {
|
|
182
|
+
if (svc?.build)
|
|
183
|
+
notes.push(`${file}: service "${name}" is built from source; if it's the app, app.command replaces it`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (/(^|\/)(postgres|postgis)(:|$)/.test(image) || /(^|\/)postgis\//.test(image)) {
|
|
187
|
+
db.image = image;
|
|
188
|
+
notes.push(`db: Postgres image ${image} (${file} service "${name}")`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (/(^|\/)(mysql|mariadb)(:|$)/.test(image)) {
|
|
192
|
+
db.engine = "mysql";
|
|
193
|
+
db.image = image;
|
|
194
|
+
notes.push(`db: MySQL image ${image} (${file} service "${name}"). Install mysql2 and @testcontainers/mysql next to slicetest.`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const known = KNOWN.find((k) => k.match.test(image));
|
|
198
|
+
const port = known?.port ?? containerPort(svc.ports?.[0] ?? svc.expose?.[0]);
|
|
199
|
+
if (!port) {
|
|
200
|
+
notes.push(`${file}: service "${name}" (${image}) exposes no port; skipped`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const environment = envOf(svc.environment);
|
|
204
|
+
const command = Array.isArray(svc.command) ? svc.command.map(String) : typeof svc.command === "string" ? svc.command.split(/\s+/).filter(Boolean) : undefined;
|
|
205
|
+
containers[name] = {
|
|
206
|
+
image,
|
|
207
|
+
port,
|
|
208
|
+
...(Object.keys(environment).length ? { env: environment } : {}),
|
|
209
|
+
...(command?.length ? { command } : {}),
|
|
210
|
+
...(known?.reset ? { reset: known.reset } : {}),
|
|
211
|
+
};
|
|
212
|
+
const [key, value] = known?.env?.(name) ?? [];
|
|
213
|
+
if (key && value)
|
|
214
|
+
appEnv[key] = value;
|
|
215
|
+
notes.push(`containers.${name}: ${image} (${file})${key ? `, passed to the app as ${key}` : `, at {{container.${name}}}`}${known?.reset ? "" : ". Add `reset` to empty it between scenarios"}`);
|
|
216
|
+
}
|
|
217
|
+
return { db, containers, appEnv };
|
|
218
|
+
}
|
|
219
|
+
/** `"6379:6379"`, `"127.0.0.1:5432:5432/tcp"`, `9000`, `{ target: 6379 }` → the port inside the container. */
|
|
220
|
+
export function containerPort(spec) {
|
|
221
|
+
if (spec && typeof spec === "object" && "target" in spec)
|
|
222
|
+
return Number(spec.target) || undefined;
|
|
223
|
+
if (typeof spec !== "string" && typeof spec !== "number")
|
|
224
|
+
return undefined;
|
|
225
|
+
const last = String(spec).split("/")[0].split(":").at(-1);
|
|
226
|
+
const n = Number(last.split("-")[0]);
|
|
227
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
228
|
+
}
|
|
229
|
+
function envOf(environment) {
|
|
230
|
+
if (Array.isArray(environment)) {
|
|
231
|
+
return Object.fromEntries(environment.map(String).filter((e) => e.includes("=")).map((e) => [e.slice(0, e.indexOf("=")), e.slice(e.indexOf("=") + 1)]));
|
|
232
|
+
}
|
|
233
|
+
if (environment && typeof environment === "object")
|
|
234
|
+
return Object.fromEntries(Object.entries(environment).map(([k, v]) => [k, String(v ?? "")]));
|
|
235
|
+
return {};
|
|
236
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
interface Operation {
|
|
2
|
+
/** `paths` key, e.g. `/polls/{id}`. */
|
|
3
|
+
template: string;
|
|
4
|
+
method: string;
|
|
5
|
+
op: Record<string, any>;
|
|
6
|
+
}
|
|
7
|
+
export interface OperationSketch {
|
|
8
|
+
method: string;
|
|
9
|
+
template: string;
|
|
10
|
+
summary?: string;
|
|
11
|
+
pathParams: Record<string, unknown>;
|
|
12
|
+
query: Record<string, unknown>;
|
|
13
|
+
json?: unknown;
|
|
14
|
+
/** Documented response keys, e.g. `201`, `4XX`, `default`. */
|
|
15
|
+
responses: string[];
|
|
16
|
+
/** Top-level properties of the first 2xx JSON response. */
|
|
17
|
+
createdFields: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface Message {
|
|
20
|
+
status?: number;
|
|
21
|
+
contentType?: string;
|
|
22
|
+
/** Parsed JSON when the body is JSON, else the raw text. */
|
|
23
|
+
body: unknown;
|
|
24
|
+
query?: URLSearchParams;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
|
|
28
|
+
* the app's responses against the app's own spec, and the app's calls to a
|
|
29
|
+
* stubbed service (and the stub's canned replies) against that service's spec.
|
|
30
|
+
*/
|
|
31
|
+
export declare class OpenApiSpec {
|
|
32
|
+
#private;
|
|
33
|
+
readonly file: string;
|
|
34
|
+
private readonly doc;
|
|
35
|
+
private constructor();
|
|
36
|
+
/** `file` is read; `label` (default: `file`) is how messages refer to it. */
|
|
37
|
+
static load(file: string, label?: string): Promise<OpenApiSpec>;
|
|
38
|
+
find(method: string, path: string): Operation | undefined;
|
|
39
|
+
/** Problems with a response to `method path`; empty when it matches the spec. */
|
|
40
|
+
/** The documented response (`200`, `4XX`, `default`) that `status` falls under, for coverage. */
|
|
41
|
+
responseKey(method: string, path: string, status: number): string | undefined;
|
|
42
|
+
/** Every documented response as `METHOD /template key`, in document order. */
|
|
43
|
+
responseKeys(): string[];
|
|
44
|
+
checkResponse(method: string, path: string, res: Message): string[];
|
|
45
|
+
/**
|
|
46
|
+
* A response the real service could send to `method path`: the lowest
|
|
47
|
+
* documented 2xx, with its example if the spec has one, else a value built
|
|
48
|
+
* from its schema. Undefined when the operation isn't in the spec.
|
|
49
|
+
*/
|
|
50
|
+
exampleResponse(method: string, path: string): {
|
|
51
|
+
status: number;
|
|
52
|
+
headers?: Record<string, string>;
|
|
53
|
+
body?: unknown;
|
|
54
|
+
} | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Every operation with what it takes to call it: sample values for its
|
|
57
|
+
* required path and query parameters and for its JSON request body, and the
|
|
58
|
+
* properties of its first 2xx JSON response. Used by `slicetest gen`.
|
|
59
|
+
*/
|
|
60
|
+
operations(): OperationSketch[];
|
|
61
|
+
/** Problems with a request the app sent to `method path`. */
|
|
62
|
+
checkRequest(method: string, path: string, req: Message): string[];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Coverage report: which documented responses the scenarios produced.
|
|
66
|
+
* `hits` are `responseKey()` values collected from every worker.
|
|
67
|
+
*/
|
|
68
|
+
export declare function formatCoverage(spec: OpenApiSpec, hits: Set<string>): {
|
|
69
|
+
covered: number;
|
|
70
|
+
total: number;
|
|
71
|
+
percent: number;
|
|
72
|
+
text: string;
|
|
73
|
+
};
|
|
74
|
+
export {};
|