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/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("app: `npm start` (package.json start script). It must listen on $PORT.");
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 = "npm run build";
80
- notes.push("app: `npm run build` once per run, before starting (package.json build script).");
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 = "npm run dev";
85
- notes.push("app: `npm run dev` (no start script). A production start command is usually faster to boot.");
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
- const knownStack = !!pkg || jvmBuild !== undefined || python.trim().length > 0 || gemfile.length > 0;
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
- for (const k of ["DATABASE_URL", "SPRING_DATASOURCE_URL", "SPRING_DATASOURCE_USERNAME", "SPRING_DATASOURCE_PASSWORD"])
273
- delete env[k];
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
- if (!image) {
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;
@@ -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 { Stub } from "./stub.js";
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`;
@@ -9,6 +9,7 @@ declare module "vitest" {
9
9
  coverageDir?: string;
10
10
  recordDir?: string;
11
11
  ciDir?: string;
12
+ usageDir?: string;
12
13
  };
13
14
  }
14
15
  }
@@ -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 = `# Recorded by slicetest from ${upstream}. Review before committing: request bodies are stored as sent.\n`;
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 };