@elench/testkit 0.1.24 → 0.1.26

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
@@ -83,6 +83,9 @@ import { check, group, http } from "@elench/testkit/runtime";
83
83
 
84
84
  `testkit` bundles these imports before execution, so tests do not need
85
85
  generated `_testkit` files, direct package-manager path imports, or any separate engine installation.
86
+ The published package also ships first-party TypeScript declarations for both
87
+ `@elench/testkit` and `@elench/testkit/runtime`, so consumer repos do not need
88
+ local ambient module shims for the supported authoring surface.
86
89
 
87
90
  Legacy compatibility:
88
91
 
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
  );
@@ -21,94 +21,6 @@ export function parseDotenvString(source) {
21
21
  return env;
22
22
  }
23
23
 
24
- export function validateRunnerManifest(raw, manifestName = "runner.manifest.json", manifestPath = manifestName) {
25
- if (!isObject(raw.services)) {
26
- throw new Error(`${manifestName} must have a "services" object (${manifestPath})`);
27
- }
28
-
29
- for (const [serviceName, service] of Object.entries(raw.services)) {
30
- if (!isObject(service)) {
31
- throw new Error(`Service "${serviceName}" in ${manifestName} must be an object`);
32
- }
33
- if (!isObject(service.suites)) {
34
- throw new Error(`Service "${serviceName}" in ${manifestName} must define suites`);
35
- }
36
-
37
- for (const [suiteType, suites] of Object.entries(service.suites)) {
38
- if (!Array.isArray(suites)) {
39
- throw new Error(
40
- `Service "${serviceName}" suite type "${suiteType}" must be an array`
41
- );
42
- }
43
-
44
- const seenNames = new Set();
45
- for (const suite of suites) {
46
- if (!isObject(suite)) {
47
- throw new Error(
48
- `Service "${serviceName}" suite type "${suiteType}" contains a non-object suite`
49
- );
50
- }
51
- if (typeof suite.name !== "string" || !suite.name.length) {
52
- throw new Error(
53
- `Service "${serviceName}" suite type "${suiteType}" has a suite with no name`
54
- );
55
- }
56
- if (seenNames.has(suite.name)) {
57
- throw new Error(
58
- `Service "${serviceName}" suite type "${suiteType}" has duplicate suite name "${suite.name}"`
59
- );
60
- }
61
- seenNames.add(suite.name);
62
-
63
- if (!Array.isArray(suite.files) || suite.files.length === 0) {
64
- throw new Error(
65
- `Service "${serviceName}" suite "${suite.name}" must define one or more files`
66
- );
67
- }
68
- for (const file of suite.files) {
69
- if (typeof file !== "string" || !file.length) {
70
- throw new Error(
71
- `Service "${serviceName}" suite "${suite.name}" contains an invalid file entry`
72
- );
73
- }
74
- }
75
-
76
- const framework = suite.framework || "k6";
77
- if (!VALID_FRAMEWORKS.has(framework)) {
78
- throw new Error(
79
- `Service "${serviceName}" suite "${suite.name}" uses unsupported framework "${framework}"`
80
- );
81
- }
82
-
83
- if (suite.testkit !== undefined) {
84
- if (!isObject(suite.testkit)) {
85
- throw new Error(
86
- `Service "${serviceName}" suite "${suite.name}" testkit config must be an object`
87
- );
88
- }
89
- if (
90
- suite.testkit.maxFileConcurrency !== undefined &&
91
- (!Number.isInteger(suite.testkit.maxFileConcurrency) ||
92
- suite.testkit.maxFileConcurrency <= 0)
93
- ) {
94
- throw new Error(
95
- `Service "${serviceName}" suite "${suite.name}" testkit.maxFileConcurrency must be a positive integer`
96
- );
97
- }
98
- if (
99
- suite.testkit.weight !== undefined &&
100
- (!Number.isInteger(suite.testkit.weight) || suite.testkit.weight <= 0)
101
- ) {
102
- throw new Error(
103
- `Service "${serviceName}" suite "${suite.name}" testkit.weight must be a positive integer`
104
- );
105
- }
106
- }
107
- }
108
- }
109
- }
110
- }
111
-
112
24
  export function validateConfigCoverage(
113
25
  config,
114
26
  configName = "testkit.config.json"
@@ -8,7 +8,6 @@ import {
8
8
  resolveSelectedDatabase,
9
9
  validateConfigCoverage,
10
10
  validateLifecycleConfig,
11
- validateRunnerManifest,
12
11
  validateServiceConfig,
13
12
  validateTelemetryConfig,
14
13
  } from "./model.mjs";
@@ -29,36 +28,6 @@ QUX='zap'
29
28
  });
30
29
  });
31
30
 
32
- it("validates runner manifests", () => {
33
- expect(() =>
34
- validateRunnerManifest({
35
- services: {
36
- api: {
37
- suites: {
38
- integration: [
39
- { name: "health", files: ["tests/health.js"] },
40
- ],
41
- },
42
- },
43
- },
44
- })
45
- ).not.toThrow();
46
-
47
- expect(() =>
48
- validateRunnerManifest({
49
- services: {
50
- api: {
51
- suites: {
52
- integration: [
53
- { name: "health", files: ["a.js"], framework: "jest" },
54
- ],
55
- },
56
- },
57
- },
58
- })
59
- ).toThrow("unsupported framework");
60
- });
61
-
62
31
  it("validates config coverage", () => {
63
32
  const config = {
64
33
  services: {
package/lib/index.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type {
2
+ HttpClient,
3
+ HttpClientConfig,
4
+ RuntimeDb,
5
+ RuntimeDalContext,
6
+ RuntimeEnv,
7
+ RuntimeHeaders,
8
+ RuntimeOptions,
9
+ RuntimeResponse,
10
+ } from "./runtime/index";
11
+
12
+ export interface TestkitSuite<TSetup = unknown> {
13
+ options: RuntimeOptions;
14
+ setup: () => TSetup | null;
15
+ exec: (setupData: TSetup | null) => unknown;
16
+ }
17
+
18
+ export interface HeaderBuilderContext {
19
+ env: RuntimeEnv;
20
+ }
21
+
22
+ export type HeaderBuilder<TSetup = unknown> = (
23
+ setupData?: TSetup | null,
24
+ context?: HeaderBuilderContext
25
+ ) => RuntimeHeaders | void;
26
+
27
+ export interface AuthAdapter<TSetup = unknown> {
28
+ setup?: (context: { env: RuntimeEnv }) => TSetup;
29
+ headers?: HeaderBuilder<TSetup>;
30
+ }
31
+
32
+ export interface HttpSuiteContext<TSetup = unknown> {
33
+ env: RuntimeEnv;
34
+ req: HttpClient<TSetup>["request"];
35
+ rawReq: HttpClient["raw"];
36
+ getWithHeaders: HttpClient<TSetup>["getWithHeaders"];
37
+ setupData: TSetup | null;
38
+ session: TSetup | null;
39
+ }
40
+
41
+ export interface HttpSuiteConfig<TSetup = unknown> {
42
+ auth?: AuthAdapter<TSetup> | null;
43
+ env?: RuntimeEnv;
44
+ headers?: HeaderBuilder<TSetup>;
45
+ rawHeaders?: HeaderBuilder<never>;
46
+ options?: RuntimeOptions;
47
+ }
48
+
49
+ export interface DalSuiteContext<TSetup = unknown> {
50
+ db: RuntimeDb;
51
+ dal: RuntimeDalContext;
52
+ setupData: TSetup | null;
53
+ }
54
+
55
+ export interface DalSuiteConfig<TSetup = unknown> {
56
+ db?: RuntimeDb;
57
+ options?: RuntimeOptions;
58
+ setup?: (context: { db: RuntimeDb; dal: RuntimeDalContext }) => TSetup;
59
+ }
60
+
61
+ export declare function defineHttpSuite<TSetup = unknown>(
62
+ run: (context: HttpSuiteContext<TSetup>) => unknown
63
+ ): TestkitSuite<TSetup>;
64
+
65
+ export declare function defineHttpSuite<TSetup = unknown>(
66
+ config: HttpSuiteConfig<TSetup>,
67
+ run: (context: HttpSuiteContext<TSetup>) => unknown
68
+ ): TestkitSuite<TSetup>;
69
+
70
+ export declare function defineDalSuite<TSetup = unknown>(
71
+ run: (context: DalSuiteContext<TSetup>) => unknown
72
+ ): TestkitSuite<TSetup>;
73
+
74
+ export declare function defineDalSuite<TSetup = unknown>(
75
+ config: DalSuiteConfig<TSetup>,
76
+ run: (context: DalSuiteContext<TSetup>) => unknown
77
+ ): TestkitSuite<TSetup>;
78
+
79
+ export declare function createAuthAdapter<TSetup = unknown>(
80
+ adapter?: AuthAdapter<TSetup>
81
+ ): AuthAdapter<TSetup>;
82
+
83
+ export type {
84
+ HttpClient,
85
+ HttpClientConfig,
86
+ RuntimeDb,
87
+ RuntimeDalContext,
88
+ RuntimeEnv,
89
+ RuntimeHeaders,
90
+ RuntimeOptions,
91
+ RuntimeResponse,
92
+ };
@@ -0,0 +1,24 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ const packageJsonPath = path.join(rootDir, "package.json");
8
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
9
+
10
+ describe("package metadata", () => {
11
+ it("ships first-party type declarations for the public exports", () => {
12
+ expect(packageJson.types).toBe("./lib/index.d.ts");
13
+ expect(packageJson.exports["."]).toEqual({
14
+ types: "./lib/index.d.ts",
15
+ default: "./lib/index.mjs",
16
+ });
17
+ expect(packageJson.exports["./runtime"]).toEqual({
18
+ types: "./lib/runtime/index.d.ts",
19
+ default: "./lib/runtime/index.mjs",
20
+ });
21
+ expect(fs.existsSync(path.join(rootDir, "lib", "index.d.ts"))).toBe(true);
22
+ expect(fs.existsSync(path.join(rootDir, "lib", "runtime", "index.d.ts"))).toBe(true);
23
+ });
24
+ });
@@ -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) {
@@ -840,12 +832,15 @@ function printRunSummary(results, durationMs) {
840
832
  );
841
833
  const failedSuites = executedServices.reduce((sum, result) => sum + result.failedSuiteCount, 0);
842
834
  const passedSuites = completedSuites - failedSuites;
835
+ const totalFiles = executedServices.reduce((sum, result) => sum + (result.totalFileCount || 0), 0);
836
+ const passedFiles = executedServices.reduce((sum, result) => sum + (result.passedFileCount || 0), 0);
843
837
 
844
838
  console.log("\n══ Summary ══");
845
839
  console.log(
846
840
  [
847
841
  `services ${passedServices.length}/${executedServices.length} passed`,
848
842
  `suites ${passedSuites}/${totalSuites} passed`,
843
+ totalFiles > 0 ? `files ${passedFiles}/${totalFiles} passed` : null,
849
844
  skippedServices.length > 0 ? `${skippedServices.length} skipped` : null,
850
845
  `duration ${formatDuration(durationMs)}`,
851
846
  ]
@@ -968,11 +963,23 @@ function writeRunArtifact(productDir, artifact) {
968
963
  function buildStatusArtifact({
969
964
  productDir,
970
965
  results,
966
+ suiteType,
967
+ suiteNames,
968
+ fileNames,
969
+ framework,
970
+ shard,
971
+ serviceFilter,
971
972
  metadata,
972
973
  }) {
973
974
  return buildStatusArtifactModel({
974
975
  productDir,
975
976
  results,
977
+ suiteType,
978
+ suiteNames,
979
+ fileNames,
980
+ framework,
981
+ shard,
982
+ serviceFilter,
976
983
  metadata,
977
984
  });
978
985
  }
@@ -1064,6 +1071,121 @@ function formatError(error) {
1064
1071
  return formatErrorModel(error);
1065
1072
  }
1066
1073
 
1074
+ async function runDefaultRuntimeTask(targetConfig, task, args) {
1075
+ const k6Binary = resolveK6Binary();
1076
+ const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
1077
+ fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
1078
+ const startedAt = Date.now();
1079
+ const result = await execa(k6Binary, [...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)], {
1080
+ cwd: targetConfig.productDir,
1081
+ env: buildExecutionEnv(targetConfig),
1082
+ reject: false,
1083
+ });
1084
+
1085
+ if (result.stdout) process.stdout.write(result.stdout);
1086
+ if (result.stderr) process.stderr.write(result.stderr);
1087
+
1088
+ const summary = readDefaultRuntimeSummary(summaryFile);
1089
+ const runtimeError = determineDefaultRuntimeFailure(result, summary);
1090
+
1091
+ return {
1092
+ task,
1093
+ failed: runtimeError !== null,
1094
+ error: runtimeError,
1095
+ durationMs: Date.now() - startedAt,
1096
+ };
1097
+ }
1098
+
1099
+ function buildDefaultRuntimeSummaryPath(targetConfig, task) {
1100
+ return path.join(
1101
+ targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit"),
1102
+ "_runtime",
1103
+ `task-${task.id}.summary.json`
1104
+ );
1105
+ }
1106
+
1107
+ function readDefaultRuntimeSummary(filePath) {
1108
+ try {
1109
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
1110
+ } catch {
1111
+ return null;
1112
+ }
1113
+ }
1114
+
1115
+ function determineDefaultRuntimeFailure(result, summary) {
1116
+ const fatalRuntimeError = extractDefaultRuntimeFatalError(result.stderr || "");
1117
+ if (fatalRuntimeError) {
1118
+ return `Default runtime uncaught error: ${fatalRuntimeError}`;
1119
+ }
1120
+
1121
+ const failedThresholds = extractDefaultRuntimeThresholdFailures(summary);
1122
+ if (failedThresholds.length > 0) {
1123
+ return `Default runtime thresholds failed: ${failedThresholds.join(", ")}`;
1124
+ }
1125
+
1126
+ if (result.exitCode !== 0) {
1127
+ return sanitizeDefaultRuntimeExitError(result.exitCode, result.stderr || result.stdout || "");
1128
+ }
1129
+
1130
+ return null;
1131
+ }
1132
+
1133
+ function extractDefaultRuntimeFatalError(stderr) {
1134
+ if (!stderr || !/source=stacktrace/.test(stderr)) return null;
1135
+ const matched = stderr.match(/Error:\s([^\n]+)/);
1136
+ return matched?.[1]?.trim() || firstLine(stderr);
1137
+ }
1138
+
1139
+ function extractDefaultRuntimeThresholdFailures(summary) {
1140
+ const metrics = summary?.metrics;
1141
+ if (!metrics || typeof metrics !== "object") return [];
1142
+
1143
+ const failures = [];
1144
+ for (const [metricName, metricSummary] of Object.entries(metrics)) {
1145
+ const thresholds = metricSummary?.thresholds;
1146
+ if (!thresholds || typeof thresholds !== "object") continue;
1147
+ for (const [threshold, crossed] of Object.entries(thresholds)) {
1148
+ if (crossed === true) {
1149
+ failures.push(`${metricName}(${threshold})`);
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ return failures.sort();
1155
+ }
1156
+
1157
+ function sanitizeDefaultRuntimeExitError(exitCode, output) {
1158
+ const message = firstLine(output);
1159
+ if (message) {
1160
+ return `Default runtime failed with exit code ${exitCode}: ${message}`;
1161
+ }
1162
+ return `Default runtime failed with exit code ${exitCode}`;
1163
+ }
1164
+
1165
+ function findUnmatchedRequestedFiles(configs, suiteType, suiteNames, framework, fileNames) {
1166
+ const matchedFiles = new Set();
1167
+ for (const config of configs) {
1168
+ const suites = collectSuites(config, suiteType, suiteNames, framework, []);
1169
+ for (const suite of suites) {
1170
+ for (const file of suite.files) {
1171
+ matchedFiles.add(normalizePathSeparators(file));
1172
+ }
1173
+ }
1174
+ }
1175
+
1176
+ return [...new Set(fileNames.map(normalizePathSeparators))].filter((file) => !matchedFiles.has(file));
1177
+ }
1178
+
1179
+ function isFullRunSelection(suiteType, suiteNames, fileNames, opts) {
1180
+ return (
1181
+ (suiteNames || []).length === 0 &&
1182
+ (fileNames || []).length === 0 &&
1183
+ (opts.framework || "all") === "all" &&
1184
+ (opts.shard || null) === null &&
1185
+ (opts.serviceFilter || null) === null
1186
+ );
1187
+ }
1188
+
1067
1189
  function loadTimings(productDir) {
1068
1190
  const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
1069
1191
  if (!fs.existsSync(filePath)) {
@@ -156,6 +156,19 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
156
156
  (suite) => suite.completedFileCount === suite.fileCount
157
157
  ).length;
158
158
  const failedSuiteCount = suites.filter((suite) => suite.failedFiles.length > 0).length;
159
+ const totalFileCount = suites.reduce((sum, suite) => sum + suite.fileCount, 0);
160
+ const completedFileCount = suites.reduce(
161
+ (sum, suite) => sum + suite.completedFileCount,
162
+ 0
163
+ );
164
+ const failedFileCount = suites.reduce((sum, suite) => sum + suite.failedFiles.length, 0);
165
+ const passedFileCount = suites.reduce(
166
+ (sum, suite) =>
167
+ sum +
168
+ suite.fileResults.filter((file) => file.status === "passed").length,
169
+ 0
170
+ );
171
+ const notRunFileCount = totalFileCount - completedFileCount;
159
172
  const accumulatedDurationMs = suites.reduce((sum, suite) => sum + suite.durationMs, 0);
160
173
  const durationMs =
161
174
  tracker.firstTaskAt && tracker.lastTaskAt
@@ -173,6 +186,11 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
173
186
  suiteCount: tracker.suiteCount,
174
187
  completedSuiteCount,
175
188
  failedSuiteCount,
189
+ totalFileCount,
190
+ completedFileCount,
191
+ passedFileCount,
192
+ failedFileCount,
193
+ notRunFileCount,
176
194
  durationMs,
177
195
  suites: suites.map((suite) => ({
178
196
  name: suite.name,
@@ -180,6 +198,10 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
180
198
  framework: formatFrameworkForArtifact(suite.framework),
181
199
  failed: suite.failedFiles.length > 0,
182
200
  fileCount: suite.fileCount,
201
+ completedFileCount: suite.completedFileCount,
202
+ passedFileCount: suite.fileResults.filter((file) => file.status === "passed").length,
203
+ failedFileCount: suite.failedFiles.length,
204
+ notRunFileCount: suite.fileCount - suite.completedFileCount,
183
205
  failedFiles: suite.failedFiles,
184
206
  durationMs: suite.durationMs,
185
207
  error: suite.error,
@@ -201,6 +223,12 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
201
223
  export function buildStatusArtifact({
202
224
  productDir,
203
225
  results,
226
+ suiteType,
227
+ suiteNames,
228
+ fileNames,
229
+ framework,
230
+ shard,
231
+ serviceFilter,
204
232
  metadata,
205
233
  }) {
206
234
  const executedResults = results.filter((result) => !result.skipped);
@@ -240,6 +268,21 @@ export function buildStatusArtifact({
240
268
  },
241
269
  };
242
270
 
271
+ const scope = {
272
+ suiteType,
273
+ suiteNames: [...(suiteNames || [])].sort(),
274
+ fileNames: [...(fileNames || [])].sort(),
275
+ framework: formatFrameworkForArtifact(framework || "all"),
276
+ shard: shard || null,
277
+ serviceFilter: serviceFilter || null,
278
+ };
279
+ scope.isFullRun =
280
+ scope.suiteNames.length === 0 &&
281
+ scope.fileNames.length === 0 &&
282
+ scope.framework === "all" &&
283
+ scope.shard === null &&
284
+ scope.serviceFilter === null;
285
+
243
286
  return {
244
287
  schemaVersion: 1,
245
288
  source: "testkit",
@@ -252,6 +295,7 @@ export function buildStatusArtifact({
252
295
  commitSha: metadata.git?.commitSha || null,
253
296
  },
254
297
  testkitVersion: metadata.testkitVersion,
298
+ scope,
255
299
  summary,
256
300
  tests,
257
301
  };
@@ -277,6 +321,10 @@ export function buildRunArtifact({
277
321
  const totalSuites = executed.reduce((sum, result) => sum + result.suiteCount, 0);
278
322
  const completedSuites = executed.reduce((sum, result) => sum + result.completedSuiteCount, 0);
279
323
  const failedSuites = executed.reduce((sum, result) => sum + result.failedSuiteCount, 0);
324
+ const totalFiles = executed.reduce((sum, result) => sum + (result.totalFileCount || 0), 0);
325
+ const passedFiles = executed.reduce((sum, result) => sum + (result.passedFileCount || 0), 0);
326
+ const failedFiles = executed.reduce((sum, result) => sum + (result.failedFileCount || 0), 0);
327
+ const notRunFiles = executed.reduce((sum, result) => sum + (result.notRunFileCount || 0), 0);
280
328
  const dbBackend = summarizeDbBackend(results);
281
329
 
282
330
  return {
@@ -317,6 +365,12 @@ export function buildRunArtifact({
317
365
  passed: completedSuites - failedSuites,
318
366
  failed: failedSuites,
319
367
  },
368
+ files: {
369
+ total: totalFiles,
370
+ passed: passedFiles,
371
+ failed: failedFiles,
372
+ notRun: notRunFiles,
373
+ },
320
374
  },
321
375
  services: results.map((result) => ({
322
376
  name: result.name,
@@ -325,6 +379,11 @@ export function buildRunArtifact({
325
379
  suiteCount: result.suiteCount,
326
380
  completedSuiteCount: result.completedSuiteCount,
327
381
  failedSuiteCount: result.failedSuiteCount,
382
+ totalFileCount: result.totalFileCount,
383
+ completedFileCount: result.completedFileCount,
384
+ passedFileCount: result.passedFileCount,
385
+ failedFileCount: result.failedFileCount,
386
+ notRunFileCount: result.notRunFileCount,
328
387
  durationMs: result.durationMs,
329
388
  dbBackend: result.dbBackend,
330
389
  suites: result.suites,
@@ -350,10 +409,15 @@ export function formatDuration(durationMs) {
350
409
 
351
410
  export function formatServiceSummary(result) {
352
411
  const passedSuites = result.completedSuiteCount - result.failedSuiteCount;
353
- const notRun = result.suiteCount - result.completedSuiteCount;
412
+ const notRunSuites = result.suiteCount - result.completedSuiteCount;
354
413
  let detail = `${passedSuites}/${result.suiteCount} suites passed`;
355
- if (notRun > 0) {
356
- detail += `, ${notRun} not run`;
414
+ if ((result.totalFileCount || 0) > 0) {
415
+ detail += `, ${result.passedFileCount}/${result.totalFileCount} files passed`;
416
+ }
417
+ if (notRunSuites > 0) {
418
+ detail += `, ${notRunSuites} ${pluralize(notRunSuites, "suite", "suites")} not run`;
419
+ } else if ((result.notRunFileCount || 0) > 0) {
420
+ detail += `, ${result.notRunFileCount} ${pluralize(result.notRunFileCount, "file", "files")} not run`;
357
421
  }
358
422
  return detail;
359
423
  }
@@ -382,3 +446,7 @@ function sanitizeErrorMessage(message) {
382
446
  .replace(/Command failed with exit code (\d+): k6 run\b/g, "Default runtime failed with exit code $1:")
383
447
  .replace(/[\\/]vendor[\\/]k6\b/g, "default-runtime");
384
448
  }
449
+
450
+ function pluralize(value, singular, plural) {
451
+ return value === 1 ? singular : plural;
452
+ }
@@ -64,8 +64,12 @@ describe("runner-results", () => {
64
64
  const result = finalizeServiceResult(tracker, 1000, 1500);
65
65
  expect(result.failed).toBe(true);
66
66
  expect(result.failedSuiteCount).toBe(1);
67
+ expect(result.totalFileCount).toBe(1);
68
+ expect(result.failedFileCount).toBe(1);
69
+ expect(result.passedFileCount).toBe(0);
67
70
  expect(result.errors).toEqual(["worker failed", "graph failed"]);
68
71
  expect(result.suites[0].framework).toBe("default");
72
+ expect(result.suites[0].failedFileCount).toBe(1);
69
73
  expect(result.suites[0].files).toEqual([
70
74
  {
71
75
  path: "tests/health.js",
@@ -86,6 +90,11 @@ describe("runner-results", () => {
86
90
  suiteCount: 1,
87
91
  completedSuiteCount: 1,
88
92
  failedSuiteCount: 0,
93
+ totalFileCount: 3,
94
+ completedFileCount: 3,
95
+ passedFileCount: 3,
96
+ failedFileCount: 0,
97
+ notRunFileCount: 0,
89
98
  durationMs: 1200,
90
99
  dbBackend: "local",
91
100
  suites: [],
@@ -98,6 +107,11 @@ describe("runner-results", () => {
98
107
  suiteCount: 0,
99
108
  completedSuiteCount: 0,
100
109
  failedSuiteCount: 0,
110
+ totalFileCount: 0,
111
+ completedFileCount: 0,
112
+ passedFileCount: 0,
113
+ failedFileCount: 0,
114
+ notRunFileCount: 0,
101
115
  durationMs: 0,
102
116
  dbBackend: null,
103
117
  suites: [],
@@ -134,6 +148,12 @@ describe("runner-results", () => {
134
148
 
135
149
  expect(artifact.product.name).toBe("my-product");
136
150
  expect(artifact.summary.services.total).toBe(1);
151
+ expect(artifact.summary.files).toEqual({
152
+ total: 3,
153
+ passed: 3,
154
+ failed: 0,
155
+ notRun: 0,
156
+ });
137
157
  expect(summarizeDbBackend(results)).toBe("local");
138
158
  expect(formatDuration(65_000)).toBe("1m 05s");
139
159
  expect(
@@ -141,8 +161,11 @@ describe("runner-results", () => {
141
161
  completedSuiteCount: 2,
142
162
  failedSuiteCount: 1,
143
163
  suiteCount: 3,
164
+ totalFileCount: 6,
165
+ passedFileCount: 5,
166
+ notRunFileCount: 1,
144
167
  })
145
- ).toBe("1/3 suites passed, 1 not run");
168
+ ).toBe("1/3 suites passed, 5/6 files passed, 1 suite not run");
146
169
  expect(formatError(new Error("boom"))).toBe("boom");
147
170
  });
148
171
 
@@ -194,6 +217,15 @@ describe("runner-results", () => {
194
217
  commitSha: "abc123",
195
218
  },
196
219
  testkitVersion: "0.1.20",
220
+ scope: {
221
+ suiteType: "int",
222
+ suiteNames: ["health"],
223
+ fileNames: ["tests/api/integration/b.int.testkit.ts"],
224
+ framework: "default",
225
+ shard: null,
226
+ serviceFilter: "api",
227
+ isFullRun: false,
228
+ },
197
229
  summary: {
198
230
  services: {
199
231
  total: 1,
@@ -223,4 +255,26 @@ describe("runner-results", () => {
223
255
  ],
224
256
  });
225
257
  });
258
+
259
+ it("marks unfiltered status artifacts as full runs", () => {
260
+ const status = buildStatusArtifact({
261
+ productDir: "/tmp/my-product",
262
+ results: [],
263
+ suiteType: "all",
264
+ suiteNames: [],
265
+ fileNames: [],
266
+ framework: "all",
267
+ shard: null,
268
+ serviceFilter: null,
269
+ metadata: {
270
+ git: {
271
+ branch: "main",
272
+ commitSha: "abc123",
273
+ },
274
+ testkitVersion: "0.1.20",
275
+ },
276
+ });
277
+
278
+ expect(status.scope.isFullRun).toBe(true);
279
+ });
226
280
  });
@@ -0,0 +1,183 @@
1
+ export type RuntimeMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
2
+
3
+ export interface RuntimeHeaders {
4
+ [key: string]: string;
5
+ }
6
+
7
+ export interface RuntimeCookie {
8
+ value: string;
9
+ }
10
+
11
+ export interface RuntimeResponse {
12
+ body: string;
13
+ cookies?: Record<string, RuntimeCookie[]>;
14
+ headers?: Record<string, string | string[] | undefined>;
15
+ status: number;
16
+ timings?: {
17
+ duration: number;
18
+ };
19
+ }
20
+
21
+ export interface RuntimeOptions {
22
+ [key: string]: unknown;
23
+ thresholds?: Record<string, unknown>;
24
+ }
25
+
26
+ export interface RuntimeEnv {
27
+ BASE: string;
28
+ MACHINE_ID?: string;
29
+ routeParams: RuntimeHeaders;
30
+ }
31
+
32
+ export interface RuntimeDb {
33
+ exec(sql: string): unknown;
34
+ query<T = Record<string, unknown>>(sql: string): T[];
35
+ }
36
+
37
+ export interface RuntimeDalContext {
38
+ db: RuntimeDb;
39
+ truncate(...tables: string[]): void;
40
+ }
41
+
42
+ export interface HttpRequestParams {
43
+ headers?: RuntimeHeaders;
44
+ redirects?: number;
45
+ }
46
+
47
+ export interface RuntimeHttpClient {
48
+ del(url: string, body?: unknown, params?: HttpRequestParams): RuntimeResponse;
49
+ file(data: unknown, filename?: string, contentType?: string): unknown;
50
+ get(url: string, params?: HttpRequestParams): RuntimeResponse;
51
+ patch(url: string, body?: unknown, params?: HttpRequestParams): RuntimeResponse;
52
+ post(url: string, body?: unknown, params?: HttpRequestParams): RuntimeResponse;
53
+ put(url: string, body?: unknown, params?: HttpRequestParams): RuntimeResponse;
54
+ }
55
+
56
+ export interface Metric {
57
+ add(value: number): void;
58
+ }
59
+
60
+ export declare class Rate implements Metric {
61
+ constructor(name: string, isTime?: boolean);
62
+ add(value: number): void;
63
+ }
64
+
65
+ export declare class Trend implements Metric {
66
+ constructor(name: string, isTime?: boolean);
67
+ add(value: number): void;
68
+ }
69
+
70
+ export interface HttpClientConfig<TSetup = unknown> {
71
+ baseUrl: string;
72
+ defaultHeaders?: RuntimeHeaders;
73
+ getHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void;
74
+ getRawHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void;
75
+ routeHeaders?: RuntimeHeaders;
76
+ }
77
+
78
+ export interface HttpClient<TSetup = unknown> {
79
+ delete(path: string, setupData?: TSetup | null, extraHeaders?: RuntimeHeaders): RuntimeResponse;
80
+ get(path: string, setupData?: TSetup | null, extraHeaders?: RuntimeHeaders): RuntimeResponse;
81
+ getWithHeaders(
82
+ path: string,
83
+ setupData?: TSetup | null,
84
+ extraHeaders?: RuntimeHeaders
85
+ ): RuntimeResponse;
86
+ patch(
87
+ path: string,
88
+ setupData?: TSetup | null,
89
+ body?: unknown,
90
+ extraHeaders?: RuntimeHeaders
91
+ ): RuntimeResponse;
92
+ post(
93
+ path: string,
94
+ setupData?: TSetup | null,
95
+ body?: unknown,
96
+ extraHeaders?: RuntimeHeaders
97
+ ): RuntimeResponse;
98
+ put(
99
+ path: string,
100
+ setupData?: TSetup | null,
101
+ body?: unknown,
102
+ extraHeaders?: RuntimeHeaders
103
+ ): RuntimeResponse;
104
+ raw(
105
+ method: RuntimeMethod,
106
+ path: string,
107
+ body?: unknown,
108
+ extraHeaders?: RuntimeHeaders
109
+ ): RuntimeResponse;
110
+ rawDelete(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
111
+ rawGet(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
112
+ rawPatch(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
113
+ rawPost(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
114
+ rawPut(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
115
+ request(
116
+ method: RuntimeMethod,
117
+ path: string,
118
+ setupData?: TSetup | null,
119
+ body?: unknown,
120
+ extraHeaders?: RuntimeHeaders
121
+ ): RuntimeResponse;
122
+ }
123
+
124
+ export declare const check: <T>(
125
+ value: T,
126
+ checks: Record<string, (value: T) => boolean>
127
+ ) => boolean;
128
+ export declare const fail: (message: string) => never;
129
+ export declare const group: (name: string, fn: () => void) => void;
130
+ export declare const sleep: (seconds?: number) => void;
131
+
132
+ export declare const http: RuntimeHttpClient;
133
+
134
+ export declare function file(data: unknown, filename?: string, contentType?: string): unknown;
135
+ export declare function json<T = unknown>(response: Pick<RuntimeResponse, "body">): T;
136
+ export declare function contains<T extends Record<string, unknown>>(
137
+ rows: T[],
138
+ field: keyof T | string,
139
+ value: unknown
140
+ ): boolean;
141
+ export declare function allMatch<T>(
142
+ rows: T[],
143
+ predicate: (row: T) => boolean
144
+ ): boolean;
145
+ export declare function isSorted<T extends Record<string, unknown>>(
146
+ rows: T[],
147
+ field: keyof T | string,
148
+ direction?: "asc" | "desc"
149
+ ): boolean;
150
+
151
+ export declare function singleIterationOptions(overrides?: RuntimeOptions): RuntimeOptions;
152
+ export declare const defaultOptions: RuntimeOptions;
153
+ export declare const httpDefaultOptions: RuntimeOptions;
154
+
155
+ export declare function createDalContext(db?: RuntimeDb): RuntimeDalContext;
156
+ export declare function openDb(): RuntimeDb;
157
+ export declare function truncate(db: RuntimeDb, ...tables: string[]): void;
158
+
159
+ export declare function getEnv(): RuntimeEnv;
160
+ export declare function createHttpClient<TSetup = unknown>(
161
+ config: HttpClientConfig<TSetup>
162
+ ): HttpClient<TSetup>;
163
+ export declare function makeReq<TSetup = unknown>(
164
+ baseUrl: string,
165
+ routeHeaders?: RuntimeHeaders,
166
+ getHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void
167
+ ): HttpClient<TSetup>["request"];
168
+ export declare function makeRawReq(
169
+ baseUrl: string,
170
+ routeHeaders?: RuntimeHeaders,
171
+ getRawHeaders?: (setupData?: never) => RuntimeHeaders | void
172
+ ): HttpClient["raw"];
173
+ export declare function makeGetWithHeaders<TSetup = unknown>(
174
+ baseUrl: string,
175
+ routeHeaders?: RuntimeHeaders,
176
+ getHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void
177
+ ): HttpClient<TSetup>["getWithHeaders"];
178
+
179
+ declare global {
180
+ const __ENV: Record<string, string | undefined>;
181
+ }
182
+
183
+ export {};
@@ -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,11 +1,18 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
+ "types": "./lib/index.d.ts",
6
7
  "exports": {
7
- ".": "./lib/index.mjs",
8
- "./runtime": "./lib/runtime/index.mjs",
8
+ ".": {
9
+ "types": "./lib/index.d.ts",
10
+ "default": "./lib/index.mjs"
11
+ },
12
+ "./runtime": {
13
+ "types": "./lib/runtime/index.d.ts",
14
+ "default": "./lib/runtime/index.mjs"
15
+ },
9
16
  "./package.json": "./package.json"
10
17
  },
11
18
  "bin": {