slicetest 0.6.0 → 0.7.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.
@@ -0,0 +1,89 @@
1
+ import { describeGraphQL } from "./graphql.js";
2
+ import { timeline } from "./timeline.js";
3
+ /**
4
+ * A Mermaid sequence diagram of what a scenario did: each request to the app,
5
+ * the stub calls the app made while answering it, the mail it sent and the
6
+ * database tables it changed. GitHub, GitLab and most Markdown viewers render it.
7
+ */
8
+ export function sequenceDiagram(history, stubs, changes, mailbox) {
9
+ const events = [];
10
+ const participants = new Map();
11
+ const id = (name, label) => {
12
+ const key = `s_${name.replace(/[^A-Za-z0-9_]/g, "_")}`;
13
+ if (!participants.has(key))
14
+ participants.set(key, label);
15
+ return key;
16
+ };
17
+ let order = 0;
18
+ const add = (at, line) => events.push({ at, order: order++, line });
19
+ for (const res of history) {
20
+ const t = timeline.get(res) ?? { start: Infinity, end: Infinity };
21
+ const external = /^https?:/.test(res.url);
22
+ const target = external ? id(`ext_${new URL(res.url).host}`, new URL(res.url).host) : "app";
23
+ const path = external ? new URL(res.url).pathname : res.url;
24
+ add(t.start, `test->>+${target}: ${text(`${res.method} ${path}`)}`);
25
+ add(t.end ?? t.start, `${target}-->>-test: ${res.status || "failed"}${summary(res)}`);
26
+ }
27
+ for (const stub of stubs) {
28
+ for (const call of stub.calls()) {
29
+ const t = timeline.get(call) ?? { start: Infinity };
30
+ const s = id(stub.name, `${stub.name} (stub)`);
31
+ add(t.start, `app->>+${s}: ${text(describeCall(call))}`);
32
+ const reply = call.fault === "reset" ? "connection dropped" : call.response ? `${call.response.status}${call.fault ? " (chaos)" : ""}` : call.matched ? "no answer" : "501 no stub";
33
+ add(t.end ?? t.start, `${s}-->>-app: ${reply}`);
34
+ }
35
+ }
36
+ events.sort((a, b) => a.at - b.at || a.order - b.order);
37
+ const lines = ["sequenceDiagram", " participant test as scenario", " participant app"];
38
+ for (const [key, label] of participants)
39
+ lines.push(` participant ${key} as ${text(label)}`);
40
+ if (mailbox && mailbox.messages().length)
41
+ lines.push(" participant mail");
42
+ const tables = Object.entries(changes ?? {}).filter(([, c]) => c.inserted.length || c.updated.length || c.deleted.length);
43
+ if (tables.length)
44
+ lines.push(" participant db as database");
45
+ for (const e of events)
46
+ lines.push(` ${e.line}`);
47
+ for (const m of mailbox?.messages() ?? [])
48
+ lines.push(` app->>mail: ${text(`${m.subject} to ${m.to.join(", ")}`)}`);
49
+ if (tables.length) {
50
+ const counts = tables.map(([table, c]) => `${table} ${[c.inserted.length && `+${c.inserted.length}`, c.updated.length && `~${c.updated.length}`, c.deleted.length && `-${c.deleted.length}`].filter(Boolean).join(" ")}`);
51
+ lines.push(` Note over app,db: ${text(counts.join(", "))}`);
52
+ }
53
+ return lines.join("\n");
54
+ }
55
+ function describeCall(call) {
56
+ if (call.graphql)
57
+ return describeGraphQL(call.graphql);
58
+ return `${call.method} ${call.path}`;
59
+ }
60
+ /** A short hint of the response: a JSON error message or the id it created. */
61
+ function summary(res) {
62
+ const j = res.json;
63
+ if (j && typeof j === "object" && !Array.isArray(j)) {
64
+ for (const key of ["error", "message", "id"]) {
65
+ const v = j[key];
66
+ if (typeof v === "string" || typeof v === "number")
67
+ return ` ${text(`${key}: ${v}`)}`;
68
+ }
69
+ }
70
+ return "";
71
+ }
72
+ /** Mermaid ends a message at `;` and reads `#…;` as an entity, so both are escaped; long text is cut. */
73
+ function text(s, max = 70) {
74
+ const cut = s.length > max ? `${s.slice(0, max - 1)}…` : s;
75
+ return cut.replace(/[\r\n]+/g, " ").replace(/[#;]/g, (c) => (c === "#" ? "#35;" : "#59;"));
76
+ }
77
+ /** Scenario diagrams per test file, for `SLICETEST_DIAGRAMS` / `--diagrams`: one Markdown page per file, in run order. */
78
+ const pages = new Map();
79
+ export function diagramPage(file, title, scenario, diagram, failed) {
80
+ const page = pages.get(file) ?? { title, sections: new Map() };
81
+ pages.set(file, page);
82
+ page.sections.set(scenario, [`## ${scenario}${failed ? " (failed)" : ""}`, "", "```mermaid", diagram, "```", ""].join("\n"));
83
+ return [`# ${page.title}`, "", `Sequence diagrams of the scenarios in \`${page.title}\`, written by slicetest. Regenerated on every run.`, "", ...page.sections.values()].join("\n");
84
+ }
85
+ /** A collapsed section for the GitHub Actions job summary. */
86
+ export function failureDiagram(where, scenario, diagram) {
87
+ const name = scenario.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c]);
88
+ return [`<details><summary>✗ ${name} <code>${where}</code>: what happened</summary>`, "", "```mermaid", diagram, "```", "", "</details>", ""].join("\n");
89
+ }
@@ -5,10 +5,12 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { annotation, appendSummary, failureAnnotations, failureSummary, onGitHub, repoPath, yamlFailures } from "./ci.js";
8
+ import { interpolate } from "./app.js";
9
+ import { connectionVars } from "./connection.js";
8
10
  import { configureContainerRuntime } from "./container-runtime.js";
9
11
  import { engineFor } from "./drivers/index.js";
10
12
  import { coverageCacheFile } from "./gen.js";
11
- import { appSpecFile, formatCoverage, OpenApiSpec } from "./openapi.js";
13
+ import { appSpecFile, formatCoverage, formatUsage, OpenApiSpec } from "./openapi.js";
12
14
  import { mergeRecordings } from "./recording.js";
13
15
  import "./provided.js";
14
16
  const exec = promisify(execFile);
@@ -27,7 +29,9 @@ export default async function setup(project) {
27
29
  const recordDir = recording ? await mkdtemp(path.join(os.tmpdir(), "slicetest-recordings-")) : undefined;
28
30
  // On GitHub Actions, failed YAML steps are collected here and turned into annotations at the end.
29
31
  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 });
32
+ // Operations the app called on providers whose spec a stub has, reported at the end.
33
+ const usageDir = Object.keys(opts.openapi.stubs).length ? await mkdtemp(path.join(os.tmpdir(), "slicetest-usage-")) : undefined;
34
+ project.provide("slicetestDb", { adminUrl: database?.adminUrl ?? "", template: database?.template ?? "", prefix: database?.prefix ?? "", coverageDir, recordDir, ciDir, usageDir });
31
35
  return async () => {
32
36
  try {
33
37
  await database?.teardown();
@@ -35,6 +39,8 @@ export default async function setup(project) {
35
39
  finally {
36
40
  if (coverageDir)
37
41
  await reportCoverage(opts, coverageDir);
42
+ if (usageDir)
43
+ await reportUsage(opts, usageDir);
38
44
  if (recordDir)
39
45
  await saveRecordings(opts, recordDir);
40
46
  if (ciDir)
@@ -129,6 +135,36 @@ async function reportCoverage(opts, dir) {
129
135
  await rm(appSpecFile(dir), { force: true });
130
136
  }
131
137
  }
138
+ async function reportUsage(opts, dir) {
139
+ try {
140
+ const used = new Map();
141
+ for (const file of await readdir(dir)) {
142
+ for (const [name, keys] of Object.entries(JSON.parse(await readFile(path.join(dir, file), "utf8")))) {
143
+ const set = used.get(name) ?? new Set();
144
+ for (const k of keys)
145
+ set.add(k);
146
+ used.set(name, set);
147
+ }
148
+ }
149
+ for (const [name, file] of Object.entries(opts.openapi.stubs)) {
150
+ const keys = used.get(name);
151
+ if (!keys)
152
+ continue;
153
+ const report = formatUsage(name, await OpenApiSpec.load(path.resolve(opts.root, file), file), keys);
154
+ console.log(`\n${report.text}\n`);
155
+ await appendSummary(report.markdown);
156
+ if (onGitHub() && report.deprecated.length) {
157
+ console.log(annotation("warning", `The app calls operations ${file} marks deprecated: ${report.deprecated.join(", ")}`, { file: repoPath(path.resolve(opts.root, file)), title: `slicetest: deprecated ${name} API` }));
158
+ }
159
+ }
160
+ }
161
+ catch (e) {
162
+ console.error(`slicetest: couldn't report API usage: ${e.message}`);
163
+ }
164
+ finally {
165
+ await rm(dir, { recursive: true, force: true });
166
+ }
167
+ }
132
168
  async function reportToGitHub(dir) {
133
169
  try {
134
170
  const failures = await yamlFailures(dir);
@@ -268,8 +304,13 @@ async function applyMigrations(opts, engine, url, m) {
268
304
  }
269
305
  return "";
270
306
  }
271
- // Through the platform shell (sh or cmd.exe), like app.command.
272
- return run(m.command, [], opts.root, { DATABASE_URL: url }, true);
307
+ // Through the platform shell (sh or cmd.exe), like app.command, with the same {{db.*}} placeholders.
308
+ const vars = { "db.url": url };
309
+ if (engine.name === "sqlite")
310
+ vars["db.path"] = (await import("./drivers/sqlite.js")).sqlitePath(url);
311
+ Object.assign(vars, connectionVars(engine.name, url, vars["db.path"]));
312
+ const env = Object.fromEntries(Object.entries(m.env ?? {}).map(([k, v]) => [k, interpolate(v, vars, `db.migrate.env.${k}`)]));
313
+ return run(interpolate(m.command, vars, "db.migrate.command"), [], opts.root, { DATABASE_URL: url, ...env }, true);
273
314
  }
274
315
  /**
275
316
  * Atlas wants `file://<path>` with forward slashes. Absolute paths (including
@@ -0,0 +1,34 @@
1
+ /**
2
+ * GraphQL over HTTP, as clients send it (Apollo, urql, graphql-request, gql, …):
3
+ * a POST with `{ query, variables, operationName }`, or a GET with those as query
4
+ * parameters. Stubs match calls by operation, and the app's own GraphQL endpoint
5
+ * is called with `http.graphql()`.
6
+ */
7
+ export interface GraphQLCall {
8
+ /** `operationName` from the request, else the name in the document (`query GetUser { … }`). Undefined for an anonymous operation. */
9
+ operation?: string;
10
+ type: "query" | "mutation" | "subscription";
11
+ query: string;
12
+ variables: Record<string, unknown>;
13
+ }
14
+ /** The operations a document defines, in order. Comments and strings don't count. */
15
+ export declare function operations(document: string): {
16
+ type: GraphQLCall["type"];
17
+ name?: string;
18
+ }[];
19
+ /** The GraphQL request in an HTTP call, or undefined when it isn't one. */
20
+ export declare function graphqlOf(call: {
21
+ method: string;
22
+ query: URLSearchParams;
23
+ json: any;
24
+ }): GraphQLCall | undefined;
25
+ /** A GraphQL error list from messages or full error objects. */
26
+ export declare function graphqlErrors(errors: (string | {
27
+ message: string;
28
+ [k: string]: unknown;
29
+ })[]): {
30
+ [k: string]: unknown;
31
+ message: string;
32
+ }[];
33
+ /** `GraphQL mutation CreateIssue`, for failure messages. */
34
+ export declare function describeGraphQL(g: GraphQLCall): string;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * GraphQL over HTTP, as clients send it (Apollo, urql, graphql-request, gql, …):
3
+ * a POST with `{ query, variables, operationName }`, or a GET with those as query
4
+ * parameters. Stubs match calls by operation, and the app's own GraphQL endpoint
5
+ * is called with `http.graphql()`.
6
+ */
7
+ const OPERATION = /(?:^|[\s}])(query|mutation|subscription)\b\s*([_A-Za-z][_0-9A-Za-z]*)?/g;
8
+ /** The operations a document defines, in order. Comments and strings don't count. */
9
+ export function operations(document) {
10
+ const code = document.replace(/"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|#[^\n]*/g, " ");
11
+ const found = [];
12
+ for (const m of code.matchAll(OPERATION))
13
+ found.push({ type: m[1], name: m[2] });
14
+ // `{ viewer { id } }` without a keyword is an anonymous query.
15
+ if (found.length === 0 && /^\s*\{/.test(code))
16
+ found.push({ type: "query" });
17
+ return found;
18
+ }
19
+ /** The GraphQL request in an HTTP call, or undefined when it isn't one. */
20
+ export function graphqlOf(call) {
21
+ let doc, name, variables;
22
+ if (call.method === "GET") {
23
+ doc = call.query.get("query") ?? undefined;
24
+ name = call.query.get("operationName") ?? undefined;
25
+ const v = call.query.get("variables");
26
+ try {
27
+ variables = v ? JSON.parse(v) : undefined;
28
+ }
29
+ catch {
30
+ variables = undefined;
31
+ }
32
+ }
33
+ else if (call.json && typeof call.json === "object" && !Array.isArray(call.json)) {
34
+ ({ query: doc, operationName: name, variables } = call.json);
35
+ }
36
+ if (typeof doc !== "string")
37
+ return undefined;
38
+ const ops = operations(doc);
39
+ const op = typeof name === "string" && name ? (ops.find((o) => o.name === name) ?? { type: "query", name }) : ops[0];
40
+ if (!op)
41
+ return undefined;
42
+ return {
43
+ operation: typeof name === "string" && name ? name : op.name,
44
+ type: op.type,
45
+ query: doc,
46
+ variables: variables && typeof variables === "object" ? variables : {},
47
+ };
48
+ }
49
+ /** A GraphQL error list from messages or full error objects. */
50
+ export function graphqlErrors(errors) {
51
+ return errors.map((e) => (typeof e === "string" ? { message: e } : e));
52
+ }
53
+ /** `GraphQL mutation CreateIssue`, for failure messages. */
54
+ export function describeGraphQL(g) {
55
+ return `GraphQL ${g.type} ${g.operation ?? "(anonymous)"}`;
56
+ }
package/dist/har.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { type Recording } from "./recording.js";
2
+ /**
3
+ * `slicetest import session.har`: turn a HAR file (saved from the browser's
4
+ * network panel, Charles, mitmproxy, Proxyman, Postman, …) into recordings a
5
+ * stub replays, without running the app against the real service first.
6
+ */
7
+ interface HarEntry {
8
+ request: {
9
+ method: string;
10
+ url: string;
11
+ postData?: {
12
+ mimeType?: string;
13
+ text?: string;
14
+ };
15
+ };
16
+ response: {
17
+ status: number;
18
+ headers?: {
19
+ name: string;
20
+ value: string;
21
+ }[];
22
+ content?: {
23
+ mimeType?: string;
24
+ text?: string;
25
+ encoding?: string;
26
+ };
27
+ };
28
+ }
29
+ export interface HarTarget {
30
+ /** Stub name. */
31
+ name: string;
32
+ /** The real service's base URL; entries under it become recordings, with this prefix removed from their path. */
33
+ upstream: string;
34
+ /** Recordings file, absolute. */
35
+ file: string;
36
+ }
37
+ export declare function harEntries(har: unknown, file?: string): HarEntry[];
38
+ /** The entry as a recording, if it is under `upstream` and replayable; otherwise why not. */
39
+ export declare function toRecording(entry: HarEntry, upstream: string): Recording | "elsewhere" | "skipped";
40
+ export declare function importHar(harFile: string, targets: HarTarget[]): Promise<{
41
+ written: {
42
+ name: string;
43
+ file: string;
44
+ count: number;
45
+ }[];
46
+ skipped: number;
47
+ others: [string, number][];
48
+ }>;
49
+ export {};
package/dist/har.js ADDED
@@ -0,0 +1,107 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { mergeRecordings } from "./recording.js";
4
+ const KEPT_HEADERS = ["content-type", "location", "retry-after", "link", "etag"];
5
+ const TEXTUAL = /json|text|xml|javascript|x-www-form-urlencoded|graphql/i;
6
+ export function harEntries(har, file = "the HAR file") {
7
+ const entries = har?.log?.entries;
8
+ if (!Array.isArray(entries))
9
+ throw new Error(`slicetest import: ${file} is not a HAR file (no log.entries)`);
10
+ return entries;
11
+ }
12
+ /** The entry as a recording, if it is under `upstream` and replayable; otherwise why not. */
13
+ export function toRecording(entry, upstream) {
14
+ const base = new URL(upstream);
15
+ let url;
16
+ try {
17
+ url = new URL(entry.request.url);
18
+ }
19
+ catch {
20
+ return "skipped";
21
+ }
22
+ const prefix = base.pathname.replace(/\/$/, "");
23
+ if (url.host !== base.host || !(url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)))
24
+ return "elsewhere";
25
+ const method = entry.request.method.toUpperCase();
26
+ // Preflights are the browser's; aborted and blocked requests (status 0) have no answer to replay.
27
+ if (method === "OPTIONS" || !entry.response || !entry.response.status)
28
+ return "skipped";
29
+ const query = Object.fromEntries([...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)));
30
+ const sent = entry.request.postData?.text;
31
+ const json = parse(sent);
32
+ const request = {
33
+ method,
34
+ path: url.pathname.slice(prefix.length) || "/",
35
+ ...(Object.keys(query).length ? { query } : {}),
36
+ ...(json !== undefined ? { json } : sent ? { body: sent } : {}),
37
+ };
38
+ const content = entry.response.content ?? {};
39
+ let text = content.text ?? "";
40
+ if (content.encoding === "base64" && text) {
41
+ if (!TEXTUAL.test(content.mimeType ?? ""))
42
+ return "skipped";
43
+ text = Buffer.from(text, "base64").toString("utf8");
44
+ }
45
+ const headers = Object.fromEntries((entry.response.headers ?? []).flatMap((h) => (KEPT_HEADERS.includes(h.name.toLowerCase()) ? [[h.name.toLowerCase(), h.value]] : [])));
46
+ const body = parse(text);
47
+ return {
48
+ request,
49
+ response: {
50
+ status: entry.response.status,
51
+ ...(Object.keys(headers).length ? { headers } : {}),
52
+ ...(body !== undefined ? { json: body } : text ? { body: text } : {}),
53
+ },
54
+ };
55
+ }
56
+ export async function importHar(harFile, targets) {
57
+ const text = await readFile(harFile, "utf8");
58
+ let har;
59
+ try {
60
+ har = JSON.parse(text);
61
+ }
62
+ catch (e) {
63
+ throw new Error(`slicetest import: ${harFile} is not JSON: ${e.message}`);
64
+ }
65
+ const entries = harEntries(har, harFile);
66
+ const written = [];
67
+ const claimed = new Set();
68
+ let skipped = 0;
69
+ for (const t of targets) {
70
+ const recordings = [];
71
+ for (const e of entries) {
72
+ const r = toRecording(e, t.upstream);
73
+ if (r === "elsewhere")
74
+ continue;
75
+ claimed.add(e);
76
+ if (r === "skipped")
77
+ skipped++;
78
+ else
79
+ recordings.push(r);
80
+ }
81
+ if (recordings.length === 0)
82
+ continue;
83
+ await mergeRecordings(t.file, t.upstream, recordings, `Imported by slicetest from ${path.basename(harFile)} (${t.upstream})`);
84
+ written.push({ name: t.name, file: t.file, count: recordings.length });
85
+ }
86
+ const others = new Map();
87
+ for (const e of entries) {
88
+ if (claimed.has(e))
89
+ continue;
90
+ try {
91
+ const host = new URL(e.request.url).origin;
92
+ others.set(host, (others.get(host) ?? 0) + 1);
93
+ }
94
+ catch { }
95
+ }
96
+ return { written, skipped, others: [...others].sort((a, b) => b[1] - a[1]) };
97
+ }
98
+ function parse(text) {
99
+ if (!text)
100
+ return undefined;
101
+ try {
102
+ return JSON.parse(text);
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
package/dist/http.d.ts CHANGED
@@ -22,6 +22,8 @@ declare class Session {
22
22
  /** Stub base URLs of intercepted hosts, which redirects may lead to (an OAuth provider's login page). */
23
23
  external: Map<string, string>;
24
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>;
25
27
  history: HttpResponse[];
26
28
  listeners: ((res: HttpResponse) => void)[];
27
29
  }
@@ -54,6 +56,16 @@ export declare class HttpClient {
54
56
  * `{ invalidSignature: true }` and `{ stale: true }` make deliveries the app must refuse.
55
57
  */
56
58
  webhook(path: string, payload: unknown, opts: WebhookOptions): Promise<HttpResponse>;
59
+ /**
60
+ * Send a GraphQL operation to the app: `http.graphql("query { poll(id: 1) { title } }")`,
61
+ * `http.graphql(CREATE_POLL, { title: "Tea?" })`. Posts `{ query, variables, operationName }` to
62
+ * `/graphql` (`path` for another endpoint). Pair with `toHaveGraphQLData()`, since GraphQL
63
+ * servers answer errors with status 200.
64
+ */
65
+ graphql(query: string, variables?: Record<string, unknown>, opts?: RequestOptions & {
66
+ path?: string;
67
+ operationName?: string;
68
+ }): Promise<HttpResponse>;
57
69
  /**
58
70
  * Run `send` `n` times at once and wait for every response, to provoke races
59
71
  * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
package/dist/http.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { formRequest } from "./form.js";
2
2
  import { lookupHost } from "./intercept.js";
3
+ import { timeline } from "./timeline.js";
3
4
  import { signWebhook, webhookBody } from "./webhook.js";
4
5
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
5
6
  class Session {
6
7
  /** Stub base URLs of intercepted hosts, which redirects may lead to (an OAuth provider's login page). */
7
8
  external = new Map();
8
9
  cookies = new Map();
10
+ /** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
11
+ cookiePaths = new Map();
9
12
  history = [];
10
13
  listeners = [];
11
14
  }
@@ -84,6 +87,16 @@ export class HttpClient {
84
87
  const { body, type } = webhookBody(payload);
85
88
  return this.request("POST", path, body, { headers: { "content-type": type, ...signWebhook(body, opts), ...opts.headers } });
86
89
  }
90
+ /**
91
+ * Send a GraphQL operation to the app: `http.graphql("query { poll(id: 1) { title } }")`,
92
+ * `http.graphql(CREATE_POLL, { title: "Tea?" })`. Posts `{ query, variables, operationName }` to
93
+ * `/graphql` (`path` for another endpoint). Pair with `toHaveGraphQLData()`, since GraphQL
94
+ * servers answer errors with status 200.
95
+ */
96
+ graphql(query, variables, opts = {}) {
97
+ const { path = "/graphql", operationName, ...request } = opts;
98
+ return this.request("POST", path, { query, ...(variables ? { variables } : {}), ...(operationName ? { operationName } : {}) }, request);
99
+ }
87
100
  /**
88
101
  * Run `send` `n` times at once and wait for every response, to provoke races
89
102
  * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
@@ -136,8 +149,10 @@ export class HttpClient {
136
149
  const stub = url.origin === new URL(this.baseUrl).origin ? undefined : lookupHost(this.#session.external, url.hostname);
137
150
  // The app's cookies stay with the app; the stub is reached at its own address.
138
151
  const target = stub ? new URL(url.pathname + url.search, stub) : url;
139
- if (!stub && this.#session.cookies.size > 0 && !headers.has("cookie")) {
140
- headers.set("cookie", [...this.#session.cookies].map(([k, v]) => `${k}=${v}`).join("; "));
152
+ if (!stub && !headers.has("cookie")) {
153
+ const cookie = this.#cookieHeader(url.pathname);
154
+ if (cookie)
155
+ headers.set("cookie", cookie);
141
156
  }
142
157
  let payload;
143
158
  if (body === undefined ||
@@ -160,12 +175,14 @@ export class HttpClient {
160
175
  }
161
176
  catch (e) {
162
177
  const cause = e.cause?.code;
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) });
178
+ const failed = { method, url: url.pathname + url.search, status: 0, headers: new Headers(), text: `${e}${cause ? ` (${cause})` : ""}`, json: undefined, durationMs: Math.round(performance.now() - started) };
179
+ timeline.set(failed, { start: started, end: performance.now() });
180
+ this.#record(failed);
164
181
  throw e;
165
182
  }
166
183
  if (!stub)
167
184
  for (const cookie of res.headers.getSetCookie())
168
- this.#storeCookie(cookie);
185
+ this.#storeCookie(cookie, url.pathname);
169
186
  const text = await res.text();
170
187
  let json;
171
188
  try {
@@ -181,6 +198,7 @@ export class HttpClient {
181
198
  json,
182
199
  durationMs: Math.round(performance.now() - started),
183
200
  };
201
+ timeline.set(out, { start: started, end: performance.now() });
184
202
  this.#record(out);
185
203
  // Listeners (contract checks, coverage) are about the app's responses only.
186
204
  if (!stub)
@@ -207,6 +225,7 @@ export class HttpClient {
207
225
  }
208
226
  clearCookies() {
209
227
  this.#session.cookies.clear();
228
+ this.#session.cookiePaths.clear();
210
229
  }
211
230
  /** Requests made during the current scenario, oldest first (last 20). */
212
231
  get history() {
@@ -214,29 +233,65 @@ export class HttpClient {
214
233
  }
215
234
  /** Start a new scenario: forget cookies and history. */
216
235
  reset() {
217
- this.#session.cookies.clear();
236
+ this.clearCookies();
218
237
  this.#session.history = [];
219
238
  }
220
- #storeCookie(header) {
239
+ /** Cookies whose Path covers `requestPath`, longest Path first as browsers send them (RFC 6265 5.4). */
240
+ #cookieHeader(requestPath) {
241
+ const { cookies, cookiePaths } = this.#session;
242
+ return [...cookies]
243
+ .map(([name, value]) => ({ name, value, path: cookiePaths.get(name) ?? "/" }))
244
+ .filter((c) => pathMatches(requestPath, c.path))
245
+ .sort((a, b) => b.path.length - a.path.length)
246
+ .map((c) => `${c.name}=${c.value}`)
247
+ .join("; ");
248
+ }
249
+ #storeCookie(header, requestPath) {
221
250
  const [pair, ...attrs] = header.split(";");
222
251
  const eq = pair.indexOf("=");
223
252
  if (eq <= 0)
224
253
  return;
225
254
  const name = pair.slice(0, eq).trim();
226
- const expired = attrs.some((a) => {
227
- const [k, v = ""] = a.split("=").map((s) => s.trim());
228
- if (k.toLowerCase() === "max-age")
229
- return Number(v) <= 0;
230
- if (k.toLowerCase() === "expires")
231
- return Date.parse(v) <= Date.now();
232
- return false;
233
- });
234
- if (expired)
255
+ let maxAge;
256
+ let expires;
257
+ let cookiePath;
258
+ for (const a of attrs) {
259
+ const i = a.indexOf("=");
260
+ const k = (i < 0 ? a : a.slice(0, i)).trim().toLowerCase();
261
+ const v = i < 0 ? "" : a.slice(i + 1).trim();
262
+ if (k === "max-age")
263
+ maxAge = Number(v);
264
+ else if (k === "expires")
265
+ expires = Date.parse(v);
266
+ else if (k === "path" && v.startsWith("/"))
267
+ cookiePath = v;
268
+ }
269
+ // Max-Age wins over Expires whichever comes first.
270
+ const expired = maxAge !== undefined && !Number.isNaN(maxAge) ? maxAge <= 0 : expires !== undefined && expires <= Date.now();
271
+ // Keyed by name alone: a second cookie of the same name under another Path replaces the first.
272
+ if (expired) {
235
273
  this.#session.cookies.delete(name);
236
- else
274
+ this.#session.cookiePaths.delete(name);
275
+ }
276
+ else {
237
277
  this.#session.cookies.set(name, pair.slice(eq + 1).trim());
278
+ this.#session.cookiePaths.set(name, cookiePath ?? defaultCookiePath(requestPath));
279
+ }
238
280
  }
239
281
  }
282
+ /** RFC 6265 5.1.4: the request path's directory, e.g. "/auth" for "/auth/login". */
283
+ function defaultCookiePath(requestPath) {
284
+ const slash = requestPath.lastIndexOf("/");
285
+ return slash <= 0 ? "/" : requestPath.slice(0, slash);
286
+ }
287
+ /** RFC 6265 5.1.4: "/api" covers "/api" and "/api/x" but not "/apix". */
288
+ function pathMatches(requestPath, cookiePath) {
289
+ if (requestPath === cookiePath)
290
+ return true;
291
+ if (!requestPath.startsWith(cookiePath))
292
+ return false;
293
+ return cookiePath.endsWith("/") || requestPath[cookiePath.length] === "/";
294
+ }
240
295
  function merge(a, b) {
241
296
  return {
242
297
  ...a,
package/dist/index.d.ts CHANGED
@@ -3,7 +3,8 @@ 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, ServerSentEvent } from "./stub.js";
6
+ export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder, GraphQLRouteBuilder, ChaosOptions, ServerSentEvent } from "./stub.js";
7
+ export type { GraphQLCall } from "./graphql.js";
7
8
  export { sse } from "./stub.js";
8
9
  export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
9
10
  export type { SubmitOptions } from "./form.js";