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:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user