@elench/testkit 0.1.55 → 0.1.57

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.
@@ -5,6 +5,7 @@ const TESTKIT_DIRNAME = "__testkit__";
5
5
  const DISCOVERY_RULES = [
6
6
  { suffix: ".int.testkit.ts", type: "integration", framework: "k6" },
7
7
  { suffix: ".e2e.testkit.ts", type: "e2e", framework: "k6" },
8
+ { suffix: ".scenario.testkit.ts", type: "scenario", framework: "k6" },
8
9
  { suffix: ".dal.testkit.ts", type: "dal", framework: "k6" },
9
10
  { suffix: ".load.testkit.ts", type: "load", framework: "k6" },
10
11
  { suffix: ".pw.testkit.ts", type: "e2e", framework: "playwright" },
@@ -28,13 +29,13 @@ const IGNORED_DIRS = new Set([
28
29
  "test-results",
29
30
  ]);
30
31
 
31
- export function discoverProject(productDir, explicitServices = {}) {
32
+ export function discoverProject(productDir, explicitServices = {}, options = {}) {
33
+ const strict = options.strict !== false;
32
34
  const { suiteFiles, legacyFiles } = discoverFiles(productDir);
33
35
  const groupedByService = {};
34
36
  const services = {};
35
- const unowned = [];
36
- const ambiguous = [];
37
- const discoveredSuites = [];
37
+ const diagnostics = buildLegacyFileDiagnostics(legacyFiles);
38
+ const discoveredFiles = [];
38
39
 
39
40
  for (const filePath of suiteFiles) {
40
41
  const rule = inferRule(filePath);
@@ -42,12 +43,20 @@ export function discoverProject(productDir, explicitServices = {}) {
42
43
 
43
44
  const owners = inferOwners(filePath, explicitServices);
44
45
  if (owners.length === 0) {
45
- unowned.push(filePath);
46
+ diagnostics.push({
47
+ code: "unowned_test",
48
+ severity: "error",
49
+ message: `Unowned test file: ${filePath}`,
50
+ path: filePath,
51
+ });
46
52
  continue;
47
53
  }
48
54
  if (owners.length > 1) {
49
- ambiguous.push({
50
- filePath,
55
+ diagnostics.push({
56
+ code: "ambiguous_test",
57
+ severity: "error",
58
+ message: `Ambiguous test file: ${filePath} -> ${owners.map((owner) => owner.name).sort((left, right) => left.localeCompare(right)).join(", ")}`,
59
+ path: filePath,
51
60
  serviceNames: owners.map((owner) => owner.name).sort((left, right) => left.localeCompare(right)),
52
61
  });
53
62
  continue;
@@ -57,7 +66,7 @@ export function discoverProject(productDir, explicitServices = {}) {
57
66
  services[owner.name] = mergeServiceDiscovery(services[owner.name], owner);
58
67
  const relativeToService = relativeToServiceRoot(owner, filePath);
59
68
  const suiteRef = deriveSuiteRef(relativeToService, rule.suffix);
60
- discoveredSuites.push({
69
+ discoveredFiles.push({
61
70
  serviceName: owner.name,
62
71
  type: rule.type,
63
72
  framework: rule.framework,
@@ -66,11 +75,11 @@ export function discoverProject(productDir, explicitServices = {}) {
66
75
  });
67
76
  }
68
77
 
69
- if (legacyFiles.length > 0 || unowned.length > 0 || ambiguous.length > 0) {
70
- throw buildDiscoveryError(legacyFiles, unowned, ambiguous);
78
+ if (strict && hasDiscoveryErrors(diagnostics)) {
79
+ throw buildDiscoveryErrorFromDiagnostics(diagnostics);
71
80
  }
72
81
 
73
- for (const entry of discoveredSuites) {
82
+ for (const entry of discoveredFiles) {
74
83
  const grouped = groupedByService[entry.serviceName] || {};
75
84
  const suitesForType = grouped[entry.type] || [];
76
85
  const suiteKey = entry.suitePath.join("/");
@@ -94,12 +103,23 @@ export function discoverProject(productDir, explicitServices = {}) {
94
103
  suite.files.push(entry.filePath);
95
104
  }
96
105
 
106
+ const fileEntries = [];
97
107
  for (const grouped of Object.values(groupedByService)) {
98
108
  for (const suites of Object.values(grouped)) {
99
109
  const suiteNames = disambiguateSuiteNames(suites);
100
110
  for (const suite of suites) {
101
111
  suite.name = suiteNames.get(suite._suiteKey);
102
112
  suite.files.sort((left, right) => left.localeCompare(right));
113
+ for (const filePath of suite.files) {
114
+ fileEntries.push({
115
+ serviceName: findSuiteServiceName(groupedByService, grouped, suite),
116
+ type: findSuiteType(grouped, suites),
117
+ framework: suite.framework,
118
+ suiteName: suite.name,
119
+ suitePath: [...suite._suitePath],
120
+ filePath,
121
+ });
122
+ }
103
123
  delete suite._suiteKey;
104
124
  delete suite._suitePath;
105
125
  }
@@ -110,11 +130,59 @@ export function discoverProject(productDir, explicitServices = {}) {
110
130
  return {
111
131
  services,
112
132
  suitesByService: groupedByService,
133
+ files: fileEntries.sort(
134
+ (left, right) =>
135
+ left.serviceName.localeCompare(right.serviceName) ||
136
+ left.type.localeCompare(right.type) ||
137
+ left.suiteName.localeCompare(right.suiteName) ||
138
+ left.filePath.localeCompare(right.filePath)
139
+ ),
140
+ diagnostics,
113
141
  };
114
142
  }
115
143
 
116
- export function discoverSuites(productDir, explicitServices = {}) {
117
- return discoverProject(productDir, explicitServices).suitesByService;
144
+ export function discoverSuites(productDir, explicitServices = {}, options = {}) {
145
+ return discoverProject(productDir, explicitServices, options).suitesByService;
146
+ }
147
+
148
+ export function hasDiscoveryErrors(diagnostics = []) {
149
+ return diagnostics.some((entry) => entry?.severity === "error");
150
+ }
151
+
152
+ export function buildDiscoveryErrorFromDiagnostics(diagnostics = []) {
153
+ const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
154
+
155
+ const legacyFiles = diagnostics.filter((entry) => entry.code === "legacy_path").map((entry) => entry.path);
156
+ const unownedFiles = diagnostics.filter((entry) => entry.code === "unowned_test").map((entry) => entry.path);
157
+ const ambiguousFiles = diagnostics.filter((entry) => entry.code === "ambiguous_test");
158
+
159
+ if (legacyFiles.length > 0) {
160
+ lines.push("");
161
+ lines.push("Legacy test files outside __testkit__:");
162
+ for (const filePath of legacyFiles) {
163
+ lines.push(`- ${filePath}`);
164
+ }
165
+ }
166
+
167
+ if (unownedFiles.length > 0) {
168
+ lines.push("");
169
+ lines.push("Unowned test files:");
170
+ for (const filePath of unownedFiles) {
171
+ lines.push(`- ${filePath}`);
172
+ }
173
+ }
174
+
175
+ if (ambiguousFiles.length > 0) {
176
+ lines.push("");
177
+ lines.push("Ambiguous test files:");
178
+ for (const entry of ambiguousFiles) {
179
+ lines.push(`- ${entry.path} -> ${entry.serviceNames.join(", ")}`);
180
+ }
181
+ }
182
+
183
+ lines.push("");
184
+ lines.push('Expected test files to live under a "__testkit__" directory within a service root.');
185
+ return new Error(lines.join("\n"));
118
186
  }
119
187
 
120
188
  function discoverFiles(productDir) {
@@ -305,38 +373,6 @@ function mergeServiceDiscovery(existing, owner) {
305
373
  };
306
374
  }
307
375
 
308
- function buildDiscoveryError(legacyFiles, unowned, ambiguous) {
309
- const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
310
-
311
- if (legacyFiles.length > 0) {
312
- lines.push("");
313
- lines.push("Legacy test files outside __testkit__:");
314
- for (const filePath of legacyFiles) {
315
- lines.push(`- ${filePath}`);
316
- }
317
- }
318
-
319
- if (unowned.length > 0) {
320
- lines.push("");
321
- lines.push("Unowned test files:");
322
- for (const filePath of unowned) {
323
- lines.push(`- ${filePath}`);
324
- }
325
- }
326
-
327
- if (ambiguous.length > 0) {
328
- lines.push("");
329
- lines.push("Ambiguous test files:");
330
- for (const entry of ambiguous) {
331
- lines.push(`- ${entry.filePath} -> ${entry.serviceNames.join(", ")}`);
332
- }
333
- }
334
-
335
- lines.push("");
336
- lines.push('Expected test files to live under a "__testkit__" directory within a service root.');
337
- return new Error(lines.join("\n"));
338
- }
339
-
340
376
  function inferRule(filePath) {
341
377
  return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
342
378
  }
@@ -350,3 +386,29 @@ function normalizePath(value) {
350
386
  if (normalized === "." || normalized === "./") return ".";
351
387
  return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "") || ".";
352
388
  }
389
+
390
+ function buildLegacyFileDiagnostics(legacyFiles) {
391
+ return legacyFiles.map((filePath) => ({
392
+ code: "legacy_path",
393
+ severity: "error",
394
+ message: `Legacy test file outside __testkit__: ${filePath}`,
395
+ path: filePath,
396
+ }));
397
+ }
398
+
399
+ function findSuiteServiceName(groupedByService, grouped, suite) {
400
+ for (const [serviceName, candidate] of Object.entries(groupedByService)) {
401
+ if (candidate !== grouped) continue;
402
+ for (const suites of Object.values(candidate)) {
403
+ if (suites.includes(suite)) return serviceName;
404
+ }
405
+ }
406
+ return "app";
407
+ }
408
+
409
+ function findSuiteType(grouped, targetSuites) {
410
+ for (const [type, suites] of Object.entries(grouped)) {
411
+ if (suites === targetSuites) return type;
412
+ }
413
+ return "integration";
414
+ }
@@ -19,9 +19,10 @@ describe("filesystem-discovery", () => {
19
19
 
20
20
  writeFile(productDir, "src/api/routes/__testkit__/auth/me.int.testkit.ts");
21
21
  writeFile(productDir, "src/api/routes/__testkit__/health/ready.int.testkit.ts");
22
+ writeFile(productDir, "src/api/routes/__testkit__/journeys/smoke.scenario.testkit.ts");
22
23
  writeFile(productDir, "frontend/app/__testkit__/homepage/homepage.pw.testkit.ts");
23
24
 
24
- const suites = discoverSuites(productDir, {
25
+ const project = discoverProject(productDir, {
25
26
  api: {
26
27
  local: {
27
28
  cwd: ".",
@@ -34,6 +35,7 @@ describe("filesystem-discovery", () => {
34
35
  },
35
36
  });
36
37
 
38
+ const suites = project.suitesByService;
37
39
  expect(suites.api.integration).toEqual([
38
40
  {
39
41
  name: "auth",
@@ -46,6 +48,13 @@ describe("filesystem-discovery", () => {
46
48
  framework: "k6",
47
49
  },
48
50
  ]);
51
+ expect(suites.api.scenario).toEqual([
52
+ {
53
+ name: "journeys",
54
+ files: ["src/api/routes/__testkit__/journeys/smoke.scenario.testkit.ts"],
55
+ framework: "k6",
56
+ },
57
+ ]);
49
58
  expect(suites.frontend.e2e).toEqual([
50
59
  {
51
60
  name: "homepage",
@@ -53,6 +62,41 @@ describe("filesystem-discovery", () => {
53
62
  framework: "playwright",
54
63
  },
55
64
  ]);
65
+ expect(project.files).toEqual([
66
+ {
67
+ serviceName: "api",
68
+ type: "integration",
69
+ framework: "k6",
70
+ suiteName: "auth",
71
+ suitePath: ["src", "api", "routes", "auth"],
72
+ filePath: "src/api/routes/__testkit__/auth/me.int.testkit.ts",
73
+ },
74
+ {
75
+ serviceName: "api",
76
+ type: "integration",
77
+ framework: "k6",
78
+ suiteName: "health",
79
+ suitePath: ["src", "api", "routes", "health"],
80
+ filePath: "src/api/routes/__testkit__/health/ready.int.testkit.ts",
81
+ },
82
+ {
83
+ serviceName: "api",
84
+ type: "scenario",
85
+ framework: "k6",
86
+ suiteName: "journeys",
87
+ suitePath: ["src", "api", "routes", "journeys"],
88
+ filePath: "src/api/routes/__testkit__/journeys/smoke.scenario.testkit.ts",
89
+ },
90
+ {
91
+ serviceName: "frontend",
92
+ type: "e2e",
93
+ framework: "playwright",
94
+ suiteName: "homepage",
95
+ suitePath: ["app", "homepage"],
96
+ filePath: "frontend/app/__testkit__/homepage/homepage.pw.testkit.ts",
97
+ },
98
+ ]);
99
+ expect(project.diagnostics).toEqual([]);
56
100
  });
57
101
 
58
102
  it("infers the suite from the directory that owns __testkit__", () => {
@@ -122,6 +166,44 @@ describe("filesystem-discovery", () => {
122
166
  })
123
167
  ).toThrow("Legacy test files outside __testkit__");
124
168
  });
169
+
170
+ it("reports legacy files in non-strict mode", () => {
171
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
172
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
173
+
174
+ writeFile(productDir, "src/api/routes/__testkit__/health/ready.int.testkit.ts");
175
+ writeFile(productDir, "tests/api/integration/health.int.testkit.ts");
176
+
177
+ const project = discoverProject(
178
+ productDir,
179
+ {
180
+ api: {
181
+ local: {
182
+ cwd: ".",
183
+ },
184
+ },
185
+ },
186
+ {
187
+ strict: false,
188
+ }
189
+ );
190
+
191
+ expect(project.suitesByService.api.integration).toEqual([
192
+ {
193
+ name: "health",
194
+ files: ["src/api/routes/__testkit__/health/ready.int.testkit.ts"],
195
+ framework: "k6",
196
+ },
197
+ ]);
198
+ expect(project.diagnostics).toEqual([
199
+ {
200
+ code: "legacy_path",
201
+ severity: "error",
202
+ message: "Legacy test file outside __testkit__: tests/api/integration/health.int.testkit.ts",
203
+ path: "tests/api/integration/health.int.testkit.ts",
204
+ },
205
+ ]);
206
+ });
125
207
  });
126
208
 
127
209
  function writeFile(productDir, relativePath) {
@@ -31,14 +31,15 @@ export function parseDotenv(filePath) {
31
31
  return parseDotenvString(fs.readFileSync(filePath, "utf8"));
32
32
  }
33
33
 
34
- export async function loadConfigs(opts = {}) {
34
+ export async function loadConfigContext(opts = {}) {
35
35
  const productDir = resolveProductDir(process.cwd(), opts.dir);
36
- const { setup, setupFile } = await loadTestkitSetup(productDir);
36
+ const setupContext = opts.setupContext || (await loadTestkitSetup(productDir));
37
+ const { setup, setupFile } = setupContext;
37
38
  const execution = normalizeRepoExecution(setup.execution);
38
39
  const reporting = normalizeReportingConfig(setup.reporting);
39
40
  const toolchains = normalizeToolchainRegistry(setup.toolchains);
40
41
  const explicitServices = setup.services || {};
41
- const discovery = discoverProject(productDir, explicitServices);
42
+ const discovery = discoverProject(productDir, explicitServices, opts.discoveryOptions || {});
42
43
  const serviceNames = new Set([
43
44
  ...Object.keys(explicitServices),
44
45
  ...Object.keys(discovery.suitesByService),
@@ -63,6 +64,23 @@ export async function loadConfigs(opts = {}) {
63
64
 
64
65
  validateConfigCoverage(configs);
65
66
 
67
+ return {
68
+ productDir,
69
+ setup,
70
+ setupFile,
71
+ execution,
72
+ reporting,
73
+ toolchains,
74
+ explicitServices,
75
+ discovery,
76
+ configs,
77
+ };
78
+ }
79
+
80
+ export async function loadConfigs(opts = {}) {
81
+ const context = await loadConfigContext(opts);
82
+ const { configs } = context;
83
+
66
84
  const filtered = opts.service
67
85
  ? configs.filter((config) => config.name === opts.service)
68
86
  : configs;
@@ -0,0 +1,121 @@
1
+ export type DiscoverySelectionType = "int" | "e2e" | "scenario" | "dal" | "load" | "pw";
2
+ export type DiscoveryInternalType = "integration" | "e2e" | "scenario" | "dal" | "load";
3
+ export type DiscoveryFramework = "k6" | "playwright";
4
+
5
+ export interface DiscoveryDiagnostic {
6
+ code: string;
7
+ severity: "error" | "warning";
8
+ message: string;
9
+ path?: string;
10
+ serviceNames?: string[];
11
+ }
12
+
13
+ export interface DiscoveryService {
14
+ name: string;
15
+ discovered: boolean;
16
+ localCwd: string;
17
+ dependsOn: string[];
18
+ suiteCount: number;
19
+ fileCount: number;
20
+ activeFileCount: number;
21
+ skippedFileCount: number;
22
+ }
23
+
24
+ export interface DiscoverySuite {
25
+ id: string;
26
+ service: string;
27
+ name: string;
28
+ displayName: string;
29
+ selectionType: DiscoverySelectionType;
30
+ internalType: DiscoveryInternalType;
31
+ framework: DiscoveryFramework;
32
+ groupLabel: string;
33
+ fileCount: number;
34
+ activeFileCount: number;
35
+ skippedFileCount: number;
36
+ dependsOn: string[];
37
+ locks: string[];
38
+ filePaths: string[];
39
+ }
40
+
41
+ export interface DiscoveryHistorySummary {
42
+ firstSeenAt?: string | null;
43
+ lastSeenAt?: string | null;
44
+ lastRunAt?: string | null;
45
+ runCount?: number;
46
+ passCount?: number;
47
+ failCount?: number;
48
+ skipCount?: number;
49
+ avgDurationMs?: number;
50
+ lastStatus?: "passed" | "failed" | "skipped" | "not_run" | null;
51
+ }
52
+
53
+ export interface DiscoveryFile {
54
+ id: string;
55
+ path: string;
56
+ displayName: string;
57
+ service: string;
58
+ suiteName: string;
59
+ groupLabel: string;
60
+ selectionType: DiscoverySelectionType;
61
+ internalType: DiscoveryInternalType;
62
+ framework: DiscoveryFramework;
63
+ skipped: boolean;
64
+ skipReason: string | null;
65
+ locks: string[];
66
+ dependsOn: string[];
67
+ history?: DiscoveryHistorySummary;
68
+ }
69
+
70
+ export interface DiscoveryResult {
71
+ schemaVersion: number;
72
+ source: "testkit-discovery";
73
+ product: {
74
+ name: string;
75
+ directory: string;
76
+ };
77
+ setupFile: string | null;
78
+ filters: {
79
+ service: string | null;
80
+ types: DiscoverySelectionType[] | ["all"];
81
+ suiteSelectors: string[];
82
+ fileNames: string[];
83
+ runnableOnly: boolean;
84
+ diagnostics: "error" | "report";
85
+ };
86
+ services: DiscoveryService[];
87
+ suites: DiscoverySuite[];
88
+ files: DiscoveryFile[];
89
+ diagnostics: DiscoveryDiagnostic[];
90
+ summary: {
91
+ services: number;
92
+ suites: number;
93
+ files: number;
94
+ activeFiles: number;
95
+ skippedFiles: number;
96
+ diagnostics: {
97
+ errors: number;
98
+ warnings: number;
99
+ };
100
+ byService: Record<string, number>;
101
+ byType: Record<string, number>;
102
+ };
103
+ history: {
104
+ available: boolean;
105
+ path?: string;
106
+ };
107
+ }
108
+
109
+ export interface DiscoverTestsOptions {
110
+ dir?: string;
111
+ service?: string;
112
+ type?: string | string[];
113
+ suite?: string | string[];
114
+ file?: string | string[];
115
+ runnableOnly?: boolean;
116
+ diagnostics?: "error" | "report";
117
+ }
118
+
119
+ export declare function discoverTests(options?: DiscoverTestsOptions): Promise<DiscoveryResult>;
120
+ export declare function formatSelectionTypeLabel(type: DiscoverySelectionType): string;
121
+ export declare function formatDisplayName(value: string): string;