slicetest 0.8.0 → 0.9.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 +14 -6
- package/dist/cli-run.d.ts +13 -0
- package/dist/cli-run.js +15 -0
- package/dist/cli.js +12 -0
- package/dist/config.js +45 -0
- package/dist/form.d.ts +1 -0
- package/dist/form.js +13 -4
- package/dist/global-setup.js +3 -6
- package/dist/har.js +3 -3
- package/dist/http.d.ts +29 -1
- package/dist/http.js +95 -5
- package/dist/init.js +53 -8
- package/dist/matchers.d.ts +14 -3
- package/dist/matchers.js +57 -2
- package/dist/openapi.d.ts +0 -5
- package/dist/openapi.js +13 -6
- package/dist/recording.d.ts +7 -0
- package/dist/recording.js +38 -4
- package/dist/runtime.js +12 -0
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +10 -3
- package/dist/stub.js +47 -10
- package/dist/yaml-runtime.js +46 -5
- package/dist/yaml.d.ts +8 -2
- package/dist/yaml.js +14 -2
- package/package.json +1 -1
- package/schema/scenario.schema.json +70 -4
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ npx slicetest init # detects your stack, writes slicetest.config.yaml and a fi
|
|
|
38
38
|
npx slicetest # starts Postgres, migrates, starts your app, runs scenarios/*.scenario.yaml
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
`init` recognises Node (`npm start`, or Bun, pnpm or Yarn from the lockfile), Deno, Phoenix, Django, FastAPI, Flask, Rails, Laravel, Symfony, plain PHP, Spring Boot, ASP.NET Core, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Laravel, Doctrine, EF Core, Ecto, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. Token issuer settings there (`OIDC_ISSUER`, `AUTH0_DOMAIN`, `JWKS_URL`, `JWT_AUDIENCE`, …) turn on [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) and point at slicetest's issuer instead of becoming stubs. It lists every guess as a comment in the config so you know what to check.
|
|
41
|
+
`init` recognises Node (`npm start`, or Bun, pnpm or Yarn from the lockfile), Deno, Phoenix, Django, FastAPI, Flask, Rails, Laravel, Symfony, plain PHP, Spring Boot, ASP.NET Core, Go (the main package at the root or in `cmd/<name>`) and Rust apps (both compiled once before the workers start); Atlas, Prisma, Alembic, Django, Rails, Laravel, Doctrine, EF Core, Ecto, Drizzle, Knex and plain SQL migrations (golang-migrate, goose, sqlx, Diesel and dbmate layouts in `migrations/`, `db/migrations/` or `sql/migrations/`); and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. Token issuer settings there (`OIDC_ISSUER`, `AUTH0_DOMAIN`, `JWKS_URL`, `JWT_AUDIENCE`, …) turn on [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) and point at slicetest's issuer instead of becoming stubs. It lists every guess as a comment in the config so you know what to check.
|
|
42
42
|
|
|
43
43
|
## What you get that's hard to find elsewhere
|
|
44
44
|
|
|
@@ -294,7 +294,7 @@ stub("slack").calls("POST", "/hook"); // recorded calls:
|
|
|
294
294
|
|
|
295
295
|
Form-encoded bodies (Stripe, Twilio, OAuth token requests) are parsed into `call.form`, with bracket keys nested the way those providers read them: `metadata[order]=7&items[0][price]=p_1` is `{ metadata: { order: "7" }, items: [{ price: "p_1" }] }`. `form` conditions match a subset of it, and numbers and booleans compare with the strings sent. `multipart/form-data` bodies are read the same way, with each file as `{ filename, type, size, text }` (`text` for text, JSON, XML and CSV files), so `form: { avatar: { filename: "a.png", type: "image/png" } }` checks an upload the app passed on.
|
|
296
296
|
|
|
297
|
-
Later routes win. `path` may also be a RegExp, and `method` may be `*`. Unanswered calls get a `501` and fail the scenario, with the closest route and why it didn't match (`stub.explain(call)`).
|
|
297
|
+
Later routes win. `path` may also be a RegExp, and `method` may be `*`. A query written into the path (`on("GET", "/search?q=tea")`, also in `calls()`, `toHaveReceived()` and YAML stubs) is a condition on those parameters, like `query: { q: "tea" }`; other parameters may come along. A list matches a repeated parameter's values in order: `query: { ids: ["1", "2"] }` for `?ids=1&ids=2`. Unanswered calls get a `501` and fail the scenario, with the closest route and why it didn't match (`stub.explain(call)`).
|
|
298
298
|
|
|
299
299
|
### OpenAPI contracts — for your app and for the services you stub
|
|
300
300
|
|
|
@@ -465,7 +465,7 @@ Record once, with real credentials in the app's environment:
|
|
|
465
465
|
SLICETEST_RECORD=github npx vitest # or SLICETEST_RECORD=1 for every stub with an upstream
|
|
466
466
|
```
|
|
467
467
|
|
|
468
|
-
Calls no route matches are forwarded to `upstream` (under its path prefix, headers included) and the answers are written to `recordings/github.yaml` (`recordings:` changes the path). Later runs replay them without touching the network. A request is identified by method, path, query and body (JSON key order doesn't matter); identical requests replay their recordings in the order they were made. Only `content-type`, `location`, `retry-after`, `link` and `etag` response headers are kept, and request headers are never stored, so tokens stay out of the file
|
|
468
|
+
Calls no route matches are forwarded to `upstream` (under its path prefix, headers included) and the answers are written to `recordings/github.yaml` (`recordings:` changes the path). Later runs replay them without touching the network. A request is identified by method, path, query and body (JSON key order doesn't matter); identical requests replay their recordings in the order they were made. Only `content-type`, `location`, `retry-after`, `link` and `etag` response headers are kept, and request headers are never stored, so tokens stay out of the file. Credentials sent in the query or the body (`api_key`, `key` in the query, `appid`, `access_token`, `client_secret`, `password`, signatures, …) are stored as `[redacted]` and match any value on replay, so the test environment's dummy key replays what the real key recorded. Other values are stored as sent, so review the file before committing it.
|
|
469
469
|
|
|
470
470
|
#### From a HAR file: `npx slicetest import`
|
|
471
471
|
|
|
@@ -486,6 +486,10 @@ Registered automatically:
|
|
|
486
486
|
|
|
487
487
|
```ts
|
|
488
488
|
expect(res).toHaveStatus(201); // failure shows the response body
|
|
489
|
+
expect(res).toHaveStatus("2xx"); // a class, or a list: [200, 204], ["2xx", 304]
|
|
490
|
+
expect(res).toRespondWithin(300); // a response-time budget, in ms
|
|
491
|
+
expect(res).toSetCookie("sid", { httpOnly: true, secure: true, sameSite: "Lax" }); // a cookie and its attributes
|
|
492
|
+
expect(res.events).toContainEqual({ event: "message_stop", data: {} }); // text/event-stream responses are parsed into events
|
|
489
493
|
expect(responses).toHaveStatuses({ 201: 1, 409: 9 }); // an array, e.g. from http.concurrently()
|
|
490
494
|
expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
|
|
491
495
|
expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
|
|
@@ -725,7 +729,7 @@ Tags select scenarios across files: `npx slicetest --tag smoke` (repeat `--tag`
|
|
|
725
729
|
| `app.readyTimeout` | `30000` | |
|
|
726
730
|
| `app.scope` | `"file"` | `"worker"`: start the app (and stubs, services) once per Vitest worker and keep it for all of that worker's test files, for apps that start slowly. Sets Vitest's `isolate: false`. |
|
|
727
731
|
| `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
|
|
728
|
-
| `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs?, env? }`. The command gets `DATABASE_URL`, and both it and `env` may use the `{{db.*}}` placeholders, for tools that read other variables: `{ command: "php artisan migrate --force", env: { DB_HOST: "{{db.host}}", DB_DATABASE: "{{db.name}}" } }`, `{ command: "dotnet ef database update --connection \"{{db.adoNet}}\"" }`. |
|
|
732
|
+
| `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs?, env? }`. A `sql` directory is applied in name order with version numbers compared as numbers (`V2__` before `V10__`), leaving out rollbacks: `*.down.sql` (golang-migrate, sqlx, Diesel), Flyway undo files (`U2__…`) and the down section of goose (`-- +goose Down`) and dbmate (`-- migrate:down`) files. The command gets `DATABASE_URL`, and both it and `env` may use the `{{db.*}}` placeholders, for tools that read other variables: `{ command: "php artisan migrate --force", env: { DB_HOST: "{{db.host}}", DB_DATABASE: "{{db.name}}" } }`, `{ command: "dotnet ef database update --connection \"{{db.adoNet}}\"" }`. |
|
|
729
733
|
| `db.seed` | none | SQL file re-run after every reset. |
|
|
730
734
|
| `db.schemas` | `["public"]` | Schemas whose tables are reset. |
|
|
731
735
|
| `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
|
|
@@ -795,7 +799,7 @@ scenarios:
|
|
|
795
799
|
| Step | Keys |
|
|
796
800
|
|---|---|
|
|
797
801
|
| `stub: <name>` | `on: METHOD /path` (`:params` allowed) or `graphql: <operation>`, `when: { query, headers, json, form, body, variables }`, one of `reply: { status, headers, body }` (`{ file }` for a fixture file, `{ data, errors }` for GraphQL) / `sequence: [...]` / `networkError: true`, plus `times`, `delay`. Replies may echo the call: `{{call.params.id}}`, `{{call.json.name}}`, `{{call.form.amount}}`, `{{call.variables.id}}`. |
|
|
798
|
-
| `submit: <button>` | `form`, `fields` (`{ file: path }` for a file input), `headers`, `follow`, `expect: { status, headers, json, text }`, `capture
|
|
802
|
+
| `submit: <button>` | `form`, `fields` (`{ file: path }` for a file input), `headers`, `follow`, `expect: { status, headers, json, text }` (`status` may be a class, `2xx`, or a list, `[200, 204]`; `duration: 300` fails a response slower than 300 ms, `duration: { $lt: 300 }` takes matchers; `cookies: { sid: { httpOnly: true, sameSite: Lax }, tracking: null }` checks the cookies it sets and their attributes, `null` for one it must not set; `events: [{ event: message_start }, { data: "[DONE]" }]` checks a `text/event-stream` response's events in order, others allowed between), `capture` (also from `events.0.data.id`). Submits a form of the page the last request returned, like `http.submit()`; `submit: true` presses the form's only button. |
|
|
799
803
|
| `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `multipart` / `body` / `graphql: { query, variables, operationName }`, `expect.schema` (a JSON Schema, or `../openapi.yaml#/components/schemas/Poll` relative to the file), `follow`, `expect: { status, headers, json, text }`, `capture`. `timeout: <ms>` fails it, by name, when the app doesn't answer in time. `concurrency: n` sends it `n` times at once; `expect` then applies to each response, and `expect.statuses: { 201: 1, 409: 9 }` counts them. |
|
|
800
804
|
| `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
|
|
801
805
|
| `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
|
|
@@ -838,7 +842,8 @@ scenarios:
|
|
|
838
842
|
| `{ $type: number }` | `string`, `number`, `integer`, `boolean`, `array`, `object`, `null` |
|
|
839
843
|
| `{ $regex: "^ch_" }` | a string the pattern finds |
|
|
840
844
|
| `{ $contains: "ok" }` | a string with that substring, or a list with a matching item (`{ $contains: { sku: a } }`) |
|
|
841
|
-
| `{ $gte: 1, $lt: 10 }` | numbers, or strings such as ISO dates (`{ $gte: "2026-01-01" }`); also `$gt`, `$lte` |
|
|
845
|
+
| `{ $gte: 1, $lt: 10 }` | numbers (also decimals that `numeric` / `DECIMAL` columns return as strings, `"12.50"`), or strings such as ISO dates (`{ $gte: "2026-01-01" }`); also `$gt`, `$lte` |
|
|
846
|
+
| `{ $closeTo: 9.99 }` | a number within ±0.005 (`[9.99, 0.1]` for another tolerance), for floats and computed totals; decimal strings too |
|
|
842
847
|
| `{ $len: 3 }` | a string or list of that length; `{ $len: { $gte: 1 } }` |
|
|
843
848
|
| `{ $oneOf: [paid, pending] }` | any of the values (or matchers) |
|
|
844
849
|
| `{ $not: "" }` | anything the value or matcher doesn't match |
|
|
@@ -897,6 +902,9 @@ npx slicetest # every *.scenario.yaml under the config's directo
|
|
|
897
902
|
npx slicetest polls -t voting # filter by file and scenario name
|
|
898
903
|
npx slicetest --watch
|
|
899
904
|
npx slicetest list --tag smoke # what would run: file, line, tags, steps (--json for tools)
|
|
905
|
+
npx slicetest -u # rewrite `snapshot: true` snapshots that no longer match
|
|
906
|
+
npx slicetest --reporter junit --output-file reports/slicetest.xml # GitLab, Jenkins, CircleCI test reports
|
|
907
|
+
npx slicetest --shard 2/4 # the second of four parts, for parallel CI jobs
|
|
900
908
|
```
|
|
901
909
|
|
|
902
910
|
### Scenarios from your OpenAPI spec: `npx slicetest gen`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface RunFlags {
|
|
2
|
+
update?: boolean;
|
|
3
|
+
reporter?: string[];
|
|
4
|
+
"output-file"?: string;
|
|
5
|
+
shard?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Vitest options for the run flags of `npx slicetest`; paths are taken from where the command runs, like `--diagrams`. */
|
|
8
|
+
export declare function runOptions(flags: RunFlags, cwd?: string): {
|
|
9
|
+
update?: boolean | undefined;
|
|
10
|
+
reporters?: string[] | undefined;
|
|
11
|
+
outputFile?: string | undefined;
|
|
12
|
+
shard?: string | undefined;
|
|
13
|
+
};
|
package/dist/cli-run.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/** Vitest options for the run flags of `npx slicetest`; paths are taken from where the command runs, like `--diagrams`. */
|
|
3
|
+
export function runOptions(flags, cwd = process.cwd()) {
|
|
4
|
+
if (flags.shard !== undefined) {
|
|
5
|
+
const m = /^(\d+)\/(\d+)$/.exec(flags.shard);
|
|
6
|
+
if (!m || Number(m[1]) < 1 || Number(m[1]) > Number(m[2]))
|
|
7
|
+
throw new Error(`slicetest: --shard takes <index>/<count> with 1 ≤ index ≤ count, e.g. --shard 1/3, got "${flags.shard}"`);
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
...(flags.update ? { update: true } : {}),
|
|
11
|
+
...(flags.reporter?.length ? { reporters: flags.reporter } : {}),
|
|
12
|
+
...(flags["output-file"] ? { outputFile: path.resolve(cwd, flags["output-file"]) } : {}),
|
|
13
|
+
...(flags.shard ? { shard: flags.shard } : {}),
|
|
14
|
+
};
|
|
15
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -46,6 +46,11 @@ Options:
|
|
|
46
46
|
--upstream <url> import: the real service's base URL for --stub
|
|
47
47
|
--uncovered gen: only responses the last run didn't cover
|
|
48
48
|
--force init, gen: overwrite existing files
|
|
49
|
+
-u, --update Rewrite snapshots (\`snapshot: true\` steps) that no longer match
|
|
50
|
+
--reporter <name> Vitest reporter: default, verbose, dot, junit, json, tap,
|
|
51
|
+
github-actions (repeat for several)
|
|
52
|
+
--output-file <file> Where junit / json / tap reporters write
|
|
53
|
+
--shard <i/n> Run the i-th of n parts of the suite (split CI jobs)
|
|
49
54
|
--diagrams <dir> Write a Mermaid sequence diagram of every scenario to <dir>,
|
|
50
55
|
one Markdown page per scenario file
|
|
51
56
|
-h, --help Show this help
|
|
@@ -83,6 +88,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
83
88
|
stub: { type: "string" },
|
|
84
89
|
upstream: { type: "string" },
|
|
85
90
|
json: { type: "boolean" },
|
|
91
|
+
update: { type: "boolean", short: "u" },
|
|
92
|
+
reporter: { type: "string", multiple: true },
|
|
93
|
+
"output-file": { type: "string" },
|
|
94
|
+
shard: { type: "string" },
|
|
86
95
|
},
|
|
87
96
|
});
|
|
88
97
|
if (values.help) {
|
|
@@ -190,6 +199,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
190
199
|
const { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
|
|
191
200
|
// Vitest 4 takes the mode ("test") first; 5 dropped it.
|
|
192
201
|
const start = (Number.parseInt(version, 10) === 4 ? startVitest.bind(null, "test") : startVitest);
|
|
202
|
+
const { runOptions } = await import("./cli-run.js");
|
|
203
|
+
const run = runOptions(values);
|
|
193
204
|
const vitest = await start(positionals, {
|
|
194
205
|
config: false,
|
|
195
206
|
root: path.dirname(configPath),
|
|
@@ -197,6 +208,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
197
208
|
watch: !!values.watch,
|
|
198
209
|
run: !values.watch,
|
|
199
210
|
testNamePattern: values.name,
|
|
211
|
+
...run,
|
|
200
212
|
}, { plugins: [slicetest(options)] });
|
|
201
213
|
if (!values.watch)
|
|
202
214
|
await vitest?.close();
|
package/dist/config.js
CHANGED
|
@@ -59,10 +59,55 @@ function resolveDb(db) {
|
|
|
59
59
|
reuse: db.reuse ?? (!url && !process.env.CI),
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
+
const TOP_LEVEL_KEYS = ["app", "db", "stubs", "openapi", "http", "services", "containers", "mail", "offline", "strictStubs", "workers", "auth", "include"];
|
|
63
|
+
const APP_KEYS = ["command", "build", "cwd", "env", "ready", "readyTimeout", "scope", "baseEnv"];
|
|
64
|
+
const STUB_KEYS = ["name", "openapi", "autoReply", "upstream", "recordings", "hosts"];
|
|
65
|
+
const CONTAINER_KEYS = ["image", "port", "env", "command", "ready", "reset"];
|
|
66
|
+
const DB_KEYS = ["engine", "image", "url", "migrate", "seed", "schemas", "keep", "ignoreChanges", "reuse", "queries", "neon"];
|
|
67
|
+
function editDistance(a, b) {
|
|
68
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
69
|
+
for (let i = 1; i <= a.length; i++) {
|
|
70
|
+
let prev = row[0];
|
|
71
|
+
row[0] = i;
|
|
72
|
+
for (let j = 1; j <= b.length; j++) {
|
|
73
|
+
const tmp = row[j];
|
|
74
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
75
|
+
prev = tmp;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return row[b.length];
|
|
79
|
+
}
|
|
62
80
|
function validate(opts) {
|
|
63
81
|
const fail = (msg) => {
|
|
64
82
|
throw new Error(`slicetest: invalid config: ${msg}`);
|
|
65
83
|
};
|
|
84
|
+
// A misspelt key (`stub:`, `strictstubs:`) would otherwise be ignored without a word; YAML configs have no type checker.
|
|
85
|
+
const checkKeys = (value, allowed, where) => {
|
|
86
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
87
|
+
return;
|
|
88
|
+
for (const key of Object.keys(value)) {
|
|
89
|
+
if (allowed.includes(key) || key === "$schema")
|
|
90
|
+
continue;
|
|
91
|
+
// Close in spelling, or sharing the first four letters (`servers` / `services`, `migrations` / `migrate`).
|
|
92
|
+
const near = allowed
|
|
93
|
+
.map((k) => ({ k, d: editDistance(k.toLowerCase(), key.toLowerCase()) }))
|
|
94
|
+
.filter(({ k, d }) => d <= Math.max(2, Math.floor(key.length / 3)) || (key.length >= 4 && k.toLowerCase().startsWith(key.slice(0, 4).toLowerCase())))
|
|
95
|
+
.sort((a, b) => a.d - b.d)[0];
|
|
96
|
+
const hint = near ? `; did you mean "${near.k}"?` : ` (expected ${allowed.join(", ")})`;
|
|
97
|
+
fail(`unknown key ${where}${key}${hint}`);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
checkKeys(opts, TOP_LEVEL_KEYS, "");
|
|
101
|
+
checkKeys(opts?.app, APP_KEYS, "app.");
|
|
102
|
+
if (opts?.db)
|
|
103
|
+
checkKeys(opts.db, DB_KEYS, "db.");
|
|
104
|
+
for (const stub of opts?.stubs ?? [])
|
|
105
|
+
if (typeof stub === "object" && stub)
|
|
106
|
+
checkKeys(stub, STUB_KEYS, `stubs.${stub.name ?? "?"}.`);
|
|
107
|
+
for (const [name, service] of Object.entries(opts?.services ?? {}))
|
|
108
|
+
checkKeys(service, APP_KEYS, `services.${name}.`);
|
|
109
|
+
for (const [name, container] of Object.entries(opts?.containers ?? {}))
|
|
110
|
+
checkKeys(container, CONTAINER_KEYS, `containers.${name}.`);
|
|
66
111
|
if (!opts?.app || typeof opts.app.command !== "string" || !opts.app.command.trim()) {
|
|
67
112
|
fail("app.command is required, e.g. { app: { command: \"node server.js\" } }");
|
|
68
113
|
}
|
package/dist/form.d.ts
CHANGED
package/dist/form.js
CHANGED
|
@@ -37,6 +37,7 @@ export function parseForms(html) {
|
|
|
37
37
|
let open;
|
|
38
38
|
let select;
|
|
39
39
|
let option;
|
|
40
|
+
let groupDisabled = false;
|
|
40
41
|
// Controls inside a disabled <fieldset> are disabled too (and not submitted).
|
|
41
42
|
const fieldsets = [];
|
|
42
43
|
const add = (control) => {
|
|
@@ -52,7 +53,7 @@ export function parseForms(html) {
|
|
|
52
53
|
if (!option || !select)
|
|
53
54
|
return;
|
|
54
55
|
const value = option.attrs.value ?? textOf(html.slice(option.start, end));
|
|
55
|
-
select.options.push({ value, selected: "selected" in option.attrs });
|
|
56
|
+
select.options.push({ value, selected: "selected" in option.attrs, disabled: "disabled" in option.attrs || groupDisabled });
|
|
56
57
|
option = undefined;
|
|
57
58
|
};
|
|
58
59
|
TAG.lastIndex = 0;
|
|
@@ -107,6 +108,7 @@ export function parseForms(html) {
|
|
|
107
108
|
if (closing) {
|
|
108
109
|
closeOption(m.index);
|
|
109
110
|
select = undefined;
|
|
111
|
+
groupDisabled = false;
|
|
110
112
|
}
|
|
111
113
|
else {
|
|
112
114
|
select = { tag, attrs: parseAttrs(m[3] ?? ""), text: "", options: [] };
|
|
@@ -116,6 +118,8 @@ export function parseForms(html) {
|
|
|
116
118
|
}
|
|
117
119
|
if (tag === "option" || tag === "optgroup") {
|
|
118
120
|
closeOption(m.index);
|
|
121
|
+
if (tag === "optgroup")
|
|
122
|
+
groupDisabled = !closing && "disabled" in parseAttrs(m[3] ?? "");
|
|
119
123
|
if (tag === "option" && !closing && select)
|
|
120
124
|
option = { attrs: parseAttrs(m[3] ?? ""), start: TAG.lastIndex };
|
|
121
125
|
continue;
|
|
@@ -238,10 +242,15 @@ export function formRequest(html, opts = {}) {
|
|
|
238
242
|
else if (c.tag === "textarea")
|
|
239
243
|
entries.push([name, c.text]);
|
|
240
244
|
else if (c.tag === "select") {
|
|
241
|
-
|
|
242
|
-
|
|
245
|
+
// With nothing selected, a single select shows its first option that isn't disabled; disabled options are never sent.
|
|
246
|
+
// A single select keeps only the last option marked selected.
|
|
247
|
+
const marked = c.options.filter((o) => o.selected);
|
|
248
|
+
const selected = "multiple" in c.attrs ? marked : marked.slice(-1);
|
|
249
|
+
const first = c.options.find((o) => !o.disabled);
|
|
250
|
+
const chosen = selected.length ? selected : "multiple" in c.attrs || !first ? [] : [first];
|
|
243
251
|
for (const o of chosen)
|
|
244
|
-
|
|
252
|
+
if (!o.disabled)
|
|
253
|
+
entries.push([name, o.value]);
|
|
245
254
|
}
|
|
246
255
|
else
|
|
247
256
|
entries.push([name, c.attrs.value ?? ""]);
|
package/dist/global-setup.js
CHANGED
|
@@ -12,6 +12,7 @@ import { engineFor } from "./drivers/index.js";
|
|
|
12
12
|
import { coverageCacheFile } from "./gen.js";
|
|
13
13
|
import { appSpecFile, formatCoverage, formatUsage, OpenApiSpec } from "./openapi.js";
|
|
14
14
|
import { mergeRecordings } from "./recording.js";
|
|
15
|
+
import { applySqlFiles, sqlMigrationFiles } from "./sql-migrations.js";
|
|
15
16
|
import "./provided.js";
|
|
16
17
|
const exec = promisify(execFile);
|
|
17
18
|
/** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
|
|
@@ -314,14 +315,10 @@ async function applyMigrations(opts, engine, url, m) {
|
|
|
314
315
|
return run("atlas", ["migrate", "apply", "--url", engine.atlasUrl(url), "--dir", dir], opts.root);
|
|
315
316
|
}
|
|
316
317
|
else if ("sql" in m) {
|
|
317
|
-
const
|
|
318
|
-
const files = (await stat(target)).isDirectory()
|
|
319
|
-
? (await readdir(target)).filter((f) => f.endsWith(".sql")).sort().map((f) => path.join(target, f))
|
|
320
|
-
: [target];
|
|
318
|
+
const files = await sqlMigrationFiles(path.resolve(opts.root, m.sql));
|
|
321
319
|
const driver = await engine.driver(url);
|
|
322
320
|
try {
|
|
323
|
-
|
|
324
|
-
await driver.exec(await readFile(file, "utf8"));
|
|
321
|
+
await applySqlFiles(files, (sql) => driver.exec(sql), opts.root);
|
|
325
322
|
}
|
|
326
323
|
finally {
|
|
327
324
|
await driver.close();
|
package/dist/har.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { mergeRecordings } from "./recording.js";
|
|
3
|
+
import { mergeRecordings, redactRequest } from "./recording.js";
|
|
4
4
|
const KEPT_HEADERS = ["content-type", "location", "retry-after", "link", "etag"];
|
|
5
5
|
const TEXTUAL = /json|text|xml|javascript|x-www-form-urlencoded|graphql/i;
|
|
6
6
|
export function harEntries(har, file = "the HAR file") {
|
|
@@ -29,12 +29,12 @@ export function toRecording(entry, upstream) {
|
|
|
29
29
|
const query = Object.fromEntries([...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
30
30
|
const sent = entry.request.postData?.text;
|
|
31
31
|
const json = parse(sent);
|
|
32
|
-
const request = {
|
|
32
|
+
const request = redactRequest({
|
|
33
33
|
method,
|
|
34
34
|
path: url.pathname.slice(prefix.length) || "/",
|
|
35
35
|
...(Object.keys(query).length ? { query } : {}),
|
|
36
36
|
...(json !== undefined ? { json } : sent ? { body: sent } : {}),
|
|
37
|
-
};
|
|
37
|
+
});
|
|
38
38
|
const content = entry.response.content ?? {};
|
|
39
39
|
let text = content.text ?? "";
|
|
40
40
|
if (content.encoding === "base64" && text) {
|
package/dist/http.d.ts
CHANGED
|
@@ -10,7 +10,19 @@ export interface HttpResponse {
|
|
|
10
10
|
json: any;
|
|
11
11
|
/** Wall-clock time of the request in milliseconds. */
|
|
12
12
|
durationMs: number;
|
|
13
|
+
/** The events of a `text/event-stream` response (data parsed as JSON when it is JSON); undefined for other responses. */
|
|
14
|
+
events?: ServerEvent[];
|
|
13
15
|
}
|
|
16
|
+
/** One server-sent event of a response. */
|
|
17
|
+
export interface ServerEvent {
|
|
18
|
+
/** The `event:` field; undefined for unnamed (`message`) events. */
|
|
19
|
+
event?: string;
|
|
20
|
+
/** The `data:` lines joined by newlines, parsed as JSON when they are JSON (`[DONE]` stays a string). */
|
|
21
|
+
data: any;
|
|
22
|
+
id?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Events of a `text/event-stream` body, as the HTML spec's parser dispatches them. */
|
|
25
|
+
export declare function parseEvents(text: string): ServerEvent[];
|
|
14
26
|
type QueryValue = string | number | boolean;
|
|
15
27
|
export interface RequestOptions {
|
|
16
28
|
headers?: Record<string, string>;
|
|
@@ -81,7 +93,7 @@ export declare class HttpClient {
|
|
|
81
93
|
*/
|
|
82
94
|
concurrently(n: number, send: (i: number) => Promise<HttpResponse>): Promise<HttpResponse[]>;
|
|
83
95
|
/**
|
|
84
|
-
* Strings, URLSearchParams, FormData, Blob and
|
|
96
|
+
* Strings, URLSearchParams, FormData, Blob, ArrayBuffer and typed arrays are sent as-is; anything else is sent as JSON.
|
|
85
97
|
* With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
|
|
86
98
|
* (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
|
|
87
99
|
*/
|
|
@@ -98,6 +110,22 @@ export declare class HttpClient {
|
|
|
98
110
|
/** Start a new scenario: forget cookies and history. */
|
|
99
111
|
reset(): void;
|
|
100
112
|
}
|
|
113
|
+
/** A `Set-Cookie` header's cookie and attributes, as `toSetCookie()` and YAML `expect.cookies` compare them. */
|
|
114
|
+
export interface SetCookie {
|
|
115
|
+
name: string;
|
|
116
|
+
value: string;
|
|
117
|
+
httpOnly: boolean;
|
|
118
|
+
secure: boolean;
|
|
119
|
+
/** As sent, e.g. `Lax`; undefined when the attribute is missing. */
|
|
120
|
+
sameSite?: string;
|
|
121
|
+
path?: string;
|
|
122
|
+
domain?: string;
|
|
123
|
+
maxAge?: number;
|
|
124
|
+
expires?: string;
|
|
125
|
+
partitioned: boolean;
|
|
126
|
+
}
|
|
127
|
+
/** The cookies a response sets, by name (the last one wins when a name repeats). */
|
|
128
|
+
export declare function setCookies(res: Pick<HttpResponse, "headers">): Record<string, SetCookie>;
|
|
101
129
|
/** One line per request, for failure output. */
|
|
102
130
|
export declare function formatHistory(history: readonly HttpResponse[]): string;
|
|
103
131
|
export {};
|
package/dist/http.js
CHANGED
|
@@ -2,6 +2,41 @@ import { encodeForm, formRequest } from "./form.js";
|
|
|
2
2
|
import { lookupHost } from "./intercept.js";
|
|
3
3
|
import { timeline } from "./timeline.js";
|
|
4
4
|
import { signWebhook, webhookBody } from "./webhook.js";
|
|
5
|
+
/** Events of a `text/event-stream` body, as the HTML spec's parser dispatches them. */
|
|
6
|
+
export function parseEvents(text) {
|
|
7
|
+
const out = [];
|
|
8
|
+
let data = [];
|
|
9
|
+
let event;
|
|
10
|
+
let id;
|
|
11
|
+
for (const line of `${text}\n`.split(/\r\n|\r|\n/)) {
|
|
12
|
+
if (line === "") {
|
|
13
|
+
if (data.length) {
|
|
14
|
+
const raw = data.join("\n");
|
|
15
|
+
let parsed = raw;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(raw);
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
out.push({ ...(event ? { event } : {}), data: parsed, ...(id !== undefined ? { id } : {}) });
|
|
21
|
+
}
|
|
22
|
+
data = [];
|
|
23
|
+
event = undefined;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (line.startsWith(":"))
|
|
27
|
+
continue;
|
|
28
|
+
const colon = line.indexOf(":");
|
|
29
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
30
|
+
const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
31
|
+
if (field === "data")
|
|
32
|
+
data.push(value);
|
|
33
|
+
else if (field === "event")
|
|
34
|
+
event = value;
|
|
35
|
+
else if (field === "id" && !value.includes("\0"))
|
|
36
|
+
id = value;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
5
40
|
/** Per-scenario state shared by a client and every client derived from it with `with()`. */
|
|
6
41
|
class Session {
|
|
7
42
|
/** Stub base URLs of intercepted hosts, which redirects may lead to (an OAuth provider's login page). */
|
|
@@ -90,7 +125,7 @@ export class HttpClient {
|
|
|
90
125
|
webhook(path, payload, opts) {
|
|
91
126
|
const { body, type } = webhookBody(payload, opts.provider);
|
|
92
127
|
const url = opts.url ?? new URL(path, this.baseUrl).href;
|
|
93
|
-
return this.request("POST", path, body, { headers: { "content-type": type, ...signWebhook(body, { ...opts, url }),
|
|
128
|
+
return this.request("POST", path, body, { headers: mergeHeaders({ "content-type": type, ...signWebhook(body, { ...opts, url }) }, opts.headers) });
|
|
94
129
|
}
|
|
95
130
|
/**
|
|
96
131
|
* Send a GraphQL operation to the app: `http.graphql("query { poll(id: 1) { title } }")`,
|
|
@@ -117,7 +152,7 @@ export class HttpClient {
|
|
|
117
152
|
return all;
|
|
118
153
|
}
|
|
119
154
|
/**
|
|
120
|
-
* Strings, URLSearchParams, FormData, Blob and
|
|
155
|
+
* Strings, URLSearchParams, FormData, Blob, ArrayBuffer and typed arrays are sent as-is; anything else is sent as JSON.
|
|
121
156
|
* With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
|
|
122
157
|
* (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
|
|
123
158
|
*/
|
|
@@ -173,7 +208,8 @@ export class HttpClient {
|
|
|
173
208
|
body instanceof URLSearchParams ||
|
|
174
209
|
body instanceof FormData ||
|
|
175
210
|
body instanceof Blob ||
|
|
176
|
-
body instanceof
|
|
211
|
+
body instanceof ArrayBuffer ||
|
|
212
|
+
ArrayBuffer.isView(body)) {
|
|
177
213
|
payload = body;
|
|
178
214
|
}
|
|
179
215
|
else {
|
|
@@ -196,7 +232,21 @@ export class HttpClient {
|
|
|
196
232
|
if (!stub)
|
|
197
233
|
for (const cookie of res.headers.getSetCookie())
|
|
198
234
|
this.#storeCookie(cookie, url.pathname);
|
|
199
|
-
|
|
235
|
+
let text;
|
|
236
|
+
try {
|
|
237
|
+
text = await res.text();
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
// The timeout also covers the body: a stream that never ends, or an app that stalls halfway.
|
|
241
|
+
const type = res.headers.get("content-type");
|
|
242
|
+
const failed = { method, url: stub ? url.href : url.pathname + url.search, status: res.status, headers: res.headers, text: "", json: undefined, durationMs: Math.round(performance.now() - started) };
|
|
243
|
+
timeline.set(failed, { start: started, end: performance.now() });
|
|
244
|
+
this.#record(failed);
|
|
245
|
+
const why = e.name === "TimeoutError"
|
|
246
|
+
? `answered ${res.status}${type ? ` (${type})` : ""} but the body didn't finish within ${opts.timeout}ms (http timeout)${/event-stream/i.test(type ?? "") ? "; the event stream has to end for its events to be checked" : ""}`
|
|
247
|
+
: `the connection broke while reading the ${res.status} response's body: ${e.message}`;
|
|
248
|
+
throw new Error(`slicetest: ${method} ${failed.url}: ${why}`, { cause: e });
|
|
249
|
+
}
|
|
200
250
|
let json;
|
|
201
251
|
try {
|
|
202
252
|
json = text ? JSON.parse(text) : undefined;
|
|
@@ -210,6 +260,7 @@ export class HttpClient {
|
|
|
210
260
|
text,
|
|
211
261
|
json,
|
|
212
262
|
durationMs: Math.round(performance.now() - started),
|
|
263
|
+
...(/^text\/event-stream\b/i.test(res.headers.get("content-type") ?? "") ? { events: parseEvents(text) } : {}),
|
|
213
264
|
};
|
|
214
265
|
timeline.set(out, { start: started, end: performance.now() });
|
|
215
266
|
this.#record(out);
|
|
@@ -292,6 +343,40 @@ export class HttpClient {
|
|
|
292
343
|
}
|
|
293
344
|
}
|
|
294
345
|
}
|
|
346
|
+
/** The cookies a response sets, by name (the last one wins when a name repeats). */
|
|
347
|
+
export function setCookies(res) {
|
|
348
|
+
const out = {};
|
|
349
|
+
for (const header of res.headers.getSetCookie()) {
|
|
350
|
+
const [pair, ...attrs] = header.split(";");
|
|
351
|
+
const eq = pair.indexOf("=");
|
|
352
|
+
if (eq <= 0)
|
|
353
|
+
continue;
|
|
354
|
+
const cookie = { name: pair.slice(0, eq).trim(), value: pair.slice(eq + 1).trim(), httpOnly: false, secure: false, partitioned: false };
|
|
355
|
+
for (const a of attrs) {
|
|
356
|
+
const i = a.indexOf("=");
|
|
357
|
+
const k = (i < 0 ? a : a.slice(0, i)).trim().toLowerCase();
|
|
358
|
+
const v = i < 0 ? "" : a.slice(i + 1).trim();
|
|
359
|
+
if (k === "httponly")
|
|
360
|
+
cookie.httpOnly = true;
|
|
361
|
+
else if (k === "secure")
|
|
362
|
+
cookie.secure = true;
|
|
363
|
+
else if (k === "partitioned")
|
|
364
|
+
cookie.partitioned = true;
|
|
365
|
+
else if (k === "samesite")
|
|
366
|
+
cookie.sameSite = v;
|
|
367
|
+
else if (k === "path")
|
|
368
|
+
cookie.path = v;
|
|
369
|
+
else if (k === "domain")
|
|
370
|
+
cookie.domain = v;
|
|
371
|
+
else if (k === "max-age")
|
|
372
|
+
cookie.maxAge = Number(v);
|
|
373
|
+
else if (k === "expires")
|
|
374
|
+
cookie.expires = v;
|
|
375
|
+
}
|
|
376
|
+
Object.defineProperty(out, cookie.name, { value: cookie, enumerable: true, writable: true, configurable: true });
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
295
380
|
/** RFC 6265 5.1.4: the request path's directory, e.g. "/auth" for "/auth/login". */
|
|
296
381
|
function defaultCookiePath(requestPath) {
|
|
297
382
|
const slash = requestPath.lastIndexOf("/");
|
|
@@ -319,10 +404,15 @@ function merge(a, b) {
|
|
|
319
404
|
return {
|
|
320
405
|
...a,
|
|
321
406
|
...b,
|
|
322
|
-
headers:
|
|
407
|
+
headers: mergeHeaders(a.headers, b.headers),
|
|
323
408
|
query: { ...a.query, ...b.query },
|
|
324
409
|
};
|
|
325
410
|
}
|
|
411
|
+
/** Header names are case-insensitive: a later `authorization` replaces an earlier `Authorization` rather than joining it. */
|
|
412
|
+
function mergeHeaders(a = {}, b = {}) {
|
|
413
|
+
const names = new Set(Object.keys(b).map((k) => k.toLowerCase()));
|
|
414
|
+
return { ...Object.fromEntries(Object.entries(a).filter(([k]) => !names.has(k.toLowerCase()))), ...b };
|
|
415
|
+
}
|
|
326
416
|
/** One line per request, for failure output. */
|
|
327
417
|
export function formatHistory(history) {
|
|
328
418
|
return history
|
package/dist/init.js
CHANGED
|
@@ -74,6 +74,37 @@ function runnable(dir) {
|
|
|
74
74
|
return false;
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
/** Where plain SQL migrations usually live (golang-migrate, goose, sqlx, Diesel, dbmate). */
|
|
78
|
+
const SQL_MIGRATION_DIRS = ["migrations", "db/migrations", "sql/migrations", "database/migrations"];
|
|
79
|
+
/** `.sql` files, or Diesel's directories with an `up.sql`. */
|
|
80
|
+
function hasSqlMigrations(dir) {
|
|
81
|
+
if (!existsSync(dir))
|
|
82
|
+
return false;
|
|
83
|
+
return readdirSync(dir, { withFileTypes: true }).some((e) => (e.isFile() && e.name.endsWith(".sql")) || (e.isDirectory() && existsSync(path.join(dir, e.name, "up.sql"))));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The Go package to run: `.` when the root is package main, else the one command in `cmd/*`
|
|
87
|
+
* (or the one named server, api, app, web or like the module). Undefined when there is none.
|
|
88
|
+
*/
|
|
89
|
+
function goMain(root, goMod) {
|
|
90
|
+
const isMain = (dir) => {
|
|
91
|
+
try {
|
|
92
|
+
return readdirSync(dir).some((f) => f.endsWith(".go") && !f.endsWith("_test.go") && /^\s*package\s+main\b/m.test(readFileSync(path.join(dir, f), "utf8")));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (isMain(root))
|
|
99
|
+
return ".";
|
|
100
|
+
const cmd = path.join(root, "cmd");
|
|
101
|
+
if (!existsSync(cmd))
|
|
102
|
+
return undefined;
|
|
103
|
+
const mains = readdirSync(cmd, { withFileTypes: true }).filter((e) => e.isDirectory() && isMain(path.join(cmd, e.name))).map((e) => e.name).sort();
|
|
104
|
+
const module = /^module\s+(\S+)/m.exec(goMod)?.[1]?.split("/").pop();
|
|
105
|
+
const pick = mains.length === 1 ? mains[0] : ["server", "api", "app", "web", "http", module].find((n) => n && mains.includes(n));
|
|
106
|
+
return pick ? `./cmd/${pick}` : undefined;
|
|
107
|
+
}
|
|
77
108
|
export async function detect(root) {
|
|
78
109
|
const notes = [];
|
|
79
110
|
// The app may live in a subdirectory (backend/, server/, …); everything about the app is read there.
|
|
@@ -238,19 +269,33 @@ export async function detect(root) {
|
|
|
238
269
|
}
|
|
239
270
|
}
|
|
240
271
|
else if (has("go.mod")) {
|
|
241
|
-
|
|
242
|
-
|
|
272
|
+
const main = goMain(appRoot, await read("go.mod"));
|
|
273
|
+
// Built once up front, so the workers' `go run` find everything compiled instead of compiling at once.
|
|
274
|
+
build = "go build ./...";
|
|
275
|
+
command = `go run ${main ?? "."}`;
|
|
276
|
+
readyTimeout = 60_000;
|
|
277
|
+
notes.push(main && main !== "."
|
|
278
|
+
? `app: Go (go.mod), the main package in ${main.slice(2)}/. It must listen on $PORT.`
|
|
279
|
+
: main
|
|
280
|
+
? "app: Go (go.mod). It must listen on $PORT."
|
|
281
|
+
: "app: Go (go.mod), but no main package at the root or in cmd/*/: set the package in app.command (`go run ./cmd/server`). It must listen on $PORT.");
|
|
243
282
|
}
|
|
244
283
|
else if (has("Cargo.toml")) {
|
|
245
|
-
|
|
246
|
-
|
|
284
|
+
const cargo = await read("Cargo.toml");
|
|
285
|
+
// Compiled once before the workers start; `cargo run` then only checks that nothing changed.
|
|
286
|
+
build = "cargo build -q";
|
|
287
|
+
command = "cargo run -q";
|
|
288
|
+
readyTimeout = 120_000;
|
|
289
|
+
notes.push(/^\[workspace\]/m.test(cargo) && !/^\[package\]/m.test(cargo)
|
|
290
|
+
? "app: Rust workspace (Cargo.toml): add `-p <crate>` to app.command for the server's crate. It must listen on $PORT."
|
|
291
|
+
: "app: Rust (Cargo.toml), built once with `cargo build`. It must listen on $PORT.");
|
|
247
292
|
}
|
|
248
293
|
else {
|
|
249
294
|
notes.push("app: couldn't tell how to start the app. Set app.command.");
|
|
250
295
|
}
|
|
251
296
|
// --- migrations ---
|
|
252
297
|
let migrate;
|
|
253
|
-
const
|
|
298
|
+
const migrationsDir = SQL_MIGRATION_DIRS.find((d) => hasSqlMigrations(path.join(appRoot, d)));
|
|
254
299
|
const atlas = await findAtlas(root, appDir);
|
|
255
300
|
if (atlas) {
|
|
256
301
|
migrate = { atlas: { dir: `file://${atlas}` } };
|
|
@@ -304,9 +349,9 @@ export async function detect(root) {
|
|
|
304
349
|
migrate = { command: "npx knex migrate:latest", inputs: ["migrations"] };
|
|
305
350
|
notes.push("db: Knex migrations");
|
|
306
351
|
}
|
|
307
|
-
else if (
|
|
308
|
-
migrate = { sql:
|
|
309
|
-
notes.push(
|
|
352
|
+
else if (migrationsDir) {
|
|
353
|
+
migrate = { sql: migrationsDir };
|
|
354
|
+
notes.push(`db: SQL migrations in ${migrationsDir}/, applied in version order (down migrations left out)`);
|
|
310
355
|
}
|
|
311
356
|
else if (has("schema.sql")) {
|
|
312
357
|
migrate = { sql: "schema.sql" };
|