diff options
| author | Adam Malczewski <[email protected]> | 2026-06-03 08:24:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-03 08:24:40 +0900 |
| commit | bc3ecbe7b72f6da6ed36d0cea5a66de1c440269a (patch) | |
| tree | 17e84ebf8d83c51a7a50312c256372a86e38b92a /packages/core/tests | |
| parent | b26821ead97b986f886065b20d3dbde8283daa64 (diff) | |
| parent | ae672fd4f5542a2c217cf97657bf81eeebdaabbd (diff) | |
| download | dispatch-bc3ecbe7b72f6da6ed36d0cea5a66de1c440269a.tar.gz dispatch-bc3ecbe7b72f6da6ed36d0cea5a66de1c440269a.zip | |
Merge branch 'dev' into cmp7/compaction-tool
# Conflicts:
# packages/frontend/src/lib/components/ChatInput.svelte
Diffstat (limited to 'packages/core/tests')
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 98 | ||||
| -rw-r--r-- | packages/core/tests/models/attachments.test.ts | 136 | ||||
| -rw-r--r-- | packages/core/tests/models/catalog.test.ts | 75 | ||||
| -rw-r--r-- | packages/core/tests/tools/key-usage.test.ts | 317 |
4 files changed, 623 insertions, 3 deletions
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts index d8edec7..f4b33cc 100644 --- a/packages/core/tests/agent/agent.test.ts +++ b/packages/core/tests/agent/agent.test.ts @@ -1544,4 +1544,102 @@ describe("anthropicThinkingProviderOptions — adaptive-thinking model detection effort: "xhigh", }); }); + + describe("multimodal user content", () => { + it("emits ordered text + image parts to the model when content is provided", async () => { + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]), + ); + + const agent = new Agent(makeConfig()); + for await (const _ of agent.run("here is image A: [image]", { + content: [ + { type: "text", text: "here is image A: " }, + { type: "attachment", mediaType: "image/png", data: "QQ==" }, + ], + })) { + // consume + } + + const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; + const messages = callArgs?.messages as Array<{ role: string; content: unknown }>; + const userMsg = messages.find((m) => m.role === "user"); + expect(userMsg).toBeDefined(); + // Multimodal turn → content is an ordered parts array, not a string. + expect(Array.isArray(userMsg?.content)).toBe(true); + const parts = userMsg?.content as Array<Record<string, unknown>>; + expect(parts[0]).toMatchObject({ type: "text", text: "here is image A: " }); + expect(parts[1]).toMatchObject({ type: "image", mediaType: "image/png" }); + expect(String(parts[1]?.image)).toBe("data:image/png;base64,QQ=="); + }); + + it("emits a FilePart for a PDF attachment with its filename", async () => { + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]), + ); + + const agent = new Agent(makeConfig()); + for await (const _ of agent.run("see [pdf]", { + content: [ + { type: "text", text: "see " }, + { type: "attachment", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" }, + ], + })) { + // consume + } + + const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; + const messages = callArgs?.messages as Array<{ role: string; content: unknown }>; + const userMsg = messages.find((m) => m.role === "user"); + const parts = userMsg?.content as Array<Record<string, unknown>>; + const filePart = parts.find((p) => p.type === "file"); + expect(filePart).toMatchObject({ + type: "file", + mediaType: "application/pdf", + filename: "doc.pdf", + }); + expect(String(filePart?.data)).toBe("data:application/pdf;base64,QQ=="); + }); + + it("persists the user turn as text only (no content) for history", async () => { + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]), + ); + + const agent = new Agent(makeConfig()); + for await (const _ of agent.run("look: [image]", { + content: [ + { type: "text", text: "look: " }, + { type: "attachment", mediaType: "image/png", data: "QQ==" }, + ], + })) { + // consume + } + + // The in-memory user message keeps the text chunk for the render/persist + // path; the ephemeral `content` rides alongside it but isn't a chunk. + const userMsg = agent.messages.find((m) => m.role === "user"); + expect(userMsg?.chunks).toEqual([{ type: "text", text: "look: [image]" }]); + }); + + it("falls back to a plain string when content has no attachment", async () => { + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]), + ); + + const agent = new Agent(makeConfig()); + for await (const _ of agent.run("plain text", { + content: [{ type: "text", text: "plain text" }], + })) { + // consume + } + + const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; + const messages = callArgs?.messages as Array<{ role: string; content: unknown }>; + const userMsg = messages.find((m) => m.role === "user"); + // No attachment → plain string content (byte-identical to text-only path). + expect(typeof userMsg?.content).toBe("string"); + expect(userMsg?.content).toBe("plain text"); + }); + }); }); diff --git a/packages/core/tests/models/attachments.test.ts b/packages/core/tests/models/attachments.test.ts new file mode 100644 index 0000000..11a9f82 --- /dev/null +++ b/packages/core/tests/models/attachments.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + base64ByteLength, + isAcceptedAttachmentMediaType, + isImageMediaType, + isPdfMediaType, + MAX_ATTACHMENTS, + MAX_IMAGE_BYTES, + MAX_PDF_BYTES, + MAX_TOTAL_ATTACHMENT_BYTES, + validateUserContent, +} from "../../src/models/attachments.js"; +import type { UserContentPart } from "../../src/types/index.js"; + +/** A base64 string that decodes to exactly `bytes` bytes (no padding chars). */ +function base64OfBytes(bytes: number): string { + // 4 base64 chars → 3 bytes. Use a multiple of 3 for clean (unpadded) output. + const groups = Math.ceil(bytes / 3); + return "A".repeat(groups * 4); +} + +function imagePart(data: string, mediaType = "image/png"): UserContentPart { + return { type: "attachment", mediaType, data }; +} + +describe("media-type predicates", () => { + it("classifies image types", () => { + expect(isImageMediaType("image/png")).toBe(true); + expect(isImageMediaType("image/jpeg")).toBe(true); + expect(isImageMediaType("image/webp")).toBe(true); + expect(isImageMediaType("image/gif")).toBe(true); + expect(isImageMediaType("application/pdf")).toBe(false); + expect(isImageMediaType("image/svg+xml")).toBe(false); + }); + + it("classifies pdf + accepted types", () => { + expect(isPdfMediaType("application/pdf")).toBe(true); + expect(isPdfMediaType("image/png")).toBe(false); + expect(isAcceptedAttachmentMediaType("image/gif")).toBe(true); + expect(isAcceptedAttachmentMediaType("application/pdf")).toBe(true); + expect(isAcceptedAttachmentMediaType("text/plain")).toBe(false); + }); +}); + +describe("base64ByteLength", () => { + it("computes decoded length without padding", () => { + // "AAAA" → 3 bytes. + expect(base64ByteLength("AAAA")).toBe(3); + }); + + it("accounts for padding", () => { + // "QQ==" → 1 byte ("A"). + expect(base64ByteLength("QQ==")).toBe(1); + // "QUI=" → 2 bytes ("AB"). + expect(base64ByteLength("QUI=")).toBe(2); + }); + + it("tolerates a data: URI prefix and whitespace", () => { + expect(base64ByteLength("data:image/png;base64,AAAA")).toBe(3); + expect(base64ByteLength("AA\nAA")).toBe(3); + }); + + it("returns 0 for empty input", () => { + expect(base64ByteLength("")).toBe(0); + expect(base64ByteLength(" ")).toBe(0); + }); +}); + +describe("validateUserContent", () => { + it("accepts a small image and ignores text parts", () => { + const content: UserContentPart[] = [ + { type: "text", text: "hi" }, + imagePart(base64OfBytes(1024)), + ]; + expect(validateUserContent(content)).toEqual({ ok: true, errors: [] }); + }); + + it("accepts an empty / text-only content list", () => { + expect(validateUserContent([]).ok).toBe(true); + expect(validateUserContent([{ type: "text", text: "no files" }]).ok).toBe(true); + }); + + it("rejects an unsupported media type", () => { + const res = validateUserContent([imagePart(base64OfBytes(10), "image/svg+xml")]); + expect(res.ok).toBe(false); + expect(res.errors[0]).toMatchObject({ code: "unsupported-type", mediaType: "image/svg+xml" }); + }); + + it("rejects an oversized image but allows a PDF of the same size", () => { + const big = base64OfBytes(MAX_IMAGE_BYTES + 3); + const imgRes = validateUserContent([imagePart(big, "image/png")]); + expect(imgRes.ok).toBe(false); + expect(imgRes.errors.some((e) => e.code === "image-too-large")).toBe(true); + + // Same byte size as a PDF is fine (PDF limit is much higher). + const pdfRes = validateUserContent([imagePart(big, "application/pdf")]); + expect(pdfRes.ok).toBe(true); + }); + + it("rejects an oversized PDF", () => { + const res = validateUserContent([ + imagePart(base64OfBytes(MAX_PDF_BYTES + 3), "application/pdf"), + ]); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.code === "pdf-too-large")).toBe(true); + }); + + it("rejects an empty attachment payload", () => { + const res = validateUserContent([imagePart("", "image/png")]); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.code === "empty")).toBe(true); + }); + + it("rejects too many attachments", () => { + const content: UserContentPart[] = Array.from({ length: MAX_ATTACHMENTS + 1 }, () => + imagePart(base64OfBytes(8)), + ); + const res = validateUserContent(content); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.code === "too-many")).toBe(true); + }); + + it("rejects when the total payload exceeds the request ceiling", () => { + // Several individually-legal PDFs that together exceed the total cap. + const each = Math.floor(MAX_TOTAL_ATTACHMENT_BYTES / 3); + const content: UserContentPart[] = [ + imagePart(base64OfBytes(each), "application/pdf"), + imagePart(base64OfBytes(each), "application/pdf"), + imagePart(base64OfBytes(each), "application/pdf"), + imagePart(base64OfBytes(each), "application/pdf"), + ]; + const res = validateUserContent(content); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.code === "total-too-large")).toBe(true); + }); +}); diff --git a/packages/core/tests/models/catalog.test.ts b/packages/core/tests/models/catalog.test.ts index 51043e6..f4bddc2 100644 --- a/packages/core/tests/models/catalog.test.ts +++ b/packages/core/tests/models/catalog.test.ts @@ -4,6 +4,7 @@ import { __resetCatalogCacheForTests, getModelsCatalog, resolveContextLimit, + resolveModelCapabilities, } from "../../src/models/catalog.js"; const CACHE_PATH = "/tmp/dispatch/models-dev.json"; @@ -13,14 +14,30 @@ const CATALOG = { anthropic: { id: "anthropic", models: { - "claude-sonnet-4-5": { limit: { context: 200000, output: 64000 } }, - "claude-sonnet-4-6": { limit: { context: 1000000, output: 64000 } }, + "claude-sonnet-4-5": { + limit: { context: 200000, output: 64000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + }, + "claude-sonnet-4-6": { + limit: { context: 1000000, output: 64000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + }, + // A text-only model: definitively no image/pdf input. + "text-only-model": { + limit: { context: 100000, output: 8192 }, + modalities: { input: ["text"], output: ["text"] }, + }, + // An entry predating the modalities field → capability unknown. + "legacy-model": { limit: { context: 100000, output: 8192 } }, }, }, opencode: { id: "opencode", models: { - "glm-4-6": { limit: { context: 131072, output: 8192 } }, + "glm-4-6": { + limit: { context: 131072, output: 8192 }, + modalities: { input: ["text", "image"], output: ["text"] }, + }, }, }, }; @@ -156,3 +173,55 @@ describe("getModelsCatalog caching", () => { warn.mockRestore(); }); }); + +describe("resolveModelCapabilities", () => { + it("reports image + pdf for a vision model", async () => { + mockFetchOnce(CATALOG); + expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toEqual({ + image: true, + pdf: true, + }); + }); + + it("reports image-only for a model whose modalities omit pdf", async () => { + mockFetchOnce(CATALOG); + // glm-4-6 lists image but not pdf (resolved via the opencode fallback). + expect(await resolveModelCapabilities("opencode-anthropic", "glm-4-6")).toEqual({ + image: true, + pdf: false, + }); + }); + + it("reports a definitive no for a text-only model", async () => { + mockFetchOnce(CATALOG); + expect(await resolveModelCapabilities("anthropic", "text-only-model")).toEqual({ + image: false, + pdf: false, + }); + }); + + it("returns null (unknown) for an entry without modalities", async () => { + mockFetchOnce(CATALOG); + expect(await resolveModelCapabilities("anthropic", "legacy-model")).toBeNull(); + }); + + it("returns null (unknown) for an unknown model id", async () => { + mockFetchOnce(CATALOG); + expect(await resolveModelCapabilities("anthropic", "no-such-model")).toBeNull(); + }); + + it("returns null for an unsupported provider without hitting the network", async () => { + const fetchFn = mockFetchOnce(CATALOG); + expect(await resolveModelCapabilities("google", "gemini-2.5-pro")).toBeNull(); + expect(await resolveModelCapabilities("anthropic", "")).toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("returns null (unknown) when the catalog is offline with no cache", async () => { + const fetchFn = vi.fn(() => Promise.reject(new Error("offline"))); + vi.stubGlobal("fetch", fetchFn); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toBeNull(); + warn.mockRestore(); + }); +}); diff --git a/packages/core/tests/tools/key-usage.test.ts b/packages/core/tests/tools/key-usage.test.ts new file mode 100644 index 0000000..643e30e --- /dev/null +++ b/packages/core/tests/tools/key-usage.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it, vi } from "vitest"; + +// The tool imports `getAccountUsageWithSource` from `claude.ts`, which +// transitively imports `db/index.js` (top-level `import { Database } from +// "bun:sqlite"`) — unresolvable under vitest's Node runtime. These tests inject +// stub fetchers and never hit the real fetchers/DB, so stubbing the db module +// is enough to let the import chain resolve. +vi.mock("../../src/db/index.js", () => ({ + getDatabase: vi.fn(() => { + throw new Error("db not available in this test"); + }), +})); + +import type { ClaudeAccount, ClaudeUsageResult } from "../../src/credentials/claude.js"; +import type { OpencodeUsageReport } from "../../src/credentials/opencode.js"; +import { + createKeyUsageTool, + formatKeyUsage, + type KeyUsageCallbacks, +} from "../../src/tools/key-usage.js"; +import type { KeyDefinition, KeyState } from "../../src/types/index.js"; + +// ─── Builders ───────────────────────────────────────────────── + +function keyState( + def: Partial<KeyDefinition> & { id: string; provider: string }, + overrides: Partial<Omit<KeyState, "definition">> = {}, +): KeyState { + return { + definition: { base_url: "https://example.test", ...def }, + status: "active", + ...overrides, + }; +} + +function account(id: string, source = `/creds/${id}.json`): ClaudeAccount { + return { + id, + label: id, + source, + credentials: { accessToken: "tok", refreshToken: "ref", expiresAt: Date.now() + 3_600_000 }, + }; +} + +/** Build the tool with explicit stub fetchers — no network, no DB. */ +function buildTool(opts: { + keys: KeyState[]; + accounts?: ClaudeAccount[]; + anthropic?: (a: ClaudeAccount) => Promise<ClaudeUsageResult | null>; + opencode?: (keyId: string) => Promise<OpencodeUsageReport | null>; +}) { + const callbacks: KeyUsageCallbacks = { + listKeys: () => opts.keys, + listClaudeAccounts: () => opts.accounts ?? [], + fetchAnthropicUsage: opts.anthropic ?? (async () => null), + fetchOpencodeUsage: opts.opencode ?? (async () => null), + }; + return createKeyUsageTool(callbacks); +} + +const HOUR = 3_600_000; + +describe("key_usage tool", () => { + it("reports all keys when no key_id is given", async () => { + const reset5h = Date.now() + 2 * HOUR; + const tool = buildTool({ + keys: [ + keyState({ id: "claude-max", provider: "anthropic", credentials_file: "/creds/max.json" }), + keyState({ id: "opencode-1", provider: "opencode-go" }), + ], + accounts: [account("claude-max", "/creds/max.json")], + anthropic: async () => ({ + source: "live", + report: { + fiveHour: { utilization: 0.25, resetsAt: reset5h }, + sevenDay: { utilization: 0.6 }, + }, + }), + opencode: async () => ({ + fiveHour: { utilization: 0.1 }, + weekly: { utilization: 0.4 }, + monthly: { utilization: 0.7 }, + }), + }); + + const out = await tool.execute({}); + + // Both keys present with providers. + expect(out).toContain("[claude-max] provider: anthropic"); + expect(out).toContain("[opencode-1] provider: opencode-go"); + // Remaining = (1 - utilization) * 100. + expect(out).toContain("5-hour: 75% remaining"); + expect(out).toContain("week: 40% remaining"); + expect(out).toContain("5-hour: 90% remaining"); + expect(out).toContain("week: 60% remaining"); + expect(out).toContain("month: 30% remaining"); + expect(out).toContain("data: live (fetched just now)"); + }); + + it("filters to a single key when key_id is given and does not fetch others", async () => { + const opencodeFetch = vi.fn(async () => ({ fiveHour: { utilization: 0.5 } })); + const tool = buildTool({ + keys: [ + keyState({ id: "claude-max", provider: "anthropic" }), + keyState({ id: "opencode-1", provider: "opencode-go" }), + ], + accounts: [account("claude-max")], + anthropic: async () => ({ + source: "live", + report: { fiveHour: { utilization: 0.2 } }, + }), + opencode: opencodeFetch, + }); + + const out = await tool.execute({ key_id: "claude-max" }); + + expect(out).toContain("[claude-max] provider: anthropic"); + expect(out).not.toContain("opencode-1"); + expect(opencodeFetch).not.toHaveBeenCalled(); + }); + + it("returns a helpful error for an unknown key_id", async () => { + const tool = buildTool({ + keys: [ + keyState({ id: "claude-max", provider: "anthropic" }), + keyState({ id: "opencode-1", provider: "opencode-go" }), + ], + }); + + const out = await tool.execute({ key_id: "nope" }); + + expect(out).toContain('no key found with id "nope"'); + expect(out).toContain("claude-max"); + expect(out).toContain("opencode-1"); + }); + + it("reports cached data with the source's last-fetched timestamp", async () => { + const cachedAt = Date.UTC(2025, 0, 2, 3, 4, 5); + const tool = buildTool({ + keys: [keyState({ id: "claude-max", provider: "anthropic" })], + accounts: [account("claude-max")], + anthropic: async () => ({ + source: "cache", + cachedAt, + report: { fiveHour: { utilization: 0.5 } }, + }), + }); + + const out = await tool.execute({}); + + expect(out).toContain("data: cached — last fetched from source 2025-01-02T03:04:05.000Z"); + expect(out).toContain("5-hour: 50% remaining"); + }); + + it("omits the month window for anthropic (no monthly bucket)", async () => { + const tool = buildTool({ + keys: [keyState({ id: "claude-max", provider: "anthropic" })], + accounts: [account("claude-max")], + anthropic: async () => ({ + source: "live", + report: { fiveHour: { utilization: 0.1 }, sevenDay: { utilization: 0.2 } }, + }), + }); + + const out = await tool.execute({}); + + expect(out).toContain("5-hour:"); + expect(out).toContain("week:"); + expect(out).not.toContain("month:"); + }); + + it("includes the month window for opencode-go", async () => { + const tool = buildTool({ + keys: [keyState({ id: "opencode-1", provider: "opencode-go" })], + opencode: async () => ({ + fiveHour: { utilization: 0.1 }, + weekly: { utilization: 0.2 }, + monthly: { utilization: 0.3 }, + }), + }); + + const out = await tool.execute({}); + + expect(out).toContain("month: 70% remaining"); + }); + + it("surfaces exhausted status with the last error", async () => { + const exhaustedAt = Date.now() - HOUR; + const tool = buildTool({ + keys: [ + keyState( + { id: "opencode-1", provider: "opencode-go" }, + { status: "exhausted", lastError: "429 rate limit exceeded", exhaustedAt }, + ), + ], + opencode: async () => null, + }); + + const out = await tool.execute({}); + + expect(out).toContain("status: EXHAUSTED"); + expect(out).toContain("last error: 429 rate limit exceeded"); + }); + + it("flags providers without usage support", async () => { + const tool = buildTool({ + keys: [keyState({ id: "gem", provider: "google" })], + }); + + const out = await tool.execute({}); + + expect(out).toContain("[gem] provider: google"); + expect(out).toContain("not supported"); + }); + + it("reports unavailable when a supported provider returns no usage", async () => { + const tool = buildTool({ + keys: [keyState({ id: "claude-max", provider: "anthropic" })], + accounts: [account("claude-max")], + anthropic: async () => null, + }); + + const out = await tool.execute({}); + + expect(out).toContain("usage: unavailable"); + expect(out).toContain("no cached usage"); + }); + + it("reports unavailable for anthropic keys with no account credentials", async () => { + const tool = buildTool({ + keys: [keyState({ id: "claude-max", provider: "anthropic" })], + accounts: [], + }); + + const out = await tool.execute({}); + + expect(out).toContain("no Claude account credentials available"); + }); + + it("treats a fetcher that throws as unavailable (does not crash)", async () => { + const tool = buildTool({ + keys: [keyState({ id: "opencode-1", provider: "opencode-go" })], + opencode: async () => { + throw new Error("network down"); + }, + }); + + const out = await tool.execute({}); + + expect(out).toContain("usage: unavailable"); + }); + + it("reports when no keys are configured at all", async () => { + const tool = buildTool({ keys: [] }); + const out = await tool.execute({}); + expect(out).toBe("No API keys are configured."); + }); + + it("clamps out-of-range utilization to 0–100%", async () => { + const tool = buildTool({ + keys: [keyState({ id: "opencode-1", provider: "opencode-go" })], + opencode: async () => ({ + fiveHour: { utilization: 1.2 }, // over 100% used → 0% remaining + weekly: { utilization: -0.5 }, // negative → 100% remaining + }), + }); + + const out = await tool.execute({}); + + expect(out).toContain("5-hour: 0% remaining"); + expect(out).toContain("week: 100% remaining"); + }); +}); + +describe("formatKeyUsage (pure)", () => { + const now = Date.UTC(2025, 5, 1, 12, 0, 0); + + it("formats reset timestamps with ISO + relative time", () => { + const out = formatKeyUsage( + [ + { + keyId: "claude-max", + provider: "anthropic", + status: "active", + dataSource: "live", + windows: [{ label: "5-hour", remainingPercent: 80, resetsAt: now + 90 * 60_000 }], + }, + ], + now, + ); + + expect(out).toContain("5-hour: 80% remaining, resets 2025-06-01T13:30:00.000Z (in 1h 30m)"); + }); + + it("renders a past reset/exhaustion time as 'ago'", () => { + const out = formatKeyUsage( + [ + { + keyId: "opencode-1", + provider: "opencode-go", + status: "exhausted", + exhaustedAt: now - 2 * HOUR, + lastError: "boom", + windows: [], + }, + ], + now, + ); + + expect(out).toContain("status: EXHAUSTED (since 2025-06-01T10:00:00.000Z, 2h ago)"); + expect(out).toContain("last error: boom"); + }); + + it("returns a friendly message when no entries match", () => { + expect(formatKeyUsage([], now)).toBe("No API keys matched."); + }); +}); |
