mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* 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.
407 lines
12 KiB
TypeScript
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");
|
|
});
|
|
});
|