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.
- package/README.md +183 -5
- package/dist/app.js +39 -11
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +13 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -3
- package/dist/config.d.ts +58 -5
- package/dist/config.js +61 -9
- package/dist/containers.d.ts +2 -0
- package/dist/containers.js +20 -1
- package/dist/db.d.ts +2 -0
- package/dist/db.js +18 -0
- package/dist/doctor.js +17 -5
- package/dist/form.d.ts +45 -0
- package/dist/form.js +226 -0
- package/dist/global-setup.d.ts +1 -0
- package/dist/global-setup.js +90 -34
- package/dist/http.d.ts +19 -1
- package/dist/http.js +130 -22
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/init.js +203 -25
- package/dist/intercept.d.ts +37 -0
- package/dist/intercept.js +199 -0
- package/dist/neon.d.ts +14 -0
- package/dist/neon.js +113 -0
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +4 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +127 -25
- package/dist/setup-file.js +19 -2
- package/dist/stub.d.ts +14 -0
- package/dist/stub.js +22 -0
- package/dist/vitest.d.ts +11 -1
- package/dist/vitest.js +27 -2
- package/dist/x509.d.ts +37 -0
- package/dist/x509.js +150 -0
- package/dist/yaml-runtime.js +35 -13
- package/dist/yaml.d.ts +32 -1
- package/dist/yaml.js +31 -1
- package/package.json +5 -3
- package/preload/node-proxy.cjs +19 -0
- package/schema/scenario.schema.json +140 -0
package/dist/global-setup.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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(
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
222
|
+
this.clearCookies();
|
|
151
223
|
this.#session.history = [];
|
|
152
224
|
}
|
|
153
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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";
|