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