@jterrazz/test 12.2.0 → 13.0.1

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.
@@ -0,0 +1,536 @@
1
+ import { LineCounter, Scalar, Scalar as Scalar$1, isMap, isScalar, isSeq, parseDocument } from "yaml";
2
+ //#region src/integrations/yaml/document.ts
3
+ /** A line that opens a mapping key — ` key:` or ` - key:`, never block-scalar text. */
4
+ const KEY_LINE = /^(?<indent> +)(?:- )?[A-Za-z_$'"][^\n]*:/;
5
+ /**
6
+ * The indentation step the file was written with, read from the shallowest
7
+ * mapping key it carries. A rewrite must come back in the author's style — a
8
+ * formatter that indents by four and a writer that indents by two would fight
9
+ * over every updated document. Two is YAML's own default, and the fallback.
10
+ */
11
+ function detectIndent(text) {
12
+ let smallest = 0;
13
+ for (const line of text.split("\n")) {
14
+ const found = KEY_LINE.exec(line);
15
+ if (found?.groups && (smallest === 0 || found.groups.indent.length < smallest)) smallest = found.groups.indent.length;
16
+ }
17
+ return smallest === 0 ? 2 : smallest;
18
+ }
19
+ /** Parse a YAML file into its document form, keeping comments and key order. */
20
+ function parseYamlSource(text) {
21
+ const lineCounter = new LineCounter();
22
+ return {
23
+ document: parseDocument(text, {
24
+ keepSourceTokens: true,
25
+ lineCounter
26
+ }),
27
+ indent: detectIndent(text),
28
+ lineAt: (offset) => lineCounter.linePos(offset).line
29
+ };
30
+ }
31
+ /** The YAML parser's own errors, with their line — an empty list when it parsed. */
32
+ function yamlSyntaxErrors(source) {
33
+ return source.document.errors.map((error) => ({
34
+ line: error.linePos?.[0]?.line ?? source.lineAt(error.pos[0]),
35
+ message: error.message
36
+ }));
37
+ }
38
+ /**
39
+ * A literal block scalar. The `yaml` writer picks the chomping indicator from
40
+ * the value itself — `|` when the text ends with a newline, `|-` when it does
41
+ * not — and adds the indentation indicator (`|2`) when the first line starts
42
+ * with a space, which is exactly the byte-exactness the format promises.
43
+ */
44
+ function blockScalar(text) {
45
+ const scalar = new Scalar(text);
46
+ scalar.type = Scalar.BLOCK_LITERAL;
47
+ return scalar;
48
+ }
49
+ /**
50
+ * Render a document back to text, in the indentation it was read with.
51
+ * `lineWidth: 0` disables the folding of long lines — a golden is compared byte
52
+ * for byte, so nothing may decide on its own where to break one.
53
+ */
54
+ function renderYamlSource(source) {
55
+ return source.document.toString({
56
+ indent: source.indent,
57
+ lineWidth: 0
58
+ });
59
+ }
60
+ //#endregion
61
+ //#region src/core/literate/spec-document.ts
62
+ /**
63
+ * The `<case>.spec.yaml` document — one scenario, written as data.
64
+ *
65
+ * A spec document states the ground a session stands on (`fixture:`, `env:`,
66
+ * `serve:`) and then the session itself (`runs:`), each run carrying its
67
+ * command, its exit code, its streams and what it left on disk:
68
+ *
69
+ * description: removes a post and names what is gone
70
+ * fixture: $FIXTURES/posts-stub/
71
+ * env:
72
+ * - frozen
73
+ * runs:
74
+ * - command: posts rm post-recap --yes
75
+ * exit: 0
76
+ * stdout: |
77
+ * Removed post-recap.
78
+ *
79
+ * This module owns the grammar and NOTHING else: it resolves no path, runs no
80
+ * command, and knows no filesystem. It is read by the runner and by the lint
81
+ * checker alike, so the document the lint accepts is exactly the document the
82
+ * runner executes. The JSON Schema editors validate against is built from the
83
+ * same constants, a few lines down — one vocabulary, three readers.
84
+ */
85
+ /** The extension that makes a file a spec document. */
86
+ const SPEC_EXTENSION = ".spec.yaml";
87
+ /**
88
+ * The facets a document may describe. `cli` is the only one implemented; the
89
+ * key exists so a second facet can arrive without a second grammar.
90
+ */
91
+ const SPEC_KINDS = ["cli"];
92
+ /**
93
+ * Top-level keys, IN THE ORDER a document is written in: what the session
94
+ * stands on first, the session last. The order is the canonical one the
95
+ * `d4b-spec-key-order` lint pass enforces and rewrites.
96
+ */
97
+ const DOCUMENT_KEYS = [
98
+ "kind",
99
+ "description",
100
+ "fixture",
101
+ "env",
102
+ "serve",
103
+ "runs"
104
+ ];
105
+ /** The keys of one run, in the canonical order: what is given, then what came back. */
106
+ const RUN_KEYS = [
107
+ "command",
108
+ "stdin",
109
+ "timeout",
110
+ "waitFor",
111
+ "exit",
112
+ "stdout",
113
+ "stderr",
114
+ "files"
115
+ ];
116
+ /** The keys of a `files:` content assertion. */
117
+ const FILE_CONTENT_KEYS = ["contains", "equals"];
118
+ /** The bare states a `files:` entry may name instead of a content assertion. */
119
+ const FILE_STATES = ["absent", "exists"];
120
+ /** A refusal from the grammar, carrying the line it happened on. */
121
+ var SpecSyntaxError = class extends Error {
122
+ line;
123
+ constructor(fileName, line, message) {
124
+ super(`${fileName}:${line}: ${message}`);
125
+ this.line = line;
126
+ this.name = "SpecSyntaxError";
127
+ }
128
+ };
129
+ function fail(context, node, message) {
130
+ throw new SpecSyntaxError(context.fileName, lineOf(context, node), message);
131
+ }
132
+ function lineOf(context, node) {
133
+ const range = node === void 0 || node === null ? void 0 : nodeRange(node);
134
+ return range === void 0 ? 1 : context.source.lineAt(range[0]);
135
+ }
136
+ function nodeRange(node) {
137
+ if ("range" in node && Array.isArray(node.range)) return node.range;
138
+ const key = node.key;
139
+ return key !== null && typeof key === "object" && "range" in key ? key.range ?? void 0 : void 0;
140
+ }
141
+ /** The line a scalar's CONTENT starts on — one past the `|` for a block scalar. */
142
+ function contentLine(context, node) {
143
+ const start = lineOf(context, node);
144
+ return isScalar(node) && String(node.type ?? "").startsWith("BLOCK") ? start + 1 : start;
145
+ }
146
+ function requireMap(context, node, what) {
147
+ if (!isMap(node)) fail(context, node ?? void 0, `${what} must be a mapping of keys to values`);
148
+ return node;
149
+ }
150
+ /** The pairs of a mapping, refusing any key outside the closed set. */
151
+ function closedPairs(context, map, allowed, what) {
152
+ const pairs = [];
153
+ for (const pair of map.items) {
154
+ const key = scalarString(pair.key);
155
+ if (key === null) fail(context, pair, `${what}: a key must be a plain word`);
156
+ if (!allowed.includes(key)) fail(context, pair, `unknown key "${key}:" in ${what} — known keys: ${allowed.join(", ")}`);
157
+ if (pairs.some((seen) => scalarString(seen.key) === key)) fail(context, pair, `"${key}:" is declared twice in ${what}`);
158
+ pairs.push(pair);
159
+ }
160
+ return pairs;
161
+ }
162
+ function scalarString(node) {
163
+ return isScalar(node) && typeof node.value === "string" ? node.value : null;
164
+ }
165
+ function requireString(context, node, what) {
166
+ const value = scalarString(node);
167
+ if (value === null) fail(context, node, `${what} must be text`);
168
+ return value;
169
+ }
170
+ function requireInteger(context, node, what) {
171
+ if (!isScalar(node) || typeof node.value !== "number" || !Number.isInteger(node.value)) fail(context, node, `${what} must be a literal integer`);
172
+ return node.value;
173
+ }
174
+ /** A `string | list of strings` field, flattened to its entries with their lines. */
175
+ function stringList(context, node, what) {
176
+ if (isSeq(node)) return node.items.map((item) => ({
177
+ line: lineOf(context, item),
178
+ value: requireString(context, item, `each entry of ${what}`)
179
+ }));
180
+ return [{
181
+ line: lineOf(context, node),
182
+ value: requireString(context, node, what)
183
+ }];
184
+ }
185
+ function readStream(context, node, what) {
186
+ return {
187
+ line: contentLine(context, node),
188
+ text: requireString(context, node, what)
189
+ };
190
+ }
191
+ function readEnv(context, node) {
192
+ return stringList(context, node, "env:").map(({ line, value }) => {
193
+ const separator = value.indexOf("=");
194
+ if (separator === -1) return {
195
+ kind: "set",
196
+ line,
197
+ name: value
198
+ };
199
+ if (separator === 0) throw new SpecSyntaxError(context.fileName, line, `env: "${value}" has no variable name before the "="`);
200
+ return {
201
+ key: value.slice(0, separator),
202
+ kind: "pair",
203
+ line,
204
+ value: value.slice(separator + 1)
205
+ };
206
+ });
207
+ }
208
+ function readServeEntry(context, node) {
209
+ const name = scalarString(node);
210
+ if (name !== null) return {
211
+ env: {},
212
+ line: lineOf(context, node),
213
+ name
214
+ };
215
+ const map = requireMap(context, node, "a serve: entry");
216
+ if (map.items.length !== 1) fail(context, node, "a serve: entry names ONE server: `- <name>: { KEY: value }`");
217
+ const pair = map.items[0];
218
+ const serverName = scalarString(pair.key);
219
+ if (serverName === null) fail(context, pair, "a serve: entry must name its server with a plain word");
220
+ const env = {};
221
+ const envMap = requireMap(context, pair.value, `serve: ${serverName}`);
222
+ for (const entry of envMap.items) {
223
+ const key = scalarString(entry.key);
224
+ if (key === null) fail(context, entry, `serve: ${serverName} — a variable name must be a plain word`);
225
+ env[key] = requireString(context, entry.value, `serve: ${serverName}.${key}`);
226
+ }
227
+ return {
228
+ env,
229
+ line: lineOf(context, pair),
230
+ name: serverName
231
+ };
232
+ }
233
+ function readServe(context, node) {
234
+ return (isSeq(node) ? node.items : [node]).map((item) => readServeEntry(context, item));
235
+ }
236
+ function readFiles(context, node) {
237
+ return requireMap(context, node, "files:").items.map((pair) => {
238
+ const path = scalarString(pair.key);
239
+ if (path === null) fail(context, pair, "files: each key is a path relative to the working directory");
240
+ const line = lineOf(context, pair);
241
+ const state = scalarString(pair.value);
242
+ if (state !== null) {
243
+ if (!FILE_STATES.includes(state)) fail(context, pair, `files: "${path}: ${state}" is not a state — write ${FILE_STATES.join(" or ")}, or a { ${FILE_CONTENT_KEYS.join(", ")} } assertion`);
244
+ return {
245
+ kind: state,
246
+ line,
247
+ path
248
+ };
249
+ }
250
+ const pairs = closedPairs(context, requireMap(context, pair.value, `files: ${path}`), FILE_CONTENT_KEYS, `files: ${path}`);
251
+ if (pairs.length === 0) fail(context, pair, `files: ${path} asserts nothing — give it contains: or equals:`);
252
+ const contains = [];
253
+ let equals = null;
254
+ for (const entry of pairs) {
255
+ if (scalarString(entry.key) === "contains") {
256
+ if (isSeq(entry.value)) for (const item of entry.value.items) contains.push(readStream(context, item, `files: ${path}.contains`));
257
+ else contains.push(readStream(context, entry.value, `files: ${path}.contains`));
258
+ continue;
259
+ }
260
+ equals = readStream(context, entry.value, `files: ${path}.equals`);
261
+ }
262
+ return {
263
+ contains,
264
+ equals,
265
+ kind: "content",
266
+ line,
267
+ path
268
+ };
269
+ });
270
+ }
271
+ function readRun(context, node, isLast) {
272
+ const pairs = closedPairs(context, requireMap(context, node, "each entry of runs:"), RUN_KEYS, "a run");
273
+ const found = /* @__PURE__ */ new Map();
274
+ for (const pair of pairs) found.set(scalarString(pair.key), pair);
275
+ for (const key of ["command", "exit"]) if (!found.has(key)) fail(context, node, `a run is missing "${key}:" — command and exit are mandatory`);
276
+ const commandPair = found.get("command");
277
+ const command = requireString(context, commandPair.value, "command:");
278
+ const waitForPair = found.get("waitFor");
279
+ if (waitForPair !== void 0 && !isLast) fail(context, waitForPair, "waitFor: is only allowed on the LAST run — a long-running command ends the session");
280
+ if (waitForPair !== void 0 && found.has("stdin")) fail(context, waitForPair, "waitFor: and stdin: cannot share a run — the pattern is watched on a process nobody closed the input of");
281
+ const stdout = found.get("stdout");
282
+ const stderr = found.get("stderr");
283
+ const stdin = found.get("stdin");
284
+ const timeout = found.get("timeout");
285
+ const files = found.get("files");
286
+ return {
287
+ command,
288
+ commandLine: lineOf(context, commandPair),
289
+ exitCode: requireInteger(context, found.get("exit").value, "exit:"),
290
+ files: files === void 0 ? [] : readFiles(context, files.value),
291
+ stderr: stderr === void 0 ? null : readStream(context, stderr.value, "stderr:"),
292
+ stdin: stdin === void 0 ? null : requireString(context, stdin.value, "stdin:"),
293
+ stdout: stdout === void 0 ? null : readStream(context, stdout.value, "stdout:"),
294
+ timeout: timeout === void 0 ? null : requireInteger(context, timeout.value, "timeout:"),
295
+ waitFor: waitForPair === void 0 ? null : requireString(context, waitForPair.value, "waitFor:")
296
+ };
297
+ }
298
+ /**
299
+ * Read a `<case>.spec.yaml`. `fileName` is used verbatim in refusals, so pass
300
+ * the path a reader would open. The YAML source comes back beside the document:
301
+ * update mode rewrites THAT, never a re-serialisation of this structure.
302
+ */
303
+ function readSpecFile(content, fileName) {
304
+ const source = parseYamlSource(content);
305
+ const syntax = yamlSyntaxErrors(source);
306
+ if (syntax.length > 0) throw new SpecSyntaxError(fileName, syntax[0].line, syntax[0].message);
307
+ const context = {
308
+ fileName,
309
+ source
310
+ };
311
+ const root = source.document.contents;
312
+ if (root === null || isScalar(root) && root.value === null) throw new SpecSyntaxError(fileName, 1, "the document is empty");
313
+ const map = requireMap(context, root, "a spec document");
314
+ const pairs = closedPairs(context, map, DOCUMENT_KEYS, "a spec document");
315
+ const found = /* @__PURE__ */ new Map();
316
+ for (const pair of pairs) found.set(scalarString(pair.key), pair);
317
+ for (const key of ["description", "runs"]) if (!found.has(key)) fail(context, map, `missing "${key}:" — description and runs are mandatory`);
318
+ const kindPair = found.get("kind");
319
+ const kind = kindPair === void 0 ? "cli" : requireString(context, kindPair.value, "kind:");
320
+ if (!SPEC_KINDS.includes(kind)) fail(context, kindPair, `kind: "${kind}" is not a facet — known kinds: ${SPEC_KINDS.join(", ")}`);
321
+ const runsNode = found.get("runs");
322
+ if (!isSeq(runsNode.value) || runsNode.value.items.length === 0) fail(context, runsNode, "runs: is a list of at least one run");
323
+ const runItems = runsNode.value.items;
324
+ const fixturePair = found.get("fixture");
325
+ const envPair = found.get("env");
326
+ const servePair = found.get("serve");
327
+ return {
328
+ document: {
329
+ description: requireString(context, found.get("description").value, "description:"),
330
+ descriptionLine: lineOf(context, found.get("description")),
331
+ env: envPair === void 0 ? [] : readEnv(context, envPair.value),
332
+ fixtures: fixturePair === void 0 ? [] : stringList(context, fixturePair.value, "fixture:").map(({ line, value }) => ({
333
+ line,
334
+ path: value
335
+ })),
336
+ kind,
337
+ runs: runItems.map((item, index) => readRun(context, item, index === runItems.length - 1)),
338
+ serve: servePair === void 0 ? [] : readServe(context, servePair.value)
339
+ },
340
+ source
341
+ };
342
+ }
343
+ /**
344
+ * Every text of a document the `{{token}}` grammar governs: the streams a
345
+ * comparison judges, and the `files:` texts it judges them with. `stdin:` is an
346
+ * INPUT — like a `requests/` fixture, it is never matched — and a description or
347
+ * a command is prose, so neither appears here.
348
+ */
349
+ function assertedStreams(document) {
350
+ const streams = [];
351
+ for (const run of document.runs) {
352
+ if (run.stdout !== null) streams.push(run.stdout);
353
+ if (run.stderr !== null) streams.push(run.stderr);
354
+ for (const assertion of run.files) {
355
+ if (assertion.kind !== "content") continue;
356
+ streams.push(...assertion.contains);
357
+ if (assertion.equals !== null) streams.push(assertion.equals);
358
+ }
359
+ }
360
+ return streams;
361
+ }
362
+ /** Read a `<case>.spec.yaml` for its content alone — the shape passes and C9's scan. */
363
+ function parseSpecDocument(content, fileName) {
364
+ return readSpecFile(content, fileName).document;
365
+ }
366
+ /** Insert (or replace) a key at its canonical position in a mapping. */
367
+ function setOrdered(map, key, value, order) {
368
+ const items = map.items;
369
+ if (items.findIndex((pair) => scalarString(pair.key) === key) !== -1) {
370
+ map.set(key, value);
371
+ return;
372
+ }
373
+ const rank = order.indexOf(key);
374
+ const at = items.findIndex((pair) => {
375
+ const seen = scalarString(pair.key);
376
+ return seen !== null && order.indexOf(seen) > rank;
377
+ });
378
+ map.set(key, value);
379
+ if (at !== -1) {
380
+ const added = map.items.pop();
381
+ map.items.splice(at, 0, added);
382
+ }
383
+ }
384
+ /**
385
+ * Rewrite the runs of a spec file from what the commands actually did — exit
386
+ * code and both streams, and nothing else. `files:`, the commands and the
387
+ * header come back byte-identical, comments and key order included, because
388
+ * the YAML DOCUMENT is edited rather than re-emitted from the parsed shape.
389
+ *
390
+ * An empty stream removes its key: absence already asserts emptiness, so a
391
+ * rewritten document says the same thing in fewer words and still passes.
392
+ */
393
+ function updateSpecFile(file, updates) {
394
+ const runs = file.source.document.contents.get("runs", true);
395
+ if (!isSeq(runs)) return renderYamlSource(file.source);
396
+ for (const [index, update] of updates.entries()) {
397
+ const run = runs.items[index];
398
+ if (!isMap(run)) continue;
399
+ setOrdered(run, "exit", update.exitCode, RUN_KEYS);
400
+ for (const [key, text] of [["stdout", update.stdout], ["stderr", update.stderr]]) {
401
+ if (text.length === 0) {
402
+ run.delete(key);
403
+ continue;
404
+ }
405
+ setOrdered(run, key, blockScalar(text), RUN_KEYS);
406
+ }
407
+ }
408
+ return renderYamlSource(file.source);
409
+ }
410
+ const STRING_OR_LIST = { oneOf: [{ type: "string" }, {
411
+ items: { type: "string" },
412
+ minItems: 1,
413
+ type: "array"
414
+ }] };
415
+ /**
416
+ * The JSON Schema editors validate a `<case>.spec.yaml` against, built from the
417
+ * constants above so it cannot describe a grammar the parser does not read. It
418
+ * is committed as `schema/spec.schema.json` by `npm run docs` and published as
419
+ * the `@jterrazz/test/schema` export.
420
+ *
421
+ * A schema states which keys exist, not which ORDER they come in — JSON Schema
422
+ * has no vocabulary for the sequence of an object's members. The canonical
423
+ * order is the `d4b-spec-key-order` lint pass's, which also rewrites it.
424
+ */
425
+ const SPEC_SCHEMA = {
426
+ $defs: {
427
+ fileAssertion: { oneOf: [{
428
+ enum: [...FILE_STATES],
429
+ type: "string"
430
+ }, {
431
+ additionalProperties: false,
432
+ minProperties: 1,
433
+ properties: {
434
+ contains: STRING_OR_LIST,
435
+ equals: { type: "string" }
436
+ },
437
+ type: "object"
438
+ }] },
439
+ run: {
440
+ additionalProperties: false,
441
+ properties: {
442
+ command: {
443
+ description: "The argv line, run through the shell.",
444
+ type: "string"
445
+ },
446
+ exit: {
447
+ description: "The exit code the command must return.",
448
+ type: "integer"
449
+ },
450
+ files: {
451
+ additionalProperties: { $ref: "#/$defs/fileAssertion" },
452
+ description: "On-disk assertions, keyed by a path under the working directory.",
453
+ type: "object"
454
+ },
455
+ stderr: {
456
+ description: "Expected stderr, byte-exact. Absent asserts an empty stream.",
457
+ type: "string"
458
+ },
459
+ stdin: {
460
+ description: "Written to the child, then closed.",
461
+ type: "string"
462
+ },
463
+ stdout: {
464
+ description: "Expected stdout, byte-exact. Absent asserts an empty stream.",
465
+ type: "string"
466
+ },
467
+ timeout: {
468
+ description: "Milliseconds before the run is killed.",
469
+ type: "integer"
470
+ },
471
+ waitFor: {
472
+ description: "Resolve when this text appears. Only on the last run.",
473
+ type: "string"
474
+ }
475
+ },
476
+ required: ["command", "exit"],
477
+ type: "object"
478
+ },
479
+ serveEntry: { oneOf: [{ type: "string" }, {
480
+ additionalProperties: {
481
+ additionalProperties: { type: "string" },
482
+ type: "object"
483
+ },
484
+ maxProperties: 1,
485
+ minProperties: 1,
486
+ type: "object"
487
+ }] }
488
+ },
489
+ $id: "https://jterrazz.com/schema/jterrazz-test/spec.schema.json",
490
+ $schema: "https://json-schema.org/draft/2020-12/schema",
491
+ additionalProperties: false,
492
+ description: "One scenario of the @jterrazz/test literate format: the ground a session stands on, then the session itself.",
493
+ properties: {
494
+ description: {
495
+ description: "The vitest test title — one lowercase line, no trailing period.",
496
+ type: "string"
497
+ },
498
+ env: {
499
+ ...STRING_OR_LIST,
500
+ description: "A bare word names an env set registered in code; KEY=value is inline. $WORKDIR expands."
501
+ },
502
+ fixture: {
503
+ ...STRING_OR_LIST,
504
+ description: "Fixture paths copied into the working directory, layering in order."
505
+ },
506
+ kind: {
507
+ default: "cli",
508
+ description: "The facet this document describes.",
509
+ enum: [...SPEC_KINDS],
510
+ type: "string"
511
+ },
512
+ runs: {
513
+ description: "The session: one or more runs, sequential, sharing one working directory.",
514
+ items: { $ref: "#/$defs/run" },
515
+ minItems: 1,
516
+ type: "array"
517
+ },
518
+ serve: {
519
+ description: "Servers registered in code, started once per file.",
520
+ oneOf: [{ type: "string" }, {
521
+ items: { $ref: "#/$defs/serveEntry" },
522
+ minItems: 1,
523
+ type: "array"
524
+ }]
525
+ }
526
+ },
527
+ required: ["description", "runs"],
528
+ title: "@jterrazz/test spec document",
529
+ type: "object"
530
+ };
531
+ /** {@link SPEC_SCHEMA} in the exact form `schema/spec.schema.json` is committed in. */
532
+ function renderSchema() {
533
+ return `${JSON.stringify(SPEC_SCHEMA, null, 4)}\n`;
534
+ }
535
+ //#endregion
536
+ export { assertedStreams as a, renderSchema as c, isMap as d, isScalar as f, SpecSyntaxError as i, updateSpecFile as l, renderYamlSource as m, RUN_KEYS as n, parseSpecDocument as o, isSeq as p, SPEC_EXTENSION as r, readSpecFile as s, DOCUMENT_KEYS as t, Scalar$1 as u };
package/dist/vitest.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  //#region src/vitest/literate-plugin.d.ts
2
2
  interface LiterateOptions {
3
3
  /**
4
- * Globs added to the project's test include. Default: every `.cli` file.
5
- * Narrow it when a tree holds `.cli` files that are INPUTS to other specs
6
- * (a deliberately-broken header) rather than scenarios to run.
4
+ * Globs added to the project's test include. Default: every `.spec.yaml`
5
+ * file. Narrow it when a tree holds documents that are INPUTS to other
6
+ * specs (a deliberately-wrong golden) rather than scenarios to run.
7
7
  */
8
8
  include?: string[];
9
9
  /**
package/dist/vitest.js CHANGED
@@ -1,15 +1,16 @@
1
- import { t as literateTitle } from "./literate.js";
1
+ import { n as specDescription } from "./literate.js";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { isAbsolute, resolve } from "node:path";
4
4
  //#region src/vitest/literate-plugin.ts
5
5
  /**
6
- * The `literate()` vite plugin — door one of the literate format.
6
+ * The `literate()` vite plugin — door one of the spec-document format.
7
7
  *
8
- * It makes a `<case>.cli` file a TEST FILE: the glob joins the project's test
9
- * include, and each `.cli` is transformed into a one-test module that runs the
10
- * file through the registered `cli` runner. The runner shows the `.cli` path as
11
- * the file and the header's `test:` line as the title, so a failing scenario is
12
- * opened where it is written — not in a `.test.ts` that merely points at it.
8
+ * It makes a `<case>.spec.yaml` file a TEST FILE: the glob joins the project's
9
+ * test include, and each document is transformed into a one-test module that
10
+ * runs the file through the registered `cli` runner. The runner shows the
11
+ * document's path as the file and its `description:` as the title, so a failing
12
+ * scenario is opened where it is written — not in a `.test.ts` that merely
13
+ * points at it.
13
14
  *
14
15
  * ```typescript
15
16
  * // vitest.config.ts
@@ -21,17 +22,17 @@ import { isAbsolute, resolve } from "node:path";
21
22
  * });
22
23
  * ```
23
24
  */
24
- /** The `.cli` extension, matched on the id vite hands the plugin. */
25
- const CLI_FILE = /\.cli(?:\?.*)?$/;
25
+ /** The `.spec.yaml` extension, matched on the id vite hands the plugin. */
26
+ const SPEC_FILE = /\.spec\.yaml(?:\?.*)?$/;
26
27
  /**
27
- * The module a `.cli` file becomes: one `test()` whose title is the header's
28
- * `test:` line and whose body runs the whole file — header, servers, every
29
- * block asserted.
28
+ * The module a `<case>.spec.yaml` becomes: one `test()` whose title is the
29
+ * document's `description:` and whose body runs the whole file — its ground,
30
+ * its servers, every run asserted.
30
31
  *
31
32
  * @internal Exported for unit tests.
32
33
  */
33
34
  function literateModule(content, filePath, specificationPath) {
34
- const title = literateTitle(content, filePath);
35
+ const title = specDescription(content, filePath);
35
36
  return [
36
37
  "import { test } from 'vitest';",
37
38
  `import { cli } from ${JSON.stringify(specificationPath)};`,
@@ -47,7 +48,7 @@ function cleanId(id) {
47
48
  return id.split("?")[0];
48
49
  }
49
50
  function literate(options) {
50
- const include = options.include ?? ["**/*.cli"];
51
+ const include = options.include ?? ["**/*.spec.yaml"];
51
52
  let root = process.cwd();
52
53
  return {
53
54
  config: () => ({ test: { include } }),
@@ -56,7 +57,7 @@ function literate(options) {
56
57
  },
57
58
  enforce: "pre",
58
59
  load: (id) => {
59
- if (!CLI_FILE.test(id)) return null;
60
+ if (!SPEC_FILE.test(id)) return null;
60
61
  const filePath = cleanId(id);
61
62
  const specificationPath = isAbsolute(options.specification) ? options.specification : resolve(root, options.specification);
62
63
  return literateModule(readFileSync(filePath, "utf8"), filePath, specificationPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "12.2.0",
3
+ "version": "13.0.1",
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",
@@ -27,7 +27,8 @@
27
27
  },
28
28
  "files": [
29
29
  "dist",
30
- "!dist/catalog.*"
30
+ "!dist/catalog.*",
31
+ "schema"
31
32
  ],
32
33
  "type": "module",
33
34
  "exports": {
@@ -43,7 +44,8 @@
43
44
  "types": "./dist/oxlint.d.ts",
44
45
  "import": "./dist/oxlint.js",
45
46
  "require": "./dist/oxlint.cjs"
46
- }
47
+ },
48
+ "./schema": "./schema/spec.schema.json"
47
49
  },
48
50
  "publishConfig": {
49
51
  "registry": "https://registry.npmjs.org/"
@@ -52,7 +54,7 @@
52
54
  "build": "tsdown --config tsdown.config.ts",
53
55
  "docs": "npm run build && node dist/catalog.js && typescript docs",
54
56
  "lint": "typescript check && node dist/checker.js specs",
55
- "lint:fix": "typescript fix",
57
+ "lint:fix": "typescript fix && node dist/checker.js specs --fix",
56
58
  "test": "vitest --run"
57
59
  },
58
60
  "dependencies": {