@jterrazz/test 15.2.0 → 16.0.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 +120 -418
- package/dist/ambiguity.js +7 -6
- package/dist/appium.adapter.js +12 -11
- package/dist/artifacts.js +25 -1698
- package/dist/browser/index.js +1484 -1142
- package/dist/checker.d.ts +1 -1
- package/dist/checker.js +1308 -76
- package/dist/fixtures.js +110 -0
- package/dist/index.d.ts +1482 -1011
- package/dist/index.js +1715 -1173
- package/dist/literate.js +1747 -0
- package/dist/match.js +625 -84
- package/dist/oxlint.cjs +3534 -1469
- package/dist/oxlint.d.cts +60 -48
- package/dist/oxlint.d.ts +60 -48
- package/dist/oxlint.js +3534 -1469
- package/dist/playwright.adapter.js +101 -6
- package/dist/{intercept.js → server.js} +11 -3
- package/dist/{queue.js → stream.js} +63 -4
- package/dist/vitest.d.ts +85 -36
- package/dist/vitest.js +177 -43
- package/package.json +31 -11
- package/dist/spec-document.js +0 -651
package/dist/match.js
CHANGED
|
@@ -1,113 +1,654 @@
|
|
|
1
|
-
import {
|
|
2
|
-
//#region src/
|
|
1
|
+
import { LineCounter, Scalar, Scalar as Scalar$1, isMap, isScalar, isSeq, parseDocument, visit } from "yaml";
|
|
2
|
+
//#region src/seams/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]*:/u;
|
|
3
5
|
/**
|
|
4
|
-
* The
|
|
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 indent = KEY_LINE.exec(line)?.groups?.indent;
|
|
15
|
+
if (indent !== void 0 && (smallest === 0 || indent.length < smallest)) smallest = indent.length;
|
|
16
|
+
}
|
|
17
|
+
return smallest === 0 ? 2 : smallest;
|
|
18
|
+
}
|
|
19
|
+
/** The tag every string goes out through — key, plain value and block scalar alike. */
|
|
20
|
+
const STRING_TAG = "tag:yaml.org,2002:str";
|
|
21
|
+
/** An indentation indicator is ONE digit, so nine spaces is the deepest step statable. */
|
|
22
|
+
const DEEPEST_STATABLE_STEP = 9;
|
|
23
|
+
/**
|
|
24
|
+
* Restate a block scalar's indentation indicator from the indentation its
|
|
25
|
+
* content is actually written at.
|
|
26
|
+
*
|
|
27
|
+
* A block scalar whose first line opens on a space NEEDS that indicator: without
|
|
28
|
+
* it a reader takes the space for indentation and gives back a string shorter
|
|
29
|
+
* than the one that was written. The `yaml` writer knows when one is called for
|
|
30
|
+
* and adds it — but it always writes the digit `2`, its own default step, never
|
|
31
|
+
* the step it was asked to indent with. In a four-space document (oxfmt formats
|
|
32
|
+
* YAML at four) `|2` puts every line back two spaces short, so the golden update
|
|
33
|
+
* mode just wrote fails on its very next run.
|
|
34
|
+
*
|
|
35
|
+
* The digit belongs to the block, not to the writer's default: content sits one
|
|
36
|
+
* step below its key, so the file's step is what the indicator names — and `1`
|
|
37
|
+
* at the root, which YAML counts from -1.
|
|
38
|
+
*/
|
|
39
|
+
function stateIndentation(written, ctx) {
|
|
40
|
+
if (!/^[|>][1-9]/u.test(written)) return written;
|
|
41
|
+
const step = ctx.indent === "" ? 1 : ctx.indentStep.length;
|
|
42
|
+
if (step > DEEPEST_STATABLE_STEP) throw new Error(`A block scalar indented by ${step} spaces cannot state its indentation: YAML writes that indicator as a single digit, so ${DEEPEST_STATABLE_STEP} spaces is the deepest step a document may be written with.`);
|
|
43
|
+
return `${written[0]}${step}${written.slice(2)}`;
|
|
44
|
+
}
|
|
45
|
+
/** A scalar tag among the schema's, told from a collection one by what it may hold. */
|
|
46
|
+
function isScalarTag(tag) {
|
|
47
|
+
return typeof tag !== "string" && tag.collection === void 0;
|
|
48
|
+
}
|
|
49
|
+
/** The string tag, wrapped so every block scalar it writes states its own indentation. */
|
|
50
|
+
function statingItsIndentation(tag) {
|
|
51
|
+
const write = tag.stringify;
|
|
52
|
+
if (write === void 0) return tag;
|
|
53
|
+
return {
|
|
54
|
+
...tag,
|
|
55
|
+
stringify: (item, ctx, onComment, onChompKeep) => stateIndentation(write(item, ctx, onComment, onChompKeep), ctx)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Parse a YAML file into its document form, keeping comments and key order. */
|
|
59
|
+
function parseYamlSource(text) {
|
|
60
|
+
const lineCounter = new LineCounter();
|
|
61
|
+
return {
|
|
62
|
+
document: parseDocument(text, {
|
|
63
|
+
customTags: (tags) => tags.map((tag) => isScalarTag(tag) && tag.tag === STRING_TAG ? statingItsIndentation(tag) : tag),
|
|
64
|
+
keepSourceTokens: true,
|
|
65
|
+
lineCounter
|
|
66
|
+
}),
|
|
67
|
+
indent: detectIndent(text),
|
|
68
|
+
lineAt: (offset) => lineCounter.linePos(offset).line,
|
|
69
|
+
text
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** The YAML parser's own errors, with their line — an empty list when it parsed. */
|
|
73
|
+
function yamlSyntaxErrors(source) {
|
|
74
|
+
return source.document.errors.map((error) => ({
|
|
75
|
+
line: error.linePos?.[0]?.line ?? source.lineAt(error.pos[0]),
|
|
76
|
+
message: error.message
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A literal block scalar. The `yaml` writer picks the chomping indicator from
|
|
81
|
+
* the value itself — `|` when the text ends with a newline, `|-` when it does
|
|
82
|
+
* not — and decides whether an indentation indicator is called for, which is
|
|
83
|
+
* when the first line opens on a space. What that indicator SAYS is restated on
|
|
84
|
+
* the way out by `stateIndentation` above, and together the two are the
|
|
85
|
+
* byte-exactness the format promises.
|
|
86
|
+
*/
|
|
87
|
+
function blockScalar(text) {
|
|
88
|
+
const scalar = new Scalar(text);
|
|
89
|
+
scalar.type = Scalar.BLOCK_LITERAL;
|
|
90
|
+
return scalar;
|
|
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/u.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 /^ */u.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 [start, end] = node.range;
|
|
117
|
+
if (start === void 0 || end === void 0) return;
|
|
118
|
+
const from = afterOwner(text, start);
|
|
119
|
+
found.push({
|
|
120
|
+
ownerIndent: indentOf(text, from === 0 ? 0 : from - 1),
|
|
121
|
+
range: [from, end],
|
|
122
|
+
text: text.slice(from, end),
|
|
123
|
+
value: JSON.stringify(node.toJSON())
|
|
124
|
+
});
|
|
125
|
+
return visit.SKIP;
|
|
126
|
+
};
|
|
127
|
+
visit(parseDocument(text), {
|
|
128
|
+
Map: (_key, node) => take(node),
|
|
129
|
+
Seq: (_key, node) => take(node)
|
|
130
|
+
});
|
|
131
|
+
return found;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Give every flow collection the rewrite did not change back exactly as it was
|
|
135
|
+
* written — padding, quotes and line breaks included.
|
|
5
136
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
137
|
+
* The `yaml` writer has ONE house style for `{ … }` and `[ … ]`, and it is not
|
|
138
|
+
* the repository formatter's: oxfmt pads a flow mapping and not a flow sequence,
|
|
139
|
+
* and explodes a long one over several lines, while the writer emits `[ 'a' ]`
|
|
140
|
+
* on one line whatever its width. Re-emitting an untouched collection therefore
|
|
141
|
+
* hands the formatter something to undo, and `check --fix` and `oxfmt` rewrite
|
|
142
|
+
* each other for ever. A collection nobody touched belongs to its author, not to
|
|
143
|
+
* either tool.
|
|
11
144
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
145
|
+
* A rendered collection is matched to a source one by VALUE and by the
|
|
146
|
+
* INDENTATION of the line that owns it: the same content under the same key is
|
|
147
|
+
* the same collection, and the indentation guard is what keeps the inner lines
|
|
148
|
+
* of a multi-line spelling true where a fix moved that key. Anything unmatched
|
|
149
|
+
* — added, removed, edited — keeps the writer's rendering.
|
|
150
|
+
*/
|
|
151
|
+
function restoreUntouchedFlow(rendered, text) {
|
|
152
|
+
const sources = flowCollections(text);
|
|
153
|
+
if (sources.length === 0) return rendered;
|
|
154
|
+
const taken = /* @__PURE__ */ new Set();
|
|
155
|
+
const splices = [];
|
|
156
|
+
for (const target of flowCollections(rendered)) {
|
|
157
|
+
const at = sources.findIndex((candidate, index) => !taken.has(index) && candidate.value === target.value && candidate.ownerIndent === target.ownerIndent);
|
|
158
|
+
if (at === -1) continue;
|
|
159
|
+
const source = sources[at];
|
|
160
|
+
if (source === void 0) continue;
|
|
161
|
+
taken.add(at);
|
|
162
|
+
splices.push({
|
|
163
|
+
range: target.range,
|
|
164
|
+
text: source.text
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
let out = rendered;
|
|
168
|
+
for (const splice of splices.toReversed()) out = out.slice(0, splice.range[0]) + splice.text + out.slice(splice.range[1]);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Render a document back to text, in the indentation it was read with.
|
|
173
|
+
* `lineWidth: 0` disables the folding of long lines — a golden is compared byte
|
|
174
|
+
* for byte, so nothing may decide on its own where to break one.
|
|
17
175
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* channels cannot disagree about where a spec's ground lives.
|
|
176
|
+
* What the caller did not change comes back as it was written — see
|
|
177
|
+
* {@link restoreUntouchedFlow} for the one construct the writer restyles.
|
|
21
178
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/** Database state: `.sql` fragments loaded by `.seed()`. */
|
|
29
|
-
const GROUND_SEEDS = "_seeds";
|
|
30
|
-
/** The four ground directory names, in the order they read. */
|
|
31
|
-
const GROUND_DIRS = [
|
|
32
|
-
GROUND_EXPECTED,
|
|
33
|
-
GROUND_FIXTURES,
|
|
34
|
-
GROUND_REQUESTS,
|
|
35
|
-
GROUND_SEEDS
|
|
36
|
-
];
|
|
179
|
+
function renderYamlSource(source) {
|
|
180
|
+
return restoreUntouchedFlow(source.document.toString({
|
|
181
|
+
indent: source.indent,
|
|
182
|
+
lineWidth: 0
|
|
183
|
+
}), source.text);
|
|
184
|
+
}
|
|
37
185
|
//#endregion
|
|
38
|
-
//#region src/
|
|
186
|
+
//#region src/model/literate/spec-document.ts
|
|
39
187
|
/**
|
|
40
|
-
*
|
|
188
|
+
* The `<case>.spec.yaml` document — one scenario, written as data.
|
|
41
189
|
*
|
|
42
|
-
* A
|
|
190
|
+
* A spec document states the ground a session stands on (`fixture:`, `env:`,
|
|
191
|
+
* `serve:`) and then the session itself (`runs:`), each run carrying its
|
|
192
|
+
* command, its exit code, its streams and what it left on disk:
|
|
43
193
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
194
|
+
* description: removes a post and names what is gone
|
|
195
|
+
* fixture: $FIXTURES/posts-stub/
|
|
196
|
+
* env:
|
|
197
|
+
* - frozen
|
|
198
|
+
* runs:
|
|
199
|
+
* - command: posts rm post-recap --yes
|
|
200
|
+
* exit: 0
|
|
201
|
+
* stdout: |
|
|
202
|
+
* Removed post-recap.
|
|
47
203
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
204
|
+
* This module owns the grammar and NOTHING else: it resolves no path, runs no
|
|
205
|
+
* command, and knows no filesystem. It is read by the runner and by the lint
|
|
206
|
+
* checker alike, so the document the lint accepts is exactly the document the
|
|
207
|
+
* runner executes. The JSON Schema editors validate against is built from the
|
|
208
|
+
* same constants, a few lines down — one vocabulary, three readers.
|
|
52
209
|
*/
|
|
210
|
+
/** The extension that makes a file a spec document. */
|
|
211
|
+
const SPEC_EXTENSION = ".spec.yaml";
|
|
53
212
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* `jterrazz/b2-known-fixture-marker`) validates literals against the same list.
|
|
213
|
+
* The facets a document may describe. `cli` is the only one implemented; the
|
|
214
|
+
* key exists so a second facet can arrive without a second grammar.
|
|
57
215
|
*/
|
|
58
|
-
const
|
|
216
|
+
const SPEC_KINDS = ["cli"];
|
|
59
217
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
218
|
+
* Top-level keys, IN THE ORDER a document is written in: what the session
|
|
219
|
+
* stands on first, the session last. The order is the canonical one the
|
|
220
|
+
* `d4b-spec-key-order` lint pass enforces and rewrites.
|
|
63
221
|
*/
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
222
|
+
const DOCUMENT_KEYS = [
|
|
223
|
+
"kind",
|
|
224
|
+
"description",
|
|
225
|
+
"fixture",
|
|
226
|
+
"env",
|
|
227
|
+
"serve",
|
|
228
|
+
"runs"
|
|
229
|
+
];
|
|
230
|
+
/** The keys of one run, in the canonical order: what is given, then what came back. */
|
|
231
|
+
const RUN_KEYS = [
|
|
232
|
+
"command",
|
|
233
|
+
"stdin",
|
|
234
|
+
"timeout",
|
|
235
|
+
"waitFor",
|
|
236
|
+
"exit",
|
|
237
|
+
"stdout",
|
|
238
|
+
"stderr",
|
|
239
|
+
"files"
|
|
240
|
+
];
|
|
241
|
+
/** The keys of a `files:` content assertion. */
|
|
242
|
+
const FILE_CONTENT_KEYS = ["contains", "equals"];
|
|
243
|
+
/** The bare states a `files:` entry may name instead of a content assertion. */
|
|
244
|
+
const FILE_STATES = ["absent", "exists"];
|
|
245
|
+
/** A refusal from the grammar, carrying the line it happened on. */
|
|
246
|
+
var SpecSyntaxError = class extends Error {
|
|
247
|
+
line;
|
|
248
|
+
constructor(fileName, line, message) {
|
|
249
|
+
super(`${fileName}:${line}: ${message}`);
|
|
250
|
+
this.line = line;
|
|
251
|
+
this.name = "SpecSyntaxError";
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
function fail(context, node, message) {
|
|
255
|
+
throw new SpecSyntaxError(context.fileName, lineOf(context, node), message);
|
|
256
|
+
}
|
|
257
|
+
function lineOf(context, node) {
|
|
258
|
+
const range = node === void 0 || node === null ? void 0 : nodeRange(node);
|
|
259
|
+
return range === void 0 ? 1 : context.source.lineAt(range[0]);
|
|
260
|
+
}
|
|
261
|
+
function nodeRange(node) {
|
|
262
|
+
if ("range" in node && Array.isArray(node.range)) return node.range;
|
|
263
|
+
const { key } = node;
|
|
264
|
+
return key !== null && typeof key === "object" && "range" in key ? key.range ?? void 0 : void 0;
|
|
265
|
+
}
|
|
266
|
+
/** The line a scalar's CONTENT starts on — one past the `|` for a block scalar. */
|
|
267
|
+
function contentLine(context, node) {
|
|
268
|
+
const start = lineOf(context, node);
|
|
269
|
+
return isScalar(node) && String(node.type ?? "").startsWith("BLOCK") ? start + 1 : start;
|
|
270
|
+
}
|
|
271
|
+
function requireMap(context, node, what) {
|
|
272
|
+
if (!isMap(node)) fail(context, node ?? void 0, `${what} must be a mapping of keys to values`);
|
|
273
|
+
return node;
|
|
274
|
+
}
|
|
275
|
+
/** The pairs of a mapping, refusing any key outside the closed set. */
|
|
276
|
+
function closedPairs(context, map, allowed, what) {
|
|
277
|
+
const pairs = [];
|
|
278
|
+
for (const pair of map.items) {
|
|
279
|
+
const key = scalarString(pair.key);
|
|
280
|
+
if (key === null) fail(context, pair, `${what}: a key must be a plain word`);
|
|
281
|
+
if (!allowed.includes(key)) fail(context, pair, `unknown key "${key}:" in ${what} — known keys: ${allowed.join(", ")}`);
|
|
282
|
+
if (pairs.some((seen) => scalarString(seen.key) === key)) fail(context, pair, `"${key}:" is declared twice in ${what}`);
|
|
283
|
+
pairs.push(pair);
|
|
284
|
+
}
|
|
285
|
+
return pairs;
|
|
286
|
+
}
|
|
287
|
+
function scalarString(node) {
|
|
288
|
+
return isScalar(node) && typeof node.value === "string" ? node.value : null;
|
|
289
|
+
}
|
|
290
|
+
function requireString(context, node, what) {
|
|
291
|
+
const value = scalarString(node);
|
|
292
|
+
if (value === null) fail(context, node, `${what} must be text`);
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
function requireInteger(context, node, what) {
|
|
296
|
+
if (!isScalar(node) || typeof node.value !== "number" || !Number.isInteger(node.value)) fail(context, node, `${what} must be a literal integer`);
|
|
297
|
+
return node.value;
|
|
298
|
+
}
|
|
299
|
+
/** A `string | list of strings` field, flattened to its entries with their lines. */
|
|
300
|
+
function stringList(context, node, what) {
|
|
301
|
+
if (isSeq(node)) return node.items.map((item) => ({
|
|
302
|
+
line: lineOf(context, item),
|
|
303
|
+
value: requireString(context, item, `each entry of ${what}`)
|
|
304
|
+
}));
|
|
305
|
+
return [{
|
|
306
|
+
line: lineOf(context, node),
|
|
307
|
+
value: requireString(context, node, what)
|
|
308
|
+
}];
|
|
309
|
+
}
|
|
310
|
+
function readStream(context, node, what) {
|
|
311
|
+
return {
|
|
312
|
+
line: contentLine(context, node),
|
|
313
|
+
text: requireString(context, node, what)
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function readEnv(context, node) {
|
|
317
|
+
return stringList(context, node, "env:").map(({ line, value }) => {
|
|
318
|
+
const separator = value.indexOf("=");
|
|
319
|
+
if (separator === -1) return {
|
|
320
|
+
kind: "set",
|
|
321
|
+
line,
|
|
322
|
+
name: value
|
|
323
|
+
};
|
|
324
|
+
if (separator === 0) throw new SpecSyntaxError(context.fileName, line, `env: "${value}" has no variable name before the "="`);
|
|
325
|
+
return {
|
|
326
|
+
key: value.slice(0, separator),
|
|
327
|
+
kind: "pair",
|
|
328
|
+
line,
|
|
329
|
+
value: value.slice(separator + 1)
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
function readServeEntry(context, node) {
|
|
334
|
+
const name = scalarString(node);
|
|
335
|
+
if (name !== null) return {
|
|
336
|
+
env: {},
|
|
337
|
+
line: lineOf(context, node),
|
|
338
|
+
name
|
|
339
|
+
};
|
|
340
|
+
const map = requireMap(context, node, "a serve: entry");
|
|
341
|
+
if (map.items.length !== 1) fail(context, node, "a serve: entry names ONE server: `- <name>: { KEY: value }`");
|
|
342
|
+
const pair = map.items[0];
|
|
343
|
+
const serverName = scalarString(pair.key);
|
|
344
|
+
if (serverName === null) fail(context, pair, "a serve: entry must name its server with a plain word");
|
|
345
|
+
const env = {};
|
|
346
|
+
const envMap = requireMap(context, pair.value, `serve: ${serverName}`);
|
|
347
|
+
for (const entry of envMap.items) {
|
|
348
|
+
const key = scalarString(entry.key);
|
|
349
|
+
if (key === null) fail(context, entry, `serve: ${serverName} — a variable name must be a plain word`);
|
|
350
|
+
env[key] = requireString(context, entry.value, `serve: ${serverName}.${key}`);
|
|
71
351
|
}
|
|
72
|
-
|
|
352
|
+
return {
|
|
353
|
+
env,
|
|
354
|
+
line: lineOf(context, pair),
|
|
355
|
+
name: serverName
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function readServe(context, node) {
|
|
359
|
+
return (isSeq(node) ? node.items : [node]).map((item) => readServeEntry(context, item));
|
|
360
|
+
}
|
|
361
|
+
function readFiles(context, node) {
|
|
362
|
+
return requireMap(context, node, "files:").items.map((pair) => {
|
|
363
|
+
const path = scalarString(pair.key);
|
|
364
|
+
if (path === null) fail(context, pair, "files: each key is a path relative to the working directory");
|
|
365
|
+
const line = lineOf(context, pair);
|
|
366
|
+
const state = scalarString(pair.value);
|
|
367
|
+
if (state !== null) {
|
|
368
|
+
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`);
|
|
369
|
+
return {
|
|
370
|
+
kind: state,
|
|
371
|
+
line,
|
|
372
|
+
path
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const pairs = closedPairs(context, requireMap(context, pair.value, `files: ${path}`), FILE_CONTENT_KEYS, `files: ${path}`);
|
|
376
|
+
if (pairs.length === 0) fail(context, pair, `files: ${path} asserts nothing — give it contains: or equals:`);
|
|
377
|
+
const contains = [];
|
|
378
|
+
let equals = null;
|
|
379
|
+
for (const entry of pairs) {
|
|
380
|
+
if (scalarString(entry.key) === "contains") {
|
|
381
|
+
if (isSeq(entry.value)) for (const item of entry.value.items) contains.push(readStream(context, item, `files: ${path}.contains`));
|
|
382
|
+
else contains.push(readStream(context, entry.value, `files: ${path}.contains`));
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
equals = readStream(context, entry.value, `files: ${path}.equals`);
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
contains,
|
|
389
|
+
equals,
|
|
390
|
+
kind: "content",
|
|
391
|
+
line,
|
|
392
|
+
path
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
function readRun(context, node, isLast) {
|
|
397
|
+
const pairs = closedPairs(context, requireMap(context, node, "each entry of runs:"), RUN_KEYS, "a run");
|
|
398
|
+
const found = /* @__PURE__ */ new Map();
|
|
399
|
+
for (const pair of pairs) found.set(scalarString(pair.key), pair);
|
|
400
|
+
for (const key of ["command", "exit"]) if (!found.has(key)) fail(context, node, `a run is missing "${key}:" — command and exit are mandatory`);
|
|
401
|
+
const commandPair = found.get("command");
|
|
402
|
+
const command = requireString(context, commandPair.value, "command:");
|
|
403
|
+
const waitForPair = found.get("waitFor");
|
|
404
|
+
if (waitForPair !== void 0 && !isLast) fail(context, waitForPair, "waitFor: is only allowed on the LAST run — a long-running command ends the session");
|
|
405
|
+
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");
|
|
406
|
+
const stdout = found.get("stdout");
|
|
407
|
+
const stderr = found.get("stderr");
|
|
408
|
+
const stdin = found.get("stdin");
|
|
409
|
+
const timeout = found.get("timeout");
|
|
410
|
+
const files = found.get("files");
|
|
411
|
+
return {
|
|
412
|
+
command,
|
|
413
|
+
commandLine: lineOf(context, commandPair),
|
|
414
|
+
exitCode: requireInteger(context, found.get("exit").value, "exit:"),
|
|
415
|
+
files: files === void 0 ? [] : readFiles(context, files.value),
|
|
416
|
+
stderr: stderr === void 0 ? null : readStream(context, stderr.value, "stderr:"),
|
|
417
|
+
stdin: stdin === void 0 ? null : requireString(context, stdin.value, "stdin:"),
|
|
418
|
+
stdout: stdout === void 0 ? null : readStream(context, stdout.value, "stdout:"),
|
|
419
|
+
timeout: timeout === void 0 ? null : requireInteger(context, timeout.value, "timeout:"),
|
|
420
|
+
waitFor: waitForPair === void 0 ? null : requireString(context, waitForPair.value, "waitFor:")
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Read a `<case>.spec.yaml`. `fileName` is used verbatim in refusals, so pass
|
|
425
|
+
* the path a reader would open. The YAML source comes back beside the document:
|
|
426
|
+
* update mode rewrites THAT, never a re-serialisation of this structure.
|
|
427
|
+
*/
|
|
428
|
+
function readSpecFile(content, fileName) {
|
|
429
|
+
const source = parseYamlSource(content);
|
|
430
|
+
const [syntax] = yamlSyntaxErrors(source);
|
|
431
|
+
if (syntax !== void 0) throw new SpecSyntaxError(fileName, syntax.line, syntax.message);
|
|
432
|
+
const context = {
|
|
433
|
+
fileName,
|
|
434
|
+
source
|
|
435
|
+
};
|
|
436
|
+
const root = source.document.contents;
|
|
437
|
+
if (root === null || isScalar(root) && root.value === null) throw new SpecSyntaxError(fileName, 1, "the document is empty");
|
|
438
|
+
const map = requireMap(context, root, "a spec document");
|
|
439
|
+
const pairs = closedPairs(context, map, DOCUMENT_KEYS, "a spec document");
|
|
440
|
+
const found = /* @__PURE__ */ new Map();
|
|
441
|
+
for (const pair of pairs) found.set(scalarString(pair.key), pair);
|
|
442
|
+
for (const key of ["description", "runs"]) if (!found.has(key)) fail(context, map, `missing "${key}:" — description and runs are mandatory`);
|
|
443
|
+
const kindPair = found.get("kind");
|
|
444
|
+
const kind = kindPair === void 0 ? "cli" : requireString(context, kindPair.value, "kind:");
|
|
445
|
+
if (!SPEC_KINDS.includes(kind)) fail(context, kindPair, `kind: "${kind}" is not a facet — known kinds: ${SPEC_KINDS.join(", ")}`);
|
|
446
|
+
const runsNode = found.get("runs");
|
|
447
|
+
if (!isSeq(runsNode.value) || runsNode.value.items.length === 0) fail(context, runsNode, "runs: is a list of at least one run");
|
|
448
|
+
const runItems = runsNode.value.items;
|
|
449
|
+
const fixturePair = found.get("fixture");
|
|
450
|
+
const envPair = found.get("env");
|
|
451
|
+
const servePair = found.get("serve");
|
|
452
|
+
return {
|
|
453
|
+
document: {
|
|
454
|
+
description: requireString(context, found.get("description").value, "description:"),
|
|
455
|
+
descriptionLine: lineOf(context, found.get("description")),
|
|
456
|
+
env: envPair === void 0 ? [] : readEnv(context, envPair.value),
|
|
457
|
+
fixtures: fixturePair === void 0 ? [] : stringList(context, fixturePair.value, "fixture:").map(({ line, value }) => ({
|
|
458
|
+
line,
|
|
459
|
+
path: value
|
|
460
|
+
})),
|
|
461
|
+
kind,
|
|
462
|
+
runs: runItems.map((item, index) => readRun(context, item, index === runItems.length - 1)),
|
|
463
|
+
serve: servePair === void 0 ? [] : readServe(context, servePair.value)
|
|
464
|
+
},
|
|
465
|
+
source
|
|
466
|
+
};
|
|
73
467
|
}
|
|
74
468
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
469
|
+
* Every text of a document the `{{token}}` grammar governs: the streams a
|
|
470
|
+
* comparison judges, and the `files:` texts it judges them with. `stdin:` is an
|
|
471
|
+
* INPUT — like a `_requests/` fixture, it is never matched — and a description or
|
|
472
|
+
* a command is prose, so neither appears here.
|
|
78
473
|
*/
|
|
79
|
-
function
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
474
|
+
function assertedStreams(document) {
|
|
475
|
+
const streams = [];
|
|
476
|
+
for (const run of document.runs) {
|
|
477
|
+
if (run.stdout !== null) streams.push(run.stdout);
|
|
478
|
+
if (run.stderr !== null) streams.push(run.stderr);
|
|
479
|
+
for (const assertion of run.files) {
|
|
480
|
+
if (assertion.kind !== "content") continue;
|
|
481
|
+
streams.push(...assertion.contains);
|
|
482
|
+
if (assertion.equals !== null) streams.push(assertion.equals);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return streams;
|
|
486
|
+
}
|
|
487
|
+
/** Read a `<case>.spec.yaml` for its content alone — the shape passes and C9's scan. */
|
|
488
|
+
function parseSpecDocument(content, fileName) {
|
|
489
|
+
return readSpecFile(content, fileName).document;
|
|
490
|
+
}
|
|
491
|
+
/** Insert (or replace) a key at its canonical position in a mapping. */
|
|
492
|
+
function setOrdered(map, key, value, order) {
|
|
493
|
+
const items = map.items;
|
|
494
|
+
if (items.findIndex((pair) => scalarString(pair.key) === key) !== -1) {
|
|
495
|
+
map.set(key, value);
|
|
496
|
+
return;
|
|
84
497
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
498
|
+
const rank = order.indexOf(key);
|
|
499
|
+
const at = items.findIndex((pair) => {
|
|
500
|
+
const seen = scalarString(pair.key);
|
|
501
|
+
return seen !== null && order.indexOf(seen) > rank;
|
|
502
|
+
});
|
|
503
|
+
map.set(key, value);
|
|
504
|
+
if (at !== -1) {
|
|
505
|
+
const added = map.items.pop();
|
|
506
|
+
map.items.splice(at, 0, added);
|
|
88
507
|
}
|
|
89
|
-
return resolve(testDir, GROUND_FIXTURES, clean);
|
|
90
508
|
}
|
|
91
509
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* - `dir` (no slash, a directory) — the directory is copied as `<cwd>/<basename>`.
|
|
97
|
-
* - `file` — copied as `<cwd>/<basename>`.
|
|
510
|
+
* Rewrite the runs of a spec file from what the commands actually did — exit
|
|
511
|
+
* code and both streams, and nothing else. `files:`, the commands and the
|
|
512
|
+
* header come back byte-identical, comments and key order included, because
|
|
513
|
+
* the YAML DOCUMENT is edited rather than re-emitted from the parsed shape.
|
|
98
514
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
515
|
+
* An empty stream removes its key: absence already asserts emptiness, so a
|
|
516
|
+
* rewritten document says the same thing in fewer words and still passes.
|
|
101
517
|
*/
|
|
102
|
-
function
|
|
103
|
-
const
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
518
|
+
function updateSpecFile(file, updates) {
|
|
519
|
+
const runs = file.source.document.contents.get("runs", true);
|
|
520
|
+
if (!isSeq(runs)) return renderYamlSource(file.source);
|
|
521
|
+
for (const [index, update] of updates.entries()) {
|
|
522
|
+
const run = runs.items[index];
|
|
523
|
+
if (!isMap(run)) continue;
|
|
524
|
+
setOrdered(run, "exit", update.exitCode, RUN_KEYS);
|
|
525
|
+
for (const [key, text] of [["stdout", update.stdout], ["stderr", update.stderr]]) {
|
|
526
|
+
if (text.length === 0) {
|
|
527
|
+
run.delete(key);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
setOrdered(run, key, blockScalar(text), RUN_KEYS);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return renderYamlSource(file.source);
|
|
534
|
+
}
|
|
535
|
+
const STRING_OR_LIST = { oneOf: [{ type: "string" }, {
|
|
536
|
+
items: { type: "string" },
|
|
537
|
+
minItems: 1,
|
|
538
|
+
type: "array"
|
|
539
|
+
}] };
|
|
540
|
+
const SPEC_SCHEMA = {
|
|
541
|
+
$defs: {
|
|
542
|
+
fileAssertion: { oneOf: [{
|
|
543
|
+
enum: FILE_STATES,
|
|
544
|
+
type: "string"
|
|
545
|
+
}, {
|
|
546
|
+
additionalProperties: false,
|
|
547
|
+
minProperties: 1,
|
|
548
|
+
properties: {
|
|
549
|
+
contains: STRING_OR_LIST,
|
|
550
|
+
equals: { type: "string" }
|
|
551
|
+
},
|
|
552
|
+
type: "object"
|
|
553
|
+
}] },
|
|
554
|
+
run: {
|
|
555
|
+
additionalProperties: false,
|
|
556
|
+
properties: {
|
|
557
|
+
command: {
|
|
558
|
+
description: "The argv line, run through the shell.",
|
|
559
|
+
type: "string"
|
|
560
|
+
},
|
|
561
|
+
exit: {
|
|
562
|
+
description: "The exit code the command must return.",
|
|
563
|
+
type: "integer"
|
|
564
|
+
},
|
|
565
|
+
files: {
|
|
566
|
+
additionalProperties: { $ref: "#/$defs/fileAssertion" },
|
|
567
|
+
description: "On-disk assertions, keyed by a path under the working directory.",
|
|
568
|
+
type: "object"
|
|
569
|
+
},
|
|
570
|
+
stderr: {
|
|
571
|
+
description: "Expected stderr, byte-exact. Absent asserts an empty stream.",
|
|
572
|
+
type: "string"
|
|
573
|
+
},
|
|
574
|
+
stdin: {
|
|
575
|
+
description: "Written to the child, then closed.",
|
|
576
|
+
type: "string"
|
|
577
|
+
},
|
|
578
|
+
stdout: {
|
|
579
|
+
description: "Expected stdout, byte-exact. Absent asserts an empty stream.",
|
|
580
|
+
type: "string"
|
|
581
|
+
},
|
|
582
|
+
timeout: {
|
|
583
|
+
description: "Milliseconds before the run is killed.",
|
|
584
|
+
type: "integer"
|
|
585
|
+
},
|
|
586
|
+
waitFor: {
|
|
587
|
+
description: "Resolve when this text appears. Only on the last run.",
|
|
588
|
+
type: "string"
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
required: ["command", "exit"],
|
|
592
|
+
type: "object"
|
|
593
|
+
},
|
|
594
|
+
serveEntry: { oneOf: [{ type: "string" }, {
|
|
595
|
+
additionalProperties: {
|
|
596
|
+
additionalProperties: { type: "string" },
|
|
597
|
+
type: "object"
|
|
598
|
+
},
|
|
599
|
+
maxProperties: 1,
|
|
600
|
+
minProperties: 1,
|
|
601
|
+
type: "object"
|
|
602
|
+
}] }
|
|
603
|
+
},
|
|
604
|
+
$id: "https://jterrazz.com/schema/jterrazz-test/spec.schema.json",
|
|
605
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
606
|
+
additionalProperties: false,
|
|
607
|
+
description: "One scenario of the @jterrazz/test literate format: the ground a session stands on, then the session itself.",
|
|
608
|
+
properties: {
|
|
609
|
+
description: {
|
|
610
|
+
description: "The vitest test title — one lowercase line, no trailing period.",
|
|
611
|
+
type: "string"
|
|
612
|
+
},
|
|
613
|
+
env: {
|
|
614
|
+
...STRING_OR_LIST,
|
|
615
|
+
description: "A bare word names an env set registered in code; KEY=value is inline. $WORKDIR expands."
|
|
616
|
+
},
|
|
617
|
+
fixture: {
|
|
618
|
+
...STRING_OR_LIST,
|
|
619
|
+
description: "Fixture paths copied into the working directory, layering in order. `$FIXTURES/<name>` is the shared pool at `<specs-root>/_fixtures/`; a bare path is the ground beside this document, at `_fixtures/<name>`."
|
|
620
|
+
},
|
|
621
|
+
kind: {
|
|
622
|
+
default: "cli",
|
|
623
|
+
description: "The facet this document describes.",
|
|
624
|
+
enum: [...SPEC_KINDS],
|
|
625
|
+
type: "string"
|
|
626
|
+
},
|
|
627
|
+
runs: {
|
|
628
|
+
description: "The session: one or more runs, sequential, sharing one working directory.",
|
|
629
|
+
items: { $ref: "#/$defs/run" },
|
|
630
|
+
minItems: 1,
|
|
631
|
+
type: "array"
|
|
632
|
+
},
|
|
633
|
+
serve: {
|
|
634
|
+
description: "Servers registered in code, started once per file.",
|
|
635
|
+
oneOf: [{ type: "string" }, {
|
|
636
|
+
items: { $ref: "#/$defs/serveEntry" },
|
|
637
|
+
minItems: 1,
|
|
638
|
+
type: "array"
|
|
639
|
+
}]
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
required: ["description", "runs"],
|
|
643
|
+
title: "@jterrazz/test spec document",
|
|
644
|
+
type: "object"
|
|
645
|
+
};
|
|
646
|
+
/** {@link SPEC_SCHEMA} in the exact form `schema/spec.schema.json` is committed in. */
|
|
647
|
+
function renderSchema() {
|
|
648
|
+
return `${JSON.stringify(SPEC_SCHEMA, null, 4)}\n`;
|
|
108
649
|
}
|
|
109
650
|
//#endregion
|
|
110
|
-
//#region src/
|
|
651
|
+
//#region src/model/matching/match.ts
|
|
111
652
|
/** Every kind usable as a `{{token}}` in fixture files (all but ref/regex/includes). */
|
|
112
653
|
const TOKEN_KINDS = [
|
|
113
654
|
"any",
|
|
@@ -260,4 +801,4 @@ var CaptureScope = class {
|
|
|
260
801
|
}
|
|
261
802
|
};
|
|
262
803
|
//#endregion
|
|
263
|
-
export {
|
|
804
|
+
export { isSeq as _, DOCUMENT_KEYS as a, SpecSyntaxError as c, readSpecFile as d, renderSchema as f, isScalar as g, isMap as h, match as i, assertedStreams as l, Scalar$1 as m, Matcher as n, RUN_KEYS as o, updateSpecFile as p, TOKEN_KINDS as r, SPEC_EXTENSION as s, CaptureScope as t, parseSpecDocument as u, renderYamlSource as v };
|