Fix Playwright workspace isolation (#1627)

* Fix Playwright workspace isolation

* Clean up empty project E2E cleanup

* Clean up worktree restore E2E projects
This commit is contained in:
Mohamed Boudra
2026-06-20 08:59:21 +00:00
committed by GitHub
parent ba8fe261ee
commit 4354ad3e27
19 changed files with 119 additions and 43 deletions
+4 -6
View File
@@ -12,7 +12,7 @@ import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
const agent = await startRunningMockAgent(page, {
prefix: "stream-scroll-",
model: "one-minute-stream",
prompt: "Stream for auto-scroll test.",
@@ -21,14 +21,13 @@ test.describe("Agent stream UI", () => {
await awaitAssistantMessage(page);
await expectScrollFollowsNewContent(page);
} finally {
await client.close();
await repo.cleanup();
await agent.cleanup();
}
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const { client, repo } = await startRunningMockAgent(page, {
const agent = await startRunningMockAgent(page, {
prefix: "stream-indicator-",
model: "ten-second-stream",
prompt: "Stream briefly for indicator transition test.",
@@ -39,8 +38,7 @@ test.describe("Agent stream UI", () => {
await expectAgentIdle(page, 30_000);
await expectTurnCopyButton(page);
} finally {
await client.close();
await repo.cleanup();
await agent.cleanup();
}
});
+17
View File
@@ -26,15 +26,26 @@ import {
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let projectId: string;
let workspaceId: string;
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectSeedClient();
const created = await client.createWorkspace({
source: { kind: "directory", path: tempRepo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
}
projectId = created.workspace.projectId;
workspaceId = created.workspace.id;
});
test.afterAll(async () => {
await client?.removeProject(projectId).catch(() => undefined);
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
@@ -42,10 +53,12 @@ test.describe("Archive tab reconciliation", () => {
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
@@ -81,10 +94,12 @@ test.describe("Archive tab reconciliation", () => {
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
@@ -111,10 +126,12 @@ test.describe("Archive tab reconciliation", () => {
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
workspaceId,
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
});
@@ -193,7 +193,7 @@ test.describe("Composer attachments", () => {
page,
}) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
const agent = await startRunningMockAgent(page, {
prefix: "attach-queue-",
model: "one-minute-stream",
prompt: "Stay running for queue test.",
@@ -205,8 +205,7 @@ test.describe("Composer attachments", () => {
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
} finally {
await client.close();
await repo.cleanup();
await agent.cleanup();
}
});
@@ -214,7 +213,7 @@ test.describe("Composer attachments", () => {
page,
}) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
const agent = await startRunningMockAgent(page, {
prefix: "attach-interrupt-",
model: "ten-second-stream",
prompt: "Stay running for interrupt test.",
@@ -226,8 +225,7 @@ test.describe("Composer attachments", () => {
await expectAgentIdle(page, 15_000);
await expectComposerDraft(page, "preserve me");
} finally {
await client.close();
await repo.cleanup();
await agent.cleanup();
}
});
@@ -4,7 +4,7 @@ import { gotoAppShell } from "./helpers/app";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
@@ -66,6 +66,13 @@ async function addProjectFromPicker(page: Page, projectPath: string): Promise<st
return testId!.replace("sidebar-project-row-", "");
}
async function waitForSidebarProjectListReady(page: Page): Promise<void> {
await page
.locator('[data-testid="sidebar-project-empty-state"], [data-testid^="sidebar-project-row-"]')
.first()
.waitFor({ state: "visible", timeout: 60_000 });
}
// Projects are parents in the sidebar. Archiving the last workspace leaves the
// project row in place with a ghost "+ New workspace" child row.
test.describe("Project with no workspaces persists", () => {
@@ -76,7 +83,7 @@ test.describe("Project with no workspaces persists", () => {
try {
await gotoAppShell(page);
await waitForNoProjectsInSidebar(page);
await waitForSidebarProjectListReady(page);
projectId = await addProjectFromPicker(page, repo.path);
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
@@ -92,7 +99,7 @@ test.describe("Project with no workspaces persists", () => {
expect(workspaces.entries).toEqual([]);
} finally {
if (projectId) {
await removeProjectFromSidebar(page, projectId).catch(() => undefined);
await client.removeProject(projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
@@ -163,7 +170,7 @@ test.describe("Project remove", () => {
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
await page.reload();
await waitForSidebarHydration(page);
await waitForSidebarProjectListReady(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readded = await workspace.client.addProject(workspace.repoPath);
@@ -179,7 +186,6 @@ test.describe("Project remove", () => {
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
await removeProjectFromSidebar(page, workspace.projectId).catch(() => undefined);
await workspace.cleanup();
}
});
+3 -13
View File
@@ -35,10 +35,6 @@ function buildSeededStoragePayload() {
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
createWorkspace(input: { source: { kind: "directory"; path: string } }): Promise<{
workspace: { id: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
model: string;
@@ -56,20 +52,14 @@ export interface IdleAgentSeedClient {
export async function createIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; title: string },
input: { cwd: string; workspaceId: string; title: string },
): Promise<ArchiveTabAgent> {
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: input.cwd },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
}
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
cwd: input.cwd,
workspaceId: createdWorkspace.workspace.id,
workspaceId: input.workspaceId,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(
@@ -84,7 +74,7 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
workspaceId: createdWorkspace.workspace.id,
workspaceId: input.workspaceId,
};
}
+21 -5
View File
@@ -149,6 +149,7 @@ export async function selectGithubOption(
export interface MockAgentSetup {
client: SeedDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
cleanup: () => Promise<void>;
}
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
@@ -166,20 +167,29 @@ export async function startRunningMockAgent(
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
}
const workspace = createdWorkspace.workspace;
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
workspaceId: createdWorkspace.workspace.id,
workspaceId: workspace.id,
model: opts.model,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, createdWorkspace.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
return { client, repo };
return {
client,
repo,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
};
}
export interface GithubWorkspaceHandle {
@@ -198,8 +208,14 @@ export async function openGithubWorkspace(
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
}
const workspace = createdWorkspace.workspace;
await gotoAppShell(page);
await selectWorkspaceInSidebar(page, createdWorkspace.workspace.id);
await selectWorkspaceInSidebar(page, workspace.id);
await waitForTabBar(page);
return { cleanup: () => client.close().catch(() => undefined) };
return {
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
}
@@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick<
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "patchDaemonConfig"
| "removeProject"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
+3
View File
@@ -14,6 +14,7 @@ export interface AgentHandle {
page: Page;
client: SeedDaemonClient;
agentId: string;
projectId: string;
workspaceId: string;
cwd: string;
provider: RewindFlowProvider;
@@ -187,6 +188,7 @@ export async function launchAgent(input: {
page: input.page,
client,
agentId: agent.id,
projectId: createdWorkspace.workspace.projectId,
workspaceId: createdWorkspace.workspace.id,
cwd: input.cwd,
provider: input.provider,
@@ -196,6 +198,7 @@ export async function launchAgent(input: {
}
export async function closeAgent(handle: AgentHandle): Promise<void> {
await handle.client.removeProject(handle.projectId).catch(() => undefined);
await handle.client.close().catch(() => undefined);
}
+8 -5
View File
@@ -30,6 +30,7 @@ export interface SeedDaemonClient {
} | null;
error: string | null;
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
@@ -199,15 +200,17 @@ export async function seedWorkspace(options: {
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
return {
client,
repoPath: project.path,
workspaceId: created.workspace.id,
workspaceName: created.workspace.name,
workspaceDirectory: created.workspace.workspaceDirectory,
projectId: created.workspace.projectId,
projectDisplayName: created.workspace.projectDisplayName,
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
await client.close().catch(() => undefined);
await project.cleanup().catch(() => undefined);
},
+5
View File
@@ -28,15 +28,18 @@ function sleep(ms: number): Promise<void> {
export class TerminalE2EHarness {
readonly client: SeedDaemonClient;
readonly tempRepo: TempRepo;
readonly projectId: string;
readonly workspaceId: string;
private constructor(input: {
client: SeedDaemonClient;
tempRepo: TempRepo;
projectId: string;
workspaceId: string;
}) {
this.client = input.client;
this.tempRepo = input.tempRepo;
this.projectId = input.projectId;
this.workspaceId = input.workspaceId;
}
@@ -54,11 +57,13 @@ export class TerminalE2EHarness {
return new TerminalE2EHarness({
client,
tempRepo,
projectId: seedResult.workspace.projectId,
workspaceId: seedResult.workspace.id,
});
}
async cleanup(): Promise<void> {
await this.client.removeProject(this.projectId).catch(() => {});
await this.client.close().catch(() => {});
await this.tempRepo.cleanup().catch(() => {});
}
@@ -37,6 +37,7 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
let client: WorkspaceSetupDaemonClient | null = null;
const repos: Array<{ cleanup: () => Promise<void> }> = [];
const worktrees: WorktreeRecord[] = [];
const projectIds = new Set<string>();
const withWorkspace: WithWorkspace = async (options) => {
if (!client) {
@@ -64,6 +65,7 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
if (!added.project) {
throw new Error(added.error ?? `Failed to add project ${repo.path}`);
}
projectIds.add(added.project.projectId);
}
const created = await client.createWorkspace({
@@ -73,6 +75,7 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
throw new Error(created.error ?? `Failed to create workspace ${workspacePath}`);
}
const workspaceId = created.workspace.id;
projectIds.add(created.workspace.projectId);
return {
workspaceId,
@@ -88,6 +91,11 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
return {
withWorkspace,
cleanup: async () => {
if (client) {
for (const projectId of projectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
}
for (const { repoPath, worktreePath } of worktrees) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
@@ -19,6 +19,7 @@ type WorkspaceSetupDaemonClient = Pick<
| "fetchAgents"
| "fetchWorkspaces"
| "listTerminals"
| "removeProject"
| "subscribeRawMessages"
>;
@@ -54,10 +54,12 @@ test.describe("Settings toggle tab regression", () => {
try {
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-b-${Date.now()}`,
});
@@ -95,10 +97,12 @@ test.describe("Settings toggle tab regression", () => {
try {
const firstAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-b-${Date.now()}`,
});
@@ -34,6 +34,7 @@ test.describe("Workspace agent tab rename", () => {
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: initialTitle,
});
@@ -204,6 +204,7 @@ test.describe("Workspace navigation regression", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-reconnect-${Date.now()}`,
});
@@ -306,6 +307,7 @@ test.describe("Workspace navigation regression", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `workspace-refresh-route-${Date.now()}`,
});
await injectDesktopBridge(page, {
@@ -344,10 +346,12 @@ test.describe("Workspace navigation regression", () => {
try {
const firstAgent = await createIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: `workspace-nav-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: `workspace-nav-b-${Date.now()}`,
});
@@ -18,6 +18,7 @@ test.describe("Workspace pane mounting", () => {
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `pane-remount-${Date.now()}`,
});
@@ -20,6 +20,7 @@ test.describe("Worktree restore after daemon restart", () => {
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
const createdProjectIds = new Set<string>();
test.describe.configure({ retries: 0, timeout: 180_000 });
@@ -34,6 +35,10 @@ test.describe("Worktree restore after daemon restart", () => {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
for (const projectId of createdProjectIds) {
await worktreeClient.removeProject(projectId).catch(() => undefined);
}
createdProjectIds.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
@@ -49,15 +54,18 @@ test.describe("Worktree restore after daemon restart", () => {
// the History table cells must show after restore — never "main".
const worktreeSlug = `restart-restore-${randomUUID().slice(0, 8)}`;
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: worktreeSlug,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restart-restore-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
+13 -2
View File
@@ -25,6 +25,7 @@ test.describe("Worktree restore", () => {
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
const createdProjectIds = new Set<string>();
test.describe.configure({ timeout: 120_000 });
@@ -39,6 +40,10 @@ test.describe("Worktree restore", () => {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
for (const projectId of createdProjectIds) {
await worktreeClient.removeProject(projectId).catch(() => undefined);
}
createdProjectIds.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
@@ -48,15 +53,18 @@ test.describe("Worktree restore", () => {
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
@@ -90,15 +98,18 @@ test.describe("Worktree restore", () => {
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);