mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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:
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user