@jterrazz/test 5.3.2 → 6.0.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 +66 -56
- package/dist/index.cjs +561 -452
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +341 -246
- package/dist/index.d.ts +341 -246
- package/dist/index.js +555 -427
- package/dist/index.js.map +1 -1
- package/dist/mock-of.cjs +59 -0
- package/dist/mock-of.cjs.map +1 -0
- package/dist/mock-of.d.cts +27 -0
- package/dist/mock-of.d.ts +27 -0
- package/dist/mock-of.js +19 -0
- package/dist/mock-of.js.map +1 -0
- package/dist/mock.cjs +4 -0
- package/dist/mock.d.cts +2 -0
- package/dist/mock.d.ts +2 -0
- package/dist/mock.js +2 -0
- package/dist/redis.adapter.cjs +214 -0
- package/dist/redis.adapter.cjs.map +1 -0
- package/dist/redis.adapter.d.cts +155 -0
- package/dist/redis.adapter.d.ts +155 -0
- package/dist/redis.adapter.js +202 -0
- package/dist/redis.adapter.js.map +1 -0
- package/dist/services.cjs +4 -0
- package/dist/services.d.cts +2 -0
- package/dist/services.d.ts +2 -0
- package/dist/services.js +2 -0
- package/package.json +14 -8
package/dist/index.cjs
CHANGED
|
@@ -1,36 +1,12 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
var __defProp = Object.defineProperty;
|
|
5
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
-
var __copyProps = (to, from, except, desc) => {
|
|
10
|
-
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
-
key = keys[i];
|
|
12
|
-
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
13
|
-
get: ((k) => from[k]).bind(null, key),
|
|
14
|
-
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
return to;
|
|
18
|
-
};
|
|
19
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
20
|
-
value: mod,
|
|
21
|
-
enumerable: true
|
|
22
|
-
}) : target, mod));
|
|
23
|
-
//#endregion
|
|
2
|
+
const require_mock_of = require("./mock-of.cjs");
|
|
3
|
+
const require_redis_adapter = require("./redis.adapter.cjs");
|
|
24
4
|
let node_child_process = require("node:child_process");
|
|
25
5
|
let node_fs = require("node:fs");
|
|
26
6
|
let node_os = require("node:os");
|
|
27
7
|
let node_path = require("node:path");
|
|
28
8
|
let node_fs_promises = require("node:fs/promises");
|
|
29
|
-
let pg = require("pg");
|
|
30
9
|
let yaml = require("yaml");
|
|
31
|
-
let mockdate = require("mockdate");
|
|
32
|
-
mockdate = __toESM(mockdate);
|
|
33
|
-
let vitest_mock_extended = require("vitest-mock-extended");
|
|
34
10
|
//#region src/adapters/exec.adapter.ts
|
|
35
11
|
/**
|
|
36
12
|
* Build a child-process env from the parent env plus user overrides.
|
|
@@ -46,8 +22,9 @@ function buildEnv(extra) {
|
|
|
46
22
|
return env;
|
|
47
23
|
}
|
|
48
24
|
/**
|
|
49
|
-
* Executes CLI commands via
|
|
50
|
-
*
|
|
25
|
+
* Executes CLI commands via Node.js child_process.
|
|
26
|
+
* Uses `execSync` for one-shot commands and `spawn` for long-running processes.
|
|
27
|
+
* Used by the `cli()` specification runner.
|
|
51
28
|
*/
|
|
52
29
|
var ExecAdapter = class {
|
|
53
30
|
command;
|
|
@@ -127,6 +104,80 @@ var ExecAdapter = class {
|
|
|
127
104
|
}
|
|
128
105
|
};
|
|
129
106
|
//#endregion
|
|
107
|
+
//#region src/adapters/fetch.adapter.ts
|
|
108
|
+
/**
|
|
109
|
+
* Server adapter that sends real HTTP requests via the Fetch API.
|
|
110
|
+
* Used by the `e2e()` specification runner to hit a live server.
|
|
111
|
+
*/
|
|
112
|
+
var FetchAdapter = class {
|
|
113
|
+
baseUrl;
|
|
114
|
+
constructor(url) {
|
|
115
|
+
this.baseUrl = url.replace(/\/$/, "");
|
|
116
|
+
}
|
|
117
|
+
async request(method, path, body) {
|
|
118
|
+
const init = {
|
|
119
|
+
method,
|
|
120
|
+
headers: { "Content-Type": "application/json" }
|
|
121
|
+
};
|
|
122
|
+
if (body !== void 0) init.body = JSON.stringify(body);
|
|
123
|
+
const response = await fetch(`${this.baseUrl}${path}`, init);
|
|
124
|
+
const responseBody = await response.json().catch(() => null);
|
|
125
|
+
const headers = {};
|
|
126
|
+
response.headers.forEach((value, key) => {
|
|
127
|
+
headers[key] = value;
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
status: response.status,
|
|
131
|
+
body: responseBody,
|
|
132
|
+
headers
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/adapters/hono.adapter.ts
|
|
138
|
+
/**
|
|
139
|
+
* Server adapter that dispatches requests in-process through a Hono app instance.
|
|
140
|
+
* Used by the `integration()` specification runner -- no network overhead.
|
|
141
|
+
*/
|
|
142
|
+
var HonoAdapter = class {
|
|
143
|
+
app;
|
|
144
|
+
constructor(app) {
|
|
145
|
+
this.app = app;
|
|
146
|
+
}
|
|
147
|
+
async request(method, path, body) {
|
|
148
|
+
const init = {
|
|
149
|
+
method,
|
|
150
|
+
headers: { "Content-Type": "application/json" }
|
|
151
|
+
};
|
|
152
|
+
if (body !== void 0) init.body = JSON.stringify(body);
|
|
153
|
+
const response = await this.app.request(path, init);
|
|
154
|
+
const responseBody = await response.json().catch(() => null);
|
|
155
|
+
const headers = {};
|
|
156
|
+
response.headers.forEach((value, key) => {
|
|
157
|
+
headers[key] = value;
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
status: response.status,
|
|
161
|
+
body: responseBody,
|
|
162
|
+
headers
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/utilities/grep.ts
|
|
168
|
+
/**
|
|
169
|
+
* Extract text blocks from output that contain a pattern.
|
|
170
|
+
* Splits by blank lines (how linter/compiler output is structured),
|
|
171
|
+
* returns only blocks matching the pattern.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
175
|
+
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
176
|
+
*/
|
|
177
|
+
function grep(output, pattern) {
|
|
178
|
+
return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
130
181
|
//#region src/utilities/directory.ts
|
|
131
182
|
/**
|
|
132
183
|
* Default ignore patterns — paths that should never appear in a tracked snapshot.
|
|
@@ -325,9 +376,11 @@ function rowLabel(n) {
|
|
|
325
376
|
function formatRow(row) {
|
|
326
377
|
return row.map((v) => String(v ?? "null")).join(" | ");
|
|
327
378
|
}
|
|
379
|
+
/** Strip ANSI escape codes from a string. */
|
|
328
380
|
function stripAnsi(str) {
|
|
329
381
|
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
330
382
|
}
|
|
383
|
+
/** Strip ANSI codes and replace volatile tokens (ports, durations) with placeholders. */
|
|
331
384
|
function normalizeOutput(str) {
|
|
332
385
|
return stripAnsi(str).replace(/localhost:\d+/g, "localhost:PORT").replace(/\d+ms/g, "Xms").replace(/\d+\.\d+s/g, "X.Xs").trim();
|
|
333
386
|
}
|
|
@@ -345,6 +398,10 @@ function shouldUpdateSnapshots() {
|
|
|
345
398
|
if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
|
|
346
399
|
return false;
|
|
347
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Handle to a directory produced by a specification run.
|
|
403
|
+
* Supports snapshot-based assertions via {@link toMatchFixture} and file listing via {@link files}.
|
|
404
|
+
*/
|
|
348
405
|
var DirectoryAccessor = class {
|
|
349
406
|
absPath;
|
|
350
407
|
testDir;
|
|
@@ -383,6 +440,7 @@ var DirectoryAccessor = class {
|
|
|
383
440
|
};
|
|
384
441
|
//#endregion
|
|
385
442
|
//#region src/builder/response-accessor.ts
|
|
443
|
+
/** Accessor for an HTTP response body with file-based assertion support. */
|
|
386
444
|
var ResponseAccessor = class {
|
|
387
445
|
body;
|
|
388
446
|
testDir;
|
|
@@ -390,6 +448,12 @@ var ResponseAccessor = class {
|
|
|
390
448
|
this.body = body;
|
|
391
449
|
this.testDir = testDir;
|
|
392
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Assert that the response body matches the JSON in `responses/{file}`.
|
|
453
|
+
*
|
|
454
|
+
* @example
|
|
455
|
+
* result.response.toMatchFile("expected-items.json");
|
|
456
|
+
*/
|
|
393
457
|
toMatchFile(file) {
|
|
394
458
|
const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
|
|
395
459
|
if (JSON.stringify(this.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.body));
|
|
@@ -397,6 +461,7 @@ var ResponseAccessor = class {
|
|
|
397
461
|
};
|
|
398
462
|
//#endregion
|
|
399
463
|
//#region src/builder/table-assertion.ts
|
|
464
|
+
/** Assertion helper for verifying database table contents after a specification run. */
|
|
400
465
|
var TableAssertion = class {
|
|
401
466
|
tableName;
|
|
402
467
|
db;
|
|
@@ -404,10 +469,20 @@ var TableAssertion = class {
|
|
|
404
469
|
this.tableName = tableName;
|
|
405
470
|
this.db = db;
|
|
406
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* Assert that the table contains exactly the expected rows for the given columns.
|
|
474
|
+
*
|
|
475
|
+
* @example
|
|
476
|
+
* await result.table("users").toMatch({
|
|
477
|
+
* columns: ["name", "email"],
|
|
478
|
+
* rows: [["Alice", "alice@example.com"]],
|
|
479
|
+
* });
|
|
480
|
+
*/
|
|
407
481
|
async toMatch(expected) {
|
|
408
482
|
const actual = await this.db.query(this.tableName, expected.columns);
|
|
409
483
|
if (JSON.stringify(actual) !== JSON.stringify(expected.rows)) throw new Error(formatTableDiff(this.tableName, expected.columns, expected.rows, actual));
|
|
410
484
|
}
|
|
485
|
+
/** Assert that the table has zero rows. */
|
|
411
486
|
async toBeEmpty() {
|
|
412
487
|
const actual = await this.db.query(this.tableName, ["*"]);
|
|
413
488
|
if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
|
|
@@ -415,6 +490,10 @@ var TableAssertion = class {
|
|
|
415
490
|
};
|
|
416
491
|
//#endregion
|
|
417
492
|
//#region src/builder/specification-result.ts
|
|
493
|
+
/**
|
|
494
|
+
* The outcome of a single specification run.
|
|
495
|
+
* Provides accessors for CLI output, HTTP responses, files, directories, and database tables.
|
|
496
|
+
*/
|
|
418
497
|
var SpecificationResult = class {
|
|
419
498
|
commandResult;
|
|
420
499
|
config;
|
|
@@ -430,29 +509,46 @@ var SpecificationResult = class {
|
|
|
430
509
|
this.requestInfo = options.requestInfo;
|
|
431
510
|
this.workDir = options.workDir;
|
|
432
511
|
}
|
|
512
|
+
/** The process exit code. Only available after a CLI action. */
|
|
433
513
|
get exitCode() {
|
|
434
514
|
if (!this.commandResult) throw new Error(".exitCode requires a CLI action (.exec())");
|
|
435
515
|
return this.commandResult.exitCode;
|
|
436
516
|
}
|
|
517
|
+
/** The HTTP response status code. Only available after an HTTP action. */
|
|
437
518
|
get status() {
|
|
438
519
|
if (!this.responseData) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
|
|
439
520
|
return this.responseData.status;
|
|
440
521
|
}
|
|
522
|
+
/** The captured standard output. Only available after a CLI action. */
|
|
441
523
|
get stdout() {
|
|
442
524
|
if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
|
|
443
525
|
return this.commandResult.stdout;
|
|
444
526
|
}
|
|
527
|
+
/** The captured standard error. Only available after a CLI action. */
|
|
445
528
|
get stderr() {
|
|
446
529
|
if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
|
|
447
530
|
return this.commandResult.stderr;
|
|
448
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* Extract text blocks from stdout that contain a pattern.
|
|
534
|
+
* Useful for parsing structured CLI output (linters, compilers).
|
|
535
|
+
*
|
|
536
|
+
* @example
|
|
537
|
+
* expect(result.grep('error.ts')).toContain('no-unused-vars');
|
|
538
|
+
*/
|
|
539
|
+
grep(pattern) {
|
|
540
|
+
return grep(this.stdout, pattern);
|
|
541
|
+
}
|
|
542
|
+
/** Access the HTTP response body for assertions. Only available after an HTTP action. */
|
|
449
543
|
get response() {
|
|
450
544
|
if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
|
|
451
545
|
return new ResponseAccessor(this.responseData.body, this.testDir);
|
|
452
546
|
}
|
|
547
|
+
/** Access a directory (relative to the working directory) for snapshot assertions. */
|
|
453
548
|
directory(path = ".") {
|
|
454
549
|
return new DirectoryAccessor((0, node_path.resolve)(this.workDir ?? this.testDir, path), this.testDir);
|
|
455
550
|
}
|
|
551
|
+
/** Access a single file (relative to the working directory) for content assertions. */
|
|
456
552
|
file(path) {
|
|
457
553
|
const resolvedPath = (0, node_path.resolve)(this.workDir ?? this.testDir, path);
|
|
458
554
|
const exists = (0, node_fs.existsSync)(resolvedPath);
|
|
@@ -464,6 +560,7 @@ var SpecificationResult = class {
|
|
|
464
560
|
exists
|
|
465
561
|
};
|
|
466
562
|
}
|
|
563
|
+
/** Access a database table for row-level assertions. */
|
|
467
564
|
table(tableName, options) {
|
|
468
565
|
const db = this.resolveDatabase(options?.service);
|
|
469
566
|
if (!db) throw new Error(options?.service ? `table("${tableName}") requires database "${options.service}" but it was not found` : `table("${tableName}") requires a database adapter`);
|
|
@@ -476,6 +573,13 @@ var SpecificationResult = class {
|
|
|
476
573
|
};
|
|
477
574
|
//#endregion
|
|
478
575
|
//#region src/builder/specification-builder.ts
|
|
576
|
+
/**
|
|
577
|
+
* Fluent builder for declaring a single test specification.
|
|
578
|
+
*
|
|
579
|
+
* Chain setup methods ({@link seed}, {@link fixture}, {@link env}), an action
|
|
580
|
+
* ({@link get}, {@link post}, {@link exec}), then call {@link run} to execute
|
|
581
|
+
* and receive a {@link SpecificationResult} for assertions.
|
|
582
|
+
*/
|
|
479
583
|
var SpecificationBuilder = class {
|
|
480
584
|
commandArgs = null;
|
|
481
585
|
commandEnv = {};
|
|
@@ -493,6 +597,12 @@ var SpecificationBuilder = class {
|
|
|
493
597
|
this.testDir = testDir;
|
|
494
598
|
this.label = label;
|
|
495
599
|
}
|
|
600
|
+
/**
|
|
601
|
+
* Queue a SQL seed file to run before the action.
|
|
602
|
+
*
|
|
603
|
+
* @example
|
|
604
|
+
* spec("creates user").seed("users.sql").exec("list-users").run();
|
|
605
|
+
*/
|
|
496
606
|
seed(file, options) {
|
|
497
607
|
this.seeds.push({
|
|
498
608
|
file,
|
|
@@ -500,14 +610,17 @@ var SpecificationBuilder = class {
|
|
|
500
610
|
});
|
|
501
611
|
return this;
|
|
502
612
|
}
|
|
613
|
+
/** Copy a file or directory from `fixtures/` into the working directory before execution. */
|
|
503
614
|
fixture(file) {
|
|
504
615
|
this.fixtures.push({ file });
|
|
505
616
|
return this;
|
|
506
617
|
}
|
|
618
|
+
/** Copy an entire project fixture from `fixturesRoot` into the working directory. */
|
|
507
619
|
project(name) {
|
|
508
620
|
this.projectName = name;
|
|
509
621
|
return this;
|
|
510
622
|
}
|
|
623
|
+
/** Register an MSW mock definition file from `mock/` to intercept HTTP calls. */
|
|
511
624
|
mock(file) {
|
|
512
625
|
this.mocks.push({ file });
|
|
513
626
|
return this;
|
|
@@ -529,6 +642,12 @@ var SpecificationBuilder = class {
|
|
|
529
642
|
};
|
|
530
643
|
return this;
|
|
531
644
|
}
|
|
645
|
+
/**
|
|
646
|
+
* Send a GET request to the server adapter.
|
|
647
|
+
*
|
|
648
|
+
* @example
|
|
649
|
+
* spec("list items").get("/api/items").run();
|
|
650
|
+
*/
|
|
532
651
|
get(path) {
|
|
533
652
|
this.request = {
|
|
534
653
|
method: "GET",
|
|
@@ -536,6 +655,13 @@ var SpecificationBuilder = class {
|
|
|
536
655
|
};
|
|
537
656
|
return this;
|
|
538
657
|
}
|
|
658
|
+
/**
|
|
659
|
+
* Send a POST request to the server adapter.
|
|
660
|
+
*
|
|
661
|
+
* @param bodyFile - Optional JSON file from `requests/` to use as the request body.
|
|
662
|
+
* @example
|
|
663
|
+
* spec("create item").post("/api/items", "new-item.json").run();
|
|
664
|
+
*/
|
|
539
665
|
post(path, bodyFile) {
|
|
540
666
|
this.request = {
|
|
541
667
|
bodyFile,
|
|
@@ -544,6 +670,7 @@ var SpecificationBuilder = class {
|
|
|
544
670
|
};
|
|
545
671
|
return this;
|
|
546
672
|
}
|
|
673
|
+
/** Send a PUT request to the server adapter. */
|
|
547
674
|
put(path, bodyFile) {
|
|
548
675
|
this.request = {
|
|
549
676
|
bodyFile,
|
|
@@ -552,6 +679,7 @@ var SpecificationBuilder = class {
|
|
|
552
679
|
};
|
|
553
680
|
return this;
|
|
554
681
|
}
|
|
682
|
+
/** Send a DELETE request to the server adapter. */
|
|
555
683
|
delete(path) {
|
|
556
684
|
this.request = {
|
|
557
685
|
method: "DELETE",
|
|
@@ -559,10 +687,19 @@ var SpecificationBuilder = class {
|
|
|
559
687
|
};
|
|
560
688
|
return this;
|
|
561
689
|
}
|
|
690
|
+
/**
|
|
691
|
+
* Execute a CLI command (or a sequence of commands) in an isolated working directory.
|
|
692
|
+
* When an array is passed, commands run sequentially and stop on the first non-zero exit code.
|
|
693
|
+
*
|
|
694
|
+
* @example
|
|
695
|
+
* spec("init project").exec("init --name demo").run();
|
|
696
|
+
* spec("multi-step").exec(["init", "build"]).run();
|
|
697
|
+
*/
|
|
562
698
|
exec(args) {
|
|
563
699
|
this.commandArgs = args;
|
|
564
700
|
return this;
|
|
565
701
|
}
|
|
702
|
+
/** Spawn a long-running CLI process with custom spawn options (e.g. timeout, signal). */
|
|
566
703
|
spawn(args, options) {
|
|
567
704
|
this.spawnConfig = {
|
|
568
705
|
args,
|
|
@@ -570,6 +707,15 @@ var SpecificationBuilder = class {
|
|
|
570
707
|
};
|
|
571
708
|
return this;
|
|
572
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Execute the specification: run seeds, copy fixtures, then perform the
|
|
712
|
+
* configured action (HTTP or CLI).
|
|
713
|
+
*
|
|
714
|
+
* @returns The result object used for assertions.
|
|
715
|
+
* @example
|
|
716
|
+
* const result = await spec("test").exec("status").run();
|
|
717
|
+
* expect(result.exitCode).toBe(0);
|
|
718
|
+
*/
|
|
573
719
|
async run() {
|
|
574
720
|
const hasHttpAction = this.request !== null;
|
|
575
721
|
const hasCliAction = this.commandArgs !== null || this.spawnConfig !== null;
|
|
@@ -667,197 +813,241 @@ function getCallerDir() {
|
|
|
667
813
|
}
|
|
668
814
|
throw new Error("Cannot detect caller directory from stack trace");
|
|
669
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* Create a {@link SpecificationRunner} bound to the given adapter configuration.
|
|
818
|
+
* The test file directory is auto-detected from the call stack.
|
|
819
|
+
*/
|
|
670
820
|
function createSpecificationRunner(config) {
|
|
671
821
|
return (label) => {
|
|
672
822
|
return new SpecificationBuilder(config, getCallerDir(), label);
|
|
673
823
|
};
|
|
674
824
|
}
|
|
675
825
|
//#endregion
|
|
676
|
-
//#region src/
|
|
826
|
+
//#region src/docker/docker-adapter.ts
|
|
677
827
|
/**
|
|
678
|
-
*
|
|
828
|
+
* Implements {@link DockerContainerPort} by shelling out to the Docker CLI.
|
|
829
|
+
* Each instance is bound to a single container ID.
|
|
679
830
|
*/
|
|
680
|
-
var
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
this.composeFile = composeFile;
|
|
685
|
-
}
|
|
686
|
-
run(command) {
|
|
687
|
-
try {
|
|
688
|
-
return (0, node_child_process.execSync)(command, {
|
|
689
|
-
cwd: (0, node_path.dirname)(this.composeFile),
|
|
690
|
-
encoding: "utf8",
|
|
691
|
-
timeout: 12e4
|
|
692
|
-
}).trim();
|
|
693
|
-
} catch (error) {
|
|
694
|
-
const stderr = error.stderr?.toString().trim() ?? error.message;
|
|
695
|
-
throw new Error(`docker compose failed: ${stderr}`, { cause: error });
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
async start() {
|
|
699
|
-
if (this.started) return;
|
|
700
|
-
this.run(`docker compose -f ${this.composeFile} up -d --wait`);
|
|
701
|
-
this.started = true;
|
|
702
|
-
}
|
|
703
|
-
async stop() {
|
|
704
|
-
if (!this.started) return;
|
|
705
|
-
this.run(`docker compose -f ${this.composeFile} down -v`);
|
|
706
|
-
this.started = false;
|
|
707
|
-
}
|
|
708
|
-
getMappedPort(serviceName, containerPort) {
|
|
709
|
-
const port = this.run(`docker compose -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
|
|
710
|
-
return Number(port);
|
|
711
|
-
}
|
|
712
|
-
getHost() {
|
|
713
|
-
return "localhost";
|
|
714
|
-
}
|
|
715
|
-
};
|
|
716
|
-
//#endregion
|
|
717
|
-
//#region src/adapters/postgres.adapter.ts
|
|
718
|
-
var PostgresHandle = class {
|
|
719
|
-
type = "postgres";
|
|
720
|
-
composeName;
|
|
721
|
-
defaultPort = 5432;
|
|
722
|
-
defaultImage;
|
|
723
|
-
environment;
|
|
724
|
-
connectionString = "";
|
|
725
|
-
started = false;
|
|
726
|
-
client = null;
|
|
727
|
-
constructor(options = {}) {
|
|
728
|
-
this.composeName = options.compose ?? null;
|
|
729
|
-
this.defaultImage = options.image ?? "postgres:17";
|
|
730
|
-
this.environment = {
|
|
731
|
-
POSTGRES_DB: "test",
|
|
732
|
-
POSTGRES_PASSWORD: "test",
|
|
733
|
-
POSTGRES_USER: "test",
|
|
734
|
-
...options.env
|
|
735
|
-
};
|
|
736
|
-
}
|
|
737
|
-
buildConnectionString(host, port) {
|
|
738
|
-
return `postgresql://${this.environment.POSTGRES_USER ?? "test"}:${this.environment.POSTGRES_PASSWORD ?? "test"}@${host}:${port}/${this.environment.POSTGRES_DB ?? "test"}`;
|
|
831
|
+
var DockerAdapter = class {
|
|
832
|
+
containerId;
|
|
833
|
+
constructor(containerId) {
|
|
834
|
+
this.containerId = containerId;
|
|
739
835
|
}
|
|
740
|
-
|
|
741
|
-
return this
|
|
836
|
+
async exec(cmd) {
|
|
837
|
+
return (0, node_child_process.execSync)(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
|
|
838
|
+
encoding: "utf8",
|
|
839
|
+
timeout: 1e4
|
|
840
|
+
}).trim();
|
|
742
841
|
}
|
|
743
|
-
async
|
|
744
|
-
if (!this.connectionString) throw new Error("postgres: cannot healthcheck — no connection string");
|
|
842
|
+
async file(path) {
|
|
745
843
|
try {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
} catch
|
|
751
|
-
|
|
844
|
+
return {
|
|
845
|
+
exists: true,
|
|
846
|
+
content: await this.exec(["cat", path])
|
|
847
|
+
};
|
|
848
|
+
} catch {
|
|
849
|
+
return {
|
|
850
|
+
exists: false,
|
|
851
|
+
content: ""
|
|
852
|
+
};
|
|
752
853
|
}
|
|
753
854
|
}
|
|
754
|
-
async
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
throw new Error(`postgres init script failed (${initPath}):\n${error.message}`, { cause: error });
|
|
763
|
-
}
|
|
764
|
-
return;
|
|
855
|
+
async isRunning() {
|
|
856
|
+
try {
|
|
857
|
+
return (0, node_child_process.execSync)(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
|
|
858
|
+
encoding: "utf8",
|
|
859
|
+
timeout: 5e3
|
|
860
|
+
}).trim() === "true";
|
|
861
|
+
} catch {
|
|
862
|
+
return false;
|
|
765
863
|
}
|
|
766
864
|
}
|
|
767
|
-
async
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
this.client = null;
|
|
865
|
+
async logs(tail) {
|
|
866
|
+
return (0, node_child_process.execSync)(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
|
|
867
|
+
encoding: "utf8",
|
|
868
|
+
timeout: 1e4
|
|
772
869
|
});
|
|
773
|
-
await client.connect();
|
|
774
|
-
this.client = client;
|
|
775
|
-
return client;
|
|
776
|
-
}
|
|
777
|
-
async seed(sql) {
|
|
778
|
-
await (await this.getClient()).query(sql);
|
|
779
|
-
}
|
|
780
|
-
async query(table, columns) {
|
|
781
|
-
const client = await this.getClient();
|
|
782
|
-
const columnList = columns.join(", ");
|
|
783
|
-
return (await client.query(`SELECT ${columnList} FROM "${table}" ORDER BY 1`)).rows.map((row) => columns.map((col) => row[col]));
|
|
784
|
-
}
|
|
785
|
-
async reset() {
|
|
786
|
-
const client = await this.getClient();
|
|
787
|
-
const result = await client.query(`
|
|
788
|
-
SELECT tablename FROM pg_tables
|
|
789
|
-
WHERE schemaname = 'public'
|
|
790
|
-
AND tablename NOT LIKE '_prisma%'
|
|
791
|
-
`);
|
|
792
|
-
for (const row of result.rows) await client.query(`TRUNCATE "${row.tablename}" CASCADE`);
|
|
793
870
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
try {
|
|
828
|
-
const { createClient } = await import("redis");
|
|
829
|
-
const client = createClient({ url: this.connectionString });
|
|
830
|
-
await client.connect();
|
|
831
|
-
await client.ping();
|
|
832
|
-
await client.disconnect();
|
|
833
|
-
} catch (error) {
|
|
834
|
-
throw new Error(`redis healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
|
|
835
|
-
}
|
|
871
|
+
async inspect() {
|
|
872
|
+
const raw = (0, node_child_process.execSync)(`docker inspect ${this.containerId}`, {
|
|
873
|
+
encoding: "utf8",
|
|
874
|
+
timeout: 5e3
|
|
875
|
+
});
|
|
876
|
+
const data = JSON.parse(raw)[0];
|
|
877
|
+
return {
|
|
878
|
+
id: data.Id,
|
|
879
|
+
name: data.Name,
|
|
880
|
+
state: {
|
|
881
|
+
running: data.State.Running,
|
|
882
|
+
exitCode: data.State.ExitCode,
|
|
883
|
+
status: data.State.Status
|
|
884
|
+
},
|
|
885
|
+
config: {
|
|
886
|
+
image: data.Config.Image,
|
|
887
|
+
env: data.Config.Env || []
|
|
888
|
+
},
|
|
889
|
+
hostConfig: {
|
|
890
|
+
memory: data.HostConfig.Memory || 0,
|
|
891
|
+
cpuQuota: data.HostConfig.CpuQuota || 0,
|
|
892
|
+
networkMode: data.HostConfig.NetworkMode || "",
|
|
893
|
+
mounts: (data.Mounts || []).map((m) => ({
|
|
894
|
+
source: m.Source,
|
|
895
|
+
destination: m.Destination,
|
|
896
|
+
type: m.Type
|
|
897
|
+
}))
|
|
898
|
+
},
|
|
899
|
+
networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
|
|
900
|
+
gateway: net.Gateway,
|
|
901
|
+
ipAddress: net.IPAddress
|
|
902
|
+
}])) }
|
|
903
|
+
};
|
|
836
904
|
}
|
|
837
|
-
async
|
|
838
|
-
async reset() {
|
|
839
|
-
const { createClient } = await import("redis");
|
|
840
|
-
const client = createClient({ url: this.connectionString });
|
|
841
|
-
await client.connect();
|
|
905
|
+
async exists(path) {
|
|
842
906
|
try {
|
|
843
|
-
await
|
|
844
|
-
|
|
845
|
-
|
|
907
|
+
await this.exec([
|
|
908
|
+
"test",
|
|
909
|
+
"-e",
|
|
910
|
+
path
|
|
911
|
+
]);
|
|
912
|
+
return true;
|
|
913
|
+
} catch {
|
|
914
|
+
return false;
|
|
846
915
|
}
|
|
847
916
|
}
|
|
848
917
|
};
|
|
849
|
-
/**
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
* @example
|
|
853
|
-
* const cache = redis({ compose: "cache" });
|
|
854
|
-
* // After start: cache.connectionString is populated
|
|
855
|
-
*/
|
|
856
|
-
function redis(options = {}) {
|
|
857
|
-
return new RedisHandle(options);
|
|
918
|
+
/** Create a {@link DockerContainerPort} bound to an existing container by ID. */
|
|
919
|
+
function dockerContainer(containerId) {
|
|
920
|
+
return new DockerAdapter(containerId);
|
|
858
921
|
}
|
|
859
922
|
//#endregion
|
|
860
|
-
//#region src/
|
|
923
|
+
//#region src/docker/docker-assertion.ts
|
|
924
|
+
/**
|
|
925
|
+
* Fluent assertion builder for Docker containers.
|
|
926
|
+
* Chain async assertions like `toBeRunning()`, `toHaveFile()`, `toHaveMount()`.
|
|
927
|
+
*/
|
|
928
|
+
var DockerAssertion = class {
|
|
929
|
+
container;
|
|
930
|
+
constructor(container) {
|
|
931
|
+
this.container = container;
|
|
932
|
+
}
|
|
933
|
+
/** Assert the container is running */
|
|
934
|
+
async toBeRunning() {
|
|
935
|
+
if (!await this.container.isRunning()) throw new Error("Expected container to be running");
|
|
936
|
+
return this;
|
|
937
|
+
}
|
|
938
|
+
/** Assert the container is NOT running / doesn't exist */
|
|
939
|
+
async toNotExist() {
|
|
940
|
+
if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
|
|
941
|
+
return this;
|
|
942
|
+
}
|
|
943
|
+
/** Assert a file exists inside the container */
|
|
944
|
+
async toHaveFile(path, opts) {
|
|
945
|
+
const file = await this.container.file(path);
|
|
946
|
+
if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
|
|
947
|
+
if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
|
|
948
|
+
return this;
|
|
949
|
+
}
|
|
950
|
+
/** Assert a file does NOT exist */
|
|
951
|
+
async toNotHaveFile(path) {
|
|
952
|
+
if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
|
|
953
|
+
return this;
|
|
954
|
+
}
|
|
955
|
+
/** Assert a directory exists */
|
|
956
|
+
async toHaveDirectory(path) {
|
|
957
|
+
if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
|
|
958
|
+
return this;
|
|
959
|
+
}
|
|
960
|
+
/** Assert a mount exists */
|
|
961
|
+
async toHaveMount(destination) {
|
|
962
|
+
const info = await this.container.inspect();
|
|
963
|
+
if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
|
|
964
|
+
const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
|
|
965
|
+
throw new Error(`Expected mount at ${destination}, found: [${available}]`);
|
|
966
|
+
}
|
|
967
|
+
return this;
|
|
968
|
+
}
|
|
969
|
+
/** Assert network mode */
|
|
970
|
+
async toHaveNetwork(mode) {
|
|
971
|
+
const info = await this.container.inspect();
|
|
972
|
+
if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
|
|
973
|
+
return this;
|
|
974
|
+
}
|
|
975
|
+
/** Assert memory limit */
|
|
976
|
+
async toHaveMemoryLimit(bytes) {
|
|
977
|
+
const info = await this.container.inspect();
|
|
978
|
+
if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
|
|
979
|
+
return this;
|
|
980
|
+
}
|
|
981
|
+
/** Assert CPU quota */
|
|
982
|
+
async toHaveCpuQuota(quota) {
|
|
983
|
+
const info = await this.container.inspect();
|
|
984
|
+
if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
|
|
985
|
+
return this;
|
|
986
|
+
}
|
|
987
|
+
/** Execute a command and return output for custom assertions */
|
|
988
|
+
async exec(cmd) {
|
|
989
|
+
return this.container.exec(cmd);
|
|
990
|
+
}
|
|
991
|
+
/** Read a file for custom assertions */
|
|
992
|
+
async readFile(path) {
|
|
993
|
+
const file = await this.container.file(path);
|
|
994
|
+
if (!file.exists) throw new Error(`File ${path} does not exist`);
|
|
995
|
+
return file.content;
|
|
996
|
+
}
|
|
997
|
+
/** Get logs for custom assertions */
|
|
998
|
+
async getLogs(tail) {
|
|
999
|
+
return this.container.logs(tail);
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
//#endregion
|
|
1003
|
+
//#region src/adapters/compose.adapter.ts
|
|
1004
|
+
/**
|
|
1005
|
+
* Start the full compose stack and stop it all on cleanup.
|
|
1006
|
+
* Supports per-worker project names for parallel execution.
|
|
1007
|
+
*/
|
|
1008
|
+
var ComposeStackAdapter = class {
|
|
1009
|
+
composeFile;
|
|
1010
|
+
projectName;
|
|
1011
|
+
started = false;
|
|
1012
|
+
constructor(composeFile, projectName) {
|
|
1013
|
+
this.composeFile = composeFile;
|
|
1014
|
+
this.projectName = projectName ?? null;
|
|
1015
|
+
}
|
|
1016
|
+
get projectFlag() {
|
|
1017
|
+
return this.projectName ? `-p ${this.projectName}` : "";
|
|
1018
|
+
}
|
|
1019
|
+
run(command) {
|
|
1020
|
+
try {
|
|
1021
|
+
return (0, node_child_process.execSync)(command, {
|
|
1022
|
+
cwd: (0, node_path.dirname)(this.composeFile),
|
|
1023
|
+
encoding: "utf8",
|
|
1024
|
+
timeout: 12e4
|
|
1025
|
+
}).trim();
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
const stderr = error.stderr?.toString().trim() ?? error.message;
|
|
1028
|
+
throw new Error(`docker compose failed: ${stderr}`, { cause: error });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
async start() {
|
|
1032
|
+
if (this.started) return;
|
|
1033
|
+
this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} up -d --wait`);
|
|
1034
|
+
this.started = true;
|
|
1035
|
+
}
|
|
1036
|
+
async stop() {
|
|
1037
|
+
if (!this.started) return;
|
|
1038
|
+
this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
|
|
1039
|
+
this.started = false;
|
|
1040
|
+
}
|
|
1041
|
+
getMappedPort(serviceName, containerPort) {
|
|
1042
|
+
const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
|
|
1043
|
+
return Number(port);
|
|
1044
|
+
}
|
|
1045
|
+
getHost() {
|
|
1046
|
+
return "localhost";
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
//#endregion
|
|
1050
|
+
//#region src/adapters/testcontainers.adapter.ts
|
|
861
1051
|
/**
|
|
862
1052
|
* Container adapter using testcontainers.
|
|
863
1053
|
* Wraps a GenericContainer for programmatic container lifecycle.
|
|
@@ -1000,6 +1190,7 @@ var Orchestrator = class {
|
|
|
1000
1190
|
services;
|
|
1001
1191
|
mode;
|
|
1002
1192
|
root;
|
|
1193
|
+
projectName;
|
|
1003
1194
|
running = [];
|
|
1004
1195
|
composeStack = null;
|
|
1005
1196
|
composeHandles = [];
|
|
@@ -1008,6 +1199,7 @@ var Orchestrator = class {
|
|
|
1008
1199
|
this.services = options.services;
|
|
1009
1200
|
this.mode = options.mode;
|
|
1010
1201
|
this.root = options.root ?? process.cwd();
|
|
1202
|
+
this.projectName = options.projectName;
|
|
1011
1203
|
}
|
|
1012
1204
|
/**
|
|
1013
1205
|
* Start declared services via testcontainers (integration mode).
|
|
@@ -1105,12 +1297,12 @@ var Orchestrator = class {
|
|
|
1105
1297
|
const startTime = Date.now();
|
|
1106
1298
|
const composeDir = (0, node_path.dirname)(composePath);
|
|
1107
1299
|
const composeConfig = parseComposeFile(composePath);
|
|
1108
|
-
this.composeStack = new ComposeStackAdapter(composePath);
|
|
1300
|
+
this.composeStack = new ComposeStackAdapter(composePath, this.projectName);
|
|
1109
1301
|
await this.composeStack.start();
|
|
1110
1302
|
for (const service of composeConfig.infraServices) {
|
|
1111
1303
|
const type = detectServiceType(service.image);
|
|
1112
1304
|
if (type === "postgres") {
|
|
1113
|
-
const handle = postgres({
|
|
1305
|
+
const handle = require_redis_adapter.postgres({
|
|
1114
1306
|
compose: service.name,
|
|
1115
1307
|
env: service.environment
|
|
1116
1308
|
});
|
|
@@ -1120,7 +1312,7 @@ var Orchestrator = class {
|
|
|
1120
1312
|
handle.started = true;
|
|
1121
1313
|
this.composeHandles.push(handle);
|
|
1122
1314
|
} else if (type === "redis") {
|
|
1123
|
-
const handle = redis({ compose: service.name });
|
|
1315
|
+
const handle = require_redis_adapter.redis({ compose: service.name });
|
|
1124
1316
|
const port = this.composeStack.getMappedPort(service.name, 6379);
|
|
1125
1317
|
handle.connectionString = handle.buildConnectionString("localhost", port);
|
|
1126
1318
|
handle.started = true;
|
|
@@ -1215,6 +1407,169 @@ function resolveCommand(command, root) {
|
|
|
1215
1407
|
return command;
|
|
1216
1408
|
}
|
|
1217
1409
|
//#endregion
|
|
1410
|
+
//#region src/runner/spec.ts
|
|
1411
|
+
/**
|
|
1412
|
+
* Create a specification runner for the given target.
|
|
1413
|
+
*
|
|
1414
|
+
* @param target - What to test against: {@link app}, {@link stack}, or {@link command}.
|
|
1415
|
+
* @param options - Shared options: root directory, infrastructure services.
|
|
1416
|
+
*
|
|
1417
|
+
* @example
|
|
1418
|
+
* // HTTP — in-process app with testcontainers
|
|
1419
|
+
* const s = await spec(app(() => createApp(db)), { services: [db] });
|
|
1420
|
+
*
|
|
1421
|
+
* // HTTP — full docker compose stack
|
|
1422
|
+
* const s = await spec(stack('../../'));
|
|
1423
|
+
*
|
|
1424
|
+
* // CLI — command binary
|
|
1425
|
+
* const s = await spec(command('my-cli'), { root: '../fixtures' });
|
|
1426
|
+
*/
|
|
1427
|
+
async function spec(target, options = {}) {
|
|
1428
|
+
switch (target.kind) {
|
|
1429
|
+
case "app": return startApp(target, options);
|
|
1430
|
+
case "stack": return startStack(target, options);
|
|
1431
|
+
case "command": return startCommand(target, options);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
function getWorkerId() {
|
|
1435
|
+
return process.env.VITEST_POOL_ID ?? "0";
|
|
1436
|
+
}
|
|
1437
|
+
async function acquireIsolation(services) {
|
|
1438
|
+
const workerId = getWorkerId();
|
|
1439
|
+
for (const service of services) await service.isolation().acquire(workerId);
|
|
1440
|
+
}
|
|
1441
|
+
async function releaseIsolation(services) {
|
|
1442
|
+
for (const service of services) await service.isolation().release();
|
|
1443
|
+
}
|
|
1444
|
+
async function startApp(target, options) {
|
|
1445
|
+
const services = options.services ?? [];
|
|
1446
|
+
const orchestrator = new Orchestrator({
|
|
1447
|
+
mode: "integration",
|
|
1448
|
+
root: resolveProjectRoot(options.root),
|
|
1449
|
+
services
|
|
1450
|
+
});
|
|
1451
|
+
await orchestrator.start();
|
|
1452
|
+
await acquireIsolation(services);
|
|
1453
|
+
const honoApp = target.factory();
|
|
1454
|
+
const database = orchestrator.getDatabase() ?? void 0;
|
|
1455
|
+
const databases = orchestrator.getDatabases();
|
|
1456
|
+
const runner = createSpecificationRunner({
|
|
1457
|
+
database,
|
|
1458
|
+
databases: databases.size > 0 ? databases : void 0,
|
|
1459
|
+
server: new HonoAdapter(honoApp)
|
|
1460
|
+
});
|
|
1461
|
+
runner.cleanup = async () => {
|
|
1462
|
+
await releaseIsolation(services);
|
|
1463
|
+
await orchestrator.stop();
|
|
1464
|
+
};
|
|
1465
|
+
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1466
|
+
runner.orchestrator = orchestrator;
|
|
1467
|
+
return runner;
|
|
1468
|
+
}
|
|
1469
|
+
async function startStack(target, options) {
|
|
1470
|
+
const root = resolveProjectRoot(target.root ?? options.root);
|
|
1471
|
+
const projectName = `test-worker-${getWorkerId()}`;
|
|
1472
|
+
const orchestrator = new Orchestrator({
|
|
1473
|
+
mode: "e2e",
|
|
1474
|
+
root,
|
|
1475
|
+
services: options.services ?? [],
|
|
1476
|
+
projectName
|
|
1477
|
+
});
|
|
1478
|
+
await orchestrator.startCompose();
|
|
1479
|
+
const appUrl = orchestrator.getAppUrl();
|
|
1480
|
+
if (!appUrl) throw new Error("stack(): could not detect app URL from compose. Ensure an app service with ports is defined.");
|
|
1481
|
+
const database = orchestrator.getDatabase() ?? void 0;
|
|
1482
|
+
const databases = orchestrator.getDatabases();
|
|
1483
|
+
const runner = createSpecificationRunner({
|
|
1484
|
+
database,
|
|
1485
|
+
databases: databases.size > 0 ? databases : void 0,
|
|
1486
|
+
server: new FetchAdapter(appUrl)
|
|
1487
|
+
});
|
|
1488
|
+
runner.cleanup = () => orchestrator.stopCompose();
|
|
1489
|
+
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1490
|
+
runner.orchestrator = orchestrator;
|
|
1491
|
+
return runner;
|
|
1492
|
+
}
|
|
1493
|
+
async function startCommand(target, options) {
|
|
1494
|
+
const root = resolveProjectRoot(options.root);
|
|
1495
|
+
const bin = resolveCommand(target.bin, root);
|
|
1496
|
+
const services = options.services ?? [];
|
|
1497
|
+
let orchestrator = null;
|
|
1498
|
+
let database;
|
|
1499
|
+
let databases;
|
|
1500
|
+
if (services.length) {
|
|
1501
|
+
orchestrator = new Orchestrator({
|
|
1502
|
+
mode: "integration",
|
|
1503
|
+
root,
|
|
1504
|
+
services
|
|
1505
|
+
});
|
|
1506
|
+
await orchestrator.start();
|
|
1507
|
+
await acquireIsolation(services);
|
|
1508
|
+
database = orchestrator.getDatabase() ?? void 0;
|
|
1509
|
+
const dbMap = orchestrator.getDatabases();
|
|
1510
|
+
databases = dbMap.size > 0 ? dbMap : void 0;
|
|
1511
|
+
}
|
|
1512
|
+
const runner = createSpecificationRunner({
|
|
1513
|
+
command: new ExecAdapter(bin),
|
|
1514
|
+
database,
|
|
1515
|
+
databases,
|
|
1516
|
+
fixturesRoot: root
|
|
1517
|
+
});
|
|
1518
|
+
runner.cleanup = async () => {
|
|
1519
|
+
await releaseIsolation(services);
|
|
1520
|
+
if (orchestrator) await orchestrator.stop();
|
|
1521
|
+
};
|
|
1522
|
+
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1523
|
+
runner.orchestrator = orchestrator;
|
|
1524
|
+
return runner;
|
|
1525
|
+
}
|
|
1526
|
+
//#endregion
|
|
1527
|
+
//#region src/runner/targets.ts
|
|
1528
|
+
/**
|
|
1529
|
+
* Test against an in-process Hono app. Services are started via testcontainers
|
|
1530
|
+
* and the app runs without network overhead.
|
|
1531
|
+
*
|
|
1532
|
+
* @param factory - Function that returns a Hono app instance (called after services start).
|
|
1533
|
+
*
|
|
1534
|
+
* @example
|
|
1535
|
+
* await spec(app(() => createApp(db)), { services: [db] });
|
|
1536
|
+
*/
|
|
1537
|
+
function app(factory) {
|
|
1538
|
+
return {
|
|
1539
|
+
kind: "app",
|
|
1540
|
+
factory
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Test against a full docker compose stack. The stack is started with
|
|
1545
|
+
* `docker compose up` and real HTTP requests are sent to the app service.
|
|
1546
|
+
*
|
|
1547
|
+
* @param root - Project root containing `docker/compose.test.yaml`.
|
|
1548
|
+
*
|
|
1549
|
+
* @example
|
|
1550
|
+
* await spec(stack('../../'));
|
|
1551
|
+
*/
|
|
1552
|
+
function stack(root) {
|
|
1553
|
+
return {
|
|
1554
|
+
kind: "stack",
|
|
1555
|
+
root
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Test a CLI binary. Each spec runs in a fresh temp directory.
|
|
1560
|
+
*
|
|
1561
|
+
* @param bin - Path to the CLI binary or command name (resolved from node_modules/.bin or PATH).
|
|
1562
|
+
*
|
|
1563
|
+
* @example
|
|
1564
|
+
* await spec(command('my-cli'), { root: '../fixtures' });
|
|
1565
|
+
*/
|
|
1566
|
+
function command(bin) {
|
|
1567
|
+
return {
|
|
1568
|
+
kind: "command",
|
|
1569
|
+
bin
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
//#endregion
|
|
1218
1573
|
//#region src/runner/cli.ts
|
|
1219
1574
|
/**
|
|
1220
1575
|
* Create a CLI specification runner.
|
|
@@ -1250,36 +1605,6 @@ async function cli(options) {
|
|
|
1250
1605
|
return runner;
|
|
1251
1606
|
}
|
|
1252
1607
|
//#endregion
|
|
1253
|
-
//#region src/adapters/fetch.adapter.ts
|
|
1254
|
-
/**
|
|
1255
|
-
* Server adapter for real HTTP — sends actual fetch requests.
|
|
1256
|
-
* Used by e2e() specification runner.
|
|
1257
|
-
*/
|
|
1258
|
-
var FetchAdapter = class {
|
|
1259
|
-
baseUrl;
|
|
1260
|
-
constructor(url) {
|
|
1261
|
-
this.baseUrl = url.replace(/\/$/, "");
|
|
1262
|
-
}
|
|
1263
|
-
async request(method, path, body) {
|
|
1264
|
-
const init = {
|
|
1265
|
-
method,
|
|
1266
|
-
headers: { "Content-Type": "application/json" }
|
|
1267
|
-
};
|
|
1268
|
-
if (body !== void 0) init.body = JSON.stringify(body);
|
|
1269
|
-
const response = await fetch(`${this.baseUrl}${path}`, init);
|
|
1270
|
-
const responseBody = await response.json().catch(() => null);
|
|
1271
|
-
const headers = {};
|
|
1272
|
-
response.headers.forEach((value, key) => {
|
|
1273
|
-
headers[key] = value;
|
|
1274
|
-
});
|
|
1275
|
-
return {
|
|
1276
|
-
status: response.status,
|
|
1277
|
-
body: responseBody,
|
|
1278
|
-
headers
|
|
1279
|
-
};
|
|
1280
|
-
}
|
|
1281
|
-
};
|
|
1282
|
-
//#endregion
|
|
1283
1608
|
//#region src/runner/e2e.ts
|
|
1284
1609
|
/**
|
|
1285
1610
|
* Create an E2E specification runner.
|
|
@@ -1306,36 +1631,6 @@ async function e2e(options = {}) {
|
|
|
1306
1631
|
return runner;
|
|
1307
1632
|
}
|
|
1308
1633
|
//#endregion
|
|
1309
|
-
//#region src/adapters/hono.adapter.ts
|
|
1310
|
-
/**
|
|
1311
|
-
* Server adapter for Hono — in-process requests, no real HTTP.
|
|
1312
|
-
* Used by integration() specification runner.
|
|
1313
|
-
*/
|
|
1314
|
-
var HonoAdapter = class {
|
|
1315
|
-
app;
|
|
1316
|
-
constructor(app) {
|
|
1317
|
-
this.app = app;
|
|
1318
|
-
}
|
|
1319
|
-
async request(method, path, body) {
|
|
1320
|
-
const init = {
|
|
1321
|
-
method,
|
|
1322
|
-
headers: { "Content-Type": "application/json" }
|
|
1323
|
-
};
|
|
1324
|
-
if (body !== void 0) init.body = JSON.stringify(body);
|
|
1325
|
-
const response = await this.app.request(path, init);
|
|
1326
|
-
const responseBody = await response.json().catch(() => null);
|
|
1327
|
-
const headers = {};
|
|
1328
|
-
response.headers.forEach((value, key) => {
|
|
1329
|
-
headers[key] = value;
|
|
1330
|
-
});
|
|
1331
|
-
return {
|
|
1332
|
-
status: response.status,
|
|
1333
|
-
body: responseBody,
|
|
1334
|
-
headers
|
|
1335
|
-
};
|
|
1336
|
-
}
|
|
1337
|
-
};
|
|
1338
|
-
//#endregion
|
|
1339
1634
|
//#region src/runner/integration.ts
|
|
1340
1635
|
/**
|
|
1341
1636
|
* Create an integration specification runner.
|
|
@@ -1361,196 +1656,6 @@ async function integration(options) {
|
|
|
1361
1656
|
return runner;
|
|
1362
1657
|
}
|
|
1363
1658
|
//#endregion
|
|
1364
|
-
//#region src/docker/docker-adapter.ts
|
|
1365
|
-
var DockerAdapter = class {
|
|
1366
|
-
containerId;
|
|
1367
|
-
constructor(containerId) {
|
|
1368
|
-
this.containerId = containerId;
|
|
1369
|
-
}
|
|
1370
|
-
async exec(cmd) {
|
|
1371
|
-
return (0, node_child_process.execSync)(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
|
|
1372
|
-
encoding: "utf8",
|
|
1373
|
-
timeout: 1e4
|
|
1374
|
-
}).trim();
|
|
1375
|
-
}
|
|
1376
|
-
async file(path) {
|
|
1377
|
-
try {
|
|
1378
|
-
return {
|
|
1379
|
-
exists: true,
|
|
1380
|
-
content: await this.exec(["cat", path])
|
|
1381
|
-
};
|
|
1382
|
-
} catch {
|
|
1383
|
-
return {
|
|
1384
|
-
exists: false,
|
|
1385
|
-
content: ""
|
|
1386
|
-
};
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
async isRunning() {
|
|
1390
|
-
try {
|
|
1391
|
-
return (0, node_child_process.execSync)(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
|
|
1392
|
-
encoding: "utf8",
|
|
1393
|
-
timeout: 5e3
|
|
1394
|
-
}).trim() === "true";
|
|
1395
|
-
} catch {
|
|
1396
|
-
return false;
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
async logs(tail) {
|
|
1400
|
-
return (0, node_child_process.execSync)(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
|
|
1401
|
-
encoding: "utf8",
|
|
1402
|
-
timeout: 1e4
|
|
1403
|
-
});
|
|
1404
|
-
}
|
|
1405
|
-
async inspect() {
|
|
1406
|
-
const raw = (0, node_child_process.execSync)(`docker inspect ${this.containerId}`, {
|
|
1407
|
-
encoding: "utf8",
|
|
1408
|
-
timeout: 5e3
|
|
1409
|
-
});
|
|
1410
|
-
const data = JSON.parse(raw)[0];
|
|
1411
|
-
return {
|
|
1412
|
-
id: data.Id,
|
|
1413
|
-
name: data.Name,
|
|
1414
|
-
state: {
|
|
1415
|
-
running: data.State.Running,
|
|
1416
|
-
exitCode: data.State.ExitCode,
|
|
1417
|
-
status: data.State.Status
|
|
1418
|
-
},
|
|
1419
|
-
config: {
|
|
1420
|
-
image: data.Config.Image,
|
|
1421
|
-
env: data.Config.Env || []
|
|
1422
|
-
},
|
|
1423
|
-
hostConfig: {
|
|
1424
|
-
memory: data.HostConfig.Memory || 0,
|
|
1425
|
-
cpuQuota: data.HostConfig.CpuQuota || 0,
|
|
1426
|
-
networkMode: data.HostConfig.NetworkMode || "",
|
|
1427
|
-
mounts: (data.Mounts || []).map((m) => ({
|
|
1428
|
-
source: m.Source,
|
|
1429
|
-
destination: m.Destination,
|
|
1430
|
-
type: m.Type
|
|
1431
|
-
}))
|
|
1432
|
-
},
|
|
1433
|
-
networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
|
|
1434
|
-
gateway: net.Gateway,
|
|
1435
|
-
ipAddress: net.IPAddress
|
|
1436
|
-
}])) }
|
|
1437
|
-
};
|
|
1438
|
-
}
|
|
1439
|
-
async exists(path) {
|
|
1440
|
-
try {
|
|
1441
|
-
await this.exec([
|
|
1442
|
-
"test",
|
|
1443
|
-
"-e",
|
|
1444
|
-
path
|
|
1445
|
-
]);
|
|
1446
|
-
return true;
|
|
1447
|
-
} catch {
|
|
1448
|
-
return false;
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
};
|
|
1452
|
-
/** Create a Docker container port for an existing container */
|
|
1453
|
-
function dockerContainer(containerId) {
|
|
1454
|
-
return new DockerAdapter(containerId);
|
|
1455
|
-
}
|
|
1456
|
-
//#endregion
|
|
1457
|
-
//#region src/docker/docker-assertion.ts
|
|
1458
|
-
/** Fluent assertion builder for Docker containers */
|
|
1459
|
-
var DockerAssertion = class {
|
|
1460
|
-
container;
|
|
1461
|
-
constructor(container) {
|
|
1462
|
-
this.container = container;
|
|
1463
|
-
}
|
|
1464
|
-
/** Assert the container is running */
|
|
1465
|
-
async toBeRunning() {
|
|
1466
|
-
if (!await this.container.isRunning()) throw new Error("Expected container to be running");
|
|
1467
|
-
return this;
|
|
1468
|
-
}
|
|
1469
|
-
/** Assert the container is NOT running / doesn't exist */
|
|
1470
|
-
async toNotExist() {
|
|
1471
|
-
if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
|
|
1472
|
-
return this;
|
|
1473
|
-
}
|
|
1474
|
-
/** Assert a file exists inside the container */
|
|
1475
|
-
async toHaveFile(path, opts) {
|
|
1476
|
-
const file = await this.container.file(path);
|
|
1477
|
-
if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
|
|
1478
|
-
if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
|
|
1479
|
-
return this;
|
|
1480
|
-
}
|
|
1481
|
-
/** Assert a file does NOT exist */
|
|
1482
|
-
async toNotHaveFile(path) {
|
|
1483
|
-
if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
|
|
1484
|
-
return this;
|
|
1485
|
-
}
|
|
1486
|
-
/** Assert a directory exists */
|
|
1487
|
-
async toHaveDirectory(path) {
|
|
1488
|
-
if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
|
|
1489
|
-
return this;
|
|
1490
|
-
}
|
|
1491
|
-
/** Assert a mount exists */
|
|
1492
|
-
async toHaveMount(destination) {
|
|
1493
|
-
const info = await this.container.inspect();
|
|
1494
|
-
if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
|
|
1495
|
-
const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
|
|
1496
|
-
throw new Error(`Expected mount at ${destination}, found: [${available}]`);
|
|
1497
|
-
}
|
|
1498
|
-
return this;
|
|
1499
|
-
}
|
|
1500
|
-
/** Assert network mode */
|
|
1501
|
-
async toHaveNetwork(mode) {
|
|
1502
|
-
const info = await this.container.inspect();
|
|
1503
|
-
if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
|
|
1504
|
-
return this;
|
|
1505
|
-
}
|
|
1506
|
-
/** Assert memory limit */
|
|
1507
|
-
async toHaveMemoryLimit(bytes) {
|
|
1508
|
-
const info = await this.container.inspect();
|
|
1509
|
-
if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
|
|
1510
|
-
return this;
|
|
1511
|
-
}
|
|
1512
|
-
/** Assert CPU quota */
|
|
1513
|
-
async toHaveCpuQuota(quota) {
|
|
1514
|
-
const info = await this.container.inspect();
|
|
1515
|
-
if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
|
|
1516
|
-
return this;
|
|
1517
|
-
}
|
|
1518
|
-
/** Execute a command and return output for custom assertions */
|
|
1519
|
-
async exec(cmd) {
|
|
1520
|
-
return this.container.exec(cmd);
|
|
1521
|
-
}
|
|
1522
|
-
/** Read a file for custom assertions */
|
|
1523
|
-
async readFile(path) {
|
|
1524
|
-
const file = await this.container.file(path);
|
|
1525
|
-
if (!file.exists) throw new Error(`File ${path} does not exist`);
|
|
1526
|
-
return file.content;
|
|
1527
|
-
}
|
|
1528
|
-
/** Get logs for custom assertions */
|
|
1529
|
-
async getLogs(tail) {
|
|
1530
|
-
return this.container.logs(tail);
|
|
1531
|
-
}
|
|
1532
|
-
};
|
|
1533
|
-
//#endregion
|
|
1534
|
-
//#region src/utilities/grep.ts
|
|
1535
|
-
/**
|
|
1536
|
-
* Extract text blocks from output that contain a pattern.
|
|
1537
|
-
* Splits by blank lines (how linter/compiler output is structured),
|
|
1538
|
-
* returns only blocks matching the pattern.
|
|
1539
|
-
*
|
|
1540
|
-
* @example
|
|
1541
|
-
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
1542
|
-
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
1543
|
-
*/
|
|
1544
|
-
function grep(output, pattern) {
|
|
1545
|
-
return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
|
|
1546
|
-
}
|
|
1547
|
-
//#endregion
|
|
1548
|
-
//#region src/mocking/mock-of-date.ts
|
|
1549
|
-
const mockOfDate = mockdate.default;
|
|
1550
|
-
//#endregion
|
|
1551
|
-
//#region src/mocking/mock-of.ts
|
|
1552
|
-
const mockOf = vitest_mock_extended.mockDeep;
|
|
1553
|
-
//#endregion
|
|
1554
1659
|
exports.DirectoryAccessor = DirectoryAccessor;
|
|
1555
1660
|
exports.DockerAssertion = DockerAssertion;
|
|
1556
1661
|
exports.ExecAdapter = ExecAdapter;
|
|
@@ -1561,17 +1666,21 @@ exports.ResponseAccessor = ResponseAccessor;
|
|
|
1561
1666
|
exports.SpecificationBuilder = SpecificationBuilder;
|
|
1562
1667
|
exports.SpecificationResult = SpecificationResult;
|
|
1563
1668
|
exports.TableAssertion = TableAssertion;
|
|
1669
|
+
exports.app = app;
|
|
1564
1670
|
exports.cli = cli;
|
|
1671
|
+
exports.command = command;
|
|
1565
1672
|
exports.createSpecificationRunner = createSpecificationRunner;
|
|
1566
1673
|
exports.dockerContainer = dockerContainer;
|
|
1567
1674
|
exports.e2e = e2e;
|
|
1568
1675
|
exports.grep = grep;
|
|
1569
1676
|
exports.integration = integration;
|
|
1570
|
-
exports.mockOf = mockOf;
|
|
1571
|
-
exports.mockOfDate = mockOfDate;
|
|
1677
|
+
exports.mockOf = require_mock_of.mockOf;
|
|
1678
|
+
exports.mockOfDate = require_mock_of.mockOfDate;
|
|
1572
1679
|
exports.normalizeOutput = normalizeOutput;
|
|
1573
|
-
exports.postgres = postgres;
|
|
1574
|
-
exports.redis = redis;
|
|
1680
|
+
exports.postgres = require_redis_adapter.postgres;
|
|
1681
|
+
exports.redis = require_redis_adapter.redis;
|
|
1682
|
+
exports.spec = spec;
|
|
1683
|
+
exports.stack = stack;
|
|
1575
1684
|
exports.stripAnsi = stripAnsi;
|
|
1576
1685
|
|
|
1577
1686
|
//# sourceMappingURL=index.cjs.map
|