@elench/testkit 0.1.54 → 0.1.56
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 +22 -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/artifacts.mjs +2 -2
- package/lib/cli/commands/logs.mjs +2 -2
- package/lib/cli/commands/run.mjs +2 -2
- package/lib/cli/commands/show.mjs +2 -2
- package/lib/cli/db.mjs +17 -2
- package/lib/cli/entrypoint.mjs +2 -1
- package/lib/cli/presentation/run-reporter.mjs +25 -0
- package/lib/cli/presentation/run-reporter.test.mjs +80 -0
- package/lib/cli/tui/watch-app.mjs +134 -18
- package/lib/cli/viewer.mjs +67 -0
- package/lib/config/discovery.mjs +1 -0
- package/lib/config/discovery.test.mjs +8 -0
- package/lib/database/index.mjs +85 -11
- package/lib/database/template-steps.mjs +45 -6
- package/lib/database/template-steps.test.mjs +43 -0
- package/lib/index.d.ts +58 -0
- package/lib/index.mjs +3 -0
- package/lib/runner/artifacts.mjs +16 -0
- package/lib/runner/default-runtime-runner.mjs +4 -1
- package/lib/runner/logs.mjs +54 -6
- package/lib/runner/orchestrator.mjs +67 -14
- package/lib/runner/planning.mjs +1 -1
- package/lib/runner/reporting.mjs +58 -2
- package/lib/runner/reporting.test.mjs +85 -2
- package/lib/runner/runtime-contexts.mjs +3 -3
- package/lib/runner/runtime-preparation.mjs +31 -0
- package/lib/runner/setup-operations.mjs +115 -0
- package/lib/runner/setup-operations.test.mjs +94 -0
- package/lib/runner/suite-selection.mjs +4 -4
- package/lib/runner/suite-selection.test.mjs +9 -2
- package/lib/runner/template-steps.mjs +129 -11
- 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/lib/toolchains/index.mjs +0 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -341,6 +341,27 @@ const suite = defineDalSuite(({ db }) => {
|
|
|
341
341
|
export default suite;
|
|
342
342
|
```
|
|
343
343
|
|
|
344
|
+
Scenario suites:
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
import { defineScenarioSuite } from "@elench/testkit";
|
|
348
|
+
|
|
349
|
+
const suite = defineScenarioSuite(({ rawReq, scenario }) => {
|
|
350
|
+
const plan = scenario.choose("journey", {
|
|
351
|
+
endpoint: scenario.pick("endpoint", ["/health", "/message"]),
|
|
352
|
+
includeHealthCheck: scenario.maybe("includeHealthCheck", 1),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const selected = scenario.resource("selected-endpoint", () => rawReq("GET", plan.endpoint));
|
|
356
|
+
|
|
357
|
+
scenario.step("fetch selected endpoint", () => {
|
|
358
|
+
selected.get();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
export default suite;
|
|
363
|
+
```
|
|
364
|
+
|
|
344
365
|
Low-level runtime primitives remain available:
|
|
345
366
|
|
|
346
367
|
```ts
|
|
@@ -375,6 +396,7 @@ Example layouts:
|
|
|
375
396
|
|
|
376
397
|
- `*.int.testkit.ts`
|
|
377
398
|
- `*.e2e.testkit.ts`
|
|
399
|
+
- `*.scenario.testkit.ts`
|
|
378
400
|
- `*.dal.testkit.ts`
|
|
379
401
|
- `*.load.testkit.ts`
|
|
380
402
|
- `*.pw.testkit.ts`
|
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"],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Args, Command } from "@oclif/core";
|
|
2
2
|
import { sharedFlags } from "../command-helpers.mjs";
|
|
3
|
-
import { collectArtifactEntries,
|
|
3
|
+
import { collectArtifactEntries, loadCurrentRunArtifact } from "../viewer.mjs";
|
|
4
4
|
|
|
5
5
|
export default class ArtifactsCommand extends Command {
|
|
6
6
|
static summary = "List persisted artifacts from the latest run";
|
|
@@ -19,7 +19,7 @@ export default class ArtifactsCommand extends Command {
|
|
|
19
19
|
async run() {
|
|
20
20
|
const { args, flags } = await this.parse(ArtifactsCommand);
|
|
21
21
|
const productDir = flags.dir || process.cwd();
|
|
22
|
-
const runArtifact =
|
|
22
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
23
23
|
const entries = collectArtifactEntries(productDir, runArtifact, args.file || null, flags.service || null)
|
|
24
24
|
.map((entry) => ({
|
|
25
25
|
service: entry.service.name,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Args, Command, Flags } from "@oclif/core";
|
|
2
2
|
import { sharedFlags } from "../command-helpers.mjs";
|
|
3
3
|
import { readLogTail } from "../../runner/logs.mjs";
|
|
4
|
-
import { getServiceLogRefs,
|
|
4
|
+
import { getServiceLogRefs, loadCurrentRunArtifact, resolveFileSubject } from "../viewer.mjs";
|
|
5
5
|
import path from "path";
|
|
6
6
|
|
|
7
7
|
export default class LogsCommand extends Command {
|
|
@@ -27,7 +27,7 @@ export default class LogsCommand extends Command {
|
|
|
27
27
|
async run() {
|
|
28
28
|
const { args, flags } = await this.parse(LogsCommand);
|
|
29
29
|
const productDir = flags.dir || process.cwd();
|
|
30
|
-
const runArtifact =
|
|
30
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
31
31
|
const subject = resolveFileSubject(runArtifact, args.file || null, flags.service || null);
|
|
32
32
|
const logs = getServiceLogRefs(runArtifact, subject.service.name).map((entry) => ({
|
|
33
33
|
...entry,
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Args, Command, Flags } from "@oclif/core";
|
|
2
2
|
import { sharedFlags } from "../command-helpers.mjs";
|
|
3
|
-
import { formatFileDetail,
|
|
3
|
+
import { formatFileDetail, loadCurrentRunArtifact, resolveFileSubject } from "../viewer.mjs";
|
|
4
4
|
|
|
5
5
|
export default class ShowCommand extends Command {
|
|
6
6
|
static summary = "Show the most useful details for one file from the latest run";
|
|
@@ -25,7 +25,7 @@ export default class ShowCommand extends Command {
|
|
|
25
25
|
async run() {
|
|
26
26
|
const { args, flags } = await this.parse(ShowCommand);
|
|
27
27
|
const productDir = flags.dir || process.cwd();
|
|
28
|
-
const runArtifact =
|
|
28
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
29
29
|
const subject = resolveFileSubject(runArtifact, args.file || null, flags.service || null);
|
|
30
30
|
const result = {
|
|
31
31
|
file: subject.file,
|
package/lib/cli/db.mjs
CHANGED
|
@@ -3,6 +3,9 @@ import os from "os";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { loadConfigs, resolveProductDir } from "../config/index.mjs";
|
|
5
5
|
import { captureDatabaseTemplateSnapshot, prepareDatabaseRuntime } from "../database/index.mjs";
|
|
6
|
+
import { createRunReporter } from "./presentation/run-reporter.mjs";
|
|
7
|
+
import { createRunLogRegistry } from "../runner/logs.mjs";
|
|
8
|
+
import { createSetupOperationRegistry } from "../runner/setup-operations.mjs";
|
|
6
9
|
import { resolveRuntimeInstanceConfigs } from "../runner/template.mjs";
|
|
7
10
|
|
|
8
11
|
export async function runDatabaseSnapshotCaptureCommand(options = {}) {
|
|
@@ -28,16 +31,28 @@ export async function runDatabaseSnapshotCaptureCommand(options = {}) {
|
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
const absoluteOutputPath = path.resolve(productDir, outputPath);
|
|
34
|
+
const reporter = createRunReporter({ outputMode: options.debug ? "debug" : "compact" });
|
|
35
|
+
const logRegistry = createRunLogRegistry(productDir);
|
|
36
|
+
const setupRegistry = createSetupOperationRegistry({ logRegistry });
|
|
31
37
|
try {
|
|
32
38
|
for (const config of topologicallySortConfigs(resolvedConfigs)) {
|
|
33
39
|
if (config.name === resolvedTarget.name) continue;
|
|
34
40
|
if (config.testkit.database?.provider === "local") {
|
|
35
|
-
await prepareDatabaseRuntime(config
|
|
41
|
+
await prepareDatabaseRuntime(config, {
|
|
42
|
+
reporter,
|
|
43
|
+
logRegistry,
|
|
44
|
+
setupRegistry,
|
|
45
|
+
});
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
|
-
await captureDatabaseTemplateSnapshot(resolvedTarget, absoluteOutputPath
|
|
48
|
+
await captureDatabaseTemplateSnapshot(resolvedTarget, absoluteOutputPath, {
|
|
49
|
+
reporter,
|
|
50
|
+
logRegistry,
|
|
51
|
+
setupRegistry,
|
|
52
|
+
});
|
|
39
53
|
console.log(`Wrote ${path.relative(productDir, absoluteOutputPath)}`);
|
|
40
54
|
} finally {
|
|
55
|
+
logRegistry.closeAll();
|
|
41
56
|
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
|
42
57
|
}
|
|
43
58
|
}
|
package/lib/cli/entrypoint.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export function normalizeCliArgs(argv) {
|
|
|
16
16
|
"--version",
|
|
17
17
|
"-v",
|
|
18
18
|
]);
|
|
19
|
-
const runTypeShortcuts = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
|
|
19
|
+
const runTypeShortcuts = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
20
20
|
const valueFlags = new Set([
|
|
21
21
|
"--dir",
|
|
22
22
|
"--service",
|
|
@@ -26,6 +26,7 @@ export function normalizeCliArgs(argv) {
|
|
|
26
26
|
"--workers",
|
|
27
27
|
"--file-timeout-seconds",
|
|
28
28
|
"--shard",
|
|
29
|
+
"--seed",
|
|
29
30
|
"--input",
|
|
30
31
|
"--output",
|
|
31
32
|
"--status",
|
|
@@ -26,6 +26,27 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
|
|
|
26
26
|
`[testkit] ${config.runtimeLabel || config.name}:${config.name} toolchain ${resolvedToolchain.summary}\n`
|
|
27
27
|
);
|
|
28
28
|
},
|
|
29
|
+
setupOperationFinished(operation) {
|
|
30
|
+
if (!operation) return;
|
|
31
|
+
if (mode === "json") return;
|
|
32
|
+
if (operation.status === "cached") return;
|
|
33
|
+
const duration = formatDuration(operation.durationMs || 0);
|
|
34
|
+
if (operation.status === "failed") {
|
|
35
|
+
const detail = shortenMessage(operation.error || operation.summary || operation.stage);
|
|
36
|
+
stdout.write(
|
|
37
|
+
`${colorStatus("FAIL")} ${"SETUP"} ${operation.serviceName} ${operation.stage} ${dim(duration)} ${detail}\n`
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (
|
|
42
|
+
mode === "compact" &&
|
|
43
|
+
isHighLevelSetupOperation(operation) &&
|
|
44
|
+
(operation.durationMs || 0) >= 5_000
|
|
45
|
+
) {
|
|
46
|
+
const summary = shortenMessage(operation.summary || operation.stage);
|
|
47
|
+
stdout.write(`${colorStatus("RUN")} ${"SETUP"} ${operation.serviceName} ${summary} ${dim(duration)}\n`);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
29
50
|
localServiceStarting(config, command) {
|
|
30
51
|
if (mode !== "debug") return;
|
|
31
52
|
stdout.write(`Starting ${config.runtimeLabel}:${config.name}: ${command}\n`);
|
|
@@ -98,3 +119,7 @@ function isThresholdWrapperMessage(message) {
|
|
|
98
119
|
function normalizePath(filePath) {
|
|
99
120
|
return String(filePath).split(path.sep).join("/");
|
|
100
121
|
}
|
|
122
|
+
|
|
123
|
+
function isHighLevelSetupOperation(operation) {
|
|
124
|
+
return operation.kind === "database-template" || operation.kind === "runtime-prepare";
|
|
125
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Writable } from "stream";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createRunReporter } from "./run-reporter.mjs";
|
|
4
|
+
|
|
5
|
+
describe("run reporter setup output", () => {
|
|
6
|
+
it("prints concise high-level setup summaries in compact mode", () => {
|
|
7
|
+
let stdout = "";
|
|
8
|
+
const reporter = createRunReporter({
|
|
9
|
+
outputMode: "compact",
|
|
10
|
+
stdout: new Writable({
|
|
11
|
+
write(chunk, _encoding, callback) {
|
|
12
|
+
stdout += chunk.toString();
|
|
13
|
+
callback();
|
|
14
|
+
},
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
reporter.setupOperationFinished({
|
|
19
|
+
serviceName: "api",
|
|
20
|
+
stage: "template",
|
|
21
|
+
kind: "database-template",
|
|
22
|
+
summary: "template rebuild",
|
|
23
|
+
status: "passed",
|
|
24
|
+
durationMs: 8_000,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(stdout).toContain("RUN SETUP api template rebuild");
|
|
28
|
+
expect(stdout).toContain("8s");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does not print low-level setup steps in compact mode", () => {
|
|
32
|
+
let stdout = "";
|
|
33
|
+
const reporter = createRunReporter({
|
|
34
|
+
outputMode: "compact",
|
|
35
|
+
stdout: new Writable({
|
|
36
|
+
write(chunk, _encoding, callback) {
|
|
37
|
+
stdout += chunk.toString();
|
|
38
|
+
callback();
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
reporter.setupOperationFinished({
|
|
44
|
+
serviceName: "api",
|
|
45
|
+
stage: "template:migrate:api:1",
|
|
46
|
+
kind: "setup-step",
|
|
47
|
+
summary: "sql-file: db/schema.sql",
|
|
48
|
+
status: "passed",
|
|
49
|
+
durationMs: 8_000,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(stdout).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("prints concise setup failures", () => {
|
|
56
|
+
let stdout = "";
|
|
57
|
+
const reporter = createRunReporter({
|
|
58
|
+
outputMode: "compact",
|
|
59
|
+
stdout: new Writable({
|
|
60
|
+
write(chunk, _encoding, callback) {
|
|
61
|
+
stdout += chunk.toString();
|
|
62
|
+
callback();
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
reporter.setupOperationFinished({
|
|
68
|
+
serviceName: "api",
|
|
69
|
+
stage: "runtime:prepare",
|
|
70
|
+
kind: "runtime-prepare",
|
|
71
|
+
summary: "runtime prepare",
|
|
72
|
+
status: "failed",
|
|
73
|
+
durationMs: 1_200,
|
|
74
|
+
error: "Command failed with exit code 1: node scripts/fail-prepare.mjs",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(stdout).toContain("FAIL SETUP api runtime:prepare");
|
|
78
|
+
expect(stdout).toContain("Command failed with exit code 1");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
1
3
|
import React, { createElement, useEffect, useMemo, useState } from "react";
|
|
2
4
|
import { Box, Text, useApp, useInput } from "ink";
|
|
3
5
|
import { formatDuration } from "../../runner/formatting.mjs";
|
|
4
|
-
import { formatFileDetail,
|
|
6
|
+
import { formatFileDetail, loadCurrentRunArtifact } from "../viewer.mjs";
|
|
5
7
|
|
|
6
8
|
export function WatchApp({ productDir, serviceFilter = null }) {
|
|
7
9
|
const { exit } = useApp();
|
|
8
10
|
const [artifact, setArtifact] = useState(() => safeLoadArtifact(productDir));
|
|
9
|
-
const [
|
|
11
|
+
const [view, setView] = useState("files");
|
|
12
|
+
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
|
13
|
+
const [selectedSetupIndex, setSelectedSetupIndex] = useState(0);
|
|
10
14
|
|
|
11
15
|
const files = useMemo(() => collectFiles(artifact, serviceFilter), [artifact, serviceFilter]);
|
|
12
|
-
const
|
|
16
|
+
const setupOperations = useMemo(
|
|
17
|
+
() => collectSetupOperations(artifact, serviceFilter),
|
|
18
|
+
[artifact, serviceFilter]
|
|
19
|
+
);
|
|
20
|
+
const selectedFile = files[Math.min(selectedFileIndex, Math.max(0, files.length - 1))] || null;
|
|
21
|
+
const selectedSetup =
|
|
22
|
+
setupOperations[Math.min(selectedSetupIndex, Math.max(0, setupOperations.length - 1))] || null;
|
|
13
23
|
|
|
14
24
|
useEffect(() => {
|
|
15
25
|
const timer = setInterval(() => {
|
|
@@ -18,6 +28,14 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
18
28
|
return () => clearInterval(timer);
|
|
19
29
|
}, [productDir]);
|
|
20
30
|
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setSelectedFileIndex((current) => Math.min(current, Math.max(0, files.length - 1)));
|
|
33
|
+
}, [files.length]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setSelectedSetupIndex((current) => Math.min(current, Math.max(0, setupOperations.length - 1)));
|
|
37
|
+
}, [setupOperations.length]);
|
|
38
|
+
|
|
21
39
|
useInput((input, key) => {
|
|
22
40
|
if (input === "q") {
|
|
23
41
|
exit();
|
|
@@ -27,12 +45,27 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
27
45
|
setArtifact(safeLoadArtifact(productDir));
|
|
28
46
|
return;
|
|
29
47
|
}
|
|
48
|
+
if (key.tab || input === "s") {
|
|
49
|
+
setView((current) => {
|
|
50
|
+
if (current === "files" && setupOperations.length > 0) return "setup";
|
|
51
|
+
return "files";
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
30
55
|
if (key.downArrow) {
|
|
31
|
-
|
|
56
|
+
if (view === "setup") {
|
|
57
|
+
setSelectedSetupIndex((current) => Math.min(current + 1, Math.max(0, setupOperations.length - 1)));
|
|
58
|
+
} else {
|
|
59
|
+
setSelectedFileIndex((current) => Math.min(current + 1, Math.max(0, files.length - 1)));
|
|
60
|
+
}
|
|
32
61
|
return;
|
|
33
62
|
}
|
|
34
63
|
if (key.upArrow) {
|
|
35
|
-
|
|
64
|
+
if (view === "setup") {
|
|
65
|
+
setSelectedSetupIndex((current) => Math.max(0, current - 1));
|
|
66
|
+
} else {
|
|
67
|
+
setSelectedFileIndex((current) => Math.max(0, current - 1));
|
|
68
|
+
}
|
|
36
69
|
}
|
|
37
70
|
});
|
|
38
71
|
|
|
@@ -46,7 +79,7 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
46
79
|
createElement(
|
|
47
80
|
Text,
|
|
48
81
|
null,
|
|
49
|
-
`testkit watch · q quit · r reload · ${artifact.run.status} · ${formatDuration(artifact.run.durationMs)}`
|
|
82
|
+
`testkit watch · q quit · r reload · tab toggle · ${artifact.run.status} · ${formatDuration(artifact.run.durationMs)}`
|
|
50
83
|
),
|
|
51
84
|
createElement(
|
|
52
85
|
Box,
|
|
@@ -54,22 +87,21 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
54
87
|
createElement(
|
|
55
88
|
Box,
|
|
56
89
|
{ width: "40%", flexDirection: "column", marginRight: 2 },
|
|
57
|
-
createElement(Text, null, "Files"),
|
|
58
|
-
...
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
Text,
|
|
62
|
-
{ key: `${entry.service.name}:${entry.file.path}` },
|
|
63
|
-
`${prefix} ${entry.file.status.toUpperCase().padEnd(7)} ${entry.file.path}`
|
|
64
|
-
);
|
|
65
|
-
})
|
|
90
|
+
createElement(Text, null, view === "setup" ? "Setup" : "Files"),
|
|
91
|
+
...(view === "setup"
|
|
92
|
+
? renderSetupEntries(setupOperations, selectedSetupIndex)
|
|
93
|
+
: renderFileEntries(files, selectedFileIndex))
|
|
66
94
|
),
|
|
67
95
|
createElement(
|
|
68
96
|
Box,
|
|
69
97
|
{ width: "60%", flexDirection: "column" },
|
|
70
98
|
createElement(Text, null, "Details"),
|
|
71
|
-
...(
|
|
72
|
-
?
|
|
99
|
+
...(view === "setup"
|
|
100
|
+
? formatSetupDetail(productDir, selectedSetup)
|
|
101
|
+
.slice(0, 28)
|
|
102
|
+
.map((line, index) => createElement(Text, { key: `${index}:${line}` }, line))
|
|
103
|
+
: selectedFile
|
|
104
|
+
? formatFileDetail(productDir, artifact, selectedFile, { logTail: 8 })
|
|
73
105
|
.slice(0, 28)
|
|
74
106
|
.map((line, index) => createElement(Text, { key: `${index}:${line}` }, line))
|
|
75
107
|
: [createElement(Text, { key: "empty" }, "No file results")])
|
|
@@ -80,7 +112,7 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
80
112
|
|
|
81
113
|
function safeLoadArtifact(productDir) {
|
|
82
114
|
try {
|
|
83
|
-
return
|
|
115
|
+
return loadCurrentRunArtifact(productDir);
|
|
84
116
|
} catch {
|
|
85
117
|
return null;
|
|
86
118
|
}
|
|
@@ -102,3 +134,87 @@ function collectFiles(runArtifact, serviceFilter) {
|
|
|
102
134
|
left.file.path.localeCompare(right.file.path)
|
|
103
135
|
);
|
|
104
136
|
}
|
|
137
|
+
|
|
138
|
+
function renderFileEntries(files, selectedIndex) {
|
|
139
|
+
return files.map((entry, index) => {
|
|
140
|
+
const prefix = index === selectedIndex ? ">" : " ";
|
|
141
|
+
return createElement(
|
|
142
|
+
Text,
|
|
143
|
+
{ key: `${entry.service.name}:${entry.file.path}` },
|
|
144
|
+
`${prefix} ${entry.file.status.toUpperCase().padEnd(7)} ${entry.file.path}`
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectSetupOperations(runArtifact, serviceFilter) {
|
|
150
|
+
if (!runArtifact) return [];
|
|
151
|
+
return [...(runArtifact.setup?.operations || [])]
|
|
152
|
+
.filter((entry) => !serviceFilter || entry.serviceName === serviceFilter)
|
|
153
|
+
.sort((left, right) => {
|
|
154
|
+
return (
|
|
155
|
+
setupStatusRank(left.status) - setupStatusRank(right.status) ||
|
|
156
|
+
String(left.serviceName || "").localeCompare(String(right.serviceName || "")) ||
|
|
157
|
+
String(left.startedAt || "").localeCompare(String(right.startedAt || "")) ||
|
|
158
|
+
String(left.stage || "").localeCompare(String(right.stage || ""))
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderSetupEntries(operations, selectedIndex) {
|
|
164
|
+
return operations.map((entry, index) => {
|
|
165
|
+
const prefix = index === selectedIndex ? ">" : " ";
|
|
166
|
+
const duration = entry.durationMs == null ? "" : ` ${formatDuration(entry.durationMs)}`;
|
|
167
|
+
return createElement(
|
|
168
|
+
Text,
|
|
169
|
+
{ key: entry.id },
|
|
170
|
+
`${prefix} ${String(entry.status || "").toUpperCase().padEnd(7)} ${entry.serviceName} ${entry.stage}${duration}`
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatSetupDetail(productDir, operation) {
|
|
176
|
+
if (!operation) return ["No setup operations"];
|
|
177
|
+
const lines = [
|
|
178
|
+
`Service: ${operation.serviceName}`,
|
|
179
|
+
`Stage: ${operation.stage}`,
|
|
180
|
+
`Status: ${operation.status}`,
|
|
181
|
+
];
|
|
182
|
+
if (operation.durationMs != null) {
|
|
183
|
+
lines.push(`Duration: ${formatDuration(operation.durationMs)}`);
|
|
184
|
+
}
|
|
185
|
+
if (operation.summary) {
|
|
186
|
+
lines.push(`Summary: ${operation.summary}`);
|
|
187
|
+
}
|
|
188
|
+
if (operation.error) {
|
|
189
|
+
lines.push(`Error: ${operation.error}`);
|
|
190
|
+
}
|
|
191
|
+
if (operation.logRef?.path) {
|
|
192
|
+
lines.push("");
|
|
193
|
+
lines.push("Log:");
|
|
194
|
+
lines.push(` ${operation.logRef.path}`);
|
|
195
|
+
const absolutePath = path.join(productDir, operation.logRef.path);
|
|
196
|
+
for (const line of readTailSafe(absolutePath, 12)) {
|
|
197
|
+
lines.push(` ${line}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readTailSafe(absolutePath, maxLines) {
|
|
204
|
+
try {
|
|
205
|
+
return fs.readFileSync(absolutePath, "utf8")
|
|
206
|
+
.split(/\r?\n/)
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.slice(-maxLines);
|
|
209
|
+
} catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function setupStatusRank(status) {
|
|
215
|
+
if (status === "failed") return 1;
|
|
216
|
+
if (status === "running") return 2;
|
|
217
|
+
if (status === "passed") return 3;
|
|
218
|
+
if (status === "cached") return 4;
|
|
219
|
+
return 5;
|
|
220
|
+
}
|