chore: consolidate remaining tests into __tests__/ dirs and refactor mocks

Mostly mechanical cleanup left over from the earlier test-consolidation pass:
- Update import paths to ../../ for mocks now that test files moved deeper
- Simplify mock setup (drop usePluginUiSlots inline mock, etc.)
- Move engine ipc + runtimes tests into __tests__/ subdirs
- Move dashboard utils tests into __tests__/ subdir
- Refresh fusion-plugin-hermes-runtime/dist artifacts

build-exe.test.ts: spawn-import fix from a parallel branch (resolved during
worktree merge of the CSS extraction work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 19:38:40 -07:00
parent f11800f2c6
commit 6685d832fa
35 changed files with 11381 additions and 12232 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,33 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { PlanningQuestion } from "@fusion/core";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MissionInterviewModal } from "../MissionInterviewModal";
import * as api from "../../api";
import * as modalPersistence from "../../hooks/modalPersistence";
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();
vi.mock("../../api", () => ({
startMissionInterview: vi.fn(),
respondToMissionInterview: vi.fn(),
cancelMissionInterview: vi.fn(),
createMissionFromInterview: vi.fn(),
connectMissionInterviewStream: vi.fn(),
fetchAiSession: vi.fn(),
parseConversationHistory: vi.fn(),
acquireSessionLock: vi.fn(),
releaseSessionLock: vi.fn(),
forceAcquireSessionLock: vi.fn(),
fetchModels: vi.fn(),
updateGlobalSettings: vi.fn(),
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),
}));
vi.mock("../../hooks/modalPersistence", () => ({
@@ -27,85 +36,26 @@ vi.mock("../../hooks/modalPersistence", () => ({
clearMissionGoal: vi.fn(),
}));
const mockStartMissionInterview = vi.mocked(api.startMissionInterview);
const mockRespondToMissionInterview = vi.mocked(api.respondToMissionInterview);
const mockCancelMissionInterview = vi.mocked(api.cancelMissionInterview);
const mockCreateMissionFromInterview = vi.mocked(api.createMissionFromInterview);
const mockConnectMissionInterviewStream = vi.mocked(api.connectMissionInterviewStream);
const mockFetchAiSession = vi.mocked(api.fetchAiSession);
const mockParseConversationHistory = vi.mocked(api.parseConversationHistory);
const mockAcquireSessionLock = vi.mocked(api.acquireSessionLock);
const mockReleaseSessionLock = vi.mocked(api.releaseSessionLock);
const mockForceAcquireSessionLock = vi.mocked(api.forceAcquireSessionLock);
const mockFetchModels = vi.mocked(api.fetchModels);
const mockUpdateGlobalSettings = vi.mocked(api.updateGlobalSettings);
const mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal);
const sampleQuestionSingle: PlanningQuestion = {
const SAMPLE_QUESTION = {
id: "scope",
type: "single_select",
type: "single_select" as const,
question: "What is the target scope?",
description: "Pick a scope",
description: "Pick the size for this mission.",
options: [
{ id: "mvp", label: "MVP" },
{ id: "full", label: "Full" },
],
};
const sampleSummary = {
missionTitle: "Mission: Collaboration Platform",
missionDescription: "Build a collaboration platform with milestones",
milestones: [
{
title: "Foundation",
description: "Set up project baseline",
verification: "Core services healthy",
slices: [
{
title: "Auth Slice",
description: "Add login flow",
verification: "Users can authenticate",
features: [
{
title: "Email login",
description: "Users can sign in with email",
acceptanceCriteria: "Successful login redirects to dashboard",
},
],
},
],
},
],
};
describe("MissionInterviewModal", () => {
let streamHandlers: Parameters<typeof api.connectMissionInterviewStream>[2] | undefined;
let closeStream: ReturnType<typeof vi.fn>;
const onClose = vi.fn();
const onMissionCreated = vi.fn();
let streamHandlers: any;
beforeEach(() => {
vi.clearAllMocks();
closeStream = vi.fn();
streamHandlers = undefined;
vi.spyOn(window, "confirm").mockReturnValue(true);
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-color-scheme: dark)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
mockRespondToMissionInterview.mockResolvedValue({ type: "question", data: sampleQuestionSingle });
mockCancelMissionInterview.mockResolvedValue(undefined);
mockCreateMissionFromInterview.mockResolvedValue({ id: "MS-001", title: "Created mission" } as any);
mockRetryMissionInterviewSession.mockResolvedValue({ success: true, sessionId: "mission-session-1" });
mockFetchAiSession.mockResolvedValue(null);
mockParseConversationHistory.mockImplementation((raw: string) => {
if (!raw) return [];
@@ -116,427 +66,230 @@ describe("MissionInterviewModal", () => {
return [];
}
});
mockGetMissionGoal.mockReturnValue("");
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: [] });
mockUpdateGlobalSettings.mockResolvedValue({});
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
streamHandlers = handlers;
return {
close: closeStream,
isConnected: vi.fn().mockReturnValue(true),
};
});
});
afterEach(() => {
vi.restoreAllMocks();
});
function renderModal(props?: Partial<React.ComponentProps<typeof MissionInterviewModal>>) {
function renderModal() {
return render(
<MissionInterviewModal
isOpen={true}
onClose={onClose}
onMissionCreated={onMissionCreated}
{...props}
onClose={vi.fn()}
onMissionCreated={vi.fn()}
/>,
);
}
async function startInterview(goal = "Build mission interview workflow") {
const user = userEvent.setup();
await user.type(screen.getByLabelText("What do you want to build?"), goal);
await user.click(screen.getByRole("button", { name: "Start Interview" }));
it("shows lock overlay and allows take-control", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockAcquireSessionLock.mockResolvedValueOnce({ 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).toHaveBeenCalledWith(goal, undefined, undefined);
expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Take Control"));
await waitFor(() => {
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("mission-session-1", "tab-self");
});
await waitFor(() => {
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
});
});
it("shows reconnecting indicator without clearing current question", 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();
});
}
it("returns null when isOpen=false", () => {
render(
<MissionInterviewModal
isOpen={false}
onClose={onClose}
onMissionCreated={onMissionCreated}
/>,
);
expect(screen.queryByText("Plan Mission with AI")).not.toBeInTheDocument();
});
it("renders modal header and initial mission goal textarea when open", () => {
renderModal();
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
expect(screen.getByLabelText("What do you want to build?")).toBeInTheDocument();
});
it("Start Interview button is disabled when mission goal is empty", () => {
renderModal();
expect(screen.getByRole("button", { name: "Start Interview" })).toBeDisabled();
});
it("Start Interview calls API and shows loading spinner while awaiting stream events", async () => {
renderModal();
await startInterview("Build planning engine");
expect(screen.getByText("Preparing next question...")).toBeInTheDocument();
expect(document.querySelector(".planning-loading .spin")).toBeTruthy();
});
it("stream onQuestion transitions to question view", async () => {
renderModal();
await startInterview();
act(() => {
streamHandlers?.onQuestion?.(sampleQuestionSingle);
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
});
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
});
it("question view renders text/single_select/multi_select/confirm types", async () => {
renderModal();
await startInterview();
act(() => {
streamHandlers?.onQuestion?.({
id: "q-text",
type: "text",
question: "Describe your goal",
});
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(await screen.findByPlaceholderText("Type your answer here...")).toBeInTheDocument();
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
act(() => {
streamHandlers?.onQuestion?.(sampleQuestionSingle);
streamHandlers.onConnectionStateChange?.("connected");
});
expect(await screen.findByText("MVP")).toBeInTheDocument();
act(() => {
streamHandlers?.onQuestion?.({
id: "q-multi",
type: "multi_select",
question: "Which capabilities?",
options: [
{ id: "chat", label: "Chat" },
{ id: "docs", label: "Docs" },
],
});
});
expect(await screen.findByText("Chat")).toBeInTheDocument();
act(() => {
streamHandlers?.onQuestion?.({
id: "q-confirm",
type: "confirm",
question: "Ship MVP first?",
});
});
expect(await screen.findByRole("button", { name: "Yes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "No" })).toBeInTheDocument();
});
it("submit response calls respondToMissionInterview with answers", async () => {
renderModal();
await startInterview();
act(() => {
streamHandlers?.onQuestion?.(sampleQuestionSingle);
});
const user = userEvent.setup();
await user.click(await screen.findByText("MVP"));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
"mission-session-1",
{ scope: "mvp" },
undefined,
expect.any(String),
);
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
});
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
});
it("stream onSummary transitions to summary view with editable fields and hierarchy", async () => {
it("preserves streaming thinking output while reconnecting", async () => {
renderModal();
await startInterview();
act(() => {
streamHandlers?.onSummary?.(sampleSummary as any);
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
expect(await screen.findByText("Mission Plan Ready")).toBeInTheDocument();
expect(screen.getByDisplayValue("Mission: Collaboration Platform")).toBeInTheDocument();
expect(screen.getByDisplayValue("Build a collaboration platform with milestones")).toBeInTheDocument();
// Hierarchy fields
expect(screen.getByDisplayValue("Foundation")).toBeInTheDocument();
expect(screen.getByDisplayValue("Auth Slice")).toBeInTheDocument();
expect(screen.getByDisplayValue("Email login")).toBeInTheDocument();
});
it("summary hierarchy is expandable/collapsible", async () => {
renderModal();
await startInterview();
act(() => {
streamHandlers?.onSummary?.(sampleSummary as any);
});
await screen.findByText("Mission Plan Ready");
const milestoneInput = screen.getByDisplayValue("Foundation");
// Click row to collapse then expand
fireEvent.click(milestoneInput.closest("div")!);
expect(screen.queryByDisplayValue("Auth Slice")).not.toBeInTheDocument();
fireEvent.click(milestoneInput.closest("div")!);
expect(screen.getByDisplayValue("Auth Slice")).toBeInTheDocument();
});
it("Approve Plan calls createMissionFromInterview and onMissionCreated", async () => {
renderModal();
await startInterview();
act(() => {
streamHandlers?.onSummary?.(sampleSummary as any);
});
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Approve Plan" }));
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(mockCreateMissionFromInterview).toHaveBeenCalledWith(
"mission-session-1",
expect.objectContaining({ missionTitle: "Mission: Collaboration Platform" }),
undefined,
);
expect(onMissionCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "MS-001" }));
expect(streamHandlers).toBeDefined();
});
});
it("Start Over resets to initial view", async () => {
renderModal();
await startInterview();
act(() => {
streamHandlers?.onSummary?.(sampleSummary as any);
streamHandlers.onThinking?.("Analyzing mission goals...");
});
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Start Over" }));
expect(screen.getByLabelText("What do you want to build?")).toBeInTheDocument();
expect(screen.queryByText("Mission Plan Ready")).not.toBeInTheDocument();
});
it("handles start interview API error and returns to initial view", async () => {
mockStartMissionInterview.mockRejectedValueOnce(new Error("Failed to start"));
renderModal();
const user = userEvent.setup();
await user.type(screen.getByLabelText("What do you want to build?"), "Bad start");
await user.click(screen.getByRole("button", { name: "Start Interview" }));
await waitFor(() => {
expect(screen.getByText("Failed to start")).toBeInTheDocument();
expect(screen.getByLabelText("What do you want to build?")).toBeInTheDocument();
});
});
it("Escape key with progress asks for confirmation", async () => {
renderModal();
await startInterview();
expect(await screen.findByText("Analyzing mission goals...")).toBeInTheDocument();
act(() => {
streamHandlers?.onQuestion?.(sampleQuestionSingle);
streamHandlers.onConnectionStateChange?.("reconnecting");
});
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
expect(screen.getByText("Analyzing mission goals...")).toBeInTheDocument();
});
it("shows error panel with retry action when stream fails", 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"));
expect(window.confirm).toHaveBeenCalledWith(
"Are you sure you want to close? Your interview progress will be lost.",
);
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
expect(streamHandlers).toBeDefined();
});
});
it("Escape key without progress closes directly", () => {
renderModal();
fireEvent.keyDown(document, { key: "Escape" });
expect(window.confirm).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it("calls cancelMissionInterview on close when session is active", async () => {
renderModal();
await startInterview();
act(() => {
streamHandlers?.onQuestion?.(sampleQuestionSingle);
streamHandlers.onError?.("Temporary outage");
});
const user = userEvent.setup();
await user.click(await screen.findByLabelText("Close"));
await waitFor(() => {
expect(mockCancelMissionInterview).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
});
expect(await screen.findByText("Temporary outage")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
it("initialGoal prop auto-starts interview", async () => {
renderModal({ initialGoal: "Auto-start goal" });
await waitFor(() => {
expect(mockStartMissionInterview).toHaveBeenCalledWith("Auto-start goal", undefined, undefined);
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),
};
});
});
it("resumeSessionId fetches AI session and restores question state", async () => {
mockFetchAiSession.mockResolvedValueOnce({
id: "resume-1",
status: "awaiting_input",
currentQuestion: JSON.stringify(sampleQuestionSingle),
result: null,
thinkingOutput: "",
error: null,
} as any);
renderModal();
renderModal({ resumeSessionId: "resume-1" });
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("resume-1");
expect(screen.getByText("Try again")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
});
await waitFor(() => {
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
});
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
});
it("unmount cleanup closes active stream connection", async () => {
const { unmount } = renderModal();
await startInterview();
unmount();
expect(closeStream).toHaveBeenCalled();
});
describe("model favorites persistence", () => {
const mockModelsWithFavorites = {
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
],
favoriteProviders: ["anthropic"],
favoriteModels: ["anthropic/claude-sonnet-4-5"],
};
beforeEach(() => {
mockFetchModels.mockResolvedValue(mockModelsWithFavorites);
it("recovers retry from connection-loss 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),
};
});
it("persists provider favorite toggle via updateGlobalSettings", async () => {
mockFetchModels.mockResolvedValue({
models: mockModelsWithFavorites.models,
favoriteProviders: [],
favoriteModels: [],
});
renderModal();
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]')!;
const addButton = within(portal).getByRole("button", { name: "Add anthropic to favorites" });
fireEvent.click(addButton);
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
favoriteProviders: ["anthropic"],
favoriteModels: [],
});
mockRetryMissionInterviewSession.mockRejectedValueOnce(
new Error("Mission interview session mission-session-1 is not in an error state"),
);
mockFetchAiSession.mockResolvedValueOnce({
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,
lockedByTab: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lockedAt: null,
});
it("persists model favorite toggle via updateGlobalSettings", async () => {
mockFetchModels.mockResolvedValue({
models: mockModelsWithFavorites.models,
favoriteProviders: [],
favoriteModels: [],
});
renderModal();
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(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]')!;
const addModelButton = within(portal).getByRole("button", { name: "Add Claude Sonnet 4.5 to favorites" });
fireEvent.click(addModelButton);
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({
favoriteProviders: [],
favoriteModels: ["anthropic/claude-sonnet-4-5"],
});
await waitFor(() => {
expect(screen.getByText("Connection lost")).toBeInTheDocument();
});
it("rolls back local favorite state when updateGlobalSettings fails", async () => {
// Provider rollback should be exercised with provider favorites only.
// When all models in a provider are favorited, the provider group can be hidden.
mockFetchModels.mockResolvedValue({
models: mockModelsWithFavorites.models,
favoriteProviders: ["anthropic"],
favoriteModels: [],
});
mockUpdateGlobalSettings.mockRejectedValueOnce(new Error("Network error"));
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
renderModal();
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
await waitFor(() => {
expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull();
});
const portal = document.body.querySelector('[data-testid="model-combobox-portal"]')!;
const removeButton = within(portal).getByRole("button", { name: "Remove anthropic from favorites" });
fireEvent.click(removeButton);
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
});
const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]')!;
expect(within(portalAfterRollback).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy();
await waitFor(() => {
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
expect(mockFetchAiSession).toHaveBeenCalledWith("mission-session-1");
});
expect(await screen.findByText("AI is thinking...")).toBeInTheDocument();
expect(screen.getByText("Continuing...")).toBeInTheDocument();
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,196 +1,510 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QuickScriptsDropdown } from "../QuickScriptsDropdown";
import { fetchScripts } from "../../api";
// Mock the API functions
const mockFetchScripts = vi.fn();
vi.mock("../../api", () => ({
fetchScripts: vi.fn(),
fetchScripts: () => mockFetchScripts(),
}));
const onOpenScripts = vi.fn();
const onRunScript = vi.fn();
const mockOnOpenScripts = vi.fn();
const mockOnRunScript = vi.fn();
const MOCK_SCRIPTS = {
build: "pnpm build",
lint: "pnpm lint",
test: "pnpm test",
};
function renderDropdown(props = {}) {
return render(
<QuickScriptsDropdown
onOpenScripts={mockOnOpenScripts}
onRunScript={mockOnRunScript}
{...props}
/>
);
}
describe("QuickScriptsDropdown", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
vi.mocked(fetchScripts).mockResolvedValue(MOCK_SCRIPTS);
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 1280,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 900,
});
Object.defineProperty(window, "visualViewport", {
configurable: true,
writable: true,
value: undefined,
});
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(),
} as MediaQueryList));
});
function renderDropdown() {
render(
<QuickScriptsDropdown
onOpenScripts={onOpenScripts}
onRunScript={onRunScript}
/>,
);
}
function mockTriggerRect(rect: Partial<DOMRect>) {
const trigger = screen.getByTestId("scripts-btn");
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
x: rect.left ?? 0,
y: rect.top ?? 0,
width: rect.width ?? 80,
height: rect.height ?? 32,
top: rect.top ?? 0,
right: (rect.left ?? 0) + (rect.width ?? 80),
bottom: (rect.top ?? 0) + (rect.height ?? 32),
left: rect.left ?? 0,
toJSON: () => ({}),
describe("rendering", () => {
it("renders the trigger button", () => {
renderDropdown();
expect(screen.getByTestId("scripts-btn")).toBeDefined();
expect(screen.getByTitle("Scripts")).toBeDefined();
});
return trigger;
}
it("renders below trigger when space is available", async () => {
const user = userEvent.setup();
renderDropdown();
const trigger = mockTriggerRect({ top: 120, left: 220, width: 120, height: 36 });
await user.click(trigger);
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
await waitFor(() => {
expect(dropdown.style.position).toBe("fixed");
expect(dropdown.style.top).toBe("162px");
expect(dropdown.style.left).toBe("220px");
expect(dropdown.style.width).toBe("260px");
});
});
it("repositions above trigger when viewport bottom is near", async () => {
const user = userEvent.setup();
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 375,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 667,
});
renderDropdown();
const trigger = mockTriggerRect({ top: 560, left: 330, width: 120, height: 36 });
await user.click(trigger);
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
await waitFor(() => {
expect(dropdown.style.top).toBe("274px");
expect(dropdown.style.left).toBe("99px");
expect(dropdown.style.width).toBe("260px");
});
});
it("clamps horizontal position to viewport edges on small screens", async () => {
const user = userEvent.setup();
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 360,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 700,
});
renderDropdown();
const trigger = mockTriggerRect({ top: 140, left: -40, width: 120, height: 36 });
await user.click(trigger);
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
await waitFor(() => {
expect(dropdown.style.left).toBe("16px");
});
});
it("repositions on window resize", async () => {
const user = userEvent.setup();
renderDropdown();
const triggerRect = { top: 120, left: 220, width: 120, height: 36 };
const trigger = mockTriggerRect(triggerRect);
await user.click(trigger);
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
await waitFor(() => {
expect(dropdown.style.top).toBe("162px");
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 360,
});
fireEvent(window, new Event("resize"));
await waitFor(() => {
expect(dropdown.style.top).toBe("64px");
});
});
it("keeps keyboard navigation behavior (arrow keys, enter, escape)", async () => {
const user = userEvent.setup();
renderDropdown();
const trigger = mockTriggerRect({ top: 120, left: 220, width: 120, height: 36 });
await user.click(trigger);
const dropdown = await screen.findByTestId("quick-scripts-dropdown");
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
await user.click(trigger);
await screen.findByTestId("quick-scripts-dropdown");
await user.keyboard("{Escape}");
await waitFor(() => {
it("does not show dropdown menu initially", () => {
renderDropdown();
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
expect(dropdown).toBeTruthy();
describe("dropdown open/close", () => {
it("opens dropdown when trigger is clicked", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
});
it("closes dropdown when clicking outside", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
it("closes dropdown on Escape key", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
it("closes dropdown when trigger is clicked again", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("fetching and displaying scripts", () => {
it("shows loading state while fetching", async () => {
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
});
it("fetches and displays scripts", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
test: "npm test",
lint: "npm run lint",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
expect(screen.getByTestId("quick-script-item-lint")).toBeDefined();
});
});
it("displays script names and truncated commands", async () => {
mockFetchScripts.mockResolvedValue({
"long-command": "this is a very long command that should be truncated",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
const item = screen.getByTestId("quick-script-item-long-command");
expect(item.textContent).toContain("long-command");
expect(item.textContent).toContain("this is a very long command that should be truncat...");
});
});
it("handles short commands without truncation", async () => {
mockFetchScripts.mockResolvedValue({
short: "echo hi",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
const item = screen.getByTestId("quick-script-item-short");
expect(item.textContent).toContain("short");
expect(item.textContent).toContain("echo hi");
});
});
it("sorts scripts alphabetically", async () => {
mockFetchScripts.mockResolvedValue({
zebra: "echo zebra",
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
const items = screen.getAllByRole("option");
expect(items[0].textContent).toContain("alpha");
expect(items[1].textContent).toContain("beta");
expect(items[2].textContent).toContain("zebra");
});
});
});
describe("running scripts", () => {
it("calls onRunScript when a script is clicked", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-script-item-build"));
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
});
it("closes dropdown after running script", async () => {
mockFetchScripts.mockResolvedValue({
test: "npm test",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-script-item-test"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("manage scripts link", () => {
it("shows 'Manage Scripts...' link when scripts exist", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
});
it("calls onOpenScripts when 'Manage Scripts...' is clicked", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
expect(mockOnOpenScripts).toHaveBeenCalled();
});
it("closes dropdown when 'Manage Scripts...' is clicked", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("empty state", () => {
it("shows empty state when no scripts configured", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
});
});
it("empty state shows 'Add your first script' button", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByText("Add your first script")).toBeDefined();
});
});
it("clicking 'Add your first script' calls onOpenScripts", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByText("Add your first script")).toBeDefined();
});
fireEvent.click(screen.getByText("Add your first script"));
expect(mockOnOpenScripts).toHaveBeenCalled();
});
it("closes dropdown when empty state action is clicked", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByText("Add your first script")).toBeDefined();
});
fireEvent.click(screen.getByText("Add your first script"));
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
});
describe("keyboard navigation", () => {
it("supports ArrowDown to highlight items", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// First ArrowDown highlights first item
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
// Second ArrowDown highlights second item
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
});
it("supports ArrowUp to highlight items", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Go to bottom first with End key
fireEvent.keyDown(menu, { key: "End" });
// ArrowUp moves to previous item
fireEvent.keyDown(menu, { key: "ArrowUp" });
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
});
it("wraps around with arrow keys", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// ArrowUp from start wraps to end (Manage Scripts...)
fireEvent.keyDown(menu, { key: "ArrowUp" });
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
// ArrowDown from end wraps to start
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
});
it("runs script with Enter key", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Highlight and press Enter
fireEvent.keyDown(menu, { key: "ArrowDown" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
});
it("opens manage scripts with Enter key on manage button", async () => {
mockFetchScripts.mockResolvedValue({
build: "npm run build",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Navigate to last item (Manage Scripts...) and press Enter
fireEvent.keyDown(menu, { key: "End" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(mockOnOpenScripts).toHaveBeenCalled();
});
it("supports Home key to go to first item", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
// Go to end first
fireEvent.keyDown(menu, { key: "End" });
// Home goes to first
fireEvent.keyDown(menu, { key: "Home" });
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
});
it("supports End key to go to last item", async () => {
mockFetchScripts.mockResolvedValue({
alpha: "echo alpha",
beta: "echo beta",
});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
fireEvent.keyDown(menu, { key: "End" });
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
});
});
describe("error handling", () => {
it("handles fetch errors gracefully", async () => {
mockFetchScripts.mockRejectedValue(new Error("Failed to fetch"));
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
// Should show empty state since scripts will be empty object on error
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
});
});
});
describe("focus management", () => {
it("menu is focusable with tabIndex", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
expect(menu).toHaveAttribute("tabIndex", "-1");
});
it("focus moves to trigger when Escape is pressed", async () => {
mockFetchScripts.mockResolvedValue({});
renderDropdown();
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
// Trigger should have focus
expect(document.activeElement).toBe(screen.getByTestId("scripts-btn"));
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture";
import { useState } from "react";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
@@ -102,19 +103,7 @@ function getCssRuleBlock(css: string, selector: string): string {
}
function readDashboardStylesSource(): string {
const candidatePaths = [
process.env.PWD ? resolve(process.env.PWD, "app/styles.css") : null,
process.env.npm_config_local_prefix ? resolve(process.env.npm_config_local_prefix, "app/styles.css") : null,
resolve(process.cwd(), "app/styles.css"),
resolve(process.cwd(), "../app/styles.css"),
resolve(process.cwd(), "../../packages/dashboard/app/styles.css"),
].filter((candidate): candidate is string => Boolean(candidate));
const cssPath = candidatePaths.find((candidate) => existsSync(candidate));
if (!cssPath) {
throw new Error("Unable to locate dashboard styles.css for Activity timeline CSS assertions");
}
return readFileSync(cssPath, "utf8");
return loadAllAppCss();
}
describe("TaskDetailModal", () => {