Files
kandev/apps/web/components/create-local-repository-surface.test.tsx
2026-09-12 16:29:16 +01:00

332 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Repository } from "@/lib/types/http";
const mocks = vi.hoisted(() => ({
initialize: vi.fn(),
createDirectory: vi.fn(),
listDirectory: vi.fn(),
isMobile: false,
}));
vi.mock("@/lib/api/domains/workspace-api", () => ({
initializeLocalRepository: mocks.initialize,
}));
vi.mock("@/lib/api/domains/fs-api", () => ({
createDirectory: mocks.createDirectory,
listDirectory: mocks.listDirectory,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: mocks.isMobile }),
}));
import {
buildLocalRepositoryTargetPath,
CreateLocalRepositorySurface,
validateLocalRepositoryName,
} from "./create-local-repository-surface";
const REPOSITORY_NAME = "alpha";
const REPOSITORY_NAME_LABEL = "Repository name";
const PARENT_DIRECTORY_LABEL = "Parent directory";
const CREATE_BUTTON_NAME = "Create repository";
const PROJECTS_PATH = "/work/projects";
const createdRepository = {
id: "repo-new",
workspace_id: "ws-1",
name: REPOSITORY_NAME,
source_type: "local",
local_path: "/work/alpha",
default_branch: "main",
created_at: "",
updated_at: "",
} as Repository;
const directLocalSelection = {
executorId: "local",
executorProfileId: "local-profile",
executorProfileName: "This computer",
requiresSwitch: true,
};
function renderSurface(
overrides: Partial<React.ComponentProps<typeof CreateLocalRepositorySurface>> = {},
) {
const props: React.ComponentProps<typeof CreateLocalRepositorySurface> = {
open: true,
onOpenChange: vi.fn(),
workspaceId: "ws-1",
executorSelection: directLocalSelection,
onCreated: vi.fn(),
...overrides,
};
render(<CreateLocalRepositorySurface {...props} />);
return props;
}
beforeEach(() => {
mocks.isMobile = false;
mocks.initialize.mockReset();
mocks.createDirectory.mockReset();
mocks.listDirectory.mockReset();
mocks.listDirectory.mockResolvedValue({ path: "/work", parent: "/", entries: [] });
});
afterEach(cleanup);
describe("local repository form helpers", () => {
it.each(["", ".", "..", "nested/name", "nested\\name", "name\0with-null"])(
"rejects invalid repository name %j",
(name) => {
expect(validateLocalRepositoryName(name)).not.toBeNull();
},
);
it("trims a valid name and derives its target path", () => {
expect(validateLocalRepositoryName(` ${REPOSITORY_NAME} `)).toBeNull();
expect(buildLocalRepositoryTargetPath("/work/projects/", ` ${REPOSITORY_NAME} `)).toBe(
"/work/projects/alpha",
);
});
});
describe("CreateLocalRepositorySurface", () => {
it("shows the target and initializes the repository immediately", async () => {
mocks.initialize.mockResolvedValue(createdRepository);
const props = renderSurface();
const nameInput = await screen.findByLabelText(REPOSITORY_NAME_LABEL);
fireEvent.change(nameInput, { target: { value: REPOSITORY_NAME } });
expect(screen.getByText("/work/alpha")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => {
expect(mocks.initialize).toHaveBeenCalledWith("ws-1", {
name: REPOSITORY_NAME,
parentPath: "/work",
});
});
expect(props.onCreated).toHaveBeenCalledWith(createdRepository);
});
it("submits a manually entered parent directory without browsing to it", async () => {
mocks.initialize.mockResolvedValue(createdRepository);
renderSurface();
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
target: { value: REPOSITORY_NAME },
});
fireEvent.change(screen.getByLabelText(PARENT_DIRECTORY_LABEL), {
target: { value: "/work/manual-parent" },
});
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => {
expect(mocks.initialize).toHaveBeenCalledWith("ws-1", {
name: REPOSITORY_NAME,
parentPath: "/work/manual-parent",
});
});
});
it("updates the parent directory input when browsing folders", async () => {
mocks.listDirectory.mockImplementation(async (path: string) => {
if (path === "") {
return {
path: "/work",
parent: "/",
entries: [{ name: "projects", path: PROJECTS_PATH }],
};
}
return { path, parent: "/work", entries: [] };
});
renderSurface();
fireEvent.click(await screen.findByTestId("folder-picker-entry"));
await waitFor(() => {
expect((screen.getByLabelText(PARENT_DIRECTORY_LABEL) as HTMLInputElement).value).toBe(
PROJECTS_PATH,
);
});
});
it("creates a folder from the navigator and enters it", async () => {
mocks.createDirectory.mockResolvedValue({ path: PROJECTS_PATH });
mocks.listDirectory.mockImplementation(async (path: string) => ({
path: path || "/work",
parent: "/",
entries: [],
choosable: true,
}));
renderSurface();
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "New folder" }) as HTMLButtonElement).disabled,
).toBe(false),
);
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
fireEvent.change(screen.getByRole("textbox", { name: "New folder name" }), {
target: { value: "projects" },
});
fireEvent.click(screen.getByRole("button", { name: "Create folder" }));
await waitFor(() => {
expect(mocks.createDirectory).toHaveBeenCalledWith("/work", "projects");
expect((screen.getByLabelText(PARENT_DIRECTORY_LABEL) as HTMLInputElement).value).toBe(
PROJECTS_PATH,
);
});
});
});
describe("CreateLocalRepositorySurface async creation", () => {
it("delivers a delayed response to the current row handler", async () => {
let complete!: (repository: Repository) => void;
mocks.initialize.mockReturnValue(
new Promise<Repository>((resolve) => {
complete = resolve;
}),
);
const previousHandler = vi.fn();
const currentHandler = vi.fn();
const props = {
open: true,
onOpenChange: vi.fn(),
workspaceId: "ws-1",
executorSelection: directLocalSelection,
onCreated: previousHandler,
};
const { rerender } = render(<CreateLocalRepositorySurface {...props} />);
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
target: { value: REPOSITORY_NAME },
});
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => expect(mocks.initialize).toHaveBeenCalledOnce());
rerender(<CreateLocalRepositorySurface {...props} onCreated={currentHandler} />);
complete(createdRepository);
await waitFor(() => expect(previousHandler).toHaveBeenCalledWith(createdRepository));
expect(currentHandler).not.toHaveBeenCalled();
});
it("creates for a multi-row task without a direct-local profile", async () => {
mocks.initialize.mockResolvedValue(createdRepository);
const props = renderSurface({ context: "task-create-multi", executorSelection: null });
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
target: { value: REPOSITORY_NAME },
});
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => expect(props.onCreated).toHaveBeenCalledWith(createdRepository));
});
it("keeps the surface open when the completion is no longer current", async () => {
mocks.initialize.mockResolvedValue(createdRepository);
const onOpenChange = vi.fn();
const onCreated = vi.fn(() => false);
renderSurface({ onOpenChange, onCreated });
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
target: { value: REPOSITORY_NAME },
});
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => expect(onCreated).toHaveBeenCalledWith(createdRepository));
expect(onOpenChange).not.toHaveBeenCalled();
});
});
describe("CreateLocalRepositorySurface submission", () => {
it("does not submit the parent task form", async () => {
mocks.initialize.mockResolvedValue(createdRepository);
const parentSubmit = vi.fn((event: React.FormEvent) => event.preventDefault());
const props: React.ComponentProps<typeof CreateLocalRepositorySurface> = {
open: true,
onOpenChange: vi.fn(),
workspaceId: "ws-1",
executorSelection: directLocalSelection,
onCreated: vi.fn(),
};
render(
<form onSubmit={parentSubmit}>
<CreateLocalRepositorySurface {...props} />
</form>,
);
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
target: { value: REPOSITORY_NAME },
});
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => expect(props.onCreated).toHaveBeenCalledWith(createdRepository));
expect(parentSubmit).not.toHaveBeenCalled();
});
it("retains entered values and allows retry after a conflict", async () => {
mocks.initialize
.mockRejectedValueOnce(new Error("A file or folder already exists at that path"))
.mockResolvedValueOnce(createdRepository);
renderSurface();
const nameInput = await screen.findByLabelText(REPOSITORY_NAME_LABEL);
fireEvent.change(nameInput, { target: { value: REPOSITORY_NAME } });
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
expect((await screen.findByRole("alert")).textContent).toContain("already exists");
expect((nameInput as HTMLInputElement).value).toBe(REPOSITORY_NAME);
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => expect(mocks.initialize).toHaveBeenCalledTimes(2));
});
it("clears a submission error when the user corrects the form", async () => {
mocks.initialize.mockRejectedValue(new Error("parent directory cannot be accessed"));
renderSurface();
const nameInput = await screen.findByLabelText(REPOSITORY_NAME_LABEL);
fireEvent.change(nameInput, { target: { value: REPOSITORY_NAME } });
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
expect(await screen.findByRole("alert")).toBeTruthy();
fireEvent.change(screen.getByLabelText(PARENT_DIRECTORY_LABEL), {
target: { value: "/work/new-parent" },
});
expect(screen.queryByRole("alert")).toBeNull();
});
it("blocks initialization when no direct-local profile exists", async () => {
renderSurface({ executorSelection: null });
const nameInput = await screen.findByLabelText(REPOSITORY_NAME_LABEL);
fireEvent.change(nameInput, { target: { value: REPOSITORY_NAME } });
expect(screen.getByText(/direct local executor profile is required/i)).toBeTruthy();
expect(
(screen.getByRole("button", { name: CREATE_BUTTON_NAME }) as HTMLButtonElement).disabled,
).toBe(true);
expect(mocks.initialize).not.toHaveBeenCalled();
});
it("allows workspace repository creation without changing an executor", async () => {
mocks.initialize.mockResolvedValue(createdRepository);
const props = renderSurface({ context: "workspace", executorSelection: null });
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
target: { value: REPOSITORY_NAME },
});
expect(screen.getByText(/registers it in this workspace/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
await waitFor(() => expect(props.onCreated).toHaveBeenCalledWith(createdRepository));
});
it("uses a mobile drawer instead of the desktop dialog on phones", async () => {
mocks.isMobile = true;
renderSurface();
expect(await screen.findByTestId("create-local-repository-drawer")).toBeTruthy();
expect(screen.queryByTestId("create-local-repository-dialog")).toBeNull();
});
});