Files
paseo/packages/app/src/screens/projects-screen.test.tsx
T
Mohamed Boudra ae80ec4252 Let plugins contribute settings screens (#4357)
* feat(plugins): contribute settings screens and shared controls

* fix(plugins): localize settings and verify shared UI consumers

* test(composer): unmount draft test roots after each case

* test(app): keep launch terminals alive until cleanup

The fixed ten-second profile lifetime could expire before a busy CI browser attached or finished revisiting the composer. Keep the real process waiting for input until project teardown terminates it.

* test(server): await the initial snapshot before a buffered ref event

Watcher setup can read checkout facts before the initial refresh publishes a snapshot. Wait for publication before injecting the event so the test consistently exercises a second refresh.
2026-09-06 10:22:26 +02:00

407 lines
12 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects";
import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects";
const { projectsState, push } = vi.hoisted(() => ({
projectsState: {
current: {
projects: [],
hostErrors: [],
isLoading: false,
isFetching: false,
refetch: vi.fn(),
} as UseProjectsResult,
},
push: vi.fn(),
}));
vi.mock("react-native", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-native")>();
const passthrough = ({
children,
testID,
accessibilityLabel,
accessibilityRole,
onPress,
onHoverIn,
onHoverOut,
...rest
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
accessibilityRole?: string;
onPress?: (event: { stopPropagation: () => void }) => void;
onHoverIn?: () => void;
onHoverOut?: () => void;
} & Record<string, unknown>) => {
const dataAttrs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rest)) {
if (key.startsWith("data-")) {
dataAttrs[key] = value;
}
}
return React.createElement(
"div",
{
role: accessibilityRole,
"aria-label": accessibilityLabel,
"data-testid": testID,
onClick: onPress
? (event: React.MouseEvent) => {
onPress({ stopPropagation: () => event.stopPropagation() });
}
: undefined,
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
...dataAttrs,
},
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
);
};
return {
...actual,
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children),
Text: ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children),
Pressable: passthrough,
Image: ({ source }: { source?: { uri?: string } }) =>
React.createElement("img", { src: source?.uri ?? "" }),
Platform: {
OS: "web",
select: <T,>(options: { web?: T; default?: T }) => options.web ?? options.default,
},
};
});
vi.mock("lucide-react-native", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react-native")>();
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
...actual,
ChevronRight: icon("ChevronRight"),
MoreVertical: icon("MoreVertical"),
ExternalLink: icon("ExternalLink"),
Pencil: icon("Pencil"),
FolderGit2: icon("FolderGit2"),
};
});
vi.mock("expo-router", () => ({
router: { push },
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) => {
if (key === "sidebar.project.empty.title") return "No projects yet";
if (key === "settings.projectList.hostLoadFailed") {
return `Couldn't load projects from host ${values?.hostName}: ${values?.message}`;
}
if (key === "settings.projectList.editProject") return `Edit ${values?.projectName}`;
return key;
},
}),
}));
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: ({ size }: { size?: string | number }) =>
React.createElement("span", {
"data-testid": "projects-loading-spinner",
"data-size": size,
}),
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "dropdown-menu" }, children),
DropdownMenuTrigger: ({
children,
accessibilityLabel,
testID,
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
accessibilityLabel?: string;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"aria-label": accessibilityLabel,
"data-testid": testID,
onClick: (event: React.MouseEvent) => event.stopPropagation(),
},
typeof children === "function"
? children({ pressed: false, hovered: false, open: false })
: children,
),
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "dropdown-menu-content" }, children),
DropdownMenuItem: ({
children,
onSelect,
testID,
}: {
children?: React.ReactNode;
onSelect?: () => void;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"data-testid": testID,
onClick: (event: React.MouseEvent) => {
event.stopPropagation();
onSelect?.();
},
},
children,
),
}));
vi.mock("@/hooks/use-projects", () => ({
useProjects: () => projectsState.current,
}));
vi.mock("@/projects/icons", () => ({
useProjectIcons: () => new Map(),
}));
import ProjectsScreen from "./projects-screen";
function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceSummary {
return {
id: "ws-1",
name: "main",
workspaceKind: "directory",
status: "done",
currentBranch: "main",
changeRequestNumber: null,
...overrides,
};
}
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
return {
serverId: "host-a",
projectId: "project-a",
projectName: "Project",
projectCustomName: null,
serverName: "alpha",
isOnline: true,
repoRoot: "/home/me/proj",
workspaceCount: 1,
workspaces: [workspaceSummary()],
...overrides,
};
}
function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
const hosts = overrides.hosts ?? [hostEntry()];
const totalWorkspaceCount =
overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length;
return {
viewKey: "remote:github.com/acme/app",
projectName: "acme/app",
hosts,
totalWorkspaceCount,
hostCount: hosts.length,
onlineHostCount,
githubUrl: "https://github.com/acme/app",
...overrides,
};
}
function setProjectsState(overrides: Partial<UseProjectsResult>) {
projectsState.current = {
projects: [],
hostErrors: [],
isLoading: false,
isFetching: false,
refetch: vi.fn(),
...overrides,
};
}
function findRow(container: HTMLElement, projectKey: string): HTMLElement {
const row = container.querySelector<HTMLElement>(`[data-testid="project-row-${projectKey}"]`);
if (!row) throw new Error(`Expected row for ${projectKey}`);
return row;
}
describe("ProjectsScreen", () => {
let container: HTMLElement | null = null;
let root: Root | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
setProjectsState({});
push.mockReset();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function render(serverId = "host-a") {
act(() => {
root?.render(<ProjectsScreen serverId={serverId} />);
});
}
it("renders a row whose visible content is the project name only", () => {
setProjectsState({
projects: [
project({
projectName: "acme/app",
hosts: [hostEntry({ projectName: "acme/app", serverName: "alpha", workspaceCount: 5 })],
}),
],
});
render();
const rows = container?.querySelectorAll('[data-testid^="project-row-"]') ?? [];
expect(rows.length).toBe(1);
expect(container?.textContent).toContain("acme/app");
expect(container?.textContent).not.toContain("workspace");
expect(container?.textContent).not.toContain("offline");
expect(container?.textContent).not.toContain("github.com");
});
it("navigates to the project detail route when the row is pressed", () => {
setProjectsState({
projects: [project({ viewKey: "remote:github.com/acme/app" })],
});
render();
const row = findRow(container!, "remote:github.com/acme/app");
act(() => {
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
expect(push).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledWith("/settings/hosts/host-a/projects/project-a");
});
it("does not render a kebab menu on the row", () => {
setProjectsState({
projects: [project({ viewKey: "remote:github.com/acme/app" })],
});
render();
expect(
container?.querySelector('[data-testid="project-row-menu-remote:github.com/acme/app"]'),
).toBeNull();
});
it("renders a centered loading spinner before the first response", () => {
setProjectsState({ isLoading: true, projects: [] });
render();
expect(container?.querySelector('[data-testid="projects-loading-spinner"]')).not.toBeNull();
});
it("renders the empty state when there are no projects", () => {
setProjectsState({ projects: [] });
render();
expect(container?.textContent).toContain("No projects yet");
expect(container?.textContent).not.toContain("Non-GitHub remote projects aren't supported yet");
});
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
const hostErrors: ProjectHostError[] = [
{ serverId: "a", serverName: "alpha", message: "timed out" },
{ serverId: "b", serverName: "beta", message: "unreachable" },
];
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a" })] })],
hostErrors,
});
render("a");
const banner = container?.querySelector('[data-testid="projects-host-errors"]');
expect(banner).not.toBeNull();
expect(banner?.textContent).toContain("alpha");
expect(banner?.textContent).not.toContain("beta");
expect(container?.querySelector('[data-testid^="project-row-"]')).not.toBeNull();
});
it("shows only projects belonging to the selected host", () => {
setProjectsState({
projects: [
project({
viewKey: "host-a-project",
projectName: "Host A",
hosts: [hostEntry({ serverId: "host-a", projectId: "project-a", projectName: "Host A" })],
}),
project({
viewKey: "host-b-project",
projectName: "Host B",
hosts: [hostEntry({ serverId: "host-b", projectId: "project-b", projectName: "Host B" })],
}),
],
});
render("host-b");
expect(container?.textContent).not.toContain("Host A");
expect(container?.textContent).toContain("Host B");
});
it("does not include the word 'checkout' anywhere in the rendered tree", () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", serverName: "alpha", workspaceCount: 3 }),
hostEntry({ serverId: "b", serverName: "beta", workspaceCount: 2 }),
],
}),
],
hostErrors: [{ serverId: "x", serverName: "x", message: "down" }],
});
render("a");
const html = container?.innerHTML.toLowerCase() ?? "";
expect(html).not.toContain("checkout");
});
});