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, /^ + ); + + 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(