2026-02-23 22:45:59 +00:00
|
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
2026-02-19 14:05:57 +00:00
|
|
|
import userEvent from "@testing-library/user-event";
|
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
import { api } from "./api/client";
|
|
|
|
|
|
|
|
|
|
vi.mock("./api/client", () => {
|
|
|
|
|
const api = {
|
|
|
|
|
getCurrentProject: vi.fn(),
|
|
|
|
|
getKnownProjects: vi.fn(),
|
|
|
|
|
getHomeDirectory: vi.fn(),
|
|
|
|
|
openProject: vi.fn(),
|
|
|
|
|
closeProject: vi.fn(),
|
|
|
|
|
forgetKnownProject: vi.fn(),
|
|
|
|
|
listDirectoryAbsolute: vi.fn(),
|
|
|
|
|
getOllamaModels: vi.fn(),
|
|
|
|
|
getAnthropicApiKeyExists: vi.fn(),
|
|
|
|
|
getAnthropicModels: vi.fn(),
|
|
|
|
|
getModelPreference: vi.fn(),
|
|
|
|
|
setModelPreference: vi.fn(),
|
|
|
|
|
cancelChat: vi.fn(),
|
|
|
|
|
setAnthropicApiKey: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
class ChatWebSocket {
|
|
|
|
|
connect() {}
|
|
|
|
|
close() {}
|
|
|
|
|
sendChat() {}
|
|
|
|
|
cancel() {}
|
|
|
|
|
}
|
|
|
|
|
return { api, ChatWebSocket };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
vi.mock("./api/workflow", () => {
|
|
|
|
|
return {
|
|
|
|
|
workflowApi: {
|
|
|
|
|
getAcceptance: vi.fn().mockResolvedValue({
|
|
|
|
|
can_accept: false,
|
|
|
|
|
reasons: [],
|
|
|
|
|
warning: null,
|
|
|
|
|
summary: { total: 0, passed: 0, failed: 0 },
|
|
|
|
|
missing_categories: [],
|
|
|
|
|
}),
|
|
|
|
|
getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }),
|
2026-02-19 15:51:12 +00:00
|
|
|
getUpcomingStories: vi.fn().mockResolvedValue({ stories: [] }),
|
2026-02-19 14:05:57 +00:00
|
|
|
recordTests: vi.fn(),
|
|
|
|
|
ensureAcceptance: vi.fn(),
|
|
|
|
|
getReviewQueue: vi.fn(),
|
2026-02-19 15:33:45 +00:00
|
|
|
collectCoverage: vi.fn(),
|
|
|
|
|
recordCoverage: vi.fn(),
|
|
|
|
|
getStoryTodos: vi.fn().mockResolvedValue({ stories: [] }),
|
2026-02-19 14:05:57 +00:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const mockedApi = vi.mocked(api);
|
|
|
|
|
|
|
|
|
|
describe("App", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.resetModules();
|
|
|
|
|
vi.clearAllMocks();
|
2026-02-26 16:18:47 +00:00
|
|
|
mockedApi.getCurrentProject.mockResolvedValue(null);
|
2026-02-19 14:05:57 +00:00
|
|
|
mockedApi.getKnownProjects.mockResolvedValue([]);
|
|
|
|
|
mockedApi.getHomeDirectory.mockResolvedValue("/home/user");
|
|
|
|
|
mockedApi.listDirectoryAbsolute.mockResolvedValue([]);
|
|
|
|
|
mockedApi.getOllamaModels.mockResolvedValue([]);
|
|
|
|
|
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
|
|
|
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
|
|
|
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function renderApp() {
|
|
|
|
|
const { default: App } = await import("./App");
|
|
|
|
|
return render(<App />);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 16:18:47 +00:00
|
|
|
it("calls getCurrentProject() on mount", async () => {
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedApi.getCurrentProject).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("skips selection screen and shows workspace when server already has a project open", async () => {
|
|
|
|
|
mockedApi.getCurrentProject.mockResolvedValue("/home/user/myproject");
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.queryByPlaceholderText(/\/path\/to\/project/i),
|
|
|
|
|
).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 14:05:57 +00:00
|
|
|
it("renders the selection screen when no project is open", async () => {
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("populates path input with home directory", async () => {
|
|
|
|
|
mockedApi.getHomeDirectory.mockResolvedValue("/Users/dave");
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const input = screen.getByPlaceholderText(
|
|
|
|
|
/\/path\/to\/project/i,
|
|
|
|
|
) as HTMLInputElement;
|
|
|
|
|
expect(input.value).toBe("/Users/dave/");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("opens project and shows chat view", async () => {
|
|
|
|
|
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(
|
|
|
|
|
/\/path\/to\/project/i,
|
|
|
|
|
) as HTMLInputElement;
|
|
|
|
|
await userEvent.clear(input);
|
|
|
|
|
await userEvent.type(input, "/home/user/myproject");
|
|
|
|
|
|
|
|
|
|
const openButton = screen.getByRole("button", { name: /open project/i });
|
|
|
|
|
await userEvent.click(openButton);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedApi.openProject).toHaveBeenCalledWith(
|
|
|
|
|
"/home/user/myproject",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows error when openProject fails", async () => {
|
|
|
|
|
mockedApi.openProject.mockRejectedValue(new Error("Path does not exist"));
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(
|
|
|
|
|
/\/path\/to\/project/i,
|
|
|
|
|
) as HTMLInputElement;
|
|
|
|
|
await userEvent.clear(input);
|
|
|
|
|
await userEvent.type(input, "/bad/path");
|
|
|
|
|
|
|
|
|
|
const openButton = screen.getByRole("button", { name: /open project/i });
|
|
|
|
|
await userEvent.click(openButton);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText(/Path does not exist/)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows known projects list", async () => {
|
|
|
|
|
mockedApi.getKnownProjects.mockResolvedValue([
|
|
|
|
|
"/home/user/project1",
|
|
|
|
|
"/home/user/project2",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTitle("/home/user/project1")).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByTitle("/home/user/project2")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-23 22:45:59 +00:00
|
|
|
|
|
|
|
|
it("shows error when path input is empty", async () => {
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(
|
|
|
|
|
/\/path\/to\/project/i,
|
|
|
|
|
) as HTMLInputElement;
|
|
|
|
|
await userEvent.clear(input);
|
|
|
|
|
|
|
|
|
|
const openButton = screen.getByRole("button", { name: /open project/i });
|
|
|
|
|
await userEvent.click(openButton);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByText(/Please enter a project path/i),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("calls forgetKnownProject and removes project from list", async () => {
|
|
|
|
|
mockedApi.getKnownProjects.mockResolvedValue(["/home/user/project1"]);
|
|
|
|
|
mockedApi.forgetKnownProject.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTitle("/home/user/project1")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const forgetButton = screen.getByRole("button", {
|
|
|
|
|
name: /Forget project1/i,
|
|
|
|
|
});
|
|
|
|
|
await userEvent.click(forgetButton);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedApi.forgetKnownProject).toHaveBeenCalledWith(
|
|
|
|
|
"/home/user/project1",
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
screen.queryByTitle("/home/user/project1"),
|
|
|
|
|
).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("closes project and returns to selection screen", async () => {
|
|
|
|
|
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
|
|
|
|
|
mockedApi.closeProject.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(
|
|
|
|
|
/\/path\/to\/project/i,
|
|
|
|
|
) as HTMLInputElement;
|
|
|
|
|
await userEvent.clear(input);
|
|
|
|
|
await userEvent.type(input, "/home/user/myproject");
|
|
|
|
|
|
|
|
|
|
const openButton = screen.getByRole("button", { name: /open project/i });
|
|
|
|
|
await userEvent.click(openButton);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedApi.openProject).toHaveBeenCalledWith(
|
|
|
|
|
"/home/user/myproject",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Chat view should appear with close button
|
|
|
|
|
const closeButton = await waitFor(() => screen.getByText("✕"));
|
|
|
|
|
await userEvent.click(closeButton);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedApi.closeProject).toHaveBeenCalled();
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles ArrowDown and ArrowUp keyboard navigation when suggestions are visible", async () => {
|
|
|
|
|
mockedApi.listDirectoryAbsolute.mockResolvedValue([
|
|
|
|
|
{ name: "projects", kind: "dir" },
|
|
|
|
|
{ name: "documents", kind: "dir" },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
// Wait for suggestions to appear after debounce
|
|
|
|
|
await waitFor(
|
|
|
|
|
() => {
|
|
|
|
|
expect(screen.getByText(/projects\//)).toBeInTheDocument();
|
|
|
|
|
},
|
|
|
|
|
{ timeout: 2000 },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(/\/path\/to\/project/i);
|
|
|
|
|
|
|
|
|
|
// ArrowDown with matchList present — moves selection forward
|
|
|
|
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
|
|
|
|
|
|
|
|
// ArrowUp with matchList present — moves selection backward
|
|
|
|
|
fireEvent.keyDown(input, { key: "ArrowUp" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles Tab keyboard navigation to accept suggestion", async () => {
|
|
|
|
|
mockedApi.listDirectoryAbsolute.mockResolvedValue([
|
|
|
|
|
{ name: "myrepo", kind: "dir" },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(
|
|
|
|
|
() => {
|
|
|
|
|
expect(screen.getByText(/myrepo\//)).toBeInTheDocument();
|
|
|
|
|
},
|
|
|
|
|
{ timeout: 2000 },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(/\/path\/to\/project/i);
|
|
|
|
|
|
|
|
|
|
// Tab with matchList present — accepts the selected match
|
|
|
|
|
fireEvent.keyDown(input, { key: "Tab" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles Escape key to close suggestions", async () => {
|
|
|
|
|
mockedApi.listDirectoryAbsolute.mockResolvedValue([
|
|
|
|
|
{ name: "workspace", kind: "dir" },
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(
|
|
|
|
|
() => {
|
|
|
|
|
expect(screen.getByText(/workspace\//)).toBeInTheDocument();
|
|
|
|
|
},
|
|
|
|
|
{ timeout: 2000 },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(/\/path\/to\/project/i);
|
|
|
|
|
|
|
|
|
|
// Escape closes suggestions
|
|
|
|
|
fireEvent.keyDown(input, { key: "Escape" });
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.queryByText(/workspace\//)).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles Enter key to trigger project open", async () => {
|
|
|
|
|
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
|
|
|
|
|
|
|
|
|
|
await renderApp();
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByPlaceholderText(/\/path\/to\/project/i),
|
|
|
|
|
).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(
|
|
|
|
|
/\/path\/to\/project/i,
|
|
|
|
|
) as HTMLInputElement;
|
|
|
|
|
await userEvent.clear(input);
|
|
|
|
|
await userEvent.type(input, "/home/user/myproject");
|
|
|
|
|
|
|
|
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockedApi.openProject).toHaveBeenCalledWith(
|
|
|
|
|
"/home/user/myproject",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-19 14:05:57 +00:00
|
|
|
});
|