diff --git a/src/reporters/terminal.ts b/src/reporters/terminal.ts index a82005a5..16a345ce 100644 --- a/src/reporters/terminal.ts +++ b/src/reporters/terminal.ts @@ -42,12 +42,12 @@ function renderNextActions(artifact: RunArtifact): string { lines.push(""); lines.push(co(ANSI.bold, "Next Actions:")); - lines.push(` → Auto-enforce: ${co(ANSI.bold, `npx @kryptosai/mcp-observatory enforce ${targetCmd}`)}`); + lines.push(` ${sym("bullet")} Auto-enforce: ${co(ANSI.bold, `npx @kryptosai/mcp-observatory enforce ${targetCmd}`)}`); if (isSeatbeltAvailable()) { - lines.push(co(ANSI.dim, " → This generates a policy AND starts the proxy — protecting you immediately")); + lines.push(co(ANSI.dim, ` ${sym("bullet")} This generates a policy AND starts the proxy — protecting you immediately`)); } else { - lines.push(co(ANSI.dim, " → Already know the risks? Enforce at runtime with mcp-seatbelt")); + lines.push(co(ANSI.dim, ` ${sym("bullet")} Already know the risks? Enforce at runtime with mcp-seatbelt`)); } return lines.join("\n"); @@ -59,12 +59,12 @@ export { renderNextActions }; function watchStatusIcon(status: CheckStatus): string { switch (status) { - case "pass": return co(ANSI.green, "✓"); - case "fail": return co(ANSI.red, "✗"); + case "pass": return co(ANSI.green, sym("pass")); + case "fail": return co(ANSI.red, sym("fail")); case "partial": - case "flaky": return co(ANSI.yellow, "⚠"); + case "flaky": return co(ANSI.yellow, sym("warn")); case "unsupported": - case "skipped": return co(ANSI.dim, "–"); + case "skipped": return co(ANSI.dim, sym("skip")); } } @@ -123,9 +123,9 @@ export function renderWatchFirstRun(artifact: RunArtifact): string { return lines.join("\n"); } -/** No changes: header + ✓ */ +/** No changes: header + pass symbol */ export function renderWatchNoChanges(artifact: RunArtifact): string { - return `${watchHeader(artifact)}\n${co(ANSI.green, "✓ No changes")}`; + return `${watchHeader(artifact)}\n${co(ANSI.green, `${sym("pass")} No changes`)}`; } /** Changes detected: header + only the changes */ @@ -135,24 +135,24 @@ export function renderWatchChanges(artifact: RunArtifact, diff: DiffArtifact): s 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}`)); + lines.push(co(ANSI.red, ` ${sym("fail")} ${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}`)); + lines.push(co(ANSI.green, ` ${sym("pass")} ${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(", ")}`)); + lines.push(co(ANSI.yellow, ` ${sym("warn")} ${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}`)); + lines.push(co(ANSI.yellow, ` ${sym("warn")} ${e.name} (${e.capability}): ${e.change}`)); } } @@ -168,6 +168,47 @@ const ANSI = { reset: "\x1b[0m", } as const; +// ── Accessible status labels ──────────────────────────────────────────────── +// Screen readers either skip the Unicode status glyphs or announce them by +// their character name ("check mark", "heavy multiplication x"), which carries +// no status meaning. In accessible mode they are swapped for text labels. +// Colour is orthogonal and stays on either way. + +const SYMBOLS = { + pass: "✓", + fail: "✗", + warn: "⚠", + skip: "–", + info: "ℹ", + bullet: "→", +} as const; + +const ACCESSIBLE_SYMBOLS: Record = { + pass: "[PASS]", + fail: "[FAIL]", + warn: "[WARN]", + skip: "[SKIP]", + info: "[INFO]", + bullet: ">", +}; + +let _accessibleMode = false; + +/** Swap Unicode status glyphs for screen-reader-friendly text labels. */ +export function setAccessibleMode(value: boolean): void { + _accessibleMode = value; +} + +/** Whether accessible (text-label) status output is active. */ +export function isAccessibleMode(): boolean { + return _accessibleMode; +} + +/** The glyph or text label for a status symbol, per the current mode. */ +function sym(name: keyof typeof SYMBOLS): string { + return _accessibleMode ? ACCESSIBLE_SYMBOLS[name] : SYMBOLS[name]; +} + const _argvNoColor = process.argv.includes("--no-color"); function shouldColor(): boolean { @@ -259,7 +300,7 @@ function renderRunTerminal(artifact: RunArtifact): string { lines.push(""); lines.push(co(ANSI.bold, "What Was Not Tested:")); for (const entry of notObserved) { - const icon = entry.severity === "warning" ? co(ANSI.yellow, "⚠") : co(ANSI.dim, "ℹ"); + const icon = entry.severity === "warning" ? co(ANSI.yellow, sym("warn")) : co(ANSI.dim, sym("info")); lines.push(` ${icon} ${entry.category}: ${entry.detail}`); } } @@ -272,7 +313,7 @@ function renderRunTerminal(artifact: RunArtifact): string { lines.push(""); lines.push(co(ANSI.red, " Security:")); for (const d of diagnostics.slice(0, 3)) { - lines.push(` ${co(ANSI.dim, "→")} ${d}`); + lines.push(` ${co(ANSI.dim, sym("bullet"))} ${d}`); } if (diagnostics.length > 3) { lines.push(` ${co(ANSI.dim, ` ...and ${diagnostics.length - 3} more (run with --security for full scan)`)}`); diff --git a/tests/reporters-accessible.test.ts b/tests/reporters-accessible.test.ts new file mode 100644 index 00000000..5c7684ca --- /dev/null +++ b/tests/reporters-accessible.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + isAccessibleMode, + renderTerminal, + renderWatchChanges, + renderWatchFirstRun, + renderWatchNoChanges, + setAccessibleMode, +} from "../src/reporters/terminal.js"; +import type { DiffArtifact, RunArtifact } from "../src/types.js"; +import { makeArtifact } from "./fixtures/test-helpers.js"; + +/** The status glyphs accessible mode is responsible for replacing. + * + * The `→` in a status transition ("pass → fail") is deliberately not here: it + * is prose inside a message, not a status symbol, and reads correctly. Only + * the leading bullet arrow is swapped — asserted separately below. + */ +const GLYPHS = ["✓", "✗", "⚠", "–", "ℹ"] as const; + +function check( + id: RunArtifact["checks"][number]["id"], + status: RunArtifact["checks"][number]["status"], +): RunArtifact["checks"][number] { + return { + id, + capability: id, + status, + durationMs: 1, + message: `${status} message`, + evidence: [ + { + endpoint: `${id}/endpoint`, + advertised: true, + responded: true, + minimalShapePresent: true, + itemCount: 1, + identifiers: ["alpha"], + diagnostics: ["a diagnostic"], + schemas: {}, + }, + ], + }; +} + +function artifact(): RunArtifact { + const a = makeArtifact([ + check("tools", "pass"), + check("security-lite", "fail"), + check("prompts", "partial"), + check("resources", "skipped"), + ]); + a.gate = "fail"; + return a; +} + +function diff(): DiffArtifact { + return { + artifactType: "diff", + schemaVersion: "1.0.0", + gate: "fail", + baseRunId: "base-run", + headRunId: "head-run", + createdAt: "2026-07-02T00:00:00Z", + summary: { + regressions: 1, + recoveries: 1, + unchanged: 0, + added: 0, + removed: 0, + schemaDriftCount: 1, + responseChangeCount: 1, + gate: "fail", + }, + regressions: [ + { id: "tools", capability: "tools", fromStatus: "pass", toStatus: "fail", message: "Tool broke" }, + ], + recoveries: [ + { id: "prompts", capability: "prompts", fromStatus: "fail", toStatus: "pass", message: "Prompt recovered" }, + ], + unchanged: [], + added: [], + removed: [], + schemaDrift: [ + { capability: "tools", name: "create_issue", severity: "high", changes: ["added required property type"] }, + ], + responseChanges: [{ capability: "tools", name: "create_issue", change: "response shape changed" }], + } as DiffArtifact; +} + +/** Every terminal surface that renders status symbols. */ +function renderAll(): string { + return [ + renderTerminal(artifact()), + renderWatchFirstRun(artifact()), + renderWatchNoChanges(artifact()), + renderWatchChanges(artifact(), diff()), + ].join("\n"); +} + +afterEach(() => { + setAccessibleMode(false); +}); + +describe("accessible mode", () => { + it("defaults to off", () => { + expect(isAccessibleMode()).toBe(false); + }); + + it("keeps the Unicode glyphs when off", () => { + const output = renderAll(); + + expect(output).toContain("✓"); + expect(output).toContain("✗"); + expect(output).toContain("⚠"); + expect(output).not.toContain("[PASS]"); + expect(output).not.toContain("[FAIL]"); + }); + + it("replaces every status glyph with a text label when on", () => { + setAccessibleMode(true); + + const output = renderAll(); + + for (const glyph of GLYPHS) { + expect(output, `expected no "${glyph}" in accessible output`).not.toContain(glyph); + } + expect(output).toContain("[PASS]"); + expect(output).toContain("[FAIL]"); + expect(output).toContain("[WARN]"); + }); + + it("labels skipped and unsupported checks as [SKIP]", () => { + setAccessibleMode(true); + + expect(renderWatchFirstRun(artifact())).toContain("[SKIP]"); + }); + + it("swaps the leading bullet arrow but keeps status-transition arrows", () => { + setAccessibleMode(true); + + // Bullet marker on the Next Actions list becomes ASCII. + expect(renderTerminal(artifact())).toContain("> Auto-enforce:"); + // The "from → to" arrow is prose in a message and is left alone. + expect(renderWatchChanges(artifact(), diff())).toContain("pass → fail"); + }); + + it("is reversible — turning it back off restores the glyphs", () => { + setAccessibleMode(true); + expect(renderWatchNoChanges(artifact())).toContain("[PASS]"); + + setAccessibleMode(false); + + expect(renderWatchNoChanges(artifact())).toContain("✓"); + }); + + it("leaves colour formatting untouched", () => { + const plain = renderWatchNoChanges(artifact()); + setAccessibleMode(true); + const accessible = renderWatchNoChanges(artifact()); + + // Same ANSI codes on both, only the glyph between them differs. + const codes = (s: string) => s.match(/\x1b\[\d+m/g) ?? []; + expect(codes(accessible)).toEqual(codes(plain)); + }); +});