@elench/testkit 0.1.56 → 0.1.58

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
@@ -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,112 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { startBrowserBridgeServer } from "@elench/testkit-bridge";
3
+ import { loadConfigContext, resolveProductDir } from "../../../config/index.mjs";
4
+ import { discoverTests } from "../../../discovery/index.mjs";
5
+ import { loadCurrentRunArtifact } from "../../viewer.mjs";
6
+
7
+ export default class BrowserServeCommand extends Command {
8
+ static summary = "Serve the local browser bridge for the current testkit product";
9
+
10
+ static enableJsonFlag = true;
11
+
12
+ static flags = {
13
+ dir: Flags.string({
14
+ description: "Product directory",
15
+ }),
16
+ host: Flags.string({
17
+ description: "Host to bind the browser bridge server",
18
+ default: "127.0.0.1",
19
+ }),
20
+ port: Flags.integer({
21
+ description: "Port to bind the browser bridge server",
22
+ default: 3847,
23
+ }),
24
+ };
25
+
26
+ async run() {
27
+ const { flags } = await this.parse(BrowserServeCommand);
28
+ const productDir = resolveProductDir(process.cwd(), flags.dir);
29
+
30
+ const adapter = {
31
+ loadProductContext: async () => {
32
+ const [configContext, discovery] = await Promise.all([
33
+ loadConfigContext({
34
+ dir: productDir,
35
+ discoveryOptions: { strict: false },
36
+ }),
37
+ discoverTests({
38
+ dir: productDir,
39
+ diagnostics: "report",
40
+ }),
41
+ ]);
42
+
43
+ return {
44
+ product: {
45
+ name: discovery.product.name,
46
+ directory: discovery.product.directory,
47
+ },
48
+ services: configContext.configs
49
+ .map((config) => {
50
+ const baseUrl = config.testkit.local?.baseUrl || null;
51
+ const browserOrigins = config.testkit.browser?.origins || [];
52
+ if (!baseUrl && browserOrigins.length === 0) return null;
53
+ const serviceEntries = [];
54
+ if (baseUrl && !baseUrl.includes("{port}")) {
55
+ try {
56
+ const parsed = new URL(baseUrl);
57
+ serviceEntries.push({
58
+ name: config.name,
59
+ baseUrl,
60
+ origin: parsed.origin,
61
+ });
62
+ } catch {
63
+ // Ignore invalid local.baseUrl templates here; explicit browser origins still work.
64
+ }
65
+ }
66
+ for (const origin of browserOrigins) {
67
+ serviceEntries.push({
68
+ name: config.name,
69
+ baseUrl: origin,
70
+ origin,
71
+ });
72
+ }
73
+ return serviceEntries;
74
+ })
75
+ .flat()
76
+ .filter(Boolean),
77
+ discovery,
78
+ runArtifact: loadRunArtifactIfPresent(productDir),
79
+ };
80
+ },
81
+ };
82
+
83
+ const serverRef = await startBrowserBridgeServer(adapter, {
84
+ host: flags.host,
85
+ port: flags.port,
86
+ });
87
+
88
+ const payload = {
89
+ ok: true,
90
+ productDir,
91
+ host: serverRef.host,
92
+ port: serverRef.port,
93
+ url: serverRef.url,
94
+ };
95
+
96
+ if (!this.jsonEnabled()) {
97
+ this.log(`testkit browser bridge serving ${productDir}`);
98
+ this.log(`Listening on ${serverRef.url}`);
99
+ }
100
+
101
+ await new Promise(() => {});
102
+ return payload;
103
+ }
104
+ }
105
+
106
+ function loadRunArtifactIfPresent(productDir) {
107
+ try {
108
+ return loadCurrentRunArtifact(productDir);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
@@ -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
+ }
@@ -8,6 +8,8 @@ export function normalizeCliArgs(argv) {
8
8
  "logs",
9
9
  "artifacts",
10
10
  "watch",
11
+ "discover",
12
+ "browser",
11
13
  "known-failures",
12
14
  "db",
13
15
  "help",
@@ -76,6 +78,9 @@ function reorderCommandArgs(args, positionals) {
76
78
  if (positionals[0]?.value === "db" && positionals[1] && positionals[2]) {
77
79
  commandTokens.push(positionals[1], positionals[2]);
78
80
  }
81
+ if (positionals[0]?.value === "browser" && positionals[1]) {
82
+ commandTokens.push(positionals[1]);
83
+ }
79
84
  const commandIndexes = new Set(commandTokens.map((token) => token.index));
80
85
  return [
81
86
  ...commandTokens.map((token) => token.value),
@@ -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
+ }