fix(FN-673): harden dashboard task resync and Claude usage handling
- resync dashboard tasks after stream reconnects and when the tab becomes visible again - add regression coverage for stale board recovery and usage-fetch edge cases - replace direct Claude usage API calls with the CLI-based usage command - document the dashboard resync safeguards and include a published package changeset
This commit is contained in:
@@ -251,7 +251,8 @@ The dashboard includes several runtime safeguards to stay responsive during long
|
||||
- **Memoized task rendering**: `TaskCard`, `Column`, and worktree grouping are memoized so unrelated SSE updates do not force the whole board to repaint. The board also preserves stable per-column task arrays for unchanged columns.
|
||||
- **Large-column pagination**: Columns with more than **100 tasks** use incremental client-side pagination, rendering **50 tasks initially** and loading **25 more** at a time. This is applied to active non-archived, non-`in-progress` columns to avoid breaking worktree grouping and archived browsing behavior.
|
||||
- **Badge update isolation**: Live GitHub PR/issue badge websocket updates are rendered through a dedicated child component so badge freshness is preserved even when task cards are memoized.
|
||||
- **SSE cleanup and reconnects**: Task and log streaming hooks explicitly clean up EventSource listeners/connections and avoid duplicate stream setup during rerenders.
|
||||
- **SSE cleanup and reconnects**: Task and log streaming hooks explicitly clean up EventSource listeners/connections, automatically refetch the task snapshot after a stream reconnect, and avoid duplicate stream setup during rerenders.
|
||||
- **Foreground recovery refresh**: The task board refreshes its task snapshot when the browser tab becomes visible again so long-lived hidden tabs do not keep showing stale board/list data after missed live events.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -52,15 +52,22 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseTasks = vi.fn(() => ({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTasks", () => ({
|
||||
useTasks: () => ({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
}),
|
||||
useTasks: () => mockUseTasks(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useProjects", () => ({
|
||||
@@ -88,6 +95,20 @@ import { fetchAuthStatus, fetchSettings, fetchTaskDetail, updateSettings } from
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseTasks.mockReset();
|
||||
mockUseTasks.mockImplementation(() => ({
|
||||
tasks: [],
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
describe("App deep link handling", () => {
|
||||
|
||||
@@ -35,6 +35,11 @@ vi.mock("../../api", () => ({
|
||||
archiveAllDone: vi.fn(),
|
||||
}));
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
const mockFetchTasks = vi.mocked(api.fetchTasks);
|
||||
const mockUpdateTask = vi.mocked(api.updateTask);
|
||||
const mockArchiveAllDone = vi.mocked(api.archiveAllDone);
|
||||
@@ -230,14 +235,10 @@ describe("useTasks", () => {
|
||||
});
|
||||
|
||||
it("closes the broken SSE connection and reconnects after an error", async () => {
|
||||
// Note: We use real timers here because React Testing Library's waitFor
|
||||
// doesn't play well with fake timers - it can hang waiting for promises.
|
||||
// The test relies on the actual 3-second reconnect delay being processed.
|
||||
vi.useFakeTimers();
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
|
||||
const first = MockEventSource.instances[0];
|
||||
|
||||
@@ -247,17 +248,58 @@ describe("useTasks", () => {
|
||||
|
||||
expect(first.close).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait for reconnect timer (3s) to create a second EventSource
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(MockEventSource.instances).toHaveLength(2);
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(2);
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("resyncs tasks after SSE reconnect so the board does not stay stale when updates were missed during disconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
const initialTask = createMockTask({
|
||||
id: "FN-001",
|
||||
title: "Stale title",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const refreshedTask = createMockTask({
|
||||
id: "FN-001",
|
||||
title: "Fresh title",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockFetchTasks
|
||||
.mockResolvedValueOnce([initialTask])
|
||||
.mockResolvedValueOnce([refreshedTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0]?.title).toBe("Stale title");
|
||||
|
||||
const first = MockEventSource.instances[0];
|
||||
|
||||
act(() => {
|
||||
first._emit("error");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(2);
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.tasks[0]?.title).toBe("Fresh title");
|
||||
});
|
||||
|
||||
|
||||
describe("SSE event: task:updated", () => {
|
||||
it("updates task fields", async () => {
|
||||
const initialTask = createMockTask({
|
||||
@@ -277,6 +319,7 @@ describe("useTasks", () => {
|
||||
id: "FN-001",
|
||||
title: "New Title",
|
||||
column: "in-progress" as Column,
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Task, Column, TaskCreateInput, MergeResult } from "@fusion/core";
|
||||
import * as api from "../api";
|
||||
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
|
||||
function normalizeTask(task: Task): Task {
|
||||
return {
|
||||
...task,
|
||||
@@ -37,32 +39,39 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [connectionNonce, setConnectionNonce] = useState(0);
|
||||
const tasksRef = useRef(tasks);
|
||||
const fetchVersionRef = useRef(0);
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
tasksRef.current = tasks;
|
||||
|
||||
// Ref to track last visibility refresh time for debouncing (1 second minimum)
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||
|
||||
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean }) => {
|
||||
const requestVersion = ++fetchVersionRef.current;
|
||||
|
||||
try {
|
||||
const refreshedTasks = projectId
|
||||
const fetchedTasks = projectId
|
||||
? await api.fetchProjectTasks(projectId)
|
||||
: await api.fetchTasks();
|
||||
setTasks(refreshedTasks.map(normalizeTask));
|
||||
if (fetchVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
setTasks(fetchedTasks.map(normalizeTask));
|
||||
} catch {
|
||||
if (fetchVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
if (options?.clearOnError) {
|
||||
setTasks([]);
|
||||
return;
|
||||
}
|
||||
setTasks((current) => current);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch initial tasks
|
||||
// Fetch initial tasks and recover when the tab becomes visible again.
|
||||
useEffect(() => {
|
||||
void refreshTasks({ clearOnError: true });
|
||||
}, [refreshTasks]);
|
||||
|
||||
// Visibility change listener - refresh tasks when tab becomes visible
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
@@ -79,7 +88,6 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
@@ -92,6 +100,9 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
useEffect(() => {
|
||||
let closedByCleanup = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (connectionNonce > 0) {
|
||||
void refreshTasks();
|
||||
}
|
||||
const es = new EventSource("/api/events");
|
||||
|
||||
const handleCreated = (e: MessageEvent) => {
|
||||
@@ -181,7 +192,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
cleanup();
|
||||
reconnectTimer = setTimeout(() => {
|
||||
setConnectionNonce((current) => current + 1);
|
||||
}, 3000);
|
||||
}, RECONNECT_DELAY_MS);
|
||||
};
|
||||
|
||||
es.addEventListener("task:created", handleCreated);
|
||||
@@ -195,7 +206,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
closedByCleanup = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [connectionNonce]);
|
||||
}, [connectionNonce, refreshTasks]);
|
||||
|
||||
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
|
||||
return normalizeTask(await api.createTask(input));
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
clearUsageCache,
|
||||
ProviderUsage,
|
||||
calculatePace,
|
||||
_setSleepFn,
|
||||
_resetSleepFn,
|
||||
} from "./usage.js";
|
||||
|
||||
// Mock the https module
|
||||
@@ -456,6 +458,378 @@ describe("usage", () => {
|
||||
|
||||
expect(claude.plan).toBe("Pro");
|
||||
});
|
||||
|
||||
it("does not send anthropic-beta header in requests", async () => {
|
||||
const mockResponse = {
|
||||
five_hour: { utilization: 10.0 },
|
||||
};
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
capturedHeaders = options.headers || {};
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from(JSON.stringify(mockResponse)));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
await fetchAllProviderUsage();
|
||||
|
||||
// Verify no anthropic-beta header is sent
|
||||
expect(capturedHeaders).not.toHaveProperty("anthropic-beta");
|
||||
});
|
||||
|
||||
it("retries on 429 and succeeds after transient rate limit", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
const mockResponse = {
|
||||
five_hour: {
|
||||
utilization: 20.0,
|
||||
resets_at: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
callCount++;
|
||||
const is429 = callCount <= 2; // First 2 calls return 429, third succeeds
|
||||
const mockRes = {
|
||||
statusCode: is429 ? 429 : 200,
|
||||
headers: is429 ? { "retry-after": "1" } : {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
const body = is429
|
||||
? '{"error":"rate_limited"}'
|
||||
: JSON.stringify(mockResponse);
|
||||
handler(Buffer.from(body));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("ok");
|
||||
expect(claude.windows).toHaveLength(1);
|
||||
expect(claude.windows[0].percentUsed).toBe(20);
|
||||
|
||||
// Verify sleep was called for retries (2 retry sleeps)
|
||||
expect(noopSleep).toHaveBeenCalledTimes(2);
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
it("reports rate limited after all retries exhausted on 429", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
// Always return 429
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 429,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error":"rate_limited"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toBe("Rate limited — try again later");
|
||||
|
||||
// Verify retries happened (2 sleeps for 3 attempts)
|
||||
expect(noopSleep).toHaveBeenCalledTimes(2);
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
it("uses exponential backoff delays when retry-after header is absent", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
// Always return 429 without retry-after
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 429,
|
||||
headers: {}, // No retry-after header
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error":"rate_limited"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
await fetchAllProviderUsage();
|
||||
|
||||
// Exponential backoff: 1000ms * 2^0 = 1000, 1000ms * 2^1 = 2000
|
||||
expect(noopSleep).toHaveBeenCalledTimes(2);
|
||||
expect(noopSleep).toHaveBeenNthCalledWith(1, 1000);
|
||||
expect(noopSleep).toHaveBeenNthCalledWith(2, 2000);
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
it("respects retry-after header value for delay", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
// 429 with retry-after: 5 seconds
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 429,
|
||||
headers: { "retry-after": "5" },
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error":"rate_limited"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
await fetchAllProviderUsage();
|
||||
|
||||
// Should use retry-after value (5s = 5000ms) for both retries
|
||||
expect(noopSleep).toHaveBeenCalledTimes(2);
|
||||
expect(noopSleep).toHaveBeenNthCalledWith(1, 5000);
|
||||
expect(noopSleep).toHaveBeenNthCalledWith(2, 5000);
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
it("does not retry on 401 auth errors", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "expired-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 401,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error": "unauthorized"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
// No retries should happen for auth errors
|
||||
expect(noopSleep).not.toHaveBeenCalled();
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
it("does not retry on 403 auth errors", async () => {
|
||||
const noopSleep = vi.fn().mockResolvedValue(undefined);
|
||||
_setSleepFn(noopSleep);
|
||||
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
if (path.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "forbidden-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 403,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") {
|
||||
handler(Buffer.from('{"error": "forbidden"}'));
|
||||
}
|
||||
if (event === "end") {
|
||||
handler();
|
||||
}
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
// No retries should happen for auth errors
|
||||
expect(noopSleep).not.toHaveBeenCalled();
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex provider", () => {
|
||||
|
||||
Reference in New Issue
Block a user