@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/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 execSync (blocking) or spawn (long-running).
26
- * Used by cli() for local command execution.
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/adapters/compose.adapter.ts
825
+ //#region src/docker/docker-adapter.ts
653
826
  /**
654
- * Start the full compose stack and stop it all on cleanup.
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 ComposeStackAdapter = class {
657
- composeFile;
658
- started = false;
659
- constructor(composeFile) {
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
- createDatabaseAdapter() {
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 healthcheck() {
720
- if (!this.connectionString) throw new Error("postgres: cannot healthcheck — no connection string");
841
+ async file(path) {
721
842
  try {
722
- const client = new Client({ connectionString: this.connectionString });
723
- await client.connect();
724
- await client.query("SELECT 1");
725
- await client.end();
726
- } catch (error) {
727
- throw new Error(`postgres healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
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 initialize(composeDir) {
731
- if (!this.composeName) return;
732
- const initPaths = [resolve(composeDir, `${this.composeName}/init.sql`), resolve(composeDir, "postgres/init.sql")];
733
- for (const initPath of initPaths) if (existsSync(initPath)) {
734
- const sql = readFileSync(initPath, "utf8");
735
- try {
736
- await this.seed(sql);
737
- } catch (error) {
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 getClient() {
744
- if (this.client) return this.client;
745
- const client = new Client({ connectionString: this.connectionString });
746
- client.on("error", () => {
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 healthcheck() {
802
- if (!this.connectionString) throw new Error("redis: cannot healthcheck — no connection string");
803
- try {
804
- const { createClient } = await import("redis");
805
- const client = createClient({ url: this.connectionString });
806
- await client.connect();
807
- await client.ping();
808
- await client.disconnect();
809
- } catch (error) {
810
- throw new Error(`redis healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
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 initialize() {}
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 client.flushAll();
820
- } finally {
821
- await client.disconnect();
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
- * Create a Redis service handle.
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/adapters/testcontainers.adapter.ts
922
+ //#region src/docker/docker-assertion.ts
837
923
  /**
838
- * Container adapter using testcontainers.
839
- * Wraps a GenericContainer for programmatic container lifecycle.
924
+ * Fluent assertion builder for Docker containers.
925
+ * Chain async assertions like `toBeRunning()`, `toHaveFile()`, `toHaveMount()`.
840
926
  */
841
- var TestcontainersAdapter = class {
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 containerTasks = this.services.map((handle) => {
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
- return {
1013
- container: new TestcontainersAdapter({
1014
- image,
1015
- port: handle.defaultPort,
1016
- env
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(containerTasks.map(({ container }) => container.start()));
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 containerTasks) {
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
- //#region src/docker/docker-adapter.ts
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