slicetest 0.1.0 → 0.2.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 +151 -11
- package/dist/app.d.ts +26 -3
- package/dist/app.js +80 -18
- package/dist/cli.js +18 -6
- package/dist/config.d.ts +68 -9
- package/dist/config.js +58 -22
- 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 +5 -0
- package/dist/drivers/postgres.d.ts +19 -0
- package/dist/drivers/postgres.js +164 -0
- package/dist/global-setup.d.ts +7 -1
- package/dist/global-setup.js +132 -30
- package/dist/http.d.ts +3 -0
- package/dist/http.js +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/init.d.ts +18 -0
- package/dist/init.js +134 -0
- package/dist/openapi.d.ts +56 -0
- package/dist/openapi.js +305 -0
- package/dist/provided.d.ts +1 -0
- package/dist/runtime.d.ts +7 -1
- package/dist/runtime.js +163 -28
- package/dist/scenario.js +2 -2
- package/dist/stub.d.ts +14 -0
- package/dist/stub.js +34 -9
- package/dist/yaml-runtime.js +61 -1
- package/dist/yaml.d.ts +30 -1
- package/dist/yaml.js +39 -4
- package/package.json +8 -4
- package/schema/scenario.schema.json +132 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
const INT8 = 20;
|
|
3
|
+
/** int8 columns (bigserial ids, count(*)) come back as numbers when they fit, instead of strings. */
|
|
4
|
+
const types = {
|
|
5
|
+
getTypeParser(oid, format) {
|
|
6
|
+
if (oid === INT8 && format !== "binary") {
|
|
7
|
+
return (v) => {
|
|
8
|
+
const n = Number(v);
|
|
9
|
+
return Number.isSafeInteger(n) ? n : v;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
return pg.types.getTypeParser(oid, format);
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
export class PostgresDriver {
|
|
16
|
+
client;
|
|
17
|
+
constructor(client) {
|
|
18
|
+
this.client = client;
|
|
19
|
+
}
|
|
20
|
+
static async connect(url) {
|
|
21
|
+
const client = new pg.Client({ connectionString: url, types });
|
|
22
|
+
// Without a listener, a dropped connection would crash the worker; the next query reports it instead.
|
|
23
|
+
client.on("error", () => { });
|
|
24
|
+
await client.connect();
|
|
25
|
+
return new PostgresDriver(client);
|
|
26
|
+
}
|
|
27
|
+
async query(sql, params = []) {
|
|
28
|
+
return (await this.client.query(sql, params)).rows;
|
|
29
|
+
}
|
|
30
|
+
async queryAll(sqls) {
|
|
31
|
+
if (sqls.length === 0)
|
|
32
|
+
return [];
|
|
33
|
+
// The simple query protocol runs several statements in one round trip.
|
|
34
|
+
const res = (await this.client.query(sqls.join(";\n")));
|
|
35
|
+
return (Array.isArray(res) ? res : [res]).map((r) => r.rows);
|
|
36
|
+
}
|
|
37
|
+
async exec(script) {
|
|
38
|
+
await this.client.query(script);
|
|
39
|
+
}
|
|
40
|
+
ident(name) {
|
|
41
|
+
return name
|
|
42
|
+
.split(".")
|
|
43
|
+
.map((part) => this.column(part))
|
|
44
|
+
.join(".");
|
|
45
|
+
}
|
|
46
|
+
column(name) {
|
|
47
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
48
|
+
}
|
|
49
|
+
param(n) {
|
|
50
|
+
return `$${n}`;
|
|
51
|
+
}
|
|
52
|
+
inList(column, values, params) {
|
|
53
|
+
params.push(values);
|
|
54
|
+
return `${this.ident(column)} = ANY($${params.length})`;
|
|
55
|
+
}
|
|
56
|
+
/** Ordinary and partitioned tables, skipping partitions and tables owned by extensions (e.g. PostGIS's `spatial_ref_sys`). */
|
|
57
|
+
async listTables(schemas, keep) {
|
|
58
|
+
const rows = await this.query(`SELECT n.nspname AS schema, c.relname AS name,
|
|
59
|
+
(SELECT array_agg(a.attname::text ORDER BY k.ord)
|
|
60
|
+
FROM pg_index i
|
|
61
|
+
CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
|
|
62
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum
|
|
63
|
+
WHERE i.indrelid = c.oid AND i.indisprimary) AS key
|
|
64
|
+
FROM pg_class c
|
|
65
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
66
|
+
WHERE c.relkind IN ('r', 'p')
|
|
67
|
+
AND NOT c.relispartition
|
|
68
|
+
AND n.nspname = ANY($1)
|
|
69
|
+
AND NOT (c.relname = ANY($2) OR n.nspname || '.' || c.relname = ANY($2))
|
|
70
|
+
AND NOT EXISTS (
|
|
71
|
+
SELECT 1 FROM pg_depend d
|
|
72
|
+
WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype = 'e')
|
|
73
|
+
ORDER BY 1, 2`, [schemas, keep]);
|
|
74
|
+
return rows.map((r) => ({
|
|
75
|
+
name: r.schema === "public" ? r.name : `${r.schema}.${r.name}`,
|
|
76
|
+
quoted: `${this.ident(r.schema)}.${this.ident(r.name)}`,
|
|
77
|
+
key: r.key ?? [],
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
async truncate(tables) {
|
|
81
|
+
if (tables.length > 0)
|
|
82
|
+
await this.client.query(`TRUNCATE ${tables.map((t) => t.quoted).join(", ")} RESTART IDENTITY CASCADE`);
|
|
83
|
+
}
|
|
84
|
+
async insert(table, row) {
|
|
85
|
+
const cols = Object.keys(row);
|
|
86
|
+
const sql = cols.length === 0
|
|
87
|
+
? `INSERT INTO ${this.ident(table)} DEFAULT VALUES RETURNING *`
|
|
88
|
+
: `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.ident(c)).join(", ")}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING *`;
|
|
89
|
+
return this.query(sql, Object.values(row));
|
|
90
|
+
}
|
|
91
|
+
async close() {
|
|
92
|
+
await this.client.end();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function withDatabase(url, database) {
|
|
96
|
+
const u = new URL(url);
|
|
97
|
+
u.pathname = `/${database}`;
|
|
98
|
+
return u.toString();
|
|
99
|
+
}
|
|
100
|
+
function quote(name) {
|
|
101
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
102
|
+
}
|
|
103
|
+
class PostgresAdmin {
|
|
104
|
+
client;
|
|
105
|
+
url;
|
|
106
|
+
constructor(client, url) {
|
|
107
|
+
this.client = client;
|
|
108
|
+
this.url = url;
|
|
109
|
+
}
|
|
110
|
+
async databases(prefix) {
|
|
111
|
+
const { rows } = await this.client.query("SELECT datname FROM pg_database WHERE starts_with(datname, $1)", [prefix]);
|
|
112
|
+
return rows.map((r) => r.datname);
|
|
113
|
+
}
|
|
114
|
+
async create(name) {
|
|
115
|
+
await this.client.query(`CREATE DATABASE ${quote(name)}`);
|
|
116
|
+
}
|
|
117
|
+
async clone(template, name) {
|
|
118
|
+
await this.client.query(`CREATE DATABASE ${quote(name)} TEMPLATE ${quote(template)}`);
|
|
119
|
+
}
|
|
120
|
+
async drop(name) {
|
|
121
|
+
await this.client.query(`DROP DATABASE IF EXISTS ${quote(name)} WITH (FORCE)`);
|
|
122
|
+
}
|
|
123
|
+
async withLock(key, fn) {
|
|
124
|
+
await this.client.query("SELECT pg_advisory_lock(hashtext($1))", [key]);
|
|
125
|
+
try {
|
|
126
|
+
return await fn();
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await this.client.query("SELECT pg_advisory_unlock(hashtext($1))", [key]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
urlFor(name) {
|
|
133
|
+
return withDatabase(this.url, name);
|
|
134
|
+
}
|
|
135
|
+
async close() {
|
|
136
|
+
await this.client.end();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export const postgres = {
|
|
140
|
+
name: "postgres",
|
|
141
|
+
defaultImage: "postgres:17-alpine",
|
|
142
|
+
async startContainer(image, reuse) {
|
|
143
|
+
const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
|
|
144
|
+
const definition = new PostgreSqlContainer(image);
|
|
145
|
+
// A reused container is left running and found again by its configuration on the next run.
|
|
146
|
+
if (reuse)
|
|
147
|
+
definition.withReuse();
|
|
148
|
+
const container = await definition.start();
|
|
149
|
+
return { url: container.getConnectionUri(), stop: () => container.stop() };
|
|
150
|
+
},
|
|
151
|
+
async admin(url) {
|
|
152
|
+
const client = new pg.Client({ connectionString: url });
|
|
153
|
+
client.on("error", () => { });
|
|
154
|
+
await client.connect();
|
|
155
|
+
return new PostgresAdmin(client, url);
|
|
156
|
+
},
|
|
157
|
+
driver: (url) => PostgresDriver.connect(url),
|
|
158
|
+
atlasUrl(url) {
|
|
159
|
+
const u = new URL(url);
|
|
160
|
+
if (!u.searchParams.has("sslmode"))
|
|
161
|
+
u.searchParams.set("sslmode", "disable");
|
|
162
|
+
return u.toString();
|
|
163
|
+
},
|
|
164
|
+
};
|
package/dist/global-setup.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { TestProject } from "vitest/node";
|
|
2
|
+
import type { ResolvedOptions } from "./config.js";
|
|
2
3
|
import "./provided.js";
|
|
3
|
-
/** Runs once per vitest run: start
|
|
4
|
+
/** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
|
|
4
5
|
export default function setup(project: TestProject): Promise<() => Promise<void>>;
|
|
6
|
+
/**
|
|
7
|
+
* A hash of everything that determines the migrated schema, or undefined when
|
|
8
|
+
* that can't be known (a migration command without `inputs`).
|
|
9
|
+
*/
|
|
10
|
+
export declare function migrationKey(opts: Pick<ResolvedOptions, "db" | "root">): Promise<string | undefined>;
|
|
5
11
|
/**
|
|
6
12
|
* Atlas wants `file://<path>` with forward slashes. Absolute paths (including
|
|
7
13
|
* Windows drive paths) are made relative to the root, which is the cwd Atlas runs in.
|
package/dist/global-setup.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
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 { mkdtemp, readdir, readFile, rm, stat } 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 { formatCoverage, OpenApiSpec } from "./openapi.js";
|
|
9
10
|
import "./provided.js";
|
|
10
11
|
const exec = promisify(execFile);
|
|
11
|
-
/** Runs once per vitest run: start
|
|
12
|
+
/** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
|
|
12
13
|
export default async function setup(project) {
|
|
13
14
|
const opts = project.getProvidedContext().slicetestOptions;
|
|
15
|
+
const engine = engineFor(opts);
|
|
14
16
|
let adminUrl;
|
|
15
17
|
let stopContainer;
|
|
16
18
|
if (opts.db.url) {
|
|
@@ -18,65 +20,165 @@ export default async function setup(project) {
|
|
|
18
20
|
}
|
|
19
21
|
else {
|
|
20
22
|
configureContainerRuntime();
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const container = await engine.startContainer(opts.db.image, opts.db.reuse);
|
|
24
|
+
adminUrl = container.url;
|
|
25
|
+
if (!opts.db.reuse)
|
|
26
|
+
stopContainer = container.stop;
|
|
25
27
|
}
|
|
26
28
|
// Unique per run and project, so parallel runs and projects sharing one server never collide.
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
29
|
+
// The timestamp lets a later run recognise databases left behind by a run that was killed.
|
|
30
|
+
const prefix = `${RUN_PREFIX}${Date.now().toString(36)}_${randomBytes(3).toString("hex")}`;
|
|
31
|
+
let admin;
|
|
32
|
+
let template;
|
|
30
33
|
try {
|
|
31
|
-
await admin
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
admin = await engine.admin(adminUrl);
|
|
35
|
+
if (!stopContainer)
|
|
36
|
+
await dropStale(admin);
|
|
37
|
+
const key = opts.db.reuse ? await migrationKey(opts) : undefined;
|
|
38
|
+
template = key ? await cachedTemplate(admin, engine, opts, key) : await freshTemplate(admin, engine, opts, prefix);
|
|
34
39
|
}
|
|
35
40
|
catch (e) {
|
|
36
|
-
await admin
|
|
41
|
+
await admin?.close().catch(() => { });
|
|
37
42
|
await stopContainer?.();
|
|
38
43
|
throw e;
|
|
39
44
|
}
|
|
40
|
-
|
|
45
|
+
const server = admin;
|
|
46
|
+
// Each worker writes the documented responses it saw here; they are merged when the run ends.
|
|
47
|
+
const coverageDir = opts.openapi.app ? await mkdtemp(path.join(os.tmpdir(), "slicetest-coverage-")) : undefined;
|
|
48
|
+
project.provide("slicetestDb", { adminUrl, template, prefix, coverageDir });
|
|
41
49
|
return async () => {
|
|
42
50
|
try {
|
|
43
51
|
if (!stopContainer) {
|
|
44
52
|
// Shared server: drop only the databases this run created.
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
await admin.query(`DROP DATABASE "${datname}" WITH (FORCE)`);
|
|
53
|
+
for (const name of await server.databases(`${prefix}_`))
|
|
54
|
+
await server.drop(name);
|
|
48
55
|
}
|
|
49
56
|
}
|
|
50
57
|
finally {
|
|
51
|
-
await
|
|
58
|
+
await server.close();
|
|
52
59
|
await stopContainer?.();
|
|
60
|
+
if (coverageDir)
|
|
61
|
+
await reportCoverage(opts, coverageDir);
|
|
53
62
|
}
|
|
54
63
|
};
|
|
55
64
|
}
|
|
56
|
-
async function
|
|
65
|
+
async function reportCoverage(opts, dir) {
|
|
66
|
+
try {
|
|
67
|
+
const hits = new Set();
|
|
68
|
+
for (const file of await readdir(dir)) {
|
|
69
|
+
for (const key of JSON.parse(await readFile(path.join(dir, file), "utf8")))
|
|
70
|
+
hits.add(key);
|
|
71
|
+
}
|
|
72
|
+
// No scenario ran (e.g. everything filtered out): nothing to report.
|
|
73
|
+
if (hits.size === 0)
|
|
74
|
+
return;
|
|
75
|
+
const spec = await OpenApiSpec.load(path.resolve(opts.root, opts.openapi.app), opts.openapi.app);
|
|
76
|
+
const report = formatCoverage(spec, hits);
|
|
77
|
+
console.log(`\n${report.text}\n`);
|
|
78
|
+
const min = opts.openapi.minCoverage;
|
|
79
|
+
if (min !== undefined && report.percent < min) {
|
|
80
|
+
// Not thrown: Vitest reports teardown errors as a crash. The failing exit code is what CI needs.
|
|
81
|
+
console.error(`slicetest: OpenAPI coverage ${report.percent}% is below openapi.minCoverage (${min}%)\n`);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await rm(dir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const RUN_PREFIX = "slicetest_r";
|
|
90
|
+
const TEMPLATE_PREFIX = "slicetest_tpl_";
|
|
91
|
+
const STALE_MS = 24 * 60 * 60 * 1000;
|
|
92
|
+
async function freshTemplate(admin, engine, opts, prefix) {
|
|
93
|
+
const template = `${prefix}_template`;
|
|
94
|
+
await admin.create(template);
|
|
95
|
+
await migrate(opts, engine, admin.urlFor(template));
|
|
96
|
+
return template;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The template for these migrations, built once and kept on the server. The
|
|
100
|
+
* lock makes concurrent runs wait for the first one instead of building their own.
|
|
101
|
+
*/
|
|
102
|
+
async function cachedTemplate(admin, engine, opts, key) {
|
|
103
|
+
const name = `${TEMPLATE_PREFIX}${key}`;
|
|
104
|
+
return admin.withLock(name, async () => {
|
|
105
|
+
if ((await admin.databases(name)).includes(name))
|
|
106
|
+
return name;
|
|
107
|
+
await admin.create(name);
|
|
108
|
+
try {
|
|
109
|
+
await migrate(opts, engine, admin.urlFor(name));
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
// Never leave a half-migrated template behind for later runs to reuse.
|
|
113
|
+
await admin.drop(name).catch(() => { });
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
return name;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* A hash of everything that determines the migrated schema, or undefined when
|
|
121
|
+
* that can't be known (a migration command without `inputs`).
|
|
122
|
+
*/
|
|
123
|
+
export async function migrationKey(opts) {
|
|
124
|
+
const m = opts.db.migrate;
|
|
125
|
+
const hash = createHash("sha256").update(`v1\0${opts.db.image}\0${JSON.stringify(m ?? null)}\0`);
|
|
126
|
+
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));
|
|
127
|
+
if (!inputs)
|
|
128
|
+
return undefined;
|
|
129
|
+
for (const input of inputs) {
|
|
130
|
+
for (const file of await filesUnder(input)) {
|
|
131
|
+
hash.update(`${path.relative(opts.root, file).replace(/\\/g, "/")}\0`);
|
|
132
|
+
hash.update(await readFile(file));
|
|
133
|
+
hash.update("\0");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return hash.digest("hex").slice(0, 20);
|
|
137
|
+
}
|
|
138
|
+
async function filesUnder(target) {
|
|
139
|
+
const info = await stat(target).catch(() => undefined);
|
|
140
|
+
if (!info)
|
|
141
|
+
throw new Error(`slicetest: migration input not found: ${target}`);
|
|
142
|
+
if (!info.isDirectory())
|
|
143
|
+
return [target];
|
|
144
|
+
const entries = await readdir(target, { recursive: true, withFileTypes: true });
|
|
145
|
+
return entries
|
|
146
|
+
.filter((e) => e.isFile())
|
|
147
|
+
.map((e) => path.join(e.parentPath, e.name))
|
|
148
|
+
.sort();
|
|
149
|
+
}
|
|
150
|
+
function atlasDirPath(dir, root) {
|
|
151
|
+
return path.resolve(root, dir.replace(/^file:\/\//, ""));
|
|
152
|
+
}
|
|
153
|
+
/** Databases from runs that were killed before cleaning up, recognised by the timestamp in their name. */
|
|
154
|
+
async function dropStale(admin) {
|
|
155
|
+
for (const datname of await admin.databases(RUN_PREFIX)) {
|
|
156
|
+
const started = parseInt(datname.slice(RUN_PREFIX.length).split("_")[0], 36);
|
|
157
|
+
if (Number.isFinite(started) && Date.now() - started > STALE_MS) {
|
|
158
|
+
await admin.drop(datname).catch(() => { });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function migrate(opts, engine, url) {
|
|
57
163
|
const m = opts.db.migrate;
|
|
58
164
|
if (!m)
|
|
59
165
|
return;
|
|
60
166
|
if ("atlas" in m) {
|
|
61
167
|
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);
|
|
168
|
+
await run("atlas", ["migrate", "apply", "--url", engine.atlasUrl(url), "--dir", dir], opts.root);
|
|
66
169
|
}
|
|
67
170
|
else if ("sql" in m) {
|
|
68
171
|
const target = path.resolve(opts.root, m.sql);
|
|
69
172
|
const files = (await stat(target)).isDirectory()
|
|
70
173
|
? (await readdir(target)).filter((f) => f.endsWith(".sql")).sort().map((f) => path.join(target, f))
|
|
71
174
|
: [target];
|
|
72
|
-
const
|
|
73
|
-
await client.connect();
|
|
175
|
+
const driver = await engine.driver(url);
|
|
74
176
|
try {
|
|
75
177
|
for (const file of files)
|
|
76
|
-
await
|
|
178
|
+
await driver.exec(await readFile(file, "utf8"));
|
|
77
179
|
}
|
|
78
180
|
finally {
|
|
79
|
-
await
|
|
181
|
+
await driver.close();
|
|
80
182
|
}
|
|
81
183
|
}
|
|
82
184
|
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,8 @@
|
|
|
1
1
|
export { scenario } from "./scenario.js";
|
|
2
2
|
export type { ScenarioContext } from "./runtime.js";
|
|
3
|
-
export type { Db, Row, Where, RowsOptions } from "./db.js";
|
|
3
|
+
export type { Db, Row, Where, RowsOptions, Changes, TableChanges } from "./db.js";
|
|
4
4
|
export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder } from "./stub.js";
|
|
5
5
|
export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
|
|
6
6
|
export type { App } from "./app.js";
|
|
7
7
|
export type { SlicetestOptions } from "./config.js";
|
|
8
|
+
export type {} from "./matchers.js";
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
}>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { 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
|
+
// --- OpenAPI ---
|
|
96
|
+
const openapi = ["openapi.yaml", "openapi.yml", "openapi.json", "docs/openapi.yaml", "docs/openapi.yml", "docs/openapi.json"].find(has);
|
|
97
|
+
if (openapi)
|
|
98
|
+
notes.push(`openapi: ${openapi}. Every response will be checked against it.`);
|
|
99
|
+
const config = {
|
|
100
|
+
app: { command, env, ready: { path: "/" } },
|
|
101
|
+
...(migrate ? { db: { migrate } } : {}),
|
|
102
|
+
stubs: [],
|
|
103
|
+
...(openapi ? { openapi } : {}),
|
|
104
|
+
};
|
|
105
|
+
return { config, notes };
|
|
106
|
+
}
|
|
107
|
+
const SCENARIO = `# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json
|
|
108
|
+
# A first scenario. Run it with: npx slicetest
|
|
109
|
+
scenarios:
|
|
110
|
+
- name: the app answers
|
|
111
|
+
steps:
|
|
112
|
+
- request: GET /
|
|
113
|
+
expect: { status: 200 }
|
|
114
|
+
`;
|
|
115
|
+
export async function init(root, { force = false } = {}) {
|
|
116
|
+
const configFile = path.join(root, "slicetest.config.yaml");
|
|
117
|
+
const scenarioFile = path.join(root, "scenarios", "smoke.scenario.yaml");
|
|
118
|
+
const existing = [configFile, scenarioFile].filter((f) => existsSync(f));
|
|
119
|
+
if (existing.length && !force) {
|
|
120
|
+
throw new Error(`slicetest: ${existing.map((f) => path.relative(root, f)).join(", ")} already exists. Use --force to overwrite.`);
|
|
121
|
+
}
|
|
122
|
+
const { config, notes } = await detect(root);
|
|
123
|
+
const header = [
|
|
124
|
+
"# slicetest config, generated by `npx slicetest init`. Paths are relative to this file.",
|
|
125
|
+
"# Everything the Vitest plugin accepts works here: https://github.com/revo1290/slicetest#configuration-reference",
|
|
126
|
+
"#",
|
|
127
|
+
...notes.map((n) => `# - ${n}`),
|
|
128
|
+
"",
|
|
129
|
+
].join("\n");
|
|
130
|
+
await writeFile(configFile, `${header}${stringify(config)}`);
|
|
131
|
+
await mkdir(path.dirname(scenarioFile), { recursive: true });
|
|
132
|
+
await writeFile(scenarioFile, SCENARIO);
|
|
133
|
+
return { files: [configFile, scenarioFile].map((f) => path.relative(root, f)), notes };
|
|
134
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 Message {
|
|
8
|
+
status?: number;
|
|
9
|
+
contentType?: string;
|
|
10
|
+
/** Parsed JSON when the body is JSON, else the raw text. */
|
|
11
|
+
body: unknown;
|
|
12
|
+
query?: URLSearchParams;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
|
|
16
|
+
* the app's responses against the app's own spec, and the app's calls to a
|
|
17
|
+
* stubbed service (and the stub's canned replies) against that service's spec.
|
|
18
|
+
*/
|
|
19
|
+
export declare class OpenApiSpec {
|
|
20
|
+
#private;
|
|
21
|
+
readonly file: string;
|
|
22
|
+
private readonly doc;
|
|
23
|
+
private constructor();
|
|
24
|
+
/** `file` is read; `label` (default: `file`) is how messages refer to it. */
|
|
25
|
+
static load(file: string, label?: string): Promise<OpenApiSpec>;
|
|
26
|
+
find(method: string, path: string): Operation | undefined;
|
|
27
|
+
/** Problems with a response to `method path`; empty when it matches the spec. */
|
|
28
|
+
/** The documented response (`200`, `4XX`, `default`) that `status` falls under, for coverage. */
|
|
29
|
+
responseKey(method: string, path: string, status: number): string | undefined;
|
|
30
|
+
/** Every documented response as `METHOD /template key`, in document order. */
|
|
31
|
+
responseKeys(): string[];
|
|
32
|
+
checkResponse(method: string, path: string, res: Message): string[];
|
|
33
|
+
/**
|
|
34
|
+
* A response the real service could send to `method path`: the lowest
|
|
35
|
+
* documented 2xx, with its example if the spec has one, else a value built
|
|
36
|
+
* from its schema. Undefined when the operation isn't in the spec.
|
|
37
|
+
*/
|
|
38
|
+
exampleResponse(method: string, path: string): {
|
|
39
|
+
status: number;
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
body?: unknown;
|
|
42
|
+
} | undefined;
|
|
43
|
+
/** Problems with a request the app sent to `method path`. */
|
|
44
|
+
checkRequest(method: string, path: string, req: Message): string[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Coverage report: which documented responses the scenarios produced.
|
|
48
|
+
* `hits` are `responseKey()` values collected from every worker.
|
|
49
|
+
*/
|
|
50
|
+
export declare function formatCoverage(spec: OpenApiSpec, hits: Set<string>): {
|
|
51
|
+
covered: number;
|
|
52
|
+
total: number;
|
|
53
|
+
percent: number;
|
|
54
|
+
text: string;
|
|
55
|
+
};
|
|
56
|
+
export {};
|