slicetest 0.5.0 → 0.6.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 +183 -5
- package/dist/app.js +2 -2
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +13 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -3
- package/dist/config.d.ts +58 -5
- package/dist/config.js +61 -9
- package/dist/containers.d.ts +2 -0
- package/dist/containers.js +20 -1
- package/dist/db.d.ts +2 -0
- package/dist/db.js +18 -0
- package/dist/doctor.js +17 -5
- package/dist/form.d.ts +45 -0
- package/dist/form.js +226 -0
- package/dist/global-setup.d.ts +1 -0
- package/dist/global-setup.js +90 -34
- package/dist/http.d.ts +17 -1
- package/dist/http.js +76 -9
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/init.js +203 -25
- package/dist/intercept.d.ts +37 -0
- package/dist/intercept.js +199 -0
- package/dist/neon.d.ts +14 -0
- package/dist/neon.js +113 -0
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +4 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +127 -25
- package/dist/setup-file.js +19 -2
- package/dist/stub.d.ts +14 -0
- package/dist/stub.js +22 -0
- package/dist/vitest.d.ts +11 -1
- package/dist/vitest.js +27 -2
- package/dist/x509.d.ts +37 -0
- package/dist/x509.js +150 -0
- package/dist/yaml-runtime.js +35 -13
- package/dist/yaml.d.ts +32 -1
- package/dist/yaml.js +31 -1
- package/package.json +5 -3
- package/preload/node-proxy.cjs +19 -0
- package/schema/scenario.schema.json +140 -0
package/dist/neon.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neon's HTTP query endpoint, served from the test database, so apps on the Neon
|
|
3
|
+
* serverless driver (`neon()` from @neondatabase/serverless, drizzle-orm/neon-http,
|
|
4
|
+
* @vercel/postgres's `sql`) run against the local Postgres without a code change.
|
|
5
|
+
*
|
|
6
|
+
* The driver posts each query to `https://api.<domain>/sql` for a connection string
|
|
7
|
+
* at `<endpoint>.<domain>`; with `db.neon` the app gets such a string and the
|
|
8
|
+
* intercepting proxy hands that host to this server.
|
|
9
|
+
*/
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import pg from "pg";
|
|
12
|
+
/** The made-up domain of the connection string the app gets; `*.` of it is intercepted. */
|
|
13
|
+
export const NEON_DOMAIN = "neon.slicetest";
|
|
14
|
+
/** `url` (the real test database) with its host replaced by a Neon-style endpoint host. */
|
|
15
|
+
export function neonUrl(url) {
|
|
16
|
+
const u = new URL(url);
|
|
17
|
+
u.hostname = `ep-slicetest.${NEON_DOMAIN}`;
|
|
18
|
+
u.port = "";
|
|
19
|
+
u.searchParams.set("sslmode", "require");
|
|
20
|
+
return u.toString();
|
|
21
|
+
}
|
|
22
|
+
// Values stay as Postgres's text: the driver parses them itself from each field's type.
|
|
23
|
+
const RAW = { getTypeParser: () => (value) => value };
|
|
24
|
+
const ERROR_FIELDS = ["severity", "code", "detail", "hint", "position", "internalPosition", "internalQuery", "where", "schema", "table", "column", "dataType", "constraint", "file", "line", "routine"];
|
|
25
|
+
export class NeonEndpoint {
|
|
26
|
+
#server;
|
|
27
|
+
#pool;
|
|
28
|
+
constructor(databaseUrl) {
|
|
29
|
+
this.#pool = new pg.Pool({ connectionString: databaseUrl, max: 5, types: RAW });
|
|
30
|
+
this.#pool.on("error", () => { });
|
|
31
|
+
this.#server = http.createServer((req, res) => {
|
|
32
|
+
this.#handle(req, res).catch((e) => res.headersSent || res.writeHead(500).end(String(e)));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** `databaseUrl` is where queries really run: the worker database, or the `db.queries` proxy in front of it. */
|
|
36
|
+
static async start(databaseUrl) {
|
|
37
|
+
const endpoint = new NeonEndpoint(databaseUrl);
|
|
38
|
+
await new Promise((resolve) => endpoint.#server.listen(0, "127.0.0.1", resolve));
|
|
39
|
+
return endpoint;
|
|
40
|
+
}
|
|
41
|
+
get port() {
|
|
42
|
+
return this.#server.address().port;
|
|
43
|
+
}
|
|
44
|
+
/** Serve a connection the intercepting proxy terminated. */
|
|
45
|
+
attach(socket) {
|
|
46
|
+
this.#server.emit("connection", socket);
|
|
47
|
+
}
|
|
48
|
+
async #handle(req, res) {
|
|
49
|
+
const send = (status, body) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
50
|
+
if (req.method !== "POST" || !req.url?.startsWith("/sql"))
|
|
51
|
+
return send(404, { message: `slicetest's Neon endpoint only answers POST /sql, not ${req.method} ${req.url}` });
|
|
52
|
+
let body;
|
|
53
|
+
try {
|
|
54
|
+
let raw = "";
|
|
55
|
+
for await (const chunk of req)
|
|
56
|
+
raw += chunk;
|
|
57
|
+
body = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return send(400, { message: "invalid JSON body" });
|
|
61
|
+
}
|
|
62
|
+
const arrayMode = req.headers["neon-array-mode"] === "true";
|
|
63
|
+
const client = await this.#pool.connect();
|
|
64
|
+
try {
|
|
65
|
+
if ("queries" in body) {
|
|
66
|
+
const level = req.headers["neon-batch-isolation-level"];
|
|
67
|
+
const readOnly = req.headers["neon-batch-read-only"] === "true";
|
|
68
|
+
const deferrable = req.headers["neon-batch-deferrable"] === "true";
|
|
69
|
+
const mode = [level && `ISOLATION LEVEL ${String(level).replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase()}`, readOnly && "READ ONLY", deferrable && "DEFERRABLE"].filter(Boolean);
|
|
70
|
+
await client.query(`BEGIN${mode.length ? ` ${mode.join(" ")}` : ""}`);
|
|
71
|
+
try {
|
|
72
|
+
const results = [];
|
|
73
|
+
for (const q of body.queries)
|
|
74
|
+
results.push(await run(client, q, arrayMode));
|
|
75
|
+
await client.query("COMMIT");
|
|
76
|
+
return send(200, { results });
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
await client.query("ROLLBACK").catch(() => { });
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return send(200, await run(client, body, arrayMode));
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
// Shaped like Neon's: a message plus Postgres's error fields, which the driver copies onto its error.
|
|
87
|
+
const err = e;
|
|
88
|
+
return send(400, { message: err.message, ...Object.fromEntries(ERROR_FIELDS.filter((f) => err[f] !== undefined).map((f) => [f, err[f]])) });
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
client.release();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async close() {
|
|
95
|
+
this.#server.closeAllConnections();
|
|
96
|
+
await new Promise((resolve) => this.#server.close(resolve));
|
|
97
|
+
await this.#pool.end();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function run(client, { query, params = [] }, arrayMode) {
|
|
101
|
+
const result = await client.query({ text: query, values: params, rowMode: "array" });
|
|
102
|
+
const fields = result.fields.map((f) => ({
|
|
103
|
+
name: f.name,
|
|
104
|
+
tableID: f.tableID,
|
|
105
|
+
columnID: f.columnID,
|
|
106
|
+
dataTypeID: f.dataTypeID,
|
|
107
|
+
dataTypeSize: f.dataTypeSize,
|
|
108
|
+
dataTypeModifier: f.dataTypeModifier,
|
|
109
|
+
format: "text",
|
|
110
|
+
}));
|
|
111
|
+
const rows = arrayMode ? result.rows : result.rows.map((r) => Object.fromEntries(fields.map((f, i) => [f.name, r[i]])));
|
|
112
|
+
return { command: result.command, rowCount: result.rowCount, rows, fields, rowAsArray: arrayMode };
|
|
113
|
+
}
|
package/dist/openapi.d.ts
CHANGED
|
@@ -80,4 +80,6 @@ export declare function formatCoverage(spec: OpenApiSpec, hits: Set<string>): {
|
|
|
80
80
|
markdown: string;
|
|
81
81
|
text: string;
|
|
82
82
|
};
|
|
83
|
+
/** Where a spec fetched from the app (`openapi.fromApp`) is kept for the coverage report. */
|
|
84
|
+
export declare function appSpecFile(coverageDir: string): string;
|
|
83
85
|
export {};
|
package/dist/openapi.js
CHANGED
package/dist/runtime.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ import type { ResolvedOptions } from "./config.js";
|
|
|
4
4
|
import { Dependency } from "./containers.js";
|
|
5
5
|
import { Db } from "./db.js";
|
|
6
6
|
import { HttpClient } from "./http.js";
|
|
7
|
+
import { Interceptor } from "./intercept.js";
|
|
7
8
|
import { Mailbox } from "./mail.js";
|
|
9
|
+
import { NeonEndpoint } from "./neon.js";
|
|
8
10
|
import { QueryLog } from "./query-log.js";
|
|
9
11
|
import { Stub } from "./stub.js";
|
|
10
12
|
import { type MaskOptions, type Trace } from "./trace.js";
|
|
@@ -44,6 +46,8 @@ export declare class Runtime {
|
|
|
44
46
|
readonly mailbox?: Mailbox | undefined;
|
|
45
47
|
readonly issuer?: Issuer | undefined;
|
|
46
48
|
readonly queryLog?: QueryLog | undefined;
|
|
49
|
+
readonly interceptor?: Interceptor | undefined;
|
|
50
|
+
readonly neon?: NeonEndpoint | undefined;
|
|
47
51
|
private constructor();
|
|
48
52
|
get http(): HttpClient;
|
|
49
53
|
static start(opts: ResolvedOptions, shared: {
|
|
@@ -59,5 +63,13 @@ export declare class Runtime {
|
|
|
59
63
|
afterScenario(): Promise<void>;
|
|
60
64
|
/** What happened during the current scenario, printed when it fails. */
|
|
61
65
|
diagnostics(): Promise<string>;
|
|
66
|
+
/** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
|
|
67
|
+
flush(): Promise<void>;
|
|
62
68
|
stop(): Promise<void>;
|
|
63
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
|
|
72
|
+
* `spring.datasource.url` plus username / password), or separate host / port / name settings.
|
|
73
|
+
*/
|
|
74
|
+
export declare function connectionVars(engine: string, url: string, sqlitePath?: string): Record<string, string>;
|
|
75
|
+
export declare function blockedHint(hosts: string[]): string;
|
package/dist/runtime.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { App } from "./app.js";
|
|
5
6
|
import { Issuer } from "./auth.js";
|
|
6
7
|
import { Dependency } from "./containers.js";
|
|
7
|
-
import { Db, formatChanges } from "./db.js";
|
|
8
|
+
import { Db, formatChanges, noDatabase } from "./db.js";
|
|
8
9
|
import { engineFor } from "./drivers/index.js";
|
|
9
10
|
import { formatHistory, HttpClient } from "./http.js";
|
|
11
|
+
import { Interceptor } from "./intercept.js";
|
|
10
12
|
import { Mailbox } from "./mail.js";
|
|
11
|
-
import {
|
|
13
|
+
import { NEON_DOMAIN, NeonEndpoint, neonUrl } from "./neon.js";
|
|
14
|
+
import { appSpecFile, OpenApiSpec } from "./openapi.js";
|
|
12
15
|
import { QueryLog } from "./query-log.js";
|
|
13
16
|
import { Recorder } from "./recording.js";
|
|
14
17
|
import { Stub } from "./stub.js";
|
|
@@ -29,12 +32,14 @@ export class Runtime {
|
|
|
29
32
|
mailbox;
|
|
30
33
|
issuer;
|
|
31
34
|
queryLog;
|
|
35
|
+
interceptor;
|
|
36
|
+
neon;
|
|
32
37
|
#http;
|
|
33
38
|
/** Responses from the app that don't match its OpenAPI spec, this scenario. */
|
|
34
39
|
#contract = [];
|
|
35
40
|
/** Documented responses seen in this file, for the run's coverage report. */
|
|
36
41
|
#covered = new Set();
|
|
37
|
-
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox, issuer, queryLog) {
|
|
42
|
+
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox, issuer, queryLog, interceptor, neon) {
|
|
38
43
|
this.app = app;
|
|
39
44
|
this.services = services;
|
|
40
45
|
this.db = db;
|
|
@@ -49,10 +54,14 @@ export class Runtime {
|
|
|
49
54
|
this.mailbox = mailbox;
|
|
50
55
|
this.issuer = issuer;
|
|
51
56
|
this.queryLog = queryLog;
|
|
57
|
+
this.interceptor = interceptor;
|
|
58
|
+
this.neon = neon;
|
|
52
59
|
this.#http = this.#client();
|
|
53
60
|
}
|
|
54
61
|
#client() {
|
|
55
62
|
const http = new HttpClient(this.app.url, this.opts.http);
|
|
63
|
+
for (const [host, name] of Object.entries(this.opts.intercept))
|
|
64
|
+
http.intercept(host, this.stubs.get(name).url);
|
|
56
65
|
const spec = this.specs.app;
|
|
57
66
|
if (spec) {
|
|
58
67
|
http.onResponse((res) => {
|
|
@@ -74,8 +83,8 @@ export class Runtime {
|
|
|
74
83
|
return this.#http;
|
|
75
84
|
}
|
|
76
85
|
static async start(opts, shared) {
|
|
77
|
-
const engine = await engineFor(opts);
|
|
78
|
-
const url = await ensureWorkerDatabase(engine, shared.adminUrl, shared.template, shared.prefix);
|
|
86
|
+
const engine = opts.db.none ? undefined : await engineFor(opts);
|
|
87
|
+
const url = engine ? await ensureWorkerDatabase(engine, shared.adminUrl, shared.template, shared.prefix) : "";
|
|
79
88
|
const stubs = new Map();
|
|
80
89
|
const recorders = new Map();
|
|
81
90
|
const containers = new Map();
|
|
@@ -84,6 +93,8 @@ export class Runtime {
|
|
|
84
93
|
let mailbox;
|
|
85
94
|
let issuer;
|
|
86
95
|
let queryLog;
|
|
96
|
+
let interceptor;
|
|
97
|
+
let neon;
|
|
87
98
|
try {
|
|
88
99
|
// Loaded first: a broken spec should fail before anything is started.
|
|
89
100
|
const specs = {
|
|
@@ -104,31 +115,40 @@ export class Runtime {
|
|
|
104
115
|
continue;
|
|
105
116
|
stub.fallback(async (call) => (await recorder?.answer(call)) ?? spec?.exampleResponse(call.method, call.path), recorder?.hint());
|
|
106
117
|
}
|
|
107
|
-
db =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
db = engine
|
|
119
|
+
? await Db.connect(await engine.driver(url), url, {
|
|
120
|
+
schemas: opts.db.schemas,
|
|
121
|
+
keep: opts.db.keep,
|
|
122
|
+
seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
|
|
123
|
+
})
|
|
124
|
+
: noDatabase();
|
|
112
125
|
const started = await Promise.allSettled(Object.entries(opts.containers).map(async ([name, c]) => containers.set(name, await Dependency.start(name, c))));
|
|
113
126
|
const failed = started.find((r) => r.status === "rejected");
|
|
114
127
|
if (failed)
|
|
115
128
|
throw failed.reason;
|
|
116
|
-
const vars = { "db.url": url };
|
|
117
|
-
if (opts.db.queries && engine.name !== "sqlite") {
|
|
129
|
+
const vars = engine ? { "db.url": url } : {};
|
|
130
|
+
if (engine && opts.db.queries && engine.name !== "sqlite") {
|
|
118
131
|
const u = new URL(url);
|
|
119
132
|
queryLog = await QueryLog.start(engine.name, { host: u.hostname, port: Number(u.port || (engine.name === "mysql" ? 3306 : 5432)) });
|
|
120
133
|
vars["db.url"] = queryLog.proxyUrl(url);
|
|
121
134
|
db.attachQueryLog(queryLog);
|
|
122
135
|
}
|
|
123
|
-
if (engine
|
|
136
|
+
if (engine?.name === "sqlite")
|
|
124
137
|
vars["db.path"] = (await import("./drivers/sqlite.js")).sqlitePath(url);
|
|
138
|
+
if (engine)
|
|
139
|
+
Object.assign(vars, connectionVars(engine.name, vars["db.url"], vars["db.path"]));
|
|
140
|
+
if (opts.db.neon) {
|
|
141
|
+
// Queries the app sends to Neon's HTTP API run on the worker database (through the db.queries proxy, if on).
|
|
142
|
+
neon = await NeonEndpoint.start(vars["db.url"]);
|
|
143
|
+
vars["db.url"] = neonUrl(url);
|
|
144
|
+
}
|
|
125
145
|
if (opts.mail) {
|
|
126
146
|
mailbox = await Mailbox.start();
|
|
127
147
|
Object.assign(vars, { "mail.host": mailbox.host, "mail.port": String(mailbox.port), "mail.url": mailbox.url });
|
|
128
148
|
}
|
|
129
149
|
if (opts.auth) {
|
|
130
150
|
issuer = await Issuer.start(opts.auth);
|
|
131
|
-
Object.assign(vars, { "auth.issuer": issuer.url, "auth.jwks": issuer.jwksUrl, "auth.audience": issuer.audience });
|
|
151
|
+
Object.assign(vars, { "auth.issuer": issuer.url, "auth.jwks": issuer.jwksUrl, "auth.audience": issuer.audience, "auth.publicKey": issuer.publicKeyPem });
|
|
132
152
|
}
|
|
133
153
|
for (const [name, c] of containers) {
|
|
134
154
|
vars[`container.${name}`] = c.address;
|
|
@@ -137,6 +157,22 @@ export class Runtime {
|
|
|
137
157
|
}
|
|
138
158
|
for (const [name, stub] of stubs)
|
|
139
159
|
vars[`stub.${name}`] = stub.url;
|
|
160
|
+
if (Object.keys(opts.intercept).length > 0 || opts.offline || neon) {
|
|
161
|
+
const routes = new Map(Object.entries(opts.intercept).map(([host, name]) => {
|
|
162
|
+
const stub = stubs.get(name);
|
|
163
|
+
return [host, { attach: (s) => stub.attach(s), port: stub.port }];
|
|
164
|
+
}));
|
|
165
|
+
if (neon) {
|
|
166
|
+
const endpoint = neon;
|
|
167
|
+
routes.set(`*.${NEON_DOMAIN}`, { attach: (s) => endpoint.attach(s), port: endpoint.port });
|
|
168
|
+
}
|
|
169
|
+
interceptor = await Interceptor.start(routes);
|
|
170
|
+
interceptor.offline = opts.offline;
|
|
171
|
+
Object.assign(vars, { "proxy.url": interceptor.url, "proxy.ca": interceptor.files.ca, "proxy.bundle": interceptor.files.bundle, "proxy.truststore": interceptor.files.trustStore });
|
|
172
|
+
// Every process gets the proxy settings; a key in its own `env` still wins.
|
|
173
|
+
const baseEnv = interceptor.env();
|
|
174
|
+
opts = { ...opts, app: { ...opts.app, baseEnv }, services: Object.fromEntries(Object.entries(opts.services).map(([n, s]) => [n, { ...s, baseEnv }])) };
|
|
175
|
+
}
|
|
140
176
|
for (const [name, service] of Object.entries(opts.services)) {
|
|
141
177
|
const started = await App.start(service, opts.root, vars, `service.${name}`);
|
|
142
178
|
services.set(name, started);
|
|
@@ -144,7 +180,9 @@ export class Runtime {
|
|
|
144
180
|
vars[`service.${name}.port`] = String(started.port);
|
|
145
181
|
}
|
|
146
182
|
const app = await App.start(opts.app, opts.root, vars);
|
|
147
|
-
|
|
183
|
+
if (opts.openapi.fromApp)
|
|
184
|
+
specs.app = await fetchAppSpec(app.url, opts.openapi.fromApp, shared.coverageDir);
|
|
185
|
+
return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox, issuer, queryLog, interceptor, neon);
|
|
148
186
|
}
|
|
149
187
|
catch (e) {
|
|
150
188
|
await Promise.all([...services.values()].map((s) => s.stop()));
|
|
@@ -154,6 +192,10 @@ export class Runtime {
|
|
|
154
192
|
await mailbox?.close();
|
|
155
193
|
await issuer?.close();
|
|
156
194
|
await queryLog?.close();
|
|
195
|
+
if (interceptor?.blocked.size && e instanceof Error)
|
|
196
|
+
e.message += `\n${blockedHint([...interceptor.blocked])}`;
|
|
197
|
+
await interceptor?.close();
|
|
198
|
+
await neon?.close();
|
|
157
199
|
throw e;
|
|
158
200
|
}
|
|
159
201
|
}
|
|
@@ -223,6 +265,8 @@ export class Runtime {
|
|
|
223
265
|
this.mailbox?.reset();
|
|
224
266
|
this.issuer?.reset();
|
|
225
267
|
this.queryLog?.reset();
|
|
268
|
+
this.interceptor?.passedThrough.clear();
|
|
269
|
+
this.interceptor?.blocked.clear();
|
|
226
270
|
await Promise.all([...this.containers.values()].map((c) => c.reset()));
|
|
227
271
|
this.http.reset();
|
|
228
272
|
for (const p of this.#processes())
|
|
@@ -232,6 +276,8 @@ export class Runtime {
|
|
|
232
276
|
async afterScenario() {
|
|
233
277
|
await Promise.all(this.#processes().map((p) => p.settle()));
|
|
234
278
|
this.#assertAlive();
|
|
279
|
+
if (this.interceptor?.blocked.size)
|
|
280
|
+
throw new Error(blockedHint([...this.interceptor.blocked]));
|
|
235
281
|
const unmatched = this.#unmatched();
|
|
236
282
|
if (unmatched.length > 0) {
|
|
237
283
|
throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
|
|
@@ -301,15 +347,19 @@ export class Runtime {
|
|
|
301
347
|
sections.push(`OpenAPI mismatches:\n${contract.map((c) => ` ${c}`).join("\n")}`);
|
|
302
348
|
if (this.http.history.length > 0)
|
|
303
349
|
sections.push(`requests to the app:\n${formatHistory(this.http.history)}`);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
350
|
+
if (!this.opts.db.none)
|
|
351
|
+
try {
|
|
352
|
+
const changes = formatChanges(await this.db.changesSinceStart());
|
|
353
|
+
sections.push(changes ? `database changes during this scenario:\n${changes}` : "database changes during this scenario: (none)");
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
sections.push(`database changes during this scenario: unavailable (${e.message})`);
|
|
357
|
+
}
|
|
311
358
|
if (this.mailbox)
|
|
312
359
|
sections.push(this.mailbox.describe());
|
|
360
|
+
if (this.interceptor?.passedThrough.size) {
|
|
361
|
+
sections.push(`outbound calls to hosts no stub intercepts (sent to the real host; add them to a stub's \`hosts\` to answer them):\n${[...this.interceptor.passedThrough].map((h) => ` ${h}`).join("\n")}`);
|
|
362
|
+
}
|
|
313
363
|
if (this.queryLog) {
|
|
314
364
|
const q = this.queryLog.queries();
|
|
315
365
|
const top = q.shapes().slice(0, 5).map((s) => ` ${s.count > 1 ? `×${s.count} ` : ""}${s.sql.length > 160 ? `${s.sql.slice(0, 157)}...` : s.sql}`);
|
|
@@ -323,15 +373,21 @@ export class Runtime {
|
|
|
323
373
|
}
|
|
324
374
|
return sections.join("\n\n");
|
|
325
375
|
}
|
|
326
|
-
|
|
376
|
+
/** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
|
|
377
|
+
async flush() {
|
|
327
378
|
if (this.coverageDir && this.#covered.size > 0) {
|
|
328
379
|
await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
|
|
380
|
+
this.#covered.clear();
|
|
329
381
|
}
|
|
330
382
|
for (const [name, recorder] of this.recorders) {
|
|
331
|
-
|
|
332
|
-
|
|
383
|
+
const added = recorder.added().splice(0);
|
|
384
|
+
if (this.recordDir && added.length > 0) {
|
|
385
|
+
await writeFile(path.join(this.recordDir, `${name}.${process.pid}-${randomUUID()}.json`), JSON.stringify(added)).catch(() => { });
|
|
333
386
|
}
|
|
334
387
|
}
|
|
388
|
+
}
|
|
389
|
+
async stop() {
|
|
390
|
+
await this.flush();
|
|
335
391
|
const results = await Promise.allSettled([
|
|
336
392
|
this.app.stop(),
|
|
337
393
|
...[...this.services.values()].map((s) => s.stop()),
|
|
@@ -341,6 +397,8 @@ export class Runtime {
|
|
|
341
397
|
this.mailbox?.close(),
|
|
342
398
|
this.issuer?.close(),
|
|
343
399
|
this.queryLog?.close(),
|
|
400
|
+
this.interceptor?.close(),
|
|
401
|
+
this.neon?.close(),
|
|
344
402
|
]);
|
|
345
403
|
const failed = results.find((r) => r.status === "rejected");
|
|
346
404
|
if (failed)
|
|
@@ -381,3 +439,47 @@ function parseJson(text) {
|
|
|
381
439
|
return undefined;
|
|
382
440
|
}
|
|
383
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
|
|
444
|
+
* `spring.datasource.url` plus username / password), or separate host / port / name settings.
|
|
445
|
+
*/
|
|
446
|
+
export function connectionVars(engine, url, sqlitePath) {
|
|
447
|
+
if (engine === "sqlite")
|
|
448
|
+
return { "db.jdbcUrl": `jdbc:sqlite:${sqlitePath}` };
|
|
449
|
+
const u = new URL(url);
|
|
450
|
+
const port = u.port || (engine === "mysql" ? "3306" : "5432");
|
|
451
|
+
const name = decodeURIComponent(u.pathname.replace(/^\//, ""));
|
|
452
|
+
return {
|
|
453
|
+
"db.host": u.hostname,
|
|
454
|
+
"db.port": port,
|
|
455
|
+
"db.name": name,
|
|
456
|
+
"db.user": decodeURIComponent(u.username),
|
|
457
|
+
"db.password": decodeURIComponent(u.password),
|
|
458
|
+
"db.jdbcUrl": `jdbc:${engine === "mysql" ? "mysql" : "postgresql"}://${u.hostname}:${port}/${encodeURIComponent(name)}`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/** The app's own spec, served at `route` (springdoc's /v3/api-docs, FastAPI's /openapi.json, …). */
|
|
462
|
+
async function fetchAppSpec(appUrl, route, coverageDir) {
|
|
463
|
+
let text;
|
|
464
|
+
try {
|
|
465
|
+
const res = await fetch(new URL(route, appUrl));
|
|
466
|
+
if (!res.ok)
|
|
467
|
+
throw new Error(`it answered ${res.status}`);
|
|
468
|
+
text = await res.text();
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
throw new Error(`slicetest: openapi.fromApp: couldn't get the spec from GET ${route}: ${e.message}`);
|
|
472
|
+
}
|
|
473
|
+
const file = coverageDir ? appSpecFile(coverageDir) : path.join(os.tmpdir(), `slicetest-spec-${process.pid}.json`);
|
|
474
|
+
await writeFile(file, text);
|
|
475
|
+
return OpenApiSpec.load(file, `GET ${route}`);
|
|
476
|
+
}
|
|
477
|
+
/** Hosts package managers download from: a build tool fetching dependencies while it starts the app. */
|
|
478
|
+
const REGISTRIES = /(^|\.)(maven\.apache\.org|repo1\.maven\.org|plugins\.gradle\.org|services\.gradle\.org|registry\.npmjs\.org|registry\.yarnpkg\.com|proxy\.golang\.org|sum\.golang\.org|pypi\.org|files\.pythonhosted\.org|crates\.io|rubygems\.org)$/;
|
|
479
|
+
export function blockedHint(hosts) {
|
|
480
|
+
const message = `slicetest: offline: the app tried to reach ${hosts.join(", ")}, which no stub answers. Add ${hosts.length > 1 ? "them" : "it"} to a stub's \`hosts\` (or remove \`offline\`).`;
|
|
481
|
+
const registries = hosts.filter((h) => REGISTRIES.test(h));
|
|
482
|
+
if (registries.length === 0)
|
|
483
|
+
return message;
|
|
484
|
+
return `${message}\n${registries.join(", ")} ${registries.length > 1 ? "are package registries" : "is a package registry"}: the command that starts the app (gradle bootRun, mvn spring-boot:run, go run, …) is downloading dependencies, through slicetest's proxy. Download them in \`app.build\` (./gradlew bootJar, mvn package), or start a built artifact (java -jar).`;
|
|
485
|
+
}
|
package/dist/setup-file.js
CHANGED
|
@@ -4,11 +4,28 @@ import { Runtime } from "./runtime.js";
|
|
|
4
4
|
import "./provided.js";
|
|
5
5
|
import "./matchers.js";
|
|
6
6
|
let runtime;
|
|
7
|
+
// With app.scope "worker" the runtime outlives the file: Vitest runs this worker's next
|
|
8
|
+
// files in the same module state (isolate: false), and the processes go when it exits.
|
|
9
|
+
const shared = globalThis;
|
|
7
10
|
beforeAll(async () => {
|
|
8
|
-
|
|
11
|
+
const options = inject("slicetestOptions");
|
|
12
|
+
if (options.app.scope === "worker") {
|
|
13
|
+
const starting = (shared.__slicetestRuntime ??= Runtime.start(options, inject("slicetestDb")));
|
|
14
|
+
starting.catch(() => {
|
|
15
|
+
if (shared.__slicetestRuntime === starting)
|
|
16
|
+
shared.__slicetestRuntime = undefined;
|
|
17
|
+
});
|
|
18
|
+
runtime = await starting;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
runtime = await Runtime.start(options, inject("slicetestDb"));
|
|
22
|
+
}
|
|
9
23
|
setRuntime(runtime);
|
|
10
24
|
});
|
|
11
25
|
afterAll(async () => {
|
|
12
26
|
setRuntime(undefined);
|
|
13
|
-
|
|
27
|
+
if (inject("slicetestOptions").app.scope === "worker")
|
|
28
|
+
await runtime?.flush();
|
|
29
|
+
else
|
|
30
|
+
await runtime?.stop();
|
|
14
31
|
});
|
package/dist/stub.d.ts
CHANGED
|
@@ -47,6 +47,17 @@ export interface StubResponse {
|
|
|
47
47
|
/** Objects are sent as JSON. */
|
|
48
48
|
body?: unknown;
|
|
49
49
|
}
|
|
50
|
+
/** One server-sent event: `[event, data]`, or `{ event, data, id }`. Data that isn't a string is sent as JSON. */
|
|
51
|
+
export type ServerSentEvent = [event: string, data: unknown] | {
|
|
52
|
+
event?: string;
|
|
53
|
+
data: unknown;
|
|
54
|
+
id?: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* A streaming reply in Server-Sent Events format, as LLM APIs stream (Anthropic, OpenAI):
|
|
58
|
+
* `stub("anthropic").on("POST", "/v1/messages").reply(sse([["message_start", {...}], ...]))`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function sse(events: ServerSentEvent[], init?: Omit<StubResponse, "body">): StubResponse;
|
|
50
61
|
export type Responder = StubResponse | ((call: RecordedCall) => StubResponse | Promise<StubResponse>);
|
|
51
62
|
/**
|
|
52
63
|
* Extra conditions a call must meet for a route to answer it. Plain values are
|
|
@@ -85,6 +96,9 @@ export declare class Stub {
|
|
|
85
96
|
readonly name: string;
|
|
86
97
|
url: string;
|
|
87
98
|
private constructor();
|
|
99
|
+
/** Serve a connection that arrived elsewhere, e.g. TLS the intercepting proxy terminated. */
|
|
100
|
+
attach(socket: import("node:net").Socket): void;
|
|
101
|
+
get port(): number;
|
|
88
102
|
static start(name: string): Promise<Stub>;
|
|
89
103
|
/**
|
|
90
104
|
* Answer `method path`. `path` may contain `:name` segments (captured into
|
package/dist/stub.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
/**
|
|
4
|
+
* A streaming reply in Server-Sent Events format, as LLM APIs stream (Anthropic, OpenAI):
|
|
5
|
+
* `stub("anthropic").on("POST", "/v1/messages").reply(sse([["message_start", {...}], ...]))`.
|
|
6
|
+
*/
|
|
7
|
+
export function sse(events, init = {}) {
|
|
8
|
+
const body = events
|
|
9
|
+
.map((e) => {
|
|
10
|
+
const { event, data, id } = Array.isArray(e) ? { event: e[0], data: e[1], id: undefined } : e;
|
|
11
|
+
const text = typeof data === "string" ? data : JSON.stringify(data);
|
|
12
|
+
const lines = [...(id !== undefined ? [`id: ${id}`] : []), ...(event ? [`event: ${event}`] : []), ...text.split("\n").map((l) => `data: ${l}`)];
|
|
13
|
+
return `${lines.join("\n")}\n\n`;
|
|
14
|
+
})
|
|
15
|
+
.join("");
|
|
16
|
+
return { status: init.status ?? 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache", ...init.headers }, body };
|
|
17
|
+
}
|
|
3
18
|
/**
|
|
4
19
|
* A fake outbound service. The app is pointed at `url`; tests register routes
|
|
5
20
|
* with `on()` and inspect what the app sent with `calls()`.
|
|
@@ -21,6 +36,13 @@ export class Stub {
|
|
|
21
36
|
this.#handle(req, res).catch(() => res.destroy());
|
|
22
37
|
});
|
|
23
38
|
}
|
|
39
|
+
/** Serve a connection that arrived elsewhere, e.g. TLS the intercepting proxy terminated. */
|
|
40
|
+
attach(socket) {
|
|
41
|
+
this.#server.emit("connection", socket);
|
|
42
|
+
}
|
|
43
|
+
get port() {
|
|
44
|
+
return Number(new URL(this.url).port);
|
|
45
|
+
}
|
|
24
46
|
static async start(name) {
|
|
25
47
|
const stub = new Stub(name);
|
|
26
48
|
await new Promise((resolve) => stub.#server.listen(0, "127.0.0.1", resolve));
|
package/dist/vitest.d.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import type { Plugin } from "vite";
|
|
2
2
|
import { type SlicetestOptions } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reads a slicetest.config.yaml, so the CLI and a vitest.config share one file.
|
|
5
|
+
* `file` is relative to `root`; without it the first of CONFIG_NAMES in `root` is used.
|
|
6
|
+
* The CLI-only `include` is dropped.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadConfigFile(root: string, file?: string): SlicetestOptions;
|
|
3
9
|
/** YAML scenario files are picked up next to the regular test files. */
|
|
4
10
|
export declare const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
|
|
5
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The Vitest plugin. Pass the options, or the path of a slicetest.config.yaml (relative to the
|
|
13
|
+
* Vitest root), or nothing to read the slicetest.config.yaml next to the Vitest config.
|
|
14
|
+
*/
|
|
15
|
+
export declare function slicetest(options?: SlicetestOptions | string): Plugin;
|
|
6
16
|
export type { SlicetestOptions } from "./config.js";
|
package/dist/vitest.js
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { configDefaults } from "vitest/config";
|
|
4
|
-
import { resolveOptions } from "./config.js";
|
|
5
|
+
import { CONFIG_NAMES, resolveOptions } from "./config.js";
|
|
5
6
|
import { SESSION_ENV } from "./record.js";
|
|
7
|
+
import { parse } from "yaml";
|
|
6
8
|
import { parseScenarioFile } from "./yaml.js";
|
|
9
|
+
/**
|
|
10
|
+
* Reads a slicetest.config.yaml, so the CLI and a vitest.config share one file.
|
|
11
|
+
* `file` is relative to `root`; without it the first of CONFIG_NAMES in `root` is used.
|
|
12
|
+
* The CLI-only `include` is dropped.
|
|
13
|
+
*/
|
|
14
|
+
export function loadConfigFile(root, file) {
|
|
15
|
+
const found = file ? path.resolve(root, file) : CONFIG_NAMES.map((n) => path.join(root, n)).find((p) => existsSync(p));
|
|
16
|
+
if (!found || !existsSync(found)) {
|
|
17
|
+
throw new Error(`slicetest: ${file ? `config file ${found} not found` : `no ${CONFIG_NAMES.join(" / ")} in ${root}; pass the options to slicetest({ ... }) or run "npx slicetest init"`}`);
|
|
18
|
+
}
|
|
19
|
+
const { include: _, ...options } = (parse(readFileSync(found, "utf8")) ?? {});
|
|
20
|
+
return options;
|
|
21
|
+
}
|
|
7
22
|
/** YAML scenario files are picked up next to the regular test files. */
|
|
8
23
|
export const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
|
|
9
24
|
const YAML_ID = /\.scenario\.ya?ml$/;
|
|
10
25
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
26
|
// Resolve sibling modules with this file's own extension, so it works from both src (.ts) and dist (.js).
|
|
12
27
|
const ext = path.extname(fileURLToPath(import.meta.url));
|
|
28
|
+
/**
|
|
29
|
+
* The Vitest plugin. Pass the options, or the path of a slicetest.config.yaml (relative to the
|
|
30
|
+
* Vitest root), or nothing to read the slicetest.config.yaml next to the Vitest config.
|
|
31
|
+
*/
|
|
13
32
|
export function slicetest(options) {
|
|
14
33
|
let root;
|
|
15
34
|
return {
|
|
@@ -21,11 +40,17 @@ export function slicetest(options) {
|
|
|
21
40
|
// `slicetest record` runs only its session file.
|
|
22
41
|
if (!process.env[SESSION_ENV])
|
|
23
42
|
test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
|
|
43
|
+
const resolved = resolveOptions(typeof options === "object" ? options : loadConfigFile(root, options), root);
|
|
24
44
|
return {
|
|
25
45
|
test: {
|
|
46
|
+
// One app per worker means module state (the running app) must survive from file to file.
|
|
47
|
+
...(resolved.app.scope === "worker" ? { isolate: false } : {}),
|
|
48
|
+
// Vitest runs projects with different maxWorkers in separate groups and wants a distinct
|
|
49
|
+
// groupOrder for each; one per worker count keeps slicetest projects out of each other's way.
|
|
50
|
+
...(resolved.workers ? { maxWorkers: resolved.workers, sequence: { groupOrder: 1000 + resolved.workers } } : {}),
|
|
26
51
|
globalSetup: [path.join(here, `global-setup${ext}`)],
|
|
27
52
|
setupFiles: [path.join(here, `setup-file${ext}`)],
|
|
28
|
-
provide: { slicetestOptions:
|
|
53
|
+
provide: { slicetestOptions: resolved },
|
|
29
54
|
hookTimeout: 120_000,
|
|
30
55
|
},
|
|
31
56
|
};
|
package/dist/x509.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough X.509 to impersonate hosts in tests: a throwaway certificate
|
|
3
|
+
* authority, leaf certificates for the intercepted hosts, and the CA in the
|
|
4
|
+
* formats apps read it from (PEM bundle, and a PKCS#12 trust store for Java).
|
|
5
|
+
* DER is written by hand so the package needs no crypto dependency.
|
|
6
|
+
*/
|
|
7
|
+
import { KeyObject } from "node:crypto";
|
|
8
|
+
export declare const seq: (...items: Buffer[]) => Buffer<ArrayBuffer>;
|
|
9
|
+
interface Issued {
|
|
10
|
+
key: KeyObject;
|
|
11
|
+
der: Buffer;
|
|
12
|
+
pem: string;
|
|
13
|
+
}
|
|
14
|
+
/** A certificate authority that exists for one test run. */
|
|
15
|
+
export declare class Authority {
|
|
16
|
+
#private;
|
|
17
|
+
readonly key: KeyObject;
|
|
18
|
+
readonly der: Buffer;
|
|
19
|
+
readonly pem: string;
|
|
20
|
+
constructor();
|
|
21
|
+
/** A certificate for `host`, signed by this authority (cached). */
|
|
22
|
+
leaf(host: string): Issued;
|
|
23
|
+
}
|
|
24
|
+
/** PEM bundle of `extra` plus the usual public roots, for SSL_CERT_FILE and friends (which replace the default store). */
|
|
25
|
+
export declare function pemBundle(roots: readonly string[], extra: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* A PKCS#12 trust store of certificates, readable by Java without a password
|
|
28
|
+
* (`-Djavax.net.ssl.trustStoreType=PKCS12`, no `trustStorePassword`): no MAC and
|
|
29
|
+
* unencrypted certificate bags, each marked as a trusted certificate entry.
|
|
30
|
+
*/
|
|
31
|
+
export declare function pkcs12TrustStore(certs: {
|
|
32
|
+
alias: string;
|
|
33
|
+
der: Buffer;
|
|
34
|
+
}[]): Buffer<ArrayBuffer>;
|
|
35
|
+
/** DER of a PEM certificate, or undefined when it doesn't parse. */
|
|
36
|
+
export declare function pemToDer(pem: string): Buffer<ArrayBuffer> | undefined;
|
|
37
|
+
export {};
|