import { chmod, readFile } from "node:fs/promises"; import path from "node:path"; import { expect, test as base, type Page } from "../support/fixtures"; import { connectSeedClient, seedWorkspace } from "../support/helpers/seed-client"; import { blockPaseoConfigWrites, bumpPaseoConfigOnDisk, chooseProjectIconImage, clickReloadProjectSettings, clickRetryProjectSettingsSave, clickSaveProjectSettings, commitPaseoConfig, corruptPaseoConfig, editWorktreeSetup, expectEmptyScriptList, expectProjectHostContextHidden, expectNoEditableTarget, expectNoProjectSettingsError, expectNoUncommittedSetupWarning, expectProjectEditFailed, expectProjectEditName, expectProjectEditSaved, expectProjectEditsSaveDisabled, expectProjectSettingsError, expectProjectSettingsFormHidden, expectProjectSettingsFormVisible, expectProjectTitle, expectProjectSettingsHistoryRoundTrip, expectSaveButtonDisabled, expectScriptRowCount, expectWriteFailedCalloutActions, expectUncommittedSetupWarning, fillProjectIconUrl, fillProjectName, installDaemonConnectionGate, installReadTransportFailure, navigateToProjectSettings, openProjectEditSheet, openProjectSettings, openProjects, removeProjectScript, restorePaseoConfig, returnToProjectsList, saveProjectEdits, unblockPaseoConfigWrites, } from "../support/helpers/project-settings"; import { gotoAppShell } from "../support/helpers/app"; import { openCompactSettings } from "../support/helpers/settings"; import { addProjectFlowInput, chooseAddProjectMethod, openAddProjectFlow, } from "../support/helpers/add-project-flow"; import { createTempGitRepo } from "../support/helpers/workspace"; import { buildOpenProjectRoute, buildProjectsSettingsRoute, buildSettingsRoute, } from "@/utils/host-routes"; import { getServerId } from "../support/helpers/server-id"; const updatedSetup = ["npm install", "npm run build"]; // Smallest valid square PNG the daemon will accept as a custom project icon. const PNG_1X1 = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, ]); interface ProjectsSettingsProject { name: string; path: string; } interface ProjectsSettingsFixtures { editableProject: ProjectsSettingsProject; gitlabRemoteProject: ProjectsSettingsProject; } const initialPaseoConfig = { worktree: { setup: ["echo initial setup"], teardown: "echo cleanup", customWorktreeField: "preserved", }, scripts: { dev: { command: "npm run dev", type: "server", port: 3000, customScriptField: "preserved", }, }, customTopLevelField: "preserved", }; const test = base.extend({ editableProject: async ({ page: _page }, provide) => { const workspace = await seedWorkspace({ repoPrefix: "projects-settings-", repo: { paseoConfig: initialPaseoConfig }, }); await provide({ name: workspace.projectDisplayName, path: workspace.repoPath, }); // Defensive: restore directory write permission in case the test left it blocked // (write_failed test), so that cleanup can remove files inside. await chmod(workspace.repoPath, 0o755).catch(() => undefined); await workspace.cleanup(); }, gitlabRemoteProject: async ({ page: _page }, provide) => { const workspace = await seedWorkspace({ repoPrefix: "projects-settings-gitlab-", repo: { paseoConfig: initialPaseoConfig, originUrl: "https://gitlab.com/acme/app.git", }, }); await provide({ name: workspace.projectDisplayName, path: workspace.repoPath, }); await workspace.cleanup(); }, }); async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise { await expect .poll( async () => { const contents = await readProjectConfigFile(project); return JSON.parse(contents) as unknown; }, { timeout: 30_000, }, ) .toMatchObject({ worktree: { setup: updatedSetup, teardown: initialPaseoConfig.worktree.teardown, customWorktreeField: initialPaseoConfig.worktree.customWorktreeField, }, scripts: { dev: { command: initialPaseoConfig.scripts.dev.command, type: initialPaseoConfig.scripts.dev.type, port: initialPaseoConfig.scripts.dev.port, customScriptField: initialPaseoConfig.scripts.dev.customScriptField, }, }, customTopLevelField: initialPaseoConfig.customTopLevelField, }); const savedConfig = await readProjectConfigFile(project); expect(savedConfig).toBe(`${JSON.stringify(JSON.parse(savedConfig), null, 2)}\n`); } async function readProjectConfigFile(project: ProjectsSettingsProject): Promise { return readFile(path.join(project.path, "paseo.json"), "utf8"); } async function addProjectFromSidebar(page: Page, projectPath: string): Promise { await openAddProjectFlow(page); await chooseAddProjectMethod(page, "directory-search"); const input = addProjectFlowInput(page); await input.fill(projectPath); await page.keyboard.press("Enter"); const projectRow = page .locator('[data-testid^="sidebar-project-row-"]') .filter({ hasText: path.basename(projectPath) }) .first(); await expect(projectRow).toBeVisible({ timeout: 30_000 }); const testId = await projectRow.getAttribute("data-testid"); expect(testId).not.toBeNull(); return testId!.replace("sidebar-project-row-", ""); } async function openProjectSettingsFromSidebar(page: Page, projectId: string): Promise { const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`); await expect(projectRow).toBeVisible({ timeout: 30_000 }); await projectRow.hover(); const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`); await expect(kebab).toBeVisible({ timeout: 10_000 }); await kebab.click(); const openSettingsItem = page.getByTestId(`sidebar-project-menu-open-settings-${projectId}`); await expect(openSettingsItem).toBeVisible({ timeout: 10_000 }); await openSettingsItem.click(); } test.describe("Projects settings", () => { test("freshly-added project with no workspace is editable from the sidebar without a reload", async ({ page, }) => { const repo = await createTempGitRepo("projects-settings-empty-"); const client = await connectSeedClient(); let projectId: string | null = null; try { await gotoAppShell(page); projectId = await addProjectFromSidebar(page, repo.path); await openProjectSettingsFromSidebar(page, projectId); await expectProjectSettingsFormVisible(page); await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible(); } finally { if (projectId) { await client.removeProject(projectId).catch(() => undefined); } await client.close().catch(() => undefined); await repo.cleanup().catch(() => undefined); } }); test("user edits worktree setup from the projects page", async ({ page, editableProject }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await expectNoUncommittedSetupWarning(page); await editWorktreeSetup(page, updatedSetup); await clickSaveProjectSettings(page); await expectProjectConfigSaved(editableProject); await expectUncommittedSetupWarning(page); commitPaseoConfig(editableProject.path); await returnToProjectsList(page); await openProjectSettings(page, editableProject.name); await expectNoUncommittedSetupWarning(page); }); test("project navigation stays inside the selected host", async ({ page, editableProject }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await expectProjectHostContextHidden(page); await returnToProjectsList(page); await openProjectSettings(page, editableProject.name); await expectProjectSettingsHistoryRoundTrip(page, editableProject.name); }); test("user edits worktree setup on a non-GitHub remote project", async ({ page, gitlabRemoteProject, }) => { expect(gitlabRemoteProject.name).toBe(path.basename(gitlabRemoteProject.path)); await openProjects(page); await openProjectSettings(page, gitlabRemoteProject.name); await editWorktreeSetup(page, updatedSetup); await clickSaveProjectSettings(page); await expectProjectConfigSaved(gitlabRemoteProject); }); test("user renames a project from the edit sheet", async ({ page, editableProject }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await openProjectEditSheet(page); await fillProjectName(page, "Renamed project"); await saveProjectEdits(page); await expectProjectEditSaved(page); await expectProjectTitle(page, "Renamed project"); }); test("reopening the edit sheet seeds from the saved project", async ({ page, editableProject, }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await openProjectEditSheet(page); await fillProjectName(page, "Renamed project"); await saveProjectEdits(page); await expectProjectEditSaved(page); await openProjectEditSheet(page); await expectProjectEditName(page, "Renamed project"); await expectProjectEditsSaveDisabled(page); }); test("user picks a custom project icon from a file", async ({ page, editableProject }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await openProjectEditSheet(page); await chooseProjectIconImage(page, { name: "logo.png", mimeType: "image/png", buffer: PNG_1X1, }); await saveProjectEdits(page); await expectProjectEditSaved(page); }); test("user sets a project name and icon in one save", async ({ page, editableProject }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await openProjectEditSheet(page); await fillProjectName(page, "Both at once"); await chooseProjectIconImage(page, { name: "logo.png", mimeType: "image/png", buffer: PNG_1X1, }); await saveProjectEdits(page); await expectProjectEditSaved(page); await expectProjectTitle(page, "Both at once"); }); test("project edit keeps a rejected icon URL actionable", async ({ page, editableProject }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await openProjectEditSheet(page); await fillProjectIconUrl(page, "file:///etc/passwd"); await saveProjectEdits(page); await expectProjectEditFailed(page, "URL must use HTTP or HTTPS without credentials"); }); }); test.describe("Projects settings — compact navigation", () => { test.use({ viewport: { width: 390, height: 844 } }); test("project Back returns through Projects and Settings without reopening the project", async ({ page, editableProject, }) => { await gotoAppShell(page); await openCompactSettings(page, buildOpenProjectRoute()); await page.getByRole("button", { name: "Projects", exact: true }).click(); await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId())); await openProjectSettings(page, editableProject.name); await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(1); await expect(page.getByRole("button", { name: "Back to projects", exact: true })).toHaveCount( 0, ); await page.getByRole("button", { name: "Back", exact: true }).click(); await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId())); await page.getByRole("button", { name: "Back", exact: true }).click(); await expect(page).toHaveURL(buildSettingsRoute()); await expect(page.getByTestId("settings-sidebar")).toBeVisible(); }); }); test.describe("Projects settings — error UX", () => { test("stale-write callout appears on save, disables save, and reload clears it", async ({ page, editableProject, }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); // Bump the file on disk so the daemon detects a revision mismatch on save. await bumpPaseoConfigOnDisk(editableProject.path); await clickSaveProjectSettings(page); await expectProjectSettingsError(page, "stale"); await expectSaveButtonDisabled(page); await clickReloadProjectSettings(page); await expectNoProjectSettingsError(page, "stale"); await expectProjectSettingsFormVisible(page); }); test("invalid paseo.json shows read-error callout, reload after fix shows form", async ({ page, editableProject, }) => { await corruptPaseoConfig(editableProject.path); await openProjects(page); await navigateToProjectSettings(page, editableProject.name); await expectProjectSettingsError(page, "invalid"); await expectProjectSettingsFormHidden(page); // Restore a valid config so the reload succeeds. await restorePaseoConfig(editableProject.path, initialPaseoConfig); await clickReloadProjectSettings(page); await expectNoProjectSettingsError(page, "invalid"); await expectProjectSettingsFormVisible(page); }); test("write_failed callout appears on save with blocked directory, retry re-attempts, reload clears it", async ({ page, editableProject, }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await blockPaseoConfigWrites(editableProject.path); await clickSaveProjectSettings(page); await expectProjectSettingsError(page, "write_failed"); await expectWriteFailedCalloutActions(page); await clickRetryProjectSettingsSave(page); await expectProjectSettingsError(page, "write_failed"); await unblockPaseoConfigWrites(editableProject.path); await clickReloadProjectSettings(page); await expectNoProjectSettingsError(page, "write_failed"); await expectProjectSettingsFormVisible(page); }); test("read-transport failure shows callout, reload recovers", async ({ page, editableProject, }) => { // Reject read_project_config_request calls until the user clicks Reload. // This keeps automatic reconnect refetches from racing past the callout. const transportFailure = await installReadTransportFailure(page); await openProjects(page); await navigateToProjectSettings(page, editableProject.name); await expectProjectSettingsError(page, "transport"); await expectProjectSettingsFormHidden(page); // Retry Reload until the refetch wins any in-flight error-state rendering. transportFailure.allowRecovery(); await expect(async () => { await clickReloadProjectSettings(page); await expectNoProjectSettingsError(page, "transport", 3_000); }).toPass({ timeout: 15_000 }); await expectProjectSettingsFormVisible(page); }); test("project settings shows no-target state when daemon connection drops", async ({ page, editableProject, }) => { const gate = await installDaemonConnectionGate(page); await openProjects(page); await openProjectSettings(page, editableProject.name); // Closing with code 1001 (Going Away) transitions DaemonClient to "error" state. // The NoEditableTarget UI renders via isHostGone check regardless of state. await gate.drop(); await expectNoEditableTarget(page); }); test("project detail does not render a second host selector", async ({ page, editableProject, }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await expectProjectHostContextHidden(page); }); test("script removal via kebab menu removes the row from the form", async ({ page, editableProject, }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await expectScriptRowCount(page, 1); await removeProjectScript(page, "dev"); await expectScriptRowCount(page, 0); await expectEmptyScriptList(page); await clickSaveProjectSettings(page); await expectNoUncommittedSetupWarning(page); }); });