Files
paseo/packages/app/src/screens/project-settings-screen.test.tsx
Mathias Kurz 7fc4dd1e86 Feat/open projects config to any (#681)
* feat(server): derive owner/repo display name for any remote host

Generalize deriveProjectGroupingName so any remote:<host>/<segments...>
project key returns the last two path segments (owner/repo) instead of
just the trailing segment. Path-fallback for non-remote keys is unchanged.

Brings GitLab, Gitea, Bitbucket, and self-hosted remotes to parity with
the prior github.com-only behavior — no separate special-case needed.

* feat(app): show projects from any git remote in Projects settings

Remove the isSupportedProjectKey filter so workspaces with non-GitHub
remotes (GitLab, Gitea, Bitbucket, self-hosted, ssh-style) appear in the
Projects list and route to the project settings screen. The daemon RPCs,
config schema, and registry were already host-agnostic; this lifts a
client-side filter that hid them.

Also remove the now-vestigial hiddenUnsupportedRemoteCount field from
ProjectSummary, BuildProjectsResult, and UseProjectsResult — once the
filter is gone, the count is always zero and the field is dead state.
This is an internal app-package type, not a wire schema, so deletion is
safe.

* chore(app): simplify Projects empty state to "No projects yet"

Drop the "Non-GitHub remote projects aren't supported yet" empty-state
branch — it can no longer fire now that any git remote is shown. The
empty state is unconditional now.

* test(app): cover non-GitHub remote in projects-settings e2e

Add a fixture that creates a temp git repo with origin pointing at a
gitlab.com URL and exercises the same paseo.json read/edit/save flow
already covered for local repos. Verifies the project surfaces with the
"acme/app" display name and that the round-trip persists correctly.

Extracted the remote-setup branch in createTempGitRepo into a small
configureRemote helper to keep the main function under the cyclomatic
complexity limit.

---------

Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
2026-05-04 16:51:33 +08:00

1153 lines
36 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
PaseoConfigRaw,
PaseoConfigRevision,
ProjectConfigRpcError,
} from "@server/shared/messages";
import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects";
import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects";
type ReadResult =
| { ok: true; config: PaseoConfigRaw | null; revision: PaseoConfigRevision | null }
| { ok: false; error: ProjectConfigRpcError };
type WriteResult =
| { ok: true; config: PaseoConfigRaw; revision: PaseoConfigRevision }
| { ok: false; error: ProjectConfigRpcError };
interface MockClient {
readProjectConfig: ReturnType<typeof vi.fn>;
writeProjectConfig: ReturnType<typeof vi.fn>;
}
const { theme, projectsState, hostState, navigate, confirmDialogMock, client } = vi.hoisted(() => {
const hoistedClient: MockClient = {
readProjectConfig: vi.fn(),
writeProjectConfig: vi.fn(),
};
return {
theme: {
spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 5: 20, 6: 24, 8: 32 },
iconSize: { sm: 14, md: 18, lg: 22 },
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
fontWeight: { normal: "400" as const, medium: "500" as const },
borderRadius: { sm: 4, md: 6, lg: 8, xl: 12, full: 999 },
borderWidth: { 1: 1 },
opacity: { 50: 0.5 },
colors: {
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
surfaceSidebarHover: "#1a1a1a",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#444",
accent: "#0a84ff",
accentForeground: "#fff",
borderAccent: "#0a84ff",
destructive: "#ff453a",
statusSuccess: "#3ecf8e",
palette: {
red: { 300: "#ff6b6b", 500: "#ff453a" },
green: { 400: "#3ecf8e" },
amber: { 500: "#fbbf24" },
white: "#fff",
},
},
},
projectsState: {
current: {
projects: [],
hostErrors: [] as ProjectHostError[],
isLoading: false,
isFetching: false,
refetch: vi.fn(),
} as UseProjectsResult,
},
hostState: {
onlineByServerId: new Map<string, boolean>(),
},
navigate: vi.fn(),
confirmDialogMock: vi.fn<(input: { title: string }) => Promise<boolean>>(),
client: hoistedClient,
};
});
vi.mock("react-native", () => {
const passthrough = ({
children,
testID,
accessibilityLabel,
accessibilityRole,
onPress,
disabled,
...rest
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
accessibilityRole?: string;
onPress?: (event: { stopPropagation: () => void }) => void;
disabled?: boolean;
} & 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,
"data-disabled": disabled ? "true" : "false",
onClick: onPress
? (event: React.MouseEvent) => {
if (disabled) return;
onPress({ stopPropagation: () => event.stopPropagation() });
}
: undefined,
...dataAttrs,
},
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
);
};
return {
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,
TextInput: ({
value,
onChangeText,
placeholder,
testID,
accessibilityLabel,
multiline,
keyboardType,
}: {
value?: string;
onChangeText?: (text: string) => void;
placeholder?: string;
testID?: string;
accessibilityLabel?: string;
multiline?: boolean;
keyboardType?: string;
}) =>
React.createElement(multiline ? "textarea" : "input", {
"data-testid": testID,
"aria-label": accessibilityLabel,
placeholder,
value: value ?? "",
"data-keyboard-type": keyboardType,
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
onChangeText?.(event.currentTarget.value),
}),
ActivityIndicator: ({ size }: { size?: string | number }) =>
React.createElement("span", { "data-testid": "loading-spinner", "data-size": size }),
Linking: { openURL: vi.fn() },
Platform: { OS: "web" },
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
ArrowLeft: icon("ArrowLeft"),
ChevronDown: icon("ChevronDown"),
ChevronRight: icon("ChevronRight"),
FolderGit2: icon("FolderGit2"),
MoreVertical: icon("MoreVertical"),
Pencil: icon("Pencil"),
Plus: icon("Plus"),
Trash2: icon("Trash2"),
Activity: icon("Activity"),
Terminal: icon("Terminal"),
Circle: icon("Circle"),
X: icon("X"),
};
});
vi.mock("expo-router", () => ({
router: { navigate },
}));
vi.mock("@/hooks/use-projects", () => ({
useProjects: () => projectsState.current,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: (serverId: string | null) => {
if (!serverId) return null;
if (hostState.onlineByServerId.get(serverId) === false) return null;
return client;
},
useHostRuntimeIsConnected: (serverId: string | null) => {
if (!serverId) return false;
return hostState.onlineByServerId.get(serverId) ?? true;
},
useHostRuntimeSnapshot: (serverId: string | null) => {
if (!serverId) return null;
const isOnline = hostState.onlineByServerId.get(serverId) ?? true;
return { connectionStatus: isOnline ? "online" : "offline" };
},
}));
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: ({ size }: { size?: string | number }) =>
React.createElement("span", { "data-testid": "loading-spinner", "data-size": size }),
}));
vi.mock("@/components/ui/switch", () => ({
Switch: ({
value,
onValueChange,
disabled,
accessibilityLabel,
testID,
}: {
value: boolean;
onValueChange?: (next: boolean) => void;
disabled?: boolean;
accessibilityLabel?: string;
testID?: string;
}) =>
React.createElement("div", {
role: "switch",
"aria-checked": value ? "true" : "false",
"aria-disabled": disabled ? "true" : undefined,
"aria-label": accessibilityLabel,
"data-testid": testID,
onClick: (event: React.MouseEvent) => {
event.stopPropagation();
if (disabled) return;
onValueChange?.(!value);
},
}),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
children,
onPress,
disabled,
testID,
accessibilityLabel,
}: {
children?: React.ReactNode;
onPress?: () => void;
disabled?: boolean;
testID?: string;
accessibilityLabel?: string;
variant?: string;
size?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"data-testid": testID,
"aria-label": accessibilityLabel,
disabled,
onClick: () => {
if (disabled) return;
onPress?.();
},
},
children,
),
}));
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,
},
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("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
title,
children,
testID,
}: {
visible: boolean;
title: string;
children?: React.ReactNode;
testID?: string;
}) => {
if (!visible) return null;
return React.createElement(
"div",
{ "data-testid": testID, "data-modal-title": title },
children,
);
},
}));
vi.mock("@/components/ui/alert", () => ({
Alert: ({
title,
description,
children,
testID,
}: {
title?: string;
description?: React.ReactNode;
children?: React.ReactNode;
testID?: string;
}) =>
React.createElement(
"div",
{ "data-testid": testID, role: "alert" },
React.createElement("span", { "data-testid": `${testID}-title` }, title),
React.createElement("span", { "data-testid": `${testID}-description` }, description),
children,
),
}));
vi.mock("@/components/ui/status-badge", () => ({
StatusBadge: ({ label }: { label: string }) =>
React.createElement("span", { "data-testid": "status-badge" }, label),
}));
vi.mock("@/screens/settings/settings-section", () => ({
SettingsSection: ({
title,
children,
testID,
}: {
title: string;
children?: React.ReactNode;
testID?: string;
}) =>
React.createElement(
"section",
{ "data-testid": testID, "data-section-title": title },
React.createElement("h2", null, title),
children,
),
}));
vi.mock("@/utils/confirm-dialog", () => ({
confirmDialog: confirmDialogMock,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ show: vi.fn(), error: vi.fn() }),
}));
import ProjectSettingsScreen from "./project-settings-screen";
function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceSummary {
return {
id: "ws-1",
name: "main",
workspaceKind: "directory",
status: "done",
currentBranch: "main",
...overrides,
};
}
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
return {
serverId: "host-a",
serverName: "alpha",
isOnline: true,
repoRoot: "/home/me/proj",
workspaceCount: 1,
workspaces: [workspaceSummary()],
...overrides,
};
}
function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
const hosts = overrides.hosts ?? [hostEntry()];
return {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
hosts,
totalWorkspaceCount: hosts.reduce((sum, host) => sum + host.workspaceCount, 0),
hostCount: hosts.length,
onlineHostCount: hosts.filter((host) => host.isOnline).length,
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 readSuccess(input: {
config: PaseoConfigRaw | null;
revision: PaseoConfigRevision | null;
}): ReadResult {
return { ok: true, config: input.config, revision: input.revision };
}
function readError(error: ProjectConfigRpcError): ReadResult {
return { ok: false, error };
}
function writeSuccess(input: {
config: PaseoConfigRaw;
revision: PaseoConfigRevision;
}): WriteResult {
return { ok: true, config: input.config, revision: input.revision };
}
function writeError(error: ProjectConfigRpcError): WriteResult {
return { ok: false, error };
}
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | 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);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
hostState.onlineByServerId.clear();
navigate.mockReset();
confirmDialogMock.mockReset();
client.readProjectConfig.mockReset();
client.writeProjectConfig.mockReset();
setProjectsState({});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
queryClient?.clear();
root = null;
container?.remove();
container = null;
queryClient = null;
vi.unstubAllGlobals();
});
function renderScreen(projectKey: string) {
act(() => {
root?.render(
<QueryClientProvider client={queryClient!}>
<ProjectSettingsScreen projectKey={projectKey} />
</QueryClientProvider>,
);
});
}
async function flush() {
for (let i = 0; i < 5; i += 1) {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
function changeValue(element: Element, value: string) {
const event = new window.Event("input", { bubbles: true });
Object.defineProperty(event, "target", { value: { value, currentTarget: { value } } });
// jsdom + React: simulate via change event
act(() => {
const input = element as HTMLInputElement | HTMLTextAreaElement;
const setter = Object.getOwnPropertyDescriptor(
input.tagName === "TEXTAREA"
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(input, value);
input.dispatchEvent(new window.Event("input", { bubbles: true }));
input.dispatchEvent(new window.Event("change", { bubbles: true }));
});
}
function findById(testID: string): HTMLElement | null {
return container?.querySelector<HTMLElement>(`[data-testid="${testID}"]`) ?? null;
}
function requireById(testID: string): HTMLElement {
const element = findById(testID);
if (!element) {
throw new Error(`Expected element with testID="${testID}"`);
}
return element;
}
describe("ProjectSettingsScreen — no editable target", () => {
it("renders the no-editable-target state for an unknown projectKey", () => {
setProjectsState({ projects: [project()] });
renderScreen("remote:github.com/unknown/repo");
expect(container?.textContent).toContain(
"We don't have an editable copy of this project on any connected host",
);
const back = requireById("project-settings-back-button");
expect(back.textContent).toBe("Back to projects");
});
it("renders no-editable-target when every host is offline", () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", isOnline: false }),
hostEntry({ serverId: "b", isOnline: false }),
],
}),
],
});
renderScreen("remote:github.com/acme/app");
expect(container?.textContent).toContain(
"We don't have an editable copy of this project on any connected host",
);
});
it("renders no-editable-target when every host has empty repoRoot or serverId", () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", repoRoot: "" }),
hostEntry({ serverId: " ", repoRoot: "/x" }),
],
}),
],
});
renderScreen("remote:github.com/acme/app");
expect(container?.textContent).toContain(
"We don't have an editable copy of this project on any connected host",
);
});
it("'Back to projects' navigates to /settings/projects", () => {
setProjectsState({ projects: [project()] });
renderScreen("remote:github.com/unknown/repo");
click(requireById("project-settings-back-button"));
expect(navigate).toHaveBeenCalledWith("/settings/projects");
});
});
describe("ProjectSettingsScreen — host selection", () => {
it("renders a static host indicator and no host picker when only one host exists", async () => {
setProjectsState({
projects: [
project({
hosts: [hostEntry({ serverId: "a", serverName: "alpha", repoRoot: "/path/to/alpha" })],
}),
],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: {}, revision: { mtimeMs: 1, size: 1 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
expect(findById("host-picker")).toBeNull();
expect(findById("host-indicator")).not.toBeNull();
expect(container?.textContent).toContain("alpha");
expect(container?.textContent).not.toContain("/path/to/alpha");
});
it("renders a host picker chip when more than one host has the project", async () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", serverName: "alpha", repoRoot: "/p/a" }),
hostEntry({ serverId: "b", serverName: "beta", repoRoot: "/p/b" }),
],
}),
],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: {}, revision: { mtimeMs: 1, size: 1 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
expect(findById("host-picker")).not.toBeNull();
expect(findById("host-picker-item-a")).not.toBeNull();
expect(findById("host-picker-item-b")).not.toBeNull();
});
it("refetches config for the new host when a different picker item is selected", async () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", serverName: "alpha", repoRoot: "/p/a" }),
hostEntry({ serverId: "b", serverName: "beta", repoRoot: "/p/b" }),
],
}),
],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: {}, revision: { mtimeMs: 1, size: 1 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
const initialCallCount = client.readProjectConfig.mock.calls.length;
expect(client.readProjectConfig.mock.calls.some((call) => call[0] === "/p/a")).toBe(true);
click(requireById("host-picker-item-b"));
await flush();
expect(client.readProjectConfig.mock.calls.length).toBeGreaterThan(initialCallCount);
expect(client.readProjectConfig.mock.calls.some((call) => call[0] === "/p/b")).toBe(true);
});
});
describe("ProjectSettingsScreen — save flow", () => {
it("disables save when the daemon client is offline", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", serverName: "alpha" })] })],
});
hostState.onlineByServerId.set("a", false);
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: {}, revision: { mtimeMs: 1, size: 1 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
// With one host offline, the screen falls into no-editable-target.
expect(container?.textContent).toContain(
"We don't have an editable copy of this project on any connected host",
);
});
it("issues writeProjectConfig with the current config + expectedRevision and installs the response", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
const baseConfig: PaseoConfigRaw = {
worktree: { setup: "npm install" },
customTopLevel: "preserved",
} as unknown as PaseoConfigRaw;
const initialRevision: PaseoConfigRevision = { mtimeMs: 100, size: 50 };
const newRevision: PaseoConfigRevision = { mtimeMs: 200, size: 60 };
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: baseConfig, revision: initialRevision }),
);
client.writeProjectConfig.mockImplementation(
async (input: { config: PaseoConfigRaw; expectedRevision: PaseoConfigRevision | null }) =>
writeSuccess({ config: input.config, revision: newRevision }),
);
const invalidateSpy = vi.spyOn(queryClient!, "invalidateQueries");
renderScreen("remote:github.com/acme/app");
await flush();
click(requireById("save-button"));
await flush();
expect(client.writeProjectConfig).toHaveBeenCalledTimes(1);
const callArg = client.writeProjectConfig.mock.calls[0][0] as {
repoRoot: string;
config: PaseoConfigRaw;
expectedRevision: PaseoConfigRevision | null;
};
expect(callArg.repoRoot).toBe("/p/a");
expect(callArg.expectedRevision).toEqual(initialRevision);
expect(callArg.config.worktree?.setup).toBe("npm install");
expect((callArg.config as Record<string, unknown>).customTopLevel).toBe("preserved");
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["projects"] });
// Subsequent saves use the freshly-returned revision.
click(requireById("save-button"));
await flush();
const secondArg = client.writeProjectConfig.mock.calls[1][0] as {
expectedRevision: PaseoConfigRevision;
};
expect(secondArg.expectedRevision).toEqual(newRevision);
});
it("renders the stale-write callout, leaves save disabled, and reload refetches", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: {}, revision: { mtimeMs: 1, size: 1 } }),
);
client.writeProjectConfig.mockResolvedValue(
writeError({ code: "stale_project_config", currentRevision: { mtimeMs: 9, size: 9 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
click(requireById("save-button"));
await flush();
const callout = requireById("stale-callout");
expect(callout.textContent).toContain("Config changed on disk");
// Save is disabled while the callout stands.
const save = requireById("save-button") as HTMLButtonElement;
expect(save.disabled).toBe(true);
// Pressing reload triggers another readProjectConfig.
const initialReadCalls = client.readProjectConfig.mock.calls.length;
click(requireById("stale-callout-action-0"));
await flush();
expect(client.readProjectConfig.mock.calls.length).toBeGreaterThan(initialReadCalls);
});
it("renders the invalid_project_config callout with a Reload action", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockResolvedValue(readError({ code: "invalid_project_config" }));
renderScreen("remote:github.com/acme/app");
await flush();
const callout = requireById("invalid-callout");
expect(callout.textContent).toContain("paseo.json couldn't be parsed");
expect(findById("invalid-callout-action-0")?.textContent).toBe("Reload");
});
it("renders the write_failed callout with Try again and Reload, and Try again retries the save", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: {}, revision: { mtimeMs: 1, size: 1 } }),
);
client.writeProjectConfig.mockResolvedValue(writeError({ code: "write_failed" }));
renderScreen("remote:github.com/acme/app");
await flush();
click(requireById("save-button"));
await flush();
const callout = requireById("write-failed-callout");
expect(callout.textContent).toContain("Couldn't save paseo.json");
expect(findById("write-failed-callout-action-0")?.textContent).toBe("Try again");
expect(findById("write-failed-callout-action-1")?.textContent).toBe("Reload");
const beforeRetry = client.writeProjectConfig.mock.calls.length;
click(requireById("write-failed-callout-action-0"));
await flush();
expect(client.writeProjectConfig.mock.calls.length).toBe(beforeRetry + 1);
});
});
describe("ProjectSettingsScreen — round-trip semantics", () => {
it("preserves the original lifecycle string kind on in-place edits", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({
config: { worktree: { setup: "npm install" } },
revision: { mtimeMs: 1, size: 1 },
}),
);
client.writeProjectConfig.mockImplementation(async (input: { config: PaseoConfigRaw }) =>
writeSuccess({ config: input.config, revision: { mtimeMs: 2, size: 2 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
const setupInput = requireById("worktree-setup-input");
changeValue(setupInput, "npm install\nnpm run prepare");
await flush();
click(requireById("save-button"));
await flush();
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
// string stays string when edited in place
expect(savedConfig.worktree?.setup).toBe("npm install\nnpm run prepare");
});
it("preserves the original lifecycle array kind on in-place edits", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({
config: { worktree: { teardown: ["docker compose down"] } },
revision: { mtimeMs: 1, size: 1 },
}),
);
client.writeProjectConfig.mockImplementation(async (input: { config: PaseoConfigRaw }) =>
writeSuccess({ config: input.config, revision: { mtimeMs: 2, size: 2 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
const teardownInput = requireById("worktree-teardown-input");
changeValue(teardownInput, "docker compose down\nrm -rf .cache");
await flush();
click(requireById("save-button"));
await flush();
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
expect(savedConfig.worktree?.teardown).toEqual(["docker compose down", "rm -rf .cache"]);
});
it("writes a single string for a newly added lifecycle field with one non-empty line", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: {}, revision: { mtimeMs: 1, size: 1 } }),
);
client.writeProjectConfig.mockImplementation(async (input: { config: PaseoConfigRaw }) =>
writeSuccess({ config: input.config, revision: { mtimeMs: 2, size: 2 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
changeValue(requireById("worktree-setup-input"), "npm install");
await flush();
click(requireById("save-button"));
await flush();
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
expect(savedConfig.worktree?.setup).toBe("npm install");
});
it("writes an array for a newly added lifecycle field with multiple non-empty lines", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: {}, revision: { mtimeMs: 1, size: 1 } }),
);
client.writeProjectConfig.mockImplementation(async (input: { config: PaseoConfigRaw }) =>
writeSuccess({ config: input.config, revision: { mtimeMs: 2, size: 2 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
changeValue(requireById("worktree-setup-input"), "npm install\nnpm run prepare");
await flush();
click(requireById("save-button"));
await flush();
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
expect(savedConfig.worktree?.setup).toEqual(["npm install", "npm run prepare"]);
});
});
describe("ProjectSettingsScreen — scripts editor", () => {
it("removes a script via confirm dialog and persists on save", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({
config: {
scripts: {
dev: { command: "npm run dev" },
build: { command: "npm run build" },
},
},
revision: { mtimeMs: 1, size: 1 },
}),
);
client.writeProjectConfig.mockImplementation(async (input: { config: PaseoConfigRaw }) =>
writeSuccess({ config: input.config, revision: { mtimeMs: 2, size: 2 } }),
);
confirmDialogMock.mockResolvedValue(true);
renderScreen("remote:github.com/acme/app");
await flush();
// Two script rows render, one per script.
expect(findById("scripts-list")).not.toBeNull();
// open kebab + select Remove on the build row by clicking the testID directly.
const removeButton = container?.querySelector<HTMLElement>(
'[data-testid^="script-row-menu-"][data-testid$="-remove"]',
);
// Find the remove menu item for "build" specifically.
const buildRemove = Array.from(
container?.querySelectorAll<HTMLElement>('[data-testid$="-remove"]') ?? [],
).find((el) => el.textContent?.toLowerCase().includes("remove"));
expect(buildRemove ?? removeButton).not.toBeNull();
// For determinism we'll click the first remove menu item we find — assert there are two scripts before.
const beforeRows =
container?.querySelectorAll('[data-testid^="script-row-"]:not([data-testid*="menu"])') ?? [];
expect(beforeRows.length).toBe(2);
click((buildRemove ?? removeButton)!);
await flush();
expect(confirmDialogMock).toHaveBeenCalled();
click(requireById("save-button"));
await flush();
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
const scriptKeys = Object.keys((savedConfig as Record<string, unknown>).scripts ?? {});
// One of the two scripts was removed.
expect(scriptKeys.length).toBe(1);
});
it("preserves passthrough fields on script entries through a save round-trip", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
const base = {
worktree: { setup: "npm install", customWorktreeField: "keep" },
scripts: {
dev: {
type: "long-running",
command: "npm run dev",
port: 3000,
customScriptField: { nested: true },
},
},
customTopLevel: "preserved",
} as unknown as PaseoConfigRaw;
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: base, revision: { mtimeMs: 1, size: 1 } }),
);
client.writeProjectConfig.mockImplementation(async (input: { config: PaseoConfigRaw }) =>
writeSuccess({ config: input.config, revision: { mtimeMs: 2, size: 2 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
click(requireById("save-button"));
await flush();
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
expect((savedConfig as Record<string, unknown>).customTopLevel).toBe("preserved");
const worktreeRecord = (savedConfig.worktree ?? {}) as Record<string, unknown>;
expect(worktreeRecord.customWorktreeField).toBe("keep");
const devEntry = (savedConfig.scripts ?? {}).dev as unknown as Record<string, unknown>;
expect(devEntry.customScriptField).toEqual({ nested: true });
});
});
describe("ProjectSettingsScreen — read failures keep the header and host picker visible", () => {
it("renders a transport-error callout when the read RPC rejects, with the host picker still mounted", async () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", serverName: "alpha", repoRoot: "/p/a" }),
hostEntry({ serverId: "b", serverName: "beta", repoRoot: "/p/b" }),
],
}),
],
});
client.readProjectConfig.mockRejectedValue(new Error("Unknown request schema"));
renderScreen("remote:github.com/acme/app");
await flush();
expect(findById("read-transport-callout")).not.toBeNull();
expect(findById("loading-spinner")).toBeNull();
expect(findById("host-picker")).not.toBeNull();
expect(findById("host-picker-item-a")).not.toBeNull();
expect(findById("host-picker-item-b")).not.toBeNull();
});
it("renders a project-not-found callout when the daemon reports the project is missing", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockResolvedValue(readError({ code: "project_not_found" }));
renderScreen("remote:github.com/acme/app");
await flush();
const callout = requireById("project-not-found-callout");
expect(callout.textContent).toContain("This host doesn't have this project");
expect(findById("loading-spinner")).toBeNull();
expect(findById("host-indicator")).not.toBeNull();
});
it("Reload action on the transport callout retries the read", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockRejectedValue(new Error("timeout"));
renderScreen("remote:github.com/acme/app");
await flush();
const before = client.readProjectConfig.mock.calls.length;
click(requireById("read-transport-callout-action-0"));
await flush();
expect(client.readProjectConfig.mock.calls.length).toBeGreaterThan(before);
});
it("does not retry the read query on transport failure (settles fast)", async () => {
setProjectsState({
projects: [project({ hosts: [hostEntry({ serverId: "a", repoRoot: "/p/a" })] })],
});
client.readProjectConfig.mockRejectedValue(new Error("boom"));
renderScreen("remote:github.com/acme/app");
await flush();
// retry: false on the query → exactly one call regardless of how many flushes happen.
expect(client.readProjectConfig.mock.calls.length).toBe(1);
expect(findById("read-transport-callout")).not.toBeNull();
});
});
describe("ProjectSettingsScreen — copy and accessibility", () => {
it("does not render the word 'checkout' anywhere", async () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", serverName: "alpha", repoRoot: "/p/a" }),
hostEntry({ serverId: "b", serverName: "beta", repoRoot: "/p/b" }),
],
}),
],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: {}, revision: { mtimeMs: 1, size: 1 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
expect(container?.innerHTML.toLowerCase()).not.toContain("checkout");
});
it("the host picker chip carries an accessibility label", async () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", serverName: "alpha", repoRoot: "/p/a" }),
hostEntry({ serverId: "b", serverName: "beta", repoRoot: "/p/b" }),
],
}),
],
});
client.readProjectConfig.mockResolvedValue(
readSuccess({ config: {}, revision: { mtimeMs: 1, size: 1 } }),
);
renderScreen("remote:github.com/acme/app");
await flush();
const picker = requireById("host-picker");
expect(picker.getAttribute("aria-label")).toBe("Switch host");
});
});