@jterrazz/test 4.0.1 → 5.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 +68 -48
- package/dist/index.cjs +410 -268
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +178 -109
- package/dist/index.d.ts +178 -109
- package/dist/index.js +410 -271
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import MockDatePackage from "mockdate";
|
|
2
2
|
import { mockDeep } from "vitest-mock-extended";
|
|
3
|
-
import { cpSync, existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
4
|
-
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { execSync, spawn } from "node:child_process";
|
|
6
6
|
import { parse } from "yaml";
|
|
7
7
|
import { Client } from "pg";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
|
+
import { readdir } from "node:fs/promises";
|
|
9
10
|
//#region src/mocking/mock-of-date.ts
|
|
10
11
|
const mockOfDate = MockDatePackage;
|
|
11
12
|
//#endregion
|
|
@@ -220,20 +221,6 @@ function formatStartupReport(mode, services, app) {
|
|
|
220
221
|
lines.push("");
|
|
221
222
|
return lines.join("\n");
|
|
222
223
|
}
|
|
223
|
-
function formatStatusError(expectedStatus, receivedStatus, request, responseBody) {
|
|
224
|
-
const lines = [];
|
|
225
|
-
lines.push(`Expected status: ${GREEN}${expectedStatus}${RESET}`);
|
|
226
|
-
lines.push(`Received status: ${RED}${receivedStatus}${RESET}`);
|
|
227
|
-
lines.push("");
|
|
228
|
-
lines.push(`${DIM}${request.method} ${request.path}${RESET}`);
|
|
229
|
-
if (request.body) lines.push(formatJson(request.body, DIM));
|
|
230
|
-
if (responseBody) {
|
|
231
|
-
lines.push("");
|
|
232
|
-
lines.push(`${DIM}Response:${RESET}`);
|
|
233
|
-
lines.push(formatJson(responseBody, RED));
|
|
234
|
-
}
|
|
235
|
-
return lines.join("\n");
|
|
236
|
-
}
|
|
237
224
|
function formatTableDiff(table, columns, expected, actual) {
|
|
238
225
|
const lines = [];
|
|
239
226
|
lines.push(`Table "${table}" mismatch`);
|
|
@@ -282,66 +269,50 @@ function formatResponseDiff(file, expected, actual) {
|
|
|
282
269
|
}
|
|
283
270
|
return lines.join("\n");
|
|
284
271
|
}
|
|
285
|
-
function
|
|
272
|
+
function formatDirectoryDiff(fixtureName, diff, hint) {
|
|
286
273
|
const lines = [];
|
|
287
|
-
|
|
288
|
-
lines.push(`
|
|
289
|
-
|
|
290
|
-
lines.push("");
|
|
291
|
-
lines.push(`${DIM}stdout:${RESET}`);
|
|
292
|
-
for (const line of stdout.trim().split("\n").slice(-15)) lines.push(` ${DIM}${line}${RESET}`);
|
|
293
|
-
}
|
|
294
|
-
if (stderr.trim()) {
|
|
295
|
-
lines.push("");
|
|
296
|
-
lines.push(`${DIM}stderr:${RESET}`);
|
|
297
|
-
for (const line of stderr.trim().split("\n").slice(-15)) lines.push(` ${RED}${line}${RESET}`);
|
|
298
|
-
}
|
|
299
|
-
return lines.join("\n");
|
|
300
|
-
}
|
|
301
|
-
function formatStdoutDiff(file, expected, actual) {
|
|
302
|
-
const lines = [];
|
|
303
|
-
lines.push(`Output mismatch (${file})`);
|
|
274
|
+
const total = diff.added.length + diff.removed.length + diff.changed.length;
|
|
275
|
+
lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`);
|
|
276
|
+
lines.push(`${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`);
|
|
304
277
|
lines.push("");
|
|
305
|
-
lines.push(`${GREEN}- Expected${RESET}`);
|
|
306
|
-
lines.push(`${RED}+ Received${RESET}`);
|
|
278
|
+
lines.push(`${GREEN}- Expected (fixture)${RESET}`);
|
|
279
|
+
lines.push(`${RED}+ Received (generated)${RESET}`);
|
|
307
280
|
lines.push("");
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
281
|
+
for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
|
|
282
|
+
for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
|
|
283
|
+
for (const { path, expected, actual } of diff.changed) {
|
|
284
|
+
const expectedLines = expected.split("\n");
|
|
285
|
+
const actualLines = actual.split("\n");
|
|
286
|
+
const changedCount = countLineDifferences(expectedLines, actualLines);
|
|
287
|
+
lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
|
|
288
|
+
let shown = 0;
|
|
289
|
+
const maxShown = 5;
|
|
290
|
+
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
291
|
+
for (let i = 0; i < maxLines && shown < maxShown; i++) {
|
|
292
|
+
const exp = expectedLines[i];
|
|
293
|
+
const act = actualLines[i];
|
|
294
|
+
if (exp !== act) {
|
|
295
|
+
lines.push(`${DIM} line ${i + 1}:${RESET}`);
|
|
296
|
+
if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
|
|
297
|
+
if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
|
|
298
|
+
shown++;
|
|
299
|
+
}
|
|
318
300
|
}
|
|
301
|
+
if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
|
|
319
302
|
}
|
|
320
|
-
return lines.join("\n");
|
|
321
|
-
}
|
|
322
|
-
function formatFileMissing(path) {
|
|
323
|
-
return `Expected file to exist: ${RED}${path}${RESET}`;
|
|
324
|
-
}
|
|
325
|
-
function formatFileUnexpected(path) {
|
|
326
|
-
return `Expected file NOT to exist: ${RED}${path}${RESET}`;
|
|
327
|
-
}
|
|
328
|
-
function formatFileContentMismatch(path, expected, actual) {
|
|
329
|
-
const lines = [];
|
|
330
|
-
lines.push(`File "${path}" does not contain expected content`);
|
|
331
303
|
lines.push("");
|
|
332
|
-
lines.push(`${
|
|
333
|
-
lines.push(` ${GREEN}${expected}${RESET}`);
|
|
334
|
-
lines.push("");
|
|
335
|
-
lines.push(`${RED}Actual content (first 20 lines):${RESET}`);
|
|
336
|
-
for (const line of actual.split("\n").slice(0, 20)) lines.push(` ${DIM}${line}${RESET}`);
|
|
304
|
+
lines.push(`${DIM}${hint}${RESET}`);
|
|
337
305
|
return lines.join("\n");
|
|
338
306
|
}
|
|
307
|
+
function countLineDifferences(expected, actual) {
|
|
308
|
+
let count = 0;
|
|
309
|
+
const max = Math.max(expected.length, actual.length);
|
|
310
|
+
for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
|
|
311
|
+
return count;
|
|
312
|
+
}
|
|
339
313
|
function rowLabel(n) {
|
|
340
314
|
return n === 1 ? "1 row" : `${n} rows`;
|
|
341
315
|
}
|
|
342
|
-
function formatJson(value, color) {
|
|
343
|
-
return JSON.stringify(value, null, 2).split("\n").map((line) => `${color}${line}${RESET}`).join("\n");
|
|
344
|
-
}
|
|
345
316
|
function formatRow(row) {
|
|
346
317
|
return row.map((v) => String(v ?? "null")).join(" | ");
|
|
347
318
|
}
|
|
@@ -690,6 +661,19 @@ var Orchestrator = class {
|
|
|
690
661
|
//#endregion
|
|
691
662
|
//#region src/specification/adapters/exec.adapter.ts
|
|
692
663
|
/**
|
|
664
|
+
* Build a child-process env from the parent env plus user overrides.
|
|
665
|
+
* `null` overrides delete keys (e.g. `INIT_CWD: null`).
|
|
666
|
+
*/
|
|
667
|
+
function buildEnv(extra) {
|
|
668
|
+
const env = {
|
|
669
|
+
...process.env,
|
|
670
|
+
INIT_CWD: void 0
|
|
671
|
+
};
|
|
672
|
+
if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
|
|
673
|
+
else env[key] = value;
|
|
674
|
+
return env;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
693
677
|
* Executes CLI commands via execSync (blocking) or spawn (long-running).
|
|
694
678
|
* Used by cli() for local command execution.
|
|
695
679
|
*/
|
|
@@ -698,11 +682,8 @@ var ExecAdapter = class {
|
|
|
698
682
|
constructor(command) {
|
|
699
683
|
this.command = command;
|
|
700
684
|
}
|
|
701
|
-
async exec(args, cwd) {
|
|
702
|
-
const env =
|
|
703
|
-
...process.env,
|
|
704
|
-
INIT_CWD: void 0
|
|
705
|
-
};
|
|
685
|
+
async exec(args, cwd, extraEnv) {
|
|
686
|
+
const env = buildEnv(extraEnv);
|
|
706
687
|
try {
|
|
707
688
|
return {
|
|
708
689
|
exitCode: 0,
|
|
@@ -726,11 +707,8 @@ var ExecAdapter = class {
|
|
|
726
707
|
};
|
|
727
708
|
}
|
|
728
709
|
}
|
|
729
|
-
async spawn(args, cwd, options) {
|
|
730
|
-
const env =
|
|
731
|
-
...process.env,
|
|
732
|
-
INIT_CWD: void 0
|
|
733
|
-
};
|
|
710
|
+
async spawn(args, cwd, options, extraEnv) {
|
|
711
|
+
const env = buildEnv(extraEnv);
|
|
734
712
|
return new Promise((resolve) => {
|
|
735
713
|
let stdout = "";
|
|
736
714
|
let stderr = "";
|
|
@@ -837,205 +815,152 @@ var HonoAdapter = class {
|
|
|
837
815
|
}
|
|
838
816
|
};
|
|
839
817
|
//#endregion
|
|
840
|
-
//#region src/specification/
|
|
818
|
+
//#region src/specification/directory.ts
|
|
841
819
|
/**
|
|
842
|
-
*
|
|
843
|
-
*
|
|
820
|
+
* Default ignore patterns — paths that should never appear in a tracked snapshot.
|
|
821
|
+
* Each entry is matched against any path segment OR a path prefix.
|
|
844
822
|
*/
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
if (this.negated) {
|
|
855
|
-
if (condition) throw new Error(negatedMessage);
|
|
856
|
-
} else if (!condition) throw new Error(message);
|
|
857
|
-
}
|
|
858
|
-
};
|
|
859
|
-
//#endregion
|
|
860
|
-
//#region src/specification/assertions/file.ts
|
|
823
|
+
const DEFAULT_IGNORES = [
|
|
824
|
+
".git",
|
|
825
|
+
".DS_Store",
|
|
826
|
+
"node_modules",
|
|
827
|
+
".next",
|
|
828
|
+
"dist",
|
|
829
|
+
".turbo",
|
|
830
|
+
".cache"
|
|
831
|
+
];
|
|
861
832
|
/**
|
|
862
|
-
*
|
|
863
|
-
*
|
|
833
|
+
* Recursively walk a directory, returning sorted relative paths of files only.
|
|
834
|
+
* Ignored entries (default + caller-supplied) are skipped.
|
|
864
835
|
*/
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const exists = existsSync(this.resolvedPath);
|
|
875
|
-
this.assert(exists, formatFileMissing(this.filePath), formatFileUnexpected(this.filePath));
|
|
876
|
-
}
|
|
877
|
-
toContain(expected) {
|
|
878
|
-
if (!existsSync(this.resolvedPath)) {
|
|
879
|
-
if (this.negated) return;
|
|
880
|
-
throw new Error(formatFileMissing(this.filePath));
|
|
836
|
+
async function walkDirectory(root, options = {}) {
|
|
837
|
+
const ignores = new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
|
|
838
|
+
const out = [];
|
|
839
|
+
async function walk(current) {
|
|
840
|
+
let entries;
|
|
841
|
+
try {
|
|
842
|
+
entries = await readdir(current);
|
|
843
|
+
} catch {
|
|
844
|
+
return;
|
|
881
845
|
}
|
|
882
|
-
const
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
if (this.negated) return;
|
|
889
|
-
throw new Error(formatFileMissing(this.filePath));
|
|
846
|
+
for (const entry of entries) {
|
|
847
|
+
if (ignores.has(entry)) continue;
|
|
848
|
+
const abs = resolve(current, entry);
|
|
849
|
+
const stat = statSync(abs);
|
|
850
|
+
if (stat.isDirectory()) await walk(abs);
|
|
851
|
+
else if (stat.isFile()) out.push(relative(root, abs).split(sep).join("/"));
|
|
890
852
|
}
|
|
891
|
-
const content = readFileSync(this.resolvedPath, "utf8");
|
|
892
|
-
const found = pattern.test(content);
|
|
893
|
-
this.assert(found, `Expected file "${this.filePath}" to match: ${pattern}\n\nActual content:\n${content.slice(0, 500)}`, `Expected file "${this.filePath}" NOT to match: ${pattern}`);
|
|
894
|
-
}
|
|
895
|
-
};
|
|
896
|
-
//#endregion
|
|
897
|
-
//#region src/specification/assertions/response.ts
|
|
898
|
-
/**
|
|
899
|
-
* Assertions on an HTTP response body.
|
|
900
|
-
* Usage: result.response.toMatchFile("expected.json")
|
|
901
|
-
*/
|
|
902
|
-
var ResponseAssertion = class extends BaseAssertion {
|
|
903
|
-
body;
|
|
904
|
-
testDir;
|
|
905
|
-
constructor(body, testDir) {
|
|
906
|
-
super();
|
|
907
|
-
this.body = body;
|
|
908
|
-
this.testDir = testDir;
|
|
909
|
-
}
|
|
910
|
-
toMatchFile(file) {
|
|
911
|
-
const expected = JSON.parse(readFileSync(resolve(this.testDir, "responses", file), "utf8"));
|
|
912
|
-
const match = JSON.stringify(this.body) === JSON.stringify(expected);
|
|
913
|
-
this.assert(match, formatResponseDiff(file, expected, this.body), `Expected response NOT to match file "${file}", but it did`);
|
|
914
|
-
}
|
|
915
|
-
toContain(subset) {
|
|
916
|
-
const bodyStr = JSON.stringify(this.body);
|
|
917
|
-
const subsetStr = JSON.stringify(subset);
|
|
918
|
-
const bodyObj = typeof this.body === "object" && this.body !== null ? this.body : {};
|
|
919
|
-
const match = Object.entries(subset).every(([key, value]) => JSON.stringify(bodyObj[key]) === JSON.stringify(value));
|
|
920
|
-
this.assert(match, `Expected response to contain: ${subsetStr}\n\nActual response:\n${bodyStr}`, `Expected response NOT to contain: ${subsetStr}`);
|
|
921
853
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
854
|
+
await walk(root);
|
|
855
|
+
out.sort();
|
|
856
|
+
return out;
|
|
857
|
+
}
|
|
925
858
|
/**
|
|
926
|
-
*
|
|
927
|
-
*
|
|
859
|
+
* Compare two directory trees file-by-file.
|
|
860
|
+
* Binary files are compared by byte equality but reported without inline diff.
|
|
928
861
|
*/
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
toMatch(pattern) {
|
|
949
|
-
const found = pattern.test(this.actual);
|
|
950
|
-
this.assert(found, `Expected ${this.label} to match: ${pattern}\n\nActual ${this.label}:\n${this.truncate(this.actual)}`, `Expected ${this.label} NOT to match: ${pattern}`);
|
|
951
|
-
}
|
|
952
|
-
toMatchFile(file) {
|
|
953
|
-
if (!this.testDir) throw new Error("toMatchFile requires a test directory context");
|
|
954
|
-
const expected = readFileSync(resolve(this.testDir, "expected", file), "utf8").trim();
|
|
955
|
-
const actual = this.actual.trim();
|
|
956
|
-
const match = actual === expected;
|
|
957
|
-
this.assert(match, formatStdoutDiff(file, expected, actual), `Expected ${this.label} NOT to match file "${file}", but it did`);
|
|
958
|
-
}
|
|
959
|
-
toBeEmpty() {
|
|
960
|
-
const empty = this.actual.trim() === "";
|
|
961
|
-
this.assert(empty, `Expected ${this.label} to be empty\n\nActual ${this.label}:\n${this.truncate(this.actual)}`, `Expected ${this.label} NOT to be empty`);
|
|
962
|
-
}
|
|
963
|
-
containsNear(target, near, proximity = 500) {
|
|
964
|
-
const clean = this.stripAnsi(this.actual);
|
|
965
|
-
const nearLower = near.toLowerCase();
|
|
966
|
-
const targetLower = target.toLowerCase();
|
|
967
|
-
let searchFrom = 0;
|
|
968
|
-
while (true) {
|
|
969
|
-
const idx = clean.toLowerCase().indexOf(nearLower, searchFrom);
|
|
970
|
-
if (idx === -1) break;
|
|
971
|
-
const windowStart = Math.max(0, idx - proximity);
|
|
972
|
-
const windowEnd = Math.min(clean.length, idx + nearLower.length + proximity);
|
|
973
|
-
if (clean.substring(windowStart, windowEnd).toLowerCase().includes(targetLower)) return true;
|
|
974
|
-
searchFrom = idx + 1;
|
|
975
|
-
}
|
|
976
|
-
return false;
|
|
977
|
-
}
|
|
978
|
-
stripAnsi(str) {
|
|
979
|
-
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
980
|
-
}
|
|
981
|
-
truncate(str, maxLines = 20) {
|
|
982
|
-
const lines = str.split("\n");
|
|
983
|
-
if (lines.length <= maxLines) return str;
|
|
984
|
-
return `${lines.slice(0, maxLines).join("\n")}\n... (${lines.length - maxLines} more lines)`;
|
|
862
|
+
async function diffDirectories(expectedRoot, actualRoot, options = {}) {
|
|
863
|
+
const expectedFiles = await walkDirectory(expectedRoot, options);
|
|
864
|
+
const actualFiles = await walkDirectory(actualRoot, options);
|
|
865
|
+
const expectedSet = new Set(expectedFiles);
|
|
866
|
+
const actualSet = new Set(actualFiles);
|
|
867
|
+
const added = actualFiles.filter((f) => !expectedSet.has(f));
|
|
868
|
+
const removed = expectedFiles.filter((f) => !actualSet.has(f));
|
|
869
|
+
const changed = [];
|
|
870
|
+
for (const file of expectedFiles) {
|
|
871
|
+
if (!actualSet.has(file)) continue;
|
|
872
|
+
const expected = readFileSync(resolve(expectedRoot, file), "utf8");
|
|
873
|
+
const actual = readFileSync(resolve(actualRoot, file), "utf8");
|
|
874
|
+
if (expected !== actual) changed.push({
|
|
875
|
+
actual,
|
|
876
|
+
expected,
|
|
877
|
+
path: file
|
|
878
|
+
});
|
|
985
879
|
}
|
|
986
|
-
|
|
880
|
+
return {
|
|
881
|
+
added,
|
|
882
|
+
changed,
|
|
883
|
+
removed
|
|
884
|
+
};
|
|
885
|
+
}
|
|
987
886
|
//#endregion
|
|
988
|
-
//#region src/specification/
|
|
989
|
-
|
|
990
|
-
* Assertions on a database table.
|
|
991
|
-
* Usage: await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] })
|
|
992
|
-
*/
|
|
993
|
-
var TableAssertion = class extends BaseAssertion {
|
|
887
|
+
//#region src/specification/specification.ts
|
|
888
|
+
var TableAssertion = class {
|
|
994
889
|
tableName;
|
|
995
890
|
db;
|
|
996
891
|
constructor(tableName, db) {
|
|
997
|
-
super();
|
|
998
892
|
this.tableName = tableName;
|
|
999
893
|
this.db = db;
|
|
1000
894
|
}
|
|
1001
895
|
async toMatch(expected) {
|
|
1002
896
|
const actual = await this.db.query(this.tableName, expected.columns);
|
|
1003
|
-
|
|
1004
|
-
this.assert(match, formatTableDiff(this.tableName, expected.columns, expected.rows, actual), `Expected table "${this.tableName}" NOT to match, but it did`);
|
|
897
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected.rows)) throw new Error(formatTableDiff(this.tableName, expected.columns, expected.rows, actual));
|
|
1005
898
|
}
|
|
1006
899
|
async toBeEmpty() {
|
|
1007
900
|
const actual = await this.db.query(this.tableName, ["*"]);
|
|
1008
|
-
|
|
1009
|
-
this.assert(empty, `Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`, `Expected table "${this.tableName}" NOT to be empty, but it is`);
|
|
901
|
+
if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
|
|
1010
902
|
}
|
|
1011
903
|
};
|
|
1012
|
-
//#endregion
|
|
1013
|
-
//#region src/specification/assertions/value.ts
|
|
1014
904
|
/**
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
905
|
+
* Detect whether the user wants to update snapshots — `true` for any of:
|
|
906
|
+
* - vitest run with `-u` / `--update`
|
|
907
|
+
* - JTERRAZZ_TEST_UPDATE=1
|
|
908
|
+
* - UPDATE_SNAPSHOTS=1
|
|
1017
909
|
*/
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
910
|
+
function shouldUpdateSnapshots() {
|
|
911
|
+
if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
|
|
912
|
+
if (process.env.UPDATE_SNAPSHOTS === "1") return true;
|
|
913
|
+
if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
var DirectoryAccessor = class {
|
|
917
|
+
absPath;
|
|
918
|
+
testDir;
|
|
919
|
+
constructor(absPath, testDir) {
|
|
920
|
+
this.absPath = absPath;
|
|
921
|
+
this.testDir = testDir;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Compare the directory tree against `expected/{name}/` (relative to the test file).
|
|
925
|
+
* On mismatch, throws with a structured diff. With update mode enabled, the
|
|
926
|
+
* fixture is overwritten with the current contents instead.
|
|
927
|
+
*/
|
|
928
|
+
async toMatchFixture(name, options = {}) {
|
|
929
|
+
const fixtureDir = resolve(this.testDir, "expected", name);
|
|
930
|
+
if (options.update ?? shouldUpdateSnapshots()) {
|
|
931
|
+
rmSync(fixtureDir, {
|
|
932
|
+
force: true,
|
|
933
|
+
recursive: true
|
|
934
|
+
});
|
|
935
|
+
mkdirSync(fixtureDir, { recursive: true });
|
|
936
|
+
cpSync(this.absPath, fixtureDir, { recursive: true });
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (!existsSync(fixtureDir)) throw new Error(`Directory fixture "${name}" does not exist at ${fixtureDir}.\nRun with JTERRAZZ_TEST_UPDATE=1 (or vitest -u) to create it.`);
|
|
940
|
+
const diff = await diffDirectories(fixtureDir, this.absPath, { ignore: options.ignore });
|
|
941
|
+
if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return;
|
|
942
|
+
throw new Error(formatDirectoryDiff(name, diff, "Run with JTERRAZZ_TEST_UPDATE=1 to update the fixture."));
|
|
1027
943
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
944
|
+
/**
|
|
945
|
+
* List all files in the directory (recursive, sorted, ignoring defaults).
|
|
946
|
+
* Useful for ad-hoc assertions when you don't want a full snapshot.
|
|
947
|
+
*/
|
|
948
|
+
async files(options = {}) {
|
|
949
|
+
return walkDirectory(this.absPath, options);
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
var ResponseAccessor = class {
|
|
953
|
+
body;
|
|
954
|
+
testDir;
|
|
955
|
+
constructor(body, testDir) {
|
|
956
|
+
this.body = body;
|
|
957
|
+
this.testDir = testDir;
|
|
958
|
+
}
|
|
959
|
+
toMatchFile(file) {
|
|
960
|
+
const expected = JSON.parse(readFileSync(resolve(this.testDir, "responses", file), "utf8"));
|
|
961
|
+
if (JSON.stringify(this.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.body));
|
|
1035
962
|
}
|
|
1036
963
|
};
|
|
1037
|
-
//#endregion
|
|
1038
|
-
//#region src/specification/specification.ts
|
|
1039
964
|
var SpecificationResult = class {
|
|
1040
965
|
commandResult;
|
|
1041
966
|
config;
|
|
@@ -1053,32 +978,37 @@ var SpecificationResult = class {
|
|
|
1053
978
|
}
|
|
1054
979
|
get exitCode() {
|
|
1055
980
|
if (!this.commandResult) throw new Error(".exitCode requires a CLI action (.exec())");
|
|
1056
|
-
return
|
|
1057
|
-
stderr: this.commandResult.stderr,
|
|
1058
|
-
stdout: this.commandResult.stdout
|
|
1059
|
-
});
|
|
981
|
+
return this.commandResult.exitCode;
|
|
1060
982
|
}
|
|
1061
983
|
get status() {
|
|
1062
|
-
if (!this.responseData
|
|
1063
|
-
return
|
|
1064
|
-
request: this.requestInfo,
|
|
1065
|
-
responseBody: this.responseData.body
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1068
|
-
get response() {
|
|
1069
|
-
if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
|
|
1070
|
-
return new ResponseAssertion(this.responseData.body, this.testDir);
|
|
984
|
+
if (!this.responseData) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
|
|
985
|
+
return this.responseData.status;
|
|
1071
986
|
}
|
|
1072
987
|
get stdout() {
|
|
1073
988
|
if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
|
|
1074
|
-
return
|
|
989
|
+
return this.commandResult.stdout;
|
|
1075
990
|
}
|
|
1076
991
|
get stderr() {
|
|
1077
992
|
if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
|
|
1078
|
-
return
|
|
993
|
+
return this.commandResult.stderr;
|
|
994
|
+
}
|
|
995
|
+
get response() {
|
|
996
|
+
if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
|
|
997
|
+
return new ResponseAccessor(this.responseData.body, this.testDir);
|
|
998
|
+
}
|
|
999
|
+
directory(path = ".") {
|
|
1000
|
+
return new DirectoryAccessor(resolve(this.workDir ?? this.testDir, path), this.testDir);
|
|
1079
1001
|
}
|
|
1080
1002
|
file(path) {
|
|
1081
|
-
|
|
1003
|
+
const resolvedPath = resolve(this.workDir ?? this.testDir, path);
|
|
1004
|
+
const exists = existsSync(resolvedPath);
|
|
1005
|
+
return {
|
|
1006
|
+
get content() {
|
|
1007
|
+
if (!exists) throw new Error(`File not found: ${path}`);
|
|
1008
|
+
return readFileSync(resolvedPath, "utf8");
|
|
1009
|
+
},
|
|
1010
|
+
exists
|
|
1011
|
+
};
|
|
1082
1012
|
}
|
|
1083
1013
|
table(tableName, options) {
|
|
1084
1014
|
const db = this.resolveDatabase(options?.service);
|
|
@@ -1092,6 +1022,7 @@ var SpecificationResult = class {
|
|
|
1092
1022
|
};
|
|
1093
1023
|
var SpecificationBuilder = class {
|
|
1094
1024
|
commandArgs = null;
|
|
1025
|
+
commandEnv = {};
|
|
1095
1026
|
config;
|
|
1096
1027
|
fixtures = [];
|
|
1097
1028
|
label;
|
|
@@ -1125,6 +1056,23 @@ var SpecificationBuilder = class {
|
|
|
1125
1056
|
this.mocks.push({ file });
|
|
1126
1057
|
return this;
|
|
1127
1058
|
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Set environment variables for the CLI process. Merged on top of process.env.
|
|
1061
|
+
* Use `null` to unset a variable. Multiple calls merge.
|
|
1062
|
+
*
|
|
1063
|
+
* The token `$WORKDIR` (in any value) is replaced with the actual working
|
|
1064
|
+
* directory at run-time — useful for tests that need a fully isolated `HOME`.
|
|
1065
|
+
*
|
|
1066
|
+
* @example
|
|
1067
|
+
* spec("...").env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status").run();
|
|
1068
|
+
*/
|
|
1069
|
+
env(env) {
|
|
1070
|
+
this.commandEnv = {
|
|
1071
|
+
...this.commandEnv,
|
|
1072
|
+
...env
|
|
1073
|
+
};
|
|
1074
|
+
return this;
|
|
1075
|
+
}
|
|
1128
1076
|
get(path) {
|
|
1129
1077
|
this.request = {
|
|
1130
1078
|
method: "GET",
|
|
@@ -1190,6 +1138,16 @@ var SpecificationBuilder = class {
|
|
|
1190
1138
|
if (hasHttpAction) return this.runHttpAction();
|
|
1191
1139
|
return this.runCliAction(workDir);
|
|
1192
1140
|
}
|
|
1141
|
+
resolveEnv(workDir) {
|
|
1142
|
+
const keys = Object.keys(this.commandEnv);
|
|
1143
|
+
if (keys.length === 0) return;
|
|
1144
|
+
const resolved = {};
|
|
1145
|
+
for (const key of keys) {
|
|
1146
|
+
const value = this.commandEnv[key];
|
|
1147
|
+
resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
|
|
1148
|
+
}
|
|
1149
|
+
return resolved;
|
|
1150
|
+
}
|
|
1193
1151
|
prepareWorkDir() {
|
|
1194
1152
|
if (!this.projectName && this.fixtures.length === 0) return this.config.fixturesRoot ?? process.cwd();
|
|
1195
1153
|
const tempDir = mkdtempSync(resolve(tmpdir(), "spec-cli-"));
|
|
@@ -1218,19 +1176,20 @@ var SpecificationBuilder = class {
|
|
|
1218
1176
|
}
|
|
1219
1177
|
async runCliAction(workDir) {
|
|
1220
1178
|
if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
|
|
1179
|
+
const env = this.resolveEnv(workDir);
|
|
1221
1180
|
let commandResult;
|
|
1222
|
-
if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options);
|
|
1181
|
+
if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
|
|
1223
1182
|
else if (Array.isArray(this.commandArgs)) {
|
|
1224
1183
|
commandResult = {
|
|
1225
1184
|
exitCode: 0,
|
|
1226
|
-
|
|
1227
|
-
|
|
1185
|
+
stderr: "",
|
|
1186
|
+
stdout: ""
|
|
1228
1187
|
};
|
|
1229
1188
|
for (const args of this.commandArgs) {
|
|
1230
|
-
commandResult = await this.config.command.exec(args, workDir);
|
|
1189
|
+
commandResult = await this.config.command.exec(args, workDir, env);
|
|
1231
1190
|
if (commandResult.exitCode !== 0) break;
|
|
1232
1191
|
}
|
|
1233
|
-
} else commandResult = await this.config.command.exec(this.commandArgs, workDir);
|
|
1192
|
+
} else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
|
|
1234
1193
|
return new SpecificationResult({
|
|
1235
1194
|
commandResult,
|
|
1236
1195
|
config: this.config,
|
|
@@ -1253,16 +1212,26 @@ function getCallerDir() {
|
|
|
1253
1212
|
}
|
|
1254
1213
|
throw new Error("Cannot detect caller directory from stack trace");
|
|
1255
1214
|
}
|
|
1256
|
-
/**
|
|
1257
|
-
* Create a specification runner.
|
|
1258
|
-
* Automatically detects the test directory from the call site.
|
|
1259
|
-
*/
|
|
1260
1215
|
function createSpecificationRunner(config) {
|
|
1261
1216
|
return (label) => {
|
|
1262
1217
|
return new SpecificationBuilder(config, getCallerDir(), label);
|
|
1263
1218
|
};
|
|
1264
1219
|
}
|
|
1265
1220
|
//#endregion
|
|
1221
|
+
//#region src/specification/grep.ts
|
|
1222
|
+
/**
|
|
1223
|
+
* Extract text blocks from output that contain a pattern.
|
|
1224
|
+
* Splits by blank lines (how linter/compiler output is structured),
|
|
1225
|
+
* returns only blocks matching the pattern.
|
|
1226
|
+
*
|
|
1227
|
+
* @example
|
|
1228
|
+
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
1229
|
+
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
1230
|
+
*/
|
|
1231
|
+
function grep(output, pattern) {
|
|
1232
|
+
return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
|
|
1233
|
+
}
|
|
1234
|
+
//#endregion
|
|
1266
1235
|
//#region src/specification/index.ts
|
|
1267
1236
|
/**
|
|
1268
1237
|
* Resolve root — if relative, resolves from the caller's directory.
|
|
@@ -1381,6 +1350,176 @@ async function cli(options) {
|
|
|
1381
1350
|
return runner;
|
|
1382
1351
|
}
|
|
1383
1352
|
//#endregion
|
|
1384
|
-
|
|
1353
|
+
//#region src/infrastructure/docker/docker-adapter.ts
|
|
1354
|
+
var DockerAdapter = class {
|
|
1355
|
+
containerId;
|
|
1356
|
+
constructor(containerId) {
|
|
1357
|
+
this.containerId = containerId;
|
|
1358
|
+
}
|
|
1359
|
+
async exec(cmd) {
|
|
1360
|
+
return execSync(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
|
|
1361
|
+
encoding: "utf8",
|
|
1362
|
+
timeout: 1e4
|
|
1363
|
+
}).trim();
|
|
1364
|
+
}
|
|
1365
|
+
async file(path) {
|
|
1366
|
+
try {
|
|
1367
|
+
return {
|
|
1368
|
+
exists: true,
|
|
1369
|
+
content: await this.exec(["cat", path])
|
|
1370
|
+
};
|
|
1371
|
+
} catch {
|
|
1372
|
+
return {
|
|
1373
|
+
exists: false,
|
|
1374
|
+
content: ""
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
async isRunning() {
|
|
1379
|
+
try {
|
|
1380
|
+
return execSync(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
|
|
1381
|
+
encoding: "utf8",
|
|
1382
|
+
timeout: 5e3
|
|
1383
|
+
}).trim() === "true";
|
|
1384
|
+
} catch {
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
async logs(tail) {
|
|
1389
|
+
return execSync(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
|
|
1390
|
+
encoding: "utf8",
|
|
1391
|
+
timeout: 1e4
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
async inspect() {
|
|
1395
|
+
const raw = execSync(`docker inspect ${this.containerId}`, {
|
|
1396
|
+
encoding: "utf8",
|
|
1397
|
+
timeout: 5e3
|
|
1398
|
+
});
|
|
1399
|
+
const data = JSON.parse(raw)[0];
|
|
1400
|
+
return {
|
|
1401
|
+
id: data.Id,
|
|
1402
|
+
name: data.Name,
|
|
1403
|
+
state: {
|
|
1404
|
+
running: data.State.Running,
|
|
1405
|
+
exitCode: data.State.ExitCode,
|
|
1406
|
+
status: data.State.Status
|
|
1407
|
+
},
|
|
1408
|
+
config: {
|
|
1409
|
+
image: data.Config.Image,
|
|
1410
|
+
env: data.Config.Env || []
|
|
1411
|
+
},
|
|
1412
|
+
hostConfig: {
|
|
1413
|
+
memory: data.HostConfig.Memory || 0,
|
|
1414
|
+
cpuQuota: data.HostConfig.CpuQuota || 0,
|
|
1415
|
+
networkMode: data.HostConfig.NetworkMode || "",
|
|
1416
|
+
mounts: (data.Mounts || []).map((m) => ({
|
|
1417
|
+
source: m.Source,
|
|
1418
|
+
destination: m.Destination,
|
|
1419
|
+
type: m.Type
|
|
1420
|
+
}))
|
|
1421
|
+
},
|
|
1422
|
+
networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
|
|
1423
|
+
gateway: net.Gateway,
|
|
1424
|
+
ipAddress: net.IPAddress
|
|
1425
|
+
}])) }
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
async exists(path) {
|
|
1429
|
+
try {
|
|
1430
|
+
await this.exec([
|
|
1431
|
+
"test",
|
|
1432
|
+
"-e",
|
|
1433
|
+
path
|
|
1434
|
+
]);
|
|
1435
|
+
return true;
|
|
1436
|
+
} catch {
|
|
1437
|
+
return false;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
/** Create a Docker container port for an existing container */
|
|
1442
|
+
function dockerContainer(containerId) {
|
|
1443
|
+
return new DockerAdapter(containerId);
|
|
1444
|
+
}
|
|
1445
|
+
//#endregion
|
|
1446
|
+
//#region src/infrastructure/docker/docker-assertion.ts
|
|
1447
|
+
/** Fluent assertion builder for Docker containers */
|
|
1448
|
+
var DockerAssertion = class {
|
|
1449
|
+
container;
|
|
1450
|
+
constructor(container) {
|
|
1451
|
+
this.container = container;
|
|
1452
|
+
}
|
|
1453
|
+
/** Assert the container is running */
|
|
1454
|
+
async toBeRunning() {
|
|
1455
|
+
if (!await this.container.isRunning()) throw new Error("Expected container to be running");
|
|
1456
|
+
return this;
|
|
1457
|
+
}
|
|
1458
|
+
/** Assert the container is NOT running / doesn't exist */
|
|
1459
|
+
async toNotExist() {
|
|
1460
|
+
if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
|
|
1461
|
+
return this;
|
|
1462
|
+
}
|
|
1463
|
+
/** Assert a file exists inside the container */
|
|
1464
|
+
async toHaveFile(path, opts) {
|
|
1465
|
+
const file = await this.container.file(path);
|
|
1466
|
+
if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
|
|
1467
|
+
if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
|
|
1468
|
+
return this;
|
|
1469
|
+
}
|
|
1470
|
+
/** Assert a file does NOT exist */
|
|
1471
|
+
async toNotHaveFile(path) {
|
|
1472
|
+
if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
|
|
1473
|
+
return this;
|
|
1474
|
+
}
|
|
1475
|
+
/** Assert a directory exists */
|
|
1476
|
+
async toHaveDirectory(path) {
|
|
1477
|
+
if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
|
|
1478
|
+
return this;
|
|
1479
|
+
}
|
|
1480
|
+
/** Assert a mount exists */
|
|
1481
|
+
async toHaveMount(destination) {
|
|
1482
|
+
const info = await this.container.inspect();
|
|
1483
|
+
if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
|
|
1484
|
+
const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
|
|
1485
|
+
throw new Error(`Expected mount at ${destination}, found: [${available}]`);
|
|
1486
|
+
}
|
|
1487
|
+
return this;
|
|
1488
|
+
}
|
|
1489
|
+
/** Assert network mode */
|
|
1490
|
+
async toHaveNetwork(mode) {
|
|
1491
|
+
const info = await this.container.inspect();
|
|
1492
|
+
if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
|
|
1493
|
+
return this;
|
|
1494
|
+
}
|
|
1495
|
+
/** Assert memory limit */
|
|
1496
|
+
async toHaveMemoryLimit(bytes) {
|
|
1497
|
+
const info = await this.container.inspect();
|
|
1498
|
+
if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
|
|
1499
|
+
return this;
|
|
1500
|
+
}
|
|
1501
|
+
/** Assert CPU quota */
|
|
1502
|
+
async toHaveCpuQuota(quota) {
|
|
1503
|
+
const info = await this.container.inspect();
|
|
1504
|
+
if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
|
|
1505
|
+
return this;
|
|
1506
|
+
}
|
|
1507
|
+
/** Execute a command and return output for custom assertions */
|
|
1508
|
+
async exec(cmd) {
|
|
1509
|
+
return this.container.exec(cmd);
|
|
1510
|
+
}
|
|
1511
|
+
/** Read a file for custom assertions */
|
|
1512
|
+
async readFile(path) {
|
|
1513
|
+
const file = await this.container.file(path);
|
|
1514
|
+
if (!file.exists) throw new Error(`File ${path} does not exist`);
|
|
1515
|
+
return file.content;
|
|
1516
|
+
}
|
|
1517
|
+
/** Get logs for custom assertions */
|
|
1518
|
+
async getLogs(tail) {
|
|
1519
|
+
return this.container.logs(tail);
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
//#endregion
|
|
1523
|
+
export { DockerAssertion, ExecAdapter, FetchAdapter, HonoAdapter, Orchestrator, cli, dockerContainer, e2e, grep, integration, mockOf, mockOfDate, normalizeOutput, postgres, redis, stripAnsi };
|
|
1385
1524
|
|
|
1386
1525
|
//# sourceMappingURL=index.js.map
|