Files
roboco/panel/src/components/projects/__tests__/edit-project-dialog.test.tsx
T
Renn F 5ca8a9c4a6 fix(github-app): PAT fallback covers every mint failure; video/motion/x cleanups
mint_installation_token now wraps JWT build + HTTP + parsing so raw
httpx/jwt failures surface as GitHubAppError and the existing PAT
fallback catches them all (a GitHub outage no longer crashes git
operations for App-bound projects). The PEM is validated at
credential-set time instead of first mint, and the installation-token
cache is cleared when credentials are deleted. Also: the video
preview root resolves symlinks like its sibling route (frames under a
symlinked workspaces_root no longer false-404), the tracked dangling
motion/node_modules symlink is removed and the gitignore gains a
slash-less entry that actually matches symlinks, changelog caption
input strips GHSA refs like PR refs, and the edit-project dialog hides
the GitHub App section for auto-detected gitlab.com projects and warns
before a save that would clear both auth sources.
2026-07-22 03:31:12 +02:00

270 lines
8.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import React from "react";
import { Team } from "@/types";
import type { Project } from "@/types";
// jsdom has no ResizeObserver; Radix Switch (the always-rendered "Active"
// toggle) measures its thumb via one on mount — mirrors
// a2a-conversation-list.test.tsx's stub.
if (typeof window !== "undefined" && !window.ResizeObserver) {
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
// Radix Select's SelectValue sizing hook calls ResizeObserver, absent in
// jsdom — mirrors select-repo-picker.test.tsx's functional replacement
// (SelectItem wired to onValueChange via context) so the dialog's Forge /
// Assigned Cell selects mount without crashing.
vi.mock("@/components/ui/select", () => {
const Ctx = React.createContext<(v: string) => void>(() => {});
return {
Select: ({
onValueChange,
children,
}: {
onValueChange?: (v: string) => void;
children: React.ReactNode;
}) => (
<Ctx.Provider value={onValueChange ?? (() => {})}>
{children}
</Ctx.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => {
const onValueChange = React.useContext(Ctx);
return (
<button type="button" onClick={() => onValueChange(value)}>
{children}
</button>
);
},
};
});
const { useProject, useUpdateProject, mutateAsync } = vi.hoisted(() => ({
useProject: vi.fn(),
useUpdateProject: vi.fn(),
mutateAsync: vi.fn(),
}));
vi.mock("@/hooks/use-projects", () => ({ useProject, useUpdateProject }));
const {
getCredentialsStatus,
listInstallations,
listInstallationRepositories,
} = vi.hoisted(() => ({
getCredentialsStatus: vi.fn(async () => ({ has_credentials: true })),
listInstallations: vi.fn(async () => [{ id: 42, account_login: "acme" }]),
listInstallationRepositories: vi.fn(async () => [
{
full_name: "acme/widgets",
clone_url: "https://github.com/acme/widgets.git",
private: false,
},
]),
}));
vi.mock("@/lib/api", () => ({
githubAppApi: {
getCredentialsStatus,
listInstallations,
listInstallationRepositories,
},
}));
import { EditProjectDialog } from "../edit-project-dialog";
function makeProject(overrides: Partial<Project> = {}): Project {
return {
id: "proj-1",
name: "RoboCo API",
slug: "roboco-api",
git_url: "https://github.com/org/repo.git",
git_provider: "github",
github_installation_id: null,
default_branch: "main",
environments: null,
protected_branches: ["main"],
assigned_cell: Team.BACKEND,
has_git_token: true,
is_active: true,
test_command: null,
lint_command: null,
format_command: null,
typecheck_command: null,
build_command: null,
quality_command: null,
codegen_command: null,
ci_watch_enabled: false,
ci_watch_workflow: null,
video_engine_enabled: false,
dep_update_command: null,
dep_update_paths: null,
sandbox_services: null,
sandbox_extensions: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
created_by: "ceo",
created_at: "2026-01-01T00:00:00Z",
updated_at: null,
...overrides,
};
}
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
function renderDialog(project: Project) {
useProject.mockReturnValue({ data: project, isLoading: false });
return render(
withQueryClient(
<EditProjectDialog projectId={project.id} open onOpenChange={vi.fn()} />,
),
);
}
describe("EditProjectDialog — GitHub App binding", () => {
beforeEach(() => {
vi.clearAllMocks();
getCredentialsStatus.mockResolvedValue({ has_credentials: true });
listInstallations.mockResolvedValue([{ id: 42, account_login: "acme" }]);
listInstallationRepositories.mockResolvedValue([
{
full_name: "acme/widgets",
clone_url: "https://github.com/acme/widgets.git",
private: false,
},
]);
mutateAsync.mockResolvedValue(makeProject());
useUpdateProject.mockReturnValue({ mutateAsync, isPending: false });
});
it("shows the PAT state and a repo picker when the App is configured", async () => {
renderDialog(makeProject());
expect(
await screen.findByText("Using personal access token"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Select repo/i }),
).toBeInTheDocument();
});
it("shows the current binding and an Unbind button when already bound", async () => {
renderDialog(makeProject({ github_installation_id: 42 }));
expect(
await screen.findByText("Using GitHub App (installation #42)"),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Unbind/i })).toBeInTheDocument();
});
it("shows a muted note and no picker when the App is not configured", async () => {
getCredentialsStatus.mockResolvedValue({ has_credentials: false });
renderDialog(makeProject());
expect(
await screen.findByText(/Configure the GitHub App/i),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Select repo/i }),
).not.toBeInTheDocument();
});
it("hides the picker for a non-GitHub forge provider", async () => {
renderDialog(makeProject({ git_provider: "gitlab" }));
expect(
await screen.findByText(/App auth is GitHub-only/i),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Select repo/i }),
).not.toBeInTheDocument();
});
it("hides the picker for an auto-detected gitlab.com URL (git_provider stored as null)", async () => {
renderDialog(
makeProject({
git_provider: null,
git_url: "https://gitlab.com/acme/widgets.git",
}),
);
expect(
await screen.findByText(/App auth is GitHub-only/i),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Select repo/i }),
).not.toBeInTheDocument();
});
it("warns when saving would clear both the App binding and the PAT", async () => {
renderDialog(
makeProject({ github_installation_id: null, has_git_token: true }),
);
expect(
screen.queryByText(/no git credentials at all/i),
).not.toBeInTheDocument();
fireEvent.click(await screen.findByRole("switch", { name: /clear token/i }));
expect(
await screen.findByText(/no git credentials at all/i),
).toBeInTheDocument();
});
it("binding via the repo picker sets github_installation_id in the submitted payload", async () => {
renderDialog(makeProject());
const pickButton = await screen.findByRole("button", {
name: /Select repo/i,
});
fireEvent.click(pickButton);
fireEvent.click(await screen.findByText("acme/widgets"));
fireEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
updates: { github_installation_id?: number | null };
};
expect(call.updates.github_installation_id).toBe(42);
});
it("unbinding sends an explicit null so the backend clears the stored installation", async () => {
renderDialog(makeProject({ github_installation_id: 42 }));
fireEvent.click(await screen.findByRole("button", { name: /Unbind/i }));
fireEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
updates: { github_installation_id?: number | null };
};
expect(call.updates.github_installation_id).toBeNull();
});
});