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/dist/config.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** The config files `npx slicetest` (and `slicetest()` without options) look for, in order. */
2
+ export const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetest.config.json"];
1
3
  export function resolveOptions(opts, root) {
2
4
  validate(opts);
3
5
  return {
@@ -6,16 +8,20 @@ export function resolveOptions(opts, root) {
6
8
  services: Object.fromEntries(Object.entries(opts.services ?? {}).map(([name, s]) => [name, { ...s, ready: s.ready && resolveReady(s.ready) }])),
7
9
  containers: opts.containers ?? {},
8
10
  mail: opts.mail ?? false,
11
+ offline: opts.offline ?? false,
12
+ workers: opts.workers,
9
13
  auth: opts.auth === true ? {} : (opts.auth ?? false),
10
- db: resolveDb(opts.db ?? {}),
14
+ db: opts.db === false ? { ...resolveDb({}), none: true } : resolveDb(opts.db ?? {}),
11
15
  stubs: (opts.stubs ?? []).map(stubName),
12
16
  openapi: {
13
17
  app: typeof opts.openapi === "object" ? opts.openapi.spec : opts.openapi,
18
+ fromApp: typeof opts.openapi === "object" ? opts.openapi.fromApp : undefined,
14
19
  minCoverage: typeof opts.openapi === "object" ? opts.openapi.minCoverage : undefined,
15
20
  stubs: Object.fromEntries((opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.openapi ? [[s.name, s.openapi]] : []))),
16
21
  autoReply: (opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.autoReply ? [s.name] : [])),
17
22
  },
18
23
  recordings: resolveRecordings(opts.stubs ?? []),
24
+ intercept: Object.fromEntries((opts.stubs ?? []).flatMap((s) => (typeof s === "object" ? (s.hosts ?? []).map((h) => [h.toLowerCase(), s.name]) : []))),
19
25
  http: opts.http,
20
26
  };
21
27
  }
@@ -66,12 +72,20 @@ function validate(opts) {
66
72
  fail(`${where}.ready.path must start with "/", got "${ready.path}"`);
67
73
  };
68
74
  checkReady(opts.app.ready, "app");
75
+ if (opts.app.scope !== undefined && opts.app.scope !== "file" && opts.app.scope !== "worker")
76
+ fail(`app.scope must be "file" or "worker", got ${JSON.stringify(opts.app.scope)}`);
77
+ const checkBuild = (build, where) => {
78
+ if (build !== undefined && (typeof build !== "string" || !build.trim()))
79
+ fail(`${where}.build must be a command, e.g. "npm run build"`);
80
+ };
81
+ checkBuild(opts.app.build, "app");
69
82
  for (const [name, s] of Object.entries(opts.services ?? {})) {
70
83
  if (!/^[\w-]+$/.test(name))
71
84
  fail(`service name "${name}" may only contain letters, digits, "_" and "-"`);
72
85
  if (!s || typeof s.command !== "string" || !s.command.trim())
73
86
  fail(`services.${name}.command is required`);
74
87
  checkReady(s.ready, `services.${name}`);
88
+ checkBuild(s.build, `services.${name}`);
75
89
  }
76
90
  for (const [name, c] of Object.entries(opts.containers ?? {})) {
77
91
  if (!/^[\w-]+$/.test(name))
@@ -86,6 +100,10 @@ function validate(opts) {
86
100
  fail(`containers.${name}.${key} must be a list of strings, e.g. ["redis-cli", "FLUSHALL"]`);
87
101
  }
88
102
  }
103
+ if (opts.workers !== undefined && !(Number.isInteger(opts.workers) && opts.workers >= 1))
104
+ fail(`workers must be a positive whole number, got ${JSON.stringify(opts.workers)}`);
105
+ if (opts.offline !== undefined && typeof opts.offline !== "boolean")
106
+ fail(`offline must be true or false, got ${JSON.stringify(opts.offline)}`);
89
107
  if (opts.mail !== undefined && typeof opts.mail !== "boolean")
90
108
  fail(`mail must be true or false, got ${JSON.stringify(opts.mail)}`);
91
109
  if (opts.auth !== undefined && typeof opts.auth !== "boolean") {
@@ -99,16 +117,21 @@ function validate(opts) {
99
117
  if (opts.auth.claims !== undefined && (!opts.auth.claims || typeof opts.auth.claims !== "object" || Array.isArray(opts.auth.claims)))
100
118
  fail("auth.claims must be a mapping of claim names to values");
101
119
  }
102
- const engine = opts.db?.engine;
120
+ const dbOpts = opts.db === false ? undefined : opts.db;
121
+ const engine = dbOpts?.engine;
103
122
  if (engine !== undefined && engine !== "postgres" && engine !== "mysql" && engine !== "sqlite")
104
123
  fail(`db.engine must be "postgres", "mysql" or "sqlite", got ${JSON.stringify(engine)}`);
105
- if (opts.db?.queries !== undefined && typeof opts.db.queries !== "boolean")
106
- fail(`db.queries must be true or false, got ${JSON.stringify(opts.db.queries)}`);
107
- if (engine === "sqlite" && opts.db?.queries)
124
+ if (dbOpts?.queries !== undefined && typeof dbOpts.queries !== "boolean")
125
+ fail(`db.queries must be true or false, got ${JSON.stringify(dbOpts.queries)}`);
126
+ if (dbOpts?.neon !== undefined && typeof dbOpts.neon !== "boolean")
127
+ fail(`db.neon must be true or false, got ${JSON.stringify(dbOpts.neon)}`);
128
+ if (dbOpts?.neon && (engine === "mysql" || engine === "sqlite"))
129
+ fail("db.neon is Neon's protocol for Postgres; it doesn't apply to " + engine);
130
+ if (engine === "sqlite" && dbOpts?.queries)
108
131
  fail("db.queries needs a database server (postgres or mysql): an SQLite app opens the file directly, so there is no connection to read");
109
- if (engine === "sqlite" && opts.db?.url)
132
+ if (engine === "sqlite" && dbOpts?.url)
110
133
  fail("db.url doesn't apply to sqlite: slicetest creates the database files itself and passes them to the app as {{db.url}} / {{db.path}}");
111
- const migrate = opts.db?.migrate;
134
+ const migrate = dbOpts?.migrate;
112
135
  if (migrate) {
113
136
  const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
114
137
  if (keys.length !== 1)
@@ -117,8 +140,15 @@ function validate(opts) {
117
140
  const oas = opts.openapi;
118
141
  if (oas !== undefined) {
119
142
  const spec = typeof oas === "object" && oas ? oas.spec : oas;
120
- if (typeof spec !== "string" || !spec)
121
- fail('openapi must be the path of an OpenAPI file, or { spec, minCoverage }');
143
+ const fromApp = typeof oas === "object" && oas ? oas.fromApp : undefined;
144
+ if (fromApp !== undefined) {
145
+ if (spec !== undefined)
146
+ fail("openapi takes either spec (a file) or fromApp (a path the app serves its spec at), not both");
147
+ if (typeof fromApp !== "string" || !fromApp.startsWith("/"))
148
+ fail(`openapi.fromApp must be a path on the app such as "/v3/api-docs", got ${JSON.stringify(fromApp)}`);
149
+ }
150
+ else if (typeof spec !== "string" || !spec)
151
+ fail('openapi must be the path of an OpenAPI file, or { spec, minCoverage }, or { fromApp: "/v3/api-docs" }');
122
152
  const min = typeof oas === "object" ? oas.minCoverage : undefined;
123
153
  if (min !== undefined && !(typeof min === "number" && min >= 0 && min <= 100))
124
154
  fail("openapi.minCoverage must be a percentage between 0 and 100");
@@ -132,6 +162,28 @@ function validate(opts) {
132
162
  fail(`stub "${s.name}": upstream must be an http(s) URL, got ${JSON.stringify(s.upstream)}`);
133
163
  if (typeof s === "object" && s.recordings !== undefined && !s.upstream)
134
164
  fail(`stub "${s.name}": recordings needs an upstream to record from`);
165
+ if (typeof s === "object" && s.hosts !== undefined) {
166
+ if (!Array.isArray(s.hosts) || s.hosts.length === 0)
167
+ fail(`stub "${s.name}": hosts must be a list of host names, e.g. ["api.github.com"]`);
168
+ for (const h of s.hosts) {
169
+ if (typeof h !== "string" || !/^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$|^[a-z0-9-]+$/i.test(h)) {
170
+ fail(`stub "${s.name}": hosts takes host names (or *.domain for every subdomain), no scheme, port or path; got ${JSON.stringify(h)}`);
171
+ }
172
+ if (["localhost", "127.0.0.1"].includes(h.toLowerCase()))
173
+ fail(`stub "${s.name}": ${h} can't be intercepted; point the app at {{stub.${s.name}}} instead`);
174
+ }
175
+ }
176
+ }
177
+ const seen = new Map();
178
+ for (const s of opts.stubs ?? []) {
179
+ if (typeof s !== "object")
180
+ continue;
181
+ for (const h of s.hosts ?? []) {
182
+ const other = seen.get(h.toLowerCase());
183
+ if (other)
184
+ fail(`host ${h} is intercepted by both stub "${other}" and stub "${s.name}"`);
185
+ seen.set(h.toLowerCase(), s.name);
186
+ }
135
187
  }
136
188
  const stubs = (opts.stubs ?? []).map(stubName);
137
189
  for (const name of stubs) {
@@ -19,4 +19,6 @@ export declare class Dependency {
19
19
  /** Start of a scenario: run the `reset` command, if any. */
20
20
  reset(): Promise<void>;
21
21
  stop(): Promise<void>;
22
+ /** Synchronous removal for a worker exiting without tearing down (Ryuk is often off with Podman). */
23
+ removeNow(): void;
22
24
  }
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { configureContainerRuntime } from "./container-runtime.js";
2
3
  /**
3
4
  * A container the app depends on (Redis, a search engine, an S3 emulator),
@@ -23,7 +24,9 @@ export class Dependency {
23
24
  if (opts.ready)
24
25
  definition = definition.withWaitStrategy(Wait.forLogMessage(opts.ready.log));
25
26
  try {
26
- return new Dependency(name, await definition.start(), opts);
27
+ const dependency = new Dependency(name, await definition.start(), opts);
28
+ running.add(dependency);
29
+ return dependency;
27
30
  }
28
31
  catch (e) {
29
32
  throw new Error(`slicetest: container "${name}" (${opts.image}) didn't start: ${e.message}`);
@@ -54,6 +57,22 @@ export class Dependency {
54
57
  await this.exec(this.opts.reset);
55
58
  }
56
59
  async stop() {
60
+ running.delete(this);
57
61
  await this.container.stop();
58
62
  }
63
+ /** Synchronous removal for a worker exiting without tearing down (Ryuk is often off with Podman). */
64
+ removeNow() {
65
+ for (const cli of ["docker", "podman"]) {
66
+ try {
67
+ execFileSync(cli, ["rm", "-f", this.container.getId()], { stdio: "ignore", timeout: 10_000, windowsHide: true });
68
+ return;
69
+ }
70
+ catch { }
71
+ }
72
+ }
59
73
  }
74
+ const running = new Set();
75
+ process.once("exit", () => {
76
+ for (const d of running)
77
+ d.removeNow();
78
+ });
package/dist/db.d.ts CHANGED
@@ -95,3 +95,5 @@ export declare class Db {
95
95
  }
96
96
  /** Short summary of `changes()` for failure output. */
97
97
  export declare function formatChanges(changes: Changes, maxRows?: number): string;
98
+ /** The `db` of an app without a database (`db: false`): resetting is a no-op, anything else explains. */
99
+ export declare function noDatabase(): Db;
package/dist/db.js CHANGED
@@ -282,3 +282,21 @@ export function formatChanges(changes, maxRows = 3) {
282
282
  function truncate(s, max = 200) {
283
283
  return s.length > max ? `${s.slice(0, max)}…` : s;
284
284
  }
285
+ /** The `db` of an app without a database (`db: false`): resetting is a no-op, anything else explains. */
286
+ export function noDatabase() {
287
+ const off = () => {
288
+ throw new Error("slicetest: the app has no database (db: false), so db.* isn't available. Configure `db` to use it.");
289
+ };
290
+ return new Proxy({}, {
291
+ get(_, prop) {
292
+ if (prop === "reset" || prop === "close")
293
+ return async () => { };
294
+ if (prop === "changesSinceStart")
295
+ return async () => ({});
296
+ if (prop === "then")
297
+ return undefined;
298
+ // The real methods are async, so this rejects like they would.
299
+ return async () => off();
300
+ },
301
+ });
302
+ }
package/dist/doctor.js CHANGED
@@ -67,7 +67,12 @@ export async function doctor(configPath, probes = machine, env = process.env) {
67
67
  }
68
68
  // Database: a server that's there already, or a container runtime to start one in.
69
69
  const url = opts.db.engine === "sqlite" ? undefined : (opts.db.url ?? env.SLICETEST_DATABASE_URL);
70
- if (opts.db.engine === "sqlite") {
70
+ if (opts.db.none) {
71
+ add("ok", "no database (db: false)");
72
+ if (Object.keys(opts.containers).length)
73
+ await checkContainerRuntime(add, probes, `runs ${Object.values(opts.containers).map((c) => c.image).join(", ")}`);
74
+ }
75
+ else if (opts.db.engine === "sqlite") {
71
76
  const [maj = 0, min = 0] = process.versions.node.split(".").map(Number);
72
77
  if (maj > 22 || (maj === 22 && min >= 5))
73
78
  add("ok", "sqlite (node:sqlite, no server needed)");
@@ -97,7 +102,10 @@ export async function doctor(configPath, probes = machine, env = process.env) {
97
102
  }
98
103
  }
99
104
  const m = opts.db.migrate;
100
- if (!m)
105
+ if (opts.db.none) {
106
+ // Nothing to migrate.
107
+ }
108
+ else if (!m)
101
109
  add("warn", "db.migrate not set", "scenarios run against an empty database unless the app creates its own tables");
102
110
  else if ("atlas" in m) {
103
111
  await checkPath(add, "migrations", m.atlas.dir.replace(/^file:\/\//, ""), root, rel);
@@ -118,12 +126,16 @@ export async function doctor(configPath, probes = machine, env = process.env) {
118
126
  for (const [label, p] of [["app", opts.app], ...Object.entries(opts.services).map(([n, s]) => [`service ${n}`, s])]) {
119
127
  if (p.cwd)
120
128
  await checkPath(add, `${label} cwd`, p.cwd, root, rel);
121
- const program = firstWord(p.command);
122
- if (program && !/^[.\\/]|\{\{|\$/.test(program)) {
129
+ const seen = new Set();
130
+ for (const command of [p.build, p.command]) {
131
+ const program = command && firstWord(command);
132
+ if (!program || /^[.\\/]|\{\{|\$/.test(program) || seen.has(program))
133
+ continue;
134
+ seen.add(program);
123
135
  if (await onPath(program, env))
124
136
  add("ok", `${label}: ${program} found`);
125
137
  else
126
- add("warn", `${label}: ${program} not found on PATH`, `\`${p.command}\` may fail to start. Fine if a shell alias or a relative script provides it`);
138
+ add("warn", `${label}: ${program} not found on PATH`, `\`${command}\` may fail to start. Fine if a shell alias or a relative script provides it`);
127
139
  }
128
140
  }
129
141
  const specs = [...(opts.openapi.app ? [["app OpenAPI", opts.openapi.app]] : []), ...Object.entries(opts.openapi.stubs).map(([n, f]) => [`stub ${n} OpenAPI`, f])];
package/dist/form.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Submitting an HTML form the way a browser with JavaScript off does: every
3
+ * successful control of the form, plus the button that was pressed. This is
4
+ * what makes hidden fields work without the test knowing about them: CSRF
5
+ * tokens (Django, Rails, Laravel) and Next.js / Remix server actions, whose
6
+ * forms carry the action id and bound arguments in hidden inputs.
7
+ */
8
+ export interface SubmitOptions {
9
+ /**
10
+ * The submit button to press, by its text, `value`, `name` or `id`. Pressing it
11
+ * also picks its form. Default: the form's only submit button, or none.
12
+ */
13
+ button?: string;
14
+ /** The form, by `id`, `name` or position (0-based), when the page has several and no `button` picks one. */
15
+ form?: string | number;
16
+ /** Values typed into the form, by field name. A name the form doesn't have is an error (a typo, usually). */
17
+ fields?: Record<string, string | number | boolean | (string | number)[]>;
18
+ }
19
+ export interface FormRequest {
20
+ method: "GET" | "POST";
21
+ /** Path (and query) relative to the page. */
22
+ action: string;
23
+ body?: URLSearchParams | FormData;
24
+ }
25
+ interface Control {
26
+ tag: "input" | "button" | "select" | "textarea";
27
+ attrs: Record<string, string>;
28
+ /** Text content: a button's label, a textarea's value. */
29
+ text: string;
30
+ /** For select: its options. */
31
+ options: {
32
+ value: string;
33
+ selected: boolean;
34
+ }[];
35
+ }
36
+ interface ParsedForm {
37
+ attrs: Record<string, string>;
38
+ controls: Control[];
39
+ }
40
+ export declare function decodeEntities(s: string): string;
41
+ /** The forms of a page with their controls, in document order. Controls with a `form="id"` attribute join that form. */
42
+ export declare function parseForms(html: string): ParsedForm[];
43
+ /** Works out the request a browser would send for this page's form. */
44
+ export declare function formRequest(html: string, opts?: SubmitOptions): FormRequest;
45
+ export {};
package/dist/form.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Submitting an HTML form the way a browser with JavaScript off does: every
3
+ * successful control of the form, plus the button that was pressed. This is
4
+ * what makes hidden fields work without the test knowing about them: CSRF
5
+ * tokens (Django, Rails, Laravel) and Next.js / Remix server actions, whose
6
+ * forms carry the action id and bound arguments in hidden inputs.
7
+ */
8
+ const TAG = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*\/?>/g;
9
+ const ATTR = /([^\s=>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
10
+ const RAW_TEXT = new Set(["script", "style", "template"]);
11
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'" };
12
+ export function decodeEntities(s) {
13
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+\d*);/gi, (m, e) => {
14
+ if (e[0] === "#") {
15
+ const code = e[1] === "x" || e[1] === "X" ? Number.parseInt(e.slice(2), 16) : Number.parseInt(e.slice(1), 10);
16
+ return Number.isFinite(code) ? String.fromCodePoint(code) : m;
17
+ }
18
+ return ENTITIES[e.toLowerCase()] ?? m;
19
+ });
20
+ }
21
+ function parseAttrs(s) {
22
+ const attrs = {};
23
+ for (const m of s.matchAll(ATTR)) {
24
+ const name = m[1].toLowerCase();
25
+ if (!(name in attrs))
26
+ attrs[name] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? "");
27
+ }
28
+ return attrs;
29
+ }
30
+ const textOf = (html) => decodeEntities(html.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
31
+ /** The forms of a page with their controls, in document order. Controls with a `form="id"` attribute join that form. */
32
+ export function parseForms(html) {
33
+ const forms = [];
34
+ const byId = new Map();
35
+ const orphans = [];
36
+ let current;
37
+ let open;
38
+ let select;
39
+ let option;
40
+ const add = (control) => {
41
+ const formId = control.attrs.form;
42
+ if (formId !== undefined)
43
+ orphans.push({ formId, control });
44
+ else
45
+ current?.controls.push(control);
46
+ };
47
+ const closeOption = (end) => {
48
+ if (!option || !select)
49
+ return;
50
+ const value = option.attrs.value ?? textOf(html.slice(option.start, end));
51
+ select.options.push({ value, selected: "selected" in option.attrs });
52
+ option = undefined;
53
+ };
54
+ TAG.lastIndex = 0;
55
+ for (let m = TAG.exec(html); m; m = TAG.exec(html)) {
56
+ if (!m[2])
57
+ continue; // comment
58
+ const closing = m[1] === "/";
59
+ const tag = m[2].toLowerCase();
60
+ if (!closing && RAW_TEXT.has(tag)) {
61
+ const end = html.toLowerCase().indexOf(`</${tag}`, TAG.lastIndex);
62
+ TAG.lastIndex = end < 0 ? html.length : end;
63
+ continue;
64
+ }
65
+ if (tag === "form") {
66
+ if (closing)
67
+ current = undefined;
68
+ else if (!current) {
69
+ current = { attrs: parseAttrs(m[3] ?? ""), controls: [] };
70
+ forms.push(current);
71
+ if (current.attrs.id)
72
+ byId.set(current.attrs.id, current);
73
+ }
74
+ continue;
75
+ }
76
+ if (tag === "button" || tag === "textarea") {
77
+ if (closing) {
78
+ if (open?.control.tag === tag) {
79
+ open.control.text = tag === "textarea" ? decodeEntities(html.slice(open.start, m.index)).replace(/^\r?\n/, "") : textOf(html.slice(open.start, m.index));
80
+ open = undefined;
81
+ }
82
+ }
83
+ else {
84
+ const control = { tag, attrs: parseAttrs(m[3] ?? ""), text: "", options: [] };
85
+ add(control);
86
+ open = { control, start: TAG.lastIndex };
87
+ if (tag === "textarea") {
88
+ // Its content is text, not markup.
89
+ const end = html.toLowerCase().indexOf("</textarea", TAG.lastIndex);
90
+ TAG.lastIndex = end < 0 ? html.length : end;
91
+ }
92
+ }
93
+ continue;
94
+ }
95
+ if (tag === "select") {
96
+ if (closing) {
97
+ closeOption(m.index);
98
+ select = undefined;
99
+ }
100
+ else {
101
+ select = { tag, attrs: parseAttrs(m[3] ?? ""), text: "", options: [] };
102
+ add(select);
103
+ }
104
+ continue;
105
+ }
106
+ if (tag === "option" || tag === "optgroup") {
107
+ closeOption(m.index);
108
+ if (tag === "option" && !closing && select)
109
+ option = { attrs: parseAttrs(m[3] ?? ""), start: TAG.lastIndex };
110
+ continue;
111
+ }
112
+ if (tag === "input" && !closing)
113
+ add({ tag, attrs: parseAttrs(m[3] ?? ""), text: "", options: [] });
114
+ }
115
+ for (const { formId, control } of orphans)
116
+ byId.get(formId)?.controls.push(control);
117
+ return forms;
118
+ }
119
+ const isSubmit = (c) => (c.tag === "button" && (c.attrs.type ?? "submit").toLowerCase() === "submit") || (c.tag === "input" && ["submit", "image"].includes((c.attrs.type ?? "").toLowerCase()));
120
+ const label = (c) => (c.tag === "button" ? c.text : (c.attrs.value ?? (c.attrs.type?.toLowerCase() === "image" ? (c.attrs.alt ?? "") : "Submit")));
121
+ function describe(form, i) {
122
+ const name = form.attrs.id ? `#${form.attrs.id}` : form.attrs.name ? `name=${form.attrs.name}` : `form ${i}`;
123
+ const buttons = form.controls.filter(isSubmit).map((b) => JSON.stringify(label(b)));
124
+ return `${name} (${(form.attrs.method ?? "get").toUpperCase()} ${form.attrs.action || "<this page>"}; buttons: ${buttons.join(", ") || "none"})`;
125
+ }
126
+ /** Works out the request a browser would send for this page's form. */
127
+ export function formRequest(html, opts = {}) {
128
+ const forms = parseForms(html);
129
+ const list = () => (forms.length ? forms.map((f, i) => ` ${describe(f, i)}`).join("\n") : " (none)");
130
+ if (forms.length === 0)
131
+ throw new Error("slicetest: submit: the page has no <form>");
132
+ let form;
133
+ if (opts.form !== undefined) {
134
+ form = typeof opts.form === "number" ? forms[opts.form] : forms.find((f) => f.attrs.id === opts.form || f.attrs.name === opts.form);
135
+ if (!form)
136
+ throw new Error(`slicetest: submit: no form ${JSON.stringify(opts.form)} on the page. Forms:\n${list()}`);
137
+ }
138
+ let button;
139
+ if (opts.button !== undefined) {
140
+ const want = opts.button.trim();
141
+ const matches = (b) => [label(b).trim(), b.attrs.value, b.attrs.name, b.attrs.id].includes(want);
142
+ const candidates = (form ? [form] : forms).flatMap((f) => f.controls.filter((c) => isSubmit(c) && matches(c)).map((c) => ({ f, c })));
143
+ if (candidates.length === 0)
144
+ throw new Error(`slicetest: submit: no submit button ${JSON.stringify(want)}${form ? " in that form" : ""}. Forms:\n${list()}`);
145
+ if (candidates.length > 1 && new Set(candidates.map((x) => x.f)).size > 1) {
146
+ throw new Error(`slicetest: submit: ${candidates.length} forms have a button ${JSON.stringify(want)}; pick one with \`form\`. Forms:\n${list()}`);
147
+ }
148
+ form = candidates[0].f;
149
+ button = candidates[0].c;
150
+ }
151
+ if (!form) {
152
+ if (forms.length > 1)
153
+ throw new Error(`slicetest: submit: the page has ${forms.length} forms; pick one with \`button\` or \`form\`. Forms:\n${list()}`);
154
+ form = forms[0];
155
+ }
156
+ if (!button) {
157
+ const submits = form.controls.filter(isSubmit);
158
+ if (submits.length === 1)
159
+ button = submits[0];
160
+ }
161
+ if (button && "disabled" in button.attrs)
162
+ throw new Error(`slicetest: submit: the button ${JSON.stringify(label(button))} is disabled`);
163
+ const names = new Set(form.controls.filter((c) => c.attrs.name !== undefined && !isSubmit(c) && c.tag !== "button").map((c) => c.attrs.name));
164
+ const typed = new Map();
165
+ for (const [name, value] of Object.entries(opts.fields ?? {})) {
166
+ if (!names.has(name)) {
167
+ const shown = [...names].filter((n) => !n.startsWith("$ACTION_"));
168
+ throw new Error(`slicetest: submit: the form has no field "${name}". Fields: ${shown.join(", ") || "(none)"}`);
169
+ }
170
+ typed.set(name, typeof value === "boolean" ? value : (Array.isArray(value) ? value : [value]).map(String));
171
+ }
172
+ // The successful controls, in document order (HTML's "constructing the entry list").
173
+ // A typed value replaces what the page had: text for a field, the checked state for
174
+ // checkboxes and radios (true / false, or the values to check), the options of a select.
175
+ const entries = [];
176
+ const done = new Set();
177
+ for (const c of form.controls) {
178
+ const name = c.attrs.name;
179
+ if (name === undefined || "disabled" in c.attrs)
180
+ continue;
181
+ if (c.tag === "button" || isSubmit(c)) {
182
+ if (c === button)
183
+ entries.push([name, c.attrs.value ?? ""]);
184
+ continue;
185
+ }
186
+ const type = c.tag === "input" ? (c.attrs.type ?? "text").toLowerCase() : c.tag;
187
+ if (type === "reset" || type === "button" || type === "file")
188
+ continue;
189
+ const want = typed.get(name);
190
+ if (type === "checkbox" || type === "radio") {
191
+ const value = c.attrs.value ?? "on";
192
+ const checked = want === undefined ? "checked" in c.attrs : typeof want === "boolean" ? want : want.includes(value);
193
+ if (checked)
194
+ entries.push([name, value]);
195
+ }
196
+ else if (want !== undefined) {
197
+ // Every value goes out at the first control of that name.
198
+ if (!done.has(name))
199
+ for (const v of want === true ? ["on"] : want === false ? [] : want)
200
+ entries.push([name, v]);
201
+ done.add(name);
202
+ }
203
+ else if (c.tag === "textarea")
204
+ entries.push([name, c.text]);
205
+ else if (c.tag === "select") {
206
+ const selected = c.options.filter((o) => o.selected);
207
+ const chosen = selected.length ? selected : "multiple" in c.attrs || c.options.length === 0 ? [] : [c.options[0]];
208
+ for (const o of chosen)
209
+ entries.push([name, o.value]);
210
+ }
211
+ else
212
+ entries.push([name, c.attrs.value ?? ""]);
213
+ }
214
+ const pick = (attr) => (button && button.attrs[`form${attr}`] !== undefined ? button.attrs[`form${attr}`] : form.attrs[attr]);
215
+ const method = (pick("method") ?? "get").toUpperCase() === "POST" ? "POST" : "GET";
216
+ const action = pick("action") ?? "";
217
+ if (method === "GET")
218
+ return { method, action: `${action.replace(/[?#].*$/, "")}?${new URLSearchParams(entries)}` };
219
+ const multipart = (pick("enctype") ?? "").toLowerCase() === "multipart/form-data";
220
+ if (!multipart)
221
+ return { method, action, body: new URLSearchParams(entries) };
222
+ const body = new FormData();
223
+ for (const [k, v] of entries)
224
+ body.append(k, v);
225
+ return { method, action, body };
226
+ }
@@ -13,3 +13,4 @@ export declare function migrationKey(opts: Pick<ResolvedOptions, "db" | "root">)
13
13
  * Windows drive paths) are made relative to the root, which is the cwd Atlas runs in.
14
14
  */
15
15
  export declare function atlasDirUrl(dir: string, root: string): string;
16
+ export declare function buildAll(opts: Pick<ResolvedOptions, "app" | "services" | "root">): Promise<void>;