test(KB-082): complete Step 6 — Add/update tests for error field and retry
This commit is contained in:
@@ -164,6 +164,34 @@ export function fetchModels(): Promise<ModelInfo[]> {
|
||||
return api<ModelInfo[]>("/models");
|
||||
}
|
||||
|
||||
// --- Usage API ---
|
||||
|
||||
/** Usage window for a provider (e.g., "Session (5h)", "Weekly") */
|
||||
export interface UsageWindow {
|
||||
label: string;
|
||||
percentUsed: number; // 0-100
|
||||
percentLeft: number; // 0-100
|
||||
resetText: string | null; // e.g., "resets in 2h"
|
||||
resetMs?: number; // ms until reset
|
||||
windowDurationMs?: number; // total window length
|
||||
}
|
||||
|
||||
/** Provider usage data */
|
||||
export interface ProviderUsage {
|
||||
name: string;
|
||||
icon: string; // emoji
|
||||
status: "ok" | "error" | "no-auth";
|
||||
error?: string;
|
||||
plan?: string | null;
|
||||
email?: string | null;
|
||||
windows: UsageWindow[];
|
||||
}
|
||||
|
||||
/** Fetch usage data from all configured AI providers */
|
||||
export function fetchUsageData(): Promise<{ providers: ProviderUsage[] }> {
|
||||
return api<{ providers: ProviderUsage[] }>("/usage");
|
||||
}
|
||||
|
||||
// --- Auth API ---
|
||||
|
||||
/** OAuth provider with current authentication status */
|
||||
|
||||
@@ -178,6 +178,123 @@ describe("TaskCard failed status", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard error display", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "KB-099",
|
||||
description: "Test task",
|
||||
column: "in-progress" as Column,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders error message when task has failed status and error field", () => {
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: "Build failed: cannot find module",
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorElement = screen.getByText("Build failed: cannot find module");
|
||||
expect(errorElement).toBeDefined();
|
||||
});
|
||||
|
||||
it("truncates long error messages to 60 characters", () => {
|
||||
const longError = "This is a very long error message that should be truncated because it exceeds sixty characters";
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: longError,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show truncated text with ellipsis
|
||||
const truncatedText = "This is a very long error message that should be truncat…";
|
||||
const errorElement = screen.getByText(truncatedText);
|
||||
expect(errorElement).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT render error section when task is not failed", () => {
|
||||
const task = makeTask({
|
||||
status: "executing",
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorSection = document.querySelector(".card-error");
|
||||
expect(errorSection).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render error section when task is failed but has no error message", () => {
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorSection = document.querySelector(".card-error");
|
||||
expect(errorSection).toBeNull();
|
||||
});
|
||||
|
||||
it("error section has tooltip with full error message", () => {
|
||||
const errorMessage = "Full error message for tooltip";
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorSection = document.querySelector(".card-error");
|
||||
expect(errorSection).toBeDefined();
|
||||
expect(errorSection?.getAttribute("title")).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard dependency tooltip", () => {
|
||||
/** Mirrors the data-tooltip computation from TaskCard.tsx */
|
||||
function computeDepTooltip(dependencies: string[]): string | undefined {
|
||||
|
||||
@@ -165,6 +165,105 @@ describe("TaskDetailModal", () => {
|
||||
expect(screen.queryByText("Retry")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Retry button for failed tasks in any column (including done)", () => {
|
||||
const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done"];
|
||||
|
||||
for (const column of columns) {
|
||||
const { unmount } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", column })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onRetryTask={noopRetry}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Retry")).toBeTruthy();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders error alert when task has failed status and error field", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", error: "Build failed: cannot find module" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const errorAlert = container.querySelector(".detail-error-alert");
|
||||
expect(errorAlert).toBeTruthy();
|
||||
expect(screen.getByText("Task Failed")).toBeTruthy();
|
||||
expect(screen.getByText("Build failed: cannot find module")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render error alert when task is not failed", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "executing" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const errorAlert = container.querySelector(".detail-error-alert");
|
||||
expect(errorAlert).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render error alert when task is failed but has no error message", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", error: undefined })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const errorAlert = container.querySelector(".detail-error-alert");
|
||||
expect(errorAlert).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onRetryTask when Retry button is clicked", async () => {
|
||||
const mockRetry = vi.fn().mockResolvedValue({});
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", column: "done" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onRetryTask={mockRetry}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const retryButton = screen.getByText("Retry");
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetry).toHaveBeenCalledWith("KB-099");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows description exactly once for a task without title", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
|
||||
235
packages/dashboard/app/hooks/useUsageData.test.ts
Normal file
235
packages/dashboard/app/hooks/useUsageData.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useUsageData } from "./useUsageData";
|
||||
import * as api from "../api";
|
||||
|
||||
describe("useUsageData", () => {
|
||||
const mockFetchUsageData = vi.spyOn(api, "fetchUsageData");
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchUsageData.mockClear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fetches data on initial mount", async () => {
|
||||
const mockData = {
|
||||
providers: [
|
||||
{
|
||||
name: "Claude",
|
||||
icon: "🟠",
|
||||
status: "ok" as const,
|
||||
windows: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockFetchUsageData.mockResolvedValue(mockData);
|
||||
|
||||
const { result } = renderHook(() => useUsageData());
|
||||
|
||||
// Should be loading initially
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.providers).toEqual([]);
|
||||
|
||||
// Wait for data to load
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.providers).toEqual(mockData.providers);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.lastUpdated).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchUsageData.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useUsageData());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Network error");
|
||||
expect(result.current.providers).toEqual([]);
|
||||
});
|
||||
|
||||
it("polls data at specified interval", async () => {
|
||||
const mockData1 = {
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
const mockData2 = {
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [{ label: "Session", percentUsed: 50, percentLeft: 50, resetText: "2h" }] }],
|
||||
};
|
||||
|
||||
mockFetchUsageData
|
||||
.mockResolvedValueOnce(mockData1)
|
||||
.mockResolvedValueOnce(mockData2);
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ pollInterval: 5000 }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.providers).toEqual(mockData1.providers);
|
||||
|
||||
// Advance time to trigger poll
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.providers[0]?.windows?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not poll when autoRefresh is false", async () => {
|
||||
const mockData = {
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
mockFetchUsageData.mockResolvedValue(mockData);
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false, pollInterval: 1000 }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// Should not have fetched again
|
||||
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("manual refresh works", async () => {
|
||||
const mockData1 = {
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
const mockData2 = {
|
||||
providers: [{ name: "Codex", icon: "🟢", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
|
||||
mockFetchUsageData
|
||||
.mockResolvedValueOnce(mockData1)
|
||||
.mockResolvedValueOnce(mockData2);
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.providers).toEqual(mockData1.providers);
|
||||
|
||||
// Manual refresh
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.providers).toEqual(mockData2.providers);
|
||||
});
|
||||
|
||||
it("sets loading state on manual refresh", async () => {
|
||||
const mockData = {
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
mockFetchUsageData.mockResolvedValue(mockData);
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Start manual refresh but don't await yet
|
||||
let refreshPromise: Promise<void>;
|
||||
act(() => {
|
||||
refreshPromise = result.current.refresh();
|
||||
});
|
||||
|
||||
// Should be loading immediately
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await refreshPromise;
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("clears error on successful manual refresh after error", async () => {
|
||||
mockFetchUsageData
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("Network error");
|
||||
|
||||
// Manual refresh
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.providers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses default 30 second poll interval", async () => {
|
||||
const mockData = {
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
mockFetchUsageData.mockResolvedValue(mockData);
|
||||
|
||||
renderHook(() => useUsageData());
|
||||
|
||||
await waitFor(() => expect(mockFetchUsageData).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Should not poll after 29 seconds
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(29000);
|
||||
});
|
||||
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Should poll after 30 seconds
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
await waitFor(() => expect(mockFetchUsageData).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("handles abort errors gracefully (does not update state)", async () => {
|
||||
const abortError = new Error("AbortError");
|
||||
abortError.name = "AbortError";
|
||||
mockFetchUsageData.mockRejectedValue(abortError);
|
||||
|
||||
const { result } = renderHook(() => useUsageData());
|
||||
|
||||
// Wait a bit
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
// State should remain in loading since we don't update on abort
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("cleans up interval and abort controller on unmount", async () => {
|
||||
const mockData = {
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
mockFetchUsageData.mockResolvedValue(mockData);
|
||||
|
||||
const { unmount } = renderHook(() => useUsageData());
|
||||
|
||||
await waitFor(() => expect(mockFetchUsageData).toHaveBeenCalledTimes(1));
|
||||
|
||||
unmount();
|
||||
|
||||
// Should not poll after unmount
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Still only called once (no additional calls after unmount)
|
||||
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
116
packages/dashboard/app/hooks/useUsageData.ts
Normal file
116
packages/dashboard/app/hooks/useUsageData.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { fetchUsageData, type ProviderUsage } from "../api";
|
||||
|
||||
interface UsageDataState {
|
||||
providers: ProviderUsage[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date | null;
|
||||
}
|
||||
|
||||
interface UseUsageDataOptions {
|
||||
/** Auto-refresh interval in ms (default: 30 seconds) */
|
||||
pollInterval?: number;
|
||||
/** Whether to auto-refresh (default: true) */
|
||||
autoRefresh?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching and polling provider usage data.
|
||||
*
|
||||
* Features:
|
||||
* - Initial fetch on mount
|
||||
* - Auto-refresh every 30 seconds when enabled
|
||||
* - Manual refresh capability
|
||||
* - Loading and error states
|
||||
* - Cleanup on unmount
|
||||
*/
|
||||
export function useUsageData(options: UseUsageDataOptions = {}) {
|
||||
const { pollInterval = 30_000, autoRefresh = true } = options;
|
||||
|
||||
const [state, setState] = useState<UsageDataState>({
|
||||
providers: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
});
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchData = useCallback(async (isManual = false) => {
|
||||
// Cancel any in-flight request
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
if (isManual) {
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
}
|
||||
|
||||
try {
|
||||
const { providers } = await fetchUsageData();
|
||||
setState({
|
||||
providers,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Don't update state if the request was aborted
|
||||
if (err.name === "AbortError") return;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: err.message || "Failed to fetch usage data",
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
pollRef.current = setInterval(() => {
|
||||
fetchData(false);
|
||||
}, pollInterval);
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, pollInterval, fetchData]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return fetchData(true);
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
providers: state.providers,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
lastUpdated: state.lastUpdated,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user