Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions src/commands/watch.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,18 +22,30 @@ 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) {
const previousRaw = await readArtifact(previousPath);
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`);

Expand All @@ -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") {
Expand All @@ -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<void> => {
await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
104 changes: 104 additions & 0 deletions src/reporters/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading