mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
* test: remove duplicate in-memory cache tests and fix missing cache reset Two tests verifying in-memory cache returns the same instance without re-fetching were duplicated across manifest.test.ts and manifest-cache-lifecycle.test.ts. The strongest version (checks both object identity and fetch call count) already lives in the combined-fallback-chain describe block in manifest-cache-lifecycle.test.ts, so the two weaker duplicates are removed. Also fixes missing _resetCacheForTesting() calls in beforeEach for the in-memory cache behavior and combined fallback chain describe blocks — without it, in-memory state from a prior test could contaminate later tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: remove duplicate and theatrical tests Consolidate 5 near-identical manifest rejection tests into a single data-driven loop, and collapse 4 identical logging-function smoke tests into a data-driven loop. Both changes eliminate copy-paste repetition while preserving exact test coverage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: spawn-qa-bot <qa@openrouter.ai> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
/**
|
|
* ui-cov.test.ts — Coverage tests for shared/ui.ts
|
|
*
|
|
* NOTE: do-payment-warning.test.ts uses mock.module("../shared/ui") which
|
|
* contaminates any file that does `await import("../shared/ui.js")`.
|
|
* To work around this, we import statically (which captures real functions)
|
|
* and exercise logging via direct calls, checking they don't throw.
|
|
* For functions that need @clack/prompts, we mock that first.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { mockClackPrompts } from "./test-helpers";
|
|
|
|
const clackMocks = mockClackPrompts({
|
|
text: mock(() => Promise.resolve("user-input")),
|
|
select: mock(() => Promise.resolve("selected-id")),
|
|
});
|
|
|
|
// Static imports capture the REAL functions before mock.module can interfere.
|
|
import {
|
|
defaultSpawnName,
|
|
getServerNameFromEnv,
|
|
loadApiToken,
|
|
logDebug,
|
|
logError,
|
|
logInfo,
|
|
logStep,
|
|
logStepDone,
|
|
logStepInline,
|
|
logWarn,
|
|
openBrowser,
|
|
prepareStdinForHandoff,
|
|
prompt,
|
|
promptSpawnNameShared,
|
|
selectFromList,
|
|
} from "../shared/ui";
|
|
|
|
// ── Setup / Teardown ────────────────────────────────────────────────────
|
|
|
|
let stderrSpy: ReturnType<typeof spyOn>;
|
|
let stderrOutput: string[];
|
|
|
|
beforeEach(() => {
|
|
stderrOutput = [];
|
|
stderrSpy = spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
|
stderrOutput.push(String(chunk));
|
|
return true;
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
stderrSpy.mockRestore();
|
|
delete process.env.SPAWN_DEBUG;
|
|
delete process.env.SPAWN_NON_INTERACTIVE;
|
|
delete process.env.SPAWN_NAME;
|
|
delete process.env.SPAWN_NAME_KEBAB;
|
|
delete process.env.SPAWN_NAME_DISPLAY;
|
|
});
|
|
|
|
// ── Logging functions ──────────────────────────────────────────────
|
|
|
|
describe("logging functions", () => {
|
|
for (const [fn, msg] of [
|
|
[
|
|
logInfo,
|
|
"test info",
|
|
],
|
|
[
|
|
logWarn,
|
|
"test warn",
|
|
],
|
|
[
|
|
logError,
|
|
"test error",
|
|
],
|
|
[
|
|
logStep,
|
|
"test step",
|
|
],
|
|
] satisfies Array<
|
|
[
|
|
(msg: string) => void,
|
|
string,
|
|
]
|
|
>) {
|
|
it(`${fn.name} writes message to stderr`, () => {
|
|
fn(msg);
|
|
expect(stderrOutput.join("")).toContain(msg);
|
|
});
|
|
}
|
|
|
|
it("logStepInline writes message (newline-terminated in non-TTY)", () => {
|
|
logStepInline("inline msg");
|
|
const output = stderrOutput.join("");
|
|
expect(output).toContain("inline msg");
|
|
// In non-TTY (test environment), output ends with newline instead of \r overwrite
|
|
expect(output).toEndWith("\n");
|
|
});
|
|
|
|
it("logStepDone is no-op in non-TTY", () => {
|
|
logStepDone();
|
|
const output = stderrOutput.join("");
|
|
// In non-TTY (test environment), logStepDone writes nothing
|
|
expect(output).toBe("");
|
|
});
|
|
|
|
it("logDebug only outputs when SPAWN_DEBUG=1", () => {
|
|
logDebug("invisible");
|
|
expect(stderrOutput.join("")).toBe("");
|
|
process.env.SPAWN_DEBUG = "1";
|
|
logDebug("visible");
|
|
expect(stderrOutput.join("")).toContain("visible");
|
|
});
|
|
});
|
|
|
|
// ── prompt ──────────────────────────────────────────────────────────
|
|
|
|
describe("prompt", () => {
|
|
it("throws when SPAWN_NON_INTERACTIVE is set", async () => {
|
|
process.env.SPAWN_NON_INTERACTIVE = "1";
|
|
await expect(prompt("question")).rejects.toThrow("Cannot prompt");
|
|
});
|
|
|
|
it("returns trimmed text input from clack", async () => {
|
|
const result = await prompt("Enter value:");
|
|
expect(result).toBe("user-input");
|
|
});
|
|
});
|
|
|
|
// ── selectFromList ─────────────────────────────────────────────────
|
|
|
|
describe("selectFromList", () => {
|
|
it("returns default for empty items", async () => {
|
|
const result = await selectFromList([], "Pick one", "fallback");
|
|
expect(result).toBe("fallback");
|
|
});
|
|
|
|
it("returns the only item when single item provided", async () => {
|
|
const result = await selectFromList(
|
|
[
|
|
"only-one|Only One",
|
|
],
|
|
"Pick",
|
|
"",
|
|
);
|
|
expect(result).toBe("only-one");
|
|
});
|
|
|
|
it("parses pipe-separated items for selection", async () => {
|
|
const result = await selectFromList(
|
|
[
|
|
"a|Alpha",
|
|
"b|Beta",
|
|
],
|
|
"Pick",
|
|
"a",
|
|
);
|
|
expect(typeof result).toBe("string");
|
|
});
|
|
});
|
|
|
|
// ── openBrowser ────────────────────────────────────────────────────
|
|
|
|
describe("openBrowser", () => {
|
|
it("shows URL in stderr output on linux", () => {
|
|
const spawnSyncSpy = spyOn(Bun, "spawnSync").mockReturnValue({
|
|
exitCode: 1,
|
|
stdout: Buffer.from(""),
|
|
stderr: Buffer.from(""),
|
|
success: false,
|
|
signalCode: null,
|
|
resourceUsage: undefined,
|
|
pid: 0,
|
|
} satisfies ReturnType<typeof Bun.spawnSync>);
|
|
openBrowser("https://example.com");
|
|
spawnSyncSpy.mockRestore();
|
|
expect(stderrOutput.join("")).toContain("https://example.com");
|
|
});
|
|
|
|
it("shows different message when browser opens successfully", () => {
|
|
const spawnSyncSpy = spyOn(Bun, "spawnSync").mockReturnValue({
|
|
exitCode: 0,
|
|
stdout: Buffer.from(""),
|
|
stderr: Buffer.from(""),
|
|
success: true,
|
|
signalCode: null,
|
|
resourceUsage: undefined,
|
|
pid: 0,
|
|
} satisfies ReturnType<typeof Bun.spawnSync>);
|
|
openBrowser("https://example.com");
|
|
spawnSyncSpy.mockRestore();
|
|
expect(stderrOutput.join("")).toContain("https://example.com");
|
|
});
|
|
|
|
it("handles exception from Bun.spawnSync gracefully", () => {
|
|
const spawnSyncSpy = spyOn(Bun, "spawnSync").mockImplementation(() => {
|
|
throw new Error("no browser");
|
|
});
|
|
openBrowser("https://example.com");
|
|
spawnSyncSpy.mockRestore();
|
|
expect(stderrOutput.join("")).toContain("https://example.com");
|
|
});
|
|
});
|
|
|
|
// ── loadApiToken ───────────────────────────────────────────────────
|
|
|
|
describe("loadApiToken", () => {
|
|
it("returns token from api_key field", () => {
|
|
const configPath = join(process.env.HOME ?? "/tmp", ".config", "spawn");
|
|
mkdirSync(configPath, {
|
|
recursive: true,
|
|
});
|
|
writeFileSync(
|
|
join(configPath, "hetzner.json"),
|
|
JSON.stringify({
|
|
api_key: "test-hetzner-token",
|
|
}),
|
|
);
|
|
const token = loadApiToken("hetzner");
|
|
expect(token).toBe("test-hetzner-token");
|
|
});
|
|
|
|
it("returns token from token field when api_key is missing", () => {
|
|
const configPath = join(process.env.HOME ?? "/tmp", ".config", "spawn");
|
|
mkdirSync(configPath, {
|
|
recursive: true,
|
|
});
|
|
writeFileSync(
|
|
join(configPath, "digitalocean.json"),
|
|
JSON.stringify({
|
|
token: "do-tok",
|
|
}),
|
|
);
|
|
const token = loadApiToken("digitalocean");
|
|
expect(token).toBe("do-tok");
|
|
});
|
|
|
|
it("returns null when no config file exists", () => {
|
|
const token = loadApiToken("nonexistent");
|
|
expect(token).toBeNull();
|
|
});
|
|
|
|
it("returns null when config is malformed", () => {
|
|
const configPath = join(process.env.HOME ?? "/tmp", ".config", "spawn");
|
|
mkdirSync(configPath, {
|
|
recursive: true,
|
|
});
|
|
writeFileSync(join(configPath, "bad.json"), "not json");
|
|
const token = loadApiToken("bad");
|
|
expect(token).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── defaultSpawnName ───────────────────────────────────────────────
|
|
|
|
describe("defaultSpawnName", () => {
|
|
it("generates a name with spawn- prefix", () => {
|
|
const name = defaultSpawnName();
|
|
expect(name).toMatch(/^spawn-[a-z0-9]+$/);
|
|
});
|
|
});
|
|
|
|
// ── getServerNameFromEnv ───────────────────────────────────────────
|
|
|
|
describe("getServerNameFromEnv", () => {
|
|
it("returns cloud-specific env var when set", () => {
|
|
process.env.MY_CLOUD_NAME = "my-server";
|
|
const name = getServerNameFromEnv("MY_CLOUD_NAME");
|
|
delete process.env.MY_CLOUD_NAME;
|
|
expect(name).toBe("my-server");
|
|
});
|
|
|
|
it("falls back to SPAWN_NAME_KEBAB or default", () => {
|
|
delete process.env.NONEXISTENT_VAR;
|
|
process.env.SPAWN_NAME_KEBAB = "kebab-name";
|
|
const name = getServerNameFromEnv("NONEXISTENT_VAR");
|
|
delete process.env.SPAWN_NAME_KEBAB;
|
|
expect(name).toBe("kebab-name");
|
|
});
|
|
});
|
|
|
|
// ── promptSpawnNameShared ──────────────────────────────────────────
|
|
|
|
describe("promptSpawnNameShared", () => {
|
|
it("skips when SPAWN_NAME_KEBAB already set", async () => {
|
|
process.env.SPAWN_NAME_KEBAB = "already-set";
|
|
await promptSpawnNameShared("Test Cloud");
|
|
// Should return immediately without prompting
|
|
expect(process.env.SPAWN_NAME_KEBAB).toBe("already-set");
|
|
});
|
|
|
|
it("uses user input from prompt in interactive mode", async () => {
|
|
delete process.env.SPAWN_NAME;
|
|
delete process.env.SPAWN_NAME_KEBAB;
|
|
delete process.env.SPAWN_NAME_DISPLAY;
|
|
delete process.env.SPAWN_NON_INTERACTIVE;
|
|
await promptSpawnNameShared("Test Cloud");
|
|
// Should have set SPAWN_NAME_KEBAB via prompt
|
|
expect(process.env.SPAWN_NAME_KEBAB).toBeTruthy();
|
|
});
|
|
|
|
it("uses default name in non-interactive mode", async () => {
|
|
delete process.env.SPAWN_NAME;
|
|
delete process.env.SPAWN_NAME_KEBAB;
|
|
process.env.SPAWN_NON_INTERACTIVE = "1";
|
|
await promptSpawnNameShared("Test Cloud");
|
|
expect(process.env.SPAWN_NAME_KEBAB).toMatch(/^spawn-/);
|
|
});
|
|
});
|
|
|
|
// ── prepareStdinForHandoff ─────────────────────────────────────────
|
|
|
|
describe("prepareStdinForHandoff", () => {
|
|
it("does not throw", () => {
|
|
expect(() => prepareStdinForHandoff()).not.toThrow();
|
|
});
|
|
});
|