@elench/testkit 0.1.53 → 0.1.55

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 (41) hide show
  1. package/lib/cli/commands/artifacts.mjs +2 -2
  2. package/lib/cli/commands/logs.mjs +2 -2
  3. package/lib/cli/commands/show.mjs +2 -2
  4. package/lib/cli/db.mjs +17 -2
  5. package/lib/cli/presentation/code-frames.mjs +57 -0
  6. package/lib/cli/presentation/code-frames.test.mjs +71 -0
  7. package/lib/cli/presentation/colors.mjs +29 -0
  8. package/lib/cli/presentation/run-reporter.mjs +41 -7
  9. package/lib/cli/presentation/run-reporter.test.mjs +80 -0
  10. package/lib/cli/tui/watch-app.mjs +134 -18
  11. package/lib/cli/viewer.mjs +146 -4
  12. package/lib/database/index.mjs +85 -11
  13. package/lib/database/template-steps.mjs +45 -6
  14. package/lib/database/template-steps.test.mjs +43 -0
  15. package/lib/known-failures/index.mjs +1 -1
  16. package/lib/known-failures/index.test.mjs +46 -0
  17. package/lib/runner/artifacts.mjs +16 -0
  18. package/lib/runner/default-runtime-errors.mjs +66 -0
  19. package/lib/runner/default-runtime-runner.mjs +8 -1
  20. package/lib/runner/failure-details.mjs +31 -0
  21. package/lib/runner/failure-details.test.mjs +51 -0
  22. package/lib/runner/formatting.mjs +114 -4
  23. package/lib/runner/formatting.test.mjs +77 -0
  24. package/lib/runner/logs.mjs +71 -6
  25. package/lib/runner/orchestrator.mjs +63 -7
  26. package/lib/runner/reporting.mjs +52 -2
  27. package/lib/runner/reporting.test.mjs +80 -2
  28. package/lib/runner/runtime-contexts.mjs +3 -3
  29. package/lib/runner/runtime-preparation.mjs +31 -0
  30. package/lib/runner/setup-operations.mjs +115 -0
  31. package/lib/runner/setup-operations.test.mjs +94 -0
  32. package/lib/runner/template-steps.mjs +129 -11
  33. package/lib/runner/triage.mjs +67 -0
  34. package/lib/runtime/index.d.ts +60 -0
  35. package/lib/runtime/index.mjs +12 -0
  36. package/lib/runtime-src/k6/checks.js +45 -12
  37. package/lib/runtime-src/k6/http-assertions.js +214 -0
  38. package/lib/runtime-src/k6/http.js +261 -13
  39. package/lib/runtime-src/k6/suite.js +46 -1
  40. package/lib/toolchains/index.mjs +0 -4
  41. package/package.json +3 -1
@@ -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,
@@ -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
  }
@@ -0,0 +1,57 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { codeFrameColumns } from "@babel/code-frame";
4
+ import { createColors } from "picocolors";
5
+
6
+ const pc = createColors(Boolean(process.stdout?.isTTY || process.env.FORCE_COLOR));
7
+
8
+ export function findFailureLocation(detail = {}, fallbackError = "") {
9
+ if (detail?.location?.path && Number.isFinite(detail?.location?.line)) {
10
+ return detail.location;
11
+ }
12
+
13
+ const stack = detail?.stack || fallbackError;
14
+ if (!stack) return null;
15
+ const match = String(stack).match(/(\/[^\s:()]+):(\d+):(\d+)/);
16
+ if (!match) return null;
17
+ return {
18
+ path: match[1],
19
+ line: Number(match[2]),
20
+ column: Number(match[3]),
21
+ };
22
+ }
23
+
24
+ export function renderCodeFrame(location, options = {}) {
25
+ if (!location?.path || !Number.isFinite(location.line)) return [];
26
+ if (!fs.existsSync(location.path)) return [];
27
+
28
+ try {
29
+ const source = fs.readFileSync(location.path, "utf8");
30
+ const frame = codeFrameColumns(
31
+ source,
32
+ {
33
+ start: {
34
+ line: location.line,
35
+ column: Math.max(1, Number(location.column) || 1),
36
+ },
37
+ },
38
+ {
39
+ highlightCode: Boolean(process.stdout?.isTTY || process.env.FORCE_COLOR),
40
+ linesAbove: options.linesAbove ?? 2,
41
+ linesBelow: options.linesBelow ?? 2,
42
+ }
43
+ );
44
+ const label = `${options.label || "code"}: ${formatLocation(location, options.cwd)}`;
45
+ return [pc.bold(label), ...frame.split(/\r?\n/)];
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+
51
+ export function formatLocation(location, cwd = process.cwd()) {
52
+ if (!location?.path || !Number.isFinite(location.line)) return null;
53
+ const relativePath = path.isAbsolute(location.path)
54
+ ? path.relative(cwd, location.path) || path.basename(location.path)
55
+ : location.path;
56
+ return `${relativePath}:${location.line}:${location.column || 1}`;
57
+ }
@@ -0,0 +1,71 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { findFailureLocation, formatLocation, renderCodeFrame } from "./code-frames.mjs";
6
+
7
+ const cleanups = [];
8
+
9
+ afterEach(() => {
10
+ while (cleanups.length > 0) {
11
+ cleanups.pop()();
12
+ }
13
+ });
14
+
15
+ describe("code frame presentation", () => {
16
+ it("finds a location from structured failure metadata", () => {
17
+ expect(
18
+ findFailureLocation({
19
+ location: {
20
+ path: "/tmp/example.ts",
21
+ line: 10,
22
+ column: 2,
23
+ },
24
+ })
25
+ ).toEqual({
26
+ path: "/tmp/example.ts",
27
+ line: 10,
28
+ column: 2,
29
+ });
30
+ });
31
+
32
+ it("renders a code frame for a local file", () => {
33
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-code-frame-"));
34
+ cleanups.push(() => fs.rmSync(tempDir, { recursive: true, force: true }));
35
+ const filePath = path.join(tempDir, "example.ts");
36
+ fs.writeFileSync(
37
+ filePath,
38
+ [
39
+ "export function run() {",
40
+ " const value = 1;",
41
+ " return value + missing;",
42
+ "}",
43
+ ].join("\n")
44
+ );
45
+
46
+ const lines = renderCodeFrame(
47
+ {
48
+ path: filePath,
49
+ line: 3,
50
+ column: 18,
51
+ },
52
+ { cwd: tempDir }
53
+ );
54
+
55
+ expect(lines.join("\n")).toContain("example.ts:3:18");
56
+ expect(lines.join("\n")).toContain("return value + missing;");
57
+ });
58
+
59
+ it("formats locations relative to the cwd", () => {
60
+ expect(
61
+ formatLocation(
62
+ {
63
+ path: "/tmp/example.ts",
64
+ line: 7,
65
+ column: 3,
66
+ },
67
+ "/tmp"
68
+ )
69
+ ).toBe("example.ts:7:3");
70
+ });
71
+ });
@@ -0,0 +1,29 @@
1
+ import { createColors } from "picocolors";
2
+
3
+ const pc = createColors(Boolean(process.stdout?.isTTY || process.env.FORCE_COLOR));
4
+
5
+ export function colorStatus(status) {
6
+ if (status === "PASS") return pc.green(status);
7
+ if (status === "FAIL") return pc.red(status);
8
+ if (status === "SKIP") return pc.yellow(status);
9
+ if (status === "RUN") return pc.cyan(status);
10
+ return status;
11
+ }
12
+
13
+ export function dim(text) {
14
+ return pc.dim(text);
15
+ }
16
+
17
+ export function colorResultLine(line) {
18
+ if (/^Result: PASSED\b/.test(line)) return line.replace("PASSED", pc.green("PASSED"));
19
+ if (/^Result: FAILED\b/.test(line)) return line.replace("FAILED", pc.red("FAILED"));
20
+ return line;
21
+ }
22
+
23
+ export function colorSectionLine(line) {
24
+ if (line === "Failures:" || line === "Runtime Errors:" || line === "Known-failure issues:" || line === "Triage:") {
25
+ return pc.bold(line);
26
+ }
27
+ if (/^Summary: /.test(line)) return line.replace("Summary:", `${pc.bold("Summary:")}`);
28
+ return colorResultLine(line);
29
+ }
@@ -4,6 +4,7 @@ import {
4
4
  buildDebugRunSummaryLines,
5
5
  formatDuration,
6
6
  } from "../../runner/formatting.mjs";
7
+ import { colorSectionLine, colorStatus, dim } from "./colors.mjs";
7
8
 
8
9
  export function createRunReporter({ outputMode = "compact", stdout = process.stdout, stderr = process.stderr } = {}) {
9
10
  const mode = outputMode || "compact";
@@ -25,34 +26,58 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
25
26
  `[testkit] ${config.runtimeLabel || config.name}:${config.name} toolchain ${resolvedToolchain.summary}\n`
26
27
  );
27
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
+ },
28
50
  localServiceStarting(config, command) {
29
51
  if (mode !== "debug") return;
30
52
  stdout.write(`Starting ${config.runtimeLabel}:${config.name}: ${command}\n`);
31
53
  },
32
54
  serviceSkipped(config, reason) {
33
- stdout.write(`SKIP ${config.name} ${reason}\n`);
55
+ stdout.write(`${colorStatus("SKIP")} ${config.name} ${reason}\n`);
34
56
  },
35
57
  plannedSkip(entry) {
36
58
  stdout.write(
37
- `SKIP ${entry.serviceName} ${entry.type} ${normalizePath(entry.file)} 0s ${shortenMessage(entry.reason || "skipped")}\n`
59
+ `${colorStatus("SKIP")} ${entry.serviceName} ${entry.type} ${normalizePath(entry.file)} ${dim("0s")} ${shortenMessage(entry.reason || "skipped")}\n`
38
60
  );
39
61
  },
40
62
  taskStarted(task, targetConfig) {
41
63
  if (mode !== "debug") return;
42
- stdout.write(`RUN ${targetConfig.name} ${task.type} ${task.file}\n`);
64
+ stdout.write(`${colorStatus("RUN").padEnd(12)} ${targetConfig.name} ${task.type} ${task.file}\n`);
43
65
  },
44
66
  taskFinished(task, outcome) {
45
67
  if (mode === "json") return;
46
68
  const status = outcome.status === "skipped" ? "SKIP" : outcome.failed ? "FAIL" : "PASS";
47
69
  const duration = formatDuration(outcome.durationMs || 0);
70
+ const primaryFailure = firstFailureDetail(outcome);
71
+ const preferredFailure =
72
+ primaryFailure && isThresholdWrapperMessage(outcome.error) ? primaryFailure : outcome.error || primaryFailure;
48
73
  const detail =
49
74
  status === "FAIL"
50
- ? ` ${shortenMessage(outcome.error || firstFailureDetail(outcome) || "failed")}`
75
+ ? ` ${shortenMessage(preferredFailure || "failed")}`
51
76
  : outcome.status === "not_run"
52
77
  ? ` ${shortenMessage(outcome.reason || "not run")}`
53
78
  : "";
54
79
  stdout.write(
55
- `${status} ${task.serviceName} ${displayTaskType(task)} ${normalizePath(task.file)} ${duration}${detail}\n`
80
+ `${colorStatus(status)} ${task.serviceName} ${displayTaskType(task)} ${normalizePath(task.file)} ${dim(duration)}${detail}\n`
56
81
  );
57
82
  },
58
83
  telemetry(message) {
@@ -64,7 +89,7 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
64
89
  mode === "debug"
65
90
  ? buildDebugRunSummaryLines(results, durationMs, knownFailureIssueValidation)
66
91
  : buildCompactRunSummaryLines(results, durationMs, knownFailureIssueValidation);
67
- for (const line of lines) stdout.write(`${line}\n`);
92
+ for (const line of lines) stdout.write(`${colorSectionLine(line)}\n`);
68
93
  },
69
94
  error(message) {
70
95
  stderr.write(`${message}\n`);
@@ -83,9 +108,18 @@ function shortenMessage(message) {
83
108
  }
84
109
 
85
110
  function firstFailureDetail(outcome) {
86
- return outcome.failureDetails?.[0]?.message || outcome.failureDetails?.[0]?.title || null;
111
+ const detail = outcome.failureDetails?.[0];
112
+ return detail?.message || detail?.title || null;
113
+ }
114
+
115
+ function isThresholdWrapperMessage(message) {
116
+ return /Default runtime thresholds failed:/.test(String(message || ""));
87
117
  }
88
118
 
89
119
  function normalizePath(filePath) {
90
120
  return String(filePath).split(path.sep).join("/");
91
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
+ }