Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions packages/cli/src/__tests__/manifest-cache-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { TestEnvironment } from "./test-helpers";
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { agentKeys, countImplemented, loadManifest } from "../manifest";
import { _resetCacheForTesting, agentKeys, countImplemented, loadManifest } from "../manifest";
import { createMockManifest, setupTestEnvironment, teardownTestEnvironment } from "./test-helpers";

/**
Expand Down Expand Up @@ -239,6 +239,7 @@ describe("Manifest Cache Lifecycle", () => {

beforeEach(() => {
env = setupTestEnvironment();
_resetCacheForTesting();
});

afterEach(() => {
Expand All @@ -255,22 +256,14 @@ describe("Manifest Cache Lifecycle", () => {
// fetch should have been called at least twice (once per forceRefresh)
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2);
});

it("should return same instance without forceRefresh", async () => {
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(mockManifest))));

const manifest1 = await loadManifest(true);
const manifest2 = await loadManifest(false);

expect(manifest1).toBe(manifest2);
});
});

describe("combined fallback chain: invalid fetch + stale cache", () => {
let env: TestEnvironment;

beforeEach(() => {
env = setupTestEnvironment();
_resetCacheForTesting();
});

afterEach(() => {
Expand Down
121 changes: 39 additions & 82 deletions packages/cli/src/__tests__/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,15 +171,6 @@ describe("manifest", () => {
expect(global.fetch).toHaveBeenCalled();
});

it("returns in-memory cache on second call without fetching", async () => {
const fetchMock = mock(async () => new Response(JSON.stringify(mockManifest)));
global.fetch = fetchMock;
await loadManifest();
const fetchCount = fetchMock.mock.calls.length;
await loadManifest();
expect(fetchMock.mock.calls.length).toBe(fetchCount);
});

it("falls back to stale cache when fetch fails", async () => {
const cacheDir = join(env.testDir, "spawn");
mkdirSync(cacheDir, {
Expand Down Expand Up @@ -217,52 +208,33 @@ describe("manifest", () => {
await expect(loadManifest(true)).rejects.toThrow("Cannot load manifest");
});

it("throws when manifest from GitHub is invalid", async () => {
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
global.fetch = mock(
async () =>
const invalidManifestCases: Array<{
label: string;
fetchImpl: () => Promise<Response>;
}> = [
{
label: "non-manifest shape",
fetchImpl: async () =>
new Response(
JSON.stringify({
not: "a manifest",
}),
),
);

const cacheFile = join(env.testDir, "spawn", "manifest.json");
if (existsSync(cacheFile)) {
rmSync(cacheFile);
}

await expect(loadManifest(true)).rejects.toThrow("Cannot load manifest");
consoleSpy.mockRestore();
});

it("rejects manifest with string agents field", async () => {
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
global.fetch = mock(
async () =>
},
{
label: "string agents field",
fetchImpl: async () =>
new Response(
JSON.stringify({
agents: "claude",
clouds: {},
matrix: {},
}),
),
);

const cacheFile = join(env.testDir, "spawn", "manifest.json");
if (existsSync(cacheFile)) {
rmSync(cacheFile);
}

await expect(loadManifest(true)).rejects.toThrow("Cannot load manifest");
consoleSpy.mockRestore();
});

it("rejects manifest with array clouds field", async () => {
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
global.fetch = mock(
async () =>
},
{
label: "array clouds field",
fetchImpl: async () =>
new Response(
JSON.stringify({
agents: {},
Expand All @@ -273,53 +245,38 @@ describe("manifest", () => {
matrix: {},
}),
),
);

const cacheFile = join(env.testDir, "spawn", "manifest.json");
if (existsSync(cacheFile)) {
rmSync(cacheFile);
}

await expect(loadManifest(true)).rejects.toThrow("Cannot load manifest");
consoleSpy.mockRestore();
});

it("rejects manifest with numeric matrix field", async () => {
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
global.fetch = mock(
async () =>
},
{
label: "numeric matrix field",
fetchImpl: async () =>
new Response(
JSON.stringify({
agents: {},
clouds: {},
matrix: 42,
}),
),
);

const cacheFile = join(env.testDir, "spawn", "manifest.json");
if (existsSync(cacheFile)) {
rmSync(cacheFile);
}

await expect(loadManifest(true)).rejects.toThrow("Cannot load manifest");
consoleSpy.mockRestore();
});

it("throws when network errors occur and no cache exists", async () => {
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
global.fetch = mock(async () => {
throw new Error("Network timeout");
},
{
label: "network error",
fetchImpl: async () => {
throw new Error("Network timeout");
},
},
];

for (const { label, fetchImpl } of invalidManifestCases) {
it(`rejects invalid manifest (${label})`, async () => {
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
global.fetch = mock(fetchImpl);
const cacheFile = join(env.testDir, "spawn", "manifest.json");
if (existsSync(cacheFile)) {
rmSync(cacheFile);
}
await expect(loadManifest(true)).rejects.toThrow("Cannot load manifest");
consoleSpy.mockRestore();
});

const cacheFile = join(env.testDir, "spawn", "manifest.json");
if (existsSync(cacheFile)) {
rmSync(cacheFile);
}

await expect(loadManifest(true)).rejects.toThrow("Cannot load manifest");
consoleSpy.mockRestore();
});
}
});
});

Expand Down
47 changes: 28 additions & 19 deletions packages/cli/src/__tests__/ui-cov.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,25 +62,34 @@ afterEach(() => {
// ── Logging functions ──────────────────────────────────────────────

describe("logging functions", () => {
it("logInfo writes green text to stderr", () => {
logInfo("test info");
expect(stderrOutput.join("")).toContain("test info");
});

it("logWarn writes yellow text to stderr", () => {
logWarn("test warn");
expect(stderrOutput.join("")).toContain("test warn");
});

it("logError writes red text to stderr", () => {
logError("test error");
expect(stderrOutput.join("")).toContain("test error");
});

it("logStep writes cyan text to stderr", () => {
logStep("test step");
expect(stderrOutput.join("")).toContain("test step");
});
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");
Expand Down
Loading