@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.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 formatExitCodeError(expected, received, stdout, stderr) {
272
+ function formatDirectoryDiff(fixtureName, diff, hint) {
286
273
  const lines = [];
287
- lines.push(`Expected exit code: ${GREEN}${expected}${RESET}`);
288
- lines.push(`Received exit code: ${RED}${received}${RESET}`);
289
- if (stdout.trim()) {
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 expectedLines = expected.split("\n");
309
- const actualLines = actual.split("\n");
310
- const maxLines = Math.max(expectedLines.length, actualLines.length);
311
- for (let i = 0; i < maxLines; i++) {
312
- const exp = expectedLines[i];
313
- const act = actualLines[i];
314
- if (exp === act) lines.push(` ${exp}`);
315
- else {
316
- if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
317
- if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
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(`${GREEN}Expected to contain:${RESET}`);
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/assertions/base.ts
818
+ //#region src/specification/directory.ts
841
819
  /**
842
- * Base assertion that handles .not negation.
843
- * Subclasses call this.assert(condition, message, negatedMessage) for each predicate.
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
- var BaseAssertion = class {
846
- negated = false;
847
- get not() {
848
- const clone = Object.create(Object.getPrototypeOf(this));
849
- Object.assign(clone, this);
850
- clone.negated = !this.negated;
851
- return clone;
852
- }
853
- assert(condition, message, negatedMessage) {
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
- * Assertions on a file in the working directory.
863
- * Usage: result.file("dist/index.js").toExist()
833
+ * Recursively walk a directory, returning sorted relative paths of files only.
834
+ * Ignored entries (default + caller-supplied) are skipped.
864
835
  */
865
- var FileAssertion = class extends BaseAssertion {
866
- filePath;
867
- resolvedPath;
868
- constructor(filePath, workDir) {
869
- super();
870
- this.filePath = filePath;
871
- this.resolvedPath = resolve(workDir, filePath);
872
- }
873
- toExist() {
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 content = readFileSync(this.resolvedPath, "utf8");
883
- const found = content.includes(expected);
884
- this.assert(found, formatFileContentMismatch(this.filePath, expected, content), `Expected file "${this.filePath}" NOT to contain "${expected}"`);
885
- }
886
- toMatch(pattern) {
887
- if (!existsSync(this.resolvedPath)) {
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
- //#endregion
924
- //#region src/specification/assertions/string.ts
854
+ await walk(root);
855
+ out.sort();
856
+ return out;
857
+ }
925
858
  /**
926
- * Assertions on a string (stdout, stderr, response body).
927
- * Usage: result.stdout.toContain("hello")
859
+ * Compare two directory trees file-by-file.
860
+ * Binary files are compared by byte equality but reported without inline diff.
928
861
  */
929
- var StringAssertion = class extends BaseAssertion {
930
- actual;
931
- label;
932
- testDir;
933
- constructor(actual, label, testDir) {
934
- super();
935
- this.actual = actual;
936
- this.label = label;
937
- this.testDir = testDir;
938
- }
939
- toContain(expected, options) {
940
- if (options?.near) {
941
- const found = this.containsNear(expected, options.near);
942
- 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`);
943
- } else {
944
- const found = this.actual.includes(expected);
945
- 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}"`);
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/assertions/table.ts
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
- const match = JSON.stringify(actual) === JSON.stringify(expected.rows);
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
- const empty = actual.length === 0;
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
- * Assertions on a single value (exit code, status code).
1016
- * Usage: result.exitCode.toBe(0)
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
- var ValueAssertion = class extends BaseAssertion {
1019
- actual;
1020
- label;
1021
- context;
1022
- constructor(actual, label, context) {
1023
- super();
1024
- this.actual = actual;
1025
- this.label = label;
1026
- this.context = context;
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
- toBe(expected) {
1029
- const match = this.actual === expected;
1030
- let message;
1031
- if (this.label === "exit code" && this.context?.stdout !== void 0) message = formatExitCodeError(expected, this.actual, this.context.stdout ?? "", this.context.stderr ?? "");
1032
- else if (this.label === "status" && this.context?.request) message = formatStatusError(expected, this.actual, this.context.request, this.context.responseBody);
1033
- else message = `Expected ${this.label}: ${expected}\nReceived ${this.label}: ${this.actual}`;
1034
- this.assert(match, message, `Expected ${this.label} NOT to be ${expected}, but it was`);
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 new ValueAssertion(this.commandResult.exitCode, "exit code", {
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 || !this.requestInfo) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
1063
- return new ValueAssertion(this.responseData.status, "status", {
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 new StringAssertion(this.commandResult.stdout, "stdout", this.testDir);
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 new StringAssertion(this.commandResult.stderr, "stderr", this.testDir);
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
- return new FileAssertion(path, this.workDir ?? this.testDir);
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
- stdout: "",
1227
- stderr: ""
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
- export { ExecAdapter, FetchAdapter, HonoAdapter, Orchestrator, cli, e2e, integration, mockOf, mockOfDate, normalizeOutput, postgres, redis, stripAnsi };
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