slicetest 0.6.1 → 0.8.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 (51) hide show
  1. package/README.md +177 -24
  2. package/dist/cli.js +66 -2
  3. package/dist/config.d.ts +18 -0
  4. package/dist/config.js +13 -0
  5. package/dist/connection.d.ts +5 -0
  6. package/dist/connection.js +31 -0
  7. package/dist/db.d.ts +12 -1
  8. package/dist/db.js +33 -6
  9. package/dist/diagram.d.ts +13 -0
  10. package/dist/diagram.js +89 -0
  11. package/dist/form.d.ts +7 -2
  12. package/dist/form.js +72 -7
  13. package/dist/global-setup.d.ts +5 -0
  14. package/dist/global-setup.js +74 -9
  15. package/dist/graphql.d.ts +34 -0
  16. package/dist/graphql.js +56 -0
  17. package/dist/har.d.ts +49 -0
  18. package/dist/har.js +107 -0
  19. package/dist/http.d.ts +21 -3
  20. package/dist/http.js +47 -10
  21. package/dist/index.d.ts +5 -3
  22. package/dist/init.js +184 -15
  23. package/dist/list.d.ts +26 -0
  24. package/dist/list.js +71 -0
  25. package/dist/matchers.d.ts +16 -0
  26. package/dist/matchers.js +81 -1
  27. package/dist/openapi.d.ts +21 -0
  28. package/dist/openapi.js +32 -1
  29. package/dist/provided.d.ts +1 -0
  30. package/dist/record.d.ts +1 -1
  31. package/dist/record.js +3 -1
  32. package/dist/recording.d.ts +1 -1
  33. package/dist/recording.js +2 -2
  34. package/dist/runtime.d.ts +12 -5
  35. package/dist/runtime.js +78 -22
  36. package/dist/scenario.d.ts +14 -4
  37. package/dist/scenario.js +36 -17
  38. package/dist/schema.d.ts +5 -0
  39. package/dist/schema.js +73 -0
  40. package/dist/stub.d.ts +63 -0
  41. package/dist/stub.js +292 -5
  42. package/dist/timeline.d.ts +9 -0
  43. package/dist/timeline.js +6 -0
  44. package/dist/webhook.d.ts +16 -7
  45. package/dist/webhook.js +47 -7
  46. package/dist/yaml-runtime.d.ts +3 -2
  47. package/dist/yaml-runtime.js +308 -51
  48. package/dist/yaml.d.ts +82 -6
  49. package/dist/yaml.js +187 -21
  50. package/package.json +2 -1
  51. package/schema/scenario.schema.json +640 -14
package/dist/config.js CHANGED
@@ -9,6 +9,7 @@ export function resolveOptions(opts, root) {
9
9
  containers: opts.containers ?? {},
10
10
  mail: opts.mail ?? false,
11
11
  offline: opts.offline ?? false,
12
+ strictStubs: opts.strictStubs ?? false,
12
13
  workers: opts.workers,
13
14
  auth: opts.auth === true ? {} : (opts.auth ?? false),
14
15
  db: opts.db === false ? { ...resolveDb({}), none: true } : resolveDb(opts.db ?? {}),
@@ -104,6 +105,8 @@ function validate(opts) {
104
105
  fail(`workers must be a positive whole number, got ${JSON.stringify(opts.workers)}`);
105
106
  if (opts.offline !== undefined && typeof opts.offline !== "boolean")
106
107
  fail(`offline must be true or false, got ${JSON.stringify(opts.offline)}`);
108
+ if (opts.strictStubs !== undefined && typeof opts.strictStubs !== "boolean")
109
+ fail(`strictStubs must be true or false, got ${JSON.stringify(opts.strictStubs)}`);
107
110
  if (opts.mail !== undefined && typeof opts.mail !== "boolean")
108
111
  fail(`mail must be true or false, got ${JSON.stringify(opts.mail)}`);
109
112
  if (opts.auth !== undefined && typeof opts.auth !== "boolean") {
@@ -121,6 +124,9 @@ function validate(opts) {
121
124
  const engine = dbOpts?.engine;
122
125
  if (engine !== undefined && engine !== "postgres" && engine !== "mysql" && engine !== "sqlite")
123
126
  fail(`db.engine must be "postgres", "mysql" or "sqlite", got ${JSON.stringify(engine)}`);
127
+ if (dbOpts?.ignoreChanges !== undefined && !(Array.isArray(dbOpts.ignoreChanges) && dbOpts.ignoreChanges.every((c) => typeof c === "string" && c && !c.startsWith(".")))) {
128
+ fail(`db.ignoreChanges must be a list of columns or tables, e.g. [updated_at, orders.synced_at, sessions.*], got ${JSON.stringify(dbOpts.ignoreChanges)}`);
129
+ }
124
130
  if (dbOpts?.queries !== undefined && typeof dbOpts.queries !== "boolean")
125
131
  fail(`db.queries must be true or false, got ${JSON.stringify(dbOpts.queries)}`);
126
132
  if (dbOpts?.neon !== undefined && typeof dbOpts.neon !== "boolean")
@@ -136,6 +142,13 @@ function validate(opts) {
136
142
  const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
137
143
  if (keys.length !== 1)
138
144
  fail(`db.migrate takes exactly one of atlas / sql / command, got ${keys.join(", ") || "none"}`);
145
+ const menv = migrate.env;
146
+ if (menv !== undefined) {
147
+ if (!("command" in migrate))
148
+ fail("db.migrate.env is for a migration `command`; atlas and sql get the database URL themselves");
149
+ if (!menv || typeof menv !== "object" || Array.isArray(menv) || Object.values(menv).some((v) => typeof v !== "string"))
150
+ fail('db.migrate.env maps variable names to strings, e.g. { DB_HOST: "{{db.host}}" }');
151
+ }
139
152
  }
140
153
  const oas = opts.openapi;
141
154
  if (oas !== undefined) {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
3
+ * `spring.datasource.url` plus username / password), or separate host / port / name settings.
4
+ */
5
+ export declare function connectionVars(engine: string, url: string, sqlitePath?: string): Record<string, string>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
3
+ * `spring.datasource.url` plus username / password), or separate host / port / name settings.
4
+ */
5
+ export function connectionVars(engine, url, sqlitePath) {
6
+ if (engine === "sqlite")
7
+ return { "db.jdbcUrl": `jdbc:sqlite:${sqlitePath}`, "db.adoNet": `Data Source=${sqlitePath}` };
8
+ const u = new URL(url);
9
+ const port = u.port || (engine === "mysql" ? "3306" : "5432");
10
+ const name = decodeURIComponent(u.pathname.replace(/^\//, ""));
11
+ return {
12
+ "db.host": u.hostname,
13
+ "db.port": port,
14
+ "db.name": name,
15
+ "db.user": decodeURIComponent(u.username),
16
+ "db.password": decodeURIComponent(u.password),
17
+ "db.jdbcUrl": `jdbc:${engine === "mysql" ? "mysql" : "postgresql"}://${u.hostname}:${port}/${encodeURIComponent(name)}`,
18
+ // ADO.NET (Npgsql, MySqlConnector), as .NET apps read `ConnectionStrings__Default`.
19
+ "db.adoNet": [
20
+ `${engine === "mysql" ? "Server" : "Host"}=${u.hostname}`,
21
+ `Port=${port}`,
22
+ `Database=${name}`,
23
+ `${engine === "mysql" ? "User ID" : "Username"}=${decodeURIComponent(u.username)}`,
24
+ `Password=${adoValue(decodeURIComponent(u.password))}`,
25
+ ].join(";"),
26
+ };
27
+ }
28
+ /** ADO.NET values with `;` or quotes are quoted. */
29
+ function adoValue(v) {
30
+ return /[;'"]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
31
+ }
package/dist/db.d.ts CHANGED
@@ -26,6 +26,10 @@ export interface TableChanges<T extends Row = Row> {
26
26
  }[];
27
27
  deleted: T[];
28
28
  }
29
+ export interface ChangesOptions {
30
+ /** Columns or tables to leave out: `updated_at` (in every table), `orders.synced_at`, `sessions.*`. Added to `db.ignoreChanges`. */
31
+ ignore?: string[];
32
+ }
29
33
  /** Changed tables only, keyed by table name (`schema.table` outside `public`). */
30
34
  export type Changes = Record<string, TableChanges>;
31
35
  /** Test-side handle to the database the app under test is using. */
@@ -37,6 +41,7 @@ export declare class Db {
37
41
  static connect(driver: Driver, url: string, opts: {
38
42
  schemas: string[];
39
43
  keep: string[];
44
+ ignoreChanges?: string[];
40
45
  seedFile?: string;
41
46
  }): Promise<Db>;
42
47
  query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
@@ -86,13 +91,19 @@ export declare class Db {
86
91
  * expect(await db.changes()).toEqual({ polls: { inserted: [expect.objectContaining({ title: "x" })], updated: [], deleted: [] } });
87
92
  * ```
88
93
  */
89
- changes(): Promise<Changes>;
94
+ changes(opts?: ChangesOptions): Promise<Changes>;
90
95
  /** Make `changes()` report only what happens from now on. */
91
96
  checkpoint(): Promise<void>;
92
97
  /** Changes since the scenario started, regardless of checkpoints. Used for failure output. */
93
98
  changesSinceStart(): Promise<Changes>;
94
99
  close(): Promise<void>;
95
100
  }
101
+ /**
102
+ * What `ignore` leaves out of one table: `"*"` for the whole table, else the columns.
103
+ * `updated_at` is that column in every table, `orders.synced_at` in one, `sessions.*` the whole table
104
+ * (the last segment is the column, so `billing.invoices.*` works for other schemas).
105
+ */
106
+ export declare function ignored(ignore: readonly string[], table: string): "*" | Set<string>;
96
107
  /** Short summary of `changes()` for failure output. */
97
108
  export declare function formatChanges(changes: Changes, maxRows?: number): string;
98
109
  /** The `db` of an app without a database (`db: false`): resetting is a no-op, anything else explains. */
package/dist/db.js CHANGED
@@ -151,9 +151,9 @@ export class Db {
151
151
  * expect(await db.changes()).toEqual({ polls: { inserted: [expect.objectContaining({ title: "x" })], updated: [], deleted: [] } });
152
152
  * ```
153
153
  */
154
- async changes() {
154
+ async changes(opts = {}) {
155
155
  const base = this.#checkpoint === undefined || this.#checkpoint === "start" ? this.#start : this.#checkpoint;
156
- return diff(this.#tables ?? [], base, await this.#snapshot());
156
+ return diff(this.#tables ?? [], base, await this.#snapshot(), [...(this.opts.ignoreChanges ?? []), ...(opts.ignore ?? [])]);
157
157
  }
158
158
  /** Make `changes()` report only what happens from now on. */
159
159
  async checkpoint() {
@@ -161,7 +161,7 @@ export class Db {
161
161
  }
162
162
  /** Changes since the scenario started, regardless of checkpoints. Used for failure output. */
163
163
  async changesSinceStart() {
164
- return diff(this.#tables ?? [], this.#start, await this.#snapshot());
164
+ return diff(this.#tables ?? [], this.#start, await this.#snapshot(), this.opts.ignoreChanges ?? []);
165
165
  }
166
166
  /** Every tracked table's rows, in one round trip. */
167
167
  async #snapshot() {
@@ -201,11 +201,38 @@ export class Db {
201
201
  await this.#driver.close();
202
202
  }
203
203
  }
204
- function diff(tables, before, after) {
204
+ /**
205
+ * What `ignore` leaves out of one table: `"*"` for the whole table, else the columns.
206
+ * `updated_at` is that column in every table, `orders.synced_at` in one, `sessions.*` the whole table
207
+ * (the last segment is the column, so `billing.invoices.*` works for other schemas).
208
+ */
209
+ export function ignored(ignore, table) {
210
+ const columns = new Set();
211
+ for (const entry of ignore) {
212
+ const dot = entry.lastIndexOf(".");
213
+ if (dot === -1) {
214
+ columns.add(entry);
215
+ continue;
216
+ }
217
+ const t = entry.slice(0, dot);
218
+ if (t !== table && !(t === table.split(".").pop() && !t.includes(".")))
219
+ continue;
220
+ const column = entry.slice(dot + 1);
221
+ if (column === "*")
222
+ return "*";
223
+ columns.add(column);
224
+ }
225
+ return columns;
226
+ }
227
+ function diff(tables, before, after, ignore = []) {
205
228
  const out = {};
206
229
  for (const table of tables) {
207
- const a = before?.get(table.name) ?? [];
208
- const b = after.get(table.name) ?? [];
230
+ const skip = ignored(ignore, table.name);
231
+ if (skip === "*")
232
+ continue;
233
+ const strip = (rows) => (skip.size ? rows.map((r) => Object.fromEntries(Object.entries(r).filter(([c]) => !skip.has(c)))) : rows);
234
+ const a = strip(before?.get(table.name) ?? []);
235
+ const b = strip(after.get(table.name) ?? []);
209
236
  const changes = table.key.length > 0 ? diffByKey(a, b, table.key) : diffAsBags(a, b);
210
237
  if (changes.inserted.length || changes.updated.length || changes.deleted.length)
211
238
  out[table.name] = changes;
@@ -0,0 +1,13 @@
1
+ import type { Changes } from "./db.js";
2
+ import type { HttpResponse } from "./http.js";
3
+ import type { Mailbox } from "./mail.js";
4
+ import type { Stub } from "./stub.js";
5
+ /**
6
+ * A Mermaid sequence diagram of what a scenario did: each request to the app,
7
+ * the stub calls the app made while answering it, the mail it sent and the
8
+ * database tables it changed. GitHub, GitLab and most Markdown viewers render it.
9
+ */
10
+ export declare function sequenceDiagram(history: readonly HttpResponse[], stubs: Iterable<Stub>, changes: Changes | undefined, mailbox?: Mailbox): string;
11
+ export declare function diagramPage(file: string, title: string, scenario: string, diagram: string, failed: boolean): string;
12
+ /** A collapsed section for the GitHub Actions job summary. */
13
+ export declare function failureDiagram(where: string, scenario: string, diagram: string): string;
@@ -0,0 +1,89 @@
1
+ import { describeGraphQL } from "./graphql.js";
2
+ import { timeline } from "./timeline.js";
3
+ /**
4
+ * A Mermaid sequence diagram of what a scenario did: each request to the app,
5
+ * the stub calls the app made while answering it, the mail it sent and the
6
+ * database tables it changed. GitHub, GitLab and most Markdown viewers render it.
7
+ */
8
+ export function sequenceDiagram(history, stubs, changes, mailbox) {
9
+ const events = [];
10
+ const participants = new Map();
11
+ const id = (name, label) => {
12
+ const key = `s_${name.replace(/[^A-Za-z0-9_]/g, "_")}`;
13
+ if (!participants.has(key))
14
+ participants.set(key, label);
15
+ return key;
16
+ };
17
+ let order = 0;
18
+ const add = (at, line) => events.push({ at, order: order++, line });
19
+ for (const res of history) {
20
+ const t = timeline.get(res) ?? { start: Infinity, end: Infinity };
21
+ const external = /^https?:/.test(res.url);
22
+ const target = external ? id(`ext_${new URL(res.url).host}`, new URL(res.url).host) : "app";
23
+ const path = external ? new URL(res.url).pathname : res.url;
24
+ add(t.start, `test->>+${target}: ${text(`${res.method} ${path}`)}`);
25
+ add(t.end ?? t.start, `${target}-->>-test: ${res.status || "failed"}${summary(res)}`);
26
+ }
27
+ for (const stub of stubs) {
28
+ for (const call of stub.calls()) {
29
+ const t = timeline.get(call) ?? { start: Infinity };
30
+ const s = id(stub.name, `${stub.name} (stub)`);
31
+ add(t.start, `app->>+${s}: ${text(describeCall(call))}`);
32
+ const reply = call.fault === "reset" ? "connection dropped" : call.response ? `${call.response.status}${call.fault ? " (chaos)" : ""}` : call.matched ? "no answer" : "501 no stub";
33
+ add(t.end ?? t.start, `${s}-->>-app: ${reply}`);
34
+ }
35
+ }
36
+ events.sort((a, b) => a.at - b.at || a.order - b.order);
37
+ const lines = ["sequenceDiagram", " participant test as scenario", " participant app"];
38
+ for (const [key, label] of participants)
39
+ lines.push(` participant ${key} as ${text(label)}`);
40
+ if (mailbox && mailbox.messages().length)
41
+ lines.push(" participant mail");
42
+ const tables = Object.entries(changes ?? {}).filter(([, c]) => c.inserted.length || c.updated.length || c.deleted.length);
43
+ if (tables.length)
44
+ lines.push(" participant db as database");
45
+ for (const e of events)
46
+ lines.push(` ${e.line}`);
47
+ for (const m of mailbox?.messages() ?? [])
48
+ lines.push(` app->>mail: ${text(`${m.subject} to ${m.to.join(", ")}`)}`);
49
+ if (tables.length) {
50
+ const counts = tables.map(([table, c]) => `${table} ${[c.inserted.length && `+${c.inserted.length}`, c.updated.length && `~${c.updated.length}`, c.deleted.length && `-${c.deleted.length}`].filter(Boolean).join(" ")}`);
51
+ lines.push(` Note over app,db: ${text(counts.join(", "))}`);
52
+ }
53
+ return lines.join("\n");
54
+ }
55
+ function describeCall(call) {
56
+ if (call.graphql)
57
+ return describeGraphQL(call.graphql);
58
+ return `${call.method} ${call.path}`;
59
+ }
60
+ /** A short hint of the response: a JSON error message or the id it created. */
61
+ function summary(res) {
62
+ const j = res.json;
63
+ if (j && typeof j === "object" && !Array.isArray(j)) {
64
+ for (const key of ["error", "message", "id"]) {
65
+ const v = j[key];
66
+ if (typeof v === "string" || typeof v === "number")
67
+ return ` ${text(`${key}: ${v}`)}`;
68
+ }
69
+ }
70
+ return "";
71
+ }
72
+ /** Mermaid ends a message at `;` and reads `#…;` as an entity, so both are escaped; long text is cut. */
73
+ function text(s, max = 70) {
74
+ const cut = s.length > max ? `${s.slice(0, max - 1)}…` : s;
75
+ return cut.replace(/[\r\n]+/g, " ").replace(/[#;]/g, (c) => (c === "#" ? "#35;" : "#59;"));
76
+ }
77
+ /** Scenario diagrams per test file, for `SLICETEST_DIAGRAMS` / `--diagrams`: one Markdown page per file, in run order. */
78
+ const pages = new Map();
79
+ export function diagramPage(file, title, scenario, diagram, failed) {
80
+ const page = pages.get(file) ?? { title, sections: new Map() };
81
+ pages.set(file, page);
82
+ page.sections.set(scenario, [`## ${scenario}${failed ? " (failed)" : ""}`, "", "```mermaid", diagram, "```", ""].join("\n"));
83
+ return [`# ${page.title}`, "", `Sequence diagrams of the scenarios in \`${page.title}\`, written by slicetest. Regenerated on every run.`, "", ...page.sections.values()].join("\n");
84
+ }
85
+ /** A collapsed section for the GitHub Actions job summary. */
86
+ export function failureDiagram(where, scenario, diagram) {
87
+ const name = scenario.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c]);
88
+ return [`<details><summary>✗ ${name} <code>${where}</code>: what happened</summary>`, "", "```mermaid", diagram, "```", "", "</details>", ""].join("\n");
89
+ }
package/dist/form.d.ts CHANGED
@@ -13,8 +13,11 @@ export interface SubmitOptions {
13
13
  button?: string;
14
14
  /** The form, by `id`, `name` or position (0-based), when the page has several and no `button` picks one. */
15
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)[]>;
16
+ /**
17
+ * Values typed into the form, by field name. A name the form doesn't have is an error (a typo, usually).
18
+ * A file input takes a `Blob` / `File` (its name is the file name sent), and the form must be multipart.
19
+ */
20
+ fields?: Record<string, string | number | boolean | (string | number)[] | Blob>;
18
21
  }
19
22
  export interface FormRequest {
20
23
  method: "GET" | "POST";
@@ -42,4 +45,6 @@ export declare function decodeEntities(s: string): string;
42
45
  export declare function parseForms(html: string): ParsedForm[];
43
46
  /** Works out the request a browser would send for this page's form. */
44
47
  export declare function formRequest(html: string, opts?: SubmitOptions): FormRequest;
48
+ /** Fields as `application/x-www-form-urlencoded`: lists repeat the key, nested objects use bracket keys (`metadata[order]`). */
49
+ export declare function encodeForm(fields: Record<string, unknown>): URLSearchParams;
45
50
  export {};
package/dist/form.js CHANGED
@@ -37,7 +37,11 @@ export function parseForms(html) {
37
37
  let open;
38
38
  let select;
39
39
  let option;
40
+ // Controls inside a disabled <fieldset> are disabled too (and not submitted).
41
+ const fieldsets = [];
40
42
  const add = (control) => {
43
+ if (fieldsets.includes(true))
44
+ control.attrs.disabled ??= "";
41
45
  const formId = control.attrs.form;
42
46
  if (formId !== undefined)
43
47
  orphans.push({ formId, control });
@@ -62,6 +66,13 @@ export function parseForms(html) {
62
66
  TAG.lastIndex = end < 0 ? html.length : end;
63
67
  continue;
64
68
  }
69
+ if (tag === "fieldset") {
70
+ if (closing)
71
+ fieldsets.pop();
72
+ else
73
+ fieldsets.push("disabled" in parseAttrs(m[3] ?? ""));
74
+ continue;
75
+ }
65
76
  if (tag === "form") {
66
77
  if (closing)
67
78
  current = undefined;
@@ -162,12 +173,22 @@ export function formRequest(html, opts = {}) {
162
173
  throw new Error(`slicetest: submit: the button ${JSON.stringify(label(button))} is disabled`);
163
174
  const names = new Set(form.controls.filter((c) => c.attrs.name !== undefined && !isSubmit(c) && c.tag !== "button").map((c) => c.attrs.name));
164
175
  const typed = new Map();
176
+ const files = new Map();
177
+ const fileInputs = new Set(form.controls.filter((c) => c.tag === "input" && c.attrs.type?.toLowerCase() === "file").map((c) => c.attrs.name));
165
178
  for (const [name, value] of Object.entries(opts.fields ?? {})) {
166
179
  if (!names.has(name)) {
167
180
  const shown = [...names].filter((n) => !n.startsWith("$ACTION_"));
168
181
  throw new Error(`slicetest: submit: the form has no field "${name}". Fields: ${shown.join(", ") || "(none)"}`);
169
182
  }
170
- typed.set(name, typeof value === "boolean" ? value : (Array.isArray(value) ? value : [value]).map(String));
183
+ if (fileInputs.has(name) !== value instanceof Blob) {
184
+ throw new Error(fileInputs.has(name)
185
+ ? `slicetest: submit: "${name}" is a file input; give it a file (a Blob or File, or { file: path } in YAML)`
186
+ : `slicetest: submit: "${name}" isn't a file input, so it can't take a file`);
187
+ }
188
+ if (value instanceof Blob)
189
+ files.set(name, value);
190
+ else
191
+ typed.set(name, typeof value === "boolean" ? value : (Array.isArray(value) ? value : [value]).map(String));
171
192
  }
172
193
  // The successful controls, in document order (HTML's "constructing the entry list").
173
194
  // A typed value replaces what the page had: text for a field, the checked state for
@@ -176,6 +197,12 @@ export function formRequest(html, opts = {}) {
176
197
  const done = new Set();
177
198
  for (const c of form.controls) {
178
199
  const name = c.attrs.name;
200
+ if (c === button && c.attrs.type?.toLowerCase() === "image") {
201
+ // An image button sends the click position, as name.x / name.y (x / y without a name).
202
+ const prefix = name ? `${name}.` : "";
203
+ entries.push([`${prefix}x`, "0"], [`${prefix}y`, "0"]);
204
+ continue;
205
+ }
179
206
  if (name === undefined || "disabled" in c.attrs)
180
207
  continue;
181
208
  if (c.tag === "button" || isSubmit(c)) {
@@ -184,8 +211,16 @@ export function formRequest(html, opts = {}) {
184
211
  continue;
185
212
  }
186
213
  const type = c.tag === "input" ? (c.attrs.type ?? "text").toLowerCase() : c.tag;
187
- if (type === "reset" || type === "button" || type === "file")
214
+ if (type === "reset" || type === "button")
215
+ continue;
216
+ if (type === "file") {
217
+ // With no file chosen, browsers still send the field: an empty file without a name.
218
+ const file = files.get(name);
219
+ if (!done.has(name))
220
+ entries.push([name, file ?? new File([], "", { type: "application/octet-stream" })]);
221
+ done.add(name);
188
222
  continue;
223
+ }
189
224
  const want = typed.get(name);
190
225
  if (type === "checkbox" || type === "radio") {
191
226
  const value = c.attrs.value ?? "on";
@@ -214,13 +249,43 @@ export function formRequest(html, opts = {}) {
214
249
  const pick = (attr) => (button && button.attrs[`form${attr}`] !== undefined ? button.attrs[`form${attr}`] : form.attrs[attr]);
215
250
  const method = (pick("method") ?? "get").toUpperCase() === "POST" ? "POST" : "GET";
216
251
  const action = pick("action") ?? "";
252
+ const multipart = method === "POST" && (pick("enctype") ?? "").toLowerCase() === "multipart/form-data";
253
+ if (files.size && !multipart) {
254
+ throw new Error(`slicetest: submit: the form sends ${method === "GET" ? "a GET" : "urlencoded"} data, which can't carry files; a browser would send only the file name. Upload forms need method="post" enctype="multipart/form-data"`);
255
+ }
256
+ // Outside multipart, a file field is sent as its file name (empty when none was chosen), as browsers do.
257
+ const plain = () => new URLSearchParams(entries.map(([k, v]) => [k, typeof v === "string" ? v : (v.name ?? "")]));
217
258
  if (method === "GET")
218
- return { method, action: `${action.replace(/[?#].*$/, "")}?${new URLSearchParams(entries)}` };
219
- const multipart = (pick("enctype") ?? "").toLowerCase() === "multipart/form-data";
259
+ return { method, action: `${action.replace(/[?#].*$/, "")}?${plain()}` };
220
260
  if (!multipart)
221
- return { method, action, body: new URLSearchParams(entries) };
261
+ return { method, action, body: plain() };
222
262
  const body = new FormData();
223
- for (const [k, v] of entries)
224
- body.append(k, v);
263
+ for (const [k, v] of entries) {
264
+ if (typeof v === "string")
265
+ body.append(k, v);
266
+ else
267
+ body.append(k, v, v.name ?? "blob");
268
+ }
225
269
  return { method, action, body };
226
270
  }
271
+ /** Fields as `application/x-www-form-urlencoded`: lists repeat the key, nested objects use bracket keys (`metadata[order]`). */
272
+ export function encodeForm(fields) {
273
+ const out = new URLSearchParams();
274
+ const add = (key, v) => {
275
+ if (v === null || v === undefined)
276
+ return;
277
+ if (Array.isArray(v)) {
278
+ const nested = v.some((x) => x !== null && typeof x === "object");
279
+ v.forEach((x, i) => add(nested ? `${key}[${i}]` : key, x));
280
+ }
281
+ else if (typeof v === "object" && !(v instanceof Date)) {
282
+ for (const [k, x] of Object.entries(v))
283
+ add(`${key}[${k}]`, x);
284
+ }
285
+ else
286
+ out.append(key, v instanceof Date ? v.toISOString() : String(v));
287
+ };
288
+ for (const [k, v] of Object.entries(fields))
289
+ add(k, v);
290
+ return out;
291
+ }
@@ -3,6 +3,11 @@ import type { ResolvedOptions } from "./config.js";
3
3
  import "./provided.js";
4
4
  /** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
5
5
  export default function setup(project: TestProject): Promise<() => Promise<void>>;
6
+ /**
7
+ * Why this run covers only part of the suite (a file or name filter, tags, a shard), or undefined for a full run.
8
+ * Coverage then says little about the suite, so `minCoverage` isn't enforced and the cache for `gen --uncovered` is kept.
9
+ */
10
+ export declare function partialRun(project: TestProject): Promise<string | undefined>;
6
11
  /**
7
12
  * A hash of everything that determines the migrated schema, or undefined when
8
13
  * that can't be known (a migration command without `inputs`).
@@ -5,10 +5,12 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { annotation, appendSummary, failureAnnotations, failureSummary, onGitHub, repoPath, yamlFailures } from "./ci.js";
8
+ import { interpolate } from "./app.js";
9
+ import { connectionVars } from "./connection.js";
8
10
  import { configureContainerRuntime } from "./container-runtime.js";
9
11
  import { engineFor } from "./drivers/index.js";
10
12
  import { coverageCacheFile } from "./gen.js";
11
- import { appSpecFile, formatCoverage, OpenApiSpec } from "./openapi.js";
13
+ import { appSpecFile, formatCoverage, formatUsage, OpenApiSpec } from "./openapi.js";
12
14
  import { mergeRecordings } from "./recording.js";
13
15
  import "./provided.js";
14
16
  const exec = promisify(execFile);
@@ -27,14 +29,18 @@ export default async function setup(project) {
27
29
  const recordDir = recording ? await mkdtemp(path.join(os.tmpdir(), "slicetest-recordings-")) : undefined;
28
30
  // On GitHub Actions, failed YAML steps are collected here and turned into annotations at the end.
29
31
  const ciDir = onGitHub() ? await mkdtemp(path.join(os.tmpdir(), "slicetest-ci-")) : undefined;
30
- project.provide("slicetestDb", { adminUrl: database?.adminUrl ?? "", template: database?.template ?? "", prefix: database?.prefix ?? "", coverageDir, recordDir, ciDir });
32
+ // Operations the app called on providers whose spec a stub has, reported at the end.
33
+ const usageDir = Object.keys(opts.openapi.stubs).length ? await mkdtemp(path.join(os.tmpdir(), "slicetest-usage-")) : undefined;
34
+ project.provide("slicetestDb", { adminUrl: database?.adminUrl ?? "", template: database?.template ?? "", prefix: database?.prefix ?? "", coverageDir, recordDir, ciDir, usageDir });
31
35
  return async () => {
32
36
  try {
33
37
  await database?.teardown();
34
38
  }
35
39
  finally {
36
40
  if (coverageDir)
37
- await reportCoverage(opts, coverageDir);
41
+ await reportCoverage(opts, coverageDir, await partialRun(project));
42
+ if (usageDir)
43
+ await reportUsage(opts, usageDir);
38
44
  if (recordDir)
39
45
  await saveRecordings(opts, recordDir);
40
46
  if (ciDir)
@@ -96,7 +102,30 @@ async function startDatabase(opts, builds) {
96
102
  },
97
103
  };
98
104
  }
99
- async function reportCoverage(opts, dir) {
105
+ /**
106
+ * Why this run covers only part of the suite (a file or name filter, tags, a shard), or undefined for a full run.
107
+ * Coverage then says little about the suite, so `minCoverage` isn't enforced and the cache for `gen --uncovered` is kept.
108
+ */
109
+ export async function partialRun(project) {
110
+ try {
111
+ const config = project.vitest.config;
112
+ if (config.testNamePattern)
113
+ return `only scenarios matching ${config.testNamePattern}`;
114
+ if (process.env.SLICETEST_TAGS)
115
+ return `only tags ${process.env.SLICETEST_TAGS}`;
116
+ if (config.shard)
117
+ return "one shard";
118
+ const all = (await project.globTestFiles()).testFiles.length;
119
+ const ran = project.vitest.state.getFiles().filter((f) => f.projectName === project.name).length;
120
+ if (ran > 0 && ran < all)
121
+ return `${ran} of ${all} files`;
122
+ }
123
+ catch {
124
+ // An API this Vitest version lacks: treat the run as complete, as before.
125
+ }
126
+ return undefined;
127
+ }
128
+ async function reportCoverage(opts, dir, partial) {
100
129
  try {
101
130
  const hits = new Set();
102
131
  for (const file of await readdir(dir)) {
@@ -111,11 +140,12 @@ async function reportCoverage(opts, dir) {
111
140
  const report = formatCoverage(spec, hits);
112
141
  // For `slicetest gen --uncovered`.
113
142
  const cache = coverageCacheFile(opts.root);
114
- await mkdir(path.dirname(cache), { recursive: true }).then(() => writeFile(cache, JSON.stringify([...hits]))).catch(() => { });
115
- console.log(`\n${report.text}\n`);
143
+ if (!partial)
144
+ await mkdir(path.dirname(cache), { recursive: true }).then(() => writeFile(cache, JSON.stringify([...hits]))).catch(() => { });
145
+ console.log(`\n${report.text}${partial ? `\n(partial run: ${partial}; openapi.minCoverage is checked on full runs)` : ""}\n`);
116
146
  await appendSummary(report.markdown);
117
147
  const min = opts.openapi.minCoverage;
118
- if (min !== undefined && report.percent < min) {
148
+ if (min !== undefined && report.percent < min && !partial) {
119
149
  // Not thrown: Vitest reports teardown errors as a crash. The failing exit code is what CI needs.
120
150
  const message = `OpenAPI coverage ${report.percent}% is below openapi.minCoverage (${min}%)`;
121
151
  console.error(`slicetest: ${message}\n`);
@@ -129,6 +159,36 @@ async function reportCoverage(opts, dir) {
129
159
  await rm(appSpecFile(dir), { force: true });
130
160
  }
131
161
  }
162
+ async function reportUsage(opts, dir) {
163
+ try {
164
+ const used = new Map();
165
+ for (const file of await readdir(dir)) {
166
+ for (const [name, keys] of Object.entries(JSON.parse(await readFile(path.join(dir, file), "utf8")))) {
167
+ const set = used.get(name) ?? new Set();
168
+ for (const k of keys)
169
+ set.add(k);
170
+ used.set(name, set);
171
+ }
172
+ }
173
+ for (const [name, file] of Object.entries(opts.openapi.stubs)) {
174
+ const keys = used.get(name);
175
+ if (!keys)
176
+ continue;
177
+ const report = formatUsage(name, await OpenApiSpec.load(path.resolve(opts.root, file), file), keys);
178
+ console.log(`\n${report.text}\n`);
179
+ await appendSummary(report.markdown);
180
+ if (onGitHub() && report.deprecated.length) {
181
+ console.log(annotation("warning", `The app calls operations ${file} marks deprecated: ${report.deprecated.join(", ")}`, { file: repoPath(path.resolve(opts.root, file)), title: `slicetest: deprecated ${name} API` }));
182
+ }
183
+ }
184
+ }
185
+ catch (e) {
186
+ console.error(`slicetest: couldn't report API usage: ${e.message}`);
187
+ }
188
+ finally {
189
+ await rm(dir, { recursive: true, force: true });
190
+ }
191
+ }
132
192
  async function reportToGitHub(dir) {
133
193
  try {
134
194
  const failures = await yamlFailures(dir);
@@ -268,8 +328,13 @@ async function applyMigrations(opts, engine, url, m) {
268
328
  }
269
329
  return "";
270
330
  }
271
- // Through the platform shell (sh or cmd.exe), like app.command.
272
- return run(m.command, [], opts.root, { DATABASE_URL: url }, true);
331
+ // Through the platform shell (sh or cmd.exe), like app.command, with the same {{db.*}} placeholders.
332
+ const vars = { "db.url": url };
333
+ if (engine.name === "sqlite")
334
+ vars["db.path"] = (await import("./drivers/sqlite.js")).sqlitePath(url);
335
+ Object.assign(vars, connectionVars(engine.name, url, vars["db.path"]));
336
+ const env = Object.fromEntries(Object.entries(m.env ?? {}).map(([k, v]) => [k, interpolate(v, vars, `db.migrate.env.${k}`)]));
337
+ return run(interpolate(m.command, vars, "db.migrate.command"), [], opts.root, { DATABASE_URL: url, ...env }, true);
273
338
  }
274
339
  /**
275
340
  * Atlas wants `file://<path>` with forward slashes. Absolute paths (including
@@ -0,0 +1,34 @@
1
+ /**
2
+ * GraphQL over HTTP, as clients send it (Apollo, urql, graphql-request, gql, …):
3
+ * a POST with `{ query, variables, operationName }`, or a GET with those as query
4
+ * parameters. Stubs match calls by operation, and the app's own GraphQL endpoint
5
+ * is called with `http.graphql()`.
6
+ */
7
+ export interface GraphQLCall {
8
+ /** `operationName` from the request, else the name in the document (`query GetUser { … }`). Undefined for an anonymous operation. */
9
+ operation?: string;
10
+ type: "query" | "mutation" | "subscription";
11
+ query: string;
12
+ variables: Record<string, unknown>;
13
+ }
14
+ /** The operations a document defines, in order. Comments and strings don't count. */
15
+ export declare function operations(document: string): {
16
+ type: GraphQLCall["type"];
17
+ name?: string;
18
+ }[];
19
+ /** The GraphQL request in an HTTP call, or undefined when it isn't one. */
20
+ export declare function graphqlOf(call: {
21
+ method: string;
22
+ query: URLSearchParams;
23
+ json: any;
24
+ }): GraphQLCall | undefined;
25
+ /** A GraphQL error list from messages or full error objects. */
26
+ export declare function graphqlErrors(errors: (string | {
27
+ message: string;
28
+ [k: string]: unknown;
29
+ })[]): {
30
+ [k: string]: unknown;
31
+ message: string;
32
+ }[];
33
+ /** `GraphQL mutation CreateIssue`, for failure messages. */
34
+ export declare function describeGraphQL(g: GraphQLCall): string;