mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
* test: remove duplicate and theatrical tests
- update-check.test.ts: fix 3 tests using stale hardcoded version '0.2.3'
(older than current 0.29.1) to use `pkg.version` so 'should not update
when up to date' actually tests the current-version path correctly
- run-path-credential-display.test.ts: strengthen weak `toBeDefined()`
assertion on digitalocean hint to `toContain('Simple cloud hosting')`,
making it verify the actual fallback hint content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: replace theatrical no-assert tests with real assertions in recursive-spawn
Two tests in recursive-spawn.test.ts captured console.log output into a
logs array but never asserted against it. Both ended with a comment like
"should not throw" — meaning they only proved the function didn't crash,
not that it produced the right output.
- "shows empty message when no history": now spies on p.log.info and
asserts cmdTree() emits "No spawn history found."
- "shows flat message when no parent-child relationships": now asserts
cmdTree() emits "no parent-child relationships" via p.log.info.
expect() call count: 4831 to 4834 (+3 real assertions added).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: consolidate redundant describe block in cmd-fix-cov.test.ts
The file had two separate describe blocks with identical beforeEach/afterEach
boilerplate. The second block ("fixSpawn connection edge cases") contained only
one test ("shows success when fix script succeeds") and could be merged directly
into the first block ("fixSpawn (additional coverage)") without any loss of
coverage or setup fidelity.
Removes 23 lines of duplicated boilerplate. Test count unchanged (6 tests).
---------
Co-authored-by: spawn-qa-bot <qa@openrouter.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
import type { Manifest } from "../manifest";
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
|
import { mockClackPrompts } from "./test-helpers";
|
|
|
|
/**
|
|
* Tests for critical-path functions in the `spawn <agent> <cloud>` run flow:
|
|
*
|
|
* - prioritizeCloudsByCredentials: sorts clouds by credential availability,
|
|
* builds hint overrides, counts clouds with credentials
|
|
* (isRetryableExitCode is covered in cmd-run-cov.test.ts)
|
|
*/
|
|
|
|
// ── Test manifest ───────────────────────────────────────────────────────
|
|
|
|
function makeManifest(overrides?: Partial<Manifest>): Manifest {
|
|
const base: Manifest = {
|
|
agents: {
|
|
claude: {
|
|
name: "Claude Code",
|
|
description: "AI coding agent by Anthropic",
|
|
url: "https://claude.ai",
|
|
install: "curl -fsSL https://claude.ai/install.sh | bash",
|
|
launch: "claude",
|
|
env: {
|
|
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
|
ANTHROPIC_AUTH_TOKEN: "$OPENROUTER_API_KEY",
|
|
ANTHROPIC_API_KEY: "",
|
|
},
|
|
},
|
|
codex: {
|
|
name: "Codex",
|
|
description: "AI pair programming in your terminal",
|
|
url: "https://codex.dev",
|
|
install: "npm install -g codex",
|
|
launch: "codex",
|
|
env: {
|
|
OPENROUTER_API_KEY: "$OPENROUTER_API_KEY",
|
|
},
|
|
},
|
|
},
|
|
clouds: {
|
|
hetzner: {
|
|
name: "Hetzner Cloud",
|
|
description: "German cloud provider",
|
|
price: "test",
|
|
url: "https://hetzner.cloud",
|
|
type: "api",
|
|
auth: "HCLOUD_TOKEN",
|
|
provision_method: "api",
|
|
exec_method: "ssh root@IP",
|
|
interactive_method: "ssh -t root@IP",
|
|
},
|
|
sprite: {
|
|
name: "Sprite",
|
|
description: "Instant cloud dev environments",
|
|
price: "test",
|
|
url: "https://sprite.dev",
|
|
type: "cli",
|
|
auth: "sprite login",
|
|
provision_method: "cli",
|
|
exec_method: "sprite exec NAME",
|
|
interactive_method: "sprite exec NAME -tty",
|
|
},
|
|
digitalocean: {
|
|
name: "DigitalOcean",
|
|
description: "Simple cloud hosting",
|
|
price: "test",
|
|
url: "https://digitalocean.com",
|
|
type: "api",
|
|
auth: "DIGITALOCEAN_ACCESS_TOKEN",
|
|
provision_method: "api",
|
|
exec_method: "ssh root@IP",
|
|
interactive_method: "ssh -t root@IP",
|
|
},
|
|
upcloud: {
|
|
name: "UpCloud",
|
|
description: "European cloud provider",
|
|
price: "test",
|
|
url: "https://upcloud.com",
|
|
type: "api",
|
|
auth: "UPCLOUD_USERNAME + UPCLOUD_PASSWORD",
|
|
provision_method: "api",
|
|
exec_method: "ssh root@IP",
|
|
interactive_method: "ssh -t root@IP",
|
|
},
|
|
localcloud: {
|
|
name: "Local Machine",
|
|
description: "Run locally",
|
|
price: "test",
|
|
url: "",
|
|
type: "local",
|
|
auth: "none",
|
|
provision_method: "local",
|
|
exec_method: "bash -c",
|
|
interactive_method: "bash",
|
|
},
|
|
},
|
|
matrix: {
|
|
"hetzner/claude": "implemented",
|
|
"hetzner/codex": "implemented",
|
|
"sprite/claude": "implemented",
|
|
"sprite/codex": "missing",
|
|
"digitalocean/claude": "implemented",
|
|
"digitalocean/codex": "implemented",
|
|
"upcloud/claude": "implemented",
|
|
"upcloud/codex": "missing",
|
|
"localcloud/claude": "implemented",
|
|
"localcloud/codex": "implemented",
|
|
},
|
|
};
|
|
return overrides
|
|
? {
|
|
...base,
|
|
...overrides,
|
|
}
|
|
: base;
|
|
}
|
|
|
|
// ── Mock @clack/prompts ─────────────────────────────────────────────────
|
|
|
|
mockClackPrompts({
|
|
select: mock(() => Promise.resolve("hetzner")),
|
|
});
|
|
|
|
// Import after mocks are set up
|
|
const { prioritizeCloudsByCredentials } = await import("../commands/index.js");
|
|
|
|
// ── prioritizeCloudsByCredentials ────────────────────────────────────────
|
|
|
|
describe("prioritizeCloudsByCredentials", () => {
|
|
const savedEnv: Record<string, string | undefined> = {};
|
|
let whichSpy: ReturnType<typeof spyOn>;
|
|
|
|
beforeEach(() => {
|
|
// Mock Bun.which to prevent CLI detection from interfering with credential tests
|
|
whichSpy = spyOn(Bun, "which").mockReturnValue(null);
|
|
// Save and clear credential env vars
|
|
for (const v of [
|
|
"HCLOUD_TOKEN",
|
|
"DIGITALOCEAN_ACCESS_TOKEN",
|
|
"DIGITALOCEAN_API_TOKEN",
|
|
"DO_API_TOKEN",
|
|
"UPCLOUD_USERNAME",
|
|
"UPCLOUD_PASSWORD",
|
|
]) {
|
|
savedEnv[v] = process.env[v];
|
|
delete process.env[v];
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
whichSpy.mockRestore();
|
|
// Restore env vars
|
|
for (const [k, v] of Object.entries(savedEnv)) {
|
|
if (v === undefined) {
|
|
delete process.env[k];
|
|
} else {
|
|
process.env[k] = v;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("should return all clouds when none have credentials", () => {
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"hetzner",
|
|
"digitalocean",
|
|
"upcloud",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
expect(result.sortedClouds).toEqual(clouds);
|
|
expect(result.credCount).toBe(0);
|
|
expect(Object.keys(result.hintOverrides).length).toBeGreaterThanOrEqual(clouds.length);
|
|
});
|
|
|
|
it("should move clouds with credentials to front", () => {
|
|
process.env.HCLOUD_TOKEN = "test-token";
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"digitalocean",
|
|
"hetzner",
|
|
"upcloud",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
expect(result.sortedClouds[0]).toBe("hetzner");
|
|
expect(result.credCount).toBe(1);
|
|
expect(result.sortedClouds).toContain("digitalocean");
|
|
expect(result.sortedClouds).toContain("upcloud");
|
|
});
|
|
|
|
it("should move multiple credential clouds to front", () => {
|
|
process.env.HCLOUD_TOKEN = "test-token";
|
|
process.env.DIGITALOCEAN_ACCESS_TOKEN = "test-do-token";
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"upcloud",
|
|
"digitalocean",
|
|
"hetzner",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
// Both hetzner and digitalocean should be first, upcloud last
|
|
expect(result.credCount).toBe(2);
|
|
expect(result.sortedClouds.indexOf("hetzner")).toBeLessThan(result.sortedClouds.indexOf("upcloud"));
|
|
expect(result.sortedClouds.indexOf("digitalocean")).toBeLessThan(result.sortedClouds.indexOf("upcloud"));
|
|
});
|
|
|
|
it("should build hint overrides for clouds with credentials", () => {
|
|
process.env.HCLOUD_TOKEN = "test-token";
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"hetzner",
|
|
"digitalocean",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
expect(result.hintOverrides["hetzner"]).toContain("credentials detected");
|
|
expect(result.hintOverrides["hetzner"]).toContain("test");
|
|
expect(result.hintOverrides["digitalocean"]).toContain("Simple cloud hosting");
|
|
});
|
|
|
|
it("should handle multi-var auth (both vars must be set)", () => {
|
|
process.env.UPCLOUD_USERNAME = "user";
|
|
// Missing UPCLOUD_PASSWORD
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"upcloud",
|
|
"hetzner",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
// upcloud should NOT be prioritized (missing one of two vars)
|
|
expect(result.credCount).toBe(0);
|
|
});
|
|
|
|
it("should handle multi-var auth when all vars set", () => {
|
|
process.env.UPCLOUD_USERNAME = "user";
|
|
process.env.UPCLOUD_PASSWORD = "pass";
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"hetzner",
|
|
"upcloud",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
expect(result.credCount).toBe(1);
|
|
expect(result.sortedClouds[0]).toBe("upcloud");
|
|
});
|
|
|
|
it("should handle empty cloud list", () => {
|
|
const manifest = makeManifest();
|
|
const result = prioritizeCloudsByCredentials([], manifest);
|
|
|
|
expect(result.sortedClouds).toEqual([]);
|
|
expect(result.credCount).toBe(0);
|
|
expect(Object.keys(result.hintOverrides)).toHaveLength(0);
|
|
});
|
|
|
|
it("should handle single cloud with credentials", () => {
|
|
process.env.HCLOUD_TOKEN = "token";
|
|
const manifest = makeManifest();
|
|
const result = prioritizeCloudsByCredentials(
|
|
[
|
|
"hetzner",
|
|
],
|
|
manifest,
|
|
);
|
|
|
|
expect(result.sortedClouds).toEqual([
|
|
"hetzner",
|
|
]);
|
|
expect(result.credCount).toBe(1);
|
|
});
|
|
|
|
it("should handle single cloud without credentials", () => {
|
|
const manifest = makeManifest();
|
|
const result = prioritizeCloudsByCredentials(
|
|
[
|
|
"hetzner",
|
|
],
|
|
manifest,
|
|
);
|
|
|
|
expect(result.sortedClouds).toEqual([
|
|
"hetzner",
|
|
]);
|
|
expect(result.credCount).toBe(0);
|
|
});
|
|
|
|
it("should preserve relative order within each group", () => {
|
|
process.env.HCLOUD_TOKEN = "token";
|
|
process.env.DIGITALOCEAN_ACCESS_TOKEN = "token";
|
|
const manifest = makeManifest();
|
|
// Input order: digitalocean before hetzner (both have creds)
|
|
const clouds = [
|
|
"digitalocean",
|
|
"hetzner",
|
|
"upcloud",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
// Both credential clouds should come first in their original relative order
|
|
expect(result.sortedClouds[0]).toBe("digitalocean");
|
|
expect(result.sortedClouds[1]).toBe("hetzner");
|
|
expect(result.sortedClouds[2]).toBe("upcloud");
|
|
});
|
|
|
|
it("should handle CLI-based auth (sprite login) as no credentials", () => {
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"sprite",
|
|
"hetzner",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
// "sprite login" is not an env var, so sprite should not be prioritized
|
|
expect(result.credCount).toBe(0);
|
|
});
|
|
|
|
it("should handle 'none' auth (local cloud) as no credentials", () => {
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"localcloud",
|
|
"hetzner",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
expect(result.credCount).toBe(0);
|
|
});
|
|
|
|
it("should count all credential clouds correctly with all set", () => {
|
|
process.env.HCLOUD_TOKEN = "t1";
|
|
process.env.DIGITALOCEAN_ACCESS_TOKEN = "t2";
|
|
process.env.UPCLOUD_USERNAME = "u";
|
|
process.env.UPCLOUD_PASSWORD = "p";
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"hetzner",
|
|
"digitalocean",
|
|
"upcloud",
|
|
"sprite",
|
|
"localcloud",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
expect(result.credCount).toBe(3); // hetzner, digitalocean, upcloud
|
|
expect(result.sortedClouds).toHaveLength(5);
|
|
// sprite and localcloud should be at the end
|
|
expect(result.sortedClouds.slice(3)).toContain("sprite");
|
|
expect(result.sortedClouds.slice(3)).toContain("localcloud");
|
|
});
|
|
|
|
it("should recognize legacy DO_API_TOKEN as alias for DIGITALOCEAN_ACCESS_TOKEN", () => {
|
|
process.env.DO_API_TOKEN = "legacy-token";
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"digitalocean",
|
|
"hetzner",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
expect(result.credCount).toBe(1);
|
|
expect(result.sortedClouds[0]).toBe("digitalocean");
|
|
});
|
|
|
|
it("should recognize DIGITALOCEAN_API_TOKEN as alias for DIGITALOCEAN_ACCESS_TOKEN", () => {
|
|
process.env.DIGITALOCEAN_API_TOKEN = "alt-token";
|
|
const manifest = makeManifest();
|
|
const clouds = [
|
|
"digitalocean",
|
|
"hetzner",
|
|
];
|
|
const result = prioritizeCloudsByCredentials(clouds, manifest);
|
|
|
|
expect(result.credCount).toBe(1);
|
|
expect(result.sortedClouds[0]).toBe("digitalocean");
|
|
});
|
|
});
|