345ea1216d
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, renderHook, act, screen } from "@testing-library/react";
|
|
import { useState } from "react";
|
|
import type { OpenFileTab } from "@/lib/types/backend";
|
|
|
|
vi.mock("@/components/toast-provider", () => ({
|
|
useToast: () => ({ toast: vi.fn() }),
|
|
}));
|
|
|
|
const fetchAndOpenFileMock = vi.fn();
|
|
vi.mock("../file-browser-hooks", () => ({
|
|
fetchAndOpenFile: (...args: unknown[]) => fetchAndOpenFileMock(...args),
|
|
}));
|
|
|
|
vi.mock("@/hooks/use-visual-viewport-offset", () => ({
|
|
useVisualViewportOffset: () => ({ keyboardOpen: false, bottomOffset: 0 }),
|
|
}));
|
|
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
|
selector({ tasks: { activeTaskId: "task-1", activeSessionId: "session-1" } }),
|
|
}));
|
|
|
|
vi.mock("@/components/review/review-pr-selector", () => ({
|
|
ReviewPRSelector: ({ onSelectPR }: { onSelectPR: (pr: { id: string }) => void }) => (
|
|
<button type="button" onClick={() => onSelectPR({ id: "pr-b" })}>
|
|
Select PR B
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/github/pr-detail-panel", async () => {
|
|
const React = await import("react");
|
|
return {
|
|
PRDetailPanelComponent: ({ params }: { params: { prKey: string } }) => {
|
|
const [feedback] = React.useState(`feedback for ${params.prKey}`);
|
|
return <button type="button">{feedback}</button>;
|
|
},
|
|
};
|
|
});
|
|
|
|
import {
|
|
MobilePanelArea,
|
|
resolveMobilePluginPanel,
|
|
resolveMobileReviewSource,
|
|
terminalPaddingBottom,
|
|
useMobilePanelHandlers,
|
|
} from "./session-mobile-layout";
|
|
import type { PluginLifecycleSnapshot } from "@/lib/plugins/registry";
|
|
import { pluginRegistry } from "@/lib/plugins/registry";
|
|
|
|
const MOCK_FILE: OpenFileTab = {
|
|
path: "src/foo.ts",
|
|
name: "foo.ts",
|
|
content: "export const x = 1;",
|
|
originalContent: "export const x = 1;",
|
|
originalHash: "abc123",
|
|
isDirty: false,
|
|
};
|
|
|
|
const OTHER_FILE: OpenFileTab = {
|
|
...MOCK_FILE,
|
|
path: "src/bar.ts",
|
|
name: "bar.ts",
|
|
};
|
|
|
|
const CHAT_LINK_PATH = "src/chat-link.ts";
|
|
const REPO = "frontend";
|
|
|
|
function renderHandlers(initialSid: string | null = "s1") {
|
|
const handlePanelChange = vi.fn();
|
|
const view = renderHook(
|
|
({ sid }) => useMobilePanelHandlers({ effectiveSessionId: sid, handlePanelChange }),
|
|
{ initialProps: { sid: initialSid } },
|
|
);
|
|
return { handlePanelChange, ...view };
|
|
}
|
|
|
|
describe("useMobilePanelHandlers", () => {
|
|
beforeEach(() => {
|
|
fetchAndOpenFileMock.mockReset();
|
|
});
|
|
|
|
it("handleOpenFile sets selectedFile and switches to files panel", () => {
|
|
const { result, handlePanelChange } = renderHandlers();
|
|
expect(result.current.selectedFile).toBeNull();
|
|
|
|
act(() => result.current.handleOpenFile(MOCK_FILE));
|
|
|
|
expect(result.current.selectedFile).toEqual(MOCK_FILE);
|
|
expect(handlePanelChange).toHaveBeenCalledWith("files");
|
|
});
|
|
|
|
it("handleOpenFileFromChat fetches and opens the viewer panel", () => {
|
|
const { result, handlePanelChange } = renderHandlers();
|
|
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
|
|
|
|
expect(fetchAndOpenFileMock).toHaveBeenCalledWith(
|
|
"s1",
|
|
CHAT_LINK_PATH,
|
|
expect.any(Function),
|
|
expect.any(Function),
|
|
{ repo: undefined, signal: expect.objectContaining({ aborted: false }) },
|
|
);
|
|
|
|
const openFile = fetchAndOpenFileMock.mock.calls[0]?.[2] as (file: OpenFileTab) => void;
|
|
act(() => openFile(MOCK_FILE));
|
|
|
|
expect(result.current.selectedFile).toEqual(MOCK_FILE);
|
|
expect(handlePanelChange).toHaveBeenCalledWith("files");
|
|
});
|
|
|
|
it("opens a fetched Markdown file with preview intent", () => {
|
|
const { result, handlePanelChange } = renderHandlers();
|
|
act(() => result.current.handleOpenFileFromChat("README.md", REPO, true));
|
|
|
|
expect(fetchAndOpenFileMock).toHaveBeenCalledWith(
|
|
"s1",
|
|
"README.md",
|
|
expect.any(Function),
|
|
expect.any(Function),
|
|
{ repo: REPO, signal: expect.objectContaining({ aborted: false }) },
|
|
);
|
|
|
|
const openFile = fetchAndOpenFileMock.mock.calls[0]?.[2] as (file: OpenFileTab) => void;
|
|
act(() => openFile({ ...MOCK_FILE, path: "README.md", name: "README.md" }));
|
|
|
|
expect(result.current.selectedFile).toMatchObject({ path: "README.md" });
|
|
expect(result.current.selectedFilePreview).toBe(true);
|
|
expect(handlePanelChange).toHaveBeenCalledWith("files");
|
|
});
|
|
|
|
it("passes repo through when opening a walkthrough file from mobile", () => {
|
|
const { result } = renderHandlers();
|
|
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH, REPO));
|
|
|
|
expect(fetchAndOpenFileMock).toHaveBeenCalledWith(
|
|
"s1",
|
|
CHAT_LINK_PATH,
|
|
expect.any(Function),
|
|
expect.any(Function),
|
|
{ repo: REPO, signal: expect.objectContaining({ aborted: false }) },
|
|
);
|
|
});
|
|
|
|
it("handleOpenFileFromChat no-ops when no active session", () => {
|
|
const { result } = renderHandlers(null);
|
|
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
|
|
|
|
expect(fetchAndOpenFileMock).not.toHaveBeenCalled();
|
|
expect(result.current.selectedFile).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("useMobilePanelHandlers selection state", () => {
|
|
beforeEach(() => {
|
|
fetchAndOpenFileMock.mockReset();
|
|
});
|
|
|
|
it("handlePanelChangeAndClearSheet clears the viewer when switching panels", () => {
|
|
const { result, handlePanelChange } = renderHandlers();
|
|
act(() => result.current.handleOpenFile(MOCK_FILE));
|
|
expect(result.current.selectedFile).toEqual(MOCK_FILE);
|
|
|
|
act(() => result.current.handlePanelChangeAndClearSheet("plan"));
|
|
|
|
expect(result.current.selectedFile).toBeNull();
|
|
expect(handlePanelChange).toHaveBeenCalledWith("plan");
|
|
});
|
|
|
|
it("clears selectedFile when effectiveSessionId changes", () => {
|
|
const { result, rerender } = renderHandlers();
|
|
act(() => result.current.handleOpenFile(MOCK_FILE));
|
|
expect(result.current.selectedFile).toEqual(MOCK_FILE);
|
|
|
|
rerender({ sid: "s2" });
|
|
expect(result.current.selectedFile).toBeNull();
|
|
});
|
|
|
|
it("keeps selectedFile when rerendered with same session", () => {
|
|
const { result, rerender } = renderHandlers();
|
|
act(() => result.current.handleOpenFile(MOCK_FILE));
|
|
rerender({ sid: "s1" });
|
|
expect(result.current.selectedFile).toEqual(MOCK_FILE);
|
|
});
|
|
|
|
it("rejects stale handleOpenFileFromChat callback after session change", () => {
|
|
const { result, rerender } = renderHandlers();
|
|
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
|
|
|
|
expect(fetchAndOpenFileMock).toHaveBeenCalledWith(
|
|
"s1",
|
|
CHAT_LINK_PATH,
|
|
expect.any(Function),
|
|
expect.any(Function),
|
|
{ repo: undefined, signal: expect.objectContaining({ aborted: false }) },
|
|
);
|
|
|
|
// Simulate session switch before the async callback fires
|
|
rerender({ sid: "s2" });
|
|
expect(result.current.selectedFile).toBeNull();
|
|
|
|
// Invoke the stale callback that was registered for session s1
|
|
const staleCallback = fetchAndOpenFileMock.mock.calls[0]?.[2] as (file: OpenFileTab) => void;
|
|
act(() => staleCallback(MOCK_FILE));
|
|
|
|
// Should still be null because the callback belongs to the old session
|
|
expect(result.current.selectedFile).toBeNull();
|
|
});
|
|
|
|
it("latest handleOpenFile call wins", () => {
|
|
const { result } = renderHandlers();
|
|
act(() => {
|
|
result.current.handleOpenFile(MOCK_FILE);
|
|
result.current.handleOpenFile(OTHER_FILE);
|
|
});
|
|
expect(result.current.selectedFile).toEqual(OTHER_FILE);
|
|
});
|
|
});
|
|
|
|
describe("useMobilePanelHandlers request cancellation", () => {
|
|
beforeEach(() => {
|
|
fetchAndOpenFileMock.mockReset();
|
|
});
|
|
|
|
it("aborts stale chat file requests when a newer one starts", () => {
|
|
const { result } = renderHandlers();
|
|
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
|
|
const firstOptions = fetchAndOpenFileMock.mock.calls[0]?.[4] as { signal: AbortSignal };
|
|
|
|
act(() => result.current.handleOpenFileFromChat("src/newer.ts"));
|
|
const secondOptions = fetchAndOpenFileMock.mock.calls[1]?.[4] as { signal: AbortSignal };
|
|
|
|
expect(firstOptions.signal.aborted).toBe(true);
|
|
expect(secondOptions.signal.aborted).toBe(false);
|
|
});
|
|
|
|
it("aborts stale chat file requests when the session changes", () => {
|
|
const { result, rerender } = renderHandlers();
|
|
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
|
|
const firstOptions = fetchAndOpenFileMock.mock.calls[0]?.[4] as { signal: AbortSignal };
|
|
|
|
rerender({ sid: "s2" });
|
|
|
|
expect(firstOptions.signal.aborted).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("resolveMobileReviewSource", () => {
|
|
it("prefers GitHub when a task has both review providers", () => {
|
|
expect(resolveMobileReviewSource(true, true)).toBe("github");
|
|
});
|
|
|
|
it("makes GitHub Review available without a GitLab MR", () => {
|
|
expect(resolveMobileReviewSource(true, false)).toBe("github");
|
|
});
|
|
|
|
it("keeps GitLab Review when no GitHub PR exists", () => {
|
|
expect(resolveMobileReviewSource(false, true)).toBe("gitlab");
|
|
});
|
|
});
|
|
|
|
describe("resolveMobilePluginPanel", () => {
|
|
const panel = "plugin:plugin-a:notes" as const;
|
|
|
|
function lifecycle(status: PluginLifecycleSnapshot["status"]): PluginLifecycleSnapshot {
|
|
return { status, generation: 3 };
|
|
}
|
|
|
|
it("preserves the selected panel while its plugin is loading or recovering", () => {
|
|
expect(resolveMobilePluginPanel(panel, lifecycle("loading"), false)).toBe(panel);
|
|
expect(resolveMobilePluginPanel(panel, lifecycle("failed"), false)).toBe(panel);
|
|
});
|
|
|
|
it("falls back to Chat only after definitive removal or a ready missing registration", () => {
|
|
expect(resolveMobilePluginPanel(panel, lifecycle("removed"), false)).toBe("chat");
|
|
expect(resolveMobilePluginPanel(panel, lifecycle("ready"), false)).toBe("chat");
|
|
});
|
|
|
|
it("keeps a ready selected panel when its registration is present", () => {
|
|
expect(resolveMobilePluginPanel(panel, lifecycle("ready"), true)).toBe(panel);
|
|
});
|
|
});
|
|
|
|
describe("MobilePanelArea PR identity", () => {
|
|
it("remounts PR detail feedback before the selected PR changes", () => {
|
|
function MobileReviewHarness() {
|
|
const [prKey, setPrKey] = useState("pr-a");
|
|
return (
|
|
<MobilePanelArea
|
|
currentMobilePanel="review"
|
|
activeTaskId="task-1"
|
|
isPassthroughMode={false}
|
|
effectiveSessionId="session-1"
|
|
selectedFile={null}
|
|
selectedFilePreview={false}
|
|
selectedDiff={null}
|
|
handleOpenFileFromChat={vi.fn()}
|
|
handleClearSelectedDiff={vi.fn()}
|
|
handleOpenFile={vi.fn()}
|
|
handlePanelChangeAndClearSheet={vi.fn()}
|
|
topNavHeight="3.5rem"
|
|
bottomNavHeight="3.25rem"
|
|
reviewSource="github"
|
|
prKey={prKey}
|
|
reviewPRs={[]}
|
|
selectedReviewPR={null}
|
|
onSelectReviewPR={() => setPrKey("pr-b")}
|
|
/>
|
|
);
|
|
}
|
|
|
|
render(<MobileReviewHarness />);
|
|
expect(screen.getByRole("button", { name: "feedback for pr-a" })).not.toBeNull();
|
|
|
|
act(() => screen.getByRole("button", { name: "Select PR B" }).click());
|
|
|
|
expect(screen.queryByRole("button", { name: "feedback for pr-a" })).toBeNull();
|
|
expect(screen.getByRole("button", { name: "feedback for pr-b" })).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
function renderMobilePanel(currentMobilePanel: string) {
|
|
return render(
|
|
<MobilePanelArea
|
|
currentMobilePanel={currentMobilePanel as never}
|
|
activeTaskId="task-1"
|
|
isPassthroughMode={false}
|
|
effectiveSessionId="session-1"
|
|
selectedFile={null}
|
|
selectedFilePreview={false}
|
|
selectedDiff={null}
|
|
handleOpenFileFromChat={vi.fn()}
|
|
handleClearSelectedDiff={vi.fn()}
|
|
handleOpenFile={vi.fn()}
|
|
handlePanelChangeAndClearSheet={vi.fn()}
|
|
topNavHeight="3.5rem"
|
|
bottomNavHeight="3.25rem"
|
|
reviewSource={null}
|
|
reviewPRs={[]}
|
|
selectedReviewPR={null}
|
|
onSelectReviewPR={vi.fn()}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
describe("MobilePanelArea — plugin task panel (AC7)", () => {
|
|
afterEach(() => {
|
|
pluginRegistry.unregisterPlugin("kandev-plugin-notes");
|
|
});
|
|
|
|
it("renders the plugin's Component with mobile presentation when selected", () => {
|
|
function Notes(props: { panelId: string; taskId: string; presentation: string }) {
|
|
return (
|
|
<div data-testid="notes-mobile-body">
|
|
{props.panelId}|{props.taskId}|{props.presentation}
|
|
</div>
|
|
);
|
|
}
|
|
pluginRegistry
|
|
.forPlugin("kandev-plugin-notes")
|
|
.registerTaskPanel({ id: "notes", title: "Notes", Component: Notes, mobileEnabled: true });
|
|
|
|
renderMobilePanel("plugin:kandev-plugin-notes:notes");
|
|
|
|
expect(screen.getByTestId("notes-mobile-body").textContent).toBe(
|
|
"plugin:kandev-plugin-notes:notes|task-1|mobile",
|
|
);
|
|
});
|
|
|
|
it("renders nothing for a panel id that isn't a plugin id", () => {
|
|
renderMobilePanel("not-a-plugin-panel-id");
|
|
expect(screen.queryByTestId("notes-mobile-body")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("terminalPaddingBottom", () => {
|
|
it("pads by the keybar height alone when the keyboard is closed", () => {
|
|
expect(terminalPaddingBottom(false, 0, "3.25rem")).toBe("48px");
|
|
// bottomOffset is irrelevant while the keyboard is closed.
|
|
expect(terminalPaddingBottom(false, 300, "3.25rem")).toBe("48px");
|
|
});
|
|
|
|
it("subtracts the bottom nav and adds the live keyboard offset when the keyboard is open", () => {
|
|
expect(terminalPaddingBottom(true, 300, "3.25rem")).toBe(
|
|
"calc(348px - 3.25rem - env(safe-area-inset-bottom, 0px))",
|
|
);
|
|
});
|
|
});
|