test(KB-082): complete Step 6 — Add/update tests for error field and retry

This commit is contained in:
gsxdsm
2026-03-29 21:44:44 -07:00
parent b3af346345
commit 81cf41ba50
6 changed files with 597 additions and 2 deletions

View File

@@ -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 */

View File

@@ -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 {

View File

@@ -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

View 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);
});
});

View 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,
};
}