332 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|