@elench/testkit 0.1.20 → 0.1.21

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,13 +1,9 @@
1
1
  # @elench/testkit
2
2
 
3
- CLI that reads `runner.manifest.json` plus `testkit.config.json` from a product repo, starts the required local services, provisions local Postgres isolation, and runs manifest-defined 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 suites across `k6` and Playwright.
4
4
 
5
5
  ## Prerequisites
6
6
 
7
- ```bash
8
- sudo apt-get install -y jq
9
- ```
10
-
11
7
  `@elench/testkit` ships its own `k6` binary and uses it for both HTTP and DAL suites by default.
12
8
  If you need to force a different binary, set `TESTKIT_K6_BIN` to an absolute or relative path.
13
9
 
@@ -41,6 +37,12 @@ npx @elench/testkit --jobs 2 --shard 2/3
41
37
  npx @elench/testkit frontend e2e -s auth
42
38
  npx @elench/testkit api int -s health
43
39
 
40
+ # Exact file
41
+ npx @elench/testkit int --file tests/api/integration/health.int.testkit.ts
42
+
43
+ # Deterministic git-trackable status snapshot
44
+ npx @elench/testkit int --write-status
45
+
44
46
  # Lifecycle
45
47
  npx @elench/testkit status
46
48
  npx @elench/testkit destroy
@@ -100,8 +102,9 @@ npx @elench/testkit --dir my-product api int -s health
100
102
 
101
103
  ## How it works
102
104
 
103
- 1. **Discovery** — reads `runner.manifest.json` for services, suites, files, and frameworks
104
- 2. **Config** reads `testkit.config.json` for local runtime, migration, dependency, and database settings
105
+ 1. **Discovery** — reads `testkit.config.json` for services, then discovers files by suffix:
106
+ `*.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
105
108
  Per-service `.env` files declared in config are loaded when present.
106
109
  3. **Database** — provisions Docker-managed local Postgres when a service declares one
107
110
  4. **Seed** — runs optional product seed commands against the provisioned database
@@ -111,16 +114,17 @@ npx @elench/testkit --dir my-product api int -s health
111
114
 
112
115
  Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
113
116
 
114
- `testkit` also writes `.testkit/results/latest.json` for every run. When `testkit.config.json` includes a top-level `telemetry` block, it can best-effort upload that artifact to a configured HTTP endpoint with bearer auth.
117
+ `testkit` also writes `.testkit/results/latest.json` for every run. With `--write-status`, it writes a deterministic `testkit.status.json` snapshot that is suitable for git tracking. When `testkit.config.json` includes a top-level `telemetry` block, it can best-effort upload the richer run artifact to a configured HTTP endpoint with bearer auth.
115
118
 
116
119
  ## File roles
117
120
 
118
- - `runner.manifest.json`: canonical test inventory
119
121
  - `testkit.config.json`: local execution and provisioning config
122
+ - `testkit.status.json`: optional deterministic run snapshot written by `--write-status`
120
123
 
121
124
  `testkit.config.json` can also declare:
122
125
 
123
126
  - `telemetry` for optional generic HTTP result upload
127
+ - `discovery.include` / `discovery.exclude` for per-service test discovery
124
128
  - `envFile` / `envFiles` for service-specific environment loading
125
129
  - `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
126
130
  - `database.provider` for local Postgres settings
@@ -145,24 +149,6 @@ Use `--shard <i/n>` to split the selected suite set deterministically before wor
145
149
 
146
150
  `testkit` also writes `.testkit/timings.json` and uses those file timings on later runs for longest-first balancing.
147
151
 
148
- ## Suite metadata
149
-
150
- `runner.manifest.json` remains the source of truth for suites. Optional per-suite `testkit` metadata can tune execution:
151
-
152
- ```json
153
- {
154
- "name": "health",
155
- "files": ["tests/example.js"],
156
- "testkit": {
157
- "maxFileConcurrency": 2,
158
- "weight": 3
159
- }
160
- }
161
- ```
162
-
163
- - `maxFileConcurrency`: k6-only opt-in for batching multiple files from the same suite onto one worker
164
- - `weight`: optional fallback scheduling weight when no file timing history exists yet
165
-
166
152
  ## Schema
167
153
 
168
154
  See [testkit-config-schema.md](testkit-config-schema.md).
package/lib/cli/index.mjs CHANGED
@@ -41,6 +41,7 @@ export function run() {
41
41
  cli
42
42
  .command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
43
43
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
44
+ .option("-f, --file <path>", "Run specific file(s)", { default: [] })
44
45
  .option("--dir <path>", "Explicit product directory")
45
46
  .option("--jobs <n>", "Number of isolated worker stacks for the whole run", {
46
47
  default: "1",
@@ -49,6 +50,7 @@ export function run() {
49
50
  .option("--framework <name>", "Filter by framework (k6, playwright, all)", {
50
51
  default: "all",
51
52
  })
53
+ .option("--write-status", "Write a deterministic testkit.status.json snapshot")
52
54
  .action(async (first, second, third, options) => {
53
55
  // Resolve: service filter, suite type, and --dir.
54
56
  //
@@ -96,12 +98,14 @@ export function run() {
96
98
 
97
99
  const suiteType = type || "all";
98
100
  const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
101
+ const fileNames = Array.isArray(options.file) ? options.file : [options.file].filter(Boolean);
99
102
  await runner.runAll(
100
103
  configs,
101
104
  suiteType,
102
105
  suiteNames,
103
106
  {
104
107
  ...options,
108
+ fileNames,
105
109
  jobs,
106
110
  shard,
107
111
  },
@@ -0,0 +1,156 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const DISCOVERY_RULES = [
5
+ { suffix: ".int.testkit.ts", type: "integration", framework: "k6" },
6
+ { suffix: ".e2e.testkit.ts", type: "e2e", framework: "k6" },
7
+ { suffix: ".dal.testkit.ts", type: "dal", framework: "k6" },
8
+ { suffix: ".load.testkit.ts", type: "load", framework: "k6" },
9
+ { suffix: ".pw.testkit.ts", type: "e2e", framework: "playwright" },
10
+ ];
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;
31
+
32
+ const rule = inferRule(normalizedFile);
33
+ if (!rule) continue;
34
+
35
+ const typeSuites = grouped[rule.type] || [];
36
+ typeSuites.push({
37
+ name: buildSuiteName(normalizedFile, rule.suffix, patternInfo.baseDir),
38
+ files: [normalizedFile],
39
+ framework: rule.framework,
40
+ });
41
+ grouped[rule.type] = typeSuites;
42
+ seen.add(normalizedFile);
43
+ }
44
+ }
45
+
46
+ for (const suites of Object.values(grouped)) {
47
+ suites.sort((left, right) => left.files[0].localeCompare(right.files[0]));
48
+ }
49
+
50
+ return grouped;
51
+ }
52
+
53
+ function inferRule(filePath) {
54
+ return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
55
+ }
56
+
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);
65
+ }
66
+
67
+ function walkFiles(productDir, rootDir) {
68
+ const rootStats = fs.statSync(rootDir);
69
+ if (rootStats.isFile()) {
70
+ return [normalizePath(path.relative(productDir, rootDir))];
71
+ }
72
+
73
+ const files = [];
74
+ const queue = [rootDir];
75
+
76
+ while (queue.length > 0) {
77
+ const current = queue.pop();
78
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
79
+ const absolute = path.join(current, entry.name);
80
+ if (entry.isDirectory()) {
81
+ queue.push(absolute);
82
+ continue;
83
+ }
84
+ if (!entry.isFile()) continue;
85
+ files.push(normalizePath(path.relative(productDir, absolute)));
86
+ }
87
+ }
88
+
89
+ return files.sort((left, right) => left.localeCompare(right));
90
+ }
91
+
92
+ function compilePattern(pattern) {
93
+ const baseDir = patternBase(pattern);
94
+ return {
95
+ baseDir,
96
+ regex: globToRegExp(pattern),
97
+ };
98
+ }
99
+
100
+ function patternBase(pattern) {
101
+ const parts = pattern.split("/");
102
+ const baseParts = [];
103
+
104
+ for (const part of parts) {
105
+ if (/[?*\[]/.test(part)) break;
106
+ baseParts.push(part);
107
+ }
108
+
109
+ return baseParts.length > 0 ? baseParts.join("/") : ".";
110
+ }
111
+
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
+ }
125
+
126
+ if (current === "*" && next === "*") {
127
+ source += ".*";
128
+ index += 1;
129
+ continue;
130
+ }
131
+
132
+ if (current === "*") {
133
+ source += "[^/]*";
134
+ continue;
135
+ }
136
+
137
+ if (current === "?") {
138
+ source += "[^/]";
139
+ continue;
140
+ }
141
+
142
+ if ("\\.[]{}()+-^$|".includes(current)) {
143
+ source += `\\${current}`;
144
+ continue;
145
+ }
146
+
147
+ source += current;
148
+ }
149
+
150
+ source += "$";
151
+ return new RegExp(source);
152
+ }
153
+
154
+ function normalizePath(value) {
155
+ return value.split(path.sep).join("/");
156
+ }
@@ -0,0 +1,44 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { discoverServiceSuites } from "./discovery.mjs";
6
+
7
+ const cleanups = [];
8
+
9
+ afterEach(() => {
10
+ while (cleanups.length > 0) {
11
+ cleanups.pop()();
12
+ }
13
+ });
14
+
15
+ describe("config-discovery", () => {
16
+ it("discovers exact-file includes with stable suite names", () => {
17
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
18
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
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"],
33
+ },
34
+ });
35
+
36
+ expect(suites.integration).toEqual([
37
+ {
38
+ name: "health",
39
+ files: ["tests/api/integration/health.int.testkit.ts"],
40
+ framework: "k6",
41
+ },
42
+ ]);
43
+ });
44
+ });
@@ -1,27 +1,19 @@
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
5
  import {
5
6
  getServiceEnvFiles as getServiceEnvFilesModel,
6
- isDalSuiteType as isDalSuiteTypeModel,
7
7
  isObject as isObjectModel,
8
8
  normalizeTelemetryConfig as normalizeTelemetryConfigModel,
9
- normalizeTemplateConfig as normalizeTemplateConfigModel,
10
9
  parseDotenvString,
11
- requireString as requireStringModel,
12
10
  resolveLifecycleConfig as resolveLifecycleConfigModel,
13
11
  resolveSelectedDatabase as resolveSelectedDatabaseModel,
14
12
  validateConfigCoverage as validateConfigCoverageModel,
15
- validateDatabaseProviderConfig as validateDatabaseProviderConfigModel,
16
- validateHttpUrl as validateHttpUrlModel,
17
- validateLifecycleConfig as validateLifecycleConfigModel,
18
- validateRunnerManifest,
19
13
  validateServiceConfig as validateServiceConfigModel,
20
14
  validateTelemetryConfig as validateTelemetryConfigModel,
21
- validateTemplateConfig as validateTemplateConfigModel,
22
15
  } from "./model.mjs";
23
16
 
24
- const RUNNER_MANIFEST = "runner.manifest.json";
25
17
  const TESTKIT_CONFIG = "testkit.config.json";
26
18
  const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
27
19
  export function parseDotenv(filePath) {
@@ -31,20 +23,19 @@ export function parseDotenv(filePath) {
31
23
 
32
24
  export function getServiceNames(cwd) {
33
25
  const dir = cwd || process.cwd();
34
- const runnerPath = path.join(dir, RUNNER_MANIFEST);
35
- if (!fs.existsSync(runnerPath)) return [];
36
- const runner = JSON.parse(fs.readFileSync(runnerPath, "utf8"));
37
- if (!isObject(runner.services)) return [];
38
- return Object.keys(runner.services);
26
+ const configPath = path.join(dir, TESTKIT_CONFIG);
27
+ if (!fs.existsSync(configPath)) return [];
28
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
29
+ if (!isObject(config.services)) return [];
30
+ return Object.keys(config.services);
39
31
  }
40
32
 
41
33
  export function loadConfigs(opts = {}) {
42
34
  const productDir = resolveProductDir(process.cwd(), opts.dir);
43
- const runner = loadRunnerManifest(productDir);
44
35
  const config = loadTestkitConfig(productDir);
45
- validateConfigCoverage(runner, config);
36
+ validateConfigCoverage(config);
46
37
 
47
- const serviceEntries = Object.entries(runner.services);
38
+ const serviceEntries = Object.entries(config.services);
48
39
  const filtered = opts.service
49
40
  ? serviceEntries.filter(([name]) => name === opts.service)
50
41
  : serviceEntries;
@@ -54,14 +45,8 @@ export function loadConfigs(opts = {}) {
54
45
  throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
55
46
  }
56
47
 
57
- return filtered.map(([name, runnerService]) => {
58
- const serviceConfig = config.services[name];
59
- if (!serviceConfig) {
60
- throw new Error(
61
- `Service "${name}" exists in ${RUNNER_MANIFEST} but is missing from ${TESTKIT_CONFIG}`
62
- );
63
- }
64
-
48
+ return filtered.map(([name, serviceConfig]) => {
49
+ const suites = discoverServiceSuites(productDir, name, serviceConfig);
65
50
  const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
66
51
  const serviceEnv = loadServiceEnv(productDir, serviceConfig);
67
52
  const selectedBackend = resolvedDatabase?.selectedBackend;
@@ -69,7 +54,7 @@ export function loadConfigs(opts = {}) {
69
54
  const resolvedSeed = resolveLifecycleConfig(serviceConfig.seed, selectedBackend);
70
55
  validateMergedService(
71
56
  name,
72
- runnerService,
57
+ suites,
73
58
  serviceConfig,
74
59
  resolvedDatabase,
75
60
  resolvedMigrate,
@@ -82,7 +67,7 @@ export function loadConfigs(opts = {}) {
82
67
  productDir,
83
68
  stateDir: path.join(productDir, ".testkit", name),
84
69
  telemetry: normalizeTelemetryConfig(config.telemetry),
85
- suites: runnerService.suites,
70
+ suites,
86
71
  testkit: {
87
72
  ...serviceConfig,
88
73
  database: resolvedDatabase,
@@ -128,13 +113,6 @@ export function resolveServiceCwd(productDir, maybeRelative) {
128
113
  return path.resolve(productDir, maybeRelative || ".");
129
114
  }
130
115
 
131
- function loadRunnerManifest(productDir) {
132
- const manifestPath = path.join(productDir, RUNNER_MANIFEST);
133
- const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
134
- validateRunnerManifest(raw, RUNNER_MANIFEST, manifestPath);
135
- return raw;
136
- }
137
-
138
116
  function loadTestkitConfig(productDir) {
139
117
  const configPath = path.join(productDir, TESTKIT_CONFIG);
140
118
  const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
@@ -154,8 +132,8 @@ function loadTestkitConfig(productDir) {
154
132
  return raw;
155
133
  }
156
134
 
157
- function validateConfigCoverage(runner, config) {
158
- return validateConfigCoverageModel(runner, config, TESTKIT_CONFIG, RUNNER_MANIFEST);
135
+ function validateConfigCoverage(config) {
136
+ return validateConfigCoverageModel(config, TESTKIT_CONFIG);
159
137
  }
160
138
 
161
139
  function resolveProductDir(cwd, explicitDir) {
@@ -170,7 +148,7 @@ function resolveProductDir(cwd, explicitDir) {
170
148
  }
171
149
 
172
150
  function ensureProductFiles(dir) {
173
- const missing = [RUNNER_MANIFEST, TESTKIT_CONFIG].filter(
151
+ const missing = [TESTKIT_CONFIG].filter(
174
152
  (file) => !fs.existsSync(path.join(dir, file))
175
153
  );
176
154
  if (missing.length > 0) {
@@ -182,18 +160,16 @@ function ensureProductFiles(dir) {
182
160
 
183
161
  function validateMergedService(
184
162
  name,
185
- runnerService,
163
+ suites,
186
164
  serviceConfig,
187
165
  resolvedDatabase,
188
166
  resolvedMigrate,
189
167
  resolvedSeed,
190
168
  productDir
191
169
  ) {
192
- const usesLocalExecution = Object.values(runnerService.suites).some((suites) =>
193
- suites.some(
194
- (suite) =>
195
- (suite.framework && suite.framework !== "k6") ||
196
- !isDalSuiteType(suite, runnerService, suites)
170
+ const usesLocalExecution = Object.entries(suites).some(([suiteType, discoveredSuites]) =>
171
+ discoveredSuites.some(
172
+ (suite) => (suite.framework && suite.framework !== "k6") || suiteType !== "dal"
197
173
  )
198
174
  );
199
175
 
@@ -249,15 +225,6 @@ function validateMergedService(
249
225
  }
250
226
 
251
227
  }
252
-
253
- function validateServiceConfig(name, service, configPath) {
254
- return validateServiceConfigModel(name, service, configPath);
255
- }
256
-
257
- function validateTelemetryConfig(telemetry, configPath) {
258
- return validateTelemetryConfigModel(telemetry, configPath, TESTKIT_CONFIG);
259
- }
260
-
261
228
  function loadServiceEnv(productDir, serviceConfig) {
262
229
  const env = {};
263
230
  for (const envFile of getServiceEnvFiles(serviceConfig)) {
@@ -274,34 +241,10 @@ function resolveSelectedDatabase(name, serviceConfig) {
274
241
  return resolveSelectedDatabaseModel(name, serviceConfig);
275
242
  }
276
243
 
277
- function validateDatabaseProviderConfig(name, db, label) {
278
- return validateDatabaseProviderConfigModel(name, db, label);
279
- }
280
-
281
- function validateTemplateConfig(name, template, label) {
282
- return validateTemplateConfigModel(name, template, label);
283
- }
284
-
285
- function normalizeTemplateConfig(template) {
286
- return normalizeTemplateConfigModel(template);
287
- }
288
-
289
- function validateLifecycleConfig(name, value, label) {
290
- return validateLifecycleConfigModel(name, value, label);
291
- }
292
-
293
244
  function resolveLifecycleConfig(value, selectedBackend) {
294
245
  return resolveLifecycleConfigModel(value, selectedBackend);
295
246
  }
296
247
 
297
- function requireString(obj, key, label) {
298
- return requireStringModel(obj, key, label);
299
- }
300
-
301
- function isDalSuiteType(suite, runnerService, suitesForType) {
302
- return isDalSuiteTypeModel(suite, runnerService, suitesForType);
303
- }
304
-
305
248
  function isObject(value) {
306
249
  return isObjectModel(value);
307
250
  }
@@ -309,7 +252,3 @@ function isObject(value) {
309
252
  function normalizeTelemetryConfig(telemetry) {
310
253
  return normalizeTelemetryConfigModel(telemetry);
311
254
  }
312
-
313
- function validateHttpUrl(value, label) {
314
- return validateHttpUrlModel(value, label);
315
- }
@@ -110,43 +110,23 @@ export function validateRunnerManifest(raw, manifestName = "runner.manifest.json
110
110
  }
111
111
 
112
112
  export function validateConfigCoverage(
113
- runner,
114
113
  config,
115
- configName = "testkit.config.json",
116
- manifestName = "runner.manifest.json"
114
+ configName = "testkit.config.json"
117
115
  ) {
118
116
  for (const serviceName of Object.keys(config.services || {})) {
119
- if (!runner.services[serviceName]) {
120
- throw new Error(
121
- `Service "${serviceName}" exists in ${configName} but not in ${manifestName}`
122
- );
123
- }
124
-
125
117
  for (const depName of config.services[serviceName].dependsOn || []) {
126
118
  if (!config.services[depName]) {
127
119
  throw new Error(
128
120
  `Service "${serviceName}" depends on "${depName}", but ${depName} is missing from ${configName}`
129
121
  );
130
122
  }
131
- if (!runner.services[depName]) {
132
- throw new Error(
133
- `Service "${serviceName}" depends on "${depName}", but ${depName} is missing from ${manifestName}`
134
- );
135
- }
136
123
  }
137
124
 
138
125
  const databaseFrom = config.services[serviceName].databaseFrom;
139
- if (databaseFrom) {
140
- if (!config.services[databaseFrom]) {
141
- throw new Error(
142
- `Service "${serviceName}" databaseFrom "${databaseFrom}" is missing from ${configName}`
143
- );
144
- }
145
- if (!runner.services[databaseFrom]) {
146
- throw new Error(
147
- `Service "${serviceName}" databaseFrom "${databaseFrom}" is missing from ${manifestName}`
148
- );
149
- }
126
+ if (databaseFrom && !config.services[databaseFrom]) {
127
+ throw new Error(
128
+ `Service "${serviceName}" databaseFrom "${databaseFrom}" is missing from ${configName}`
129
+ );
150
130
  }
151
131
  }
152
132
  }
@@ -231,6 +211,26 @@ export function validateServiceConfig(name, service, configPath) {
231
211
  throw new Error(`Service "${name}" local.env must be an object`);
232
212
  }
233
213
  }
214
+
215
+ if (service.discovery !== undefined) {
216
+ if (!isObject(service.discovery)) {
217
+ throw new Error(`Service "${name}" discovery must be an object`);
218
+ }
219
+ if (
220
+ service.discovery.include !== undefined &&
221
+ (!Array.isArray(service.discovery.include) ||
222
+ service.discovery.include.some((value) => typeof value !== "string" || value.length === 0))
223
+ ) {
224
+ throw new Error(`Service "${name}" discovery.include must be an array of glob strings`);
225
+ }
226
+ if (
227
+ service.discovery.exclude !== undefined &&
228
+ (!Array.isArray(service.discovery.exclude) ||
229
+ service.discovery.exclude.some((value) => typeof value !== "string" || value.length === 0))
230
+ ) {
231
+ throw new Error(`Service "${name}" discovery.exclude must be an array of glob strings`);
232
+ }
233
+ }
234
234
  }
235
235
 
236
236
  export function validateTelemetryConfig(
@@ -60,11 +60,6 @@ QUX='zap'
60
60
  });
61
61
 
62
62
  it("validates config coverage", () => {
63
- const runner = {
64
- services: {
65
- api: { suites: {} },
66
- },
67
- };
68
63
  const config = {
69
64
  services: {
70
65
  frontend: {
@@ -73,8 +68,8 @@ QUX='zap'
73
68
  },
74
69
  };
75
70
 
76
- expect(() => validateConfigCoverage(runner, config)).toThrow(
77
- 'Service "frontend" exists in testkit.config.json but not in runner.manifest.json'
71
+ expect(() => validateConfigCoverage(config)).toThrow(
72
+ 'Service "frontend" depends on "api", but api is missing from testkit.config.json'
78
73
  );
79
74
  });
80
75
 
@@ -98,6 +93,14 @@ QUX='zap'
98
93
  }, "testkit.config.json")
99
94
  ).toThrow("database.backends is no longer supported");
100
95
 
96
+ expect(() =>
97
+ validateServiceConfig("api", {
98
+ discovery: {
99
+ include: "tests/**/*.int.testkit.ts",
100
+ },
101
+ }, "testkit.config.json")
102
+ ).toThrow("discovery.include must be an array of glob strings");
103
+
101
104
  expect(() =>
102
105
  validateTelemetryConfig(
103
106
  {
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { spawn } from "child_process";
4
4
  import net from "net";
5
+ import { pathToFileURL } from "url";
5
6
  import { execa, execaCommand } from "execa";
6
7
  import { bundleK6File } from "../bundler/index.mjs";
7
8
  import { resolveK6Binary, resolveServiceCwd } from "../config/index.mjs";
@@ -45,6 +46,7 @@ import {
45
46
  import {
46
47
  addTrackerError as addTrackerErrorModel,
47
48
  buildRunArtifact as buildRunArtifactModel,
49
+ buildStatusArtifact as buildStatusArtifactModel,
48
50
  buildServiceTrackers as buildServiceTrackersModel,
49
51
  finalizeServiceResult as finalizeServiceResultModel,
50
52
  formatDuration as formatDurationModel,
@@ -97,13 +99,21 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
97
99
  const configMap = new Map(allConfigs.map((config) => [config.name, config]));
98
100
  const startedAt = Date.now();
99
101
  const telemetry = configs[0]?.telemetry || null;
102
+ const productDir = configs[0]?.productDir || process.cwd();
103
+ const metadata = {
104
+ git: collectGitMetadata(productDir),
105
+ host: {
106
+ hostname: safeHostname(),
107
+ username: safeUsername(),
108
+ },
109
+ testkitVersion: readPackageMetadata().version,
110
+ };
100
111
  const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
101
112
  const trackers = buildServiceTrackers(servicePlans, startedAt);
102
113
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
103
114
  let workerCount = 0;
104
115
 
105
116
  if (executedPlans.length > 0) {
106
- const productDir = executedPlans[0].config.productDir;
107
117
  const timings = loadTimings(productDir);
108
118
  const graphs = buildRuntimeGraphs(executedPlans);
109
119
  const queue = buildTaskQueue(executedPlans, graphs, timings);
@@ -137,7 +147,7 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
137
147
  finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
138
148
  );
139
149
  const artifact = buildRunArtifact({
140
- productDir: configs[0]?.productDir || process.cwd(),
150
+ productDir,
141
151
  results,
142
152
  startedAt,
143
153
  finishedAt,
@@ -145,12 +155,30 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
145
155
  workerCount,
146
156
  suiteType,
147
157
  suiteNames,
158
+ fileNames: opts.fileNames || [],
148
159
  framework: opts.framework || "all",
149
160
  shard: opts.shard || null,
150
161
  serviceFilter: configs.length === 1 ? configs[0].name : null,
162
+ metadata,
151
163
  });
152
164
 
153
- writeRunArtifact(configs[0]?.productDir || process.cwd(), artifact);
165
+ writeRunArtifact(productDir, artifact);
166
+ if (opts.writeStatus) {
167
+ writeStatusArtifact(
168
+ productDir,
169
+ buildStatusArtifact({
170
+ productDir,
171
+ results,
172
+ suiteType,
173
+ suiteNames,
174
+ fileNames: opts.fileNames || [],
175
+ framework: opts.framework || "all",
176
+ shard: opts.shard || null,
177
+ serviceFilter: configs.length === 1 ? configs[0].name : null,
178
+ metadata,
179
+ })
180
+ );
181
+ }
154
182
 
155
183
  printRunSummary(results, finishedAt - startedAt);
156
184
  await reportTelemetry(telemetry, artifact);
@@ -201,13 +229,13 @@ function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
201
229
  return configs.map((config) => {
202
230
  console.log(`\n══ ${config.name} ══`);
203
231
  const suites = applyShard(
204
- collectSuites(config, suiteType, suiteNames, opts.framework),
232
+ collectSuites(config, suiteType, suiteNames, opts.framework, opts.fileNames || []),
205
233
  opts.shard
206
234
  );
207
235
 
208
236
  if (suites.length === 0) {
209
237
  console.log(
210
- `No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
238
+ `No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} files=${(opts.fileNames || []).join(",") || "all"} — skipping`
211
239
  );
212
240
  return {
213
241
  config,
@@ -618,10 +646,11 @@ async function runPlaywrightBatch(targetConfig, batch) {
618
646
  const requestedFiles = batch.tasks.map((task) =>
619
647
  path.relative(cwd, path.join(targetConfig.productDir, task.file))
620
648
  );
649
+ const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles);
621
650
  const startedAt = Date.now();
622
651
  const result = await execa(
623
652
  "npx",
624
- ["playwright", "test", "--reporter=json", ...requestedFiles],
653
+ ["playwright", "test", "--config", playwrightConfigPath, "--reporter=json", ...requestedFiles],
625
654
  {
626
655
  cwd,
627
656
  env: buildPlaywrightEnv(targetConfig, local.baseUrl),
@@ -724,8 +753,8 @@ function resolveRuntimeConfigs(targetConfig, configMap) {
724
753
  return resolveRuntimeConfigsModel(targetConfig, configMap);
725
754
  }
726
755
 
727
- function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
728
- return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter);
756
+ function collectSuites(config, suiteType, suiteNames, frameworkFilter, fileNames = []) {
757
+ return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter, fileNames);
729
758
  }
730
759
 
731
760
  function applyShard(suites, shard) {
@@ -891,9 +920,11 @@ function buildRunArtifact({
891
920
  workerCount,
892
921
  suiteType,
893
922
  suiteNames,
923
+ fileNames,
894
924
  framework,
895
925
  shard,
896
926
  serviceFilter,
927
+ metadata,
897
928
  }) {
898
929
  return buildRunArtifactModel({
899
930
  productDir,
@@ -904,17 +935,11 @@ function buildRunArtifact({
904
935
  workerCount,
905
936
  suiteType,
906
937
  suiteNames,
938
+ fileNames,
907
939
  framework,
908
940
  shard,
909
941
  serviceFilter,
910
- metadata: {
911
- git: collectGitMetadata(productDir),
912
- host: {
913
- hostname: safeHostname(),
914
- username: safeUsername(),
915
- },
916
- testkitVersion: readPackageMetadata().version,
917
- },
942
+ metadata,
918
943
  });
919
944
  }
920
945
 
@@ -924,6 +949,73 @@ function writeRunArtifact(productDir, artifact) {
924
949
  fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
925
950
  }
926
951
 
952
+ function buildStatusArtifact({
953
+ productDir,
954
+ results,
955
+ metadata,
956
+ }) {
957
+ return buildStatusArtifactModel({
958
+ productDir,
959
+ results,
960
+ metadata,
961
+ });
962
+ }
963
+
964
+ function writeStatusArtifact(productDir, artifact) {
965
+ fs.writeFileSync(
966
+ path.join(productDir, "testkit.status.json"),
967
+ `${JSON.stringify(artifact, null, 2)}\n`
968
+ );
969
+ }
970
+
971
+ function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
972
+ const stateDir = targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit");
973
+ fs.mkdirSync(stateDir, { recursive: true });
974
+ const configPath = path.join(stateDir, "playwright.testkit.config.mjs");
975
+ const baseConfigPath = findPlaywrightConfig(cwd);
976
+ const normalizedFiles = requestedFiles.map(normalizePathSeparators);
977
+
978
+ let source = "";
979
+ if (baseConfigPath) {
980
+ source = `import baseConfig from ${JSON.stringify(pathToFileURL(baseConfigPath).href)};\n` +
981
+ `const resolvedBase = typeof baseConfig === "function" ? await baseConfig() : baseConfig;\n` +
982
+ `export default {\n` +
983
+ ` ...(resolvedBase || {}),\n` +
984
+ ` testDir: ${JSON.stringify(cwd)},\n` +
985
+ ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
986
+ `};\n`;
987
+ } else {
988
+ source =
989
+ `export default {\n` +
990
+ ` testDir: ${JSON.stringify(cwd)},\n` +
991
+ ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
992
+ `};\n`;
993
+ }
994
+
995
+ fs.writeFileSync(configPath, source);
996
+ return configPath;
997
+ }
998
+
999
+ function findPlaywrightConfig(cwd) {
1000
+ const candidates = [
1001
+ "playwright.config.ts",
1002
+ "playwright.config.mts",
1003
+ "playwright.config.js",
1004
+ "playwright.config.mjs",
1005
+ "playwright.config.cjs",
1006
+ "playwright.config.cts",
1007
+ ];
1008
+
1009
+ for (const candidate of candidates) {
1010
+ const candidatePath = path.join(cwd, candidate);
1011
+ if (fs.existsSync(candidatePath)) {
1012
+ return candidatePath;
1013
+ }
1014
+ }
1015
+
1016
+ return null;
1017
+ }
1018
+
927
1019
  function summarizeDbBackend(results) {
928
1020
  return summarizeDbBackendModel(results);
929
1021
  }
@@ -34,24 +34,31 @@ export function resolveRuntimeConfigs(targetConfig, configMap) {
34
34
  return ordered;
35
35
  }
36
36
 
37
- export function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
37
+ export function collectSuites(config, suiteType, suiteNames, frameworkFilter, fileNames = []) {
38
38
  const types =
39
39
  suiteType === "all"
40
40
  ? orderedTypes(Object.keys(config.suites))
41
41
  : [suiteType === "int" ? "integration" : suiteType];
42
42
 
43
43
  const selectedNames = new Set(suiteNames);
44
+ const selectedFiles = new Set(fileNames.map(normalizePathSeparators));
44
45
  const suites = [];
45
46
  let orderIndex = 0;
46
47
 
47
48
  for (const type of types) {
48
49
  for (const suite of config.suites[type] || []) {
49
50
  const framework = suite.framework || "k6";
51
+ const files =
52
+ selectedFiles.size === 0
53
+ ? suite.files
54
+ : suite.files.filter((file) => selectedFiles.has(normalizePathSeparators(file)));
50
55
  if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
51
56
  if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
57
+ if (files.length === 0) continue;
52
58
 
53
59
  suites.push({
54
60
  ...suite,
61
+ files,
55
62
  framework,
56
63
  type,
57
64
  orderIndex,
@@ -59,8 +66,8 @@ export function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
59
66
  weight:
60
67
  suite.testkit?.weight ||
61
68
  (framework === "playwright"
62
- ? Math.max(2, suite.files.length)
63
- : Math.max(1, suite.files.length)),
69
+ ? Math.max(2, files.length)
70
+ : Math.max(1, files.length)),
64
71
  maxFileConcurrency:
65
72
  framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
66
73
  });
@@ -260,6 +267,10 @@ export function compareGraphsForAssignment(left, right) {
260
267
  return left.key.localeCompare(right.key);
261
268
  }
262
269
 
270
+ function normalizePathSeparators(filePath) {
271
+ return String(filePath).split("\\").join("/");
272
+ }
273
+
263
274
  export function buildGraphDirName(runtimeNames) {
264
275
  const slug = runtimeNames.map(slugSegment).join("__");
265
276
  return slug.length > 0 ? slug : "graph";
@@ -42,10 +42,17 @@ describe("runner-planning", () => {
42
42
  const config = makeConfig("api", {
43
43
  suites: {
44
44
  integration: [
45
- { name: "health", files: ["health.js"] },
45
+ { name: "health", files: ["tests/api/integration/health.int.testkit.ts"] },
46
46
  ],
47
47
  e2e: [
48
- { name: "auth", framework: "playwright", files: ["auth.spec.js", "signup.spec.js"] },
48
+ {
49
+ name: "auth",
50
+ framework: "playwright",
51
+ files: [
52
+ "tests/frontend/e2e/auth.pw.testkit.ts",
53
+ "tests/frontend/e2e/signup.pw.testkit.ts",
54
+ ],
55
+ },
49
56
  ],
50
57
  },
51
58
  });
@@ -62,6 +69,19 @@ describe("runner-planning", () => {
62
69
  framework: "playwright",
63
70
  weight: 2,
64
71
  });
72
+
73
+ expect(
74
+ collectSuites(
75
+ config,
76
+ "all",
77
+ [],
78
+ "all",
79
+ ["tests/frontend/e2e/signup.pw.testkit.ts"]
80
+ )[0]
81
+ ).toMatchObject({
82
+ name: "auth",
83
+ files: ["tests/frontend/e2e/signup.pw.testkit.ts"],
84
+ });
65
85
  });
66
86
 
67
87
  it("applies shards, builds graphs, queues tasks, and claims batches", () => {
@@ -31,11 +31,34 @@ export function buildServiceTrackers(servicePlans, startedAt) {
31
31
  completedFileCount: 0,
32
32
  failedFiles: [],
33
33
  failedFileSet: new Set(),
34
- fileResults: [],
35
- fileResultsByPath: new Map(),
34
+ fileResults: suite.files.map((file) => ({
35
+ path: normalizePathSeparators(file),
36
+ failed: false,
37
+ durationMs: 0,
38
+ error: null,
39
+ status: "not_run",
40
+ })),
41
+ fileResultsByPath: new Map(
42
+ suite.files.map((file) => {
43
+ const normalizedPath = normalizePathSeparators(file);
44
+ return [
45
+ normalizedPath,
46
+ {
47
+ path: normalizedPath,
48
+ failed: false,
49
+ durationMs: 0,
50
+ error: null,
51
+ status: "not_run",
52
+ },
53
+ ];
54
+ })
55
+ ),
36
56
  durationMs: 0,
37
57
  error: null,
38
58
  }));
59
+ for (const suite of suites) {
60
+ suite.fileResults = [...suite.fileResultsByPath.values()];
61
+ }
39
62
 
40
63
  trackers.set(plan.config.name, {
41
64
  name: plan.config.name,
@@ -73,12 +96,14 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
73
96
  existingFileResult.failed = outcome.failed;
74
97
  existingFileResult.durationMs = outcome.durationMs;
75
98
  existingFileResult.error = outcome.error;
99
+ existingFileResult.status = outcome.failed ? "failed" : "passed";
76
100
  } else {
77
101
  const fileResult = {
78
102
  path: normalizedPath,
79
103
  failed: outcome.failed,
80
104
  durationMs: outcome.durationMs,
81
105
  error: outcome.error,
106
+ status: outcome.failed ? "failed" : "passed",
82
107
  };
83
108
  suite.fileResultsByPath.set(normalizedPath, fileResult);
84
109
  suite.fileResults.push(fileResult);
@@ -164,6 +189,7 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
164
189
  .map((file) => ({
165
190
  path: file.path,
166
191
  failed: file.failed,
192
+ status: file.status,
167
193
  durationMs: file.durationMs,
168
194
  error: file.error,
169
195
  })),
@@ -172,6 +198,66 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
172
198
  };
173
199
  }
174
200
 
201
+ export function buildStatusArtifact({
202
+ productDir,
203
+ results,
204
+ metadata,
205
+ }) {
206
+ const executedResults = results.filter((result) => !result.skipped);
207
+ const tests = [];
208
+
209
+ for (const result of executedResults) {
210
+ for (const suite of result.suites) {
211
+ for (const file of suite.files) {
212
+ tests.push({
213
+ service: result.name,
214
+ type: suite.type,
215
+ framework: suite.framework,
216
+ path: file.path,
217
+ status: file.status,
218
+ });
219
+ }
220
+ }
221
+ }
222
+
223
+ tests.sort(
224
+ (left, right) =>
225
+ left.service.localeCompare(right.service) ||
226
+ left.type.localeCompare(right.type) ||
227
+ left.path.localeCompare(right.path)
228
+ );
229
+
230
+ const summary = {
231
+ services: {
232
+ total: executedResults.length,
233
+ passed: executedResults.filter((result) => !result.failed).length,
234
+ failed: executedResults.filter((result) => result.failed).length,
235
+ },
236
+ tests: {
237
+ total: tests.length,
238
+ passed: tests.filter((test) => test.status === "passed").length,
239
+ failed: tests.filter((test) => test.status === "failed").length,
240
+ notRun: tests.filter((test) => test.status === "not_run").length,
241
+ },
242
+ };
243
+
244
+ return {
245
+ schemaVersion: 1,
246
+ source: "testkit",
247
+ notice: "Generated file. Do not edit manually.",
248
+ product: {
249
+ name: path.basename(productDir),
250
+ },
251
+ git: {
252
+ branch: metadata.git?.branch || null,
253
+ commitSha: metadata.git?.commitSha || null,
254
+ },
255
+ testkitVersion: metadata.testkitVersion,
256
+ summary,
257
+ tests,
258
+ };
259
+ }
260
+
175
261
  export function buildRunArtifact({
176
262
  productDir,
177
263
  results,
@@ -181,6 +267,7 @@ export function buildRunArtifact({
181
267
  workerCount,
182
268
  suiteType,
183
269
  suiteNames,
270
+ fileNames,
184
271
  framework,
185
272
  shard,
186
273
  serviceFilter,
@@ -213,6 +300,7 @@ export function buildRunArtifact({
213
300
  dbBackend,
214
301
  suiteType,
215
302
  suiteNames,
303
+ fileNames,
216
304
  framework,
217
305
  shard,
218
306
  serviceFilter,
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  addTrackerError,
4
4
  buildRunArtifact,
5
+ buildStatusArtifact,
5
6
  buildServiceTrackers,
6
7
  finalizeServiceResult,
7
8
  formatDuration,
@@ -68,6 +69,7 @@ describe("runner-results", () => {
68
69
  {
69
70
  path: "tests/health.js",
70
71
  failed: true,
72
+ status: "failed",
71
73
  durationMs: 250,
72
74
  error: "boom",
73
75
  },
@@ -111,6 +113,7 @@ describe("runner-results", () => {
111
113
  workerCount: 1,
112
114
  suiteType: "all",
113
115
  suiteNames: [],
116
+ fileNames: [],
114
117
  framework: "all",
115
118
  shard: null,
116
119
  serviceFilter: null,
@@ -141,4 +144,84 @@ describe("runner-results", () => {
141
144
  ).toBe("1/3 suites passed, 1 not run");
142
145
  expect(formatError(new Error("boom"))).toBe("boom");
143
146
  });
147
+
148
+ it("builds deterministic status artifacts", () => {
149
+ const status = buildStatusArtifact({
150
+ productDir: "/tmp/my-product",
151
+ results: [
152
+ {
153
+ name: "api",
154
+ failed: true,
155
+ skipped: false,
156
+ suites: [
157
+ {
158
+ name: "health",
159
+ type: "integration",
160
+ framework: "k6",
161
+ files: [
162
+ { path: "tests/api/integration/a.int.testkit.ts", status: "passed" },
163
+ { path: "tests/api/integration/b.int.testkit.ts", status: "failed" },
164
+ ],
165
+ },
166
+ ],
167
+ },
168
+ ],
169
+ suiteType: "int",
170
+ suiteNames: ["health"],
171
+ fileNames: ["tests/api/integration/b.int.testkit.ts"],
172
+ framework: "k6",
173
+ shard: null,
174
+ serviceFilter: "api",
175
+ metadata: {
176
+ git: {
177
+ branch: "main",
178
+ commitSha: "abc123",
179
+ },
180
+ testkitVersion: "0.1.20",
181
+ },
182
+ });
183
+
184
+ expect(status).toEqual({
185
+ schemaVersion: 1,
186
+ source: "testkit",
187
+ notice: "Generated file. Do not edit manually.",
188
+ product: {
189
+ name: "my-product",
190
+ },
191
+ git: {
192
+ branch: "main",
193
+ commitSha: "abc123",
194
+ },
195
+ testkitVersion: "0.1.20",
196
+ summary: {
197
+ services: {
198
+ total: 1,
199
+ passed: 0,
200
+ failed: 1,
201
+ },
202
+ tests: {
203
+ total: 2,
204
+ passed: 1,
205
+ failed: 1,
206
+ notRun: 0,
207
+ },
208
+ },
209
+ tests: [
210
+ {
211
+ service: "api",
212
+ type: "integration",
213
+ framework: "k6",
214
+ path: "tests/api/integration/a.int.testkit.ts",
215
+ status: "passed",
216
+ },
217
+ {
218
+ service: "api",
219
+ type: "integration",
220
+ framework: "k6",
221
+ path: "tests/api/integration/b.int.testkit.ts",
222
+ status: "failed",
223
+ },
224
+ ],
225
+ });
226
+ });
144
227
  });
@@ -3,7 +3,6 @@ import fs from "fs";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
 
6
- const RUNNER_MANIFEST = "runner.manifest.json";
7
6
  const TESTKIT_CONFIG = "testkit.config.json";
8
7
  const DEFAULT_RUNTIME_DIR = path.join("tests", "_testkit");
9
8
  const METADATA_FILE = ".runtime-manifest.json";
@@ -124,7 +123,7 @@ function resolveProductDir(cwd, explicitDir) {
124
123
  }
125
124
 
126
125
  function ensureProductFiles(dir) {
127
- const missing = [RUNNER_MANIFEST, TESTKIT_CONFIG].filter(
126
+ const missing = [TESTKIT_CONFIG].filter(
128
127
  (file) => !fs.existsSync(path.join(dir, file))
129
128
  );
130
129
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.20",
4
- "description": "CLI for running manifest-defined local test suites across k6 and Playwright",
3
+ "version": "0.1.21",
4
+ "description": "CLI for discovering and running local test suites across k6 and Playwright",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./lib/index.mjs",