@elench/testkit 0.1.19 → 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,15 +1,11 @@
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 snap install k6
9
- sudo apt-get install -y jq
10
- ```
11
-
12
- For DAL suites, `@elench/testkit` ships its own `k6` SQL binary.
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.
13
9
 
14
10
  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`.
15
11
 
@@ -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
@@ -77,6 +79,12 @@ const suite = defineHttpSuite({ auth: clerkSessionAuth }, ({ req, setupData }) =
77
79
  `testkit` bundles these imports before invoking k6, so tests do not need
78
80
  generated `_testkit` files or direct package-manager path imports.
79
81
 
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
86
+ ```
87
+
80
88
  Legacy compatibility:
81
89
 
82
90
  - `testkit runtime install`
@@ -94,8 +102,9 @@ npx @elench/testkit --dir my-product api int -s health
94
102
 
95
103
  ## How it works
96
104
 
97
- 1. **Discovery** — reads `runner.manifest.json` for services, suites, files, and frameworks
98
- 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
99
108
  Per-service `.env` files declared in config are loaded when present.
100
109
  3. **Database** — provisions Docker-managed local Postgres when a service declares one
101
110
  4. **Seed** — runs optional product seed commands against the provisioned database
@@ -105,16 +114,17 @@ npx @elench/testkit --dir my-product api int -s health
105
114
 
106
115
  Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
107
116
 
108
- `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.
109
118
 
110
119
  ## File roles
111
120
 
112
- - `runner.manifest.json`: canonical test inventory
113
121
  - `testkit.config.json`: local execution and provisioning config
122
+ - `testkit.status.json`: optional deterministic run snapshot written by `--write-status`
114
123
 
115
124
  `testkit.config.json` can also declare:
116
125
 
117
126
  - `telemetry` for optional generic HTTP result upload
127
+ - `discovery.include` / `discovery.exclude` for per-service test discovery
118
128
  - `envFile` / `envFiles` for service-specific environment loading
119
129
  - `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
120
130
  - `database.provider` for local Postgres settings
@@ -139,24 +149,6 @@ Use `--shard <i/n>` to split the selected suite set deterministically before wor
139
149
 
140
150
  `testkit` also writes `.testkit/timings.json` and uses those file timings on later runs for longest-first balancing.
141
151
 
142
- ## Suite metadata
143
-
144
- `runner.manifest.json` remains the source of truth for suites. Optional per-suite `testkit` metadata can tune execution:
145
-
146
- ```json
147
- {
148
- "name": "health",
149
- "files": ["tests/example.js"],
150
- "testkit": {
151
- "maxFileConcurrency": 2,
152
- "weight": 3
153
- }
154
- }
155
- ```
156
-
157
- - `maxFileConcurrency`: k6-only opt-in for batching multiple files from the same suite onto one worker
158
- - `weight`: optional fallback scheduling weight when no file timing history exists yet
159
-
160
152
  ## Schema
161
153
 
162
154
  See [testkit-config-schema.md](testkit-config-schema.md).
@@ -173,5 +165,4 @@ npm run test:system
173
165
  `test:system` runs real end-to-end fixtures from `test/fixtures/system/`. It requires:
174
166
 
175
167
  - Docker with a running daemon
176
- - `k6` on `PATH`
177
168
  - Playwright Chromium browser assets available to `@playwright/test`
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,28 +1,21 @@
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";
18
+ const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
26
19
  export function parseDotenv(filePath) {
27
20
  if (!fs.existsSync(filePath)) return {};
28
21
  return parseDotenvString(fs.readFileSync(filePath, "utf8"));
@@ -30,20 +23,19 @@ export function parseDotenv(filePath) {
30
23
 
31
24
  export function getServiceNames(cwd) {
32
25
  const dir = cwd || process.cwd();
33
- const runnerPath = path.join(dir, RUNNER_MANIFEST);
34
- if (!fs.existsSync(runnerPath)) return [];
35
- const runner = JSON.parse(fs.readFileSync(runnerPath, "utf8"));
36
- if (!isObject(runner.services)) return [];
37
- 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);
38
31
  }
39
32
 
40
33
  export function loadConfigs(opts = {}) {
41
34
  const productDir = resolveProductDir(process.cwd(), opts.dir);
42
- const runner = loadRunnerManifest(productDir);
43
35
  const config = loadTestkitConfig(productDir);
44
- validateConfigCoverage(runner, config);
36
+ validateConfigCoverage(config);
45
37
 
46
- const serviceEntries = Object.entries(runner.services);
38
+ const serviceEntries = Object.entries(config.services);
47
39
  const filtered = opts.service
48
40
  ? serviceEntries.filter(([name]) => name === opts.service)
49
41
  : serviceEntries;
@@ -53,14 +45,8 @@ export function loadConfigs(opts = {}) {
53
45
  throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
54
46
  }
55
47
 
56
- return filtered.map(([name, runnerService]) => {
57
- const serviceConfig = config.services[name];
58
- if (!serviceConfig) {
59
- throw new Error(
60
- `Service "${name}" exists in ${RUNNER_MANIFEST} but is missing from ${TESTKIT_CONFIG}`
61
- );
62
- }
63
-
48
+ return filtered.map(([name, serviceConfig]) => {
49
+ const suites = discoverServiceSuites(productDir, name, serviceConfig);
64
50
  const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
65
51
  const serviceEnv = loadServiceEnv(productDir, serviceConfig);
66
52
  const selectedBackend = resolvedDatabase?.selectedBackend;
@@ -68,7 +54,7 @@ export function loadConfigs(opts = {}) {
68
54
  const resolvedSeed = resolveLifecycleConfig(serviceConfig.seed, selectedBackend);
69
55
  validateMergedService(
70
56
  name,
71
- runnerService,
57
+ suites,
72
58
  serviceConfig,
73
59
  resolvedDatabase,
74
60
  resolvedMigrate,
@@ -81,7 +67,7 @@ export function loadConfigs(opts = {}) {
81
67
  productDir,
82
68
  stateDir: path.join(productDir, ".testkit", name),
83
69
  telemetry: normalizeTelemetryConfig(config.telemetry),
84
- suites: runnerService.suites,
70
+ suites,
85
71
  testkit: {
86
72
  ...serviceConfig,
87
73
  database: resolvedDatabase,
@@ -94,24 +80,37 @@ export function loadConfigs(opts = {}) {
94
80
  });
95
81
  }
96
82
 
97
- export function resolveDalBinary() {
83
+ export function resolveK6Binary() {
84
+ const override = process.env[TESTKIT_K6_BIN]?.trim();
85
+ if (override) {
86
+ const isPathLike =
87
+ path.isAbsolute(override) ||
88
+ override.includes(path.sep) ||
89
+ override.includes(path.posix.sep) ||
90
+ override.includes(path.win32.sep);
91
+ const overridePath = isPathLike ? path.resolve(process.cwd(), override) : override;
92
+
93
+ if (isPathLike && !fs.existsSync(overridePath)) {
94
+ throw new Error(`${TESTKIT_K6_BIN} points to a missing file: ${overridePath}`);
95
+ }
96
+
97
+ return overridePath;
98
+ }
99
+
98
100
  const thisFile = fileURLToPath(import.meta.url);
99
101
  const abs = path.resolve(path.dirname(thisFile), "..", "..", "vendor", "k6");
100
102
  if (!fs.existsSync(abs)) {
101
- throw new Error(`Bundled DAL k6 binary not found: ${abs}`);
103
+ throw new Error(`Bundled k6 binary not found: ${abs}`);
102
104
  }
103
105
  return abs;
104
106
  }
105
107
 
106
- export function resolveServiceCwd(productDir, maybeRelative) {
107
- return path.resolve(productDir, maybeRelative || ".");
108
+ export function resolveDalBinary() {
109
+ return resolveK6Binary();
108
110
  }
109
111
 
110
- function loadRunnerManifest(productDir) {
111
- const manifestPath = path.join(productDir, RUNNER_MANIFEST);
112
- const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
113
- validateRunnerManifest(raw, RUNNER_MANIFEST, manifestPath);
114
- return raw;
112
+ export function resolveServiceCwd(productDir, maybeRelative) {
113
+ return path.resolve(productDir, maybeRelative || ".");
115
114
  }
116
115
 
117
116
  function loadTestkitConfig(productDir) {
@@ -133,8 +132,8 @@ function loadTestkitConfig(productDir) {
133
132
  return raw;
134
133
  }
135
134
 
136
- function validateConfigCoverage(runner, config) {
137
- return validateConfigCoverageModel(runner, config, TESTKIT_CONFIG, RUNNER_MANIFEST);
135
+ function validateConfigCoverage(config) {
136
+ return validateConfigCoverageModel(config, TESTKIT_CONFIG);
138
137
  }
139
138
 
140
139
  function resolveProductDir(cwd, explicitDir) {
@@ -149,7 +148,7 @@ function resolveProductDir(cwd, explicitDir) {
149
148
  }
150
149
 
151
150
  function ensureProductFiles(dir) {
152
- const missing = [RUNNER_MANIFEST, TESTKIT_CONFIG].filter(
151
+ const missing = [TESTKIT_CONFIG].filter(
153
152
  (file) => !fs.existsSync(path.join(dir, file))
154
153
  );
155
154
  if (missing.length > 0) {
@@ -161,18 +160,16 @@ function ensureProductFiles(dir) {
161
160
 
162
161
  function validateMergedService(
163
162
  name,
164
- runnerService,
163
+ suites,
165
164
  serviceConfig,
166
165
  resolvedDatabase,
167
166
  resolvedMigrate,
168
167
  resolvedSeed,
169
168
  productDir
170
169
  ) {
171
- const usesLocalExecution = Object.values(runnerService.suites).some((suites) =>
172
- suites.some(
173
- (suite) =>
174
- (suite.framework && suite.framework !== "k6") ||
175
- !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"
176
173
  )
177
174
  );
178
175
 
@@ -228,15 +225,6 @@ function validateMergedService(
228
225
  }
229
226
 
230
227
  }
231
-
232
- function validateServiceConfig(name, service, configPath) {
233
- return validateServiceConfigModel(name, service, configPath);
234
- }
235
-
236
- function validateTelemetryConfig(telemetry, configPath) {
237
- return validateTelemetryConfigModel(telemetry, configPath, TESTKIT_CONFIG);
238
- }
239
-
240
228
  function loadServiceEnv(productDir, serviceConfig) {
241
229
  const env = {};
242
230
  for (const envFile of getServiceEnvFiles(serviceConfig)) {
@@ -253,34 +241,10 @@ function resolveSelectedDatabase(name, serviceConfig) {
253
241
  return resolveSelectedDatabaseModel(name, serviceConfig);
254
242
  }
255
243
 
256
- function validateDatabaseProviderConfig(name, db, label) {
257
- return validateDatabaseProviderConfigModel(name, db, label);
258
- }
259
-
260
- function validateTemplateConfig(name, template, label) {
261
- return validateTemplateConfigModel(name, template, label);
262
- }
263
-
264
- function normalizeTemplateConfig(template) {
265
- return normalizeTemplateConfigModel(template);
266
- }
267
-
268
- function validateLifecycleConfig(name, value, label) {
269
- return validateLifecycleConfigModel(name, value, label);
270
- }
271
-
272
244
  function resolveLifecycleConfig(value, selectedBackend) {
273
245
  return resolveLifecycleConfigModel(value, selectedBackend);
274
246
  }
275
247
 
276
- function requireString(obj, key, label) {
277
- return requireStringModel(obj, key, label);
278
- }
279
-
280
- function isDalSuiteType(suite, runnerService, suitesForType) {
281
- return isDalSuiteTypeModel(suite, runnerService, suitesForType);
282
- }
283
-
284
248
  function isObject(value) {
285
249
  return isObjectModel(value);
286
250
  }
@@ -288,7 +252,3 @@ function isObject(value) {
288
252
  function normalizeTelemetryConfig(telemetry) {
289
253
  return normalizeTelemetryConfigModel(telemetry);
290
254
  }
291
-
292
- function validateHttpUrl(value, label) {
293
- return validateHttpUrlModel(value, label);
294
- }
@@ -1,12 +1,44 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { describe, expect, it } from "vitest";
4
- import { resolveDalBinary } from "./index.mjs";
3
+ import os from "os";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { resolveDalBinary, resolveK6Binary } from "./index.mjs";
6
+
7
+ const originalK6Bin = process.env.TESTKIT_K6_BIN;
8
+
9
+ afterEach(() => {
10
+ if (originalK6Bin === undefined) {
11
+ delete process.env.TESTKIT_K6_BIN;
12
+ } else {
13
+ process.env.TESTKIT_K6_BIN = originalK6Bin;
14
+ }
15
+ });
5
16
 
6
17
  describe("config-index", () => {
7
- it("resolves the bundled DAL k6 binary from the package root", () => {
8
- const binaryPath = resolveDalBinary();
18
+ it("resolves the bundled k6 binary from the package root", () => {
19
+ const binaryPath = resolveK6Binary();
9
20
  expect(path.basename(binaryPath)).toBe("k6");
10
21
  expect(fs.existsSync(binaryPath)).toBe(true);
11
22
  });
23
+
24
+ it("keeps resolveDalBinary aligned with the shared k6 resolver", () => {
25
+ expect(resolveDalBinary()).toBe(resolveK6Binary());
26
+ });
27
+
28
+ it("uses TESTKIT_K6_BIN when provided with a relative path", () => {
29
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-k6-"));
30
+ const binPath = path.join(tempDir, "k6-custom");
31
+ fs.writeFileSync(binPath, "#!/usr/bin/env bash\nexit 0\n");
32
+ process.env.TESTKIT_K6_BIN = path.relative(process.cwd(), binPath);
33
+
34
+ expect(resolveK6Binary()).toBe(binPath);
35
+ });
36
+
37
+ it("throws a clear error when TESTKIT_K6_BIN points at a missing path", () => {
38
+ process.env.TESTKIT_K6_BIN = "./definitely-missing-k6-bin";
39
+
40
+ expect(() => resolveK6Binary()).toThrow(
41
+ /TESTKIT_K6_BIN points to a missing file/
42
+ );
43
+ });
12
44
  });
@@ -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,9 +2,10 @@ 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
- import { resolveDalBinary, resolveServiceCwd } from "../config/index.mjs";
8
+ import { resolveK6Binary, resolveServiceCwd } from "../config/index.mjs";
8
9
  import {
9
10
  cleanupOrphanedLocalInfrastructure,
10
11
  destroyRuntimeDatabase,
@@ -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,
@@ -521,6 +549,7 @@ async function runHttpK6Batch(targetConfig, batch) {
521
549
 
522
550
  async function runHttpK6Task(targetConfig, task, baseUrl) {
523
551
  const absFile = path.join(targetConfig.productDir, task.file);
552
+ const k6Binary = resolveK6Binary();
524
553
  const bundledFile = await bundleK6File({
525
554
  productDir: targetConfig.productDir,
526
555
  serviceName: targetConfig.name,
@@ -529,7 +558,7 @@ async function runHttpK6Task(targetConfig, task, baseUrl) {
529
558
  console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
530
559
  const startedAt = Date.now();
531
560
  try {
532
- await execa("k6", ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, bundledFile], {
561
+ await execa(k6Binary, ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, bundledFile], {
533
562
  cwd: targetConfig.productDir,
534
563
  env: buildExecutionEnv(targetConfig),
535
564
  stdio: "inherit",
@@ -567,7 +596,7 @@ async function runDalBatch(targetConfig, batch) {
567
596
 
568
597
  async function runDalTask(targetConfig, task, databaseUrl) {
569
598
  const absFile = path.join(targetConfig.productDir, task.file);
570
- const k6Binary = resolveDalBinary();
599
+ const k6Binary = resolveK6Binary();
571
600
  const bundledFile = await bundleK6File({
572
601
  productDir: targetConfig.productDir,
573
602
  serviceName: targetConfig.name,
@@ -617,10 +646,11 @@ async function runPlaywrightBatch(targetConfig, batch) {
617
646
  const requestedFiles = batch.tasks.map((task) =>
618
647
  path.relative(cwd, path.join(targetConfig.productDir, task.file))
619
648
  );
649
+ const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles);
620
650
  const startedAt = Date.now();
621
651
  const result = await execa(
622
652
  "npx",
623
- ["playwright", "test", "--reporter=json", ...requestedFiles],
653
+ ["playwright", "test", "--config", playwrightConfigPath, "--reporter=json", ...requestedFiles],
624
654
  {
625
655
  cwd,
626
656
  env: buildPlaywrightEnv(targetConfig, local.baseUrl),
@@ -723,8 +753,8 @@ function resolveRuntimeConfigs(targetConfig, configMap) {
723
753
  return resolveRuntimeConfigsModel(targetConfig, configMap);
724
754
  }
725
755
 
726
- function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
727
- return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter);
756
+ function collectSuites(config, suiteType, suiteNames, frameworkFilter, fileNames = []) {
757
+ return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter, fileNames);
728
758
  }
729
759
 
730
760
  function applyShard(suites, shard) {
@@ -890,9 +920,11 @@ function buildRunArtifact({
890
920
  workerCount,
891
921
  suiteType,
892
922
  suiteNames,
923
+ fileNames,
893
924
  framework,
894
925
  shard,
895
926
  serviceFilter,
927
+ metadata,
896
928
  }) {
897
929
  return buildRunArtifactModel({
898
930
  productDir,
@@ -903,17 +935,11 @@ function buildRunArtifact({
903
935
  workerCount,
904
936
  suiteType,
905
937
  suiteNames,
938
+ fileNames,
906
939
  framework,
907
940
  shard,
908
941
  serviceFilter,
909
- metadata: {
910
- git: collectGitMetadata(productDir),
911
- host: {
912
- hostname: safeHostname(),
913
- username: safeUsername(),
914
- },
915
- testkitVersion: readPackageMetadata().version,
916
- },
942
+ metadata,
917
943
  });
918
944
  }
919
945
 
@@ -923,6 +949,73 @@ function writeRunArtifact(productDir, artifact) {
923
949
  fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
924
950
  }
925
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
+
926
1019
  function summarizeDbBackend(results) {
927
1020
  return summarizeDbBackendModel(results);
928
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.19",
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",