mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Refactor router config handling
This commit is contained in:
parent
20691a8215
commit
1a8d7cb610
17 changed files with 754 additions and 53 deletions
|
|
@ -8,26 +8,60 @@ import { fileURLToPath } from "node:url";
|
|||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-test-home-"));
|
||||
const testSuites = ["main", "renderer"];
|
||||
const requestedSuites = process.argv.slice(2);
|
||||
const suites = requestedSuites.length === 0 ? testSuites : requestedSuites;
|
||||
|
||||
const child = spawn(electron, ["--test", "dist/tests/*.js"], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"),
|
||||
CCR_INTERNAL_HOME_DIR: testHome,
|
||||
CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"),
|
||||
HOME: testHome,
|
||||
ELECTRON_RUN_AS_NODE: "1"
|
||||
},
|
||||
shell: process.platform === "win32",
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
rmSync(testHome, { force: true, recursive: true });
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
for (const suite of suites) {
|
||||
if (!testSuites.includes(suite)) {
|
||||
cleanup();
|
||||
throw new Error(`Unknown test suite: ${suite}`);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
for (const suite of suites) {
|
||||
await runSuite(suite);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function runSuite(suite) {
|
||||
console.log(`\nRunning ${suite} tests...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(electron, ["--test", `dist/tests/${suite}/*.js`], {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"),
|
||||
CCR_INTERNAL_HOME_DIR: testHome,
|
||||
CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"),
|
||||
HOME: testHome,
|
||||
ELECTRON_RUN_AS_NODE: "1"
|
||||
},
|
||||
shell: process.platform === "win32",
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${suite} tests exited with code ${code ?? 1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
rmSync(testHome, { force: true, recursive: true });
|
||||
}
|
||||
|
|
|
|||
112
build/test.mjs
112
build/test.mjs
|
|
@ -1,44 +1,112 @@
|
|||
import esbuild from "esbuild";
|
||||
import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const outdir = path.join(projectRoot, "dist", "tests");
|
||||
const testsOutDir = path.join(projectRoot, "dist", "tests");
|
||||
const rendererRoot = path.join(projectRoot, "src", "renderer");
|
||||
const testSuites = [
|
||||
{ name: "main", testDir: path.join(projectRoot, "tests", "main") },
|
||||
{ name: "renderer", testDir: path.join(projectRoot, "tests", "renderer") }
|
||||
];
|
||||
const requestedSuites = new Set(process.argv.slice(2));
|
||||
const suiteNames = new Set(testSuites.map((suite) => suite.name));
|
||||
const unknownSuites = [...requestedSuites].filter((suite) => !suiteNames.has(suite));
|
||||
const selectedSuites = requestedSuites.size === 0
|
||||
? testSuites
|
||||
: testSuites.filter((suite) => requestedSuites.has(suite.name));
|
||||
|
||||
rmSync(outdir, { force: true, recursive: true });
|
||||
mkdirSync(outdir, { recursive: true });
|
||||
if (unknownSuites.length > 0) {
|
||||
throw new Error(`Unknown test suite: ${unknownSuites.join(", ")}`);
|
||||
}
|
||||
|
||||
const entryPoints = findTestFiles(path.join(projectRoot, "tests"));
|
||||
rmSync(testsOutDir, { force: true, recursive: true });
|
||||
|
||||
await esbuild.build({
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints,
|
||||
external: [
|
||||
"better-sqlite3",
|
||||
"electron"
|
||||
],
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
logLevel: "info",
|
||||
outdir,
|
||||
platform: "node",
|
||||
target: "node22"
|
||||
});
|
||||
for (const suite of selectedSuites) {
|
||||
const entryPoints = findTestFiles(suite.testDir);
|
||||
if (entryPoints.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
absWorkingDir: projectRoot,
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints,
|
||||
external: [
|
||||
"better-sqlite3",
|
||||
"electron"
|
||||
],
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
loader: {
|
||||
".gif": "file",
|
||||
".ico": "file",
|
||||
".jpg": "file",
|
||||
".jpeg": "file",
|
||||
".png": "file",
|
||||
".svg": "file",
|
||||
".webp": "file"
|
||||
},
|
||||
logLevel: "info",
|
||||
outdir: path.join(testsOutDir, suite.name),
|
||||
platform: "node",
|
||||
plugins: [rendererAliasPlugin()],
|
||||
target: "node22"
|
||||
});
|
||||
}
|
||||
|
||||
function findTestFiles(dir) {
|
||||
if (!existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const file = path.join(dir, name);
|
||||
const stat = statSync(file);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...findTestFiles(file));
|
||||
} else if (name.endsWith(".test.mjs")) {
|
||||
} else if (/\.(test|spec)\.(mjs|ts|tsx)$/.test(name)) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function rendererAliasPlugin() {
|
||||
return {
|
||||
name: "renderer-test-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, (args) => {
|
||||
return { path: resolveRendererImport(args.path.slice(2)) };
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRendererImport(importPath) {
|
||||
const basePath = path.resolve(rendererRoot, importPath);
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.jsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.json`,
|
||||
path.join(basePath, "index.tsx"),
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.jsx"),
|
||||
path.join(basePath, "index.js")
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return basePath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@
|
|||
"prepublishOnly": "npm run typecheck",
|
||||
"preview": "npm run build:assets && electron .",
|
||||
"test": "node build/test.mjs && node build/run-tests.mjs",
|
||||
"test:main": "node build/test.mjs main && node build/run-tests.mjs main",
|
||||
"test:renderer": "node build/test.mjs renderer && node build/run-tests.mjs renderer",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { botGatewayProfileEnv } from "../src/main/bot-gateway-env.ts";
|
||||
import { botGatewayProfileEnv } from "../../src/main/bot-gateway-env.ts";
|
||||
|
||||
function botGateway(overrides = {}) {
|
||||
return {
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
parseProviderDeepLinkPayload,
|
||||
parseProviderManifestDeepLinkPayload,
|
||||
parseProviderManifestPayload
|
||||
} from "../src/shared/deep-link.ts";
|
||||
} from "../../src/shared/deep-link.ts";
|
||||
|
||||
function base64UrlJson(value) {
|
||||
return Buffer.from(JSON.stringify(value), "utf8")
|
||||
|
|
@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { loadModelCatalogPayload, modelCatalogPathCandidates } from "../src/main/model-catalog-file.ts";
|
||||
import { loadModelCatalogPayload, modelCatalogPathCandidates } from "../../src/main/model-catalog-file.ts";
|
||||
|
||||
test("modelCatalogPathCandidates prefers env paths and removes duplicates", () => {
|
||||
const previousCatalogPath = process.env.CCR_MODEL_CATALOG_PATH;
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
resolveClaudeCodeSettingsFile,
|
||||
resolveCodexConfigFile,
|
||||
resolveProfileOpenSurface
|
||||
} from "../src/main/profile-launch-core.ts";
|
||||
} from "../../src/main/profile-launch-core.ts";
|
||||
|
||||
const claudeProfile = {
|
||||
agent: "claude-code",
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
providerEndpointCanReceiveProviderApiKeyInList,
|
||||
providerIdentitySafetyIssueInList,
|
||||
providerPresetMatchesBaseUrl
|
||||
} from "../src/shared/provider-preset-utils.ts";
|
||||
} from "../../src/shared/provider-preset-utils.ts";
|
||||
|
||||
const openAiPreset = {
|
||||
aliases: ["OpenAI", "ChatGPT"],
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
parseProviderBaseUrl,
|
||||
providerBaseUrlForProtocol,
|
||||
providerUrlWithDefaultScheme
|
||||
} from "../src/shared/provider-url.ts";
|
||||
} from "../../src/shared/provider-url.ts";
|
||||
|
||||
test("provider URL parsing strips endpoint paths and unsafe URL parts", () => {
|
||||
const parsed = parseProviderBaseUrl("https://user:secret@api.example.com/v1/chat/completions?token=secret#section");
|
||||
|
|
@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { RequestLogStore } from "../src/main/request-log-store.ts";
|
||||
import { RequestLogStore } from "../../src/main/request-log-store.ts";
|
||||
|
||||
test("RequestLogStore keeps list rows lightweight and detail rows complete", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-test-"));
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { normalizeUsageInputTokens } from "../src/main/usage-normalization.ts";
|
||||
import { normalizeUsageInputTokens } from "../../src/main/usage-normalization.ts";
|
||||
|
||||
test("normalizeUsageInputTokens subtracts cache tokens for OpenAI-compatible protocols", () => {
|
||||
const usage = normalizeUsageInputTokens(
|
||||
|
|
@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { UsageStore } from "../src/main/usage-store.ts";
|
||||
import { UsageStore } from "../../src/main/usage-store.ts";
|
||||
|
||||
test("UsageStore aggregates stats in SQLite without loading all events", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-test-"));
|
||||
79
tests/renderer/components.test.tsx
Normal file
79
tests/renderer/components.test.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Badge } from "../../src/renderer/components/ui/badge.tsx";
|
||||
import { Button } from "../../src/renderer/components/ui/button.tsx";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../src/renderer/components/ui/card.tsx";
|
||||
import { Switch } from "../../src/renderer/components/ui/switch.tsx";
|
||||
|
||||
test("Button renders default button semantics and variant classes", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Button className="extra-action" size="iconSm" variant="outline">
|
||||
Run
|
||||
</Button>
|
||||
);
|
||||
|
||||
assert.match(html, /^<button /);
|
||||
assert.match(html, /type="button"/);
|
||||
assert.match(html, /border-input/);
|
||||
assert.match(html, /h-7/);
|
||||
assert.match(html, /w-7/);
|
||||
assert.match(html, /extra-action/);
|
||||
assert.match(html, />Run<\/button>$/);
|
||||
});
|
||||
|
||||
test("Button unstyled mode keeps caller supplied styling only", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Button className="plain-button" unstyled>
|
||||
Plain
|
||||
</Button>
|
||||
);
|
||||
|
||||
assert.match(html, /class="plain-button"/);
|
||||
assert.match(html, /type="button"/);
|
||||
assert.doesNotMatch(html, /inline-flex/);
|
||||
});
|
||||
|
||||
test("Badge renders the selected visual variant", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Badge className="status-badge" variant="warning">
|
||||
Delayed
|
||||
</Badge>
|
||||
);
|
||||
|
||||
assert.match(html, /^<span /);
|
||||
assert.match(html, /text-amber-700/);
|
||||
assert.match(html, /bg-amber-50/);
|
||||
assert.match(html, /status-badge/);
|
||||
assert.match(html, />Delayed<\/span>$/);
|
||||
});
|
||||
|
||||
test("Card primitives compose the expected document structure", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Card className="settings-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Provider settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>Ready</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
assert.match(html, /^<div /);
|
||||
assert.match(html, /settings-card/);
|
||||
assert.match(html, /<h2 class="[^"]*text-\[13px\][^"]*">Provider settings<\/h2>/);
|
||||
assert.match(html, /<div class="p-4">Ready<\/div>/);
|
||||
});
|
||||
|
||||
test("Switch renders accessible checked and disabled state", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Switch aria-label="Enable provider" checked className="provider-switch" disabled />
|
||||
);
|
||||
|
||||
assert.match(html, /provider-switch/);
|
||||
assert.match(html, /role="switch"/);
|
||||
assert.match(html, /aria-checked="true"/);
|
||||
assert.match(html, /aria-label="Enable provider"/);
|
||||
assert.match(html, /disabled=""/);
|
||||
assert.match(html, /translate-x-\[24px\]/);
|
||||
});
|
||||
189
tests/renderer/fixtures.ts
Normal file
189
tests/renderer/fixtures.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import type {
|
||||
ProviderAccountSnapshot,
|
||||
UsageComparisonRow,
|
||||
UsageStatsRange,
|
||||
UsageStatsSnapshot,
|
||||
UsageTotals
|
||||
} from "../../src/shared/app.ts";
|
||||
|
||||
export function installBrowserGlobals() {
|
||||
const localStorage = {
|
||||
getItem: () => null,
|
||||
removeItem: () => undefined,
|
||||
setItem: () => undefined
|
||||
};
|
||||
const windowMock = {
|
||||
addEventListener: () => undefined,
|
||||
cancelAnimationFrame: () => undefined,
|
||||
ccr: {
|
||||
closeTray: () => undefined,
|
||||
quitApp: () => undefined,
|
||||
setTrayDetailOpen: () => undefined,
|
||||
showMainWindow: () => undefined
|
||||
},
|
||||
clearInterval: () => undefined,
|
||||
localStorage,
|
||||
matchMedia: () => ({
|
||||
addEventListener: () => undefined,
|
||||
matches: false,
|
||||
removeEventListener: () => undefined
|
||||
}),
|
||||
removeEventListener: () => undefined,
|
||||
requestAnimationFrame: () => 0,
|
||||
setInterval: () => 0
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: windowMock
|
||||
});
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: {
|
||||
body: {
|
||||
classList: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined
|
||||
},
|
||||
style: {}
|
||||
},
|
||||
documentElement: {
|
||||
lang: "en"
|
||||
}
|
||||
}
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
language: "en-US",
|
||||
languages: ["en-US"]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function usageTotals(patch: Partial<UsageTotals> = {}): UsageTotals {
|
||||
return {
|
||||
avgDurationMs: 842,
|
||||
cacheRatio: 0.24,
|
||||
cacheTokens: 2400,
|
||||
costUsd: 1.23,
|
||||
errorCount: 2,
|
||||
inputTokens: 6200,
|
||||
outputTokens: 3400,
|
||||
requestCount: 128,
|
||||
successRate: 0.984,
|
||||
totalTokens: 12000,
|
||||
...patch
|
||||
};
|
||||
}
|
||||
|
||||
export function usageRow(key: string, label: string, patch: Partial<UsageComparisonRow> = {}): UsageComparisonRow {
|
||||
return {
|
||||
...usageTotals(),
|
||||
caption: "Test usage row",
|
||||
key,
|
||||
label,
|
||||
maxShare: 0.5,
|
||||
...patch
|
||||
};
|
||||
}
|
||||
|
||||
export function usageStats(range: UsageStatsRange = "30d", patch: Partial<UsageStatsSnapshot> = {}): UsageStatsSnapshot {
|
||||
const series = Array.from({ length: 8 }, (_, index) => {
|
||||
const totalTokens = 1000 + index * 250;
|
||||
const requestCount = 8 + index;
|
||||
return {
|
||||
...usageTotals({
|
||||
cacheRatio: 0.1 + index * 0.02,
|
||||
cacheTokens: 120 + index * 30,
|
||||
errorCount: index % 4 === 0 ? 1 : 0,
|
||||
inputTokens: 560 + index * 120,
|
||||
outputTokens: 320 + index * 80,
|
||||
requestCount,
|
||||
successRate: index % 4 === 0 ? 0.92 : 1,
|
||||
totalTokens
|
||||
}),
|
||||
bucket: `2026-06-${String(20 + index).padStart(2, "0")}T00:00:00.000Z`,
|
||||
label: `6/${20 + index}`
|
||||
};
|
||||
});
|
||||
const models = [
|
||||
usageRow("model:gpt-4.1", "gpt-4.1", { maxShare: 0.62, model: "gpt-4.1", provider: "openai", totalTokens: 7200 }),
|
||||
usageRow("model:claude-sonnet", "claude-sonnet", { maxShare: 0.38, model: "claude-sonnet", provider: "anthropic", totalTokens: 4800 })
|
||||
];
|
||||
|
||||
return {
|
||||
clientModels: [
|
||||
usageRow("client:claude-code", "Claude Code", { client: "claude-code", model: "gpt-4.1", provider: "openai", totalTokens: 6800 }),
|
||||
usageRow("client:codex", "Codex", { client: "codex", model: "claude-sonnet", provider: "anthropic", totalTokens: 5200 })
|
||||
],
|
||||
generatedAt: "2026-06-30T00:00:00.000Z",
|
||||
models,
|
||||
providerModels: [
|
||||
usageRow("provider:openai", "OpenAI", { credentialId: "primary", model: "gpt-4.1", provider: "openai", totalTokens: 7200 }),
|
||||
usageRow("provider:anthropic", "Anthropic", { credentialId: "secondary", model: "claude-sonnet", provider: "anthropic", totalTokens: 4800 })
|
||||
],
|
||||
range,
|
||||
recentRequests: [],
|
||||
series,
|
||||
totals: usageTotals(),
|
||||
...patch
|
||||
};
|
||||
}
|
||||
|
||||
export function accountSnapshots(): ProviderAccountSnapshot[] {
|
||||
return [
|
||||
{
|
||||
credentialId: "primary",
|
||||
credentialLabel: "Primary Key",
|
||||
meters: [
|
||||
{
|
||||
id: "5h",
|
||||
kind: "quota",
|
||||
label: "5h quota",
|
||||
limit: 100,
|
||||
remaining: 42,
|
||||
unit: "requests",
|
||||
window: "5h"
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
kind: "quota",
|
||||
label: "Weekly quota",
|
||||
limit: 500,
|
||||
remaining: 310,
|
||||
unit: "requests",
|
||||
window: "weekly"
|
||||
},
|
||||
{
|
||||
id: "balance",
|
||||
kind: "balance",
|
||||
label: "Cash balance",
|
||||
remaining: 12.34,
|
||||
unit: "USD"
|
||||
}
|
||||
],
|
||||
provider: "openai",
|
||||
source: "standard",
|
||||
status: "warning",
|
||||
updatedAt: "2026-06-30T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
credentialId: "secondary",
|
||||
credentialLabel: "Secondary Key",
|
||||
meters: [
|
||||
{
|
||||
id: "balance",
|
||||
kind: "balance",
|
||||
label: "Credit balance",
|
||||
remaining: 88,
|
||||
unit: "USD"
|
||||
}
|
||||
],
|
||||
provider: "anthropic",
|
||||
source: "standard",
|
||||
status: "ok",
|
||||
updatedAt: "2026-06-30T00:00:00.000Z"
|
||||
}
|
||||
];
|
||||
}
|
||||
71
tests/renderer/overview-components.test.tsx
Normal file
71
tests/renderer/overview-components.test.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { OverviewView } from "../../src/renderer/pages/home/components/dashboard.tsx";
|
||||
import type { OverviewWidgetConfig } from "../../src/shared/app.ts";
|
||||
import { accountSnapshots, installBrowserGlobals, usageStats } from "./fixtures.ts";
|
||||
|
||||
installBrowserGlobals();
|
||||
|
||||
test("OverviewView renders every overview widget type", () => {
|
||||
const widgets: OverviewWidgetConfig[] = [
|
||||
{ enabled: true, id: "status", size: "4:1", type: "system-status", variant: "timeline" },
|
||||
{ enabled: true, id: "account", size: "4:2", type: "account-balance", variant: "nested-rings" },
|
||||
{ enabled: true, id: "metric-requests", metric: "requests", size: "1:1", type: "metric", variant: "card" },
|
||||
{ enabled: true, id: "metric-cache", metric: "cache-ratio", size: "1:1", type: "metric", variant: "ring" },
|
||||
{ enabled: true, id: "metric-errors", metric: "errors", size: "1:1", type: "metric", variant: "bar" },
|
||||
{ enabled: true, id: "trend", size: "3:2", type: "usage-trend", variant: "composed" },
|
||||
{ enabled: true, id: "activity", size: "4:2", type: "token-activity", variant: "heatmap" },
|
||||
{ enabled: true, id: "token-mix", size: "2:2", type: "token-mix", variant: "stacked" },
|
||||
{ enabled: true, id: "models", size: "2:2", type: "model-distribution", variant: "donut" },
|
||||
{ enabled: true, id: "clients", size: "4:2", type: "client-analysis", variant: "table" },
|
||||
{ enabled: true, id: "providers", size: "4:2", type: "provider-analysis", variant: "table" }
|
||||
];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<OverviewView
|
||||
overviewWidgets={widgets}
|
||||
providerAccounts={accountSnapshots()}
|
||||
refreshProviderAccounts={() => undefined}
|
||||
setUsageRange={() => undefined}
|
||||
usageRange="30d"
|
||||
usageStats={usageStats("30d")}
|
||||
onWidgetsChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /<h2 class="[^"]*">Overview<\/h2>/);
|
||||
assert.match(html, /aria-label="Edit widgets"/);
|
||||
assert.match(html, /System status/);
|
||||
assert.match(html, /API Service/);
|
||||
assert.match(html, /openai \/ Primary Key/);
|
||||
assert.match(html, /Requests/);
|
||||
assert.match(html, /Cache ratio/);
|
||||
assert.match(html, /Errors/);
|
||||
assert.match(html, /Usage Trend/);
|
||||
assert.match(html, /Activity/);
|
||||
assert.match(html, /Token Mix/);
|
||||
assert.match(html, /Model Distribution/);
|
||||
assert.match(html, /Client Analysis/);
|
||||
assert.match(html, /Provider Analysis/);
|
||||
assert.match(html, /claude-code/);
|
||||
assert.match(html, /openai/);
|
||||
});
|
||||
|
||||
test("OverviewView renders the empty widget layout state", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OverviewView
|
||||
overviewWidgets={[]}
|
||||
providerAccounts={[]}
|
||||
setUsageRange={() => undefined}
|
||||
usageRange="7d"
|
||||
usageStats={usageStats("7d")}
|
||||
onWidgetsChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /Overview/);
|
||||
assert.match(html, /No widgets configured/);
|
||||
assert.match(html, /aria-label="Edit widgets"/);
|
||||
});
|
||||
258
tests/renderer/tray-components.test.tsx
Normal file
258
tests/renderer/tray-components.test.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
AccountSummaryPanel,
|
||||
AnimatedUsageChart,
|
||||
ChartShell,
|
||||
ModelShareChart,
|
||||
RadialMetric,
|
||||
RangeSwitch,
|
||||
RingMetrics,
|
||||
SourceGrid,
|
||||
StatsGrid,
|
||||
TokenActivityPanel,
|
||||
TokenMixPanel,
|
||||
TrayStatusStrip,
|
||||
UsageDetailPanel,
|
||||
UsageOverviewPanel
|
||||
} from "../../src/renderer/pages/tray/components/index.ts";
|
||||
import { TrayApp } from "../../src/renderer/pages/tray/TrayApp.tsx";
|
||||
import { TrayDetailApp } from "../../src/renderer/pages/tray/TrayDetailApp.tsx";
|
||||
import { accountSnapshots, installBrowserGlobals, usageStats, usageTotals } from "./fixtures.ts";
|
||||
|
||||
installBrowserGlobals();
|
||||
|
||||
const componentVariants = {
|
||||
account: "bar",
|
||||
modelShare: "bars",
|
||||
rings: "rings",
|
||||
stats: "cards",
|
||||
tokenFlow: "line",
|
||||
tokenMix: "bars"
|
||||
} as const;
|
||||
|
||||
test("UsageOverviewPanel renders every enabled overview tray module", () => {
|
||||
const activeStats = usageStats("30d");
|
||||
const html = renderToStaticMarkup(
|
||||
<UsageOverviewPanel
|
||||
accountRefreshing
|
||||
accountSnapshots={accountSnapshots()}
|
||||
activeStats={activeStats}
|
||||
componentVariants={componentVariants}
|
||||
loading
|
||||
modules={new Set(["account", "token-flow", "activity", "stats", "token-mix", "rings", "model-share"])}
|
||||
monthTotals={usageTotals({ totalTokens: 32000 })}
|
||||
todayTotals={usageTotals({ requestCount: 12, totalTokens: 3400 })}
|
||||
topModel={activeStats.models[0]}
|
||||
weekTotals={usageTotals({ totalTokens: 8600 })}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /openai \/ Primary Key/);
|
||||
assert.match(html, /30d Token Flow/);
|
||||
assert.match(html, /Activity/);
|
||||
assert.match(html, /Today tokens/);
|
||||
assert.match(html, /Token Mix/);
|
||||
assert.match(html, /Circular metrics/);
|
||||
assert.match(html, /Model Share/);
|
||||
assert.match(html, /Syncing usage\.\.\./);
|
||||
});
|
||||
|
||||
test("UsageDetailPanel renders configured detail widgets and empty state", () => {
|
||||
const activeStats = usageStats("7d");
|
||||
const html = renderToStaticMarkup(
|
||||
<UsageDetailPanel
|
||||
accountSnapshots={accountSnapshots()}
|
||||
activeStats={activeStats}
|
||||
provider="openai"
|
||||
range="7d"
|
||||
widgets={[
|
||||
{ id: "tabs", type: "source-tabs" },
|
||||
{ id: "header", type: "header" },
|
||||
{ id: "stats", type: "stats", variant: "compact" },
|
||||
{ id: "account", type: "account", variant: "stacked" },
|
||||
{ id: "flow", type: "token-flow", variant: "area" },
|
||||
{ id: "activity", type: "activity" },
|
||||
{ id: "mix", type: "token-mix", variant: "stacked" },
|
||||
{ id: "rings", type: "rings", variant: "arcs" },
|
||||
{ id: "share", type: "model-share", variant: "list" }
|
||||
]}
|
||||
onRangeChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
const emptyHtml = renderToStaticMarkup(
|
||||
<UsageDetailPanel
|
||||
accountSnapshots={[]}
|
||||
activeStats={activeStats}
|
||||
range="30d"
|
||||
widgets={[{ id: "tabs", type: "source-tabs" }]}
|
||||
onRangeChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, /Usage Detail/);
|
||||
assert.match(html, /7d - OpenAI/);
|
||||
assert.match(html, /7d tokens/);
|
||||
assert.match(html, /Token Flow/);
|
||||
assert.match(html, /Token Mix/);
|
||||
assert.match(html, /Circular metrics/);
|
||||
assert.match(html, /Model Share/);
|
||||
assert.match(emptyHtml, /No tray modules enabled/);
|
||||
});
|
||||
|
||||
test("TrayStatusStrip renders open and quit actions", () => {
|
||||
const html = renderToStaticMarkup(<TrayStatusStrip totalTokens={12500} />);
|
||||
|
||||
assert.match(html, /aria-label="Open CCR"/);
|
||||
assert.match(html, /title="Open CCR"/);
|
||||
assert.match(html, /12\.5K tokens/);
|
||||
assert.match(html, /CCR/);
|
||||
assert.match(html, /aria-label="Quit"/);
|
||||
});
|
||||
|
||||
test("SourceGrid renders provider tabs with the selected state", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SourceGrid
|
||||
selectedProvider="openai"
|
||||
tabs={[
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "provider:openai", label: "OpenAI", provider: "openai" },
|
||||
{ id: "provider:anthropic", label: "Anthropic", provider: "anthropic" }
|
||||
]}
|
||||
onSelect={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.match(html, />All<\/button>/);
|
||||
assert.match(html, /border-teal-300\/35 bg-teal-300\/16 text-teal-50/);
|
||||
assert.match(html, />OpenAI<\/button>/);
|
||||
assert.match(html, />Anthropic<\/button>/);
|
||||
});
|
||||
|
||||
test("AccountSummaryPanel covers empty and metered account states", () => {
|
||||
const emptyHtml = renderToStaticMarkup(<AccountSummaryPanel snapshots={[]} variant="bar" />);
|
||||
const meteredHtml = renderToStaticMarkup(
|
||||
<AccountSummaryPanel snapshots={accountSnapshots()} variant="stacked" onRefresh={() => undefined} />
|
||||
);
|
||||
|
||||
assert.match(emptyHtml, /No account data configured/);
|
||||
assert.match(meteredHtml, /openai \/ Primary Key/);
|
||||
assert.match(meteredHtml, /5h quota/);
|
||||
assert.match(meteredHtml, /42 requests/);
|
||||
assert.match(meteredHtml, /style="\s*width:42%"/);
|
||||
});
|
||||
|
||||
test("RangeSwitch renders every usage range option", () => {
|
||||
const html = renderToStaticMarkup(<RangeSwitch range="7d" onChange={() => undefined} />);
|
||||
|
||||
assert.match(html, />Today<\/button>/);
|
||||
assert.match(html, />24h<\/button>/);
|
||||
assert.match(html, /bg-white\/14 text-slate-50/);
|
||||
assert.match(html, />7d<\/button>/);
|
||||
assert.match(html, />30d<\/button>/);
|
||||
});
|
||||
|
||||
test("ChartShell renders title, meta, and children", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ChartShell meta="gpt-4.1" title="Token Flow">
|
||||
<span>chart body</span>
|
||||
</ChartShell>
|
||||
);
|
||||
|
||||
assert.match(html, /Token Flow/);
|
||||
assert.match(html, /gpt-4\.1/);
|
||||
assert.match(html, /chart body/);
|
||||
});
|
||||
|
||||
test("StatsGrid renders cards, compact, and pill variants", () => {
|
||||
const items = [
|
||||
{ label: "Tokens", value: "12K" },
|
||||
{ label: "Requests", value: "128" }
|
||||
];
|
||||
const cardsHtml = renderToStaticMarkup(<StatsGrid items={items} variant="cards" />);
|
||||
const compactHtml = renderToStaticMarkup(<StatsGrid items={items} variant="compact" />);
|
||||
const pillsHtml = renderToStaticMarkup(<StatsGrid items={items} variant="pills" />);
|
||||
|
||||
assert.match(cardsHtml, /grid-cols-2/);
|
||||
assert.match(compactHtml, /py-0\.5/);
|
||||
assert.match(pillsHtml, /rounded-full/);
|
||||
assert.match(`${cardsHtml}${compactHtml}${pillsHtml}`, /Tokens/);
|
||||
assert.match(`${cardsHtml}${compactHtml}${pillsHtml}`, /12K/);
|
||||
});
|
||||
|
||||
test("AnimatedUsageChart renders line, area, bar, and sparkline output", () => {
|
||||
const series = usageStats().series;
|
||||
const lineHtml = renderToStaticMarkup(<AnimatedUsageChart chartId="line-chart" series={series} variant="line" />);
|
||||
const areaHtml = renderToStaticMarkup(<AnimatedUsageChart chartId="area-chart" series={series} variant="area" />);
|
||||
const barHtml = renderToStaticMarkup(<AnimatedUsageChart chartId="bar-chart" series={series} variant="bar" />);
|
||||
const sparkHtml = renderToStaticMarkup(<AnimatedUsageChart chartId="spark-chart" series={series} variant="sparkline" />);
|
||||
|
||||
assert.match(lineHtml, /aria-label="Usage chart"/);
|
||||
assert.match(lineHtml, /line-chart-glow/);
|
||||
assert.match(areaHtml, /fill="rgba\(45,212,191,.18\)"/);
|
||||
assert.match(barHtml, /<rect /);
|
||||
assert.match(sparkHtml, /stroke-width="3"/);
|
||||
});
|
||||
|
||||
test("TokenActivityPanel renders summary, grid, and legend", () => {
|
||||
const html = renderToStaticMarkup(<TokenActivityPanel series={usageStats().series} />);
|
||||
|
||||
assert.match(html, /Activity/);
|
||||
assert.match(html, /Longest streak/);
|
||||
assert.match(html, /aria-label="Activity Tokens"/);
|
||||
assert.match(html, /Less/);
|
||||
assert.match(html, /More/);
|
||||
});
|
||||
|
||||
test("TokenMixPanel renders bars, stacked, and share chart variants", () => {
|
||||
const totals = usageTotals();
|
||||
const barsHtml = renderToStaticMarkup(<TokenMixPanel totals={totals} variant="bars" />);
|
||||
const stackedHtml = renderToStaticMarkup(<TokenMixPanel totals={totals} variant="stacked" />);
|
||||
const donutHtml = renderToStaticMarkup(<TokenMixPanel totals={totals} variant="donut" />);
|
||||
|
||||
assert.match(barsHtml, /Token Mix/);
|
||||
assert.match(barsHtml, /Input/);
|
||||
assert.match(stackedHtml, /bg-blue-400/);
|
||||
assert.match(donutHtml, /aria-label="Share chart"/);
|
||||
});
|
||||
|
||||
test("RingMetrics and RadialMetric render accessible radial charts", () => {
|
||||
const ringsHtml = renderToStaticMarkup(<RingMetrics totals={usageTotals()} variant="gauges" />);
|
||||
const radialHtml = renderToStaticMarkup(
|
||||
<RadialMetric centerUnit="tokens" centerValue="12K" color="rgb(45,212,191)" label="Cache 24%" value={0.24} variant="ring" />
|
||||
);
|
||||
|
||||
assert.match(ringsHtml, /Circular metrics/);
|
||||
assert.match(ringsHtml, /Success/);
|
||||
assert.match(ringsHtml, /Cache/);
|
||||
assert.match(radialHtml, /aria-label="Cache 24%"/);
|
||||
assert.match(radialHtml, /12K/);
|
||||
assert.match(radialHtml, /tokens/);
|
||||
});
|
||||
|
||||
test("ModelShareChart renders populated variants and empty state", () => {
|
||||
const rows = usageStats().models;
|
||||
const barsHtml = renderToStaticMarkup(<ModelShareChart rows={rows} variant="bars" />);
|
||||
const listHtml = renderToStaticMarkup(<ModelShareChart rows={rows} variant="list" />);
|
||||
const donutHtml = renderToStaticMarkup(<ModelShareChart rows={rows} variant="donut" />);
|
||||
const emptyHtml = renderToStaticMarkup(<ModelShareChart rows={[]} variant="bars" />);
|
||||
|
||||
assert.match(barsHtml, /Model Share/);
|
||||
assert.match(barsHtml, /gpt-4\.1/);
|
||||
assert.match(listHtml, /1\. gpt-4\.1/);
|
||||
assert.match(listHtml, /62%/);
|
||||
assert.match(donutHtml, /aria-label="Share chart"/);
|
||||
assert.match(emptyHtml, /No usage captured yet/);
|
||||
});
|
||||
|
||||
test("TrayApp and TrayDetailApp render their shell components without browser runtime", () => {
|
||||
const trayHtml = renderToStaticMarkup(<TrayApp />);
|
||||
const detailHtml = renderToStaticMarkup(<TrayDetailApp provider="openai" />);
|
||||
|
||||
assert.match(trayHtml, /Usage Overview/);
|
||||
assert.match(trayHtml, /Open CCR/);
|
||||
assert.match(detailHtml, /Usage Detail/);
|
||||
assert.match(detailHtml, /OpenAI/);
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildTokenActivity, activityDateKey } from "../src/renderer/lib/usage-activity.ts";
|
||||
import { buildTokenActivity, activityDateKey } from "../../src/renderer/lib/usage-activity.ts";
|
||||
|
||||
test("buildTokenActivity summarizes observed token days and streaks", () => {
|
||||
const summary = buildTokenActivity(
|
||||
Loading…
Add table
Add a link
Reference in a new issue