@elench/testkit 0.1.20 → 0.1.22

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,7 +102,8 @@ 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
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`
104
107
  2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, and database 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
@@ -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
+ - no test registration in config; `testkit` discovers `*.testkit.ts` files from the filesystem automatically
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,197 @@
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
+ const IGNORED_DIRS = new Set([
13
+ ".cache",
14
+ ".git",
15
+ ".hg",
16
+ ".next",
17
+ ".nuxt",
18
+ ".playwright-browsers",
19
+ ".svn",
20
+ ".testkit",
21
+ ".turbo",
22
+ "build",
23
+ "coverage",
24
+ "dist",
25
+ "node_modules",
26
+ "playwright-report",
27
+ "test-results",
28
+ ]);
29
+
30
+ export function discoverSuites(productDir, services) {
31
+ const serviceEntries = Object.entries(services || {});
32
+ const rules = serviceEntries.map(([serviceName, serviceConfig]) =>
33
+ buildServiceRule(serviceName, serviceConfig)
34
+ );
35
+ const groupedByService = Object.fromEntries(
36
+ serviceEntries.map(([serviceName]) => [serviceName, {}])
37
+ );
38
+ const discoveredFiles = discoverFiles(productDir);
39
+ const unowned = [];
40
+ const ambiguous = [];
41
+
42
+ for (const filePath of discoveredFiles) {
43
+ const rule = inferRule(filePath);
44
+ if (!rule) continue;
45
+
46
+ const matches = rules
47
+ .filter((serviceRule) => ownsFile(serviceRule, filePath))
48
+ .map((serviceRule) => serviceRule.name);
49
+
50
+ if (matches.length === 0) {
51
+ unowned.push(filePath);
52
+ continue;
53
+ }
54
+
55
+ if (matches.length > 1) {
56
+ ambiguous.push({
57
+ filePath,
58
+ serviceNames: matches.sort((left, right) => left.localeCompare(right)),
59
+ });
60
+ continue;
61
+ }
62
+
63
+ const serviceName = matches[0];
64
+ const serviceRule = rules.find((candidate) => candidate.name === serviceName);
65
+ const relativeToService = relativeToServiceRoot(serviceRule, filePath);
66
+ const suiteName = deriveSuiteName(relativeToService, rule.suffix);
67
+ const grouped = groupedByService[serviceName];
68
+ const suitesForType = grouped[rule.type] || [];
69
+ let suite = suitesForType.find(
70
+ (candidate) => candidate.name === suiteName && candidate.framework === rule.framework
71
+ );
72
+
73
+ if (!suite) {
74
+ suite = {
75
+ name: suiteName,
76
+ files: [],
77
+ framework: rule.framework,
78
+ };
79
+ suitesForType.push(suite);
80
+ grouped[rule.type] = suitesForType;
81
+ }
82
+
83
+ suite.files.push(filePath);
84
+ }
85
+
86
+ if (unowned.length > 0 || ambiguous.length > 0) {
87
+ throw buildDiscoveryError(unowned, ambiguous);
88
+ }
89
+
90
+ for (const grouped of Object.values(groupedByService)) {
91
+ for (const suites of Object.values(grouped)) {
92
+ for (const suite of suites) {
93
+ suite.files.sort((left, right) => left.localeCompare(right));
94
+ }
95
+ suites.sort((left, right) => left.name.localeCompare(right.name));
96
+ }
97
+ }
98
+
99
+ return groupedByService;
100
+ }
101
+
102
+ function discoverFiles(productDir) {
103
+ const files = [];
104
+ const queue = [productDir];
105
+
106
+ while (queue.length > 0) {
107
+ const current = queue.pop();
108
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
109
+ const absolutePath = path.join(current, entry.name);
110
+
111
+ if (entry.isSymbolicLink()) continue;
112
+
113
+ if (entry.isDirectory()) {
114
+ if (IGNORED_DIRS.has(entry.name)) continue;
115
+ queue.push(absolutePath);
116
+ continue;
117
+ }
118
+
119
+ if (!entry.isFile()) continue;
120
+
121
+ const relativePath = normalizePath(path.relative(productDir, absolutePath));
122
+ if (inferRule(relativePath)) files.push(relativePath);
123
+ }
124
+ }
125
+
126
+ return files.sort((left, right) => left.localeCompare(right));
127
+ }
128
+
129
+ function buildServiceRule(serviceName, serviceConfig) {
130
+ const testPrefix = normalizePath(path.posix.join("tests", serviceName));
131
+ const cwd = normalizePath(serviceConfig?.local?.cwd || ".");
132
+ return {
133
+ name: serviceName,
134
+ testPrefix,
135
+ cwdPrefix: cwd === "." ? null : cwd,
136
+ };
137
+ }
138
+
139
+ function ownsFile(serviceRule, filePath) {
140
+ if (hasPrefix(filePath, serviceRule.testPrefix)) return true;
141
+ if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) return true;
142
+ return false;
143
+ }
144
+
145
+ function relativeToServiceRoot(serviceRule, filePath) {
146
+ if (hasPrefix(filePath, serviceRule.testPrefix)) {
147
+ return path.posix.relative(serviceRule.testPrefix, filePath);
148
+ }
149
+ if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) {
150
+ return path.posix.relative(serviceRule.cwdPrefix, filePath);
151
+ }
152
+ return filePath;
153
+ }
154
+
155
+ function deriveSuiteName(relativePath, suffix) {
156
+ const parts = relativePath.split("/").filter(Boolean);
157
+ if (parts.length >= 3) return parts[1];
158
+ return path.posix.basename(relativePath, suffix);
159
+ }
160
+
161
+ function buildDiscoveryError(unowned, ambiguous) {
162
+ const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
163
+
164
+ if (unowned.length > 0) {
165
+ lines.push("");
166
+ lines.push("Unowned test files:");
167
+ for (const filePath of unowned) {
168
+ lines.push(`- ${filePath}`);
169
+ }
170
+ }
171
+
172
+ if (ambiguous.length > 0) {
173
+ lines.push("");
174
+ lines.push("Ambiguous test files:");
175
+ for (const entry of ambiguous) {
176
+ lines.push(`- ${entry.filePath} -> ${entry.serviceNames.join(", ")}`);
177
+ }
178
+ }
179
+
180
+ lines.push("");
181
+ lines.push('Expected test files to live under "tests/<service>/..." or a non-root service local.cwd directory.');
182
+ return new Error(lines.join("\n"));
183
+ }
184
+
185
+ function inferRule(filePath) {
186
+ return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
187
+ }
188
+
189
+ function hasPrefix(filePath, prefix) {
190
+ return filePath === prefix || filePath.startsWith(`${prefix}/`);
191
+ }
192
+
193
+ function normalizePath(value) {
194
+ const normalized = String(value).split(path.sep).join("/");
195
+ if (normalized === "." || normalized === "./") return ".";
196
+ return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "") || ".";
197
+ }
@@ -0,0 +1,102 @@
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 { discoverSuites } from "./discovery.mjs";
6
+
7
+ const cleanups = [];
8
+
9
+ afterEach(() => {
10
+ while (cleanups.length > 0) {
11
+ cleanups.pop()();
12
+ }
13
+ });
14
+
15
+ describe("filesystem-discovery", () => {
16
+ it("discovers tests by convention without config registration", () => {
17
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
18
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
19
+
20
+ writeFile(productDir, "tests/api/integration/health.int.testkit.ts");
21
+ writeFile(productDir, "tests/api/integration/auth/me.int.testkit.ts");
22
+ writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
23
+
24
+ const suites = discoverSuites(productDir, {
25
+ api: {
26
+ local: {
27
+ cwd: ".",
28
+ },
29
+ },
30
+ frontend: {
31
+ local: {
32
+ cwd: "frontend",
33
+ },
34
+ },
35
+ });
36
+
37
+ expect(suites.api.integration).toEqual([
38
+ {
39
+ name: "auth",
40
+ files: ["tests/api/integration/auth/me.int.testkit.ts"],
41
+ framework: "k6",
42
+ },
43
+ {
44
+ name: "health",
45
+ files: ["tests/api/integration/health.int.testkit.ts"],
46
+ framework: "k6",
47
+ },
48
+ ]);
49
+ expect(suites.frontend.e2e).toEqual([
50
+ {
51
+ name: "homepage",
52
+ files: ["frontend/e2e/homepage.pw.testkit.ts"],
53
+ framework: "playwright",
54
+ },
55
+ ]);
56
+ });
57
+
58
+ it("fails when a discovered file does not map to any configured service", () => {
59
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
60
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
61
+
62
+ writeFile(productDir, "tests/unknown/integration/health.int.testkit.ts");
63
+
64
+ expect(() =>
65
+ discoverSuites(productDir, {
66
+ api: {
67
+ local: {
68
+ cwd: ".",
69
+ },
70
+ },
71
+ })
72
+ ).toThrow("Unowned test files");
73
+ });
74
+
75
+ it("fails when a discovered file maps to multiple services", () => {
76
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
77
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
78
+
79
+ writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
80
+
81
+ expect(() =>
82
+ discoverSuites(productDir, {
83
+ frontend: {
84
+ local: {
85
+ cwd: "frontend",
86
+ },
87
+ },
88
+ web: {
89
+ local: {
90
+ cwd: "frontend",
91
+ },
92
+ },
93
+ })
94
+ ).toThrow("Ambiguous test files");
95
+ });
96
+ });
97
+
98
+ function writeFile(productDir, relativePath) {
99
+ const absolutePath = path.join(productDir, relativePath);
100
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
101
+ fs.writeFileSync(absolutePath, "export {};\n");
102
+ }
@@ -1,27 +1,19 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { fileURLToPath } from "url";
4
+ import { discoverSuites } 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,20 @@ 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);
37
+ const discoveredSuites = discoverSuites(productDir, config.services);
46
38
 
47
- const serviceEntries = Object.entries(runner.services);
39
+ const serviceEntries = Object.entries(config.services);
48
40
  const filtered = opts.service
49
41
  ? serviceEntries.filter(([name]) => name === opts.service)
50
42
  : serviceEntries;
@@ -54,14 +46,8 @@ export function loadConfigs(opts = {}) {
54
46
  throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
55
47
  }
56
48
 
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
-
49
+ return filtered.map(([name, serviceConfig]) => {
50
+ const suites = discoveredSuites[name] || {};
65
51
  const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
66
52
  const serviceEnv = loadServiceEnv(productDir, serviceConfig);
67
53
  const selectedBackend = resolvedDatabase?.selectedBackend;
@@ -69,7 +55,7 @@ export function loadConfigs(opts = {}) {
69
55
  const resolvedSeed = resolveLifecycleConfig(serviceConfig.seed, selectedBackend);
70
56
  validateMergedService(
71
57
  name,
72
- runnerService,
58
+ suites,
73
59
  serviceConfig,
74
60
  resolvedDatabase,
75
61
  resolvedMigrate,
@@ -82,7 +68,7 @@ export function loadConfigs(opts = {}) {
82
68
  productDir,
83
69
  stateDir: path.join(productDir, ".testkit", name),
84
70
  telemetry: normalizeTelemetryConfig(config.telemetry),
85
- suites: runnerService.suites,
71
+ suites,
86
72
  testkit: {
87
73
  ...serviceConfig,
88
74
  database: resolvedDatabase,
@@ -128,13 +114,6 @@ export function resolveServiceCwd(productDir, maybeRelative) {
128
114
  return path.resolve(productDir, maybeRelative || ".");
129
115
  }
130
116
 
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
117
  function loadTestkitConfig(productDir) {
139
118
  const configPath = path.join(productDir, TESTKIT_CONFIG);
140
119
  const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
@@ -154,8 +133,8 @@ function loadTestkitConfig(productDir) {
154
133
  return raw;
155
134
  }
156
135
 
157
- function validateConfigCoverage(runner, config) {
158
- return validateConfigCoverageModel(runner, config, TESTKIT_CONFIG, RUNNER_MANIFEST);
136
+ function validateConfigCoverage(config) {
137
+ return validateConfigCoverageModel(config, TESTKIT_CONFIG);
159
138
  }
160
139
 
161
140
  function resolveProductDir(cwd, explicitDir) {
@@ -170,7 +149,7 @@ function resolveProductDir(cwd, explicitDir) {
170
149
  }
171
150
 
172
151
  function ensureProductFiles(dir) {
173
- const missing = [RUNNER_MANIFEST, TESTKIT_CONFIG].filter(
152
+ const missing = [TESTKIT_CONFIG].filter(
174
153
  (file) => !fs.existsSync(path.join(dir, file))
175
154
  );
176
155
  if (missing.length > 0) {
@@ -182,18 +161,16 @@ function ensureProductFiles(dir) {
182
161
 
183
162
  function validateMergedService(
184
163
  name,
185
- runnerService,
164
+ suites,
186
165
  serviceConfig,
187
166
  resolvedDatabase,
188
167
  resolvedMigrate,
189
168
  resolvedSeed,
190
169
  productDir
191
170
  ) {
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)
171
+ const usesLocalExecution = Object.entries(suites).some(([suiteType, discoveredSuites]) =>
172
+ discoveredSuites.some(
173
+ (suite) => (suite.framework && suite.framework !== "k6") || suiteType !== "dal"
197
174
  )
198
175
  );
199
176
 
@@ -249,15 +226,6 @@ function validateMergedService(
249
226
  }
250
227
 
251
228
  }
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
229
  function loadServiceEnv(productDir, serviceConfig) {
262
230
  const env = {};
263
231
  for (const envFile of getServiceEnvFiles(serviceConfig)) {
@@ -274,34 +242,10 @@ function resolveSelectedDatabase(name, serviceConfig) {
274
242
  return resolveSelectedDatabaseModel(name, serviceConfig);
275
243
  }
276
244
 
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
245
  function resolveLifecycleConfig(value, selectedBackend) {
294
246
  return resolveLifecycleConfigModel(value, selectedBackend);
295
247
  }
296
248
 
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
249
  function isObject(value) {
306
250
  return isObjectModel(value);
307
251
  }
@@ -309,7 +253,3 @@ function isObject(value) {
309
253
  function normalizeTelemetryConfig(telemetry) {
310
254
  return normalizeTelemetryConfigModel(telemetry);
311
255
  }
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,12 @@ 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
+ throw new Error(
217
+ `Service "${name}" cannot define discovery. Testkit discovers *.testkit.ts files from the filesystem automatically.`
218
+ );
219
+ }
234
220
  }
235
221
 
236
222
  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,12 @@ 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
+ }, "testkit.config.json")
100
+ ).toThrow("cannot define discovery");
101
+
101
102
  expect(() =>
102
103
  validateTelemetryConfig(
103
104
  {
@@ -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.22",
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",