@jterrazz/test 14.0.1 → 14.1.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 CHANGED
@@ -6,7 +6,7 @@ Declarative testing framework for APIs, jobs, CLIs, websites, and mobile apps. F
6
6
  npm install -D @jterrazz/test vitest
7
7
  ```
8
8
 
9
- Everything imports from `@jterrazz/test` — the one exception is the tool-facing `@jterrazz/test/oxlint` subpath (the zero-runtime lint plugin and its config fragment), which never loads any test runtime.
9
+ Everything a spec needs imports from `@jterrazz/test`. The exceptions are the two TOOL subpaths, which no spec ever imports: `@jterrazz/test/oxlint` (the zero-runtime lint plugin and its config fragment, loading no test runtime) and `@jterrazz/test/vitest` (what `vitest.config.ts` imports — the `defineSpecConfig()` preset and the `literate()` plugin).
10
10
 
11
11
  ## Quick start
12
12
 
@@ -172,7 +172,9 @@ Resolution: `options.mode` > `TEST_MODE` env var > `'node'`. Only `.api()` has a
172
172
 
173
173
  ```typescript
174
174
  // vitest.config.ts — the mode switch lives HERE
175
- export default defineConfig({
175
+ import { defineSpecConfig } from '@jterrazz/test/vitest';
176
+
177
+ export default defineSpecConfig({
176
178
  test: {
177
179
  projects: [
178
180
  { test: { name: 'http', include: ['specs/api/**/*.test.ts'] } },
@@ -262,6 +264,30 @@ The handle destructures to `{ mobile, cleanup, udid }` (plus `backendUrl` with `
262
264
 
263
265
  When `root` is absent, the framework walks up from the specification file to the **nearest** directory carrying `package.json` or `docker/compose.test.yaml`. In a workspace that is the member being tested, not the repository root above it. Pass `root` only when the convention does not fit. `root` is strictly the **project root** (compose detection + local-bin resolution, or the cwd of a `specification.website()` server command) — it is not a fixtures root; `.fixture()` resolves its own paths.
264
266
 
267
+ ## vitest config — `defineSpecConfig()`
268
+
269
+ `@jterrazz/test/vitest` is the subpath `vitest.config.ts` imports, never a spec. Beside `literate()` it exports the shared preset: what you pass is a plain vite/vitest config merged **over** its defaults, and `literate:` adds the plugin.
270
+
271
+ ```typescript
272
+ import { defineSpecConfig } from '@jterrazz/test/vitest';
273
+
274
+ export default defineSpecConfig({
275
+ literate: { specification: './specs/cli/cli.specification.ts' },
276
+ test: { include: ['specs/**/*.test.ts'] },
277
+ });
278
+ ```
279
+
280
+ | Sets | To |
281
+ | ---------------------------------- | ------------------------------------------------------ |
282
+ | `cacheDir` | `.artifacts/vitest` |
283
+ | `test.coverage.reportsDirectory` | `.artifacts/vitest/coverage` (bring your own provider) |
284
+ | `test.testTimeout` / `hookTimeout` | `30_000` |
285
+ | `test.exclude` | vitest's defaults **+** `**/_fixtures/**` |
286
+
287
+ Nothing else: `fileParallelism`, `reporters`, `environment` and every `include` stay yours. Inline `projects` inherit the same defaults (vitest gives a project nothing from the root), arrays are concatenated rather than replaced, and scalars you state win. Full walkthrough, including migrating a hand-rolled config: [docs/01-getting-started.md](docs/01-getting-started.md#vitest-config-the-preset).
288
+
289
+ Every artefact a run produces lands under `.artifacts/<tool>/` — one `.gitignore` line, one `rm -rf`. Per-run scratch (a CLI spec's temp cwd, a browser profile) stays in the OS temp dir.
290
+
265
291
  ## Builder API
266
292
 
267
293
  ### Setup (chainable)
@@ -412,10 +438,12 @@ test('deploy spawns a labelled container', async () => {
412
438
  | ------------ | -------------------------------- | ------------------------------------- |
413
439
  | `postgres()` | `composeService`, `image`, `env` | `postgresql://user:pass@host:port/db` |
414
440
  | `redis()` | `composeService`, `image` | `redis://host:port` |
415
- | `sqlite()` | `init` or `prismaSchema` | `file:/tmp/....sqlite` |
441
+ | `sqlite()` | `init` or `prismaSchema` | `file:/…/….sqlite` |
416
442
 
417
443
  `docker/compose.test.yaml` is the single source of truth for test infrastructure; `docker/<service>/init.sql` runs when the corresponding service starts. Parallel isolation is automatic per vitest worker: postgres clones a schema, redis assigns a database index, sqlite copies the template file, compose mode gets a dedicated project.
418
444
 
445
+ `sqlite()` caches its schema template inside the project — `.artifacts/vitest/sqlite/template-<key>.sqlite` — so two checkouts never share one, and workers racing for a cold cache wait for the one that is building rather than all building at once. Details: [docs/08-services.md](docs/08-services.md#where-the-template-lives).
446
+
419
447
  ## Mocking utilities
420
448
 
421
449
  ```typescript
@@ -1727,5 +1727,11 @@ async function runSpecDocument(options) {
1727
1727
  function specDescription(content, displayPath) {
1728
1728
  return readSpecFile(content, displayPath).document.description;
1729
1729
  }
1730
+ /** Everything a vitest run generates. */
1731
+ const VITEST_ARTIFACTS_DIR = `.artifacts/vitest`;
1732
+ /** Where the coverage provider writes its report. */
1733
+ const COVERAGE_DIR = `${VITEST_ARTIFACTS_DIR}/coverage`;
1734
+ /** Where `sqlite()` caches the schema template it copies per worker. */
1735
+ const SQLITE_TEMPLATE_DIR = `${VITEST_ARTIFACTS_DIR}/sqlite`;
1730
1736
  //#endregion
1731
- export { placeholderPatternSource as A, formatResponseDiff as C, hasPlaceholders as D, formatTableDiff as E, structuralEquals as M, structuralSubset as N, mergePreservingPlaceholders as O, textEquals as P, formatDirectoryDiff as S, formatStdoutDiff as T, getCallerDir as _, shouldUpdateSnapshots as a, diffDirectories as b, BaseResult as c, inspectContainer as d, removeContainers as f, text as g, TextAccessor as h, UPDATE_HINT as i, renderExpected as j, mergeTextPreservingPlaceholders as k, validateDatabaseOption as l, JsonAccessor as m, specDescription as n, ContainerAccessor as o, TableAccessor as p, ServeAdapter as r, CliResult as s, runSpecDocument as t, findContainersByLabel as u, FilesystemAccessor as v, formatStartupReport as w, walkDirectory as x, DirectoryAccessor as y };
1737
+ export { hasPlaceholders as A, diffDirectories as C, formatStartupReport as D, formatResponseDiff as E, structuralEquals as F, structuralSubset as I, textEquals as L, mergeTextPreservingPlaceholders as M, placeholderPatternSource as N, formatStdoutDiff as O, renderExpected as P, DirectoryAccessor as S, formatDirectoryDiff as T, JsonAccessor as _, specDescription as a, getCallerDir as b, shouldUpdateSnapshots as c, BaseResult as d, validateDatabaseOption as f, TableAccessor as g, removeContainers as h, runSpecDocument as i, mergePreservingPlaceholders as j, formatTableDiff as k, ContainerAccessor as l, inspectContainer as m, SQLITE_TEMPLATE_DIR as n, ServeAdapter as o, findContainersByLabel as p, VITEST_ARTIFACTS_DIR as r, UPDATE_HINT as s, COVERAGE_DIR as t, CliResult as u, TextAccessor as v, walkDirectory as w, FilesystemAccessor as x, text as y };
package/dist/index.d.ts CHANGED
@@ -876,8 +876,17 @@ interface ServiceHandle {
876
876
  createDatabaseAdapter: () => DatabasePort | null;
877
877
  /** Verify the service is ready and accepting connections. Throws with context if not. */
878
878
  healthcheck: () => Promise<void>;
879
- /** Run initialization scripts (e.g., init.sql). Throws with SQL error context if it fails. */
880
- initialize: (composeDir: string) => Promise<void>;
879
+ /**
880
+ * Run initialization scripts (e.g., init.sql). Throws with SQL error
881
+ * context if it fails.
882
+ *
883
+ * `composeDir` is where the compose file lives — where a service reads its
884
+ * `init.sql` from. `root` is the project root the specification resolved
885
+ * (A9), handed down so a service that CACHES something writes it under the
886
+ * project's own `.artifacts/`, never in a machine-global directory two
887
+ * checkouts would share.
888
+ */
889
+ initialize: (composeDir: string, root: string) => Promise<void>;
881
890
  /** Reset state between tests (truncate tables, flush cache, etc.) */
882
891
  reset: () => Promise<void>;
883
892
  /** Get the isolation strategy for parallel test execution. */
@@ -2230,7 +2239,31 @@ declare class SqliteHandle implements DatabasePort, ServiceHandle {
2230
2239
  buildConnectionString(): string;
2231
2240
  createDatabaseAdapter(): DatabasePort;
2232
2241
  healthcheck(): Promise<void>;
2233
- initialize(): Promise<void>;
2242
+ /**
2243
+ * Make the schema template available, building it if this worker is the one
2244
+ * that gets to.
2245
+ *
2246
+ * The template is cached under the PROJECT's `.artifacts/vitest/sqlite/`,
2247
+ * keyed by schema. `root` is the root the specification resolved (A9); a
2248
+ * handle initialised outside an orchestrator — a unit test holding one
2249
+ * directly — falls back to the same walk from the cwd.
2250
+ *
2251
+ * One worker builds, the others WAIT: the lock is taken with an exclusive
2252
+ * create, so exactly one wins, and a loser polls until the winner is done
2253
+ * rather than proceeding into a second concurrent `prisma db push` on the
2254
+ * same file (which is what `database is locked` was).
2255
+ */
2256
+ initialize(_composeDir?: string, root?: string): Promise<void>;
2257
+ /**
2258
+ * Build the template on a private path, then rename it into place.
2259
+ *
2260
+ * The rename is what makes a half-built template unobservable: it is atomic
2261
+ * within a filesystem, so a reader sees either the previous template or the
2262
+ * finished one. Building in place could not promise that — `prisma db push`
2263
+ * writes the SQLite header long before the tables exist, and a reader that
2264
+ * arrived in between accepted a valid-looking file with nothing in it.
2265
+ */
2266
+ private buildTemplate;
2234
2267
  private getDb;
2235
2268
  private closeDb;
2236
2269
  seed(sql: string): Promise<void>;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as copyPlan, c as GROUND_EXPECTED, d as GROUND_SEEDS, i as match, n as Matcher, t as CaptureScope, u as GROUND_REQUESTS } from "./match.js";
2
- import { C as formatResponseDiff, E as formatTableDiff, M as structuralEquals, N as structuralSubset, O as mergePreservingPlaceholders, P as textEquals, S as formatDirectoryDiff, T as formatStdoutDiff, _ as getCallerDir, a as shouldUpdateSnapshots, b as diffDirectories, c as BaseResult, d as inspectContainer, f as removeContainers, g as text, h as TextAccessor, i as UPDATE_HINT, j as renderExpected, k as mergeTextPreservingPlaceholders, l as validateDatabaseOption, m as JsonAccessor, o as ContainerAccessor, p as TableAccessor, r as ServeAdapter, s as CliResult, t as runSpecDocument, u as findContainersByLabel, v as FilesystemAccessor, w as formatStartupReport, x as walkDirectory, y as DirectoryAccessor } from "./literate.js";
2
+ import { C as diffDirectories, D as formatStartupReport, E as formatResponseDiff, F as structuralEquals, I as structuralSubset, L as textEquals, M as mergeTextPreservingPlaceholders, O as formatStdoutDiff, P as renderExpected, S as DirectoryAccessor, T as formatDirectoryDiff, _ as JsonAccessor, b as getCallerDir, c as shouldUpdateSnapshots, d as BaseResult, f as validateDatabaseOption, g as TableAccessor, h as removeContainers, i as runSpecDocument, j as mergePreservingPlaceholders, k as formatTableDiff, l as ContainerAccessor, m as inspectContainer, n as SQLITE_TEMPLATE_DIR, o as ServeAdapter, p as findContainersByLabel, s as UPDATE_HINT, u as CliResult, v as TextAccessor, w as walkDirectory, x as FilesystemAccessor, y as text } from "./artifacts.js";
3
3
  import { a as isContract, i as defineContracts, n as contractsOf, o as isContracts, r as defineContract, t as ContractQueue } from "./queue.js";
4
- import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
5
5
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { parse } from "yaml";
7
7
  import { execFile, execSync, spawn, spawnSync } from "node:child_process";
@@ -1726,7 +1726,7 @@ var Orchestrator = class {
1726
1726
  });
1727
1727
  }
1728
1728
  await Promise.all([...containerServices.map(({ container }) => container.start()), ...embeddedServices.map(async (handle) => {
1729
- await handle.initialize(composeDir);
1729
+ await handle.initialize(composeDir, this.root);
1730
1730
  handle.started = true;
1731
1731
  this.running.push({
1732
1732
  handle,
@@ -1741,7 +1741,7 @@ var Orchestrator = class {
1741
1741
  const port = container.getMappedPort(handle.defaultPort);
1742
1742
  handle.connectionString = handle.buildConnectionString(host, port);
1743
1743
  await handle.healthcheck();
1744
- await handle.initialize(composeDir);
1744
+ await handle.initialize(composeDir, this.root);
1745
1745
  handle.started = true;
1746
1746
  reports.push({
1747
1747
  name: handle.composeName ?? handle.type,
@@ -1801,7 +1801,7 @@ var Orchestrator = class {
1801
1801
  const declaredComposeNames = /* @__PURE__ */ new Set();
1802
1802
  for (const handle of Object.values(this.services)) {
1803
1803
  if (handle.defaultPort === 0) {
1804
- await handle.initialize(composeDir);
1804
+ await handle.initialize(composeDir, this.root);
1805
1805
  handle.started = true;
1806
1806
  continue;
1807
1807
  }
@@ -1812,7 +1812,7 @@ var Orchestrator = class {
1812
1812
  const port = this.composeStack.getMappedPort(handle.composeName, handle.defaultPort);
1813
1813
  handle.connectionString = handle.buildConnectionString("localhost", port);
1814
1814
  await handle.healthcheck();
1815
- await handle.initialize(composeDir);
1815
+ await handle.initialize(composeDir, this.root);
1816
1816
  handle.started = true;
1817
1817
  }
1818
1818
  for (const service of composeConfig.infraServices) {
@@ -1822,7 +1822,7 @@ var Orchestrator = class {
1822
1822
  const handle = factory(service);
1823
1823
  const port = this.composeStack.getMappedPort(service.name, handle.defaultPort);
1824
1824
  handle.connectionString = handle.buildConnectionString("localhost", port);
1825
- await handle.initialize(composeDir);
1825
+ await handle.initialize(composeDir, this.root);
1826
1826
  handle.started = true;
1827
1827
  this.composeHandles.push(handle);
1828
1828
  }
@@ -2860,7 +2860,16 @@ function prismaPushCommand(prismaSchema) {
2860
2860
  return `${PRISMA_PUSH} --schema ${JSON.stringify(resolve(prismaSchema))}`;
2861
2861
  }
2862
2862
  /** Every template file this package writes carries that prefix, then its key. */
2863
- const TEMPLATE_PREFIX = "jterrazz-test-sqlite-template";
2863
+ const TEMPLATE_PREFIX = "template";
2864
+ /**
2865
+ * How long a waiter tolerates a lock nobody is touching before deciding its
2866
+ * holder died mid-build and taking over. A cold `prisma db push` is the slowest
2867
+ * build there is and finishes far inside this; a crashed holder would otherwise
2868
+ * wedge the suite forever.
2869
+ */
2870
+ const LOCK_STALE_MS = 12e4;
2871
+ /** How often a waiter re-checks the lock. */
2872
+ const LOCK_POLL_MS = 50;
2864
2873
  /**
2865
2874
  * The schema source as bytes: a file's content, or every file under a
2866
2875
  * directory in a stable order (a Prisma `schema/` folder is a directory). Each
@@ -2880,12 +2889,12 @@ function schemaBytes(path) {
2880
2889
  * The name of the template database for a set of options — `<prefix>-<sha8>`,
2881
2890
  * the digest taken over the schema KIND, its resolved path and its content.
2882
2891
  *
2883
- * The template lives in the machine-global OS tmpdir, so the name is the only
2884
- * thing keeping two projects apart. A single fixed name meant the first project
2885
- * to run on a machine built the template and every other project that day
2886
- * silently inherited its tables — the file is header-valid, so the staleness
2887
- * check accepts it and the schema never gets built. Keying on the schema makes
2888
- * reuse mean "the same schema", which is what the cache was ever for.
2892
+ * The key survives the move out of the OS tmpdir. Within one project it is what
2893
+ * makes reuse mean "the same schema": editing a schema builds a new template
2894
+ * instead of inheriting the old one — the stale file is header-valid, so the
2895
+ * validity check would accept it and the tables would never be rebuilt. It also
2896
+ * keeps two checkouts of the SAME project apart when a schema differs between
2897
+ * them, on top of the separation the per-project folder already gives.
2889
2898
  */
2890
2899
  function sqliteTemplateName(options = {}) {
2891
2900
  let fingerprint;
@@ -2899,6 +2908,76 @@ function sqliteTemplateName(options = {}) {
2899
2908
  const key = createHash("sha256").update(fingerprint).digest("hex").slice(0, 8);
2900
2909
  return `${TEMPLATE_PREFIX}-${key}.sqlite`;
2901
2910
  }
2911
+ /**
2912
+ * The directory a project caches its schema templates in —
2913
+ * `<root>/.artifacts/vitest/sqlite/`, created on demand.
2914
+ *
2915
+ * A project, not a machine. The template used to live in the OS tmpdir, where
2916
+ * two checkouts of the same repository shared one file: whichever ran first
2917
+ * built it, the other silently inherited that schema, and a branch that changed
2918
+ * the schema poisoned the branch beside it. A path under the project root
2919
+ * cannot be reached from another checkout at all.
2920
+ */
2921
+ function sqliteTemplateDir(root) {
2922
+ const dir = resolve(root, SQLITE_TEMPLATE_DIR);
2923
+ mkdirSync(dir, { recursive: true });
2924
+ return dir;
2925
+ }
2926
+ /**
2927
+ * Take the build lock, or report that someone else holds it.
2928
+ *
2929
+ * `wx` is the whole point: the file is created and claimed in ONE syscall, so
2930
+ * two workers cannot both come out of it believing they won. The former
2931
+ * check-then-write pair could, and both then ran `prisma db push` against the
2932
+ * same file — the `database is locked` failure a consumer keeps
2933
+ * `fileParallelism: false` for.
2934
+ *
2935
+ * @internal Exported for unit tests.
2936
+ */
2937
+ function acquireTemplateLock(lockPath) {
2938
+ let fd;
2939
+ try {
2940
+ fd = openSync(lockPath, "wx");
2941
+ } catch (error) {
2942
+ if (error.code === "EEXIST") return false;
2943
+ throw error;
2944
+ }
2945
+ try {
2946
+ writeSync(fd, process.pid.toString());
2947
+ } finally {
2948
+ closeSync(fd);
2949
+ }
2950
+ return true;
2951
+ }
2952
+ /** Drop the build lock. A lock already gone is not an error — the goal is met. */
2953
+ function releaseTemplateLock(lockPath) {
2954
+ try {
2955
+ unlinkSync(lockPath);
2956
+ } catch {}
2957
+ }
2958
+ /**
2959
+ * Whether a lock has gone untouched long enough that its holder must be dead.
2960
+ * A lock that no longer exists is not stale — the waiter simply retries.
2961
+ *
2962
+ * @internal Exported for unit tests.
2963
+ */
2964
+ function isStaleTemplateLock(lockPath, now = Date.now()) {
2965
+ try {
2966
+ return now - statSync(lockPath).mtimeMs > LOCK_STALE_MS;
2967
+ } catch {
2968
+ return false;
2969
+ }
2970
+ }
2971
+ /** Remove a file and the WAL sidecars SQLite may have left beside it. */
2972
+ function removeDatabaseFiles(path) {
2973
+ for (const suffix of [
2974
+ "",
2975
+ "-wal",
2976
+ "-shm"
2977
+ ]) try {
2978
+ unlinkSync(`${path}${suffix}`);
2979
+ } catch {}
2980
+ }
2902
2981
  var SqliteHandle = class {
2903
2982
  type = "sqlite";
2904
2983
  composeName = null;
@@ -2925,50 +3004,78 @@ var SqliteHandle = class {
2925
3004
  return this;
2926
3005
  }
2927
3006
  async healthcheck() {}
2928
- async initialize() {
2929
- this.templatePath = resolve(tmpdir(), sqliteTemplateName(this.options));
3007
+ /**
3008
+ * Make the schema template available, building it if this worker is the one
3009
+ * that gets to.
3010
+ *
3011
+ * The template is cached under the PROJECT's `.artifacts/vitest/sqlite/`,
3012
+ * keyed by schema. `root` is the root the specification resolved (A9); a
3013
+ * handle initialised outside an orchestrator — a unit test holding one
3014
+ * directly — falls back to the same walk from the cwd.
3015
+ *
3016
+ * One worker builds, the others WAIT: the lock is taken with an exclusive
3017
+ * create, so exactly one wins, and a loser polls until the winner is done
3018
+ * rather than proceeding into a second concurrent `prisma db push` on the
3019
+ * same file (which is what `database is locked` was).
3020
+ */
3021
+ async initialize(_composeDir, root) {
3022
+ const projectRoot = root ?? discoverRoot(process.cwd());
3023
+ this.templatePath = resolve(sqliteTemplateDir(projectRoot), sqliteTemplateName(this.options));
2930
3024
  const lockPath = `${this.templatePath}.lock`;
2931
- if (existsSync(lockPath)) {
2932
- const start = Date.now();
2933
- while (existsSync(lockPath) && Date.now() - start < 3e4) await new Promise((r) => setTimeout(r, 100));
2934
- }
2935
- if (existsSync(this.templatePath)) {
2936
- if (isValidSqliteTemplate(this.templatePath)) {
2937
- this.connectionString = `file:${this.templatePath}`;
2938
- this.started = true;
2939
- return;
3025
+ for (;;) {
3026
+ if (isValidSqliteTemplate(this.templatePath)) break;
3027
+ if (!acquireTemplateLock(lockPath)) {
3028
+ if (isStaleTemplateLock(lockPath)) releaseTemplateLock(lockPath);
3029
+ else await new Promise((wake) => setTimeout(wake, LOCK_POLL_MS));
3030
+ continue;
2940
3031
  }
2941
3032
  try {
2942
- unlinkSync(this.templatePath);
2943
- } catch {}
3033
+ await this.buildTemplate();
3034
+ } finally {
3035
+ releaseTemplateLock(lockPath);
3036
+ }
3037
+ break;
2944
3038
  }
2945
- const { writeFileSync } = await import("node:fs");
2946
- writeFileSync(lockPath, process.pid.toString());
2947
- if (this.prismaSchema) {
2948
- const { execSync } = await import("node:child_process");
2949
- execSync(prismaPushCommand(this.prismaSchema), {
2950
- env: {
2951
- ...process.env,
2952
- DATABASE_URL: `file:${this.templatePath}`,
2953
- PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: "yes"
2954
- },
2955
- stdio: "pipe"
2956
- });
2957
- const tmpDb = new Database(this.templatePath);
2958
- tmpDb.pragma("wal_checkpoint(TRUNCATE)");
2959
- tmpDb.close();
2960
- } else if (this.initSql) {
2961
- const sql = readFileSync(this.initSql, "utf8");
2962
- const templateDb = new Database(this.templatePath);
2963
- templateDb.exec(sql);
2964
- templateDb.close();
2965
- } else new Database(this.templatePath).close();
2966
- try {
2967
- unlinkSync(lockPath);
2968
- } catch {}
2969
3039
  this.connectionString = `file:${this.templatePath}`;
2970
3040
  this.started = true;
2971
3041
  }
3042
+ /**
3043
+ * Build the template on a private path, then rename it into place.
3044
+ *
3045
+ * The rename is what makes a half-built template unobservable: it is atomic
3046
+ * within a filesystem, so a reader sees either the previous template or the
3047
+ * finished one. Building in place could not promise that — `prisma db push`
3048
+ * writes the SQLite header long before the tables exist, and a reader that
3049
+ * arrived in between accepted a valid-looking file with nothing in it.
3050
+ */
3051
+ async buildTemplate() {
3052
+ const buildPath = `${this.templatePath}.${process.pid}.building`;
3053
+ removeDatabaseFiles(buildPath);
3054
+ try {
3055
+ if (this.prismaSchema) {
3056
+ const { execSync } = await import("node:child_process");
3057
+ execSync(prismaPushCommand(this.prismaSchema), {
3058
+ env: {
3059
+ ...process.env,
3060
+ DATABASE_URL: `file:${buildPath}`,
3061
+ PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: "yes"
3062
+ },
3063
+ stdio: "pipe"
3064
+ });
3065
+ const tmpDb = new Database(buildPath);
3066
+ tmpDb.pragma("wal_checkpoint(TRUNCATE)");
3067
+ tmpDb.close();
3068
+ } else if (this.initSql) {
3069
+ const sql = readFileSync(this.initSql, "utf8");
3070
+ const templateDb = new Database(buildPath);
3071
+ templateDb.exec(sql);
3072
+ templateDb.close();
3073
+ } else new Database(buildPath).close();
3074
+ renameSync(buildPath, this.templatePath);
3075
+ } finally {
3076
+ removeDatabaseFiles(buildPath);
3077
+ }
3078
+ }
2972
3079
  getDb() {
2973
3080
  const dbPath = this.workerDbPath || this.templatePath;
2974
3081
  if (!this.db) {
@@ -2999,7 +3106,7 @@ var SqliteHandle = class {
2999
3106
  return {
3000
3107
  acquire: async (workerId) => {
3001
3108
  this.closeDb();
3002
- this.workerDbPath = resolve(tmpdir(), `test-worker-${workerId}-${Date.now()}.sqlite`);
3109
+ this.workerDbPath = resolve(tmpdir(), `test-worker-${workerId}-${process.pid}-${Date.now()}.sqlite`);
3003
3110
  copyFileSync(this.templatePath, this.workerDbPath);
3004
3111
  this.connectionString = `file:${this.workerDbPath}`;
3005
3112
  },
package/dist/queue.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as CaptureScope } from "./match.js";
2
- import { A as placeholderPatternSource, D as hasPlaceholders, P as textEquals } from "./literate.js";
2
+ import { A as hasPlaceholders, L as textEquals, N as placeholderPatternSource } from "./artifacts.js";
3
3
  //#region src/core/contracts/contract.ts
4
4
  /**
5
5
  * Declare one contract. Identity function — its value is the enforced shape
package/dist/vitest.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ViteUserConfig } from "vitest/config";
1
2
  //#region src/vitest/literate-plugin.d.ts
2
3
  interface LiterateOptions {
3
4
  /**
@@ -38,4 +39,22 @@ interface LiteratePlugin {
38
39
  }
39
40
  declare function literate(options: LiterateOptions): LiteratePlugin;
40
41
  //#endregion
41
- export { type LiterateOptions, type LiteratePlugin, literate };
42
+ //#region src/vitest/preset.d.ts
43
+ /**
44
+ * Options are a vite/vitest config plus one key of the framework's own.
45
+ * Everything stated here wins over the preset's defaults.
46
+ */
47
+ interface SpecConfig extends ViteUserConfig {
48
+ /**
49
+ * Turns every matching `<case>.spec.yaml` into a test file bound to the
50
+ * named specification — the {@link literate} plugin, added to `plugins`.
51
+ *
52
+ * Declare it here for a config with no `projects`. With projects, the glob
53
+ * has to join the include of the ONE project that collects those documents,
54
+ * so the plugin goes in that project's own `plugins` instead.
55
+ */
56
+ literate?: LiterateOptions;
57
+ }
58
+ declare function defineSpecConfig(config?: SpecConfig): ViteUserConfig;
59
+ //#endregion
60
+ export { type LiterateOptions, type LiteratePlugin, type SpecConfig, defineSpecConfig, literate };
package/dist/vitest.js CHANGED
@@ -1,6 +1,7 @@
1
- import { n as specDescription } from "./literate.js";
1
+ import { a as specDescription, r as VITEST_ARTIFACTS_DIR, t as COVERAGE_DIR } from "./artifacts.js";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { isAbsolute, resolve } from "node:path";
4
+ import { configDefaults, mergeConfig } from "vitest/config";
4
5
  //#region src/vitest/literate-plugin.ts
5
6
  /**
6
7
  * The `literate()` vite plugin — door one of the spec-document format.
@@ -66,4 +67,101 @@ function literate(options) {
66
67
  };
67
68
  }
68
69
  //#endregion
69
- export { literate };
70
+ //#region src/vitest/preset.ts
71
+ /**
72
+ * `defineSpecConfig()` — the shared vitest config every repository of the
73
+ * ecosystem starts from.
74
+ *
75
+ * Fourteen hand-rolled `vitest.config.ts` files had converged on the same
76
+ * handful of settings and disagreed on the rest by accident; the preset is
77
+ * their common ground, stated once. A consumer writes ONE call and keeps
78
+ * everything vitest offers: what it passes is a plain vite/vitest config,
79
+ * merged OVER the defaults, so any of them is overridable in place.
80
+ *
81
+ * ```typescript
82
+ * // vitest.config.ts
83
+ * import { defineSpecConfig } from '@jterrazz/test/vitest';
84
+ *
85
+ * export default defineSpecConfig({
86
+ * literate: { specification: './specs/cli/cli.specification.ts' },
87
+ * test: {
88
+ * projects: [
89
+ * { test: { include: ['src/**\/*.test.ts'], name: 'unit' } },
90
+ * { test: { include: ['specs/api/**\/*.test.ts'], name: 'api' } },
91
+ * ],
92
+ * },
93
+ * });
94
+ * ```
95
+ */
96
+ /**
97
+ * A run's budget. Vitest's own 5s test / 10s hook defaults were raised by every
98
+ * repository that starts real infrastructure, and none of them meant 5s — a
99
+ * container boot, a `prisma db push` or a `next build` all outlive it. 30s is
100
+ * the value the ecosystem's configs already converged on; a suite that needs
101
+ * more (a simulator cold boot, a real image build) states its own per project.
102
+ */
103
+ const TEST_TIMEOUT_MS = 3e4;
104
+ const HOOK_TIMEOUT_MS = 3e4;
105
+ /**
106
+ * What a spec STANDS ON is never itself a test: `_fixtures/` holds inputs —
107
+ * deliberately-wrong twins, fixture projects a spec lints — and collecting them
108
+ * runs a repository's own counter-examples as if they were its suite. Every
109
+ * config that ever hit it added the same line by hand.
110
+ *
111
+ * Merged, not replaced: vite concatenates arrays, so a consumer's `exclude`
112
+ * ADDS to this list and never has to spread `configDefaults.exclude` again.
113
+ */
114
+ const EXCLUDE = [...configDefaults.exclude, "**/_fixtures/**"];
115
+ /**
116
+ * Defaults a vitest PROJECT accepts. `coverage` and `reporters` are root-only
117
+ * options (vitest omits them from a project's config), so they are absent here
118
+ * on purpose — setting them would be silently dropped.
119
+ */
120
+ function projectDefaults() {
121
+ return {
122
+ cacheDir: VITEST_ARTIFACTS_DIR,
123
+ test: {
124
+ exclude: EXCLUDE,
125
+ hookTimeout: HOOK_TIMEOUT_MS,
126
+ testTimeout: TEST_TIMEOUT_MS
127
+ }
128
+ };
129
+ }
130
+ /** Defaults for the root config — the project ones, plus what only a root carries. */
131
+ function rootDefaults() {
132
+ return {
133
+ cacheDir: VITEST_ARTIFACTS_DIR,
134
+ test: {
135
+ coverage: { reportsDirectory: COVERAGE_DIR },
136
+ exclude: EXCLUDE,
137
+ hookTimeout: HOOK_TIMEOUT_MS,
138
+ testTimeout: TEST_TIMEOUT_MS
139
+ }
140
+ };
141
+ }
142
+ /**
143
+ * A project inherits NOTHING from the root `test` block — vitest resolves each
144
+ * project as its own config — so the defaults are merged into every inline one.
145
+ * A project declared as a glob string, a promise or a function is handed back
146
+ * untouched: there is no object to merge into.
147
+ */
148
+ function withProjectDefaults(project) {
149
+ if (typeof project !== "object" || project === null || "then" in project) return project;
150
+ return mergeConfig(projectDefaults(), project);
151
+ }
152
+ function defineSpecConfig(config = {}) {
153
+ const { literate: literateOptions, ...userConfig } = config;
154
+ const stated = literateOptions ? mergeConfig({ plugins: [literate(literateOptions)] }, userConfig) : userConfig;
155
+ const merged = mergeConfig(rootDefaults(), stated);
156
+ const projects = merged.test?.projects;
157
+ if (!projects) return merged;
158
+ return {
159
+ ...merged,
160
+ test: {
161
+ ...merged.test,
162
+ projects: projects.map(withProjectDefaults)
163
+ }
164
+ };
165
+ }
166
+ //#endregion
167
+ export { defineSpecConfig, literate };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "14.0.1",
3
+ "version": "14.1.0",
4
4
  "description": "Declarative testing framework for HTTP APIs, CLIs, and background jobs — one entry point, real infrastructure, golden-file assertions, plus an oxlint convention plugin.",
5
5
  "keywords": [
6
6
  "api-testing",