@elench/testkit 0.1.21 → 0.1.23

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 CHANGED
@@ -1,11 +1,10 @@
1
1
  # @elench/testkit
2
2
 
3
- CLI that reads `testkit.config.json` from a product repo, discovers `*.testkit.ts` files by convention, starts the required local services, provisions local Postgres isolation, and runs suites across `k6` and Playwright.
3
+ CLI that reads `testkit.config.json` from a product repo, discovers `*.testkit.ts` files by convention, starts the required local services, provisions local Postgres isolation, and runs HTTP, DAL, and Playwright suites.
4
4
 
5
5
  ## Prerequisites
6
6
 
7
- `@elench/testkit` ships its own `k6` binary and uses it for both HTTP and DAL suites by default.
8
- If you need to force a different binary, set `TESTKIT_K6_BIN` to an absolute or relative path.
7
+ `@elench/testkit` ships its own execution engine for HTTP and DAL suites. Consumers do not need to install or invoke any separate load-testing binary.
9
8
 
10
9
  Database isolation uses Docker-managed local Postgres containers. `testkit` creates and reuses template databases automatically, then clones per-worker databases from those templates for fast reruns. The default image is `pgvector/pgvector:pg16`.
11
10
 
@@ -24,7 +23,7 @@ npx @elench/testkit e2e
24
23
 
25
24
  # Filter by framework
26
25
  npx @elench/testkit --framework playwright
27
- npx @elench/testkit --framework k6
26
+ npx @elench/testkit --framework default
28
27
 
29
28
  # Parallelize with isolated worker stacks
30
29
  npx @elench/testkit --jobs 3
@@ -48,9 +47,9 @@ npx @elench/testkit status
48
47
  npx @elench/testkit destroy
49
48
  ```
50
49
 
51
- ## K6 Authoring
50
+ ## Authoring
52
51
 
53
- Consumer k6 tests can import the shared authoring API directly from the package:
52
+ Consumer tests can import the shared authoring API directly from the package:
54
53
 
55
54
  ```js
56
55
  import { defineHttpSuite } from "@elench/testkit";
@@ -76,15 +75,15 @@ const suite = defineHttpSuite({ auth: clerkSessionAuth }, ({ req, setupData }) =
76
75
  });
77
76
  ```
78
77
 
79
- `testkit` bundles these imports before invoking k6, so tests do not need
80
- generated `_testkit` files or direct package-manager path imports.
78
+ Low-level runtime primitives are also available without exposing the underlying engine:
81
79
 
82
- If you need to override the packaged `k6` binary for local environment reasons:
83
-
84
- ```bash
85
- TESTKIT_K6_BIN=/path/to/k6 npx @elench/testkit int
80
+ ```js
81
+ import { check, group, http } from "@elench/testkit/runtime";
86
82
  ```
87
83
 
84
+ `testkit` bundles these imports before execution, so tests do not need
85
+ generated `_testkit` files, direct package-manager path imports, or any separate engine installation.
86
+
88
87
  Legacy compatibility:
89
88
 
90
89
  - `testkit runtime install`
@@ -104,12 +103,12 @@ npx @elench/testkit --dir my-product api int -s health
104
103
 
105
104
  1. **Discovery** — reads `testkit.config.json` for services, then discovers files by suffix:
106
105
  `*.int.testkit.ts`, `*.e2e.testkit.ts`, `*.dal.testkit.ts`, `*.load.testkit.ts`, `*.pw.testkit.ts`
107
- 2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, database, and discovery settings
106
+ 2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, and database settings
108
107
  Per-service `.env` files declared in config are loaded when present.
109
108
  3. **Database** — provisions Docker-managed local Postgres when a service declares one
110
109
  4. **Seed** — runs optional product seed commands against the provisioned database
111
110
  5. **Runtime** — starts required local services, waits for readiness, and injects test env
112
- 6. **Execution** — schedules file-level execution tasks across a global worker pool, reuses warm dependency graphs when possible, bundles `k6` files before execution so package imports resolve cleanly, and batches Playwright files per worker
111
+ 6. **Execution** — schedules file-level execution tasks across a global worker pool, reuses warm dependency graphs when possible, bundles test files before execution so package imports resolve cleanly, and batches Playwright files per worker
113
112
  7. **Cleanup** — stops local processes and preserves `.testkit/` state for inspection or later destroy
114
113
 
115
114
  Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
@@ -124,7 +123,7 @@ Each run ends with a compact summary that shows per-service pass/fail status, su
124
123
  `testkit.config.json` can also declare:
125
124
 
126
125
  - `telemetry` for optional generic HTTP result upload
127
- - `discovery.include` / `discovery.exclude` for per-service test discovery
126
+ - no test registration in config; `testkit` discovers `*.testkit.ts` files from the filesystem automatically
128
127
  - `envFile` / `envFiles` for service-specific environment loading
129
128
  - `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
130
129
  - `database.provider` for local Postgres settings
@@ -7,8 +7,7 @@ import { fileURLToPath } from "url";
7
7
 
8
8
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
9
9
  const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
10
- const K6_ENTRY = path.join(PACKAGE_ROOT, "lib", "k6", "index.mjs");
11
- const K6_DIR = path.join(PACKAGE_ROOT, "lib", "k6");
10
+ const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
12
11
  const bundleCache = new Map();
13
12
 
14
13
  export async function bundleK6File({
@@ -82,14 +81,7 @@ function testkitPackageAliasPlugin() {
82
81
  function resolvePackageSubpath(specifier) {
83
82
  const subpath = specifier.slice("@elench/testkit".length);
84
83
  if (!subpath) return ROOT_ENTRY;
85
- if (subpath === "/k6") return K6_ENTRY;
86
- if (subpath.startsWith("/k6/")) {
87
- const rel = subpath.slice("/k6/".length);
88
- const candidate = path.join(K6_DIR, `${rel}.mjs`);
89
- if (fs.existsSync(candidate)) {
90
- return candidate;
91
- }
92
- }
84
+ if (subpath === "/runtime") return RUNTIME_ENTRY;
93
85
 
94
86
  throw new Error(`Unsupported @elench/testkit import "${specifier}" in ${os.platform()}`);
95
87
  }
@@ -12,8 +12,8 @@ afterEach(() => {
12
12
  }
13
13
  });
14
14
 
15
- describe("k6 bundler", () => {
16
- it("bundles root package imports for k6 execution", async () => {
15
+ describe("runtime bundler", () => {
16
+ it("bundles root and runtime package imports for execution", async () => {
17
17
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
18
18
  cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
19
19
 
@@ -21,8 +21,8 @@ describe("k6 bundler", () => {
21
21
  fs.writeFileSync(
22
22
  sourceFile,
23
23
  [
24
- 'import { defineHttpSuite, json } from "@elench/testkit";',
25
- 'import { check } from "k6";',
24
+ 'import { defineHttpSuite } from "@elench/testkit";',
25
+ 'import { check, json } from "@elench/testkit/runtime";',
26
26
  "const suite = defineHttpSuite(({ rawReq }) => {",
27
27
  ' const res = rawReq("GET", "/health");',
28
28
  " check(json(res), {",
@@ -44,10 +44,11 @@ describe("k6 bundler", () => {
44
44
 
45
45
  const bundled = fs.readFileSync(bundledFile, "utf8");
46
46
  expect(bundled).toContain("defineHttpSuite");
47
- expect(bundled).toContain('import { check } from "k6"');
47
+ expect(bundled).toContain('import { check');
48
+ expect(bundled).toContain('from "k6"');
48
49
  });
49
50
 
50
- it("bundles subpath package imports for DAL execution", async () => {
51
+ it("bundles DAL execution through the public package surface", async () => {
51
52
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
52
53
  cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
53
54
 
@@ -55,7 +56,7 @@ describe("k6 bundler", () => {
55
56
  fs.writeFileSync(
56
57
  sourceFile,
57
58
  [
58
- 'import { defineDalSuite } from "@elench/testkit/k6";',
59
+ 'import { defineDalSuite } from "@elench/testkit";',
59
60
  "const suite = defineDalSuite(({ db }) => {",
60
61
  ' db.query("SELECT 1");',
61
62
  "});",
package/lib/cli/args.mjs CHANGED
@@ -22,11 +22,12 @@ export function resolveCliSelection({ first, second, serviceNames }) {
22
22
  }
23
23
 
24
24
  export function validateFrameworkOption(value) {
25
- if (!["all", "k6", "playwright"].includes(value)) {
25
+ if (!["all", "default", "playwright"].includes(value)) {
26
26
  throw new Error(
27
- `Unknown framework "${value}". Expected one of: all, k6, playwright.`
27
+ `Unknown framework "${value}". Expected one of: all, default, playwright.`
28
28
  );
29
29
  }
30
+ return value === "default" ? "k6" : value;
30
31
  }
31
32
 
32
33
  export function parseJobsOption(value) {
@@ -44,7 +44,8 @@ describe("cli-args", () => {
44
44
  });
45
45
 
46
46
  it("validates framework names", () => {
47
- expect(() => validateFrameworkOption("playwright")).not.toThrow();
47
+ expect(validateFrameworkOption("playwright")).toBe("playwright");
48
+ expect(validateFrameworkOption("default")).toBe("k6");
48
49
  expect(() => validateFrameworkOption("jest")).toThrow("Unknown framework");
49
50
  });
50
51
 
package/lib/cli/index.mjs CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  validateFrameworkOption,
9
9
  } from "./args.mjs";
10
10
  import * as runner from "../runner/index.mjs";
11
- import * as runtime from "../runtime/index.mjs";
12
11
 
13
12
  export function run() {
14
13
  const cli = cac("testkit");
@@ -18,11 +17,13 @@ export function run() {
18
17
  .option("--dir <path>", "Explicit product directory")
19
18
  .option("--path <path>", "Target runtime path relative to the product directory")
20
19
  .option("--strict", "Exit non-zero when runtime status is missing or drifted")
21
- .action((action, options) => {
20
+ .action(async (action, options) => {
22
21
  if (!["install", "status", "update"].includes(action)) {
23
22
  throw new Error('Unknown runtime action. Expected one of: install, status, update.');
24
23
  }
25
24
 
25
+ const runtime = await import("../runtime-manager/index.mjs");
26
+
26
27
  if (action === "status") {
27
28
  const status = runtime.getRuntimeStatus(options);
28
29
  console.log(runtime.formatRuntimeStatus(status));
@@ -47,7 +48,7 @@ export function run() {
47
48
  default: "1",
48
49
  })
49
50
  .option("--shard <i/n>", "Run only shard i of n at suite granularity")
50
- .option("--framework <name>", "Filter by framework (k6, playwright, all)", {
51
+ .option("--framework <name>", "Filter by framework (default, playwright, all)", {
51
52
  default: "all",
52
53
  })
53
54
  .option("--write-status", "Write a deterministic testkit.status.json snapshot")
@@ -91,7 +92,7 @@ export function run() {
91
92
  return;
92
93
  }
93
94
 
94
- validateFrameworkOption(options.framework);
95
+ const framework = validateFrameworkOption(options.framework);
95
96
 
96
97
  const jobs = parseJobsOption(options.jobs);
97
98
  const shard = parseShardOption(options.shard);
@@ -105,6 +106,7 @@ export function run() {
105
106
  suiteNames,
106
107
  {
107
108
  ...options,
109
+ framework,
108
110
  fileNames,
109
111
  jobs,
110
112
  shard,
@@ -9,148 +9,189 @@ const DISCOVERY_RULES = [
9
9
  { suffix: ".pw.testkit.ts", type: "e2e", framework: "playwright" },
10
10
  ];
11
11
 
12
- export function discoverServiceSuites(productDir, serviceName, serviceConfig) {
13
- const include = serviceConfig.discovery?.include || [];
14
- const exclude = serviceConfig.discovery?.exclude || [];
15
- const grouped = {};
16
- const seen = new Set();
17
-
18
- for (const pattern of include) {
19
- const normalizedPattern = normalizePath(pattern);
20
- const patternInfo = compilePattern(normalizedPattern);
21
- const rootDir = path.resolve(productDir, patternInfo.baseDir);
22
- if (!fs.existsSync(rootDir)) continue;
23
-
24
- for (const relativeFile of walkFiles(productDir, rootDir)) {
25
- const normalizedFile = normalizePath(relativeFile);
26
- if (!patternInfo.regex.test(normalizedFile)) continue;
27
- if (exclude.some((candidate) => compilePattern(normalizePath(candidate)).regex.test(normalizedFile))) {
28
- continue;
29
- }
30
- if (seen.has(normalizedFile)) continue;
12
+ const IGNORED_DIRS = new Set([
13
+ ".cache",
14
+ ".git",
15
+ ".hg",
16
+ ".next",
17
+ ".nuxt",
18
+ ".playwright-browsers",
19
+ ".svn",
20
+ ".testkit",
21
+ ".turbo",
22
+ "build",
23
+ "coverage",
24
+ "dist",
25
+ "node_modules",
26
+ "playwright-report",
27
+ "test-results",
28
+ ]);
29
+
30
+ export function discoverSuites(productDir, services) {
31
+ const serviceEntries = Object.entries(services || {});
32
+ const rules = serviceEntries.map(([serviceName, serviceConfig]) =>
33
+ buildServiceRule(serviceName, serviceConfig)
34
+ );
35
+ const groupedByService = Object.fromEntries(
36
+ serviceEntries.map(([serviceName]) => [serviceName, {}])
37
+ );
38
+ const discoveredFiles = discoverFiles(productDir);
39
+ const unowned = [];
40
+ const ambiguous = [];
41
+
42
+ for (const filePath of discoveredFiles) {
43
+ const rule = inferRule(filePath);
44
+ if (!rule) continue;
45
+
46
+ const matches = rules
47
+ .filter((serviceRule) => ownsFile(serviceRule, filePath))
48
+ .map((serviceRule) => serviceRule.name);
49
+
50
+ if (matches.length === 0) {
51
+ unowned.push(filePath);
52
+ continue;
53
+ }
31
54
 
32
- const rule = inferRule(normalizedFile);
33
- if (!rule) continue;
55
+ if (matches.length > 1) {
56
+ ambiguous.push({
57
+ filePath,
58
+ serviceNames: matches.sort((left, right) => left.localeCompare(right)),
59
+ });
60
+ continue;
61
+ }
34
62
 
35
- const typeSuites = grouped[rule.type] || [];
36
- typeSuites.push({
37
- name: buildSuiteName(normalizedFile, rule.suffix, patternInfo.baseDir),
38
- files: [normalizedFile],
63
+ const serviceName = matches[0];
64
+ const serviceRule = rules.find((candidate) => candidate.name === serviceName);
65
+ const relativeToService = relativeToServiceRoot(serviceRule, filePath);
66
+ const suiteName = deriveSuiteName(relativeToService, rule.suffix);
67
+ const grouped = groupedByService[serviceName];
68
+ const suitesForType = grouped[rule.type] || [];
69
+ let suite = suitesForType.find(
70
+ (candidate) => candidate.name === suiteName && candidate.framework === rule.framework
71
+ );
72
+
73
+ if (!suite) {
74
+ suite = {
75
+ name: suiteName,
76
+ files: [],
39
77
  framework: rule.framework,
40
- });
41
- grouped[rule.type] = typeSuites;
42
- seen.add(normalizedFile);
78
+ };
79
+ suitesForType.push(suite);
80
+ grouped[rule.type] = suitesForType;
43
81
  }
44
- }
45
82
 
46
- for (const suites of Object.values(grouped)) {
47
- suites.sort((left, right) => left.files[0].localeCompare(right.files[0]));
83
+ suite.files.push(filePath);
48
84
  }
49
85
 
50
- return grouped;
51
- }
86
+ if (unowned.length > 0 || ambiguous.length > 0) {
87
+ throw buildDiscoveryError(unowned, ambiguous);
88
+ }
52
89
 
53
- function inferRule(filePath) {
54
- return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
55
- }
90
+ for (const grouped of Object.values(groupedByService)) {
91
+ for (const suites of Object.values(grouped)) {
92
+ for (const suite of suites) {
93
+ suite.files.sort((left, right) => left.localeCompare(right));
94
+ }
95
+ suites.sort((left, right) => left.name.localeCompare(right.name));
96
+ }
97
+ }
56
98
 
57
- function buildSuiteName(filePath, suffix, baseDir) {
58
- const normalizedBase = normalizePath(baseDir);
59
- const relativeToBase =
60
- normalizedBase === "." || normalizedBase.length === 0
61
- ? filePath
62
- : path.posix.relative(normalizedBase, filePath);
63
- const candidate = relativeToBase.length > 0 ? relativeToBase : path.posix.basename(filePath);
64
- return candidate.slice(0, -suffix.length);
99
+ return groupedByService;
65
100
  }
66
101
 
67
- function walkFiles(productDir, rootDir) {
68
- const rootStats = fs.statSync(rootDir);
69
- if (rootStats.isFile()) {
70
- return [normalizePath(path.relative(productDir, rootDir))];
71
- }
72
-
102
+ function discoverFiles(productDir) {
73
103
  const files = [];
74
- const queue = [rootDir];
104
+ const queue = [productDir];
75
105
 
76
106
  while (queue.length > 0) {
77
107
  const current = queue.pop();
78
108
  for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
79
- const absolute = path.join(current, entry.name);
109
+ const absolutePath = path.join(current, entry.name);
110
+
111
+ if (entry.isSymbolicLink()) continue;
112
+
80
113
  if (entry.isDirectory()) {
81
- queue.push(absolute);
114
+ if (IGNORED_DIRS.has(entry.name)) continue;
115
+ queue.push(absolutePath);
82
116
  continue;
83
117
  }
118
+
84
119
  if (!entry.isFile()) continue;
85
- files.push(normalizePath(path.relative(productDir, absolute)));
120
+
121
+ const relativePath = normalizePath(path.relative(productDir, absolutePath));
122
+ if (inferRule(relativePath)) files.push(relativePath);
86
123
  }
87
124
  }
88
125
 
89
126
  return files.sort((left, right) => left.localeCompare(right));
90
127
  }
91
128
 
92
- function compilePattern(pattern) {
93
- const baseDir = patternBase(pattern);
129
+ function buildServiceRule(serviceName, serviceConfig) {
130
+ const testPrefix = normalizePath(path.posix.join("tests", serviceName));
131
+ const cwd = normalizePath(serviceConfig?.local?.cwd || ".");
94
132
  return {
95
- baseDir,
96
- regex: globToRegExp(pattern),
133
+ name: serviceName,
134
+ testPrefix,
135
+ cwdPrefix: cwd === "." ? null : cwd,
97
136
  };
98
137
  }
99
138
 
100
- function patternBase(pattern) {
101
- const parts = pattern.split("/");
102
- const baseParts = [];
139
+ function ownsFile(serviceRule, filePath) {
140
+ if (hasPrefix(filePath, serviceRule.testPrefix)) return true;
141
+ if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) return true;
142
+ return false;
143
+ }
103
144
 
104
- for (const part of parts) {
105
- if (/[?*\[]/.test(part)) break;
106
- baseParts.push(part);
145
+ function relativeToServiceRoot(serviceRule, filePath) {
146
+ if (hasPrefix(filePath, serviceRule.testPrefix)) {
147
+ return path.posix.relative(serviceRule.testPrefix, filePath);
107
148
  }
108
-
109
- return baseParts.length > 0 ? baseParts.join("/") : ".";
149
+ if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) {
150
+ return path.posix.relative(serviceRule.cwdPrefix, filePath);
151
+ }
152
+ return filePath;
110
153
  }
111
154
 
112
- function globToRegExp(pattern) {
113
- let source = "^";
114
-
115
- for (let index = 0; index < pattern.length; index += 1) {
116
- const current = pattern[index];
117
- const next = pattern[index + 1];
118
- const afterNext = pattern[index + 2];
119
-
120
- if (current === "*" && next === "*" && afterNext === "/") {
121
- source += "(?:.*/)?";
122
- index += 2;
123
- continue;
124
- }
155
+ function deriveSuiteName(relativePath, suffix) {
156
+ const parts = relativePath.split("/").filter(Boolean);
157
+ if (parts.length >= 3) return parts[1];
158
+ return path.posix.basename(relativePath, suffix);
159
+ }
125
160
 
126
- if (current === "*" && next === "*") {
127
- source += ".*";
128
- index += 1;
129
- continue;
130
- }
161
+ function buildDiscoveryError(unowned, ambiguous) {
162
+ const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
131
163
 
132
- if (current === "*") {
133
- source += "[^/]*";
134
- continue;
164
+ if (unowned.length > 0) {
165
+ lines.push("");
166
+ lines.push("Unowned test files:");
167
+ for (const filePath of unowned) {
168
+ lines.push(`- ${filePath}`);
135
169
  }
170
+ }
136
171
 
137
- if (current === "?") {
138
- source += "[^/]";
139
- continue;
172
+ if (ambiguous.length > 0) {
173
+ lines.push("");
174
+ lines.push("Ambiguous test files:");
175
+ for (const entry of ambiguous) {
176
+ lines.push(`- ${entry.filePath} -> ${entry.serviceNames.join(", ")}`);
140
177
  }
178
+ }
141
179
 
142
- if ("\\.[]{}()+-^$|".includes(current)) {
143
- source += `\\${current}`;
144
- continue;
145
- }
180
+ lines.push("");
181
+ lines.push('Expected test files to live under "tests/<service>/..." or a non-root service local.cwd directory.');
182
+ return new Error(lines.join("\n"));
183
+ }
146
184
 
147
- source += current;
148
- }
185
+ function inferRule(filePath) {
186
+ return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
187
+ }
149
188
 
150
- source += "$";
151
- return new RegExp(source);
189
+ function hasPrefix(filePath, prefix) {
190
+ return filePath === prefix || filePath.startsWith(`${prefix}/`);
152
191
  }
153
192
 
154
193
  function normalizePath(value) {
155
- return value.split(path.sep).join("/");
194
+ const normalized = String(value).split(path.sep).join("/");
195
+ if (normalized === "." || normalized === "./") return ".";
196
+ return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "") || ".";
156
197
  }
@@ -2,7 +2,7 @@ import fs from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import { afterEach, describe, expect, it } from "vitest";
5
- import { discoverServiceSuites } from "./discovery.mjs";
5
+ import { discoverSuites } from "./discovery.mjs";
6
6
 
7
7
  const cleanups = [];
8
8
 
@@ -12,33 +12,91 @@ afterEach(() => {
12
12
  }
13
13
  });
14
14
 
15
- describe("config-discovery", () => {
16
- it("discovers exact-file includes with stable suite names", () => {
15
+ describe("filesystem-discovery", () => {
16
+ it("discovers tests by convention without config registration", () => {
17
17
  const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
18
18
  cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
19
19
 
20
- const filePath = path.join(
21
- productDir,
22
- "tests",
23
- "api",
24
- "integration",
25
- "health.int.testkit.ts"
26
- );
27
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
28
- fs.writeFileSync(filePath, "export {};\n");
29
-
30
- const suites = discoverServiceSuites(productDir, "api", {
31
- discovery: {
32
- include: ["tests/api/integration/health.int.testkit.ts"],
20
+ writeFile(productDir, "tests/api/integration/health.int.testkit.ts");
21
+ writeFile(productDir, "tests/api/integration/auth/me.int.testkit.ts");
22
+ writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
23
+
24
+ const suites = discoverSuites(productDir, {
25
+ api: {
26
+ local: {
27
+ cwd: ".",
28
+ },
29
+ },
30
+ frontend: {
31
+ local: {
32
+ cwd: "frontend",
33
+ },
33
34
  },
34
35
  });
35
36
 
36
- expect(suites.integration).toEqual([
37
+ expect(suites.api.integration).toEqual([
38
+ {
39
+ name: "auth",
40
+ files: ["tests/api/integration/auth/me.int.testkit.ts"],
41
+ framework: "k6",
42
+ },
37
43
  {
38
44
  name: "health",
39
45
  files: ["tests/api/integration/health.int.testkit.ts"],
40
46
  framework: "k6",
41
47
  },
42
48
  ]);
49
+ expect(suites.frontend.e2e).toEqual([
50
+ {
51
+ name: "homepage",
52
+ files: ["frontend/e2e/homepage.pw.testkit.ts"],
53
+ framework: "playwright",
54
+ },
55
+ ]);
56
+ });
57
+
58
+ it("fails when a discovered file does not map to any configured service", () => {
59
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
60
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
61
+
62
+ writeFile(productDir, "tests/unknown/integration/health.int.testkit.ts");
63
+
64
+ expect(() =>
65
+ discoverSuites(productDir, {
66
+ api: {
67
+ local: {
68
+ cwd: ".",
69
+ },
70
+ },
71
+ })
72
+ ).toThrow("Unowned test files");
73
+ });
74
+
75
+ it("fails when a discovered file maps to multiple services", () => {
76
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
77
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
78
+
79
+ writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
80
+
81
+ expect(() =>
82
+ discoverSuites(productDir, {
83
+ frontend: {
84
+ local: {
85
+ cwd: "frontend",
86
+ },
87
+ },
88
+ web: {
89
+ local: {
90
+ cwd: "frontend",
91
+ },
92
+ },
93
+ })
94
+ ).toThrow("Ambiguous test files");
43
95
  });
44
96
  });
97
+
98
+ function writeFile(productDir, relativePath) {
99
+ const absolutePath = path.join(productDir, relativePath);
100
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
101
+ fs.writeFileSync(absolutePath, "export {};\n");
102
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { fileURLToPath } from "url";
4
- import { discoverServiceSuites } from "./discovery.mjs";
4
+ import { discoverSuites } from "./discovery.mjs";
5
5
  import {
6
6
  getServiceEnvFiles as getServiceEnvFilesModel,
7
7
  isObject as isObjectModel,
@@ -34,6 +34,7 @@ export function loadConfigs(opts = {}) {
34
34
  const productDir = resolveProductDir(process.cwd(), opts.dir);
35
35
  const config = loadTestkitConfig(productDir);
36
36
  validateConfigCoverage(config);
37
+ const discoveredSuites = discoverSuites(productDir, config.services);
37
38
 
38
39
  const serviceEntries = Object.entries(config.services);
39
40
  const filtered = opts.service
@@ -46,7 +47,7 @@ export function loadConfigs(opts = {}) {
46
47
  }
47
48
 
48
49
  return filtered.map(([name, serviceConfig]) => {
49
- const suites = discoverServiceSuites(productDir, name, serviceConfig);
50
+ const suites = discoveredSuites[name] || {};
50
51
  const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
51
52
  const serviceEnv = loadServiceEnv(productDir, serviceConfig);
52
53
  const selectedBackend = resolvedDatabase?.selectedBackend;