@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/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 formatExitCodeError(expected, received, stdout, stderr) {
296
+ function formatDirectoryDiff(fixtureName, diff, hint) {
310
297
  const lines = [];
311
- lines.push(`Expected exit code: ${GREEN}${expected}${RESET}`);
312
- lines.push(`Received exit code: ${RED}${received}${RESET}`);
313
- if (stdout.trim()) {
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 expectedLines = expected.split("\n");
333
- const actualLines = actual.split("\n");
334
- const maxLines = Math.max(expectedLines.length, actualLines.length);
335
- for (let i = 0; i < maxLines; i++) {
336
- const exp = expectedLines[i];
337
- const act = actualLines[i];
338
- if (exp === act) lines.push(` ${exp}`);
339
- else {
340
- if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
341
- if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
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(`${GREEN}Expected to contain:${RESET}`);
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/assertions/base.ts
842
+ //#region src/specification/directory.ts
865
843
  /**
866
- * Base assertion that handles .not negation.
867
- * Subclasses call this.assert(condition, message, negatedMessage) for each predicate.
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
- var BaseAssertion = class {
870
- negated = false;
871
- get not() {
872
- const clone = Object.create(Object.getPrototypeOf(this));
873
- Object.assign(clone, this);
874
- clone.negated = !this.negated;
875
- return clone;
876
- }
877
- assert(condition, message, negatedMessage) {
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
- * Assertions on a file in the working directory.
887
- * Usage: result.file("dist/index.js").toExist()
857
+ * Recursively walk a directory, returning sorted relative paths of files only.
858
+ * Ignored entries (default + caller-supplied) are skipped.
888
859
  */
889
- var FileAssertion = class extends BaseAssertion {
890
- filePath;
891
- resolvedPath;
892
- constructor(filePath, workDir) {
893
- super();
894
- this.filePath = filePath;
895
- this.resolvedPath = (0, node_path.resolve)(workDir, filePath);
896
- }
897
- toExist() {
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 content = (0, node_fs.readFileSync)(this.resolvedPath, "utf8");
907
- const found = content.includes(expected);
908
- this.assert(found, formatFileContentMismatch(this.filePath, expected, content), `Expected file "${this.filePath}" NOT to contain "${expected}"`);
909
- }
910
- toMatch(pattern) {
911
- if (!(0, node_fs.existsSync)(this.resolvedPath)) {
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
- //#endregion
948
- //#region src/specification/assertions/string.ts
878
+ await walk(root);
879
+ out.sort();
880
+ return out;
881
+ }
949
882
  /**
950
- * Assertions on a string (stdout, stderr, response body).
951
- * Usage: result.stdout.toContain("hello")
883
+ * Compare two directory trees file-by-file.
884
+ * Binary files are compared by byte equality but reported without inline diff.
952
885
  */
953
- var StringAssertion = class extends BaseAssertion {
954
- actual;
955
- label;
956
- testDir;
957
- constructor(actual, label, testDir) {
958
- super();
959
- this.actual = actual;
960
- this.label = label;
961
- this.testDir = testDir;
962
- }
963
- toContain(expected, options) {
964
- if (options?.near) {
965
- const found = this.containsNear(expected, options.near);
966
- this.assert(found, `Expected ${this.label} to contain "${expected}" near "${options.near}"\n\n${this.label}:\n${this.truncate(this.actual)}`, `Expected ${this.label} NOT to contain "${expected}" near "${options.near}", but it was found`);
967
- } else {
968
- const found = this.actual.includes(expected);
969
- this.assert(found, `Expected ${this.label} to contain: "${expected}"\n\nActual ${this.label}:\n${this.truncate(this.actual)}`, `Expected ${this.label} NOT to contain: "${expected}"`);
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/assertions/table.ts
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
- const match = JSON.stringify(actual) === JSON.stringify(expected.rows);
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
- const empty = actual.length === 0;
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
- * Assertions on a single value (exit code, status code).
1040
- * Usage: result.exitCode.toBe(0)
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
- var ValueAssertion = class extends BaseAssertion {
1043
- actual;
1044
- label;
1045
- context;
1046
- constructor(actual, label, context) {
1047
- super();
1048
- this.actual = actual;
1049
- this.label = label;
1050
- this.context = context;
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
- toBe(expected) {
1053
- const match = this.actual === expected;
1054
- let message;
1055
- if (this.label === "exit code" && this.context?.stdout !== void 0) message = formatExitCodeError(expected, this.actual, this.context.stdout ?? "", this.context.stderr ?? "");
1056
- else if (this.label === "status" && this.context?.request) message = formatStatusError(expected, this.actual, this.context.request, this.context.responseBody);
1057
- else message = `Expected ${this.label}: ${expected}\nReceived ${this.label}: ${this.actual}`;
1058
- this.assert(match, message, `Expected ${this.label} NOT to be ${expected}, but it was`);
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 new ValueAssertion(this.commandResult.exitCode, "exit code", {
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 || !this.requestInfo) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
1087
- return new ValueAssertion(this.responseData.status, "status", {
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 new StringAssertion(this.commandResult.stdout, "stdout", this.testDir);
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 new StringAssertion(this.commandResult.stderr, "stderr", this.testDir);
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
- return new FileAssertion(path, this.workDir ?? this.testDir);
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
- stdout: "",
1251
- stderr: ""
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;