spawn/cli/src/__tests__/cmd-interactive.test.ts
A b62dc1af33
feat: ban as type assertions, add runtime schema validation with valibot (#1775)
* fix: resolve all biome lint warnings across the codebase

- Replace all noExplicitAny with proper types (unknown, Record<string, unknown>)
- Fix useBlockStatements in picker.ts (braceless if)
- Fix useNumberNamespace in picker.ts (parseInt → Number.parseInt)
- Codebase now passes biome lint with 0 errors and 0 warnings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: ban `as` type assertions, add runtime schema validation with valibot

Replace all ~170 unsafe `as` type assertions across the entire codebase
(production + tests) with runtime-validated alternatives:

- Add GritQL biome plugin (`no-type-assertion.grit`) that bans all `as`
  casts except `as const`
- Add valibot for schema-validated JSON parsing (`parseJsonWith`)
- Add shared utilities: `parse.ts` (schema parsing), `type-guards.ts`
- Replace `as` casts in all 5 cloud modules (aws, daytona, hetzner,
  digitalocean, fly) with valibot schemas + type guards
- Replace `as` casts in shared modules (manifest, update-check, oauth,
  commands, history, ui)
- Replace `as any` in all 26 test files with proper `new Response()`
  mocks and typed variables
- Add 13 tests for parseJsonWith/parseJsonRaw
- Add "Embrace Bold Changes" culture rule to CLAUDE.md
- Bump version 0.6.19 → 0.7.0

1859 tests pass, 0 lint errors across 95 files, bundle +6KB from valibot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: move GritQL plugin into cli/lint/ directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-22 18:50:53 -08:00

584 lines
18 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { createMockManifest, createConsoleMocks, restoreMocks } from "./test-helpers";
import { loadManifest } from "../manifest";
/**
* Tests for cmdInteractive() in commands.ts.
*
* cmdInteractive is the primary user entry point (invoked with bare `spawn`).
* It has zero test coverage for:
* - User cancels agent selection (Ctrl+C at first prompt)
* - User cancels cloud selection (Ctrl+C at second prompt)
* - Agent with no implemented clouds (empty cloud list)
* - Happy path: agent selected, cloud selected, execScript called
* - Intro banner and outro messaging
* - "Next time, run directly" hint after selection
*
* Agent: test-engineer
*/
const mockManifest = createMockManifest();
// Mutable state to control per-test behavior of select() and isCancel()
const CANCEL_SYMBOL = Symbol("cancel");
let selectCallIndex = 0;
let selectReturnValues: any[] = [];
let isCancelValues: Set<any> = new Set();
// Mock @clack/prompts
const mockLogError = mock(() => {});
const mockLogInfo = mock(() => {});
const mockLogStep = mock(() => {});
const mockLogWarn = mock(() => {});
const mockIntro = mock(() => {});
const mockOutro = mock(() => {});
const mockCancel = mock(() => {});
const mockConfirm = mock(async () => true);
const mockSpinnerStart = mock(() => {});
const mockSpinnerStop = mock(() => {});
const mockSpinnerMessage = mock(() => {});
mock.module("@clack/prompts", () => ({
spinner: () => ({
start: mockSpinnerStart,
stop: mockSpinnerStop,
message: mockSpinnerMessage,
}),
log: {
step: mockLogStep,
info: mockLogInfo,
error: mockLogError,
warn: mockLogWarn,
},
intro: mockIntro,
outro: mockOutro,
cancel: mockCancel,
confirm: mockConfirm,
text: mock(async () => undefined),
autocomplete: mock(async () => {
const value = selectReturnValues[selectCallIndex] ?? "claude";
selectCallIndex++;
return value;
}),
select: mock(async () => {
const value = selectReturnValues[selectCallIndex] ?? "claude";
selectCallIndex++;
return value;
}),
isCancel: (value: any) => isCancelValues.has(value),
}));
// Import commands after mock setup
const { cmdInteractive } = await import("../commands.js");
describe("cmdInteractive", () => {
let consoleMocks: ReturnType<typeof createConsoleMocks>;
let originalFetch: typeof global.fetch;
let processExitSpy: ReturnType<typeof spyOn>;
beforeEach(async () => {
consoleMocks = createConsoleMocks();
mockLogError.mockClear();
mockLogInfo.mockClear();
mockLogStep.mockClear();
mockLogWarn.mockClear();
mockIntro.mockClear();
mockOutro.mockClear();
mockCancel.mockClear();
mockConfirm.mockClear();
mockSpinnerStart.mockClear();
mockSpinnerStop.mockClear();
mockSpinnerMessage.mockClear();
// Reset per-test mutable state
selectCallIndex = 0;
selectReturnValues = [];
isCancelValues = new Set();
processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => {
throw new Error("process.exit");
});
originalFetch = global.fetch;
// Pre-load manifest
global.fetch = mock(async () =>
new Response(JSON.stringify(mockManifest)),
);
await loadManifest(true);
});
afterEach(() => {
global.fetch = originalFetch;
processExitSpy.mockRestore();
restoreMocks(consoleMocks.log, consoleMocks.error);
});
// ── Cancel handling ──────────────────────────────────────────────────────
describe("cancel handling", () => {
it("should exit with code 0 when user cancels agent selection", async () => {
selectReturnValues = [
CANCEL_SYMBOL,
"sprite",
];
isCancelValues = new Set([
CANCEL_SYMBOL,
]);
await expect(cmdInteractive()).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(0);
});
it("should show cancelled message when user cancels agent selection", async () => {
selectReturnValues = [
CANCEL_SYMBOL,
"sprite",
];
isCancelValues = new Set([
CANCEL_SYMBOL,
]);
try {
await cmdInteractive();
} catch {
// Expected
}
const outroOutput = mockOutro.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(outroOutput.toLowerCase()).toContain("cancelled");
});
it("should exit with code 0 when user cancels cloud selection", async () => {
selectReturnValues = [
"claude",
CANCEL_SYMBOL,
];
isCancelValues = new Set([
CANCEL_SYMBOL,
]);
await expect(cmdInteractive()).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(0);
});
it("should show cancelled message when user cancels cloud selection", async () => {
selectReturnValues = [
"claude",
CANCEL_SYMBOL,
];
isCancelValues = new Set([
CANCEL_SYMBOL,
]);
try {
await cmdInteractive();
} catch {
// Expected
}
const outroOutput = mockOutro.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(outroOutput.toLowerCase()).toContain("cancelled");
});
it("should not show launch message when user cancels", async () => {
selectReturnValues = [
CANCEL_SYMBOL,
"sprite",
];
isCancelValues = new Set([
CANCEL_SYMBOL,
]);
try {
await cmdInteractive();
} catch {
// Expected
}
const stepCalls = mockLogStep.mock.calls.map((c: any[]) => c.join(" "));
const launchMsg = stepCalls.find((msg: string) => msg.includes("Launching"));
expect(launchMsg).toBeUndefined();
});
});
// ── No clouds available ──────────────────────────────────────────────────
describe("no clouds available", () => {
it("should exit with code 1 when agent has no implemented clouds", async () => {
// "codex" is only implemented on "sprite", but we need an agent with zero implementations.
// Create a manifest where codex has no implemented clouds.
const noCloudManifest = {
...mockManifest,
matrix: {
"sprite/claude": "implemented",
"hetzner/claude": "implemented",
"sprite/codex": "missing",
"hetzner/codex": "missing",
},
};
global.fetch = mock(async () =>
new Response(JSON.stringify(noCloudManifest)),
);
await loadManifest(true);
selectReturnValues = [
"codex",
"sprite",
];
await expect(cmdInteractive()).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should show agent name in 'no clouds' error message", async () => {
const noCloudManifest = {
...mockManifest,
matrix: {
"sprite/claude": "implemented",
"hetzner/claude": "implemented",
"sprite/codex": "missing",
"hetzner/codex": "missing",
},
};
global.fetch = mock(async () =>
new Response(JSON.stringify(noCloudManifest)),
);
await loadManifest(true);
selectReturnValues = [
"codex",
"sprite",
];
try {
await cmdInteractive();
} catch {
// Expected
}
const errorCalls = mockLogError.mock.calls.map((c: any[]) => c.join(" "));
expect(errorCalls.some((msg: string) => msg.includes("Codex"))).toBe(true);
});
it("should suggest 'spawn matrix' when no clouds available", async () => {
const noCloudManifest = {
...mockManifest,
matrix: {
"sprite/claude": "implemented",
"hetzner/claude": "implemented",
"sprite/codex": "missing",
"hetzner/codex": "missing",
},
};
global.fetch = mock(async () =>
new Response(JSON.stringify(noCloudManifest)),
);
await loadManifest(true);
selectReturnValues = [
"codex",
"sprite",
];
try {
await cmdInteractive();
} catch {
// Expected
}
const infoCalls = mockLogInfo.mock.calls.map((c: any[]) => c.join(" "));
expect(infoCalls.some((msg: string) => msg.includes("spawn matrix"))).toBe(true);
});
});
// ── Happy path ───────────────────────────────────────────────────────────
describe("happy path", () => {
it("should show intro banner with version", async () => {
// Select claude + sprite, fetch returns valid script
selectReturnValues = [
"claude",
"sprite",
];
global.fetch = mock(async (url: string) => {
if (typeof url === "string" && url.includes("manifest.json")) {
return new Response(JSON.stringify(mockManifest));
}
return new Response("#!/bin/bash\nset -eo pipefail\nexit 0");
});
await loadManifest(true);
await cmdInteractive();
expect(mockIntro).toHaveBeenCalled();
const introArg = mockIntro.mock.calls[0]?.[0] ?? "";
expect(introArg).toContain("spawn");
});
it("should show launch step with agent and cloud names", async () => {
selectReturnValues = [
"claude",
"sprite",
];
global.fetch = mock(async (url: string) => {
if (typeof url === "string" && url.includes("manifest.json")) {
return new Response(JSON.stringify(mockManifest));
}
return new Response("#!/bin/bash\nset -eo pipefail\nexit 0");
});
await loadManifest(true);
await cmdInteractive();
const stepCalls = mockLogStep.mock.calls.map((c: any[]) => c.join(" "));
const launchMsg = stepCalls.find((msg: string) => msg.includes("Launching"));
expect(launchMsg).toBeDefined();
expect(launchMsg).toContain("Claude Code");
expect(launchMsg).toContain("Sprite");
});
it("should show 'run directly' hint with agent and cloud keys", async () => {
selectReturnValues = [
"claude",
"sprite",
];
global.fetch = mock(async (url: string) => {
if (typeof url === "string" && url.includes("manifest.json")) {
return new Response(JSON.stringify(mockManifest));
}
return new Response("#!/bin/bash\nset -eo pipefail\nexit 0");
});
await loadManifest(true);
await cmdInteractive();
const infoCalls = mockLogInfo.mock.calls.map((c: any[]) => c.join(" "));
const hintMsg = infoCalls.find((msg: string) => msg.includes("Next time"));
expect(hintMsg).toBeDefined();
expect(hintMsg).toContain("spawn claude sprite");
});
it("should show outro message before handing off", async () => {
selectReturnValues = [
"claude",
"sprite",
];
global.fetch = mock(async (url: string) => {
if (typeof url === "string" && url.includes("manifest.json")) {
return new Response(JSON.stringify(mockManifest));
}
return new Response("#!/bin/bash\nset -eo pipefail\nexit 0");
});
await loadManifest(true);
await cmdInteractive();
expect(mockOutro).toHaveBeenCalled();
const outroArg = mockOutro.mock.calls[0]?.[0] ?? "";
expect(outroArg).toContain("spawn script");
});
it("should work with codex agent on sprite cloud", async () => {
selectReturnValues = [
"codex",
"sprite",
];
global.fetch = mock(async (url: string) => {
if (typeof url === "string" && url.includes("manifest.json")) {
return new Response(JSON.stringify(mockManifest));
}
return new Response("#!/bin/bash\nset -eo pipefail\nexit 0");
});
await loadManifest(true);
await cmdInteractive();
const stepCalls = mockLogStep.mock.calls.map((c: any[]) => c.join(" "));
const launchMsg = stepCalls.find((msg: string) => msg.includes("Launching"));
expect(launchMsg).toBeDefined();
expect(launchMsg).toContain("Codex");
expect(launchMsg).toContain("Sprite");
});
});
// ── Script execution integration ─────────────────────────────────────────
describe("script execution after selection", () => {
it("should attempt to download script after user selects agent and cloud", async () => {
const fetchedUrls: string[] = [];
selectReturnValues = [
"claude",
"sprite",
];
global.fetch = mock(async (url: string) => {
if (typeof url === "string") {
fetchedUrls.push(url);
}
if (typeof url === "string" && url.includes("manifest.json")) {
return new Response(JSON.stringify(mockManifest));
}
return new Response("#!/bin/bash\nset -eo pipefail\nexit 0");
});
await loadManifest(true);
await cmdInteractive();
// Should have fetched script URLs for sprite/claude
const scriptUrls = fetchedUrls.filter((u) => u.includes(".sh"));
expect(scriptUrls.length).toBeGreaterThanOrEqual(1);
expect(scriptUrls.some((u) => u.includes("sprite") && u.includes("claude"))).toBe(true);
});
it("should propagate script download failure as process.exit(1)", async () => {
selectReturnValues = [
"claude",
"sprite",
];
global.fetch = mock(async (url: string) => {
if (typeof url === "string" && url.includes("manifest.json")) {
return new Response(JSON.stringify(mockManifest));
}
// Both primary and fallback fail
return new Response("Not Found", { status: 404 });
});
await loadManifest(true);
await expect(cmdInteractive()).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
// ── Preflight credential check ──────────────────────────────────────────
describe("preflight credential check", () => {
it("should warn about missing credentials before launching", async () => {
// Use a manifest with a real-looking auth var that won't be set
const credManifest = {
...mockManifest,
clouds: {
...mockManifest.clouds,
sprite: {
...mockManifest.clouds.sprite,
auth: "SPRITE_API_KEY",
},
},
};
selectReturnValues = [
"claude",
"sprite",
];
delete process.env.SPRITE_API_KEY;
global.fetch = mock(async (url: string) => {
if (typeof url === "string" && url.includes("manifest.json")) {
return new Response(JSON.stringify(credManifest));
}
return new Response("#!/bin/bash\nset -eo pipefail\nexit 0");
});
await loadManifest(true);
await cmdInteractive();
const warnCalls = mockLogWarn.mock.calls.map((c: any[]) => c.join(" "));
expect(warnCalls.some((msg: string) => msg.includes("SPRITE_API_KEY"))).toBe(true);
});
it("should not warn when all credentials are set", async () => {
// Use a manifest with auth var that IS set
const credManifest = {
...mockManifest,
clouds: {
...mockManifest.clouds,
sprite: {
...mockManifest.clouds.sprite,
auth: "SPRITE_API_KEY",
},
},
};
selectReturnValues = [
"claude",
"sprite",
];
const savedKey = process.env.SPRITE_API_KEY;
const savedOR = process.env.OPENROUTER_API_KEY;
process.env.SPRITE_API_KEY = "test-sprite-key";
process.env.OPENROUTER_API_KEY = "sk-or-test";
global.fetch = mock(async (url: string) => {
if (typeof url === "string" && url.includes("manifest.json")) {
return new Response(JSON.stringify(credManifest));
}
return new Response("#!/bin/bash\nset -eo pipefail\nexit 0");
});
await loadManifest(true);
await cmdInteractive();
const warnCalls = mockLogWarn.mock.calls.map((c: any[]) => c.join(" "));
const credWarn = warnCalls.find((msg: string) => msg.includes("Missing credentials"));
expect(credWarn).toBeUndefined();
// Restore env
if (savedKey === undefined) {
delete process.env.SPRITE_API_KEY;
} else {
process.env.SPRITE_API_KEY = savedKey;
}
if (savedOR === undefined) {
delete process.env.OPENROUTER_API_KEY;
} else {
process.env.OPENROUTER_API_KEY = savedOR;
}
});
it("should still launch script after credential warning", async () => {
const credManifest = {
...mockManifest,
clouds: {
...mockManifest.clouds,
sprite: {
...mockManifest.clouds.sprite,
auth: "SPRITE_API_KEY",
},
},
};
selectReturnValues = [
"claude",
"sprite",
];
delete process.env.SPRITE_API_KEY;
const fetchedUrls: string[] = [];
global.fetch = mock(async (url: string) => {
if (typeof url === "string") {
fetchedUrls.push(url);
}
if (typeof url === "string" && url.includes("manifest.json")) {
return new Response(JSON.stringify(credManifest));
}
return new Response("#!/bin/bash\nset -eo pipefail\nexit 0");
});
await loadManifest(true);
await cmdInteractive();
// Script should still be downloaded despite credential warning
const scriptUrls = fetchedUrls.filter((u) => u.includes(".sh"));
expect(scriptUrls.length).toBeGreaterThanOrEqual(1);
});
});
});