@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.
Files changed (43) hide show
  1. package/README.md +22 -0
  2. package/lib/bundler/index.mjs +1 -1
  3. package/lib/bundler/index.test.mjs +29 -0
  4. package/lib/cli/args.mjs +2 -2
  5. package/lib/cli/args.test.mjs +8 -2
  6. package/lib/cli/command-helpers.mjs +5 -1
  7. package/lib/cli/commands/artifacts.mjs +2 -2
  8. package/lib/cli/commands/logs.mjs +2 -2
  9. package/lib/cli/commands/run.mjs +2 -2
  10. package/lib/cli/commands/show.mjs +2 -2
  11. package/lib/cli/db.mjs +17 -2
  12. package/lib/cli/entrypoint.mjs +2 -1
  13. package/lib/cli/presentation/run-reporter.mjs +25 -0
  14. package/lib/cli/presentation/run-reporter.test.mjs +80 -0
  15. package/lib/cli/tui/watch-app.mjs +134 -18
  16. package/lib/cli/viewer.mjs +67 -0
  17. package/lib/config/discovery.mjs +1 -0
  18. package/lib/config/discovery.test.mjs +8 -0
  19. package/lib/database/index.mjs +85 -11
  20. package/lib/database/template-steps.mjs +45 -6
  21. package/lib/database/template-steps.test.mjs +43 -0
  22. package/lib/index.d.ts +58 -0
  23. package/lib/index.mjs +3 -0
  24. package/lib/runner/artifacts.mjs +16 -0
  25. package/lib/runner/default-runtime-runner.mjs +4 -1
  26. package/lib/runner/logs.mjs +54 -6
  27. package/lib/runner/orchestrator.mjs +67 -14
  28. package/lib/runner/planning.mjs +1 -1
  29. package/lib/runner/reporting.mjs +58 -2
  30. package/lib/runner/reporting.test.mjs +85 -2
  31. package/lib/runner/runtime-contexts.mjs +3 -3
  32. package/lib/runner/runtime-preparation.mjs +31 -0
  33. package/lib/runner/setup-operations.mjs +115 -0
  34. package/lib/runner/setup-operations.test.mjs +94 -0
  35. package/lib/runner/suite-selection.mjs +4 -4
  36. package/lib/runner/suite-selection.test.mjs +9 -2
  37. package/lib/runner/template-steps.mjs +129 -11
  38. package/lib/runner/worker-loop.mjs +1 -1
  39. package/lib/runtime-src/k6/checks.js +9 -0
  40. package/lib/runtime-src/k6/scenario-runtime.js +234 -0
  41. package/lib/runtime-src/k6/scenario-suite.js +179 -0
  42. package/lib/toolchains/index.mjs +0 -4
  43. 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`
@@ -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 = defineHttpSuite(...); export default 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
 
@@ -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(["int", "e2e", "dal"]);
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, loadLatestRunArtifact } from "../viewer.mjs";
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 = loadLatestRunArtifact(productDir);
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, loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
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 = loadLatestRunArtifact(productDir);
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,
@@ -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, loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
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 = loadLatestRunArtifact(productDir);
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
  }
@@ -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, loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
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 [selectedIndex, setSelectedIndex] = useState(0);
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 selected = files[Math.min(selectedIndex, Math.max(0, files.length - 1))] || null;
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
- setSelectedIndex((current) => Math.min(current + 1, Math.max(0, files.length - 1)));
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
- setSelectedIndex((current) => Math.max(0, current - 1));
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
- ...files.map((entry, index) => {
59
- const prefix = index === selectedIndex ? ">" : " ";
60
- return createElement(
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
- ...(selected
72
- ? formatFileDetail(productDir, artifact, selected, { logTail: 8 })
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 loadLatestRunArtifact(productDir);
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
+ }