@jterrazz/test 5.3.2 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -56
- package/dist/chunk.cjs +28 -0
- package/dist/index.cjs +597 -463
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +343 -246
- package/dist/index.d.ts +343 -246
- package/dist/index.js +589 -438
- package/dist/index.js.map +1 -1
- package/dist/mock-of.cjs +32 -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/services.cjs +5 -0
- package/dist/services.d.cts +2 -0
- package/dist/services.d.ts +2 -0
- package/dist/services.js +2 -0
- package/dist/sqlite.adapter.cjs +331 -0
- package/dist/sqlite.adapter.cjs.map +1 -0
- package/dist/sqlite.adapter.d.cts +209 -0
- package/dist/sqlite.adapter.d.ts +209 -0
- package/dist/sqlite.adapter.js +312 -0
- package/dist/sqlite.adapter.js.map +1 -0
- package/package.json +16 -8
package/dist/index.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
+
import { n as redis, r as postgres, t as sqlite } from "./sqlite.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;
|
|
@@ -643,202 +812,246 @@ function getCallerDir() {
|
|
|
643
812
|
}
|
|
644
813
|
throw new Error("Cannot detect caller directory from stack trace");
|
|
645
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
|
+
*/
|
|
646
819
|
function createSpecificationRunner(config) {
|
|
647
820
|
return (label) => {
|
|
648
821
|
return new SpecificationBuilder(config, getCallerDir(), label);
|
|
649
822
|
};
|
|
650
823
|
}
|
|
651
824
|
//#endregion
|
|
652
|
-
//#region src/
|
|
825
|
+
//#region src/docker/docker-adapter.ts
|
|
653
826
|
/**
|
|
654
|
-
*
|
|
827
|
+
* Implements {@link DockerContainerPort} by shelling out to the Docker CLI.
|
|
828
|
+
* Each instance is bound to a single container ID.
|
|
655
829
|
*/
|
|
656
|
-
var
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
this.composeFile = composeFile;
|
|
661
|
-
}
|
|
662
|
-
run(command) {
|
|
663
|
-
try {
|
|
664
|
-
return execSync(command, {
|
|
665
|
-
cwd: dirname(this.composeFile),
|
|
666
|
-
encoding: "utf8",
|
|
667
|
-
timeout: 12e4
|
|
668
|
-
}).trim();
|
|
669
|
-
} catch (error) {
|
|
670
|
-
const stderr = error.stderr?.toString().trim() ?? error.message;
|
|
671
|
-
throw new Error(`docker compose failed: ${stderr}`, { cause: error });
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
async start() {
|
|
675
|
-
if (this.started) return;
|
|
676
|
-
this.run(`docker compose -f ${this.composeFile} up -d --wait`);
|
|
677
|
-
this.started = true;
|
|
678
|
-
}
|
|
679
|
-
async stop() {
|
|
680
|
-
if (!this.started) return;
|
|
681
|
-
this.run(`docker compose -f ${this.composeFile} down -v`);
|
|
682
|
-
this.started = false;
|
|
683
|
-
}
|
|
684
|
-
getMappedPort(serviceName, containerPort) {
|
|
685
|
-
const port = this.run(`docker compose -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
|
|
686
|
-
return Number(port);
|
|
687
|
-
}
|
|
688
|
-
getHost() {
|
|
689
|
-
return "localhost";
|
|
690
|
-
}
|
|
691
|
-
};
|
|
692
|
-
//#endregion
|
|
693
|
-
//#region src/adapters/postgres.adapter.ts
|
|
694
|
-
var PostgresHandle = class {
|
|
695
|
-
type = "postgres";
|
|
696
|
-
composeName;
|
|
697
|
-
defaultPort = 5432;
|
|
698
|
-
defaultImage;
|
|
699
|
-
environment;
|
|
700
|
-
connectionString = "";
|
|
701
|
-
started = false;
|
|
702
|
-
client = null;
|
|
703
|
-
constructor(options = {}) {
|
|
704
|
-
this.composeName = options.compose ?? null;
|
|
705
|
-
this.defaultImage = options.image ?? "postgres:17";
|
|
706
|
-
this.environment = {
|
|
707
|
-
POSTGRES_DB: "test",
|
|
708
|
-
POSTGRES_PASSWORD: "test",
|
|
709
|
-
POSTGRES_USER: "test",
|
|
710
|
-
...options.env
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
buildConnectionString(host, port) {
|
|
714
|
-
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;
|
|
715
834
|
}
|
|
716
|
-
|
|
717
|
-
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();
|
|
718
840
|
}
|
|
719
|
-
async
|
|
720
|
-
if (!this.connectionString) throw new Error("postgres: cannot healthcheck — no connection string");
|
|
841
|
+
async file(path) {
|
|
721
842
|
try {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
} catch
|
|
727
|
-
|
|
843
|
+
return {
|
|
844
|
+
exists: true,
|
|
845
|
+
content: await this.exec(["cat", path])
|
|
846
|
+
};
|
|
847
|
+
} catch {
|
|
848
|
+
return {
|
|
849
|
+
exists: false,
|
|
850
|
+
content: ""
|
|
851
|
+
};
|
|
728
852
|
}
|
|
729
853
|
}
|
|
730
|
-
async
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
throw new Error(`postgres init script failed (${initPath}):\n${error.message}`, { cause: error });
|
|
739
|
-
}
|
|
740
|
-
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;
|
|
741
862
|
}
|
|
742
863
|
}
|
|
743
|
-
async
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
this.client = null;
|
|
864
|
+
async logs(tail) {
|
|
865
|
+
return execSync(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
|
|
866
|
+
encoding: "utf8",
|
|
867
|
+
timeout: 1e4
|
|
748
868
|
});
|
|
749
|
-
await client.connect();
|
|
750
|
-
this.client = client;
|
|
751
|
-
return client;
|
|
752
|
-
}
|
|
753
|
-
async seed(sql) {
|
|
754
|
-
await (await this.getClient()).query(sql);
|
|
755
|
-
}
|
|
756
|
-
async query(table, columns) {
|
|
757
|
-
const client = await this.getClient();
|
|
758
|
-
const columnList = columns.join(", ");
|
|
759
|
-
return (await client.query(`SELECT ${columnList} FROM "${table}" ORDER BY 1`)).rows.map((row) => columns.map((col) => row[col]));
|
|
760
|
-
}
|
|
761
|
-
async reset() {
|
|
762
|
-
const client = await this.getClient();
|
|
763
|
-
const result = await client.query(`
|
|
764
|
-
SELECT tablename FROM pg_tables
|
|
765
|
-
WHERE schemaname = 'public'
|
|
766
|
-
AND tablename NOT LIKE '_prisma%'
|
|
767
|
-
`);
|
|
768
|
-
for (const row of result.rows) await client.query(`TRUNCATE "${row.tablename}" CASCADE`);
|
|
769
|
-
}
|
|
770
|
-
};
|
|
771
|
-
/**
|
|
772
|
-
* Create a PostgreSQL service handle.
|
|
773
|
-
*
|
|
774
|
-
* @example
|
|
775
|
-
* const db = postgres({ compose: "db" });
|
|
776
|
-
* // After start: db.connectionString is populated
|
|
777
|
-
*/
|
|
778
|
-
function postgres(options = {}) {
|
|
779
|
-
return new PostgresHandle(options);
|
|
780
|
-
}
|
|
781
|
-
//#endregion
|
|
782
|
-
//#region src/adapters/redis.adapter.ts
|
|
783
|
-
var RedisHandle = class {
|
|
784
|
-
type = "redis";
|
|
785
|
-
composeName;
|
|
786
|
-
defaultPort = 6379;
|
|
787
|
-
defaultImage;
|
|
788
|
-
environment = {};
|
|
789
|
-
connectionString = "";
|
|
790
|
-
started = false;
|
|
791
|
-
constructor(options = {}) {
|
|
792
|
-
this.composeName = options.compose ?? null;
|
|
793
|
-
this.defaultImage = options.image ?? "redis:7";
|
|
794
|
-
}
|
|
795
|
-
buildConnectionString(host, port) {
|
|
796
|
-
return `redis://${host}:${port}`;
|
|
797
|
-
}
|
|
798
|
-
createDatabaseAdapter() {
|
|
799
|
-
return null;
|
|
800
869
|
}
|
|
801
|
-
async
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
+
};
|
|
812
903
|
}
|
|
813
|
-
async
|
|
814
|
-
async reset() {
|
|
815
|
-
const { createClient } = await import("redis");
|
|
816
|
-
const client = createClient({ url: this.connectionString });
|
|
817
|
-
await client.connect();
|
|
904
|
+
async exists(path) {
|
|
818
905
|
try {
|
|
819
|
-
await
|
|
820
|
-
|
|
821
|
-
|
|
906
|
+
await this.exec([
|
|
907
|
+
"test",
|
|
908
|
+
"-e",
|
|
909
|
+
path
|
|
910
|
+
]);
|
|
911
|
+
return true;
|
|
912
|
+
} catch {
|
|
913
|
+
return false;
|
|
822
914
|
}
|
|
823
915
|
}
|
|
824
916
|
};
|
|
825
|
-
/**
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
* @example
|
|
829
|
-
* const cache = redis({ compose: "cache" });
|
|
830
|
-
* // After start: cache.connectionString is populated
|
|
831
|
-
*/
|
|
832
|
-
function redis(options = {}) {
|
|
833
|
-
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);
|
|
834
920
|
}
|
|
835
921
|
//#endregion
|
|
836
|
-
//#region src/
|
|
922
|
+
//#region src/docker/docker-assertion.ts
|
|
837
923
|
/**
|
|
838
|
-
*
|
|
839
|
-
*
|
|
924
|
+
* Fluent assertion builder for Docker containers.
|
|
925
|
+
* Chain async assertions like `toBeRunning()`, `toHaveFile()`, `toHaveMount()`.
|
|
840
926
|
*/
|
|
841
|
-
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 {
|
|
842
1055
|
image;
|
|
843
1056
|
containerPort;
|
|
844
1057
|
env;
|
|
@@ -976,6 +1189,7 @@ var Orchestrator = class {
|
|
|
976
1189
|
services;
|
|
977
1190
|
mode;
|
|
978
1191
|
root;
|
|
1192
|
+
projectName;
|
|
979
1193
|
running = [];
|
|
980
1194
|
composeStack = null;
|
|
981
1195
|
composeHandles = [];
|
|
@@ -984,6 +1198,7 @@ var Orchestrator = class {
|
|
|
984
1198
|
this.services = options.services;
|
|
985
1199
|
this.mode = options.mode;
|
|
986
1200
|
this.root = options.root ?? process.cwd();
|
|
1201
|
+
this.projectName = options.projectName;
|
|
987
1202
|
}
|
|
988
1203
|
/**
|
|
989
1204
|
* Start declared services via testcontainers (integration mode).
|
|
@@ -995,7 +1210,13 @@ var Orchestrator = class {
|
|
|
995
1210
|
const composePath = findComposeFile(this.root);
|
|
996
1211
|
const composeDir = composePath ? dirname(composePath) : this.root;
|
|
997
1212
|
const composeConfig = composePath ? parseComposeFile(composePath) : null;
|
|
998
|
-
const
|
|
1213
|
+
const containerServices = [];
|
|
1214
|
+
const embeddedServices = [];
|
|
1215
|
+
for (const handle of this.services) {
|
|
1216
|
+
if (handle.defaultPort === 0) {
|
|
1217
|
+
embeddedServices.push(handle);
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
999
1220
|
let image = handle.defaultImage;
|
|
1000
1221
|
let env = { ...handle.environment };
|
|
1001
1222
|
if (handle.composeName && composeConfig) {
|
|
@@ -1009,18 +1230,26 @@ var Orchestrator = class {
|
|
|
1009
1230
|
Object.assign(handle.environment, composeService.environment);
|
|
1010
1231
|
}
|
|
1011
1232
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1233
|
+
const container = new TestcontainersAdapter({
|
|
1234
|
+
image,
|
|
1235
|
+
port: handle.defaultPort,
|
|
1236
|
+
env
|
|
1237
|
+
});
|
|
1238
|
+
containerServices.push({
|
|
1239
|
+
container,
|
|
1018
1240
|
handle
|
|
1019
|
-
};
|
|
1020
|
-
}
|
|
1021
|
-
await Promise.all(
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
await Promise.all([...containerServices.map(({ container }) => container.start()), ...embeddedServices.map(async (handle) => {
|
|
1244
|
+
await handle.initialize(composeDir);
|
|
1245
|
+
handle.started = true;
|
|
1246
|
+
this.running.push({
|
|
1247
|
+
handle,
|
|
1248
|
+
container: null
|
|
1249
|
+
});
|
|
1250
|
+
})]);
|
|
1022
1251
|
const reports = [];
|
|
1023
|
-
for (const { container, handle } of
|
|
1252
|
+
for (const { container, handle } of containerServices) {
|
|
1024
1253
|
const serviceStartTime = Date.now();
|
|
1025
1254
|
try {
|
|
1026
1255
|
const host = container.getHost();
|
|
@@ -1081,7 +1310,7 @@ var Orchestrator = class {
|
|
|
1081
1310
|
const startTime = Date.now();
|
|
1082
1311
|
const composeDir = dirname(composePath);
|
|
1083
1312
|
const composeConfig = parseComposeFile(composePath);
|
|
1084
|
-
this.composeStack = new ComposeStackAdapter(composePath);
|
|
1313
|
+
this.composeStack = new ComposeStackAdapter(composePath, this.projectName);
|
|
1085
1314
|
await this.composeStack.start();
|
|
1086
1315
|
for (const service of composeConfig.infraServices) {
|
|
1087
1316
|
const type = detectServiceType(service.image);
|
|
@@ -1191,6 +1420,178 @@ function resolveCommand(command, root) {
|
|
|
1191
1420
|
return command;
|
|
1192
1421
|
}
|
|
1193
1422
|
//#endregion
|
|
1423
|
+
//#region src/runner/spec.ts
|
|
1424
|
+
/**
|
|
1425
|
+
* Create a specification runner for the given target.
|
|
1426
|
+
*
|
|
1427
|
+
* @param target - What to test against: {@link app}, {@link stack}, or {@link command}.
|
|
1428
|
+
* @param options - Shared options: root directory, infrastructure services.
|
|
1429
|
+
*
|
|
1430
|
+
* @example
|
|
1431
|
+
* // HTTP — in-process app with testcontainers
|
|
1432
|
+
* const s = await spec(app(() => createApp(db)), { services: [db] });
|
|
1433
|
+
*
|
|
1434
|
+
* // HTTP — full docker compose stack
|
|
1435
|
+
* const s = await spec(stack('../../'));
|
|
1436
|
+
*
|
|
1437
|
+
* // CLI — command binary
|
|
1438
|
+
* const s = await spec(command('my-cli'), { root: '../fixtures' });
|
|
1439
|
+
*/
|
|
1440
|
+
async function spec(target, options = {}) {
|
|
1441
|
+
switch (target.kind) {
|
|
1442
|
+
case "app": return startApp(target, options);
|
|
1443
|
+
case "stack": return startStack(target, options);
|
|
1444
|
+
case "command": return startCommand(target, options);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
function getWorkerId() {
|
|
1448
|
+
return process.env.VITEST_POOL_ID ?? "0";
|
|
1449
|
+
}
|
|
1450
|
+
async function acquireIsolation(services) {
|
|
1451
|
+
const workerId = getWorkerId();
|
|
1452
|
+
for (const service of services) await service.isolation().acquire(workerId);
|
|
1453
|
+
}
|
|
1454
|
+
async function releaseIsolation(services) {
|
|
1455
|
+
for (const service of services) await service.isolation().release();
|
|
1456
|
+
}
|
|
1457
|
+
async function startApp(target, options) {
|
|
1458
|
+
const services = options.services ?? [];
|
|
1459
|
+
const orchestrator = new Orchestrator({
|
|
1460
|
+
mode: "integration",
|
|
1461
|
+
root: resolveProjectRoot(options.root),
|
|
1462
|
+
services
|
|
1463
|
+
});
|
|
1464
|
+
await orchestrator.start();
|
|
1465
|
+
await acquireIsolation(services);
|
|
1466
|
+
const servicesMap = {};
|
|
1467
|
+
for (const svc of services) {
|
|
1468
|
+
const key = svc.composeName ?? svc.type;
|
|
1469
|
+
servicesMap[key] = svc;
|
|
1470
|
+
}
|
|
1471
|
+
const honoApp = target.factory(servicesMap);
|
|
1472
|
+
const database = orchestrator.getDatabase() ?? void 0;
|
|
1473
|
+
const databases = orchestrator.getDatabases();
|
|
1474
|
+
const runner = createSpecificationRunner({
|
|
1475
|
+
database,
|
|
1476
|
+
databases: databases.size > 0 ? databases : void 0,
|
|
1477
|
+
server: new HonoAdapter(honoApp)
|
|
1478
|
+
});
|
|
1479
|
+
runner.cleanup = async () => {
|
|
1480
|
+
await releaseIsolation(services);
|
|
1481
|
+
await orchestrator.stop();
|
|
1482
|
+
};
|
|
1483
|
+
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1484
|
+
runner.orchestrator = orchestrator;
|
|
1485
|
+
return runner;
|
|
1486
|
+
}
|
|
1487
|
+
async function startStack(target, options) {
|
|
1488
|
+
const root = resolveProjectRoot(target.root ?? options.root);
|
|
1489
|
+
const projectName = `test-worker-${getWorkerId()}`;
|
|
1490
|
+
const orchestrator = new Orchestrator({
|
|
1491
|
+
mode: "e2e",
|
|
1492
|
+
root,
|
|
1493
|
+
services: options.services ?? [],
|
|
1494
|
+
projectName
|
|
1495
|
+
});
|
|
1496
|
+
await orchestrator.startCompose();
|
|
1497
|
+
const appUrl = orchestrator.getAppUrl();
|
|
1498
|
+
if (!appUrl) throw new Error("stack(): could not detect app URL from compose. Ensure an app service with ports is defined.");
|
|
1499
|
+
const database = orchestrator.getDatabase() ?? void 0;
|
|
1500
|
+
const databases = orchestrator.getDatabases();
|
|
1501
|
+
const runner = createSpecificationRunner({
|
|
1502
|
+
database,
|
|
1503
|
+
databases: databases.size > 0 ? databases : void 0,
|
|
1504
|
+
server: new FetchAdapter(appUrl)
|
|
1505
|
+
});
|
|
1506
|
+
runner.cleanup = () => orchestrator.stopCompose();
|
|
1507
|
+
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1508
|
+
runner.orchestrator = orchestrator;
|
|
1509
|
+
return runner;
|
|
1510
|
+
}
|
|
1511
|
+
async function startCommand(target, options) {
|
|
1512
|
+
const root = resolveProjectRoot(options.root);
|
|
1513
|
+
const bin = resolveCommand(target.bin, root);
|
|
1514
|
+
const services = options.services ?? [];
|
|
1515
|
+
let orchestrator = null;
|
|
1516
|
+
let database;
|
|
1517
|
+
let databases;
|
|
1518
|
+
if (services.length) {
|
|
1519
|
+
orchestrator = new Orchestrator({
|
|
1520
|
+
mode: "integration",
|
|
1521
|
+
root,
|
|
1522
|
+
services
|
|
1523
|
+
});
|
|
1524
|
+
await orchestrator.start();
|
|
1525
|
+
await acquireIsolation(services);
|
|
1526
|
+
database = orchestrator.getDatabase() ?? void 0;
|
|
1527
|
+
const dbMap = orchestrator.getDatabases();
|
|
1528
|
+
databases = dbMap.size > 0 ? dbMap : void 0;
|
|
1529
|
+
}
|
|
1530
|
+
const runner = createSpecificationRunner({
|
|
1531
|
+
command: new ExecAdapter(bin),
|
|
1532
|
+
database,
|
|
1533
|
+
databases,
|
|
1534
|
+
fixturesRoot: root
|
|
1535
|
+
});
|
|
1536
|
+
runner.cleanup = async () => {
|
|
1537
|
+
await releaseIsolation(services);
|
|
1538
|
+
if (orchestrator) await orchestrator.stop();
|
|
1539
|
+
};
|
|
1540
|
+
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1541
|
+
runner.orchestrator = orchestrator;
|
|
1542
|
+
return runner;
|
|
1543
|
+
}
|
|
1544
|
+
//#endregion
|
|
1545
|
+
//#region src/runner/targets.ts
|
|
1546
|
+
/**
|
|
1547
|
+
* Test against an in-process Hono app. The factory receives started services
|
|
1548
|
+
* so you can wire connection strings into your app/DI container.
|
|
1549
|
+
*
|
|
1550
|
+
* @param factory - Function that receives services and returns a Hono app instance.
|
|
1551
|
+
*
|
|
1552
|
+
* @example
|
|
1553
|
+
* const db = postgres({ compose: 'db' });
|
|
1554
|
+
* await spec(
|
|
1555
|
+
* app((services) => createApp({ databaseUrl: services.db.connectionString })),
|
|
1556
|
+
* { services: [db] },
|
|
1557
|
+
* );
|
|
1558
|
+
*/
|
|
1559
|
+
function app(factory) {
|
|
1560
|
+
return {
|
|
1561
|
+
kind: "app",
|
|
1562
|
+
factory
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Test against a full docker compose stack. The stack is started with
|
|
1567
|
+
* `docker compose up` and real HTTP requests are sent to the app service.
|
|
1568
|
+
*
|
|
1569
|
+
* @param root - Project root containing `docker/compose.test.yaml`.
|
|
1570
|
+
*
|
|
1571
|
+
* @example
|
|
1572
|
+
* await spec(stack('../../'));
|
|
1573
|
+
*/
|
|
1574
|
+
function stack(root) {
|
|
1575
|
+
return {
|
|
1576
|
+
kind: "stack",
|
|
1577
|
+
root
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Test a CLI binary. Each spec runs in a fresh temp directory.
|
|
1582
|
+
*
|
|
1583
|
+
* @param bin - Path to the CLI binary or command name (resolved from node_modules/.bin or PATH).
|
|
1584
|
+
*
|
|
1585
|
+
* @example
|
|
1586
|
+
* await spec(command('my-cli'), { root: '../fixtures' });
|
|
1587
|
+
*/
|
|
1588
|
+
function command(bin) {
|
|
1589
|
+
return {
|
|
1590
|
+
kind: "command",
|
|
1591
|
+
bin
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
//#endregion
|
|
1194
1595
|
//#region src/runner/cli.ts
|
|
1195
1596
|
/**
|
|
1196
1597
|
* Create a CLI specification runner.
|
|
@@ -1226,36 +1627,6 @@ async function cli(options) {
|
|
|
1226
1627
|
return runner;
|
|
1227
1628
|
}
|
|
1228
1629
|
//#endregion
|
|
1229
|
-
//#region src/adapters/fetch.adapter.ts
|
|
1230
|
-
/**
|
|
1231
|
-
* Server adapter for real HTTP — sends actual fetch requests.
|
|
1232
|
-
* Used by e2e() specification runner.
|
|
1233
|
-
*/
|
|
1234
|
-
var FetchAdapter = class {
|
|
1235
|
-
baseUrl;
|
|
1236
|
-
constructor(url) {
|
|
1237
|
-
this.baseUrl = url.replace(/\/$/, "");
|
|
1238
|
-
}
|
|
1239
|
-
async request(method, path, body) {
|
|
1240
|
-
const init = {
|
|
1241
|
-
method,
|
|
1242
|
-
headers: { "Content-Type": "application/json" }
|
|
1243
|
-
};
|
|
1244
|
-
if (body !== void 0) init.body = JSON.stringify(body);
|
|
1245
|
-
const response = await fetch(`${this.baseUrl}${path}`, init);
|
|
1246
|
-
const responseBody = await response.json().catch(() => null);
|
|
1247
|
-
const headers = {};
|
|
1248
|
-
response.headers.forEach((value, key) => {
|
|
1249
|
-
headers[key] = value;
|
|
1250
|
-
});
|
|
1251
|
-
return {
|
|
1252
|
-
status: response.status,
|
|
1253
|
-
body: responseBody,
|
|
1254
|
-
headers
|
|
1255
|
-
};
|
|
1256
|
-
}
|
|
1257
|
-
};
|
|
1258
|
-
//#endregion
|
|
1259
1630
|
//#region src/runner/e2e.ts
|
|
1260
1631
|
/**
|
|
1261
1632
|
* Create an E2E specification runner.
|
|
@@ -1282,36 +1653,6 @@ async function e2e(options = {}) {
|
|
|
1282
1653
|
return runner;
|
|
1283
1654
|
}
|
|
1284
1655
|
//#endregion
|
|
1285
|
-
//#region src/adapters/hono.adapter.ts
|
|
1286
|
-
/**
|
|
1287
|
-
* Server adapter for Hono — in-process requests, no real HTTP.
|
|
1288
|
-
* Used by integration() specification runner.
|
|
1289
|
-
*/
|
|
1290
|
-
var HonoAdapter = class {
|
|
1291
|
-
app;
|
|
1292
|
-
constructor(app) {
|
|
1293
|
-
this.app = app;
|
|
1294
|
-
}
|
|
1295
|
-
async request(method, path, body) {
|
|
1296
|
-
const init = {
|
|
1297
|
-
method,
|
|
1298
|
-
headers: { "Content-Type": "application/json" }
|
|
1299
|
-
};
|
|
1300
|
-
if (body !== void 0) init.body = JSON.stringify(body);
|
|
1301
|
-
const response = await this.app.request(path, init);
|
|
1302
|
-
const responseBody = await response.json().catch(() => null);
|
|
1303
|
-
const headers = {};
|
|
1304
|
-
response.headers.forEach((value, key) => {
|
|
1305
|
-
headers[key] = value;
|
|
1306
|
-
});
|
|
1307
|
-
return {
|
|
1308
|
-
status: response.status,
|
|
1309
|
-
body: responseBody,
|
|
1310
|
-
headers
|
|
1311
|
-
};
|
|
1312
|
-
}
|
|
1313
|
-
};
|
|
1314
|
-
//#endregion
|
|
1315
1656
|
//#region src/runner/integration.ts
|
|
1316
1657
|
/**
|
|
1317
1658
|
* Create an integration specification runner.
|
|
@@ -1337,196 +1678,6 @@ async function integration(options) {
|
|
|
1337
1678
|
return runner;
|
|
1338
1679
|
}
|
|
1339
1680
|
//#endregion
|
|
1340
|
-
|
|
1341
|
-
var DockerAdapter = class {
|
|
1342
|
-
containerId;
|
|
1343
|
-
constructor(containerId) {
|
|
1344
|
-
this.containerId = containerId;
|
|
1345
|
-
}
|
|
1346
|
-
async exec(cmd) {
|
|
1347
|
-
return execSync(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
|
|
1348
|
-
encoding: "utf8",
|
|
1349
|
-
timeout: 1e4
|
|
1350
|
-
}).trim();
|
|
1351
|
-
}
|
|
1352
|
-
async file(path) {
|
|
1353
|
-
try {
|
|
1354
|
-
return {
|
|
1355
|
-
exists: true,
|
|
1356
|
-
content: await this.exec(["cat", path])
|
|
1357
|
-
};
|
|
1358
|
-
} catch {
|
|
1359
|
-
return {
|
|
1360
|
-
exists: false,
|
|
1361
|
-
content: ""
|
|
1362
|
-
};
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
async isRunning() {
|
|
1366
|
-
try {
|
|
1367
|
-
return execSync(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
|
|
1368
|
-
encoding: "utf8",
|
|
1369
|
-
timeout: 5e3
|
|
1370
|
-
}).trim() === "true";
|
|
1371
|
-
} catch {
|
|
1372
|
-
return false;
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
async logs(tail) {
|
|
1376
|
-
return execSync(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
|
|
1377
|
-
encoding: "utf8",
|
|
1378
|
-
timeout: 1e4
|
|
1379
|
-
});
|
|
1380
|
-
}
|
|
1381
|
-
async inspect() {
|
|
1382
|
-
const raw = execSync(`docker inspect ${this.containerId}`, {
|
|
1383
|
-
encoding: "utf8",
|
|
1384
|
-
timeout: 5e3
|
|
1385
|
-
});
|
|
1386
|
-
const data = JSON.parse(raw)[0];
|
|
1387
|
-
return {
|
|
1388
|
-
id: data.Id,
|
|
1389
|
-
name: data.Name,
|
|
1390
|
-
state: {
|
|
1391
|
-
running: data.State.Running,
|
|
1392
|
-
exitCode: data.State.ExitCode,
|
|
1393
|
-
status: data.State.Status
|
|
1394
|
-
},
|
|
1395
|
-
config: {
|
|
1396
|
-
image: data.Config.Image,
|
|
1397
|
-
env: data.Config.Env || []
|
|
1398
|
-
},
|
|
1399
|
-
hostConfig: {
|
|
1400
|
-
memory: data.HostConfig.Memory || 0,
|
|
1401
|
-
cpuQuota: data.HostConfig.CpuQuota || 0,
|
|
1402
|
-
networkMode: data.HostConfig.NetworkMode || "",
|
|
1403
|
-
mounts: (data.Mounts || []).map((m) => ({
|
|
1404
|
-
source: m.Source,
|
|
1405
|
-
destination: m.Destination,
|
|
1406
|
-
type: m.Type
|
|
1407
|
-
}))
|
|
1408
|
-
},
|
|
1409
|
-
networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
|
|
1410
|
-
gateway: net.Gateway,
|
|
1411
|
-
ipAddress: net.IPAddress
|
|
1412
|
-
}])) }
|
|
1413
|
-
};
|
|
1414
|
-
}
|
|
1415
|
-
async exists(path) {
|
|
1416
|
-
try {
|
|
1417
|
-
await this.exec([
|
|
1418
|
-
"test",
|
|
1419
|
-
"-e",
|
|
1420
|
-
path
|
|
1421
|
-
]);
|
|
1422
|
-
return true;
|
|
1423
|
-
} catch {
|
|
1424
|
-
return false;
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
};
|
|
1428
|
-
/** Create a Docker container port for an existing container */
|
|
1429
|
-
function dockerContainer(containerId) {
|
|
1430
|
-
return new DockerAdapter(containerId);
|
|
1431
|
-
}
|
|
1432
|
-
//#endregion
|
|
1433
|
-
//#region src/docker/docker-assertion.ts
|
|
1434
|
-
/** Fluent assertion builder for Docker containers */
|
|
1435
|
-
var DockerAssertion = class {
|
|
1436
|
-
container;
|
|
1437
|
-
constructor(container) {
|
|
1438
|
-
this.container = container;
|
|
1439
|
-
}
|
|
1440
|
-
/** Assert the container is running */
|
|
1441
|
-
async toBeRunning() {
|
|
1442
|
-
if (!await this.container.isRunning()) throw new Error("Expected container to be running");
|
|
1443
|
-
return this;
|
|
1444
|
-
}
|
|
1445
|
-
/** Assert the container is NOT running / doesn't exist */
|
|
1446
|
-
async toNotExist() {
|
|
1447
|
-
if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
|
|
1448
|
-
return this;
|
|
1449
|
-
}
|
|
1450
|
-
/** Assert a file exists inside the container */
|
|
1451
|
-
async toHaveFile(path, opts) {
|
|
1452
|
-
const file = await this.container.file(path);
|
|
1453
|
-
if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
|
|
1454
|
-
if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
|
|
1455
|
-
return this;
|
|
1456
|
-
}
|
|
1457
|
-
/** Assert a file does NOT exist */
|
|
1458
|
-
async toNotHaveFile(path) {
|
|
1459
|
-
if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
|
|
1460
|
-
return this;
|
|
1461
|
-
}
|
|
1462
|
-
/** Assert a directory exists */
|
|
1463
|
-
async toHaveDirectory(path) {
|
|
1464
|
-
if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
|
|
1465
|
-
return this;
|
|
1466
|
-
}
|
|
1467
|
-
/** Assert a mount exists */
|
|
1468
|
-
async toHaveMount(destination) {
|
|
1469
|
-
const info = await this.container.inspect();
|
|
1470
|
-
if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
|
|
1471
|
-
const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
|
|
1472
|
-
throw new Error(`Expected mount at ${destination}, found: [${available}]`);
|
|
1473
|
-
}
|
|
1474
|
-
return this;
|
|
1475
|
-
}
|
|
1476
|
-
/** Assert network mode */
|
|
1477
|
-
async toHaveNetwork(mode) {
|
|
1478
|
-
const info = await this.container.inspect();
|
|
1479
|
-
if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
|
|
1480
|
-
return this;
|
|
1481
|
-
}
|
|
1482
|
-
/** Assert memory limit */
|
|
1483
|
-
async toHaveMemoryLimit(bytes) {
|
|
1484
|
-
const info = await this.container.inspect();
|
|
1485
|
-
if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
|
|
1486
|
-
return this;
|
|
1487
|
-
}
|
|
1488
|
-
/** Assert CPU quota */
|
|
1489
|
-
async toHaveCpuQuota(quota) {
|
|
1490
|
-
const info = await this.container.inspect();
|
|
1491
|
-
if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
|
|
1492
|
-
return this;
|
|
1493
|
-
}
|
|
1494
|
-
/** Execute a command and return output for custom assertions */
|
|
1495
|
-
async exec(cmd) {
|
|
1496
|
-
return this.container.exec(cmd);
|
|
1497
|
-
}
|
|
1498
|
-
/** Read a file for custom assertions */
|
|
1499
|
-
async readFile(path) {
|
|
1500
|
-
const file = await this.container.file(path);
|
|
1501
|
-
if (!file.exists) throw new Error(`File ${path} does not exist`);
|
|
1502
|
-
return file.content;
|
|
1503
|
-
}
|
|
1504
|
-
/** Get logs for custom assertions */
|
|
1505
|
-
async getLogs(tail) {
|
|
1506
|
-
return this.container.logs(tail);
|
|
1507
|
-
}
|
|
1508
|
-
};
|
|
1509
|
-
//#endregion
|
|
1510
|
-
//#region src/utilities/grep.ts
|
|
1511
|
-
/**
|
|
1512
|
-
* Extract text blocks from output that contain a pattern.
|
|
1513
|
-
* Splits by blank lines (how linter/compiler output is structured),
|
|
1514
|
-
* returns only blocks matching the pattern.
|
|
1515
|
-
*
|
|
1516
|
-
* @example
|
|
1517
|
-
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
1518
|
-
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
1519
|
-
*/
|
|
1520
|
-
function grep(output, pattern) {
|
|
1521
|
-
return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
|
|
1522
|
-
}
|
|
1523
|
-
//#endregion
|
|
1524
|
-
//#region src/mocking/mock-of-date.ts
|
|
1525
|
-
const mockOfDate = MockDatePackage;
|
|
1526
|
-
//#endregion
|
|
1527
|
-
//#region src/mocking/mock-of.ts
|
|
1528
|
-
const mockOf = mockDeep;
|
|
1529
|
-
//#endregion
|
|
1530
|
-
export { DirectoryAccessor, DockerAssertion, ExecAdapter, FetchAdapter, HonoAdapter, Orchestrator, ResponseAccessor, SpecificationBuilder, SpecificationResult, TableAssertion, cli, createSpecificationRunner, dockerContainer, e2e, grep, integration, mockOf, mockOfDate, normalizeOutput, postgres, redis, stripAnsi };
|
|
1681
|
+
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, sqlite, stack, stripAnsi };
|
|
1531
1682
|
|
|
1532
1683
|
//# sourceMappingURL=index.js.map
|