@jterrazz/test 14.0.0 → 14.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -3
- package/dist/{literate.js → artifacts.js} +22 -11
- package/dist/index.d.ts +39 -5
- package/dist/index.js +159 -52
- package/dist/oxlint.cjs +18 -4
- package/dist/oxlint.js +18 -4
- package/dist/queue.js +1 -1
- package/dist/spec-document.js +83 -4
- 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
|
|
@@ -1547,7 +1547,11 @@ function atLine(error, filePath, line) {
|
|
|
1547
1547
|
error.stack = `${error.name}: ${error.message}\n at ${filePath}:${line}:1`;
|
|
1548
1548
|
return error;
|
|
1549
1549
|
}
|
|
1550
|
-
/**
|
|
1550
|
+
/**
|
|
1551
|
+
* Compare one run against what the command actually did — its exit code, its
|
|
1552
|
+
* streams and its `files:`, all read at the same instant: the moment that
|
|
1553
|
+
* command returned.
|
|
1554
|
+
*/
|
|
1551
1555
|
function assertRun(document, outcome, context, workDir, scope) {
|
|
1552
1556
|
const { actual, run } = outcome;
|
|
1553
1557
|
if (actual.exitCode !== run.exitCode) throw failure(document, run, context, [[
|
|
@@ -1682,9 +1686,9 @@ async function runSpecDocument(options) {
|
|
|
1682
1686
|
...servers.env,
|
|
1683
1687
|
...documentEnv(document, config, displayPath)
|
|
1684
1688
|
}, workDir);
|
|
1685
|
-
|
|
1689
|
+
const rewriting = shouldUpdateSnapshots() && options.frozen !== true;
|
|
1690
|
+
const outcomes = [];
|
|
1686
1691
|
try {
|
|
1687
|
-
outcomes = [];
|
|
1688
1692
|
for (const run of document.runs) {
|
|
1689
1693
|
const actual = run.waitFor === null ? await config.command.exec(run.command, workDir, env, {
|
|
1690
1694
|
stdin: run.stdin ?? void 0,
|
|
@@ -1693,21 +1697,22 @@ async function runSpecDocument(options) {
|
|
|
1693
1697
|
timeout: run.timeout ?? void 0,
|
|
1694
1698
|
waitFor: run.waitFor
|
|
1695
1699
|
}, env);
|
|
1696
|
-
|
|
1700
|
+
const outcome = {
|
|
1697
1701
|
actual,
|
|
1698
1702
|
run,
|
|
1699
1703
|
stderr: comparable(actual.stderr, config.transform),
|
|
1700
1704
|
stdout: comparable(actual.stdout, config.transform)
|
|
1701
|
-
}
|
|
1705
|
+
};
|
|
1706
|
+
outcomes.push(outcome);
|
|
1707
|
+
if (!rewriting) assertRun(document, outcome, {
|
|
1708
|
+
displayPath,
|
|
1709
|
+
filePath
|
|
1710
|
+
}, workDir, scope);
|
|
1702
1711
|
}
|
|
1703
1712
|
} finally {
|
|
1704
1713
|
await servers.stop();
|
|
1705
1714
|
}
|
|
1706
|
-
if (
|
|
1707
|
-
else for (const outcome of outcomes) assertRun(document, outcome, {
|
|
1708
|
-
displayPath,
|
|
1709
|
-
filePath
|
|
1710
|
-
}, workDir, scope);
|
|
1715
|
+
if (rewriting) writeFileSync(filePath, updateSpecFile(file, updates(outcomes, scope)));
|
|
1711
1716
|
return new CliResult({
|
|
1712
1717
|
commandOutput: outcomes.at(-1).actual,
|
|
1713
1718
|
config,
|
|
@@ -1722,5 +1727,11 @@ async function runSpecDocument(options) {
|
|
|
1722
1727
|
function specDescription(content, displayPath) {
|
|
1723
1728
|
return readSpecFile(content, displayPath).document.description;
|
|
1724
1729
|
}
|
|
1730
|
+
/** Everything a vitest run generates. */
|
|
1731
|
+
const VITEST_ARTIFACTS_DIR = `.artifacts/vitest`;
|
|
1732
|
+
/** Where the coverage provider writes its report. */
|
|
1733
|
+
const COVERAGE_DIR = `${VITEST_ARTIFACTS_DIR}/coverage`;
|
|
1734
|
+
/** Where `sqlite()` caches the schema template it copies per worker. */
|
|
1735
|
+
const SQLITE_TEMPLATE_DIR = `${VITEST_ARTIFACTS_DIR}/sqlite`;
|
|
1725
1736
|
//#endregion
|
|
1726
|
-
export {
|
|
1737
|
+
export { hasPlaceholders as A, diffDirectories as C, formatStartupReport as D, formatResponseDiff as E, structuralEquals as F, structuralSubset as I, textEquals as L, mergeTextPreservingPlaceholders as M, placeholderPatternSource as N, formatStdoutDiff as O, renderExpected as P, DirectoryAccessor as S, formatDirectoryDiff as T, JsonAccessor as _, specDescription as a, getCallerDir as b, shouldUpdateSnapshots as c, BaseResult as d, validateDatabaseOption as f, TableAccessor as g, removeContainers as h, runSpecDocument as i, mergePreservingPlaceholders as j, formatTableDiff as k, ContainerAccessor as l, inspectContainer as m, SQLITE_TEMPLATE_DIR as n, ServeAdapter as o, findContainersByLabel as p, VITEST_ARTIFACTS_DIR as r, UPDATE_HINT as s, COVERAGE_DIR as t, CliResult as u, TextAccessor as v, walkDirectory as w, FilesystemAccessor as x, text as y };
|
package/dist/index.d.ts
CHANGED
|
@@ -876,8 +876,17 @@ interface ServiceHandle {
|
|
|
876
876
|
createDatabaseAdapter: () => DatabasePort | null;
|
|
877
877
|
/** Verify the service is ready and accepting connections. Throws with context if not. */
|
|
878
878
|
healthcheck: () => Promise<void>;
|
|
879
|
-
/**
|
|
880
|
-
|
|
879
|
+
/**
|
|
880
|
+
* Run initialization scripts (e.g., init.sql). Throws with SQL error
|
|
881
|
+
* context if it fails.
|
|
882
|
+
*
|
|
883
|
+
* `composeDir` is where the compose file lives — where a service reads its
|
|
884
|
+
* `init.sql` from. `root` is the project root the specification resolved
|
|
885
|
+
* (A9), handed down so a service that CACHES something writes it under the
|
|
886
|
+
* project's own `.artifacts/`, never in a machine-global directory two
|
|
887
|
+
* checkouts would share.
|
|
888
|
+
*/
|
|
889
|
+
initialize: (composeDir: string, root: string) => Promise<void>;
|
|
881
890
|
/** Reset state between tests (truncate tables, flush cache, etc.) */
|
|
882
891
|
reset: () => Promise<void>;
|
|
883
892
|
/** Get the isolation strategy for parallel test execution. */
|
|
@@ -948,8 +957,9 @@ declare class HttpResult extends BaseResult {
|
|
|
948
957
|
* for each run, {@link ServeAdapter} for `serve:`, the `{{token}}` engine for
|
|
949
958
|
* every comparison, and {@link mergeTextPreservingPlaceholders} for the
|
|
950
959
|
* rewrite. What is new here is only the ORCHESTRATION the chain cannot express:
|
|
951
|
-
* several commands sharing one working directory, each
|
|
952
|
-
*
|
|
960
|
+
* several commands sharing one working directory, each judged the moment it
|
|
961
|
+
* ends — so what a run states about that directory is what IT left there, and
|
|
962
|
+
* the first run to disagree is the one the failure names.
|
|
953
963
|
*/
|
|
954
964
|
/**
|
|
955
965
|
* A server a document may start by name (`serve: mcp`), registered once per app
|
|
@@ -2229,7 +2239,31 @@ declare class SqliteHandle implements DatabasePort, ServiceHandle {
|
|
|
2229
2239
|
buildConnectionString(): string;
|
|
2230
2240
|
createDatabaseAdapter(): DatabasePort;
|
|
2231
2241
|
healthcheck(): Promise<void>;
|
|
2232
|
-
|
|
2242
|
+
/**
|
|
2243
|
+
* Make the schema template available, building it if this worker is the one
|
|
2244
|
+
* that gets to.
|
|
2245
|
+
*
|
|
2246
|
+
* The template is cached under the PROJECT's `.artifacts/vitest/sqlite/`,
|
|
2247
|
+
* keyed by schema. `root` is the root the specification resolved (A9); a
|
|
2248
|
+
* handle initialised outside an orchestrator — a unit test holding one
|
|
2249
|
+
* directly — falls back to the same walk from the cwd.
|
|
2250
|
+
*
|
|
2251
|
+
* One worker builds, the others WAIT: the lock is taken with an exclusive
|
|
2252
|
+
* create, so exactly one wins, and a loser polls until the winner is done
|
|
2253
|
+
* rather than proceeding into a second concurrent `prisma db push` on the
|
|
2254
|
+
* same file (which is what `database is locked` was).
|
|
2255
|
+
*/
|
|
2256
|
+
initialize(_composeDir?: string, root?: string): Promise<void>;
|
|
2257
|
+
/**
|
|
2258
|
+
* Build the template on a private path, then rename it into place.
|
|
2259
|
+
*
|
|
2260
|
+
* The rename is what makes a half-built template unobservable: it is atomic
|
|
2261
|
+
* within a filesystem, so a reader sees either the previous template or the
|
|
2262
|
+
* finished one. Building in place could not promise that — `prisma db push`
|
|
2263
|
+
* writes the SQLite header long before the tables exist, and a reader that
|
|
2264
|
+
* arrived in between accepted a valid-looking file with nothing in it.
|
|
2265
|
+
*/
|
|
2266
|
+
private buildTemplate;
|
|
2233
2267
|
private getDb;
|
|
2234
2268
|
private closeDb;
|
|
2235
2269
|
seed(sql: string): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { a as copyPlan, c as GROUND_EXPECTED, d as GROUND_SEEDS, i as match, n as Matcher, t as CaptureScope, u as GROUND_REQUESTS } from "./match.js";
|
|
2
|
-
import { C as
|
|
2
|
+
import { C as diffDirectories, D as formatStartupReport, E as formatResponseDiff, F as structuralEquals, I as structuralSubset, L as textEquals, M as mergeTextPreservingPlaceholders, O as formatStdoutDiff, P as renderExpected, S as DirectoryAccessor, T as formatDirectoryDiff, _ as JsonAccessor, b as getCallerDir, c as shouldUpdateSnapshots, d as BaseResult, f as validateDatabaseOption, g as TableAccessor, h as removeContainers, i as runSpecDocument, j as mergePreservingPlaceholders, k as formatTableDiff, l as ContainerAccessor, m as inspectContainer, n as SQLITE_TEMPLATE_DIR, o as ServeAdapter, p as findContainersByLabel, s as UPDATE_HINT, u as CliResult, v as TextAccessor, w as walkDirectory, x as FilesystemAccessor, y as text } from "./artifacts.js";
|
|
3
3
|
import { a as isContract, i as defineContracts, n as contractsOf, o as isContracts, r as defineContract, t as ContractQueue } from "./queue.js";
|
|
4
|
-
import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
5
5
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { parse } from "yaml";
|
|
7
7
|
import { execFile, execSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -1726,7 +1726,7 @@ var Orchestrator = class {
|
|
|
1726
1726
|
});
|
|
1727
1727
|
}
|
|
1728
1728
|
await Promise.all([...containerServices.map(({ container }) => container.start()), ...embeddedServices.map(async (handle) => {
|
|
1729
|
-
await handle.initialize(composeDir);
|
|
1729
|
+
await handle.initialize(composeDir, this.root);
|
|
1730
1730
|
handle.started = true;
|
|
1731
1731
|
this.running.push({
|
|
1732
1732
|
handle,
|
|
@@ -1741,7 +1741,7 @@ var Orchestrator = class {
|
|
|
1741
1741
|
const port = container.getMappedPort(handle.defaultPort);
|
|
1742
1742
|
handle.connectionString = handle.buildConnectionString(host, port);
|
|
1743
1743
|
await handle.healthcheck();
|
|
1744
|
-
await handle.initialize(composeDir);
|
|
1744
|
+
await handle.initialize(composeDir, this.root);
|
|
1745
1745
|
handle.started = true;
|
|
1746
1746
|
reports.push({
|
|
1747
1747
|
name: handle.composeName ?? handle.type,
|
|
@@ -1801,7 +1801,7 @@ var Orchestrator = class {
|
|
|
1801
1801
|
const declaredComposeNames = /* @__PURE__ */ new Set();
|
|
1802
1802
|
for (const handle of Object.values(this.services)) {
|
|
1803
1803
|
if (handle.defaultPort === 0) {
|
|
1804
|
-
await handle.initialize(composeDir);
|
|
1804
|
+
await handle.initialize(composeDir, this.root);
|
|
1805
1805
|
handle.started = true;
|
|
1806
1806
|
continue;
|
|
1807
1807
|
}
|
|
@@ -1812,7 +1812,7 @@ var Orchestrator = class {
|
|
|
1812
1812
|
const port = this.composeStack.getMappedPort(handle.composeName, handle.defaultPort);
|
|
1813
1813
|
handle.connectionString = handle.buildConnectionString("localhost", port);
|
|
1814
1814
|
await handle.healthcheck();
|
|
1815
|
-
await handle.initialize(composeDir);
|
|
1815
|
+
await handle.initialize(composeDir, this.root);
|
|
1816
1816
|
handle.started = true;
|
|
1817
1817
|
}
|
|
1818
1818
|
for (const service of composeConfig.infraServices) {
|
|
@@ -1822,7 +1822,7 @@ var Orchestrator = class {
|
|
|
1822
1822
|
const handle = factory(service);
|
|
1823
1823
|
const port = this.composeStack.getMappedPort(service.name, handle.defaultPort);
|
|
1824
1824
|
handle.connectionString = handle.buildConnectionString("localhost", port);
|
|
1825
|
-
await handle.initialize(composeDir);
|
|
1825
|
+
await handle.initialize(composeDir, this.root);
|
|
1826
1826
|
handle.started = true;
|
|
1827
1827
|
this.composeHandles.push(handle);
|
|
1828
1828
|
}
|
|
@@ -2860,7 +2860,16 @@ function prismaPushCommand(prismaSchema) {
|
|
|
2860
2860
|
return `${PRISMA_PUSH} --schema ${JSON.stringify(resolve(prismaSchema))}`;
|
|
2861
2861
|
}
|
|
2862
2862
|
/** Every template file this package writes carries that prefix, then its key. */
|
|
2863
|
-
const TEMPLATE_PREFIX = "
|
|
2863
|
+
const TEMPLATE_PREFIX = "template";
|
|
2864
|
+
/**
|
|
2865
|
+
* How long a waiter tolerates a lock nobody is touching before deciding its
|
|
2866
|
+
* holder died mid-build and taking over. A cold `prisma db push` is the slowest
|
|
2867
|
+
* build there is and finishes far inside this; a crashed holder would otherwise
|
|
2868
|
+
* wedge the suite forever.
|
|
2869
|
+
*/
|
|
2870
|
+
const LOCK_STALE_MS = 12e4;
|
|
2871
|
+
/** How often a waiter re-checks the lock. */
|
|
2872
|
+
const LOCK_POLL_MS = 50;
|
|
2864
2873
|
/**
|
|
2865
2874
|
* The schema source as bytes: a file's content, or every file under a
|
|
2866
2875
|
* directory in a stable order (a Prisma `schema/` folder is a directory). Each
|
|
@@ -2880,12 +2889,12 @@ function schemaBytes(path) {
|
|
|
2880
2889
|
* The name of the template database for a set of options — `<prefix>-<sha8>`,
|
|
2881
2890
|
* the digest taken over the schema KIND, its resolved path and its content.
|
|
2882
2891
|
*
|
|
2883
|
-
* The
|
|
2884
|
-
*
|
|
2885
|
-
*
|
|
2886
|
-
*
|
|
2887
|
-
*
|
|
2888
|
-
*
|
|
2892
|
+
* The key survives the move out of the OS tmpdir. Within one project it is what
|
|
2893
|
+
* makes reuse mean "the same schema": editing a schema builds a new template
|
|
2894
|
+
* instead of inheriting the old one — the stale file is header-valid, so the
|
|
2895
|
+
* validity check would accept it and the tables would never be rebuilt. It also
|
|
2896
|
+
* keeps two checkouts of the SAME project apart when a schema differs between
|
|
2897
|
+
* them, on top of the separation the per-project folder already gives.
|
|
2889
2898
|
*/
|
|
2890
2899
|
function sqliteTemplateName(options = {}) {
|
|
2891
2900
|
let fingerprint;
|
|
@@ -2899,6 +2908,76 @@ function sqliteTemplateName(options = {}) {
|
|
|
2899
2908
|
const key = createHash("sha256").update(fingerprint).digest("hex").slice(0, 8);
|
|
2900
2909
|
return `${TEMPLATE_PREFIX}-${key}.sqlite`;
|
|
2901
2910
|
}
|
|
2911
|
+
/**
|
|
2912
|
+
* The directory a project caches its schema templates in —
|
|
2913
|
+
* `<root>/.artifacts/vitest/sqlite/`, created on demand.
|
|
2914
|
+
*
|
|
2915
|
+
* A project, not a machine. The template used to live in the OS tmpdir, where
|
|
2916
|
+
* two checkouts of the same repository shared one file: whichever ran first
|
|
2917
|
+
* built it, the other silently inherited that schema, and a branch that changed
|
|
2918
|
+
* the schema poisoned the branch beside it. A path under the project root
|
|
2919
|
+
* cannot be reached from another checkout at all.
|
|
2920
|
+
*/
|
|
2921
|
+
function sqliteTemplateDir(root) {
|
|
2922
|
+
const dir = resolve(root, SQLITE_TEMPLATE_DIR);
|
|
2923
|
+
mkdirSync(dir, { recursive: true });
|
|
2924
|
+
return dir;
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* Take the build lock, or report that someone else holds it.
|
|
2928
|
+
*
|
|
2929
|
+
* `wx` is the whole point: the file is created and claimed in ONE syscall, so
|
|
2930
|
+
* two workers cannot both come out of it believing they won. The former
|
|
2931
|
+
* check-then-write pair could, and both then ran `prisma db push` against the
|
|
2932
|
+
* same file — the `database is locked` failure a consumer keeps
|
|
2933
|
+
* `fileParallelism: false` for.
|
|
2934
|
+
*
|
|
2935
|
+
* @internal Exported for unit tests.
|
|
2936
|
+
*/
|
|
2937
|
+
function acquireTemplateLock(lockPath) {
|
|
2938
|
+
let fd;
|
|
2939
|
+
try {
|
|
2940
|
+
fd = openSync(lockPath, "wx");
|
|
2941
|
+
} catch (error) {
|
|
2942
|
+
if (error.code === "EEXIST") return false;
|
|
2943
|
+
throw error;
|
|
2944
|
+
}
|
|
2945
|
+
try {
|
|
2946
|
+
writeSync(fd, process.pid.toString());
|
|
2947
|
+
} finally {
|
|
2948
|
+
closeSync(fd);
|
|
2949
|
+
}
|
|
2950
|
+
return true;
|
|
2951
|
+
}
|
|
2952
|
+
/** Drop the build lock. A lock already gone is not an error — the goal is met. */
|
|
2953
|
+
function releaseTemplateLock(lockPath) {
|
|
2954
|
+
try {
|
|
2955
|
+
unlinkSync(lockPath);
|
|
2956
|
+
} catch {}
|
|
2957
|
+
}
|
|
2958
|
+
/**
|
|
2959
|
+
* Whether a lock has gone untouched long enough that its holder must be dead.
|
|
2960
|
+
* A lock that no longer exists is not stale — the waiter simply retries.
|
|
2961
|
+
*
|
|
2962
|
+
* @internal Exported for unit tests.
|
|
2963
|
+
*/
|
|
2964
|
+
function isStaleTemplateLock(lockPath, now = Date.now()) {
|
|
2965
|
+
try {
|
|
2966
|
+
return now - statSync(lockPath).mtimeMs > LOCK_STALE_MS;
|
|
2967
|
+
} catch {
|
|
2968
|
+
return false;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
/** Remove a file and the WAL sidecars SQLite may have left beside it. */
|
|
2972
|
+
function removeDatabaseFiles(path) {
|
|
2973
|
+
for (const suffix of [
|
|
2974
|
+
"",
|
|
2975
|
+
"-wal",
|
|
2976
|
+
"-shm"
|
|
2977
|
+
]) try {
|
|
2978
|
+
unlinkSync(`${path}${suffix}`);
|
|
2979
|
+
} catch {}
|
|
2980
|
+
}
|
|
2902
2981
|
var SqliteHandle = class {
|
|
2903
2982
|
type = "sqlite";
|
|
2904
2983
|
composeName = null;
|
|
@@ -2925,50 +3004,78 @@ var SqliteHandle = class {
|
|
|
2925
3004
|
return this;
|
|
2926
3005
|
}
|
|
2927
3006
|
async healthcheck() {}
|
|
2928
|
-
|
|
2929
|
-
|
|
3007
|
+
/**
|
|
3008
|
+
* Make the schema template available, building it if this worker is the one
|
|
3009
|
+
* that gets to.
|
|
3010
|
+
*
|
|
3011
|
+
* The template is cached under the PROJECT's `.artifacts/vitest/sqlite/`,
|
|
3012
|
+
* keyed by schema. `root` is the root the specification resolved (A9); a
|
|
3013
|
+
* handle initialised outside an orchestrator — a unit test holding one
|
|
3014
|
+
* directly — falls back to the same walk from the cwd.
|
|
3015
|
+
*
|
|
3016
|
+
* One worker builds, the others WAIT: the lock is taken with an exclusive
|
|
3017
|
+
* create, so exactly one wins, and a loser polls until the winner is done
|
|
3018
|
+
* rather than proceeding into a second concurrent `prisma db push` on the
|
|
3019
|
+
* same file (which is what `database is locked` was).
|
|
3020
|
+
*/
|
|
3021
|
+
async initialize(_composeDir, root) {
|
|
3022
|
+
const projectRoot = root ?? discoverRoot(process.cwd());
|
|
3023
|
+
this.templatePath = resolve(sqliteTemplateDir(projectRoot), sqliteTemplateName(this.options));
|
|
2930
3024
|
const lockPath = `${this.templatePath}.lock`;
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
this.connectionString = `file:${this.templatePath}`;
|
|
2938
|
-
this.started = true;
|
|
2939
|
-
return;
|
|
3025
|
+
for (;;) {
|
|
3026
|
+
if (isValidSqliteTemplate(this.templatePath)) break;
|
|
3027
|
+
if (!acquireTemplateLock(lockPath)) {
|
|
3028
|
+
if (isStaleTemplateLock(lockPath)) releaseTemplateLock(lockPath);
|
|
3029
|
+
else await new Promise((wake) => setTimeout(wake, LOCK_POLL_MS));
|
|
3030
|
+
continue;
|
|
2940
3031
|
}
|
|
2941
3032
|
try {
|
|
2942
|
-
|
|
2943
|
-
}
|
|
3033
|
+
await this.buildTemplate();
|
|
3034
|
+
} finally {
|
|
3035
|
+
releaseTemplateLock(lockPath);
|
|
3036
|
+
}
|
|
3037
|
+
break;
|
|
2944
3038
|
}
|
|
2945
|
-
const { writeFileSync } = await import("node:fs");
|
|
2946
|
-
writeFileSync(lockPath, process.pid.toString());
|
|
2947
|
-
if (this.prismaSchema) {
|
|
2948
|
-
const { execSync } = await import("node:child_process");
|
|
2949
|
-
execSync(prismaPushCommand(this.prismaSchema), {
|
|
2950
|
-
env: {
|
|
2951
|
-
...process.env,
|
|
2952
|
-
DATABASE_URL: `file:${this.templatePath}`,
|
|
2953
|
-
PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: "yes"
|
|
2954
|
-
},
|
|
2955
|
-
stdio: "pipe"
|
|
2956
|
-
});
|
|
2957
|
-
const tmpDb = new Database(this.templatePath);
|
|
2958
|
-
tmpDb.pragma("wal_checkpoint(TRUNCATE)");
|
|
2959
|
-
tmpDb.close();
|
|
2960
|
-
} else if (this.initSql) {
|
|
2961
|
-
const sql = readFileSync(this.initSql, "utf8");
|
|
2962
|
-
const templateDb = new Database(this.templatePath);
|
|
2963
|
-
templateDb.exec(sql);
|
|
2964
|
-
templateDb.close();
|
|
2965
|
-
} else new Database(this.templatePath).close();
|
|
2966
|
-
try {
|
|
2967
|
-
unlinkSync(lockPath);
|
|
2968
|
-
} catch {}
|
|
2969
3039
|
this.connectionString = `file:${this.templatePath}`;
|
|
2970
3040
|
this.started = true;
|
|
2971
3041
|
}
|
|
3042
|
+
/**
|
|
3043
|
+
* Build the template on a private path, then rename it into place.
|
|
3044
|
+
*
|
|
3045
|
+
* The rename is what makes a half-built template unobservable: it is atomic
|
|
3046
|
+
* within a filesystem, so a reader sees either the previous template or the
|
|
3047
|
+
* finished one. Building in place could not promise that — `prisma db push`
|
|
3048
|
+
* writes the SQLite header long before the tables exist, and a reader that
|
|
3049
|
+
* arrived in between accepted a valid-looking file with nothing in it.
|
|
3050
|
+
*/
|
|
3051
|
+
async buildTemplate() {
|
|
3052
|
+
const buildPath = `${this.templatePath}.${process.pid}.building`;
|
|
3053
|
+
removeDatabaseFiles(buildPath);
|
|
3054
|
+
try {
|
|
3055
|
+
if (this.prismaSchema) {
|
|
3056
|
+
const { execSync } = await import("node:child_process");
|
|
3057
|
+
execSync(prismaPushCommand(this.prismaSchema), {
|
|
3058
|
+
env: {
|
|
3059
|
+
...process.env,
|
|
3060
|
+
DATABASE_URL: `file:${buildPath}`,
|
|
3061
|
+
PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: "yes"
|
|
3062
|
+
},
|
|
3063
|
+
stdio: "pipe"
|
|
3064
|
+
});
|
|
3065
|
+
const tmpDb = new Database(buildPath);
|
|
3066
|
+
tmpDb.pragma("wal_checkpoint(TRUNCATE)");
|
|
3067
|
+
tmpDb.close();
|
|
3068
|
+
} else if (this.initSql) {
|
|
3069
|
+
const sql = readFileSync(this.initSql, "utf8");
|
|
3070
|
+
const templateDb = new Database(buildPath);
|
|
3071
|
+
templateDb.exec(sql);
|
|
3072
|
+
templateDb.close();
|
|
3073
|
+
} else new Database(buildPath).close();
|
|
3074
|
+
renameSync(buildPath, this.templatePath);
|
|
3075
|
+
} finally {
|
|
3076
|
+
removeDatabaseFiles(buildPath);
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
2972
3079
|
getDb() {
|
|
2973
3080
|
const dbPath = this.workerDbPath || this.templatePath;
|
|
2974
3081
|
if (!this.db) {
|
|
@@ -2999,7 +3106,7 @@ var SqliteHandle = class {
|
|
|
2999
3106
|
return {
|
|
3000
3107
|
acquire: async (workerId) => {
|
|
3001
3108
|
this.closeDb();
|
|
3002
|
-
this.workerDbPath = resolve(tmpdir(), `test-worker-${workerId}-${Date.now()}.sqlite`);
|
|
3109
|
+
this.workerDbPath = resolve(tmpdir(), `test-worker-${workerId}-${process.pid}-${Date.now()}.sqlite`);
|
|
3003
3110
|
copyFileSync(this.templatePath, this.workerDbPath);
|
|
3004
3111
|
this.connectionString = `file:${this.workerDbPath}`;
|
|
3005
3112
|
},
|
package/dist/oxlint.cjs
CHANGED
|
@@ -341,7 +341,7 @@ const RULE_DOCS = {
|
|
|
341
341
|
},
|
|
342
342
|
"c1-domain-structure": {
|
|
343
343
|
channel: "statique",
|
|
344
|
-
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans.",
|
|
344
|
+
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans. Une exception, et une seule : le sol peut être du CODE, et un `<module>.test.ts` posé À CÔTÉ du `<module>.ts` qu’il teste y est légal (c’est la loi I2) ; un test sans module voisin, ou un `*.specification.ts`, reste une violation.",
|
|
345
345
|
family: "C",
|
|
346
346
|
id: "C1",
|
|
347
347
|
rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
|
|
@@ -1604,6 +1604,14 @@ const TEST_SUFFIX = ".test.ts";
|
|
|
1604
1604
|
const SPECIFICATION_SUFFIX = ".specification.ts";
|
|
1605
1605
|
const DEFAULT_DEPTH = "facet-domain";
|
|
1606
1606
|
/**
|
|
1607
|
+
* Whether a file is the unit test of the module it sits beside — `x.test.ts`
|
|
1608
|
+
* next to `x.ts`, which is the whole of I2's pairing. This is what tells a
|
|
1609
|
+
* ground MODULE's test from a spec that wandered into the ground.
|
|
1610
|
+
*/
|
|
1611
|
+
function testsItsNeighbour(filename) {
|
|
1612
|
+
return filename.endsWith(TEST_SUFFIX) && isFile(`${filename.slice(0, -8)}.ts`);
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1607
1615
|
* CONVENTIONS C1' — the shape of a spec tree, DECLARED by the consumer.
|
|
1608
1616
|
*
|
|
1609
1617
|
* A project states which shape its `specs/` tree has; the rule checks that one.
|
|
@@ -1649,9 +1657,15 @@ const DEFAULT_DEPTH = "facet-domain";
|
|
|
1649
1657
|
*
|
|
1650
1658
|
* The ground clause holds in every mode, `off` included: a directory whose name
|
|
1651
1659
|
* carries a LEADING UNDERSCORE is ground — what the specs of a row stand on —
|
|
1652
|
-
* and never a domain, so no
|
|
1660
|
+
* and never a domain, so no SPEC may live inside one. That is not a question of
|
|
1653
1661
|
* depth, which is why no project's declared shape switches it off.
|
|
1654
1662
|
*
|
|
1663
|
+
* Ground is not always inert, though: it may be CODE the specs stand on — the
|
|
1664
|
+
* build of the subject under test, a harness the runner spawns. A `<module>.ts`
|
|
1665
|
+
* there keeps its own unit test under I2's law, `<module>.test.ts` NEXT to it,
|
|
1666
|
+
* and the clause lets that one pair through. Nothing else: a test with no module
|
|
1667
|
+
* beside it, or a `*.specification.ts`, is a spec that wandered into the ground.
|
|
1668
|
+
*
|
|
1655
1669
|
* Module tests under `src/` follow the neighbour rule (I2) and are out of scope
|
|
1656
1670
|
* in every mode.
|
|
1657
1671
|
*/
|
|
@@ -1666,7 +1680,7 @@ const c1DomainStructure = {
|
|
|
1666
1680
|
if (base.endsWith(TEST_SUFFIX) || base.endsWith(SPECIFICATION_SUFFIX)) {
|
|
1667
1681
|
const ground = anchor.relative.slice(0, -1).find((segment) => segment.startsWith("_"));
|
|
1668
1682
|
if (ground !== void 0) {
|
|
1669
|
-
context.report({
|
|
1683
|
+
if (!testsItsNeighbour(context.filename)) context.report({
|
|
1670
1684
|
data: { ground },
|
|
1671
1685
|
messageId: "specInGround",
|
|
1672
1686
|
node
|
|
@@ -1733,7 +1747,7 @@ const c1DomainStructure = {
|
|
|
1733
1747
|
defaultOptions: [{ depth: DEFAULT_DEPTH }],
|
|
1734
1748
|
docs: RULE_DOCS["c1-domain-structure"],
|
|
1735
1749
|
messages: {
|
|
1736
|
-
specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/10-linting.md).",
|
|
1750
|
+
specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/10-linting.md). Ground that is CODE keeps its own unit test: `<module>.test.ts` NEXT to the `<module>.ts` it tests (I2), and nothing else.",
|
|
1737
1751
|
specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
|
|
1738
1752
|
testAtFacetRoot: "A `*.test.ts` must live in a domain folder: `specs/<facet>/<domain>/<aspect>.test.ts` — tests directly at the facet root are forbidden (C1 — see docs/10-linting.md).",
|
|
1739
1753
|
testAtSpecsRoot: "A `*.test.ts` must live in a directory under specs/ — with `depth: \"mirror\"` the tree mirrors a structure, and the specs root mirrors nothing (C1 — see docs/10-linting.md).",
|
package/dist/oxlint.js
CHANGED
|
@@ -337,7 +337,7 @@ const RULE_DOCS = {
|
|
|
337
337
|
},
|
|
338
338
|
"c1-domain-structure": {
|
|
339
339
|
channel: "statique",
|
|
340
|
-
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans.",
|
|
340
|
+
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans. Une exception, et une seule : le sol peut être du CODE, et un `<module>.test.ts` posé À CÔTÉ du `<module>.ts` qu’il teste y est légal (c’est la loi I2) ; un test sans module voisin, ou un `*.specification.ts`, reste une violation.",
|
|
341
341
|
family: "C",
|
|
342
342
|
id: "C1",
|
|
343
343
|
rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
|
|
@@ -1600,6 +1600,14 @@ const TEST_SUFFIX = ".test.ts";
|
|
|
1600
1600
|
const SPECIFICATION_SUFFIX = ".specification.ts";
|
|
1601
1601
|
const DEFAULT_DEPTH = "facet-domain";
|
|
1602
1602
|
/**
|
|
1603
|
+
* Whether a file is the unit test of the module it sits beside — `x.test.ts`
|
|
1604
|
+
* next to `x.ts`, which is the whole of I2's pairing. This is what tells a
|
|
1605
|
+
* ground MODULE's test from a spec that wandered into the ground.
|
|
1606
|
+
*/
|
|
1607
|
+
function testsItsNeighbour(filename) {
|
|
1608
|
+
return filename.endsWith(TEST_SUFFIX) && isFile(`${filename.slice(0, -8)}.ts`);
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1603
1611
|
* CONVENTIONS C1' — the shape of a spec tree, DECLARED by the consumer.
|
|
1604
1612
|
*
|
|
1605
1613
|
* A project states which shape its `specs/` tree has; the rule checks that one.
|
|
@@ -1645,9 +1653,15 @@ const DEFAULT_DEPTH = "facet-domain";
|
|
|
1645
1653
|
*
|
|
1646
1654
|
* The ground clause holds in every mode, `off` included: a directory whose name
|
|
1647
1655
|
* carries a LEADING UNDERSCORE is ground — what the specs of a row stand on —
|
|
1648
|
-
* and never a domain, so no
|
|
1656
|
+
* and never a domain, so no SPEC may live inside one. That is not a question of
|
|
1649
1657
|
* depth, which is why no project's declared shape switches it off.
|
|
1650
1658
|
*
|
|
1659
|
+
* Ground is not always inert, though: it may be CODE the specs stand on — the
|
|
1660
|
+
* build of the subject under test, a harness the runner spawns. A `<module>.ts`
|
|
1661
|
+
* there keeps its own unit test under I2's law, `<module>.test.ts` NEXT to it,
|
|
1662
|
+
* and the clause lets that one pair through. Nothing else: a test with no module
|
|
1663
|
+
* beside it, or a `*.specification.ts`, is a spec that wandered into the ground.
|
|
1664
|
+
*
|
|
1651
1665
|
* Module tests under `src/` follow the neighbour rule (I2) and are out of scope
|
|
1652
1666
|
* in every mode.
|
|
1653
1667
|
*/
|
|
@@ -1662,7 +1676,7 @@ const c1DomainStructure = {
|
|
|
1662
1676
|
if (base.endsWith(TEST_SUFFIX) || base.endsWith(SPECIFICATION_SUFFIX)) {
|
|
1663
1677
|
const ground = anchor.relative.slice(0, -1).find((segment) => segment.startsWith("_"));
|
|
1664
1678
|
if (ground !== void 0) {
|
|
1665
|
-
context.report({
|
|
1679
|
+
if (!testsItsNeighbour(context.filename)) context.report({
|
|
1666
1680
|
data: { ground },
|
|
1667
1681
|
messageId: "specInGround",
|
|
1668
1682
|
node
|
|
@@ -1729,7 +1743,7 @@ const c1DomainStructure = {
|
|
|
1729
1743
|
defaultOptions: [{ depth: DEFAULT_DEPTH }],
|
|
1730
1744
|
docs: RULE_DOCS["c1-domain-structure"],
|
|
1731
1745
|
messages: {
|
|
1732
|
-
specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/10-linting.md).",
|
|
1746
|
+
specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/10-linting.md). Ground that is CODE keeps its own unit test: `<module>.test.ts` NEXT to the `<module>.ts` it tests (I2), and nothing else.",
|
|
1733
1747
|
specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
|
|
1734
1748
|
testAtFacetRoot: "A `*.test.ts` must live in a domain folder: `specs/<facet>/<domain>/<aspect>.test.ts` — tests directly at the facet root are forbidden (C1 — see docs/10-linting.md).",
|
|
1735
1749
|
testAtSpecsRoot: "A `*.test.ts` must live in a directory under specs/ — with `depth: \"mirror\"` the tree mirrors a structure, and the specs root mirrors nothing (C1 — see docs/10-linting.md).",
|
package/dist/queue.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as CaptureScope } from "./match.js";
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as hasPlaceholders, L as textEquals, N as placeholderPatternSource } from "./artifacts.js";
|
|
3
3
|
//#region src/core/contracts/contract.ts
|
|
4
4
|
/**
|
|
5
5
|
* Declare one contract. Identity function — its value is the enforced shape
|
package/dist/spec-document.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LineCounter, Scalar, Scalar as Scalar$1, isMap, isScalar, isSeq, parseDocument } from "yaml";
|
|
1
|
+
import { LineCounter, Scalar, Scalar as Scalar$1, isMap, isScalar, isSeq, parseDocument, visit } from "yaml";
|
|
2
2
|
//#region src/integrations/yaml/document.ts
|
|
3
3
|
/** A line that opens a mapping key — ` key:` or ` - key:`, never block-scalar text. */
|
|
4
4
|
const KEY_LINE = /^(?<indent> +)(?:- )?[A-Za-z_$'"][^\n]*:/;
|
|
@@ -65,7 +65,8 @@ function parseYamlSource(text) {
|
|
|
65
65
|
lineCounter
|
|
66
66
|
}),
|
|
67
67
|
indent: detectIndent(text),
|
|
68
|
-
lineAt: (offset) => lineCounter.linePos(offset).line
|
|
68
|
+
lineAt: (offset) => lineCounter.linePos(offset).line,
|
|
69
|
+
text
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
/** The YAML parser's own errors, with their line — an empty list when it parsed. */
|
|
@@ -88,16 +89,94 @@ function blockScalar(text) {
|
|
|
88
89
|
scalar.type = Scalar.BLOCK_LITERAL;
|
|
89
90
|
return scalar;
|
|
90
91
|
}
|
|
92
|
+
/** The first offset of the line `offset` sits on. */
|
|
93
|
+
function lineStart(text, offset) {
|
|
94
|
+
return text.lastIndexOf("\n", offset - 1) + 1;
|
|
95
|
+
}
|
|
96
|
+
/** Back over the whitespace before `offset`, to just past the `:` or `-` that owns it. */
|
|
97
|
+
function afterOwner(text, offset) {
|
|
98
|
+
let at = offset;
|
|
99
|
+
while (at > 0 && /\s/.test(text[at - 1])) at -= 1;
|
|
100
|
+
return at;
|
|
101
|
+
}
|
|
102
|
+
/** How many spaces a line opens with. */
|
|
103
|
+
function indentOf(text, offset) {
|
|
104
|
+
const start = lineStart(text, offset);
|
|
105
|
+
return /^ */.exec(text.slice(start))[0].length;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Every OUTERMOST flow collection of a document, in source order. A nested one
|
|
109
|
+
* is part of its parent's text and is restored with it, so descending past a
|
|
110
|
+
* flow collection would splice the same span twice.
|
|
111
|
+
*/
|
|
112
|
+
function flowCollections(text) {
|
|
113
|
+
const found = [];
|
|
114
|
+
const take = (node) => {
|
|
115
|
+
if (node.flow !== true || !node.range) return;
|
|
116
|
+
const from = afterOwner(text, node.range[0]);
|
|
117
|
+
found.push({
|
|
118
|
+
ownerIndent: indentOf(text, from === 0 ? 0 : from - 1),
|
|
119
|
+
range: [from, node.range[1]],
|
|
120
|
+
text: text.slice(from, node.range[1]),
|
|
121
|
+
value: JSON.stringify(node.toJSON())
|
|
122
|
+
});
|
|
123
|
+
return visit.SKIP;
|
|
124
|
+
};
|
|
125
|
+
visit(parseDocument(text), {
|
|
126
|
+
Map: (_key, node) => take(node),
|
|
127
|
+
Seq: (_key, node) => take(node)
|
|
128
|
+
});
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Give every flow collection the rewrite did not change back exactly as it was
|
|
133
|
+
* written — padding, quotes and line breaks included.
|
|
134
|
+
*
|
|
135
|
+
* The `yaml` writer has ONE house style for `{ … }` and `[ … ]`, and it is not
|
|
136
|
+
* the repository formatter's: oxfmt pads a flow mapping and not a flow sequence,
|
|
137
|
+
* and explodes a long one over several lines, while the writer emits `[ 'a' ]`
|
|
138
|
+
* on one line whatever its width. Re-emitting an untouched collection therefore
|
|
139
|
+
* hands the formatter something to undo, and `check --fix` and `oxfmt` rewrite
|
|
140
|
+
* each other for ever. A collection nobody touched belongs to its author, not to
|
|
141
|
+
* either tool.
|
|
142
|
+
*
|
|
143
|
+
* A rendered collection is matched to a source one by VALUE and by the
|
|
144
|
+
* INDENTATION of the line that owns it: the same content under the same key is
|
|
145
|
+
* the same collection, and the indentation guard is what keeps the inner lines
|
|
146
|
+
* of a multi-line spelling true where a fix moved that key. Anything unmatched
|
|
147
|
+
* — added, removed, edited — keeps the writer's rendering.
|
|
148
|
+
*/
|
|
149
|
+
function restoreUntouchedFlow(rendered, text) {
|
|
150
|
+
const sources = flowCollections(text);
|
|
151
|
+
if (sources.length === 0) return rendered;
|
|
152
|
+
const taken = /* @__PURE__ */ new Set();
|
|
153
|
+
const splices = [];
|
|
154
|
+
for (const target of flowCollections(rendered)) {
|
|
155
|
+
const at = sources.findIndex((candidate, index) => !taken.has(index) && candidate.value === target.value && candidate.ownerIndent === target.ownerIndent);
|
|
156
|
+
if (at === -1) continue;
|
|
157
|
+
taken.add(at);
|
|
158
|
+
splices.push({
|
|
159
|
+
range: target.range,
|
|
160
|
+
text: sources[at].text
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
let out = rendered;
|
|
164
|
+
for (const splice of splices.toReversed()) out = out.slice(0, splice.range[0]) + splice.text + out.slice(splice.range[1]);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
91
167
|
/**
|
|
92
168
|
* Render a document back to text, in the indentation it was read with.
|
|
93
169
|
* `lineWidth: 0` disables the folding of long lines — a golden is compared byte
|
|
94
170
|
* for byte, so nothing may decide on its own where to break one.
|
|
171
|
+
*
|
|
172
|
+
* What the caller did not change comes back as it was written — see
|
|
173
|
+
* {@link restoreUntouchedFlow} for the one construct the writer restyles.
|
|
95
174
|
*/
|
|
96
175
|
function renderYamlSource(source) {
|
|
97
|
-
return source.document.toString({
|
|
176
|
+
return restoreUntouchedFlow(source.document.toString({
|
|
98
177
|
indent: source.indent,
|
|
99
178
|
lineWidth: 0
|
|
100
|
-
});
|
|
179
|
+
}), source.text);
|
|
101
180
|
}
|
|
102
181
|
//#endregion
|
|
103
182
|
//#region src/core/literate/spec-document.ts
|
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.
|
|
3
|
+
"version": "14.1.0",
|
|
4
4
|
"description": "Declarative testing framework for HTTP APIs, CLIs, and background jobs — one entry point, real infrastructure, golden-file assertions, plus an oxlint convention plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api-testing",
|