@jterrazz/test 14.0.1 → 14.2.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
@@ -179,12 +179,12 @@ function projectScreen(xml) {
179
179
  }
180
180
  //#endregion
181
181
  //#region src/integrations/appium/appium.adapter.ts
182
- const ACTION_TIMEOUT_MS = 3e4;
182
+ const DEFAULT_ACTION_TIMEOUT_MS = 3e4;
183
183
  const POLL_INTERVAL_MS = 500;
184
184
  /** How many candidates the ambiguity error enumerates before truncating. */
185
185
  const MAX_REPORTED_MATCHES = 10;
186
186
  /** WebDriverAgent builds once per simulator — the first session is the slow one. */
187
- const WDA_LAUNCH_TIMEOUT_MS = 24e4;
187
+ const DEFAULT_WDA_LAUNCH_TIMEOUT_MS = 24e4;
188
188
  const delay = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
189
189
  /** Escape a name for an NSPredicate single-quoted string literal. */
190
190
  function escapePredicate(value) {
@@ -213,6 +213,19 @@ function scopeChain(element) {
213
213
  for (let current = element; current; current = current.scope) chain.unshift(current);
214
214
  return chain;
215
215
  }
216
+ /**
217
+ * Load webdriverio's session factory, naming the install when the optional
218
+ * peer dependency is absent. Lazy: the dependency is only required by a spec
219
+ * that actually opens the app.
220
+ */
221
+ async function loadRemote() {
222
+ try {
223
+ const { remote: remoteFn } = await import("webdriverio");
224
+ return remoteFn;
225
+ } catch {
226
+ throw new Error(".open() requires webdriverio (optional peer dependency): npm install -D appium webdriverio && npx appium driver install xcuitest");
227
+ }
228
+ }
216
229
  /** Capture one candidate's evidence for the ambiguity refusal. */
217
230
  async function captureMatch(element) {
218
231
  const [type, label, value, identifier] = await Promise.all([
@@ -241,10 +254,27 @@ async function captureMatch(element) {
241
254
  * this module is only loaded when a spec calls `.open()`.
242
255
  */
243
256
  var AppiumAdapter = class {
257
+ /** The verb poll budget — the runner's `timeouts.action`, or the default. */
258
+ actionTimeoutMs;
244
259
  driver = null;
260
+ /** The WebDriverAgent launch budget — the runner's `timeouts.launch`, or the default. */
261
+ launchTimeoutMs;
245
262
  options;
263
+ /**
264
+ * The session factory. Absent in production — webdriverio is then loaded
265
+ * lazily on the first `open()`. Injected by this module's own unit test,
266
+ * which drives the waits over a stub driver with no simulator in reach
267
+ * (mocks are CODE here, CONVENTIONS I4).
268
+ */
269
+ remote;
246
270
  constructor(options) {
247
- this.options = options;
271
+ this.options = {
272
+ serverUrl: options.serverUrl,
273
+ udid: options.udid
274
+ };
275
+ this.remote = options.remote;
276
+ this.actionTimeoutMs = options.timeouts?.action ?? DEFAULT_ACTION_TIMEOUT_MS;
277
+ this.launchTimeoutMs = options.timeouts?.launch ?? DEFAULT_WDA_LAUNCH_TIMEOUT_MS;
248
278
  }
249
279
  async close() {
250
280
  if (this.driver) {
@@ -305,7 +335,7 @@ var AppiumAdapter = class {
305
335
  */
306
336
  async resolveOne(driver, element, verb, cardinality) {
307
337
  const chain = scopeChain(element);
308
- const deadline = Date.now() + ACTION_TIMEOUT_MS;
338
+ const deadline = Date.now() + this.actionTimeoutMs;
309
339
  let scrollAttempted = false;
310
340
  for (;;) {
311
341
  const resolved = await this.resolveChain(driver, chain, cardinality, { tryScroll: !scrollAttempted });
@@ -367,17 +397,12 @@ var AppiumAdapter = class {
367
397
  const labels = [...new Set([...source.matchAll(/label="(?<label>[^"]{2,60})"/g)].map((match) => match.groups?.["label"] ?? ""))].slice(0, 15);
368
398
  if (labels.length > 0) excerpt = `\nCurrently on screen: ${labels.join(" | ")}`;
369
399
  } catch {}
370
- return /* @__PURE__ */ new Error(`${verb}(${formatElement(element)}) timed out after ${ACTION_TIMEOUT_MS}ms — no visible match.${excerpt}`);
400
+ return /* @__PURE__ */ new Error(`${verb}(${formatElement(element)}) timed out after ${this.actionTimeoutMs}ms — no visible match.${excerpt}`);
371
401
  }
372
402
  /** Create the shared driver session (once), with an actionable error when webdriverio is absent. */
373
403
  async session(bundleId) {
374
404
  if (this.driver) return this.driver;
375
- let remoteFn;
376
- try {
377
- ({remote: remoteFn} = await import("webdriverio"));
378
- } catch {
379
- throw new Error(".open() requires webdriverio (optional peer dependency): npm install -D appium webdriverio && npx appium driver install xcuitest");
380
- }
405
+ const remoteFn = this.remote ?? await loadRemote();
381
406
  const url = new URL(this.options.serverUrl);
382
407
  this.driver = await remoteFn({
383
408
  capabilities: {
@@ -385,7 +410,7 @@ var AppiumAdapter = class {
385
410
  "appium:bundleId": bundleId,
386
411
  "appium:noReset": true,
387
412
  "appium:udid": this.options.udid,
388
- "appium:wdaLaunchTimeout": WDA_LAUNCH_TIMEOUT_MS,
413
+ "appium:wdaLaunchTimeout": this.launchTimeoutMs,
389
414
  platformName: "iOS"
390
415
  },
391
416
  hostname: url.hostname,
@@ -932,6 +932,95 @@ function removeContainers(ids) {
932
932
  } catch {}
933
933
  }
934
934
  //#endregion
935
+ //#region src/core/specification/shared/resolve.ts
936
+ /** What makes a directory a project root — a manifest, or a test stack. */
937
+ const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
938
+ /**
939
+ * The NEAREST ancestor of `startDir` (itself included) carrying a root marker,
940
+ * `undefined` when none does.
941
+ *
942
+ * Both markers are probed at each ancestor, in ONE walk. Two walks — the
943
+ * compose file all the way up, then `package.json` all the way up — made the
944
+ * further directory win: in a workspace, a `docker/compose.test.yaml` at the
945
+ * repository root outranked the `package.json` of the very package being
946
+ * tested, and every path the runner resolved was measured from the wrong unit.
947
+ * The unit is the nearest thing that declares itself a project.
948
+ *
949
+ * The existence probe is injected so the lint layer can pass its cached one
950
+ * (A9's `a9w-redundant-root` derives the same root, and must derive it the
951
+ * same way).
952
+ */
953
+ function findRoot(startDir, exists) {
954
+ let dir = startDir;
955
+ for (;;) {
956
+ if (ROOT_MARKERS.some((marker) => exists(resolve(dir, marker)))) return dir;
957
+ const parent = dirname(dir);
958
+ if (parent === dir) return;
959
+ dir = parent;
960
+ }
961
+ }
962
+ /**
963
+ * Auto-discover the project root from a starting directory (CONVENTIONS A9):
964
+ * walk up to the first directory that carries `package.json` or
965
+ * `docker/compose.test.yaml`; if none does, the starting directory itself.
966
+ */
967
+ function discoverRoot(startDir) {
968
+ return findRoot(startDir, existsSync) ?? startDir;
969
+ }
970
+ /**
971
+ * Resolve the project root for a specification. An explicit `root` option is
972
+ * an override (resolved from the caller's directory when relative); when
973
+ * absent, the root is auto-discovered by walking up from the calling
974
+ * specification file.
975
+ */
976
+ function resolveRoot(root, callerDir) {
977
+ if (!root) return discoverRoot(callerDir);
978
+ if (isAbsolute(root)) return root;
979
+ return resolve(callerDir, root);
980
+ }
981
+ /**
982
+ * Resolve a command — checks node_modules/.bin, then treats as absolute/PATH.
983
+ */
984
+ function resolveCommand(command, root) {
985
+ if (isAbsolute(command)) return command;
986
+ const binPath = resolve(root, "node_modules/.bin", command);
987
+ if (existsSync(binPath)) return binPath;
988
+ const cwdBinPath = resolve(process.cwd(), "node_modules/.bin", command);
989
+ if (existsSync(cwdBinPath)) return cwdBinPath;
990
+ return command;
991
+ }
992
+ /**
993
+ * The realpath form of a working directory — what a child process prints as
994
+ * `$PWD` (macOS resolves the tmpdir symlink), and therefore what `{{workdir}}`
995
+ * compares against. Falls back to the path itself when it cannot be resolved.
996
+ *
997
+ * @internal Shared by the result scope, the literate runner and `$WORKDIR`.
998
+ */
999
+ function safeRealpath(path) {
1000
+ try {
1001
+ return realpathSync(path);
1002
+ } catch {
1003
+ return path;
1004
+ }
1005
+ }
1006
+ /**
1007
+ * Expand the `$WORKDIR` token in every string value of a child environment —
1008
+ * the one place the token is spelled out, for both doors (`.env()` on the
1009
+ * chain, `env:` in a `<case>.spec.yaml`).
1010
+ *
1011
+ * It expands to the RESOLVED directory, never the raw temp path: a value the
1012
+ * child receives comes back in its output, and it must come back spelled the
1013
+ * way `{{workdir}}` holds it (realpath form, CONVENTIONS D4). Expanding the raw
1014
+ * path made `HOME=$WORKDIR` on macOS echo `/var/folders/…` against a
1015
+ * `{{workdir}}` holding `/private/var/folders/…` — a golden no run could match.
1016
+ */
1017
+ function expandWorkdir(env, workDir) {
1018
+ const workdir = safeRealpath(workDir);
1019
+ const expanded = {};
1020
+ for (const [key, value] of Object.entries(env)) expanded[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workdir) : value;
1021
+ return expanded;
1022
+ }
1023
+ //#endregion
935
1024
  //#region src/core/specification/shared/result/result.ts
936
1025
  /**
937
1026
  * Base result - common accessors available after any action type.
@@ -986,20 +1075,6 @@ var BaseResult = class {
986
1075
  }
987
1076
  };
988
1077
  /**
989
- * The realpath form of a working directory — what a child process prints as
990
- * `$PWD` (macOS resolves the tmpdir symlink), and therefore what `{{workdir}}`
991
- * compares against. Falls back to the path itself when it cannot be resolved.
992
- *
993
- * @internal Shared with the literate runner, which builds its own file-wide scope.
994
- */
995
- function safeRealpath(path) {
996
- try {
997
- return realpathSync(path);
998
- } catch {
999
- return path;
1000
- }
1001
- }
1002
- /**
1003
1078
  * Enforce CONVENTIONS A7: with two or more databases in the services record
1004
1079
  * the `database` option is mandatory on `.seed()` / `.table()`; with exactly
1005
1080
  * one it is forbidden (redundant).
@@ -1650,11 +1725,6 @@ function documentEnv(document, config, displayPath) {
1650
1725
  }
1651
1726
  return env;
1652
1727
  }
1653
- function expandWorkdir(env, workDir) {
1654
- const resolved = {};
1655
- for (const [key, value] of Object.entries(env)) resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
1656
- return resolved;
1657
- }
1658
1728
  /**
1659
1729
  * Run one spec document end to end and resolve with the LAST run's result —
1660
1730
  * the handle a `.test.ts` adds its own assertion to (a directory golden, a
@@ -1727,5 +1797,11 @@ async function runSpecDocument(options) {
1727
1797
  function specDescription(content, displayPath) {
1728
1798
  return readSpecFile(content, displayPath).document.description;
1729
1799
  }
1800
+ /** Everything a vitest run generates. */
1801
+ const VITEST_ARTIFACTS_DIR = `.artifacts/vitest`;
1802
+ /** Where the coverage provider writes its report. */
1803
+ const COVERAGE_DIR = `${VITEST_ARTIFACTS_DIR}/coverage`;
1804
+ /** Where `sqlite()` caches the schema template it copies per worker. */
1805
+ const SQLITE_TEMPLATE_DIR = `${VITEST_ARTIFACTS_DIR}/sqlite`;
1730
1806
  //#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 };
1807
+ export { formatResponseDiff as A, structuralSubset as B, text as C, diffDirectories as D, DirectoryAccessor as E, mergePreservingPlaceholders as F, mergeTextPreservingPlaceholders as I, placeholderPatternSource as L, formatStdoutDiff as M, formatTableDiff as N, walkDirectory as O, hasPlaceholders as P, renderExpected as R, TextAccessor as S, FilesystemAccessor as T, textEquals as V, findContainersByLabel as _, specDescription as a, TableAccessor as b, shouldUpdateSnapshots as c, BaseResult as d, validateDatabaseOption as f, resolveRoot as g, resolveCommand as h, runSpecDocument as i, formatStartupReport as j, formatDirectoryDiff as k, ContainerAccessor as l, expandWorkdir as m, SQLITE_TEMPLATE_DIR as n, ServeAdapter as o, discoverRoot as p, VITEST_ARTIFACTS_DIR as r, UPDATE_HINT as s, COVERAGE_DIR as t, CliResult as u, inspectContainer as v, getCallerDir as w, JsonAccessor as x, removeContainers as y, structuralEquals as z };
package/dist/index.d.ts CHANGED
@@ -798,6 +798,24 @@ interface DeviceOpenOptions {
798
798
  /** The interaction scenario to run after launch; the capture reflects the final state. */
799
799
  scenario?: MobileScenario;
800
800
  }
801
+ /**
802
+ * How long a device session waits, in milliseconds. Every field is optional
803
+ * and falls back to the framework's own default, so a runner states only the
804
+ * wait it needs to move — the defaults suit a release build, and a project
805
+ * that drives a DEV build pays for its bundler's cold boot.
806
+ */
807
+ interface DeviceTimeouts {
808
+ /**
809
+ * How long every verb polls for a visible match before refusing.
810
+ * Default: `30_000` — enough for a cold app boot on a release bundle.
811
+ */
812
+ action?: number;
813
+ /**
814
+ * How long the FIRST session waits for WebDriverAgent to build and
815
+ * launch on the simulator. Default: `240_000`.
816
+ */
817
+ launch?: number;
818
+ }
801
819
  /**
802
820
  * Abstract device interface for the mobile specification runner.
803
821
  * One implementation lives in `integrations/appium/` — a single driver
@@ -876,8 +894,17 @@ interface ServiceHandle {
876
894
  createDatabaseAdapter: () => DatabasePort | null;
877
895
  /** Verify the service is ready and accepting connections. Throws with context if not. */
878
896
  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>;
897
+ /**
898
+ * Run initialization scripts (e.g., init.sql). Throws with SQL error
899
+ * context if it fails.
900
+ *
901
+ * `composeDir` is where the compose file lives — where a service reads its
902
+ * `init.sql` from. `root` is the project root the specification resolved
903
+ * (A9), handed down so a service that CACHES something writes it under the
904
+ * project's own `.artifacts/`, never in a machine-global directory two
905
+ * checkouts would share.
906
+ */
907
+ initialize: (composeDir: string, root: string) => Promise<void>;
881
908
  /** Reset state between tests (truncate tables, flush cache, etc.) */
882
909
  reset: () => Promise<void>;
883
910
  /** Get the isolation strategy for parallel test execution. */
@@ -1767,6 +1794,13 @@ interface MobileSpecificationOptions {
1767
1794
  * file when absent.
1768
1795
  */
1769
1796
  root?: string;
1797
+ /**
1798
+ * How long this runner's session waits, in milliseconds — every field
1799
+ * optional, each falling back to the framework's default. A project
1800
+ * driving a DEV build states the wait its bundler's cold boot needs
1801
+ * (`{ action: 45_000 }`) instead of sleeping inside its scenarios.
1802
+ */
1803
+ timeouts?: DeviceTimeouts;
1770
1804
  }
1771
1805
  /**
1772
1806
  * The record returned by {@link startMobile | specification.mobile}.
@@ -2230,7 +2264,31 @@ declare class SqliteHandle implements DatabasePort, ServiceHandle {
2230
2264
  buildConnectionString(): string;
2231
2265
  createDatabaseAdapter(): DatabasePort;
2232
2266
  healthcheck(): Promise<void>;
2233
- initialize(): Promise<void>;
2267
+ /**
2268
+ * Make the schema template available, building it if this worker is the one
2269
+ * that gets to.
2270
+ *
2271
+ * The template is cached under the PROJECT's `.artifacts/vitest/sqlite/`,
2272
+ * keyed by schema. `root` is the root the specification resolved (A9); a
2273
+ * handle initialised outside an orchestrator — a unit test holding one
2274
+ * directly — falls back to the same walk from the cwd.
2275
+ *
2276
+ * One worker builds, the others WAIT: the lock is taken with an exclusive
2277
+ * create, so exactly one wins, and a loser polls until the winner is done
2278
+ * rather than proceeding into a second concurrent `prisma db push` on the
2279
+ * same file (which is what `database is locked` was).
2280
+ */
2281
+ initialize(_composeDir?: string, root?: string): Promise<void>;
2282
+ /**
2283
+ * Build the template on a private path, then rename it into place.
2284
+ *
2285
+ * The rename is what makes a half-built template unobservable: it is atomic
2286
+ * within a filesystem, so a reader sees either the previous template or the
2287
+ * finished one. Building in place could not promise that — `prisma db push`
2288
+ * writes the SQLite header long before the tables exist, and a reader that
2289
+ * arrived in between accepted a valid-looking file with nothing in it.
2290
+ */
2291
+ private buildTemplate;
2234
2292
  private getDb;
2235
2293
  private closeDb;
2236
2294
  seed(sql: string): Promise<void>;
@@ -2479,4 +2537,4 @@ declare module 'vitest' {
2479
2537
  }
2480
2538
  }
2481
2539
  //#endregion
2482
- export { type ApiHandle, type ApiSpecification, type ApiSpecificationOptions, BaseResult, type BrowserConsoleMessage, type BrowserLinkElement, type BrowserMetaElement, type BrowserOpenOptions, type BrowserPage, type BrowserPort, type CaptureScope, type CliEnv, type CliHandle, type CliInput, type CliOutput, type CliPort, CliResult, type CliSpecification, type CliSpecificationOptions, ContainerAccessor, type ContainerPort, type Contract, type ContractInput, type ContractRequest, type ContractResponder, type ContractResponse, type ContractResponseValue, type Contracts, type DatabaseKeys, type DatabasePort, type DeviceOpenOptions, type DevicePort, type DeviceScreen, DirectoryAccessor, type DockerSpecConfig, type ElementKind, type ElementMatch, type ElementOptions, type ElementRef, type ExecOptions, FetchResult, type FileAccessor, FilesystemAccessor, type HonoApp, type HttpContractFilter, type HttpResponseInit, HttpResult, type IsolationStrategy, type JobHandle, type JobsHandle, type JobsSpecification, type JobsSpecificationOptions, JsonAccessor, type LandmarkKind, type LiterateRunFlags, type LiterateServeRegistration, type MatchFixtureOptions, type MatchableRequest, Matcher, type MatcherKind, type MobileBackendOptions, type MobileElementKind, type MobileElementMatch, type MobileElementRef, type MobileHandle, type MobileScenario, type MobileSpecification, type MobileSpecificationOptions, type MobileVisitor, type MockDatePort, type MockPort, Orchestrator, PageResult, type PostgresOptions, type RedisOptions, ResponseAccessor, type ScreenNode, ScreenResult, type ServeOptions, type ServerPort, type ServerResponse, type ServiceHandle, type ServiceRecord, type SpecDocument, type SpecEnvToken, type SpecFileAssertion, type SpecFixture, type SpecKind, type SpecRun, type SpecServeEntry, type SpecStream, type SpecificationConfig, type SpecificationMode, type SqliteOptions, TableAccessor, TextAccessor, type TextFilter, type VisitScenario, type Visitor, type WebsiteBackendOptions, type WebsiteHandle, type WebsiteSpecification, type WebsiteSpecificationOptions, anthropic, banner, button, complementary, content, contentinfo, defineContract, defineContracts, field, findContainersByLabel, form, heading, http, inspectContainer, link, main, match, mockOf, mockOfDate, navigation, openai, postgres, redis, region, removeContainers, search, specification, sqlite, testId, text, within };
2540
+ export { type ApiHandle, type ApiSpecification, type ApiSpecificationOptions, BaseResult, type BrowserConsoleMessage, type BrowserLinkElement, type BrowserMetaElement, type BrowserOpenOptions, type BrowserPage, type BrowserPort, type CaptureScope, type CliEnv, type CliHandle, type CliInput, type CliOutput, type CliPort, CliResult, type CliSpecification, type CliSpecificationOptions, ContainerAccessor, type ContainerPort, type Contract, type ContractInput, type ContractRequest, type ContractResponder, type ContractResponse, type ContractResponseValue, type Contracts, type DatabaseKeys, type DatabasePort, type DeviceOpenOptions, type DevicePort, type DeviceScreen, type DeviceTimeouts, DirectoryAccessor, type DockerSpecConfig, type ElementKind, type ElementMatch, type ElementOptions, type ElementRef, type ExecOptions, FetchResult, type FileAccessor, FilesystemAccessor, type HonoApp, type HttpContractFilter, type HttpResponseInit, HttpResult, type IsolationStrategy, type JobHandle, type JobsHandle, type JobsSpecification, type JobsSpecificationOptions, JsonAccessor, type LandmarkKind, type LiterateRunFlags, type LiterateServeRegistration, type MatchFixtureOptions, type MatchableRequest, Matcher, type MatcherKind, type MobileBackendOptions, type MobileElementKind, type MobileElementMatch, type MobileElementRef, type MobileHandle, type MobileScenario, type MobileSpecification, type MobileSpecificationOptions, type MobileVisitor, type MockDatePort, type MockPort, Orchestrator, PageResult, type PostgresOptions, type RedisOptions, ResponseAccessor, type ScreenNode, ScreenResult, type ServeOptions, type ServerPort, type ServerResponse, type ServiceHandle, type ServiceRecord, type SpecDocument, type SpecEnvToken, type SpecFileAssertion, type SpecFixture, type SpecKind, type SpecRun, type SpecServeEntry, type SpecStream, type SpecificationConfig, type SpecificationMode, type SqliteOptions, TableAccessor, TextAccessor, type TextFilter, type VisitScenario, type Visitor, type WebsiteBackendOptions, type WebsiteHandle, type WebsiteSpecification, type WebsiteSpecificationOptions, anthropic, banner, button, complementary, content, contentinfo, defineContract, defineContracts, field, findContainersByLabel, form, heading, http, inspectContainer, link, main, match, mockOf, mockOfDate, navigation, openai, postgres, redis, region, removeContainers, search, specification, sqlite, testId, text, within };
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
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 { A as formatResponseDiff, B as structuralSubset, C as text, D as diffDirectories, E as DirectoryAccessor, F as mergePreservingPlaceholders, I as mergeTextPreservingPlaceholders, M as formatStdoutDiff, N as formatTableDiff, O as walkDirectory, R as renderExpected, S as TextAccessor, T as FilesystemAccessor, V as textEquals, _ as findContainersByLabel, b as TableAccessor, c as shouldUpdateSnapshots, d as BaseResult, f as validateDatabaseOption, g as resolveRoot, h as resolveCommand, i as runSpecDocument, j as formatStartupReport, k as formatDirectoryDiff, l as ContainerAccessor, m as expandWorkdir, n as SQLITE_TEMPLATE_DIR, o as ServeAdapter, p as discoverRoot, s as UPDATE_HINT, u as CliResult, v as inspectContainer, w as getCallerDir, x as JsonAccessor, y as removeContainers, z as structuralEquals } 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";
5
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
6
  import { parse } from "yaml";
7
7
  import { execFile, execSync, spawn, spawnSync } from "node:child_process";
8
8
  import { Client } from "pg";
@@ -1092,8 +1092,10 @@ var SpecificationBuilder = class {
1092
1092
  * Set environment variables for the command process. Merged on top of process.env.
1093
1093
  * Use `null` to unset a variable. Multiple calls merge.
1094
1094
  *
1095
- * The token `$WORKDIR` (in any value) is replaced with the actual working
1096
- * directory at run-time — useful for tests that need a fully isolated `HOME`.
1095
+ * The token `$WORKDIR` (in any value) is replaced at run-time with the
1096
+ * spec's working directory in its resolved form — the same string
1097
+ * `{{workdir}}` asserts against — so a path handed to the child comes back
1098
+ * matchable. Useful for tests that need a fully isolated `HOME`.
1097
1099
  *
1098
1100
  * @example
1099
1101
  * cli.env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status");
@@ -1346,14 +1348,8 @@ var SpecificationBuilder = class {
1346
1348
  }
1347
1349
  }
1348
1350
  resolveEnv(workDir) {
1349
- const keys = Object.keys(this.commandEnv);
1350
- if (keys.length === 0) return;
1351
- const resolved = {};
1352
- for (const key of keys) {
1353
- const value = this.commandEnv[key];
1354
- resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
1355
- }
1356
- return resolved;
1351
+ if (Object.keys(this.commandEnv).length === 0) return;
1352
+ return expandWorkdir(this.commandEnv, workDir);
1357
1353
  }
1358
1354
  prepareWorkDir() {
1359
1355
  return mkdtempSync(resolve(tmpdir(), "spec-command-"));
@@ -1726,7 +1722,7 @@ var Orchestrator = class {
1726
1722
  });
1727
1723
  }
1728
1724
  await Promise.all([...containerServices.map(({ container }) => container.start()), ...embeddedServices.map(async (handle) => {
1729
- await handle.initialize(composeDir);
1725
+ await handle.initialize(composeDir, this.root);
1730
1726
  handle.started = true;
1731
1727
  this.running.push({
1732
1728
  handle,
@@ -1741,7 +1737,7 @@ var Orchestrator = class {
1741
1737
  const port = container.getMappedPort(handle.defaultPort);
1742
1738
  handle.connectionString = handle.buildConnectionString(host, port);
1743
1739
  await handle.healthcheck();
1744
- await handle.initialize(composeDir);
1740
+ await handle.initialize(composeDir, this.root);
1745
1741
  handle.started = true;
1746
1742
  reports.push({
1747
1743
  name: handle.composeName ?? handle.type,
@@ -1801,7 +1797,7 @@ var Orchestrator = class {
1801
1797
  const declaredComposeNames = /* @__PURE__ */ new Set();
1802
1798
  for (const handle of Object.values(this.services)) {
1803
1799
  if (handle.defaultPort === 0) {
1804
- await handle.initialize(composeDir);
1800
+ await handle.initialize(composeDir, this.root);
1805
1801
  handle.started = true;
1806
1802
  continue;
1807
1803
  }
@@ -1812,7 +1808,7 @@ var Orchestrator = class {
1812
1808
  const port = this.composeStack.getMappedPort(handle.composeName, handle.defaultPort);
1813
1809
  handle.connectionString = handle.buildConnectionString("localhost", port);
1814
1810
  await handle.healthcheck();
1815
- await handle.initialize(composeDir);
1811
+ await handle.initialize(composeDir, this.root);
1816
1812
  handle.started = true;
1817
1813
  }
1818
1814
  for (const service of composeConfig.infraServices) {
@@ -1822,7 +1818,7 @@ var Orchestrator = class {
1822
1818
  const handle = factory(service);
1823
1819
  const port = this.composeStack.getMappedPort(service.name, handle.defaultPort);
1824
1820
  handle.connectionString = handle.buildConnectionString("localhost", port);
1825
- await handle.initialize(composeDir);
1821
+ await handle.initialize(composeDir, this.root);
1826
1822
  handle.started = true;
1827
1823
  this.composeHandles.push(handle);
1828
1824
  }
@@ -1890,64 +1886,6 @@ var Orchestrator = class {
1890
1886
  }
1891
1887
  };
1892
1888
  //#endregion
1893
- //#region src/core/specification/shared/resolve.ts
1894
- /** What makes a directory a project root — a manifest, or a test stack. */
1895
- const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
1896
- /**
1897
- * The NEAREST ancestor of `startDir` (itself included) carrying a root marker,
1898
- * `undefined` when none does.
1899
- *
1900
- * Both markers are probed at each ancestor, in ONE walk. Two walks — the
1901
- * compose file all the way up, then `package.json` all the way up — made the
1902
- * further directory win: in a workspace, a `docker/compose.test.yaml` at the
1903
- * repository root outranked the `package.json` of the very package being
1904
- * tested, and every path the runner resolved was measured from the wrong unit.
1905
- * The unit is the nearest thing that declares itself a project.
1906
- *
1907
- * The existence probe is injected so the lint layer can pass its cached one
1908
- * (A9's `a9w-redundant-root` derives the same root, and must derive it the
1909
- * same way).
1910
- */
1911
- function findRoot(startDir, exists) {
1912
- let dir = startDir;
1913
- for (;;) {
1914
- if (ROOT_MARKERS.some((marker) => exists(resolve(dir, marker)))) return dir;
1915
- const parent = dirname(dir);
1916
- if (parent === dir) return;
1917
- dir = parent;
1918
- }
1919
- }
1920
- /**
1921
- * Auto-discover the project root from a starting directory (CONVENTIONS A9):
1922
- * walk up to the first directory that carries `package.json` or
1923
- * `docker/compose.test.yaml`; if none does, the starting directory itself.
1924
- */
1925
- function discoverRoot(startDir) {
1926
- return findRoot(startDir, existsSync) ?? startDir;
1927
- }
1928
- /**
1929
- * Resolve the project root for a specification. An explicit `root` option is
1930
- * an override (resolved from the caller's directory when relative); when
1931
- * absent, the root is auto-discovered by walking up from the calling
1932
- * specification file.
1933
- */
1934
- function resolveRoot(root, callerDir) {
1935
- if (!root) return discoverRoot(callerDir);
1936
- if (isAbsolute(root)) return root;
1937
- return resolve(callerDir, root);
1938
- }
1939
- /**
1940
- * Resolve a command — checks node_modules/.bin, then treats as absolute/PATH.
1941
- */
1942
- function resolveCommand(command, root) {
1943
- if (isAbsolute(command)) return command;
1944
- const binPath = resolve(root, "node_modules/.bin", command);
1945
- if (existsSync(binPath)) return binPath;
1946
- const cwdBinPath = resolve(process.cwd(), "node_modules/.bin", command);
1947
- if (existsSync(cwdBinPath)) return cwdBinPath;
1948
- return command;
1949
- }
1950
- //#endregion
1951
1889
  //#region src/core/specification/shared/services.ts
1952
1890
  function getWorkerId() {
1953
1891
  return process.env.VITEST_POOL_ID ?? "0";
@@ -2615,6 +2553,7 @@ async function startMobile(options) {
2615
2553
  const { AppiumAdapter } = await import("./appium.adapter.js");
2616
2554
  device = new AppiumAdapter({
2617
2555
  serverUrl: appium.url,
2556
+ timeouts: options.timeouts,
2618
2557
  udid
2619
2558
  });
2620
2559
  }
@@ -2860,7 +2799,16 @@ function prismaPushCommand(prismaSchema) {
2860
2799
  return `${PRISMA_PUSH} --schema ${JSON.stringify(resolve(prismaSchema))}`;
2861
2800
  }
2862
2801
  /** Every template file this package writes carries that prefix, then its key. */
2863
- const TEMPLATE_PREFIX = "jterrazz-test-sqlite-template";
2802
+ const TEMPLATE_PREFIX = "template";
2803
+ /**
2804
+ * How long a waiter tolerates a lock nobody is touching before deciding its
2805
+ * holder died mid-build and taking over. A cold `prisma db push` is the slowest
2806
+ * build there is and finishes far inside this; a crashed holder would otherwise
2807
+ * wedge the suite forever.
2808
+ */
2809
+ const LOCK_STALE_MS = 12e4;
2810
+ /** How often a waiter re-checks the lock. */
2811
+ const LOCK_POLL_MS = 50;
2864
2812
  /**
2865
2813
  * The schema source as bytes: a file's content, or every file under a
2866
2814
  * directory in a stable order (a Prisma `schema/` folder is a directory). Each
@@ -2880,12 +2828,12 @@ function schemaBytes(path) {
2880
2828
  * The name of the template database for a set of options — `<prefix>-<sha8>`,
2881
2829
  * the digest taken over the schema KIND, its resolved path and its content.
2882
2830
  *
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.
2831
+ * The key survives the move out of the OS tmpdir. Within one project it is what
2832
+ * makes reuse mean "the same schema": editing a schema builds a new template
2833
+ * instead of inheriting the old one — the stale file is header-valid, so the
2834
+ * validity check would accept it and the tables would never be rebuilt. It also
2835
+ * keeps two checkouts of the SAME project apart when a schema differs between
2836
+ * them, on top of the separation the per-project folder already gives.
2889
2837
  */
2890
2838
  function sqliteTemplateName(options = {}) {
2891
2839
  let fingerprint;
@@ -2899,6 +2847,76 @@ function sqliteTemplateName(options = {}) {
2899
2847
  const key = createHash("sha256").update(fingerprint).digest("hex").slice(0, 8);
2900
2848
  return `${TEMPLATE_PREFIX}-${key}.sqlite`;
2901
2849
  }
2850
+ /**
2851
+ * The directory a project caches its schema templates in —
2852
+ * `<root>/.artifacts/vitest/sqlite/`, created on demand.
2853
+ *
2854
+ * A project, not a machine. The template used to live in the OS tmpdir, where
2855
+ * two checkouts of the same repository shared one file: whichever ran first
2856
+ * built it, the other silently inherited that schema, and a branch that changed
2857
+ * the schema poisoned the branch beside it. A path under the project root
2858
+ * cannot be reached from another checkout at all.
2859
+ */
2860
+ function sqliteTemplateDir(root) {
2861
+ const dir = resolve(root, SQLITE_TEMPLATE_DIR);
2862
+ mkdirSync(dir, { recursive: true });
2863
+ return dir;
2864
+ }
2865
+ /**
2866
+ * Take the build lock, or report that someone else holds it.
2867
+ *
2868
+ * `wx` is the whole point: the file is created and claimed in ONE syscall, so
2869
+ * two workers cannot both come out of it believing they won. The former
2870
+ * check-then-write pair could, and both then ran `prisma db push` against the
2871
+ * same file — the `database is locked` failure a consumer keeps
2872
+ * `fileParallelism: false` for.
2873
+ *
2874
+ * @internal Exported for unit tests.
2875
+ */
2876
+ function acquireTemplateLock(lockPath) {
2877
+ let fd;
2878
+ try {
2879
+ fd = openSync(lockPath, "wx");
2880
+ } catch (error) {
2881
+ if (error.code === "EEXIST") return false;
2882
+ throw error;
2883
+ }
2884
+ try {
2885
+ writeSync(fd, process.pid.toString());
2886
+ } finally {
2887
+ closeSync(fd);
2888
+ }
2889
+ return true;
2890
+ }
2891
+ /** Drop the build lock. A lock already gone is not an error — the goal is met. */
2892
+ function releaseTemplateLock(lockPath) {
2893
+ try {
2894
+ unlinkSync(lockPath);
2895
+ } catch {}
2896
+ }
2897
+ /**
2898
+ * Whether a lock has gone untouched long enough that its holder must be dead.
2899
+ * A lock that no longer exists is not stale — the waiter simply retries.
2900
+ *
2901
+ * @internal Exported for unit tests.
2902
+ */
2903
+ function isStaleTemplateLock(lockPath, now = Date.now()) {
2904
+ try {
2905
+ return now - statSync(lockPath).mtimeMs > LOCK_STALE_MS;
2906
+ } catch {
2907
+ return false;
2908
+ }
2909
+ }
2910
+ /** Remove a file and the WAL sidecars SQLite may have left beside it. */
2911
+ function removeDatabaseFiles(path) {
2912
+ for (const suffix of [
2913
+ "",
2914
+ "-wal",
2915
+ "-shm"
2916
+ ]) try {
2917
+ unlinkSync(`${path}${suffix}`);
2918
+ } catch {}
2919
+ }
2902
2920
  var SqliteHandle = class {
2903
2921
  type = "sqlite";
2904
2922
  composeName = null;
@@ -2925,50 +2943,78 @@ var SqliteHandle = class {
2925
2943
  return this;
2926
2944
  }
2927
2945
  async healthcheck() {}
2928
- async initialize() {
2929
- this.templatePath = resolve(tmpdir(), sqliteTemplateName(this.options));
2946
+ /**
2947
+ * Make the schema template available, building it if this worker is the one
2948
+ * that gets to.
2949
+ *
2950
+ * The template is cached under the PROJECT's `.artifacts/vitest/sqlite/`,
2951
+ * keyed by schema. `root` is the root the specification resolved (A9); a
2952
+ * handle initialised outside an orchestrator — a unit test holding one
2953
+ * directly — falls back to the same walk from the cwd.
2954
+ *
2955
+ * One worker builds, the others WAIT: the lock is taken with an exclusive
2956
+ * create, so exactly one wins, and a loser polls until the winner is done
2957
+ * rather than proceeding into a second concurrent `prisma db push` on the
2958
+ * same file (which is what `database is locked` was).
2959
+ */
2960
+ async initialize(_composeDir, root) {
2961
+ const projectRoot = root ?? discoverRoot(process.cwd());
2962
+ this.templatePath = resolve(sqliteTemplateDir(projectRoot), sqliteTemplateName(this.options));
2930
2963
  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;
2964
+ for (;;) {
2965
+ if (isValidSqliteTemplate(this.templatePath)) break;
2966
+ if (!acquireTemplateLock(lockPath)) {
2967
+ if (isStaleTemplateLock(lockPath)) releaseTemplateLock(lockPath);
2968
+ else await new Promise((wake) => setTimeout(wake, LOCK_POLL_MS));
2969
+ continue;
2940
2970
  }
2941
2971
  try {
2942
- unlinkSync(this.templatePath);
2943
- } catch {}
2972
+ await this.buildTemplate();
2973
+ } finally {
2974
+ releaseTemplateLock(lockPath);
2975
+ }
2976
+ break;
2944
2977
  }
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
2978
  this.connectionString = `file:${this.templatePath}`;
2970
2979
  this.started = true;
2971
2980
  }
2981
+ /**
2982
+ * Build the template on a private path, then rename it into place.
2983
+ *
2984
+ * The rename is what makes a half-built template unobservable: it is atomic
2985
+ * within a filesystem, so a reader sees either the previous template or the
2986
+ * finished one. Building in place could not promise that — `prisma db push`
2987
+ * writes the SQLite header long before the tables exist, and a reader that
2988
+ * arrived in between accepted a valid-looking file with nothing in it.
2989
+ */
2990
+ async buildTemplate() {
2991
+ const buildPath = `${this.templatePath}.${process.pid}.building`;
2992
+ removeDatabaseFiles(buildPath);
2993
+ try {
2994
+ if (this.prismaSchema) {
2995
+ const { execSync } = await import("node:child_process");
2996
+ execSync(prismaPushCommand(this.prismaSchema), {
2997
+ env: {
2998
+ ...process.env,
2999
+ DATABASE_URL: `file:${buildPath}`,
3000
+ PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: "yes"
3001
+ },
3002
+ stdio: "pipe"
3003
+ });
3004
+ const tmpDb = new Database(buildPath);
3005
+ tmpDb.pragma("wal_checkpoint(TRUNCATE)");
3006
+ tmpDb.close();
3007
+ } else if (this.initSql) {
3008
+ const sql = readFileSync(this.initSql, "utf8");
3009
+ const templateDb = new Database(buildPath);
3010
+ templateDb.exec(sql);
3011
+ templateDb.close();
3012
+ } else new Database(buildPath).close();
3013
+ renameSync(buildPath, this.templatePath);
3014
+ } finally {
3015
+ removeDatabaseFiles(buildPath);
3016
+ }
3017
+ }
2972
3018
  getDb() {
2973
3019
  const dbPath = this.workerDbPath || this.templatePath;
2974
3020
  if (!this.db) {
@@ -2999,7 +3045,7 @@ var SqliteHandle = class {
2999
3045
  return {
3000
3046
  acquire: async (workerId) => {
3001
3047
  this.closeDb();
3002
- this.workerDbPath = resolve(tmpdir(), `test-worker-${workerId}-${Date.now()}.sqlite`);
3048
+ this.workerDbPath = resolve(tmpdir(), `test-worker-${workerId}-${process.pid}-${Date.now()}.sqlite`);
3003
3049
  copyFileSync(this.templatePath, this.workerDbPath);
3004
3050
  this.connectionString = `file:${this.workerDbPath}`;
3005
3051
  },
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 { L as placeholderPatternSource, P as hasPlaceholders, V as textEquals } 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.2.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",