import path from "node:path"; import type { Page, TestInfo } from "@playwright/test"; import { test, expect } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, expectWorkspaceHoverCardClosed, expectWorkspaceHoverCardOpen, expectWorkspaceHoverCardStaysOpen, focusWorkspaceRowWithKeyboard, movePointerAwayFromWorkspaceHoverCard, openMobileAgentSidebar, pinWorkspaceFromSidebar, } from "../support/helpers/sidebar"; import { seedWorkspace } from "../support/helpers/seed-client"; import { expectWorkspaceHeader } from "../support/helpers/workspace-ui"; import { getServerId } from "../support/helpers/server-id"; import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { escapeRegex } from "../support/helpers/regex"; import { openFilesPanel } from "../support/helpers/workspace-tabs"; import { seedMockAgentWorkspace } from "../support/helpers/mock-agent"; const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git"; function getWorkspaceRowTestId(workspaceId: string): string { return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; } async function openWorkspaceFromSidebar( page: import("@playwright/test").Page, workspaceId: string, ) { const row = page.getByTestId(getWorkspaceRowTestId(workspaceId)); await expect(row).toBeVisible({ timeout: 30_000 }); await row.click(); await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); return row; } async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) { const row = page .getByRole("button", { name: new RegExp(escapeRegex(projectName), "i"), }) .first(); await expect(row).toBeVisible({ timeout: 30_000 }); return row; } async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) { const row = page.getByTestId(getWorkspaceRowTestId(workspaceId)); await expect(row).toBeVisible({ timeout: 30_000 }); return row; } async function openWorkspaceReadAction( page: import("@playwright/test").Page, workspaceId: string, action: "read" | "unread", ) { const workspaceKey = `${getServerId()}:${workspaceId}`; const row = await waitForSidebarWorkspace(page, workspaceId); await row.hover(); await page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`).click(); const item = page.getByTestId(`sidebar-workspace-menu-mark-as-${action}-${workspaceKey}`); await expect(item).toBeVisible({ timeout: 30_000 }); return item; } async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) { const row = await waitForSidebarWorkspace(page, workspaceId); await row.hover(); const hoverCard = page.getByRole("menu", { name: "Workspace scripts" }); await expect(hoverCard).toBeVisible({ timeout: 30_000 }); return hoverCard; } interface PaseoOwnedWorktree { projectName: string; workspaceId: string; worktreeSlug: string; } async function withPaseoOwnedWorktree( run: (workspace: PaseoOwnedWorktree) => Promise, ): Promise { const project = await seedWorkspace({ repoPrefix: "sidebar-hover-owned-worktree-" }); const worktreeSlug = "hover-card-owned-worktree"; try { const created = await project.client.createWorkspace({ source: { kind: "worktree", cwd: project.repoPath, projectId: project.projectId, worktreeSlug, }, }); if (!created.workspace) { throw new Error(created.error ?? "Failed to create Paseo-owned worktree"); } expect(path.basename(created.workspace.workspaceDirectory)).toBe(worktreeSlug); await run({ projectName: path.basename(project.repoPath), workspaceId: created.workspace.id, worktreeSlug, }); } finally { await project.cleanup(); } } async function readCachedRowIds( page: Page, rowKind: "workspace" | "checkpoint", ): Promise { return page.evaluate( async ({ serverId, kind }) => { const database = await new Promise((resolve, reject) => { const request = indexedDB.open("paseo-replica-row-store"); request.addEventListener("success", () => resolve(request.result)); request.addEventListener("error", () => reject(request.error)); }); try { return await new Promise((resolve, reject) => { const transaction = database.transaction("rows", "readonly"); const request = transaction.objectStore("rows").getAll(); request.onsuccess = () => resolve( request.result .filter( (row: { serverId: string; kind: string }) => row.serverId === serverId && row.kind === kind, ) .map((row: { id: string }) => row.id) .sort(), ); request.addEventListener("error", () => reject(request.error)); }); } finally { database.close(); } }, { serverId: getServerId(), kind: rowKind }, ); } async function simulateDamagedLegacyDirectoryCache( page: Page, workspaceIdToRemove: string, ): Promise { // Stop the app before altering storage so no pending app write can repair the fixture. const fixtureUrl = new URL("/__directory_cache_fixture", page.url()).href; await page.route(fixtureUrl, (route) => route.fulfill({ contentType: "text/html", body: "Cache fixture" }), ); await page.goto(fixtureUrl); await page.evaluate( async ({ serverId, missingWorkspaceId }) => { const database = await new Promise((resolve, reject) => { const request = indexedDB.open("paseo-replica-row-store"); request.addEventListener("success", () => resolve(request.result)); request.addEventListener("error", () => reject(request.error)); }); try { await new Promise((resolve, reject) => { const transaction = database.transaction("rows", "readwrite"); const store = transaction.objectStore("rows"); const request = store.getAll(); request.onsuccess = () => { const rows = request.result as Array<{ serverId: string; kind: string; id: string; payload: string; }>; const checkpoint = rows.find( (row) => row.serverId === serverId && row.kind === "checkpoint", ); if (!checkpoint) { transaction.abort(); return; } const stored = JSON.parse(checkpoint.payload); // Accept either writer so this fixture also reproduces on the unfixed client. const cursors = stored.version === 1 ? stored.cursors : stored; if (!cursors.workspaces?.generation) { transaction.abort(); return; } store.put({ ...checkpoint, payload: JSON.stringify(cursors) }); for (const row of rows) { if ( row.serverId === serverId && row.kind === "workspace" && row.id === missingWorkspaceId ) store.delete([serverId, row.kind, row.id]); } }; transaction.addEventListener("complete", () => resolve()); transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("Missing current directory checkpoint")), ); transaction.addEventListener("error", () => reject(transaction.error)); }); } finally { database.close(); } }, { serverId: getServerId(), missingWorkspaceId: workspaceIdToRemove }, ); expect(await readCachedRowIds(page, "workspace")).not.toContain(workspaceIdToRemove); } async function holdHostResponsesUntilCacheIsVisible(page: Page): Promise<() => void> { let release!: () => void; const ready = new Promise((resolve) => { release = resolve; }); await page.routeWebSocket(new RegExp(`:${getE2EDaemonPort()}(?:/|$)`), (socket) => { const server = socket.connectToServer(); server.onMessage(async (message) => { await ready; socket.send(message); }); }); return release; } async function createPinnedSiblingWorkspace( project: Awaited>, ): Promise { const sibling = await project.client.createWorkspace({ source: { kind: "directory", path: project.repoPath, projectId: project.projectId }, title: "Pinned sibling", }); if (!sibling.workspace) throw new Error(sibling.error ?? "Sibling workspace was not created"); await project.client.setWorkspacePinned(sibling.workspace.id, true); return sibling.workspace.id; } async function expectSidebarWorkspaces(page: Page, workspaceIds: string[]): Promise { for (const workspaceId of workspaceIds) { await waitForSidebarWorkspace(page, workspaceId); } } async function cacheWorkspaces(page: Page, workspaceIds: string[]): Promise { await gotoAppShell(page); await expectSidebarWorkspaces(page, workspaceIds); await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds); } async function reopenDamagedCacheBeforeHostResponds( page: Page, missingWorkspaceId: string, ): Promise<() => void> { await simulateDamagedLegacyDirectoryCache(page, missingWorkspaceId); const releaseHost = await holdHostResponsesUntilCacheIsVisible(page); await gotoAppShell(page); return releaseHost; } async function expectDamagedCacheHydrated( page: Page, retainedWorkspaceId: string, missingWorkspaceId: string, ): Promise { await waitForSidebarWorkspace(page, retainedWorkspaceId); await expect(page.getByTestId(getWorkspaceRowTestId(missingWorkspaceId))).toHaveCount(0); await expect.poll(() => readCachedRowIds(page, "checkpoint")).toEqual([]); } async function expectRecoveredWorkspacesAfterReload( page: Page, workspaceIds: string[], testInfo: TestInfo, ): Promise { await expectSidebarWorkspaces(page, workspaceIds); await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds); await page.reload(); await expectSidebarWorkspaces(page, workspaceIds); await page.screenshot({ path: testInfo.outputPath("recovered-workspaces.png") }); await testInfo.attach("recovered-workspaces", { path: testInfo.outputPath("recovered-workspaces.png"), contentType: "image/png", }); } test.describe("Sidebar workspace list", () => { test("recovers missing workspaces from a damaged legacy cache after reload", async ({ page, }, testInfo) => { const project = await seedWorkspace({ repoPrefix: "directory-cache-recovery-" }); try { const siblingId = await createPinnedSiblingWorkspace(project); const ids = [project.workspaceId, siblingId].sort(); await test.step("cache both workspaces", () => cacheWorkspaces(page, ids)); const releaseHost = await test.step("reopen damaged cache before host responds", () => reopenDamagedCacheBeforeHostResponds(page, project.workspaceId)); await test.step("keep cached workspace and discard stale checkpoint", () => expectDamagedCacheHydrated(page, siblingId, project.workspaceId)); releaseHost(); await test.step("recover missing workspace and retain both after reload", () => expectRecoveredWorkspacesAfterReload(page, ids, testInfo)); } finally { await project.cleanup(); } }); test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-remote-", repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL }, }); try { const projectName = path.basename(workspace.repoPath); await gotoAppShell(page); await waitForSidebarProject(page, projectName); await waitForSidebarWorkspace(page, workspace.workspaceId); const projectRow = page .locator('[data-testid^="sidebar-project-row-"]') .filter({ hasText: projectName }) .first(); await expect(projectRow).toBeVisible({ timeout: 30_000 }); await expect(projectRow).not.toContainText("test-owner/test-repo"); } finally { await workspace.cleanup(); } }); test("non-git project shows directory name", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false }); try { await gotoAppShell(page); const directoryName = path.basename(workspace.repoPath); const projectRow = await waitForSidebarProject(page, directoryName); await expect(projectRow).toContainText(directoryName); } finally { await workspace.cleanup(); } }); test("workspace header uses the selected folder name instead of its GitHub remote", async ({ page, }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-header-", repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL }, }); try { const projectName = path.basename(workspace.repoPath); await gotoAppShell(page); await waitForSidebarProject(page, projectName); await waitForSidebarWorkspace(page, workspace.workspaceId); await openWorkspaceFromSidebar(page, workspace.workspaceId); await expectWorkspaceHeader(page, { title: workspace.workspaceName, subtitle: projectName, }); } finally { await workspace.cleanup(); } }); test("git project shows branch name in workspace row", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" }); try { await gotoAppShell(page); await waitForSidebarProject(page, path.basename(workspace.repoPath)); expect(workspace.workspaceName).toBe("main"); await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText( "main", ); } finally { await workspace.cleanup(); } }); test("workspace hover card shows host as metadata", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" }); try { await gotoAppShell(page); await waitForSidebarProject(page, path.basename(workspace.repoPath)); const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId); await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost"); await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/); } finally { await workspace.cleanup(); } }); test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" }); try { await gotoAppShell(page); await waitForSidebarProject(page, path.basename(workspace.repoPath)); const row = await openWorkspaceFromSidebar(page, workspace.workspaceId); await movePointerAwayFromWorkspaceHoverCard(page); await expectWorkspaceHoverCardClosed(page); await row.click(); await expect(row).toBeFocused(); await expectWorkspaceHoverCardOpen(page); await movePointerAwayFromWorkspaceHoverCard(page); await expectWorkspaceHoverCardClosed(page); } finally { await workspace.cleanup(); } }); test("workspace hover card follows keyboard focus on a row", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" }); try { await gotoAppShell(page); await waitForSidebarProject(page, path.basename(workspace.repoPath)); await openWorkspaceFromSidebar(page, workspace.workspaceId); await movePointerAwayFromWorkspaceHoverCard(page); await expectWorkspaceHoverCardClosed(page); await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); await expectWorkspaceHoverCardOpen(page); await movePointerAwayFromWorkspaceHoverCard(page); await expectWorkspaceHoverCardStaysOpen(page); await page.keyboard.press("Escape"); await expectWorkspaceHoverCardClosed(page); await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); await expectWorkspaceHoverCardOpen(page); await page.keyboard.press("Tab"); await expectWorkspaceHoverCardClosed(page); } finally { await workspace.cleanup(); } }); test("marks a finished workspace unread until it is opened again", async ({ page }) => { const workspace = await seedMockAgentWorkspace({ repoPrefix: "sidebar-mark-unread-", title: "Mark unread", initialPrompt: "Finish this test turn.", }); try { await workspace.client.waitForFinish(workspace.agentId, 20_000); await workspace.client.clearWorkspaceAttention(workspace.workspaceId); expect(workspace.client.getLastServerInfoMessage()?.features?.workspaceMarkUnread).toBe(true); await gotoAppShell(page); const row = await waitForSidebarWorkspace(page, workspace.workspaceId); await expect(row.getByTestId("workspace-status-indicator-done")).toBeVisible(); await (await openWorkspaceReadAction(page, workspace.workspaceId, "unread")).click(); await openWorkspaceReadAction(page, workspace.workspaceId, "read"); await page.keyboard.press("Escape"); await openWorkspaceFromSidebar(page, workspace.workspaceId); await openWorkspaceReadAction(page, workspace.workspaceId, "unread"); } finally { await workspace.cleanup(); } }); test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => { await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => { await gotoAppShell(page); await waitForSidebarProject(page, projectName); await openWorkspaceHoverCard(page, workspaceId); await expect(page.getByTestId("hover-card-workspace-cwd")).toHaveText(worktreeSlug); }); }); }); test.describe("Mobile sidebar panelState transition", () => { test.use({ viewport: { width: 390, height: 844 } }); test("showMobileAgent open and close transition", async ({ page }) => { await gotoAppShell(page); await expectMobileAgentSidebarHidden(page); await openMobileAgentSidebar(page); await expectMobileAgentSidebarVisible(page); await closeMobileAgentSidebar(page); await expectMobileAgentSidebarHidden(page); }); test("places the close button beside the first nav row instead of over it", async ({ page }) => { await gotoAppShell(page); await openMobileAgentSidebar(page); await expectMobileAgentSidebarVisible(page); const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox(); const closeButton = await page.getByTestId("sidebar-close").boundingBox(); if (!firstRow || !closeButton) throw new Error("first nav row and close button must render"); expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width); }); test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({ page, }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" }); try { await gotoAppShell(page); await openMobileAgentSidebar(page); await expectMobileAgentSidebarVisible(page); const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId)); await expect(row).toBeVisible({ timeout: 30_000 }); await pinWorkspaceFromSidebar(page, workspace.workspaceId); await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible(); await closeMobileAgentSidebar(page); await expectMobileAgentSidebarHidden(page); await expect(row).toHaveCount(1); } finally { await workspace.cleanup(); } }); }); test.describe("Half-screen desktop layout", () => { test.use({ viewport: { width: 751, height: 982 } }); test("keeps the sidebar scroll position across close and reopen", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-scroll-" }); try { let lastWorkspaceId = workspace.workspaceId; for (let index = 0; index < 24; index += 1) { const created = await workspace.client.createWorkspace({ source: { kind: "directory", path: workspace.repoPath, projectId: workspace.projectId, }, title: `Retained sidebar ${index + 1}`, }); if (!created.workspace) { throw new Error(created.error ?? "Failed to fill the retained sidebar"); } lastWorkspaceId = created.workspace.id; } await gotoAppShell(page); await page .getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(workspace.projectKey)}`) .click(); await waitForSidebarWorkspace(page, lastWorkspaceId); const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll"); const scrollTop = await sidebarScroll.evaluate((element) => { element.scrollTop = 160; return element.scrollTop; }); expect(scrollTop).toBe(160); await page.getByTestId("menu-button").click(); await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); await page.getByTestId("menu-button").click(); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop); } finally { await workspace.cleanup(); } }); test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => { await gotoAppShell(page); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible(); }); test("keeps the left toggle center-owned without left window controls", async ({ page }) => { await gotoAppShell(page); const openToggle = page.getByTestId("menu-button"); const openIcon = openToggle.locator("svg").first(); await expect(openIcon).toBeVisible(); const openBounds = await openIcon.boundingBox(); expect(openBounds).not.toBeNull(); expect(openBounds?.x).toBeGreaterThan(12); await openToggle.click(); await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); const closedToggle = page.getByTestId("menu-button"); const closedIcon = closedToggle.locator("svg").first(); await expect(closedIcon).toBeVisible(); const closedBounds = await closedIcon.boundingBox(); expect(closedBounds).not.toBeNull(); expect(closedBounds?.x).toBeCloseTo(9, 0); expect(closedBounds?.y).toBe(openBounds?.y); }); test("yields app navigation to the settings split", async ({ page }) => { await gotoAppShell(page); await page.getByTestId("sidebar-settings").click(); await expect(page.getByTestId("settings-sidebar")).toBeVisible(); await expect(page.getByTestId("settings-detail-pane")).toBeVisible(); await expect(page.getByTestId("sidebar-settings")).not.toBeVisible(); }); test("yields app navigation to Explorer and restores it when Explorer closes", async ({ page, }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" }); try { await gotoAppShell(page); await waitForSidebarProject(page, path.basename(workspace.repoPath)); await openWorkspaceFromSidebar(page, workspace.workspaceId); await openFilesPanel(page); const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible(); await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar"); await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); await expect( page .locator('[data-testid^="workspace-pane-"]') .getByTestId("workspace-tabs-row") .filter({ visible: true }), ).toHaveCount(1); await explorerToggle.click(); await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount( 0, ); await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar"); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); } finally { await workspace.cleanup(); } }); });