@jterrazz/test 5.0.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/dist/index.cjs +383 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +136 -5
- package/dist/index.d.ts +136 -5
- package/dist/index.js +384 -16
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -157,11 +157,12 @@ 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) |
|
|
165
166
|
|
|
166
167
|
### Assertions
|
|
167
168
|
|
|
@@ -184,6 +185,16 @@ Result properties are raw values — use vitest `expect()` for assertions. Datab
|
|
|
184
185
|
| `expect(result.file("dist/index.js").content).toContain("Hello")` | Assert file contains string |
|
|
185
186
|
| `expect(result.file("dist/index.cjs").exists).toBe(false)` | Assert file does not exist |
|
|
186
187
|
|
|
188
|
+
**Directories (CLI scaffolding / codegen output):**
|
|
189
|
+
|
|
190
|
+
| Expression | Description |
|
|
191
|
+
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
|
192
|
+
| `await result.directory("out").toMatchFixture("go-api")` | Snapshot the tree against `expected/go-api/`, structured diff on mismatch |
|
|
193
|
+
| `await result.directory().toMatchFixture("scaffold", { ignore })` | Pass extra ignore patterns; defaults already skip `.git`, `node_modules`, etc. |
|
|
194
|
+
| `await result.directory("out").files()` | List all files (recursive, sorted) for ad-hoc assertions |
|
|
195
|
+
|
|
196
|
+
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.
|
|
197
|
+
|
|
187
198
|
**Grep (scoped text matching):**
|
|
188
199
|
|
|
189
200
|
```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,6 +1162,16 @@ 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
1176
|
if (!this.projectName && this.fixtures.length === 0) return this.config.fixturesRoot ?? process.cwd();
|
|
980
1177
|
const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
|
|
@@ -1003,8 +1200,9 @@ var SpecificationBuilder = class {
|
|
|
1003
1200
|
}
|
|
1004
1201
|
async runCliAction(workDir) {
|
|
1005
1202
|
if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
|
|
1203
|
+
const env = this.resolveEnv(workDir);
|
|
1006
1204
|
let commandResult;
|
|
1007
|
-
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);
|
|
1008
1206
|
else if (Array.isArray(this.commandArgs)) {
|
|
1009
1207
|
commandResult = {
|
|
1010
1208
|
exitCode: 0,
|
|
@@ -1012,10 +1210,10 @@ var SpecificationBuilder = class {
|
|
|
1012
1210
|
stdout: ""
|
|
1013
1211
|
};
|
|
1014
1212
|
for (const args of this.commandArgs) {
|
|
1015
|
-
commandResult = await this.config.command.exec(args, workDir);
|
|
1213
|
+
commandResult = await this.config.command.exec(args, workDir, env);
|
|
1016
1214
|
if (commandResult.exitCode !== 0) break;
|
|
1017
1215
|
}
|
|
1018
|
-
} else commandResult = await this.config.command.exec(this.commandArgs, workDir);
|
|
1216
|
+
} else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
|
|
1019
1217
|
return new SpecificationResult({
|
|
1020
1218
|
commandResult,
|
|
1021
1219
|
config: this.config,
|
|
@@ -1176,11 +1374,183 @@ async function cli(options) {
|
|
|
1176
1374
|
return runner;
|
|
1177
1375
|
}
|
|
1178
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;
|
|
1179
1548
|
exports.ExecAdapter = ExecAdapter;
|
|
1180
1549
|
exports.FetchAdapter = FetchAdapter;
|
|
1181
1550
|
exports.HonoAdapter = HonoAdapter;
|
|
1182
1551
|
exports.Orchestrator = Orchestrator;
|
|
1183
1552
|
exports.cli = cli;
|
|
1553
|
+
exports.dockerContainer = dockerContainer;
|
|
1184
1554
|
exports.e2e = e2e;
|
|
1185
1555
|
exports.grep = grep;
|
|
1186
1556
|
exports.integration = integration;
|