@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.cjs
CHANGED
|
@@ -30,6 +30,7 @@ let node_child_process = require("node:child_process");
|
|
|
30
30
|
let yaml = require("yaml");
|
|
31
31
|
let pg = require("pg");
|
|
32
32
|
let node_os = require("node:os");
|
|
33
|
+
let node_fs_promises = require("node:fs/promises");
|
|
33
34
|
//#region src/mocking/mock-of-date.ts
|
|
34
35
|
const mockOfDate = mockdate.default;
|
|
35
36
|
//#endregion
|
|
@@ -244,20 +245,6 @@ function formatStartupReport(mode, services, app) {
|
|
|
244
245
|
lines.push("");
|
|
245
246
|
return lines.join("\n");
|
|
246
247
|
}
|
|
247
|
-
function formatStatusError(expectedStatus, receivedStatus, request, responseBody) {
|
|
248
|
-
const lines = [];
|
|
249
|
-
lines.push(`Expected status: ${GREEN}${expectedStatus}${RESET}`);
|
|
250
|
-
lines.push(`Received status: ${RED}${receivedStatus}${RESET}`);
|
|
251
|
-
lines.push("");
|
|
252
|
-
lines.push(`${DIM}${request.method} ${request.path}${RESET}`);
|
|
253
|
-
if (request.body) lines.push(formatJson(request.body, DIM));
|
|
254
|
-
if (responseBody) {
|
|
255
|
-
lines.push("");
|
|
256
|
-
lines.push(`${DIM}Response:${RESET}`);
|
|
257
|
-
lines.push(formatJson(responseBody, RED));
|
|
258
|
-
}
|
|
259
|
-
return lines.join("\n");
|
|
260
|
-
}
|
|
261
248
|
function formatTableDiff(table, columns, expected, actual) {
|
|
262
249
|
const lines = [];
|
|
263
250
|
lines.push(`Table "${table}" mismatch`);
|
|
@@ -306,66 +293,50 @@ function formatResponseDiff(file, expected, actual) {
|
|
|
306
293
|
}
|
|
307
294
|
return lines.join("\n");
|
|
308
295
|
}
|
|
309
|
-
function
|
|
296
|
+
function formatDirectoryDiff(fixtureName, diff, hint) {
|
|
310
297
|
const lines = [];
|
|
311
|
-
|
|
312
|
-
lines.push(`
|
|
313
|
-
|
|
314
|
-
lines.push("");
|
|
315
|
-
lines.push(`${DIM}stdout:${RESET}`);
|
|
316
|
-
for (const line of stdout.trim().split("\n").slice(-15)) lines.push(` ${DIM}${line}${RESET}`);
|
|
317
|
-
}
|
|
318
|
-
if (stderr.trim()) {
|
|
319
|
-
lines.push("");
|
|
320
|
-
lines.push(`${DIM}stderr:${RESET}`);
|
|
321
|
-
for (const line of stderr.trim().split("\n").slice(-15)) lines.push(` ${RED}${line}${RESET}`);
|
|
322
|
-
}
|
|
323
|
-
return lines.join("\n");
|
|
324
|
-
}
|
|
325
|
-
function formatStdoutDiff(file, expected, actual) {
|
|
326
|
-
const lines = [];
|
|
327
|
-
lines.push(`Output mismatch (${file})`);
|
|
298
|
+
const total = diff.added.length + diff.removed.length + diff.changed.length;
|
|
299
|
+
lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`);
|
|
300
|
+
lines.push(`${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`);
|
|
328
301
|
lines.push("");
|
|
329
|
-
lines.push(`${GREEN}- Expected${RESET}`);
|
|
330
|
-
lines.push(`${RED}+ Received${RESET}`);
|
|
302
|
+
lines.push(`${GREEN}- Expected (fixture)${RESET}`);
|
|
303
|
+
lines.push(`${RED}+ Received (generated)${RESET}`);
|
|
331
304
|
lines.push("");
|
|
332
|
-
const
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
305
|
+
for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
|
|
306
|
+
for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
|
|
307
|
+
for (const { path, expected, actual } of diff.changed) {
|
|
308
|
+
const expectedLines = expected.split("\n");
|
|
309
|
+
const actualLines = actual.split("\n");
|
|
310
|
+
const changedCount = countLineDifferences(expectedLines, actualLines);
|
|
311
|
+
lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
|
|
312
|
+
let shown = 0;
|
|
313
|
+
const maxShown = 5;
|
|
314
|
+
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
315
|
+
for (let i = 0; i < maxLines && shown < maxShown; i++) {
|
|
316
|
+
const exp = expectedLines[i];
|
|
317
|
+
const act = actualLines[i];
|
|
318
|
+
if (exp !== act) {
|
|
319
|
+
lines.push(`${DIM} line ${i + 1}:${RESET}`);
|
|
320
|
+
if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
|
|
321
|
+
if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
|
|
322
|
+
shown++;
|
|
323
|
+
}
|
|
342
324
|
}
|
|
325
|
+
if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
|
|
343
326
|
}
|
|
344
|
-
return lines.join("\n");
|
|
345
|
-
}
|
|
346
|
-
function formatFileMissing(path) {
|
|
347
|
-
return `Expected file to exist: ${RED}${path}${RESET}`;
|
|
348
|
-
}
|
|
349
|
-
function formatFileUnexpected(path) {
|
|
350
|
-
return `Expected file NOT to exist: ${RED}${path}${RESET}`;
|
|
351
|
-
}
|
|
352
|
-
function formatFileContentMismatch(path, expected, actual) {
|
|
353
|
-
const lines = [];
|
|
354
|
-
lines.push(`File "${path}" does not contain expected content`);
|
|
355
327
|
lines.push("");
|
|
356
|
-
lines.push(`${
|
|
357
|
-
lines.push(` ${GREEN}${expected}${RESET}`);
|
|
358
|
-
lines.push("");
|
|
359
|
-
lines.push(`${RED}Actual content (first 20 lines):${RESET}`);
|
|
360
|
-
for (const line of actual.split("\n").slice(0, 20)) lines.push(` ${DIM}${line}${RESET}`);
|
|
328
|
+
lines.push(`${DIM}${hint}${RESET}`);
|
|
361
329
|
return lines.join("\n");
|
|
362
330
|
}
|
|
331
|
+
function countLineDifferences(expected, actual) {
|
|
332
|
+
let count = 0;
|
|
333
|
+
const max = Math.max(expected.length, actual.length);
|
|
334
|
+
for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
|
|
335
|
+
return count;
|
|
336
|
+
}
|
|
363
337
|
function rowLabel(n) {
|
|
364
338
|
return n === 1 ? "1 row" : `${n} rows`;
|
|
365
339
|
}
|
|
366
|
-
function formatJson(value, color) {
|
|
367
|
-
return JSON.stringify(value, null, 2).split("\n").map((line) => `${color}${line}${RESET}`).join("\n");
|
|
368
|
-
}
|
|
369
340
|
function formatRow(row) {
|
|
370
341
|
return row.map((v) => String(v ?? "null")).join(" | ");
|
|
371
342
|
}
|
|
@@ -714,6 +685,19 @@ var Orchestrator = class {
|
|
|
714
685
|
//#endregion
|
|
715
686
|
//#region src/specification/adapters/exec.adapter.ts
|
|
716
687
|
/**
|
|
688
|
+
* Build a child-process env from the parent env plus user overrides.
|
|
689
|
+
* `null` overrides delete keys (e.g. `INIT_CWD: null`).
|
|
690
|
+
*/
|
|
691
|
+
function buildEnv(extra) {
|
|
692
|
+
const env = {
|
|
693
|
+
...process.env,
|
|
694
|
+
INIT_CWD: void 0
|
|
695
|
+
};
|
|
696
|
+
if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
|
|
697
|
+
else env[key] = value;
|
|
698
|
+
return env;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
717
701
|
* Executes CLI commands via execSync (blocking) or spawn (long-running).
|
|
718
702
|
* Used by cli() for local command execution.
|
|
719
703
|
*/
|
|
@@ -722,11 +706,8 @@ var ExecAdapter = class {
|
|
|
722
706
|
constructor(command) {
|
|
723
707
|
this.command = command;
|
|
724
708
|
}
|
|
725
|
-
async exec(args, cwd) {
|
|
726
|
-
const env =
|
|
727
|
-
...process.env,
|
|
728
|
-
INIT_CWD: void 0
|
|
729
|
-
};
|
|
709
|
+
async exec(args, cwd, extraEnv) {
|
|
710
|
+
const env = buildEnv(extraEnv);
|
|
730
711
|
try {
|
|
731
712
|
return {
|
|
732
713
|
exitCode: 0,
|
|
@@ -750,11 +731,8 @@ var ExecAdapter = class {
|
|
|
750
731
|
};
|
|
751
732
|
}
|
|
752
733
|
}
|
|
753
|
-
async spawn(args, cwd, options) {
|
|
754
|
-
const env =
|
|
755
|
-
...process.env,
|
|
756
|
-
INIT_CWD: void 0
|
|
757
|
-
};
|
|
734
|
+
async spawn(args, cwd, options, extraEnv) {
|
|
735
|
+
const env = buildEnv(extraEnv);
|
|
758
736
|
return new Promise((resolve) => {
|
|
759
737
|
let stdout = "";
|
|
760
738
|
let stderr = "";
|
|
@@ -861,205 +839,152 @@ var HonoAdapter = class {
|
|
|
861
839
|
}
|
|
862
840
|
};
|
|
863
841
|
//#endregion
|
|
864
|
-
//#region src/specification/
|
|
842
|
+
//#region src/specification/directory.ts
|
|
865
843
|
/**
|
|
866
|
-
*
|
|
867
|
-
*
|
|
844
|
+
* Default ignore patterns — paths that should never appear in a tracked snapshot.
|
|
845
|
+
* Each entry is matched against any path segment OR a path prefix.
|
|
868
846
|
*/
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
if (this.negated) {
|
|
879
|
-
if (condition) throw new Error(negatedMessage);
|
|
880
|
-
} else if (!condition) throw new Error(message);
|
|
881
|
-
}
|
|
882
|
-
};
|
|
883
|
-
//#endregion
|
|
884
|
-
//#region src/specification/assertions/file.ts
|
|
847
|
+
const DEFAULT_IGNORES = [
|
|
848
|
+
".git",
|
|
849
|
+
".DS_Store",
|
|
850
|
+
"node_modules",
|
|
851
|
+
".next",
|
|
852
|
+
"dist",
|
|
853
|
+
".turbo",
|
|
854
|
+
".cache"
|
|
855
|
+
];
|
|
885
856
|
/**
|
|
886
|
-
*
|
|
887
|
-
*
|
|
857
|
+
* Recursively walk a directory, returning sorted relative paths of files only.
|
|
858
|
+
* Ignored entries (default + caller-supplied) are skipped.
|
|
888
859
|
*/
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
const exists = (0, node_fs.existsSync)(this.resolvedPath);
|
|
899
|
-
this.assert(exists, formatFileMissing(this.filePath), formatFileUnexpected(this.filePath));
|
|
900
|
-
}
|
|
901
|
-
toContain(expected) {
|
|
902
|
-
if (!(0, node_fs.existsSync)(this.resolvedPath)) {
|
|
903
|
-
if (this.negated) return;
|
|
904
|
-
throw new Error(formatFileMissing(this.filePath));
|
|
860
|
+
async function walkDirectory(root, options = {}) {
|
|
861
|
+
const ignores = new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
|
|
862
|
+
const out = [];
|
|
863
|
+
async function walk(current) {
|
|
864
|
+
let entries;
|
|
865
|
+
try {
|
|
866
|
+
entries = await (0, node_fs_promises.readdir)(current);
|
|
867
|
+
} catch {
|
|
868
|
+
return;
|
|
905
869
|
}
|
|
906
|
-
const
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
if (this.negated) return;
|
|
913
|
-
throw new Error(formatFileMissing(this.filePath));
|
|
870
|
+
for (const entry of entries) {
|
|
871
|
+
if (ignores.has(entry)) continue;
|
|
872
|
+
const abs = (0, node_path.resolve)(current, entry);
|
|
873
|
+
const stat = (0, node_fs.statSync)(abs);
|
|
874
|
+
if (stat.isDirectory()) await walk(abs);
|
|
875
|
+
else if (stat.isFile()) out.push((0, node_path.relative)(root, abs).split(node_path.sep).join("/"));
|
|
914
876
|
}
|
|
915
|
-
const content = (0, node_fs.readFileSync)(this.resolvedPath, "utf8");
|
|
916
|
-
const found = pattern.test(content);
|
|
917
|
-
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}`);
|
|
918
|
-
}
|
|
919
|
-
};
|
|
920
|
-
//#endregion
|
|
921
|
-
//#region src/specification/assertions/response.ts
|
|
922
|
-
/**
|
|
923
|
-
* Assertions on an HTTP response body.
|
|
924
|
-
* Usage: result.response.toMatchFile("expected.json")
|
|
925
|
-
*/
|
|
926
|
-
var ResponseAssertion = class extends BaseAssertion {
|
|
927
|
-
body;
|
|
928
|
-
testDir;
|
|
929
|
-
constructor(body, testDir) {
|
|
930
|
-
super();
|
|
931
|
-
this.body = body;
|
|
932
|
-
this.testDir = testDir;
|
|
933
|
-
}
|
|
934
|
-
toMatchFile(file) {
|
|
935
|
-
const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
|
|
936
|
-
const match = JSON.stringify(this.body) === JSON.stringify(expected);
|
|
937
|
-
this.assert(match, formatResponseDiff(file, expected, this.body), `Expected response NOT to match file "${file}", but it did`);
|
|
938
|
-
}
|
|
939
|
-
toContain(subset) {
|
|
940
|
-
const bodyStr = JSON.stringify(this.body);
|
|
941
|
-
const subsetStr = JSON.stringify(subset);
|
|
942
|
-
const bodyObj = typeof this.body === "object" && this.body !== null ? this.body : {};
|
|
943
|
-
const match = Object.entries(subset).every(([key, value]) => JSON.stringify(bodyObj[key]) === JSON.stringify(value));
|
|
944
|
-
this.assert(match, `Expected response to contain: ${subsetStr}\n\nActual response:\n${bodyStr}`, `Expected response NOT to contain: ${subsetStr}`);
|
|
945
877
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
878
|
+
await walk(root);
|
|
879
|
+
out.sort();
|
|
880
|
+
return out;
|
|
881
|
+
}
|
|
949
882
|
/**
|
|
950
|
-
*
|
|
951
|
-
*
|
|
883
|
+
* Compare two directory trees file-by-file.
|
|
884
|
+
* Binary files are compared by byte equality but reported without inline diff.
|
|
952
885
|
*/
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
toMatch(pattern) {
|
|
973
|
-
const found = pattern.test(this.actual);
|
|
974
|
-
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}`);
|
|
975
|
-
}
|
|
976
|
-
toMatchFile(file) {
|
|
977
|
-
if (!this.testDir) throw new Error("toMatchFile requires a test directory context");
|
|
978
|
-
const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "expected", file), "utf8").trim();
|
|
979
|
-
const actual = this.actual.trim();
|
|
980
|
-
const match = actual === expected;
|
|
981
|
-
this.assert(match, formatStdoutDiff(file, expected, actual), `Expected ${this.label} NOT to match file "${file}", but it did`);
|
|
982
|
-
}
|
|
983
|
-
toBeEmpty() {
|
|
984
|
-
const empty = this.actual.trim() === "";
|
|
985
|
-
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`);
|
|
986
|
-
}
|
|
987
|
-
containsNear(target, near, proximity = 500) {
|
|
988
|
-
const clean = this.stripAnsi(this.actual);
|
|
989
|
-
const nearLower = near.toLowerCase();
|
|
990
|
-
const targetLower = target.toLowerCase();
|
|
991
|
-
let searchFrom = 0;
|
|
992
|
-
while (true) {
|
|
993
|
-
const idx = clean.toLowerCase().indexOf(nearLower, searchFrom);
|
|
994
|
-
if (idx === -1) break;
|
|
995
|
-
const windowStart = Math.max(0, idx - proximity);
|
|
996
|
-
const windowEnd = Math.min(clean.length, idx + nearLower.length + proximity);
|
|
997
|
-
if (clean.substring(windowStart, windowEnd).toLowerCase().includes(targetLower)) return true;
|
|
998
|
-
searchFrom = idx + 1;
|
|
999
|
-
}
|
|
1000
|
-
return false;
|
|
1001
|
-
}
|
|
1002
|
-
stripAnsi(str) {
|
|
1003
|
-
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
1004
|
-
}
|
|
1005
|
-
truncate(str, maxLines = 20) {
|
|
1006
|
-
const lines = str.split("\n");
|
|
1007
|
-
if (lines.length <= maxLines) return str;
|
|
1008
|
-
return `${lines.slice(0, maxLines).join("\n")}\n... (${lines.length - maxLines} more lines)`;
|
|
886
|
+
async function diffDirectories(expectedRoot, actualRoot, options = {}) {
|
|
887
|
+
const expectedFiles = await walkDirectory(expectedRoot, options);
|
|
888
|
+
const actualFiles = await walkDirectory(actualRoot, options);
|
|
889
|
+
const expectedSet = new Set(expectedFiles);
|
|
890
|
+
const actualSet = new Set(actualFiles);
|
|
891
|
+
const added = actualFiles.filter((f) => !expectedSet.has(f));
|
|
892
|
+
const removed = expectedFiles.filter((f) => !actualSet.has(f));
|
|
893
|
+
const changed = [];
|
|
894
|
+
for (const file of expectedFiles) {
|
|
895
|
+
if (!actualSet.has(file)) continue;
|
|
896
|
+
const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(expectedRoot, file), "utf8");
|
|
897
|
+
const actual = (0, node_fs.readFileSync)((0, node_path.resolve)(actualRoot, file), "utf8");
|
|
898
|
+
if (expected !== actual) changed.push({
|
|
899
|
+
actual,
|
|
900
|
+
expected,
|
|
901
|
+
path: file
|
|
902
|
+
});
|
|
1009
903
|
}
|
|
1010
|
-
|
|
904
|
+
return {
|
|
905
|
+
added,
|
|
906
|
+
changed,
|
|
907
|
+
removed
|
|
908
|
+
};
|
|
909
|
+
}
|
|
1011
910
|
//#endregion
|
|
1012
|
-
//#region src/specification/
|
|
1013
|
-
|
|
1014
|
-
* Assertions on a database table.
|
|
1015
|
-
* Usage: await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] })
|
|
1016
|
-
*/
|
|
1017
|
-
var TableAssertion = class extends BaseAssertion {
|
|
911
|
+
//#region src/specification/specification.ts
|
|
912
|
+
var TableAssertion = class {
|
|
1018
913
|
tableName;
|
|
1019
914
|
db;
|
|
1020
915
|
constructor(tableName, db) {
|
|
1021
|
-
super();
|
|
1022
916
|
this.tableName = tableName;
|
|
1023
917
|
this.db = db;
|
|
1024
918
|
}
|
|
1025
919
|
async toMatch(expected) {
|
|
1026
920
|
const actual = await this.db.query(this.tableName, expected.columns);
|
|
1027
|
-
|
|
1028
|
-
this.assert(match, formatTableDiff(this.tableName, expected.columns, expected.rows, actual), `Expected table "${this.tableName}" NOT to match, but it did`);
|
|
921
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected.rows)) throw new Error(formatTableDiff(this.tableName, expected.columns, expected.rows, actual));
|
|
1029
922
|
}
|
|
1030
923
|
async toBeEmpty() {
|
|
1031
924
|
const actual = await this.db.query(this.tableName, ["*"]);
|
|
1032
|
-
|
|
1033
|
-
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`);
|
|
925
|
+
if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
|
|
1034
926
|
}
|
|
1035
927
|
};
|
|
1036
|
-
//#endregion
|
|
1037
|
-
//#region src/specification/assertions/value.ts
|
|
1038
928
|
/**
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
929
|
+
* Detect whether the user wants to update snapshots — `true` for any of:
|
|
930
|
+
* - vitest run with `-u` / `--update`
|
|
931
|
+
* - JTERRAZZ_TEST_UPDATE=1
|
|
932
|
+
* - UPDATE_SNAPSHOTS=1
|
|
1041
933
|
*/
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
934
|
+
function shouldUpdateSnapshots() {
|
|
935
|
+
if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
|
|
936
|
+
if (process.env.UPDATE_SNAPSHOTS === "1") return true;
|
|
937
|
+
if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
var DirectoryAccessor = class {
|
|
941
|
+
absPath;
|
|
942
|
+
testDir;
|
|
943
|
+
constructor(absPath, testDir) {
|
|
944
|
+
this.absPath = absPath;
|
|
945
|
+
this.testDir = testDir;
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Compare the directory tree against `expected/{name}/` (relative to the test file).
|
|
949
|
+
* On mismatch, throws with a structured diff. With update mode enabled, the
|
|
950
|
+
* fixture is overwritten with the current contents instead.
|
|
951
|
+
*/
|
|
952
|
+
async toMatchFixture(name, options = {}) {
|
|
953
|
+
const fixtureDir = (0, node_path.resolve)(this.testDir, "expected", name);
|
|
954
|
+
if (options.update ?? shouldUpdateSnapshots()) {
|
|
955
|
+
(0, node_fs.rmSync)(fixtureDir, {
|
|
956
|
+
force: true,
|
|
957
|
+
recursive: true
|
|
958
|
+
});
|
|
959
|
+
(0, node_fs.mkdirSync)(fixtureDir, { recursive: true });
|
|
960
|
+
(0, node_fs.cpSync)(this.absPath, fixtureDir, { recursive: true });
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (!(0, node_fs.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.`);
|
|
964
|
+
const diff = await diffDirectories(fixtureDir, this.absPath, { ignore: options.ignore });
|
|
965
|
+
if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return;
|
|
966
|
+
throw new Error(formatDirectoryDiff(name, diff, "Run with JTERRAZZ_TEST_UPDATE=1 to update the fixture."));
|
|
1051
967
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
968
|
+
/**
|
|
969
|
+
* List all files in the directory (recursive, sorted, ignoring defaults).
|
|
970
|
+
* Useful for ad-hoc assertions when you don't want a full snapshot.
|
|
971
|
+
*/
|
|
972
|
+
async files(options = {}) {
|
|
973
|
+
return walkDirectory(this.absPath, options);
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
var ResponseAccessor = class {
|
|
977
|
+
body;
|
|
978
|
+
testDir;
|
|
979
|
+
constructor(body, testDir) {
|
|
980
|
+
this.body = body;
|
|
981
|
+
this.testDir = testDir;
|
|
982
|
+
}
|
|
983
|
+
toMatchFile(file) {
|
|
984
|
+
const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
|
|
985
|
+
if (JSON.stringify(this.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.body));
|
|
1059
986
|
}
|
|
1060
987
|
};
|
|
1061
|
-
//#endregion
|
|
1062
|
-
//#region src/specification/specification.ts
|
|
1063
988
|
var SpecificationResult = class {
|
|
1064
989
|
commandResult;
|
|
1065
990
|
config;
|
|
@@ -1077,32 +1002,37 @@ var SpecificationResult = class {
|
|
|
1077
1002
|
}
|
|
1078
1003
|
get exitCode() {
|
|
1079
1004
|
if (!this.commandResult) throw new Error(".exitCode requires a CLI action (.exec())");
|
|
1080
|
-
return
|
|
1081
|
-
stderr: this.commandResult.stderr,
|
|
1082
|
-
stdout: this.commandResult.stdout
|
|
1083
|
-
});
|
|
1005
|
+
return this.commandResult.exitCode;
|
|
1084
1006
|
}
|
|
1085
1007
|
get status() {
|
|
1086
|
-
if (!this.responseData
|
|
1087
|
-
return
|
|
1088
|
-
request: this.requestInfo,
|
|
1089
|
-
responseBody: this.responseData.body
|
|
1090
|
-
});
|
|
1091
|
-
}
|
|
1092
|
-
get response() {
|
|
1093
|
-
if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
|
|
1094
|
-
return new ResponseAssertion(this.responseData.body, this.testDir);
|
|
1008
|
+
if (!this.responseData) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
|
|
1009
|
+
return this.responseData.status;
|
|
1095
1010
|
}
|
|
1096
1011
|
get stdout() {
|
|
1097
1012
|
if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
|
|
1098
|
-
return
|
|
1013
|
+
return this.commandResult.stdout;
|
|
1099
1014
|
}
|
|
1100
1015
|
get stderr() {
|
|
1101
1016
|
if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
|
|
1102
|
-
return
|
|
1017
|
+
return this.commandResult.stderr;
|
|
1018
|
+
}
|
|
1019
|
+
get response() {
|
|
1020
|
+
if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
|
|
1021
|
+
return new ResponseAccessor(this.responseData.body, this.testDir);
|
|
1022
|
+
}
|
|
1023
|
+
directory(path = ".") {
|
|
1024
|
+
return new DirectoryAccessor((0, node_path.resolve)(this.workDir ?? this.testDir, path), this.testDir);
|
|
1103
1025
|
}
|
|
1104
1026
|
file(path) {
|
|
1105
|
-
|
|
1027
|
+
const resolvedPath = (0, node_path.resolve)(this.workDir ?? this.testDir, path);
|
|
1028
|
+
const exists = (0, node_fs.existsSync)(resolvedPath);
|
|
1029
|
+
return {
|
|
1030
|
+
get content() {
|
|
1031
|
+
if (!exists) throw new Error(`File not found: ${path}`);
|
|
1032
|
+
return (0, node_fs.readFileSync)(resolvedPath, "utf8");
|
|
1033
|
+
},
|
|
1034
|
+
exists
|
|
1035
|
+
};
|
|
1106
1036
|
}
|
|
1107
1037
|
table(tableName, options) {
|
|
1108
1038
|
const db = this.resolveDatabase(options?.service);
|
|
@@ -1116,6 +1046,7 @@ var SpecificationResult = class {
|
|
|
1116
1046
|
};
|
|
1117
1047
|
var SpecificationBuilder = class {
|
|
1118
1048
|
commandArgs = null;
|
|
1049
|
+
commandEnv = {};
|
|
1119
1050
|
config;
|
|
1120
1051
|
fixtures = [];
|
|
1121
1052
|
label;
|
|
@@ -1149,6 +1080,23 @@ var SpecificationBuilder = class {
|
|
|
1149
1080
|
this.mocks.push({ file });
|
|
1150
1081
|
return this;
|
|
1151
1082
|
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Set environment variables for the CLI process. Merged on top of process.env.
|
|
1085
|
+
* Use `null` to unset a variable. Multiple calls merge.
|
|
1086
|
+
*
|
|
1087
|
+
* The token `$WORKDIR` (in any value) is replaced with the actual working
|
|
1088
|
+
* directory at run-time — useful for tests that need a fully isolated `HOME`.
|
|
1089
|
+
*
|
|
1090
|
+
* @example
|
|
1091
|
+
* spec("...").env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status").run();
|
|
1092
|
+
*/
|
|
1093
|
+
env(env) {
|
|
1094
|
+
this.commandEnv = {
|
|
1095
|
+
...this.commandEnv,
|
|
1096
|
+
...env
|
|
1097
|
+
};
|
|
1098
|
+
return this;
|
|
1099
|
+
}
|
|
1152
1100
|
get(path) {
|
|
1153
1101
|
this.request = {
|
|
1154
1102
|
method: "GET",
|
|
@@ -1214,6 +1162,16 @@ var SpecificationBuilder = class {
|
|
|
1214
1162
|
if (hasHttpAction) return this.runHttpAction();
|
|
1215
1163
|
return this.runCliAction(workDir);
|
|
1216
1164
|
}
|
|
1165
|
+
resolveEnv(workDir) {
|
|
1166
|
+
const keys = Object.keys(this.commandEnv);
|
|
1167
|
+
if (keys.length === 0) return;
|
|
1168
|
+
const resolved = {};
|
|
1169
|
+
for (const key of keys) {
|
|
1170
|
+
const value = this.commandEnv[key];
|
|
1171
|
+
resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
|
|
1172
|
+
}
|
|
1173
|
+
return resolved;
|
|
1174
|
+
}
|
|
1217
1175
|
prepareWorkDir() {
|
|
1218
1176
|
if (!this.projectName && this.fixtures.length === 0) return this.config.fixturesRoot ?? process.cwd();
|
|
1219
1177
|
const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
|
|
@@ -1242,19 +1200,20 @@ var SpecificationBuilder = class {
|
|
|
1242
1200
|
}
|
|
1243
1201
|
async runCliAction(workDir) {
|
|
1244
1202
|
if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
|
|
1203
|
+
const env = this.resolveEnv(workDir);
|
|
1245
1204
|
let commandResult;
|
|
1246
|
-
if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options);
|
|
1205
|
+
if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
|
|
1247
1206
|
else if (Array.isArray(this.commandArgs)) {
|
|
1248
1207
|
commandResult = {
|
|
1249
1208
|
exitCode: 0,
|
|
1250
|
-
|
|
1251
|
-
|
|
1209
|
+
stderr: "",
|
|
1210
|
+
stdout: ""
|
|
1252
1211
|
};
|
|
1253
1212
|
for (const args of this.commandArgs) {
|
|
1254
|
-
commandResult = await this.config.command.exec(args, workDir);
|
|
1213
|
+
commandResult = await this.config.command.exec(args, workDir, env);
|
|
1255
1214
|
if (commandResult.exitCode !== 0) break;
|
|
1256
1215
|
}
|
|
1257
|
-
} else commandResult = await this.config.command.exec(this.commandArgs, workDir);
|
|
1216
|
+
} else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
|
|
1258
1217
|
return new SpecificationResult({
|
|
1259
1218
|
commandResult,
|
|
1260
1219
|
config: this.config,
|
|
@@ -1277,16 +1236,26 @@ function getCallerDir() {
|
|
|
1277
1236
|
}
|
|
1278
1237
|
throw new Error("Cannot detect caller directory from stack trace");
|
|
1279
1238
|
}
|
|
1280
|
-
/**
|
|
1281
|
-
* Create a specification runner.
|
|
1282
|
-
* Automatically detects the test directory from the call site.
|
|
1283
|
-
*/
|
|
1284
1239
|
function createSpecificationRunner(config) {
|
|
1285
1240
|
return (label) => {
|
|
1286
1241
|
return new SpecificationBuilder(config, getCallerDir(), label);
|
|
1287
1242
|
};
|
|
1288
1243
|
}
|
|
1289
1244
|
//#endregion
|
|
1245
|
+
//#region src/specification/grep.ts
|
|
1246
|
+
/**
|
|
1247
|
+
* Extract text blocks from output that contain a pattern.
|
|
1248
|
+
* Splits by blank lines (how linter/compiler output is structured),
|
|
1249
|
+
* returns only blocks matching the pattern.
|
|
1250
|
+
*
|
|
1251
|
+
* @example
|
|
1252
|
+
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
1253
|
+
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
1254
|
+
*/
|
|
1255
|
+
function grep(output, pattern) {
|
|
1256
|
+
return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
|
|
1257
|
+
}
|
|
1258
|
+
//#endregion
|
|
1290
1259
|
//#region src/specification/index.ts
|
|
1291
1260
|
/**
|
|
1292
1261
|
* Resolve root — if relative, resolves from the caller's directory.
|
|
@@ -1405,12 +1374,185 @@ async function cli(options) {
|
|
|
1405
1374
|
return runner;
|
|
1406
1375
|
}
|
|
1407
1376
|
//#endregion
|
|
1377
|
+
//#region src/infrastructure/docker/docker-adapter.ts
|
|
1378
|
+
var DockerAdapter = class {
|
|
1379
|
+
containerId;
|
|
1380
|
+
constructor(containerId) {
|
|
1381
|
+
this.containerId = containerId;
|
|
1382
|
+
}
|
|
1383
|
+
async exec(cmd) {
|
|
1384
|
+
return (0, node_child_process.execSync)(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
|
|
1385
|
+
encoding: "utf8",
|
|
1386
|
+
timeout: 1e4
|
|
1387
|
+
}).trim();
|
|
1388
|
+
}
|
|
1389
|
+
async file(path) {
|
|
1390
|
+
try {
|
|
1391
|
+
return {
|
|
1392
|
+
exists: true,
|
|
1393
|
+
content: await this.exec(["cat", path])
|
|
1394
|
+
};
|
|
1395
|
+
} catch {
|
|
1396
|
+
return {
|
|
1397
|
+
exists: false,
|
|
1398
|
+
content: ""
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
async isRunning() {
|
|
1403
|
+
try {
|
|
1404
|
+
return (0, node_child_process.execSync)(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
|
|
1405
|
+
encoding: "utf8",
|
|
1406
|
+
timeout: 5e3
|
|
1407
|
+
}).trim() === "true";
|
|
1408
|
+
} catch {
|
|
1409
|
+
return false;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
async logs(tail) {
|
|
1413
|
+
return (0, node_child_process.execSync)(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
|
|
1414
|
+
encoding: "utf8",
|
|
1415
|
+
timeout: 1e4
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
async inspect() {
|
|
1419
|
+
const raw = (0, node_child_process.execSync)(`docker inspect ${this.containerId}`, {
|
|
1420
|
+
encoding: "utf8",
|
|
1421
|
+
timeout: 5e3
|
|
1422
|
+
});
|
|
1423
|
+
const data = JSON.parse(raw)[0];
|
|
1424
|
+
return {
|
|
1425
|
+
id: data.Id,
|
|
1426
|
+
name: data.Name,
|
|
1427
|
+
state: {
|
|
1428
|
+
running: data.State.Running,
|
|
1429
|
+
exitCode: data.State.ExitCode,
|
|
1430
|
+
status: data.State.Status
|
|
1431
|
+
},
|
|
1432
|
+
config: {
|
|
1433
|
+
image: data.Config.Image,
|
|
1434
|
+
env: data.Config.Env || []
|
|
1435
|
+
},
|
|
1436
|
+
hostConfig: {
|
|
1437
|
+
memory: data.HostConfig.Memory || 0,
|
|
1438
|
+
cpuQuota: data.HostConfig.CpuQuota || 0,
|
|
1439
|
+
networkMode: data.HostConfig.NetworkMode || "",
|
|
1440
|
+
mounts: (data.Mounts || []).map((m) => ({
|
|
1441
|
+
source: m.Source,
|
|
1442
|
+
destination: m.Destination,
|
|
1443
|
+
type: m.Type
|
|
1444
|
+
}))
|
|
1445
|
+
},
|
|
1446
|
+
networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
|
|
1447
|
+
gateway: net.Gateway,
|
|
1448
|
+
ipAddress: net.IPAddress
|
|
1449
|
+
}])) }
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
async exists(path) {
|
|
1453
|
+
try {
|
|
1454
|
+
await this.exec([
|
|
1455
|
+
"test",
|
|
1456
|
+
"-e",
|
|
1457
|
+
path
|
|
1458
|
+
]);
|
|
1459
|
+
return true;
|
|
1460
|
+
} catch {
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
/** Create a Docker container port for an existing container */
|
|
1466
|
+
function dockerContainer(containerId) {
|
|
1467
|
+
return new DockerAdapter(containerId);
|
|
1468
|
+
}
|
|
1469
|
+
//#endregion
|
|
1470
|
+
//#region src/infrastructure/docker/docker-assertion.ts
|
|
1471
|
+
/** Fluent assertion builder for Docker containers */
|
|
1472
|
+
var DockerAssertion = class {
|
|
1473
|
+
container;
|
|
1474
|
+
constructor(container) {
|
|
1475
|
+
this.container = container;
|
|
1476
|
+
}
|
|
1477
|
+
/** Assert the container is running */
|
|
1478
|
+
async toBeRunning() {
|
|
1479
|
+
if (!await this.container.isRunning()) throw new Error("Expected container to be running");
|
|
1480
|
+
return this;
|
|
1481
|
+
}
|
|
1482
|
+
/** Assert the container is NOT running / doesn't exist */
|
|
1483
|
+
async toNotExist() {
|
|
1484
|
+
if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
|
|
1485
|
+
return this;
|
|
1486
|
+
}
|
|
1487
|
+
/** Assert a file exists inside the container */
|
|
1488
|
+
async toHaveFile(path, opts) {
|
|
1489
|
+
const file = await this.container.file(path);
|
|
1490
|
+
if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
|
|
1491
|
+
if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
|
|
1492
|
+
return this;
|
|
1493
|
+
}
|
|
1494
|
+
/** Assert a file does NOT exist */
|
|
1495
|
+
async toNotHaveFile(path) {
|
|
1496
|
+
if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
|
|
1497
|
+
return this;
|
|
1498
|
+
}
|
|
1499
|
+
/** Assert a directory exists */
|
|
1500
|
+
async toHaveDirectory(path) {
|
|
1501
|
+
if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
|
|
1502
|
+
return this;
|
|
1503
|
+
}
|
|
1504
|
+
/** Assert a mount exists */
|
|
1505
|
+
async toHaveMount(destination) {
|
|
1506
|
+
const info = await this.container.inspect();
|
|
1507
|
+
if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
|
|
1508
|
+
const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
|
|
1509
|
+
throw new Error(`Expected mount at ${destination}, found: [${available}]`);
|
|
1510
|
+
}
|
|
1511
|
+
return this;
|
|
1512
|
+
}
|
|
1513
|
+
/** Assert network mode */
|
|
1514
|
+
async toHaveNetwork(mode) {
|
|
1515
|
+
const info = await this.container.inspect();
|
|
1516
|
+
if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
|
|
1517
|
+
return this;
|
|
1518
|
+
}
|
|
1519
|
+
/** Assert memory limit */
|
|
1520
|
+
async toHaveMemoryLimit(bytes) {
|
|
1521
|
+
const info = await this.container.inspect();
|
|
1522
|
+
if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
|
|
1523
|
+
return this;
|
|
1524
|
+
}
|
|
1525
|
+
/** Assert CPU quota */
|
|
1526
|
+
async toHaveCpuQuota(quota) {
|
|
1527
|
+
const info = await this.container.inspect();
|
|
1528
|
+
if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
|
|
1529
|
+
return this;
|
|
1530
|
+
}
|
|
1531
|
+
/** Execute a command and return output for custom assertions */
|
|
1532
|
+
async exec(cmd) {
|
|
1533
|
+
return this.container.exec(cmd);
|
|
1534
|
+
}
|
|
1535
|
+
/** Read a file for custom assertions */
|
|
1536
|
+
async readFile(path) {
|
|
1537
|
+
const file = await this.container.file(path);
|
|
1538
|
+
if (!file.exists) throw new Error(`File ${path} does not exist`);
|
|
1539
|
+
return file.content;
|
|
1540
|
+
}
|
|
1541
|
+
/** Get logs for custom assertions */
|
|
1542
|
+
async getLogs(tail) {
|
|
1543
|
+
return this.container.logs(tail);
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
//#endregion
|
|
1547
|
+
exports.DockerAssertion = DockerAssertion;
|
|
1408
1548
|
exports.ExecAdapter = ExecAdapter;
|
|
1409
1549
|
exports.FetchAdapter = FetchAdapter;
|
|
1410
1550
|
exports.HonoAdapter = HonoAdapter;
|
|
1411
1551
|
exports.Orchestrator = Orchestrator;
|
|
1412
1552
|
exports.cli = cli;
|
|
1553
|
+
exports.dockerContainer = dockerContainer;
|
|
1413
1554
|
exports.e2e = e2e;
|
|
1555
|
+
exports.grep = grep;
|
|
1414
1556
|
exports.integration = integration;
|
|
1415
1557
|
exports.mockOf = mockOf;
|
|
1416
1558
|
exports.mockOfDate = mockOfDate;
|