slicetest 0.8.0 → 0.10.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 +131 -35
- package/dist/app.js +10 -2
- package/dist/auth.js +2 -1
- package/dist/cli-run.d.ts +53 -0
- package/dist/cli-run.js +80 -0
- package/dist/cli.js +27 -29
- package/dist/config.d.ts +33 -2
- package/dist/config.js +121 -2
- package/dist/container-runtime.d.ts +4 -0
- package/dist/container-runtime.js +16 -0
- package/dist/containers.js +4 -3
- package/dist/db.d.ts +3 -1
- package/dist/db.js +45 -8
- package/dist/deadline.d.ts +7 -0
- package/dist/deadline.js +24 -0
- package/dist/doctor.js +27 -5
- package/dist/drivers/driver.d.ts +6 -0
- package/dist/drivers/mysql.js +5 -1
- package/dist/drivers/postgres.d.ts +2 -0
- package/dist/drivers/postgres.js +20 -1
- package/dist/exit.d.ts +1 -0
- package/dist/exit.js +28 -0
- package/dist/form.d.ts +2 -0
- package/dist/form.js +62 -9
- package/dist/global-setup.js +3 -6
- package/dist/har.js +4 -4
- package/dist/http.d.ts +38 -4
- package/dist/http.js +135 -10
- package/dist/init.js +64 -10
- package/dist/list.d.ts +1 -0
- package/dist/list.js +16 -3
- package/dist/mail.d.ts +1 -1
- package/dist/mail.js +7 -7
- package/dist/matchers.d.ts +18 -6
- package/dist/matchers.js +75 -8
- package/dist/openapi.d.ts +2 -5
- package/dist/openapi.js +146 -20
- package/dist/record-cli.js +2 -1
- package/dist/record.js +2 -1
- package/dist/recording.d.ts +11 -1
- package/dist/recording.js +51 -6
- package/dist/request-url.d.ts +1 -0
- package/dist/request-url.js +5 -0
- package/dist/runtime.d.ts +3 -3
- package/dist/runtime.js +144 -18
- package/dist/scenario.js +11 -2
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +10 -3
- package/dist/stub.js +64 -16
- package/dist/vitest.d.ts +4 -0
- package/dist/vitest.js +6 -2
- package/dist/webhook.d.ts +3 -3
- package/dist/webhook.js +10 -2
- package/dist/yaml-runtime.d.ts +4 -4
- package/dist/yaml-runtime.js +141 -38
- package/dist/yaml.d.ts +12 -2
- package/dist/yaml.js +47 -4
- package/docs/guarantees.md +92 -0
- package/docs/state-isolation.md +94 -0
- package/package.json +4 -1
- package/schema/config.schema.json +675 -0
- package/schema/scenario.schema.json +97 -7
package/dist/runtime.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { App } from "./app.js";
|
|
6
6
|
import { Issuer } from "./auth.js";
|
|
7
|
+
import { seedFiles } from "./config.js";
|
|
7
8
|
import { Dependency } from "./containers.js";
|
|
8
9
|
import { connectionVars } from "./connection.js";
|
|
9
10
|
import { Db, formatChanges, noDatabase } from "./db.js";
|
|
@@ -125,7 +126,7 @@ export class Runtime {
|
|
|
125
126
|
schemas: opts.db.schemas,
|
|
126
127
|
keep: opts.db.keep,
|
|
127
128
|
ignoreChanges: opts.db.ignoreChanges,
|
|
128
|
-
|
|
129
|
+
seedFiles: seedFiles(opts.db.seed).map((f) => path.resolve(opts.root, f)),
|
|
129
130
|
})
|
|
130
131
|
: noDatabase();
|
|
131
132
|
const started = await Promise.allSettled(Object.entries(opts.containers).map(async ([name, c]) => containers.set(name, await Dependency.start(name, c))));
|
|
@@ -234,7 +235,7 @@ export class Runtime {
|
|
|
234
235
|
throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
|
|
235
236
|
return c;
|
|
236
237
|
},
|
|
237
|
-
trace: async (opts) => mask(buildTrace(this.http.
|
|
238
|
+
trace: async (opts) => mask(buildTrace(this.http.requests, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
|
|
238
239
|
diagram: () => this.diagram(),
|
|
239
240
|
get mail() {
|
|
240
241
|
if (!mailbox)
|
|
@@ -252,6 +253,34 @@ export class Runtime {
|
|
|
252
253
|
#processes() {
|
|
253
254
|
return [this.app, ...this.services.values()];
|
|
254
255
|
}
|
|
256
|
+
// Marked busy until afterScenario() sees `idle` answer 2xx, so a scenario that failed, or that Vitest
|
|
257
|
+
// timed out mid-wait, leaves its processes flagged and the next scenario restarts them.
|
|
258
|
+
#busy = new Set();
|
|
259
|
+
#queue = Promise.resolve();
|
|
260
|
+
/** Not concurrent: a scenario Vitest timed out leaves its beforeScenario() running, and a second would race it for `this.app`. */
|
|
261
|
+
#serial(fn) {
|
|
262
|
+
const run = this.#queue.then(fn);
|
|
263
|
+
this.#queue = run.catch(() => { });
|
|
264
|
+
return run;
|
|
265
|
+
}
|
|
266
|
+
#hooked(hook) {
|
|
267
|
+
const all = [
|
|
268
|
+
{ name: "app", proc: this.app, options: this.opts.app },
|
|
269
|
+
...[...this.services].map(([n, proc]) => ({ name: `services.${n}`, proc, options: this.opts.services[n] })),
|
|
270
|
+
];
|
|
271
|
+
return all.filter((p) => p.options[hook] && !p.proc.exited);
|
|
272
|
+
}
|
|
273
|
+
/** Every busy process fails the scenario, not only the first, and each is flagged for a restart. */
|
|
274
|
+
async #waitIdle(deadline) {
|
|
275
|
+
const hooked = this.#hooked("idle");
|
|
276
|
+
const results = await Promise.allSettled(hooked.map(async ({ name, proc, options }) => {
|
|
277
|
+
await pollIdle(proc, options.idle, `${name}.idle`, deadline);
|
|
278
|
+
this.#busy.delete(proc);
|
|
279
|
+
}));
|
|
280
|
+
const failed = results.flatMap((r) => (r.status === "rejected" ? [r.reason.message] : []));
|
|
281
|
+
if (failed.length > 0)
|
|
282
|
+
throw new Error(failed.join("\n"));
|
|
283
|
+
}
|
|
255
284
|
#usageDir;
|
|
256
285
|
/** Note which provider operations the stubs were called for, before their calls are cleared. */
|
|
257
286
|
#collectUsage() {
|
|
@@ -266,20 +295,30 @@ export class Runtime {
|
|
|
266
295
|
this.#used.set(name, used);
|
|
267
296
|
}
|
|
268
297
|
}
|
|
269
|
-
|
|
298
|
+
beforeScenario() {
|
|
299
|
+
return this.#serial(() => this.#before());
|
|
300
|
+
}
|
|
301
|
+
/** `deadline`: when the test's own timeout is about to end, so `idle` reports before Vitest does. */
|
|
302
|
+
afterScenario(deadline) {
|
|
303
|
+
return this.#serial(() => this.#after(deadline));
|
|
304
|
+
}
|
|
305
|
+
async #before() {
|
|
270
306
|
this.#collectUsage();
|
|
271
|
-
// A
|
|
272
|
-
for (const
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
await service.stop();
|
|
276
|
-
this.services.set(name, await App.start(this.opts.services[name], this.opts.root, this.vars, `service.${name}`, service.port));
|
|
307
|
+
// A failed scenario skipped afterScenario(). One look, not a wait: it already failed, and a restart ends the work.
|
|
308
|
+
for (const { proc, options } of this.#hooked("idle")) {
|
|
309
|
+
if (this.#busy.has(proc) && (await probeIdle(proc, options.idle)))
|
|
310
|
+
this.#busy.delete(proc);
|
|
277
311
|
}
|
|
278
|
-
|
|
312
|
+
// Stop before the reset, start after. Not start-then-reset: the old process could still write into the
|
|
313
|
+
// fresh database, and the new one would boot against stale rows.
|
|
314
|
+
const restartService = (name, service) => service.exited || this.#busy.has(service) || this.opts.services[name].restart === "scenario";
|
|
315
|
+
const services = [...this.services].filter(([name, service]) => restartService(name, service));
|
|
316
|
+
const restartApp = !!this.app.exited || this.#busy.has(this.app) || this.opts.app.restart === "scenario";
|
|
317
|
+
if (restartApp)
|
|
279
318
|
await this.app.stop();
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
319
|
+
for (const [, service] of [...services].reverse())
|
|
320
|
+
await service.stop();
|
|
321
|
+
this.#busy.clear();
|
|
283
322
|
this.#contract = [];
|
|
284
323
|
await this.db.reset();
|
|
285
324
|
for (const stub of this.stubs.values())
|
|
@@ -293,15 +332,32 @@ export class Runtime {
|
|
|
293
332
|
this.interceptor?.blocked.clear();
|
|
294
333
|
await Promise.all([...this.containers.values()].map((c) => c.reset()));
|
|
295
334
|
this.http.reset();
|
|
335
|
+
for (const [name, old] of services) {
|
|
336
|
+
this.services.set(name, await App.start(this.opts.services[name], this.opts.root, this.vars, `service.${name}`, old.port));
|
|
337
|
+
}
|
|
338
|
+
if (restartApp) {
|
|
339
|
+
this.app = await App.start(this.opts.app, this.opts.root, this.vars);
|
|
340
|
+
this.#http = this.#client();
|
|
341
|
+
}
|
|
296
342
|
for (const p of this.#processes())
|
|
297
343
|
p.beginScenario();
|
|
344
|
+
// Not before the start: a cache filled at boot would hold the pre-reset rows.
|
|
345
|
+
for (const { name, proc, options } of this.#hooked("reset")) {
|
|
346
|
+
await callHook(proc, options.reset, `${name}.reset`);
|
|
347
|
+
}
|
|
348
|
+
for (const { proc } of this.#hooked("idle"))
|
|
349
|
+
this.#busy.add(proc);
|
|
298
350
|
}
|
|
299
351
|
/** Failures that the scenario body can't see on its own. */
|
|
300
|
-
async
|
|
352
|
+
async #after(deadline) {
|
|
301
353
|
await Promise.all(this.#processes().map((p) => p.settle()));
|
|
302
354
|
this.#assertAlive();
|
|
355
|
+
await this.#waitIdle(deadline);
|
|
303
356
|
if (this.interceptor?.blocked.size)
|
|
304
357
|
throw new Error(blockedHint([...this.interceptor.blocked]));
|
|
358
|
+
const thrown = this.#handlerErrors();
|
|
359
|
+
if (thrown.length > 0)
|
|
360
|
+
throw new Error(`slicetest: a stub's reply threw, so the app got an error response instead of the reply:\n${thrown.join("\n")}`);
|
|
305
361
|
const unmatched = this.#unmatched();
|
|
306
362
|
if (unmatched.length > 0) {
|
|
307
363
|
throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
|
|
@@ -337,16 +393,22 @@ export class Runtime {
|
|
|
337
393
|
#checkStubCall(name, spec, call) {
|
|
338
394
|
if (!call.matched)
|
|
339
395
|
return []; // already reported as an unmatched call
|
|
340
|
-
const req = { contentType: header(call.headers["content-type"]), body: call.json ?? call.body, query: call.query };
|
|
396
|
+
const req = { contentType: header(call.headers["content-type"]), body: call.json ?? call.body, query: call.query, headers: call.headers };
|
|
341
397
|
const out = spec.checkRequest(call.method, call.path, req).map((c) => `app → ${name}: ${c}`);
|
|
342
398
|
const res = call.response;
|
|
343
399
|
if (res) {
|
|
344
400
|
const body = res.body ? (parseJson(res.body) ?? res.body) : "";
|
|
345
|
-
const problems = spec.checkResponse(call.method, call.path, { status: res.status, contentType: res.headers["content-type"], body });
|
|
401
|
+
const problems = spec.checkResponse(call.method, call.path, { status: res.status, contentType: res.headers["content-type"], body, headers: Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k.toLowerCase(), v])) });
|
|
346
402
|
out.push(...problems.map((c) => `stub ${name} reply (the real service wouldn't answer this way): ${c}`));
|
|
347
403
|
}
|
|
348
404
|
return out;
|
|
349
405
|
}
|
|
406
|
+
#handlerErrors() {
|
|
407
|
+
return [...this.stubs.values()].flatMap((s) => s.handlerErrors().map(({ call, error }) => {
|
|
408
|
+
const where = error?.stack?.split("\n").find((l) => /^\s+at /.test(l) && !l.includes("node_modules") && !l.includes("node:"))?.trim();
|
|
409
|
+
return ` ${s.name}: ${call.method} ${call.path}: ${error instanceof Error ? error.message : String(error)}${where ? `\n ${where}` : ""}`;
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
350
412
|
#unmatched() {
|
|
351
413
|
return [...this.stubs.values()].flatMap((s) => {
|
|
352
414
|
const calls = s.unmatched();
|
|
@@ -366,7 +428,7 @@ export class Runtime {
|
|
|
366
428
|
/** The current scenario as a Mermaid sequence diagram. */
|
|
367
429
|
async diagram() {
|
|
368
430
|
const changes = this.opts.db.none ? undefined : await this.db.changesSinceStart().catch(() => undefined);
|
|
369
|
-
return sequenceDiagram(this.http.
|
|
431
|
+
return sequenceDiagram(this.http.requests, this.stubs.values(), changes, this.mailbox);
|
|
370
432
|
}
|
|
371
433
|
/**
|
|
372
434
|
* After a scenario: its diagram goes to the `SLICETEST_DIAGRAMS` directory (one Markdown page
|
|
@@ -401,6 +463,9 @@ export class Runtime {
|
|
|
401
463
|
if (exit)
|
|
402
464
|
sections.push(`the ${p.label} exited (code ${exit.code}, signal ${exit.signal}); it will be restarted for the next scenario`);
|
|
403
465
|
}
|
|
466
|
+
const thrown = this.#handlerErrors();
|
|
467
|
+
if (thrown.length > 0)
|
|
468
|
+
sections.push(`stub replies that threw:\n${thrown.join("\n")}`);
|
|
404
469
|
const unmatched = this.#unmatched();
|
|
405
470
|
if (unmatched.length > 0)
|
|
406
471
|
sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
|
|
@@ -499,8 +564,69 @@ async function ensureWorkerDatabase(engine, adminUrl, template, prefix) {
|
|
|
499
564
|
await admin.close();
|
|
500
565
|
}
|
|
501
566
|
}
|
|
567
|
+
const HOOK_TIMEOUT = 5000;
|
|
568
|
+
/** Not `new URL(path, base)` alone: `//host/x` and `/\host` would send the request to another host. */
|
|
569
|
+
function hookUrl(proc, hook) {
|
|
570
|
+
const url = new URL(hook.path, proc.url);
|
|
571
|
+
if (url.origin !== new URL(proc.url).origin)
|
|
572
|
+
throw new Error(`slicetest: hook path ${JSON.stringify(hook.path)} leaves the ${proc.label} (${url.origin}); use a path on the app`);
|
|
573
|
+
return url;
|
|
574
|
+
}
|
|
575
|
+
async function callHook(proc, hook, where) {
|
|
576
|
+
const method = (hook.method ?? "POST").toUpperCase();
|
|
577
|
+
const what = `${method} ${hook.path}`;
|
|
578
|
+
let res;
|
|
579
|
+
try {
|
|
580
|
+
// Not following redirects: a 3xx must not move the request off the app.
|
|
581
|
+
res = await fetch(hookUrl(proc, hook), { method, redirect: "manual", signal: AbortSignal.timeout(hook.timeout ?? HOOK_TIMEOUT) });
|
|
582
|
+
}
|
|
583
|
+
catch (e) {
|
|
584
|
+
throw new Error(`slicetest: ${where}: ${what} failed: ${e.message}. The ${proc.label}'s in-process state wasn't reset, so this scenario could see the previous one's. Check that the ${proc.label} serves ${hook.path}.`);
|
|
585
|
+
}
|
|
586
|
+
const body = (await res.text().catch(() => "")).slice(0, 300);
|
|
587
|
+
if (res.status >= 300) {
|
|
588
|
+
throw new Error(`slicetest: ${where}: ${what} answered ${res.status}${body ? `: ${body}` : ""}. The ${proc.label}'s in-process state wasn't reset, so this scenario could see the previous one's.`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** One request: is the process idle right now. */
|
|
592
|
+
async function probeIdle(proc, hook) {
|
|
593
|
+
try {
|
|
594
|
+
const res = await fetch(hookUrl(proc, hook), { method: (hook.method ?? "GET").toUpperCase(), redirect: "manual", signal: AbortSignal.timeout(1000) });
|
|
595
|
+
await res.body?.cancel();
|
|
596
|
+
return res.ok;
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/** Poll `app.idle` until it answers 2xx, for `timeout` or until `deadline` (epoch ms), whichever is first. */
|
|
603
|
+
async function pollIdle(proc, hook, where, deadline) {
|
|
604
|
+
const timeout = hook.timeout ?? HOOK_TIMEOUT;
|
|
605
|
+
const start = Date.now();
|
|
606
|
+
const limit = Math.min(start + timeout, deadline ?? Infinity);
|
|
607
|
+
const method = (hook.method ?? "GET").toUpperCase();
|
|
608
|
+
let last = "no answer";
|
|
609
|
+
for (;;) {
|
|
610
|
+
try {
|
|
611
|
+
const res = await fetch(hookUrl(proc, hook), { method, redirect: "manual", signal: AbortSignal.timeout(Math.max(100, Math.min(1000, limit - Date.now()))) });
|
|
612
|
+
const body = (await res.text().catch(() => "")).trim().slice(0, 200);
|
|
613
|
+
if (res.ok)
|
|
614
|
+
return;
|
|
615
|
+
last = `${res.status}${body ? ` ${body}` : ""}`;
|
|
616
|
+
}
|
|
617
|
+
catch (e) {
|
|
618
|
+
last = e.message;
|
|
619
|
+
}
|
|
620
|
+
if (Date.now() >= limit)
|
|
621
|
+
break;
|
|
622
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
623
|
+
}
|
|
624
|
+
const cut = limit < start + timeout ? `; the test's own timeout left no more (raise it, or lower ${where}.timeout)` : "";
|
|
625
|
+
throw new Error(`slicetest: ${where}: the ${proc.label} still had background work after ${Date.now() - start}ms (${method} ${hook.path} last answered: ${last})${cut}. ` +
|
|
626
|
+
`Left running, it would write into a later scenario, so the ${proc.label} is restarted before the next one. Finish the work before the scenario ends, wait for it in the scenario, or raise the timeout.`);
|
|
627
|
+
}
|
|
502
628
|
function message(res) {
|
|
503
|
-
return { status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: res.json ?? res.text };
|
|
629
|
+
return { status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: res.json ?? res.text, headers: Object.fromEntries(res.headers) };
|
|
504
630
|
}
|
|
505
631
|
function header(v) {
|
|
506
632
|
return Array.isArray(v) ? v[0] : v;
|
package/dist/scenario.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clearScenarioDeadline, setScenarioDeadline } from "./deadline.js";
|
|
1
2
|
import { test } from "vitest";
|
|
2
3
|
// Kept on globalThis so it survives the setup file and the test file loading
|
|
3
4
|
// separate copies of this module (e.g. one from dist, one through an alias).
|
|
@@ -35,9 +36,17 @@ function define(register) {
|
|
|
35
36
|
console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
|
|
36
37
|
await runtime.reportDiagram(file, task.name, true);
|
|
37
38
|
});
|
|
39
|
+
const started = Date.now();
|
|
38
40
|
await runtime.beforeScenario();
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
setScenarioDeadline(started, task.timeout);
|
|
42
|
+
try {
|
|
43
|
+
await body(runtime.context());
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
clearScenarioDeadline();
|
|
47
|
+
}
|
|
48
|
+
// Leaves a margin for the checks after the wait, so `idle` reports before Vitest's timeout does.
|
|
49
|
+
await runtime.afterScenario(task.timeout ? started + task.timeout - 500 : undefined);
|
|
41
50
|
await runtime.reportDiagram(file, task.name, false);
|
|
42
51
|
}, timeout);
|
|
43
52
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files `migrate: { sql }` applies, in order: the file itself, or a directory's `.sql` files (and
|
|
3
|
+
* Diesel's `<version>_<name>/up.sql`) sorted by name with numbers compared as numbers. Rollback
|
|
4
|
+
* files are left out: golang-migrate / sqlx `*.down.sql`, Diesel's `down.sql` and Flyway undo
|
|
5
|
+
* migrations (`U2__…`).
|
|
6
|
+
*/
|
|
7
|
+
export declare function sqlMigrationFiles(target: string): Promise<string[]>;
|
|
8
|
+
/** The statements of a migration file that migrate up: without a goose or dbmate down section. */
|
|
9
|
+
export declare function upSection(sql: string): string;
|
|
10
|
+
/** Apply `files` in order with `exec`, naming the file that fails. */
|
|
11
|
+
export declare function applySqlFiles(files: string[], exec: (sql: string) => Promise<unknown>, root: string): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Version numbers compare as numbers: Flyway's `V2__` comes before `V10__`. */
|
|
5
|
+
const byVersion = new Intl.Collator("en", { numeric: true }).compare;
|
|
6
|
+
/**
|
|
7
|
+
* Files `migrate: { sql }` applies, in order: the file itself, or a directory's `.sql` files (and
|
|
8
|
+
* Diesel's `<version>_<name>/up.sql`) sorted by name with numbers compared as numbers. Rollback
|
|
9
|
+
* files are left out: golang-migrate / sqlx `*.down.sql`, Diesel's `down.sql` and Flyway undo
|
|
10
|
+
* migrations (`U2__…`).
|
|
11
|
+
*/
|
|
12
|
+
export async function sqlMigrationFiles(target) {
|
|
13
|
+
if (!(await stat(target)).isDirectory())
|
|
14
|
+
return [target];
|
|
15
|
+
const entries = await readdir(target, { withFileTypes: true });
|
|
16
|
+
const files = entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".sql") && !/\.down\.sql$/i.test(e.name) && !/^U\d+(\.\d+)*__/.test(e.name)).map((e) => e.name);
|
|
17
|
+
// Diesel: one directory per migration, holding up.sql and down.sql.
|
|
18
|
+
const dirs = entries.filter((e) => e.isDirectory() && existsSync(path.join(target, e.name, "up.sql"))).map((e) => path.join(e.name, "up.sql"));
|
|
19
|
+
return [...files, ...dirs].sort(byVersion).map((f) => path.join(target, f));
|
|
20
|
+
}
|
|
21
|
+
/** Where a migration's rollback section starts in goose (`-- +goose Down`) and dbmate (`-- migrate:down`) files. */
|
|
22
|
+
const DOWN = /^--\s*(\+goose\s+down|migrate:down)\b/im;
|
|
23
|
+
/** The statements of a migration file that migrate up: without a goose or dbmate down section. */
|
|
24
|
+
export function upSection(sql) {
|
|
25
|
+
const m = DOWN.exec(sql);
|
|
26
|
+
return m ? sql.slice(0, m.index) : sql;
|
|
27
|
+
}
|
|
28
|
+
/** Apply `files` in order with `exec`, naming the file that fails. */
|
|
29
|
+
export async function applySqlFiles(files, exec, root) {
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
try {
|
|
32
|
+
await exec(upSection(await readFile(file, "utf8")));
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
throw new Error(`slicetest: db.migrate.sql: ${path.relative(root, file) || file}: ${e.message}`, { cause: e });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/stub.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface ChaosOptions {
|
|
|
54
54
|
export interface StubResponse {
|
|
55
55
|
status?: number;
|
|
56
56
|
headers?: Record<string, string>;
|
|
57
|
-
/**
|
|
57
|
+
/** Strings and bytes (`Uint8Array`, `ArrayBuffer`) are sent as they are; anything else as JSON. */
|
|
58
58
|
body?: unknown;
|
|
59
59
|
}
|
|
60
60
|
/** One server-sent event: `[event, data]`, or `{ event, data, id }`. Data that isn't a string is sent as JSON. */
|
|
@@ -75,7 +75,8 @@ export type Responder = StubResponse | ((call: RecordedCall) => StubResponse | P
|
|
|
75
75
|
* `expect.*` asymmetric matchers receive the actual value.
|
|
76
76
|
*/
|
|
77
77
|
export interface MatchOptions {
|
|
78
|
-
|
|
78
|
+
/** A list matches a repeated parameter's values in order: `{ ids: ["1", "2"] }` for `?ids=1&ids=2`. */
|
|
79
|
+
query?: Record<string, Matcher | Matcher[]>;
|
|
79
80
|
headers?: Record<string, Matcher>;
|
|
80
81
|
json?: unknown;
|
|
81
82
|
/** Subset of a form-encoded body's fields (`call.form`). Numbers and booleans compare as the strings sent. */
|
|
@@ -137,6 +138,7 @@ export declare class Stub {
|
|
|
137
138
|
/**
|
|
138
139
|
* Answer `method path`. `path` may contain `:name` segments (captured into
|
|
139
140
|
* `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
|
|
141
|
+
* A query in `path` (`/search?q=tea`) is a condition on those parameters; others may come along.
|
|
140
142
|
*/
|
|
141
143
|
on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
|
|
142
144
|
/**
|
|
@@ -160,7 +162,12 @@ export declare class Stub {
|
|
|
160
162
|
/** The active `chaos()` settings and what they did, for failure output; undefined when off. */
|
|
161
163
|
describeChaos(): string | undefined;
|
|
162
164
|
/** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
|
|
163
|
-
calls(method?: string,
|
|
165
|
+
calls(method?: string, fullPath?: string | RegExp, fullMatch?: MatchOptions): RecordedCall[];
|
|
166
|
+
/** Calls whose reply function or fallback threw, with the error; the app got a 500 (502 for a fallback). */
|
|
167
|
+
handlerErrors(): {
|
|
168
|
+
call: RecordedCall;
|
|
169
|
+
error: unknown;
|
|
170
|
+
}[];
|
|
164
171
|
unmatched(): RecordedCall[];
|
|
165
172
|
/** Routes registered in this scenario that no call reached, except `optional()` ones. */
|
|
166
173
|
unusedRoutes(): string[];
|
package/dist/stub.js
CHANGED
|
@@ -2,6 +2,7 @@ import http from "node:http";
|
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
3
|
import { describeGraphQL, graphqlErrors, graphqlOf } from "./graphql.js";
|
|
4
4
|
import { timeline } from "./timeline.js";
|
|
5
|
+
import { requestUrl } from "./request-url.js";
|
|
5
6
|
/**
|
|
6
7
|
* A streaming reply in Server-Sent Events format, as LLM APIs stream (Anthropic, OpenAI):
|
|
7
8
|
* `stub("anthropic").on("POST", "/v1/messages").reply(sse([["message_start", {...}], ...]))`.
|
|
@@ -30,6 +31,8 @@ export class Stub {
|
|
|
30
31
|
/** Appended to the 501 answer for a call nothing could answer. */
|
|
31
32
|
#hint;
|
|
32
33
|
#chaos;
|
|
34
|
+
/** Reply functions (and fallbacks) that threw: the app got a 500 / 502, the scenario must fail with the error. */
|
|
35
|
+
#errors = [];
|
|
33
36
|
url = "";
|
|
34
37
|
constructor(name) {
|
|
35
38
|
this.name = name;
|
|
@@ -54,6 +57,7 @@ export class Stub {
|
|
|
54
57
|
/**
|
|
55
58
|
* Answer `method path`. `path` may contain `:name` segments (captured into
|
|
56
59
|
* `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
|
|
60
|
+
* A query in `path` (`/search?q=tea`) is a condition on those parameters; others may come along.
|
|
57
61
|
*/
|
|
58
62
|
on(method, path, match = {}) {
|
|
59
63
|
return this.#on(method, path, match);
|
|
@@ -77,7 +81,8 @@ export class Stub {
|
|
|
77
81
|
};
|
|
78
82
|
return gql;
|
|
79
83
|
}
|
|
80
|
-
#on(method,
|
|
84
|
+
#on(method, fullPath, fullMatch, label) {
|
|
85
|
+
const { path, match } = splitQuery(fullPath, fullMatch);
|
|
81
86
|
const { pattern, paramNames } = compilePath(path);
|
|
82
87
|
const route = {
|
|
83
88
|
method: method.toUpperCase(),
|
|
@@ -179,12 +184,17 @@ export class Stub {
|
|
|
179
184
|
this.#send(call, res, { status: fault.status, headers: retry, body: { error: "slicetest chaos", status: fault.status } });
|
|
180
185
|
}
|
|
181
186
|
/** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
|
|
182
|
-
calls(method,
|
|
187
|
+
calls(method, fullPath, fullMatch = {}) {
|
|
188
|
+
const { path, match } = fullPath === undefined ? { path: undefined, match: fullMatch } : splitQuery(fullPath, fullMatch);
|
|
183
189
|
const compiled = path === undefined ? undefined : compilePath(path);
|
|
184
190
|
return this.#calls.filter((c) => (!method || method === "*" || c.method === method.toUpperCase()) &&
|
|
185
191
|
(!compiled || matchPath(path, compiled.pattern, c.path) !== undefined) &&
|
|
186
192
|
matchConditions(match, c));
|
|
187
193
|
}
|
|
194
|
+
/** Calls whose reply function or fallback threw, with the error; the app got a 500 (502 for a fallback). */
|
|
195
|
+
handlerErrors() {
|
|
196
|
+
return this.#errors;
|
|
197
|
+
}
|
|
188
198
|
unmatched() {
|
|
189
199
|
return this.#calls.filter((c) => !c.matched);
|
|
190
200
|
}
|
|
@@ -239,6 +249,7 @@ export class Stub {
|
|
|
239
249
|
this.#routes = [];
|
|
240
250
|
this.#calls = [];
|
|
241
251
|
this.#chaos = undefined;
|
|
252
|
+
this.#errors = [];
|
|
242
253
|
}
|
|
243
254
|
/**
|
|
244
255
|
* Answer calls that no registered route matches, instead of failing with 501.
|
|
@@ -262,7 +273,7 @@ export class Stub {
|
|
|
262
273
|
const raw = Buffer.concat(chunks);
|
|
263
274
|
const body = raw.toString("utf8");
|
|
264
275
|
const contentType = req.headers["content-type"] ?? "";
|
|
265
|
-
const url =
|
|
276
|
+
const url = requestUrl(req.url, this.url);
|
|
266
277
|
const call = {
|
|
267
278
|
method: req.method ?? "GET",
|
|
268
279
|
path: url.pathname,
|
|
@@ -303,6 +314,7 @@ export class Stub {
|
|
|
303
314
|
out = await this.#fallback?.(call);
|
|
304
315
|
}
|
|
305
316
|
catch (e) {
|
|
317
|
+
this.#errors.push({ call, error: e });
|
|
306
318
|
res.writeHead(502, { "content-type": "text/plain" }).end(e.message);
|
|
307
319
|
return;
|
|
308
320
|
}
|
|
@@ -330,10 +342,15 @@ export class Stub {
|
|
|
330
342
|
this.#send(call, res, typeof route.respond === "function" ? await route.respond(call) : route.respond);
|
|
331
343
|
}
|
|
332
344
|
catch (e) {
|
|
345
|
+
this.#errors.push({ call, error: e });
|
|
333
346
|
res.writeHead(500).end(`slicetest: stub handler threw: ${e}`);
|
|
334
347
|
}
|
|
335
348
|
}
|
|
336
349
|
#send(call, res, out) {
|
|
350
|
+
if (out.body instanceof ArrayBuffer || (ArrayBuffer.isView(out.body) && !(out.body instanceof Uint8Array))) {
|
|
351
|
+
const view = out.body;
|
|
352
|
+
out = { ...out, body: view instanceof ArrayBuffer ? new Uint8Array(view) : new Uint8Array(view.buffer, view.byteOffset, view.byteLength) };
|
|
353
|
+
}
|
|
337
354
|
const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
|
|
338
355
|
const headers = { ...out.headers };
|
|
339
356
|
if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
|
|
@@ -349,15 +366,29 @@ export class Stub {
|
|
|
349
366
|
res.end(payload);
|
|
350
367
|
}
|
|
351
368
|
}
|
|
369
|
+
/** `/search?q=tea` is the path `/search` with the query condition `q: "tea"`; `match.query` wins over the path's. */
|
|
370
|
+
function splitQuery(path, match) {
|
|
371
|
+
if (typeof path !== "string" || !path.includes("?"))
|
|
372
|
+
return { path, match };
|
|
373
|
+
const [bare, search] = [path.slice(0, path.indexOf("?")), path.slice(path.indexOf("?") + 1)];
|
|
374
|
+
const params = new URLSearchParams(search);
|
|
375
|
+
const query = Object.fromEntries([...new Set(params.keys())].map((k) => [k, params.getAll(k).length > 1 ? params.getAll(k) : params.get(k)]));
|
|
376
|
+
return { path: bare || "/", match: { ...match, query: { ...query, ...match.query } } };
|
|
377
|
+
}
|
|
352
378
|
function compilePath(path) {
|
|
353
|
-
if (typeof path !== "string" ||
|
|
379
|
+
if (typeof path !== "string" || !/\/[:*]/.test(path))
|
|
354
380
|
return { pattern: undefined, paramNames: [] };
|
|
355
381
|
const paramNames = [];
|
|
356
382
|
const source = path
|
|
357
383
|
.split("/")
|
|
358
384
|
.map((seg) => {
|
|
385
|
+
// Like Express's `*`: the rest of the path, slashes included.
|
|
386
|
+
if (seg === "*") {
|
|
387
|
+
paramNames.push("*");
|
|
388
|
+
return "(.+)";
|
|
389
|
+
}
|
|
359
390
|
if (!seg.startsWith(":"))
|
|
360
|
-
return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
391
|
+
return encodeLiteral(seg).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
361
392
|
paramNames.push(seg.slice(1));
|
|
362
393
|
return "([^/]+)";
|
|
363
394
|
})
|
|
@@ -367,7 +398,7 @@ function compilePath(path) {
|
|
|
367
398
|
/** Returns captured params on a match, undefined otherwise. */
|
|
368
399
|
function matchPath(path, pattern, actual, names = []) {
|
|
369
400
|
if (typeof path === "string" && !pattern)
|
|
370
|
-
return path === actual ? {} : undefined;
|
|
401
|
+
return path === actual || encodeLiteral(path) === actual ? {} : undefined;
|
|
371
402
|
const re = pattern ?? path;
|
|
372
403
|
re.lastIndex = 0;
|
|
373
404
|
const m = re.exec(actual);
|
|
@@ -377,6 +408,10 @@ function matchPath(path, pattern, actual, names = []) {
|
|
|
377
408
|
names.forEach((n, i) => (params[n] = decodeSegment(m[i + 1])));
|
|
378
409
|
return params;
|
|
379
410
|
}
|
|
411
|
+
/** A route written with a space or non-ASCII characters (`/検索`) is what the app sent percent-encoded (`/%E6%A4%9C…`). */
|
|
412
|
+
function encodeLiteral(path) {
|
|
413
|
+
return path.replace(/[^\x21-\x7e]+/gu, encodeURIComponent);
|
|
414
|
+
}
|
|
380
415
|
/** A path segment decoded, or as sent when it isn't valid percent-encoding (`%zz`), instead of dropping the call. */
|
|
381
416
|
function decodeSegment(segment) {
|
|
382
417
|
try {
|
|
@@ -388,7 +423,7 @@ function decodeSegment(segment) {
|
|
|
388
423
|
}
|
|
389
424
|
function matchConditions(match, call) {
|
|
390
425
|
for (const [k, m] of Object.entries(match.query ?? {})) {
|
|
391
|
-
if (!
|
|
426
|
+
if (!testQuery(m, call.query, k))
|
|
392
427
|
return false;
|
|
393
428
|
}
|
|
394
429
|
for (const [k, m] of Object.entries(match.headers ?? {})) {
|
|
@@ -417,9 +452,11 @@ function matchConditions(match, call) {
|
|
|
417
452
|
/** The first condition of `match` that `call` fails, described; undefined when it meets them all. */
|
|
418
453
|
function conditionMismatch(match, call) {
|
|
419
454
|
for (const [k, m] of Object.entries(match.query ?? {})) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
455
|
+
if (testQuery(m, call.query, k))
|
|
456
|
+
continue;
|
|
457
|
+
const all = call.query.getAll(k);
|
|
458
|
+
const got = all.length === 0 ? "nothing" : Array.isArray(m) || all.length > 1 ? JSON.stringify(all) : JSON.stringify(all[0]);
|
|
459
|
+
return `query ${k}: expected ${Array.isArray(m) ? `[${m.map(show).join(", ")}]` : show(m)}, got ${got}`;
|
|
423
460
|
}
|
|
424
461
|
for (const [k, m] of Object.entries(match.headers ?? {})) {
|
|
425
462
|
const raw = call.headers[k.toLowerCase()];
|
|
@@ -525,6 +562,13 @@ function distance(a, b) {
|
|
|
525
562
|
}
|
|
526
563
|
return row[b.length];
|
|
527
564
|
}
|
|
565
|
+
/** A query condition: one matcher for the first value, or a list for every value of a repeated parameter, in order. */
|
|
566
|
+
function testQuery(m, query, key) {
|
|
567
|
+
if (!Array.isArray(m))
|
|
568
|
+
return test(m, query.get(key) ?? undefined);
|
|
569
|
+
const values = query.getAll(key);
|
|
570
|
+
return values.length === m.length && m.every((x, i) => test(x, values[i]));
|
|
571
|
+
}
|
|
528
572
|
function test(m, value) {
|
|
529
573
|
if (isAsymmetric(m))
|
|
530
574
|
return m.asymmetricMatch(value);
|
|
@@ -558,6 +602,8 @@ function isAsymmetric(m) {
|
|
|
558
602
|
function isForm(contentType) {
|
|
559
603
|
return !!contentType && contentType.split(";")[0].trim().toLowerCase() === "application/x-www-form-urlencoded";
|
|
560
604
|
}
|
|
605
|
+
/** Keys kept as plain fields rather than nested into, so a body can't reach `Object.prototype`. */
|
|
606
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
561
607
|
/**
|
|
562
608
|
* A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
|
|
563
609
|
* `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
|
|
@@ -567,12 +613,13 @@ export function parseForm(body) {
|
|
|
567
613
|
const out = {};
|
|
568
614
|
for (const [key, value] of new URLSearchParams(body)) {
|
|
569
615
|
const m = /^([^[\]]+)((?:\[[^\]]*\])*)$/.exec(key);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
616
|
+
const path = m?.[2] ? [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])] : undefined;
|
|
617
|
+
// `__proto__[x]=1` is sent by whatever the app forwards; it must not reach Object.prototype.
|
|
618
|
+
if (!path || path.some((seg) => UNSAFE_KEYS.has(seg))) {
|
|
619
|
+
const prev = Object.hasOwn(out, key) ? out[key] : undefined;
|
|
620
|
+
Object.defineProperty(out, key, { value: prev === undefined ? value : Array.isArray(prev) ? [...prev, value] : [prev, value], enumerable: true, writable: true, configurable: true });
|
|
573
621
|
continue;
|
|
574
622
|
}
|
|
575
|
-
const path = [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])];
|
|
576
623
|
let node = out;
|
|
577
624
|
for (let i = 0; i < path.length; i++) {
|
|
578
625
|
const seg = path[i];
|
|
@@ -632,8 +679,9 @@ async function parseMultipart(raw, contentType) {
|
|
|
632
679
|
size: value.size,
|
|
633
680
|
...(TEXTUAL.test(value.type) ? { text: await value.text() } : {}),
|
|
634
681
|
};
|
|
635
|
-
|
|
636
|
-
|
|
682
|
+
// `out[key]` would read Object's `constructor` and set the prototype for `__proto__`.
|
|
683
|
+
const prev = Object.hasOwn(out, key) ? out[key] : undefined;
|
|
684
|
+
Object.defineProperty(out, key, { value: prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v], enumerable: true, writable: true, configurable: true });
|
|
637
685
|
}
|
|
638
686
|
return out;
|
|
639
687
|
}
|
package/dist/vitest.d.ts
CHANGED
|
@@ -13,4 +13,8 @@ export declare const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
|
|
|
13
13
|
* Vitest root), or nothing to read the slicetest.config.yaml next to the Vitest config.
|
|
14
14
|
*/
|
|
15
15
|
export declare function slicetest(options?: SlicetestOptions | string): Plugin;
|
|
16
|
+
/** @internal `include`: the CLI's test files, used as they are instead of extending Vitest's. */
|
|
17
|
+
export declare function slicetestPlugin(options?: SlicetestOptions | string, cli?: {
|
|
18
|
+
include?: string[];
|
|
19
|
+
}): Plugin;
|
|
16
20
|
export type { SlicetestOptions } from "./config.js";
|
package/dist/vitest.js
CHANGED
|
@@ -30,6 +30,10 @@ const ext = path.extname(fileURLToPath(import.meta.url));
|
|
|
30
30
|
* Vitest root), or nothing to read the slicetest.config.yaml next to the Vitest config.
|
|
31
31
|
*/
|
|
32
32
|
export function slicetest(options) {
|
|
33
|
+
return slicetestPlugin(options);
|
|
34
|
+
}
|
|
35
|
+
/** @internal `include`: the CLI's test files, used as they are instead of extending Vitest's. */
|
|
36
|
+
export function slicetestPlugin(options, cli = {}) {
|
|
33
37
|
let root;
|
|
34
38
|
return {
|
|
35
39
|
name: "slicetest",
|
|
@@ -37,9 +41,9 @@ export function slicetest(options) {
|
|
|
37
41
|
const root = path.resolve(config.root ?? process.cwd());
|
|
38
42
|
// Mutated rather than returned: a returned list would replace Vitest's default include instead of extending it.
|
|
39
43
|
const test = (config.test ??= {});
|
|
40
|
-
// `slicetest record` runs only its session file.
|
|
44
|
+
// `slicetest record` runs only its session file. Extending the CLI's include would run every scenario file.
|
|
41
45
|
if (!process.env[SESSION_ENV])
|
|
42
|
-
test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
|
|
46
|
+
test.include = cli.include ?? [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
|
|
43
47
|
const resolved = resolveOptions(typeof options === "object" ? options : loadConfigFile(root, options), root);
|
|
44
48
|
return {
|
|
45
49
|
test: {
|
package/dist/webhook.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface HmacScheme {
|
|
|
8
8
|
/** Prepended to the digest, e.g. `sha256=`. */
|
|
9
9
|
prefix?: string;
|
|
10
10
|
}
|
|
11
|
-
export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | "line" | "paddle" | "linear" | "gitlab" | "zoom" | "twitch" | "twilio" | HmacScheme;
|
|
11
|
+
export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | "line" | "paddle" | "linear" | "gitlab" | "zoom" | "twitch" | "twilio" | "typeform" | "notion" | "bitbucket" | "intercom" | HmacScheme;
|
|
12
12
|
export interface WebhookOptions {
|
|
13
13
|
provider: WebhookProvider;
|
|
14
14
|
/** The signing secret the app is configured with. For `standard` (Svix), `whsec_<base64>` or the raw key. */
|
|
@@ -32,14 +32,14 @@ export interface WebhookOptions {
|
|
|
32
32
|
headers?: Record<string, string>;
|
|
33
33
|
}
|
|
34
34
|
/** The providers `signWebhook` knows by name. */
|
|
35
|
-
export declare const WEBHOOK_PROVIDERS: readonly ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio"];
|
|
35
|
+
export declare const WEBHOOK_PROVIDERS: readonly ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio", "typeform", "notion", "bitbucket", "intercom"];
|
|
36
36
|
/**
|
|
37
37
|
* The headers a provider sends with `body`, signed with `secret` the way its
|
|
38
38
|
* SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
|
|
39
39
|
* `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC,
|
|
40
40
|
* Standard Webhooks (Svix, Resend, Clerk, …), LINE's `X-Line-Signature`,
|
|
41
41
|
* Paddle Billing's `Paddle-Signature`, Linear, GitLab's token, Zoom's `v0`
|
|
42
|
-
* signature
|
|
42
|
+
* signature, Twitch EventSub, Typeform, Notion, Bitbucket and Intercom.
|
|
43
43
|
*/
|
|
44
44
|
export declare function signWebhook(body: string, opts: WebhookOptions): Record<string, string>;
|
|
45
45
|
/** The bytes to send and their content type: objects as JSON (as a form for Twilio), URLSearchParams as a form (Slack commands), strings as-is. */
|