mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
* fix(app): retain Explorer resize width until acknowledged Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): choose workspace header by measured column width Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): separate iOS composer clipping from its shadow Keep explicit coverage of the existing keyboard rotation guard. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): choose workspace chrome within each desktop pane The outer width wrapper added a compact header while the desktop split container kept its full header and pane rails. Each pane now owns the complete choice, replacing its header and rail together. Each split pane uses its own measured width: below 500 points it shows its own tab dropdown, even if the neighboring pane is wide. Pane width determines usable header space. Keep new-tab keyboard ownership outside the switched chrome and retain positive measurements while hidden. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): reserve readable width for the compact tab popover Native scroll content does not contribute intrinsic width to the anchored frame. The count badge alone allowed the popover to compress its tab list and footer labels. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): preserve compact pane actions and window clearance Carry the owning pane into compact launch actions, keep split and restore controls in its menu, and distribute window corners through visible split children. Keep tab rails below header-owned window controls. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): retain window dragging in compact panes Keep the shared titlebar drag region when pane width replaces full desktop chrome. Cover two compact panes with both sidebars closed through the desktop host adapter. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(app): align pane journeys with measured workspace chrome Give rail-specific split journeys enough column width, assert launched Diff content independently of header shape, and retain pane identity across compact transitions. Expect the compact selector in the half-screen Explorer workflow. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * refactor(app): share pane tab-switcher rendering after header rebase Keep compact and vertical-bar header actions on the same pane-aware switcher contract while preserving the merged control geometry and script ownership. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): retain Explorer acknowledgements across consecutive drags Keep earlier released widths as expected allocations while a newer gesture owns the frame, and restore the latest release if that gesture is canceled. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep focus mode exit in the compact workspace menu Carry the workspace focus state into its compact action menu so a narrow header retains an exit without keyboard shortcuts. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): distinguish resize rollback from pending acknowledgements Restore the last released width when repeated widths precede a canceled gesture, and retain ownership when an unchanged old allocation matches a newer release before its predecessors arrive. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(app): allow software WebGL time to finish atlas growth coverage The 256-batch pixel regression crosses more than 500 render boundaries and exceeds its 30-second total budget under CPU contention. Keep the corpus, every pixel assertion, and the two-second per-frame deadline; allow 60 seconds for the complete workload. Reproduced the timeout with Chromium confined to one CPU and verified two successful constrained runs after the adjustment. Co-authored-by: Elsa Granger <zeyugao@outlook.com> --------- Co-authored-by: Elsa Granger <zeyugao@outlook.com>
671 lines
25 KiB
TypeScript
671 lines
25 KiB
TypeScript
import path from "node:path";
|
|
import type { Page, TestInfo } from "@playwright/test";
|
|
import { test, expect } from "../support/fixtures";
|
|
import { gotoAppShell } from "../support/helpers/app";
|
|
import {
|
|
closeMobileAgentSidebar,
|
|
expectMobileAgentSidebarHidden,
|
|
expectMobileAgentSidebarVisible,
|
|
expectWorkspaceHoverCardClosed,
|
|
expectWorkspaceHoverCardOpen,
|
|
expectWorkspaceHoverCardStaysOpen,
|
|
focusWorkspaceRowWithKeyboard,
|
|
movePointerAwayFromWorkspaceHoverCard,
|
|
openMobileAgentSidebar,
|
|
pinWorkspaceFromSidebar,
|
|
scrollWorkspaceList,
|
|
} from "../support/helpers/sidebar";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
|
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
|
import { escapeRegex } from "../support/helpers/regex";
|
|
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
|
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
|
|
|
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
|
|
|
|
function getWorkspaceRowTestId(workspaceId: string): string {
|
|
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
|
}
|
|
|
|
async function openWorkspaceFromSidebar(
|
|
page: import("@playwright/test").Page,
|
|
workspaceId: string,
|
|
) {
|
|
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
await row.click();
|
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
|
return row;
|
|
}
|
|
|
|
async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) {
|
|
const row = page
|
|
.getByRole("button", {
|
|
name: new RegExp(escapeRegex(projectName), "i"),
|
|
})
|
|
.first();
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
return row;
|
|
}
|
|
|
|
async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) {
|
|
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
return row;
|
|
}
|
|
|
|
async function openWorkspaceReadAction(
|
|
page: import("@playwright/test").Page,
|
|
workspaceId: string,
|
|
action: "read" | "unread",
|
|
) {
|
|
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
|
const row = await waitForSidebarWorkspace(page, workspaceId);
|
|
await row.hover();
|
|
await page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`).click();
|
|
const item = page.getByTestId(`sidebar-workspace-menu-mark-as-${action}-${workspaceKey}`);
|
|
await expect(item).toBeVisible({ timeout: 30_000 });
|
|
return item;
|
|
}
|
|
|
|
async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) {
|
|
const row = await waitForSidebarWorkspace(page, workspaceId);
|
|
await row.hover();
|
|
|
|
const hoverCard = page.getByRole("menu", { name: "Workspace scripts" });
|
|
await expect(hoverCard).toBeVisible({ timeout: 30_000 });
|
|
return hoverCard;
|
|
}
|
|
|
|
interface PaseoOwnedWorktree {
|
|
projectName: string;
|
|
workspaceId: string;
|
|
worktreeSlug: string;
|
|
}
|
|
|
|
async function withPaseoOwnedWorktree(
|
|
run: (workspace: PaseoOwnedWorktree) => Promise<void>,
|
|
): Promise<void> {
|
|
const project = await seedWorkspace({ repoPrefix: "sidebar-hover-owned-worktree-" });
|
|
const worktreeSlug = "hover-card-owned-worktree";
|
|
|
|
try {
|
|
const created = await project.client.createWorkspace({
|
|
source: {
|
|
kind: "worktree",
|
|
cwd: project.repoPath,
|
|
projectId: project.projectId,
|
|
worktreeSlug,
|
|
},
|
|
});
|
|
if (!created.workspace) {
|
|
throw new Error(created.error ?? "Failed to create Paseo-owned worktree");
|
|
}
|
|
expect(path.basename(created.workspace.workspaceDirectory)).toBe(worktreeSlug);
|
|
|
|
await run({
|
|
projectName: path.basename(project.repoPath),
|
|
workspaceId: created.workspace.id,
|
|
worktreeSlug,
|
|
});
|
|
} finally {
|
|
await project.cleanup();
|
|
}
|
|
}
|
|
|
|
async function readCachedRowIds(
|
|
page: Page,
|
|
rowKind: "workspace" | "checkpoint",
|
|
): Promise<string[]> {
|
|
return page.evaluate(
|
|
async ({ serverId, kind }) => {
|
|
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
const request = indexedDB.open("paseo-replica-row-store");
|
|
request.addEventListener("success", () => resolve(request.result));
|
|
request.addEventListener("error", () => reject(request.error));
|
|
});
|
|
try {
|
|
return await new Promise<string[]>((resolve, reject) => {
|
|
const transaction = database.transaction("rows", "readonly");
|
|
const request = transaction.objectStore("rows").getAll();
|
|
request.onsuccess = () =>
|
|
resolve(
|
|
request.result
|
|
.filter(
|
|
(row: { serverId: string; kind: string }) =>
|
|
row.serverId === serverId && row.kind === kind,
|
|
)
|
|
.map((row: { id: string }) => row.id)
|
|
.sort(),
|
|
);
|
|
request.addEventListener("error", () => reject(request.error));
|
|
});
|
|
} finally {
|
|
database.close();
|
|
}
|
|
},
|
|
{ serverId: getServerId(), kind: rowKind },
|
|
);
|
|
}
|
|
|
|
async function simulateDamagedLegacyDirectoryCache(
|
|
page: Page,
|
|
workspaceIdToRemove: string,
|
|
): Promise<void> {
|
|
// Stop the app before altering storage so no pending app write can repair the fixture.
|
|
const fixtureUrl = new URL("/__directory_cache_fixture", page.url()).href;
|
|
await page.route(fixtureUrl, (route) =>
|
|
route.fulfill({ contentType: "text/html", body: "<html><body>Cache fixture</body></html>" }),
|
|
);
|
|
await page.goto(fixtureUrl);
|
|
await page.evaluate(
|
|
async ({ serverId, missingWorkspaceId }) => {
|
|
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
const request = indexedDB.open("paseo-replica-row-store");
|
|
request.addEventListener("success", () => resolve(request.result));
|
|
request.addEventListener("error", () => reject(request.error));
|
|
});
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const transaction = database.transaction("rows", "readwrite");
|
|
const store = transaction.objectStore("rows");
|
|
const request = store.getAll();
|
|
request.onsuccess = () => {
|
|
const rows = request.result as Array<{
|
|
serverId: string;
|
|
kind: string;
|
|
id: string;
|
|
payload: string;
|
|
}>;
|
|
const checkpoint = rows.find(
|
|
(row) => row.serverId === serverId && row.kind === "checkpoint",
|
|
);
|
|
if (!checkpoint) {
|
|
transaction.abort();
|
|
return;
|
|
}
|
|
const stored = JSON.parse(checkpoint.payload);
|
|
// Accept either writer so this fixture also reproduces on the unfixed client.
|
|
const cursors = stored.version === 1 ? stored.cursors : stored;
|
|
if (!cursors.workspaces?.generation) {
|
|
transaction.abort();
|
|
return;
|
|
}
|
|
store.put({ ...checkpoint, payload: JSON.stringify(cursors) });
|
|
for (const row of rows) {
|
|
if (
|
|
row.serverId === serverId &&
|
|
row.kind === "workspace" &&
|
|
row.id === missingWorkspaceId
|
|
)
|
|
store.delete([serverId, row.kind, row.id]);
|
|
}
|
|
};
|
|
transaction.addEventListener("complete", () => resolve());
|
|
transaction.addEventListener("abort", () =>
|
|
reject(transaction.error ?? new Error("Missing current directory checkpoint")),
|
|
);
|
|
transaction.addEventListener("error", () => reject(transaction.error));
|
|
});
|
|
} finally {
|
|
database.close();
|
|
}
|
|
},
|
|
{ serverId: getServerId(), missingWorkspaceId: workspaceIdToRemove },
|
|
);
|
|
expect(await readCachedRowIds(page, "workspace")).not.toContain(workspaceIdToRemove);
|
|
}
|
|
|
|
async function holdHostResponsesUntilCacheIsVisible(page: Page): Promise<() => void> {
|
|
let release!: () => void;
|
|
const ready = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.routeWebSocket(new RegExp(`:${getE2EDaemonPort()}(?:/|$)`), (socket) => {
|
|
const server = socket.connectToServer();
|
|
server.onMessage(async (message) => {
|
|
await ready;
|
|
socket.send(message);
|
|
});
|
|
});
|
|
return release;
|
|
}
|
|
|
|
async function createPinnedSiblingWorkspace(
|
|
project: Awaited<ReturnType<typeof seedWorkspace>>,
|
|
): Promise<string> {
|
|
const sibling = await project.client.createWorkspace({
|
|
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
|
|
title: "Pinned sibling",
|
|
});
|
|
if (!sibling.workspace) throw new Error(sibling.error ?? "Sibling workspace was not created");
|
|
await project.client.setWorkspacePinned(sibling.workspace.id, true);
|
|
return sibling.workspace.id;
|
|
}
|
|
|
|
async function expectSidebarWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
|
for (const workspaceId of workspaceIds) {
|
|
await waitForSidebarWorkspace(page, workspaceId);
|
|
}
|
|
}
|
|
|
|
async function cacheWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
|
await gotoAppShell(page);
|
|
await expectSidebarWorkspaces(page, workspaceIds);
|
|
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
|
}
|
|
|
|
async function reopenDamagedCacheBeforeHostResponds(
|
|
page: Page,
|
|
missingWorkspaceId: string,
|
|
): Promise<() => void> {
|
|
await simulateDamagedLegacyDirectoryCache(page, missingWorkspaceId);
|
|
const releaseHost = await holdHostResponsesUntilCacheIsVisible(page);
|
|
await gotoAppShell(page);
|
|
return releaseHost;
|
|
}
|
|
|
|
async function expectDamagedCacheHydrated(
|
|
page: Page,
|
|
retainedWorkspaceId: string,
|
|
missingWorkspaceId: string,
|
|
): Promise<void> {
|
|
await waitForSidebarWorkspace(page, retainedWorkspaceId);
|
|
await expect(page.getByTestId(getWorkspaceRowTestId(missingWorkspaceId))).toHaveCount(0);
|
|
await expect.poll(() => readCachedRowIds(page, "checkpoint")).toEqual([]);
|
|
}
|
|
|
|
async function expectRecoveredWorkspacesAfterReload(
|
|
page: Page,
|
|
workspaceIds: string[],
|
|
testInfo: TestInfo,
|
|
): Promise<void> {
|
|
await expectSidebarWorkspaces(page, workspaceIds);
|
|
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
|
await page.reload();
|
|
await expectSidebarWorkspaces(page, workspaceIds);
|
|
await page.screenshot({ path: testInfo.outputPath("recovered-workspaces.png") });
|
|
await testInfo.attach("recovered-workspaces", {
|
|
path: testInfo.outputPath("recovered-workspaces.png"),
|
|
contentType: "image/png",
|
|
});
|
|
}
|
|
|
|
test.describe("Sidebar workspace list", () => {
|
|
test("recovers missing workspaces from a damaged legacy cache after reload", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const project = await seedWorkspace({ repoPrefix: "directory-cache-recovery-" });
|
|
try {
|
|
const siblingId = await createPinnedSiblingWorkspace(project);
|
|
const ids = [project.workspaceId, siblingId].sort();
|
|
await test.step("cache both workspaces", () => cacheWorkspaces(page, ids));
|
|
|
|
const releaseHost = await test.step("reopen damaged cache before host responds", () =>
|
|
reopenDamagedCacheBeforeHostResponds(page, project.workspaceId));
|
|
await test.step("keep cached workspace and discard stale checkpoint", () =>
|
|
expectDamagedCacheHydrated(page, siblingId, project.workspaceId));
|
|
|
|
releaseHost();
|
|
await test.step("recover missing workspace and retain both after reload", () =>
|
|
expectRecoveredWorkspacesAfterReload(page, ids, testInfo));
|
|
} finally {
|
|
await project.cleanup();
|
|
}
|
|
});
|
|
|
|
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
|
const workspace = await seedWorkspace({
|
|
repoPrefix: "sidebar-remote-",
|
|
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
|
});
|
|
|
|
try {
|
|
const projectName = path.basename(workspace.repoPath);
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, projectName);
|
|
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
|
|
|
const projectRow = page
|
|
.locator('[data-testid^="sidebar-project-row-"]')
|
|
.filter({ hasText: projectName })
|
|
.first();
|
|
|
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
|
await expect(projectRow).not.toContainText("test-owner/test-repo");
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("non-git project shows directory name", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
|
|
const directoryName = path.basename(workspace.repoPath);
|
|
const projectRow = await waitForSidebarProject(page, directoryName);
|
|
await expect(projectRow).toContainText(directoryName);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({
|
|
repoPrefix: "sidebar-header-",
|
|
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
|
});
|
|
|
|
try {
|
|
const projectName = path.basename(workspace.repoPath);
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, projectName);
|
|
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
|
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
|
|
|
await expectWorkspaceHeader(page, {
|
|
title: workspace.workspaceName,
|
|
subtitle: projectName,
|
|
});
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("git project shows branch name in workspace row", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
|
|
expect(workspace.workspaceName).toBe("main");
|
|
await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText(
|
|
"main",
|
|
);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("workspace hover card shows host as metadata", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
|
|
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
|
|
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
|
|
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
const row = await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
|
await movePointerAwayFromWorkspaceHoverCard(page);
|
|
await expectWorkspaceHoverCardClosed(page);
|
|
|
|
await row.click();
|
|
await expect(row).toBeFocused();
|
|
await expectWorkspaceHoverCardOpen(page);
|
|
await movePointerAwayFromWorkspaceHoverCard(page);
|
|
await expectWorkspaceHoverCardClosed(page);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
|
await movePointerAwayFromWorkspaceHoverCard(page);
|
|
await expectWorkspaceHoverCardClosed(page);
|
|
|
|
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
|
await expectWorkspaceHoverCardOpen(page);
|
|
await movePointerAwayFromWorkspaceHoverCard(page);
|
|
await expectWorkspaceHoverCardStaysOpen(page);
|
|
await page.keyboard.press("Escape");
|
|
await expectWorkspaceHoverCardClosed(page);
|
|
|
|
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
|
await expectWorkspaceHoverCardOpen(page);
|
|
await page.keyboard.press("Tab");
|
|
await expectWorkspaceHoverCardClosed(page);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
|
|
const workspace = await seedMockAgentWorkspace({
|
|
repoPrefix: "sidebar-mark-unread-",
|
|
title: "Mark unread",
|
|
initialPrompt: "Finish this test turn.",
|
|
});
|
|
|
|
try {
|
|
await workspace.client.waitForFinish(workspace.agentId, 20_000);
|
|
await workspace.client.clearWorkspaceAttention(workspace.workspaceId);
|
|
expect(workspace.client.getLastServerInfoMessage()?.features?.workspaceMarkUnread).toBe(true);
|
|
await gotoAppShell(page);
|
|
|
|
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
|
await expect(row.getByTestId("workspace-status-indicator-done")).toBeVisible();
|
|
await (await openWorkspaceReadAction(page, workspace.workspaceId, "unread")).click();
|
|
await openWorkspaceReadAction(page, workspace.workspaceId, "read");
|
|
|
|
await page.keyboard.press("Escape");
|
|
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
|
await openWorkspaceReadAction(page, workspace.workspaceId, "unread");
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => {
|
|
await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => {
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, projectName);
|
|
await openWorkspaceHoverCard(page, workspaceId);
|
|
|
|
await expect(page.getByTestId("hover-card-workspace-cwd")).toHaveText(worktreeSlug);
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe("Mobile sidebar panelState transition", () => {
|
|
test.use({ viewport: { width: 390, height: 844 } });
|
|
|
|
test("showMobileAgent open and close transition", async ({ page }) => {
|
|
await gotoAppShell(page);
|
|
await expectMobileAgentSidebarHidden(page);
|
|
await openMobileAgentSidebar(page);
|
|
await expectMobileAgentSidebarVisible(page);
|
|
await closeMobileAgentSidebar(page);
|
|
await expectMobileAgentSidebarHidden(page);
|
|
});
|
|
|
|
test("places the close button beside the first nav row instead of over it", async ({ page }) => {
|
|
await gotoAppShell(page);
|
|
await openMobileAgentSidebar(page);
|
|
await expectMobileAgentSidebarVisible(page);
|
|
|
|
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
|
|
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
|
|
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
|
|
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
|
|
});
|
|
|
|
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await openMobileAgentSidebar(page);
|
|
await expectMobileAgentSidebarVisible(page);
|
|
|
|
const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId));
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
await pinWorkspaceFromSidebar(page, workspace.workspaceId);
|
|
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
|
|
|
|
await closeMobileAgentSidebar(page);
|
|
await expectMobileAgentSidebarHidden(page);
|
|
|
|
await expect(row).toHaveCount(1);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe("Half-screen desktop layout", () => {
|
|
test.use({ viewport: { width: 751, height: 982 } });
|
|
|
|
test("keeps the sidebar scroll position across close and reopen", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-scroll-" });
|
|
|
|
try {
|
|
let lastWorkspaceId = workspace.workspaceId;
|
|
for (let index = 0; index < 24; index += 1) {
|
|
const created = await workspace.client.createWorkspace({
|
|
source: {
|
|
kind: "directory",
|
|
path: workspace.repoPath,
|
|
projectId: workspace.projectId,
|
|
},
|
|
title: `Retained sidebar ${index + 1}`,
|
|
});
|
|
if (!created.workspace) {
|
|
throw new Error(created.error ?? "Failed to fill the retained sidebar");
|
|
}
|
|
lastWorkspaceId = created.workspace.id;
|
|
}
|
|
|
|
await gotoAppShell(page);
|
|
await page
|
|
.getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(workspace.projectKey)}`)
|
|
.click();
|
|
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
|
|
|
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
|
const scrollTop = await sidebarScroll.evaluate((element) => {
|
|
element.scrollTop = 160;
|
|
return element.scrollTop;
|
|
});
|
|
expect(scrollTop).toBe(160);
|
|
|
|
await page.getByTestId("menu-button").click();
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
|
|
|
await page.getByTestId("menu-button").click();
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
|
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
|
|
|
|
await movePointerAwayFromWorkspaceHoverCard(page);
|
|
await focusWorkspaceRowWithKeyboard(page, lastWorkspaceId);
|
|
await expectWorkspaceHoverCardOpen(page);
|
|
await scrollWorkspaceList(page);
|
|
await expectWorkspaceHoverCardClosed(page);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => {
|
|
await gotoAppShell(page);
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
|
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
|
|
});
|
|
|
|
test("keeps the left toggle center-owned without left window controls", async ({ page }) => {
|
|
await gotoAppShell(page);
|
|
|
|
const openToggle = page.getByTestId("menu-button");
|
|
const openIcon = openToggle.locator("svg").first();
|
|
await expect(openIcon).toBeVisible();
|
|
const openBounds = await openIcon.boundingBox();
|
|
expect(openBounds).not.toBeNull();
|
|
expect(openBounds?.x).toBeGreaterThan(12);
|
|
|
|
await openToggle.click();
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
|
|
|
const closedToggle = page.getByTestId("menu-button");
|
|
const closedIcon = closedToggle.locator("svg").first();
|
|
await expect(closedIcon).toBeVisible();
|
|
const closedBounds = await closedIcon.boundingBox();
|
|
expect(closedBounds).not.toBeNull();
|
|
expect(closedBounds?.x).toBeCloseTo(9, 0);
|
|
expect(closedBounds?.y).toBe(openBounds?.y);
|
|
});
|
|
|
|
test("yields app navigation to the settings split", async ({ page }) => {
|
|
await gotoAppShell(page);
|
|
await page.getByTestId("sidebar-settings").click();
|
|
|
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
|
await expect(page.getByTestId("settings-detail-pane")).toBeVisible();
|
|
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
|
|
});
|
|
|
|
test("yields app navigation to Explorer and restores it when Explorer closes", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
|
|
|
await openFilesPanel(page);
|
|
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
|
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
|
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
|
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
|
await expect(
|
|
page
|
|
.locator('[data-testid^="workspace-pane-"]')
|
|
.getByTestId("workspace-tabs-row")
|
|
.filter({ visible: true }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
|
|
).toHaveCount(1);
|
|
|
|
await explorerToggle.click();
|
|
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|