Files
fusion/packages/dashboard/app/api/__tests__/mission-interview-stream.test.ts
gsxdsm a0954c7f27 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
2026-06-25 16:31:10 -07:00

99 lines
3.7 KiB
TypeScript

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);
});
});