Files
fusion/packages/dashboard/app/components/__tests__/MilestoneSliceInterviewModal.test.tsx
gsxdsm 1f0d371228 fix(tests): three more portal query-root failures (pr-tab, worktree-terminal, milestone-slice) (#2959)
Three dashboard test files asserted against `render()`'s `container`,
but the components under test mount through `createPortal` — so
`container` is **empty** and every query returns nothing. Same root
cause as the earlier portal batch; these are the three that were still
held back.

| File | Before | After |
|---|---|---|
| `TaskDetailModal.pr-tab` | failing | pass |
| `TaskDetailModal.worktree-terminal` | failing | pass |
| `MilestoneSliceInterviewModal` | failing | pass |

**Measured: 39/39 passing**, rebased on current main (`3461ae7a92`).
Lint clean, FNXC date gate exit 0.

### Why this stayed hidden

The queries were a **mix** of `container.querySelector(...)` and
`screen.*`. `screen` queries `document`, so they kept working — a
portal-mounted modal makes only the `container` half go blind. The
result is a file that looks half-alive rather than obviously broken, and
the failures present as five different-looking symptoms (`null`,
`undefined`, `+0`, `[]`, `-1`) that don't read as one bug.

Grouping candidate files by **`container.querySelector` call count**
rather than by symptom is what identified these correctly, and — the
part that mattered — correctly *excluded* the neighbouring files that
were failing for unrelated reasons.

### One thing to know if you repeat this

A blanket `container` → `document` replace is wrong: it also rewrites
`renderResult.container.querySelector` into
`renderResult.document.querySelector`, which is not a thing. That broke
two already-passing tests on my first attempt. This uses two separate
passes with a lookbehind so only the bare receiver is rewritten.

### Scope

Test-side only — **no product code changes**, so no changeset. This does
not fix the *cause* (tests are still free to query the wrong root); a
lint rule for that is worth considering separately, but it would need to
distinguish portal-mounting components from ordinary ones, and I did not
want to guess at that boundary inside a test-fix PR.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Tests**
* Improved modal and task detail accessibility test reliability by
querying rendered elements from the document.
* Updated coverage for keyboard navigation, Pull Request status
indicators, tab ordering, and onboarding provider cards.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:42:33 -07:00

1043 lines
34 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MilestoneSliceInterviewModal } from "../MilestoneSliceInterviewModal";
const mockStartMilestoneInterview = vi.fn();
const mockStartSliceInterview = vi.fn();
const mockRespondToMilestoneInterview = vi.fn();
const mockRespondToSliceInterview = vi.fn();
const mockApplyMilestoneInterview = vi.fn();
const mockApplySliceInterview = vi.fn();
const mockSkipMilestoneInterview = vi.fn();
const mockSkipSliceInterview = vi.fn();
const mockConnectMilestoneInterviewStream = vi.fn();
const mockConnectSliceInterviewStream = vi.fn();
const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn();
const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
vi.mock("../../api", () => ({
startMilestoneInterview: (...args: any[]) => mockStartMilestoneInterview(...args),
startSliceInterview: (...args: any[]) => mockStartSliceInterview(...args),
respondToMilestoneInterview: (...args: any[]) => mockRespondToMilestoneInterview(...args),
respondToSliceInterview: (...args: any[]) => mockRespondToSliceInterview(...args),
applyMilestoneInterview: (...args: any[]) => mockApplyMilestoneInterview(...args),
applySliceInterview: (...args: any[]) => mockApplySliceInterview(...args),
skipMilestoneInterview: (...args: any[]) => mockSkipMilestoneInterview(...args),
skipSliceInterview: (...args: any[]) => mockSkipSliceInterview(...args),
connectMilestoneInterviewStream: (...args: any[]) => mockConnectMilestoneInterviewStream(...args),
connectSliceInterviewStream: (...args: any[]) => mockConnectSliceInterviewStream(...args),
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
}));
vi.mock("../../hooks/useAiSessionSync", () => ({
useAiSessionSync: vi.fn(() => ({
broadcastUpdate: vi.fn(),
broadcastCompleted: vi.fn(),
})),
}));
const mockUseMobileKeyboard = vi.fn();
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
isFullScreenSheetViewport: () => false,
isShortViewport: () => false,
getViewportMode: () => "mobile",
isMobileViewport: () => true,
isTabletTouchViewport: (mode?: string) => mode === "tablet",
useViewportMode: () => "mobile",
}));
vi.mock("lucide-react", () => ({
X: () => <span data-testid="x-icon">X</span>,
Loader2: ({ className }: any) => <span data-testid="loader-icon" className={className}>Loader</span>,
CheckCircle: () => <span data-testid="check-circle-icon">CheckCircle</span>,
ArrowRight: () => <span data-testid="arrow-right-icon">ArrowRight</span>,
Sparkles: () => <span data-testid="sparkles-icon">Sparkles</span>,
ChevronRight: () => <span data-testid="chevron-right-icon">ChevronRight</span>,
ChevronDown: () => <span data-testid="chevron-down-icon">ChevronDown</span>,
Minimize2: () => <span data-testid="minimize-icon">Minimize2</span>,
}));
const SAMPLE_QUESTION = {
id: "scope",
type: "single_select" as const,
question: "What is the target scope?",
description: "Pick the size for this feature.",
options: [
{ id: "mvp", label: "MVP" },
{ id: "full", label: "Full" },
],
};
const MARKDOWN_QUESTION = {
id: "markdown-question",
type: "text" as const,
question: "Do you want **fast** mode?\n\nfirst line \nsecond line\n\n- Option A\n- Option B",
description: "Choose the mode before continuing.",
};
function expectMarkdownQuestionFormatting() {
const question = screen.getByTestId("planning-question-text");
expect(question.querySelector("strong")).toHaveTextContent("fast");
expect([...question.querySelectorAll("p")].find((paragraph) => paragraph.textContent?.includes("first line"))?.querySelector("br")).not.toBeNull();
expect([...question.querySelectorAll("li")].map((item) => item.textContent)).toEqual(["Option A", "Option B"]);
expect(question).not.toHaveTextContent("**fast**");
}
describe("MilestoneSliceInterviewModal", () => {
let streamHandlers: any;
beforeEach(() => {
mockStartMilestoneInterview.mockReset();
mockStartSliceInterview.mockReset();
mockRespondToMilestoneInterview.mockReset();
mockRespondToSliceInterview.mockReset();
mockApplyMilestoneInterview.mockReset();
mockApplySliceInterview.mockReset();
mockSkipMilestoneInterview.mockReset();
mockSkipSliceInterview.mockReset();
mockConnectMilestoneInterviewStream.mockReset();
mockConnectSliceInterviewStream.mockReset();
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
mockFetchAiSession.mockReset();
mockParseConversationHistory.mockReset();
mockParseConversationHistory.mockReturnValue([]);
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: false,
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
});
// Setup stream handlers capture
mockConnectMilestoneInterviewStream.mockImplementation((sessionId, projectId, handlers) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
mockConnectSliceInterviewStream.mockImplementation((sessionId, projectId, handlers) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
});
describe("initial view", () => {
it("renders with correct title for milestone", () => {
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
expect(screen.getByText("Plan Milestone: Test Milestone")).toBeDefined();
});
it("renders with correct title for slice", () => {
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>
);
expect(screen.getByText("Plan Slice: Test Slice")).toBeDefined();
});
it("shows three action buttons: Start Interview, Use Mission Context, Cancel", () => {
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
expect(screen.getByText("Start Interview")).toBeDefined();
expect(screen.getByText("Use Mission Context")).toBeDefined();
expect(screen.getByText("Cancel")).toBeDefined();
});
it("calls onClose when Cancel is clicked", () => {
const onClose = vi.fn();
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={onClose}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Cancel"));
expect(onClose).toHaveBeenCalled();
});
});
describe("Start Interview button", () => {
it("calls startMilestoneInterview for targetType=milestone", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(mockStartMilestoneInterview).toHaveBeenCalledWith("MS-001", "test-project");
});
});
it("calls startSliceInterview for targetType=slice", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(mockStartSliceInterview).toHaveBeenCalledWith("SL-001", "test-project");
});
});
it("applies keyboard CSS variables to planning modal when keyboard is open", () => {
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: true,
keyboardOverlap: 250,
viewportHeight: 400,
viewportOffsetTop: 50,
});
const { container } = render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="milestone-1"
targetTitle="Milestone 1"
/>,
);
const modal = document.querySelector(".planning-modal");
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
});
it("shows loading state after clicking Start Interview", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
mockConnectMilestoneInterviewStream.mockReturnValue({
close: vi.fn(),
isConnected: vi.fn(() => false),
});
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText("Preparing next question...")).toBeDefined();
});
});
});
describe("Use Mission Context button", () => {
it("calls skipMilestoneInterview and onApplied for targetType=milestone", async () => {
mockSkipMilestoneInterview.mockResolvedValue({ id: "MS-001", title: "Test Milestone" });
const onApplied = vi.fn();
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={onApplied}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Use Mission Context"));
await waitFor(() => {
expect(mockSkipMilestoneInterview).toHaveBeenCalledWith("MS-001", "test-project");
expect(onApplied).toHaveBeenCalled();
});
});
it("calls skipSliceInterview and onApplied for targetType=slice", async () => {
mockSkipSliceInterview.mockResolvedValue({ id: "SL-001", title: "Test Slice" });
const onApplied = vi.fn();
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={onApplied}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Use Mission Context"));
await waitFor(() => {
expect(mockSkipSliceInterview).toHaveBeenCalledWith("SL-001", "test-project");
expect(onApplied).toHaveBeenCalled();
});
});
it("does not call any interview API when Cancel is clicked", () => {
const onClose = vi.fn();
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={onClose}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Cancel"));
expect(mockStartMilestoneInterview).not.toHaveBeenCalled();
expect(mockSkipMilestoneInterview).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
describe("question flow", () => {
it("shows question after interview starts and AI responds", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Start Interview"));
// Simulate AI response with question
await waitFor(() => {
// Loading state should appear first
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
// Simulate question event from stream
act(() => {
streamHandlers.onQuestion(SAMPLE_QUESTION);
});
await waitFor(() => {
expect(screen.getByText("What is the target scope?")).toBeDefined();
expect(screen.getByText("Pick the size for this feature.")).toBeDefined();
});
});
it("renders markdown formatting in AI milestone and slice interview questions", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => expect(streamHandlers).toBeDefined());
act(() => {
streamHandlers.onQuestion(MARKDOWN_QUESTION);
});
await waitFor(expectMarkdownQuestionFormatting);
});
it("shows reconnecting only during active generation, not on persisted questions", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => expect(streamHandlers).toBeDefined());
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("connected");
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
});
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
});
});
describe("summary and apply", () => {
it("shows summary view after interview completes", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>
);
fireEvent.click(screen.getByText("Start Interview"));
// Wait for loading state
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
// Simulate summary event
act(() => {
if (streamHandlers?.onSummary) {
streamHandlers.onSummary({
description: "Refined description",
});
}
});
// Give React time to update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
// Summary should show Refined Scope header
expect(screen.getByText("Refined Scope")).toBeDefined();
});
});
describe("comment input", () => {
it("submits trimmed Other-only answers for single-select milestone questions", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion(SAMPLE_QUESTION);
});
await screen.findByText("What is the target scope?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Split this differently " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMilestoneInterview).toHaveBeenCalledWith(
"session-123",
{ _other: "Split this differently" },
"test-project",
);
});
});
it("renders Other for single-select milestone questions with no provided options", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "open_scope",
type: "single_select",
question: "What is the target scope?",
});
});
await screen.findByText("What is the target scope?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Define a custom scope " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMilestoneInterview).toHaveBeenCalledWith(
"session-123",
{ _other: "Define a custom scope" },
"test-project",
);
});
});
it("clears stale Other text when unchecking Other in multi-select slice questions", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByTestId("planning-option-other"));
fireEvent.change(screen.getByTestId("planning-other-input"), { target: { value: " " } });
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: "Keep this manual" },
});
expect(continueButton).toBeEnabled();
fireEvent.click(screen.getByText("Speed"));
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(screen.queryByTestId("planning-other-input")).toBeNull();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ priorities: ["speed"] },
"test-project",
);
});
});
it("submits Other-only answers for multi-select slice questions", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Reframe around dependencies " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ _other: "Reframe around dependencies" },
"test-project",
);
});
});
it("renders Other for multi-select slice questions with no provided options", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "open_priorities",
type: "multi_select",
question: "Which priorities matter?",
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Ask customers first " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ _other: "Ask customers first" },
"test-project",
);
});
});
it("combines provided options with Other text for multi-select slice questions", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByText("Speed"));
fireEvent.click(screen.getByTestId("planning-option-other"));
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Preserve manual review " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ priorities: ["speed"], _other: "Preserve manual review" },
"test-project",
);
});
});
it("shows comment textarea and submits _comment in milestone interview", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion(SAMPLE_QUESTION);
});
await screen.findByText("What is the target scope?");
expect(screen.getByPlaceholderText("Add any extra context or direction...")).toBeDefined();
fireEvent.click(screen.getByText("MVP"));
fireEvent.change(screen.getByPlaceholderText("Add any extra context or direction..."), {
target: { value: "Keep this aligned with mission MVP" },
});
fireEvent.click(screen.getByRole("button", { name: /Continue/ }));
await waitFor(() => {
expect(mockRespondToMilestoneInterview).toHaveBeenCalledWith(
"session-123",
expect.objectContaining({ scope: "mvp", _comment: "Keep this aligned with mission MVP" }),
"test-project",
);
});
});
});
describe("resume session rehydration", () => {
const mockSessionAwaitingInput = {
id: "session-resume-123",
type: "milestone_interview" as const,
status: "awaiting_input" as const,
title: "Plan milestone scope",
projectId: "proj-1",
updatedAt: new Date().toISOString(),
inputPayload: JSON.stringify({
targetType: "milestone",
targetId: "MS-001",
targetTitle: "Test Milestone",
missionContext: "Test Mission",
}),
conversationHistory: JSON.stringify([
{ question: { id: "q1", type: "text", question: "What is the scope?" }, response: { q1: "MVP" } } ]),
currentQuestion: JSON.stringify(SAMPLE_QUESTION),
result: null,
thinkingOutput: "",
error: null,
createdAt: new Date().toISOString(),
};
const mockSessionGenerating = {
...mockSessionAwaitingInput,
id: "session-resume-456",
status: "generating" as const,
currentQuestion: null,
thinkingOutput: "Analyzing requirements...",
};
const mockSessionError = {
...mockSessionAwaitingInput,
id: "session-resume-789",
status: "error" as const,
currentQuestion: null,
result: null,
error: "AI service unavailable",
};
it("restores awaiting_input session with question when resumeSessionId is provided", async () => {
mockFetchAiSession.mockResolvedValue(mockSessionAwaitingInput);
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
resumeSessionId="session-resume-123"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-123");
});
await waitFor(() => {
expect(screen.getByText("What is the target scope?")).toBeDefined();
expect(screen.getByText("Pick the size for this feature.")).toBeDefined();
});
});
/*
FNXC:PlanningMultiTab 2026-07-16-17:35:
A legacy persisted lock holder must never gate either milestone or slice interview resumption.
The multi-tab contract opens the shared session directly without a Take Control affordance.
*/
it.each([
["milestone", "MS-001", "Test Milestone", "milestone_interview"],
["slice", "SL-001", "Test Slice", "slice_interview"],
] as const)("resumes a legacy other-tab-owned %s session without a lock affordance", async (targetType, targetId, targetTitle, type) => {
mockFetchAiSession.mockResolvedValue({
...mockSessionAwaitingInput,
type,
lockedByTab: "tab-other",
lockedAt: new Date().toISOString(),
});
mockAcquireSessionLock.mockResolvedValue({ acquired: false, currentHolder: "tab-other" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType={targetType}
targetId={targetId}
targetTitle={targetTitle}
projectId="test-project"
resumeSessionId="session-resume-123"
/>,
);
expect(await screen.findByText("What is the target scope?")).toBeDefined();
expect(screen.queryByRole("button", { name: /take control/i })).toBeNull();
expect(screen.queryByText(/active in another tab|live heartbeat/i)).toBeNull();
expect(mockAcquireSessionLock).not.toHaveBeenCalled();
expect(mockForceAcquireSessionLock).not.toHaveBeenCalled();
});
it("reconnects to stream for generating session when resumeSessionId is provided", async () => {
mockFetchAiSession.mockResolvedValue(mockSessionGenerating);
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
resumeSessionId="session-resume-456"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-456");
expect(mockConnectMilestoneInterviewStream).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText(/AI is thinking/)).toBeDefined();
});
});
it("shows error state for error session when resumeSessionId is provided", async () => {
mockFetchAiSession.mockResolvedValue(mockSessionError);
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
resumeSessionId="session-resume-789"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-789");
});
await waitFor(() => {
expect(screen.getByText("AI service unavailable")).toBeDefined();
});
});
it("does not resume when resumeSessionId is not provided", async () => {
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
expect(mockFetchAiSession).not.toHaveBeenCalled();
expect(screen.getByText("Start Interview")).toBeDefined();
});
});
});