@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 +31 -3
- package/dist/appium.adapter.js +37 -12
- package/dist/{literate.js → artifacts.js} +96 -20
- package/dist/index.d.ts +62 -4
- package/dist/index.js +167 -121
- package/dist/queue.js +1 -1
- package/dist/vitest.d.ts +20 -1
- package/dist/vitest.js +100 -2
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/appium.adapter.js
CHANGED
|
@@ -179,12 +179,12 @@ function projectScreen(xml) {
|
|
|
179
179
|
}
|
|
180
180
|
//#endregion
|
|
181
181
|
//#region src/integrations/appium/appium.adapter.ts
|
|
182
|
-
const
|
|
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
|
|
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 =
|
|
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() +
|
|
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 ${
|
|
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
|
-
|
|
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":
|
|
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 {
|
|
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
|
-
/**
|
|
880
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
1096
|
-
*
|
|
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
|
-
|
|
1350
|
-
|
|
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 = "
|
|
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
|
|
2884
|
-
*
|
|
2885
|
-
*
|
|
2886
|
-
*
|
|
2887
|
-
*
|
|
2888
|
-
*
|
|
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
|
-
|
|
2929
|
-
|
|
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
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
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
|
-
|
|
2943
|
-
}
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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",
|