@elench/testkit 0.1.59 → 0.1.61

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.
Files changed (36) hide show
  1. package/lib/config/database.mjs +53 -0
  2. package/lib/config/database.test.mjs +29 -0
  3. package/lib/config/discovery-config.mjs +13 -0
  4. package/lib/config/env.mjs +55 -0
  5. package/lib/config/env.test.mjs +40 -0
  6. package/lib/config/index.mjs +21 -807
  7. package/lib/config/paths.mjs +28 -0
  8. package/lib/config/paths.test.mjs +27 -0
  9. package/lib/config/runtime.mjs +241 -0
  10. package/lib/config/runtime.test.mjs +56 -0
  11. package/lib/config/skip-config.mjs +189 -0
  12. package/lib/config/skip-config.test.mjs +63 -0
  13. package/lib/config/telemetry.mjs +28 -0
  14. package/lib/config/validation.mjs +124 -0
  15. package/lib/coverage/backend-discovery.mjs +183 -0
  16. package/lib/coverage/backend-discovery.test.mjs +52 -0
  17. package/lib/coverage/evidence.mjs +146 -0
  18. package/lib/coverage/evidence.test.mjs +64 -0
  19. package/lib/coverage/fs-walk.mjs +64 -0
  20. package/lib/coverage/graph-builder.mjs +167 -0
  21. package/lib/coverage/index.mjs +1 -776
  22. package/lib/coverage/index.test.mjs +183 -14
  23. package/lib/coverage/next-discovery.mjs +174 -0
  24. package/lib/coverage/next-static-analysis.mjs +728 -0
  25. package/lib/coverage/routing.mjs +86 -0
  26. package/lib/coverage/routing.test.mjs +52 -0
  27. package/lib/coverage/shared.mjs +197 -0
  28. package/lib/coverage/shared.test.mjs +39 -0
  29. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  30. package/node_modules/@elench/testkit-bridge/src/index.mjs +101 -15
  31. package/node_modules/@elench/testkit-bridge/src/index.test.mjs +36 -6
  32. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  33. package/node_modules/@elench/testkit-protocol/src/index.d.ts +1 -0
  34. package/node_modules/@elench/testkit-protocol/src/index.mjs +3 -1
  35. package/node_modules/@elench/testkit-protocol/src/index.test.mjs +14 -0
  36. package/package.json +5 -4
@@ -0,0 +1,28 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ export function resolveServiceCwd(productDir, maybeRelative) {
5
+ return path.resolve(productDir, maybeRelative || ".");
6
+ }
7
+
8
+ export function ensureExistingPath(productDir, relativePath, label) {
9
+ const absolute = resolveServiceCwd(productDir, relativePath);
10
+ if (!fs.existsSync(absolute)) {
11
+ throw new Error(`${label} does not exist: ${relativePath}`);
12
+ }
13
+ }
14
+
15
+ export function normalizePath(value) {
16
+ return String(value || "")
17
+ .split(path.sep)
18
+ .join("/")
19
+ .replace(/^\.\/+/, "");
20
+ }
21
+
22
+ export function resolveProductDir(cwd, explicitDir) {
23
+ const dir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
24
+ if (!fs.existsSync(dir)) {
25
+ throw new Error(`Product directory does not exist: ${dir}`);
26
+ }
27
+ return dir;
28
+ }
@@ -0,0 +1,27 @@
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 { normalizePath, resolveProductDir, resolveServiceCwd } from "./paths.mjs";
6
+
7
+ const cleanups = [];
8
+
9
+ afterEach(() => {
10
+ while (cleanups.length > 0) {
11
+ cleanups.pop()();
12
+ }
13
+ });
14
+
15
+ describe("config path helpers", () => {
16
+ it("normalizes repo-relative paths consistently", () => {
17
+ expect(normalizePath("./src/app/page.tsx")).toBe("src/app/page.tsx");
18
+ });
19
+
20
+ it("resolves product and service directories", () => {
21
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-paths-"));
22
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
23
+
24
+ expect(resolveProductDir(productDir, ".")).toBe(productDir);
25
+ expect(resolveServiceCwd(productDir, "apps/web")).toBe(path.join(productDir, "apps", "web"));
26
+ });
27
+ });
@@ -0,0 +1,241 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { DEFAULT_FILE_TIMEOUT_SECONDS, normalizeExecutionConfig, normalizeRuntimeMaxConcurrentTasks, normalizeRuntimeInstances } from "../runner/execution-config.mjs";
4
+ import { normalizeKnownFailureIssueValidationConfig } from "../known-failures/github.mjs";
5
+ import { normalizeRuntimeToolchain } from "../toolchains/index.mjs";
6
+ import { resolveServiceCwd } from "./paths.mjs";
7
+
8
+ export function normalizeOptionalString(value) {
9
+ if (typeof value !== "string") return null;
10
+ const normalized = value.trim();
11
+ return normalized.length > 0 ? normalized : null;
12
+ }
13
+
14
+ export function normalizeReportingConfig(value) {
15
+ if (!value) return null;
16
+
17
+ const knownFailuresFile = normalizeOptionalString(value.knownFailuresFile);
18
+ if (!knownFailuresFile) {
19
+ throw new Error('testkit.setup.ts reporting.knownFailuresFile must be a non-empty string');
20
+ }
21
+
22
+ const issueValidation = normalizeKnownFailureIssueValidationConfig(value.issueValidation);
23
+
24
+ return {
25
+ knownFailuresFile,
26
+ issueValidation,
27
+ };
28
+ }
29
+
30
+ export function inferLocalRuntime(productDir, cwd) {
31
+ const absoluteCwd = resolveServiceCwd(productDir, cwd);
32
+ if (!fs.existsSync(absoluteCwd)) return undefined;
33
+
34
+ if (detectNextApp(absoluteCwd)) {
35
+ return {
36
+ cwd,
37
+ start: "./node_modules/.bin/next dev -p {port}",
38
+ port: 3000,
39
+ baseUrl: "http://127.0.0.1:{port}",
40
+ readyUrl: "http://127.0.0.1:{port}",
41
+ env: {},
42
+ };
43
+ }
44
+
45
+ if (fs.existsSync(path.join(absoluteCwd, "cmd", "server"))) {
46
+ return {
47
+ cwd,
48
+ start: "go run ./cmd/server",
49
+ port: 3000,
50
+ baseUrl: "http://127.0.0.1:{port}",
51
+ readyUrl: "http://127.0.0.1:{port}/health",
52
+ env: {},
53
+ };
54
+ }
55
+
56
+ if (fs.existsSync(path.join(absoluteCwd, "package.json")) && fs.existsSync(path.join(absoluteCwd, "src"))) {
57
+ return {
58
+ cwd,
59
+ start: "./node_modules/.bin/tsx watch src/index.ts",
60
+ port: 3000,
61
+ baseUrl: "http://127.0.0.1:{port}",
62
+ readyUrl: "http://127.0.0.1:{port}/health",
63
+ env: {},
64
+ };
65
+ }
66
+
67
+ return undefined;
68
+ }
69
+
70
+ export function normalizeRuntimeConfig(value, serviceName, toolchains) {
71
+ if (!value) {
72
+ return {
73
+ instances: 1,
74
+ maxConcurrentTasks: Number.POSITIVE_INFINITY,
75
+ prepare: {
76
+ inputs: [],
77
+ steps: [],
78
+ },
79
+ toolchain: null,
80
+ };
81
+ }
82
+
83
+ return {
84
+ instances: normalizeRuntimeInstances(value.instances ?? 1, `Service "${serviceName}" runtime.instances`),
85
+ maxConcurrentTasks: normalizeRuntimeMaxConcurrentTasks(
86
+ value.maxConcurrentTasks,
87
+ `Service "${serviceName}" runtime.maxConcurrentTasks`
88
+ ),
89
+ prepare: normalizeRuntimePrepareConfig(value.prepare, serviceName),
90
+ toolchain: normalizeRuntimeToolchain(
91
+ value.toolchain,
92
+ `Service "${serviceName}" runtime.toolchain`,
93
+ toolchains || {}
94
+ ),
95
+ };
96
+ }
97
+
98
+ export function normalizeRuntimePrepareConfig(value, serviceName) {
99
+ if (value == null) {
100
+ return {
101
+ inputs: [],
102
+ steps: [],
103
+ };
104
+ }
105
+ if (!value || typeof value !== "object") {
106
+ throw new Error(`Service "${serviceName}" runtime.prepare must be an object`);
107
+ }
108
+
109
+ return {
110
+ inputs: normalizeTemplateInputs(value.inputs, `Service "${serviceName}" runtime.prepare`),
111
+ steps: normalizeTemplateLifecycleSteps(value.steps, `Service "${serviceName}" runtime.prepare.steps`),
112
+ };
113
+ }
114
+
115
+ export function normalizeTemplateInputs(value, label) {
116
+ if (value == null) return [];
117
+ if (!Array.isArray(value)) {
118
+ throw new Error(`${label}.inputs must be an array`);
119
+ }
120
+
121
+ return value.map((entry, index) => {
122
+ const normalized = normalizeOptionalString(entry);
123
+ if (!normalized) {
124
+ throw new Error(`${label}.inputs[${index}] must be a non-empty string`);
125
+ }
126
+ return normalized;
127
+ });
128
+ }
129
+
130
+ export function normalizeTemplateLifecycleSteps(value, label) {
131
+ if (value == null) return [];
132
+ if (!Array.isArray(value)) {
133
+ throw new Error(`${label} must be an array`);
134
+ }
135
+
136
+ return value.map((step, index) => normalizeTemplateLifecycleStep(step, `${label}[${index}]`));
137
+ }
138
+
139
+ export function normalizeTemplateLifecycleStep(step, label) {
140
+ if (!step || typeof step !== "object") {
141
+ throw new Error(`${label} must be an object`);
142
+ }
143
+
144
+ const kind = normalizeOptionalString(step.kind);
145
+ if (kind === "command") {
146
+ const cmd = normalizeOptionalString(step.cmd);
147
+ if (!cmd) throw new Error(`${label}.cmd must be a non-empty string`);
148
+ return {
149
+ kind,
150
+ cmd,
151
+ cwd: normalizeOptionalString(step.cwd),
152
+ inputs: normalizeTemplateStepInputs(step.inputs, label),
153
+ };
154
+ }
155
+ if (kind === "sql-file") {
156
+ const filePath = normalizeOptionalString(step.path);
157
+ if (!filePath) throw new Error(`${label}.path must be a non-empty string`);
158
+ return {
159
+ kind,
160
+ path: filePath,
161
+ cwd: normalizeOptionalString(step.cwd),
162
+ inputs: normalizeTemplateStepInputs(step.inputs, label),
163
+ };
164
+ }
165
+ if (kind === "module") {
166
+ const specifier = normalizeOptionalString(step.specifier);
167
+ if (!specifier) throw new Error(`${label}.specifier must be a non-empty string`);
168
+ return {
169
+ kind,
170
+ specifier,
171
+ cwd: normalizeOptionalString(step.cwd),
172
+ inputs: normalizeTemplateStepInputs(step.inputs, label),
173
+ };
174
+ }
175
+
176
+ throw new Error(`${label}.kind must be one of: command, sql-file, module`);
177
+ }
178
+
179
+ export function normalizeTemplateStepInputs(value, label) {
180
+ if (value == null) return [];
181
+ if (!Array.isArray(value)) {
182
+ throw new Error(`${label}.inputs must be an array`);
183
+ }
184
+
185
+ return value.map((entry, index) => {
186
+ const normalized = normalizeOptionalString(entry);
187
+ if (!normalized) {
188
+ throw new Error(`${label}.inputs[${index}] must be a non-empty string`);
189
+ }
190
+ return normalized;
191
+ });
192
+ }
193
+
194
+ export function normalizeBrowserServiceConfig(value, serviceName) {
195
+ if (!value) return undefined;
196
+ if (typeof value !== "object" || Array.isArray(value)) {
197
+ throw new Error(`Service "${serviceName}" browser config must be an object`);
198
+ }
199
+
200
+ const origins = Array.isArray(value.origins)
201
+ ? value.origins.map((origin) => normalizeOptionalString(origin)).filter(Boolean)
202
+ : [];
203
+
204
+ for (const origin of origins) {
205
+ try {
206
+ const parsed = new URL(origin);
207
+ if (!parsed.origin) throw new Error("missing origin");
208
+ } catch {
209
+ throw new Error(`Service "${serviceName}" browser.origins contains an invalid URL: ${origin}`);
210
+ }
211
+ }
212
+
213
+ if (origins.length === 0) return undefined;
214
+ return { origins };
215
+ }
216
+
217
+ export function normalizeRepoExecution(execution) {
218
+ if (!execution) {
219
+ return normalizeExecutionConfig({
220
+ workers: 1,
221
+ fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
222
+ });
223
+ }
224
+ return normalizeExecutionConfig(execution);
225
+ }
226
+
227
+ export function detectNextApp(cwd) {
228
+ return (
229
+ fs.existsSync(path.join(cwd, "next.config.js")) ||
230
+ fs.existsSync(path.join(cwd, "next.config.mjs")) ||
231
+ fs.existsSync(path.join(cwd, "next.config.ts"))
232
+ );
233
+ }
234
+
235
+ export function parseModuleSpecifier(specifier) {
236
+ const [modulePath, exportName] = String(specifier).split("#", 2);
237
+ return {
238
+ modulePath,
239
+ exportName: exportName || "default",
240
+ };
241
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ normalizeBrowserServiceConfig,
4
+ normalizeRuntimePrepareConfig,
5
+ normalizeTemplateLifecycleStep,
6
+ normalizeTemplateStepInputs,
7
+ normalizeTemplateInputs,
8
+ parseModuleSpecifier,
9
+ } from "./runtime.mjs";
10
+
11
+ describe("config runtime helpers", () => {
12
+ it("normalizes template inputs and steps", () => {
13
+ expect(normalizeTemplateInputs(["foo.sql", "bar.sql"], "Service \"web\" database.template")).toEqual([
14
+ "foo.sql",
15
+ "bar.sql",
16
+ ]);
17
+ expect(
18
+ normalizeRuntimePrepareConfig(
19
+ {
20
+ inputs: ["fixtures/users.json"],
21
+ steps: [{ kind: "module", specifier: "./scripts/setup.mjs#run", inputs: ["fixtures/users.json"] }],
22
+ },
23
+ "web"
24
+ )
25
+ ).toEqual({
26
+ inputs: ["fixtures/users.json"],
27
+ steps: [
28
+ {
29
+ kind: "module",
30
+ specifier: "./scripts/setup.mjs#run",
31
+ cwd: null,
32
+ inputs: ["fixtures/users.json"],
33
+ },
34
+ ],
35
+ });
36
+ });
37
+
38
+ it("validates browser origins and module specifiers", () => {
39
+ expect(normalizeBrowserServiceConfig({ origins: ["http://localhost:3000"] }, "web")).toEqual({
40
+ origins: ["http://localhost:3000"],
41
+ });
42
+ expect(parseModuleSpecifier("./script.mjs#seed")).toEqual({
43
+ modulePath: "./script.mjs",
44
+ exportName: "seed",
45
+ });
46
+ });
47
+
48
+ it("rejects malformed lifecycle steps and empty step inputs", () => {
49
+ expect(() => normalizeTemplateLifecycleStep({ kind: "module" }, "runtime.prepare.steps[0]")).toThrow(
50
+ /specifier must be a non-empty string/
51
+ );
52
+ expect(() => normalizeTemplateStepInputs([""], "runtime.prepare.steps[0]")).toThrow(
53
+ /must be a non-empty string/
54
+ );
55
+ });
56
+ });
@@ -0,0 +1,189 @@
1
+ import { matchesSuiteSelectors, parseSuiteSelectors, suiteSelectionType } from "../runner/suite-selection.mjs";
2
+ import { normalizePath } from "./paths.mjs";
3
+
4
+ export function normalizeSkipReason(reason, label) {
5
+ const normalized = String(reason || "").trim();
6
+ if (!normalized) {
7
+ throw new Error(`${label} requires a non-empty reason`);
8
+ }
9
+ return normalized;
10
+ }
11
+
12
+ export function normalizeSkipConfig(value, { name, suites }) {
13
+ if (!value) return undefined;
14
+
15
+ const discoveredFiles = new Set();
16
+ const discoveredSuites = [];
17
+ for (const [type, typedSuites] of Object.entries(suites || {})) {
18
+ for (const suite of typedSuites || []) {
19
+ const displayType = suiteSelectionType(type, suite.framework || "k6");
20
+ discoveredSuites.push({ type, displayType, name: suite.name });
21
+ for (const file of suite.files || []) discoveredFiles.add(normalizePath(file));
22
+ }
23
+ }
24
+
25
+ const seenFiles = new Set();
26
+ const files = [];
27
+ for (const rule of value.files || []) {
28
+ if (!rule || typeof rule !== "object") {
29
+ throw new Error(`Service "${name}" skip.files entries must be objects`);
30
+ }
31
+ const filePath = normalizePath(rule.path);
32
+ const reason = normalizeSkipReason(rule.reason, `Service "${name}" skip.files["${filePath}"]`);
33
+ if (!filePath) throw new Error(`Service "${name}" skip.files entries require a non-empty path`);
34
+ if (seenFiles.has(filePath)) {
35
+ throw new Error(`Service "${name}" defines duplicate skip.files path "${filePath}"`);
36
+ }
37
+ if (!discoveredFiles.has(filePath)) {
38
+ throw new Error(`Service "${name}" skip.files path "${filePath}" did not match any discovered suite file`);
39
+ }
40
+ seenFiles.add(filePath);
41
+ files.push({ path: filePath, reason });
42
+ }
43
+
44
+ const parsedSelectors = (value.suites || []).flatMap((rule, index) => {
45
+ if (!rule || typeof rule !== "object") {
46
+ throw new Error(`Service "${name}" skip.suites entries must be objects`);
47
+ }
48
+ const selector = String(rule.selector || "").trim();
49
+ if (!selector) {
50
+ throw new Error(`Service "${name}" skip.suites[${index}] requires a non-empty selector`);
51
+ }
52
+ const reason = normalizeSkipReason(rule.reason, `Service "${name}" skip.suites["${selector}"]`);
53
+ const parsed = parseSuiteSelectors([selector]);
54
+ if (parsed.length !== 1) {
55
+ throw new Error(`Service "${name}" skip.suites["${selector}"] is invalid`);
56
+ }
57
+ return [{ selector: parsed[0], reason }];
58
+ });
59
+
60
+ const seenSelectors = new Set();
61
+ const suitesWithReasons = [];
62
+ for (const rule of parsedSelectors) {
63
+ if (seenSelectors.has(rule.selector.raw)) {
64
+ throw new Error(`Service "${name}" defines duplicate skip.suites selector "${rule.selector.raw}"`);
65
+ }
66
+ const matched = discoveredSuites.some((suite) =>
67
+ matchesSuiteSelectors(suite.displayType, suite.name, [rule.selector])
68
+ );
69
+ if (!matched) {
70
+ throw new Error(`Service "${name}" skip.suites selector "${rule.selector.raw}" did not match any discovered suite`);
71
+ }
72
+ seenSelectors.add(rule.selector.raw);
73
+ suitesWithReasons.push(rule);
74
+ }
75
+
76
+ if (files.length === 0 && suitesWithReasons.length === 0) return undefined;
77
+
78
+ return {
79
+ files,
80
+ fileReasonByPath: new Map(files.map((rule) => [rule.path, rule.reason])),
81
+ suites: suitesWithReasons,
82
+ };
83
+ }
84
+
85
+ export function normalizeServiceRequirements(value, { name, suites }) {
86
+ if (!value) return { suites: [], files: [], fileLocksByPath: new Map() };
87
+
88
+ const discoveredSuites = [];
89
+ const discoveredFiles = new Set();
90
+ for (const [type, typedSuites] of Object.entries(suites || {})) {
91
+ for (const suite of typedSuites || []) {
92
+ discoveredSuites.push({
93
+ displayType: suiteSelectionType(type, suite.framework || "k6"),
94
+ name: suite.name,
95
+ });
96
+ for (const file of suite.files || []) discoveredFiles.add(file);
97
+ }
98
+ }
99
+
100
+ const suiteRules = [];
101
+ const seenSelectors = new Set();
102
+ for (const rule of value.suites || []) {
103
+ if (!rule || typeof rule !== "object") {
104
+ throw new Error(`Service "${name}" requirements.suites entries must be objects`);
105
+ }
106
+ const selector = String(rule.selector || "").trim();
107
+ if (!selector) {
108
+ throw new Error(`Service "${name}" requirements.suites entries require a selector`);
109
+ }
110
+ const parsed = parseSuiteSelectors([selector]);
111
+ if (parsed.length !== 1) {
112
+ throw new Error(`Service "${name}" requirements.suites["${selector}"] is invalid`);
113
+ }
114
+ const parsedSelector = parsed[0];
115
+ if (parsedSelector.kind !== "typed") {
116
+ throw new Error(
117
+ `Service "${name}" requirements.suites["${selector}"] must use a typed selector like int:health`
118
+ );
119
+ }
120
+ if (seenSelectors.has(parsedSelector.raw)) {
121
+ throw new Error(`Service "${name}" defines duplicate requirements.suites selector "${parsedSelector.raw}"`);
122
+ }
123
+ const matched = discoveredSuites.some((suite) =>
124
+ matchesSuiteSelectors(suite.displayType, suite.name, [parsedSelector])
125
+ );
126
+ if (!matched) {
127
+ throw new Error(
128
+ `Service "${name}" requirements.suites selector "${parsedSelector.raw}" did not match any discovered suite`
129
+ );
130
+ }
131
+ seenSelectors.add(parsedSelector.raw);
132
+ suiteRules.push({
133
+ selector: parsedSelector,
134
+ locks: normalizeRequirementLocks(
135
+ rule.locks,
136
+ `Service "${name}" requirements.suites["${parsedSelector.raw}"].locks`
137
+ ),
138
+ });
139
+ }
140
+
141
+ const fileRules = [];
142
+ const seenFiles = new Set();
143
+ for (const [index, rule] of (value.files || []).entries()) {
144
+ if (!rule || typeof rule !== "object") {
145
+ throw new Error(`Service "${name}" requirements.files entries must be objects`);
146
+ }
147
+
148
+ const filePath = String(rule.path || "").trim();
149
+ if (!filePath) {
150
+ throw new Error(`Service "${name}" requirements.files[${index}] requires a path`);
151
+ }
152
+ if (!discoveredFiles.has(filePath)) {
153
+ throw new Error(`Service "${name}" requirements.files["${filePath}"] did not match any discovered test file`);
154
+ }
155
+ if (seenFiles.has(filePath)) {
156
+ throw new Error(`Service "${name}" defines duplicate requirements.files path "${filePath}"`);
157
+ }
158
+
159
+ seenFiles.add(filePath);
160
+ fileRules.push({
161
+ path: filePath,
162
+ locks: normalizeRequirementLocks(rule.locks, `Service "${name}" requirements.files["${filePath}"].locks`),
163
+ });
164
+ }
165
+
166
+ return {
167
+ suites: suiteRules,
168
+ files: fileRules,
169
+ fileLocksByPath: new Map(fileRules.map((rule) => [rule.path, rule.locks])),
170
+ };
171
+ }
172
+
173
+ export function normalizeRequirementLocks(value, label) {
174
+ const input = Array.isArray(value) ? value : value == null ? [] : [value];
175
+ const seen = new Set();
176
+ const locks = [];
177
+
178
+ for (const rawLock of input) {
179
+ const lockName = String(rawLock || "").trim();
180
+ if (!lockName) {
181
+ throw new Error(`${label} entries must be non-empty strings`);
182
+ }
183
+ if (seen.has(lockName)) continue;
184
+ seen.add(lockName);
185
+ locks.push(lockName);
186
+ }
187
+
188
+ return locks.sort();
189
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ normalizeRequirementLocks,
4
+ normalizeServiceRequirements,
5
+ normalizeSkipConfig,
6
+ normalizeSkipReason,
7
+ } from "./skip-config.mjs";
8
+
9
+ const suites = {
10
+ integration: [
11
+ {
12
+ name: "health",
13
+ framework: "k6",
14
+ files: ["app/api/health/__testkit__/health.int.testkit.ts"],
15
+ },
16
+ ],
17
+ playwright: [
18
+ {
19
+ name: "campaigns",
20
+ framework: "playwright",
21
+ files: ["app/campaigns/__testkit__/campaigns.pw.testkit.ts"],
22
+ },
23
+ ],
24
+ };
25
+
26
+ describe("config skip helpers", () => {
27
+ it("normalizes skip reasons and requirement locks", () => {
28
+ expect(normalizeSkipReason("flaky on CI", "skip.files[0]")).toBe("flaky on CI");
29
+ expect(normalizeRequirementLocks(["db", "db", "redis"], "locks")).toEqual(["db", "redis"]);
30
+ });
31
+
32
+ it("normalizes skip config against discovered suites and files", () => {
33
+ const result = normalizeSkipConfig(
34
+ {
35
+ files: [{ path: "app/campaigns/__testkit__/campaigns.pw.testkit.ts", reason: "manual only" }],
36
+ suites: [{ selector: "pw:campaigns", reason: "broken locally" }],
37
+ },
38
+ { name: "web", suites }
39
+ );
40
+
41
+ expect(result.files).toHaveLength(1);
42
+ expect(result.suites).toHaveLength(1);
43
+ });
44
+
45
+ it("normalizes typed service requirements", () => {
46
+ const result = normalizeServiceRequirements(
47
+ {
48
+ suites: [{ selector: "int:health", locks: ["db"] }],
49
+ files: [{ path: "app/campaigns/__testkit__/campaigns.pw.testkit.ts", locks: ["browser", "browser"] }],
50
+ },
51
+ { name: "web", suites }
52
+ );
53
+
54
+ expect(result.suites[0]).toMatchObject({
55
+ selector: expect.objectContaining({ raw: "int:health" }),
56
+ locks: ["db"],
57
+ });
58
+ expect(result.files[0]).toMatchObject({
59
+ path: "app/campaigns/__testkit__/campaigns.pw.testkit.ts",
60
+ locks: ["browser"],
61
+ });
62
+ });
63
+ });
@@ -0,0 +1,28 @@
1
+ export function normalizeTelemetryConfig(telemetry) {
2
+ if (!telemetry) return null;
3
+ if (telemetry.endpoint) {
4
+ let parsed;
5
+ try {
6
+ parsed = new URL(telemetry.endpoint);
7
+ } catch {
8
+ throw new Error("testkit.setup telemetry.endpoint must be a valid URL");
9
+ }
10
+ if (!["http:", "https:"].includes(parsed.protocol)) {
11
+ throw new Error("testkit.setup telemetry.endpoint must use http or https");
12
+ }
13
+ }
14
+ if (telemetry.enabled === true) {
15
+ if (!telemetry.endpoint) {
16
+ throw new Error("testkit.setup telemetry.endpoint is required when telemetry.enabled is true");
17
+ }
18
+ if (!telemetry.tokenEnv) {
19
+ throw new Error("testkit.setup telemetry.tokenEnv is required when telemetry.enabled is true");
20
+ }
21
+ }
22
+ return {
23
+ enabled: telemetry.enabled === true,
24
+ endpoint: telemetry.endpoint,
25
+ tokenEnv: telemetry.tokenEnv,
26
+ timeoutMs: telemetry.timeoutMs || 3000,
27
+ };
28
+ }