From 8122d501b09ea4bd12c4a1efbdcc52e04a3ba734 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 17:08:00 +0700 Subject: [PATCH] fix: add getId to workspace route so navigation updates params (#325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace Stack.Screen was missing a getId callback, causing expo-router to reuse the same screen instance across workspace navigations. This left stale params — sidebar switching and new workspace creation both appeared to navigate but the header stayed on the old workspace (skeleton for new workspaces). Adds e2e tests for sidebar workspace switching and new workspace creation flow (URL, sidebar row, header, single draft tab). --- packages/app/e2e/helpers/new-workspace.ts | 231 ++++++++++++++++++++++ packages/app/e2e/new-workspace.spec.ts | 153 ++++++++++++++ packages/app/src/app/_layout.tsx | 25 ++- 3 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/helpers/new-workspace.ts create mode 100644 packages/app/e2e/new-workspace.spec.ts diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts new file mode 100644 index 000000000..e9d9a401a --- /dev/null +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -0,0 +1,231 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, type Page } from "@playwright/test"; +import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client"; +import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; +import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui"; +import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory"; + +type NewWorkspaceDaemonClient = Pick< + ServerDaemonClient, + | "archivePaseoWorktree" + | "archiveWorkspace" + | "close" + | "connect" + | "createPaseoWorktree" + | "openProject" +>; + +type NewWorkspaceDaemonClientConfig = { + url: string; + clientId: string; + clientType: "cli"; + webSocketFactory?: NodeWebSocketFactory; +}; + +type OpenProjectPayload = Awaited>; + +export type OpenedProject = { + workspaceId: string; + projectKey: string; + projectDisplayName: string; + workspaceName: string; +}; + +function getDaemonPort(): string { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + if (daemonPort === "6767") { + throw new Error("E2E_DAEMON_PORT must not point at the developer daemon."); + } + return daemonPort; +} + +function getDaemonWsUrl(): string { + return `ws://127.0.0.1:${getDaemonPort()}/ws`; +} + +async function loadDaemonClientConstructor(): Promise< + new ( + config: NewWorkspaceDaemonClientConfig, + ) => NewWorkspaceDaemonClient +> { + const repoRoot = path.resolve(__dirname, "../../../../"); + const moduleUrl = pathToFileURL( + path.join(repoRoot, "packages/server/dist/server/server/exports.js"), + ).href; + const mod = (await import(moduleUrl)) as { + DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient; + }; + return mod.DaemonClient; +} + +function requireWorkspace(payload: OpenProjectPayload) { + if (payload.error) { + throw new Error(payload.error); + } + if (!payload.workspace) { + throw new Error("openProject returned no workspace."); + } + return payload.workspace; +} + +function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null { + const pathname = new URL(page.url()).pathname; + const match = pathname.match( + new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`), + ); + if (!match?.[1]) { + return null; + } + return decodeWorkspaceIdFromPathSegment(match[1]); +} + +function parseWorkspaceIdFromSidebarRowTestId( + testId: string, + input: { serverId: string; previousWorkspaceId: string }, +): string | null { + const prefix = `sidebar-workspace-row-${input.serverId}:`; + if (!testId.startsWith(prefix)) { + return null; + } + const workspaceId = testId.slice(prefix.length).trim(); + if (!workspaceId || workspaceId === input.previousWorkspaceId) { + return null; + } + return workspaceId; +} + +export async function connectNewWorkspaceDaemonClient(): Promise { + const DaemonClient = await loadDaemonClientConstructor(); + const webSocketFactory = createNodeWebSocketFactory(); + const client = new DaemonClient({ + url: getDaemonWsUrl(), + clientId: `app-e2e-new-workspace-${randomUUID()}`, + clientType: "cli", + webSocketFactory, + }); + await client.connect(); + return client; +} + +export async function openProjectViaDaemon( + client: NewWorkspaceDaemonClient, + repoPath: string, +): Promise { + const workspace = requireWorkspace(await client.openProject(repoPath)); + return { + workspaceId: workspace.id, + projectKey: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + workspaceName: workspace.name, + }; +} + +export async function archiveWorkspaceFromDaemon( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId }); + if (payload.error) { + throw new Error(payload.error.message); + } + if (!payload.success) { + throw new Error(`Failed to archive workspace: ${workspaceId}`); + } +} + +export async function archiveLocalWorkspaceFromDaemon( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const payload = await client.archiveWorkspace(workspaceId); + if (payload.error) { + throw new Error(payload.error); + } + if (!payload.archivedAt) { + throw new Error(`Failed to archive workspace: ${workspaceId}`); + } +} + +export async function createWorktreeViaDaemon( + client: NewWorkspaceDaemonClient, + input: { cwd: string; slug: string }, +): Promise { + const payload = await client.createPaseoWorktree({ + cwd: input.cwd, + worktreeSlug: input.slug, + }); + const workspace = requireWorkspace(payload); + return { + workspaceId: workspace.id, + projectKey: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + workspaceName: workspace.name, + }; +} + +export async function clickNewWorkspaceButton( + page: Page, + input: { projectKey: string; projectDisplayName: string }, +): Promise { + const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first(); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await projectRow.hover(); + + const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first(); + await expect(button).toBeVisible({ timeout: 30_000 }); + await button.click(); +} + +export async function assertNewWorkspaceSidebarAndHeader( + page: Page, + input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string }, +): Promise<{ workspaceId: string }> { + let workspaceId: string | null = null; + const sidebarWorkspaceRows = page.locator( + `[data-testid^="sidebar-workspace-row-${input.serverId}:"]`, + ); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-testid") ?? ""), + ); + workspaceId = + sidebarRowTestIds + .map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input)) + .find((id) => id !== null) ?? null; + if (workspaceId) { + break; + } + await page.waitForTimeout(250); + } + + if (!workspaceId || workspaceId === input.previousWorkspaceId) { + const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-testid") ?? ""), + ); + throw new Error( + [ + "Expected a newly created workspace to load.", + `Current URL: ${page.url()}`, + `Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || ""}`, + ].join("\n"), + ); + } + + const createdWorkspaceRow = page.getByTestId( + `sidebar-workspace-row-${input.serverId}:${workspaceId}`, + ); + await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 }); + + await expectWorkspaceHeader(page, { + title: workspaceLabelFromPath(workspaceId), + subtitle: input.projectDisplayName, + }); + + return { workspaceId }; +} diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts new file mode 100644 index 000000000..e4aa23ff4 --- /dev/null +++ b/packages/app/e2e/new-workspace.spec.ts @@ -0,0 +1,153 @@ +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { expect, test } from "./fixtures"; +import { + archiveWorkspaceFromDaemon, + archiveLocalWorkspaceFromDaemon, + assertNewWorkspaceSidebarAndHeader, + clickNewWorkspaceButton, + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + openProjectViaDaemon, +} from "./helpers/new-workspace"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + expectWorkspaceHeader, + switchWorkspaceViaSidebar, + workspaceLabelFromPath, +} from "./helpers/workspace-ui"; + +test.describe("New workspace flow", () => { + let client: Awaited>; + const localWorkspaceIds = new Set(); + const createdWorktreeIds = new Set(); + + test.describe.configure({ timeout: 120_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + }); + + test.afterEach(async () => { + if (client) { + for (const workspaceId of createdWorktreeIds) { + await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); + } + for (const workspaceId of localWorkspaceIds) { + await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); + } + } + createdWorktreeIds.clear(); + localWorkspaceIds.clear(); + await client?.close().catch(() => undefined); + }); + + test("sidebar workspace navigation updates URL and header", async ({ page }) => { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + const firstRepo = await createTempGitRepo("workspace-nav-a-"); + const secondRepo = await createTempGitRepo("workspace-nav-b-"); + + try { + const firstWorkspace = await openProjectViaDaemon(client, firstRepo.path); + const secondWorkspace = await openProjectViaDaemon(client, secondRepo.path); + localWorkspaceIds.add(firstWorkspace.workspaceId); + localWorkspaceIds.add(secondWorkspace.workspaceId); + + await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId)); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId)); + await expectWorkspaceHeader(page, { + title: firstWorkspace.workspaceName, + subtitle: workspaceLabelFromPath(firstRepo.path), + }); + + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: secondWorkspace.workspaceId, + }); + await expectWorkspaceHeader(page, { + title: secondWorkspace.workspaceName, + subtitle: workspaceLabelFromPath(secondRepo.path), + }); + + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: firstWorkspace.workspaceId, + }); + await expectWorkspaceHeader(page, { + title: firstWorkspace.workspaceName, + subtitle: workspaceLabelFromPath(firstRepo.path), + }); + } finally { + await secondRepo.cleanup(); + await firstRepo.cleanup(); + } + }); + + test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({ + page, + }) => { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + const tempRepo = await createTempGitRepo("new-workspace-"); + + try { + const openedProject = await openProjectViaDaemon(client, tempRepo.path); + localWorkspaceIds.add(openedProject.workspaceId); + + await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId)); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId)); + await expectWorkspaceHeader(page, { + title: openedProject.workspaceName, + subtitle: workspaceLabelFromPath(tempRepo.path), + }); + + await clickNewWorkspaceButton(page, { + projectKey: openedProject.projectKey, + projectDisplayName: openedProject.projectDisplayName, + }); + + const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { + serverId, + previousWorkspaceId: openedProject.workspaceId, + projectDisplayName: openedProject.projectDisplayName, + }); + createdWorktreeIds.add(createdWorkspace.workspaceId); + + expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId); + await expect(page).toHaveURL( + buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId), + { + timeout: 30_000, + }, + ); + + const createdWorkspaceRow = page.getByTestId( + `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`, + ); + await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 }); + + await expectWorkspaceHeader(page, { + title: workspaceLabelFromPath(createdWorkspace.workspaceId), + subtitle: openedProject.projectDisplayName, + }); + + const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({ + has: page.getByText("New Agent", { exact: true }), + }); + await expect(draftTabs).toHaveCount(1, { timeout: 30_000 }); + + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await expect(composer).toBeEditable({ timeout: 30_000 }); + } finally { + await tempRepo.cleanup(); + } + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index dd68ff244..4422ba3c6 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -91,6 +91,22 @@ export type HostRuntimeBootstrapState = { retry: () => void; }; +function getRouteParamValue(value: string | string[] | undefined): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (Array.isArray(value)) { + const firstValue = value[0]; + if (typeof firstValue !== "string") { + return undefined; + } + const trimmed = firstValue.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + return undefined; +} + const HostRuntimeBootstrapContext = createContext({ phase: "starting-daemon", error: null, @@ -788,7 +804,14 @@ function RootStack() { - + { + const serverId = getRouteParamValue(params?.serverId); + const workspaceId = getRouteParamValue(params?.workspaceId); + return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined; + }} + />