@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.
- package/README.md +12 -14
- package/dist/checker.js +554 -48
- package/dist/index.d.ts +111 -96
- package/dist/index.js +16 -14
- package/dist/literate.js +156 -164
- package/dist/match.js +60 -180
- package/dist/oxlint.cjs +83 -11
- package/dist/oxlint.js +83 -11
- package/dist/queue.js +1 -1
- package/dist/spec-document.js +536 -0
- package/dist/vitest.d.ts +3 -3
- package/dist/vitest.js +16 -15
- package/package.json +6 -4
- package/schema/spec.schema.json +179 -0
|
@@ -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 `.
|
|
5
|
-
* Narrow it when a tree holds
|
|
6
|
-
* (a deliberately-
|
|
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 {
|
|
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
|
|
6
|
+
* The `literate()` vite plugin — door one of the spec-document format.
|
|
7
7
|
*
|
|
8
|
-
* It makes a `<case>.
|
|
9
|
-
* include, and each
|
|
10
|
-
* file through the registered `cli` runner. The runner shows the
|
|
11
|
-
* the file and
|
|
12
|
-
* opened where it is written — not in a `.test.ts` that merely
|
|
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 `.
|
|
25
|
-
const
|
|
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
|
|
28
|
-
* `
|
|
29
|
-
*
|
|
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 =
|
|
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 ?? ["**/*.
|
|
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 (!
|
|
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": "
|
|
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": {
|