mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
feat(slice-21 FR-5d): rig workspace doctor CLI subcommand + formatters + tests (OPR.0.3.2.21.FR-5)
Fourth commit of the FR-5 chain. Wires the operator-facing CLI for
the daemon route shipped in FR-5c. After this commit `rig workspace
doctor` is end-to-end reachable.
CLI subcommand (packages/cli/src/commands/workspace.ts):
- Added `doctor` subcommand alongside the shipped slice-01 `validate`.
- Options:
--workspace <path> override the workspace under check (resolved
to absolute via path.resolve before posting)
--json structured JSON output for agent pipelines
--strict exit non-zero on warn-or-fail (default:
non-zero only on fail) per IMPL-PRD §74
- POSTs to /api/workspace/doctor (the FR-5c route) via the existing
withClient + DaemonClient helper, mirroring the validate request
shape so wiring is uniform across the workspace surface.
- 4xx/5xx daemon responses print the error JSON to stderr and exit 1
(same shape as validate).
Human formatter (renderHumanDoctorReport, exported for unit testing):
- Header: `workspace doctor — <workspaceRoot>` + summary counts
- Body: checks grouped under workspace / missions / daemon
categories per IMPL-PRD §76
- workspace: workspace_root_reachable, file_allowlist_sane
- missions: missions_folder_present, optional_slice_docs,
mission_notes_presence
- daemon: daemon_points_at_this_workspace, daemon_reload_needed
- Each check rendered as `[OK|WARN|FAIL] <check-name>: <message>`
with an indented `Fix: <fixHint>` line below failing/warning
checks
- Forward-compat: any check absent from the documented group map
(e.g. a future #8 check shipped daemon-side before the CLI knows
about it) falls into an "other:" section rather than silently
vanishing
JSON formatter:
- Single-line pass-through of the daemon's DoctorReport (no
re-shape; the schema is the daemon contract per IMPL-PRD §78)
Exit-code semantics:
- default: exit 1 only on fail
- --strict: exit 1 on warn-or-fail
- HTTP 5xx response: exit 1
Tests (12 in new test file workspace-doctor-cli.test.ts):
- Request-shape (3 tests):
- default doctor invocation posts to /api/workspace/doctor with
empty body
- --workspace passes body.workspaceRoot (absolute)
- --workspace resolves relative path to absolute before posting
- Output-formatter (4 tests):
- --json prints single-line structured JSON
- human output groups checks under the three category labels in
fixed order (positional asserts on label vs check-name index)
- fixHint renders under failing/warning checks via the indented
Fix line
- forward-compat: ungrouped check falls into "other:" section
- Exit-code (5 tests):
- all-ok summary → exit 0
- at least one fail (default mode) → exit 1
- warns only (default mode) → exit 0
- warns + --strict → exit 1
- HTTP 5xx → exit 1
Test pattern mirrors queue.test.ts:
- vi.mock daemon-lifecycle to skip live status probe
- makeDeps factory returns deterministic post responses
- captureStdout helper restores console.log on cleanup
- process.exitCode saved/restored per test
DoctorReport type duplicated in cli/workspace.ts rather than imported
from the daemon package — no @openrig/daemon dependency edge from
the CLI; the HTTP boundary already enforces the JSON shape.
Verification:
- packages/cli tsc EXIT=0
- packages/cli workspace-doctor-cli.test.ts: 12/12 PASS
- Regression run of related CLI tests (workspace-max-files +
queue + workspace-doctor): 49/49 PASS (8 + 29 + 12)
- Pre-commit staged-diff grep for forbidden-attribution pattern:
ZERO matches
- Pre-commit FULL-CONTENT grep on every touched file (touch-event-
scope rule): ZERO matches
- Commit body verification grep clean per banked discipline
- git diff --cached --stat: 2 files (+441 / -6)
Branch: slice-21-fr-5-rig-workspace-doctor off LOCAL main e154a5ce.
Chain: a4b4b67d (FR-5a, CLEAR) → e5d61983 (FR-5b, BLOCKED) →
bc5ec05f (FR-5b fix-up, CLEAR) → 6b21ce3c (FR-5c, BLOCKED on
test determinism) → 784222d3 (FR-5c determinism fix-up, CLEAR) →
this commit (FR-5d CLI subcommand). LOCAL only, NO push.
Files:
- packages/cli/src/commands/workspace.ts (+127 / -6)
- packages/cli/test/workspace-doctor-cli.test.ts (+320 new)
This commit is contained in:
1 parent
784222d30d
commit
941a2e3149
2 files changed
+441
-6
No files matched your search
@@ -1,9 +1,12 @@
|
||||
// PL-007 Workspace Primitive v0 — `rig workspace` CLI surface.
|
||||
// PL-007 Workspace Primitive — `rig workspace` CLI surface.
|
||||
//
|
||||
// v0 surface (one verb): `rig workspace validate`. Walks a workspace root,
|
||||
// parses each .md file's YAML frontmatter, and emits a structured gap
|
||||
// report. Advisory only — never modifies. curate-steward consumes the
|
||||
// report.
|
||||
// Verbs:
|
||||
// - `rig workspace validate` (slice-01) — walks a workspace root,
|
||||
// parses each .md file's YAML frontmatter, emits a structured gap
|
||||
// report. Advisory only — never modifies. curate-steward consumes.
|
||||
// - `rig workspace doctor` (slice-21 FR-5) — runs the 7-check
|
||||
// workspace-readiness diagnostic against the daemon's resolved
|
||||
// workspace (or a --workspace override). Reports state + fix-hints.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
@@ -74,7 +77,7 @@ function emit3PartError(json: boolean, fact: string, consequence: string, action
|
||||
|
||||
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
const cmd = new Command("workspace").description(
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. `validate` walks a root and reports frontmatter gaps; `doctor` runs the 7-check workspace-readiness diagnostic.",
|
||||
);
|
||||
|
||||
const getDeps = (): WorkspaceDeps =>
|
||||
@@ -142,6 +145,47 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
},
|
||||
);
|
||||
|
||||
// Slice-21 FR-5 — `rig workspace doctor`.
|
||||
cmd
|
||||
.command("doctor")
|
||||
.description(
|
||||
"Run the 7-check workspace-readiness diagnostic against the daemon's resolved workspace. Reports state of workspace root, missions folder, file allowlist, daemon alignment, daemon reload, slice docs, and MISSION_NOTES. Read-only.",
|
||||
)
|
||||
.option(
|
||||
"--workspace <path>",
|
||||
"Override the workspace under check (default: daemon-resolved configured root)",
|
||||
)
|
||||
.option("--json", "JSON output for agents")
|
||||
.option(
|
||||
"--strict",
|
||||
"Exit non-zero on warn-or-fail (default: non-zero only on fail)",
|
||||
)
|
||||
.action(async (opts: { workspace?: string; json?: boolean; strict?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const requestBody: { workspaceRoot?: string } = {};
|
||||
if (opts.workspace) requestBody.workspaceRoot = path.resolve(opts.workspace);
|
||||
const res = await client.post<DoctorReport>("/api/workspace/doctor", requestBody);
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const report = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(report));
|
||||
} else {
|
||||
renderHumanDoctorReport(report);
|
||||
}
|
||||
// Exit-code semantics per FR-5 IMPL-PRD §74:
|
||||
// default: non-zero only on fail
|
||||
// --strict: non-zero on warn-or-fail
|
||||
const hasFail = report.summary.fail > 0;
|
||||
const hasWarn = report.summary.warn > 0;
|
||||
if (hasFail || (opts.strict && hasWarn)) process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -162,3 +206,74 @@ function renderHumanReport(r: ValidationReport): void {
|
||||
console.log(` ${g.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- slice-21 FR-5 — `rig workspace doctor` types + human formatter ---
|
||||
//
|
||||
// DoctorReport shape mirrors the daemon's runWorkspaceDoctor output
|
||||
// at packages/daemon/src/domain/workspace/workspace-doctor.ts. Type
|
||||
// is duplicated here (not imported from the daemon package) because
|
||||
// the CLI does not have a direct daemon-package dependency edge; the
|
||||
// HTTP boundary already enforces the JSON shape.
|
||||
|
||||
interface DoctorCheckResult {
|
||||
check: string;
|
||||
status: "ok" | "warn" | "fail";
|
||||
message: string;
|
||||
fixHint?: string;
|
||||
evidence?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DoctorReport {
|
||||
workspaceRoot: string;
|
||||
checks: DoctorCheckResult[];
|
||||
summary: { ok: number; warn: number; fail: number };
|
||||
daemonResolvedAt: string;
|
||||
}
|
||||
|
||||
const DOCTOR_CHECK_GROUPS: ReadonlyArray<{ category: string; checks: ReadonlyArray<string> }> = [
|
||||
{ category: "workspace", checks: ["workspace_root_reachable", "file_allowlist_sane"] },
|
||||
{ category: "missions", checks: ["missions_folder_present", "optional_slice_docs", "mission_notes_presence"] },
|
||||
{ category: "daemon", checks: ["daemon_points_at_this_workspace", "daemon_reload_needed"] },
|
||||
];
|
||||
|
||||
function statusIcon(status: DoctorCheckResult["status"]): string {
|
||||
switch (status) {
|
||||
case "ok": return "OK";
|
||||
case "warn": return "WARN";
|
||||
case "fail": return "FAIL";
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHumanDoctorReport(report: DoctorReport): void {
|
||||
console.log(`workspace doctor — ${report.workspaceRoot}`);
|
||||
console.log(` summary: ${report.summary.ok} ok, ${report.summary.warn} warn, ${report.summary.fail} fail`);
|
||||
console.log("");
|
||||
|
||||
const byName = new Map(report.checks.map((c) => [c.check, c]));
|
||||
const remaining = new Set(report.checks.map((c) => c.check));
|
||||
|
||||
for (const group of DOCTOR_CHECK_GROUPS) {
|
||||
console.log(`${group.category}:`);
|
||||
for (const checkName of group.checks) {
|
||||
const c = byName.get(checkName);
|
||||
if (!c) continue;
|
||||
remaining.delete(checkName);
|
||||
console.log(` [${statusIcon(c.status)}] ${c.check}: ${c.message}`);
|
||||
if (c.fixHint) console.log(` Fix: ${c.fixHint}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Any checks not in the documented groups still get rendered so a
|
||||
// future check addition doesn't silently drop from the human view
|
||||
// before the group-map is updated.
|
||||
if (remaining.size > 0) {
|
||||
console.log("other:");
|
||||
for (const checkName of remaining) {
|
||||
const c = byName.get(checkName);
|
||||
if (!c) continue;
|
||||
console.log(` [${statusIcon(c.status)}] ${c.check}: ${c.message}`);
|
||||
if (c.fixHint) console.log(` Fix: ${c.fixHint}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Slice-21 FR-5 — `rig workspace doctor` CLI subcommand tests.
|
||||
//
|
||||
// Covers:
|
||||
// - default behavior posts to /api/workspace/doctor with empty body
|
||||
// - --workspace passes body.workspaceRoot
|
||||
// - --json passes through the structured report
|
||||
// - human output groups by category (workspace / missions / daemon)
|
||||
// - exit-code semantics for fail / warn-with-strict / warn-without-strict / all-ok
|
||||
//
|
||||
// Mirrors the queue.test.ts pattern: vi.mock daemon-lifecycle to skip
|
||||
// the live status probe; clientFactory delivers stub HTTP responses.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
workspaceCommand,
|
||||
renderHumanDoctorReport,
|
||||
type WorkspaceDeps,
|
||||
} from "../src/commands/workspace.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>(
|
||||
"../src/daemon-lifecycle.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({
|
||||
state: "running",
|
||||
healthy: true,
|
||||
pid: 1234,
|
||||
port: 7433,
|
||||
})),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
interface DoctorReportFixture {
|
||||
workspaceRoot: string;
|
||||
checks: Array<{
|
||||
check: string;
|
||||
status: "ok" | "warn" | "fail";
|
||||
message: string;
|
||||
fixHint?: string;
|
||||
}>;
|
||||
summary: { ok: number; warn: number; fail: number };
|
||||
daemonResolvedAt: string;
|
||||
}
|
||||
|
||||
function healthyReport(workspaceRoot = "/ws"): DoctorReportFixture {
|
||||
return {
|
||||
workspaceRoot,
|
||||
checks: [
|
||||
{ check: "workspace_root_reachable", status: "ok", message: `workspace root '${workspaceRoot}' is a reachable directory` },
|
||||
{ check: "missions_folder_present", status: "ok", message: `missions folder '${workspaceRoot}/missions' is present` },
|
||||
{ check: "file_allowlist_sane", status: "ok", message: "files.allowlist has 1 usable entry covering workspace root" },
|
||||
{ check: "daemon_points_at_this_workspace", status: "ok", message: "daemon and caller agree on workspace root" },
|
||||
{ check: "daemon_reload_needed", status: "ok", message: "config file mtime is older than daemon start" },
|
||||
{ check: "optional_slice_docs", status: "ok", message: "every slice has a README" },
|
||||
{ check: "mission_notes_presence", status: "ok", message: "every mission has MISSION_NOTES.md" },
|
||||
],
|
||||
summary: { ok: 7, warn: 0, fail: 0 },
|
||||
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(response: { status: number; data: unknown }): {
|
||||
deps: WorkspaceDeps;
|
||||
post: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const post = vi.fn(async (_path: string, _body: unknown) => response);
|
||||
return {
|
||||
post,
|
||||
deps: {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => ({
|
||||
post,
|
||||
get: vi.fn(),
|
||||
getText: vi.fn(),
|
||||
postText: vi.fn(),
|
||||
}) as unknown as ReturnType<WorkspaceDeps["clientFactory"]>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
let originalExitCode: number | string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
describe("rig workspace doctor — request shape", () => {
|
||||
it("POSTs to /api/workspace/doctor with empty body when no --workspace", async () => {
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(post).toHaveBeenCalledWith("/api/workspace/doctor", {});
|
||||
});
|
||||
|
||||
// Discriminator-flip: when --workspace is passed, body.workspaceRoot
|
||||
// must carry the resolved absolute path. Without path.resolve(), a
|
||||
// relative input like "./ws" would land as "./ws" on the wire and
|
||||
// the daemon check #4 would compare against an un-normalized value.
|
||||
it("POSTs body.workspaceRoot (absolute) when --workspace is supplied", async () => {
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport("/ws-override") });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync([
|
||||
"node", "rig", "doctor", "--workspace", "/ws-override",
|
||||
]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(post).toHaveBeenCalledWith("/api/workspace/doctor", { workspaceRoot: "/ws-override" });
|
||||
});
|
||||
|
||||
it("resolves relative --workspace to absolute path before posting", async () => {
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync([
|
||||
"node", "rig", "doctor", "--workspace", "./local-ws",
|
||||
]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const [, body] = post.mock.calls[0]!;
|
||||
const sent = body as { workspaceRoot: string };
|
||||
expect(sent.workspaceRoot.startsWith("/")).toBe(true);
|
||||
expect(sent.workspaceRoot.endsWith("/local-ws")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workspace doctor — output formatters", () => {
|
||||
it("--json prints the report as a single-line JSON document", async () => {
|
||||
const report = healthyReport();
|
||||
const { deps } = makeDeps({ status: 200, data: report });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor", "--json"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(out.logs).toHaveLength(1);
|
||||
const parsed = JSON.parse(out.logs[0]!) as DoctorReportFixture;
|
||||
expect(parsed.summary).toEqual({ ok: 7, warn: 0, fail: 0 });
|
||||
expect(parsed.checks).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("default human output groups checks under workspace / missions / daemon categories", async () => {
|
||||
const { deps } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toContain("workspace doctor — /ws");
|
||||
expect(joined).toContain("summary: 7 ok, 0 warn, 0 fail");
|
||||
expect(joined).toContain("workspace:");
|
||||
expect(joined).toContain("missions:");
|
||||
expect(joined).toContain("daemon:");
|
||||
// Discriminator: each check name must appear under the correct
|
||||
// group section. We assert the category labels appear BEFORE the
|
||||
// check names that belong to them.
|
||||
const wsIdx = joined.indexOf("workspace:");
|
||||
const wsCheckIdx = joined.indexOf("workspace_root_reachable");
|
||||
const missionsIdx = joined.indexOf("missions:");
|
||||
const missionsCheckIdx = joined.indexOf("missions_folder_present");
|
||||
const daemonIdx = joined.indexOf("daemon:");
|
||||
const daemonCheckIdx = joined.indexOf("daemon_points_at_this_workspace");
|
||||
expect(wsIdx).toBeLessThan(wsCheckIdx);
|
||||
expect(missionsIdx).toBeLessThan(missionsCheckIdx);
|
||||
expect(daemonIdx).toBeLessThan(daemonCheckIdx);
|
||||
});
|
||||
|
||||
// Discriminator-flip: fixHint MUST render below the failing check.
|
||||
// Without `if (c.fixHint) console.log(...)` operators don't see how
|
||||
// to fix the failure.
|
||||
it("human output renders fixHint under failing/warning checks", () => {
|
||||
const report: DoctorReportFixture = {
|
||||
workspaceRoot: "/ws",
|
||||
checks: [
|
||||
{ check: "workspace_root_reachable", status: "fail", message: "workspace root '/ws' does not exist", fixHint: "run `rig config init-workspace`" },
|
||||
{ check: "missions_folder_present", status: "ok", message: "ok" },
|
||||
{ check: "file_allowlist_sane", status: "ok", message: "ok" },
|
||||
{ check: "daemon_points_at_this_workspace", status: "ok", message: "ok" },
|
||||
{ check: "daemon_reload_needed", status: "ok", message: "ok" },
|
||||
{ check: "optional_slice_docs", status: "ok", message: "ok" },
|
||||
{ check: "mission_notes_presence", status: "ok", message: "ok" },
|
||||
],
|
||||
summary: { ok: 6, warn: 0, fail: 1 },
|
||||
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
|
||||
};
|
||||
const out = captureStdout();
|
||||
try {
|
||||
renderHumanDoctorReport(report);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toContain("[FAIL] workspace_root_reachable");
|
||||
expect(joined).toContain("Fix: run `rig config init-workspace`");
|
||||
});
|
||||
|
||||
// Discriminator-flip: a check absent from DOCTOR_CHECK_GROUPS falls
|
||||
// into "other:" rather than vanishing. Protects against silent
|
||||
// future-check drop-off.
|
||||
it("human output renders ungrouped checks under 'other:'", () => {
|
||||
const report: DoctorReportFixture = {
|
||||
workspaceRoot: "/ws",
|
||||
checks: [
|
||||
{ check: "future_unknown_check", status: "warn", message: "new check the formatter didn't know about" },
|
||||
],
|
||||
summary: { ok: 0, warn: 1, fail: 0 },
|
||||
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
|
||||
};
|
||||
const out = captureStdout();
|
||||
try {
|
||||
renderHumanDoctorReport(report);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toContain("other:");
|
||||
expect(joined).toContain("future_unknown_check");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workspace doctor — exit-code semantics", () => {
|
||||
it("exits 0 when summary is all-ok (default mode)", async () => {
|
||||
const { deps } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// Discriminator-flip: any fail must trip exit code 1 even without
|
||||
// --strict.
|
||||
it("exits 1 when summary has at least one fail (default mode)", async () => {
|
||||
const failing = healthyReport();
|
||||
failing.checks[0]!.status = "fail";
|
||||
failing.summary = { ok: 6, warn: 0, fail: 1 };
|
||||
const { deps } = makeDeps({ status: 200, data: failing });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// Discriminator-flip: warns are NOT failures in default mode.
|
||||
it("exits 0 when summary has warns but no fails (default mode)", async () => {
|
||||
const warning = healthyReport();
|
||||
warning.checks[6]!.status = "warn";
|
||||
warning.summary = { ok: 6, warn: 1, fail: 0 };
|
||||
const { deps } = makeDeps({ status: 200, data: warning });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// Discriminator-flip: --strict turns warns into exit-1.
|
||||
it("exits 1 when summary has warns and --strict is set", async () => {
|
||||
const warning = healthyReport();
|
||||
warning.checks[6]!.status = "warn";
|
||||
warning.summary = { ok: 6, warn: 1, fail: 0 };
|
||||
const { deps } = makeDeps({ status: 200, data: warning });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor", "--strict"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// Discriminator-flip: HTTP 5xx → exit 1.
|
||||
it("exits 1 when daemon returns 5xx", async () => {
|
||||
const { deps } = makeDeps({ status: 503, data: { error: "settings_unavailable" } });
|
||||
const out = captureStdout();
|
||||
const stderrOrig = process.stderr.write;
|
||||
process.stderr.write = ((_chunk: unknown) => true) as typeof process.stderr.write;
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
process.stderr.write = stderrOrig;
|
||||
}
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user