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.
- package/README.md +177 -24
- package/dist/cli.js +66 -2
- package/dist/config.d.ts +18 -0
- package/dist/config.js +13 -0
- package/dist/connection.d.ts +5 -0
- package/dist/connection.js +31 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/diagram.d.ts +13 -0
- package/dist/diagram.js +89 -0
- package/dist/form.d.ts +7 -2
- package/dist/form.js +72 -7
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +74 -9
- package/dist/graphql.d.ts +34 -0
- package/dist/graphql.js +56 -0
- package/dist/har.d.ts +49 -0
- package/dist/har.js +107 -0
- package/dist/http.d.ts +21 -3
- package/dist/http.js +47 -10
- package/dist/index.d.ts +5 -3
- package/dist/init.js +184 -15
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +16 -0
- package/dist/matchers.js +81 -1
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +32 -1
- package/dist/provided.d.ts +1 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +1 -1
- package/dist/recording.js +2 -2
- package/dist/runtime.d.ts +12 -5
- package/dist/runtime.js +78 -22
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -17
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +73 -0
- package/dist/stub.d.ts +63 -0
- package/dist/stub.js +292 -5
- package/dist/timeline.d.ts +9 -0
- package/dist/timeline.js +6 -0
- package/dist/webhook.d.ts +16 -7
- package/dist/webhook.js +47 -7
- package/dist/yaml-runtime.d.ts +3 -2
- package/dist/yaml-runtime.js +308 -51
- package/dist/yaml.d.ts +82 -6
- package/dist/yaml.js +187 -21
- package/package.json +2 -1
- 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
|
-
|
|
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
|
|
208
|
-
|
|
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;
|
package/dist/diagram.js
ADDED
|
@@ -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) => ({ "<": "<", ">": ">", "&": "&" })[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
|
-
/**
|
|
17
|
-
|
|
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
|
-
|
|
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"
|
|
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(/[?#].*$/, "")}?${
|
|
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:
|
|
261
|
+
return { method, action, body: plain() };
|
|
222
262
|
const body = new FormData();
|
|
223
|
-
for (const [k, v] of entries)
|
|
224
|
-
|
|
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
|
+
}
|
package/dist/global-setup.d.ts
CHANGED
|
@@ -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`).
|
package/dist/global-setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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;
|