diff --git a/build/run-tests.mjs b/build/run-tests.mjs
index 086d390..a870705 100644
--- a/build/run-tests.mjs
+++ b/build/run-tests.mjs
@@ -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 });
+}
diff --git a/build/test.mjs b/build/test.mjs
index 09e17dd..8319a30 100644
--- a/build/test.mjs
+++ b/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;
+}
diff --git a/package.json b/package.json
index 0f930d8..16e4cf8 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/tests/bot-gateway-env.test.mjs b/tests/main/bot-gateway-env.test.mjs
similarity index 97%
rename from tests/bot-gateway-env.test.mjs
rename to tests/main/bot-gateway-env.test.mjs
index ac3e384..a4e8acb 100644
--- a/tests/bot-gateway-env.test.mjs
+++ b/tests/main/bot-gateway-env.test.mjs
@@ -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 {
diff --git a/tests/deep-link.test.mjs b/tests/main/deep-link.test.mjs
similarity index 99%
rename from tests/deep-link.test.mjs
rename to tests/main/deep-link.test.mjs
index 2a9c63b..ba1ab95 100644
--- a/tests/deep-link.test.mjs
+++ b/tests/main/deep-link.test.mjs
@@ -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")
diff --git a/tests/model-catalog-file.test.mjs b/tests/main/model-catalog-file.test.mjs
similarity index 98%
rename from tests/model-catalog-file.test.mjs
rename to tests/main/model-catalog-file.test.mjs
index 8a93289..4a012e7 100644
--- a/tests/model-catalog-file.test.mjs
+++ b/tests/main/model-catalog-file.test.mjs
@@ -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;
diff --git a/tests/profile-launch-core.test.mjs b/tests/main/profile-launch-core.test.mjs
similarity index 98%
rename from tests/profile-launch-core.test.mjs
rename to tests/main/profile-launch-core.test.mjs
index 2659aa4..228b0bb 100644
--- a/tests/profile-launch-core.test.mjs
+++ b/tests/main/profile-launch-core.test.mjs
@@ -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",
diff --git a/tests/provider-preset-utils.test.mjs b/tests/main/provider-preset-utils.test.mjs
similarity index 98%
rename from tests/provider-preset-utils.test.mjs
rename to tests/main/provider-preset-utils.test.mjs
index 7ee4b50..3aea090 100644
--- a/tests/provider-preset-utils.test.mjs
+++ b/tests/main/provider-preset-utils.test.mjs
@@ -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"],
diff --git a/tests/provider-url.test.mjs b/tests/main/provider-url.test.mjs
similarity index 97%
rename from tests/provider-url.test.mjs
rename to tests/main/provider-url.test.mjs
index b776876..633a8c8 100644
--- a/tests/provider-url.test.mjs
+++ b/tests/main/provider-url.test.mjs
@@ -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");
diff --git a/tests/request-log-store.test.mjs b/tests/main/request-log-store.test.mjs
similarity index 99%
rename from tests/request-log-store.test.mjs
rename to tests/main/request-log-store.test.mjs
index 30a9cb1..09ba8ad 100644
--- a/tests/request-log-store.test.mjs
+++ b/tests/main/request-log-store.test.mjs
@@ -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-"));
diff --git a/tests/usage-normalization.test.mjs b/tests/main/usage-normalization.test.mjs
similarity index 94%
rename from tests/usage-normalization.test.mjs
rename to tests/main/usage-normalization.test.mjs
index 65dfc03..0876d04 100644
--- a/tests/usage-normalization.test.mjs
+++ b/tests/main/usage-normalization.test.mjs
@@ -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(
diff --git a/tests/usage-store.test.mjs b/tests/main/usage-store.test.mjs
similarity index 98%
rename from tests/usage-store.test.mjs
rename to tests/main/usage-store.test.mjs
index 0cae4ff..ce8d20c 100644
--- a/tests/usage-store.test.mjs
+++ b/tests/main/usage-store.test.mjs
@@ -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-"));
diff --git a/tests/renderer/components.test.tsx b/tests/renderer/components.test.tsx
new file mode 100644
index 0000000..e6de94c
--- /dev/null
+++ b/tests/renderer/components.test.tsx
@@ -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(
+
+ );
+
+ assert.match(html, /^Run<\/button>$/);
+});
+
+test("Button unstyled mode keeps caller supplied styling only", () => {
+ const html = renderToStaticMarkup(
+
+ );
+
+ 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(
+
+ Delayed
+
+ );
+
+ assert.match(html, /^Delayed<\/span>$/);
+});
+
+test("Card primitives compose the expected document structure", () => {
+ const html = renderToStaticMarkup(
+
+
+ Provider settings
+
+ Ready
+
+ );
+
+ assert.match(html, /^
Provider settings<\/h2>/);
+ assert.match(html, /
Ready<\/div>/);
+});
+
+test("Switch renders accessible checked and disabled state", () => {
+ const html = renderToStaticMarkup(
+
+ );
+
+ 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\]/);
+});
diff --git a/tests/renderer/fixtures.ts b/tests/renderer/fixtures.ts
new file mode 100644
index 0000000..79cf1e6
--- /dev/null
+++ b/tests/renderer/fixtures.ts
@@ -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 {
+ 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 {
+ return {
+ ...usageTotals(),
+ caption: "Test usage row",
+ key,
+ label,
+ maxShare: 0.5,
+ ...patch
+ };
+}
+
+export function usageStats(range: UsageStatsRange = "30d", patch: Partial = {}): 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"
+ }
+ ];
+}
diff --git a/tests/renderer/overview-components.test.tsx b/tests/renderer/overview-components.test.tsx
new file mode 100644
index 0000000..b8e5b84
--- /dev/null
+++ b/tests/renderer/overview-components.test.tsx
@@ -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(
+ undefined}
+ setUsageRange={() => undefined}
+ usageRange="30d"
+ usageStats={usageStats("30d")}
+ onWidgetsChange={() => undefined}
+ />
+ );
+
+ assert.match(html, /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(
+ 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"/);
+});
diff --git a/tests/renderer/tray-components.test.tsx b/tests/renderer/tray-components.test.tsx
new file mode 100644
index 0000000..6e55776
--- /dev/null
+++ b/tests/renderer/tray-components.test.tsx
@@ -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(
+
+ );
+
+ 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(
+ undefined}
+ />
+ );
+ const emptyHtml = renderToStaticMarkup(
+ 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();
+
+ 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(
+ 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();
+ const meteredHtml = renderToStaticMarkup(
+ 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( 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(
+
+ chart body
+
+ );
+
+ 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();
+ const compactHtml = renderToStaticMarkup();
+ const pillsHtml = renderToStaticMarkup();
+
+ 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();
+ const areaHtml = renderToStaticMarkup();
+ const barHtml = renderToStaticMarkup();
+ const sparkHtml = renderToStaticMarkup();
+
+ 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, / {
+ const html = renderToStaticMarkup();
+
+ 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();
+ const stackedHtml = renderToStaticMarkup();
+ const donutHtml = renderToStaticMarkup();
+
+ 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();
+ const radialHtml = renderToStaticMarkup(
+
+ );
+
+ 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();
+ const listHtml = renderToStaticMarkup();
+ const donutHtml = renderToStaticMarkup();
+ const emptyHtml = renderToStaticMarkup();
+
+ 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();
+ const detailHtml = renderToStaticMarkup();
+
+ assert.match(trayHtml, /Usage Overview/);
+ assert.match(trayHtml, /Open CCR/);
+ assert.match(detailHtml, /Usage Detail/);
+ assert.match(detailHtml, /OpenAI/);
+});
diff --git a/tests/usage-activity.test.mjs b/tests/renderer/usage-activity.test.mjs
similarity index 92%
rename from tests/usage-activity.test.mjs
rename to tests/renderer/usage-activity.test.mjs
index a3c0e2a..37dc1a2 100644
--- a/tests/usage-activity.test.mjs
+++ b/tests/renderer/usage-activity.test.mjs
@@ -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(