FN-6975: make mission planning modal movable and resilient

Make the AI mission planning workspace movable on desktop and safer when streams fail.

- Host the Plan Mission with AI modal in FloatingWindow with desktop drag/resize geometry and mobile full-screen preservation.
- Normalize terminal mission interview stream failures, close SSE/keepalive once, and suppress duplicate late terminal events.
- Cover modal geometry and stream-error behavior with dashboard tests and document the operator-facing behavior.

Files changed:
 .changeset/fn-6975-mission-modal-stream.md         |   7 ++
 docs/dashboard-guide.md                            |   7 ++
 .../api/__tests__/mission-interview-stream.test.ts |  98 ++++++++++++++++++
 packages/dashboard/app/api/legacy.ts               |  66 ++++++++++---
 .../app/components/MissionInterviewModal.css       |  51 ++++++++++
 .../app/components/MissionInterviewModal.tsx       |  37 ++++---
 .../__tests__/MissionInterviewModal.test.tsx       | 110 ++++++++++++++++++++-
 7 files changed, 344 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-6975

Fusion-Task-Lineage: b2ffa558-8b7e-4a46-8882-f2c4f6189831
This commit is contained in:
gsxdsm
2026-06-25 16:31:10 -07:00
parent 0f95f6d1c1
commit a0954c7f27
7 changed files with 344 additions and 32 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make Plan Mission with AI desktop modal movable and recover cleanly from stream failures.
category: fix
dev: Dashboard mission interview now uses floating desktop geometry and normalizes terminal SSE errors into one retry state.

View File

@@ -724,6 +724,13 @@ Workflow behavior:
- Feature triage and slice **Triage all features** create new tasks on the selected workflow. - Feature triage and slice **Triage all features** create new tasks on the selected workflow.
- If no workflow is selected, or workflow columns are unavailable, mission-created tasks continue to use the project default workflow. - If no workflow is selected, or workflow columns are unavailable, mission-created tasks continue to use the project default workflow.
<!-- FNXC:MissionInterviewDocs 2026-06-25-15:55: FN-6975 made the Plan Mission with AI workspace movable/resizable on desktop while preserving mobile's fixed full-screen flow, and stream failures now surface one recoverable retry state instead of leaving the modal spinning. -->
Plan Mission with AI modal behavior:
- On desktop, the modal opens as a floating workspace that can be dragged by its title bar and resized from the window edges/corners.
- On mobile, the mission interview keeps the fixed full-screen/sheet-style layout so touch users retain the original focused flow.
- If the mission interview stream reports a terminal failure, the modal closes the failed stream, shows one normalized error, and offers retry without duplicating late error/complete events.
## Roadmaps View ## Roadmaps View
Roadmaps view manages roadmap hierarchies (roadmaps, milestones, features) and planning handoff exports. Roadmaps view manages roadmap hierarchies (roadmaps, milestones, features) and planning handoff exports.

View File

@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { connectMissionInterviewStream } from "../legacy";
class MockEventSource {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 2;
static instances: MockEventSource[] = [];
url: string;
readyState = MockEventSource.OPEN;
onopen: (() => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
close = vi.fn(() => {
this.readyState = MockEventSource.CLOSED;
});
private listeners = new Map<string, Array<(event: MessageEvent) => void>>();
constructor(url: string) {
this.url = url;
MockEventSource.instances.push(this);
}
addEventListener(eventName: string, listener: EventListener) {
const listeners = this.listeners.get(eventName) ?? [];
listeners.push(listener as (event: MessageEvent) => void);
this.listeners.set(eventName, listeners);
}
dispatch(eventName: string, data = "", lastEventId = "") {
const event = { data, lastEventId } as MessageEvent;
for (const listener of this.listeners.get(eventName) ?? []) {
listener(event);
}
}
}
describe("connectMissionInterviewStream", () => {
beforeEach(() => {
MockEventSource.instances = [];
vi.stubGlobal("EventSource", MockEventSource);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function connect(handlers: Partial<Parameters<typeof connectMissionInterviewStream>[2]> = {}, options?: { maxReconnectAttempts?: number }) {
const onError = vi.fn();
const onComplete = vi.fn();
const connection = connectMissionInterviewStream("mission-session-1", undefined, { onError, onComplete, ...handlers }, options);
const source = MockEventSource.instances[0];
return { connection, source, onError, onComplete };
}
it.each([
["JSON message", JSON.stringify({ message: "The model rejected the prompt." }), "The model rejected the prompt."],
["JSON error fallback", JSON.stringify({ error: "Provider is unavailable." }), "Provider is unavailable."],
["JSON string", JSON.stringify("Please try again later."), "Please try again later."],
["non-JSON text", "Temporary outage", "Temporary outage"],
["empty data", "", "The mission interview stream was interrupted. Please retry the session."],
["generic stream error", "Stream error", "The mission interview stream was interrupted. Please retry the session."],
["JSON primitive", JSON.stringify(500), "The mission interview stream was interrupted. Please retry the session."],
])("normalizes terminal error payloads: %s", (_name, data, expected) => {
const { source, onError } = connect();
source.dispatch("error", data);
expect(onError).toHaveBeenCalledWith(expected);
expect(source.close).toHaveBeenCalledTimes(1);
});
it("dedupes late terminal events and closes the stale EventSource once", () => {
const { source, onError, onComplete } = connect();
source.dispatch("error", JSON.stringify({ message: "First failure" }), "1");
source.dispatch("error", JSON.stringify({ message: "Second failure" }), "2");
source.dispatch("complete", "", "3");
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith("First failure");
expect(onComplete).not.toHaveBeenCalled();
expect(source.close).toHaveBeenCalledTimes(1);
});
it("reports fatal reconnect exhaustion through the same recoverable error path", () => {
const { source, onError } = connect({}, { maxReconnectAttempts: 0 });
source.readyState = MockEventSource.CLOSED;
source.onerror?.();
expect(onError).toHaveBeenCalledWith("Connection lost");
expect(source.close).toHaveBeenCalledTimes(1);
});
});

View File

@@ -3975,6 +3975,9 @@ function createResilientEventSource(
} }
source.close(); source.close();
if (eventSource === source) {
eventSource = null;
}
if (reconnectAttempts >= maxReconnectAttempts) { if (reconnectAttempts >= maxReconnectAttempts) {
options.onFatalError?.("Connection lost"); options.onFatalError?.("Connection lost");
@@ -8415,6 +8418,31 @@ export function createMissionFromInterview(
}); });
} }
const MISSION_INTERVIEW_STREAM_ERROR_MESSAGE = "The mission interview stream was interrupted. Please retry the session.";
function normalizeMissionInterviewStreamError(data: string | undefined): string {
const raw = data?.trim() ?? "";
if (!raw) return MISSION_INTERVIEW_STREAM_ERROR_MESSAGE;
const normalizeMessage = (value: unknown): string => {
if (typeof value !== "string") return MISSION_INTERVIEW_STREAM_ERROR_MESSAGE;
const message = value.trim();
if (!message || message === "Stream error") return MISSION_INTERVIEW_STREAM_ERROR_MESSAGE;
return message;
};
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const message = (parsed as { message?: unknown; error?: unknown }).message ?? (parsed as { error?: unknown }).error;
return normalizeMessage(message);
}
return normalizeMessage(parsed);
} catch {
return normalizeMessage(raw);
}
}
/** Connect to mission interview SSE stream and handle events */ /** Connect to mission interview SSE stream and handle events */
export function connectMissionInterviewStream( export function connectMissionInterviewStream(
sessionId: string, sessionId: string,
@@ -8432,12 +8460,32 @@ export function connectMissionInterviewStream(
const url = buildApiUrl(withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/stream`, projectId)); const url = buildApiUrl(withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/stream`, projectId));
let keepAlive: { stop: () => void } | null = null; let keepAlive: { stop: () => void } | null = null;
let connection: { close: () => void; isConnected: () => boolean } | null = null; let connection: { close: () => void; isConnected: () => boolean } | null = null;
let terminalEventHandled = false;
const stopKeepAlive = () => { const stopKeepAlive = () => {
keepAlive?.stop(); keepAlive?.stop();
keepAlive = null; keepAlive = null;
}; };
const closeTerminalConnection = () => {
stopKeepAlive();
connection?.close();
};
const notifyTerminalError = (message: string) => {
if (terminalEventHandled) return;
terminalEventHandled = true;
closeTerminalConnection();
handlers.onError?.(message);
};
const notifyTerminalComplete = () => {
if (terminalEventHandled) return;
terminalEventHandled = true;
closeTerminalConnection();
handlers.onComplete?.();
};
const resilient = createResilientEventSource( const resilient = createResilientEventSource(
url, url,
{ {
@@ -8471,17 +8519,14 @@ export function connectMissionInterviewStream(
} }
}, },
error: (event) => { error: (event) => {
try { /*
const parsed = JSON.parse(event.data); FNXC:MissionInterviewStream 2026-06-24-00:00:
handlers.onError?.(parsed.message || parsed); Mission interview stream failures are terminal for the current EventSource. Normalize malformed/empty/generic payloads, close keepalive + SSE once, and ignore duplicate late error/complete events so the modal can show one recoverable Retry state instead of a stale spinner or raw stream failure.
} catch { */
handlers.onError?.(event.data || "Stream error"); notifyTerminalError(normalizeMissionInterviewStreamError(event.data));
}
connection?.close();
}, },
complete: () => { complete: () => {
handlers.onComplete?.(); notifyTerminalComplete();
connection?.close();
}, },
}, },
}, },
@@ -8489,8 +8534,7 @@ export function connectMissionInterviewStream(
maxReconnectAttempts: options?.maxReconnectAttempts, maxReconnectAttempts: options?.maxReconnectAttempts,
onConnectionStateChange: handlers.onConnectionStateChange, onConnectionStateChange: handlers.onConnectionStateChange,
onFatalError: (message) => { onFatalError: (message) => {
stopKeepAlive(); notifyTerminalError(normalizeMissionInterviewStreamError(message));
handlers.onError?.(message);
}, },
}, },
); );

View File

@@ -1,3 +1,54 @@
/*
FNXC:MissionInterviewModal 2026-06-24-00:00:
Desktop mission planning is a floating workspace hosted by FloatingWindow. The embedded `.modal` must fill the floating panel instead of applying the fixed planning modal size, while mobile keeps the full-screen sheet contract and hides FloatingWindow resize shells.
*/
.floating-window--mission-interview .floating-window__body {
overflow: hidden;
}
.floating-window--mission-interview .mission-interview-modal {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
border: 0;
border-radius: inherit;
box-shadow: none;
}
.floating-window--mission-interview .mission-interview-modal__drag-handle {
cursor: grab;
user-select: none;
touch-action: none;
}
.floating-window--mission-interview .mission-interview-modal__drag-handle:active {
cursor: grabbing;
}
@media (max-width: 768px) {
.floating-window--mission-interview {
inset: 0 !important;
width: 100vw !important;
height: 100dvh !important;
min-width: 0 !important;
min-height: 0 !important;
max-width: 100vw !important;
max-height: 100dvh !important;
border: 0;
border-radius: 0;
box-shadow: none;
}
.floating-window--mission-interview .floating-window__resize-handle {
display: none;
}
.floating-window--mission-interview .mission-interview-modal {
border-radius: 0;
}
}
.roadmap-list { .roadmap-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -47,6 +47,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { ConversationHistory } from "./ConversationHistory"; import { ConversationHistory } from "./ConversationHistory";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import { FloatingWindow } from "./FloatingWindow";
import { useSessionLock } from "../hooks/useSessionLock"; import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
@@ -125,7 +126,6 @@ export function MissionInterviewModal({
const [editedSummary, setEditedSummary] = useState<MissionPlanSummary | null>(null); const [editedSummary, setEditedSummary] = useState<MissionPlanSummary | null>(null);
const [_hasProgress, setHasProgress] = useState(false); const [_hasProgress, setHasProgress] = useState(false);
const hasAutoStartedRef = useRef(false); const hasAutoStartedRef = useRef(false);
const overlayMouseDownOnSelfRef = useRef(false);
const [streamingOutput, setStreamingOutput] = useState(""); const [streamingOutput, setStreamingOutput] = useState("");
const [showThinking, setShowThinking] = useState(true); const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false);
@@ -631,7 +631,6 @@ export function MissionInterviewModal({
streamConnectionRef.current?.close(); streamConnectionRef.current?.close();
streamConnectionRef.current = null; streamConnectionRef.current = null;
overlayMouseDownOnSelfRef.current = false;
setIsReconnecting(false); setIsReconnecting(false);
setIsRetrying(false); setIsRetrying(false);
setIsCreating(false); setIsCreating(false);
@@ -641,7 +640,6 @@ export function MissionInterviewModal({
const handleSendToBackground = useCallback(() => { const handleSendToBackground = useCallback(() => {
streamConnectionRef.current?.close(); streamConnectionRef.current?.close();
streamConnectionRef.current = null; streamConnectionRef.current = null;
overlayMouseDownOnSelfRef.current = false;
setIsReconnecting(false); setIsReconnecting(false);
setIsRetrying(false); setIsRetrying(false);
setIsCreating(false); setIsCreating(false);
@@ -838,22 +836,23 @@ export function MissionInterviewModal({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div <FloatingWindow
className="modal-overlay open" windowKey="mission-interview"
onMouseDown={(e) => { title={t("missions.planTitle", "Plan Mission with AI")}
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget; onClose={handleClose}
}} hideHeader
onClick={(e) => { dragHandleSelector=".mission-interview-modal__drag-handle"
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) { className="floating-window--mission-interview"
handleClose(); defaultSize={{ width: 760, height: 680 }}
} minSize={{ width: 560, height: 420 }}
overlayMouseDownOnSelfRef.current = false; persistGeometryKey="floating-window:mission-interview"
}}
role="dialog"
aria-modal="true"
> >
<div className="modal modal-lg planning-modal"> {/*
<div className="modal-header"> FNXC:MissionInterviewModal 2026-06-24-00:00:
The Plan Mission with AI workspace must be draggable and resizable on desktop by delegating geometry to FloatingWindow, while mobile keeps the existing full-screen/sheet-like mission interview flow. Keep one embedded mission header so close/send-to-background/session-lock controls do not duplicate FloatingWindow chrome.
*/}
<div className="modal modal-lg planning-modal mission-interview-modal">
<div className="modal-header mission-interview-modal__drag-handle">
<div className="detail-title-row"> <div className="detail-title-row">
<Target size={20} className="icon-triage" /> <Target size={20} className="icon-triage" />
<h3>{t("missions.planTitle", "Plan Mission with AI")}</h3> <h3>{t("missions.planTitle", "Plan Mission with AI")}</h3>
@@ -1112,7 +1111,7 @@ export function MissionInterviewModal({
)} )}
</div> </div>
</div> </div>
</div> </FloatingWindow>
); );
} }

View File

@@ -1,8 +1,11 @@
import type React from "react"; import type React from "react";
import { readFileSync } from "node:fs";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { MissionInterviewModal } from "../MissionInterviewModal"; import { MissionInterviewModal } from "../MissionInterviewModal";
const missionInterviewCss = readFileSync("app/components/MissionInterviewModal.css", "utf8");
const mockStartMissionInterview = vi.fn(); const mockStartMissionInterview = vi.fn();
const mockRespondToMissionInterview = vi.fn(); const mockRespondToMissionInterview = vi.fn();
const mockRetryMissionInterviewSession = vi.fn(); const mockRetryMissionInterviewSession = vi.fn();
@@ -133,6 +136,12 @@ describe("MissionInterviewModal", () => {
mockReleaseSessionLock.mockResolvedValue(undefined); mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null }); mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }); mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
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>> = {}) { function renderModal(props: Partial<React.ComponentProps<typeof MissionInterviewModal>> = {}) {
@@ -151,6 +160,79 @@ describe("MissionInterviewModal", () => {
}; };
} }
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 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;");
});
it("shows lock overlay and allows take-control", async () => { it("shows lock overlay and allows take-control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self"); window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" }); mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
@@ -378,6 +460,30 @@ describe("MissionInterviewModal", () => {
expect(screen.getByRole("button", { name: "Retry" })).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 () => { it("shows persisted mission interview errors after stream recovery refreshes the session", async () => {
mockFetchAiSession.mockResolvedValueOnce( mockFetchAiSession.mockResolvedValueOnce(
buildMissionSession({ buildMissionSession({
@@ -582,14 +688,14 @@ describe("MissionInterviewModal", () => {
expect(mockCancelMissionInterview).not.toHaveBeenCalled(); expect(mockCancelMissionInterview).not.toHaveBeenCalled();
}); });
it("closes from the backdrop after overlay mousedown", () => { it("does not render a blocking backdrop click target around the floating workspace", () => {
const { onClose } = renderModal(); const { onClose } = renderModal();
const overlay = screen.getByRole("dialog"); const overlay = screen.getByRole("dialog");
fireEvent.mouseDown(overlay); fireEvent.mouseDown(overlay);
fireEvent.click(overlay); fireEvent.click(overlay);
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).not.toHaveBeenCalled();
expect(mockCancelMissionInterview).not.toHaveBeenCalled(); expect(mockCancelMissionInterview).not.toHaveBeenCalled();
}); });