@elench/testkit 0.1.24 → 0.1.25

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/lib/cli/args.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import path from "path";
2
+
1
3
  export const SUITE_TYPES = new Set(["int", "integration", "e2e", "dal", "load", "all"]);
2
4
  export const LIFECYCLE = new Set(["status", "destroy"]);
3
5
  export const RESERVED = new Set([...SUITE_TYPES, ...LIFECYCLE]);
@@ -56,3 +58,37 @@ export function parseShardOption(value) {
56
58
 
57
59
  return { index, total };
58
60
  }
61
+
62
+ export function resolveRequestedFiles(fileNames, productDir, invocationCwd = process.cwd()) {
63
+ const resolved = [];
64
+ const seen = new Set();
65
+
66
+ for (const rawFile of fileNames || []) {
67
+ const normalized = resolveRequestedFile(rawFile, productDir, invocationCwd);
68
+ if (seen.has(normalized)) continue;
69
+ seen.add(normalized);
70
+ resolved.push(normalized);
71
+ }
72
+
73
+ return resolved;
74
+ }
75
+
76
+ function resolveRequestedFile(rawFile, productDir, invocationCwd) {
77
+ const rawValue = String(rawFile);
78
+ const candidates = path.isAbsolute(rawValue)
79
+ ? [rawValue]
80
+ : [path.resolve(invocationCwd, rawValue), path.resolve(productDir, rawValue)];
81
+
82
+ for (const candidate of candidates) {
83
+ const relative = normalizePathSeparators(path.relative(productDir, candidate));
84
+ if (!relative.startsWith("../") && relative !== ".." && !path.isAbsolute(relative)) {
85
+ return relative.replace(/^\.\/+/, "");
86
+ }
87
+ }
88
+
89
+ return normalizePathSeparators(rawValue).replace(/^\.\/+/, "");
90
+ }
91
+
92
+ function normalizePathSeparators(filePath) {
93
+ return String(filePath).split(path.sep).join("/");
94
+ }
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  parseJobsOption,
4
4
  parseShardOption,
5
+ resolveRequestedFiles,
5
6
  resolveCliSelection,
6
7
  validateFrameworkOption,
7
8
  } from "./args.mjs";
@@ -60,4 +61,18 @@ describe("cli-args", () => {
60
61
  expect(() => parseShardOption("2-of-5")).toThrow("Invalid --shard value");
61
62
  expect(() => parseShardOption("3/2")).toThrow("Expected 1 <= i <= n");
62
63
  });
64
+
65
+ it("normalizes requested file paths against the product directory", () => {
66
+ expect(
67
+ resolveRequestedFiles(
68
+ [
69
+ "/tmp/product/tests/api/integration/health.int.testkit.ts",
70
+ "./tests/api/integration/health.int.testkit.ts",
71
+ "product/tests/api/integration/health.int.testkit.ts",
72
+ ],
73
+ "/tmp/product",
74
+ "/tmp"
75
+ )
76
+ ).toEqual(["tests/api/integration/health.int.testkit.ts"]);
77
+ });
63
78
  });
package/lib/cli/index.mjs CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  parseJobsOption,
5
5
  parseShardOption,
6
6
  RESERVED,
7
+ resolveRequestedFiles,
7
8
  resolveCliSelection,
8
9
  validateFrameworkOption,
9
10
  } from "./args.mjs";
@@ -52,6 +53,7 @@ export function run() {
52
53
  default: "all",
53
54
  })
54
55
  .option("--write-status", "Write a deterministic testkit.status.json snapshot")
56
+ .option("--allow-partial-status", "Allow --write-status for filtered runs")
55
57
  .action(async (first, second, third, options) => {
56
58
  // Resolve: service filter, suite type, and --dir.
57
59
  //
@@ -99,7 +101,9 @@ export function run() {
99
101
 
100
102
  const suiteType = type || "all";
101
103
  const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
102
- const fileNames = Array.isArray(options.file) ? options.file : [options.file].filter(Boolean);
104
+ const rawFileNames = Array.isArray(options.file) ? options.file : [options.file].filter(Boolean);
105
+ const productDir = allConfigs[0]?.productDir || process.cwd();
106
+ const fileNames = resolveRequestedFiles(rawFileNames, productDir, process.cwd());
103
107
  await runner.runAll(
104
108
  configs,
105
109
  suiteType,
@@ -110,6 +114,7 @@ export function run() {
110
114
  fileNames,
111
115
  jobs,
112
116
  shard,
117
+ serviceFilter: service,
113
118
  },
114
119
  allConfigs
115
120
  );
@@ -108,6 +108,28 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
108
108
  },
109
109
  testkitVersion: readPackageMetadata().version,
110
110
  };
111
+ const requestedFiles = opts.fileNames || [];
112
+ if (requestedFiles.length > 0) {
113
+ const unmatchedFiles = findUnmatchedRequestedFiles(
114
+ configs,
115
+ suiteType,
116
+ suiteNames,
117
+ opts.framework || "all",
118
+ requestedFiles
119
+ );
120
+ if (unmatchedFiles.length > 0) {
121
+ throw new Error(
122
+ `Requested file${unmatchedFiles.length === 1 ? "" : "s"} did not match any selected suites:\n` +
123
+ unmatchedFiles.map((file) => `- ${file}`).join("\n")
124
+ );
125
+ }
126
+ }
127
+ if (opts.writeStatus && !opts.allowPartialStatus && !isFullRunSelection(suiteType, suiteNames, requestedFiles, opts)) {
128
+ throw new Error(
129
+ "Refusing to overwrite testkit.status.json from a filtered run. " +
130
+ "Run the full suite with --write-status, or pass --allow-partial-status to opt in."
131
+ );
132
+ }
111
133
  const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
112
134
  const trackers = buildServiceTrackers(servicePlans, startedAt);
113
135
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
@@ -155,10 +177,10 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
155
177
  workerCount,
156
178
  suiteType,
157
179
  suiteNames,
158
- fileNames: opts.fileNames || [],
180
+ fileNames: requestedFiles,
159
181
  framework: opts.framework || "all",
160
182
  shard: opts.shard || null,
161
- serviceFilter: configs.length === 1 ? configs[0].name : null,
183
+ serviceFilter: opts.serviceFilter || null,
162
184
  metadata,
163
185
  });
164
186
 
@@ -171,10 +193,10 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
171
193
  results,
172
194
  suiteType,
173
195
  suiteNames,
174
- fileNames: opts.fileNames || [],
196
+ fileNames: requestedFiles,
175
197
  framework: opts.framework || "all",
176
198
  shard: opts.shard || null,
177
- serviceFilter: configs.length === 1 ? configs[0].name : null,
199
+ serviceFilter: opts.serviceFilter || null,
178
200
  metadata,
179
201
  })
180
202
  );
@@ -556,27 +578,14 @@ async function runHttpK6Task(targetConfig, task, baseUrl) {
556
578
  sourceFile: absFile,
557
579
  });
558
580
  console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
559
- const startedAt = Date.now();
560
- try {
561
- await execa(k6Binary, ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, bundledFile], {
562
- cwd: targetConfig.productDir,
563
- env: buildExecutionEnv(targetConfig),
564
- stdio: "inherit",
565
- });
566
- return {
567
- task,
568
- failed: false,
569
- error: null,
570
- durationMs: Date.now() - startedAt,
571
- };
572
- } catch (error) {
573
- return {
574
- task,
575
- failed: true,
576
- error: formatError(error),
577
- durationMs: Date.now() - startedAt,
578
- };
579
- }
581
+ return runDefaultRuntimeTask(targetConfig, task, [
582
+ "run",
583
+ "--address",
584
+ "127.0.0.1:0",
585
+ "-e",
586
+ `BASE_URL=${baseUrl}`,
587
+ bundledFile,
588
+ ]);
580
589
  }
581
590
 
582
591
  async function runDalBatch(targetConfig, batch) {
@@ -603,31 +612,14 @@ async function runDalTask(targetConfig, task, databaseUrl) {
603
612
  sourceFile: absFile,
604
613
  });
605
614
  console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
606
- const startedAt = Date.now();
607
- try {
608
- await execa(
609
- k6Binary,
610
- ["run", "--address", "127.0.0.1:0", "-e", `DATABASE_URL=${databaseUrl}`, bundledFile],
611
- {
612
- cwd: targetConfig.productDir,
613
- env: buildExecutionEnv(targetConfig),
614
- stdio: "inherit",
615
- }
616
- );
617
- return {
618
- task,
619
- failed: false,
620
- error: null,
621
- durationMs: Date.now() - startedAt,
622
- };
623
- } catch (error) {
624
- return {
625
- task,
626
- failed: true,
627
- error: formatError(error),
628
- durationMs: Date.now() - startedAt,
629
- };
630
- }
615
+ return runDefaultRuntimeTask(targetConfig, task, [
616
+ "run",
617
+ "--address",
618
+ "127.0.0.1:0",
619
+ "-e",
620
+ `DATABASE_URL=${databaseUrl}`,
621
+ bundledFile,
622
+ ]);
631
623
  }
632
624
 
633
625
  async function runPlaywrightBatch(targetConfig, batch) {
@@ -968,11 +960,23 @@ function writeRunArtifact(productDir, artifact) {
968
960
  function buildStatusArtifact({
969
961
  productDir,
970
962
  results,
963
+ suiteType,
964
+ suiteNames,
965
+ fileNames,
966
+ framework,
967
+ shard,
968
+ serviceFilter,
971
969
  metadata,
972
970
  }) {
973
971
  return buildStatusArtifactModel({
974
972
  productDir,
975
973
  results,
974
+ suiteType,
975
+ suiteNames,
976
+ fileNames,
977
+ framework,
978
+ shard,
979
+ serviceFilter,
976
980
  metadata,
977
981
  });
978
982
  }
@@ -1064,6 +1068,121 @@ function formatError(error) {
1064
1068
  return formatErrorModel(error);
1065
1069
  }
1066
1070
 
1071
+ async function runDefaultRuntimeTask(targetConfig, task, args) {
1072
+ const k6Binary = resolveK6Binary();
1073
+ const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
1074
+ fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
1075
+ const startedAt = Date.now();
1076
+ const result = await execa(k6Binary, [...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)], {
1077
+ cwd: targetConfig.productDir,
1078
+ env: buildExecutionEnv(targetConfig),
1079
+ reject: false,
1080
+ });
1081
+
1082
+ if (result.stdout) process.stdout.write(result.stdout);
1083
+ if (result.stderr) process.stderr.write(result.stderr);
1084
+
1085
+ const summary = readDefaultRuntimeSummary(summaryFile);
1086
+ const runtimeError = determineDefaultRuntimeFailure(result, summary);
1087
+
1088
+ return {
1089
+ task,
1090
+ failed: runtimeError !== null,
1091
+ error: runtimeError,
1092
+ durationMs: Date.now() - startedAt,
1093
+ };
1094
+ }
1095
+
1096
+ function buildDefaultRuntimeSummaryPath(targetConfig, task) {
1097
+ return path.join(
1098
+ targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit"),
1099
+ "_runtime",
1100
+ `task-${task.id}.summary.json`
1101
+ );
1102
+ }
1103
+
1104
+ function readDefaultRuntimeSummary(filePath) {
1105
+ try {
1106
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
1107
+ } catch {
1108
+ return null;
1109
+ }
1110
+ }
1111
+
1112
+ function determineDefaultRuntimeFailure(result, summary) {
1113
+ const fatalRuntimeError = extractDefaultRuntimeFatalError(result.stderr || "");
1114
+ if (fatalRuntimeError) {
1115
+ return `Default runtime uncaught error: ${fatalRuntimeError}`;
1116
+ }
1117
+
1118
+ const failedThresholds = extractDefaultRuntimeThresholdFailures(summary);
1119
+ if (failedThresholds.length > 0) {
1120
+ return `Default runtime thresholds failed: ${failedThresholds.join(", ")}`;
1121
+ }
1122
+
1123
+ if (result.exitCode !== 0) {
1124
+ return sanitizeDefaultRuntimeExitError(result.exitCode, result.stderr || result.stdout || "");
1125
+ }
1126
+
1127
+ return null;
1128
+ }
1129
+
1130
+ function extractDefaultRuntimeFatalError(stderr) {
1131
+ if (!stderr || !/source=stacktrace/.test(stderr)) return null;
1132
+ const matched = stderr.match(/Error:\s([^\n]+)/);
1133
+ return matched?.[1]?.trim() || firstLine(stderr);
1134
+ }
1135
+
1136
+ function extractDefaultRuntimeThresholdFailures(summary) {
1137
+ const metrics = summary?.metrics;
1138
+ if (!metrics || typeof metrics !== "object") return [];
1139
+
1140
+ const failures = [];
1141
+ for (const [metricName, metricSummary] of Object.entries(metrics)) {
1142
+ const thresholds = metricSummary?.thresholds;
1143
+ if (!thresholds || typeof thresholds !== "object") continue;
1144
+ for (const [threshold, crossed] of Object.entries(thresholds)) {
1145
+ if (crossed === true) {
1146
+ failures.push(`${metricName}(${threshold})`);
1147
+ }
1148
+ }
1149
+ }
1150
+
1151
+ return failures.sort();
1152
+ }
1153
+
1154
+ function sanitizeDefaultRuntimeExitError(exitCode, output) {
1155
+ const message = firstLine(output);
1156
+ if (message) {
1157
+ return `Default runtime failed with exit code ${exitCode}: ${message}`;
1158
+ }
1159
+ return `Default runtime failed with exit code ${exitCode}`;
1160
+ }
1161
+
1162
+ function findUnmatchedRequestedFiles(configs, suiteType, suiteNames, framework, fileNames) {
1163
+ const matchedFiles = new Set();
1164
+ for (const config of configs) {
1165
+ const suites = collectSuites(config, suiteType, suiteNames, framework, []);
1166
+ for (const suite of suites) {
1167
+ for (const file of suite.files) {
1168
+ matchedFiles.add(normalizePathSeparators(file));
1169
+ }
1170
+ }
1171
+ }
1172
+
1173
+ return [...new Set(fileNames.map(normalizePathSeparators))].filter((file) => !matchedFiles.has(file));
1174
+ }
1175
+
1176
+ function isFullRunSelection(suiteType, suiteNames, fileNames, opts) {
1177
+ return (
1178
+ (suiteNames || []).length === 0 &&
1179
+ (fileNames || []).length === 0 &&
1180
+ (opts.framework || "all") === "all" &&
1181
+ (opts.shard || null) === null &&
1182
+ (opts.serviceFilter || null) === null
1183
+ );
1184
+ }
1185
+
1067
1186
  function loadTimings(productDir) {
1068
1187
  const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
1069
1188
  if (!fs.existsSync(filePath)) {
@@ -201,6 +201,12 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
201
201
  export function buildStatusArtifact({
202
202
  productDir,
203
203
  results,
204
+ suiteType,
205
+ suiteNames,
206
+ fileNames,
207
+ framework,
208
+ shard,
209
+ serviceFilter,
204
210
  metadata,
205
211
  }) {
206
212
  const executedResults = results.filter((result) => !result.skipped);
@@ -240,6 +246,21 @@ export function buildStatusArtifact({
240
246
  },
241
247
  };
242
248
 
249
+ const scope = {
250
+ suiteType,
251
+ suiteNames: [...(suiteNames || [])].sort(),
252
+ fileNames: [...(fileNames || [])].sort(),
253
+ framework: formatFrameworkForArtifact(framework || "all"),
254
+ shard: shard || null,
255
+ serviceFilter: serviceFilter || null,
256
+ };
257
+ scope.isFullRun =
258
+ scope.suiteNames.length === 0 &&
259
+ scope.fileNames.length === 0 &&
260
+ scope.framework === "all" &&
261
+ scope.shard === null &&
262
+ scope.serviceFilter === null;
263
+
243
264
  return {
244
265
  schemaVersion: 1,
245
266
  source: "testkit",
@@ -252,6 +273,7 @@ export function buildStatusArtifact({
252
273
  commitSha: metadata.git?.commitSha || null,
253
274
  },
254
275
  testkitVersion: metadata.testkitVersion,
276
+ scope,
255
277
  summary,
256
278
  tests,
257
279
  };
@@ -194,6 +194,15 @@ describe("runner-results", () => {
194
194
  commitSha: "abc123",
195
195
  },
196
196
  testkitVersion: "0.1.20",
197
+ scope: {
198
+ suiteType: "int",
199
+ suiteNames: ["health"],
200
+ fileNames: ["tests/api/integration/b.int.testkit.ts"],
201
+ framework: "default",
202
+ shard: null,
203
+ serviceFilter: "api",
204
+ isFullRun: false,
205
+ },
197
206
  summary: {
198
207
  services: {
199
208
  total: 1,
@@ -223,4 +232,26 @@ describe("runner-results", () => {
223
232
  ],
224
233
  });
225
234
  });
235
+
236
+ it("marks unfiltered status artifacts as full runs", () => {
237
+ const status = buildStatusArtifact({
238
+ productDir: "/tmp/my-product",
239
+ results: [],
240
+ suiteType: "all",
241
+ suiteNames: [],
242
+ fileNames: [],
243
+ framework: "all",
244
+ shard: null,
245
+ serviceFilter: null,
246
+ metadata: {
247
+ git: {
248
+ branch: "main",
249
+ commitSha: "abc123",
250
+ },
251
+ testkitVersion: "0.1.20",
252
+ },
253
+ });
254
+
255
+ expect(status.scope.isFullRun).toBe(true);
256
+ });
226
257
  });
@@ -1,8 +1,13 @@
1
+ import { Rate } from "k6/metrics";
2
+
3
+ export const runtimeFailures = new Rate("testkit_runtime_failures");
4
+
1
5
  export function singleIterationOptions(overrides = {}) {
2
6
  return {
3
7
  iterations: 1,
4
8
  thresholds: {
5
9
  checks: ["rate==1.0"],
10
+ testkit_runtime_failures: ["rate==0"],
6
11
  ...(overrides.thresholds || {}),
7
12
  },
8
13
  ...overrides,
@@ -37,3 +42,7 @@ export function isSorted(rows, field, direction = "asc") {
37
42
 
38
43
  return true;
39
44
  }
45
+
46
+ export function recordRuntimeFailure() {
47
+ runtimeFailures.add(1);
48
+ }
@@ -1,4 +1,5 @@
1
- import { defaultOptions } from "./checks.js";
1
+ import { fail } from "k6";
2
+ import { defaultOptions, recordRuntimeFailure } from "./checks.js";
2
3
  import { createDalContext, openDb } from "./dal.js";
3
4
 
4
5
  export function defineDalSuite(configOrRun, maybeRun) {
@@ -10,14 +11,24 @@ export function defineDalSuite(configOrRun, maybeRun) {
10
11
  options: config.options || defaultOptions,
11
12
  setup() {
12
13
  if (typeof config.setup !== "function") return null;
13
- return config.setup({ db, dal });
14
+ try {
15
+ return config.setup({ db, dal });
16
+ } catch (error) {
17
+ recordRuntimeFailure();
18
+ fail(formatFatalSuiteError("setup", error));
19
+ }
14
20
  },
15
21
  exec(setupData) {
16
- return run({
17
- db,
18
- dal,
19
- setupData,
20
- });
22
+ try {
23
+ return run({
24
+ db,
25
+ dal,
26
+ setupData,
27
+ });
28
+ } catch (error) {
29
+ recordRuntimeFailure();
30
+ fail(formatFatalSuiteError("exec", error));
31
+ }
21
32
  },
22
33
  };
23
34
  }
@@ -31,3 +42,10 @@ function normalizeSuiteArgs(configOrRun, maybeRun) {
31
42
  }
32
43
  return { config: configOrRun || {}, run: maybeRun };
33
44
  }
45
+
46
+ function formatFatalSuiteError(phase, error) {
47
+ if (error instanceof Error) {
48
+ return `Uncaught testkit suite error during ${phase}: ${error.message}`;
49
+ }
50
+ return `Uncaught testkit suite error during ${phase}: ${String(error)}`;
51
+ }
@@ -1,4 +1,5 @@
1
- import { defaultOptions } from "./checks.js";
1
+ import { fail } from "k6";
2
+ import { defaultOptions, recordRuntimeFailure } from "./checks.js";
2
3
  import { createHttpClient, getEnv } from "./http.js";
3
4
 
4
5
  export function defineHttpSuite(configOrRun, maybeRun) {
@@ -24,17 +25,27 @@ export function defineHttpSuite(configOrRun, maybeRun) {
24
25
  options: config.options || defaultOptions,
25
26
  setup() {
26
27
  if (typeof auth?.setup !== "function") return null;
27
- return auth.setup({ env });
28
+ try {
29
+ return auth.setup({ env });
30
+ } catch (error) {
31
+ recordRuntimeFailure();
32
+ fail(formatFatalSuiteError("setup", error));
33
+ }
28
34
  },
29
35
  exec(setupData) {
30
- return run({
31
- env,
32
- req: client.request,
33
- rawReq: client.raw,
34
- getWithHeaders: client.getWithHeaders,
35
- setupData,
36
- session: setupData,
37
- });
36
+ try {
37
+ return run({
38
+ env,
39
+ req: client.request,
40
+ rawReq: client.raw,
41
+ getWithHeaders: client.getWithHeaders,
42
+ setupData,
43
+ session: setupData,
44
+ });
45
+ } catch (error) {
46
+ recordRuntimeFailure();
47
+ fail(formatFatalSuiteError("exec", error));
48
+ }
38
49
  },
39
50
  };
40
51
  }
@@ -53,3 +64,10 @@ function callHeaders(builder, setupData, env) {
53
64
  if (typeof builder !== "function") return {};
54
65
  return builder(setupData, { env }) || {};
55
66
  }
67
+
68
+ function formatFatalSuiteError(phase, error) {
69
+ if (error instanceof Error) {
70
+ return `Uncaught testkit suite error during ${phase}: ${error.message}`;
71
+ }
72
+ return `Uncaught testkit suite error during ${phase}: ${String(error)}`;
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "exports": {