@jterrazz/test 5.0.0 → 5.2.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
@@ -268,6 +269,47 @@ function formatResponseDiff(file, expected, actual) {
268
269
  }
269
270
  return lines.join("\n");
270
271
  }
272
+ function formatDirectoryDiff(fixtureName, diff, hint) {
273
+ const lines = [];
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}`);
277
+ lines.push("");
278
+ lines.push(`${GREEN}- Expected (fixture)${RESET}`);
279
+ lines.push(`${RED}+ Received (generated)${RESET}`);
280
+ lines.push("");
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
+ }
300
+ }
301
+ if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
302
+ }
303
+ lines.push("");
304
+ lines.push(`${DIM}${hint}${RESET}`);
305
+ return lines.join("\n");
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
+ }
271
313
  function rowLabel(n) {
272
314
  return n === 1 ? "1 row" : `${n} rows`;
273
315
  }
@@ -619,6 +661,19 @@ var Orchestrator = class {
619
661
  //#endregion
620
662
  //#region src/specification/adapters/exec.adapter.ts
621
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
+ /**
622
677
  * Executes CLI commands via execSync (blocking) or spawn (long-running).
623
678
  * Used by cli() for local command execution.
624
679
  */
@@ -627,11 +682,8 @@ var ExecAdapter = class {
627
682
  constructor(command) {
628
683
  this.command = command;
629
684
  }
630
- async exec(args, cwd) {
631
- const env = {
632
- ...process.env,
633
- INIT_CWD: void 0
634
- };
685
+ async exec(args, cwd, extraEnv) {
686
+ const env = buildEnv(extraEnv);
635
687
  try {
636
688
  return {
637
689
  exitCode: 0,
@@ -655,11 +707,8 @@ var ExecAdapter = class {
655
707
  };
656
708
  }
657
709
  }
658
- async spawn(args, cwd, options) {
659
- const env = {
660
- ...process.env,
661
- INIT_CWD: void 0
662
- };
710
+ async spawn(args, cwd, options, extraEnv) {
711
+ const env = buildEnv(extraEnv);
663
712
  return new Promise((resolve) => {
664
713
  let stdout = "";
665
714
  let stderr = "";
@@ -766,6 +815,75 @@ var HonoAdapter = class {
766
815
  }
767
816
  };
768
817
  //#endregion
818
+ //#region src/specification/directory.ts
819
+ /**
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.
822
+ */
823
+ const DEFAULT_IGNORES = [
824
+ ".git",
825
+ ".DS_Store",
826
+ "node_modules",
827
+ ".next",
828
+ "dist",
829
+ ".turbo",
830
+ ".cache"
831
+ ];
832
+ /**
833
+ * Recursively walk a directory, returning sorted relative paths of files only.
834
+ * Ignored entries (default + caller-supplied) are skipped.
835
+ */
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;
845
+ }
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("/"));
852
+ }
853
+ }
854
+ await walk(root);
855
+ out.sort();
856
+ return out;
857
+ }
858
+ /**
859
+ * Compare two directory trees file-by-file.
860
+ * Binary files are compared by byte equality but reported without inline diff.
861
+ */
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
+ });
879
+ }
880
+ return {
881
+ added,
882
+ changed,
883
+ removed
884
+ };
885
+ }
886
+ //#endregion
769
887
  //#region src/specification/specification.ts
770
888
  var TableAssertion = class {
771
889
  tableName;
@@ -783,6 +901,54 @@ var TableAssertion = class {
783
901
  if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
784
902
  }
785
903
  };
904
+ /**
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
909
+ */
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."));
943
+ }
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
+ };
786
952
  var ResponseAccessor = class {
787
953
  body;
788
954
  testDir;
@@ -830,6 +996,9 @@ var SpecificationResult = class {
830
996
  if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
831
997
  return new ResponseAccessor(this.responseData.body, this.testDir);
832
998
  }
999
+ directory(path = ".") {
1000
+ return new DirectoryAccessor(resolve(this.workDir ?? this.testDir, path), this.testDir);
1001
+ }
833
1002
  file(path) {
834
1003
  const resolvedPath = resolve(this.workDir ?? this.testDir, path);
835
1004
  const exists = existsSync(resolvedPath);
@@ -853,6 +1022,7 @@ var SpecificationResult = class {
853
1022
  };
854
1023
  var SpecificationBuilder = class {
855
1024
  commandArgs = null;
1025
+ commandEnv = {};
856
1026
  config;
857
1027
  fixtures = [];
858
1028
  label;
@@ -886,6 +1056,23 @@ var SpecificationBuilder = class {
886
1056
  this.mocks.push({ file });
887
1057
  return this;
888
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
+ }
889
1076
  get(path) {
890
1077
  this.request = {
891
1078
  method: "GET",
@@ -951,8 +1138,17 @@ var SpecificationBuilder = class {
951
1138
  if (hasHttpAction) return this.runHttpAction();
952
1139
  return this.runCliAction(workDir);
953
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
+ }
954
1151
  prepareWorkDir() {
955
- if (!this.projectName && this.fixtures.length === 0) return this.config.fixturesRoot ?? process.cwd();
956
1152
  const tempDir = mkdtempSync(resolve(tmpdir(), "spec-cli-"));
957
1153
  if (this.projectName && this.config.fixturesRoot) {
958
1154
  const projectDir = resolve(this.config.fixturesRoot, this.projectName);
@@ -979,8 +1175,9 @@ var SpecificationBuilder = class {
979
1175
  }
980
1176
  async runCliAction(workDir) {
981
1177
  if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
1178
+ const env = this.resolveEnv(workDir);
982
1179
  let commandResult;
983
- if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options);
1180
+ if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
984
1181
  else if (Array.isArray(this.commandArgs)) {
985
1182
  commandResult = {
986
1183
  exitCode: 0,
@@ -988,10 +1185,10 @@ var SpecificationBuilder = class {
988
1185
  stdout: ""
989
1186
  };
990
1187
  for (const args of this.commandArgs) {
991
- commandResult = await this.config.command.exec(args, workDir);
1188
+ commandResult = await this.config.command.exec(args, workDir, env);
992
1189
  if (commandResult.exitCode !== 0) break;
993
1190
  }
994
- } else commandResult = await this.config.command.exec(this.commandArgs, workDir);
1191
+ } else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
995
1192
  return new SpecificationResult({
996
1193
  commandResult,
997
1194
  config: this.config,
@@ -1152,6 +1349,176 @@ async function cli(options) {
1152
1349
  return runner;
1153
1350
  }
1154
1351
  //#endregion
1155
- export { ExecAdapter, FetchAdapter, HonoAdapter, Orchestrator, cli, e2e, grep, integration, mockOf, mockOfDate, normalizeOutput, postgres, redis, stripAnsi };
1352
+ //#region src/infrastructure/docker/docker-adapter.ts
1353
+ var DockerAdapter = class {
1354
+ containerId;
1355
+ constructor(containerId) {
1356
+ this.containerId = containerId;
1357
+ }
1358
+ async exec(cmd) {
1359
+ return execSync(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
1360
+ encoding: "utf8",
1361
+ timeout: 1e4
1362
+ }).trim();
1363
+ }
1364
+ async file(path) {
1365
+ try {
1366
+ return {
1367
+ exists: true,
1368
+ content: await this.exec(["cat", path])
1369
+ };
1370
+ } catch {
1371
+ return {
1372
+ exists: false,
1373
+ content: ""
1374
+ };
1375
+ }
1376
+ }
1377
+ async isRunning() {
1378
+ try {
1379
+ return execSync(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
1380
+ encoding: "utf8",
1381
+ timeout: 5e3
1382
+ }).trim() === "true";
1383
+ } catch {
1384
+ return false;
1385
+ }
1386
+ }
1387
+ async logs(tail) {
1388
+ return execSync(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
1389
+ encoding: "utf8",
1390
+ timeout: 1e4
1391
+ });
1392
+ }
1393
+ async inspect() {
1394
+ const raw = execSync(`docker inspect ${this.containerId}`, {
1395
+ encoding: "utf8",
1396
+ timeout: 5e3
1397
+ });
1398
+ const data = JSON.parse(raw)[0];
1399
+ return {
1400
+ id: data.Id,
1401
+ name: data.Name,
1402
+ state: {
1403
+ running: data.State.Running,
1404
+ exitCode: data.State.ExitCode,
1405
+ status: data.State.Status
1406
+ },
1407
+ config: {
1408
+ image: data.Config.Image,
1409
+ env: data.Config.Env || []
1410
+ },
1411
+ hostConfig: {
1412
+ memory: data.HostConfig.Memory || 0,
1413
+ cpuQuota: data.HostConfig.CpuQuota || 0,
1414
+ networkMode: data.HostConfig.NetworkMode || "",
1415
+ mounts: (data.Mounts || []).map((m) => ({
1416
+ source: m.Source,
1417
+ destination: m.Destination,
1418
+ type: m.Type
1419
+ }))
1420
+ },
1421
+ networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
1422
+ gateway: net.Gateway,
1423
+ ipAddress: net.IPAddress
1424
+ }])) }
1425
+ };
1426
+ }
1427
+ async exists(path) {
1428
+ try {
1429
+ await this.exec([
1430
+ "test",
1431
+ "-e",
1432
+ path
1433
+ ]);
1434
+ return true;
1435
+ } catch {
1436
+ return false;
1437
+ }
1438
+ }
1439
+ };
1440
+ /** Create a Docker container port for an existing container */
1441
+ function dockerContainer(containerId) {
1442
+ return new DockerAdapter(containerId);
1443
+ }
1444
+ //#endregion
1445
+ //#region src/infrastructure/docker/docker-assertion.ts
1446
+ /** Fluent assertion builder for Docker containers */
1447
+ var DockerAssertion = class {
1448
+ container;
1449
+ constructor(container) {
1450
+ this.container = container;
1451
+ }
1452
+ /** Assert the container is running */
1453
+ async toBeRunning() {
1454
+ if (!await this.container.isRunning()) throw new Error("Expected container to be running");
1455
+ return this;
1456
+ }
1457
+ /** Assert the container is NOT running / doesn't exist */
1458
+ async toNotExist() {
1459
+ if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
1460
+ return this;
1461
+ }
1462
+ /** Assert a file exists inside the container */
1463
+ async toHaveFile(path, opts) {
1464
+ const file = await this.container.file(path);
1465
+ if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
1466
+ if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
1467
+ return this;
1468
+ }
1469
+ /** Assert a file does NOT exist */
1470
+ async toNotHaveFile(path) {
1471
+ if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
1472
+ return this;
1473
+ }
1474
+ /** Assert a directory exists */
1475
+ async toHaveDirectory(path) {
1476
+ if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
1477
+ return this;
1478
+ }
1479
+ /** Assert a mount exists */
1480
+ async toHaveMount(destination) {
1481
+ const info = await this.container.inspect();
1482
+ if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
1483
+ const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
1484
+ throw new Error(`Expected mount at ${destination}, found: [${available}]`);
1485
+ }
1486
+ return this;
1487
+ }
1488
+ /** Assert network mode */
1489
+ async toHaveNetwork(mode) {
1490
+ const info = await this.container.inspect();
1491
+ if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
1492
+ return this;
1493
+ }
1494
+ /** Assert memory limit */
1495
+ async toHaveMemoryLimit(bytes) {
1496
+ const info = await this.container.inspect();
1497
+ if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
1498
+ return this;
1499
+ }
1500
+ /** Assert CPU quota */
1501
+ async toHaveCpuQuota(quota) {
1502
+ const info = await this.container.inspect();
1503
+ if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
1504
+ return this;
1505
+ }
1506
+ /** Execute a command and return output for custom assertions */
1507
+ async exec(cmd) {
1508
+ return this.container.exec(cmd);
1509
+ }
1510
+ /** Read a file for custom assertions */
1511
+ async readFile(path) {
1512
+ const file = await this.container.file(path);
1513
+ if (!file.exists) throw new Error(`File ${path} does not exist`);
1514
+ return file.content;
1515
+ }
1516
+ /** Get logs for custom assertions */
1517
+ async getLogs(tail) {
1518
+ return this.container.logs(tail);
1519
+ }
1520
+ };
1521
+ //#endregion
1522
+ export { DockerAssertion, ExecAdapter, FetchAdapter, HonoAdapter, Orchestrator, cli, dockerContainer, e2e, grep, integration, mockOf, mockOfDate, normalizeOutput, postgres, redis, stripAnsi };
1156
1523
 
1157
1524
  //# sourceMappingURL=index.js.map