@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.
- package/README.md +81 -0
- package/lib/bundler/index.mjs +1 -1
- package/lib/bundler/index.test.mjs +29 -0
- package/lib/cli/args.mjs +2 -2
- package/lib/cli/args.test.mjs +8 -2
- package/lib/cli/command-helpers.mjs +5 -1
- package/lib/cli/commands/discover.mjs +80 -0
- package/lib/cli/commands/run.mjs +2 -2
- package/lib/cli/entrypoint.mjs +3 -1
- package/lib/cli/presentation/colors.mjs +32 -0
- package/lib/cli/presentation/discovery-reporter.mjs +166 -0
- package/lib/cli/viewer.mjs +30 -0
- package/lib/config/discovery.mjs +107 -45
- package/lib/config/discovery.test.mjs +83 -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/index.d.ts +58 -0
- package/lib/index.mjs +3 -0
- package/lib/package.test.mjs +5 -0
- package/lib/runner/default-runtime-runner.mjs +4 -1
- package/lib/runner/orchestrator.mjs +21 -8
- package/lib/runner/planning.mjs +1 -1
- package/lib/runner/reporting.mjs +6 -0
- package/lib/runner/reporting.test.mjs +5 -0
- package/lib/runner/suite-selection.mjs +4 -4
- package/lib/runner/suite-selection.test.mjs +9 -2
- package/lib/runner/worker-loop.mjs +1 -1
- package/lib/runtime-src/k6/checks.js +9 -0
- package/lib/runtime-src/k6/scenario-runtime.js +234 -0
- package/lib/runtime-src/k6/scenario-suite.js +179 -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:
|
|
@@ -341,6 +352,27 @@ const suite = defineDalSuite(({ db }) => {
|
|
|
341
352
|
export default suite;
|
|
342
353
|
```
|
|
343
354
|
|
|
355
|
+
Scenario suites:
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
import { defineScenarioSuite } from "@elench/testkit";
|
|
359
|
+
|
|
360
|
+
const suite = defineScenarioSuite(({ rawReq, scenario }) => {
|
|
361
|
+
const plan = scenario.choose("journey", {
|
|
362
|
+
endpoint: scenario.pick("endpoint", ["/health", "/message"]),
|
|
363
|
+
includeHealthCheck: scenario.maybe("includeHealthCheck", 1),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const selected = scenario.resource("selected-endpoint", () => rawReq("GET", plan.endpoint));
|
|
367
|
+
|
|
368
|
+
scenario.step("fetch selected endpoint", () => {
|
|
369
|
+
selected.get();
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
export default suite;
|
|
374
|
+
```
|
|
375
|
+
|
|
344
376
|
Low-level runtime primitives remain available:
|
|
345
377
|
|
|
346
378
|
```ts
|
|
@@ -375,6 +407,7 @@ Example layouts:
|
|
|
375
407
|
|
|
376
408
|
- `*.int.testkit.ts`
|
|
377
409
|
- `*.e2e.testkit.ts`
|
|
410
|
+
- `*.scenario.testkit.ts`
|
|
378
411
|
- `*.dal.testkit.ts`
|
|
379
412
|
- `*.load.testkit.ts`
|
|
380
413
|
- `*.pw.testkit.ts`
|
|
@@ -389,6 +422,54 @@ Suite names are inferred from the colocated path:
|
|
|
389
422
|
- `auth/__testkit__/*.int.testkit.ts` => `auth`
|
|
390
423
|
- `routes/__testkit__/auth/*.int.testkit.ts` => `auth`
|
|
391
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
|
+
|
|
392
473
|
## Local Databases
|
|
393
474
|
|
|
394
475
|
`@elench/testkit` provisions Docker-managed local Postgres automatically for
|
package/lib/bundler/index.mjs
CHANGED
|
@@ -132,7 +132,7 @@ function normalizeTestkitSuite(module) {
|
|
|
132
132
|
const candidate = module?.default;
|
|
133
133
|
if (!candidate || typeof candidate !== "object") {
|
|
134
134
|
throw new Error(
|
|
135
|
-
"testkit suite files must default-export the suite object returned by defineHttpSuite(...) or defineDalSuite(...). Example: export default defineHttpSuite(...) or const suite =
|
|
135
|
+
"testkit suite files must default-export the suite object returned by defineHttpSuite(...), defineScenarioSuite(...), or defineDalSuite(...). Example: export default defineHttpSuite(...) or const suite = defineScenarioSuite(...); export default suite;"
|
|
136
136
|
);
|
|
137
137
|
}
|
|
138
138
|
if (typeof candidate.exec !== "function") {
|
|
@@ -75,6 +75,35 @@ describe("runtime bundler", () => {
|
|
|
75
75
|
expect(bundled).toContain('import sql from "k6/x/sql"');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
it("bundles scenario execution through the public package surface", async () => {
|
|
79
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
|
|
80
|
+
cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
|
|
81
|
+
|
|
82
|
+
const sourceFile = path.join(tmpDir, "scenario.js");
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
sourceFile,
|
|
85
|
+
[
|
|
86
|
+
'import { defineScenarioSuite } from "@elench/testkit";',
|
|
87
|
+
"const suite = defineScenarioSuite(({ scenario }) => {",
|
|
88
|
+
" const plan = scenario.choose('journey', { endpoint: scenario.pick('endpoint', ['/a', '/b']) });",
|
|
89
|
+
" scenario.step('record choice', () => plan.endpoint);",
|
|
90
|
+
"});",
|
|
91
|
+
"export default suite;",
|
|
92
|
+
"",
|
|
93
|
+
].join("\n")
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const bundledFile = await bundleK6File({
|
|
97
|
+
productDir: tmpDir,
|
|
98
|
+
serviceName: "api",
|
|
99
|
+
sourceFile,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const bundled = fs.readFileSync(bundledFile, "utf8");
|
|
103
|
+
expect(bundled).toContain("defineScenarioSuite");
|
|
104
|
+
expect(bundled).toContain("createScenarioRuntime");
|
|
105
|
+
});
|
|
106
|
+
|
|
78
107
|
it("normalizes a default-exported suite object with no setup override", async () => {
|
|
79
108
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
|
|
80
109
|
cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
|
package/lib/cli/args.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
parseWorkersOption,
|
|
6
6
|
} from "../runner/execution-config.mjs";
|
|
7
7
|
|
|
8
|
-
export const POSITIONAL_TYPES = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
|
|
8
|
+
export const POSITIONAL_TYPES = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
9
9
|
export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
|
|
10
10
|
export const KNOWN_FAILURES_ACTIONS = new Set(["render", "validate"]);
|
|
11
11
|
export const RESERVED = new Set([...POSITIONAL_TYPES, ...LIFECYCLE]);
|
|
@@ -45,7 +45,7 @@ export function resolveCliSelection({ first, second, third }) {
|
|
|
45
45
|
} else if (first) {
|
|
46
46
|
throw new Error(
|
|
47
47
|
`Unknown argument "${first}". Expected a lifecycle command (status, destroy, cleanup) ` +
|
|
48
|
-
`or suite type (int, e2e, dal, load, pw, all).`
|
|
48
|
+
`or suite type (int, e2e, scenario, dal, load, pw, all).`
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
package/lib/cli/args.test.mjs
CHANGED
|
@@ -79,11 +79,17 @@ describe("cli-args", () => {
|
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
it("parses types and suite selectors", () => {
|
|
82
|
-
expect(parseTypeOption(["e2e,dal"], "int")).toEqual([
|
|
82
|
+
expect(parseTypeOption(["e2e,scenario,dal"], "int")).toEqual([
|
|
83
|
+
"int",
|
|
84
|
+
"e2e",
|
|
85
|
+
"scenario",
|
|
86
|
+
"dal",
|
|
87
|
+
]);
|
|
83
88
|
expect(() => parseTypeOption(["all", "int"])).toThrow("cannot be combined");
|
|
84
89
|
|
|
85
|
-
expect(parseSuiteOption(["auth,dal:queries"])).toEqual([
|
|
90
|
+
expect(parseSuiteOption(["auth,scenario:journeys,dal:queries"])).toEqual([
|
|
86
91
|
{ kind: "plain", name: "auth", raw: "auth" },
|
|
92
|
+
{ kind: "typed", type: "scenario", name: "journeys", raw: "scenario:journeys" },
|
|
87
93
|
{ kind: "typed", type: "dal", name: "queries", raw: "dal:queries" },
|
|
88
94
|
]);
|
|
89
95
|
});
|
|
@@ -26,7 +26,7 @@ export const runFlags = {
|
|
|
26
26
|
type: Flags.string({
|
|
27
27
|
char: "t",
|
|
28
28
|
multiple: true,
|
|
29
|
-
description: "Run specific suite type(s): int, e2e, dal, load, pw, all",
|
|
29
|
+
description: "Run specific suite type(s): int, e2e, scenario, dal, load, pw, all",
|
|
30
30
|
}),
|
|
31
31
|
suite: Flags.string({
|
|
32
32
|
char: "s",
|
|
@@ -47,6 +47,9 @@ export const runFlags = {
|
|
|
47
47
|
shard: Flags.string({
|
|
48
48
|
description: "Run only shard i of n at suite granularity",
|
|
49
49
|
}),
|
|
50
|
+
seed: Flags.string({
|
|
51
|
+
description: "Deterministic seed for scenario suites",
|
|
52
|
+
}),
|
|
50
53
|
"write-status": Flags.boolean({
|
|
51
54
|
description: "Write a deterministic testkit.status.json snapshot",
|
|
52
55
|
default: false,
|
|
@@ -111,6 +114,7 @@ export async function executeRunCommand(command, flags, positionalType = null) {
|
|
|
111
114
|
workers,
|
|
112
115
|
fileTimeoutSeconds,
|
|
113
116
|
shard,
|
|
117
|
+
scenarioSeed: flags.seed || null,
|
|
114
118
|
serviceFilter: flags.service || null,
|
|
115
119
|
reporter,
|
|
116
120
|
writeStatus: flags["write-status"],
|
|
@@ -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/commands/run.mjs
CHANGED
|
@@ -8,9 +8,9 @@ export default class RunCommand extends Command {
|
|
|
8
8
|
|
|
9
9
|
static args = {
|
|
10
10
|
type: Args.string({
|
|
11
|
-
description: "Optional suite type shortcut: int, e2e, dal, load, pw, all",
|
|
11
|
+
description: "Optional suite type shortcut: int, e2e, scenario, dal, load, pw, all",
|
|
12
12
|
required: false,
|
|
13
|
-
options: ["int", "e2e", "dal", "load", "pw", "all"],
|
|
13
|
+
options: ["int", "e2e", "scenario", "dal", "load", "pw", "all"],
|
|
14
14
|
}),
|
|
15
15
|
};
|
|
16
16
|
|
package/lib/cli/entrypoint.mjs
CHANGED
|
@@ -8,6 +8,7 @@ export function normalizeCliArgs(argv) {
|
|
|
8
8
|
"logs",
|
|
9
9
|
"artifacts",
|
|
10
10
|
"watch",
|
|
11
|
+
"discover",
|
|
11
12
|
"known-failures",
|
|
12
13
|
"db",
|
|
13
14
|
"help",
|
|
@@ -16,7 +17,7 @@ export function normalizeCliArgs(argv) {
|
|
|
16
17
|
"--version",
|
|
17
18
|
"-v",
|
|
18
19
|
]);
|
|
19
|
-
const runTypeShortcuts = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
|
|
20
|
+
const runTypeShortcuts = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
20
21
|
const valueFlags = new Set([
|
|
21
22
|
"--dir",
|
|
22
23
|
"--service",
|
|
@@ -26,6 +27,7 @@ export function normalizeCliArgs(argv) {
|
|
|
26
27
|
"--workers",
|
|
27
28
|
"--file-timeout-seconds",
|
|
28
29
|
"--shard",
|
|
30
|
+
"--seed",
|
|
29
31
|
"--input",
|
|
30
32
|
"--output",
|
|
31
33
|
"--status",
|
|
@@ -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/cli/viewer.mjs
CHANGED
|
@@ -190,6 +190,9 @@ export function formatArtifactPreview(payload, maxLines = 6) {
|
|
|
190
190
|
if (payload.kind === "agentic-query") {
|
|
191
191
|
return formatAgenticArtifact(payload, maxLines);
|
|
192
192
|
}
|
|
193
|
+
if (payload.kind === "testkit.scenario") {
|
|
194
|
+
return formatScenarioArtifact(payload, maxLines);
|
|
195
|
+
}
|
|
193
196
|
if (payload.kind === "testkit.http-traces") {
|
|
194
197
|
return formatHttpTraceArtifact(payload, maxLines);
|
|
195
198
|
}
|
|
@@ -230,12 +233,39 @@ function formatHttpTraceArtifact(payload, maxLines) {
|
|
|
230
233
|
return lines;
|
|
231
234
|
}
|
|
232
235
|
|
|
236
|
+
function formatScenarioArtifact(payload, maxLines) {
|
|
237
|
+
const artifact = payload.data || {};
|
|
238
|
+
const lines = [];
|
|
239
|
+
if (artifact.scenarioName) lines.push(`Scenario: ${artifact.scenarioName}`);
|
|
240
|
+
if (artifact.seed) lines.push(`Seed: ${artifact.seed}`);
|
|
241
|
+
const choiceEntries = Object.entries(artifact.choices || {});
|
|
242
|
+
if (choiceEntries.length > 0) {
|
|
243
|
+
lines.push(
|
|
244
|
+
`Choices: ${choiceEntries
|
|
245
|
+
.map(([key, value]) => `${key}=${formatScenarioChoiceValue(value)}`)
|
|
246
|
+
.join(", ")}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const failedStep = (artifact.steps || []).find((step) => step.status === "failed");
|
|
250
|
+
if (failedStep) {
|
|
251
|
+
lines.push(`Failed Step: ${failedStep.name}`);
|
|
252
|
+
} else if (Array.isArray(artifact.steps) && artifact.steps.length > 0) {
|
|
253
|
+
lines.push(`Steps: ${artifact.steps.map((step) => step.name).join(" -> ")}`);
|
|
254
|
+
}
|
|
255
|
+
return lines.slice(0, maxLines);
|
|
256
|
+
}
|
|
257
|
+
|
|
233
258
|
function rankFailureDetails(details) {
|
|
234
259
|
return [...(Array.isArray(details) ? details : [])].sort((left, right) => {
|
|
235
260
|
return failureDetailRank(left) - failureDetailRank(right) || String(left?.key || "").localeCompare(String(right?.key || ""));
|
|
236
261
|
});
|
|
237
262
|
}
|
|
238
263
|
|
|
264
|
+
function formatScenarioChoiceValue(value) {
|
|
265
|
+
if (typeof value === "string") return value;
|
|
266
|
+
return JSON.stringify(value);
|
|
267
|
+
}
|
|
268
|
+
|
|
239
269
|
function failureDetailRank(detail) {
|
|
240
270
|
if (detail?.kind === "http-assertion") return 1;
|
|
241
271
|
if (detail?.request && detail?.response) return 2;
|