slicetest 0.1.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/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/app.d.ts +31 -0
- package/dist/app.js +217 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +70 -0
- package/dist/config.d.ts +69 -0
- package/dist/config.js +54 -0
- package/dist/container-runtime.d.ts +8 -0
- package/dist/container-runtime.js +31 -0
- package/dist/db.d.ts +39 -0
- package/dist/db.js +166 -0
- package/dist/global-setup.d.ts +9 -0
- package/dist/global-setup.js +105 -0
- package/dist/http.d.ts +51 -0
- package/dist/http.js +158 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1 -0
- package/dist/matchers.d.ts +17 -0
- package/dist/matchers.js +69 -0
- package/dist/provided.d.ts +11 -0
- package/dist/provided.js +1 -0
- package/dist/runtime.d.ts +34 -0
- package/dist/runtime.js +145 -0
- package/dist/scenario.d.ts +15 -0
- package/dist/scenario.js +59 -0
- package/dist/setup-file.d.ts +2 -0
- package/dist/setup-file.js +14 -0
- package/dist/stub.d.ts +75 -0
- package/dist/stub.js +228 -0
- package/dist/vitest.d.ts +6 -0
- package/dist/vitest.js +46 -0
- package/dist/yaml-runtime.d.ts +18 -0
- package/dist/yaml-runtime.js +255 -0
- package/dist/yaml.d.ts +99 -0
- package/dist/yaml.js +151 -0
- package/package.json +76 -0
- package/schema/scenario.schema.json +405 -0
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { RequestOptions } from "./http.js";
|
|
2
|
+
export interface SlicetestOptions {
|
|
3
|
+
app: AppOptions;
|
|
4
|
+
db?: DbOptions;
|
|
5
|
+
/** Names of outbound HTTP services to stub. Each gets its own server, referenced as `{{stub.<name>}}` in `app.env`. */
|
|
6
|
+
stubs?: string[];
|
|
7
|
+
/** Defaults for every request made with `http`, e.g. `{ headers: { accept: "application/json" } }`. */
|
|
8
|
+
http?: RequestOptions;
|
|
9
|
+
}
|
|
10
|
+
export interface AppOptions {
|
|
11
|
+
/** Command that starts the app, run through the shell. */
|
|
12
|
+
command: string;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Environment passed to the app. Values may reference
|
|
16
|
+
* `{{app.port}}`, `{{db.url}}` and `{{stub.<name>}}`.
|
|
17
|
+
* Defaults to `{ PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}" }`.
|
|
18
|
+
*/
|
|
19
|
+
env?: Record<string, string>;
|
|
20
|
+
/** How to tell the app is up. Defaults to polling `GET /` until it answers. */
|
|
21
|
+
ready?: {
|
|
22
|
+
path: string;
|
|
23
|
+
} | {
|
|
24
|
+
log: string | RegExp;
|
|
25
|
+
};
|
|
26
|
+
/** Milliseconds to wait for readiness. Default 30000. */
|
|
27
|
+
readyTimeout?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface DbOptions {
|
|
30
|
+
/** Postgres image used when no `url` is given. Default `postgres:17-alpine`. */
|
|
31
|
+
image?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Use an existing Postgres server instead of starting a container. Must point at a superuser-capable database.
|
|
34
|
+
* Defaults to the `SLICETEST_DATABASE_URL` environment variable, which is handy in CI.
|
|
35
|
+
*/
|
|
36
|
+
url?: string;
|
|
37
|
+
migrate?: MigrateOptions;
|
|
38
|
+
/** SQL file (relative to the vitest root) run after every reset. */
|
|
39
|
+
seed?: string;
|
|
40
|
+
/** Schemas whose tables are reset between scenarios. Default `["public"]`. */
|
|
41
|
+
schemas?: string[];
|
|
42
|
+
/** Extra tables kept across resets, in addition to known migration bookkeeping tables. */
|
|
43
|
+
keep?: string[];
|
|
44
|
+
}
|
|
45
|
+
export type MigrateOptions = {
|
|
46
|
+
atlas: {
|
|
47
|
+
dir: string;
|
|
48
|
+
};
|
|
49
|
+
} | {
|
|
50
|
+
sql: string;
|
|
51
|
+
} | {
|
|
52
|
+
command: string;
|
|
53
|
+
};
|
|
54
|
+
/** Normalized shape passed from the plugin to globalSetup and workers. Must stay JSON-serializable. */
|
|
55
|
+
export interface ResolvedOptions {
|
|
56
|
+
root: string;
|
|
57
|
+
app: Omit<AppOptions, "ready"> & {
|
|
58
|
+
ready: {
|
|
59
|
+
path: string;
|
|
60
|
+
} | {
|
|
61
|
+
log: string;
|
|
62
|
+
flags: string;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
db: Required<Pick<DbOptions, "image" | "schemas" | "keep">> & Omit<DbOptions, "image" | "schemas" | "keep">;
|
|
66
|
+
stubs: string[];
|
|
67
|
+
http?: RequestOptions;
|
|
68
|
+
}
|
|
69
|
+
export declare function resolveOptions(opts: SlicetestOptions, root: string): ResolvedOptions;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function resolveOptions(opts, root) {
|
|
2
|
+
validate(opts);
|
|
3
|
+
const ready = opts.app.ready ?? { path: "/" };
|
|
4
|
+
return {
|
|
5
|
+
root,
|
|
6
|
+
app: {
|
|
7
|
+
...opts.app,
|
|
8
|
+
ready: "log" in ready
|
|
9
|
+
? typeof ready.log === "string"
|
|
10
|
+
? { log: escapeRegExp(ready.log), flags: "" }
|
|
11
|
+
: { log: ready.log.source, flags: ready.log.flags }
|
|
12
|
+
: ready,
|
|
13
|
+
},
|
|
14
|
+
db: {
|
|
15
|
+
image: "postgres:17-alpine",
|
|
16
|
+
schemas: ["public"],
|
|
17
|
+
keep: [],
|
|
18
|
+
...opts.db,
|
|
19
|
+
url: opts.db?.url ?? (process.env.SLICETEST_DATABASE_URL || undefined),
|
|
20
|
+
},
|
|
21
|
+
stubs: opts.stubs ?? [],
|
|
22
|
+
http: opts.http,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function validate(opts) {
|
|
26
|
+
const fail = (msg) => {
|
|
27
|
+
throw new Error(`slicetest: invalid config: ${msg}`);
|
|
28
|
+
};
|
|
29
|
+
if (!opts?.app || typeof opts.app.command !== "string" || !opts.app.command.trim()) {
|
|
30
|
+
fail("app.command is required, e.g. { app: { command: \"node server.js\" } }");
|
|
31
|
+
}
|
|
32
|
+
const ready = opts.app.ready;
|
|
33
|
+
if (ready !== undefined && !("path" in ready) && !("log" in ready))
|
|
34
|
+
fail("app.ready must be { path } or { log }");
|
|
35
|
+
if (ready && "path" in ready && !ready.path.startsWith("/"))
|
|
36
|
+
fail(`app.ready.path must start with "/", got "${ready.path}"`);
|
|
37
|
+
const migrate = opts.db?.migrate;
|
|
38
|
+
if (migrate) {
|
|
39
|
+
const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
|
|
40
|
+
if (keys.length !== 1)
|
|
41
|
+
fail(`db.migrate takes exactly one of atlas / sql / command, got ${keys.join(", ") || "none"}`);
|
|
42
|
+
}
|
|
43
|
+
const stubs = opts.stubs ?? [];
|
|
44
|
+
for (const name of stubs) {
|
|
45
|
+
if (!/^[\w-]+$/.test(name))
|
|
46
|
+
fail(`stub name "${name}" may only contain letters, digits, "_" and "-"`);
|
|
47
|
+
}
|
|
48
|
+
const dup = stubs.find((n, i) => stubs.indexOf(n) !== i);
|
|
49
|
+
if (dup)
|
|
50
|
+
fail(`stub "${dup}" is declared twice`);
|
|
51
|
+
}
|
|
52
|
+
function escapeRegExp(s) {
|
|
53
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
54
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testcontainers talks to the Docker API. When Docker isn't configured but a
|
|
3
|
+
* Podman machine is running, point Testcontainers at Podman's socket (macOS,
|
|
4
|
+
* Linux) or named pipe (Windows).
|
|
5
|
+
*/
|
|
6
|
+
export declare function configureContainerRuntime(platform?: NodeJS.Platform): void;
|
|
7
|
+
/** `\\.\pipe\podman-machine-default` → `npipe:////./pipe/podman-machine-default`. */
|
|
8
|
+
export declare function dockerHost(location: string, windows: boolean): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Testcontainers talks to the Docker API. When Docker isn't configured but a
|
|
4
|
+
* Podman machine is running, point Testcontainers at Podman's socket (macOS,
|
|
5
|
+
* Linux) or named pipe (Windows).
|
|
6
|
+
*/
|
|
7
|
+
export function configureContainerRuntime(platform = process.platform) {
|
|
8
|
+
if (process.env.DOCKER_HOST)
|
|
9
|
+
return;
|
|
10
|
+
const windows = platform === "win32";
|
|
11
|
+
try {
|
|
12
|
+
const field = windows ? "PodmanPipe" : "PodmanSocket";
|
|
13
|
+
const location = execFileSync("podman", ["machine", "inspect", "--format", `{{.ConnectionInfo.${field}.Path}}`], {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
16
|
+
windowsHide: true,
|
|
17
|
+
}).trim();
|
|
18
|
+
if (!location || location === "<no value>")
|
|
19
|
+
return;
|
|
20
|
+
process.env.DOCKER_HOST = dockerHost(location, windows);
|
|
21
|
+
// Ryuk needs a privileged socket mount that rootless Podman usually refuses.
|
|
22
|
+
process.env.TESTCONTAINERS_RYUK_DISABLED ??= "true";
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// No podman; let Testcontainers find Docker on its own.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** `\\.\pipe\podman-machine-default` → `npipe:////./pipe/podman-machine-default`. */
|
|
29
|
+
export function dockerHost(location, windows) {
|
|
30
|
+
return windows ? `npipe://${location.replace(/\\/g, "/")}` : `unix://${location}`;
|
|
31
|
+
}
|
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare function withDatabase(url: string, database: string): string;
|
|
2
|
+
export interface Row {
|
|
3
|
+
[column: string]: unknown;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Column filters. `null` means IS NULL and an array means IN (...);
|
|
7
|
+
* every other value is compared with `=`.
|
|
8
|
+
*/
|
|
9
|
+
export type Where = Record<string, unknown>;
|
|
10
|
+
export interface RowsOptions {
|
|
11
|
+
/** Column(s) to sort by; prefix with `-` for descending. Default: the first column. */
|
|
12
|
+
orderBy?: string | string[];
|
|
13
|
+
limit?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Test-side handle to the database the app under test is using. */
|
|
16
|
+
export declare class Db {
|
|
17
|
+
#private;
|
|
18
|
+
readonly url: string;
|
|
19
|
+
private readonly opts;
|
|
20
|
+
private constructor();
|
|
21
|
+
static connect(url: string, opts: {
|
|
22
|
+
schemas: string[];
|
|
23
|
+
keep: string[];
|
|
24
|
+
seedFile?: string;
|
|
25
|
+
}): Promise<Db>;
|
|
26
|
+
query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
27
|
+
/** Rows of a table, optionally filtered, ordered by the first column unless `orderBy` is given. */
|
|
28
|
+
rows<T extends Row = Row>(table: string, where?: Where, opts?: RowsOptions): Promise<T[]>;
|
|
29
|
+
/** The single row matching `where`. Throws, listing what was found, unless there is exactly one. */
|
|
30
|
+
one<T extends Row = Row>(table: string, where?: Where): Promise<T>;
|
|
31
|
+
/** Tagged-template query: values become bind parameters. */
|
|
32
|
+
sql<T extends Row = Row>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
|
|
33
|
+
count(table: string, where?: Where): Promise<number>;
|
|
34
|
+
/** Insert rows and return them as stored (with defaults and generated ids). */
|
|
35
|
+
insert<T extends Row = Row>(table: string, rows: Row | Row[]): Promise<T[]>;
|
|
36
|
+
/** Empty every data table without dropping the app's connections, then re-apply the seed. */
|
|
37
|
+
reset(): Promise<void>;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
/** Tables that record applied migrations. Truncating them would make tools re-run migrations. */
|
|
4
|
+
const MIGRATION_TABLES = [
|
|
5
|
+
"atlas_schema_revisions",
|
|
6
|
+
"schema_migrations",
|
|
7
|
+
"_prisma_migrations",
|
|
8
|
+
"__drizzle_migrations",
|
|
9
|
+
"knex_migrations",
|
|
10
|
+
"knex_migrations_lock",
|
|
11
|
+
"alembic_version",
|
|
12
|
+
"flyway_schema_history",
|
|
13
|
+
"goose_db_version",
|
|
14
|
+
"_sqlx_migrations",
|
|
15
|
+
"__diesel_schema_migrations",
|
|
16
|
+
"django_migrations",
|
|
17
|
+
"databasechangelog",
|
|
18
|
+
"databasechangeloglock",
|
|
19
|
+
"SequelizeMeta",
|
|
20
|
+
"ar_internal_metadata",
|
|
21
|
+
"__EFMigrationsHistory",
|
|
22
|
+
];
|
|
23
|
+
export function withDatabase(url, database) {
|
|
24
|
+
const u = new URL(url);
|
|
25
|
+
u.pathname = `/${database}`;
|
|
26
|
+
return u.toString();
|
|
27
|
+
}
|
|
28
|
+
const INT8 = 20;
|
|
29
|
+
/** int8 columns (bigserial ids, count(*)) come back as numbers when they fit, instead of strings. */
|
|
30
|
+
const types = {
|
|
31
|
+
getTypeParser(oid, format) {
|
|
32
|
+
if (oid === INT8 && format !== "binary") {
|
|
33
|
+
return (v) => {
|
|
34
|
+
const n = Number(v);
|
|
35
|
+
return Number.isSafeInteger(n) ? n : v;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return pg.types.getTypeParser(oid, format);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
/** Test-side handle to the database the app under test is using. */
|
|
42
|
+
export class Db {
|
|
43
|
+
url;
|
|
44
|
+
opts;
|
|
45
|
+
#client;
|
|
46
|
+
#tables;
|
|
47
|
+
#seed;
|
|
48
|
+
constructor(client, url, opts) {
|
|
49
|
+
this.url = url;
|
|
50
|
+
this.opts = opts;
|
|
51
|
+
this.#client = client;
|
|
52
|
+
}
|
|
53
|
+
static async connect(url, opts) {
|
|
54
|
+
const client = new pg.Client({ connectionString: url, types });
|
|
55
|
+
// Without a listener, a dropped connection would crash the worker; the next query reports it instead.
|
|
56
|
+
client.on("error", () => { });
|
|
57
|
+
await client.connect();
|
|
58
|
+
const db = new Db(client, url, opts);
|
|
59
|
+
if (opts.seedFile)
|
|
60
|
+
db.#seed = await readFile(opts.seedFile, "utf8");
|
|
61
|
+
return db;
|
|
62
|
+
}
|
|
63
|
+
async query(sql, params = []) {
|
|
64
|
+
return (await this.#client.query(sql, params)).rows;
|
|
65
|
+
}
|
|
66
|
+
/** Rows of a table, optionally filtered, ordered by the first column unless `orderBy` is given. */
|
|
67
|
+
async rows(table, where = {}, opts = {}) {
|
|
68
|
+
const { clause, params } = whereClause(where);
|
|
69
|
+
const order = [opts.orderBy ?? []]
|
|
70
|
+
.flat()
|
|
71
|
+
.map((c) => (c.startsWith("-") ? `${ident(c.slice(1))} DESC` : ident(c)))
|
|
72
|
+
.join(", ");
|
|
73
|
+
const limit = opts.limit === undefined ? "" : ` LIMIT ${Math.trunc(opts.limit)}`;
|
|
74
|
+
return this.query(`SELECT * FROM ${ident(table)}${clause} ORDER BY ${order || "1"}${limit}`, params);
|
|
75
|
+
}
|
|
76
|
+
/** The single row matching `where`. Throws, listing what was found, unless there is exactly one. */
|
|
77
|
+
async one(table, where = {}) {
|
|
78
|
+
const rows = await this.rows(table, where, { limit: 3 });
|
|
79
|
+
if (rows.length === 1)
|
|
80
|
+
return rows[0];
|
|
81
|
+
const found = rows.length === 0 ? "none" : `${rows.length === 3 ? "3 or more" : rows.length}, e.g.\n${rows.map((r) => ` ${JSON.stringify(r)}`).join("\n")}`;
|
|
82
|
+
throw new Error(`slicetest: expected exactly one row in ${table} where ${JSON.stringify(where)}, found ${found}`);
|
|
83
|
+
}
|
|
84
|
+
/** Tagged-template query: values become bind parameters. */
|
|
85
|
+
sql(strings, ...values) {
|
|
86
|
+
const text = strings.reduce((acc, s, i) => acc + `$${i}` + s);
|
|
87
|
+
return this.query(text, values);
|
|
88
|
+
}
|
|
89
|
+
async count(table, where = {}) {
|
|
90
|
+
const { clause, params } = whereClause(where);
|
|
91
|
+
const [row] = await this.query(`SELECT count(*) AS n FROM ${ident(table)}${clause}`, params);
|
|
92
|
+
return Number(row.n);
|
|
93
|
+
}
|
|
94
|
+
/** Insert rows and return them as stored (with defaults and generated ids). */
|
|
95
|
+
async insert(table, rows) {
|
|
96
|
+
const list = Array.isArray(rows) ? rows : [rows];
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const row of list) {
|
|
99
|
+
const cols = Object.keys(row);
|
|
100
|
+
const sql = cols.length === 0
|
|
101
|
+
? `INSERT INTO ${ident(table)} DEFAULT VALUES RETURNING *`
|
|
102
|
+
: `INSERT INTO ${ident(table)} (${cols.map(ident).join(", ")}) VALUES (${cols
|
|
103
|
+
.map((_, i) => `$${i + 1}`)
|
|
104
|
+
.join(", ")}) RETURNING *`;
|
|
105
|
+
out.push(...(await this.query(sql, Object.values(row))));
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** Empty every data table without dropping the app's connections, then re-apply the seed. */
|
|
110
|
+
async reset() {
|
|
111
|
+
this.#tables ??= await this.#listTables();
|
|
112
|
+
if (this.#tables.length > 0) {
|
|
113
|
+
await this.#client.query(`TRUNCATE ${this.#tables.join(", ")} RESTART IDENTITY CASCADE`);
|
|
114
|
+
}
|
|
115
|
+
if (this.#seed)
|
|
116
|
+
await this.#client.query(this.#seed);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Tables to empty: ordinary tables in the configured schemas, minus migration
|
|
120
|
+
* bookkeeping, `keep` (bare or `schema.table`) and tables owned by extensions
|
|
121
|
+
* such as PostGIS's `spatial_ref_sys`.
|
|
122
|
+
*/
|
|
123
|
+
async #listTables() {
|
|
124
|
+
const keep = [...MIGRATION_TABLES, ...this.opts.keep];
|
|
125
|
+
const rows = await this.query(`SELECT n.nspname AS schema, c.relname AS name
|
|
126
|
+
FROM pg_class c
|
|
127
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
128
|
+
WHERE c.relkind IN ('r', 'p')
|
|
129
|
+
AND NOT c.relispartition
|
|
130
|
+
AND n.nspname = ANY($1)
|
|
131
|
+
AND NOT (c.relname = ANY($2) OR n.nspname || '.' || c.relname = ANY($2))
|
|
132
|
+
AND NOT EXISTS (
|
|
133
|
+
SELECT 1 FROM pg_depend d
|
|
134
|
+
WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype = 'e')
|
|
135
|
+
ORDER BY 1, 2`, [this.opts.schemas, keep]);
|
|
136
|
+
return rows.map((r) => `${ident(r.schema)}.${ident(r.name)}`);
|
|
137
|
+
}
|
|
138
|
+
async close() {
|
|
139
|
+
await this.#client.end();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function whereClause(where) {
|
|
143
|
+
const conds = [];
|
|
144
|
+
const params = [];
|
|
145
|
+
for (const [col, value] of Object.entries(where)) {
|
|
146
|
+
if (value === null || value === undefined) {
|
|
147
|
+
conds.push(`${ident(col)} IS NULL`);
|
|
148
|
+
}
|
|
149
|
+
else if (Array.isArray(value)) {
|
|
150
|
+
params.push(value);
|
|
151
|
+
conds.push(`${ident(col)} = ANY($${params.length})`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
params.push(value);
|
|
155
|
+
conds.push(`${ident(col)} = $${params.length}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { clause: conds.length ? ` WHERE ${conds.join(" AND ")}` : "", params };
|
|
159
|
+
}
|
|
160
|
+
/** Quote an identifier; `schema.table` is split on the dot. */
|
|
161
|
+
function ident(name) {
|
|
162
|
+
return name
|
|
163
|
+
.split(".")
|
|
164
|
+
.map((part) => `"${part.replace(/"/g, '""')}"`)
|
|
165
|
+
.join(".");
|
|
166
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TestProject } from "vitest/node";
|
|
2
|
+
import "./provided.js";
|
|
3
|
+
/** Runs once per vitest run: start Postgres, migrate a template database, hand its location to the workers. */
|
|
4
|
+
export default function setup(project: TestProject): Promise<() => Promise<void>>;
|
|
5
|
+
/**
|
|
6
|
+
* Atlas wants `file://<path>` with forward slashes. Absolute paths (including
|
|
7
|
+
* Windows drive paths) are made relative to the root, which is the cwd Atlas runs in.
|
|
8
|
+
*/
|
|
9
|
+
export declare function atlasDirUrl(dir: string, root: string): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import pg from "pg";
|
|
7
|
+
import { configureContainerRuntime } from "./container-runtime.js";
|
|
8
|
+
import { withDatabase } from "./db.js";
|
|
9
|
+
import "./provided.js";
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
/** Runs once per vitest run: start Postgres, migrate a template database, hand its location to the workers. */
|
|
12
|
+
export default async function setup(project) {
|
|
13
|
+
const opts = project.getProvidedContext().slicetestOptions;
|
|
14
|
+
let adminUrl;
|
|
15
|
+
let stopContainer;
|
|
16
|
+
if (opts.db.url) {
|
|
17
|
+
adminUrl = opts.db.url;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
configureContainerRuntime();
|
|
21
|
+
const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
|
|
22
|
+
const container = await new PostgreSqlContainer(opts.db.image).start();
|
|
23
|
+
adminUrl = container.getConnectionUri();
|
|
24
|
+
stopContainer = () => container.stop();
|
|
25
|
+
}
|
|
26
|
+
// Unique per run and project, so parallel runs and projects sharing one server never collide.
|
|
27
|
+
const prefix = `slicetest_${randomBytes(4).toString("hex")}`;
|
|
28
|
+
const template = `${prefix}_template`;
|
|
29
|
+
const admin = new pg.Client({ connectionString: adminUrl });
|
|
30
|
+
try {
|
|
31
|
+
await admin.connect();
|
|
32
|
+
await admin.query(`CREATE DATABASE "${template}"`);
|
|
33
|
+
await migrate(opts, withDatabase(adminUrl, template));
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
await admin.end().catch(() => { });
|
|
37
|
+
await stopContainer?.();
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
project.provide("slicetestDb", { adminUrl, template, prefix });
|
|
41
|
+
return async () => {
|
|
42
|
+
try {
|
|
43
|
+
if (!stopContainer) {
|
|
44
|
+
// Shared server: drop only the databases this run created.
|
|
45
|
+
const { rows } = await admin.query("SELECT datname FROM pg_database WHERE starts_with(datname, $1)", [`${prefix}_`]);
|
|
46
|
+
for (const { datname } of rows)
|
|
47
|
+
await admin.query(`DROP DATABASE "${datname}" WITH (FORCE)`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await admin.end();
|
|
52
|
+
await stopContainer?.();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function migrate(opts, url) {
|
|
57
|
+
const m = opts.db.migrate;
|
|
58
|
+
if (!m)
|
|
59
|
+
return;
|
|
60
|
+
if ("atlas" in m) {
|
|
61
|
+
const dir = atlasDirUrl(m.atlas.dir, opts.root);
|
|
62
|
+
const u = new URL(url);
|
|
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);
|
|
66
|
+
}
|
|
67
|
+
else if ("sql" in m) {
|
|
68
|
+
const target = path.resolve(opts.root, m.sql);
|
|
69
|
+
const files = (await stat(target)).isDirectory()
|
|
70
|
+
? (await readdir(target)).filter((f) => f.endsWith(".sql")).sort().map((f) => path.join(target, f))
|
|
71
|
+
: [target];
|
|
72
|
+
const client = new pg.Client({ connectionString: url });
|
|
73
|
+
await client.connect();
|
|
74
|
+
try {
|
|
75
|
+
for (const file of files)
|
|
76
|
+
await client.query(await readFile(file, "utf8"));
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await client.end();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Through the platform shell (sh or cmd.exe), like app.command.
|
|
84
|
+
await run(m.command, [], opts.root, { DATABASE_URL: url }, true);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Atlas wants `file://<path>` with forward slashes. Absolute paths (including
|
|
89
|
+
* Windows drive paths) are made relative to the root, which is the cwd Atlas runs in.
|
|
90
|
+
*/
|
|
91
|
+
export function atlasDirUrl(dir, root) {
|
|
92
|
+
if (dir.startsWith("file://"))
|
|
93
|
+
return dir;
|
|
94
|
+
const rel = path.isAbsolute(dir) || /^[a-zA-Z]:[\\/]/.test(dir) ? path.relative(root, dir) : dir;
|
|
95
|
+
return `file://${rel.replace(/\\/g, "/")}`;
|
|
96
|
+
}
|
|
97
|
+
async function run(cmd, args, cwd, env = {}, shell = false) {
|
|
98
|
+
try {
|
|
99
|
+
await exec(cmd, args, { cwd, env: { ...process.env, ...env }, shell, windowsHide: true });
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
const err = e;
|
|
103
|
+
throw new Error(`slicetest: migration failed: ${cmd} ${args.join(" ")}\n${err.stderr || err.stdout || err.message}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface HttpResponse {
|
|
2
|
+
method: string;
|
|
3
|
+
url: string;
|
|
4
|
+
status: number;
|
|
5
|
+
headers: Headers;
|
|
6
|
+
text: string;
|
|
7
|
+
/** Parsed JSON body, or undefined when the body isn't JSON. */
|
|
8
|
+
json: any;
|
|
9
|
+
/** Wall-clock time of the request in milliseconds. */
|
|
10
|
+
durationMs: number;
|
|
11
|
+
}
|
|
12
|
+
export interface RequestOptions {
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
15
|
+
/** Follow redirects instead of returning the 3xx response. Default false. */
|
|
16
|
+
follow?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** Per-scenario state shared by a client and every client derived from it with `with()`. */
|
|
19
|
+
declare class Session {
|
|
20
|
+
cookies: Map<string, string>;
|
|
21
|
+
history: HttpResponse[];
|
|
22
|
+
}
|
|
23
|
+
/** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
|
|
24
|
+
export declare class HttpClient {
|
|
25
|
+
#private;
|
|
26
|
+
readonly baseUrl: string;
|
|
27
|
+
private readonly defaults;
|
|
28
|
+
constructor(baseUrl: string, defaults?: RequestOptions, session?: Session);
|
|
29
|
+
/** A client that adds `opts` (e.g. an auth header) to every request, sharing this one's cookies. */
|
|
30
|
+
with(opts: RequestOptions): HttpClient;
|
|
31
|
+
/** Wrap fields to be sent as `application/x-www-form-urlencoded`. */
|
|
32
|
+
form(fields: Record<string, string | number | boolean>): URLSearchParams;
|
|
33
|
+
get(path: string, opts?: RequestOptions): Promise<HttpResponse>;
|
|
34
|
+
head(path: string, opts?: RequestOptions): Promise<HttpResponse>;
|
|
35
|
+
delete(path: string, opts?: RequestOptions): Promise<HttpResponse>;
|
|
36
|
+
post(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
|
|
37
|
+
put(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
|
|
38
|
+
patch(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
|
|
39
|
+
/** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
|
|
40
|
+
request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<HttpResponse>;
|
|
41
|
+
/** Cookies the app has set during this scenario. Mutations are sent with later requests. */
|
|
42
|
+
get cookies(): Map<string, string>;
|
|
43
|
+
clearCookies(): void;
|
|
44
|
+
/** Requests made during the current scenario, oldest first (last 20). */
|
|
45
|
+
get history(): readonly HttpResponse[];
|
|
46
|
+
/** Start a new scenario: forget cookies and history. */
|
|
47
|
+
reset(): void;
|
|
48
|
+
}
|
|
49
|
+
/** One line per request, for failure output. */
|
|
50
|
+
export declare function formatHistory(history: readonly HttpResponse[]): string;
|
|
51
|
+
export {};
|