@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 +25 -12
- package/dist/index.cjs +383 -14
- 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 -17
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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")` |
|
|
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;
|