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/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
@@ -363,3 +363,7 @@ function nullableToType(node) {
363
363
  }
364
364
  return node;
365
365
  }
366
+ /** Where a spec fetched from the app (`openapi.fromApp`) is kept for the coverage report. */
367
+ export function appSpecFile(coverageDir) {
368
+ return `${coverageDir}.spec.json`;
369
+ }
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 { OpenApiSpec } from "./openapi.js";
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 = await Db.connect(await engine.driver(url), url, {
108
- schemas: opts.db.schemas,
109
- keep: opts.db.keep,
110
- seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
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.name === "sqlite")
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
- return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox, issuer, queryLog);
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
- try {
305
- const changes = formatChanges(await this.db.changesSinceStart());
306
- sections.push(changes ? `database changes during this scenario:\n${changes}` : "database changes during this scenario: (none)");
307
- }
308
- catch (e) {
309
- sections.push(`database changes during this scenario: unavailable (${e.message})`);
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
- async stop() {
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
- if (this.recordDir && recorder.added().length > 0) {
332
- await writeFile(path.join(this.recordDir, `${name}.${process.pid}-${randomUUID()}.json`), JSON.stringify(recorder.added())).catch(() => { });
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
+ }
@@ -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
- runtime = await Runtime.start(inject("slicetestOptions"), inject("slicetestDb"));
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
- await runtime?.stop();
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
- export declare function slicetest(options: SlicetestOptions): Plugin;
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: resolveOptions(options, root) },
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 {};