forked from molecule-ai/molecule-core
Merge pull request #627 from Molecule-AI/feat/issue-592-wire-metrics-api
Merge gate passed (all 7 gates). Conflicts were mechanical: WorkspaceUsage.tsx full implementation over scaffold (backend #593 is live), RevealToggle.tsx 'use client' deduplicated. UNSTABLE = known GitHub App token scope gap.
This commit is contained in:
commit
3159e03ec1
@ -1,15 +1,49 @@
|
||||
'use client';
|
||||
|
||||
// WorkspaceUsage — Usage panel for a single workspace.
|
||||
// Currently renders placeholder stat rows.
|
||||
// TODO: fetch GET /workspaces/:id/metrics when #593 lands and replace
|
||||
// placeholder values with real token/cost data from the response.
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export interface WorkspaceUsageProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function WorkspaceUsage({ workspaceId: _workspaceId }: WorkspaceUsageProps) {
|
||||
interface WorkspaceMetrics {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_calls: number;
|
||||
estimated_cost_usd: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
}
|
||||
|
||||
export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
|
||||
const [metrics, setMetrics] = useState<WorkspaceMetrics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
api
|
||||
.get<WorkspaceMetrics>(`/workspaces/${workspaceId}/metrics`)
|
||||
.then((data) => {
|
||||
if (!ignore) setMetrics(data);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!ignore)
|
||||
setError(e instanceof Error ? e.message : "Failed to load metrics");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ignore) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 p-3 space-y-2"
|
||||
@ -19,24 +53,72 @@ export function WorkspaceUsage({ workspaceId: _workspaceId }: WorkspaceUsageProp
|
||||
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
Usage
|
||||
</h4>
|
||||
<span
|
||||
className="text-[10px] text-zinc-500 bg-zinc-800 border border-zinc-700 rounded px-1.5 py-0.5"
|
||||
data-testid="usage-pending-badge"
|
||||
>
|
||||
pending #593
|
||||
</span>
|
||||
{!loading && metrics && (
|
||||
<span
|
||||
className="text-[10px] text-zinc-600 font-mono"
|
||||
data-testid="usage-period"
|
||||
>
|
||||
{formatPeriod(metrics.period_start, metrics.period_end)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Placeholder stat rows — will be replaced with live data once #593 lands */}
|
||||
<div className="space-y-1.5" data-testid="usage-stats">
|
||||
<StatRow label="Input tokens" value="—" testId="usage-input-tokens" />
|
||||
<StatRow label="Output tokens" value="—" testId="usage-output-tokens" />
|
||||
<StatRow label="Estimated cost" value="—" testId="usage-estimated-cost" />
|
||||
{loading ? (
|
||||
<>
|
||||
<SkeletonRow />
|
||||
<SkeletonRow />
|
||||
<SkeletonRow />
|
||||
</>
|
||||
) : error ? (
|
||||
<p className="text-xs text-red-400" data-testid="usage-error">
|
||||
{error}
|
||||
</p>
|
||||
) : metrics ? (
|
||||
<>
|
||||
<StatRow
|
||||
label="Input tokens"
|
||||
value={`${metrics.input_tokens.toLocaleString()} tokens`}
|
||||
testId="usage-input-tokens"
|
||||
/>
|
||||
<StatRow
|
||||
label="Output tokens"
|
||||
value={`${metrics.output_tokens.toLocaleString()} tokens`}
|
||||
testId="usage-output-tokens"
|
||||
/>
|
||||
<StatRow
|
||||
label="Estimated cost"
|
||||
value={`$${parseFloat(metrics.estimated_cost_usd).toFixed(6)}`}
|
||||
testId="usage-estimated-cost"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPeriod(start: string, end: string): string {
|
||||
const fmt = (s: string) =>
|
||||
new Date(s).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
return `${fmt(start)} – ${fmt(end)}`;
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center animate-pulse"
|
||||
data-testid="usage-skeleton-row"
|
||||
>
|
||||
<div className="h-3 w-20 rounded bg-zinc-700" />
|
||||
<div className="h-3 w-16 rounded bg-zinc-700" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({
|
||||
label,
|
||||
value,
|
||||
|
||||
@ -1,75 +1,148 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// Mock api before importing the component
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { WorkspaceUsage } from "../WorkspaceUsage";
|
||||
|
||||
const mockGet = vi.mocked(api.get);
|
||||
|
||||
const METRICS_RESPONSE = {
|
||||
input_tokens: 12345,
|
||||
output_tokens: 678,
|
||||
total_calls: 42,
|
||||
estimated_cost_usd: "0.123456",
|
||||
period_start: "2026-04-17T00:00:00Z",
|
||||
period_end: "2026-04-18T00:00:00Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("WorkspaceUsage", () => {
|
||||
it("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
<WorkspaceUsage workspaceId="ws-test-123" />
|
||||
);
|
||||
it("renders the outer container without crashing", () => {
|
||||
// Keep fetch pending so we can check initial state
|
||||
mockGet.mockReturnValue(new Promise(() => {}));
|
||||
const { container } = render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Usage heading", () => {
|
||||
render(<WorkspaceUsage workspaceId="ws-test-123" />);
|
||||
mockGet.mockReturnValue(new Promise(() => {}));
|
||||
render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
expect(screen.getByText("Usage")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the pending #593 badge", () => {
|
||||
render(<WorkspaceUsage workspaceId="ws-test-123" />);
|
||||
const badge = screen.getByTestId("usage-pending-badge");
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.textContent).toBe("pending #593");
|
||||
it("shows skeleton rows while loading", () => {
|
||||
mockGet.mockReturnValue(new Promise(() => {}));
|
||||
render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
const skeletons = screen.getAllByTestId("usage-skeleton-row");
|
||||
expect(skeletons.length).toBe(3);
|
||||
});
|
||||
|
||||
it("renders the outer container and stats container", () => {
|
||||
render(<WorkspaceUsage workspaceId="ws-test-123" />);
|
||||
expect(screen.getByTestId("workspace-usage")).toBeTruthy();
|
||||
it("calls GET /workspaces/:id/metrics with the correct workspaceId", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
|
||||
render(<WorkspaceUsage workspaceId="ws-abc-123" />);
|
||||
await waitFor(() => expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-abc-123/metrics"));
|
||||
});
|
||||
|
||||
it("displays input tokens formatted with toLocaleString after load", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
|
||||
render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
const row = screen.getByTestId("usage-input-tokens");
|
||||
expect(row).toBeTruthy();
|
||||
// 12345 formatted — locale-dependent but always has digits + "tokens"
|
||||
expect(row.textContent).toContain("tokens");
|
||||
expect(row.textContent).toContain("12");
|
||||
});
|
||||
});
|
||||
|
||||
it("displays output tokens formatted with toLocaleString after load", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
|
||||
render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
const row = screen.getByTestId("usage-output-tokens");
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.textContent).toContain("tokens");
|
||||
expect(row.textContent).toContain("678");
|
||||
});
|
||||
});
|
||||
|
||||
it("displays estimated cost formatted as $X.XXXXXX after load", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
|
||||
render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
const row = screen.getByTestId("usage-estimated-cost");
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.textContent).toBe("Estimated cost$0.123456");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the stat rows and hides skeletons after successful load", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
|
||||
render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId("usage-skeleton-row").length).toBe(0);
|
||||
expect(screen.getByTestId("usage-input-tokens")).toBeTruthy();
|
||||
expect(screen.getByTestId("usage-output-tokens")).toBeTruthy();
|
||||
expect(screen.getByTestId("usage-estimated-cost")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when fetch fails", async () => {
|
||||
mockGet.mockRejectedValue(new Error("API GET /workspaces/ws-1/metrics: 403 Forbidden"));
|
||||
render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
const err = screen.getByTestId("usage-error");
|
||||
expect(err).toBeTruthy();
|
||||
expect(err.textContent).toContain("403");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show stat rows on error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("network error"));
|
||||
render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("usage-input-tokens")).toBeNull();
|
||||
expect(screen.queryByTestId("usage-output-tokens")).toBeNull();
|
||||
expect(screen.queryByTestId("usage-estimated-cost")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("re-fetches when workspaceId prop changes", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
|
||||
const { rerender } = render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(<WorkspaceUsage workspaceId="ws-2" />);
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledTimes(2);
|
||||
expect(mockGet).toHaveBeenLastCalledWith("/workspaces/ws-2/metrics");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the usage-stats container in all states", () => {
|
||||
mockGet.mockReturnValue(new Promise(() => {}));
|
||||
render(<WorkspaceUsage workspaceId="ws-1" />);
|
||||
expect(screen.getByTestId("usage-stats")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Input tokens row with placeholder dash", () => {
|
||||
render(<WorkspaceUsage workspaceId="ws-test-123" />);
|
||||
const row = screen.getByTestId("usage-input-tokens");
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.textContent).toContain("Input tokens");
|
||||
expect(row.textContent).toContain("—");
|
||||
});
|
||||
|
||||
it("renders Output tokens row with placeholder dash", () => {
|
||||
render(<WorkspaceUsage workspaceId="ws-test-123" />);
|
||||
const row = screen.getByTestId("usage-output-tokens");
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.textContent).toContain("Output tokens");
|
||||
expect(row.textContent).toContain("—");
|
||||
});
|
||||
|
||||
it("renders Estimated cost row with placeholder dash", () => {
|
||||
render(<WorkspaceUsage workspaceId="ws-test-123" />);
|
||||
const row = screen.getByTestId("usage-estimated-cost");
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.textContent).toContain("Estimated cost");
|
||||
expect(row.textContent).toContain("—");
|
||||
});
|
||||
|
||||
it("accepts any workspaceId without throwing", () => {
|
||||
const ids = ["", "ws-abc", "00000000-0000-0000-0000-000000000000"];
|
||||
for (const id of ids) {
|
||||
const { unmount } = render(<WorkspaceUsage workspaceId={id} />);
|
||||
expect(screen.getByTestId("workspace-usage")).toBeTruthy();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not display live token counts or dollar amounts", () => {
|
||||
render(<WorkspaceUsage workspaceId="ws-test-123" />);
|
||||
const stats = screen.getByTestId("usage-stats");
|
||||
// Placeholder state must not contain any digit sequences
|
||||
expect(stats.textContent).not.toMatch(/\d+/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,6 +6,24 @@ import React from "react";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// vi.mock is hoisted to module top level by Vitest regardless of where it appears
|
||||
// in the source. Placing it here explicitly matches that runtime behaviour and
|
||||
// silences the "not at top level" warning (closes #632).
|
||||
vi.mock("../../../store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn(() => null),
|
||||
{
|
||||
getState: () => ({
|
||||
selectedNodeId: null,
|
||||
nodes: [],
|
||||
contextMenu: null,
|
||||
closeContextMenu: vi.fn(),
|
||||
selectNode: vi.fn(),
|
||||
}),
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
// ─── Z key handler unit tests (no React needed) ─────────────────────────────
|
||||
@ -25,22 +43,6 @@ describe("Z key → molecule:zoom-to-team", () => {
|
||||
});
|
||||
|
||||
it("does NOT fire when no node is selected", () => {
|
||||
// Simulate store: no selection
|
||||
vi.mock("../../../store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn(() => null),
|
||||
{
|
||||
getState: () => ({
|
||||
selectedNodeId: null,
|
||||
nodes: [],
|
||||
contextMenu: null,
|
||||
closeContextMenu: vi.fn(),
|
||||
selectNode: vi.fn(),
|
||||
}),
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
fireEvent.keyDown(window, { key: "Z" });
|
||||
expect(dispatchedEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user