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
@@ -8,13 +8,42 @@ import { annotation, appendSummary, failureAnnotations, failureSummary, onGitHub
8
8
  import { configureContainerRuntime } from "./container-runtime.js";
9
9
  import { engineFor } from "./drivers/index.js";
10
10
  import { coverageCacheFile } from "./gen.js";
11
- import { formatCoverage, OpenApiSpec } from "./openapi.js";
11
+ import { appSpecFile, formatCoverage, OpenApiSpec } from "./openapi.js";
12
12
  import { mergeRecordings } from "./recording.js";
13
13
  import "./provided.js";
14
14
  const exec = promisify(execFile);
15
15
  /** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
16
16
  export default async function setup(project) {
17
17
  const opts = project.getProvidedContext().slicetestOptions;
18
+ // Builds don't need the database, so they run while it starts.
19
+ const builds = buildAll(opts);
20
+ builds.catch(() => { });
21
+ // No database (`db: false`): nothing to start or migrate, only the builds to wait for.
22
+ const database = opts.db.none ? (await builds, undefined) : await startDatabase(opts, builds);
23
+ // Each worker writes the documented responses it saw here; they are merged when the run ends.
24
+ const coverageDir = opts.openapi.app || opts.openapi.fromApp ? await mkdtemp(path.join(os.tmpdir(), "slicetest-coverage-")) : undefined;
25
+ // Likewise for recordings made against real services, merged into the recordings files at the end.
26
+ const recording = Object.values(opts.recordings).some((r) => r.record);
27
+ const recordDir = recording ? await mkdtemp(path.join(os.tmpdir(), "slicetest-recordings-")) : undefined;
28
+ // On GitHub Actions, failed YAML steps are collected here and turned into annotations at the end.
29
+ const ciDir = onGitHub() ? await mkdtemp(path.join(os.tmpdir(), "slicetest-ci-")) : undefined;
30
+ project.provide("slicetestDb", { adminUrl: database?.adminUrl ?? "", template: database?.template ?? "", prefix: database?.prefix ?? "", coverageDir, recordDir, ciDir });
31
+ return async () => {
32
+ try {
33
+ await database?.teardown();
34
+ }
35
+ finally {
36
+ if (coverageDir)
37
+ await reportCoverage(opts, coverageDir);
38
+ if (recordDir)
39
+ await saveRecordings(opts, recordDir);
40
+ if (ciDir)
41
+ await reportToGitHub(ciDir);
42
+ }
43
+ };
44
+ }
45
+ /** Start (or reuse) the database server and migrate a template database for the workers to clone. */
46
+ async function startDatabase(opts, builds) {
18
47
  const engine = await engineFor(opts);
19
48
  let adminUrl;
20
49
  let stopContainer;
@@ -40,6 +69,7 @@ export default async function setup(project) {
40
69
  await dropStale(admin);
41
70
  const key = opts.db.reuse ? await migrationKey(opts) : undefined;
42
71
  template = key ? await cachedTemplate(admin, engine, opts, key) : await freshTemplate(admin, engine, opts, prefix);
72
+ await builds;
43
73
  }
44
74
  catch (e) {
45
75
  await admin?.close().catch(() => { });
@@ -47,32 +77,23 @@ export default async function setup(project) {
47
77
  throw e;
48
78
  }
49
79
  const server = admin;
50
- // Each worker writes the documented responses it saw here; they are merged when the run ends.
51
- const coverageDir = opts.openapi.app ? await mkdtemp(path.join(os.tmpdir(), "slicetest-coverage-")) : undefined;
52
- // Likewise for recordings made against real services, merged into the recordings files at the end.
53
- const recording = Object.values(opts.recordings).some((r) => r.record);
54
- const recordDir = recording ? await mkdtemp(path.join(os.tmpdir(), "slicetest-recordings-")) : undefined;
55
- // On GitHub Actions, failed YAML steps are collected here and turned into annotations at the end.
56
- const ciDir = onGitHub() ? await mkdtemp(path.join(os.tmpdir(), "slicetest-ci-")) : undefined;
57
- project.provide("slicetestDb", { adminUrl, template, prefix, coverageDir, recordDir, ciDir });
58
- return async () => {
59
- try {
60
- if (!stopContainer) {
61
- // Shared server: drop only the databases this run created.
62
- for (const name of await server.databases(`${prefix}_`))
63
- await server.drop(name);
80
+ return {
81
+ adminUrl,
82
+ template,
83
+ prefix,
84
+ async teardown() {
85
+ try {
86
+ if (!stopContainer) {
87
+ // Shared server: drop only the databases this run created.
88
+ for (const name of await server.databases(`${prefix}_`))
89
+ await server.drop(name);
90
+ }
64
91
  }
65
- }
66
- finally {
67
- await server.close();
68
- await stopContainer?.();
69
- if (coverageDir)
70
- await reportCoverage(opts, coverageDir);
71
- if (recordDir)
72
- await saveRecordings(opts, recordDir);
73
- if (ciDir)
74
- await reportToGitHub(ciDir);
75
- }
92
+ finally {
93
+ await server.close();
94
+ await stopContainer?.();
95
+ }
96
+ },
76
97
  };
77
98
  }
78
99
  async function reportCoverage(opts, dir) {
@@ -85,7 +106,8 @@ async function reportCoverage(opts, dir) {
85
106
  // No scenario ran (e.g. everything filtered out): nothing to report.
86
107
  if (hits.size === 0)
87
108
  return;
88
- const spec = await OpenApiSpec.load(path.resolve(opts.root, opts.openapi.app), opts.openapi.app);
109
+ // A spec served by the app was saved next to the coverage files by the first worker that fetched it.
110
+ const spec = opts.openapi.fromApp ? await OpenApiSpec.load(appSpecFile(dir), `GET ${opts.openapi.fromApp}`) : await OpenApiSpec.load(path.resolve(opts.root, opts.openapi.app), opts.openapi.app);
89
111
  const report = formatCoverage(spec, hits);
90
112
  // For `slicetest gen --uncovered`.
91
113
  const cache = coverageCacheFile(opts.root);
@@ -97,13 +119,14 @@ async function reportCoverage(opts, dir) {
97
119
  // Not thrown: Vitest reports teardown errors as a crash. The failing exit code is what CI needs.
98
120
  const message = `OpenAPI coverage ${report.percent}% is below openapi.minCoverage (${min}%)`;
99
121
  console.error(`slicetest: ${message}\n`);
100
- if (onGitHub())
122
+ if (onGitHub() && opts.openapi.app)
101
123
  console.log(annotation("error", message, { file: repoPath(path.resolve(opts.root, opts.openapi.app)), title: "slicetest: OpenAPI coverage" }));
102
124
  process.exitCode = 1;
103
125
  }
104
126
  }
105
127
  finally {
106
128
  await rm(dir, { recursive: true, force: true });
129
+ await rm(appSpecFile(dir), { force: true });
107
130
  }
108
131
  }
109
132
  async function reportToGitHub(dir) {
@@ -211,9 +234,24 @@ async function migrate(opts, engine, url) {
211
234
  const m = opts.db.migrate;
212
235
  if (!m)
213
236
  return;
237
+ const output = await applyMigrations(opts, engine, url, m);
238
+ // Some tools exit 0 when they couldn't connect (drizzle-kit push through a driver that can't reach
239
+ // the server): an empty database after migrating means they did nothing.
240
+ const driver = await engine.driver(url);
241
+ try {
242
+ if ((await driver.listTables(opts.db.schemas, [])).length > 0)
243
+ return;
244
+ }
245
+ finally {
246
+ await driver.close();
247
+ }
248
+ const tail = output.trim().split("\n").slice(-15).join("\n");
249
+ throw new Error(`slicetest: db.migrate finished without error but created no tables${engine.name === "sqlite" ? "" : ` in ${opts.db.schemas.join(", ")}`}. Check that it reaches the database at DATABASE_URL.${tail ? `\nIts output:\n${tail}` : ""}`);
250
+ }
251
+ async function applyMigrations(opts, engine, url, m) {
214
252
  if ("atlas" in m) {
215
253
  const dir = atlasDirUrl(m.atlas.dir, opts.root);
216
- await run("atlas", ["migrate", "apply", "--url", engine.atlasUrl(url), "--dir", dir], opts.root);
254
+ return run("atlas", ["migrate", "apply", "--url", engine.atlasUrl(url), "--dir", dir], opts.root);
217
255
  }
218
256
  else if ("sql" in m) {
219
257
  const target = path.resolve(opts.root, m.sql);
@@ -228,11 +266,10 @@ async function migrate(opts, engine, url) {
228
266
  finally {
229
267
  await driver.close();
230
268
  }
269
+ return "";
231
270
  }
232
- else {
233
- // Through the platform shell (sh or cmd.exe), like app.command.
234
- await run(m.command, [], opts.root, { DATABASE_URL: url }, true);
235
- }
271
+ // Through the platform shell (sh or cmd.exe), like app.command.
272
+ return run(m.command, [], opts.root, { DATABASE_URL: url }, true);
236
273
  }
237
274
  /**
238
275
  * Atlas wants `file://<path>` with forward slashes. Absolute paths (including
@@ -244,9 +281,28 @@ export function atlasDirUrl(dir, root) {
244
281
  const rel = path.isAbsolute(dir) || /^[a-zA-Z]:[\\/]/.test(dir) ? path.relative(root, dir) : dir;
245
282
  return `file://${rel.replace(/\\/g, "/")}`;
246
283
  }
284
+ export async function buildAll(opts) {
285
+ const processes = [["app", opts.app], ...Object.entries(opts.services).map(([n, s]) => [`service ${n}`, s])];
286
+ await Promise.all(processes
287
+ .filter(([, p]) => p.build)
288
+ .map(async ([label, p]) => {
289
+ try {
290
+ // The process's literal settings apply to its build too: Next.js inlines NEXT_PUBLIC_* at build
291
+ // time. Values with placeholders ({{db.url}}, …) don't exist yet and are left out.
292
+ const literal = Object.fromEntries(Object.entries(p.env ?? {}).filter(([, v]) => !v.includes("{{")));
293
+ await exec(p.build, [], { cwd: path.resolve(opts.root, p.cwd ?? "."), env: { ...process.env, ...literal }, shell: true, windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
294
+ }
295
+ catch (e) {
296
+ const err = e;
297
+ const output = `${err.stdout ?? ""}${err.stderr ?? ""}`.trim().split("\n").slice(-40).join("\n");
298
+ throw new Error(`slicetest: ${label} build failed: ${p.build}\n${output || err.message}`);
299
+ }
300
+ }));
301
+ }
247
302
  async function run(cmd, args, cwd, env = {}, shell = false) {
248
303
  try {
249
- await exec(cmd, args, { cwd, env: { ...process.env, ...env }, shell, windowsHide: true });
304
+ const { stdout, stderr } = await exec(cmd, args, { cwd, env: { ...process.env, ...env }, shell, windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
305
+ return `${stdout}${stderr}`;
250
306
  }
251
307
  catch (e) {
252
308
  const err = e;
package/dist/http.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type SubmitOptions } from "./form.js";
2
+ import { type WebhookOptions } from "./webhook.js";
1
3
  export interface HttpResponse {
2
4
  method: string;
3
5
  url: string;
@@ -17,6 +19,8 @@ export interface RequestOptions {
17
19
  }
18
20
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
19
21
  declare class Session {
22
+ /** Stub base URLs of intercepted hosts, which redirects may lead to (an OAuth provider's login page). */
23
+ external: Map<string, string>;
20
24
  cookies: Map<string, string>;
21
25
  history: HttpResponse[];
22
26
  listeners: ((res: HttpResponse) => void)[];
@@ -37,16 +41,35 @@ export declare class HttpClient {
37
41
  post(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
38
42
  put(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
39
43
  patch(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
44
+ /**
45
+ * Submit a form of `page` (a response from the app) as a browser with JavaScript off would:
46
+ * hidden fields such as CSRF tokens or a Next.js server action's id are sent along,
47
+ * plus the pressed button and the values you type.
48
+ * `http.submit(await http.get("/signup"), { button: "Sign up", fields: { email: "a@b.test" } })`.
49
+ */
50
+ submit(page: HttpResponse, opts?: SubmitOptions & RequestOptions): Promise<HttpResponse>;
51
+ /**
52
+ * POST `payload` to the app as `provider` would deliver it, signed with `secret`:
53
+ * `http.webhook("/webhooks/stripe", event, { provider: "stripe", secret: "whsec_test" })`.
54
+ * `{ invalidSignature: true }` and `{ stale: true }` make deliveries the app must refuse.
55
+ */
56
+ webhook(path: string, payload: unknown, opts: WebhookOptions): Promise<HttpResponse>;
40
57
  /**
41
58
  * Run `send` `n` times at once and wait for every response, to provoke races
42
59
  * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
43
60
  * and a check of the database. Requests are released together once all are prepared.
44
61
  */
45
62
  concurrently(n: number, send: (i: number) => Promise<HttpResponse>): Promise<HttpResponse[]>;
46
- /** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
63
+ /**
64
+ * Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON.
65
+ * With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
66
+ * (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
67
+ */
47
68
  request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<HttpResponse>;
48
69
  /** Call `fn` with every response this client (or one derived with `with()`) receives. */
49
70
  onResponse(fn: (res: HttpResponse) => void): void;
71
+ /** Let redirects to `host` continue to the stub at `stubUrl`, as they would to the real host in a browser. */
72
+ intercept(host: string, stubUrl: string): void;
50
73
  /** Cookies the app has set during this scenario. Mutations are sent with later requests. */
51
74
  get cookies(): Map<string, string>;
52
75
  clearCookies(): void;
package/dist/http.js CHANGED
@@ -1,10 +1,22 @@
1
+ import { formRequest } from "./form.js";
2
+ import { lookupHost } from "./intercept.js";
3
+ import { signWebhook, webhookBody } from "./webhook.js";
1
4
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
2
5
  class Session {
6
+ /** Stub base URLs of intercepted hosts, which redirects may lead to (an OAuth provider's login page). */
7
+ external = new Map();
3
8
  cookies = new Map();
4
9
  history = [];
5
10
  listeners = [];
6
11
  }
7
12
  const HISTORY = 20;
13
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
14
+ /** A redirected request that lost its body must not keep claiming a content type. */
15
+ function withoutContentType(headers, body) {
16
+ if (body !== undefined || !headers)
17
+ return headers;
18
+ return Object.fromEntries(Object.entries(headers).filter(([k]) => k.toLowerCase() !== "content-type"));
19
+ }
8
20
  /** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
9
21
  export class HttpClient {
10
22
  baseUrl;
@@ -41,6 +53,37 @@ export class HttpClient {
41
53
  patch(path, body, opts) {
42
54
  return this.request("PATCH", path, body, opts);
43
55
  }
56
+ /**
57
+ * Submit a form of `page` (a response from the app) as a browser with JavaScript off would:
58
+ * hidden fields such as CSRF tokens or a Next.js server action's id are sent along,
59
+ * plus the pressed button and the values you type.
60
+ * `http.submit(await http.get("/signup"), { button: "Sign up", fields: { email: "a@b.test" } })`.
61
+ */
62
+ async submit(page, opts = {}) {
63
+ const { button, form, fields, ...request } = opts;
64
+ let built;
65
+ try {
66
+ built = formRequest(page.text, { button, form, fields });
67
+ }
68
+ catch (e) {
69
+ // A redirect or an error page is the usual reason there is no such form.
70
+ e.message += `\n(the page: ${page.method} ${page.url} → ${page.status}${REDIRECTS.has(page.status) ? `, a redirect to ${page.headers.get("location")}; request it with follow: true` : ""})`;
71
+ throw e;
72
+ }
73
+ const { method, action, body } = built;
74
+ const target = new URL(action, new URL(page.url, this.baseUrl));
75
+ target.hash = "";
76
+ return this.request(method, target.pathname + target.search, body, request);
77
+ }
78
+ /**
79
+ * POST `payload` to the app as `provider` would deliver it, signed with `secret`:
80
+ * `http.webhook("/webhooks/stripe", event, { provider: "stripe", secret: "whsec_test" })`.
81
+ * `{ invalidSignature: true }` and `{ stale: true }` make deliveries the app must refuse.
82
+ */
83
+ webhook(path, payload, opts) {
84
+ const { body, type } = webhookBody(payload);
85
+ return this.request("POST", path, body, { headers: { "content-type": type, ...signWebhook(body, opts), ...opts.headers } });
86
+ }
44
87
  /**
45
88
  * Run `send` `n` times at once and wait for every response, to provoke races
46
89
  * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
@@ -55,18 +98,45 @@ export class HttpClient {
55
98
  release();
56
99
  return all;
57
100
  }
58
- /** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
101
+ /**
102
+ * Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON.
103
+ * With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
104
+ * (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
105
+ */
59
106
  async request(method, path, body, options = {}) {
60
107
  const opts = merge(this.defaults, options);
61
- const url = new URL(path, this.baseUrl);
108
+ let url = new URL(path, this.baseUrl);
62
109
  if (url.origin !== new URL(this.baseUrl).origin) {
63
110
  throw new Error(`slicetest: http only talks to the app under test; "${path}" resolves to ${url.origin}`);
64
111
  }
65
112
  for (const [k, v] of Object.entries(opts.query ?? {}))
66
113
  if (v !== undefined)
67
114
  url.searchParams.set(k, String(v));
115
+ let res = await this.#send(method, url, body, opts);
116
+ for (let hops = 0; opts.follow && REDIRECTS.has(res.status) && hops < 20; hops++) {
117
+ const location = res.headers.get("location");
118
+ if (!location)
119
+ break;
120
+ const next = new URL(location, url);
121
+ // Like a browser: on to the app, or to a host a stub stands in for; anywhere else is returned.
122
+ if (next.origin !== new URL(this.baseUrl).origin && lookupHost(this.#session.external, next.hostname) === undefined)
123
+ break;
124
+ // 307/308 repeat the request as it was; the others turn it into a GET without a body, like browsers do.
125
+ if (res.status !== 307 && res.status !== 308 && method !== "HEAD") {
126
+ method = "GET";
127
+ body = undefined;
128
+ }
129
+ url = next;
130
+ res = await this.#send(method, url, body, { ...opts, headers: withoutContentType(opts.headers, body) });
131
+ }
132
+ return res;
133
+ }
134
+ async #send(method, url, body, opts) {
68
135
  const headers = new Headers(opts.headers);
69
- if (this.#session.cookies.size > 0 && !headers.has("cookie")) {
136
+ const stub = url.origin === new URL(this.baseUrl).origin ? undefined : lookupHost(this.#session.external, url.hostname);
137
+ // The app's cookies stay with the app; the stub is reached at its own address.
138
+ const target = stub ? new URL(url.pathname + url.search, stub) : url;
139
+ if (!stub && this.#session.cookies.size > 0 && !headers.has("cookie")) {
70
140
  headers.set("cookie", [...this.#session.cookies].map(([k, v]) => `${k}=${v}`).join("; "));
71
141
  }
72
142
  let payload;
@@ -86,15 +156,16 @@ export class HttpClient {
86
156
  const started = performance.now();
87
157
  let res;
88
158
  try {
89
- res = await fetch(url, { method, headers, body: payload, redirect: opts.follow ? "follow" : "manual" });
159
+ res = await fetch(target, { method, headers, body: payload, redirect: "manual" });
90
160
  }
91
161
  catch (e) {
92
162
  const cause = e.cause?.code;
93
163
  this.#record({ method, url: url.pathname + url.search, status: 0, headers: new Headers(), text: `${e}${cause ? ` (${cause})` : ""}`, json: undefined, durationMs: Math.round(performance.now() - started) });
94
164
  throw e;
95
165
  }
96
- for (const cookie of res.headers.getSetCookie())
97
- this.#storeCookie(cookie);
166
+ if (!stub)
167
+ for (const cookie of res.headers.getSetCookie())
168
+ this.#storeCookie(cookie);
98
169
  const text = await res.text();
99
170
  let json;
100
171
  try {
@@ -103,7 +174,7 @@ export class HttpClient {
103
174
  catch { }
104
175
  const out = {
105
176
  method,
106
- url: url.pathname + url.search,
177
+ url: stub ? url.href : url.pathname + url.search,
107
178
  status: res.status,
108
179
  headers: res.headers,
109
180
  text,
@@ -111,8 +182,10 @@ export class HttpClient {
111
182
  durationMs: Math.round(performance.now() - started),
112
183
  };
113
184
  this.#record(out);
114
- for (const listener of this.#session.listeners)
115
- listener(out);
185
+ // Listeners (contract checks, coverage) are about the app's responses only.
186
+ if (!stub)
187
+ for (const listener of this.#session.listeners)
188
+ listener(out);
116
189
  return out;
117
190
  }
118
191
  /** Call `fn` with every response this client (or one derived with `with()`) receives. */
@@ -124,6 +197,10 @@ export class HttpClient {
124
197
  if (this.#session.history.length > HISTORY)
125
198
  this.#session.history.shift();
126
199
  }
200
+ /** Let redirects to `host` continue to the stub at `stubUrl`, as they would to the real host in a browser. */
201
+ intercept(host, stubUrl) {
202
+ this.#session.external.set(host.toLowerCase(), stubUrl);
203
+ }
127
204
  /** Cookies the app has set during this scenario. Mutations are sent with later requests. */
128
205
  get cookies() {
129
206
  return this.#session.cookies;
package/dist/index.d.ts CHANGED
@@ -3,10 +3,15 @@ export { mask } from "./trace.js";
3
3
  export type { Trace, MaskOptions } from "./trace.js";
4
4
  export type { ScenarioContext } from "./runtime.js";
5
5
  export type { Db, Row, Where, RowsOptions, Changes, TableChanges } from "./db.js";
6
- export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder } from "./stub.js";
6
+ export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder, ChaosOptions, ServerSentEvent } from "./stub.js";
7
+ export { sse } from "./stub.js";
7
8
  export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
9
+ export type { SubmitOptions } from "./form.js";
8
10
  export type { App } from "./app.js";
9
11
  export type { Dependency } from "./containers.js";
10
12
  export type { Mailbox, Mail, MailFilter } from "./mail.js";
13
+ export type { Issuer, TokenOptions, AuthOptions } from "./auth.js";
14
+ export { normalizeSql, type Query, type QueryList } from "./query-log.js";
15
+ export { signWebhook, type WebhookOptions, type WebhookProvider, type HmacScheme } from "./webhook.js";
11
16
  export type { SlicetestOptions, ContainerOptions } from "./config.js";
12
17
  export type {} from "./matchers.js";
package/dist/index.js CHANGED
@@ -1,2 +1,5 @@
1
1
  export { scenario } from "./scenario.js";
2
2
  export { mask } from "./trace.js";
3
+ export { sse } from "./stub.js";
4
+ export { normalizeSql } from "./query-log.js";
5
+ export { signWebhook } from "./webhook.js";