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.
- package/README.md +293 -7
- package/dist/app.js +2 -2
- package/dist/auth.d.ts +55 -0
- package/dist/auth.js +140 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +18 -6
- package/dist/config.d.ts +72 -5
- package/dist/config.js +74 -6
- package/dist/containers.d.ts +2 -0
- package/dist/containers.js +20 -1
- package/dist/db.d.ts +28 -0
- package/dist/db.js +68 -0
- package/dist/doctor.js +17 -5
- package/dist/drivers/driver.d.ts +27 -0
- package/dist/drivers/mysql.d.ts +2 -1
- package/dist/drivers/mysql.js +38 -0
- package/dist/drivers/postgres.d.ts +2 -1
- package/dist/drivers/postgres.js +28 -0
- package/dist/drivers/sqlite.d.ts +2 -1
- package/dist/drivers/sqlite.js +52 -0
- package/dist/factory.d.ts +21 -0
- package/dist/factory.js +129 -0
- package/dist/form.d.ts +45 -0
- package/dist/form.js +226 -0
- package/dist/gen.d.ts +1 -0
- package/dist/gen.js +9 -4
- package/dist/global-setup.d.ts +1 -0
- package/dist/global-setup.js +90 -34
- package/dist/http.d.ts +24 -1
- package/dist/http.js +86 -9
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- package/dist/init.js +245 -24
- 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 +10 -0
- package/dist/openapi.js +22 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -0
- package/dist/runtime.d.ts +18 -0
- package/dist/runtime.js +163 -23
- package/dist/setup-file.js +19 -2
- package/dist/stub.d.ts +46 -0
- package/dist/stub.js +109 -0
- package/dist/vitest.d.ts +11 -1
- package/dist/vitest.js +27 -2
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/x509.d.ts +37 -0
- package/dist/x509.js +150 -0
- package/dist/yaml-runtime.js +76 -16
- package/dist/yaml.d.ts +67 -1
- package/dist/yaml.js +87 -3
- package/package.json +12 -4
- package/preload/node-proxy.cjs +19 -0
- package/schema/scenario.schema.json +320 -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,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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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";
|