Stabilize dashboard composer tests when Vitest launches from the workspace root. - Resolve dashboard test source fixtures relative to the app directory. - Migrate affected component tests away from cwd-relative CSS reads. - Enforce the fixture convention in test hooks and document it. Files changed: docs/testing.md | 4 +++ package.json | 6 ++-- .../__tests__/AuthTokenRecoveryDialog.test.tsx | 3 +- .../components/__tests__/ChatView.mobile.test.tsx | 5 +-- .../__tests__/EngineControlMenu.test.tsx | 11 ++---- .../components/__tests__/FloatingWindow.test.tsx | 5 +-- .../app/components/__tests__/ListView.test.tsx | 5 +-- .../__tests__/MissionInterviewModal.test.tsx | 3 +- .../app/components/__tests__/MobileNavBar.test.tsx | 3 +- .../app/components/__tests__/NewTaskModal.test.tsx | 3 +- .../__tests__/PlanningModeModal.initial.test.tsx | 3 +- .../PlanningModeModal.ui-interactions.test.tsx | 7 ++-- .../components/__tests__/PrCreateModal.test.tsx | 3 +- .../__tests__/QuickChat.persist.test.tsx | 3 +- .../components/__tests__/QuickEntryBox.test.tsx | 3 +- .../components/__tests__/ReportActionMenu.test.tsx | 9 ++--- .../app/components/__tests__/ReportModal.test.tsx | 3 +- .../__tests__/ShadcnColorPicker.test.tsx | 3 +- .../components/__tests__/TerminalModal.test.tsx | 3 +- .../components/__tests__/ThemeDropdown.test.tsx | 9 ++--- .../__tests__/WorkflowNodeEditor.test.tsx | 5 +-- .../WorkflowOptionalStepsDropdown.test.tsx | 3 +- .../components/__tests__/WorkflowSwitcher.test.tsx | 5 +-- .../app/components/__tests__/board-mobile.test.tsx | 4 +-- .../__tests__/CommandCenterControls.test.tsx | 6 ++-- .../__tests__/SystemControlsArea.test.tsx | 5 +-- .../__tests__/SystemStatsArea.test.tsx | 3 +- .../command-center/areas/__tests__/areas.test.tsx | 3 +- .../__tests__/KeyboardShortcutsSection.test.tsx | 3 +- .../app/test/__tests__/cssFixture.test.ts | 35 +++++++++++++++++++ packages/dashboard/app/test/cssFixture.ts | 12 +++++++ .../check-no-cwd-relative-dashboard-test-reads.mjs | 39 ++++++++++++++++++++++ 32 files changed, 162 insertions(+), 55 deletions(-) Fusion-Task-Id: FN-8585 Fusion-Task-Lineage: 83a35fb6-a29d-4e97-b282-1054c68b8cc9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
1022 lines
36 KiB
TypeScript
1022 lines
36 KiB
TypeScript
import type React from "react";
|
|
import { readFileSync } from "node:fs";
|
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { MissionInterviewModal } from "../MissionInterviewModal";
|
|
import { readAppFile } from "../../test/cssFixture";
|
|
|
|
const missionInterviewCss = readAppFile("components/MissionInterviewModal.css");
|
|
|
|
const mockStartMissionInterview = vi.fn();
|
|
const mockRespondToMissionInterview = vi.fn();
|
|
const mockRetryMissionInterviewSession = vi.fn();
|
|
const mockCancelMissionInterview = vi.fn();
|
|
const mockCreateMissionFromInterview = vi.fn();
|
|
const mockConnectMissionInterviewStream = vi.fn();
|
|
const mockFetchAiSession = vi.fn();
|
|
const mockParseConversationHistory = vi.fn();
|
|
const mockAcquireSessionLock = vi.fn();
|
|
const mockReleaseSessionLock = vi.fn();
|
|
const mockForceAcquireSessionLock = vi.fn();
|
|
const mockFetchModels = vi.fn();
|
|
const mockFetchSettings = vi.fn();
|
|
|
|
vi.mock("../../api", () => ({
|
|
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
|
|
respondToMissionInterview: (...args: any[]) => mockRespondToMissionInterview(...args),
|
|
retryMissionInterviewSession: (...args: any[]) => mockRetryMissionInterviewSession(...args),
|
|
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
|
|
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
|
|
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...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),
|
|
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
|
fetchSettings: (...args: any[]) => mockFetchSettings(...args),
|
|
}));
|
|
|
|
const mockGetMissionGoal = vi.fn(() => "");
|
|
const mockSaveMissionGoal = vi.fn();
|
|
|
|
vi.mock("../../hooks/modalPersistence", () => ({
|
|
saveMissionGoal: (...args: any[]) => mockSaveMissionGoal(...args),
|
|
getMissionGoal: (...args: any[]) => mockGetMissionGoal(...args),
|
|
clearMissionGoal: vi.fn(),
|
|
}));
|
|
|
|
const SAMPLE_QUESTION = {
|
|
id: "scope",
|
|
type: "single_select" as const,
|
|
question: "What is the target scope?",
|
|
description: "Pick the size for this mission.",
|
|
options: [
|
|
{ id: "mvp", label: "MVP" },
|
|
{ id: "full", label: "Full" },
|
|
],
|
|
};
|
|
|
|
const SECOND_QUESTION = {
|
|
id: "platform",
|
|
type: "text" as const,
|
|
question: "Which platforms should this mission cover?",
|
|
description: "List the product surfaces that need support.",
|
|
};
|
|
|
|
const MARKDOWN_QUESTION = {
|
|
id: "markdown-question",
|
|
type: "text" as const,
|
|
question: "Do you want **fast** mode?\n\nfirst line \nsecond line\n\n- Option A\n- Option B",
|
|
description: "Choose the mode before continuing.",
|
|
};
|
|
|
|
function expectMarkdownQuestionFormatting() {
|
|
const question = screen.getByTestId("planning-question-text");
|
|
expect(question.querySelector("strong")).toHaveTextContent("fast");
|
|
expect([...question.querySelectorAll("p")].find((paragraph) => paragraph.textContent?.includes("first line"))?.querySelector("br")).not.toBeNull();
|
|
expect([...question.querySelectorAll("li")].map((item) => item.textContent)).toEqual(["Option A", "Option B"]);
|
|
expect(question).not.toHaveTextContent("**fast**");
|
|
}
|
|
|
|
const SAMPLE_SUMMARY = {
|
|
missionTitle: "Resilient mission planning",
|
|
missionDescription: "Recover mission AI planning after transient stream interruptions.",
|
|
milestones: [
|
|
{
|
|
title: "Recovery milestone",
|
|
description: "Keep the interview usable after reconnecting.",
|
|
slices: [
|
|
{
|
|
title: "Stream recovery",
|
|
description: "Reconnect recoverable mission interviews.",
|
|
features: [
|
|
{
|
|
title: "Continue interview",
|
|
description: "The modal resumes from the next streamed state.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
function buildMissionSession(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "mission-session-1",
|
|
type: "mission_interview",
|
|
status: "generating",
|
|
title: "Build a mission planning workflow",
|
|
inputPayload: JSON.stringify({ goal: "Build a mission planning workflow" }),
|
|
conversationHistory: "[]",
|
|
currentQuestion: null,
|
|
result: null,
|
|
thinkingOutput: "Continuing...",
|
|
error: null,
|
|
projectId: null,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("MissionInterviewModal", () => {
|
|
let streamHandlers: any;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
streamHandlers = undefined;
|
|
|
|
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
|
|
mockRetryMissionInterviewSession.mockResolvedValue({ success: true, sessionId: "mission-session-1" });
|
|
mockFetchAiSession.mockResolvedValue(null);
|
|
mockSaveMissionGoal.mockReset();
|
|
mockParseConversationHistory.mockImplementation((raw: string) => {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
|
streamHandlers = handlers;
|
|
return {
|
|
close: vi.fn(),
|
|
isConnected: vi.fn().mockReturnValue(true),
|
|
};
|
|
});
|
|
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
|
mockReleaseSessionLock.mockResolvedValue(undefined);
|
|
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
|
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
|
mockFetchSettings.mockResolvedValue({ defaultThinkingLevel: "off" });
|
|
localStorage.removeItem("floating-window:mission-interview");
|
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
|
|
callback(0);
|
|
return 1;
|
|
});
|
|
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
|
|
});
|
|
|
|
function renderModal(props: Partial<React.ComponentProps<typeof MissionInterviewModal>> = {}) {
|
|
const onClose = props.onClose ?? vi.fn();
|
|
|
|
return {
|
|
onClose,
|
|
...render(
|
|
<MissionInterviewModal
|
|
isOpen={true}
|
|
onClose={onClose}
|
|
onMissionCreated={vi.fn()}
|
|
{...props}
|
|
/>,
|
|
),
|
|
};
|
|
}
|
|
|
|
function setViewport(width: number, height: number) {
|
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
|
|
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
|
|
}
|
|
|
|
function stubPointerCapture(element: HTMLElement) {
|
|
Object.defineProperty(element, "setPointerCapture", { configurable: true, value: vi.fn() });
|
|
Object.defineProperty(element, "releasePointerCapture", { configurable: true, value: vi.fn() });
|
|
}
|
|
|
|
it("renders the configured thinking level as the mission interview default", async () => {
|
|
mockFetchSettings.mockResolvedValue({ defaultThinkingLevel: "high" });
|
|
|
|
renderModal();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("custom-model-dropdown-thinking-badge")).toHaveTextContent("Default (high)");
|
|
});
|
|
});
|
|
|
|
it("renders mission interview inside a floating desktop workspace", () => {
|
|
setViewport(1200, 900);
|
|
|
|
renderModal();
|
|
|
|
const panel = screen.getByTestId("floating-window-mission-interview");
|
|
expect(panel).toHaveClass("floating-window--mission-interview");
|
|
expect(panel).toHaveClass("floating-window--headerless");
|
|
expect(panel.style.width).toBe("760px");
|
|
expect(panel.style.height).toBe("680px");
|
|
expect(screen.queryByTestId("floating-window-drag-handle-mission-interview")).toBeNull();
|
|
expect(screen.getByText("Plan Mission with AI").closest(".mission-interview-modal__drag-handle")).toBeTruthy();
|
|
expect(screen.getAllByRole("button", { name: "Close" })).toHaveLength(1);
|
|
});
|
|
|
|
it("drags and resizes the desktop mission floating window while clamping geometry", async () => {
|
|
setViewport(1200, 1000);
|
|
|
|
renderModal();
|
|
|
|
const panel = screen.getByTestId("floating-window-mission-interview");
|
|
const header = screen.getByText("Plan Mission with AI").closest(".mission-interview-modal__drag-handle") as HTMLElement;
|
|
stubPointerCapture(panel);
|
|
|
|
const initialLeft = Number.parseFloat(panel.style.left);
|
|
const initialTop = Number.parseFloat(panel.style.top);
|
|
|
|
act(() => {
|
|
fireEvent.pointerDown(header, { pointerId: 7, clientX: 120, clientY: 80 });
|
|
fireEvent.pointerMove(panel, { pointerId: 7, clientX: 220, clientY: 140 });
|
|
fireEvent.pointerUp(panel, { pointerId: 7, clientX: 220, clientY: 140 });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(Number.parseFloat(panel.style.left)).toBeGreaterThan(initialLeft);
|
|
expect(Number.parseFloat(panel.style.top)).toBeGreaterThan(initialTop);
|
|
});
|
|
|
|
const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement;
|
|
stubPointerCapture(resizeHandle);
|
|
|
|
act(() => {
|
|
fireEvent.pointerDown(resizeHandle, { pointerId: 8, clientX: 700, clientY: 600 });
|
|
fireEvent.pointerMove(resizeHandle, { pointerId: 8, clientX: 3000, clientY: 3000 });
|
|
fireEvent.pointerUp(resizeHandle, { pointerId: 8, clientX: 3000, clientY: 3000 });
|
|
});
|
|
|
|
expect(Number.parseFloat(panel.style.width)).toBeLessThanOrEqual(1200);
|
|
expect(Number.parseFloat(panel.style.height)).toBeLessThanOrEqual(1000);
|
|
expect(Number.parseFloat(panel.style.width)).toBeGreaterThanOrEqual(560);
|
|
expect(Number.parseFloat(panel.style.height)).toBeGreaterThanOrEqual(420);
|
|
});
|
|
|
|
it("keeps mobile mission planning full-screen and hides resize handles by CSS contract", () => {
|
|
const mobileBlock = missionInterviewCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.floating-window--mission-interview \.mission-interview-modal\s*\{[\s\S]*?\n\}/)?.[0];
|
|
|
|
expect(mobileBlock).toContain(".floating-window--mission-interview");
|
|
expect(mobileBlock).toContain("width: 100vw !important;");
|
|
expect(mobileBlock).toContain("height: 100dvh !important;");
|
|
expect(mobileBlock).toContain(".floating-window--mission-interview .floating-window__resize-handle");
|
|
expect(mobileBlock).toContain("display: none;");
|
|
});
|
|
|
|
/*
|
|
FNXC:PlanningMultiTab 2026-07-14-00:00:
|
|
Mission interviews are multi-tab: this tab must never acquire a lock, never render a lock
|
|
overlay or "active in another tab" banner, and must stay interactive even when another tab
|
|
is using the same session.
|
|
*/
|
|
it("never acquires a tab lock and renders no lock overlay", async () => {
|
|
// A rejecting lock API would surface an overlay if any legacy lock path survived.
|
|
mockAcquireSessionLock.mockResolvedValue({ acquired: false, currentHolder: "tab-other" });
|
|
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockStartMissionInterview).toHaveBeenCalled();
|
|
});
|
|
|
|
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId("session-active-another-tab-banner")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Take Control")).not.toBeInTheDocument();
|
|
expect(mockAcquireSessionLock).not.toHaveBeenCalled();
|
|
expect(mockForceAcquireSessionLock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows reconnecting only during active generation, not on persisted questions", async () => {
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockStartMissionInterview).toHaveBeenCalledWith("Build a mission planning workflow", undefined, undefined);
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onConnectionStateChange?.("reconnecting");
|
|
});
|
|
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
|
|
|
act(() => {
|
|
streamHandlers.onConnectionStateChange?.("connected");
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
|
|
|
|
act(() => {
|
|
streamHandlers.onConnectionStateChange?.("reconnecting");
|
|
});
|
|
|
|
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
|
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders markdown formatting in AI mission interview questions", async () => {
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Format a mission interview question" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => expect(streamHandlers).toBeDefined());
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(MARKDOWN_QUESTION);
|
|
});
|
|
|
|
await waitFor(expectMarkdownQuestionFormatting);
|
|
});
|
|
|
|
it("preserves streaming thinking output while reconnecting", async () => {
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onThinking?.("Analyzing mission goals...");
|
|
});
|
|
|
|
expect(await screen.findByText("Analyzing mission goals...")).toBeInTheDocument();
|
|
|
|
act(() => {
|
|
streamHandlers.onConnectionStateChange?.("reconnecting");
|
|
});
|
|
|
|
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
|
expect(screen.getByText("Analyzing mission goals...")).toBeInTheDocument();
|
|
});
|
|
|
|
it("recovers a generating mission interview after a transient Stream error", async () => {
|
|
mockFetchAiSession.mockResolvedValueOnce(buildMissionSession({ status: "generating" }));
|
|
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
await act(async () => {
|
|
streamHandlers.onError?.("Stream error");
|
|
});
|
|
|
|
expect(await screen.findByText("Reconnecting…")).toBeInTheDocument();
|
|
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAiSession).toHaveBeenCalledWith("mission-session-1");
|
|
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SECOND_QUESTION);
|
|
});
|
|
|
|
expect(await screen.findByText("Which platforms should this mission cover?")).toBeInTheDocument();
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("preserves an awaiting-input question while recovering a transient Stream error", async () => {
|
|
mockFetchAiSession.mockResolvedValueOnce(
|
|
buildMissionSession({
|
|
status: "awaiting_input",
|
|
currentQuestion: JSON.stringify(SAMPLE_QUESTION),
|
|
thinkingOutput: "",
|
|
}),
|
|
);
|
|
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
streamHandlers.onError?.("Stream error");
|
|
});
|
|
|
|
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
|
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
|
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAiSession).toHaveBeenCalledWith("mission-session-1");
|
|
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onSummary?.(SAMPLE_SUMMARY);
|
|
});
|
|
|
|
expect(await screen.findByDisplayValue("Resilient mission planning")).toBeInTheDocument();
|
|
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders a completed mission summary instead of Stream error after recovery finds completion", async () => {
|
|
mockFetchAiSession.mockResolvedValueOnce(
|
|
buildMissionSession({
|
|
status: "complete",
|
|
result: JSON.stringify(SAMPLE_SUMMARY),
|
|
thinkingOutput: "",
|
|
}),
|
|
);
|
|
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
await act(async () => {
|
|
streamHandlers.onError?.("Stream error");
|
|
});
|
|
|
|
expect(await screen.findByDisplayValue("Resilient mission planning")).toBeInTheDocument();
|
|
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows error panel with retry action when stream recovery cannot refresh the session", async () => {
|
|
mockFetchAiSession.mockRejectedValueOnce(new Error("refresh failed"));
|
|
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
await act(async () => {
|
|
streamHandlers.onError?.("Temporary outage");
|
|
});
|
|
|
|
expect(await screen.findByText("Temporary outage")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders normalized generic stream failures as a recoverable retry state", async () => {
|
|
mockFetchAiSession.mockRejectedValueOnce(new Error("refresh failed"));
|
|
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
await act(async () => {
|
|
streamHandlers.onError?.("The mission interview stream was interrupted. Please retry the session.");
|
|
});
|
|
|
|
expect(await screen.findByText("The mission interview stream was interrupted. Please retry the session.")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
|
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("AI is thinking...")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows persisted mission interview errors after stream recovery refreshes the session", async () => {
|
|
mockFetchAiSession.mockResolvedValueOnce(
|
|
buildMissionSession({
|
|
status: "error",
|
|
error: "The mission interview failed permanently.",
|
|
thinkingOutput: "",
|
|
}),
|
|
);
|
|
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
await act(async () => {
|
|
streamHandlers.onError?.("Stream error");
|
|
});
|
|
|
|
expect(await screen.findByText("The mission interview failed permanently.")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("retries interview session from error view", async () => {
|
|
let attempt = 0;
|
|
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
|
streamHandlers = handlers;
|
|
attempt += 1;
|
|
if (attempt === 1) {
|
|
setTimeout(() => handlers.onError?.("Try again"), 10);
|
|
} else {
|
|
setTimeout(() => handlers.onQuestion?.(SAMPLE_QUESTION), 10);
|
|
}
|
|
return {
|
|
close: vi.fn(),
|
|
isConnected: vi.fn().mockReturnValue(true),
|
|
};
|
|
});
|
|
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Try again")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
|
});
|
|
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("recovers connection-loss directly when interview session is still generating", async () => {
|
|
let attempt = 0;
|
|
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
|
streamHandlers = handlers;
|
|
attempt += 1;
|
|
if (attempt === 1) {
|
|
setTimeout(() => handlers.onError?.("Connection lost"), 10);
|
|
}
|
|
return {
|
|
close: vi.fn(),
|
|
isConnected: vi.fn().mockReturnValue(true),
|
|
};
|
|
});
|
|
|
|
mockFetchAiSession.mockResolvedValueOnce(buildMissionSession({ status: "generating" }));
|
|
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAiSession).toHaveBeenCalledWith("mission-session-1");
|
|
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
expect(await screen.findByText("AI is thinking...")).toBeInTheDocument();
|
|
expect(screen.getByText("Continuing...")).toBeInTheDocument();
|
|
expect(screen.queryByText("Connection lost")).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
|
expect(mockRetryMissionInterviewSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows comment textarea and submits _comment for non-text questions", async () => {
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
fireEvent.click(await screen.findByText("MVP"));
|
|
fireEvent.change(screen.getByPlaceholderText("Add any extra context or direction..."), {
|
|
target: { value: "Optimize for launch speed" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
expect.objectContaining({ scope: "mvp", _comment: "Optimize for launch speed" }),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("submits trimmed Other-only answers for single-select mission questions", async () => {
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
expect(continueButton).toBeDisabled();
|
|
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: " Start with discovery instead " },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ _other: "Start with discovery instead" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("renders Other for single-select mission questions with no provided options", async () => {
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.({
|
|
id: "open_scope",
|
|
type: "single_select",
|
|
question: "What scope should we use?",
|
|
});
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
expect(continueButton).toBeDisabled();
|
|
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: " Define a custom scope " },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ _other: "Define a custom scope" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("clears stale Other text when switching back to a provided mission option", async () => {
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), { target: { value: " " } });
|
|
expect(continueButton).toBeDisabled();
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: "Plan a discovery mission" },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
|
|
fireEvent.click(screen.getByText("MVP"));
|
|
expect(screen.queryByTestId("planning-other-input")).toBeNull();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ scope: "mvp" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("submits Other-only answers for multi-select mission questions", async () => {
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.({
|
|
id: "priorities",
|
|
type: "multi_select",
|
|
question: "Which priorities matter?",
|
|
options: [
|
|
{ id: "speed", label: "Speed" },
|
|
{ id: "quality", label: "Quality" },
|
|
],
|
|
});
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
expect(continueButton).toBeDisabled();
|
|
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: " Add field research first " },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ _other: "Add field research first" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("renders Other for multi-select mission questions with no provided options", async () => {
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.({
|
|
id: "open_priorities",
|
|
type: "multi_select",
|
|
question: "Which priorities matter?",
|
|
});
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
expect(continueButton).toBeDisabled();
|
|
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: " Ask customers first " },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ _other: "Ask customers first" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("combines provided options with Other text for multi-select mission questions", async () => {
|
|
renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.({
|
|
id: "priorities",
|
|
type: "multi_select",
|
|
question: "Which priorities matter?",
|
|
options: [
|
|
{ id: "speed", label: "Speed" },
|
|
{ id: "quality", label: "Quality" },
|
|
],
|
|
});
|
|
});
|
|
|
|
const continueButton = await screen.findByRole("button", { name: "Continue" });
|
|
fireEvent.click(screen.getByText("Speed"));
|
|
fireEvent.click(screen.getByTestId("planning-option-other"));
|
|
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
|
target: { value: " Preserve operator review " },
|
|
});
|
|
expect(continueButton).toBeEnabled();
|
|
fireEvent.click(continueButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
|
|
"mission-session-1",
|
|
{ priorities: ["speed"], _other: "Preserve operator review" },
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("restores persisted goal from localStorage on open", () => {
|
|
mockGetMissionGoal.mockReturnValue("Previous mission goal");
|
|
|
|
renderModal();
|
|
|
|
const textarea = screen.getByLabelText("What do you want to build?");
|
|
expect(textarea).toHaveValue("Previous mission goal");
|
|
});
|
|
|
|
it("closes without cancelling an in-progress interview and renders only one close button", async () => {
|
|
const { onClose } = renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
const closeButtons = screen.getAllByRole("button", { name: "Close" });
|
|
expect(closeButtons).toHaveLength(1);
|
|
expect(screen.queryByRole("button", { name: "Send to background" })).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(closeButtons[0]);
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists the draft goal and closes from the initial view", () => {
|
|
const { onClose } = renderModal({ projectId: "proj-1" });
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Draft mission goal" },
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
|
|
expect(mockSaveMissionGoal).toHaveBeenCalledWith("Draft mission goal", "proj-1");
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes without cancelling when pressing Escape", async () => {
|
|
const { onClose } = renderModal();
|
|
|
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
target: { value: "Build a mission planning workflow" },
|
|
});
|
|
fireEvent.click(screen.getByText("Start Interview"));
|
|
|
|
await waitFor(() => {
|
|
expect(streamHandlers).toBeDefined();
|
|
});
|
|
|
|
act(() => {
|
|
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
});
|
|
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not render a blocking backdrop click target around the floating workspace", () => {
|
|
const { onClose } = renderModal();
|
|
const overlay = screen.getByRole("dialog");
|
|
|
|
fireEvent.mouseDown(overlay);
|
|
fireEvent.click(overlay);
|
|
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows typing in textarea without resetting to stale persisted goal", async () => {
|
|
// Simulate a stale persisted goal from a previous session
|
|
mockGetMissionGoal.mockReturnValue("Old stale goal");
|
|
|
|
renderModal();
|
|
|
|
const textarea = screen.getByLabelText("What do you want to build?");
|
|
expect(textarea).toHaveValue("Old stale goal");
|
|
|
|
// User starts typing a new goal
|
|
fireEvent.change(textarea, { target: { value: "New mission" } });
|
|
expect(textarea).toHaveValue("New mission");
|
|
|
|
// Type more characters — the stale value should NOT overwrite
|
|
fireEvent.change(textarea, { target: { value: "New mission idea" } });
|
|
expect(textarea).toHaveValue("New mission idea");
|
|
|
|
// Even after a re-render cycle, user input should persist
|
|
await act(async () => {
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
});
|
|
expect(textarea).toHaveValue("New mission idea");
|
|
});
|
|
|
|
/*
|
|
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
|
|
Missions is keyed by project, so a project switch unmounts this modal mid-composition.
|
|
The unmount must persist an un-started goal under the instance's OWN project id — before
|
|
the keyed remount, the surviving instance saved it under the NEW project's
|
|
kb-mission-last-goal key on close.
|
|
*/
|
|
it("saves an un-started goal draft under its own project id on unmount", () => {
|
|
const { unmount } = renderModal({ projectId: "proj_a" });
|
|
|
|
const textarea = screen.getByLabelText("What do you want to build?");
|
|
fireEvent.change(textarea, { target: { value: "Goal from project A" } });
|
|
|
|
unmount();
|
|
|
|
expect(mockSaveMissionGoal).toHaveBeenCalledWith("Goal from project A", "proj_a");
|
|
});
|
|
});
|