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; + }} + />