diff --git a/CLAUDE.md b/CLAUDE.md index d6bfa7d5..724e8eba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,8 +61,9 @@ bun run gate:security | Piece | Requirement and current reality | Status | Evidence | | --- | --- | --- | --- | -| L1 Proxy | Four metrics ≥95%, plus baseline line floor ≥97.5%, ≤0.1pp regression and no untested files | planned | `gate:coverage` enforces stronger line baseline in hooks/CI; Vitest branch threshold still 89% | -| L1 Dashboard/scripts | Four metrics ≥95% | planned | Dashboard config only statements 90%; script config branches 89% | +| L1 Proxy | Four metrics ≥95%, plus baseline line floor ≥97.5%, ≤0.1pp regression and no untested files | enforced | `gate:coverage` retains the stronger line/directory baseline, regression and untested-file rules; Vitest enforces all four 95% thresholds | +| L1 Dashboard | Four metrics ≥95% | enforced | Dashboard tests and CI enforce all four 95% thresholds on the declared scope | +| L1 scripts | Four metrics ≥95% | enforced | The baseline gate explicitly runs the dedicated Bun/Istanbul config for the declared `scripts/lib` scope; reports are isolated in `coverage/scripts` | | L2 | Every API endpoint/method through real isolated HTTP/SQLite | planned | CI `test:l2` is in-process route/handler tests with mocked upstream; legacy live E2E uses real state | | L3 | Real isolated dashboard/auth/provider workflows | planned | Playwright manual runner uses real auth and fixed test DB | | G1 | Strict types, zero-warning/error Biome and architecture boundaries | planned | Checks enforced; pre-commit lint-staged mutation/worktree concurrency violates check-only target | diff --git a/bun.lock b/bun.lock index 176ba189..68d6c29f 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "devDependencies": { "@biomejs/biome": "2.5.14", "@types/bun": "1.4.2", + "@vitest/coverage-istanbul": "5.0.1", "dependency-cruiser": "18.3.1", "husky": "^9.1.7", "lint-staged": "17.5.1", @@ -25,7 +26,7 @@ "dependencies": { "@nocoo/basalt": "2.1.8", "clsx": "^2.1.1", - "lucide-react": "1.46.0", + "lucide-react": "1.47.0", "next": "16.3.5", "next-auth": "^5.0.0-beta.32", "react": "19.3.0", @@ -41,7 +42,7 @@ "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "14.6.7", - "@types/node": "26.5.1", + "@types/node": "26.6.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@typescript/native-preview": "7.0.0-dev.20260707.2", @@ -512,7 +513,7 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], @@ -804,7 +805,7 @@ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], - "lucide-react": ["lucide-react@1.46.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA=="], + "lucide-react": ["lucide-react@1.47.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -1064,6 +1065,8 @@ "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "bun-types/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], diff --git a/package.json b/package.json index edcf4b41..4fe1784e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "devDependencies": { "@biomejs/biome": "2.5.14", "@types/bun": "1.4.2", + "@vitest/coverage-istanbul": "5.0.1", "dependency-cruiser": "18.3.1", "husky": "^9.1.7", "lint-staged": "17.5.1", diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index e31d4916..770ef580 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -14,7 +14,7 @@ "dependencies": { "@nocoo/basalt": "2.1.8", "clsx": "^2.1.1", - "lucide-react": "1.46.0", + "lucide-react": "1.47.0", "next": "16.3.5", "next-auth": "^5.0.0-beta.32", "react": "19.3.0", @@ -30,7 +30,7 @@ "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "14.6.7", - "@types/node": "26.5.1", + "@types/node": "26.6.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@typescript/native-preview": "7.0.0-dev.20260707.2", diff --git a/packages/dashboard/test/api/proxy-error-contract.test.ts b/packages/dashboard/test/api/proxy-error-contract.test.ts new file mode 100644 index 00000000..f4897d77 --- /dev/null +++ b/packages/dashboard/test/api/proxy-error-contract.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const proxyFetch = vi.fn(); +vi.mock("@/lib/proxy", () => { + class ProxyError extends Error { + constructor(message: string, public readonly statusCode?: number) { super(message); } + } + return { proxyFetch, ProxyError }; +}); + +const { ProxyError } = await import("@/lib/proxy"); +const upstreams = await import("@/app/api/upstreams/route"); +const upstream = await import("@/app/api/upstreams/[id]/route"); +const upstreamModels = await import("@/app/api/upstreams/[id]/models/route"); +const keys = await import("@/app/api/keys/route"); +const key = await import("@/app/api/keys/[id]/route"); +const revoke = await import("@/app/api/keys/[id]/revoke/route"); +const settings = await import("@/app/api/settings/route"); +const setting = await import("@/app/api/settings/[key]/route"); +const requests = await import("@/app/api/requests/route"); +const stats = await import("@/app/api/stats/[...path]/route"); +const copilot = await import("@/app/api/copilot/[...path]/route"); +const connection = await import("@/app/api/connection-info/route"); + +const request = () => new Request("http://dashboard.test/api/fixture", { method: "POST", body: "{}" }); +const context = () => ({ params: Promise.resolve({ id: "fixture-id" }) }); +const routes: [string, () => Promise][] = [ + ["list providers", () => upstreams.GET()], + ["create provider", () => upstreams.POST(request())], + ["read provider", () => upstream.GET(request(), context())], + ["update provider", () => upstream.PUT(request(), context())], + ["delete provider", () => upstream.DELETE(request(), context())], + ["provider models", () => upstreamModels.GET(request(), context())], + ["list keys", () => keys.GET()], + ["create key", () => keys.POST(request())], + ["delete key", () => key.DELETE(request(), context())], + ["revoke key", () => revoke.POST(request(), context())], + ["read settings", () => settings.GET()], + ["write settings", () => settings.PUT(request())], + ["delete setting", () => setting.DELETE(request(), { params: Promise.resolve({ key: "fixture" }) })], + ["requests", () => requests.GET(request())], + ["statistics", () => stats.GET(request(), { params: Promise.resolve({ path: ["summary"] }) })], + ["copilot metadata", () => copilot.GET(request(), { params: Promise.resolve({ path: ["user"] }) })], + ["connection metadata", () => connection.GET()], +]; + +beforeEach(() => proxyFetch.mockReset()); + +describe.each(routes)("Dashboard proxy failure contract: %s", (_label, invoke) => { + it.each([ + [new ProxyError("fixture failure"), 502, "fixture failure"], + [new ProxyError("fixture failure", 429), 429, "fixture failure"], + [new Error("fixture failure"), 502, "fixture failure"], + ["untrusted non-Error rejection", 502, "Failed to reach proxy"], + ])("normalizes rejection %# without returning success or raw unknown data", async (error, status, message) => { + proxyFetch.mockRejectedValueOnce(error); + const response = await invoke(); + expect(response.status).toBe(status); + const body = await response.json(); + expect(JSON.stringify(body)).toContain(message); + expect(JSON.stringify(body)).not.toContain("untrusted non-Error rejection"); + expect(proxyFetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/dashboard/test/components/chart-primitives.test.tsx b/packages/dashboard/test/components/chart-primitives.test.tsx new file mode 100644 index 00000000..c147ecc0 --- /dev/null +++ b/packages/dashboard/test/components/chart-primitives.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ChartTooltip, ChartTooltipRow, ChartTooltipSummary } from "@/components/dashboard/chart-primitives"; +import { CodeBlock } from "@/components/code-block"; + +describe("dashboard display atoms", () => { + it.each([undefined, null, "", "Requests"])("keeps tooltip content with optional title %j", (title) => { + const { container } = render( + + + + ); + expect(screen.getByText("Success")).toBeDefined(); + expect(screen.getByText("Failure")).toBeDefined(); + expect(screen.getByText("Total")).toBeDefined(); + expect(container.querySelector('[aria-hidden="true"]')).toHaveStyle({ background: "rgb(12, 34, 56)" }); + expect(container.querySelectorAll("p")).toHaveLength(title ? 1 : 0); + }); + + it.each([undefined, "custom-code"])("preserves literal code with optional class %j", (className) => { + render(); + expect(screen.getByText("echo fixture").tagName).toBe("CODE"); + }); +}); diff --git a/packages/dashboard/test/components/filter-bar.test.tsx b/packages/dashboard/test/components/filter-bar.test.tsx index be8949a0..1878b7b3 100644 --- a/packages/dashboard/test/components/filter-bar.test.tsx +++ b/packages/dashboard/test/components/filter-bar.test.tsx @@ -169,6 +169,64 @@ describe("FilterChip", () => { // --------------------------------------------------------------------------- describe("FilterBar", () => { + it("shows every deep-linked dimension, including zero latency and synchronous mode", () => { + mockSearchParams = new URLSearchParams({ + model: "m", resolved_model: "resolved", strategy: "native", upstream: "fixture-provider", + account: "fixture-account", client: "fixture-client", client_version: "1.2.3", + session: "fixture-session", path: "/v1/messages", status: "error", status_code: "429", + stream: "false", has_error: "true", min_latency: "0", max_latency: "1000", + stop_reason: "length", routing_path: "fallback", + }); + render(); + expect(screen.getByText("17 active")).toBeDefined(); + expect(screen.getAllByRole("button", { name: /^Remove .* filter$/ })).toHaveLength(17); + expect(screen.getByText("0ms")).toBeDefined(); + expect(screen.getByText("Version:")).toBeDefined(); + }); + + it.each([ + [1, "fixture-model", "model"], [2, "fixture-strategy", "strategy"], + [3, "fixture-provider", "upstream"], [4, "error", "status"], + ])("updates dimension %s through the actual dropdown", async (index, value, key) => { + render(); + const user = userEvent.setup(); + await user.click(screen.getAllByRole("combobox")[index]!); + await user.click(screen.getByRole("option", { name: value })); + const params = new URLSearchParams(String(mockPush.mock.calls[0]![0]).split("?")[1]); + expect(params.get(key)).toBe(value); + }); + + it("clears the last upstream selection without retaining an empty query string", async () => { + mockSearchParams = new URLSearchParams("upstream=fixture-provider"); + render(); + const user = userEvent.setup(); + await user.click(screen.getAllByRole("combobox")[1]!); + await user.click(screen.getByRole("option", { name: "All upstreams" })); + expect(mockPush).toHaveBeenCalledWith("/"); + }); + + it.each([ + ["stream=false", "Streaming", "/?stream=true"], + ["stream=true", "Synchronous", "/?stream=false"], + ["stream=true", "All modes", "/"], + ])("changes request mode from %s to %s", async (query, label, expected) => { + mockSearchParams = new URLSearchParams(query); + render(); + const user = userEvent.setup(); + await user.click(screen.getAllByRole("combobox")[2]!); + await user.click(screen.getByRole("option", { name: label })); + expect(mockPush).toHaveBeenCalledWith(expected); + }); + + it("clears old custom bounds when selecting the default time range", async () => { + mockSearchParams = new URLSearchParams("range=custom&from=1000&to=2000"); + render(); + const user = userEvent.setup(); + await user.click(screen.getAllByRole("combobox")[0]!); + await user.click(screen.getByRole("option", { name: "Last 24 hours" })); + expect(mockPush).toHaveBeenCalledWith("/"); + }); + it("renders time range picker with default 24h", () => { render(); expect(screen.getByText("Last 24 hours")).toBeDefined(); diff --git a/packages/dashboard/test/components/request-detail-drawer.test.tsx b/packages/dashboard/test/components/request-detail-drawer.test.tsx index 14d4758f..98d913e5 100644 --- a/packages/dashboard/test/components/request-detail-drawer.test.tsx +++ b/packages/dashboard/test/components/request-detail-drawer.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; // --------------------------------------------------------------------------- @@ -103,6 +103,49 @@ function makeExtendedRecord(overrides: Partial = {}): Ext // =========================================================================== describe("RequestDetailDrawer", () => { + it("renders sparse non-streaming requests without fabricated token or routing details", () => { + const request = makeExtendedRecord({ + latency_ms: 0, ttft_ms: 0, processing_ms: 0, stream: 0, + input_tokens: null, output_tokens: null, cache_read_tokens: null, cache_write_tokens: null, + translated_model: "", strategy: "", upstream: "", upstream_format: "", routing_path: "", + copilot_model: "", account_name: "", client_name: "", stop_reason: "", tool_call_count: 0, + }); + render( {}} />); + expect(screen.getByText("No")).toBeDefined(); + expect(screen.getAllByText("—")).toHaveLength(5); + expect(screen.queryByText("Strategy")).toBeNull(); + expect(screen.queryByText("Tool Calls")).toBeNull(); + expect(screen.getAllByText("0ms").length).toBeGreaterThan(0); + }); + + it.each([null, {}])("leaves the drawer usable when clipboard support is %j", (clipboard) => { + const previous = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: clipboard }); + try { + render( {}} />); + fireEvent.click(screen.getByLabelText("Copy request ID")); + expect(screen.getByText("req-test-001")).toBeDefined(); + } finally { + if (previous) Object.defineProperty(navigator, "clipboard", previous); + else Reflect.deleteProperty(navigator, "clipboard"); + } + }); + + it("handles denied clipboard writes without an unhandled rejection", async () => { + const previous = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + const writeText = vi.fn().mockRejectedValue(new Error("Permission denied")); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + try { + render( {}} />); + fireEvent.click(screen.getByLabelText("Copy request ID")); + await Promise.resolve(); + expect(writeText).toHaveBeenCalledWith("req-test-001"); + } finally { + if (previous) Object.defineProperty(navigator, "clipboard", previous); + else Reflect.deleteProperty(navigator, "clipboard"); + } + }); + it("renders nothing when request is null", () => { const { container } = render( {}} />, diff --git a/packages/dashboard/test/components/request-table.test.tsx b/packages/dashboard/test/components/request-table.test.tsx index 2b5146d5..8513ef0e 100644 --- a/packages/dashboard/test/components/request-table.test.tsx +++ b/packages/dashboard/test/components/request-table.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ALL_COLUMNS } from "@/components/requests/column-config"; import { userEvent } from "@testing-library/user-event"; // --------------------------------------------------------------------------- @@ -346,3 +347,69 @@ describe("pathname-based routing", () => { expect(url).toMatch(/^\/requests\?/); }); }); + +describe("request metadata and navigation boundaries", () => { + const allColumns = new Set(ALL_COLUMNS.map((column) => column.key)); + + it("shows optional diagnostics and non-streaming failures when all columns are enabled", () => { + const record = makeRecord({ + path: "/v1/a-very-long-request-endpoint", + status: "error", status_code: 429, stream: 0, + ttft_ms: 12, processing_ms: 34, strategy: "fixture-strategy", + upstream: "fixture-upstream", account_name: "fixture-account", + client_name: "fixture-client", session_id: "short-session", + stop_reason: "length", tool_call_count: 3, routing_path: "fallback", + translated_model: "mapped-model", error_message: "fixture error", + }); + render(); + for (const text of ["Format", "Processing", "Strategy", "Session", "12ms", "34ms", "fixture-strategy", "fixture-upstream", "fixture-account", "fixture-client", "length", "fallback", "mapped-model", "fixture error", "no", "1,200 total"]) { + expect(screen.getByText(text)).toBeDefined(); + } + expect(screen.getByText(/\/v1\/a-very-long-request.*…/)).toBeDefined(); + fireEvent.click(screen.getAllByRole("row")[1]!); + expect(mockPush).not.toHaveBeenCalled(); + }); + + it("keeps missing optional diagnostic fields distinguishable from measured zero values", () => { + render(); + expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(8); + expect(screen.getByText("0ms")).toBeDefined(); + }); + + it("reverses an ascending sort and advertises the active order accessibly", () => { + mockSearchParams = new URLSearchParams("sort=processing_ms&order=asc"); + render(); + const button = screen.getByRole("button", { name: "Processing" }); + expect(button.closest("th")).toHaveAttribute("aria-sort", "ascending"); + fireEvent.click(button); + expect(mockPush).toHaveBeenCalledWith("/requests?sort=processing_ms&order=desc"); + }); + + it("retains cursor history and active filters on the next page", () => { + mockSearchParams = new URLSearchParams("cursor=cur-1&prevCursors=cur-0&model=fixture"); + render(); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + const params = new URLSearchParams(String(mockPush.mock.calls[0]![0]).split("?")[1]); + expect(params.get("prevCursors")).toBe("cur-0,cur-1"); + expect(params.get("cursor")).toBe("cur-2"); + expect(params.get("model")).toBe("fixture"); + }); + + it("uses the default page size for offset pagination", () => { + mockSearchParams = new URLSearchParams("sort=latency_ms"); + render(); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(mockPush).toHaveBeenCalledWith("/requests?sort=latency_ms&offset=50"); + }); + + it("returns an offset page to the beginning using the default page size", () => { + mockSearchParams = new URLSearchParams("sort=latency_ms&offset=20"); + render(); + fireEvent.click(screen.getByRole("button", { name: "Previous" })); + expect(mockPush).toHaveBeenCalledWith("/requests?sort=latency_ms"); + }); +}); diff --git a/packages/dashboard/test/lib/analytics-filter-contract.test.ts b/packages/dashboard/test/lib/analytics-filter-contract.test.ts new file mode 100644 index 00000000..0de884a1 --- /dev/null +++ b/packages/dashboard/test/lib/analytics-filter-contract.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + type AnalyticsFilters, type TimeRange, countActiveFilters, filtersToApiQuery, + filtersToSearchParams, rangeToEpoch, rangeToInterval, searchParamsToFilters, +} from "@/lib/analytics-filters"; + +describe("complete analytics deep-link contract", () => { + const filters: AnalyticsFilters = { + range: "custom", from: 1000, to: 2000, model: "raw/model", + resolved_model: "resolved/model", strategy: "native", upstream: "fixture-provider", + account: "fixture-account", client: "fixture-client", client_version: "1.2.3", + session: "session with spaces", path: "/v1/messages", status: "error", + status_code: 429, stream: false, has_error: true, min_latency: 0, + max_latency: 1000, stop_reason: "length", routing_path: "fallback", + }; + + it("round-trips every supported dimension without losing false or zero", () => { + expect(searchParamsToFilters(filtersToSearchParams(filters))).toEqual(filters); + expect(countActiveFilters(filters)).toBe(17); + }); + + it("forwards the same complete selection to the proxy API", () => { + const params = new URLSearchParams(filtersToApiQuery(filters)); + expect(Object.fromEntries(params)).toEqual({ + from: "1000", to: "2000", model: "raw/model", resolved_model: "resolved/model", + strategy: "native", upstream: "fixture-provider", account: "fixture-account", + client: "fixture-client", client_version: "1.2.3", session: "session with spaces", + path: "/v1/messages", status: "error", status_code: "429", stream: "false", + has_error: "true", min_latency: "0", max_latency: "1000", stop_reason: "length", + routing_path: "fallback", + }); + }); + + it("does not invent dates for an incomplete custom range", () => { + expect(filtersToApiQuery({ range: "custom" })).toBe(""); + expect(rangeToInterval("custom")).toBe("hour"); + }); + + it("tolerates an unknown range from an old saved URL without inventing epoch bounds", () => { + const oldRange = "legacy-range" as TimeRange; + expect(rangeToEpoch(oldRange)).toBeNull(); + expect(filtersToApiQuery({ range: oldRange })).toBe(""); + }); +}); diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 8e0e580c..e6bfa362 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -59,7 +59,10 @@ export default defineConfig({ "src/hooks/use-mobile.tsx", ], thresholds: { - statements: 90, + statements: 95, + branches: 95, + functions: 95, + lines: 95, }, }, }, diff --git a/packages/proxy/test/db/requests.test.ts b/packages/proxy/test/db/requests.test.ts index 1ae2d876..463f7660 100644 --- a/packages/proxy/test/db/requests.test.ts +++ b/packages/proxy/test/db/requests.test.ts @@ -87,6 +87,29 @@ afterEach(() => { db.close(); }); +describe("request storage boundary contracts", () => { + test.each([new Error("fixture migration denied"), "fixture migration denied"])("does not swallow a non-duplicate migration failure: %#", (failure) => { + const failingDb = { exec(sql: string) { if (sql.startsWith("ALTER TABLE")) throw failure } } as unknown as Database + let caught: unknown + try { initDatabase(failingDb) } catch (error) { caught = error } + expect(caught).toBe(failure) + }) + + test("supports default retrieval and ascending cursor pagination without duplicating the boundary row", () => { + insertRequest(db, makeRecord({ id: "first", timestamp: 1 })) + insertRequest(db, makeRecord({ id: "second", timestamp: 2 })) + expect(queryRequests(db).data.map((row) => row.id)).toEqual(["second", "first"]) + const result = queryRequests(db, { model: null, status: null, format: null, sort: "timestamp", order: "asc", cursor: "first", offset: null, limit: 10 }) + expect(result.data.map((row) => row.id)).toEqual(["second"]) + }) + + test("sorts computed latency percentiles in ascending order", () => { + insertRequest(db, makeRecord({ model: "slow", latency_ms: 500 })) + insertRequest(db, makeRecord({ model: "fast", latency_ms: 10 })) + expect(queryBreakdown(db, { by: "model", sort: "p95_latency_ms", order: "asc" }).map((row) => row.key)).toEqual(["fast", "slow"]) + }) +}) + // =========================================================================== // Schema initialization // =========================================================================== diff --git a/packages/proxy/test/protocols/chat-responses/boundary-contract.test.ts b/packages/proxy/test/protocols/chat-responses/boundary-contract.test.ts new file mode 100644 index 00000000..1f1b5214 --- /dev/null +++ b/packages/proxy/test/protocols/chat-responses/boundary-contract.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "vitest" +import { isResponsesFailure, responsesFailureMessage } from "../../../src/protocols/chat-responses/errors" +import { chatRequestToResponses } from "../../../src/protocols/chat-responses/request" +import { ResponsesProtocolError, responsesJsonToChatCompletion } from "../../../src/protocols/chat-responses/response" +import type { ChatViaResponsesClientReq } from "../../../src/protocols/chat-responses/types" +import { mapResponsesFinishReason } from "../../../src/protocols/chat-responses/finish-reason" + +describe("Responses failure envelope compatibility", () => { + test.each([null, undefined, false, 42, "not an object"])("ignores a non-envelope %j without losing a stable error fallback", (body) => { + expect(isResponsesFailure(body)).toBe(false) + expect(responsesFailureMessage(body)).toBe("Responses upstream failed") + }) + test.each([ + [{ status: "failed" }, "Responses upstream failed (status=failed)"], + [{ error: { message: "denied" } }, "denied"], + [{ error: "offline" }, "offline"], + [{ error: {} }, "Responses upstream failed (status=unknown)"], + [{ error: { message: "" } }, "Responses upstream failed (status=unknown)"], + [{ error: false }, "Responses upstream failed (status=unknown)"], + ])("preserves failure envelope %#", (body, message) => { + expect(isResponsesFailure(body)).toBe(true) + expect(responsesFailureMessage(body)).toBe(message) + expect(() => responsesJsonToChatCompletion(body, "fixture-model")).toThrow(ResponsesProtocolError) + }) +}) + +describe("chat request content compatibility", () => { + test.each([ + [null, ""], [42, "42"], + [[null, 4, {}, { text: null }, { text: "tool output" }], "tool output"], + ])("normalizes legacy tool output %j without manufacturing text", (content, output) => { + const payload = chatRequestToResponses({ model: "fixture-model", messages: [{ role: "tool", content }] } as unknown as ChatViaResponsesClientReq) + expect(payload.input).toEqual([{ type: "function_call_output", call_id: "", output }]) + }) + + test.each([ + [null, ""], [42, "42"], [[null, 4, {}], ""], + [[{ type: "text" }], ""], + [[{ type: "image_url", image_url: {} }], ""], + [[null, { text: "retained" }], [{ type: "input_text", text: "retained" }]], + ])("normalizes sparse user content %j", (content, expected) => { + const payload = chatRequestToResponses({ model: "fixture-model", messages: [{ role: "user", content }] } as unknown as ChatViaResponsesClientReq) + expect(payload.input).toEqual([{ role: "user", content: expected }]) + }) + + test("uses safe defaults for incomplete function declarations and JSON schema metadata", () => { + const payload = chatRequestToResponses({ + model: "fixture-model", messages: [], + tools: [{ type: "function", function: { name: "fixture" } }], + response_format: { type: "json_schema" }, + } as unknown as ChatViaResponsesClientReq) + expect(payload.tools).toEqual([{ type: "function", name: "fixture", description: undefined, parameters: {}, strict: false }]) + expect(payload.text).toEqual({ format: { type: "json_schema" } }) + }) +}) + +describe("Responses output compatibility", () => { + test("handles missing or non-string incomplete reasons and the legacy max_tokens spelling", () => { + expect(mapResponsesFinishReason({ status: "incomplete", incomplete_details: null })).toBe("stop") + expect(mapResponsesFinishReason({ status: "incomplete", incomplete_details: { reason: 42 } })).toBe("stop") + expect(mapResponsesFinishReason({ status: "incomplete", incomplete_details: { reason: "max_tokens" } })).toBe("length") + }) + + test.each([null, false, "legacy body", {}, { output: false }])("uses stable metadata defaults for %j", (body) => { + const response = responsesJsonToChatCompletion(body, "fallback-model") + expect(response).toMatchObject({ id: "resp_unknown", model: "fallback-model", usage: null }) + expect(response.choices[0]?.message.content).toBeNull() + }) + + test.each([undefined, { path: "fixture" }])("serializes non-string tool arguments %j", (args) => { + const response = responsesJsonToChatCompletion({ output: [null, false, { type: "function_call", call_id: "call-1", name: "read", arguments: args }] }, "fixture-model") + expect(response.choices[0]?.message.tool_calls).toEqual([{ id: "call-1", type: "function", function: { name: "read", arguments: args ? '{"path":"fixture"}' : "{}" } }]) + }) + + test("collects refusal and text from sparse response items without leaking unknown fields", () => { + const response = responsesJsonToChatCompletion({ output: [ + {}, { refusal: "top refusal" }, { type: "message", content: false }, + { content: [null, false, { refusal: "part refusal" }, { type: "text", text: "text" }, { text: "legacy" }, { type: "text", text: 42 }] }, + ] }, "fixture-model") + expect(response.choices[0]?.message).toMatchObject({ content: "textlegacy", refusal: "top refusalpart refusal" }) + }) +}) diff --git a/packages/proxy/test/protocols/chat-responses/stream-boundary-contract.test.ts b/packages/proxy/test/protocols/chat-responses/stream-boundary-contract.test.ts new file mode 100644 index 00000000..0f388d52 --- /dev/null +++ b/packages/proxy/test/protocols/chat-responses/stream-boundary-contract.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest" +import { adaptResponsesEventToChatChunks as adapt, initChatViaResponsesStreamState as init } from "../../../src/protocols/chat-responses/stream" +import type { ServerSentEvent } from "../../../src/util/sse" + +const event = (name: string | null, body: unknown): ServerSentEvent => ({ event: name, data: typeof body === "string" ? body : JSON.stringify(body), id: null, retry: null }) +const parsed = (chunks: ReturnType) => chunks.filter((chunk) => chunk.data !== "[DONE]").map((chunk) => JSON.parse(String(chunk.data))) + +describe("Responses streaming boundary compatibility", () => { + test.each(["", "[DONE]", "{", "{}"])("ignores an untyped non-event %j", (body) => { + const state = init({ model: "fixture-model", includeUsage: false }) + expect(adapt(event(null, body), state)).toEqual([]) + expect(state.done).toBe(false) + }) + + test.each([ + ["error", "{", "Responses stream failed"], + ["response.failed", { message: "fixture failure" }, "fixture failure"], + ["message", { type: "response.failed", error: "fixture failure" }, "fixture failure"], + ["message", { type: "error" }, "Responses stream failed"], + ])("terminates a failed stream %# and ignores late events", (name, body, message) => { + const state = init({ model: "fixture-model", includeUsage: false }) + expect(() => adapt(event(name, body), state)).toThrow(message) + expect(state.failed).toBe(true) + expect(adapt(event("response.output_text.delta", { delta: "late" }), state)).toEqual([]) + }) + + test.each([{}, { id: "direct-id", model: "direct-model", created_at: 10.9 }, "{"])("accepts sparse creation metadata %# only once", (body) => { + const state = init({ model: "fixture-model", includeUsage: false }) + const first = parsed(adapt(event("response.created", body), state)) + expect(first[0].choices[0].delta.role).toBe("assistant") + expect(adapt(event("response.created", body), state)).toEqual([]) + }) + + test("accepts metadata on the first delta when no created event was sent", () => { + const state = init({ model: "fixture-model", includeUsage: false }) + const chunks = parsed(adapt(event(null, { type: "response.output_text.delta", response: { id: "r1", model: "resolved-model" }, delta: "text" }), state)) + expect(chunks[0]).toMatchObject({ id: "r1", model: "resolved-model" }) + expect(chunks[1].choices[0].delta.content).toBe("text") + }) + + test("emits the assistant role for an early refusal and preserves subsequent refusal chunks", () => { + const state = init({ model: "fixture-model", includeUsage: false }) + const chunks = parsed(adapt(event("response.refusal.delta", { delta: "refusal" }), state)) + expect(chunks[0].choices[0].delta.role).toBe("assistant") + expect(chunks[1].choices[0].delta.refusal).toBe("refusal") + expect(parsed(adapt(event("response.refusal.delta", { delta: "more" }), state))).toHaveLength(1) + }) + + test.each([ + ["response.incomplete", { incomplete_details: { reason: "content_filter" } }, "content_filter"], + ["response.incomplete", {}, "length"], + ["response.incomplete", "{", "length"], + ["", { type: "response.completed", usage: { output_tokens: 2 } }, "stop"], + ["response.done", { usage: { input_tokens: 2 } }, "stop"], + ])("finishes a sparse terminal event %# with exactly one DONE", (name, body, reason) => { + const state = init({ model: "fixture-model", includeUsage: true }) + const chunks = adapt(event(name, body), state) + const data = parsed(chunks) + expect(data[0].choices[0].delta.role).toBe("assistant") + expect(data.some((chunk) => chunk.choices[0]?.finish_reason === reason)).toBe(true) + expect(chunks.filter((chunk) => chunk.data === "[DONE]")).toHaveLength(1) + expect(data.find((chunk) => chunk.usage)?.usage.total_tokens).toBeGreaterThanOrEqual(0) + }) + + test.each([null, 3, {}, "{"])("ignores a non-function output item %j", (item) => { + const state = init({ model: "fixture-model", includeUsage: false }) + expect(adapt(event("response.output_item.added", item === "{" ? item : { item }), state)).toEqual([]) + }) + + test("accepts nested item identity and legacy arguments deltas", () => { + const state = init({ model: "fixture-model", includeUsage: false }) + adapt(event("response.output_item.added", { item: { type: "function_call", id: "item-1", call_id: "call-1", name: "read" } }), state) + const chunks = parsed(adapt(event("response.function_call_arguments.delta", { item: { id: "item-1" }, arguments: "{}" }), state)) + expect(chunks[0].choices[0].delta.tool_calls).toEqual([{ index: 0, function: { arguments: "{}" } }]) + }) +}) diff --git a/packages/proxy/test/routes/effort-fallback.test.ts b/packages/proxy/test/routes/effort-fallback.test.ts index f369b932..a3144428 100644 --- a/packages/proxy/test/routes/effort-fallback.test.ts +++ b/packages/proxy/test/routes/effort-fallback.test.ts @@ -5,7 +5,10 @@ import { parseReasoningEffortError, getSupportedEfforts, adjustEffortInPayload, + logEffortFallback, } from "../../src/strategies/support/effort-fallback" +import { logEmitter } from "../../src/util/log-emitter" +import type { LogEvent } from "../../src/util/log-event" import { state } from "../../src/lib/state" import type { AnthropicMessagesPayload } from "../../src/protocols/anthropic/types" @@ -73,6 +76,27 @@ describe("pickSupportedEffort", () => { // --------------------------------------------------------------------------- describe("parseReasoningEffortError", () => { + test.each([42, 'effort "future" is not supported; supported values: [high]'])("does not invent a fallback from an unsupported error message: %j", (message) => { + expect(parseReasoningEffortError({ error: { code: "invalid_reasoning_effort", message } })).toBeNull() + }) + + test("ignores unknown advertised effort levels while retaining valid supported levels", () => { + expect(parseReasoningEffortError({ error: { code: "invalid_reasoning_effort", message: 'effort "max" is not supported; supported values: [future, medium]' } })).toEqual({ requestedEffort: "max", supportedEfforts: ["medium"] }) + expect(pickSupportedEffort("future" as Parameters[0], ["high"])).toBeNull() + }) + + test("records removal of unsupported effort without changing the raw model identifier", () => { + const events: LogEvent[] = [] + const listener = (event: LogEvent) => { events.push(event) } + logEmitter.on("log", listener) + try { + logEffortFallback("fixture-effort", "fixture-model", "max", null) + expect(events).toHaveLength(1) + expect(events[0]?.msg).toContain("removed") + expect(events[0]?.data).toMatchObject({ model: "fixture-model", originalEffort: "max", fallbackEffort: null }) + } finally { logEmitter.off("log", listener) } + }) + test("parses valid error response", () => { const error = { error: { diff --git a/packages/proxy/test/routes/models-route.test.ts b/packages/proxy/test/routes/models-route.test.ts index 98acf9db..fcec9e63 100644 --- a/packages/proxy/test/routes/models-route.test.ts +++ b/packages/proxy/test/routes/models-route.test.ts @@ -40,7 +40,7 @@ beforeEach(() => { }, }], } - fetchSpy = vi.spyOn(globalThis, "fetch") + fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unconfigured test upstream")) }) afterEach(() => { @@ -58,6 +58,22 @@ afterEach(() => { // =========================================================================== describe("GET /v1/models (route wrapper)", () => { + test.each([{}, { data: "invalid" }, { data: [] }])("omits a malformed or empty public-provider catalog without adding authentication: %j", async (catalog) => { + setProviders([{ + id: "public-fixture", name: "Public fixture", base_url: "https://models.fixture.invalid", + format: "openai", api_key: "", model_patterns: '["*"]', enabled: 1, + supports_reasoning: 0, supports_models_endpoint: 1, auth_style: null, use_socks5: null, + created_at: 0, updated_at: 0, + }]) + fetchSpy.mockResolvedValueOnce(Response.json(catalog)) + const response = await new Hono().route("/v1/models", modelRoutes).request("/v1/models") + expect(response.status).toBe(200) + const result = await response.json() as { data: { id: string }[] } + expect(result.data.map((entry) => entry.id)).toEqual(["gpt-4o"]) + expect(fetchSpy).toHaveBeenCalledOnce() + expect(fetchSpy.mock.calls[0]?.[1]?.headers).toEqual({ "Content-Type": "application/json" }) + }) + test("returns model list in OpenAI format", async () => { const app = new Hono() app.route("/v1/models", modelRoutes) diff --git a/packages/proxy/test/routes/server-tools-consume-stream.test.ts b/packages/proxy/test/routes/server-tools-consume-stream.test.ts index d93c1fd9..88f29502 100644 --- a/packages/proxy/test/routes/server-tools-consume-stream.test.ts +++ b/packages/proxy/test/routes/server-tools-consume-stream.test.ts @@ -25,6 +25,29 @@ function makeChunk(overrides: Record = {}): string { } describe("consumeStreamToResponse", () => { + test("reassembles sparse out-of-order tool fragments while defaulting absent usage", async () => { + async function* stream(): AsyncGenerator { + yield makeEvent("") + yield makeEvent(JSON.stringify({ choices: [], usage: {} })) + yield makeEvent(JSON.stringify({ choices: [{ delta: { tool_calls: [ + null, { index: 9, function: {} }, + { index: 1, id: "call-b", function: { name: "second" } }, + { index: 0, id: "call-a", function: { name: "first", arguments: "{" } }, + ] } }] })) + yield makeEvent(JSON.stringify({ choices: [{ delta: { tool_calls: [ + { index: 0, function: { arguments: "}" } }, { index: 1 }, + ] }, finish_reason: "tool_calls" }] })) + yield makeEvent("[DONE]") + } + const response = await consumeStreamToResponse(stream()) + expect(response.id).toMatch(/^chatcmpl-/) + expect(response.usage).toMatchObject({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }) + expect(response.choices[0]?.message.tool_calls).toEqual([ + { id: "call-a", type: "function", function: { name: "first", arguments: "{}" } }, + { id: "call-b", type: "function", function: { name: "second", arguments: "" } }, + ]) + }) + test("reassembles text content from stream", async () => { async function* stream(): AsyncGenerator { // First chunk: role diff --git a/packages/proxy/test/routes/stats.test.ts b/packages/proxy/test/routes/stats.test.ts index 44db39be..7f7e5ab6 100644 --- a/packages/proxy/test/routes/stats.test.ts +++ b/packages/proxy/test/routes/stats.test.ts @@ -76,6 +76,36 @@ afterEach(() => { // Stats routes // =========================================================================== +describe("analytics request boundaries with an isolated SQLite database", () => { + test.each(["timeseries-group", "breakdown"])("rejects %s without a grouping field", async (endpoint) => { + const app = new Hono().route("/api", createStatsRoute(db)) + const response = await app.request(`/api/stats/${endpoint}`) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: "missing 'by' parameter" }) + }) + + test.each([ + "/api/stats/timeseries-group?by=model&from=1&interval=minute&limit=bad", + "/api/stats/timeseries-group?by=model&range=7d&limit=2", + "/api/stats/breakdown?by=model&order=asc&sort=unsupported&limit=bad", + "/api/stats/breakdown?by=model&order=asc&from=1&limit=2", + ])("uses safe query defaults and preserves schema for %s", async (url) => { + seedDb(db) + const app = new Hono().route("/api", createStatsRoute(db)) + const response = await app.request(url) + expect(response.status).toBe(200) + const body = await response.json() + if (url.includes("/breakdown")) { + expect(body).toHaveLength(2) + expect(body.map((item: { count: number }) => item.count)).toEqual([2, 2]) + } else { + expect(body.keys.sort()).toEqual(["claude-sonnet-4", "gpt-4o"]) + expect(body.points.length).toBeGreaterThan(0) + } + expect(db.query("SELECT COUNT(*) AS n FROM requests").get()).toEqual({ n: 4 }) + }) +}) + describe("GET /api/stats/overview", () => { test("returns aggregate stats", async () => { seedDb(db); diff --git a/packages/proxy/test/routes/upstream-health-boundary.test.ts b/packages/proxy/test/routes/upstream-health-boundary.test.ts new file mode 100644 index 00000000..a0b3846e --- /dev/null +++ b/packages/proxy/test/routes/upstream-health-boundary.test.ts @@ -0,0 +1,56 @@ +import { Database } from "bun:sqlite" +import { Hono } from "hono" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +const { proxyUrl } = vi.hoisted(() => ({ proxyUrl: vi.fn() })) +vi.mock("../../src/lib/socks5-bridge", async (importOriginal) => ({ + ...await importOriginal(), getProxyUrl: proxyUrl, +})) +import { createUpstreamsRoute } from "../../src/routes/upstreams" +import { createProvider, initProviders } from "../../src/db/providers" +import { state } from "../../src/lib/state" + +let db: Database +const fetcher = vi.fn() +const savedProviders = state.providers +beforeEach(() => { + db = new Database(":memory:") + initProviders(db) + proxyUrl.mockReturnValue("http://proxy.fixture.invalid:1234") + fetcher.mockReset().mockRejectedValue(new Error("Unconfigured mock transport")) + vi.stubGlobal("fetch", fetcher) +}) +afterEach(() => { db.close(); state.providers = savedProviders; vi.unstubAllGlobals() }) + +describe("provider health transport boundaries", () => { + test.each(["bearer", "x-api-key"] as const)("honors stored %s authentication and configured proxy using only a mocked transport", async (style) => { + const provider = createProvider(db, { name: "fixture", base_url: "https://provider.fixture.invalid", format: "anthropic", api_key: "synthetic-key", model_patterns: ["fixture"], auth_style: style }) + fetcher.mockResolvedValueOnce(Response.json({})) + const response = await new Hono().route("/api", createUpstreamsRoute(db)).request(`/api/upstreams/${provider.id}/models`) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ healthy: true, total: 0 }) + expect(fetcher).toHaveBeenCalledOnce() + const init = fetcher.mock.calls[0]?.[1] + expect(init.proxy).toBe("http://proxy.fixture.invalid:1234") + const headers = new Headers(init.headers) + expect(headers.get(style === "bearer" ? "authorization" : "x-api-key")).toBe(style === "bearer" ? "Bearer synthetic-key" : "synthetic-key") + }) + + test.each(["opaque rejection", null])("does not report health after a non-Error transport failure: %j", async (failure) => { + const provider = createProvider(db, { name: "fixture", base_url: "https://provider.fixture.invalid", format: "openai", api_key: "synthetic-key", model_patterns: ["fixture"] }) + fetcher.mockRejectedValue(failure) + const response = await new Hono().route("/api", createUpstreamsRoute(db)).request(`/api/upstreams/${provider.id}/models`) + expect(response.status).toBe(502) + expect(await response.json()).toMatchObject({ healthy: false, supports_models_endpoint: false }) + expect(fetcher).toHaveBeenCalledOnce() + }) + + test("preserves failure status when reading an upstream error body also fails", async () => { + const provider = createProvider(db, { name: "fixture", base_url: "https://provider.fixture.invalid", format: "openai", api_key: "synthetic-key", model_patterns: ["fixture"] }) + const upstream = new Response("unreadable", { status: 503 }) + vi.spyOn(upstream, "text").mockRejectedValue(new Error("body read failed")) + fetcher.mockResolvedValueOnce(upstream) + const response = await new Hono().route("/api", createUpstreamsRoute(db)).request(`/api/upstreams/${provider.id}/models`) + expect(response.status).toBe(502) + expect(await response.json()).toMatchObject({ healthy: false, error: { type: "upstream_error", message: "Upstream returned 503: " } }) + }) +}) diff --git a/packages/proxy/test/services/proxy-option-contract.test.ts b/packages/proxy/test/services/proxy-option-contract.test.ts new file mode 100644 index 00000000..dc93a08f --- /dev/null +++ b/packages/proxy/test/services/proxy-option-contract.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +const { proxyUrl } = vi.hoisted(() => ({ proxyUrl: vi.fn() })) +vi.mock("../../src/lib/socks5-bridge", async (importOriginal) => ({ + ...await importOriginal(), + getProxyUrl: proxyUrl, +})) +import { getModels } from "../../src/services/copilot/get-models" +import { getCopilotToken } from "../../src/services/github/get-copilot-token" +import { getDeviceCode } from "../../src/services/github/get-device-code" +import { getGitHubUser } from "../../src/services/github/get-user" +import { getCopilotUsage } from "../../src/services/github/get-copilot-usage" +import { HTTPError } from "../../src/lib/error" +import { state } from "../../src/lib/state" + +const originalState = { ...state } +const fetcher = vi.fn() +beforeEach(() => { + proxyUrl.mockReturnValue("http://proxy.fixture.test:1234") + fetcher.mockReset() + vi.stubGlobal("fetch", fetcher) + state.githubToken = "synthetic-token" + state.copilotToken = "synthetic-token" + state.accountType = "individual" +}) +afterEach(() => { Object.assign(state, originalState); vi.unstubAllGlobals() }) + +describe.each([ + ["models", getModels], ["copilot token", getCopilotToken], ["device code", getDeviceCode], + ["github user", getGitHubUser], ["quota", getCopilotUsage], +])("mocked upstream proxy option: %s", (_name, invoke) => { + test("passes the configured proxy only to the mocked fetch transport", async () => { + fetcher.mockResolvedValueOnce(Response.json({ fixture: true })) + expect(await invoke()).toEqual({ fixture: true }) + expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ proxy: "http://proxy.fixture.test:1234" }) + }) + test("returns a structured HTTP failure without retrying the mocked upstream", async () => { + fetcher.mockResolvedValueOnce(new Response("fixture denied", { status: 403 })) + await expect(invoke()).rejects.toBeInstanceOf(HTTPError) + expect(fetcher).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/proxy/test/strategies/copilot-responses.test.ts b/packages/proxy/test/strategies/copilot-responses.test.ts index d5a93413..7fe05cbd 100644 --- a/packages/proxy/test/strategies/copilot-responses.test.ts +++ b/packages/proxy/test/strategies/copilot-responses.test.ts @@ -43,6 +43,55 @@ function makeReq(stream = false): ResponsesPayload { } describe("strategies/copilot-responses", () => { + test("drops malformed tool declarations while preserving valid namespaced functions", () => { + const s = makeCopilotResponses({ client: fakeClient(() => ({})) }) + const req = { model: "fixture", input: [], tools: [ + null, 3, { type: "namespace", name: 3, tools: [] }, + { type: "namespace", name: "ignored", tools: false }, + { type: "namespace", name: "mcp__", tools: [null, 3, {}, { type: "function", name: 4 }, { type: "function", name: "read" }] }, + ] } as unknown as ResponsesPayload + const prepared = s.prepare(req, makeCtx()) + expect(prepared.tools).toEqual([{ type: "function", name: "mcp__read" }]) + expect(req.tools).toHaveLength(5) + }) + + test("flattens an already-prefixed historical call even when all supplied tools were discarded", () => { + const s = makeCopilotResponses({ client: fakeClient(() => ({})) }) + const req = { model: "fixture", input: [{ type: "function_call", namespace: "mcp__", name: "mcp__read" }], tools: [null] } as unknown as ResponsesPayload + const prepared = s.prepare(req, makeCtx()) + expect(prepared).not.toHaveProperty("tools") + expect(prepared.input).toEqual([{ type: "function_call", name: "mcp__read" }]) + }) + + test("keeps unrelated and deeply nested output stable instead of rewriting arbitrary data", () => { + const s = makeCopilotResponses({ client: fakeClient(() => ({})) }) + const tools = [{ type: "namespace", name: "mcp__", tools: [{ type: "function", name: "read" }] }] + let deep: unknown = { type: "function_call", name: "mcp__read" } + for (let i = 0; i < 24; i++) deep = { nested: deep } + const req = s.prepare({ model: "fixture", input: [deep], tools } as unknown as ResponsesPayload, makeCtx()) + const output = { unrelated: [null, 3, { type: "function_call", name: "other" }], deep } + expect(s.adaptJson(output, req, makeCtx())).toBe(output) + const st = s.initStreamState(req, makeCtx()) + for (const data of ['{ "type": "reasoning", "values": [] }', "{"]) { + const chunk = { event: null, data, id: null, retry: null } + expect(s.adaptChunk(chunk, st, makeCtx())[0]?.data).toBe(data) + } + }) + + test("restores nested tool names without mutating unrelated response fields", () => { + const s = makeCopilotResponses({ client: fakeClient(() => ({})) }) + const req = s.prepare({ model: "fixture", input: [], tools: [{ type: "namespace", name: "mcp__", tools: [{ type: "function", name: "read" }] }] } as unknown as ResponsesPayload, makeCtx()) + const response = { wrapper: { output: [{ type: "function_call", name: "mcp__read" }] }, untouched: [1, null] } + const restored = s.adaptJson(response, req, makeCtx()) + expect(restored).toEqual({ wrapper: { output: [{ type: "function_call", name: "read", namespace: "mcp__" }] }, untouched: [1, null] }) + expect(response.wrapper.output[0]?.name).toBe("mcp__read") + }) + + test("handles an empty non-iterable streaming response without starting any network activity", async () => { + const s = makeCopilotResponses({ client: fakeClient(() => null) }) + expect(await s.dispatch(makeReq(true), makeCtx())).toEqual({ kind: "json", body: null }) + }) + let captured: LogEvent[] let off: () => void beforeEach(() => { diff --git a/packages/proxy/test/strategies/custom-openai.test.ts b/packages/proxy/test/strategies/custom-openai.test.ts index 99f6f49d..34389dba 100644 --- a/packages/proxy/test/strategies/custom-openai.test.ts +++ b/packages/proxy/test/strategies/custom-openai.test.ts @@ -70,6 +70,21 @@ function makeJsonResp(model = "gpt-4o"): ChatCompletionResponse { } describe("strategies/custom-openai", () => { + test.each([undefined, "client-model"])("handles empty SSE data and missing usage in mode %s", (originalModel) => { + const s = makeCustomOpenAI({ client: fakeClient(() => makeJsonResp()), filterWhitespaceChunks: false, toolCallDebug: false }) + const req = makeReq(originalModel ? { originalModel } : {}) + const st = s.initStreamState(req, makeCtx()) + const empty = { event: null, data: "", id: null, retry: null } + expect(s.adaptChunk(empty, st, makeCtx())).toEqual(originalModel ? [] : [empty]) + const chunk = { event: null, data: JSON.stringify({ id: "c1", choices: [], usage: {} }), id: null, retry: null } + s.adaptChunk(chunk, st, makeCtx()) + expect(st.inputTokens).toBe(0) + expect(st.outputTokens).toBe(0) + const response = { ...makeJsonResp(), usage: undefined } as unknown as ChatCompletionResponse + expect(s.describeEndLog({ kind: "json", req, resp: response }, makeCtx())).toMatchObject({ inputTokens: 0, outputTokens: 0 }) + expect(s.describeEndLog({ kind: "stream", req, state: st }, makeCtx())).toMatchObject({ stopReason: "end_turn", toolCallCount: 0 }) + }) + let captured: LogEvent[] let off: () => void beforeEach(() => { diff --git a/packages/proxy/test/strategies/server-tools-boundary.test.ts b/packages/proxy/test/strategies/server-tools-boundary.test.ts new file mode 100644 index 00000000..9ddb1e45 --- /dev/null +++ b/packages/proxy/test/strategies/server-tools-boundary.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +const { search } = vi.hoisted(() => ({ search: vi.fn() })) +vi.mock("../../src/lib/server-tools/tavily", async (importOriginal) => ({ + ...await importOriginal(), searchTavily: search, +})) +import { TavilyError } from "../../src/lib/server-tools/tavily" +import { state } from "../../src/lib/state" +import { withServerToolInterception } from "../../src/strategies/support/server-tools" +import type { AnthropicMessagesPayload, AnthropicResponse } from "../../src/protocols/anthropic/types" + +const savedKey = state.stWebSearchApiKey +beforeEach(() => { state.stWebSearchApiKey = "synthetic-web-key"; search.mockReset() }) +afterEach(() => { state.stWebSearchApiKey = savedKey }) +const pure = { hasServerSideTools: true, allServerSide: true, serverSideToolNames: ["web_search"] } +const payload = (messages: unknown[]): AnthropicMessagesPayload => ({ model: "fixture-model", max_tokens: 100, messages, tools: null, tool_choice: null } as AnthropicMessagesPayload) +const answer = { id: "fixture-response", model: "fixture-model", content: [{ type: "text", text: "answer" }] } as AnthropicResponse + +describe("server tool boundaries with a mocked search client", () => { + test.each([ + { messages: [] }, + { messages: [{ role: "assistant", content: "context" }] }, + { messages: [{ role: "user", content: 42 }] }, + ])("does not search without a usable user query: %j", async ({ messages }) => { + const send = vi.fn().mockResolvedValue(answer) + expect(await withServerToolInterception(payload(messages), pure, send, "fixture")).toBe(answer) + expect(send.mock.calls[0]?.[0]).toMatchObject({ tools: null, tool_choice: null }) + expect(search).not.toHaveBeenCalled() + }) + + test("extracts text from mixed user blocks and tolerates missing synthesis metadata", async () => { + const send = vi.fn().mockResolvedValue({ model: "fixture-model", id: "" }) + search.mockResolvedValueOnce({ content: { type: "web_search_tool_result_error", error_code: "unavailable" }, textContent: "No results" }) + const response = await withServerToolInterception(payload([{ role: "user", content: [{ type: "text", text: "query" }, { type: "image" }, { type: "text", text: 3 }] }]), pure, send, "fixture") + expect(search).toHaveBeenCalledWith("synthetic-web-key", { query: "query" }) + expect(response.id).toMatch(/^msg_/) + expect(response.usage).toMatchObject({ input_tokens: 0, output_tokens: 0 }) + expect(response.content?.at(-1)).toEqual({ type: "text", text: "" }) + }) + + test("passes numeric paging fields and an absent query through the mixed tool executor", async () => { + search.mockResolvedValueOnce({ content: [], textContent: "none" }) + const send = vi.fn() + .mockResolvedValueOnce({ ...answer, content: [{ type: "tool_use", id: "call-1", name: "web_search", input: { count: 2, offset: 0 } }] }) + .mockResolvedValueOnce(answer) + await withServerToolInterception(payload([{ role: "user", content: "query" }]), { ...pure, allServerSide: false }, send, "fixture") + expect(search).toHaveBeenCalledWith("synthetic-web-key", { query: "", count: 2, offset: 0 }) + expect(send).toHaveBeenCalledTimes(2) + }) + + test("returns a sparse mixed-mode response without attempting an unnecessary tool loop", async () => { + const sparse = { id: "fixture", model: "fixture-model" } + const send = vi.fn().mockResolvedValue(sparse) + expect(await withServerToolInterception(payload([]), { ...pure, allServerSide: false }, send, "fixture")).toBe(sparse) + expect(search).not.toHaveBeenCalled() + }) + + test.each([new TavilyError("fixture limit", 429, "rate_limit"), new Error("fixture transport")])("propagates search errors without retrying a live provider: %#", async (error) => { + search.mockRejectedValueOnce(error) + const send = vi.fn() + await expect(withServerToolInterception(payload([{ role: "user", content: "query" }]), pure, send, "fixture")).rejects.toThrow(error.message) + expect(search).toHaveBeenCalledOnce() + expect(send).not.toHaveBeenCalled() + }) +}) diff --git a/packages/proxy/test/strategies/server-tools-decorate.test.ts b/packages/proxy/test/strategies/server-tools-decorate.test.ts index afa6e2f3..01b7b2cc 100644 --- a/packages/proxy/test/strategies/server-tools-decorate.test.ts +++ b/packages/proxy/test/strategies/server-tools-decorate.test.ts @@ -101,6 +101,25 @@ async function runDecorate( } describe("decorate()", () => { + test("logs zero usage when an upstream omits token metadata and no strategy extras exist", async () => { + const { extras: _extras, ...log } = baseLogFields() + const sparse = { ...makeResp(), usage: undefined } as unknown as AnthropicResponse + const { response, events } = await runDecorate({ log, sendRequest: async () => sparse }) + expect(response.status).toBe(200) + const end = events.find((event) => event.type === "request_end") + expect(end?.data).toMatchObject({ inputTokens: 0, outputTokens: 0, status: "success" }) + expect(end?.data).not.toHaveProperty("routingPath") + }) + + test("records an upstream failure without requiring optional strategy log extras", async () => { + const { extras: _extras, ...log } = baseLogFields() + const { response, events } = await runDecorate({ log, sendRequest: async () => { throw new Error("fixture denied") } }) + expect(response.status).toBe(500) + const end = events.find((event) => event.type === "request_end") + expect(end?.data).toMatchObject({ status: "error" }) + expect(end?.data).not.toHaveProperty("routingPath") + }) + let originalApiKey: string | null beforeEach(() => { originalApiKey = state.stWebSearchApiKey diff --git a/packages/proxy/test/translate/anthropic-to-openai.test.ts b/packages/proxy/test/translate/anthropic-to-openai.test.ts index 9bd692bd..551bd0c8 100644 --- a/packages/proxy/test/translate/anthropic-to-openai.test.ts +++ b/packages/proxy/test/translate/anthropic-to-openai.test.ts @@ -82,6 +82,39 @@ describe("system prompt", () => { // =========================================================================== describe("text messages", () => { + test("reorders known tool results while retaining unknown results when orphan sanitization is disabled", () => { + const result = translateToOpenAI(makeRequest({ messages: [ + { role: "assistant", content: [ + { type: "tool_use", id: "a", name: "read", input: {} }, + { type: "tool_use", id: "b", name: "read", input: {} }, + ] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "unknown-x", content: null }, + { type: "tool_result", tool_use_id: "b", content: "second" }, + { type: "tool_result", tool_use_id: "unknown-y", content: "extra" }, + { type: "tool_result", tool_use_id: "a", content: "first" }, + ] }, + ] } as unknown as Partial), { reorderToolResults: true, sanitizeOrphanedToolResults: false }) + const tools = result.messages.filter((message) => message.role === "tool") + expect(tools.map((message) => message.tool_call_id)).toEqual(["a", "b", "unknown-x", "unknown-y"]) + expect(tools[2]?.content).toBeNull() + }) + + test("normalizes non-string legacy user content without fabricating text", () => { + const request = makeRequest({ messages: [{ role: "user", content: null }] } as unknown as Partial) + expect(translateToOpenAI(request).messages[0]?.content).toBeNull() + }) + + test.each([ + [[{ type: "text", text: "one" }, { type: "text", text: "two" }], "one\n\ntwo"], + [[{ type: "thinking", thinking: "one" }], "one"], + [[{ type: "thinking", thinking: "one" }, { type: "thinking", thinking: "two" }], "one\n\ntwo"], + ])("retains text or thinking beside a tool call: %#", (blocks, content) => { + const result = translateToOpenAI(makeRequest({ messages: [{ role: "assistant", content: [...blocks, { type: "tool_use", id: "call-1", name: "read", input: {} }] }] } as unknown as Partial)) + expect(result.messages[0]?.content).toBe(content) + expect(result.messages[0]?.tool_calls?.[0]?.function.name).toBe("read") + }) + test("string content passes through", () => { const result = translateToOpenAI( makeRequest({ diff --git a/packages/proxy/test/util/terminal-format-boundary.test.ts b/packages/proxy/test/util/terminal-format-boundary.test.ts new file mode 100644 index 00000000..43de2a15 --- /dev/null +++ b/packages/proxy/test/util/terminal-format-boundary.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, test, vi } from "vitest" +import type { LogEvent } from "../../src/util/log-event" + +afterEach(() => { vi.unstubAllEnvs(); vi.resetModules() }) + +describe.each([true, false])("terminal formatting with NO_COLOR=%s", (noColor) => { + test("formats sparse request/error events without exposing undefined fields", async () => { + vi.stubEnv("NO_COLOR", noColor ? "1" : undefined) + vi.resetModules() + const { formatEvent, shortenSession } = await import("../../src/util/terminal-format") + const base: LogEvent = { ts: 0, level: "info", type: "request_start", requestId: null, msg: "fixture" } + const start = formatEvent(base)! + expect(start).toContain("unknown") + expect(start.includes("\u001b[")).toBe(!noColor) + const success = formatEvent({ ...base, type: "request_end", data: { statusCode: null } })! + expect(success).toContain("200") + expect(success).toContain("0→0 tok") + expect(success).not.toContain("undefined") + const failure = formatEvent({ ...base, type: "request_end", data: { status: "error" } })! + expect(failure).toContain("err") + expect(failure).not.toContain("undefined") + expect(formatEvent({ ...base, type: "upstream_error" })).toContain("fixture") + expect(shortenSession("::")).toBe("unknown") + expect(shortenSession("")).toBe("unknown") + for (const level of ["debug", "info", "warn", "error"] as const) { + expect(formatEvent({ ...base, type: "system", level })).toContain("fixture") + } + expect(formatEvent({ ...base, type: "system", level: "trace" as LogEvent["level"] })).toContain("TRACE") + }) +}) diff --git a/packages/proxy/vitest.config.ts b/packages/proxy/vitest.config.ts index 46ab1f37..bb2c5630 100644 --- a/packages/proxy/vitest.config.ts +++ b/packages/proxy/vitest.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ lines: 95, statements: 95, functions: 95, - branches: 89, + branches: 95, }, }, }, diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts index ed0ae8cc..0f947dfe 100644 --- a/scripts/check-coverage.ts +++ b/scripts/check-coverage.ts @@ -102,8 +102,9 @@ async function runVitest( } if (!skipTests) { - // Scripts/lib tests live under the root vitest "scripts" project. - const libRun = await runVitest(["--project", "scripts"], REPO_ROOT) + // Measure scripts/lib in its own Istanbul configuration. Running a workspace + // project without --coverage never enforced that project's declared floors. + const libRun = await runVitest(["--config", "vitest.scripts.config.ts", "--coverage"], REPO_ROOT) if (libRun.exitCode !== 0) process.exit(libRun.exitCode) // Proxy L1 tests with coverage — vitest istanbul provider emits diff --git a/scripts/lib/__tests__/coverage.test.ts b/scripts/lib/__tests__/coverage.test.ts index e5e11b21..1fb0c84b 100644 --- a/scripts/lib/__tests__/coverage.test.ts +++ b/scripts/lib/__tests__/coverage.test.ts @@ -53,6 +53,24 @@ function baseline(overrides: Partial = {}, proxyOverrid } describe("parseLcov", () => { + test("handles empty counters and root entrypoints without dividing by zero or counting helper code", () => { + const report = parseLcov( + "SF:src/lib/types.ts\nLF:invalid\nLH:invalid\nend_of_record\n" + + "SF:src/index.ts\nLF:2\nLH:2\nend_of_record\n", + ) + expect(report.total).toEqual({ linesFound: 0, linesHit: 0, pct: 0 }) + expect(report.byDirectory.lib).toEqual({ linesFound: 0, linesHit: 0, pct: 0 }) + expect(report.byFile).toHaveLength(2) + }) + + test("combines multiple files in the same directory using line counts, not average percentages", () => { + const report = parseLcov( + "SF:src/lib/a.ts\nLF:1\nLH:1\nend_of_record\n" + + "SF:src/lib/b.ts\nLF:9\nLH:0\nend_of_record\n", + ) + expect(report.byDirectory.lib).toEqual({ linesFound: 10, linesHit: 1, pct: 10 }) + }) + test("aggregates total lines found and hit", () => { const r = parseLcov(SAMPLE_LCOV) expect(r.total.linesFound).toBe(8) @@ -98,6 +116,19 @@ describe("parseLcov", () => { describe("evaluateGate", () => { const report = parseLcov(SAMPLE_LCOV) + test("accepts a met directory floor without inventing a regression for an unmeasured baseline", () => { + const result = evaluateGate(report, baseline( + { mode: "enforce", globalFloorPct: 50, perDirectoryFloors: { routes: 60 } }, + { l1TestCount: 100, perDirectoryCoveragePct: { routes: null } }, + )) + expect(result).toEqual([]) + }) + + test("does not classify a reported zero-runtime declaration as an untested executable file", () => { + const report = parseLcov(`${SAMPLE_LCOV}SF:src/lib/types.ts\nLF:0\nLH:0\nend_of_record\n`) + expect(evaluateGate(report, baseline(), { sourceFiles: ["src/lib/types.ts"] })).toEqual([]) + }) + test("passes when above floor in placeholder mode", () => { expect(evaluateGate(report, baseline({ globalFloorPct: 50 }))).toEqual([]) }) diff --git a/vitest.config.ts b/vitest.config.ts index 80c8857c..1fc3264e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,29 +5,7 @@ export default defineConfig({ projects: [ "packages/proxy", "packages/dashboard", - { - test: { - name: "scripts", - include: ["scripts/**/__tests__/**/*.test.ts"], - exclude: ["node_modules/**"], - server: { - deps: { - external: [/^bun(:|$)/], - }, - }, - coverage: { - provider: "istanbul", - include: ["scripts/lib/**/*.ts"], - exclude: ["scripts/lib/**/__tests__/**"], - thresholds: { - lines: 95, - statements: 95, - functions: 95, - branches: 89, - }, - }, - }, - }, + "./vitest.scripts.config.ts", ], }, }) diff --git a/vitest.scripts.config.ts b/vitest.scripts.config.ts new file mode 100644 index 00000000..35d03178 --- /dev/null +++ b/vitest.scripts.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config" + +// Standalone coverage configuration: project-local coverage options are not +// the workspace runner's coverage policy. The baseline gate invokes this +// file explicitly, keeping scripts reports separate from Proxy and Dashboard. +export default defineConfig({ + test: { + name: "scripts", + include: ["scripts/**/__tests__/**/*.test.ts"], + exclude: ["node_modules/**"], + server: { deps: { external: [/^bun(:|$)/] } }, + coverage: { + provider: "istanbul", + reporter: ["text", "json", "lcov"], + reportsDirectory: "coverage/scripts", + include: ["scripts/lib/**/*.ts"], + exclude: ["scripts/lib/**/__tests__/**"], + thresholds: { + lines: 95, + statements: 95, + functions: 95, + branches: 95, + }, + }, + }, +})