@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/README.md CHANGED
@@ -136,13 +136,13 @@ Every test follows the same pattern: `spec("label") → setup → action → ass
136
136
 
137
137
  ### Setup (cross-mode)
138
138
 
139
- | Method | Description |
140
- | ---------------------------------------- | -------------------------------------------------------- |
141
- | `.seed("file.sql")` | Load SQL from `seeds/file.sql` into the default database |
142
- | `.seed("file.sql", { service: "name" })` | Load SQL into a specific database |
143
- | `.fixture("file")` | Copy `fixtures/file` into the CLI working directory |
144
- | `.project("name")` | Use `fixtures/name/` as the CLI working directory |
145
- | `.mock("file.json")` | Register mocked external API response (MSW, planned) |
139
+ | Method | Description |
140
+ | ---------------------------------------- | --------------------------------------------------------- |
141
+ | `.seed("file.sql")` | Load SQL from `seeds/file.sql` into the default database |
142
+ | `.seed("file.sql", { service: "name" })` | Load SQL into a specific database |
143
+ | `.fixture("file")` | Copy `fixtures/file` into the CLI working directory |
144
+ | `.project("name")` | Copy `fixtures/name/` into a fresh temp dir and run there |
145
+ | `.mock("file.json")` | Register mocked external API response (MSW, planned) |
146
146
 
147
147
  ### Actions (one per spec, mutually exclusive)
148
148
 
@@ -157,11 +157,14 @@ Every test follows the same pattern: `spec("label") → setup → action → ass
157
157
 
158
158
  **CLI:**
159
159
 
160
- | Method | Description |
161
- | -------------------------------------- | ----------------------------------------------------------- |
162
- | `.exec("args")` | Run command (blocking) |
163
- | `.exec(["build", "start"])` | Run commands sequentially in same directory |
164
- | `.spawn("args", { waitFor, timeout })` | Run long-lived process, resolve on pattern match or timeout |
160
+ | Method | Description |
161
+ | -------------------------------------- | ------------------------------------------------------------------------------------- |
162
+ | `.exec("args")` | Run command (blocking) |
163
+ | `.exec(["build", "start"])` | Run commands sequentially in same directory |
164
+ | `.spawn("args", { waitFor, timeout })` | Run long-lived process, resolve on pattern match or timeout |
165
+ | `.env({ KEY: "value" })` | Set env vars on the child process (`null` unsets, `$WORKDIR` expands to the temp cwd) |
166
+
167
+ Every CLI spec runs in a **fresh, empty temp directory** by default. `.project("name")` starts from a copy of `fixtures/name/`; `.fixture("file")` seeds specific files into the temp dir. Bare specs (`spec("x").exec("...")`) get an empty dir — ideal for testing scaffolding CLIs that write into their cwd.
165
168
 
166
169
  ### Assertions
167
170
 
@@ -184,6 +187,16 @@ Result properties are raw values — use vitest `expect()` for assertions. Datab
184
187
  | `expect(result.file("dist/index.js").content).toContain("Hello")` | Assert file contains string |
185
188
  | `expect(result.file("dist/index.cjs").exists).toBe(false)` | Assert file does not exist |
186
189
 
190
+ **Directories (CLI scaffolding / codegen output):**
191
+
192
+ | Expression | Description |
193
+ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ |
194
+ | `await result.directory("out").toMatchFixture("go-api")` | Snapshot the tree against `expected/go-api/`, structured diff on mismatch |
195
+ | `await result.directory().toMatchFixture("scaffold", { ignore })` | Pass extra ignore patterns; defaults already skip `.git`, `node_modules`, etc. |
196
+ | `await result.directory("out").files()` | List all files (recursive, sorted) for ad-hoc assertions |
197
+
198
+ Run with `JTERRAZZ_TEST_UPDATE=1` (or vitest `-u`) to overwrite fixtures with the current output. Fixtures live at `{test}/expected/{name}/` — same convention as `responses/` for HTTP bodies.
199
+
187
200
  **Grep (scoped text matching):**
188
201
 
189
202
  ```typescript
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
@@ -292,6 +293,47 @@ function formatResponseDiff(file, expected, actual) {
292
293
  }
293
294
  return lines.join("\n");
294
295
  }
296
+ function formatDirectoryDiff(fixtureName, diff, hint) {
297
+ const lines = [];
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}`);
301
+ lines.push("");
302
+ lines.push(`${GREEN}- Expected (fixture)${RESET}`);
303
+ lines.push(`${RED}+ Received (generated)${RESET}`);
304
+ lines.push("");
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
+ }
324
+ }
325
+ if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
326
+ }
327
+ lines.push("");
328
+ lines.push(`${DIM}${hint}${RESET}`);
329
+ return lines.join("\n");
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
+ }
295
337
  function rowLabel(n) {
296
338
  return n === 1 ? "1 row" : `${n} rows`;
297
339
  }
@@ -643,6 +685,19 @@ var Orchestrator = class {
643
685
  //#endregion
644
686
  //#region src/specification/adapters/exec.adapter.ts
645
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
+ /**
646
701
  * Executes CLI commands via execSync (blocking) or spawn (long-running).
647
702
  * Used by cli() for local command execution.
648
703
  */
@@ -651,11 +706,8 @@ var ExecAdapter = class {
651
706
  constructor(command) {
652
707
  this.command = command;
653
708
  }
654
- async exec(args, cwd) {
655
- const env = {
656
- ...process.env,
657
- INIT_CWD: void 0
658
- };
709
+ async exec(args, cwd, extraEnv) {
710
+ const env = buildEnv(extraEnv);
659
711
  try {
660
712
  return {
661
713
  exitCode: 0,
@@ -679,11 +731,8 @@ var ExecAdapter = class {
679
731
  };
680
732
  }
681
733
  }
682
- async spawn(args, cwd, options) {
683
- const env = {
684
- ...process.env,
685
- INIT_CWD: void 0
686
- };
734
+ async spawn(args, cwd, options, extraEnv) {
735
+ const env = buildEnv(extraEnv);
687
736
  return new Promise((resolve) => {
688
737
  let stdout = "";
689
738
  let stderr = "";
@@ -790,6 +839,75 @@ var HonoAdapter = class {
790
839
  }
791
840
  };
792
841
  //#endregion
842
+ //#region src/specification/directory.ts
843
+ /**
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.
846
+ */
847
+ const DEFAULT_IGNORES = [
848
+ ".git",
849
+ ".DS_Store",
850
+ "node_modules",
851
+ ".next",
852
+ "dist",
853
+ ".turbo",
854
+ ".cache"
855
+ ];
856
+ /**
857
+ * Recursively walk a directory, returning sorted relative paths of files only.
858
+ * Ignored entries (default + caller-supplied) are skipped.
859
+ */
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;
869
+ }
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("/"));
876
+ }
877
+ }
878
+ await walk(root);
879
+ out.sort();
880
+ return out;
881
+ }
882
+ /**
883
+ * Compare two directory trees file-by-file.
884
+ * Binary files are compared by byte equality but reported without inline diff.
885
+ */
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
+ });
903
+ }
904
+ return {
905
+ added,
906
+ changed,
907
+ removed
908
+ };
909
+ }
910
+ //#endregion
793
911
  //#region src/specification/specification.ts
794
912
  var TableAssertion = class {
795
913
  tableName;
@@ -807,6 +925,54 @@ var TableAssertion = class {
807
925
  if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
808
926
  }
809
927
  };
928
+ /**
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
933
+ */
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."));
967
+ }
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
+ };
810
976
  var ResponseAccessor = class {
811
977
  body;
812
978
  testDir;
@@ -854,6 +1020,9 @@ var SpecificationResult = class {
854
1020
  if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
855
1021
  return new ResponseAccessor(this.responseData.body, this.testDir);
856
1022
  }
1023
+ directory(path = ".") {
1024
+ return new DirectoryAccessor((0, node_path.resolve)(this.workDir ?? this.testDir, path), this.testDir);
1025
+ }
857
1026
  file(path) {
858
1027
  const resolvedPath = (0, node_path.resolve)(this.workDir ?? this.testDir, path);
859
1028
  const exists = (0, node_fs.existsSync)(resolvedPath);
@@ -877,6 +1046,7 @@ var SpecificationResult = class {
877
1046
  };
878
1047
  var SpecificationBuilder = class {
879
1048
  commandArgs = null;
1049
+ commandEnv = {};
880
1050
  config;
881
1051
  fixtures = [];
882
1052
  label;
@@ -910,6 +1080,23 @@ var SpecificationBuilder = class {
910
1080
  this.mocks.push({ file });
911
1081
  return this;
912
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
+ }
913
1100
  get(path) {
914
1101
  this.request = {
915
1102
  method: "GET",
@@ -975,8 +1162,17 @@ var SpecificationBuilder = class {
975
1162
  if (hasHttpAction) return this.runHttpAction();
976
1163
  return this.runCliAction(workDir);
977
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
+ }
978
1175
  prepareWorkDir() {
979
- if (!this.projectName && this.fixtures.length === 0) return this.config.fixturesRoot ?? process.cwd();
980
1176
  const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
981
1177
  if (this.projectName && this.config.fixturesRoot) {
982
1178
  const projectDir = (0, node_path.resolve)(this.config.fixturesRoot, this.projectName);
@@ -1003,8 +1199,9 @@ var SpecificationBuilder = class {
1003
1199
  }
1004
1200
  async runCliAction(workDir) {
1005
1201
  if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
1202
+ const env = this.resolveEnv(workDir);
1006
1203
  let commandResult;
1007
- if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options);
1204
+ if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
1008
1205
  else if (Array.isArray(this.commandArgs)) {
1009
1206
  commandResult = {
1010
1207
  exitCode: 0,
@@ -1012,10 +1209,10 @@ var SpecificationBuilder = class {
1012
1209
  stdout: ""
1013
1210
  };
1014
1211
  for (const args of this.commandArgs) {
1015
- commandResult = await this.config.command.exec(args, workDir);
1212
+ commandResult = await this.config.command.exec(args, workDir, env);
1016
1213
  if (commandResult.exitCode !== 0) break;
1017
1214
  }
1018
- } else commandResult = await this.config.command.exec(this.commandArgs, workDir);
1215
+ } else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
1019
1216
  return new SpecificationResult({
1020
1217
  commandResult,
1021
1218
  config: this.config,
@@ -1176,11 +1373,183 @@ async function cli(options) {
1176
1373
  return runner;
1177
1374
  }
1178
1375
  //#endregion
1376
+ //#region src/infrastructure/docker/docker-adapter.ts
1377
+ var DockerAdapter = class {
1378
+ containerId;
1379
+ constructor(containerId) {
1380
+ this.containerId = containerId;
1381
+ }
1382
+ async exec(cmd) {
1383
+ return (0, node_child_process.execSync)(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
1384
+ encoding: "utf8",
1385
+ timeout: 1e4
1386
+ }).trim();
1387
+ }
1388
+ async file(path) {
1389
+ try {
1390
+ return {
1391
+ exists: true,
1392
+ content: await this.exec(["cat", path])
1393
+ };
1394
+ } catch {
1395
+ return {
1396
+ exists: false,
1397
+ content: ""
1398
+ };
1399
+ }
1400
+ }
1401
+ async isRunning() {
1402
+ try {
1403
+ return (0, node_child_process.execSync)(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
1404
+ encoding: "utf8",
1405
+ timeout: 5e3
1406
+ }).trim() === "true";
1407
+ } catch {
1408
+ return false;
1409
+ }
1410
+ }
1411
+ async logs(tail) {
1412
+ return (0, node_child_process.execSync)(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
1413
+ encoding: "utf8",
1414
+ timeout: 1e4
1415
+ });
1416
+ }
1417
+ async inspect() {
1418
+ const raw = (0, node_child_process.execSync)(`docker inspect ${this.containerId}`, {
1419
+ encoding: "utf8",
1420
+ timeout: 5e3
1421
+ });
1422
+ const data = JSON.parse(raw)[0];
1423
+ return {
1424
+ id: data.Id,
1425
+ name: data.Name,
1426
+ state: {
1427
+ running: data.State.Running,
1428
+ exitCode: data.State.ExitCode,
1429
+ status: data.State.Status
1430
+ },
1431
+ config: {
1432
+ image: data.Config.Image,
1433
+ env: data.Config.Env || []
1434
+ },
1435
+ hostConfig: {
1436
+ memory: data.HostConfig.Memory || 0,
1437
+ cpuQuota: data.HostConfig.CpuQuota || 0,
1438
+ networkMode: data.HostConfig.NetworkMode || "",
1439
+ mounts: (data.Mounts || []).map((m) => ({
1440
+ source: m.Source,
1441
+ destination: m.Destination,
1442
+ type: m.Type
1443
+ }))
1444
+ },
1445
+ networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
1446
+ gateway: net.Gateway,
1447
+ ipAddress: net.IPAddress
1448
+ }])) }
1449
+ };
1450
+ }
1451
+ async exists(path) {
1452
+ try {
1453
+ await this.exec([
1454
+ "test",
1455
+ "-e",
1456
+ path
1457
+ ]);
1458
+ return true;
1459
+ } catch {
1460
+ return false;
1461
+ }
1462
+ }
1463
+ };
1464
+ /** Create a Docker container port for an existing container */
1465
+ function dockerContainer(containerId) {
1466
+ return new DockerAdapter(containerId);
1467
+ }
1468
+ //#endregion
1469
+ //#region src/infrastructure/docker/docker-assertion.ts
1470
+ /** Fluent assertion builder for Docker containers */
1471
+ var DockerAssertion = class {
1472
+ container;
1473
+ constructor(container) {
1474
+ this.container = container;
1475
+ }
1476
+ /** Assert the container is running */
1477
+ async toBeRunning() {
1478
+ if (!await this.container.isRunning()) throw new Error("Expected container to be running");
1479
+ return this;
1480
+ }
1481
+ /** Assert the container is NOT running / doesn't exist */
1482
+ async toNotExist() {
1483
+ if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
1484
+ return this;
1485
+ }
1486
+ /** Assert a file exists inside the container */
1487
+ async toHaveFile(path, opts) {
1488
+ const file = await this.container.file(path);
1489
+ if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
1490
+ if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
1491
+ return this;
1492
+ }
1493
+ /** Assert a file does NOT exist */
1494
+ async toNotHaveFile(path) {
1495
+ if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
1496
+ return this;
1497
+ }
1498
+ /** Assert a directory exists */
1499
+ async toHaveDirectory(path) {
1500
+ if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
1501
+ return this;
1502
+ }
1503
+ /** Assert a mount exists */
1504
+ async toHaveMount(destination) {
1505
+ const info = await this.container.inspect();
1506
+ if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
1507
+ const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
1508
+ throw new Error(`Expected mount at ${destination}, found: [${available}]`);
1509
+ }
1510
+ return this;
1511
+ }
1512
+ /** Assert network mode */
1513
+ async toHaveNetwork(mode) {
1514
+ const info = await this.container.inspect();
1515
+ if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
1516
+ return this;
1517
+ }
1518
+ /** Assert memory limit */
1519
+ async toHaveMemoryLimit(bytes) {
1520
+ const info = await this.container.inspect();
1521
+ if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
1522
+ return this;
1523
+ }
1524
+ /** Assert CPU quota */
1525
+ async toHaveCpuQuota(quota) {
1526
+ const info = await this.container.inspect();
1527
+ if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
1528
+ return this;
1529
+ }
1530
+ /** Execute a command and return output for custom assertions */
1531
+ async exec(cmd) {
1532
+ return this.container.exec(cmd);
1533
+ }
1534
+ /** Read a file for custom assertions */
1535
+ async readFile(path) {
1536
+ const file = await this.container.file(path);
1537
+ if (!file.exists) throw new Error(`File ${path} does not exist`);
1538
+ return file.content;
1539
+ }
1540
+ /** Get logs for custom assertions */
1541
+ async getLogs(tail) {
1542
+ return this.container.logs(tail);
1543
+ }
1544
+ };
1545
+ //#endregion
1546
+ exports.DockerAssertion = DockerAssertion;
1179
1547
  exports.ExecAdapter = ExecAdapter;
1180
1548
  exports.FetchAdapter = FetchAdapter;
1181
1549
  exports.HonoAdapter = HonoAdapter;
1182
1550
  exports.Orchestrator = Orchestrator;
1183
1551
  exports.cli = cli;
1552
+ exports.dockerContainer = dockerContainer;
1184
1553
  exports.e2e = e2e;
1185
1554
  exports.grep = grep;
1186
1555
  exports.integration = integration;