@jterrazz/test 14.3.0 → 15.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/dist/ambiguity.js +2 -2
- package/dist/appium.adapter.js +34 -25
- package/dist/artifacts.js +503 -525
- package/dist/checker.js +74 -73
- package/dist/index.d.ts +587 -660
- package/dist/index.js +191 -188
- package/dist/match.js +6 -16
- package/dist/oxlint.cjs +681 -873
- package/dist/oxlint.d.cts +7066 -17
- package/dist/oxlint.d.ts +7066 -17
- package/dist/oxlint.js +681 -873
- package/dist/playwright.adapter.js +36 -14
- package/dist/queue.js +27 -28
- package/dist/spec-document.js +19 -25
- package/dist/vitest.d.ts +6 -6
- package/dist/vitest.js +2 -2
- package/package.json +10 -6
package/dist/artifacts.js
CHANGED
|
@@ -4,9 +4,271 @@ import { cpSync, existsSync, readFileSync, realpathSync, statSync, writeFileSync
|
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { execSync, spawn } from "node:child_process";
|
|
6
6
|
import { readdir } from "node:fs/promises";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
7
|
import { createServer } from "node:net";
|
|
9
|
-
//#region src/
|
|
8
|
+
//#region src/specification/facets/_common/caller.ts
|
|
9
|
+
/** Sibling module tests are callers, not internals (CONVENTIONS I2). */
|
|
10
|
+
const TEST_FILE = /\.test\.[cm]?[jt]s$/u;
|
|
11
|
+
/** Where this very module sits inside the framework's SOURCE tree. */
|
|
12
|
+
const SOURCE_LOCATION = join("specification", "facets", "_common");
|
|
13
|
+
/** The real path of `path`, or `path` itself when it cannot be resolved. */
|
|
14
|
+
function realPath(path) {
|
|
15
|
+
try {
|
|
16
|
+
return realpathSync(path);
|
|
17
|
+
} catch {
|
|
18
|
+
return path;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Directory of the running framework module itself. From the built package
|
|
23
|
+
* this is `<package root>/dist`, whatever the resolution path — including a
|
|
24
|
+
* `file:` link, where the real path carries neither `node_modules` nor
|
|
25
|
+
* `/src/…` and would otherwise be mistaken for a caller frame.
|
|
26
|
+
*/
|
|
27
|
+
const FRAMEWORK_DIR = realPath(import.meta.dirname);
|
|
28
|
+
/**
|
|
29
|
+
* The directory holding the framework's OWN modules: `<package>/dist` for a
|
|
30
|
+
* built install (everything is bundled there), `<repo>/src` when the framework
|
|
31
|
+
* runs from source — this module's known location inside that tree is what
|
|
32
|
+
* tells the two apart.
|
|
33
|
+
*/
|
|
34
|
+
const FRAMEWORK_TREE = FRAMEWORK_DIR.endsWith(`${sep}${SOURCE_LOCATION}`) ? resolve(FRAMEWORK_DIR, "../../..") : FRAMEWORK_DIR;
|
|
35
|
+
/** Is `path` inside `directory` (strictly — a directory is not inside itself)? */
|
|
36
|
+
function isInside(directory, path) {
|
|
37
|
+
const rest = relative(directory, path);
|
|
38
|
+
return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Is this stack frame a module of the framework itself, rather than of the code
|
|
42
|
+
* calling it?
|
|
43
|
+
*
|
|
44
|
+
* Answered by IDENTITY: the frame is inside the framework's own directory.
|
|
45
|
+
* Answering it by substring — `/src/core/`, `/src/integrations/`, `/src/vitest/`
|
|
46
|
+
* — was answering a different question, "does this path look like the framework's
|
|
47
|
+
* layout", and a consumer that happens to have its own `src/core/` matched it.
|
|
48
|
+
* Its files were then skipped as framework internals, and fixture resolution
|
|
49
|
+
* anchored on the wrong directory (or fell through to the cwd) with nothing
|
|
50
|
+
* reported.
|
|
51
|
+
*
|
|
52
|
+
* @internal
|
|
53
|
+
*/
|
|
54
|
+
function isFrameworkFrame(filePath, frameworkTree = FRAMEWORK_TREE) {
|
|
55
|
+
if (TEST_FILE.test(filePath)) return false;
|
|
56
|
+
return isInside(realPath(frameworkTree), realPath(filePath));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Detect the directory of the first stack frame that lives outside this
|
|
60
|
+
* package. Used to anchor fixture resolution (`_seeds/`, `_requests/`,
|
|
61
|
+
* `_expected/`, …) on the calling test / specification file.
|
|
62
|
+
*
|
|
63
|
+
* @internal
|
|
64
|
+
*/
|
|
65
|
+
function getCallerDir() {
|
|
66
|
+
const { stack } = /* @__PURE__ */ new Error("caller detection");
|
|
67
|
+
if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
|
|
68
|
+
const lines = stack.split("\n");
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const match = /at\s+(?:.*?\()?(?:file:\/\/)?(?<filePath>[^:)]+):\d+:\d+/u.exec(line);
|
|
71
|
+
if (!match?.groups?.filePath) continue;
|
|
72
|
+
const { filePath } = match.groups;
|
|
73
|
+
if (filePath.includes("node_modules")) continue;
|
|
74
|
+
if (isFrameworkFrame(filePath)) continue;
|
|
75
|
+
return resolve(filePath, "..");
|
|
76
|
+
}
|
|
77
|
+
return process.cwd();
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/specification/facets/_common/result/grep.ts
|
|
81
|
+
/**
|
|
82
|
+
* Extract text blocks from output that contain a pattern.
|
|
83
|
+
* Splits by blank lines (how linter/compiler output is structured),
|
|
84
|
+
* returns only blocks matching the pattern.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
88
|
+
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
89
|
+
*/
|
|
90
|
+
function grep(output, pattern) {
|
|
91
|
+
return output.replaceAll(/\u001B\[[0-9;]*m/gu, "").split(/\n\s*\n/u).filter((block) => block.includes(pattern)).join("\n\n");
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/specification/facets/_common/result/text.ts
|
|
95
|
+
const ANSI_RE = /\u001B\[[0-9;]*m/gu;
|
|
96
|
+
/** Strip ANSI escape sequences from a string. */
|
|
97
|
+
function stripAnsiCodes(value) {
|
|
98
|
+
return value.replace(ANSI_RE, "");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read-only accessor for a captured text handle — THE universal one: stdout,
|
|
102
|
+
* stderr, container logs, and file text all surface as a `TextAccessor`.
|
|
103
|
+
*
|
|
104
|
+
* Backed by a primitive string, exposed via {@link text}, but also provides
|
|
105
|
+
* `toString()` / `valueOf()` for string coercion, so common patterns like
|
|
106
|
+
* `String(result.stdout)` and template-literal interpolation still work.
|
|
107
|
+
*
|
|
108
|
+
* ANSI escape sequences are stripped by default before every comparison
|
|
109
|
+
* (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
|
|
110
|
+
* an escape hatch for application noise not covered by the `{{token}}`
|
|
111
|
+
* grammar.
|
|
112
|
+
*
|
|
113
|
+
* Text operations are **closed** over the type: `.grep(pattern)` returns a
|
|
114
|
+
* `TextAccessor` (not a bare string), preserving the `_expected/`-resolution
|
|
115
|
+
* context and the `transform`, so results are chainable
|
|
116
|
+
* (`result.stdout.grep(a).grep(b)`) and snapshot-able
|
|
117
|
+
* (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
|
|
118
|
+
*
|
|
119
|
+
* Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
|
|
120
|
+
* and `expect(result.stdout).toMatch('name.txt')` (resolved against
|
|
121
|
+
* `_expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
|
|
122
|
+
*/
|
|
123
|
+
var TextAccessor = class TextAccessor {
|
|
124
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
125
|
+
captures;
|
|
126
|
+
/** @internal Stream label used in failure messages ("stdout" / "stderr"). */
|
|
127
|
+
streamName;
|
|
128
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
129
|
+
testDir;
|
|
130
|
+
/** The raw captured text (never transformed, ANSI preserved). */
|
|
131
|
+
text;
|
|
132
|
+
/** @internal Normaliser applied before comparisons, never to fixtures. */
|
|
133
|
+
transform;
|
|
134
|
+
constructor(value, streamName, testDir, options = {}) {
|
|
135
|
+
this.text = value;
|
|
136
|
+
this.streamName = streamName;
|
|
137
|
+
this.testDir = testDir;
|
|
138
|
+
this.transform = options.transform;
|
|
139
|
+
this.captures = options.captures ?? new CaptureScope();
|
|
140
|
+
}
|
|
141
|
+
/** @internal The text as compared by matchers — ANSI stripped, transform applied. */
|
|
142
|
+
get comparableText() {
|
|
143
|
+
const stripped = stripAnsiCodes(this.text);
|
|
144
|
+
return this.transform ? this.transform(stripped) : stripped;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Keep only the blank-line-separated blocks of the text that contain
|
|
148
|
+
* `pattern` (how linter/compiler output is structured), returned as a new
|
|
149
|
+
* `TextAccessor` — the same subject type, so the result is chainable and
|
|
150
|
+
* snapshot-able. The scalpel for probing large tool outputs; snapshot the
|
|
151
|
+
* whole surface by default, reach for `.grep()` for targeted checks.
|
|
152
|
+
*/
|
|
153
|
+
grep(pattern) {
|
|
154
|
+
return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
|
|
155
|
+
captures: this.captures,
|
|
156
|
+
transform: this.transform
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
toString() {
|
|
160
|
+
return this.text;
|
|
161
|
+
}
|
|
162
|
+
valueOf() {
|
|
163
|
+
return this.text;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Wrap an arbitrary string into a {@link TextAccessor} anchored on the calling
|
|
168
|
+
* test's directory — the same caller-detection the builders use.
|
|
169
|
+
*
|
|
170
|
+
* The product surface of a test framework is its own error messages, checker
|
|
171
|
+
* output, and reports; those deserve the same goldening as any other output.
|
|
172
|
+
* `text()` makes an ad-hoc string a first-class snapshot subject:
|
|
173
|
+
*
|
|
174
|
+
* ```typescript
|
|
175
|
+
* const message = await catchMessage(() => expect(result.response).toMatch('wrong-body.http'));
|
|
176
|
+
* expect(text(message)).toMatch('wrong-body-error.txt'); // resolves to _expected/
|
|
177
|
+
* ```
|
|
178
|
+
*
|
|
179
|
+
* ANSI is stripped before every comparison (the raw form stays on `.text`),
|
|
180
|
+
* the `{{token}}` grammar applies to the fixture, and `.grep()` composition
|
|
181
|
+
* works exactly as on any stream accessor.
|
|
182
|
+
*/
|
|
183
|
+
function text(value) {
|
|
184
|
+
return new TextAccessor(value, "text", getCallerDir());
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/specification/facets/_common/result/json.ts
|
|
188
|
+
/**
|
|
189
|
+
* Read-only accessor for a JSON payload parsed from a text stream (stdout).
|
|
190
|
+
*
|
|
191
|
+
* Lazily parses the JSON on first use; parse errors are deferred until the
|
|
192
|
+
* value is read so that callers can still read the raw stream text without
|
|
193
|
+
* triggering a throw.
|
|
194
|
+
*
|
|
195
|
+
* ANSI escape sequences are stripped by default before `JSON.parse`
|
|
196
|
+
* (CONVENTIONS D6). When a `transform` is configured on the spec runner, it
|
|
197
|
+
* runs on the stripped text — an escape hatch for application noise not
|
|
198
|
+
* covered by the `{{token}}` grammar.
|
|
199
|
+
*
|
|
200
|
+
* Assertions go through `expect()`: `expect(result.json).toMatch('name.json')`
|
|
201
|
+
* (deep-equal against `_expected/<name>`, with `{{placeholder}}` support).
|
|
202
|
+
*/
|
|
203
|
+
var JsonAccessor = class {
|
|
204
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
205
|
+
captures;
|
|
206
|
+
/** @internal */
|
|
207
|
+
rawText;
|
|
208
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
209
|
+
testDir;
|
|
210
|
+
/** @internal */
|
|
211
|
+
transform;
|
|
212
|
+
constructor(rawText, testDir, transform, captures) {
|
|
213
|
+
this.rawText = rawText;
|
|
214
|
+
this.testDir = testDir;
|
|
215
|
+
this.transform = transform;
|
|
216
|
+
this.captures = captures ?? new CaptureScope();
|
|
217
|
+
}
|
|
218
|
+
/** The parsed JSON value. Throws if the text is not valid JSON. */
|
|
219
|
+
get value() {
|
|
220
|
+
const stripped = stripAnsiCodes(this.rawText);
|
|
221
|
+
const source = this.transform ? this.transform(stripped) : stripped;
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(source);
|
|
224
|
+
} catch {
|
|
225
|
+
const preview = source.slice(0, 200);
|
|
226
|
+
throw new Error(`stdout is not valid JSON: ${preview}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/integrations/docker/docker-lookup.ts
|
|
232
|
+
/**
|
|
233
|
+
* Thin synchronous wrappers around the `docker` CLI used by the docker() spec
|
|
234
|
+
* mode. Sync is deliberate — the calls are fast, run only on the host, and
|
|
235
|
+
* keep the dispose path straightforward (`Symbol.asyncDispose` can still be
|
|
236
|
+
* async without needing these to be).
|
|
237
|
+
*/
|
|
238
|
+
const DEFAULT_TIMEOUT = 1e4;
|
|
239
|
+
/** Return all container IDs (running or stopped) that carry `key=value`. */
|
|
240
|
+
function findContainersByLabel(key, value) {
|
|
241
|
+
try {
|
|
242
|
+
return execSync(`docker ps -aq -f label=${key}=${value}`, {
|
|
243
|
+
encoding: "utf8",
|
|
244
|
+
timeout: DEFAULT_TIMEOUT
|
|
245
|
+
}).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
246
|
+
} catch {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Return the raw `docker inspect` payload (object, not array) for a container. */
|
|
251
|
+
function inspectContainer(id) {
|
|
252
|
+
const raw = execSync(`docker inspect ${id}`, {
|
|
253
|
+
encoding: "utf8",
|
|
254
|
+
timeout: DEFAULT_TIMEOUT
|
|
255
|
+
});
|
|
256
|
+
const parsed = JSON.parse(raw);
|
|
257
|
+
return Array.isArray(parsed) ? parsed[0] : parsed;
|
|
258
|
+
}
|
|
259
|
+
/** Force-remove the given container IDs in a single call. Errors are swallowed. */
|
|
260
|
+
function removeContainers(ids) {
|
|
261
|
+
if (ids.length === 0) return;
|
|
262
|
+
try {
|
|
263
|
+
execSync(`docker rm -f ${ids.join(" ")}`, {
|
|
264
|
+
encoding: "utf8",
|
|
265
|
+
stdio: "pipe",
|
|
266
|
+
timeout: DEFAULT_TIMEOUT
|
|
267
|
+
});
|
|
268
|
+
} catch {}
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/specification/matching/structural.ts
|
|
10
272
|
/**
|
|
11
273
|
* Structural comparison engine shared by every fixture matcher.
|
|
12
274
|
*
|
|
@@ -59,9 +321,9 @@ const EMBEDDED_SOURCES = {
|
|
|
59
321
|
url: URL_SOURCE,
|
|
60
322
|
uuid: UUID_SOURCE
|
|
61
323
|
};
|
|
62
|
-
const wholeRe = (source) => new RegExp(`^(?:${source})
|
|
324
|
+
const wholeRe = (source) => new RegExp(`^(?:${source})$`, "u");
|
|
63
325
|
const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
|
|
64
|
-
const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "
|
|
326
|
+
const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "gu");
|
|
65
327
|
/** Whether a fixture string contains at least one `{{placeholder}}`. */
|
|
66
328
|
function hasPlaceholders(value) {
|
|
67
329
|
PLACEHOLDER_RE.lastIndex = 0;
|
|
@@ -126,7 +388,7 @@ function placeholderSource(kind, scope) {
|
|
|
126
388
|
return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
|
|
127
389
|
}
|
|
128
390
|
function escapeRegExp(text) {
|
|
129
|
-
return text.
|
|
391
|
+
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
130
392
|
}
|
|
131
393
|
function parsePlaceholderString(expected, scope) {
|
|
132
394
|
PLACEHOLDER_RE.lastIndex = 0;
|
|
@@ -137,7 +399,7 @@ function parsePlaceholderString(expected, scope) {
|
|
|
137
399
|
let count = 0;
|
|
138
400
|
for (const found of expected.matchAll(PLACEHOLDER_RE)) {
|
|
139
401
|
const kind = found.groups.kind;
|
|
140
|
-
const ref = found.groups
|
|
402
|
+
const { ref } = found.groups;
|
|
141
403
|
source += escapeRegExp(expected.slice(lastIndex, found.index));
|
|
142
404
|
source += `(${placeholderSource(kind, scope)})`;
|
|
143
405
|
refs.push({
|
|
@@ -154,7 +416,7 @@ function parsePlaceholderString(expected, scope) {
|
|
|
154
416
|
}
|
|
155
417
|
source += escapeRegExp(expected.slice(lastIndex));
|
|
156
418
|
return {
|
|
157
|
-
pattern: new RegExp(`^${source}
|
|
419
|
+
pattern: new RegExp(`^${source}$`, "u"),
|
|
158
420
|
refs,
|
|
159
421
|
single,
|
|
160
422
|
source
|
|
@@ -248,7 +510,7 @@ function textEquals(expected, actual, scope) {
|
|
|
248
510
|
function textContains(expected, actual, scope) {
|
|
249
511
|
if (!hasPlaceholders(expected)) return actual.includes(expected);
|
|
250
512
|
const parsed = parsePlaceholderString(expected, scope);
|
|
251
|
-
const found = new RegExp(parsed.source).exec(actual);
|
|
513
|
+
const found = new RegExp(parsed.source, "u").exec(actual);
|
|
252
514
|
if (!found) return false;
|
|
253
515
|
return parsed.refs.every((entry) => !entry.ref || recordRef(entry.ref, found[entry.index + 1], scope));
|
|
254
516
|
}
|
|
@@ -322,6 +584,7 @@ function mergeTextPreservingPlaceholders(previous, actual, scope) {
|
|
|
322
584
|
const prev = prevLines[prevIndex];
|
|
323
585
|
if (prev === void 0 || !hasPlaceholders(prev)) return false;
|
|
324
586
|
const line = actualLines[actualIndex];
|
|
587
|
+
if (line === void 0) return false;
|
|
325
588
|
return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, rawLines[actualIndex] ?? line, new CaptureScope(scope.workdir));
|
|
326
589
|
};
|
|
327
590
|
const merged = actualLines.map(() => void 0);
|
|
@@ -342,187 +605,7 @@ function mergeTextPreservingPlaceholders(previous, actual, scope) {
|
|
|
342
605
|
return merged.join("\n");
|
|
343
606
|
}
|
|
344
607
|
//#endregion
|
|
345
|
-
//#region src/
|
|
346
|
-
const GREEN = "\x1B[32m";
|
|
347
|
-
const RED = "\x1B[31m";
|
|
348
|
-
const DIM = "\x1B[2m";
|
|
349
|
-
const BOLD = "\x1B[1m";
|
|
350
|
-
const RESET = "\x1B[0m";
|
|
351
|
-
const BG_CYAN = "\x1B[46m";
|
|
352
|
-
const BLACK = "\x1B[30m";
|
|
353
|
-
const CHECK = "✓";
|
|
354
|
-
const CROSS = "×";
|
|
355
|
-
const ARROW = "→";
|
|
356
|
-
function formatStartupReport(mode, services, app) {
|
|
357
|
-
const lines = [];
|
|
358
|
-
lines.push("");
|
|
359
|
-
lines.push(`${BG_CYAN}${BLACK}${BOLD} INFRA ${RESET} Starting infrastructure...`);
|
|
360
|
-
lines.push("");
|
|
361
|
-
for (const service of services) if (service.error) {
|
|
362
|
-
lines.push(` ${RED}${CROSS}${RESET} ${service.type} (${service.name}) ${RED}${service.error}${RESET} ${DIM}${service.durationMs}ms${RESET}`);
|
|
363
|
-
if (service.logs) {
|
|
364
|
-
const logLines = service.logs.trim().split("\n").slice(-10);
|
|
365
|
-
for (const logLine of logLines) lines.push(` ${DIM}${logLine}${RESET}`);
|
|
366
|
-
}
|
|
367
|
-
} else {
|
|
368
|
-
const conn = service.connectionString ? `${DIM}${service.connectionString}${RESET}` : "";
|
|
369
|
-
lines.push(` ${GREEN}${CHECK}${RESET} ${service.type} (${service.name}) ${conn} ${DIM}${service.durationMs}ms${RESET}`);
|
|
370
|
-
}
|
|
371
|
-
if (app) {
|
|
372
|
-
lines.push("");
|
|
373
|
-
if (app.type === "in-process") lines.push(` ${DIM}${ARROW} app: in-process (Hono)${RESET}`);
|
|
374
|
-
else lines.push(` ${DIM}${ARROW} app: ${app.url}${RESET}`);
|
|
375
|
-
}
|
|
376
|
-
lines.push("");
|
|
377
|
-
return lines.join("\n");
|
|
378
|
-
}
|
|
379
|
-
function formatTableDiff(table, columns, expected, actual) {
|
|
380
|
-
const lines = [];
|
|
381
|
-
lines.push(`Table "${table}" mismatch`);
|
|
382
|
-
lines.push(`${DIM} query: ${columns.join(", ")}${RESET}`);
|
|
383
|
-
lines.push(`${DIM} expected: ${rowLabel(expected.length)}${RESET}`);
|
|
384
|
-
lines.push(`${DIM} received: ${rowLabel(actual.length)}${RESET}`);
|
|
385
|
-
lines.push("");
|
|
386
|
-
lines.push(`${GREEN}- Expected${RESET}`);
|
|
387
|
-
lines.push(`${RED}+ Received${RESET}`);
|
|
388
|
-
lines.push("");
|
|
389
|
-
const header = columns.join(" | ");
|
|
390
|
-
lines.push(`${DIM} ${header}${RESET}`);
|
|
391
|
-
const maxRows = Math.max(expected.length, actual.length);
|
|
392
|
-
for (let i = 0; i < maxRows; i++) {
|
|
393
|
-
const exp = expected[i];
|
|
394
|
-
const act = actual[i];
|
|
395
|
-
if (exp && !act) lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
|
|
396
|
-
else if (!exp && act) lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
|
|
397
|
-
else if (exp && act) if (JSON.stringify(exp) === JSON.stringify(act)) lines.push(` ${formatRow(act)}`);
|
|
398
|
-
else {
|
|
399
|
-
lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
|
|
400
|
-
lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
if (expected.length === 0 && actual.length === 0) lines.push(` (empty)`);
|
|
404
|
-
return lines.join("\n");
|
|
405
|
-
}
|
|
406
|
-
function formatResponseDiff(file, expected, actual) {
|
|
407
|
-
const lines = [];
|
|
408
|
-
lines.push(`Response mismatch (${file})`);
|
|
409
|
-
lines.push("");
|
|
410
|
-
lines.push(`${GREEN}- Expected${RESET}`);
|
|
411
|
-
lines.push(`${RED}+ Received${RESET}`);
|
|
412
|
-
lines.push("");
|
|
413
|
-
const expectedLines = JSON.stringify(expected, null, 2).split("\n");
|
|
414
|
-
const actualLines = JSON.stringify(actual, null, 2).split("\n");
|
|
415
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
416
|
-
for (let i = 0; i < maxLines; i++) {
|
|
417
|
-
const exp = expectedLines[i];
|
|
418
|
-
const act = actualLines[i];
|
|
419
|
-
if (exp === act) lines.push(` ${exp}`);
|
|
420
|
-
else {
|
|
421
|
-
if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
|
|
422
|
-
if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
return lines.join("\n");
|
|
426
|
-
}
|
|
427
|
-
const strictEquals = (expected, actual) => expected === actual;
|
|
428
|
-
function renderLines(expectedLines, actualLines, equals) {
|
|
429
|
-
const rendered = [];
|
|
430
|
-
let marked = 0;
|
|
431
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
432
|
-
for (let i = 0; i < maxLines; i++) {
|
|
433
|
-
const exp = expectedLines[i];
|
|
434
|
-
const act = actualLines[i];
|
|
435
|
-
if (exp !== void 0 && act !== void 0 && equals(exp, act)) {
|
|
436
|
-
rendered.push(` ${exp}`);
|
|
437
|
-
continue;
|
|
438
|
-
}
|
|
439
|
-
marked++;
|
|
440
|
-
if (exp !== void 0) rendered.push(`${GREEN}- ${exp}${RESET}`);
|
|
441
|
-
if (act !== void 0) rendered.push(`${RED}+ ${act}${RESET}`);
|
|
442
|
-
}
|
|
443
|
-
return {
|
|
444
|
-
marked,
|
|
445
|
-
rendered
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
/**
|
|
449
|
-
* Render an expected/received diff over two texts.
|
|
450
|
-
*
|
|
451
|
-
* `equals` is how a line is judged EQUAL. Callers that compare through the
|
|
452
|
-
* `{{token}}` grammar pass a token-aware predicate, so a `{{url}}` line that
|
|
453
|
-
* matched is shown as equal — with its token, not as a `-`/`+` pair against
|
|
454
|
-
* the concrete URL. Marking a line the comparison ACCEPTED buries the one it
|
|
455
|
-
* rejected: the reader then has to work out which of the differences is the
|
|
456
|
-
* actual cause.
|
|
457
|
-
*
|
|
458
|
-
* Safety net: if the token-aware pass marks nothing at all, the whole diff
|
|
459
|
-
* would render as a clean block against a failure that really happened (a
|
|
460
|
-
* `#ref` captured inconsistently across lines, say). The strict rendering is
|
|
461
|
-
* used instead, so a diff always shows the reader something.
|
|
462
|
-
*/
|
|
463
|
-
function formatStdoutDiff(file, expected, actual, options) {
|
|
464
|
-
const lines = [];
|
|
465
|
-
lines.push(`Output mismatch (${file})`);
|
|
466
|
-
lines.push("");
|
|
467
|
-
lines.push(`${GREEN}- Expected${RESET}`);
|
|
468
|
-
lines.push(`${RED}+ Received${RESET}`);
|
|
469
|
-
lines.push("");
|
|
470
|
-
const expectedLines = expected.split("\n");
|
|
471
|
-
const actualLines = actual.split("\n");
|
|
472
|
-
const equals = options?.equals ?? strictEquals;
|
|
473
|
-
const first = renderLines(expectedLines, actualLines, equals);
|
|
474
|
-
const rendered = first.marked === 0 && equals !== strictEquals ? renderLines(expectedLines, actualLines, strictEquals).rendered : first.rendered;
|
|
475
|
-
return [...lines, ...rendered].join("\n");
|
|
476
|
-
}
|
|
477
|
-
function formatDirectoryDiff(fixtureName, diff, hint) {
|
|
478
|
-
const lines = [];
|
|
479
|
-
const total = diff.added.length + diff.removed.length + diff.changed.length;
|
|
480
|
-
lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`);
|
|
481
|
-
lines.push(`${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`);
|
|
482
|
-
lines.push("");
|
|
483
|
-
lines.push(`${GREEN}- Expected (fixture)${RESET}`);
|
|
484
|
-
lines.push(`${RED}+ Received (generated)${RESET}`);
|
|
485
|
-
lines.push("");
|
|
486
|
-
for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
|
|
487
|
-
for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
|
|
488
|
-
for (const { path, expected, actual } of diff.changed) {
|
|
489
|
-
const expectedLines = expected.split("\n");
|
|
490
|
-
const actualLines = actual.split("\n");
|
|
491
|
-
const changedCount = countLineDifferences(expectedLines, actualLines);
|
|
492
|
-
lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
|
|
493
|
-
let shown = 0;
|
|
494
|
-
const maxShown = 5;
|
|
495
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
496
|
-
for (let i = 0; i < maxLines && shown < maxShown; i++) {
|
|
497
|
-
const exp = expectedLines[i];
|
|
498
|
-
const act = actualLines[i];
|
|
499
|
-
if (exp !== act) {
|
|
500
|
-
lines.push(`${DIM} line ${i + 1}:${RESET}`);
|
|
501
|
-
if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
|
|
502
|
-
if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
|
|
503
|
-
shown++;
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
|
|
507
|
-
}
|
|
508
|
-
lines.push("");
|
|
509
|
-
lines.push(`${DIM}${hint}${RESET}`);
|
|
510
|
-
return lines.join("\n");
|
|
511
|
-
}
|
|
512
|
-
function countLineDifferences(expected, actual) {
|
|
513
|
-
let count = 0;
|
|
514
|
-
const max = Math.max(expected.length, actual.length);
|
|
515
|
-
for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
|
|
516
|
-
return count;
|
|
517
|
-
}
|
|
518
|
-
function rowLabel(n) {
|
|
519
|
-
return n === 1 ? "1 row" : `${n} rows`;
|
|
520
|
-
}
|
|
521
|
-
function formatRow(row) {
|
|
522
|
-
return row.map((v) => String(v ?? "null")).join(" | ");
|
|
523
|
-
}
|
|
524
|
-
//#endregion
|
|
525
|
-
//#region src/core/specification/shared/result/directory.ts
|
|
608
|
+
//#region src/specification/facets/_common/result/directory.ts
|
|
526
609
|
const DEFAULT_IGNORES = [
|
|
527
610
|
".git",
|
|
528
611
|
".DS_Store",
|
|
@@ -599,340 +682,47 @@ var DirectoryAccessor = class {
|
|
|
599
682
|
captures;
|
|
600
683
|
/** @internal Absolute path of the directory under assertion. */
|
|
601
684
|
root;
|
|
602
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
603
|
-
testDir;
|
|
604
|
-
constructor(absPath, testDir, captures) {
|
|
605
|
-
this.root = absPath;
|
|
606
|
-
this.testDir = testDir;
|
|
607
|
-
this.captures = captures ?? new CaptureScope();
|
|
608
|
-
}
|
|
609
|
-
/** List all files (recursively) under the directory, sorted. */
|
|
610
|
-
async files(options = {}) {
|
|
611
|
-
return walkDirectory(this.root, options);
|
|
612
|
-
}
|
|
613
|
-
};
|
|
614
|
-
//#endregion
|
|
615
|
-
//#region src/core/specification/shared/result/filesystem.ts
|
|
616
|
-
/**
|
|
617
|
-
* Read-only accessor for the whole temporary working directory used by a
|
|
618
|
-
* command spec.
|
|
619
|
-
*
|
|
620
|
-
* Assertions go through `expect()` (async — they walk the disk):
|
|
621
|
-
* `await expect(result.filesystem).toMatch('scaffolded')` compares the
|
|
622
|
-
* tree against the fixture directory `_expected/<name>/`.
|
|
623
|
-
*/
|
|
624
|
-
var FilesystemAccessor = class {
|
|
625
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
626
|
-
captures;
|
|
627
|
-
/** The absolute path of the temporary working directory. */
|
|
628
|
-
cwd;
|
|
629
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
630
|
-
testDir;
|
|
631
|
-
constructor(cwd, testDir, captures) {
|
|
632
|
-
this.cwd = cwd;
|
|
633
|
-
this.testDir = testDir;
|
|
634
|
-
this.captures = captures ?? new CaptureScope();
|
|
635
|
-
}
|
|
636
|
-
/** List all files (recursively) under the working directory, sorted. */
|
|
637
|
-
async files(options = {}) {
|
|
638
|
-
return walkDirectory(this.cwd, options);
|
|
639
|
-
}
|
|
640
|
-
};
|
|
641
|
-
//#endregion
|
|
642
|
-
//#region src/core/specification/shared/caller.ts
|
|
643
|
-
/** Sibling module tests are callers, not internals (CONVENTIONS I2). */
|
|
644
|
-
const TEST_FILE = /\.test\.[cm]?[jt]s$/;
|
|
645
|
-
/** Where this very module sits inside the framework's SOURCE tree. */
|
|
646
|
-
const SOURCE_LOCATION = join("core", "specification", "shared");
|
|
647
|
-
/** The real path of `path`, or `path` itself when it cannot be resolved. */
|
|
648
|
-
function realPath(path) {
|
|
649
|
-
try {
|
|
650
|
-
return realpathSync(path);
|
|
651
|
-
} catch {
|
|
652
|
-
return path;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
/**
|
|
656
|
-
* Directory of the running framework module itself. From the built package
|
|
657
|
-
* this is `<package root>/dist`, whatever the resolution path — including a
|
|
658
|
-
* `file:` link, where the real path carries neither `node_modules` nor
|
|
659
|
-
* `/src/…` and would otherwise be mistaken for a caller frame.
|
|
660
|
-
*/
|
|
661
|
-
const FRAMEWORK_DIR = realPath(dirname(fileURLToPath(import.meta.url)));
|
|
662
|
-
/**
|
|
663
|
-
* The directory holding the framework's OWN modules: `<package>/dist` for a
|
|
664
|
-
* built install (everything is bundled there), `<repo>/src` when the framework
|
|
665
|
-
* runs from source — this module's known location inside that tree is what
|
|
666
|
-
* tells the two apart.
|
|
667
|
-
*/
|
|
668
|
-
const FRAMEWORK_TREE = FRAMEWORK_DIR.endsWith(`${sep}${SOURCE_LOCATION}`) ? resolve(FRAMEWORK_DIR, "../../..") : FRAMEWORK_DIR;
|
|
669
|
-
/** Is `path` inside `directory` (strictly — a directory is not inside itself)? */
|
|
670
|
-
function isInside(directory, path) {
|
|
671
|
-
const rest = relative(directory, path);
|
|
672
|
-
return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
|
|
673
|
-
}
|
|
674
|
-
/**
|
|
675
|
-
* Is this stack frame a module of the framework itself, rather than of the code
|
|
676
|
-
* calling it?
|
|
677
|
-
*
|
|
678
|
-
* Answered by IDENTITY: the frame is inside the framework's own directory.
|
|
679
|
-
* Answering it by substring — `/src/core/`, `/src/integrations/`, `/src/vitest/`
|
|
680
|
-
* — was answering a different question, "does this path look like the framework's
|
|
681
|
-
* layout", and a consumer that happens to have its own `src/core/` matched it.
|
|
682
|
-
* Its files were then skipped as framework internals, and fixture resolution
|
|
683
|
-
* anchored on the wrong directory (or fell through to the cwd) with nothing
|
|
684
|
-
* reported.
|
|
685
|
-
*
|
|
686
|
-
* @internal
|
|
687
|
-
*/
|
|
688
|
-
function isFrameworkFrame(filePath, frameworkTree = FRAMEWORK_TREE) {
|
|
689
|
-
if (TEST_FILE.test(filePath)) return false;
|
|
690
|
-
return isInside(realPath(frameworkTree), realPath(filePath));
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* Detect the directory of the first stack frame that lives outside this
|
|
694
|
-
* package. Used to anchor fixture resolution (`_seeds/`, `_requests/`,
|
|
695
|
-
* `_expected/`, …) on the calling test / specification file.
|
|
696
|
-
*
|
|
697
|
-
* @internal
|
|
698
|
-
*/
|
|
699
|
-
function getCallerDir() {
|
|
700
|
-
const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
|
|
701
|
-
if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
|
|
702
|
-
const lines = stack.split("\n");
|
|
703
|
-
for (const line of lines) {
|
|
704
|
-
const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?(?<filePath>[^:)]+):\d+:\d+/);
|
|
705
|
-
if (!match?.groups?.filePath) continue;
|
|
706
|
-
const filePath = match.groups.filePath;
|
|
707
|
-
if (filePath.includes("node_modules")) continue;
|
|
708
|
-
if (isFrameworkFrame(filePath)) continue;
|
|
709
|
-
return resolve(filePath, "..");
|
|
710
|
-
}
|
|
711
|
-
return process.cwd();
|
|
712
|
-
}
|
|
713
|
-
//#endregion
|
|
714
|
-
//#region src/core/specification/shared/result/grep.ts
|
|
715
|
-
/**
|
|
716
|
-
* Extract text blocks from output that contain a pattern.
|
|
717
|
-
* Splits by blank lines (how linter/compiler output is structured),
|
|
718
|
-
* returns only blocks matching the pattern.
|
|
719
|
-
*
|
|
720
|
-
* @example
|
|
721
|
-
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
722
|
-
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
723
|
-
*/
|
|
724
|
-
function grep(output, pattern) {
|
|
725
|
-
return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
|
|
726
|
-
}
|
|
727
|
-
//#endregion
|
|
728
|
-
//#region src/core/specification/shared/result/text.ts
|
|
729
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
730
|
-
/** Strip ANSI escape sequences from a string. */
|
|
731
|
-
function stripAnsiCodes(value) {
|
|
732
|
-
return value.replace(ANSI_RE, "");
|
|
733
|
-
}
|
|
734
|
-
/**
|
|
735
|
-
* Read-only accessor for a captured text handle — THE universal one: stdout,
|
|
736
|
-
* stderr, container logs, and file text all surface as a `TextAccessor`.
|
|
737
|
-
*
|
|
738
|
-
* Backed by a primitive string, exposed via {@link text}, but also provides
|
|
739
|
-
* `toString()` / `valueOf()` for string coercion, so common patterns like
|
|
740
|
-
* `String(result.stdout)` and template-literal interpolation still work.
|
|
741
|
-
*
|
|
742
|
-
* ANSI escape sequences are stripped by default before every comparison
|
|
743
|
-
* (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
|
|
744
|
-
* an escape hatch for application noise not covered by the `{{token}}`
|
|
745
|
-
* grammar.
|
|
746
|
-
*
|
|
747
|
-
* Text operations are **closed** over the type: `.grep(pattern)` returns a
|
|
748
|
-
* `TextAccessor` (not a bare string), preserving the `_expected/`-resolution
|
|
749
|
-
* context and the `transform`, so results are chainable
|
|
750
|
-
* (`result.stdout.grep(a).grep(b)`) and snapshot-able
|
|
751
|
-
* (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
|
|
752
|
-
*
|
|
753
|
-
* Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
|
|
754
|
-
* and `expect(result.stdout).toMatch('name.txt')` (resolved against
|
|
755
|
-
* `_expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
|
|
756
|
-
*/
|
|
757
|
-
var TextAccessor = class TextAccessor {
|
|
758
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
759
|
-
captures;
|
|
760
|
-
/** @internal Stream label used in failure messages ("stdout" / "stderr"). */
|
|
761
|
-
streamName;
|
|
762
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
763
|
-
testDir;
|
|
764
|
-
/** The raw captured text (never transformed, ANSI preserved). */
|
|
765
|
-
text;
|
|
766
|
-
/** @internal Normaliser applied before comparisons, never to fixtures. */
|
|
767
|
-
transform;
|
|
768
|
-
constructor(value, streamName, testDir, options = {}) {
|
|
769
|
-
this.text = value;
|
|
770
|
-
this.streamName = streamName;
|
|
771
|
-
this.testDir = testDir;
|
|
772
|
-
this.transform = options.transform;
|
|
773
|
-
this.captures = options.captures ?? new CaptureScope();
|
|
774
|
-
}
|
|
775
|
-
/** @internal The text as compared by matchers — ANSI stripped, transform applied. */
|
|
776
|
-
get comparableText() {
|
|
777
|
-
const stripped = stripAnsiCodes(this.text);
|
|
778
|
-
return this.transform ? this.transform(stripped) : stripped;
|
|
779
|
-
}
|
|
780
|
-
/**
|
|
781
|
-
* Keep only the blank-line-separated blocks of the text that contain
|
|
782
|
-
* `pattern` (how linter/compiler output is structured), returned as a new
|
|
783
|
-
* `TextAccessor` — the same subject type, so the result is chainable and
|
|
784
|
-
* snapshot-able. The scalpel for probing large tool outputs; snapshot the
|
|
785
|
-
* whole surface by default, reach for `.grep()` for targeted checks.
|
|
786
|
-
*/
|
|
787
|
-
grep(pattern) {
|
|
788
|
-
return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
|
|
789
|
-
captures: this.captures,
|
|
790
|
-
transform: this.transform
|
|
791
|
-
});
|
|
792
|
-
}
|
|
793
|
-
toString() {
|
|
794
|
-
return this.text;
|
|
795
|
-
}
|
|
796
|
-
valueOf() {
|
|
797
|
-
return this.text;
|
|
798
|
-
}
|
|
799
|
-
};
|
|
800
|
-
/**
|
|
801
|
-
* Wrap an arbitrary string into a {@link TextAccessor} anchored on the calling
|
|
802
|
-
* test's directory — the same caller-detection the builders use.
|
|
803
|
-
*
|
|
804
|
-
* The product surface of a test framework is its own error messages, checker
|
|
805
|
-
* output, and reports; those deserve the same goldening as any other output.
|
|
806
|
-
* `text()` makes an ad-hoc string a first-class snapshot subject:
|
|
807
|
-
*
|
|
808
|
-
* ```typescript
|
|
809
|
-
* const message = await catchMessage(() => expect(result.response).toMatch('wrong-body.http'));
|
|
810
|
-
* expect(text(message)).toMatch('wrong-body-error.txt'); // resolves to _expected/
|
|
811
|
-
* ```
|
|
812
|
-
*
|
|
813
|
-
* ANSI is stripped before every comparison (the raw form stays on `.text`),
|
|
814
|
-
* the `{{token}}` grammar applies to the fixture, and `.grep()` composition
|
|
815
|
-
* works exactly as on any stream accessor.
|
|
816
|
-
*/
|
|
817
|
-
function text(value) {
|
|
818
|
-
return new TextAccessor(value, "text", getCallerDir());
|
|
819
|
-
}
|
|
820
|
-
//#endregion
|
|
821
|
-
//#region src/core/specification/shared/result/json.ts
|
|
822
|
-
/**
|
|
823
|
-
* Read-only accessor for a JSON payload parsed from a text stream (stdout).
|
|
824
|
-
*
|
|
825
|
-
* Lazily parses the JSON on first use; parse errors are deferred until the
|
|
826
|
-
* value is read so that callers can still read the raw stream text without
|
|
827
|
-
* triggering a throw.
|
|
828
|
-
*
|
|
829
|
-
* ANSI escape sequences are stripped by default before `JSON.parse`
|
|
830
|
-
* (CONVENTIONS D6). When a `transform` is configured on the spec runner, it
|
|
831
|
-
* runs on the stripped text — an escape hatch for application noise not
|
|
832
|
-
* covered by the `{{token}}` grammar.
|
|
833
|
-
*
|
|
834
|
-
* Assertions go through `expect()`: `expect(result.json).toMatch('name.json')`
|
|
835
|
-
* (deep-equal against `_expected/<name>`, with `{{placeholder}}` support).
|
|
836
|
-
*/
|
|
837
|
-
var JsonAccessor = class {
|
|
838
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
839
|
-
captures;
|
|
840
|
-
/** @internal */
|
|
841
|
-
rawText;
|
|
842
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
843
|
-
testDir;
|
|
844
|
-
/** @internal */
|
|
845
|
-
transform;
|
|
846
|
-
constructor(rawText, testDir, transform, captures) {
|
|
847
|
-
this.rawText = rawText;
|
|
848
|
-
this.testDir = testDir;
|
|
849
|
-
this.transform = transform;
|
|
850
|
-
this.captures = captures ?? new CaptureScope();
|
|
851
|
-
}
|
|
852
|
-
/** The parsed JSON value. Throws if the text is not valid JSON. */
|
|
853
|
-
get value() {
|
|
854
|
-
const stripped = stripAnsiCodes(this.rawText);
|
|
855
|
-
const source = this.transform ? this.transform(stripped) : stripped;
|
|
856
|
-
try {
|
|
857
|
-
return JSON.parse(source);
|
|
858
|
-
} catch {
|
|
859
|
-
const preview = source.slice(0, 200);
|
|
860
|
-
throw new Error(`stdout is not valid JSON: ${preview}`);
|
|
861
|
-
}
|
|
685
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
686
|
+
testDir;
|
|
687
|
+
constructor(absPath, testDir, captures) {
|
|
688
|
+
this.root = absPath;
|
|
689
|
+
this.testDir = testDir;
|
|
690
|
+
this.captures = captures ?? new CaptureScope();
|
|
691
|
+
}
|
|
692
|
+
/** List all files (recursively) under the directory, sorted. */
|
|
693
|
+
async files(options = {}) {
|
|
694
|
+
return await walkDirectory(this.root, options);
|
|
862
695
|
}
|
|
863
696
|
};
|
|
864
697
|
//#endregion
|
|
865
|
-
//#region src/
|
|
698
|
+
//#region src/specification/facets/_common/result/filesystem.ts
|
|
866
699
|
/**
|
|
867
|
-
* Read-only accessor for
|
|
868
|
-
*
|
|
869
|
-
* Assertions go through `expect()` (async — they query the database):
|
|
700
|
+
* Read-only accessor for the whole temporary working directory used by a
|
|
701
|
+
* command spec.
|
|
870
702
|
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
* });
|
|
875
|
-
* await expect(result.table('users', { database: 'db' })).toBeEmpty();
|
|
703
|
+
* Assertions go through `expect()` (async — they walk the disk):
|
|
704
|
+
* `await expect(result.filesystem).toMatch('scaffolded')` compares the
|
|
705
|
+
* tree against the fixture directory `_expected/<name>/`.
|
|
876
706
|
*/
|
|
877
|
-
var
|
|
707
|
+
var FilesystemAccessor = class {
|
|
878
708
|
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
879
709
|
captures;
|
|
880
|
-
/**
|
|
881
|
-
|
|
882
|
-
/**
|
|
883
|
-
|
|
884
|
-
constructor(
|
|
885
|
-
this.
|
|
886
|
-
this.
|
|
710
|
+
/** The absolute path of the temporary working directory. */
|
|
711
|
+
cwd;
|
|
712
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
713
|
+
testDir;
|
|
714
|
+
constructor(cwd, testDir, captures) {
|
|
715
|
+
this.cwd = cwd;
|
|
716
|
+
this.testDir = testDir;
|
|
887
717
|
this.captures = captures ?? new CaptureScope();
|
|
888
718
|
}
|
|
889
|
-
/**
|
|
890
|
-
|
|
891
|
-
return
|
|
719
|
+
/** List all files (recursively) under the working directory, sorted. */
|
|
720
|
+
async files(options = {}) {
|
|
721
|
+
return await walkDirectory(this.cwd, options);
|
|
892
722
|
}
|
|
893
723
|
};
|
|
894
724
|
//#endregion
|
|
895
|
-
//#region src/
|
|
896
|
-
/**
|
|
897
|
-
* Thin synchronous wrappers around the `docker` CLI used by the docker() spec
|
|
898
|
-
* mode. Sync is deliberate — the calls are fast, run only on the host, and
|
|
899
|
-
* keep the dispose path straightforward (`Symbol.asyncDispose` can still be
|
|
900
|
-
* async without needing these to be).
|
|
901
|
-
*/
|
|
902
|
-
const DEFAULT_TIMEOUT = 1e4;
|
|
903
|
-
/** Return all container IDs (running or stopped) that carry `key=value`. */
|
|
904
|
-
function findContainersByLabel(key, value) {
|
|
905
|
-
try {
|
|
906
|
-
return execSync(`docker ps -aq -f label=${key}=${value}`, {
|
|
907
|
-
encoding: "utf8",
|
|
908
|
-
timeout: DEFAULT_TIMEOUT
|
|
909
|
-
}).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
910
|
-
} catch {
|
|
911
|
-
return [];
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
/** Return the raw `docker inspect` payload (object, not array) for a container. */
|
|
915
|
-
function inspectContainer(id) {
|
|
916
|
-
const raw = execSync(`docker inspect ${id}`, {
|
|
917
|
-
encoding: "utf8",
|
|
918
|
-
timeout: DEFAULT_TIMEOUT
|
|
919
|
-
});
|
|
920
|
-
const parsed = JSON.parse(raw);
|
|
921
|
-
return Array.isArray(parsed) ? parsed[0] : parsed;
|
|
922
|
-
}
|
|
923
|
-
/** Force-remove the given container IDs in a single call. Errors are swallowed. */
|
|
924
|
-
function removeContainers(ids) {
|
|
925
|
-
if (ids.length === 0) return;
|
|
926
|
-
try {
|
|
927
|
-
execSync(`docker rm -f ${ids.join(" ")}`, {
|
|
928
|
-
encoding: "utf8",
|
|
929
|
-
stdio: "pipe",
|
|
930
|
-
timeout: DEFAULT_TIMEOUT
|
|
931
|
-
});
|
|
932
|
-
} catch {}
|
|
933
|
-
}
|
|
934
|
-
//#endregion
|
|
935
|
-
//#region src/core/specification/shared/resolve.ts
|
|
725
|
+
//#region src/specification/facets/_common/resolve.ts
|
|
936
726
|
/** What makes a directory a project root — a manifest, or a test stack. */
|
|
937
727
|
const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
|
|
938
728
|
/**
|
|
@@ -1017,11 +807,41 @@ function safeRealpath(path) {
|
|
|
1017
807
|
function expandWorkdir(env, workDir) {
|
|
1018
808
|
const workdir = safeRealpath(workDir);
|
|
1019
809
|
const expanded = {};
|
|
1020
|
-
for (const [key, value] of Object.entries(env)) expanded[key] = typeof value === "string" ? value.
|
|
810
|
+
for (const [key, value] of Object.entries(env)) expanded[key] = typeof value === "string" ? value.replaceAll("$WORKDIR", workdir) : value;
|
|
1021
811
|
return expanded;
|
|
1022
812
|
}
|
|
1023
813
|
//#endregion
|
|
1024
|
-
//#region src/
|
|
814
|
+
//#region src/specification/facets/_common/result/table.ts
|
|
815
|
+
/**
|
|
816
|
+
* Read-only accessor for a database table after a specification run.
|
|
817
|
+
*
|
|
818
|
+
* Assertions go through `expect()` (async — they query the database):
|
|
819
|
+
*
|
|
820
|
+
* await expect(result.table('users', { database: 'db' })).toMatchRows({
|
|
821
|
+
* columns: ['name'],
|
|
822
|
+
* rows: [['Alice']],
|
|
823
|
+
* });
|
|
824
|
+
* await expect(result.table('users', { database: 'db' })).toBeEmpty();
|
|
825
|
+
*/
|
|
826
|
+
var TableAccessor = class {
|
|
827
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
828
|
+
captures;
|
|
829
|
+
/** @internal */
|
|
830
|
+
db;
|
|
831
|
+
/** The table name this accessor reads. */
|
|
832
|
+
name;
|
|
833
|
+
constructor(name, db, captures) {
|
|
834
|
+
this.name = name;
|
|
835
|
+
this.db = db;
|
|
836
|
+
this.captures = captures ?? new CaptureScope();
|
|
837
|
+
}
|
|
838
|
+
/** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
|
|
839
|
+
async query(columns) {
|
|
840
|
+
return await this.db.query(this.name, columns);
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
//#endregion
|
|
844
|
+
//#region src/specification/facets/_common/result/result.ts
|
|
1025
845
|
/**
|
|
1026
846
|
* Base result - common accessors available after any action type.
|
|
1027
847
|
* Extended by HttpResult, CliResult, and used directly by job results.
|
|
@@ -1088,7 +908,7 @@ function validateDatabaseOption(method, config, database) {
|
|
|
1088
908
|
if (keys.length === 1 && database) throw new Error(`${method}(): redundant database option — "${keys[0]}" is the only declared database.`);
|
|
1089
909
|
}
|
|
1090
910
|
//#endregion
|
|
1091
|
-
//#region src/
|
|
911
|
+
//#region src/specification/facets/cli/result.ts
|
|
1092
912
|
/**
|
|
1093
913
|
* Result from a command action (`.exec()`).
|
|
1094
914
|
*
|
|
@@ -1169,7 +989,7 @@ var CliResult = class extends BaseResult {
|
|
|
1169
989
|
}
|
|
1170
990
|
async [Symbol.asyncDispose]() {
|
|
1171
991
|
if (!this.dockerConfig || !this.testRunId) return;
|
|
1172
|
-
removeContainers(this.containersCache
|
|
992
|
+
removeContainers(this.containersCache === null ? findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId) : [...this.containersCache.values()].map((c) => c.id));
|
|
1173
993
|
}
|
|
1174
994
|
ensureDockerAware(member) {
|
|
1175
995
|
if (!this.dockerConfig || !this.testRunId) throw new Error(`CliResult.${member}: runner was not configured with a docker option. Pass \`docker: { envVar, nameLabel, testRunLabel }\` in the specification.cli() options.`);
|
|
@@ -1365,6 +1185,160 @@ var ContainerAccessor = class {
|
|
|
1365
1185
|
}
|
|
1366
1186
|
};
|
|
1367
1187
|
//#endregion
|
|
1188
|
+
//#region src/specification/facets/_common/reporter.ts
|
|
1189
|
+
const GREEN = "\x1B[32m";
|
|
1190
|
+
const RED = "\x1B[31m";
|
|
1191
|
+
const DIM = "\x1B[2m";
|
|
1192
|
+
const BOLD = "\x1B[1m";
|
|
1193
|
+
const RESET = "\x1B[0m";
|
|
1194
|
+
const BG_CYAN = "\x1B[46m";
|
|
1195
|
+
const BLACK = "\x1B[30m";
|
|
1196
|
+
const CHECK = "✓";
|
|
1197
|
+
const CROSS = "×";
|
|
1198
|
+
const ARROW = "→";
|
|
1199
|
+
function formatStartupReport(mode, services, app) {
|
|
1200
|
+
const lines = [];
|
|
1201
|
+
lines.push("", `${BG_CYAN}${BLACK}${BOLD} INFRA ${RESET} Starting infrastructure...`, "");
|
|
1202
|
+
for (const service of services) if (service.error) {
|
|
1203
|
+
lines.push(` ${RED}${CROSS}${RESET} ${service.type} (${service.name}) ${RED}${service.error}${RESET} ${DIM}${service.durationMs}ms${RESET}`);
|
|
1204
|
+
if (service.logs) {
|
|
1205
|
+
const logLines = service.logs.trim().split("\n").slice(-10);
|
|
1206
|
+
for (const logLine of logLines) lines.push(` ${DIM}${logLine}${RESET}`);
|
|
1207
|
+
}
|
|
1208
|
+
} else {
|
|
1209
|
+
const conn = service.connectionString ? `${DIM}${service.connectionString}${RESET}` : "";
|
|
1210
|
+
lines.push(` ${GREEN}${CHECK}${RESET} ${service.type} (${service.name}) ${conn} ${DIM}${service.durationMs}ms${RESET}`);
|
|
1211
|
+
}
|
|
1212
|
+
if (app) {
|
|
1213
|
+
lines.push("");
|
|
1214
|
+
if (app.type === "in-process") lines.push(` ${DIM}${ARROW} app: in-process (Hono)${RESET}`);
|
|
1215
|
+
else lines.push(` ${DIM}${ARROW} app: ${app.url}${RESET}`);
|
|
1216
|
+
}
|
|
1217
|
+
lines.push("");
|
|
1218
|
+
return lines.join("\n");
|
|
1219
|
+
}
|
|
1220
|
+
function formatTableDiff(table, columns, expected, actual) {
|
|
1221
|
+
const lines = [];
|
|
1222
|
+
lines.push(`Table "${table}" mismatch`, `${DIM} query: ${columns.join(", ")}${RESET}`, `${DIM} expected: ${rowLabel(expected.length)}${RESET}`, `${DIM} received: ${rowLabel(actual.length)}${RESET}`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
|
|
1223
|
+
const header = columns.join(" | ");
|
|
1224
|
+
lines.push(`${DIM} ${header}${RESET}`);
|
|
1225
|
+
const maxRows = Math.max(expected.length, actual.length);
|
|
1226
|
+
for (let i = 0; i < maxRows; i++) {
|
|
1227
|
+
const exp = expected[i];
|
|
1228
|
+
const act = actual[i];
|
|
1229
|
+
if (exp && !act) lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
|
|
1230
|
+
else if (!exp && act) lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
|
|
1231
|
+
else if (exp && act) if (JSON.stringify(exp) === JSON.stringify(act)) lines.push(` ${formatRow(act)}`);
|
|
1232
|
+
else lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`, `${RED}+ ${formatRow(act)}${RESET}`);
|
|
1233
|
+
}
|
|
1234
|
+
if (expected.length === 0 && actual.length === 0) lines.push(` (empty)`);
|
|
1235
|
+
return lines.join("\n");
|
|
1236
|
+
}
|
|
1237
|
+
function formatResponseDiff(file, expected, actual) {
|
|
1238
|
+
const lines = [];
|
|
1239
|
+
lines.push(`Response mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
|
|
1240
|
+
const expectedLines = JSON.stringify(expected, null, 2).split("\n");
|
|
1241
|
+
const actualLines = JSON.stringify(actual, null, 2).split("\n");
|
|
1242
|
+
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
1243
|
+
for (let i = 0; i < maxLines; i++) {
|
|
1244
|
+
const exp = expectedLines[i];
|
|
1245
|
+
const act = actualLines[i];
|
|
1246
|
+
if (exp === act) lines.push(` ${exp}`);
|
|
1247
|
+
else {
|
|
1248
|
+
if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
|
|
1249
|
+
if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return lines.join("\n");
|
|
1253
|
+
}
|
|
1254
|
+
const strictEquals = (expected, actual) => expected === actual;
|
|
1255
|
+
function renderLines(expectedLines, actualLines, equals) {
|
|
1256
|
+
const rendered = [];
|
|
1257
|
+
let marked = 0;
|
|
1258
|
+
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
1259
|
+
for (let i = 0; i < maxLines; i++) {
|
|
1260
|
+
const exp = expectedLines[i];
|
|
1261
|
+
const act = actualLines[i];
|
|
1262
|
+
if (exp !== void 0 && act !== void 0 && equals(exp, act)) {
|
|
1263
|
+
rendered.push(` ${exp}`);
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
marked++;
|
|
1267
|
+
if (exp !== void 0) rendered.push(`${GREEN}- ${exp}${RESET}`);
|
|
1268
|
+
if (act !== void 0) rendered.push(`${RED}+ ${act}${RESET}`);
|
|
1269
|
+
}
|
|
1270
|
+
return {
|
|
1271
|
+
marked,
|
|
1272
|
+
rendered
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Render an expected/received diff over two texts.
|
|
1277
|
+
*
|
|
1278
|
+
* `equals` is how a line is judged EQUAL. Callers that compare through the
|
|
1279
|
+
* `{{token}}` grammar pass a token-aware predicate, so a `{{url}}` line that
|
|
1280
|
+
* matched is shown as equal — with its token, not as a `-`/`+` pair against
|
|
1281
|
+
* the concrete URL. Marking a line the comparison ACCEPTED buries the one it
|
|
1282
|
+
* rejected: the reader then has to work out which of the differences is the
|
|
1283
|
+
* actual cause.
|
|
1284
|
+
*
|
|
1285
|
+
* Safety net: if the token-aware pass marks nothing at all, the whole diff
|
|
1286
|
+
* would render as a clean block against a failure that really happened (a
|
|
1287
|
+
* `#ref` captured inconsistently across lines, say). The strict rendering is
|
|
1288
|
+
* used instead, so a diff always shows the reader something.
|
|
1289
|
+
*/
|
|
1290
|
+
function formatStdoutDiff(file, expected, actual, options) {
|
|
1291
|
+
const lines = [];
|
|
1292
|
+
lines.push(`Output mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
|
|
1293
|
+
const expectedLines = expected.split("\n");
|
|
1294
|
+
const actualLines = actual.split("\n");
|
|
1295
|
+
const equals = options?.equals ?? strictEquals;
|
|
1296
|
+
const first = renderLines(expectedLines, actualLines, equals);
|
|
1297
|
+
const rendered = first.marked === 0 && equals !== strictEquals ? renderLines(expectedLines, actualLines, strictEquals).rendered : first.rendered;
|
|
1298
|
+
return [...lines, ...rendered].join("\n");
|
|
1299
|
+
}
|
|
1300
|
+
function formatDirectoryDiff(fixtureName, diff, hint) {
|
|
1301
|
+
const lines = [];
|
|
1302
|
+
const total = diff.added.length + diff.removed.length + diff.changed.length;
|
|
1303
|
+
lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`, `${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`, "", `${GREEN}- Expected (fixture)${RESET}`, `${RED}+ Received (generated)${RESET}`, "");
|
|
1304
|
+
for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
|
|
1305
|
+
for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
|
|
1306
|
+
for (const { path, expected, actual } of diff.changed) {
|
|
1307
|
+
const expectedLines = expected.split("\n");
|
|
1308
|
+
const actualLines = actual.split("\n");
|
|
1309
|
+
const changedCount = countLineDifferences(expectedLines, actualLines);
|
|
1310
|
+
lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
|
|
1311
|
+
let shown = 0;
|
|
1312
|
+
const maxShown = 5;
|
|
1313
|
+
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
1314
|
+
for (let i = 0; i < maxLines && shown < maxShown; i++) {
|
|
1315
|
+
const exp = expectedLines[i];
|
|
1316
|
+
const act = actualLines[i];
|
|
1317
|
+
if (exp !== act) {
|
|
1318
|
+
lines.push(`${DIM} line ${i + 1}:${RESET}`);
|
|
1319
|
+
if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
|
|
1320
|
+
if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
|
|
1321
|
+
shown++;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
|
|
1325
|
+
}
|
|
1326
|
+
lines.push("", `${DIM}${hint}${RESET}`);
|
|
1327
|
+
return lines.join("\n");
|
|
1328
|
+
}
|
|
1329
|
+
function countLineDifferences(expected, actual) {
|
|
1330
|
+
let count = 0;
|
|
1331
|
+
const max = Math.max(expected.length, actual.length);
|
|
1332
|
+
for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
|
|
1333
|
+
return count;
|
|
1334
|
+
}
|
|
1335
|
+
function rowLabel(n) {
|
|
1336
|
+
return n === 1 ? "1 row" : `${n} rows`;
|
|
1337
|
+
}
|
|
1338
|
+
function formatRow(row) {
|
|
1339
|
+
return row.map((v) => String(v ?? "null")).join(" | ");
|
|
1340
|
+
}
|
|
1341
|
+
//#endregion
|
|
1368
1342
|
//#region src/vitest/update.ts
|
|
1369
1343
|
/**
|
|
1370
1344
|
* Snapshot update-mode detection. `TEST_UPDATE=1` (framework convention, see
|
|
@@ -1378,15 +1352,17 @@ function shouldUpdateSnapshots() {
|
|
|
1378
1352
|
/** Standard hint appended to missing-fixture errors. */
|
|
1379
1353
|
const UPDATE_HINT = "Run with TEST_UPDATE=1 (or vitest -u) to create it.";
|
|
1380
1354
|
//#endregion
|
|
1381
|
-
//#region src/
|
|
1355
|
+
//#region src/specification/facets/website/serve.adapter.ts
|
|
1382
1356
|
/** Grace period between SIGTERM and the SIGKILL escalation. */
|
|
1383
1357
|
const KILL_GRACE_MS = 2e3;
|
|
1384
1358
|
const DEFAULT_READY_TIMEOUT = 3e4;
|
|
1385
1359
|
const READY_POLL_INTERVAL_MS = 250;
|
|
1386
|
-
const delay = (ms) =>
|
|
1360
|
+
const delay = async (ms) => {
|
|
1361
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1362
|
+
};
|
|
1387
1363
|
/** Ask the OS for a free TCP port. */
|
|
1388
|
-
function findFreePort() {
|
|
1389
|
-
return new Promise((resolve, reject) => {
|
|
1364
|
+
async function findFreePort() {
|
|
1365
|
+
return await new Promise((resolve, reject) => {
|
|
1390
1366
|
const server = createServer();
|
|
1391
1367
|
server.listen(0, () => {
|
|
1392
1368
|
const address = server.address();
|
|
@@ -1396,7 +1372,9 @@ function findFreePort() {
|
|
|
1396
1372
|
return;
|
|
1397
1373
|
}
|
|
1398
1374
|
const { port } = address;
|
|
1399
|
-
server.close(() =>
|
|
1375
|
+
server.close(() => {
|
|
1376
|
+
resolve(port);
|
|
1377
|
+
});
|
|
1400
1378
|
});
|
|
1401
1379
|
server.on("error", reject);
|
|
1402
1380
|
});
|
|
@@ -1500,7 +1478,7 @@ var ServeAdapter = class {
|
|
|
1500
1478
|
}
|
|
1501
1479
|
/** Terminate the server process group (idempotent). */
|
|
1502
1480
|
async stop() {
|
|
1503
|
-
const child = this
|
|
1481
|
+
const { child } = this;
|
|
1504
1482
|
if (!child) return;
|
|
1505
1483
|
this.child = null;
|
|
1506
1484
|
await new Promise((resolve) => {
|
|
@@ -1532,7 +1510,7 @@ var ServeAdapter = class {
|
|
|
1532
1510
|
}
|
|
1533
1511
|
};
|
|
1534
1512
|
//#endregion
|
|
1535
|
-
//#region src/
|
|
1513
|
+
//#region src/specification/facets/cli/literate.ts
|
|
1536
1514
|
/**
|
|
1537
1515
|
* The form a stream is compared in: ANSI stripped (rule D6) and the runner's
|
|
1538
1516
|
* `transform` applied. Nothing else — a block scalar states its own final
|
|
@@ -1636,7 +1614,7 @@ function assertRun(document, outcome, context, workDir, scope) {
|
|
|
1636
1614
|
...outcome.stderr.length > 0 ? [
|
|
1637
1615
|
"",
|
|
1638
1616
|
"stderr was:",
|
|
1639
|
-
outcome.stderr.replace(/\n
|
|
1617
|
+
outcome.stderr.replace(/\n$/u, "")
|
|
1640
1618
|
] : []
|
|
1641
1619
|
].join("\n")]);
|
|
1642
1620
|
const equals = tokenAwareEquals(scope);
|
|
@@ -1754,8 +1732,8 @@ async function runSpecDocument(options) {
|
|
|
1754
1732
|
} catch (error) {
|
|
1755
1733
|
throw error instanceof SpecSyntaxError ? atLine(error, filePath, error.line) : error;
|
|
1756
1734
|
}
|
|
1757
|
-
const document = file
|
|
1758
|
-
const fileDir = filePath.replace(/[/\\][^/\\]
|
|
1735
|
+
const { document } = file;
|
|
1736
|
+
const fileDir = filePath.replace(/[/\\][^/\\]*$/u, "");
|
|
1759
1737
|
for (const fixture of document.fixtures) {
|
|
1760
1738
|
const { dest, src } = copyPlan(fixture.path, fileDir, workDir);
|
|
1761
1739
|
cpSync(src, dest, { recursive: true });
|
|
@@ -1816,4 +1794,4 @@ const COVERAGE_DIR = `${VITEST_ARTIFACTS_DIR}/coverage`;
|
|
|
1816
1794
|
/** Where `sqlite()` caches the schema template it copies per worker. */
|
|
1817
1795
|
const SQLITE_TEMPLATE_DIR = `${VITEST_ARTIFACTS_DIR}/sqlite`;
|
|
1818
1796
|
//#endregion
|
|
1819
|
-
export {
|
|
1797
|
+
export { placeholderPatternSource as A, text as B, FilesystemAccessor as C, hasPlaceholders as D, walkDirectory as E, findContainersByLabel as F, inspectContainer as I, removeContainers as L, structuralEquals as M, structuralSubset as N, mergePreservingPlaceholders as O, textEquals as P, JsonAccessor as R, resolveRoot as S, diffDirectories as T, getCallerDir as V, validateDatabaseOption as _, specDescription as a, expandWorkdir as b, shouldUpdateSnapshots as c, formatStartupReport as d, formatStdoutDiff as f, BaseResult as g, CliResult as h, runSpecDocument as i, renderExpected as j, mergeTextPreservingPlaceholders as k, formatDirectoryDiff as l, ContainerAccessor as m, SQLITE_TEMPLATE_DIR as n, ServeAdapter as o, formatTableDiff as p, VITEST_ARTIFACTS_DIR as r, UPDATE_HINT as s, COVERAGE_DIR as t, formatResponseDiff as u, TableAccessor as v, DirectoryAccessor as w, resolveCommand as x, discoverRoot as y, TextAccessor as z };
|