test(app): encapsulate Explorer layout setup and cleanup

Keep persisted layout schemas and corrupted-state construction inside the seedCorruptedWorkspaceLayout helper. Use the existing withWorkspace fixture for cleanup in all four Explorer journeys so test bodies follow a straight setup, action and assertion flow.
This commit is contained in:
Mohamed Boudra
2026-09-15 00:48:15 +02:00
parent 287a3db11d
commit 253d34e43c
2 changed files with 208 additions and 210 deletions
@@ -6,11 +6,8 @@
// interactive in the [agent | New | explorer] state.
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../src/stores/workspace-layout-storage";
import type { WorkspaceTab } from "../../src/workspace-tabs/model";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { seedCorruptedWorkspaceLayout } from "../support/helpers/workspace-layout";
import {
openChangesTreePanel,
waitForWorkspaceTabsVisible,
@@ -96,134 +93,137 @@ async function dragChipTo(
await page.mouse.up();
}
function collectConsoleErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("pageerror", (error) => errors.push(`pageerror: ${String(error)}`));
return errors;
}
test.describe("explorer pane tab placement", () => {
test("agents open in the main pane and New-tab splits keep the app usable", async ({
page,
withWorkspace,
e2eWorkerClient,
}, testInfo) => {
const consoleErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${String(error)}`));
const consoleErrors = collectConsoleErrors(page);
const workspace = await seedWorkspace({ repoPrefix: "explorer-pane-placement-" });
const workspace = await withWorkspace({ prefix: "explorer-pane-placement-" });
let agentId = "";
try {
await test.step("choose Agent from the empty workspace launcher", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await openAgentDraftFromLauncher(page);
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
await test.step("choose Agent from the empty workspace launcher", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await openAgentDraftFromLauncher(page);
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
});
await test.step("select Changes in Explorer", async () => {
await selectExplorerChanges(page);
});
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
const agent = await e2eWorkerClient.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Explorer Placement Agent",
modeId: "load-test",
model: "ten-second-stream",
// Keep the agent streaming through the drag so pane contents are live,
// matching the reporter's "spinners were still going" environment.
initialPrompt: "stream please",
});
agentId = agent.id;
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
await expect(
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toBeVisible();
await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0);
// The background open must not change the selected Explorer view.
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
});
await test.step("close the draft; the agent is the main pane's only tab", async () => {
await closeSeededDraftInMainPane(page);
await expect(mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]')).toHaveCount(
1,
);
await testInfo.attach("before-drag", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await test.step("split the agent out of the main pane, leaving it empty", async () => {
const chip = agentTabChip(page, agentId).first();
const mainBox = await paneBoxContainingChip(page, chip);
// Land inside the left 15% edge band of the main pane content.
await dragChipTo(page, chip, {
x: mainBox.x + mainBox.width * 0.06,
y: mainBox.y + mainBox.height * 0.6,
});
await testInfo.attach("after-split", {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
// JS main thread alive?
const pong = await page.evaluate("1 + 1");
expect(pong).toBe(2);
// Pointer interaction alive? Selecting the agent chip must still work.
const chip = agentTabChip(page, agentId).first();
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = newTabPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// A second drag must work: center-drop the agent tab back into the New pane.
const target = await emptyPaneBox(page);
await dragChipTo(page, chip, {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await testInfo.attach("after-center-drop", {
body: await page.screenshot(),
contentType: "image/png",
});
await test.step("select Changes in Explorer", async () => {
await selectExplorerChanges(page);
// The agent chip must have replaced New in that pane and stay clickable.
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
.first()
.click({ position: { x: 12, y: 13 } });
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Explorer Placement Agent",
modeId: "load-test",
model: "ten-second-stream",
// Keep the agent streaming through the drag so pane contents are live,
// matching the reporter's "spinners were still going" environment.
initialPrompt: "stream please",
});
agentId = agent.id;
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
await expect(
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toBeVisible();
await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0);
// The background open must not change the selected Explorer view.
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
// The explorer toggle must still respond.
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
await testInfo.attach("after-interactions", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await test.step("close the draft; the agent is the main pane's only tab", async () => {
await closeSeededDraftInMainPane(page);
await expect(
mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]'),
).toHaveCount(1);
await testInfo.attach("before-drag", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await test.step("split the agent out of the main pane, leaving it empty", async () => {
const chip = agentTabChip(page, agentId).first();
const mainBox = await paneBoxContainingChip(page, chip);
// Land inside the left 15% edge band of the main pane content.
await dragChipTo(page, chip, {
x: mainBox.x + mainBox.width * 0.06,
y: mainBox.y + mainBox.height * 0.6,
});
await testInfo.attach("after-split", {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
// JS main thread alive?
const pong = await page.evaluate("1 + 1");
expect(pong).toBe(2);
// Pointer interaction alive? Selecting the agent chip must still work.
const chip = agentTabChip(page, agentId).first();
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = newTabPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// A second drag must work: center-drop the agent tab back into the New pane.
const target = await emptyPaneBox(page);
await dragChipTo(page, chip, {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await testInfo.attach("after-center-drop", {
body: await page.screenshot(),
contentType: "image/png",
});
// The agent chip must have replaced New in that pane and stay clickable.
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
.first()
.click({ position: { x: 12, y: 13 } });
// The explorer toggle must still respond.
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
await testInfo.attach("after-interactions", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await testInfo.attach("console-errors", {
body: JSON.stringify(consoleErrors, null, 2),
contentType: "application/json",
});
} finally {
await workspace.cleanup();
}
await testInfo.attach("console-errors", {
body: JSON.stringify(consoleErrors, null, 2),
contentType: "application/json",
});
});
});
@@ -261,106 +261,47 @@ async function openAgentDraftFromLauncher(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
async function persistHiddenGeneratedExplorer(page: Page): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Explorer fixture: workspace layout was not persisted");
const stored = z
.strictObject({
state: WorkspaceLayoutPersistedStateSchema,
version: z.number().int().nonnegative().optional(),
})
.parse(JSON.parse(raw), {
error: () => "Explorer fixture: invalid persisted workspace layout",
});
const [key] = Object.keys(stored.state.layoutByWorkspace);
if (!key) throw new Error("Explorer fixture: persisted layout contains no workspace");
const tabs: WorkspaceTab[] = [
{ tabId: "files", target: { kind: "files" }, createdAt: 1 },
{ tabId: "changes_tree", target: { kind: "changes_tree" }, createdAt: 1 },
...Array.from(
{ length: 2250 },
(_, index): WorkspaceTab => ({
tabId: `draft_saved_${index}`,
target: { kind: "draft", draftId: `draft_saved_${index}` },
createdAt: index + 2,
}),
),
];
const corruptedLayout = {
root: {
kind: "pane",
pane: {
id: "pane_generated_report_equivalent",
hidden: true,
tabIds: tabs.map((tab) => tab.tabId),
tabs,
focusedTabId: tabs[tabs.length - 1].tabId,
},
},
focusedPaneId: null,
};
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: corruptedLayout },
explorerPaneIdByWorkspace: {
...stored.state.explorerPaneIdByWorkspace,
[key]: "pane_generated_report_equivalent",
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
// Explorer cannot replace the ordinary workspace canvas, including on restore.
test("closing the last split-born tab with hidden Explorer keeps a usable workspace", async ({
page,
withWorkspace,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "last-pane-hidden-explorer-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await openAgentDraftFromLauncher(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await openAgentDraftFromLauncher(page);
await moveOnlyDraftIntoRightSplit(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await page.reload();
await expectNewLauncher(page);
} finally {
await workspace.cleanup();
}
const workspace = await withWorkspace({ prefix: "last-pane-hidden-explorer-" });
await gotoWorkspace(page, workspace.workspaceId);
await openAgentDraftFromLauncher(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await openAgentDraftFromLauncher(page);
await moveOnlyDraftIntoRightSplit(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await page.reload();
await expectNewLauncher(page);
});
test("visible Explorer does not replace the last ordinary workspace pane", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "last-pane-visible-explorer-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
await openAgentDraftFromLauncher(page);
await moveOnlyDraftIntoRightSplit(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await page.reload();
await expectNewLauncher(page);
} finally {
await workspace.cleanup();
}
test("visible Explorer does not replace the last ordinary workspace pane", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "last-pane-visible-explorer-" });
await gotoWorkspace(page, workspace.workspaceId);
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
await openAgentDraftFromLauncher(page);
await moveOnlyDraftIntoRightSplit(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await page.reload();
await expectNewLauncher(page);
});
test("reloading a saved hidden generated Explorer recovers a usable workspace", async ({
page,
withWorkspace,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "saved-hidden-explorer-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await expectNewLauncher(page);
await persistHiddenGeneratedExplorer(page);
await page.reload();
await expectNewLauncher(page);
} finally {
await workspace.cleanup();
}
const workspace = await withWorkspace({ prefix: "saved-hidden-explorer-" });
await gotoWorkspace(page, workspace.workspaceId);
await expectNewLauncher(page);
await seedCorruptedWorkspaceLayout(page);
await page.reload();
await expectNewLauncher(page);
});
@@ -0,0 +1,57 @@
import type { Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import type { WorkspaceTab } from "../../../src/workspace-tabs/model";
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Explorer fixture: workspace layout was not persisted");
const stored = z
.strictObject({
state: WorkspaceLayoutPersistedStateSchema,
version: z.number().int().nonnegative().optional(),
})
.parse(JSON.parse(raw), {
error: () => "Explorer fixture: invalid persisted workspace layout",
});
const [key] = Object.keys(stored.state.layoutByWorkspace);
if (!key) throw new Error("Explorer fixture: persisted layout contains no workspace");
const tabs: WorkspaceTab[] = [
{ tabId: "files", target: { kind: "files" }, createdAt: 1 },
{ tabId: "changes_tree", target: { kind: "changes_tree" }, createdAt: 1 },
...Array.from(
{ length: 2250 },
(_, index): WorkspaceTab => ({
tabId: `draft_saved_${index}`,
target: { kind: "draft", draftId: `draft_saved_${index}` },
createdAt: index + 2,
}),
),
];
const corruptedLayout = {
root: {
kind: "pane",
pane: {
id: "pane_generated_report_equivalent",
hidden: true,
tabIds: tabs.map((tab) => tab.tabId),
tabs,
focusedTabId: tabs[tabs.length - 1].tabId,
},
},
focusedPaneId: null,
};
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: corruptedLayout },
explorerPaneIdByWorkspace: {
...stored.state.explorerPaneIdByWorkspace,
[key]: "pane_generated_report_equivalent",
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}