slicetest 0.4.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.
Files changed (58) hide show
  1. package/README.md +293 -7
  2. package/dist/app.js +2 -2
  3. package/dist/auth.d.ts +55 -0
  4. package/dist/auth.js +140 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +18 -6
  7. package/dist/config.d.ts +72 -5
  8. package/dist/config.js +74 -6
  9. package/dist/containers.d.ts +2 -0
  10. package/dist/containers.js +20 -1
  11. package/dist/db.d.ts +28 -0
  12. package/dist/db.js +68 -0
  13. package/dist/doctor.js +17 -5
  14. package/dist/drivers/driver.d.ts +27 -0
  15. package/dist/drivers/mysql.d.ts +2 -1
  16. package/dist/drivers/mysql.js +38 -0
  17. package/dist/drivers/postgres.d.ts +2 -1
  18. package/dist/drivers/postgres.js +28 -0
  19. package/dist/drivers/sqlite.d.ts +2 -1
  20. package/dist/drivers/sqlite.js +52 -0
  21. package/dist/factory.d.ts +21 -0
  22. package/dist/factory.js +129 -0
  23. package/dist/form.d.ts +45 -0
  24. package/dist/form.js +226 -0
  25. package/dist/gen.d.ts +1 -0
  26. package/dist/gen.js +9 -4
  27. package/dist/global-setup.d.ts +1 -0
  28. package/dist/global-setup.js +90 -34
  29. package/dist/http.d.ts +24 -1
  30. package/dist/http.js +86 -9
  31. package/dist/index.d.ts +6 -1
  32. package/dist/index.js +3 -0
  33. package/dist/init.js +245 -24
  34. package/dist/intercept.d.ts +37 -0
  35. package/dist/intercept.js +199 -0
  36. package/dist/neon.d.ts +14 -0
  37. package/dist/neon.js +113 -0
  38. package/dist/openapi.d.ts +10 -0
  39. package/dist/openapi.js +22 -0
  40. package/dist/query-log.d.ts +49 -0
  41. package/dist/query-log.js +261 -0
  42. package/dist/runtime.d.ts +18 -0
  43. package/dist/runtime.js +163 -23
  44. package/dist/setup-file.js +19 -2
  45. package/dist/stub.d.ts +46 -0
  46. package/dist/stub.js +109 -0
  47. package/dist/vitest.d.ts +11 -1
  48. package/dist/vitest.js +27 -2
  49. package/dist/webhook.d.ts +40 -0
  50. package/dist/webhook.js +52 -0
  51. package/dist/x509.d.ts +37 -0
  52. package/dist/x509.js +150 -0
  53. package/dist/yaml-runtime.js +76 -16
  54. package/dist/yaml.d.ts +67 -1
  55. package/dist/yaml.js +87 -3
  56. package/package.json +12 -4
  57. package/preload/node-proxy.cjs +19 -0
  58. package/schema/scenario.schema.json +320 -0
package/dist/runtime.js CHANGED
@@ -1,13 +1,18 @@
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";
6
+ import { Issuer } from "./auth.js";
5
7
  import { Dependency } from "./containers.js";
6
- import { Db, formatChanges } from "./db.js";
8
+ import { Db, formatChanges, noDatabase } from "./db.js";
7
9
  import { engineFor } from "./drivers/index.js";
8
10
  import { formatHistory, HttpClient } from "./http.js";
11
+ import { Interceptor } from "./intercept.js";
9
12
  import { Mailbox } from "./mail.js";
10
- import { OpenApiSpec } from "./openapi.js";
13
+ import { NEON_DOMAIN, NeonEndpoint, neonUrl } from "./neon.js";
14
+ import { appSpecFile, OpenApiSpec } from "./openapi.js";
15
+ import { QueryLog } from "./query-log.js";
11
16
  import { Recorder } from "./recording.js";
12
17
  import { Stub } from "./stub.js";
13
18
  import { buildTrace, mask } from "./trace.js";
@@ -25,12 +30,16 @@ export class Runtime {
25
30
  recordDir;
26
31
  containers;
27
32
  mailbox;
33
+ issuer;
34
+ queryLog;
35
+ interceptor;
36
+ neon;
28
37
  #http;
29
38
  /** Responses from the app that don't match its OpenAPI spec, this scenario. */
30
39
  #contract = [];
31
40
  /** Documented responses seen in this file, for the run's coverage report. */
32
41
  #covered = new Set();
33
- constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox) {
42
+ constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox, issuer, queryLog, interceptor, neon) {
34
43
  this.app = app;
35
44
  this.services = services;
36
45
  this.db = db;
@@ -43,10 +52,16 @@ export class Runtime {
43
52
  this.recordDir = recordDir;
44
53
  this.containers = containers;
45
54
  this.mailbox = mailbox;
55
+ this.issuer = issuer;
56
+ this.queryLog = queryLog;
57
+ this.interceptor = interceptor;
58
+ this.neon = neon;
46
59
  this.#http = this.#client();
47
60
  }
48
61
  #client() {
49
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);
50
65
  const spec = this.specs.app;
51
66
  if (spec) {
52
67
  http.onResponse((res) => {
@@ -68,14 +83,18 @@ export class Runtime {
68
83
  return this.#http;
69
84
  }
70
85
  static async start(opts, shared) {
71
- const engine = await engineFor(opts);
72
- 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) : "";
73
88
  const stubs = new Map();
74
89
  const recorders = new Map();
75
90
  const containers = new Map();
76
91
  const services = new Map();
77
92
  let db;
78
93
  let mailbox;
94
+ let issuer;
95
+ let queryLog;
96
+ let interceptor;
97
+ let neon;
79
98
  try {
80
99
  // Loaded first: a broken spec should fail before anything is started.
81
100
  const specs = {
@@ -96,22 +115,41 @@ export class Runtime {
96
115
  continue;
97
116
  stub.fallback(async (call) => (await recorder?.answer(call)) ?? spec?.exampleResponse(call.method, call.path), recorder?.hint());
98
117
  }
99
- db = await Db.connect(await engine.driver(url), url, {
100
- schemas: opts.db.schemas,
101
- keep: opts.db.keep,
102
- seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
103
- });
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();
104
125
  const started = await Promise.allSettled(Object.entries(opts.containers).map(async ([name, c]) => containers.set(name, await Dependency.start(name, c))));
105
126
  const failed = started.find((r) => r.status === "rejected");
106
127
  if (failed)
107
128
  throw failed.reason;
108
- const vars = { "db.url": url };
109
- if (engine.name === "sqlite")
129
+ const vars = engine ? { "db.url": url } : {};
130
+ if (engine && opts.db.queries && engine.name !== "sqlite") {
131
+ const u = new URL(url);
132
+ queryLog = await QueryLog.start(engine.name, { host: u.hostname, port: Number(u.port || (engine.name === "mysql" ? 3306 : 5432)) });
133
+ vars["db.url"] = queryLog.proxyUrl(url);
134
+ db.attachQueryLog(queryLog);
135
+ }
136
+ if (engine?.name === "sqlite")
110
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
+ }
111
145
  if (opts.mail) {
112
146
  mailbox = await Mailbox.start();
113
147
  Object.assign(vars, { "mail.host": mailbox.host, "mail.port": String(mailbox.port), "mail.url": mailbox.url });
114
148
  }
149
+ if (opts.auth) {
150
+ issuer = await Issuer.start(opts.auth);
151
+ Object.assign(vars, { "auth.issuer": issuer.url, "auth.jwks": issuer.jwksUrl, "auth.audience": issuer.audience, "auth.publicKey": issuer.publicKeyPem });
152
+ }
115
153
  for (const [name, c] of containers) {
116
154
  vars[`container.${name}`] = c.address;
117
155
  vars[`container.${name}.host`] = c.host;
@@ -119,6 +157,22 @@ export class Runtime {
119
157
  }
120
158
  for (const [name, stub] of stubs)
121
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
+ }
122
176
  for (const [name, service] of Object.entries(opts.services)) {
123
177
  const started = await App.start(service, opts.root, vars, `service.${name}`);
124
178
  services.set(name, started);
@@ -126,7 +180,9 @@ export class Runtime {
126
180
  vars[`service.${name}.port`] = String(started.port);
127
181
  }
128
182
  const app = await App.start(opts.app, opts.root, vars);
129
- return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox);
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);
130
186
  }
131
187
  catch (e) {
132
188
  await Promise.all([...services.values()].map((s) => s.stop()));
@@ -134,11 +190,18 @@ export class Runtime {
134
190
  await Promise.all([...stubs.values()].map((s) => s.close()));
135
191
  await Promise.allSettled([...containers.values()].map((c) => c.stop()));
136
192
  await mailbox?.close();
193
+ await issuer?.close();
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();
137
199
  throw e;
138
200
  }
139
201
  }
140
202
  context() {
141
203
  const mailbox = this.mailbox;
204
+ const issuer = this.issuer;
142
205
  return {
143
206
  http: this.http,
144
207
  db: this.db,
@@ -169,6 +232,11 @@ export class Runtime {
169
232
  throw new Error("slicetest: mail is off. Add `mail: true` to the config and point the app's SMTP settings at {{mail.host}} / {{mail.port}}.");
170
233
  return mailbox;
171
234
  },
235
+ get auth() {
236
+ if (!issuer)
237
+ throw new Error("slicetest: auth is off. Add `auth: true` to the config and point the app's JWT settings at {{auth.issuer}} / {{auth.jwks}}.");
238
+ return issuer;
239
+ },
172
240
  };
173
241
  }
174
242
  /** The app and every service, for checks that apply to all of them. */
@@ -195,6 +263,10 @@ export class Runtime {
195
263
  for (const recorder of this.recorders.values())
196
264
  recorder.reset();
197
265
  this.mailbox?.reset();
266
+ this.issuer?.reset();
267
+ this.queryLog?.reset();
268
+ this.interceptor?.passedThrough.clear();
269
+ this.interceptor?.blocked.clear();
198
270
  await Promise.all([...this.containers.values()].map((c) => c.reset()));
199
271
  this.http.reset();
200
272
  for (const p of this.#processes())
@@ -204,6 +276,8 @@ export class Runtime {
204
276
  async afterScenario() {
205
277
  await Promise.all(this.#processes().map((p) => p.settle()));
206
278
  this.#assertAlive();
279
+ if (this.interceptor?.blocked.size)
280
+ throw new Error(blockedHint([...this.interceptor.blocked]));
207
281
  const unmatched = this.#unmatched();
208
282
  if (unmatched.length > 0) {
209
283
  throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
@@ -265,20 +339,32 @@ export class Runtime {
265
339
  const unmatched = this.#unmatched();
266
340
  if (unmatched.length > 0)
267
341
  sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
342
+ const chaos = [...this.stubs.values()].map((s) => s.describeChaos()).filter((c) => c !== undefined);
343
+ if (chaos.length > 0)
344
+ sections.push(chaos.join("\n"));
268
345
  const contract = this.#contractViolations();
269
346
  if (contract.length > 0)
270
347
  sections.push(`OpenAPI mismatches:\n${contract.map((c) => ` ${c}`).join("\n")}`);
271
348
  if (this.http.history.length > 0)
272
349
  sections.push(`requests to the app:\n${formatHistory(this.http.history)}`);
273
- try {
274
- const changes = formatChanges(await this.db.changesSinceStart());
275
- sections.push(changes ? `database changes during this scenario:\n${changes}` : "database changes during this scenario: (none)");
276
- }
277
- catch (e) {
278
- sections.push(`database changes during this scenario: unavailable (${e.message})`);
279
- }
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
+ }
280
358
  if (this.mailbox)
281
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
+ }
363
+ if (this.queryLog) {
364
+ const q = this.queryLog.queries();
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}`);
366
+ sections.push(q.length ? `SQL the app ran during this scenario (${q.length} statements, most frequent first):\n${top.join("\n")}` : "SQL the app ran during this scenario: (none)");
367
+ }
282
368
  const logs = this.app.scenarioLogs();
283
369
  sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
284
370
  for (const [name, service] of this.services) {
@@ -287,15 +373,21 @@ export class Runtime {
287
373
  }
288
374
  return sections.join("\n\n");
289
375
  }
290
- async stop() {
376
+ /** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
377
+ async flush() {
291
378
  if (this.coverageDir && this.#covered.size > 0) {
292
379
  await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
380
+ this.#covered.clear();
293
381
  }
294
382
  for (const [name, recorder] of this.recorders) {
295
- if (this.recordDir && recorder.added().length > 0) {
296
- 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(() => { });
297
386
  }
298
387
  }
388
+ }
389
+ async stop() {
390
+ await this.flush();
299
391
  const results = await Promise.allSettled([
300
392
  this.app.stop(),
301
393
  ...[...this.services.values()].map((s) => s.stop()),
@@ -303,6 +395,10 @@ export class Runtime {
303
395
  ...[...this.stubs.values()].map((s) => s.close()),
304
396
  ...[...this.containers.values()].map((c) => c.stop()),
305
397
  this.mailbox?.close(),
398
+ this.issuer?.close(),
399
+ this.queryLog?.close(),
400
+ this.interceptor?.close(),
401
+ this.neon?.close(),
306
402
  ]);
307
403
  const failed = results.find((r) => r.status === "rejected");
308
404
  if (failed)
@@ -343,3 +439,47 @@ function parseJson(text) {
343
439
  return undefined;
344
440
  }
345
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
@@ -19,6 +19,27 @@ export interface RecordedCall {
19
19
  };
20
20
  /** Answered by the fallback (e.g. an example from the provider's OpenAPI spec), not a registered route. */
21
21
  fallback?: boolean;
22
+ /** The fault `chaos()` injected instead of the normal answer: `503`, `reset`. */
23
+ fault?: string;
24
+ }
25
+ /**
26
+ * Faults injected into a stub's answers, to test the app's retries, timeouts and
27
+ * fallbacks. Random faults come from a seeded generator, so a failing run can be
28
+ * replayed with the seed printed in the failure output.
29
+ */
30
+ export interface ChaosOptions {
31
+ /** Fail the first `n` calls, then answer normally: the shape of a retry test. */
32
+ failFirst?: number;
33
+ /** Share of calls (0–1) answered with one of `statuses`. */
34
+ errorRate?: number;
35
+ /** Error statuses to pick from. Default [500, 502, 503]; 429 and 503 come with `Retry-After: 1`. */
36
+ statuses?: number[];
37
+ /** Share of calls (0–1) whose connection is dropped without an answer. */
38
+ networkErrorRate?: number;
39
+ /** Extra delay for every call, in ms: a fixed value or a [min, max] range. */
40
+ latency?: number | [number, number];
41
+ /** Seed for the random choices. Default: `SLICETEST_CHAOS_SEED`, else random. */
42
+ seed?: number;
22
43
  }
23
44
  export interface StubResponse {
24
45
  status?: number;
@@ -26,6 +47,17 @@ export interface StubResponse {
26
47
  /** Objects are sent as JSON. */
27
48
  body?: unknown;
28
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;
29
61
  export type Responder = StubResponse | ((call: RecordedCall) => StubResponse | Promise<StubResponse>);
30
62
  /**
31
63
  * Extra conditions a call must meet for a route to answer it. Plain values are
@@ -64,12 +96,26 @@ export declare class Stub {
64
96
  readonly name: string;
65
97
  url: string;
66
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;
67
102
  static start(name: string): Promise<Stub>;
68
103
  /**
69
104
  * Answer `method path`. `path` may contain `:name` segments (captured into
70
105
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
71
106
  */
72
107
  on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
108
+ /**
109
+ * Inject faults into this stub's answers for the rest of the scenario:
110
+ * `stub("payments").chaos({ failFirst: 2 })` to test a retry,
111
+ * `chaos({ errorRate: 0.3, latency: [50, 200] })` to test resilience.
112
+ * Calls that fault don't use up `once()` / `times()` routes.
113
+ */
114
+ chaos(opts: ChaosOptions): this;
115
+ /** Calls `chaos()` answered with a fault. */
116
+ faults(): RecordedCall[];
117
+ /** The active `chaos()` settings and what they did, for failure output; undefined when off. */
118
+ describeChaos(): string | undefined;
73
119
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
74
120
  calls(method?: string, path?: string | RegExp, match?: MatchOptions): RecordedCall[];
75
121
  unmatched(): RecordedCall[];
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()`.
@@ -12,6 +27,7 @@ export class Stub {
12
27
  #fallback;
13
28
  /** Appended to the 501 answer for a call nothing could answer. */
14
29
  #hint;
30
+ #chaos;
15
31
  url = "";
16
32
  constructor(name) {
17
33
  this.name = name;
@@ -20,6 +36,13 @@ export class Stub {
20
36
  this.#handle(req, res).catch(() => res.destroy());
21
37
  });
22
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
+ }
23
46
  static async start(name) {
24
47
  const stub = new Stub(name);
25
48
  await new Promise((resolve) => stub.#server.listen(0, "127.0.0.1", resolve));
@@ -61,6 +84,74 @@ export class Stub {
61
84
  };
62
85
  return builder;
63
86
  }
87
+ /**
88
+ * Inject faults into this stub's answers for the rest of the scenario:
89
+ * `stub("payments").chaos({ failFirst: 2 })` to test a retry,
90
+ * `chaos({ errorRate: 0.3, latency: [50, 200] })` to test resilience.
91
+ * Calls that fault don't use up `once()` / `times()` routes.
92
+ */
93
+ chaos(opts) {
94
+ for (const key of ["errorRate", "networkErrorRate"]) {
95
+ const v = opts[key];
96
+ if (v !== undefined && !(v >= 0 && v <= 1))
97
+ throw new Error(`slicetest: chaos ${key} must be between 0 and 1, got ${v}`);
98
+ }
99
+ if (opts.statuses && (opts.statuses.length === 0 || opts.statuses.some((s) => !Number.isInteger(s) || s < 400 || s > 599))) {
100
+ throw new Error(`slicetest: chaos statuses must be 4xx/5xx codes, got ${JSON.stringify(opts.statuses)}`);
101
+ }
102
+ const envSeed = Number(process.env.SLICETEST_CHAOS_SEED);
103
+ const seed = opts.seed ?? (Number.isInteger(envSeed) ? envSeed : Math.floor(Math.random() * 2 ** 31));
104
+ this.#chaos = { opts, seed, random: mulberry32(seed), calls: 0 };
105
+ return this;
106
+ }
107
+ /** Calls `chaos()` answered with a fault. */
108
+ faults() {
109
+ return this.#calls.filter((c) => c.fault !== undefined);
110
+ }
111
+ /** The active `chaos()` settings and what they did, for failure output; undefined when off. */
112
+ describeChaos() {
113
+ const c = this.#chaos;
114
+ if (!c)
115
+ return undefined;
116
+ const settings = Object.entries(c.opts)
117
+ .filter(([k]) => k !== "seed")
118
+ .map(([k, v]) => `${k} ${JSON.stringify(v)}`)
119
+ .join(", ");
120
+ return `chaos on ${this.name}: ${settings}; ${this.faults().length} of ${c.calls} calls faulted. Replay with SLICETEST_CHAOS_SEED=${c.seed}`;
121
+ }
122
+ /** Delay and fault for the next call, consuming the random sequence in a fixed order. */
123
+ async #injectFault() {
124
+ const c = this.#chaos;
125
+ if (!c)
126
+ return undefined;
127
+ const { opts, random } = c;
128
+ const n = ++c.calls;
129
+ const lat = opts.latency;
130
+ const ms = lat === undefined ? 0 : Array.isArray(lat) ? lat[0] + Math.floor(random() * (lat[1] - lat[0] + 1)) : lat;
131
+ if (ms > 0)
132
+ await new Promise((r) => setTimeout(r, ms));
133
+ const statuses = opts.statuses ?? [500, 502, 503];
134
+ const pick = () => statuses[Math.floor(random() * statuses.length)];
135
+ if (opts.failFirst !== undefined && n <= opts.failFirst)
136
+ return { status: pick() };
137
+ const roll = random();
138
+ if (roll < (opts.networkErrorRate ?? 0))
139
+ return "reset";
140
+ if (roll < (opts.networkErrorRate ?? 0) + (opts.errorRate ?? 0))
141
+ return { status: pick() };
142
+ return undefined;
143
+ }
144
+ #fail(call, req, res, fault) {
145
+ call.matched = true;
146
+ if (fault === "reset") {
147
+ call.fault = "reset";
148
+ req.socket.destroy();
149
+ return;
150
+ }
151
+ call.fault = String(fault.status);
152
+ const retry = fault.status === 429 || fault.status === 503 ? { "retry-after": "1" } : undefined;
153
+ this.#send(call, res, { status: fault.status, headers: retry, body: { error: "slicetest chaos", status: fault.status } });
154
+ }
64
155
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
65
156
  calls(method, path, match = {}) {
66
157
  const compiled = path === undefined ? undefined : compilePath(path);
@@ -82,6 +173,7 @@ export class Stub {
82
173
  reset() {
83
174
  this.#routes = [];
84
175
  this.#calls = [];
176
+ this.#chaos = undefined;
85
177
  }
86
178
  /**
87
179
  * Answer calls that no registered route matches, instead of failing with 501.
@@ -127,6 +219,12 @@ export class Stub {
127
219
  route = r;
128
220
  break;
129
221
  }
222
+ // Faults only replace answers the stub would give: an unknown route still fails as unmatched.
223
+ if (route || this.#fallback) {
224
+ const fault = await this.#injectFault();
225
+ if (fault)
226
+ return this.#fail(call, req, res, fault);
227
+ }
130
228
  if (!route) {
131
229
  let out;
132
230
  try {
@@ -262,3 +360,14 @@ function parseJson(body) {
262
360
  return undefined;
263
361
  }
264
362
  }
363
+ /** A small seeded PRNG: the same seed gives the same faults on every run. */
364
+ function mulberry32(seed) {
365
+ let a = seed >>> 0;
366
+ return () => {
367
+ a = (a + 0x6d2b79f5) >>> 0;
368
+ let t = a;
369
+ t = Math.imul(t ^ (t >>> 15), t | 1);
370
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
371
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
372
+ };
373
+ }
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
  };