slicetest 0.5.0 → 0.6.1

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.
@@ -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,4 @@
1
+ import { type SubmitOptions } from "./form.js";
1
2
  import { type WebhookOptions } from "./webhook.js";
2
3
  export interface HttpResponse {
3
4
  method: string;
@@ -18,7 +19,11 @@ export interface RequestOptions {
18
19
  }
19
20
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
20
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>;
21
24
  cookies: Map<string, string>;
25
+ /** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
26
+ cookiePaths: Map<string, string>;
22
27
  history: HttpResponse[];
23
28
  listeners: ((res: HttpResponse) => void)[];
24
29
  }
@@ -38,6 +43,13 @@ export declare class HttpClient {
38
43
  post(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
39
44
  put(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
40
45
  patch(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
46
+ /**
47
+ * Submit a form of `page` (a response from the app) as a browser with JavaScript off would:
48
+ * hidden fields such as CSRF tokens or a Next.js server action's id are sent along,
49
+ * plus the pressed button and the values you type.
50
+ * `http.submit(await http.get("/signup"), { button: "Sign up", fields: { email: "a@b.test" } })`.
51
+ */
52
+ submit(page: HttpResponse, opts?: SubmitOptions & RequestOptions): Promise<HttpResponse>;
41
53
  /**
42
54
  * POST `payload` to the app as `provider` would deliver it, signed with `secret`:
43
55
  * `http.webhook("/webhooks/stripe", event, { provider: "stripe", secret: "whsec_test" })`.
@@ -50,10 +62,16 @@ export declare class HttpClient {
50
62
  * and a check of the database. Requests are released together once all are prepared.
51
63
  */
52
64
  concurrently(n: number, send: (i: number) => Promise<HttpResponse>): Promise<HttpResponse[]>;
53
- /** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
65
+ /**
66
+ * Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON.
67
+ * With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
68
+ * (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
69
+ */
54
70
  request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<HttpResponse>;
55
71
  /** Call `fn` with every response this client (or one derived with `with()`) receives. */
56
72
  onResponse(fn: (res: HttpResponse) => void): void;
73
+ /** Let redirects to `host` continue to the stub at `stubUrl`, as they would to the real host in a browser. */
74
+ intercept(host: string, stubUrl: string): void;
57
75
  /** Cookies the app has set during this scenario. Mutations are sent with later requests. */
58
76
  get cookies(): Map<string, string>;
59
77
  clearCookies(): void;
package/dist/http.js CHANGED
@@ -1,11 +1,24 @@
1
+ import { formRequest } from "./form.js";
2
+ import { lookupHost } from "./intercept.js";
1
3
  import { signWebhook, webhookBody } from "./webhook.js";
2
4
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
3
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();
4
8
  cookies = new Map();
9
+ /** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
10
+ cookiePaths = new Map();
5
11
  history = [];
6
12
  listeners = [];
7
13
  }
8
14
  const HISTORY = 20;
15
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
16
+ /** A redirected request that lost its body must not keep claiming a content type. */
17
+ function withoutContentType(headers, body) {
18
+ if (body !== undefined || !headers)
19
+ return headers;
20
+ return Object.fromEntries(Object.entries(headers).filter(([k]) => k.toLowerCase() !== "content-type"));
21
+ }
9
22
  /** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
10
23
  export class HttpClient {
11
24
  baseUrl;
@@ -42,6 +55,28 @@ export class HttpClient {
42
55
  patch(path, body, opts) {
43
56
  return this.request("PATCH", path, body, opts);
44
57
  }
58
+ /**
59
+ * Submit a form of `page` (a response from the app) as a browser with JavaScript off would:
60
+ * hidden fields such as CSRF tokens or a Next.js server action's id are sent along,
61
+ * plus the pressed button and the values you type.
62
+ * `http.submit(await http.get("/signup"), { button: "Sign up", fields: { email: "a@b.test" } })`.
63
+ */
64
+ async submit(page, opts = {}) {
65
+ const { button, form, fields, ...request } = opts;
66
+ let built;
67
+ try {
68
+ built = formRequest(page.text, { button, form, fields });
69
+ }
70
+ catch (e) {
71
+ // A redirect or an error page is the usual reason there is no such form.
72
+ 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` : ""})`;
73
+ throw e;
74
+ }
75
+ const { method, action, body } = built;
76
+ const target = new URL(action, new URL(page.url, this.baseUrl));
77
+ target.hash = "";
78
+ return this.request(method, target.pathname + target.search, body, request);
79
+ }
45
80
  /**
46
81
  * POST `payload` to the app as `provider` would deliver it, signed with `secret`:
47
82
  * `http.webhook("/webhooks/stripe", event, { provider: "stripe", secret: "whsec_test" })`.
@@ -65,19 +100,48 @@ export class HttpClient {
65
100
  release();
66
101
  return all;
67
102
  }
68
- /** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
103
+ /**
104
+ * Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON.
105
+ * With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
106
+ * (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
107
+ */
69
108
  async request(method, path, body, options = {}) {
70
109
  const opts = merge(this.defaults, options);
71
- const url = new URL(path, this.baseUrl);
110
+ let url = new URL(path, this.baseUrl);
72
111
  if (url.origin !== new URL(this.baseUrl).origin) {
73
112
  throw new Error(`slicetest: http only talks to the app under test; "${path}" resolves to ${url.origin}`);
74
113
  }
75
114
  for (const [k, v] of Object.entries(opts.query ?? {}))
76
115
  if (v !== undefined)
77
116
  url.searchParams.set(k, String(v));
117
+ let res = await this.#send(method, url, body, opts);
118
+ for (let hops = 0; opts.follow && REDIRECTS.has(res.status) && hops < 20; hops++) {
119
+ const location = res.headers.get("location");
120
+ if (!location)
121
+ break;
122
+ const next = new URL(location, url);
123
+ // Like a browser: on to the app, or to a host a stub stands in for; anywhere else is returned.
124
+ if (next.origin !== new URL(this.baseUrl).origin && lookupHost(this.#session.external, next.hostname) === undefined)
125
+ break;
126
+ // 307/308 repeat the request as it was; the others turn it into a GET without a body, like browsers do.
127
+ if (res.status !== 307 && res.status !== 308 && method !== "HEAD") {
128
+ method = "GET";
129
+ body = undefined;
130
+ }
131
+ url = next;
132
+ res = await this.#send(method, url, body, { ...opts, headers: withoutContentType(opts.headers, body) });
133
+ }
134
+ return res;
135
+ }
136
+ async #send(method, url, body, opts) {
78
137
  const headers = new Headers(opts.headers);
79
- if (this.#session.cookies.size > 0 && !headers.has("cookie")) {
80
- headers.set("cookie", [...this.#session.cookies].map(([k, v]) => `${k}=${v}`).join("; "));
138
+ const stub = url.origin === new URL(this.baseUrl).origin ? undefined : lookupHost(this.#session.external, url.hostname);
139
+ // The app's cookies stay with the app; the stub is reached at its own address.
140
+ const target = stub ? new URL(url.pathname + url.search, stub) : url;
141
+ if (!stub && !headers.has("cookie")) {
142
+ const cookie = this.#cookieHeader(url.pathname);
143
+ if (cookie)
144
+ headers.set("cookie", cookie);
81
145
  }
82
146
  let payload;
83
147
  if (body === undefined ||
@@ -96,15 +160,16 @@ export class HttpClient {
96
160
  const started = performance.now();
97
161
  let res;
98
162
  try {
99
- res = await fetch(url, { method, headers, body: payload, redirect: opts.follow ? "follow" : "manual" });
163
+ res = await fetch(target, { method, headers, body: payload, redirect: "manual" });
100
164
  }
101
165
  catch (e) {
102
166
  const cause = e.cause?.code;
103
167
  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) });
104
168
  throw e;
105
169
  }
106
- for (const cookie of res.headers.getSetCookie())
107
- this.#storeCookie(cookie);
170
+ if (!stub)
171
+ for (const cookie of res.headers.getSetCookie())
172
+ this.#storeCookie(cookie, url.pathname);
108
173
  const text = await res.text();
109
174
  let json;
110
175
  try {
@@ -113,7 +178,7 @@ export class HttpClient {
113
178
  catch { }
114
179
  const out = {
115
180
  method,
116
- url: url.pathname + url.search,
181
+ url: stub ? url.href : url.pathname + url.search,
117
182
  status: res.status,
118
183
  headers: res.headers,
119
184
  text,
@@ -121,8 +186,10 @@ export class HttpClient {
121
186
  durationMs: Math.round(performance.now() - started),
122
187
  };
123
188
  this.#record(out);
124
- for (const listener of this.#session.listeners)
125
- listener(out);
189
+ // Listeners (contract checks, coverage) are about the app's responses only.
190
+ if (!stub)
191
+ for (const listener of this.#session.listeners)
192
+ listener(out);
126
193
  return out;
127
194
  }
128
195
  /** Call `fn` with every response this client (or one derived with `with()`) receives. */
@@ -134,12 +201,17 @@ export class HttpClient {
134
201
  if (this.#session.history.length > HISTORY)
135
202
  this.#session.history.shift();
136
203
  }
204
+ /** Let redirects to `host` continue to the stub at `stubUrl`, as they would to the real host in a browser. */
205
+ intercept(host, stubUrl) {
206
+ this.#session.external.set(host.toLowerCase(), stubUrl);
207
+ }
137
208
  /** Cookies the app has set during this scenario. Mutations are sent with later requests. */
138
209
  get cookies() {
139
210
  return this.#session.cookies;
140
211
  }
141
212
  clearCookies() {
142
213
  this.#session.cookies.clear();
214
+ this.#session.cookiePaths.clear();
143
215
  }
144
216
  /** Requests made during the current scenario, oldest first (last 20). */
145
217
  get history() {
@@ -147,29 +219,65 @@ export class HttpClient {
147
219
  }
148
220
  /** Start a new scenario: forget cookies and history. */
149
221
  reset() {
150
- this.#session.cookies.clear();
222
+ this.clearCookies();
151
223
  this.#session.history = [];
152
224
  }
153
- #storeCookie(header) {
225
+ /** Cookies whose Path covers `requestPath`, longest Path first as browsers send them (RFC 6265 5.4). */
226
+ #cookieHeader(requestPath) {
227
+ const { cookies, cookiePaths } = this.#session;
228
+ return [...cookies]
229
+ .map(([name, value]) => ({ name, value, path: cookiePaths.get(name) ?? "/" }))
230
+ .filter((c) => pathMatches(requestPath, c.path))
231
+ .sort((a, b) => b.path.length - a.path.length)
232
+ .map((c) => `${c.name}=${c.value}`)
233
+ .join("; ");
234
+ }
235
+ #storeCookie(header, requestPath) {
154
236
  const [pair, ...attrs] = header.split(";");
155
237
  const eq = pair.indexOf("=");
156
238
  if (eq <= 0)
157
239
  return;
158
240
  const name = pair.slice(0, eq).trim();
159
- const expired = attrs.some((a) => {
160
- const [k, v = ""] = a.split("=").map((s) => s.trim());
161
- if (k.toLowerCase() === "max-age")
162
- return Number(v) <= 0;
163
- if (k.toLowerCase() === "expires")
164
- return Date.parse(v) <= Date.now();
165
- return false;
166
- });
167
- if (expired)
241
+ let maxAge;
242
+ let expires;
243
+ let cookiePath;
244
+ for (const a of attrs) {
245
+ const i = a.indexOf("=");
246
+ const k = (i < 0 ? a : a.slice(0, i)).trim().toLowerCase();
247
+ const v = i < 0 ? "" : a.slice(i + 1).trim();
248
+ if (k === "max-age")
249
+ maxAge = Number(v);
250
+ else if (k === "expires")
251
+ expires = Date.parse(v);
252
+ else if (k === "path" && v.startsWith("/"))
253
+ cookiePath = v;
254
+ }
255
+ // Max-Age wins over Expires whichever comes first.
256
+ const expired = maxAge !== undefined && !Number.isNaN(maxAge) ? maxAge <= 0 : expires !== undefined && expires <= Date.now();
257
+ // Keyed by name alone: a second cookie of the same name under another Path replaces the first.
258
+ if (expired) {
168
259
  this.#session.cookies.delete(name);
169
- else
260
+ this.#session.cookiePaths.delete(name);
261
+ }
262
+ else {
170
263
  this.#session.cookies.set(name, pair.slice(eq + 1).trim());
264
+ this.#session.cookiePaths.set(name, cookiePath ?? defaultCookiePath(requestPath));
265
+ }
171
266
  }
172
267
  }
268
+ /** RFC 6265 5.1.4: the request path's directory, e.g. "/auth" for "/auth/login". */
269
+ function defaultCookiePath(requestPath) {
270
+ const slash = requestPath.lastIndexOf("/");
271
+ return slash <= 0 ? "/" : requestPath.slice(0, slash);
272
+ }
273
+ /** RFC 6265 5.1.4: "/api" covers "/api" and "/api/x" but not "/apix". */
274
+ function pathMatches(requestPath, cookiePath) {
275
+ if (requestPath === cookiePath)
276
+ return true;
277
+ if (!requestPath.startsWith(cookiePath))
278
+ return false;
279
+ return cookiePath.endsWith("/") || requestPath[cookiePath.length] === "/";
280
+ }
173
281
  function merge(a, b) {
174
282
  return {
175
283
  ...a,
package/dist/index.d.ts CHANGED
@@ -3,8 +3,10 @@ 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, ChaosOptions } 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";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { scenario } from "./scenario.js";
2
2
  export { mask } from "./trace.js";
3
+ export { sse } from "./stub.js";
3
4
  export { normalizeSql } from "./query-log.js";
4
5
  export { signWebhook } from "./webhook.js";