@elench/testkit 0.1.56 → 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.
- package/README.md +59 -0
- package/lib/cli/commands/discover.mjs +80 -0
- package/lib/cli/entrypoint.mjs +1 -0
- package/lib/cli/presentation/colors.mjs +32 -0
- package/lib/cli/presentation/discovery-reporter.mjs +166 -0
- package/lib/config/discovery.mjs +106 -45
- package/lib/config/discovery.test.mjs +75 -1
- package/lib/config/index.mjs +21 -3
- package/lib/discovery/index.d.ts +121 -0
- package/lib/discovery/index.mjs +540 -0
- package/lib/discovery/index.test.mjs +182 -0
- package/lib/history/index.d.ts +46 -0
- package/lib/history/index.mjs +166 -0
- package/lib/history/index.test.mjs +115 -0
- package/lib/package.test.mjs +5 -0
- package/lib/runner/orchestrator.mjs +7 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -14,6 +14,12 @@ cd my-product
|
|
|
14
14
|
# Run every testkit-managed suite
|
|
15
15
|
npx @elench/testkit
|
|
16
16
|
|
|
17
|
+
# Inspect discovered tests without running them
|
|
18
|
+
npx @elench/testkit discover
|
|
19
|
+
npx @elench/testkit discover --output-mode verbose
|
|
20
|
+
npx @elench/testkit discover --json > .testkit/discovery.json
|
|
21
|
+
npx @elench/testkit discover --output .testkit/discovery.json
|
|
22
|
+
|
|
17
23
|
# Filter by type
|
|
18
24
|
npx @elench/testkit --type int
|
|
19
25
|
npx @elench/testkit --type dal
|
|
@@ -70,6 +76,11 @@ captured runtime output, emitted artifacts, and user-visible LLM responses are
|
|
|
70
76
|
persisted under `.testkit/results/` and inspected on demand with `show`,
|
|
71
77
|
`artifacts`, `logs`, or `watch`.
|
|
72
78
|
|
|
79
|
+
`testkit discover` also maintains a small durable per-test history index at
|
|
80
|
+
`.testkit/history/tests.json`. The index tracks first/last seen timestamps,
|
|
81
|
+
run counts, pass/fail/skip counts, average duration, and last observed status,
|
|
82
|
+
and those summaries are exposed in compact, verbose, and JSON discovery output.
|
|
83
|
+
|
|
73
84
|
## Setup
|
|
74
85
|
|
|
75
86
|
Create `testkit.setup.ts` at repo root:
|
|
@@ -411,6 +422,54 @@ Suite names are inferred from the colocated path:
|
|
|
411
422
|
- `auth/__testkit__/*.int.testkit.ts` => `auth`
|
|
412
423
|
- `routes/__testkit__/auth/*.int.testkit.ts` => `auth`
|
|
413
424
|
|
|
425
|
+
Discovery is also a first-class CLI/API surface:
|
|
426
|
+
|
|
427
|
+
- `testkit discover`
|
|
428
|
+
- human-first compact output with service -> type -> suite -> file hierarchy
|
|
429
|
+
- `testkit discover --output-mode verbose`
|
|
430
|
+
- explicit paths, IDs, locks, dependencies, skip reasons, and history detail
|
|
431
|
+
- `testkit discover --json`
|
|
432
|
+
- machine-readable output with stable enums, canonical paths, and summary data
|
|
433
|
+
- `testkit discover --output .testkit/discovery.json`
|
|
434
|
+
- writes the same machine-readable JSON document to a file artifact
|
|
435
|
+
|
|
436
|
+
Compact mode prefers derived human labels such as `Agent Configs Auth Gate`
|
|
437
|
+
instead of printing long file paths as the primary row label. Exact paths remain
|
|
438
|
+
available in verbose and JSON output.
|
|
439
|
+
|
|
440
|
+
The public API is exported from `@elench/testkit/discovery`:
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
import { discoverTests } from "@elench/testkit/discovery";
|
|
444
|
+
|
|
445
|
+
const result = await discoverTests({
|
|
446
|
+
dir: process.cwd(),
|
|
447
|
+
runnableOnly: true,
|
|
448
|
+
diagnostics: "report",
|
|
449
|
+
});
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
JSON and file output are machine-first. Each discovered file carries a stable
|
|
453
|
+
identifier plus canonical metadata such as:
|
|
454
|
+
|
|
455
|
+
- `id`
|
|
456
|
+
- `path`
|
|
457
|
+
- `service`
|
|
458
|
+
- `suiteName`
|
|
459
|
+
- `selectionType`
|
|
460
|
+
- `internalType`
|
|
461
|
+
- `framework`
|
|
462
|
+
- `skipped`
|
|
463
|
+
- `skipReason`
|
|
464
|
+
- `locks`
|
|
465
|
+
- `dependsOn`
|
|
466
|
+
- `displayName`
|
|
467
|
+
- `history`
|
|
468
|
+
|
|
469
|
+
Discovery history is generic and local to `testkit`. `firstSeenAt` is derived
|
|
470
|
+
from the first time a file appears in the history index, not from filesystem or
|
|
471
|
+
Git metadata.
|
|
472
|
+
|
|
414
473
|
## Local Databases
|
|
415
474
|
|
|
416
475
|
`@elench/testkit` provisions Docker-managed local Postgres automatically for
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Command, Flags } from "@oclif/core";
|
|
4
|
+
import { discoverTests } from "../../discovery/index.mjs";
|
|
5
|
+
import { resolveProductDir } from "../../config/index.mjs";
|
|
6
|
+
import { buildDiscoveryReportLines } from "../presentation/discovery-reporter.mjs";
|
|
7
|
+
import { resolveRequestedFiles } from "../args.mjs";
|
|
8
|
+
import { sharedFlags } from "../command-helpers.mjs";
|
|
9
|
+
|
|
10
|
+
export default class DiscoverCommand extends Command {
|
|
11
|
+
static summary = "Discover managed tests and report their metadata";
|
|
12
|
+
|
|
13
|
+
static enableJsonFlag = true;
|
|
14
|
+
|
|
15
|
+
static flags = {
|
|
16
|
+
...sharedFlags,
|
|
17
|
+
type: Flags.string({
|
|
18
|
+
char: "t",
|
|
19
|
+
multiple: true,
|
|
20
|
+
description: "Filter by suite type(s): int, e2e, scenario, dal, load, pw, all",
|
|
21
|
+
}),
|
|
22
|
+
suite: Flags.string({
|
|
23
|
+
char: "s",
|
|
24
|
+
multiple: true,
|
|
25
|
+
description: "Filter by suite selector(s)",
|
|
26
|
+
}),
|
|
27
|
+
file: Flags.string({
|
|
28
|
+
char: "f",
|
|
29
|
+
multiple: true,
|
|
30
|
+
description: "Filter by exact file path(s)",
|
|
31
|
+
}),
|
|
32
|
+
"runnable-only": Flags.boolean({
|
|
33
|
+
description: "Show only files not skipped by repo rules",
|
|
34
|
+
default: false,
|
|
35
|
+
}),
|
|
36
|
+
strict: Flags.boolean({
|
|
37
|
+
description: "Fail instead of reporting discovery/config diagnostics",
|
|
38
|
+
default: false,
|
|
39
|
+
}),
|
|
40
|
+
output: Flags.string({
|
|
41
|
+
description: "Write machine-readable discovery JSON to a file",
|
|
42
|
+
}),
|
|
43
|
+
"output-mode": Flags.string({
|
|
44
|
+
description: "Human-readable output mode",
|
|
45
|
+
options: ["compact", "verbose"],
|
|
46
|
+
default: "compact",
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
async run() {
|
|
51
|
+
const { flags } = await this.parse(DiscoverCommand);
|
|
52
|
+
const productDir = resolveProductDir(process.cwd(), flags.dir);
|
|
53
|
+
const fileNames = resolveRequestedFiles(flags.file || [], productDir, process.cwd());
|
|
54
|
+
const result = await discoverTests({
|
|
55
|
+
dir: productDir,
|
|
56
|
+
service: flags.service || null,
|
|
57
|
+
type: flags.type || [],
|
|
58
|
+
suite: flags.suite || [],
|
|
59
|
+
file: fileNames,
|
|
60
|
+
runnableOnly: flags["runnable-only"],
|
|
61
|
+
diagnostics: flags.strict ? "error" : "report",
|
|
62
|
+
});
|
|
63
|
+
let outputLabel = null;
|
|
64
|
+
if (flags.output) {
|
|
65
|
+
const outputPath = path.resolve(productDir, flags.output);
|
|
66
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
67
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
68
|
+
outputLabel = path.relative(productDir, outputPath) || path.basename(outputPath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!this.jsonEnabled()) {
|
|
72
|
+
for (const line of buildDiscoveryReportLines(result, { outputMode: flags["output-mode"] })) {
|
|
73
|
+
this.log(line);
|
|
74
|
+
}
|
|
75
|
+
if (outputLabel) this.log(`Wrote ${outputLabel}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/lib/cli/entrypoint.mjs
CHANGED
|
@@ -14,6 +14,38 @@ export function dim(text) {
|
|
|
14
14
|
return pc.dim(text);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export function bold(text) {
|
|
18
|
+
return pc.bold(text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function muted(text) {
|
|
22
|
+
return pc.dim(text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function colorHeading(text) {
|
|
26
|
+
return pc.bold(text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function colorService(text) {
|
|
30
|
+
return pc.cyan(pc.bold(text));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function colorTypeBadge(type) {
|
|
34
|
+
if (type === "INT") return pc.blue(type);
|
|
35
|
+
if (type === "E2E") return pc.magenta(type);
|
|
36
|
+
if (type === "SCENARIO") return pc.yellow(type);
|
|
37
|
+
if (type === "DAL") return pc.green(type);
|
|
38
|
+
if (type === "LOAD") return pc.cyan(type);
|
|
39
|
+
if (type === "PW") return pc.blue(pc.bold(type));
|
|
40
|
+
return type;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function colorDiagnosticSeverity(severity) {
|
|
44
|
+
if (severity === "error") return pc.red(pc.bold("ERR"));
|
|
45
|
+
if (severity === "warning") return pc.yellow(pc.bold("WARN"));
|
|
46
|
+
return pc.dim("INFO");
|
|
47
|
+
}
|
|
48
|
+
|
|
17
49
|
export function colorResultLine(line) {
|
|
18
50
|
if (/^Result: PASSED\b/.test(line)) return line.replace("PASSED", pc.green("PASSED"));
|
|
19
51
|
if (/^Result: FAILED\b/.test(line)) return line.replace("FAILED", pc.red("FAILED"));
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { formatDuration } from "../../runner/formatting.mjs";
|
|
2
|
+
import { formatSelectionTypeLabel } from "../../discovery/index.mjs";
|
|
3
|
+
import {
|
|
4
|
+
bold,
|
|
5
|
+
colorDiagnosticSeverity,
|
|
6
|
+
colorHeading,
|
|
7
|
+
colorService,
|
|
8
|
+
colorStatus,
|
|
9
|
+
colorTypeBadge,
|
|
10
|
+
muted,
|
|
11
|
+
} from "./colors.mjs";
|
|
12
|
+
|
|
13
|
+
const TYPE_ORDER = ["int", "e2e", "scenario", "dal", "load", "pw"];
|
|
14
|
+
|
|
15
|
+
export function buildDiscoveryReportLines(result, options = {}) {
|
|
16
|
+
const mode = options.outputMode || "compact";
|
|
17
|
+
return mode === "verbose" ? buildVerboseLines(result) : buildCompactLines(result);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildCompactLines(result) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
lines.push(
|
|
23
|
+
`${colorHeading("Summary")} ${result.summary.files} files · ${result.summary.activeFiles} active · ${result.summary.skippedFiles} skipped · ${result.summary.suites} suites · ${result.summary.services} services`
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (result.history?.available) {
|
|
27
|
+
lines.push(muted(`history ${result.history.path || ".testkit/history/tests.json"}`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
appendDiagnostics(lines, result.diagnostics);
|
|
31
|
+
|
|
32
|
+
const suitesByService = groupSuitesByServiceAndType(result.suites);
|
|
33
|
+
const filesBySuite = groupFilesBySuite(result.files);
|
|
34
|
+
|
|
35
|
+
for (const service of result.services) {
|
|
36
|
+
lines.push("");
|
|
37
|
+
lines.push(
|
|
38
|
+
`${colorService(service.name)} ${muted(`(${service.fileCount} files${service.dependsOn.length > 0 ? ` · depends on ${service.dependsOn.join(", ")}` : ""})`)}`
|
|
39
|
+
);
|
|
40
|
+
const typeGroups = suitesByService.get(service.name) || new Map();
|
|
41
|
+
for (const type of orderedTypes([...typeGroups.keys()])) {
|
|
42
|
+
const suites = typeGroups.get(type) || [];
|
|
43
|
+
lines.push(` ${colorTypeBadge(type.toUpperCase())} ${formatSelectionTypeLabel(type)}`);
|
|
44
|
+
for (const suite of suites) {
|
|
45
|
+
lines.push(` ${bold(suite.groupLabel)} ${muted(`(${suite.fileCount} files)`)}`);
|
|
46
|
+
for (const file of filesBySuite.get(suite.id) || []) {
|
|
47
|
+
const status = file.skipped ? `${colorStatus("SKIP")} ${file.skipReason}` : muted(buildHistoryHint(file));
|
|
48
|
+
lines.push(` ${file.displayName}${status ? ` ${status}` : ""}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildVerboseLines(result) {
|
|
58
|
+
const lines = [];
|
|
59
|
+
lines.push(`${colorHeading("Summary")} ${JSON.stringify(result.summary)}`);
|
|
60
|
+
if (result.setupFile) {
|
|
61
|
+
lines.push(`setup ${result.setupFile}`);
|
|
62
|
+
}
|
|
63
|
+
if (result.history?.available) {
|
|
64
|
+
lines.push(`history ${result.history.path}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
appendDiagnostics(lines, result.diagnostics);
|
|
68
|
+
|
|
69
|
+
for (const service of result.services) {
|
|
70
|
+
lines.push("");
|
|
71
|
+
lines.push(colorService(service.name));
|
|
72
|
+
lines.push(` cwd ${service.localCwd}`);
|
|
73
|
+
if (service.dependsOn.length > 0) {
|
|
74
|
+
lines.push(` dependsOn ${service.dependsOn.join(", ")}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push(` files ${service.fileCount} · active ${service.activeFileCount} · skipped ${service.skippedFileCount}`);
|
|
77
|
+
|
|
78
|
+
const suites = result.suites.filter((suite) => suite.service === service.name).sort(compareSuites);
|
|
79
|
+
for (const suite of suites) {
|
|
80
|
+
lines.push(
|
|
81
|
+
` suite ${suite.selectionType}:${suite.name} ${muted(`[${suite.framework}]`)} ${muted(`${suite.fileCount} files`)}`
|
|
82
|
+
);
|
|
83
|
+
if (suite.locks.length > 0) {
|
|
84
|
+
lines.push(` locks ${suite.locks.join(", ")}`);
|
|
85
|
+
}
|
|
86
|
+
for (const file of result.files.filter((entry) => entry.service === service.name && entry.suiteName === suite.name && entry.selectionType === suite.selectionType)) {
|
|
87
|
+
lines.push(` ${file.displayName}`);
|
|
88
|
+
lines.push(` path ${file.path}`);
|
|
89
|
+
lines.push(` id ${file.id}`);
|
|
90
|
+
lines.push(` status ${file.skipped ? "skipped" : "active"}`);
|
|
91
|
+
if (file.skipReason) lines.push(` skipReason ${file.skipReason}`);
|
|
92
|
+
if (file.locks.length > 0) lines.push(` locks ${file.locks.join(", ")}`);
|
|
93
|
+
if (file.history) {
|
|
94
|
+
lines.push(
|
|
95
|
+
` history runs=${file.history.runCount} pass=${file.history.passCount} fail=${file.history.failCount} skip=${file.history.skipCount} avg=${file.history.avgDurationMs > 0 ? formatDuration(file.history.avgDurationMs) : "0s"} last=${file.history.lastStatus || "unknown"} firstSeen=${file.history.firstSeenAt || "unknown"}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function appendDiagnostics(lines, diagnostics = []) {
|
|
106
|
+
if (!diagnostics.length) return;
|
|
107
|
+
lines.push("");
|
|
108
|
+
lines.push(colorHeading("Diagnostics"));
|
|
109
|
+
for (const entry of diagnostics) {
|
|
110
|
+
const location = entry.path ? ` ${entry.path}` : "";
|
|
111
|
+
lines.push(` ${colorDiagnosticSeverity(entry.severity)} ${entry.code}${location}`);
|
|
112
|
+
lines.push(` ${entry.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function groupSuitesByServiceAndType(suites) {
|
|
117
|
+
const grouped = new Map();
|
|
118
|
+
for (const suite of suites) {
|
|
119
|
+
let byType = grouped.get(suite.service);
|
|
120
|
+
if (!byType) {
|
|
121
|
+
byType = new Map();
|
|
122
|
+
grouped.set(suite.service, byType);
|
|
123
|
+
}
|
|
124
|
+
const list = byType.get(suite.selectionType) || [];
|
|
125
|
+
list.push(suite);
|
|
126
|
+
byType.set(suite.selectionType, list.sort(compareSuites));
|
|
127
|
+
}
|
|
128
|
+
return grouped;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function groupFilesBySuite(files) {
|
|
132
|
+
const bySuite = new Map();
|
|
133
|
+
for (const file of files) {
|
|
134
|
+
const suiteId = [file.service, file.selectionType, file.framework, file.suiteName].join("|");
|
|
135
|
+
const list = bySuite.get(suiteId) || [];
|
|
136
|
+
list.push(file);
|
|
137
|
+
bySuite.set(
|
|
138
|
+
suiteId,
|
|
139
|
+
list.sort((left, right) => left.displayName.localeCompare(right.displayName) || left.path.localeCompare(right.path))
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return bySuite;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function orderedTypes(types) {
|
|
146
|
+
return [...types].sort((left, right) => TYPE_ORDER.indexOf(left) - TYPE_ORDER.indexOf(right));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function compareSuites(left, right) {
|
|
150
|
+
return left.groupLabel.localeCompare(right.groupLabel) || left.name.localeCompare(right.name);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildHistoryHint(file) {
|
|
154
|
+
if (!file.history) return "";
|
|
155
|
+
const totalCompleted = file.history.passCount + file.history.failCount;
|
|
156
|
+
const passRate = totalCompleted > 0 ? `${Math.round((file.history.passCount / totalCompleted) * 100)}% pass` : "no result history";
|
|
157
|
+
const avg = file.history.avgDurationMs > 0 ? formatDuration(file.history.avgDurationMs) : null;
|
|
158
|
+
return [
|
|
159
|
+
`${file.history.runCount} ${file.history.runCount === 1 ? "run" : "runs"}`,
|
|
160
|
+
passRate,
|
|
161
|
+
avg ? `avg ${avg}` : null,
|
|
162
|
+
file.history.firstSeenAt ? `seen ${file.history.firstSeenAt.slice(0, 10)}` : null,
|
|
163
|
+
]
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.join(" · ");
|
|
166
|
+
}
|
package/lib/config/discovery.mjs
CHANGED
|
@@ -29,13 +29,13 @@ const IGNORED_DIRS = new Set([
|
|
|
29
29
|
"test-results",
|
|
30
30
|
]);
|
|
31
31
|
|
|
32
|
-
export function discoverProject(productDir, explicitServices = {}) {
|
|
32
|
+
export function discoverProject(productDir, explicitServices = {}, options = {}) {
|
|
33
|
+
const strict = options.strict !== false;
|
|
33
34
|
const { suiteFiles, legacyFiles } = discoverFiles(productDir);
|
|
34
35
|
const groupedByService = {};
|
|
35
36
|
const services = {};
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const discoveredSuites = [];
|
|
37
|
+
const diagnostics = buildLegacyFileDiagnostics(legacyFiles);
|
|
38
|
+
const discoveredFiles = [];
|
|
39
39
|
|
|
40
40
|
for (const filePath of suiteFiles) {
|
|
41
41
|
const rule = inferRule(filePath);
|
|
@@ -43,12 +43,20 @@ export function discoverProject(productDir, explicitServices = {}) {
|
|
|
43
43
|
|
|
44
44
|
const owners = inferOwners(filePath, explicitServices);
|
|
45
45
|
if (owners.length === 0) {
|
|
46
|
-
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
code: "unowned_test",
|
|
48
|
+
severity: "error",
|
|
49
|
+
message: `Unowned test file: ${filePath}`,
|
|
50
|
+
path: filePath,
|
|
51
|
+
});
|
|
47
52
|
continue;
|
|
48
53
|
}
|
|
49
54
|
if (owners.length > 1) {
|
|
50
|
-
|
|
51
|
-
|
|
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,
|
|
52
60
|
serviceNames: owners.map((owner) => owner.name).sort((left, right) => left.localeCompare(right)),
|
|
53
61
|
});
|
|
54
62
|
continue;
|
|
@@ -58,7 +66,7 @@ export function discoverProject(productDir, explicitServices = {}) {
|
|
|
58
66
|
services[owner.name] = mergeServiceDiscovery(services[owner.name], owner);
|
|
59
67
|
const relativeToService = relativeToServiceRoot(owner, filePath);
|
|
60
68
|
const suiteRef = deriveSuiteRef(relativeToService, rule.suffix);
|
|
61
|
-
|
|
69
|
+
discoveredFiles.push({
|
|
62
70
|
serviceName: owner.name,
|
|
63
71
|
type: rule.type,
|
|
64
72
|
framework: rule.framework,
|
|
@@ -67,11 +75,11 @@ export function discoverProject(productDir, explicitServices = {}) {
|
|
|
67
75
|
});
|
|
68
76
|
}
|
|
69
77
|
|
|
70
|
-
if (
|
|
71
|
-
throw
|
|
78
|
+
if (strict && hasDiscoveryErrors(diagnostics)) {
|
|
79
|
+
throw buildDiscoveryErrorFromDiagnostics(diagnostics);
|
|
72
80
|
}
|
|
73
81
|
|
|
74
|
-
for (const entry of
|
|
82
|
+
for (const entry of discoveredFiles) {
|
|
75
83
|
const grouped = groupedByService[entry.serviceName] || {};
|
|
76
84
|
const suitesForType = grouped[entry.type] || [];
|
|
77
85
|
const suiteKey = entry.suitePath.join("/");
|
|
@@ -95,12 +103,23 @@ export function discoverProject(productDir, explicitServices = {}) {
|
|
|
95
103
|
suite.files.push(entry.filePath);
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
const fileEntries = [];
|
|
98
107
|
for (const grouped of Object.values(groupedByService)) {
|
|
99
108
|
for (const suites of Object.values(grouped)) {
|
|
100
109
|
const suiteNames = disambiguateSuiteNames(suites);
|
|
101
110
|
for (const suite of suites) {
|
|
102
111
|
suite.name = suiteNames.get(suite._suiteKey);
|
|
103
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
|
+
}
|
|
104
123
|
delete suite._suiteKey;
|
|
105
124
|
delete suite._suitePath;
|
|
106
125
|
}
|
|
@@ -111,11 +130,59 @@ export function discoverProject(productDir, explicitServices = {}) {
|
|
|
111
130
|
return {
|
|
112
131
|
services,
|
|
113
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,
|
|
114
141
|
};
|
|
115
142
|
}
|
|
116
143
|
|
|
117
|
-
export function discoverSuites(productDir, explicitServices = {}) {
|
|
118
|
-
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"));
|
|
119
186
|
}
|
|
120
187
|
|
|
121
188
|
function discoverFiles(productDir) {
|
|
@@ -306,38 +373,6 @@ function mergeServiceDiscovery(existing, owner) {
|
|
|
306
373
|
};
|
|
307
374
|
}
|
|
308
375
|
|
|
309
|
-
function buildDiscoveryError(legacyFiles, unowned, ambiguous) {
|
|
310
|
-
const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
|
|
311
|
-
|
|
312
|
-
if (legacyFiles.length > 0) {
|
|
313
|
-
lines.push("");
|
|
314
|
-
lines.push("Legacy test files outside __testkit__:");
|
|
315
|
-
for (const filePath of legacyFiles) {
|
|
316
|
-
lines.push(`- ${filePath}`);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (unowned.length > 0) {
|
|
321
|
-
lines.push("");
|
|
322
|
-
lines.push("Unowned test files:");
|
|
323
|
-
for (const filePath of unowned) {
|
|
324
|
-
lines.push(`- ${filePath}`);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (ambiguous.length > 0) {
|
|
329
|
-
lines.push("");
|
|
330
|
-
lines.push("Ambiguous test files:");
|
|
331
|
-
for (const entry of ambiguous) {
|
|
332
|
-
lines.push(`- ${entry.filePath} -> ${entry.serviceNames.join(", ")}`);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
lines.push("");
|
|
337
|
-
lines.push('Expected test files to live under a "__testkit__" directory within a service root.');
|
|
338
|
-
return new Error(lines.join("\n"));
|
|
339
|
-
}
|
|
340
|
-
|
|
341
376
|
function inferRule(filePath) {
|
|
342
377
|
return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
|
|
343
378
|
}
|
|
@@ -351,3 +386,29 @@ function normalizePath(value) {
|
|
|
351
386
|
if (normalized === "." || normalized === "./") return ".";
|
|
352
387
|
return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "") || ".";
|
|
353
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
|
+
}
|
|
@@ -22,7 +22,7 @@ describe("filesystem-discovery", () => {
|
|
|
22
22
|
writeFile(productDir, "src/api/routes/__testkit__/journeys/smoke.scenario.testkit.ts");
|
|
23
23
|
writeFile(productDir, "frontend/app/__testkit__/homepage/homepage.pw.testkit.ts");
|
|
24
24
|
|
|
25
|
-
const
|
|
25
|
+
const project = discoverProject(productDir, {
|
|
26
26
|
api: {
|
|
27
27
|
local: {
|
|
28
28
|
cwd: ".",
|
|
@@ -35,6 +35,7 @@ describe("filesystem-discovery", () => {
|
|
|
35
35
|
},
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
const suites = project.suitesByService;
|
|
38
39
|
expect(suites.api.integration).toEqual([
|
|
39
40
|
{
|
|
40
41
|
name: "auth",
|
|
@@ -61,6 +62,41 @@ describe("filesystem-discovery", () => {
|
|
|
61
62
|
framework: "playwright",
|
|
62
63
|
},
|
|
63
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([]);
|
|
64
100
|
});
|
|
65
101
|
|
|
66
102
|
it("infers the suite from the directory that owns __testkit__", () => {
|
|
@@ -130,6 +166,44 @@ describe("filesystem-discovery", () => {
|
|
|
130
166
|
})
|
|
131
167
|
).toThrow("Legacy test files outside __testkit__");
|
|
132
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
|
+
});
|
|
133
207
|
});
|
|
134
208
|
|
|
135
209
|
function writeFile(productDir, relativePath) {
|