diff --git a/src/commands/watch.ts b/src/commands/watch.ts index 0151dcf..0d4a6d7 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -1,11 +1,11 @@ import type { Command } from "commander"; import { - renderTerminal, runTarget, writeRunArtifact, type TargetConfig, } from "../index.js"; +import { renderWatchFirstRun, renderWatchNoChanges, renderWatchChanges } from "../reporters/terminal.js"; import { isCI } from "../ci.js"; import { defaultRunsDirectory, findLatestArtifact, readArtifact } from "../storage.js"; import { ANSI, c, formatOutput, targetFromCommand } from "./helpers.js"; @@ -22,6 +22,14 @@ async function runWatchOneShot( const artifact = await runTarget(target); const outPath = await writeRunArtifact(artifact, outDir); + // JSON mode bypasses compact rendering + if (options.format === "json") { + process.stdout.write(formatOutput(artifact, "json") + "\n"); + process.stdout.write(`${c(ANSI.dim, `Artifact: ${outPath}`)}\n`); + if (artifact.gate === "fail") process.exitCode = 1; + return; + } + // Find the PREVIOUS run for this target (excluding the one just written) const previousPath = await findLatestArtifact(outDir, target.targetId, outPath); if (previousPath) { @@ -29,11 +37,15 @@ async function runWatchOneShot( if (previousRaw.artifactType === "run") { const diffResult = diff(previousRaw, artifact); - if (diffResult.summary.regressions === 0 && diffResult.summary.recoveries === 0 && diffResult.summary.added === 0 && diffResult.summary.removed === 0) { - process.stdout.write(formatOutput(artifact, options.format as "terminal" | "json") + "\n"); - process.stdout.write(`${c(ANSI.green, "✓ No changes")} since last run\n`); + const hasChanges = diffResult.summary.regressions > 0 + || diffResult.summary.recoveries > 0 + || diffResult.summary.added > 0 + || diffResult.summary.removed > 0; + + if (!hasChanges) { + process.stdout.write(renderWatchNoChanges(artifact) + "\n"); } else { - process.stdout.write(formatOutput(diffResult, options.format as "terminal" | "json") + "\n"); + process.stdout.write(renderWatchChanges(artifact, diffResult) + "\n"); } process.stdout.write(`${c(ANSI.dim, `Artifact: ${outPath}`)}\n`); @@ -45,7 +57,7 @@ async function runWatchOneShot( } // First run — no previous artifact to diff against - process.stdout.write(formatOutput(artifact, options.format as "terminal" | "json") + "\n"); + process.stdout.write(renderWatchFirstRun(artifact) + "\n"); process.stdout.write(`${c(ANSI.dim, `Artifact: ${outPath}`)}\n`); if (artifact.gate === "fail") { @@ -62,7 +74,7 @@ async function runWatchMode(target: TargetConfig, outDir: string, intervalSecond let previousArtifact = await runTarget(target); await writeRunArtifact(previousArtifact, outDir); - process.stdout.write(`${renderTerminal(previousArtifact)}\n\n`); + process.stdout.write(renderWatchFirstRun(previousArtifact) + "\n\n"); const loop = async (): Promise => { await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000)); @@ -72,9 +84,9 @@ async function runWatchMode(target: TargetConfig, outDir: string, intervalSecond if (diffResult.summary.regressions > 0 || diffResult.summary.recoveries > 0 || diffResult.summary.added > 0 || diffResult.summary.removed > 0) { const outPath = await writeRunArtifact(currentArtifact, outDir); - process.stdout.write(`\n--- Change detected at ${currentArtifact.createdAt} ---\n`); - process.stdout.write(`${renderTerminal(diffResult)}\n`); - process.stdout.write(`Artifact: ${outPath}\n\n`); + process.stdout.write(`\n--- ${new Date().toLocaleTimeString()} ---\n`); + process.stdout.write(renderWatchChanges(currentArtifact, diffResult) + "\n"); + process.stdout.write(`${c(ANSI.dim, `Artifact: ${outPath}`)}\n\n`); } previousArtifact = currentArtifact; diff --git a/src/index.ts b/src/index.ts index ec304a9..6f4e710 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ export { renderHtml } from "./reporters/html.js"; export { renderJUnit } from "./reporters/junit.js"; export { renderMarkdown } from "./reporters/markdown.js"; export { renderSarif } from "./reporters/sarif.js"; -export { renderTerminal } from "./reporters/terminal.js"; +export { renderTerminal, renderWatchFirstRun, renderWatchNoChanges, renderWatchChanges } from "./reporters/terminal.js"; export { runTarget, runTargetRecording, type RunOptions, type RunResult } from "./runner.js"; export { computeHealthScore, type ScoreWeights, DEFAULT_WEIGHTS } from "./score.js"; export { diff --git a/src/reporters/terminal.ts b/src/reporters/terminal.ts index 6a449bf..27caf1d 100644 --- a/src/reporters/terminal.ts +++ b/src/reporters/terminal.ts @@ -6,6 +6,110 @@ import { sortChecksByActionability } from "./common.js"; +// ── Watch-specific compact renderers ──────────────────────────────────────── + +function watchStatusIcon(status: CheckStatus): string { + switch (status) { + case "pass": return co(ANSI.green, "✓"); + case "fail": return co(ANSI.red, "✗"); + case "partial": + case "flaky": return co(ANSI.yellow, "⚠"); + case "unsupported": + case "skipped": return co(ANSI.dim, "–"); + } +} + +function scoreString(artifact: RunArtifact): string { + if (!artifact.healthScore) return ""; + const s = artifact.healthScore; + const color = s.grade === "A" || s.grade === "B" ? ANSI.green + : s.grade === "C" ? ANSI.yellow : ANSI.red; + return co(color, `${s.overall}/100 (${s.grade})`); +} + +function serverLabel(artifact: RunArtifact): string { + const name = artifact.target.serverName ?? artifact.target.targetId; + const version = artifact.target.serverVersion ?? ""; + return version ? `${name} ${version}` : name; +} + +/** Compact single-line header: server name — score — gate */ +function watchHeader(artifact: RunArtifact): string { + const parts = [co(ANSI.bold, serverLabel(artifact))]; + const score = scoreString(artifact); + if (score) parts.push(score); + parts.push(artifact.gate === "pass" ? co(ANSI.green, "pass") : co(ANSI.red, "FAIL")); + return parts.join(" — "); +} + +/** First run: header + one line per check + fatal error if any */ +export function renderWatchFirstRun(artifact: RunArtifact): string { + const lines = [watchHeader(artifact)]; + + if (artifact.fatalError !== undefined) { + lines.push(""); + lines.push(co(ANSI.red, "Server failed to start:")); + // Show just the diagnosis, not the full multi-paragraph dump + const diagLines = artifact.fatalError.split("\n"); + const diagIdx = diagLines.findIndex(l => l.startsWith("Diagnosis:")); + if (diagIdx >= 0) { + lines.push(` ${diagLines[diagIdx]}`); + } else { + lines.push(` ${diagLines[0]}`); + } + return lines.join("\n"); + } + + const orderedChecks = sortChecksByActionability(artifact.checks); + for (const check of orderedChecks) { + const icon = watchStatusIcon(check.status); + // Compact: only show detail for non-pass checks + if (check.status === "pass") { + lines.push(` ${icon} ${check.id}`); + } else { + lines.push(` ${icon} ${check.id} ${co(ANSI.dim, check.message)}`); + } + } + + return lines.join("\n"); +} + +/** No changes: header + ✓ */ +export function renderWatchNoChanges(artifact: RunArtifact): string { + return `${watchHeader(artifact)}\n${co(ANSI.green, "✓ No changes")}`; +} + +/** Changes detected: header + only the changes */ +export function renderWatchChanges(artifact: RunArtifact, diff: DiffArtifact): string { + const lines = [watchHeader(artifact)]; + + if (diff.regressions.length > 0) { + lines.push(""); + for (const e of diff.regressions) { + lines.push(co(ANSI.red, ` ✗ ${e.id}: ${e.fromStatus ?? "n/a"} → ${e.toStatus ?? "n/a"} ${e.message}`)); + } + } + if (diff.recoveries.length > 0) { + lines.push(""); + for (const e of diff.recoveries) { + lines.push(co(ANSI.green, ` ✓ ${e.id}: ${e.fromStatus ?? "n/a"} → ${e.toStatus ?? "n/a"} ${e.message}`)); + } + } + if (diff.schemaDrift && diff.schemaDrift.length > 0) { + lines.push(""); + for (const e of diff.schemaDrift) { + lines.push(co(ANSI.yellow, ` ⚠ ${e.name} (${e.capability}): ${e.changes.join(", ")}`)); + } + } + if (diff.responseChanges && diff.responseChanges.length > 0) { + for (const e of diff.responseChanges) { + lines.push(co(ANSI.yellow, ` ⚠ ${e.name} (${e.capability}): ${e.change}`)); + } + } + + return lines.join("\n"); +} + const ANSI = { red: "\x1b[31m", green: "\x1b[32m",