slicetest 0.6.0 → 0.7.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 +110 -15
- package/dist/app.js +37 -9
- package/dist/cli.js +46 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +10 -0
- package/dist/connection.d.ts +5 -0
- package/dist/connection.js +31 -0
- package/dist/diagram.d.ts +13 -0
- package/dist/diagram.js +89 -0
- package/dist/global-setup.js +45 -4
- 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 +12 -0
- package/dist/http.js +71 -16
- package/dist/index.d.ts +2 -1
- package/dist/init.js +184 -15
- package/dist/matchers.d.ts +9 -0
- package/dist/matchers.js +47 -1
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +32 -1
- package/dist/provided.d.ts +1 -0
- package/dist/recording.d.ts +1 -1
- package/dist/recording.js +2 -2
- package/dist/runtime.d.ts +12 -5
- package/dist/runtime.js +77 -22
- package/dist/scenario.js +3 -0
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +73 -0
- package/dist/stub.d.ts +41 -0
- package/dist/stub.js +187 -3
- package/dist/timeline.d.ts +9 -0
- package/dist/timeline.js +6 -0
- package/dist/yaml-runtime.js +67 -20
- package/dist/yaml.d.ts +40 -5
- package/dist/yaml.js +102 -15
- package/package.json +2 -1
- package/schema/scenario.schema.json +279 -12
package/dist/init.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parse, stringify } from "yaml";
|
|
@@ -15,7 +15,11 @@ const KNOWN_APIS = [
|
|
|
15
15
|
{ packages: ["twilio"], stub: "twilio", hosts: ["api.twilio.com"], env: { TWILIO_ACCOUNT_SID: "AC00000000000000000000000000000000", TWILIO_AUTH_TOKEN: "slicetest" } },
|
|
16
16
|
];
|
|
17
17
|
/** Files that mark a directory as an app slicetest can start. */
|
|
18
|
-
const APP_MARKERS = ["manage.py", "requirements.txt", "pyproject.toml", "Gemfile", "go.mod", "Cargo.toml", "build.gradle", "build.gradle.kts", "pom.xml"];
|
|
18
|
+
const APP_MARKERS = ["manage.py", "requirements.txt", "pyproject.toml", "Gemfile", "go.mod", "Cargo.toml", "build.gradle", "build.gradle.kts", "pom.xml", "composer.json", "mix.exs", "deno.json", "deno.jsonc"];
|
|
19
|
+
/** A fixed Phoenix SECRET_KEY_BASE for tests (it must be at least 64 bytes). */
|
|
20
|
+
const PHOENIX_SECRET = "slicetest-secret-key-base-for-tests-only-".padEnd(64, "0");
|
|
21
|
+
/** A fixed Laravel APP_KEY for tests (32 bytes), so encrypted cookies and sessions work without `key:generate`. */
|
|
22
|
+
const LARAVEL_KEY = `base64:${Buffer.from("slicetest-app-key-for-tests-only").toString("base64")}`;
|
|
19
23
|
/** Build files of a Gradle or Maven multi-project build (two levels down), for dependencies declared in a subproject. */
|
|
20
24
|
async function subprojectBuildFiles(dir, gradle) {
|
|
21
25
|
const names = gradle ? ["build.gradle", "build.gradle.kts"] : ["pom.xml"];
|
|
@@ -36,11 +40,32 @@ async function subprojectBuildFiles(dir, gradle) {
|
|
|
36
40
|
await walk(dir, 1);
|
|
37
41
|
return texts;
|
|
38
42
|
}
|
|
43
|
+
/** The ASP.NET Core project (Sdk="Microsoft.NET.Sdk.Web") in `dir` or two levels below it (src/Api/Api.csproj). */
|
|
44
|
+
async function findWebProject(dir) {
|
|
45
|
+
const found = [];
|
|
46
|
+
const walk = async (d, depth) => {
|
|
47
|
+
for (const entry of await readdir(path.join(dir, d), { withFileTypes: true }).catch(() => [])) {
|
|
48
|
+
const rel = d ? `${d}/${entry.name}` : entry.name;
|
|
49
|
+
if (entry.isFile() && entry.name.endsWith(".csproj"))
|
|
50
|
+
found.push({ file: rel, text: await readFile(path.join(dir, rel), "utf8") });
|
|
51
|
+
else if (entry.isDirectory() && depth < 2 && !entry.name.startsWith(".") && !["bin", "obj", "node_modules", "tests", "test"].includes(entry.name))
|
|
52
|
+
await walk(rel, depth + 1);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
await walk("", 0);
|
|
56
|
+
// Test projects reference the web project too; the web SDK is what marks the app.
|
|
57
|
+
return found.find((p) => /Sdk="Microsoft\.NET\.Sdk\.Web"/.test(p.text) && !/\.Tests?\.csproj$/.test(p.file));
|
|
58
|
+
}
|
|
39
59
|
/** Where monorepos usually keep the server, when the root isn't one. */
|
|
40
60
|
const APP_DIRS = ["backend", "server", "api", "app", "service"];
|
|
41
61
|
function runnable(dir) {
|
|
42
62
|
if (APP_MARKERS.some((f) => existsSync(path.join(dir, f))))
|
|
43
63
|
return true;
|
|
64
|
+
try {
|
|
65
|
+
if (readdirSync(dir).some((f) => f.endsWith(".csproj") || f.endsWith(".sln") || f.endsWith(".slnx")))
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
44
69
|
try {
|
|
45
70
|
const scripts = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")).scripts ?? {};
|
|
46
71
|
return !!(scripts.start || scripts.dev);
|
|
@@ -62,6 +87,23 @@ export async function detect(root) {
|
|
|
62
87
|
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
63
88
|
const python = `${await read("requirements.txt")}\n${await read("pyproject.toml")}`.toLowerCase();
|
|
64
89
|
const gemfile = await read("Gemfile");
|
|
90
|
+
const composer = has("composer.json") ? JSON.parse(await read("composer.json")) : undefined;
|
|
91
|
+
const php = { ...composer?.require, ...composer?.["require-dev"] };
|
|
92
|
+
const laravel = !!php["laravel/framework"] && has("artisan");
|
|
93
|
+
const symfony = !laravel && !!php["symfony/framework-bundle"] && has("bin/console");
|
|
94
|
+
const mix = await read("mix.exs");
|
|
95
|
+
const phoenix = /:phoenix\b/.test(mix);
|
|
96
|
+
const denoConfig = has("deno.json") ? "deno.json" : has("deno.jsonc") ? "deno.jsonc" : undefined;
|
|
97
|
+
let denoTasks = {};
|
|
98
|
+
try {
|
|
99
|
+
denoTasks = denoConfig ? (JSON.parse((await read(denoConfig)).replace(/^\s*\/\/.*$/gm, "")).tasks ?? {}) : {};
|
|
100
|
+
}
|
|
101
|
+
catch { }
|
|
102
|
+
// The package manager the lockfile belongs to runs the scripts (Bun scripts may call `bun` itself).
|
|
103
|
+
const pm = has("bun.lock") || has("bun.lockb") ? "bun" : has("pnpm-lock.yaml") ? "pnpm" : has("yarn.lock") ? "yarn" : "npm";
|
|
104
|
+
const dotnet = !pkg?.scripts?.start && !pkg?.scripts?.dev ? await findWebProject(appRoot) : undefined;
|
|
105
|
+
const nuget = new Set([...(dotnet?.text.matchAll(/<PackageReference\s+Include="([^"]+)"/g) ?? [])].map((m) => m[1].toLowerCase()));
|
|
106
|
+
let dotnetConnection;
|
|
65
107
|
// --- app ---
|
|
66
108
|
let command = TODO_COMMAND;
|
|
67
109
|
let jvmBuild;
|
|
@@ -72,17 +114,35 @@ export async function detect(root) {
|
|
|
72
114
|
let readyTimeout;
|
|
73
115
|
const env = { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}" };
|
|
74
116
|
if (pkg?.scripts?.start) {
|
|
75
|
-
command = "npm start"
|
|
76
|
-
notes.push(
|
|
117
|
+
command = pm === "npm" ? "npm start" : `${pm} run start`;
|
|
118
|
+
notes.push(`app: \`${command}\` (package.json start script${pm === "npm" ? "" : `, ${pm} from its lockfile`}). It must listen on $PORT.`);
|
|
77
119
|
if (pkg.scripts.build) {
|
|
78
120
|
// Otherwise `next start` and the like serve whatever was built last, and scenarios pass against stale code.
|
|
79
|
-
build =
|
|
80
|
-
notes.push(
|
|
121
|
+
build = `${pm} run build`;
|
|
122
|
+
notes.push(`app: \`${build}\` once per run, before starting (package.json build script).`);
|
|
81
123
|
}
|
|
82
124
|
}
|
|
83
125
|
else if (pkg?.scripts?.dev) {
|
|
84
|
-
command =
|
|
85
|
-
notes.push(
|
|
126
|
+
command = `${pm} run dev`;
|
|
127
|
+
notes.push(`app: \`${command}\` (no start script). A production start command is usually faster to boot.`);
|
|
128
|
+
}
|
|
129
|
+
else if (denoConfig) {
|
|
130
|
+
const task = ["start", "serve", "dev"].find((t) => t in denoTasks);
|
|
131
|
+
const main = ["main.ts", "server.ts", "src/main.ts", "mod.ts"].find(has);
|
|
132
|
+
command = task ? `deno task ${task}` : `deno run --allow-net --allow-env --allow-read ${main ?? "main.ts"}`;
|
|
133
|
+
notes.push(`app: Deno (${denoConfig}), \`${command}\`. It must listen on Deno.env.get("PORT").`);
|
|
134
|
+
}
|
|
135
|
+
else if (phoenix) {
|
|
136
|
+
// Phoenix reads PORT and DATABASE_URL in config/runtime.exs for prod only; dev.exs hard-codes port 4000.
|
|
137
|
+
command = "mix phx.server";
|
|
138
|
+
build = "mix compile";
|
|
139
|
+
Object.assign(env, { MIX_ENV: "prod", PHX_SERVER: "true", PHX_HOST: "127.0.0.1", SECRET_KEY_BASE: PHOENIX_SECRET });
|
|
140
|
+
readyTimeout = 60_000;
|
|
141
|
+
notes.push("app: Phoenix in MIX_ENV=prod, where config/runtime.exs reads PORT and DATABASE_URL (dev.exs hard-codes them). SECRET_KEY_BASE is a fixed test key. Run `MIX_ENV=prod mix deps.get` once first");
|
|
142
|
+
}
|
|
143
|
+
else if (mix) {
|
|
144
|
+
command = "mix run --no-halt";
|
|
145
|
+
notes.push("app: Elixir (mix.exs). It must listen on $PORT.");
|
|
86
146
|
}
|
|
87
147
|
else if (has("manage.py")) {
|
|
88
148
|
command = "python manage.py runserver 127.0.0.1:{{app.port}} --noreload";
|
|
@@ -100,6 +160,48 @@ export async function detect(root) {
|
|
|
100
160
|
command = "bin/rails server -p {{app.port}}";
|
|
101
161
|
notes.push("app: Rails");
|
|
102
162
|
}
|
|
163
|
+
else if (dotnet) {
|
|
164
|
+
// Built once, then run without building, so workers don't compile at the same time; launch profiles would override the URL.
|
|
165
|
+
build = `dotnet build ${dotnet.file} -v q`;
|
|
166
|
+
command = `dotnet run --project ${dotnet.file} --no-build --no-launch-profile`;
|
|
167
|
+
delete env.PORT;
|
|
168
|
+
delete env.DATABASE_URL;
|
|
169
|
+
const dir = path.posix.dirname(dotnet.file);
|
|
170
|
+
let settings = {};
|
|
171
|
+
try {
|
|
172
|
+
settings = JSON.parse((await read(path.posix.join(dir, "appsettings.json"))).replace(/^\uFEFF/, "") || "{}");
|
|
173
|
+
}
|
|
174
|
+
catch { }
|
|
175
|
+
dotnetConnection = Object.keys(settings.ConnectionStrings ?? {})[0] ?? "DefaultConnection";
|
|
176
|
+
Object.assign(env, { ASPNETCORE_URLS: "http://127.0.0.1:{{app.port}}", ASPNETCORE_ENVIRONMENT: "Development" });
|
|
177
|
+
readyTimeout = 60_000;
|
|
178
|
+
const program = await read(path.posix.join(dir, "Program.cs"));
|
|
179
|
+
const health = /MapHealthChecks\(\s*"([^"]+)"/.exec(program)?.[1];
|
|
180
|
+
if (health)
|
|
181
|
+
readyPath = health;
|
|
182
|
+
notes.push(`app: ASP.NET Core (${dotnet.file}), built once with \`dotnet build\` and started with \`dotnet run --no-build\` at ASPNETCORE_URLS`);
|
|
183
|
+
}
|
|
184
|
+
else if (laravel) {
|
|
185
|
+
// `artisan serve` is PHP's built-in server; Laravel reads DB_* (set below, once the engine is known).
|
|
186
|
+
command = "php artisan serve --host=127.0.0.1 --port={{app.port}} --no-reload";
|
|
187
|
+
delete env.PORT;
|
|
188
|
+
delete env.DATABASE_URL;
|
|
189
|
+
Object.assign(env, { APP_ENV: "local", APP_KEY: LARAVEL_KEY, APP_DEBUG: "true", LOG_CHANNEL: "stderr" });
|
|
190
|
+
if (/health:\s*['"]\/up['"]/.test(await read("bootstrap/app.php")))
|
|
191
|
+
readyPath = "/up";
|
|
192
|
+
notes.push("app: Laravel (php artisan serve). DB_* variables point Laravel at the test database; APP_KEY is a fixed test key; logs go to stderr so failures show them");
|
|
193
|
+
}
|
|
194
|
+
else if (symfony) {
|
|
195
|
+
command = "php -S 127.0.0.1:{{app.port}} -t public";
|
|
196
|
+
delete env.PORT;
|
|
197
|
+
Object.assign(env, { APP_ENV: "dev", APP_DEBUG: "1" });
|
|
198
|
+
notes.push("app: Symfony, served by PHP's built-in server from public/. Doctrine reads DATABASE_URL");
|
|
199
|
+
}
|
|
200
|
+
else if (composer && has("public/index.php")) {
|
|
201
|
+
command = "php -S 127.0.0.1:{{app.port}} -t public";
|
|
202
|
+
delete env.PORT;
|
|
203
|
+
notes.push("app: PHP (composer.json), served by PHP's built-in server from public/");
|
|
204
|
+
}
|
|
103
205
|
else if (has("build.gradle") || has("build.gradle.kts") || has("pom.xml")) {
|
|
104
206
|
const gradle = !has("pom.xml");
|
|
105
207
|
const buildFile = await read(gradle ? (has("build.gradle.kts") ? "build.gradle.kts" : "build.gradle") : "pom.xml");
|
|
@@ -154,6 +256,23 @@ export async function detect(root) {
|
|
|
154
256
|
migrate = { atlas: { dir: `file://${atlas}` } };
|
|
155
257
|
notes.push(`db: Atlas migrations in ${atlas}/`);
|
|
156
258
|
}
|
|
259
|
+
else if (phoenix && has("priv/repo/migrations")) {
|
|
260
|
+
migrate = { command: "mix ecto.migrate", inputs: ["priv/repo/migrations"], env: { MIX_ENV: "prod", SECRET_KEY_BASE: PHOENIX_SECRET } };
|
|
261
|
+
notes.push("db: Ecto migrations (mix ecto.migrate, in MIX_ENV=prod like the app)");
|
|
262
|
+
}
|
|
263
|
+
else if (dotnet && nuget.has("microsoft.entityframeworkcore.design") && has(path.posix.join(path.posix.dirname(dotnet.file), "Migrations"))) {
|
|
264
|
+
const migrations = path.posix.join(path.posix.dirname(dotnet.file), "Migrations");
|
|
265
|
+
migrate = { command: `dotnet ef database update --project ${dotnet.file}`, inputs: [migrations] };
|
|
266
|
+
notes.push(`db: EF Core migrations in ${migrations}/ (dotnet ef database update; install it with \`dotnet tool install --global dotnet-ef\`)`);
|
|
267
|
+
}
|
|
268
|
+
else if (laravel) {
|
|
269
|
+
migrate = { command: "php artisan migrate --force", inputs: ["database/migrations"] };
|
|
270
|
+
notes.push("db: Laravel migrations (php artisan migrate), given the same DB_* variables as the app");
|
|
271
|
+
}
|
|
272
|
+
else if (symfony && php["doctrine/doctrine-migrations-bundle"]) {
|
|
273
|
+
migrate = { command: "php bin/console doctrine:migrations:migrate --no-interaction", inputs: ["migrations"] };
|
|
274
|
+
notes.push("db: Doctrine migrations (doctrine:migrations:migrate)");
|
|
275
|
+
}
|
|
157
276
|
else if (has("prisma/schema.prisma")) {
|
|
158
277
|
migrate = { command: "npx prisma migrate deploy", inputs: ["prisma/migrations"] };
|
|
159
278
|
notes.push("db: Prisma (prisma migrate deploy)");
|
|
@@ -201,7 +320,7 @@ export async function detect(root) {
|
|
|
201
320
|
if ("sql" in migrate)
|
|
202
321
|
migrate = { sql: path.posix.join(appDir, migrate.sql) };
|
|
203
322
|
else
|
|
204
|
-
migrate = { command: `cd ${appDir} && ${migrate.command}`, ...(migrate.inputs ? { inputs: migrate.inputs.map((i) => path.posix.join(appDir, i)) } : {}) };
|
|
323
|
+
migrate = { ...migrate, command: `cd ${appDir} && ${migrate.command}`, ...(migrate.inputs ? { inputs: migrate.inputs.map((i) => path.posix.join(appDir, i)) } : {}) };
|
|
205
324
|
}
|
|
206
325
|
// --- docker compose: the database image and other dependencies ---
|
|
207
326
|
const { db: composeDb, containers, appEnv, mail: composeMail } = await fromCompose(root, notes, [...new Set(["", appDir])]);
|
|
@@ -249,11 +368,53 @@ export async function detect(root) {
|
|
|
249
368
|
notes.push("auth: Clerk. Sessions are tokens from slicetest's issuer (auth: true); the clerk stub serves its keys at /v1/jwks and users at /v1/users/:id. See the README's Clerk section");
|
|
250
369
|
}
|
|
251
370
|
notes.push("network: URLs written in the code (https://api.example.com) can be stubbed with `hosts`. Add `offline: true` and the first run names every host the app calls");
|
|
252
|
-
const mysqlDeps = !!deps.mysql2 || !!deps.mysql || /\b(pymysql|mysqlclient|aiomysql)\b/.test(python) || /\bgem ['"]mysql2['"]/.test(gemfile);
|
|
371
|
+
const mysqlDeps = !!deps.mysql2 || !!deps.mysql || /\b(pymysql|mysqlclient|aiomysql)\b/.test(python) || /\bgem ['"]mysql2['"]/.test(gemfile) || /:myxql\b/.test(mix);
|
|
253
372
|
if (!composeDb.engine && mysqlDeps) {
|
|
254
373
|
composeDb.engine = "mysql";
|
|
255
374
|
notes.push("db: MySQL (a MySQL driver is a dependency). Install mysql2 and @testcontainers/mysql next to slicetest.");
|
|
256
375
|
}
|
|
376
|
+
if (dotnet) {
|
|
377
|
+
if (!composeDb.engine && [...nuget].some((n) => /^(pomelo\.entityframeworkcore\.mysql|mysqlconnector|mysql\.data|mysql\.entityframeworkcore)$/.test(n))) {
|
|
378
|
+
composeDb.engine = "mysql";
|
|
379
|
+
notes.push("db: MySQL (a MySQL provider is a NuGet dependency). Install mysql2 and @testcontainers/mysql next to slicetest.");
|
|
380
|
+
}
|
|
381
|
+
else if (!composeDb.engine && nuget.has("microsoft.entityframeworkcore.sqlite") && ![...nuget].some((n) => n.startsWith("npgsql"))) {
|
|
382
|
+
composeDb.engine = "sqlite";
|
|
383
|
+
notes.push("db: SQLite (Microsoft.EntityFrameworkCore.Sqlite), no container needed");
|
|
384
|
+
}
|
|
385
|
+
// ASP.NET reads ConnectionStrings:<name> from ConnectionStrings__<name>; `dotnet ef` builds the same host, so it reads it too.
|
|
386
|
+
const dbEnv = { [`ConnectionStrings__${dotnetConnection}`]: "{{db.adoNet}}" };
|
|
387
|
+
Object.assign(env, dbEnv);
|
|
388
|
+
if (migrate && "command" in migrate)
|
|
389
|
+
migrate = { ...migrate, env: dbEnv };
|
|
390
|
+
notes.push(`db: ConnectionStrings__${dotnetConnection} is the test database as an ADO.NET connection string ({{db.adoNet}})`);
|
|
391
|
+
}
|
|
392
|
+
if (laravel) {
|
|
393
|
+
// Laravel names its connection in .env (DB_CONNECTION=sqlite is the default of new projects).
|
|
394
|
+
const connection = /^\s*DB_CONNECTION\s*=\s*["']?(\w+)/m.exec(exampleText)?.[1];
|
|
395
|
+
if (!composeDb.engine && connection === "sqlite") {
|
|
396
|
+
composeDb.engine = "sqlite";
|
|
397
|
+
notes.push("db: SQLite (DB_CONNECTION=sqlite in .env.example), no container needed");
|
|
398
|
+
}
|
|
399
|
+
else if (!composeDb.engine && (connection === "mysql" || connection === "mariadb")) {
|
|
400
|
+
composeDb.engine = "mysql";
|
|
401
|
+
notes.push(`db: MySQL (DB_CONNECTION=${connection} in .env.example). Install mysql2 and @testcontainers/mysql next to slicetest.`);
|
|
402
|
+
}
|
|
403
|
+
const dbEnv = composeDb.engine === "sqlite"
|
|
404
|
+
? { DB_CONNECTION: "sqlite", DB_DATABASE: "{{db.path}}" }
|
|
405
|
+
: { DB_CONNECTION: composeDb.engine === "mysql" ? "mysql" : "pgsql", DB_HOST: "{{db.host}}", DB_PORT: "{{db.port}}", DB_DATABASE: "{{db.name}}", DB_USERNAME: "{{db.user}}", DB_PASSWORD: "{{db.password}}" };
|
|
406
|
+
if (env.DATABASE_URL?.startsWith("{{"))
|
|
407
|
+
delete env.DATABASE_URL;
|
|
408
|
+
Object.assign(env, dbEnv);
|
|
409
|
+
if (migrate && "command" in migrate)
|
|
410
|
+
migrate = { ...migrate, env: dbEnv };
|
|
411
|
+
if (mail) {
|
|
412
|
+
delete env.SMTP_HOST;
|
|
413
|
+
delete env.SMTP_PORT;
|
|
414
|
+
Object.assign(env, { MAIL_MAILER: "smtp", MAIL_HOST: "{{mail.host}}", MAIL_PORT: "{{mail.port}}" });
|
|
415
|
+
notes.push("mail: Laravel's MAIL_MAILER=smtp, MAIL_HOST and MAIL_PORT point at slicetest's SMTP server");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
257
418
|
// --- OpenAPI ---
|
|
258
419
|
const openapi = ["openapi.yaml", "openapi.yml", "openapi.json", "docs/openapi.yaml", "docs/openapi.yml", "docs/openapi.json"].find(has);
|
|
259
420
|
if (openapi)
|
|
@@ -265,12 +426,19 @@ export async function detect(root) {
|
|
|
265
426
|
const dbLibrary = Object.keys(deps).some((d) => /^(pg|postgres|mysql2?|@prisma\/client|prisma|drizzle-orm|knex|typeorm|sequelize|kysely|better-sqlite3|sqlite3|@neondatabase\/serverless|@vercel\/postgres|@libsql\/client|mongoose|mongodb)$/.test(d)) ||
|
|
266
427
|
/data-jpa|data-jdbc|spring-jdbc|r2dbc|postgresql|mysql|flyway|liquibase|hibernate/.test(jvmBuild ?? "") ||
|
|
267
428
|
/\b(psycopg|sqlalchemy|asyncpg|pymysql|mysqlclient|django|peewee|tortoise|sqlmodel)\b/.test(python) ||
|
|
268
|
-
/\b(rails|activerecord|sequel|pg|mysql2|sqlite3)\b/.test(gemfile)
|
|
269
|
-
|
|
429
|
+
/\b(rails|activerecord|sequel|pg|mysql2|sqlite3)\b/.test(gemfile) ||
|
|
430
|
+
Object.keys(php).some((d) => /^(laravel\/framework|illuminate\/database|doctrine\/(orm|dbal)|doctrine\/doctrine-bundle)$/.test(d)) ||
|
|
431
|
+
[...nuget].some((n) => /^(microsoft\.entityframeworkcore|npgsql|dapper|mysqlconnector|pomelo\.|microsoft\.data\.sqlite)/.test(n)) ||
|
|
432
|
+
/:(ecto_sql|postgrex|myxql|ecto_sqlite3)\b/.test(mix);
|
|
433
|
+
const knownStack = !!pkg || jvmBuild !== undefined || python.trim().length > 0 || gemfile.length > 0 || !!composer || !!dotnet || !!mix;
|
|
270
434
|
const noDb = knownStack && !dbLibrary && !migrate && !composeDb.engine && !composeDb.image && !has("manage.py");
|
|
271
435
|
if (noDb) {
|
|
272
|
-
|
|
273
|
-
|
|
436
|
+
// Whatever was meant to reach the database (DATABASE_URL, SPRING_DATASOURCE_*, ConnectionStrings__*).
|
|
437
|
+
for (const [k, v] of Object.entries(env))
|
|
438
|
+
if (v.includes("{{db."))
|
|
439
|
+
delete env[k];
|
|
440
|
+
for (let i = notes.findIndex((n) => n.startsWith("db: ConnectionStrings__")); i >= 0; i = notes.findIndex((n) => n.startsWith("db: ConnectionStrings__")))
|
|
441
|
+
notes.splice(i, 1);
|
|
274
442
|
const i = notes.indexOf("db: no migrations found; the database starts empty. Set db.migrate.");
|
|
275
443
|
if (i >= 0)
|
|
276
444
|
notes.splice(i, 1);
|
|
@@ -468,7 +636,8 @@ async function fromCompose(root, notes, dirs = [""]) {
|
|
|
468
636
|
}
|
|
469
637
|
for (const [name, svc] of Object.entries(doc.services ?? {})) {
|
|
470
638
|
const image = typeof svc?.image === "string" ? svc.image : undefined;
|
|
471
|
-
|
|
639
|
+
// A service built from source (with or without an image name for the result, like Laravel Sail's) is usually the app.
|
|
640
|
+
if (!image || svc?.build) {
|
|
472
641
|
if (svc?.build)
|
|
473
642
|
notes.push(`${file}: service "${name}" is built from source; if it's the app, app.command replaces it`);
|
|
474
643
|
continue;
|
package/dist/matchers.d.ts
CHANGED
|
@@ -6,6 +6,15 @@ interface SlicetestMatchers<R = unknown> {
|
|
|
6
6
|
toHaveReceived(method: string, path: string | RegExp, match?: MatchOptions): R;
|
|
7
7
|
/** The stub received exactly `n` calls matching `method path`. */
|
|
8
8
|
toHaveReceivedTimes(n: number, method?: string, path?: string | RegExp, match?: MatchOptions): R;
|
|
9
|
+
/** The stub received a GraphQL request for `operation` (with `variables` as a subset, if given). */
|
|
10
|
+
toHaveReceivedGraphQL(operation: string | RegExp, variables?: unknown): R;
|
|
11
|
+
/** A GraphQL response without `errors`, whose `data` contains `expected` (if given). The failure message shows the errors. */
|
|
12
|
+
toHaveGraphQLData(expected?: unknown): R;
|
|
13
|
+
/**
|
|
14
|
+
* The value (a response's JSON, for a response) matches a JSON Schema: an object, or a file
|
|
15
|
+
* relative to the working directory with an optional pointer, `openapi.yaml#/components/schemas/Poll`.
|
|
16
|
+
*/
|
|
17
|
+
toMatchSchema(schema: object | string): R;
|
|
9
18
|
/** The response has this status; the failure message shows the response body. */
|
|
10
19
|
toHaveStatus(status: number): R;
|
|
11
20
|
/** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`. */
|
package/dist/matchers.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { expect } from "vitest";
|
|
2
2
|
import { Db } from "./db.js";
|
|
3
|
-
import {
|
|
3
|
+
import { describeGraphQL } from "./graphql.js";
|
|
4
|
+
import { schemaProblems } from "./schema.js";
|
|
5
|
+
import { Stub, subset } from "./stub.js";
|
|
4
6
|
const MAX_SHOWN = 5;
|
|
5
7
|
function describeCalls(calls) {
|
|
6
8
|
if (calls.length === 0)
|
|
7
9
|
return " (no calls)";
|
|
8
10
|
const shown = calls.slice(-MAX_SHOWN).map((c) => {
|
|
9
11
|
const q = c.query.size ? `?${c.query}` : "";
|
|
12
|
+
if (c.graphql)
|
|
13
|
+
return ` ${describeGraphQL(c.graphql)} (${c.method} ${c.path}) variables ${JSON.stringify(c.graphql.variables)}`;
|
|
10
14
|
const body = c.body ? ` ${c.body.length > 200 ? `${c.body.slice(0, 200)}…` : c.body}` : "";
|
|
11
15
|
return ` ${c.method} ${c.path}${q}${body}`;
|
|
12
16
|
});
|
|
@@ -59,6 +63,48 @@ expect.extend({
|
|
|
59
63
|
expected: n,
|
|
60
64
|
};
|
|
61
65
|
},
|
|
66
|
+
toHaveReceivedGraphQL(received, operation, variables) {
|
|
67
|
+
assertStub(received);
|
|
68
|
+
const pass = received.calls("*", undefined, { graphql: { operation, variables } }).length > 0;
|
|
69
|
+
const cond = variables === undefined ? "" : ` with variables ${this.utils.stringify(variables)}`;
|
|
70
|
+
return {
|
|
71
|
+
pass,
|
|
72
|
+
message: () => `expected stub "${received.name}" ${pass ? "not " : ""}to have received GraphQL ${operation}${cond}\n` +
|
|
73
|
+
`Calls received:\n${describeCalls(received.calls())}`,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
toHaveGraphQLData(received, expected) {
|
|
77
|
+
const body = received?.json;
|
|
78
|
+
const errors = body && typeof body === "object" ? body.errors : undefined;
|
|
79
|
+
const isGraphQL = body && typeof body === "object" && ("data" in body || "errors" in body);
|
|
80
|
+
const hasErrors = Array.isArray(errors) && errors.length > 0;
|
|
81
|
+
const pass = received?.status === 200 && isGraphQL && !hasErrors && (expected === undefined || subset(expected, body.data));
|
|
82
|
+
return {
|
|
83
|
+
pass,
|
|
84
|
+
message: () => {
|
|
85
|
+
const head = `expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to answer GraphQL data${expected === undefined ? "" : ` containing ${this.utils.stringify(expected)}`}`;
|
|
86
|
+
if (!isGraphQL)
|
|
87
|
+
return `${head}, got status ${received.status} without data or errors:\n ${received.text.slice(0, 1000) || "(empty)"}`;
|
|
88
|
+
if (hasErrors)
|
|
89
|
+
return `${head}, got errors:\n${errors.map((e) => ` ${e.message}${e.path ? ` (at ${e.path.join(".")})` : ""}`).join("\n")}`;
|
|
90
|
+
return `${head}, got status ${received.status} and data:\n ${JSON.stringify(body.data)}`;
|
|
91
|
+
},
|
|
92
|
+
actual: body?.data,
|
|
93
|
+
expected,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
toMatchSchema(received, schema) {
|
|
97
|
+
const isResponse = !!received && typeof received === "object" && "status" in received && received.headers instanceof Headers;
|
|
98
|
+
const value = isResponse ? received.json : received;
|
|
99
|
+
const problems = schemaProblems(schema, value);
|
|
100
|
+
const what = isResponse ? `${received.method} ${received.url}'s JSON` : "the value";
|
|
101
|
+
return {
|
|
102
|
+
pass: problems.length === 0,
|
|
103
|
+
message: () => problems.length
|
|
104
|
+
? `expected ${what} to match ${typeof schema === "string" ? schema : "the schema"}:\n${problems.map((p) => ` ${p}`).join("\n")}\nValue:\n ${JSON.stringify(value)?.slice(0, 1000)}`
|
|
105
|
+
: `expected ${what} not to match ${typeof schema === "string" ? schema : "the schema"}`,
|
|
106
|
+
};
|
|
107
|
+
},
|
|
62
108
|
toHaveStatus(received, status) {
|
|
63
109
|
const pass = received?.status === status;
|
|
64
110
|
return {
|
package/dist/openapi.d.ts
CHANGED
|
@@ -47,6 +47,16 @@ export declare class OpenApiSpec {
|
|
|
47
47
|
/** Problems with a response to `method path`; empty when it matches the spec. */
|
|
48
48
|
/** The documented response (`200`, `4XX`, `default`) that `status` falls under, for coverage. */
|
|
49
49
|
responseKey(method: string, path: string, status: number): string | undefined;
|
|
50
|
+
/** The documented operation `method path` falls under, as `METHOD /template`, and whether the spec deprecates it. */
|
|
51
|
+
operationOf(method: string, path: string): {
|
|
52
|
+
key: string;
|
|
53
|
+
deprecated: boolean;
|
|
54
|
+
} | undefined;
|
|
55
|
+
/** Every documented operation as `METHOD /template`, with its `deprecated` flag. */
|
|
56
|
+
operationKeys(): {
|
|
57
|
+
key: string;
|
|
58
|
+
deprecated: boolean;
|
|
59
|
+
}[];
|
|
50
60
|
/** Every documented response as `METHOD /template key`, in document order. */
|
|
51
61
|
responseKeys(): string[];
|
|
52
62
|
checkResponse(method: string, path: string, res: Message): string[];
|
|
@@ -80,6 +90,17 @@ export declare function formatCoverage(spec: OpenApiSpec, hits: Set<string>): {
|
|
|
80
90
|
markdown: string;
|
|
81
91
|
text: string;
|
|
82
92
|
};
|
|
93
|
+
/** OpenAPI 3.0's `nullable: true` → JSON Schema's `type: [T, "null"]`. */
|
|
94
|
+
export declare function nullableToType(node: any): any;
|
|
95
|
+
/**
|
|
96
|
+
* Which of a provider's operations the app called during the run: its dependency
|
|
97
|
+
* surface on that API, with the operations the provider has deprecated flagged.
|
|
98
|
+
*/
|
|
99
|
+
export declare function formatUsage(name: string, spec: OpenApiSpec, used: Set<string>): {
|
|
100
|
+
text: string;
|
|
101
|
+
markdown: string;
|
|
102
|
+
deprecated: string[];
|
|
103
|
+
};
|
|
83
104
|
/** Where a spec fetched from the app (`openapi.fromApp`) is kept for the coverage report. */
|
|
84
105
|
export declare function appSpecFile(coverageDir: string): string;
|
|
85
106
|
export {};
|
package/dist/openapi.js
CHANGED
|
@@ -81,6 +81,15 @@ export class OpenApiSpec {
|
|
|
81
81
|
const key = pickResponse(found.op.responses ?? {}, status);
|
|
82
82
|
return key && `${method.toUpperCase()} ${found.template} ${key}`;
|
|
83
83
|
}
|
|
84
|
+
/** The documented operation `method path` falls under, as `METHOD /template`, and whether the spec deprecates it. */
|
|
85
|
+
operationOf(method, path) {
|
|
86
|
+
const found = this.find(method, path);
|
|
87
|
+
return found && { key: `${method.toUpperCase()} ${found.template}`, deprecated: found.op.deprecated === true };
|
|
88
|
+
}
|
|
89
|
+
/** Every documented operation as `METHOD /template`, with its `deprecated` flag. */
|
|
90
|
+
operationKeys() {
|
|
91
|
+
return this.#documentOrder.map(({ template, method, op }) => ({ key: `${method.toUpperCase()} ${template}`, deprecated: op.deprecated === true }));
|
|
92
|
+
}
|
|
84
93
|
/** Every documented response as `METHOD /template key`, in document order. */
|
|
85
94
|
responseKeys() {
|
|
86
95
|
return this.#documentOrder.flatMap(({ template, method, op }) => Object.keys(op.responses ?? {}).map((key) => `${method.toUpperCase()} ${template} ${key}`));
|
|
@@ -343,7 +352,7 @@ function escape(s) {
|
|
|
343
352
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
344
353
|
}
|
|
345
354
|
/** OpenAPI 3.0's `nullable: true` → JSON Schema's `type: [T, "null"]`. */
|
|
346
|
-
function nullableToType(node) {
|
|
355
|
+
export function nullableToType(node) {
|
|
347
356
|
if (Array.isArray(node))
|
|
348
357
|
return node.map(nullableToType);
|
|
349
358
|
if (!node || typeof node !== "object")
|
|
@@ -363,6 +372,28 @@ function nullableToType(node) {
|
|
|
363
372
|
}
|
|
364
373
|
return node;
|
|
365
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* Which of a provider's operations the app called during the run: its dependency
|
|
377
|
+
* surface on that API, with the operations the provider has deprecated flagged.
|
|
378
|
+
*/
|
|
379
|
+
export function formatUsage(name, spec, used) {
|
|
380
|
+
const ops = spec.operationKeys().filter((o) => used.has(o.key));
|
|
381
|
+
const deprecated = ops.filter((o) => o.deprecated);
|
|
382
|
+
const width = Math.max(0, ...ops.map((o) => o.key.length));
|
|
383
|
+
const text = [
|
|
384
|
+
`slicetest: the app used ${ops.length} of ${spec.operationKeys().length} operations of ${name} (${spec.file})${deprecated.length ? `, ${deprecated.length} deprecated` : ""}`,
|
|
385
|
+
...ops.map((o) => ` ${o.key.padEnd(width)}${o.deprecated ? " ⚠ deprecated" : ""}`),
|
|
386
|
+
].join("\n");
|
|
387
|
+
const markdown = [
|
|
388
|
+
`### slicetest: ${name} API usage`,
|
|
389
|
+
"",
|
|
390
|
+
`The app called ${ops.length} of ${spec.operationKeys().length} operations in \`${spec.file}\`${deprecated.length ? `, **${deprecated.length} deprecated**` : ""}.`,
|
|
391
|
+
"",
|
|
392
|
+
...ops.map((o) => `- \`${o.key}\`${o.deprecated ? " ⚠️ deprecated" : ""}`),
|
|
393
|
+
"",
|
|
394
|
+
].join("\n");
|
|
395
|
+
return { text, markdown, deprecated: deprecated.map((o) => o.key) };
|
|
396
|
+
}
|
|
366
397
|
/** Where a spec fetched from the app (`openapi.fromApp`) is kept for the coverage report. */
|
|
367
398
|
export function appSpecFile(coverageDir) {
|
|
368
399
|
return `${coverageDir}.spec.json`;
|
package/dist/provided.d.ts
CHANGED
package/dist/recording.d.ts
CHANGED
|
@@ -40,4 +40,4 @@ export declare class Recorder {
|
|
|
40
40
|
}
|
|
41
41
|
export declare function readRecordings(file: string): Promise<Recording[]>;
|
|
42
42
|
/** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
|
|
43
|
-
export declare function mergeRecordings(file: string, upstream: string, added: Recording[]): Promise<void>;
|
|
43
|
+
export declare function mergeRecordings(file: string, upstream: string, added: Recording[], source?: string): Promise<void>;
|
package/dist/recording.js
CHANGED
|
@@ -137,12 +137,12 @@ export async function readRecordings(file) {
|
|
|
137
137
|
return doc;
|
|
138
138
|
}
|
|
139
139
|
/** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
|
|
140
|
-
export async function mergeRecordings(file, upstream, added) {
|
|
140
|
+
export async function mergeRecordings(file, upstream, added, source = `Recorded by slicetest from ${upstream}`) {
|
|
141
141
|
const entries = await readRecordings(file);
|
|
142
142
|
for (const e of added)
|
|
143
143
|
if (!entries.some((x) => isDeepStrictEqual(x, e)))
|
|
144
144
|
entries.push(e);
|
|
145
145
|
await mkdir(path.dirname(file), { recursive: true });
|
|
146
|
-
const header = `#
|
|
146
|
+
const header = `# ${source}. Review before committing: request bodies are stored as sent.\n`;
|
|
147
147
|
await writeFile(file, header + YAML.stringify(entries, { lineWidth: 0 }));
|
|
148
148
|
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { App } from "./app.js";
|
|
|
2
2
|
import { Issuer } from "./auth.js";
|
|
3
3
|
import type { ResolvedOptions } from "./config.js";
|
|
4
4
|
import { Dependency } from "./containers.js";
|
|
5
|
+
import { connectionVars } from "./connection.js";
|
|
5
6
|
import { Db } from "./db.js";
|
|
6
7
|
import { HttpClient } from "./http.js";
|
|
7
8
|
import { Interceptor } from "./intercept.js";
|
|
@@ -24,6 +25,8 @@ export interface ScenarioContext {
|
|
|
24
25
|
* with timestamps and UUIDs masked. `expect(await trace()).toMatchSnapshot()`.
|
|
25
26
|
*/
|
|
26
27
|
trace: (opts?: MaskOptions) => Promise<Trace>;
|
|
28
|
+
/** The scenario so far as a Mermaid sequence diagram: requests, stub calls, mail and changed tables. */
|
|
29
|
+
diagram: () => Promise<string>;
|
|
27
30
|
/** Mail the app sent during the scenario. Needs `mail: true` in the config. */
|
|
28
31
|
mail: Mailbox;
|
|
29
32
|
/** The OpenID Connect issuer the app trusts: `auth.token(claims)`. Needs `auth` in the config. */
|
|
@@ -56,20 +59,24 @@ export declare class Runtime {
|
|
|
56
59
|
prefix: string;
|
|
57
60
|
coverageDir?: string;
|
|
58
61
|
recordDir?: string;
|
|
62
|
+
usageDir?: string;
|
|
59
63
|
}): Promise<Runtime>;
|
|
60
64
|
context(): ScenarioContext;
|
|
61
65
|
beforeScenario(): Promise<void>;
|
|
62
66
|
/** Failures that the scenario body can't see on its own. */
|
|
63
67
|
afterScenario(): Promise<void>;
|
|
68
|
+
/** The current scenario as a Mermaid sequence diagram. */
|
|
69
|
+
diagram(): Promise<string>;
|
|
70
|
+
/**
|
|
71
|
+
* After a scenario: its diagram goes to the `SLICETEST_DIAGRAMS` directory (one Markdown page
|
|
72
|
+
* per test file) and, when it failed on GitHub Actions, to the job summary.
|
|
73
|
+
*/
|
|
74
|
+
reportDiagram(file: string, scenario: string, failed: boolean, env?: NodeJS.ProcessEnv): Promise<void>;
|
|
64
75
|
/** What happened during the current scenario, printed when it fails. */
|
|
65
76
|
diagnostics(): Promise<string>;
|
|
66
77
|
/** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
|
|
67
78
|
flush(): Promise<void>;
|
|
68
79
|
stop(): Promise<void>;
|
|
69
80
|
}
|
|
70
|
-
/**
|
|
71
|
-
* The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
|
|
72
|
-
* `spring.datasource.url` plus username / password), or separate host / port / name settings.
|
|
73
|
-
*/
|
|
74
|
-
export declare function connectionVars(engine: string, url: string, sqlitePath?: string): Record<string, string>;
|
|
75
81
|
export declare function blockedHint(hosts: string[]): string;
|
|
82
|
+
export { connectionVars };
|