slicetest 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +293 -7
  2. package/dist/app.js +2 -2
  3. package/dist/auth.d.ts +55 -0
  4. package/dist/auth.js +140 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +18 -6
  7. package/dist/config.d.ts +72 -5
  8. package/dist/config.js +74 -6
  9. package/dist/containers.d.ts +2 -0
  10. package/dist/containers.js +20 -1
  11. package/dist/db.d.ts +28 -0
  12. package/dist/db.js +68 -0
  13. package/dist/doctor.js +17 -5
  14. package/dist/drivers/driver.d.ts +27 -0
  15. package/dist/drivers/mysql.d.ts +2 -1
  16. package/dist/drivers/mysql.js +38 -0
  17. package/dist/drivers/postgres.d.ts +2 -1
  18. package/dist/drivers/postgres.js +28 -0
  19. package/dist/drivers/sqlite.d.ts +2 -1
  20. package/dist/drivers/sqlite.js +52 -0
  21. package/dist/factory.d.ts +21 -0
  22. package/dist/factory.js +129 -0
  23. package/dist/form.d.ts +45 -0
  24. package/dist/form.js +226 -0
  25. package/dist/gen.d.ts +1 -0
  26. package/dist/gen.js +9 -4
  27. package/dist/global-setup.d.ts +1 -0
  28. package/dist/global-setup.js +90 -34
  29. package/dist/http.d.ts +24 -1
  30. package/dist/http.js +86 -9
  31. package/dist/index.d.ts +6 -1
  32. package/dist/index.js +3 -0
  33. package/dist/init.js +245 -24
  34. package/dist/intercept.d.ts +37 -0
  35. package/dist/intercept.js +199 -0
  36. package/dist/neon.d.ts +14 -0
  37. package/dist/neon.js +113 -0
  38. package/dist/openapi.d.ts +10 -0
  39. package/dist/openapi.js +22 -0
  40. package/dist/query-log.d.ts +49 -0
  41. package/dist/query-log.js +261 -0
  42. package/dist/runtime.d.ts +18 -0
  43. package/dist/runtime.js +163 -23
  44. package/dist/setup-file.js +19 -2
  45. package/dist/stub.d.ts +46 -0
  46. package/dist/stub.js +109 -0
  47. package/dist/vitest.d.ts +11 -1
  48. package/dist/vitest.js +27 -2
  49. package/dist/webhook.d.ts +40 -0
  50. package/dist/webhook.js +52 -0
  51. package/dist/x509.d.ts +37 -0
  52. package/dist/x509.js +150 -0
  53. package/dist/yaml-runtime.js +76 -16
  54. package/dist/yaml.d.ts +67 -1
  55. package/dist/yaml.js +87 -3
  56. package/package.json +12 -4
  57. package/preload/node-proxy.cjs +19 -0
  58. package/schema/scenario.schema.json +320 -0
@@ -134,6 +134,44 @@ export class MysqlDriver {
134
134
  const conds = Object.keys(where).map((k) => `${this.column(k)} = ?`);
135
135
  return this.query(`SELECT * FROM ${this.ident(table)} WHERE ${conds.join(" AND ")}`, Object.values(where));
136
136
  }
137
+ async describe(table) {
138
+ const [schema, name] = table.includes(".") ? table.split(".", 2) : [null, table];
139
+ const columns = await this.query(`SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS dflt, EXTRA AS extra,
140
+ CHARACTER_MAXIMUM_LENGTH AS max
141
+ FROM information_schema.COLUMNS
142
+ WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ?
143
+ ORDER BY ORDINAL_POSITION`, [schema, name]);
144
+ if (columns.length === 0)
145
+ throw new Error(`slicetest: there is no table "${table}"`);
146
+ const keys = await this.query(`SELECT CONSTRAINT_NAME AS id, COLUMN_NAME AS \`column\`, REFERENCED_TABLE_SCHEMA AS \`schema\`,
147
+ REFERENCED_TABLE_NAME AS \`table\`, REFERENCED_COLUMN_NAME AS ref, DATABASE() AS current
148
+ FROM information_schema.KEY_COLUMN_USAGE
149
+ WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL
150
+ ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION`, [schema, name]);
151
+ const foreignKeys = new Map();
152
+ for (const k of keys) {
153
+ const fk = foreignKeys.get(k.id) ?? { columns: [], table: k.schema === k.current ? k.table : `${k.schema}.${k.table}`, references: [] };
154
+ fk.columns.push(k.column);
155
+ fk.references.push(k.ref);
156
+ foreignKeys.set(k.id, fk);
157
+ }
158
+ const checks = await this.query(`SELECT c.CHECK_CLAUSE AS def
159
+ FROM information_schema.CHECK_CONSTRAINTS c
160
+ JOIN information_schema.TABLE_CONSTRAINTS t ON t.CONSTRAINT_SCHEMA = c.CONSTRAINT_SCHEMA AND t.CONSTRAINT_NAME = c.CONSTRAINT_NAME
161
+ WHERE t.TABLE_SCHEMA = COALESCE(?, DATABASE()) AND t.TABLE_NAME = ?`, [schema, name]);
162
+ return {
163
+ columns: columns.map((c) => ({
164
+ name: c.name,
165
+ type: c.type,
166
+ nullable: c.nullable === "YES",
167
+ hasDefault: c.dflt !== null || /auto_increment|generated/i.test(c.extra),
168
+ maxLength: c.max === null ? undefined : Number(c.max),
169
+ values: /^enum\(/i.test(c.type) ? [...c.type.matchAll(/'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'")) : undefined,
170
+ })),
171
+ foreignKeys: [...foreignKeys.values()],
172
+ checks: checks.map((c) => c.def),
173
+ };
174
+ }
137
175
  async #primaryKey(table) {
138
176
  let key = this.#keys.get(table);
139
177
  if (!key) {
@@ -1,4 +1,4 @@
1
- import type { Driver, Engine, Row, Table } from "./driver.js";
1
+ import type { Driver, Engine, Row, Table, TableShape } from "./driver.js";
2
2
  export declare class PostgresDriver implements Driver {
3
3
  private readonly client;
4
4
  private constructor();
@@ -14,6 +14,7 @@ export declare class PostgresDriver implements Driver {
14
14
  listTables(schemas: string[], keep: string[]): Promise<Table[]>;
15
15
  truncate(tables: Table[]): Promise<void>;
16
16
  insert(table: string, row: Row): Promise<Row[]>;
17
+ describe(table: string): Promise<TableShape>;
17
18
  close(): Promise<void>;
18
19
  }
19
20
  export declare const postgres: Engine;
@@ -88,6 +88,34 @@ export class PostgresDriver {
88
88
  : `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.ident(c)).join(", ")}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING *`;
89
89
  return this.query(sql, Object.values(row));
90
90
  }
91
+ async describe(table) {
92
+ const [{ oid } = { oid: null }] = await this.query("SELECT to_regclass($1)::oid AS oid", [this.ident(table)]);
93
+ if (oid === null)
94
+ throw new Error(`slicetest: there is no table "${table}"`);
95
+ const columns = await this.query(`SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type, NOT a.attnotnull AS nullable,
96
+ (a.atthasdef OR a.attidentity <> '' OR a.attgenerated <> '') AS has_default,
97
+ information_schema._pg_char_max_length(a.atttypid, a.atttypmod) AS max,
98
+ (SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = a.atttypid) AS values
99
+ FROM pg_attribute a
100
+ WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped
101
+ ORDER BY a.attnum`, [oid]);
102
+ const keys = await this.query(`SELECT (SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(c.conkey) WITH ORDINALITY k(n, ord)
103
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.n) AS columns,
104
+ n.nspname AS schema, r.relname AS table,
105
+ (SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(c.confkey) WITH ORDINALITY k(n, ord)
106
+ JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.n) AS references
107
+ FROM pg_constraint c
108
+ JOIN pg_class r ON r.oid = c.confrelid
109
+ JOIN pg_namespace n ON n.oid = r.relnamespace
110
+ WHERE c.contype = 'f' AND c.conrelid = $1
111
+ ORDER BY c.conname`, [oid]);
112
+ const checks = await this.query("SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE contype = 'c' AND conrelid = $1", [oid]);
113
+ return {
114
+ columns: columns.map((c) => ({ name: c.name, type: c.type, nullable: c.nullable, hasDefault: c.has_default, maxLength: c.max ?? undefined, values: c.values ?? undefined })),
115
+ foreignKeys: keys.map((k) => ({ columns: k.columns, table: k.schema === "public" ? k.table : `${k.schema}.${k.table}`, references: k.references })),
116
+ checks: checks.map((c) => c.def),
117
+ };
118
+ }
91
119
  async close() {
92
120
  await this.client.end();
93
121
  }
@@ -1,4 +1,4 @@
1
- import type { Driver, Engine, Row, Table } from "./driver.js";
1
+ import type { Driver, Engine, Row, Table, TableShape } from "./driver.js";
2
2
  /** `sqlite:///tmp/a.db` → `/tmp/a.db`; `sqlite:///C:/x/a.db` → `C:/x/a.db`. Plain paths pass through. */
3
3
  export declare function sqlitePath(url: string): string;
4
4
  export declare function sqliteUrl(file: string): string;
@@ -18,6 +18,7 @@ export declare class SqliteDriver implements Driver {
18
18
  /** DELETE, then forget AUTOINCREMENT counters. Foreign keys are off for this connection only. */
19
19
  truncate(tables: Table[]): Promise<void>;
20
20
  insert(table: string, row: Row): Promise<Row[]>;
21
+ describe(table: string): Promise<TableShape>;
21
22
  close(): Promise<void>;
22
23
  }
23
24
  export declare const sqliteEngine: Engine;
@@ -35,6 +35,27 @@ export function sqliteUrl(file) {
35
35
  return `sqlite://${p.startsWith("/") ? "" : "/"}${p}`;
36
36
  }
37
37
  /** Values node:sqlite can't bind are stored the way an app would store them. */
38
+ /** The parenthesised bodies of every CHECK in a CREATE TABLE statement. */
39
+ function checkBodies(sql) {
40
+ const bodies = [];
41
+ for (const m of sql.matchAll(/\bcheck\s*\(/gi)) {
42
+ let depth = 1;
43
+ let i = m.index + m[0].length;
44
+ const start = i;
45
+ for (; i < sql.length && depth > 0; i++) {
46
+ if (sql[i] === "'")
47
+ i = sql.indexOf("'", i + 1);
48
+ else if (sql[i] === "(")
49
+ depth++;
50
+ else if (sql[i] === ")")
51
+ depth--;
52
+ if (i < 0)
53
+ return bodies;
54
+ }
55
+ bodies.push(sql.slice(start, i - 1));
56
+ }
57
+ return bodies;
58
+ }
38
59
  function bind(v) {
39
60
  if (typeof v === "boolean")
40
61
  return v ? 1 : 0;
@@ -117,6 +138,37 @@ export class SqliteDriver {
117
138
  : `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.column(c)).join(", ")}) VALUES (${cols.map((_, i) => `?${i + 1}`).join(", ")}) RETURNING *`;
118
139
  return this.query(sql, Object.values(row));
119
140
  }
141
+ async describe(table) {
142
+ const [master] = await this.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1", [table]);
143
+ if (!master)
144
+ throw new Error(`slicetest: there is no table "${table}"`);
145
+ const columns = await this.query("SELECT name, type, \"notnull\", dflt_value, pk, hidden FROM pragma_table_xinfo(?1) ORDER BY cid", [table]);
146
+ const pk = columns.filter((c) => c.pk > 0);
147
+ // An INTEGER PRIMARY KEY is the rowid: SQLite assigns it.
148
+ const rowid = pk.length === 1 && pk[0].type.toUpperCase() === "INTEGER" ? pk[0].name : undefined;
149
+ const keys = await this.query('SELECT id, "table", "from", "to" FROM pragma_foreign_key_list(?1) ORDER BY id, seq', [table]);
150
+ const foreignKeys = new Map();
151
+ for (const k of keys) {
152
+ const fk = foreignKeys.get(k.id) ?? { columns: [], table: k.table, references: [] };
153
+ fk.columns.push(k.from);
154
+ if (k.to !== null)
155
+ fk.references.push(k.to);
156
+ foreignKeys.set(k.id, fk);
157
+ }
158
+ return {
159
+ columns: columns
160
+ .filter((c) => c.hidden !== 1)
161
+ .map((c) => ({
162
+ name: c.name,
163
+ type: c.type,
164
+ nullable: c.notnull === 0 && c.pk === 0,
165
+ hasDefault: c.dflt_value !== null || c.hidden > 1 || c.name === rowid,
166
+ maxLength: Number(/\((\d+)\)/.exec(c.type)?.[1]) || undefined,
167
+ })),
168
+ foreignKeys: [...foreignKeys.values()],
169
+ checks: checkBodies(master.sql),
170
+ };
171
+ }
120
172
  async close() {
121
173
  this.db.close();
122
174
  }
@@ -0,0 +1,21 @@
1
+ import type { Column, Row, TableShape } from "./drivers/driver.js";
2
+ /**
3
+ * `db.make()`: rows that satisfy the schema without spelling it out. Required
4
+ * columns the caller doesn't give get a value of their type, enum and
5
+ * `CHECK (col IN (...))` columns their first allowed value, and required
6
+ * foreign keys a parent row made the same way. Values are numbered per
7
+ * scenario, so they are unique and the same on every run.
8
+ */
9
+ export declare class Factory {
10
+ #private;
11
+ private readonly describe;
12
+ private readonly insert;
13
+ constructor(describe: (table: string) => Promise<TableShape>, insert: (table: string, row: Row) => Promise<Row[]>);
14
+ /** Restart numbering; called when the database is reset. */
15
+ reset(): void;
16
+ make(table: string, overrides?: Row, path?: string[]): Promise<Row>;
17
+ }
18
+ /** String literals allowed by a CHECK on `column` such as `status IN ('open', 'closed')` or `status = ANY (ARRAY[...])`. */
19
+ export declare function allowedValues(column: string, checks: string[]): string[] | undefined;
20
+ /** A value of `column`'s type, distinct for each `n`. */
21
+ export declare function valueFor(table: string, column: Column, n: number, allowed?: string[]): unknown;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `db.make()`: rows that satisfy the schema without spelling it out. Required
3
+ * columns the caller doesn't give get a value of their type, enum and
4
+ * `CHECK (col IN (...))` columns their first allowed value, and required
5
+ * foreign keys a parent row made the same way. Values are numbered per
6
+ * scenario, so they are unique and the same on every run.
7
+ */
8
+ export class Factory {
9
+ describe;
10
+ insert;
11
+ #n = 0;
12
+ #shapes = new Map();
13
+ constructor(describe, insert) {
14
+ this.describe = describe;
15
+ this.insert = insert;
16
+ }
17
+ /** Restart numbering; called when the database is reset. */
18
+ reset() {
19
+ this.#n = 0;
20
+ }
21
+ async make(table, overrides = {}, path = []) {
22
+ const shape = await this.#shape(table);
23
+ for (const col of Object.keys(overrides)) {
24
+ if (!shape.columns.some((c) => c.name === col)) {
25
+ throw new Error(`slicetest: db.make("${table}"): there is no column "${col}" (columns: ${shape.columns.map((c) => c.name).join(", ")})`);
26
+ }
27
+ }
28
+ const n = ++this.#n;
29
+ const row = { ...overrides };
30
+ const byName = new Map(shape.columns.map((c) => [c.name, c]));
31
+ const needed = (name) => {
32
+ const c = byName.get(name);
33
+ return !!c && !c.nullable && !c.hasDefault && !(name in row);
34
+ };
35
+ for (const fk of shape.foreignKeys) {
36
+ // Only fill keys that must be set and that the caller left entirely to us.
37
+ if (!fk.columns.some(needed) || fk.columns.some((c) => c in overrides))
38
+ continue;
39
+ if (fk.table === table || path.includes(fk.table)) {
40
+ const chain = [...path, table, fk.table].join(" → ");
41
+ throw new Error(`slicetest: db.make("${table}") can't create a parent for ${fk.columns.join(", ")}: the foreign keys form a cycle (${chain}). Pass ${fk.columns.join(", ")} yourself.`);
42
+ }
43
+ const parent = await this.make(fk.table, {}, [...path, table]);
44
+ const refs = fk.references.length ? fk.references : (await this.#primaryKey(fk.table, parent));
45
+ fk.columns.forEach((c, i) => (row[c] = parent[refs[i]]));
46
+ }
47
+ for (const c of shape.columns) {
48
+ if (needed(c.name))
49
+ row[c.name] = valueFor(table, c, n, allowedValues(c.name, shape.checks));
50
+ }
51
+ try {
52
+ const [stored] = await this.insert(table, row);
53
+ return stored ?? row;
54
+ }
55
+ catch (e) {
56
+ const generated = Object.fromEntries(Object.entries(row).filter(([k]) => !(k in overrides)));
57
+ throw new Error(`slicetest: db.make("${table}") failed: ${e.message}\n` +
58
+ ` generated: ${JSON.stringify(generated)}\n` +
59
+ ` pass the columns that need specific values, e.g. db.make("${table}", { ${Object.keys(generated)[0] ?? "column"}: ... })`, { cause: e });
60
+ }
61
+ }
62
+ #shape(table) {
63
+ let shape = this.#shapes.get(table);
64
+ if (!shape) {
65
+ shape = this.describe(table);
66
+ this.#shapes.set(table, shape);
67
+ shape.catch(() => this.#shapes.delete(table));
68
+ }
69
+ return shape;
70
+ }
71
+ /** SQLite foreign keys may omit the referenced columns: they mean the parent's primary key, or its rowid. */
72
+ async #primaryKey(table, parent) {
73
+ const shape = await this.#shape(table);
74
+ const key = shape.columns.filter((c) => c.hasDefault && /^integer$/i.test(c.type)).map((c) => c.name);
75
+ return key.length ? key : Object.keys(parent).slice(0, 1);
76
+ }
77
+ }
78
+ /** String literals allowed by a CHECK on `column` such as `status IN ('open', 'closed')` or `status = ANY (ARRAY[...])`. */
79
+ export function allowedValues(column, checks) {
80
+ const esc = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
+ const mention = new RegExp(`(^|[^\\w])[\`"]?${esc}[\`"]?\\)?(::\\w+(\\s\\w+)?)?\\s*(in\\s*\\(|=\\s*any\\s*\\()`, "i");
82
+ for (const check of checks) {
83
+ if (!mention.test(check))
84
+ continue;
85
+ const literals = [...check.matchAll(/(?:_\w+)?'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'"));
86
+ if (literals.length)
87
+ return literals;
88
+ }
89
+ return undefined;
90
+ }
91
+ const EPOCH = Date.UTC(2026, 0, 1);
92
+ /** A value of `column`'s type, distinct for each `n`. */
93
+ export function valueFor(table, column, n, allowed) {
94
+ const values = column.values?.length ? column.values : allowed;
95
+ if (values?.length)
96
+ return values[0];
97
+ const t = column.type.toLowerCase();
98
+ if (/\[\]$/.test(t))
99
+ return "{}";
100
+ if (/^bool/.test(t) || t === "tinyint(1)")
101
+ return false;
102
+ if (/uuid/.test(t))
103
+ return `00000000-0000-4000-8000-${n.toString(16).padStart(12, "0")}`;
104
+ if (/json/.test(t))
105
+ return "{}";
106
+ if (/^(timestamp|datetime)/.test(t))
107
+ return new Date(EPOCH + n * 1000).toISOString().slice(0, 19).replace("T", " ");
108
+ if (/^date/.test(t))
109
+ return new Date(EPOCH + (n - 1) * 86_400_000).toISOString().slice(0, 10);
110
+ if (/^time/.test(t))
111
+ return "00:00:00";
112
+ if (/int|serial|numeric|decimal|real|double|float|money|number/.test(t))
113
+ return n;
114
+ if (/bytea|blob|binary/.test(t))
115
+ return Buffer.from(String(n));
116
+ if (/^inet/.test(t))
117
+ return `10.0.${Math.floor(n / 256) % 256}.${n % 256}`;
118
+ if (t === "" || /char|text|clob|string|citext|name/.test(t))
119
+ return text(table, column, n);
120
+ throw new Error(`slicetest: db.make("${table}") doesn't know how to make a value for ${column.name} (${column.type}); pass it yourself: db.make("${table}", { ${column.name}: ... })`);
121
+ }
122
+ function text(table, column, n) {
123
+ const name = column.name.toLowerCase();
124
+ let s = /e_?mail/.test(name) ? `${table}-${n}@example.test` : /url|website|link/.test(name) ? `https://example.test/${table}/${n}` : `${column.name}-${n}`;
125
+ const max = column.maxLength;
126
+ if (max !== undefined && s.length > max)
127
+ s = String(n).padStart(max, "0").slice(-max);
128
+ return s;
129
+ }
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/gen.d.ts CHANGED
@@ -21,4 +21,5 @@ export declare function gen(root: string, opts: GenOptions): Promise<{
21
21
  written: string[];
22
22
  skipped: string[];
23
23
  count: number;
24
+ needsAuth: boolean;
24
25
  }>;
package/dist/gen.js CHANGED
@@ -58,7 +58,7 @@ export async function gen(root, opts) {
58
58
  await writeFile(file, header + stringify({ scenarios }, { lineWidth: 0, aliasDuplicateObjects: false }));
59
59
  written.push(rel);
60
60
  }
61
- return { written, skipped, count };
61
+ return { written, skipped, count, needsAuth: ops.some((o) => o.bearer) };
62
62
  }
63
63
  function scenarioFor(op, key, all) {
64
64
  const steps = [];
@@ -80,9 +80,11 @@ function scenarioFor(op, key, all) {
80
80
  // Two error responses need no arranging: a made-up id is not found, and an empty body is invalid.
81
81
  const notFound = status === 404 && Object.keys(op.pathParams).length > 0;
82
82
  const invalid = (status === 400 || status === 422) && isFilledObject(op.json);
83
- steps.push(requestStep(invalid ? { ...op, json: {} } : op, pathText, { status }));
83
+ // A protected operation answers 401 to a request without a token, whatever its state.
84
+ const unauthenticated = status === 401 && !!op.bearer;
85
+ steps.push(requestStep(invalid ? { ...op, json: {} } : op, pathText, { status }, undefined, unauthenticated));
84
86
  const name = `${op.method} ${op.template} → ${key}${op.summary ? ` (${op.summary})` : ""}`;
85
- const runnable = success ? !unresolved : notFound || invalid;
87
+ const runnable = success ? !unresolved : notFound || invalid || unauthenticated;
86
88
  return runnable ? { name, steps } : { name: `${name} — TODO: arrange the state that produces this`, skip: true, steps };
87
89
  }
88
90
  /**
@@ -99,8 +101,11 @@ function creatorFor(op, param, all) {
99
101
  const status = post.responses.filter((k) => /^2\d\d$/.test(k)).sort()[0];
100
102
  return field && status ? { op: post, field, status } : undefined;
101
103
  }
102
- function requestStep(op, pathText, expect, capture) {
104
+ function requestStep(op, pathText, expect, capture, anonymous = false) {
103
105
  const step = { request: `${op.method} ${pathText}` };
106
+ // Tokens come from the `auth` issuer; scopes the spec requires go into the `scope` claim.
107
+ if (op.bearer && !anonymous)
108
+ step.auth = op.bearer.scopes.length ? { scope: op.bearer.scopes.join(" ") } : true;
104
109
  if (Object.keys(op.query).length)
105
110
  step.query = Object.fromEntries(Object.entries(op.query).map(([k, v]) => [k, String(v)]));
106
111
  if (op.json !== undefined)
@@ -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>;