feat(FN-5328): guard malformed mission hierarchy data in MissionManager

- test(FN-5328): assert mission detail load error logging path
- test(FN-5328): mock lucide icons in mission recovery regression
- test(FN-5328): add malformed mission detail recovery regression
- fix(FN-5328): guard malformed mission hierarchy payloads

Fusion-Task-Id: FN-5328
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 09:36:59 -07:00
committed by gsxdsm
parent 321c170b90
commit 13b564b0f6
2 changed files with 150 additions and 6 deletions

View File

@@ -472,6 +472,35 @@ function getAutopilotActivitySummary(state: AutopilotState, lastActivityAt?: str
return `Last activation ${getRelativeTime(lastActivityAt)}`;
}
function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHierarchy {
if (!Array.isArray(mission.milestones)) {
throw new Error("Malformed mission detail response: missing milestones");
}
return {
...mission,
milestones: mission.milestones.map((milestone) => {
if (!Array.isArray(milestone.slices)) {
throw new Error(`Malformed mission detail response: milestone ${milestone.id} is missing slices`);
}
return {
...milestone,
slices: milestone.slices.map((slice) => {
if (!Array.isArray(slice.features)) {
throw new Error(`Malformed mission detail response: slice ${slice.id} is missing features`);
}
return {
...slice,
features: slice.features,
};
}),
};
}),
};
}
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError }: MissionManagerProps) {
const isActive = isInline || isOpen;
const cacheSuffix = projectId ?? "";
@@ -837,13 +866,12 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const loadMissionDetail = useCallback(async (missionId: string) => {
try {
setDetailLoading(true);
const data = await fetchMission(missionId, projectId);
// Guard against malformed responses (e.g. test fetch fallbacks): without
// a milestones array the detail view crashes on `.milestones.length`.
if (!data || !Array.isArray((data as MissionWithHierarchy).milestones)) {
setDetailLoading(false);
return;
const payload = await fetchMission(missionId, projectId);
if (!payload || typeof payload !== "object") {
throw new Error("Malformed mission detail response");
}
const data = normalizeMissionHierarchy(payload as MissionWithHierarchy);
setSelectedMission(data);
if (data.milestones.length > 0) {
const firstMilestoneId = data.milestones[0].id;
@@ -901,6 +929,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
setValidationTelemetry(null);
}
} catch (err) {
console.error("[MissionManager] loadMissionDetail:", err);
addToast(getErrorMessage(err) || "Failed to load mission details", "error");
} finally {
setDetailLoading(false);

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { PageErrorBoundary } from "../ErrorBoundary";
import { MissionManager } from "../MissionManager";
const mockFetchMissions = vi.fn();
const mockFetchMission = vi.fn();
const mockFetchMissionsHealth = vi.fn();
const mockFetchAiSessions = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn();
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
};
});
vi.mock("lucide-react", () => ({
X: () => <span>X</span>,
Plus: () => <span>+</span>,
Pencil: () => <span>Pencil</span>,
Trash2: () => <span>Trash</span>,
ChevronRight: () => <span>ChevronRight</span>,
ChevronDown: () => <span>ChevronDown</span>,
ChevronLeft: () => <span>ChevronLeft</span>,
Target: () => <span>Target</span>,
Layers: () => <span>Layers</span>,
Package: () => <span>Package</span>,
Box: () => <span>Box</span>,
Check: () => <span>Check</span>,
Loader2: () => <span>Loader</span>,
Link: () => <span>Link</span>,
Unlink: () => <span>Unlink</span>,
Play: () => <span>Play</span>,
Square: () => <span>Square</span>,
Sparkles: () => <span>Sparkles</span>,
Zap: () => <span>Zap</span>,
Activity: () => <span>Activity</span>,
FileText: () => <span>FileText</span>,
RefreshCw: () => <span>Refresh</span>,
}));
describe("MissionManager malformed mission recovery", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockFetchMissions.mockResolvedValue([
{ id: "M-001", title: "Malformed Mission", description: "", status: "planning", milestones: [] },
]);
mockFetchMissionsHealth.mockResolvedValue({});
mockFetchAiSessions.mockResolvedValue([]);
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
mockFetchMission.mockResolvedValue({
id: "M-001",
title: "Malformed Mission",
description: "",
status: "planning",
milestones: [
{
id: "MS-001",
title: "Milestone missing slices",
description: "",
acceptanceCriteria: "",
status: "planning",
missionId: "M-001",
// intentionally malformed
slices: undefined,
},
],
});
});
it("avoids page ErrorBoundary fallback and shows toast on malformed mission detail", async () => {
const addToast = vi.fn();
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
render(
<PageErrorBoundary>
<MissionManager isInline isOpen onClose={() => {}} addToast={addToast} />
</PageErrorBoundary>,
);
await screen.findByText("Malformed Mission");
fireEvent.click(screen.getByText("Malformed Mission"));
await waitFor(() => {
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/failed to load mission details|malformed/i), "error");
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[MissionManager] loadMissionDetail:",
expect.any(Error),
);
expect(screen.getByTestId("mission-header-title")).toHaveTextContent("Missions");
expect(screen.getByText("Malformed Mission")).toBeInTheDocument();
});
});