slicetest 0.5.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 +183 -5
- package/dist/app.js +2 -2
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +13 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -3
- package/dist/config.d.ts +58 -5
- package/dist/config.js +61 -9
- package/dist/containers.d.ts +2 -0
- package/dist/containers.js +20 -1
- package/dist/db.d.ts +2 -0
- package/dist/db.js +18 -0
- package/dist/doctor.js +17 -5
- package/dist/form.d.ts +45 -0
- package/dist/form.js +226 -0
- package/dist/global-setup.d.ts +1 -0
- package/dist/global-setup.js +90 -34
- package/dist/http.d.ts +17 -1
- package/dist/http.js +76 -9
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/init.js +203 -25
- package/dist/intercept.d.ts +37 -0
- package/dist/intercept.js +199 -0
- package/dist/neon.d.ts +14 -0
- package/dist/neon.js +113 -0
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +4 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +127 -25
- package/dist/setup-file.js +19 -2
- package/dist/stub.d.ts +14 -0
- package/dist/stub.js +22 -0
- package/dist/vitest.d.ts +11 -1
- package/dist/vitest.js +27 -2
- package/dist/x509.d.ts +37 -0
- package/dist/x509.js +150 -0
- package/dist/yaml-runtime.js +35 -13
- package/dist/yaml.d.ts +32 -1
- package/dist/yaml.js +31 -1
- package/package.json +5 -3
- package/preload/node-proxy.cjs +19 -0
- package/schema/scenario.schema.json +140 -0
package/dist/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
|
|
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 (
|
|
106
|
-
fail(`db.queries must be true or false, got ${JSON.stringify(
|
|
107
|
-
if (
|
|
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" &&
|
|
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 =
|
|
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
|
-
|
|
121
|
-
|
|
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) {
|
package/dist/containers.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/containers.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
122
|
-
|
|
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`, `\`${
|
|
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
|
+
}
|
package/dist/global-setup.d.ts
CHANGED
|
@@ -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>;
|