fix(FN-1895): improve companies.sh import error handling and add retry behavior

- Surface companies.sh API errors to frontend instead of returning empty list
- Add 30s timeout to GitHub archive download to prevent hanging
- Prevent infinite retry loop in browse catalog useEffect
- Update retry handler to call fetch directly for better control
- Fix TypeScript error with Response type in timeout code
- Add comprehensive tests for error handling and retry behavior
This commit is contained in:
Fusion
2026-04-16 13:31:47 -07:00
committed by gsxdsm
parent 222a807dc2
commit 0bbdb87e55
6 changed files with 307 additions and 17 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, Globe, Search } from "lucide-react";
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, Globe, Search, RefreshCw } from "lucide-react";
import { fetchCompanies, type CompanyEntry } from "../api";
export interface AgentImportModalProps {
@@ -133,14 +133,24 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
const [isLoadingCompanies, setIsLoadingCompanies] = useState(false);
const [companiesError, setCompaniesError] = useState<string | null>(null);
// Track whether we've attempted to fetch to prevent infinite retry loops
const fetchAttemptedRef = useRef(false);
// Load companies when browse mode is selected
useEffect(() => {
if (inputMethod === "browse" && companies.length === 0 && !isLoadingCompanies) {
if (inputMethod === "browse" && !fetchAttemptedRef.current && !isLoadingCompanies) {
fetchAttemptedRef.current = true;
setIsLoadingCompanies(true);
setCompaniesError(null);
fetchCompanies()
.then((data) => {
setCompanies(data);
if (data.error) {
setCompaniesError(data.error);
} else if (data.companies.length > 0) {
setCompanies(data.companies);
} else {
setCompaniesError("No companies available");
}
})
.catch((err) => {
setCompaniesError(err instanceof Error ? err.message : "Failed to load companies");
@@ -149,7 +159,32 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
setIsLoadingCompanies(false);
});
}
}, [inputMethod, companies.length, isLoadingCompanies]);
}, [inputMethod, isLoadingCompanies]);
/** Retry fetching companies after an error - calls fetch directly to bypass useEffect */
const handleRetryFetchCompanies = useCallback(() => {
fetchAttemptedRef.current = true; // Prevent useEffect from also firing
setCompaniesError(null);
setCompanies([]);
setSelectedCompany(null);
setIsLoadingCompanies(true);
fetchCompanies()
.then((data) => {
if (data.error) {
setCompaniesError(data.error);
} else if (data.companies.length > 0) {
setCompanies(data.companies);
} else {
setCompaniesError("No companies available");
}
})
.catch((err) => {
setCompaniesError(err instanceof Error ? err.message : "Failed to load companies");
})
.finally(() => {
setIsLoadingCompanies(false);
});
}, []);
const reset = useCallback(() => {
setStep("input");
@@ -168,6 +203,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
setSelectedCompany(null);
setIsLoadingCompanies(false);
setCompaniesError(null);
fetchAttemptedRef.current = false;
}, []);
const handleClose = useCallback(() => {
@@ -454,6 +490,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<div className="agent-import-browse-error">
<AlertTriangle size={16} />
<span>{companiesError}</span>
<button
type="button"
className="btn btn-sm"
onClick={handleRetryFetchCompanies}
>
<RefreshCw size={14} />
Retry
</button>
</div>
)}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AgentImportModal } from "../AgentImportModal";
@@ -285,4 +285,147 @@ describe("AgentImportModal", () => {
fireEvent.click(overlay!);
expect(onClose).toHaveBeenCalledTimes(1);
});
describe("Browse Catalog Mode", () => {
function createMockResponse(body: unknown): Response {
return {
ok: true,
status: 200,
headers: new globalThis.Headers({ "content-type": "application/json" }),
text: async () => JSON.stringify(body),
json: async () => body,
} as unknown as Response;
}
it("shows companies when fetch succeeds", async () => {
const mockCompanies = [
{ slug: "test-company", name: "Test Company", tagline: "A great company" },
{ slug: "another-co", name: "Another Company" },
];
globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse({ companies: mockCompanies }));
renderModal(true);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Browse Catalog" }));
await waitFor(() => {
expect(screen.getByText("Test Company")).toBeInTheDocument();
expect(screen.getByText("Another Company")).toBeInTheDocument();
});
});
it("shows error message when companies.sh returns error", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse({
companies: [],
error: "Failed to fetch companies.sh catalog: Network unreachable",
}));
renderModal(true);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Browse Catalog" }));
await waitFor(() => {
expect(screen.getByText(/Failed to fetch companies.sh catalog/)).toBeInTheDocument();
});
});
it("shows Retry button when error occurs", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse({
companies: [],
error: "Failed to fetch companies.sh catalog: Network unreachable",
}));
renderModal(true);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Browse Catalog" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument();
});
});
it("does not retry infinitely after error", async () => {
let fetchCallCount = 0;
globalThis.fetch = vi.fn().mockImplementation(() => {
fetchCallCount++;
return Promise.resolve(createMockResponse({
companies: [],
error: "Network unreachable",
}));
});
renderModal(true);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Browse Catalog" }));
// Wait for the error to appear
await waitFor(() => {
expect(screen.getByText(/Network unreachable/)).toBeInTheDocument();
});
// Allow a bit of time for any potential extra fetches
await new Promise((resolve) => setTimeout(resolve, 500));
// Should have exactly 1 fetch call (no infinite retry)
expect(fetchCallCount).toBe(1);
});
it("retry button allows re-fetching after error", async () => {
// Use a ref to track call count
const callCountRef = { current: 0 };
globalThis.fetch = vi.fn().mockImplementation(() => {
callCountRef.current++;
const callNum = callCountRef.current;
// First call returns error, subsequent calls return success
if (callNum === 1) {
return Promise.resolve(createMockResponse({
companies: [],
error: "Network unreachable",
}));
}
return Promise.resolve(createMockResponse({
companies: [{ slug: "test-co", name: "Test Company" }],
}));
});
renderModal(true);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Browse Catalog" }));
// Wait for error
await waitFor(() => {
expect(screen.getByText(/Network unreachable/)).toBeInTheDocument();
});
// Verify only 1 fetch happened
expect(callCountRef.current).toBe(1);
// Click retry using act to ensure state updates are processed
const retryButton = screen.getByRole("button", { name: /Retry/i });
await act(async () => {
await user.click(retryButton);
});
// Wait for the error to be cleared (indicating retry is in progress)
await waitFor(() => {
expect(screen.queryByText(/Network unreachable/)).not.toBeInTheDocument();
});
// The mock should return success on second call, so we should see the companies
await waitFor(() => {
expect(screen.getByText("Test Company")).toBeInTheDocument();
});
// Verify a second fetch happened
expect(callCountRef.current).toBe(2);
});
});
});