Cover lifecycle badge fallback behavior across AppModals status surfaces. - Verify authoritative status refreshes replace stale Planning labels with In Progress lifecycle badges. - Exercise board, desktop list, mobile list, and detail modal status renderers. - Isolate task cache and viewport mocks for deterministic responsive assertions. Files changed: .../app/components/__tests__/AppModals.test.tsx | 145 ++++++++++++++++++++- 1 file changed, 140 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8892 Fusion-Task-Lineage: 34ccda74-3f29-4bc3-8221-f44983a97a5e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
1085 lines
43 KiB
TypeScript
1085 lines
43 KiB
TypeScript
import { createElement, useEffect as reactUseEffect } from "react";
|
|
import type { ReactElement, ReactNode } from "react";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { act, fireEvent, render as rtlRender, screen, waitFor, type RenderOptions } from "@testing-library/react";
|
|
import { AppModals } from "../AppModals";
|
|
import { TaskCard } from "../TaskCard";
|
|
import { ListView } from "../ListView";
|
|
import { useTasks } from "../../hooks/useTasks";
|
|
import { clearCache, SWR_CACHE_KEYS } from "../../utils/swrCache";
|
|
import * as taskApi from "../../api";
|
|
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
|
|
import type { ModalManager } from "../../hooks/useModalManager";
|
|
import type { Toast } from "../../hooks/useToast";
|
|
|
|
const sseSubscriptions = vi.hoisted(() => [] as Array<{
|
|
url: string;
|
|
events?: Record<string, (event: { data: string }) => void>;
|
|
}>);
|
|
vi.mock("../../sse-bus", () => ({
|
|
subscribeSse: vi.fn((url: string, options: { events?: Record<string, (event: { data: string }) => void> }) => {
|
|
sseSubscriptions.push({ url, events: options.events });
|
|
return vi.fn();
|
|
}),
|
|
}));
|
|
|
|
// Spy through the real detail host so lifecycle assertions exercise its rendered state.
|
|
const mockTaskDetailModalProps = vi.fn();
|
|
vi.mock("../TaskDetailModal", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../TaskDetailModal")>();
|
|
return {
|
|
...actual,
|
|
TaskDetailModal: (props: React.ComponentProps<typeof actual.TaskDetailModal>) => {
|
|
mockTaskDetailModalProps(props);
|
|
return createElement(actual.TaskDetailModal, props);
|
|
},
|
|
};
|
|
});
|
|
|
|
const mockSettingsModalProps = vi.fn();
|
|
vi.mock("../SettingsModal", () => ({
|
|
SettingsModal: (props: any) => {
|
|
mockSettingsModalProps(props);
|
|
return <div data-testid="settings-modal">Settings Modal</div>;
|
|
},
|
|
}));
|
|
|
|
/*
|
|
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
|
GitHub Import is always-mounted and its persist effect depends on projectId, so a project
|
|
swap used to re-fire it with the old project's selections under the new project's storage
|
|
key. The mock records mounts so the project-keyed remount contract is testable.
|
|
*/
|
|
const githubImportMounts = vi.hoisted(() => [] as Array<string | undefined>);
|
|
vi.mock("../GitHubImportModal", () => ({
|
|
GitHubImportModal: ({ projectId }: { projectId?: string }) => {
|
|
reactUseEffect(() => {
|
|
githubImportMounts.push(projectId);
|
|
}, []);
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
vi.mock("../PlanningModeModal", () => ({
|
|
PlanningModeModal: () => null,
|
|
}));
|
|
|
|
/*
|
|
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
|
The subtask breakdown mock records mounts so the project-keyed remount contract is
|
|
testable: a project swap must create a fresh instance, not update the old one (which
|
|
persisted the previous project's draft under the new project's storage key).
|
|
*/
|
|
const subtaskMounts = vi.hoisted(() => [] as Array<string | undefined>);
|
|
vi.mock("../SubtaskBreakdownModal", () => ({
|
|
SubtaskBreakdownModal: ({ projectId }: { projectId?: string }) => {
|
|
reactUseEffect(() => {
|
|
subtaskMounts.push(projectId);
|
|
}, []);
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
/*
|
|
FNXC:Terminal 2026-07-26-19:50:
|
|
This mock is VESTIGIAL: AppModals does not render TerminalModal — App.tsx does, gated on
|
|
`modalManager.terminalOpen`. It is kept only to keep the heavy xterm module out of this file's graph if
|
|
an import path ever reaches it. It is deliberately NOT a shallow `isOpen ? <div/> : null` stand-in,
|
|
because that shape is what made App's terminal mount/unmount invariant unverifiable (see the lifecycle
|
|
recorder in App.test.tsx). The guard below fails if the terminal ever moves into AppModals, so this mock
|
|
cannot silently become the thing that hides the invariant a second time.
|
|
*/
|
|
vi.mock("../TerminalModal", () => ({
|
|
TerminalModal: () => null,
|
|
}));
|
|
|
|
vi.mock("../ScriptsModal", () => ({
|
|
ScriptsModal: () => null,
|
|
}));
|
|
|
|
vi.mock("../FileBrowserModal", () => ({
|
|
FileBrowserModal: () => null,
|
|
}));
|
|
|
|
vi.mock("../UsageIndicator", () => ({
|
|
UsageIndicator: () => null,
|
|
}));
|
|
|
|
// Mock ScheduledTasksModal to capture props
|
|
const mockScheduledTasksModalProps = vi.fn();
|
|
vi.mock("../ScheduledTasksModal", () => ({
|
|
ScheduledTasksModal: ({ projectId, ...rest }: any) => {
|
|
mockScheduledTasksModalProps({ projectId, rest });
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
vi.mock("../NewTaskModal", () => ({
|
|
NewTaskModal: () => null,
|
|
}));
|
|
|
|
const mockActivityLogModalProps = vi.fn();
|
|
vi.mock("../ActivityLogModal", () => ({
|
|
ActivityLogModal: (props: any) => {
|
|
mockActivityLogModalProps(props);
|
|
return (
|
|
<button data-testid="activity-log-open-task" onClick={() => props.onOpenTaskDetail?.("FN-1")}>
|
|
open task detail
|
|
</button>
|
|
);
|
|
},
|
|
}));
|
|
|
|
vi.mock("../GitManagerModal", () => ({
|
|
GitManagerModal: () => null,
|
|
}));
|
|
|
|
vi.mock("../AgentListModal", () => ({
|
|
AgentListModal: () => null,
|
|
}));
|
|
|
|
const mockSetupWizardModalProps = vi.fn();
|
|
vi.mock("../SetupWizardModal", () => ({
|
|
SetupWizardModal: (props: any) => {
|
|
mockSetupWizardModalProps(props);
|
|
return <div data-testid="setup-wizard-modal" />;
|
|
},
|
|
}));
|
|
|
|
const mockModelOnboardingModalProps = vi.fn();
|
|
vi.mock("../ModelOnboardingModal", () => ({
|
|
ModelOnboardingModal: (props: any) => {
|
|
mockModelOnboardingModalProps(props);
|
|
return (
|
|
<button data-testid="onboarding-view-task" onClick={() => props.onViewTask?.({ id: "FN-1", title: "Created task" })}>
|
|
view task
|
|
</button>
|
|
);
|
|
},
|
|
}));
|
|
|
|
vi.mock("../ToastContainer", () => ({
|
|
ToastContainer: () => null,
|
|
}));
|
|
|
|
vi.mock("../../hooks/useTaskHandlers", () => ({
|
|
useTaskHandlers: () => ({
|
|
handleModalCreate: vi.fn(),
|
|
handlePlanningTaskCreated: vi.fn(),
|
|
handlePlanningTasksCreated: vi.fn(),
|
|
handleSubtaskTasksCreated: vi.fn(),
|
|
handleGitHubImport: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useProjectActions", () => ({
|
|
useProjectActions: () => ({
|
|
handleSetupComplete: vi.fn(),
|
|
handleModelOnboardingComplete: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// Preserve runtime core constants because the real TaskDetailModal now renders in this suite.
|
|
vi.mock("@fusion/core", async (importOriginal) => importOriginal<typeof import("@fusion/core")>());
|
|
|
|
// Mock ModalErrorBoundary
|
|
vi.mock("../ErrorBoundary", () => ({
|
|
ModalErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
function NavigationWrapper({ children }: { children: ReactNode }) {
|
|
const history = useNavigationHistory({ enabled: true });
|
|
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
|
}
|
|
|
|
function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
|
|
return rtlRender(ui, { wrapper: NavigationWrapper, ...options });
|
|
}
|
|
|
|
const integrationNoop = vi.fn();
|
|
const integrationAsyncNoop = vi.fn(async () => ({}));
|
|
|
|
function ensureMatchMedia() {
|
|
if (!window.matchMedia) {
|
|
Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn() });
|
|
}
|
|
}
|
|
|
|
function mockMobileViewport() {
|
|
ensureMatchMedia();
|
|
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
|
|
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
|
matches: query === "(max-width: 768px)" || query === "(max-width: 600px)" || query === "(max-width: 768px), (max-height: 480px)",
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}));
|
|
}
|
|
|
|
function restoreDesktopViewport() {
|
|
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
|
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}));
|
|
}
|
|
|
|
/** Drives the same hook-owned board row into every production status renderer. */
|
|
function PlanningStatusConvergenceHarness({ detailTask, modalManager, settings }: {
|
|
detailTask: Record<string, unknown>;
|
|
modalManager: ModalManager;
|
|
settings: Record<string, unknown>;
|
|
}) {
|
|
const { tasks } = useTasks({ projectId: "project-a" });
|
|
const task = tasks[0];
|
|
if (!task) return null;
|
|
|
|
return (
|
|
<>
|
|
<TaskCard task={task} onOpenDetail={integrationNoop} addToast={integrationNoop} projectId="project-a" />
|
|
<ListView
|
|
tasks={tasks}
|
|
projectId="project-a"
|
|
onMoveTask={integrationAsyncNoop}
|
|
onDeleteTask={integrationAsyncNoop}
|
|
onArchiveTask={integrationAsyncNoop}
|
|
onMergeTask={integrationAsyncNoop}
|
|
onOpenDetail={integrationNoop}
|
|
addToast={integrationNoop}
|
|
/>
|
|
<AppModals
|
|
projectId="project-a"
|
|
tasks={tasks}
|
|
projects={[]}
|
|
currentProject={null}
|
|
addToast={integrationNoop}
|
|
toasts={[]}
|
|
removeToast={integrationNoop}
|
|
modalManager={modalManager}
|
|
projectActions={{ handleAddProject: integrationNoop, handleSetupComplete: integrationNoop, handleModelOnboardingComplete: integrationNoop }}
|
|
taskHandlers={{ handleModalCreate: integrationAsyncNoop, handlePlanningTaskCreated: integrationNoop, handlePlanningTasksCreated: integrationNoop, handleSubtaskTasksCreated: integrationNoop, handleGitHubImport: integrationNoop }}
|
|
taskOperations={{ moveTask: integrationAsyncNoop, deleteTask: integrationAsyncNoop, mergeTask: integrationAsyncNoop, retryTask: integrationAsyncNoop, duplicateTask: integrationAsyncNoop }}
|
|
deepLink={{ handleDetailClose: integrationNoop }}
|
|
settings={settings as any}
|
|
/>
|
|
<output data-testid="planning-harness-detail-id">{String(detailTask.id)}</output>
|
|
</>
|
|
);
|
|
}
|
|
|
|
describe("AppModals", () => {
|
|
const mockModalManager: ModalManager = {
|
|
// State
|
|
detailTask: null,
|
|
detailTaskInitialTab: "chat",
|
|
settingsOpen: false,
|
|
settingsInitialSection: undefined,
|
|
githubImportOpen: false,
|
|
isPlanningOpen: false,
|
|
planningInitialPlan: null,
|
|
planningResumeSessionId: undefined,
|
|
isSubtaskOpen: false,
|
|
subtaskInitialDescription: null,
|
|
subtaskResumeSessionId: undefined,
|
|
terminalOpen: false,
|
|
terminalInitialCommand: undefined,
|
|
terminalInitialCommandGeneration: 0,
|
|
scriptsOpen: false,
|
|
filesOpen: false,
|
|
fileBrowserWorkspace: "project",
|
|
fileBrowserInitialFile: null,
|
|
usageOpen: false,
|
|
usageAnchorRect: null,
|
|
schedulesOpen: false,
|
|
newTaskModalOpen: false,
|
|
activityLogOpen: false,
|
|
gitManagerOpen: false,
|
|
agentsOpen: false,
|
|
setupWizardOpen: false,
|
|
modelOnboardingOpen: false,
|
|
anyModalOpen: false,
|
|
// Handlers
|
|
openDetailTask: vi.fn(),
|
|
openDetailWithChangesTab: vi.fn(),
|
|
updateDetailTask: vi.fn(),
|
|
closeDetailTask: vi.fn(),
|
|
openSettings: vi.fn(),
|
|
closeSettings: vi.fn(),
|
|
openGitHubImport: vi.fn(),
|
|
closeGitHubImport: vi.fn(),
|
|
openPlanning: vi.fn(),
|
|
openPlanningWithInitialPlan: vi.fn(),
|
|
resumePlanning: vi.fn(),
|
|
openPlanningWithSession: vi.fn(),
|
|
closePlanning: vi.fn(),
|
|
openSubtaskBreakdown: vi.fn(),
|
|
openSubtaskWithSession: vi.fn(),
|
|
closeSubtask: vi.fn(),
|
|
toggleTerminal: vi.fn(),
|
|
closeTerminal: vi.fn(),
|
|
openScripts: vi.fn(),
|
|
closeScripts: vi.fn(),
|
|
runScript: vi.fn(),
|
|
openFiles: vi.fn(),
|
|
closeFiles: vi.fn(),
|
|
setFileWorkspace: vi.fn(),
|
|
openUsage: vi.fn(),
|
|
closeUsage: vi.fn(),
|
|
openSchedules: vi.fn(),
|
|
closeSchedules: vi.fn(),
|
|
openNewTask: vi.fn(),
|
|
closeNewTask: vi.fn(),
|
|
openActivityLog: vi.fn(),
|
|
closeActivityLog: vi.fn(),
|
|
openGitManager: vi.fn(),
|
|
closeGitManager: vi.fn(),
|
|
openAgents: vi.fn(),
|
|
closeAgents: vi.fn(),
|
|
openSetupWizard: vi.fn(),
|
|
closeSetupWizard: vi.fn(),
|
|
openModelOnboarding: vi.fn(),
|
|
closeModelOnboarding: vi.fn(),
|
|
closeProjectScopedModals: vi.fn(),
|
|
onPlanningTaskCreated: vi.fn(),
|
|
onPlanningTasksCreated: vi.fn(),
|
|
onSubtaskTasksCreated: vi.fn(),
|
|
};
|
|
|
|
const mockToasts: Toast[] = [];
|
|
const mockSettings = {
|
|
prAuthAvailable: false,
|
|
themeMode: "dark" as const,
|
|
colorTheme: "default" as const,
|
|
setThemeMode: vi.fn(),
|
|
setColorTheme: vi.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
sseSubscriptions.length = 0;
|
|
clearCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}project-a`);
|
|
mockTaskDetailModalProps.mockClear();
|
|
mockScheduledTasksModalProps.mockClear();
|
|
mockModelOnboardingModalProps.mockClear();
|
|
mockActivityLogModalProps.mockClear();
|
|
mockSettingsModalProps.mockClear();
|
|
restoreDesktopViewport();
|
|
});
|
|
|
|
it("renders without crashing", () => {
|
|
render(
|
|
<AppModals
|
|
projectId={undefined}
|
|
tasks={[]}
|
|
projects={[]}
|
|
currentProject={null}
|
|
addToast={vi.fn()}
|
|
toasts={mockToasts}
|
|
removeToast={vi.fn()}
|
|
modalManager={mockModalManager}
|
|
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
|
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
|
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
|
deepLink={{ handleDetailClose: vi.fn() }}
|
|
settings={mockSettings}
|
|
/>
|
|
);
|
|
expect(document.body).toBeDefined();
|
|
});
|
|
|
|
/*
|
|
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
|
Switching projects must remount the subtask breakdown (project-keyed), mirroring the
|
|
embedded Planning view: a prop update on the surviving instance ran resetState with the
|
|
NEW projectId and persisted the old project's draft under the new project's storage key.
|
|
*/
|
|
it("remounts the project-keyed modals (subtask breakdown, GitHub import) when the active project changes", () => {
|
|
const buildProps = (projectId: string) => ({
|
|
projectId,
|
|
tasks: [],
|
|
projects: [],
|
|
currentProject: null,
|
|
addToast: vi.fn(),
|
|
toasts: mockToasts,
|
|
removeToast: vi.fn(),
|
|
modalManager: mockModalManager,
|
|
projectActions: { handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() },
|
|
taskHandlers: { handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() },
|
|
taskOperations: { moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() },
|
|
deepLink: { handleDetailClose: vi.fn() },
|
|
settings: mockSettings,
|
|
});
|
|
|
|
subtaskMounts.length = 0;
|
|
githubImportMounts.length = 0;
|
|
const { rerender } = render(<AppModals {...buildProps("proj_a")} />);
|
|
expect(subtaskMounts).toEqual(["proj_a"]);
|
|
expect(githubImportMounts).toEqual(["proj_a"]);
|
|
|
|
rerender(<AppModals {...buildProps("proj_b")} />);
|
|
|
|
// A fresh mount for the new project — not a prop update on the old instance.
|
|
expect(subtaskMounts).toEqual(["proj_a", "proj_b"]);
|
|
expect(githubImportMounts).toEqual(["proj_a", "proj_b"]);
|
|
});
|
|
|
|
it("passes the live board task snapshot into the open detail modal while preserving prompt data", async () => {
|
|
const manager = {
|
|
...mockModalManager,
|
|
detailTask: {
|
|
id: "FN-123",
|
|
title: "Stale detail task",
|
|
description: "Original",
|
|
column: "todo",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [{ timestamp: "2026-04-25T12:00:00.000Z", action: "Created task" }],
|
|
prompt: "# Spec",
|
|
createdAt: "2026-04-25T12:00:00.000Z",
|
|
updatedAt: "2026-04-25T12:00:00.000Z",
|
|
},
|
|
};
|
|
const liveTask = {
|
|
id: "FN-123",
|
|
title: "Live board task",
|
|
description: "Updated",
|
|
column: "in-progress" as const,
|
|
status: "executing",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
tokenUsage: {
|
|
inputTokens: 1200,
|
|
outputTokens: 300,
|
|
cachedTokens: 100,
|
|
cacheWriteTokens: 25,
|
|
totalTokens: 1600,
|
|
firstUsedAt: "2026-04-25T12:05:00.000Z",
|
|
lastUsedAt: "2026-04-25T12:10:00.000Z",
|
|
},
|
|
createdAt: "2026-04-25T12:00:00.000Z",
|
|
updatedAt: "2026-04-25T12:10:00.000Z",
|
|
};
|
|
|
|
render(
|
|
<AppModals
|
|
projectId={undefined}
|
|
tasks={[liveTask]}
|
|
projects={[]}
|
|
currentProject={null}
|
|
addToast={vi.fn()}
|
|
toasts={mockToasts}
|
|
removeToast={vi.fn()}
|
|
modalManager={manager}
|
|
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
|
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
|
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
|
deepLink={{ handleDetailClose: vi.fn() }}
|
|
settings={mockSettings}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockTaskDetailModalProps).toHaveBeenCalled();
|
|
});
|
|
|
|
const detailTask = mockTaskDetailModalProps.mock.calls.at(-1)?.[0]?.task;
|
|
expect(detailTask).toMatchObject({
|
|
id: "FN-123",
|
|
title: "Live board task",
|
|
column: "in-progress",
|
|
status: "executing",
|
|
tokenUsage: liveTask.tokenUsage,
|
|
prompt: "# Spec",
|
|
});
|
|
expect(detailTask.log).toEqual([
|
|
{ timestamp: "2026-04-25T12:00:00.000Z", action: "Created task" },
|
|
]);
|
|
});
|
|
|
|
/*
|
|
FNXC:TaskDetailStateStability 2026-08-05-02:55:
|
|
This is the rendered production modal reproduction: scheduler resync delivers Todo after the
|
|
dependency/file-overlap queue transition. The open detail must continuously show Queued because
|
|
AppModals reconciles its retained detail snapshot with the live board by lifecycle freshness.
|
|
*/
|
|
it("does not roll an open queued detail back when a stale scheduler board row arrives", async () => {
|
|
const detail = {
|
|
id: "FN-QUEUED",
|
|
title: "Blocked task",
|
|
description: "",
|
|
column: "todo",
|
|
status: "queued",
|
|
overlapBlockedBy: "FN-UPSTREAM",
|
|
dependencies: ["FN-UPSTREAM"],
|
|
steps: [],
|
|
log: [{ timestamp: "2026-08-05T10:02:00.000Z", action: "Queued for dependency" }],
|
|
prompt: "# Prompt",
|
|
createdAt: "2026-08-05T10:00:00.000Z",
|
|
updatedAt: "2026-08-05T10:02:00.000Z",
|
|
columnMovedAt: "2026-08-05T10:02:00.000Z",
|
|
};
|
|
const staleSchedulerTodo = {
|
|
...detail,
|
|
status: undefined,
|
|
prompt: undefined,
|
|
log: [],
|
|
updatedAt: "2026-08-05T10:00:00.000Z",
|
|
columnMovedAt: "2026-08-05T10:00:00.000Z",
|
|
};
|
|
const manager = { ...mockModalManager, detailTask: detail };
|
|
|
|
const renderModal = (tasks: typeof detail[]) => (
|
|
<AppModals
|
|
projectId="project-a"
|
|
tasks={tasks}
|
|
projects={[]}
|
|
currentProject={null}
|
|
addToast={vi.fn()}
|
|
toasts={mockToasts}
|
|
removeToast={vi.fn()}
|
|
modalManager={manager}
|
|
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
|
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
|
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
|
deepLink={{ handleDetailClose: vi.fn() }}
|
|
settings={mockSettings}
|
|
/>
|
|
);
|
|
const { rerender } = render(renderModal([detail]));
|
|
await waitFor(() => expect(mockTaskDetailModalProps).toHaveBeenCalled());
|
|
|
|
rerender(renderModal([staleSchedulerTodo]));
|
|
|
|
const renderedTask = mockTaskDetailModalProps.mock.calls.at(-1)?.[0]?.task;
|
|
expect(renderedTask).toMatchObject({ status: "queued", overlapBlockedBy: "FN-UPSTREAM", prompt: "# Prompt" });
|
|
expect(renderedTask.log).toEqual(detail.log);
|
|
});
|
|
|
|
it("keeps an open planning detail authoritative while the board has only live planner evidence", async () => {
|
|
const detail = {
|
|
id: "FN-8798",
|
|
title: "Revision task",
|
|
description: "",
|
|
column: "triage" as const,
|
|
status: "planning",
|
|
dependencies: [],
|
|
steps: [],
|
|
log: [],
|
|
prompt: "# Prompt",
|
|
createdAt: "2026-08-05T10:00:00.000Z",
|
|
updatedAt: "2026-08-05T10:01:00.000Z",
|
|
};
|
|
const boardRow = {
|
|
...detail,
|
|
status: "needs-replan",
|
|
prompt: undefined,
|
|
updatedAt: "2026-08-05T10:00:00.000Z",
|
|
recentAgentActivityAt: "2026-08-05T10:01:00.000Z",
|
|
};
|
|
const manager = { ...mockModalManager, detailTask: detail };
|
|
|
|
render(
|
|
<AppModals
|
|
projectId="project-a"
|
|
tasks={[boardRow]}
|
|
projects={[]}
|
|
currentProject={null}
|
|
addToast={vi.fn()}
|
|
toasts={mockToasts}
|
|
removeToast={vi.fn()}
|
|
modalManager={manager}
|
|
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
|
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
|
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
|
deepLink={{ handleDetailClose: vi.fn() }}
|
|
settings={mockSettings}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(mockTaskDetailModalProps).toHaveBeenCalled());
|
|
expect(mockTaskDetailModalProps.mock.calls.at(-1)?.[0]?.task).toMatchObject({
|
|
id: "FN-8798",
|
|
status: "planning",
|
|
prompt: "# Prompt",
|
|
});
|
|
expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning");
|
|
});
|
|
|
|
it("drives SSE planner activity and its authoritative status update through board, the desktop list row, and the real detail host", async () => {
|
|
vi.spyOn(taskApi, "fetchTasks").mockReset();
|
|
vi.spyOn(taskApi, "fetchBoardWorkflows").mockReset();
|
|
window.localStorage.setItem("kb:project-a:kb-dashboard-list-columns", JSON.stringify(["title", "status"]));
|
|
const parkedTask = {
|
|
id: "FN-8798",
|
|
title: "Revision task",
|
|
description: "",
|
|
column: "triage" as const,
|
|
status: "needs-replan",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# Prompt",
|
|
createdAt: "2026-08-05T10:00:00.000Z",
|
|
updatedAt: "2026-08-05T10:00:00.000Z",
|
|
columnMovedAt: "2026-08-05T10:00:00.000Z",
|
|
};
|
|
const manager = { ...mockModalManager, detailTask: parkedTask };
|
|
vi.spyOn(taskApi, "fetchTasks").mockResolvedValueOnce([parkedTask] as any);
|
|
vi.spyOn(taskApi, "fetchBoardWorkflows").mockResolvedValue({
|
|
flagEnabled: true,
|
|
defaultWorkflowId: "builtin:coding",
|
|
workflows: [{
|
|
id: "builtin:coding",
|
|
name: "Coding",
|
|
columns: [{ id: "triage", name: "Planning", flags: { intake: true } }],
|
|
}],
|
|
taskWorkflowIds: { [parkedTask.id]: "builtin:coding" },
|
|
});
|
|
|
|
render(<PlanningStatusConvergenceHarness detailTask={parkedTask} modalManager={manager} settings={mockSettings} />);
|
|
|
|
await waitFor(() => expect(sseSubscriptions.some(({ url }) => url.startsWith("/api/events"))).toBe(true));
|
|
const boardEvents = sseSubscriptions.find(({ url }) => url.startsWith("/api/events"))?.events;
|
|
act(() => {
|
|
boardEvents?.["agent:log"]?.({ data: JSON.stringify({
|
|
taskId: parkedTask.id,
|
|
timestamp: "2026-08-05T10:01:00.000Z",
|
|
type: "tool",
|
|
agent: "triage",
|
|
}) });
|
|
boardEvents?.["task:updated"]?.({ data: JSON.stringify({
|
|
...parkedTask,
|
|
status: "planning",
|
|
updatedAt: "2026-08-05T10:02:00.000Z",
|
|
}) });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelector('.card[data-id="FN-8798"] .card-status-badge')).toHaveTextContent("Planning");
|
|
expect(document.querySelector('tr[data-id="FN-8798"] td.list-cell .list-status-badge')).toHaveTextContent("Planning");
|
|
expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning");
|
|
});
|
|
|
|
const inProgressTask = {
|
|
...parkedTask,
|
|
column: "in-progress" as const,
|
|
status: "planning",
|
|
updatedAt: "2026-08-05T10:03:00.000Z",
|
|
columnMovedAt: "2026-08-05T10:03:00.000Z",
|
|
};
|
|
act(() => {
|
|
boardEvents?.["task:moved"]?.({ data: JSON.stringify({
|
|
task: inProgressTask,
|
|
from: "triage",
|
|
to: "in-progress",
|
|
}) });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelector('.card[data-id="FN-8798"] .card-status-badge')).toHaveTextContent("Planning");
|
|
expect(document.querySelector('tr[data-id="FN-8798"] td.list-cell .list-status-badge')).toHaveTextContent("Planning");
|
|
expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning");
|
|
});
|
|
|
|
vi.mocked(taskApi.fetchTasks).mockResolvedValueOnce([{
|
|
...inProgressTask,
|
|
status: null,
|
|
}] as any);
|
|
act(() => window.dispatchEvent(new Event("focus")));
|
|
|
|
await waitFor(() => expect(taskApi.fetchTasks).toHaveBeenCalledTimes(2));
|
|
/*
|
|
FNXC:TaskStatusBadge 2026-08-09-08:24:
|
|
FN-8826's empty-status WIP lifecycle fallback and FN-8764/FN-8798's stale-planning
|
|
retirement are compatible: an authoritative status:null refresh must stop the card claiming
|
|
Planning, not stop it rendering its In Progress lifecycle badge. The detail header intentionally
|
|
has no fallback because its sibling .detail-column-badge already names the lane.
|
|
*/
|
|
await waitFor(() => {
|
|
const card = document.querySelector('.card[data-id="FN-8798"]');
|
|
const cardBadge = card?.querySelector(".card-status-badge");
|
|
const desktopListBadge = document.querySelector('tr[data-id="FN-8798"] td.list-cell .list-status-badge');
|
|
expect(card).toBeInTheDocument();
|
|
expect(cardBadge).toHaveTextContent(/in progress/i);
|
|
expect(cardBadge).not.toHaveTextContent(/planning/i);
|
|
expect(cardBadge?.className).not.toMatch(/queued-to-plan|planning/i);
|
|
expect(desktopListBadge).toHaveTextContent(/in progress/i);
|
|
expect(desktopListBadge).not.toHaveTextContent(/planning/i);
|
|
expect(document.querySelector("#task-detail-modal-title")).toHaveTextContent("FN-8798");
|
|
expect(screen.queryByTestId("task-detail-status-badge")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
/*
|
|
FNXC:TaskStatusBadge 2026-08-09-08:24:
|
|
ListView has two responsive status-badge renderers: .list-card on mobile and td.list-cell on
|
|
desktop. Both share getTaskWipLifecycleBadgeLabel, but one jsdom render exercises only desktop,
|
|
so this cross-surface guard explicitly mounts and proves the mobile renderer.
|
|
*/
|
|
it("drives SSE planner activity and its authoritative status update through board, the mobile list card, and the real detail host", async () => {
|
|
vi.spyOn(taskApi, "fetchTasks").mockReset();
|
|
vi.spyOn(taskApi, "fetchBoardWorkflows").mockReset();
|
|
const viewportSpy = mockMobileViewport();
|
|
try {
|
|
window.localStorage.setItem("kb:project-a:kb-dashboard-list-columns", JSON.stringify(["title", "status"]));
|
|
const parkedTask = {
|
|
id: "FN-8798",
|
|
title: "Revision task",
|
|
description: "",
|
|
column: "triage" as const,
|
|
status: "needs-replan",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# Prompt",
|
|
createdAt: "2026-08-05T10:00:00.000Z",
|
|
updatedAt: "2026-08-05T10:00:00.000Z",
|
|
columnMovedAt: "2026-08-05T10:00:00.000Z",
|
|
};
|
|
const manager = { ...mockModalManager, detailTask: parkedTask };
|
|
vi.spyOn(taskApi, "fetchTasks").mockResolvedValueOnce([parkedTask] as any);
|
|
vi.spyOn(taskApi, "fetchBoardWorkflows").mockResolvedValue({
|
|
flagEnabled: true,
|
|
defaultWorkflowId: "builtin:coding",
|
|
workflows: [{ id: "builtin:coding", name: "Coding", columns: [{ id: "triage", name: "Planning", flags: { intake: true } }] }],
|
|
taskWorkflowIds: { [parkedTask.id]: "builtin:coding" },
|
|
});
|
|
|
|
render(<PlanningStatusConvergenceHarness detailTask={parkedTask} modalManager={manager} settings={mockSettings} />);
|
|
await waitFor(() => expect(sseSubscriptions.some(({ url }) => url.startsWith("/api/events"))).toBe(true));
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".list-cards")).toBeInTheDocument();
|
|
expect(document.querySelector('tr[data-id="FN-8798"]')).not.toBeInTheDocument();
|
|
});
|
|
const boardEvents = sseSubscriptions.find(({ url }) => url.startsWith("/api/events"))?.events;
|
|
act(() => {
|
|
boardEvents?.["agent:log"]?.({ data: JSON.stringify({ taskId: parkedTask.id, timestamp: "2026-08-05T10:01:00.000Z", type: "tool", agent: "triage" }) });
|
|
boardEvents?.["task:updated"]?.({ data: JSON.stringify({ ...parkedTask, status: "planning", updatedAt: "2026-08-05T10:02:00.000Z" }) });
|
|
});
|
|
await waitFor(() => {
|
|
expect(document.querySelector('.card[data-id="FN-8798"] .card-status-badge')).toHaveTextContent("Planning");
|
|
expect(document.querySelector(".list-card .list-status-badge")).toHaveTextContent("Planning");
|
|
expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning");
|
|
});
|
|
|
|
const inProgressTask = { ...parkedTask, column: "in-progress" as const, status: "planning", updatedAt: "2026-08-05T10:03:00.000Z", columnMovedAt: "2026-08-05T10:03:00.000Z" };
|
|
act(() => boardEvents?.["task:moved"]?.({ data: JSON.stringify({ task: inProgressTask, from: "triage", to: "in-progress" }) }));
|
|
await waitFor(() => {
|
|
expect(document.querySelector('.card[data-id="FN-8798"] .card-status-badge')).toHaveTextContent("Planning");
|
|
expect(document.querySelector(".list-card .list-status-badge")).toHaveTextContent("Planning");
|
|
expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning");
|
|
});
|
|
|
|
vi.mocked(taskApi.fetchTasks).mockResolvedValueOnce([{ ...inProgressTask, status: null }] as any);
|
|
act(() => window.dispatchEvent(new Event("focus")));
|
|
await waitFor(() => expect(taskApi.fetchTasks).toHaveBeenCalledTimes(2));
|
|
await waitFor(() => {
|
|
const card = document.querySelector('.card[data-id="FN-8798"]');
|
|
const cardBadge = card?.querySelector(".card-status-badge");
|
|
const mobileListBadge = document.querySelector(".list-card .list-status-badge");
|
|
expect(card).toBeInTheDocument();
|
|
expect(cardBadge).toHaveTextContent(/in progress/i);
|
|
expect(cardBadge).not.toHaveTextContent(/planning/i);
|
|
expect(cardBadge?.className).not.toMatch(/queued-to-plan|planning/i);
|
|
expect(mobileListBadge).toHaveTextContent(/in progress/i);
|
|
expect(mobileListBadge).not.toHaveTextContent(/planning/i);
|
|
expect(document.querySelector("#task-detail-modal-title")).toHaveTextContent("FN-8798");
|
|
expect(screen.queryByTestId("task-detail-status-badge")).not.toBeInTheDocument();
|
|
});
|
|
} finally {
|
|
viewportSpy.mockRestore();
|
|
restoreDesktopViewport();
|
|
}
|
|
});
|
|
|
|
describe("ModelOnboardingModal wiring", () => {
|
|
beforeEach(() => {
|
|
mockModelOnboardingModalProps.mockClear();
|
|
mockSetupWizardModalProps.mockClear();
|
|
});
|
|
|
|
it("passes empty project id and setup-wizard callback into onboarding modal when no project is selected", () => {
|
|
const handleAddProject = vi.fn();
|
|
const manager = { ...mockModalManager, modelOnboardingOpen: true };
|
|
|
|
render(
|
|
<AppModals
|
|
projectId={undefined}
|
|
tasks={[]}
|
|
projects={[]}
|
|
currentProject={null}
|
|
addToast={vi.fn()}
|
|
toasts={mockToasts}
|
|
removeToast={vi.fn()}
|
|
modalManager={manager}
|
|
projectActions={{ handleAddProject, handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
|
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
|
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
|
deepLink={{ handleDetailClose: vi.fn() }}
|
|
settings={mockSettings}
|
|
/>,
|
|
);
|
|
|
|
expect(mockModelOnboardingModalProps).toHaveBeenCalledTimes(1);
|
|
const props = mockModelOnboardingModalProps.mock.calls[0][0];
|
|
expect(props.projectId).toBe("");
|
|
expect(props.onOpenSetupWizard).toBe(handleAddProject);
|
|
});
|
|
|
|
it("hides model onboarding while setup wizard is open as its project sub-flow", async () => {
|
|
const manager = { ...mockModalManager, modelOnboardingOpen: true, setupWizardOpen: true };
|
|
|
|
render(
|
|
<AppModals
|
|
projectId={undefined}
|
|
tasks={[]}
|
|
projects={[]}
|
|
currentProject={null}
|
|
addToast={vi.fn()}
|
|
toasts={mockToasts}
|
|
removeToast={vi.fn()}
|
|
modalManager={manager}
|
|
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
|
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
|
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
|
deepLink={{ handleDetailClose: vi.fn() }}
|
|
settings={mockSettings}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockSetupWizardModalProps).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(mockModelOnboardingModalProps).not.toHaveBeenCalled();
|
|
expect(mockSetupWizardModalProps.mock.calls[0][0].includeAgentStep).toBe(false);
|
|
});
|
|
|
|
it("keeps the standalone setup wizard agent step for new projects", async () => {
|
|
const manager = { ...mockModalManager, setupWizardOpen: true };
|
|
|
|
render(
|
|
<AppModals
|
|
projectId={undefined}
|
|
tasks={[]}
|
|
projects={[]}
|
|
currentProject={null}
|
|
addToast={vi.fn()}
|
|
toasts={mockToasts}
|
|
removeToast={vi.fn()}
|
|
modalManager={manager}
|
|
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
|
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
|
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
|
deepLink={{ handleDetailClose: vi.fn() }}
|
|
settings={mockSettings}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockSetupWizardModalProps).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(mockSetupWizardModalProps.mock.calls[0][0].includeAgentStep).toBe(true);
|
|
});
|
|
|
|
it("passes active project id into onboarding modal when a project is selected", () => {
|
|
const manager = { ...mockModalManager, modelOnboardingOpen: true };
|
|
|
|
render(
|
|
<AppModals
|
|
projectId="proj_123"
|
|
tasks={[]}
|
|
projects={[]}
|
|
currentProject={null}
|
|
addToast={vi.fn()}
|
|
toasts={mockToasts}
|
|
removeToast={vi.fn()}
|
|
modalManager={manager}
|
|
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
|
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
|
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
|
deepLink={{ handleDetailClose: vi.fn() }}
|
|
settings={mockSettings}
|
|
/>,
|
|
);
|
|
|
|
expect(mockModelOnboardingModalProps).toHaveBeenCalledTimes(1);
|
|
const props = mockModelOnboardingModalProps.mock.calls[0][0];
|
|
expect(props.projectId).toBe("proj_123");
|
|
});
|
|
});
|
|
|
|
describe("Settings modal lazy loading", () => {
|
|
it("renders SettingsModal asynchronously when settingsOpen is true", async () => {
|
|
render(
|
|
<AppModals
|
|
projectId="proj-123"
|
|
tasks={[]}
|
|
projects={[]}
|
|
currentProject={null}
|
|
addToast={vi.fn()}
|
|
toasts={mockToasts}
|
|
removeToast={vi.fn()}
|
|
modalManager={{ ...mockModalManager, settingsOpen: true, settingsInitialSection: "memory" }}
|
|
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
|
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
|
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
|
deepLink={{ handleDetailClose: vi.fn() }}
|
|
settings={mockSettings}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByTestId("settings-modal")).toBeInTheDocument();
|
|
await waitFor(() => expect(mockSettingsModalProps).toHaveBeenCalled());
|
|
const props = mockSettingsModalProps.mock.calls[0][0];
|
|
expect(props.projectId).toBe("proj-123");
|
|
expect(props.initialSection).toBe("memory");
|
|
});
|
|
});
|
|
|
|
describe("ScheduledTasksModal projectId forwarding", () => {
|
|
const commonProps = {
|
|
tasks: [],
|
|
projects: [],
|
|
currentProject: null,
|
|
toasts: mockToasts,
|
|
removeToast: vi.fn(),
|
|
projectActions: { handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() },
|
|
taskHandlers: { handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() },
|
|
taskOperations: { moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() },
|
|
deepLink: { handleDetailClose: vi.fn() },
|
|
settings: mockSettings,
|
|
};
|
|
|
|
it("does not render ScheduledTasksModal when schedulesOpen is false", () => {
|
|
render(
|
|
<AppModals
|
|
{...commonProps}
|
|
projectId="proj-123"
|
|
addToast={vi.fn()}
|
|
modalManager={{ ...mockModalManager, schedulesOpen: false }}
|
|
/>,
|
|
);
|
|
expect(mockScheduledTasksModalProps).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each<[string, string | undefined, string | undefined]>([
|
|
["defined project id", "proj-abc", "proj-abc"],
|
|
["undefined project id", undefined, undefined],
|
|
["empty string project id passes through as-is", "", ""],
|
|
])("forwards projectId through to ScheduledTasksModal — %s", (_label, input, expected) => {
|
|
render(
|
|
<AppModals
|
|
{...commonProps}
|
|
projectId={input}
|
|
addToast={vi.fn()}
|
|
modalManager={{ ...mockModalManager, schedulesOpen: true }}
|
|
/>,
|
|
);
|
|
expect(mockScheduledTasksModalProps).toHaveBeenCalledTimes(1);
|
|
expect(mockScheduledTasksModalProps.mock.calls[0][0].projectId).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("task detail history wiring", () => {
|
|
const commonProps = {
|
|
projectId: "proj-1",
|
|
tasks: [{ id: "FN-1", title: "Task one" }],
|
|
projects: [],
|
|
currentProject: null,
|
|
addToast: vi.fn(),
|
|
toasts: mockToasts,
|
|
removeToast: vi.fn(),
|
|
projectActions: { handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() },
|
|
taskHandlers: { handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() },
|
|
taskOperations: { moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() },
|
|
deepLink: { handleDetailClose: vi.fn() },
|
|
settings: mockSettings,
|
|
};
|
|
|
|
it("pushes history for activity-log open and closes with deep-link cleanup on popstate", async () => {
|
|
const pushStateSpy = vi.spyOn(window.history, "pushState");
|
|
const closeDetailTask = vi.fn();
|
|
const handleDetailClose = vi.fn();
|
|
render(
|
|
<AppModals
|
|
{...commonProps}
|
|
deepLink={{ handleDetailClose }}
|
|
modalManager={{ ...mockModalManager, activityLogOpen: true, closeDetailTask }}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId("activity-log-open-task"));
|
|
expect(pushStateSpy).toHaveBeenCalled();
|
|
|
|
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
|
await waitFor(() => expect(closeDetailTask).toHaveBeenCalledTimes(1));
|
|
expect(handleDetailClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("pushes history for onboarding view-task open and closes on popstate", async () => {
|
|
const pushStateSpy = vi.spyOn(window.history, "pushState");
|
|
const closeDetailTask = vi.fn();
|
|
render(
|
|
<AppModals
|
|
{...commonProps}
|
|
modalManager={{ ...mockModalManager, modelOnboardingOpen: true, closeDetailTask }}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId("onboarding-view-task"));
|
|
expect(pushStateSpy).toHaveBeenCalled();
|
|
|
|
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
|
await waitFor(() => expect(closeDetailTask).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it("pushes an additional history entry for task-to-task detail navigation", () => {
|
|
const pushStateSpy = vi.spyOn(window.history, "pushState");
|
|
render(
|
|
<AppModals
|
|
{...commonProps}
|
|
modalManager={{ ...mockModalManager, detailTask: { id: "FN-1", title: "Task one" } }}
|
|
/>,
|
|
);
|
|
|
|
act(() => {
|
|
mockTaskDetailModalProps.mock.calls.at(-1)?.[0]?.onOpenDetail({ id: "FN-2", title: "Nested" });
|
|
});
|
|
expect(pushStateSpy).toHaveBeenCalledTimes(1);
|
|
/*
|
|
FNXC:TaskDetailNav 2026-07-07-09:15:
|
|
FN-7352 (route completed-task refine menus to detail) added a third `opts?: { origin?: DetailTaskOrigin }` argument to openDetailTask / openDetailTaskWithNav, so the modalManager call now carries three args (task, tab, opts). A task-to-task open with no explicit tab/origin passes (task, undefined, undefined).
|
|
*/
|
|
expect(mockModalManager.openDetailTask).toHaveBeenCalledWith({ id: "FN-2", title: "Nested" }, undefined, undefined);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("AppModals does not own the terminal", () => {
|
|
it("never renders TerminalModal (its mount lifecycle is App's, and is asserted there)", async () => {
|
|
const { readFileSync } = await import("node:fs");
|
|
const { resolve } = await import("node:path");
|
|
const { fileURLToPath } = await import("node:url");
|
|
const source = readFileSync(resolve(fileURLToPath(import.meta.url), "../../AppModals.tsx"), "utf8");
|
|
expect(
|
|
source.includes("TerminalModal"),
|
|
"TerminalModal moved into AppModals. Its mount/unmount invariant (a mounted-but-closed terminal holds a PTY WebSocket and a 45s heartbeat) is currently asserted against App.tsx in App.test.tsx, and the `() => null` mock in this file would hide it here. Move the lifecycle recorder over before landing this.",
|
|
).toBe(false);
|
|
});
|
|
});
|