FN-6454: delete expired quarantined dashboard tests
Apply the dashboard quarantine deletion ratchet by removing stale quarantined suites and clearing the active skip ledger. - Delete dashboard component and API test files that remained quarantined under the ratchet. - Empty the dashboard Vitest quarantine exclude list so future quarantines must be newly mirrored. - Clear the quarantine ledger entries after deleting their associated files. Files changed: ...hatView.regular-composer-no-right-line.test.tsx | 108 - .../components/__tests__/MissionManager.test.tsx | 5654 ------------------ .../app/components/__tests__/ModalReentry.test.tsx | 439 -- .../components/__tests__/ModelSelectorTab.test.tsx | 1325 ----- .../components/__tests__/NewAgentDialog.test.tsx | 2043 ------- .../__tests__/OAuthReloginBanner.test.tsx | 237 - .../__tests__/PlanningModeModal.favorites.test.tsx | 540 -- .../__tests__/PlanningModeModal.questions.test.tsx | 1230 ---- .../PlanningModeModal.swipe-back.test.tsx | 239 - .../components/__tests__/SkillsView.css.test.ts | 89 - .../app/components/__tests__/mobile-css.test.tsx | 143 - .../dashboard/src/__tests__/mission-e2e.test.ts | 6176 -------------------- packages/dashboard/src/__tests__/planning.test.ts | 3633 ------------ packages/dashboard/vitest.config.ts | 40 +- scripts/lib/test-quarantine.json | 70 +- 15 files changed, 8 insertions(+), 21958 deletions(-) Fusion-Task-Id: FN-6454 Fusion-Task-Lineage: f18e940d-f8ee-4ade-aedb-714b8e39e2a4
This commit is contained in:
@@ -1,108 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ChatView } from "../ChatView";
|
||||
import * as useChatModule from "../../hooks/useChat";
|
||||
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||
import type { UseChatReturn } from "../../hooks/useChat";
|
||||
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useChatRooms");
|
||||
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,
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||
fetchTasks: vi.fn().mockResolvedValue([]),
|
||||
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
const chatViewCss = readFileSync(resolve(__dirname, "../ChatView.css"), "utf8");
|
||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||
|
||||
const defaultChatState: UseChatReturn = {
|
||||
sessions: [],
|
||||
activeSession: null,
|
||||
sessionsLoading: false,
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
isStreaming: false,
|
||||
streamingText: "",
|
||||
streamingThinking: "",
|
||||
streamingToolCalls: [],
|
||||
selectSession: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
archiveSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
stopStreaming: vi.fn(),
|
||||
pendingMessage: "",
|
||||
clearPendingMessage: vi.fn(),
|
||||
loadMoreMessages: vi.fn(),
|
||||
hasMoreMessages: false,
|
||||
searchQuery: "",
|
||||
setSearchQuery: vi.fn(),
|
||||
filteredSessions: [],
|
||||
refreshSessions: vi.fn(),
|
||||
agentsMap: new Map(),
|
||||
};
|
||||
|
||||
const defaultRoomsState: UseChatRoomsResult = {
|
||||
rooms: [],
|
||||
roomsLoading: false,
|
||||
roomsError: null,
|
||||
activeRoom: null,
|
||||
activeRoomMembers: [],
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
selectRoom: vi.fn(),
|
||||
createRoom: vi.fn(),
|
||||
deleteRoom: vi.fn(),
|
||||
sendRoomMessage: vi.fn().mockResolvedValue(undefined),
|
||||
refreshRooms: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ChatView regular composer right-edge artifact regression", () => {
|
||||
beforeEach(() => {
|
||||
_resetInitialViewportHeight();
|
||||
vi.clearAllMocks();
|
||||
mockUseChat.mockReturnValue(defaultChatState);
|
||||
mockUseChatRooms.mockReturnValue(defaultRoomsState);
|
||||
});
|
||||
|
||||
it("keeps textarea sizing rules and wrapper border invariants that prevent a right-edge line", () => {
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Type a message...")).toBeInTheDocument();
|
||||
|
||||
const textareaRule = chatViewCss.match(/\.chat-input-textarea\s*\{[^}]*\}/);
|
||||
expect(textareaRule).not.toBeNull();
|
||||
expect(textareaRule?.[0]).toContain("box-sizing: border-box");
|
||||
expect(textareaRule?.[0]).toContain("width: 100%");
|
||||
expect(textareaRule?.[0]).toContain("-webkit-appearance: none");
|
||||
expect(textareaRule?.[0]).toContain("appearance: none");
|
||||
|
||||
const wrapperRule = chatViewCss.match(/\.chat-input-wrapper\s*\{[^}]*\}/);
|
||||
expect(wrapperRule).not.toBeNull();
|
||||
expect(wrapperRule?.[0]).not.toMatch(/border(?:-right)?\s*:/);
|
||||
|
||||
const dragoverRule = chatViewCss.match(/\.chat-input-wrapper--dragover\s*\{[^}]*\}/);
|
||||
expect(dragoverRule).not.toBeNull();
|
||||
expect(dragoverRule?.[0]).toContain("border: 1px dashed var(--todo)");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,439 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
|
||||
// Use vi.hoisted to ensure mock functions are defined before vi.mock factory runs
|
||||
const {
|
||||
mockSavePlanningDescription,
|
||||
mockGetPlanningDescription,
|
||||
mockClearPlanningDescription,
|
||||
mockSaveSubtaskDescription,
|
||||
mockGetSubtaskDescription,
|
||||
mockClearSubtaskDescription,
|
||||
mockSaveMissionGoal,
|
||||
mockGetMissionGoal,
|
||||
mockClearMissionGoal,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSavePlanningDescription: vi.fn<(description: string, projectId?: string) => void>(),
|
||||
mockGetPlanningDescription: vi.fn<(projectId?: string) => string>(() => ""),
|
||||
mockClearPlanningDescription: vi.fn<(projectId?: string) => void>(),
|
||||
mockSaveSubtaskDescription: vi.fn<(description: string, projectId?: string) => void>(),
|
||||
mockGetSubtaskDescription: vi.fn<(projectId?: string) => string>(() => ""),
|
||||
mockClearSubtaskDescription: vi.fn<(projectId?: string) => void>(),
|
||||
mockSaveMissionGoal: vi.fn<(goal: string, projectId?: string) => void>(),
|
||||
mockGetMissionGoal: vi.fn<(projectId?: string) => string>(() => ""),
|
||||
mockClearMissionGoal: vi.fn<(projectId?: string) => void>(),
|
||||
}));
|
||||
|
||||
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("../../hooks/modalPersistence", () => ({
|
||||
savePlanningDescription: (description: string, projectId?: string) => mockSavePlanningDescription(description, projectId),
|
||||
getPlanningDescription: (projectId?: string) => mockGetPlanningDescription(projectId),
|
||||
clearPlanningDescription: (projectId?: string) => mockClearPlanningDescription(projectId),
|
||||
saveSubtaskDescription: (description: string, projectId?: string) => mockSaveSubtaskDescription(description, projectId),
|
||||
getSubtaskDescription: (projectId?: string) => mockGetSubtaskDescription(projectId),
|
||||
clearSubtaskDescription: (projectId?: string) => mockClearSubtaskDescription(projectId),
|
||||
saveMissionGoal: (goal: string, projectId?: string) => mockSaveMissionGoal(goal, projectId),
|
||||
getMissionGoal: (projectId?: string) => mockGetMissionGoal(projectId),
|
||||
clearMissionGoal: (projectId?: string) => mockClearMissionGoal(projectId),
|
||||
}));
|
||||
|
||||
// Mock the API functions
|
||||
const {
|
||||
mockStartPlanningStreaming,
|
||||
mockConnectPlanningStream,
|
||||
mockCancelPlanning,
|
||||
mockCreateTaskFromPlanning,
|
||||
mockRespondToPlanning,
|
||||
mockStartSubtaskBreakdown,
|
||||
mockConnectSubtaskStream,
|
||||
mockCancelSubtaskBreakdown,
|
||||
mockCreateTasksFromBreakdown,
|
||||
mockStartMissionInterview,
|
||||
mockConnectMissionInterviewStream,
|
||||
mockCancelMissionInterview,
|
||||
mockCreateMissionFromInterview,
|
||||
mockAcquireSessionLock,
|
||||
mockReleaseSessionLock,
|
||||
mockForceAcquireSessionLock,
|
||||
} = vi.hoisted(() => ({
|
||||
mockStartPlanningStreaming: vi.fn(),
|
||||
mockConnectPlanningStream: vi.fn(),
|
||||
mockCancelPlanning: vi.fn(),
|
||||
mockCreateTaskFromPlanning: vi.fn(),
|
||||
mockRespondToPlanning: vi.fn(),
|
||||
mockStartSubtaskBreakdown: vi.fn(),
|
||||
mockConnectSubtaskStream: vi.fn(),
|
||||
mockCancelSubtaskBreakdown: vi.fn(),
|
||||
mockCreateTasksFromBreakdown: vi.fn(),
|
||||
mockStartMissionInterview: vi.fn(),
|
||||
mockConnectMissionInterviewStream: vi.fn(),
|
||||
mockCancelMissionInterview: vi.fn(),
|
||||
mockCreateMissionFromInterview: vi.fn(),
|
||||
mockAcquireSessionLock: vi.fn(),
|
||||
mockReleaseSessionLock: vi.fn(),
|
||||
mockForceAcquireSessionLock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
|
||||
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
|
||||
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
|
||||
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
|
||||
createTasksFromBreakdown: (...args: any[]) => mockCreateTasksFromBreakdown(...args),
|
||||
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
|
||||
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
|
||||
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
|
||||
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
|
||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||
uploadAttachment: vi.fn(),
|
||||
deleteAttachment: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
pauseTask: vi.fn(),
|
||||
unpauseTask: vi.fn(),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
requestSpecRevision: vi.fn(),
|
||||
approvePlan: vi.fn(),
|
||||
rejectPlan: vi.fn(),
|
||||
refineTask: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockConfirm = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: mockConfirm }),
|
||||
}));
|
||||
|
||||
// Import components AFTER mocking
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
import { SubtaskBreakdownModal } from "../SubtaskBreakdownModal";
|
||||
import { MissionInterviewModal } from "../MissionInterviewModal";
|
||||
|
||||
describe("ModalReentry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetPlanningDescription.mockReturnValue("");
|
||||
mockGetSubtaskDescription.mockReturnValue("");
|
||||
mockGetMissionGoal.mockReturnValue("");
|
||||
|
||||
// Default API mocks
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "planning-session-1" });
|
||||
mockConnectPlanningStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
mockCancelPlanning.mockResolvedValue(undefined);
|
||||
mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-100" });
|
||||
|
||||
mockStartSubtaskBreakdown.mockResolvedValue({ sessionId: "subtask-session-1" });
|
||||
mockConnectSubtaskStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
mockCancelSubtaskBreakdown.mockResolvedValue(undefined);
|
||||
mockCreateTasksFromBreakdown.mockResolvedValue({ tasks: [{ id: "FN-101" }, { id: "FN-102" }] });
|
||||
|
||||
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
|
||||
mockConnectMissionInterviewStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
mockCancelMissionInterview.mockResolvedValue(undefined);
|
||||
mockCreateMissionFromInterview.mockResolvedValue({
|
||||
mission: { id: "MSN-001" },
|
||||
slices: [],
|
||||
features: [],
|
||||
});
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockConfirm.mockReset();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
// ─── PlanningModeModal ───────────────────────────────────────────────
|
||||
|
||||
describe("PlanningModal re-entry", () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onTaskCreated: vi.fn(),
|
||||
onTasksCreated: vi.fn(),
|
||||
tasks: [],
|
||||
};
|
||||
|
||||
it("reads persisted description from localStorage when no prop provided", async () => {
|
||||
mockGetPlanningDescription.mockReturnValue("Persisted planning description");
|
||||
|
||||
render(<PlanningModeModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetPlanningDescription).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify the textarea has the persisted value
|
||||
const textarea = document.getElementById("initial-plan") as HTMLTextAreaElement;
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(textarea.value).toBe("Persisted planning description");
|
||||
});
|
||||
|
||||
it("uses prop value instead of localStorage when initialPlan prop is provided", async () => {
|
||||
mockGetPlanningDescription.mockReturnValue("From localStorage");
|
||||
|
||||
render(<PlanningModeModal {...defaultProps} initialPlan="From prop" />);
|
||||
|
||||
// Wait for auto-start (which reads the prop)
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("From prop", undefined, undefined, {
|
||||
planningDepth: "medium",
|
||||
customQuestionCount: undefined,
|
||||
}, undefined);
|
||||
});
|
||||
|
||||
// localStorage should NOT be read since prop was provided
|
||||
expect(mockGetPlanningDescription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears localStorage when planning session produces events", async () => {
|
||||
// Set up stream to trigger onQuestion which calls clearPlanningDescription
|
||||
mockConnectPlanningStream.mockImplementation((_sid, _pid, handlers) => {
|
||||
setTimeout(() => handlers.onQuestion({ id: "q1", type: "text", question: "Test?" }), 0);
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
|
||||
render(<PlanningModeModal {...defaultProps} initialPlan="Build auth" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClearPlanningDescription).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("saves description to localStorage on cancel", async () => {
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
const { unmount } = render(<PlanningModeModal {...defaultProps} />);
|
||||
|
||||
// Type something in the textarea
|
||||
const textarea = document.getElementById("initial-plan") as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: "My planning text" } });
|
||||
});
|
||||
|
||||
// Click the close button
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
await act(async () => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
|
||||
expect(mockSavePlanningDescription).toHaveBeenCalledWith("My planning text", undefined);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("does not save empty description to localStorage on cancel", async () => {
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
const { unmount } = render(<PlanningModeModal {...defaultProps} />);
|
||||
|
||||
// Click the close button without typing anything
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
await act(async () => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
|
||||
expect(mockSavePlanningDescription).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SubtaskBreakdownModal ───────────────────────────────────────────
|
||||
|
||||
describe("SubtaskBreakdownModal re-entry", () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
initialDescription: "",
|
||||
onTasksCreated: vi.fn(),
|
||||
};
|
||||
|
||||
it("reads persisted description from localStorage when no prop provided", async () => {
|
||||
mockGetSubtaskDescription.mockReturnValue("Persisted subtask description");
|
||||
|
||||
render(<SubtaskBreakdownModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetSubtaskDescription).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify the persisted description is shown in the pre element
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Persisted subtask description")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses prop value and starts breakdown immediately when initialDescription is provided", async () => {
|
||||
render(
|
||||
<SubtaskBreakdownModal
|
||||
{...defaultProps}
|
||||
initialDescription="Build a complex feature"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartSubtaskBreakdown).toHaveBeenCalledWith("Build a complex feature", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears localStorage when subtasks are received", async () => {
|
||||
// Set up the stream to emit subtasks
|
||||
mockConnectSubtaskStream.mockImplementation((_sid, _pid, handlers) => {
|
||||
// Simulate subtasks arriving synchronously
|
||||
handlers.onSubtasks([{ id: "subtask-1", title: "First", description: "", suggestedSize: "M", dependsOn: [] }]);
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
|
||||
render(
|
||||
<SubtaskBreakdownModal
|
||||
{...defaultProps}
|
||||
initialDescription="Break this down"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClearSubtaskDescription).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("saves description to localStorage on close", async () => {
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
// Set up the stream so the modal can start
|
||||
mockConnectSubtaskStream.mockImplementation((_sid, _pid, handlers) => {
|
||||
handlers.onSubtasks([{ id: "subtask-1", title: "First", description: "", suggestedSize: "M", dependsOn: [] }]);
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
|
||||
const { unmount } = render(
|
||||
<SubtaskBreakdownModal
|
||||
{...defaultProps}
|
||||
initialDescription="Some description"
|
||||
/>
|
||||
);
|
||||
|
||||
// Close the modal (resetState is called which saves to localStorage)
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
await act(async () => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
|
||||
expect(mockSaveSubtaskDescription).toHaveBeenCalledWith("Some description", undefined);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MissionInterviewModal ───────────────────────────────────────────
|
||||
|
||||
describe("MissionInterviewModal re-entry", () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onMissionCreated: vi.fn(),
|
||||
};
|
||||
|
||||
it("reads persisted goal from localStorage when no prop provided", async () => {
|
||||
mockGetMissionGoal.mockReturnValue("Persisted mission goal");
|
||||
|
||||
render(<MissionInterviewModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetMissionGoal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify the textarea has the persisted value
|
||||
const textarea = document.getElementById("mission-goal") as HTMLTextAreaElement;
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(textarea.value).toBe("Persisted mission goal");
|
||||
});
|
||||
|
||||
it("uses prop value instead of localStorage when initialGoal prop is provided", async () => {
|
||||
mockGetMissionGoal.mockReturnValue("From localStorage");
|
||||
|
||||
render(<MissionInterviewModal {...defaultProps} initialGoal="From prop" />);
|
||||
|
||||
// Wait for auto-start
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("From prop", undefined, undefined);
|
||||
});
|
||||
|
||||
// localStorage should NOT be read since prop was provided
|
||||
expect(mockGetMissionGoal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears localStorage when interview starts successfully", async () => {
|
||||
render(<MissionInterviewModal {...defaultProps} initialGoal="Build a platform" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartMissionInterview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// clearMissionGoal is called immediately after startMissionInterview
|
||||
expect(mockClearMissionGoal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves goal to localStorage on cancel", async () => {
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
const { unmount } = render(<MissionInterviewModal {...defaultProps} />);
|
||||
|
||||
// Type something in the textarea
|
||||
const textarea = document.getElementById("mission-goal") as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: "My mission goal" } });
|
||||
});
|
||||
|
||||
// Click the close button
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
await act(async () => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
|
||||
expect(mockSaveMissionGoal).toHaveBeenCalledWith("My mission goal", undefined);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("does not save empty goal to localStorage on cancel", async () => {
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
const { unmount } = render(<MissionInterviewModal {...defaultProps} />);
|
||||
|
||||
// Click the close button without typing anything
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
await act(async () => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
|
||||
expect(mockSaveMissionGoal).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cross-modal storage independence ────────────────────────────────
|
||||
|
||||
describe("Storage independence", () => {
|
||||
it("each modal type uses independent persistence functions", () => {
|
||||
// Verify the mock functions are distinct (unit-level independence)
|
||||
expect(mockSavePlanningDescription).not.toBe(mockSaveSubtaskDescription);
|
||||
expect(mockSavePlanningDescription).not.toBe(mockSaveMissionGoal);
|
||||
expect(mockSaveSubtaskDescription).not.toBe(mockSaveMissionGoal);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,237 +0,0 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OAuthReloginBanner } from "../OAuthReloginBanner";
|
||||
import * as api from "../../api";
|
||||
import { OAUTH_RELOGIN_SUCCESS_EVENT } from "../../auth";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus);
|
||||
|
||||
describe("OAuthReloginBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders nothing when no providers are expired", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false }],
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a banner for one expired oauth provider", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
});
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your Claude session expired/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a comma-joined list when multiple providers are expired", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true },
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", expired: true },
|
||||
],
|
||||
});
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Re-login required: Claude, GitHub Copilot")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onReLogin with providerId for single and undefined for multi", async () => {
|
||||
const onReLogin = vi.fn();
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true },
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", expired: true },
|
||||
],
|
||||
});
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={onReLogin} pollIntervalMs={1_000} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Re-login" }));
|
||||
expect(onReLogin).toHaveBeenCalledWith("claude");
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Re-login" }));
|
||||
expect(onReLogin).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("dismisses banner and stores provider ids in localStorage", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /dismiss oauth re-login banner/i }));
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toBe(JSON.stringify(["claude"]));
|
||||
});
|
||||
|
||||
it("keeps banner dismissed until provider recovers then expires again", async () => {
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: true, type: "oauth", expired: false }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={1_000} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /dismiss oauth re-login banner/i }));
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toBe(JSON.stringify([]));
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores expired flags on api_key and cli providers", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key", expired: true },
|
||||
{ id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli", expired: true },
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("clears a provider row immediately when oauth relogin success event is dispatched", async () => {
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: true, type: "oauth", expired: false }],
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={1_000} />);
|
||||
|
||||
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "claude" } }));
|
||||
});
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("triggers an immediate auth status refetch when oauth relogin success event is dispatched", async () => {
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: true, type: "oauth", expired: false }],
|
||||
});
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={10_000} />);
|
||||
|
||||
await screen.findByText(/Re-login required: Claude/i);
|
||||
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "claude" } }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not clear unrelated providers when event is for a different provider", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true },
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", expired: true },
|
||||
],
|
||||
});
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Re-login required: Claude, GitHub Copilot")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "openai" } }));
|
||||
});
|
||||
|
||||
expect(screen.getByText("Re-login required: Claude, GitHub Copilot")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps provider row until poll result changes when no success event is dispatched", async () => {
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: true, type: "oauth", expired: false }],
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={1_000} />);
|
||||
|
||||
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,540 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import * as api from "../../api";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
import { TaskDetailModal } from "../TaskDetailModal";
|
||||
import { useSessionLock } from "../../hooks/useSessionLock";
|
||||
import { getSessionTabId } from "../../utils/getSessionTabId";
|
||||
import type { MergeResult } from "@fusion/core";
|
||||
import {
|
||||
mockStartPlanning,
|
||||
mockStartPlanningStreaming,
|
||||
mockCreatePlanningDraft,
|
||||
mockConnectPlanningStream,
|
||||
mockRespondToPlanning,
|
||||
mockRetryPlanningSession,
|
||||
mockCancelPlanning,
|
||||
mockStopPlanningGeneration,
|
||||
mockUpdatePlanningSessionDraft,
|
||||
mockCreateTaskFromPlanning,
|
||||
mockStartPlanningBreakdown,
|
||||
mockCreateTasksFromPlanning,
|
||||
mockFetchAiSession,
|
||||
mockParseConversationHistory,
|
||||
mockFetchModels,
|
||||
mockAcquireSessionLock,
|
||||
mockReleaseSessionLock,
|
||||
mockForceAcquireSessionLock,
|
||||
mockUploadAttachment,
|
||||
mockDeleteAttachment,
|
||||
mockUpdateTask,
|
||||
mockPauseTask,
|
||||
mockUnpauseTask,
|
||||
mockFetchTaskDetail,
|
||||
mockRequestSpecRevision,
|
||||
mockApprovePlan,
|
||||
mockRejectPlan,
|
||||
mockRefineTask,
|
||||
mockFetchAiSessions,
|
||||
mockConfirm,
|
||||
mockUseViewportMode,
|
||||
mockUseMobileKeyboard,
|
||||
mockTasks,
|
||||
mockModels,
|
||||
mockQuestion,
|
||||
mockSummary,
|
||||
mockTaskDetail,
|
||||
MockEventSource,
|
||||
getMediaBlocks,
|
||||
mockViewport,
|
||||
} from "./PlanningModeModal.test-helpers";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
startPlanning: (...args: any[]) => mockStartPlanning(...args),
|
||||
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
|
||||
createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args),
|
||||
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
||||
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
|
||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
uploadAttachment: (...args: any[]) => mockUploadAttachment(...args),
|
||||
deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args),
|
||||
updateTask: (...args: any[]) => mockUpdateTask(...args),
|
||||
pauseTask: (...args: any[]) => mockPauseTask(...args),
|
||||
unpauseTask: (...args: any[]) => mockUnpauseTask(...args),
|
||||
fetchTaskDetail: (...args: any[]) => mockFetchTaskDetail(...args),
|
||||
requestSpecRevision: (...args: any[]) => mockRequestSpecRevision(...args),
|
||||
approvePlan: (...args: any[]) => mockApprovePlan(...args),
|
||||
rejectPlan: (...args: any[]) => mockRejectPlan(...args),
|
||||
refineTask: (...args: any[]) => mockRefineTask(...args),
|
||||
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||
fetchAiSessions: (...args: any[]) => mockFetchAiSessions(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: mockConfirm }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
getViewportMode: () => mockUseViewportMode(),
|
||||
isMobileViewport: () => mockUseViewportMode() === "mobile",
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args),
|
||||
}));
|
||||
|
||||
describe("PlanningModeModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnTaskCreated = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfirm.mockReset();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
MockEventSource.reset();
|
||||
vi.stubGlobal("EventSource", MockEventSource as any);
|
||||
window.sessionStorage.clear();
|
||||
// Default to desktop viewport; mobile-specific tests override per-test.
|
||||
mockViewport("desktop");
|
||||
|
||||
// Default mock for streaming
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
|
||||
// Server's createDraftSession always returns the placeholder title; the
|
||||
// real summarized title only arrives later via blur/close summarize or
|
||||
// when the session transitions out of draft. Mirror that in the mock so
|
||||
// the sidebar render rule (preview while title === placeholder) behaves
|
||||
// realistically in tests.
|
||||
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
mockParseConversationHistory.mockImplementation((raw: string) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModels,
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
resolvedPlanningProvider: "openai",
|
||||
resolvedPlanningModelId: "gpt-4o",
|
||||
});
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||
mockCancelPlanning.mockResolvedValue(undefined);
|
||||
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
|
||||
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
||||
|
||||
// Default: simulate receiving a question after a brief delay
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onQuestion?.(mockQuestion);
|
||||
}, 10);
|
||||
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
describe("Model favorites persistence", () => {
|
||||
it("persists provider favorite toggle to global settings", async () => {
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModels,
|
||||
favoriteProviders: ["anthropic"],
|
||||
favoriteModels: [],
|
||||
});
|
||||
vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any);
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModels).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
|
||||
// When provider is favorited, the optgroup header shows "Remove" button
|
||||
const removeButton = within(portal).queryByRole("button", { name: "Remove anthropic from favorites" });
|
||||
expect(removeButton).not.toBeNull();
|
||||
fireEvent.click(removeButton!);
|
||||
|
||||
expect(api.updateGlobalSettings).toHaveBeenCalledWith({
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("persists model favorite toggle to global settings", async () => {
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModels,
|
||||
favoriteProviders: [],
|
||||
favoriteModels: ["anthropic/claude-sonnet-4-5"],
|
||||
});
|
||||
vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any);
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModels).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
|
||||
// When model is favorited, it appears as a pinned row with "Remove" button
|
||||
// There may be duplicates (in pinned row + provider group), use first one
|
||||
const removeButtons = within(portal).queryAllByRole("button", { name: "Remove Claude Sonnet 4.5 from favorites" });
|
||||
expect(removeButtons.length).toBeGreaterThan(0);
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
expect(api.updateGlobalSettings).toHaveBeenCalledWith({
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("adds provider to favorites", async () => {
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModels,
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
});
|
||||
vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any);
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModels).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
|
||||
const addButton = within(portal).getByRole("button", { name: "Add anthropic to favorites" });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(api.updateGlobalSettings).toHaveBeenCalledWith({
|
||||
favoriteProviders: ["anthropic"],
|
||||
favoriteModels: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back local favorite state when updateGlobalSettings fails", async () => {
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModels,
|
||||
favoriteProviders: ["anthropic"],
|
||||
favoriteModels: [],
|
||||
});
|
||||
vi.mocked(api.updateGlobalSettings).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModels).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
|
||||
const removeButton = within(portal).getByRole("button", { name: "Remove anthropic from favorites" });
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
// Optimistic state should immediately show unfavorited UI.
|
||||
expect(within(portal).getByRole("button", { name: "Add anthropic to favorites" })).toBeTruthy();
|
||||
|
||||
// The API call is fire-and-forget; rollback runs in the rejected-promise catch microtask.
|
||||
await waitFor(() => {
|
||||
expect(api.updateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Re-query until rollback flushes and favorited UI is restored.
|
||||
await waitFor(() => {
|
||||
const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]');
|
||||
expect(portalAfterRollback).not.toBeNull();
|
||||
expect(within(portalAfterRollback as HTMLElement).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionTabId", () => {
|
||||
it("creates and persists a per-tab id in sessionStorage", () => {
|
||||
window.sessionStorage.clear();
|
||||
|
||||
const first = getSessionTabId();
|
||||
const second = getSessionTabId();
|
||||
|
||||
expect(first).toBeTruthy();
|
||||
expect(second).toBe(first);
|
||||
expect(window.sessionStorage.getItem("fusion-tab-id")).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionLock", () => {
|
||||
beforeEach(() => {
|
||||
MockEventSource.reset();
|
||||
vi.stubGlobal("EventSource", MockEventSource as any);
|
||||
window.sessionStorage.clear();
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("acquires on mount and releases on unmount", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
|
||||
const { unmount } = renderHook(() => useSessionLock("session-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReleaseSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes locked state and allows taking control", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
|
||||
|
||||
const { result } = renderHook(() => useSessionLock("session-2"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLockedByOther).toBe(true);
|
||||
expect(result.current.currentHolder).toBe("tab-other");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.takeControl();
|
||||
});
|
||||
|
||||
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-2", "tab-self");
|
||||
expect(result.current.isLockedByOther).toBe(false);
|
||||
expect(result.current.currentHolder).toBeNull();
|
||||
});
|
||||
|
||||
it("updates lock state from ai_session:updated SSE events and uses sendBeacon on beforeunload", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
const sendBeaconSpy = vi.fn(() => true);
|
||||
vi.stubGlobal("navigator", {
|
||||
...window.navigator,
|
||||
sendBeacon: sendBeaconSpy,
|
||||
} as Navigator);
|
||||
|
||||
const { result } = renderHook(() => useSessionLock("session-3"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-3", "tab-self");
|
||||
});
|
||||
|
||||
const source = MockEventSource.instances[0];
|
||||
expect(source).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
source?.emit("ai_session:updated", {
|
||||
id: "session-3",
|
||||
type: "planning",
|
||||
status: "awaiting_input",
|
||||
title: "Session",
|
||||
projectId: null,
|
||||
lockedByTab: "tab-other",
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isLockedByOther).toBe(true);
|
||||
expect(result.current.currentHolder).toBe("tab-other");
|
||||
|
||||
act(() => {
|
||||
source?.emit("ai_session:updated", {
|
||||
id: "session-3",
|
||||
type: "planning",
|
||||
status: "awaiting_input",
|
||||
title: "Session",
|
||||
projectId: null,
|
||||
lockedByTab: "tab-self",
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isLockedByOther).toBe(false);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("beforeunload"));
|
||||
});
|
||||
|
||||
expect(sendBeaconSpy).toHaveBeenCalledWith(
|
||||
"/api/ai-sessions/session-3/lock/beacon?tabId=tab-self",
|
||||
);
|
||||
});
|
||||
|
||||
describe("Mobile keyboard behavior (FN-3337)", () => {
|
||||
beforeEach(() => {
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOverlap: 0,
|
||||
viewportHeight: null,
|
||||
viewportOffsetTop: 0,
|
||||
keyboardOpen: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies keyboard CSS variables when keyboard is open on mobile", () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOverlap: 300,
|
||||
viewportHeight: 400,
|
||||
viewportOffsetTop: 50,
|
||||
keyboardOpen: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const modal = screen.getByRole("dialog").querySelector(".planning-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
expect(modal!.getAttribute("style")).toContain("--keyboard-overlap");
|
||||
expect(modal!.getAttribute("style")).toContain("--vv-height");
|
||||
expect(modal!.getAttribute("style")).toContain("--vv-offset-top");
|
||||
});
|
||||
|
||||
it("does not apply keyboard CSS variables when keyboard is closed", () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOverlap: 0,
|
||||
viewportHeight: null,
|
||||
viewportOffsetTop: 0,
|
||||
keyboardOpen: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const modal = screen.getByRole("dialog").querySelector(".planning-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
expect(modal!.getAttribute("style")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not apply keyboard CSS variables on desktop", () => {
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOverlap: 0,
|
||||
viewportHeight: null,
|
||||
viewportOffsetTop: 0,
|
||||
keyboardOpen: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const modal = screen.getByRole("dialog").querySelector(".planning-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
expect(modal!.getAttribute("style")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,239 +0,0 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
|
||||
|
||||
const mockViewportMode = vi.fn<() => "mobile" | "desktop">();
|
||||
const mockFetchAiSessions = vi.fn();
|
||||
const mockFetchAiSession = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
const mockSubscribeSse = vi.fn(() => vi.fn());
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
getViewportMode: () => mockViewportMode(),
|
||||
isMobileViewport: () => mockViewportMode() === "mobile",
|
||||
useViewportMode: () => mockViewportMode(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSessionLock", () => ({
|
||||
useSessionLock: () => ({
|
||||
isLockedByOther: false,
|
||||
takeControl: vi.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAiSessionSync", () => ({
|
||||
useAiSessionSync: () => ({
|
||||
activeTabMap: new Map(),
|
||||
broadcastUpdate: vi.fn(),
|
||||
broadcastCompleted: vi.fn(),
|
||||
broadcastLock: vi.fn(),
|
||||
broadcastUnlock: vi.fn(),
|
||||
broadcastHeartbeat: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/getSessionTabId", () => ({
|
||||
getSessionTabId: () => "tab-1",
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
||||
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args),
|
||||
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
|
||||
parseConversationHistory: () => [],
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const planningSessionSummary = {
|
||||
id: "plan-1",
|
||||
type: "planning" as const,
|
||||
title: "Roadmap draft",
|
||||
preview: "Plan authentication",
|
||||
status: "draft" as const,
|
||||
archived: false,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
updatedAt: "2026-05-01T00:00:00.000Z",
|
||||
projectId: null,
|
||||
};
|
||||
|
||||
const planningSessionDetail = {
|
||||
...planningSessionSummary,
|
||||
inputPayload: JSON.stringify({ initialPlan: "Plan authentication" }),
|
||||
conversationHistory: "[]",
|
||||
thinkingOutput: "",
|
||||
currentQuestion: null,
|
||||
result: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function HistoryHarness({ children }: { children: ReactNode }) {
|
||||
const history = useNavigationHistory({ enabled: true });
|
||||
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
||||
}
|
||||
|
||||
const countNavIndexPushes = (pushStateSpy: ReturnType<typeof vi.spyOn>) =>
|
||||
pushStateSpy.mock.calls.filter(([state]) => typeof (state as { navIndex?: unknown })?.navIndex === "number").length;
|
||||
|
||||
describe("PlanningModeModal mobile swipe-back", () => {
|
||||
let pushStateSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
mockFetchAiSessions.mockResolvedValue([planningSessionSummary]);
|
||||
mockFetchAiSession.mockResolvedValue(planningSessionDetail);
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
pushStateSpy = vi.spyOn(window.history, "pushState");
|
||||
});
|
||||
|
||||
it("pushes one mobile nav entry when opening a planning session and popstate returns to list view", async () => {
|
||||
const { rerender } = render(
|
||||
<HistoryHarness>
|
||||
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Roadmap draft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Roadmap draft"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("plan-1");
|
||||
expect(countNavIndexPushes(pushStateSpy)).toBe(1);
|
||||
});
|
||||
|
||||
rerender(
|
||||
<HistoryHarness>
|
||||
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(countNavIndexPushes(pushStateSpy)).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const body = document.querySelector(".planning-modal-body");
|
||||
expect(body).toHaveClass("planning-modal-body--show-list");
|
||||
expect(body).not.toHaveClass("planning-modal-body--show-detail");
|
||||
});
|
||||
});
|
||||
|
||||
it("pushes a mobile nav entry when opening New Session and popstate returns to the list", async () => {
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Roadmap draft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /new session/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushStateSpy).toHaveBeenCalledWith(expect.objectContaining({ navIndex: expect.any(Number) }), "");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const body = document.querySelector(".planning-modal-body");
|
||||
expect(body).toHaveClass("planning-modal-body--show-list");
|
||||
expect(body).not.toHaveClass("planning-modal-body--show-detail");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not push nav entries on desktop for either selecting a session or opening New Session", async () => {
|
||||
mockViewportMode.mockReturnValue("desktop");
|
||||
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Roadmap draft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Roadmap draft"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /new session/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("plan-1");
|
||||
});
|
||||
|
||||
expect(countNavIndexPushes(pushStateSpy)).toBe(0);
|
||||
});
|
||||
|
||||
it("re-arms mobile push after closing and reopening the modal", async () => {
|
||||
const { rerender } = render(
|
||||
<HistoryHarness>
|
||||
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Roadmap draft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /new session/i }));
|
||||
await waitFor(() => {
|
||||
expect(countNavIndexPushes(pushStateSpy)).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const body = document.querySelector(".planning-modal-body");
|
||||
expect(body).toHaveClass("planning-modal-body--show-list");
|
||||
expect(body).not.toHaveClass("planning-modal-body--show-detail");
|
||||
});
|
||||
|
||||
rerender(
|
||||
<HistoryHarness>
|
||||
<PlanningModeModal isOpen={false} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<HistoryHarness>
|
||||
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Roadmap draft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /new session/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(countNavIndexPushes(pushStateSpy)).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
function extractRuleBlock(css: string, selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const matches = [...css.matchAll(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`, "g"))];
|
||||
return matches.at(-1)?.[1] ?? "";
|
||||
}
|
||||
|
||||
function extractMobileMediaBlocks(content: string): string {
|
||||
const blocks: string[] = [];
|
||||
const regex = /@media[^{]*\(max-width: 768px\)[^{]*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const startIdx = match.index + match[0].length;
|
||||
let braceCount = 1;
|
||||
let endIdx = startIdx;
|
||||
|
||||
while (braceCount > 0 && endIdx < content.length) {
|
||||
if (content[endIdx] === "{") braceCount += 1;
|
||||
if (content[endIdx] === "}") braceCount -= 1;
|
||||
endIdx += 1;
|
||||
}
|
||||
|
||||
if (braceCount === 0) {
|
||||
blocks.push(content.slice(startIdx, endIdx - 1));
|
||||
}
|
||||
}
|
||||
|
||||
return blocks.join("\n");
|
||||
}
|
||||
|
||||
describe("SkillsView/runtime-card token guardrails", () => {
|
||||
it("does not use forbidden runtime fallback literals/tokens", async () => {
|
||||
const css = await loadAllAppCss();
|
||||
|
||||
expect(css).not.toContain("var(--accent-green");
|
||||
expect(css).not.toContain("var(--accent-red");
|
||||
expect(css).not.toContain("var(--space-xxs");
|
||||
expect(css).not.toContain("var(--accent-green, #22c55e)");
|
||||
expect(css).not.toContain("var(--accent-red, #ef4444)");
|
||||
expect(css).not.toContain("var(--accent, #4f46e5)");
|
||||
});
|
||||
|
||||
it("keeps discovered-skill rows on one line at the mobile breakpoint", async () => {
|
||||
const css = await loadAllAppCss();
|
||||
const mobileMediaBlock = extractMobileMediaBlocks(css);
|
||||
const itemBlock = extractRuleBlock(mobileMediaBlock, ".skills-view-item");
|
||||
const infoBlock = extractRuleBlock(mobileMediaBlock, ".skills-view-item-info");
|
||||
|
||||
expect(itemBlock).toContain("flex-wrap: nowrap");
|
||||
expect(infoBlock).toContain("flex: 1 1 auto");
|
||||
expect(infoBlock).toContain("width: auto");
|
||||
});
|
||||
|
||||
it("anchors the hidden toggle input to the toggle label across desktop and mobile", async () => {
|
||||
const css = await loadAllAppCss();
|
||||
const toggleBlock = extractRuleBlock(css, ".skills-view-item-toggle");
|
||||
const inputBlock = extractRuleBlock(css, ".skills-view-item-toggle input");
|
||||
const mobileMediaBlock = extractMobileMediaBlocks(css);
|
||||
const mobileToggleBlock = extractRuleBlock(mobileMediaBlock, ".skills-view-item-toggle");
|
||||
|
||||
expect(toggleBlock).toContain("position: relative");
|
||||
expect(inputBlock).toContain("position: absolute");
|
||||
expect(inputBlock).toContain("clip: rect(0, 0, 0, 0)");
|
||||
expect(mobileToggleBlock).not.toMatch(/position\s*:/);
|
||||
});
|
||||
|
||||
it("keeps checked and unchecked toggle geometry token-aligned", async () => {
|
||||
const css = await loadAllAppCss();
|
||||
const sliderBlock = extractRuleBlock(css, ".skills-view-toggle-slider");
|
||||
const checkedSliderBlock = extractRuleBlock(
|
||||
css,
|
||||
".skills-view-item-toggle input:checked + .skills-view-toggle-slider"
|
||||
);
|
||||
const checkedKnobBlock = extractRuleBlock(
|
||||
css,
|
||||
".skills-view-item-toggle input:checked + .skills-view-toggle-slider::after"
|
||||
);
|
||||
|
||||
expect(sliderBlock).toContain("width: calc(var(--space-xl) + var(--space-lg))");
|
||||
expect(checkedSliderBlock).toContain("background: var(--color-success)");
|
||||
expect(checkedKnobBlock).toContain(
|
||||
"transform: translateX(calc(var(--space-lg) + (var(--space-xs) / 2)))"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const indexHtmlPath = path.resolve(__dirname, "../../index.html");
|
||||
|
||||
function getMainMobileSection(css: string): string {
|
||||
// After CSS extraction, mobile rules live both in styles.css's
|
||||
// "Mobile Responsive Overrides" section AND in @media (max-width: 768px)
|
||||
// blocks at the bottom of each co-located component CSS file. Treat the
|
||||
// union of all 768px-and-below media blocks as the "main mobile section".
|
||||
const matches = [...css.matchAll(/@media[^{]*\(max-width:\s*768px\)[^{]*\{/g)];
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const match of matches) {
|
||||
const start = match.index!;
|
||||
const open = css.indexOf("{", start);
|
||||
let depth = 1;
|
||||
let i = open + 1;
|
||||
while (i < css.length && depth > 0) {
|
||||
if (css[i] === "{") depth++;
|
||||
else if (css[i] === "}") depth--;
|
||||
i++;
|
||||
}
|
||||
parts.push(css.slice(start, i));
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function getFirstRootBlock(css: string): string {
|
||||
const match = css.match(/:root\s*\{([\s\S]*?)\n\}/);
|
||||
expect(match).toBeTruthy();
|
||||
return match![1];
|
||||
}
|
||||
|
||||
describe("mobile CSS foundation", () => {
|
||||
it("defines canonical mobile breakpoint custom properties in the first :root block", () => {
|
||||
const css = loadAllAppCss();
|
||||
const firstRoot = getFirstRootBlock(css);
|
||||
|
||||
expect(firstRoot).toContain("--mobile-breakpoint: 768px;");
|
||||
expect(firstRoot).toContain("--tablet-breakpoint: 1024px;");
|
||||
expect(firstRoot).toContain("--small-breakpoint: 480px;");
|
||||
expect(firstRoot).toContain("--xsmall-breakpoint: 640px;");
|
||||
});
|
||||
|
||||
it("provides a touch-target utility class with 44px minimum dimensions", () => {
|
||||
const css = loadAllAppCss();
|
||||
const touchTargetMatch = css.match(/\.touch-target\s*\{([\s\S]*?)\}/);
|
||||
|
||||
expect(touchTargetMatch).toBeTruthy();
|
||||
expect(touchTargetMatch![1]).toContain("min-width: 44px;");
|
||||
expect(touchTargetMatch![1]).toContain("min-height: 44px;");
|
||||
});
|
||||
|
||||
it("defines the shared btn-icon size variable contract", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
const btnIconBlock = css.match(/\.btn-icon\s*\{([\s\S]*?)\}/);
|
||||
expect(btnIconBlock).toBeTruthy();
|
||||
expect(btnIconBlock![1]).toContain("--btn-icon-size: var(--icon-size-md);");
|
||||
|
||||
const btnIconSvgBlock = css.match(/\.btn-icon\s*>\s*svg\s*\{([\s\S]*?)\}/);
|
||||
expect(btnIconSvgBlock).toBeTruthy();
|
||||
expect(btnIconSvgBlock![1]).toContain("width: var(--btn-icon-size);");
|
||||
expect(btnIconSvgBlock![1]).toContain("height: var(--btn-icon-size);");
|
||||
|
||||
const btnIconCompactBlock = css.match(/\.btn-icon\.btn-sm[\s\S]*?\{([\s\S]*?)\}/);
|
||||
expect(btnIconCompactBlock).toBeTruthy();
|
||||
expect(btnIconCompactBlock![1]).toContain("--btn-icon-size: var(--icon-size-sm);");
|
||||
});
|
||||
|
||||
it("enforces 16px font size for text inputs in the main mobile media query", () => {
|
||||
const css = loadAllAppCss();
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expect(mobileSection).toContain("@media (max-width: 768px)");
|
||||
expect(mobileSection).toContain('input[type="text"]');
|
||||
expect(mobileSection).toContain('input[type="search"]');
|
||||
expect(mobileSection).toContain('input[type="tel"]');
|
||||
expect(mobileSection).toContain("select,");
|
||||
expect(mobileSection).toContain("textarea {");
|
||||
expect(mobileSection).toContain("font-size: 16px;");
|
||||
});
|
||||
|
||||
it("applies safe-area inset handling in the main mobile section", () => {
|
||||
const css = loadAllAppCss();
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expect(mobileSection).toContain("#root {");
|
||||
expect(mobileSection).toContain("overflow: hidden;");
|
||||
expect(mobileSection).toContain(".header {");
|
||||
expect(mobileSection).toContain("padding-left: max(var(--space-md), env(safe-area-inset-left, 0px));");
|
||||
expect(mobileSection).toContain(".board {");
|
||||
expect(mobileSection).toContain("padding-bottom: max(var(--space-md), env(safe-area-inset-bottom, 0px));");
|
||||
expect(mobileSection).toContain(".modal:not(.confirm-dialog),");
|
||||
expect(mobileSection).toContain("padding-bottom: env(safe-area-inset-bottom, 0px);");
|
||||
});
|
||||
|
||||
it("adds mobile overflow guards for wide content", () => {
|
||||
const css = loadAllAppCss();
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expect(mobileSection).toContain("* {");
|
||||
expect(mobileSection).toContain("max-width: 100vw;");
|
||||
expect(mobileSection).toContain("pre,");
|
||||
expect(mobileSection).toContain("overflow-x: auto;");
|
||||
expect(mobileSection).toContain(".code-block");
|
||||
expect(mobileSection).toContain("word-break: break-all;");
|
||||
expect(mobileSection).toContain("word-break: break-word;");
|
||||
expect(mobileSection).toContain("img,");
|
||||
expect(mobileSection).toContain("svg {");
|
||||
expect(mobileSection).toContain("max-width: 100%;");
|
||||
expect(mobileSection).toContain("table {");
|
||||
expect(mobileSection).toContain("display: block;");
|
||||
expect(mobileSection).toContain("-webkit-overflow-scrolling: touch;");
|
||||
expect(mobileSection).toContain(".workflow-step-manager-modal {");
|
||||
expect(mobileSection).toContain("max-height: 100dvh;");
|
||||
});
|
||||
|
||||
it("keeps the capacitor viewport meta tag configured", () => {
|
||||
const html = fs.readFileSync(indexHtmlPath, "utf-8");
|
||||
|
||||
expect(html).toContain("name=\"viewport\"");
|
||||
expect(html).toContain("width=device-width");
|
||||
expect(html).toContain("maximum-scale=1.0");
|
||||
expect(html).toContain("user-scalable=no");
|
||||
});
|
||||
|
||||
it("uses only approved max-width breakpoint values", () => {
|
||||
const css = loadAllAppCss();
|
||||
const matches = [...css.matchAll(/@media\s*\(max-width:\s*(\d+)px\)/g)];
|
||||
const foundValues = new Set(matches.map((match) => Number(match[1])));
|
||||
const allowedValues = new Set([480, 640, 720, 768, 860]);
|
||||
|
||||
expect(foundValues.size).toBeGreaterThan(0);
|
||||
for (const value of foundValues) {
|
||||
expect(allowedValues.has(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -231,40 +231,12 @@ const qualityAppComponentBatchBTests = buildComponentQualityInclude(batchedQuali
|
||||
const qualityAppAppOnlyTests = ["app/components/__tests__/App.test.tsx"];
|
||||
const qualityAppChatOnlyTests = ["app/components/__tests__/ChatView.test.tsx"];
|
||||
const qualityAppSettingsOnlyTests = ["app/components/__tests__/SettingsModal.test.tsx"];
|
||||
const quarantinedDashboardTests: string[] = [
|
||||
/*
|
||||
FNXC:Testing 2026-06-13-18:05:
|
||||
Full dashboard API quality runs exposed suite-load-sensitive failures in process-group timeout and git branch-commit route tests, while both files passed standalone immediately afterward. FN-6416 required exclusion during the 14-day deletion-ratchet window instead of widening waits or weakening assertions.
|
||||
|
||||
FNXC:DashboardTests 2026-06-14-00:43:
|
||||
Vitest project entries must apply the same quarantine list as the exported dashboardQualityProjectGlobs inventory. Some projects define their own exclude arrays, so each runnable project includes these entries explicitly instead of relying on top-level inheritance.
|
||||
|
||||
FNXC:DashboardTests 2026-06-14-02:24:
|
||||
FN-6433 rescued the dashboard quarantine batch after unquarantined app-backfill and API-quality runs passed with no assertion or timeout changes. Keep this array empty unless a future dashboard quarantine is mirrored in scripts/lib/test-quarantine.json in the same commit.
|
||||
|
||||
FNXC:DashboardTests 2026-06-14-08:28:
|
||||
FN-6441 removed the dashboard component orphan batch from the curated skip-list so passing rescues run in backfill and still-failing tests are excluded only through the dated quarantine ledger. Keep these one-line excludes mirrored with scripts/lib/test-quarantine.json until each file is rescued or deleted under the deletion ratchet.
|
||||
|
||||
FNXC:DashboardTests 2026-06-14-09:58:
|
||||
FN-6444 applies the same no-silent-orphan invariant to dashboard src route/API tests: rescued files run in backfill, while broad stale mission/planning suites are represented only by the dated quarantine ledger.
|
||||
|
||||
FNXC:DashboardSessionTests 2026-06-14-12:10:
|
||||
FN-6447 rescued session-reconnect by isolating the SSE harness from unrelated route background workers, so it must stay out of this quarantine list and run in dashboard-api-quality-backfill.
|
||||
*/
|
||||
"app/components/__tests__/ChatView.regular-composer-no-right-line.test.tsx",
|
||||
"app/components/__tests__/MissionManager.test.tsx",
|
||||
"app/components/__tests__/ModalReentry.test.tsx",
|
||||
"app/components/__tests__/ModelSelectorTab.test.tsx",
|
||||
"app/components/__tests__/NewAgentDialog.test.tsx",
|
||||
"app/components/__tests__/OAuthReloginBanner.test.tsx",
|
||||
"app/components/__tests__/PlanningModeModal.favorites.test.tsx",
|
||||
"app/components/__tests__/PlanningModeModal.questions.test.tsx",
|
||||
"app/components/__tests__/PlanningModeModal.swipe-back.test.tsx",
|
||||
"app/components/__tests__/SkillsView.css.test.ts",
|
||||
"app/components/__tests__/mobile-css.test.tsx",
|
||||
"src/__tests__/mission-e2e.test.ts",
|
||||
"src/__tests__/planning.test.ts",
|
||||
];
|
||||
/*
|
||||
FNXC:DashboardTestQuarantine 2026-06-14-17:01:
|
||||
FN-6454 applied the quarantine deletion ratchet to every dashboard test quarantined on 2026-06-14.
|
||||
Keep this list empty until a new flaky dashboard test is quarantined with a matching ledger entry.
|
||||
*/
|
||||
const quarantinedDashboardTests: string[] = [];
|
||||
|
||||
const qualityApiTests = [
|
||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||
|
||||
@@ -1,70 +1,4 @@
|
||||
{
|
||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet \u2014 see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date \u2014 the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.",
|
||||
"entries": [
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/ChatView.regular-composer-no-right-line.test.tsx",
|
||||
"reason": "FN-6441: orphaned dashboard component test fails standalone; ChatView emits act warnings and regular-composer right-line invariant assertion fails. Quarantined instead of widening waits or weakening assertions.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/MissionManager.test.tsx",
|
||||
"reason": "FN-6441: orphaned dashboard component test fails standalone with stale mission hierarchy/progress/status expectations while most cases pass. Quarantined for rescue/delete ratchet instead of assertion appeasement.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/ModalReentry.test.tsx",
|
||||
"reason": "FN-6441: orphaned dashboard component test fails standalone because PlanningModal cases render outside ToastProvider. Quarantined for harness rescue instead of product/source changes.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx",
|
||||
"reason": "FN-6441: orphaned dashboard component test fails standalone across model selector cases because expected Executor Model labels/options are no longer rendered by the current component contract. Quarantined for harness/expectation rescue.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/NewAgentDialog.test.tsx",
|
||||
"reason": "FN-6441: orphaned dashboard component test fails standalone across broad dialog flows with duplicate fetch/update calls and stale favorite labels. Quarantined for focused rescue rather than timeout/assertion appeasement.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx",
|
||||
"reason": "FN-6441: orphaned dashboard component test times out every case under current async/polling behavior. Quarantined instead of increasing test timeouts.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/PlanningModeModal.favorites.test.tsx",
|
||||
"reason": "FN-6441: orphaned dashboard component test fails standalone because PlanningModeModal favorite/keyboard cases render outside ToastProvider. Quarantined for harness rescue.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/PlanningModeModal.questions.test.tsx",
|
||||
"reason": "FN-6441: orphaned dashboard component test fails standalone because PlanningModeModal question/summary cases render outside ToastProvider. Quarantined for harness rescue.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/PlanningModeModal.swipe-back.test.tsx",
|
||||
"reason": "FN-6441: orphaned dashboard component test fails standalone because PlanningModeModal mobile navigation cases render outside ToastProvider. Quarantined for harness rescue.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/SkillsView.css.test.ts",
|
||||
"reason": "FN-6441: orphaned dashboard CSS guardrail fails standalone because runtime-card toggle positioning expectation no longer matches current stylesheet. Quarantined for rescue/delete review without weakening assertion.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/mobile-css.test.tsx",
|
||||
"reason": "FN-6441: orphaned dashboard CSS foundation test fails standalone on stale workflow-step-manager modal and breakpoint assertions. Quarantined for rescue/delete review without broad CSS changes.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/src/__tests__/mission-e2e.test.ts",
|
||||
"reason": "FN-6444: orphaned dashboard mission API test fails standalone across broad stale mission creation/update/backfill/shared-branch assertions. Quarantined for focused rescue/delete ratchet instead of weakening assertions or editing product route source.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/src/__tests__/planning.test.ts",
|
||||
"reason": "FN-6444: orphaned dashboard planning route/API test is slow and fails standalone across stale agent/session mocks plus temp cleanup leakage. Quarantined instead of widening waits/timeouts or weakening assertions.",
|
||||
"quarantinedAt": "2026-06-14"
|
||||
}
|
||||
]
|
||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.",
|
||||
"entries": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user