Files
paseo/packages/app/e2e/browser/launcher-tab.spec.ts

347 lines
14 KiB
TypeScript

import { test, expect } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import {
gotoWorkspace,
assertNewTabMenuTriggerVisible,
openNewTabMenuWithShortcut,
clickNewChat,
clickNewTerminal,
countTabsOfKind,
waitForTabBar,
pressNewTabShortcut,
pressDirectNewTabShortcut,
getTabTestIds,
measureTileTransition,
sampleTabsDuringTransition,
expectTabTitleFits,
terminalSurfaceLocator,
} from "../support/helpers/launcher";
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
import { expectTerminalSurfaceVisible } from "../support/helpers/terminal-perf";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalOutputContains,
seedTerminalProfiles,
type TerminalProfile,
} from "../support/helpers/new-workspace-launch";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getServerId } from "../support/helpers/server-id";
// ─── Shared state ──────────────────────────────────────────────────────────
let workspace: SeededWorkspace;
let secondWorkspaceId: string | null = null;
const EMPTY_PROMPT_PROFILE: TerminalProfile = {
id: "e2e-empty-prompt",
name: "Empty Prompt",
command: "/bin/sh",
args: ["-c", 'echo prompt-args: "$#"; exec cat', "profile-name", "{{{prompt}}}"],
};
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
return tabs.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")),
);
}
function tabIdentityKey(snapshot: Array<{ id: string }>): string {
return JSON.stringify(snapshot.map(({ id }) => id));
}
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
const created = await workspace.client.createWorkspace({
source: { kind: "directory", path: workspace.repoPath, projectId: workspace.projectId },
title: "launcher-e2e-cmd-t-switch-target",
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create secondary workspace for cmd+t switch test");
}
secondWorkspaceId = created.workspace.id;
});
test.afterAll(async () => {
await workspace?.cleanup();
});
// ═══════════════════════════════════════════════════════════════════════════
// Tab Creation Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab creation", () => {
test("Cmd+T keeps creating a New tab after workspace switches", async ({ page }) => {
if (!secondWorkspaceId) {
throw new Error("Secondary workspace was not created");
}
const serverId = getServerId();
const switchWorkspaceRow = async (workspaceId: string) => {
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
// The shortcut must follow the route, so prove the route actually moved first.
await expect(page).toHaveURL(new RegExp(`workspace/${workspaceId}(\\b|/|$)`), {
timeout: 15_000,
});
await waitForTabBar(page);
};
await gotoAppShell(page);
await waitForSidebarHydration(page);
const sequence = [workspace.workspaceId, secondWorkspaceId];
for (let i = 0; i < 8; i++) {
await switchWorkspaceRow(sequence[i % sequence.length]);
await pressNewTabShortcut(page);
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
`New tab did not open after switch ${i}`,
).toBeVisible({
timeout: 5_000,
});
}
});
test("retained inactive workspaces cannot own New tab shortcuts", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const workspaceUrl = page.url();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
await page.keyboard.press(`${modifier}+Comma`);
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
await page.keyboard.press(`${modifier}+t`);
await page.goto(workspaceUrl);
await assertNewTabMenuTriggerVisible(page);
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
});
test("opens the menu, then creates independent New tabs without creating agents", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertNewTabMenuTriggerVisible(page);
await pressNewTabShortcut(page);
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await expect(newTabs.first()).toBeVisible();
const countBefore = await newTabs.count();
const draftCount = await countTabsOfKind(page, "draft");
await test.step("opening the plus menu leaves the current tabs intact", async () => {
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await expect(
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
).toBeVisible();
await expect(newTabs).toHaveCount(countBefore);
await page.keyboard.press("Escape");
});
await test.step("two shortcuts open two independent launchers", async () => {
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 1);
const firstIds = await tabTestIds(newTabs);
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 2);
const ids = await tabTestIds(newTabs);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toEqual(expect.arrayContaining(firstIds));
expect(await countTabsOfKind(page, "draft")).toBe(draftCount);
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
});
});
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openNewTabMenuWithShortcut(page);
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
const agent = panel.getByRole("button", { name: /^Agent/ });
const terminal = panel.getByRole("button", { name: /^Terminal/ });
const diff = panel.getByRole("button", { name: /Diff/ });
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(agent).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(terminal).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(agent).toBeFocused();
await pressDirectNewTabShortcut(page, "e");
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expect(
page.getByTestId("file-explorer-tree-scroll").filter({ visible: true }),
).toBeVisible();
await page.locator("body").click({ position: { x: 1, y: 1 } });
await panel.click({ position: { x: 20, y: 20 } });
await expect(agent).toBeFocused();
});
test("the Agent menu creates a draft and shows its composer within the visual budget", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composerLocator(page),
10_000,
);
expect(elapsed).toBeLessThan(3_000);
await expectComposerVisible(page);
const tabsAfter = await getTabTestIds(page);
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
});
test("the Terminal menu creates a terminal within the visual budget", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,
() => clickNewTerminal(page),
terminalSurfaceLocator(page),
20_000,
);
expect(elapsed).toBeLessThan(5_000);
await expectTerminalSurfaceVisible(page);
const tabsAfter = await getTabTestIds(page);
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
});
test("launching a profile from the New tab menu drops its empty prompt argument", async ({
page,
}) => {
test.setTimeout(45_000);
const profileSeed = await seedTerminalProfiles([EMPTY_PROMPT_PROFILE]);
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page
.getByTestId("workspace-new-tab-menu")
.filter({ visible: true })
.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name })
.click();
await expectTerminalOutputContains(page, "prompt-args: 0");
} finally {
await profileSeed.restore();
}
});
test("terminal profiles are grouped with a settings action", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
const editProfiles = menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles");
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
await editProfiles.click();
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
});
test("Cursor CLI profile uses the Cursor icon in New tab and host settings", async ({ page }) => {
const guessed: TerminalProfile = {
id: "e2e-cursor-guessed",
name: "Cursor guessed",
command: "cursor-agent",
args: ["{{{prompt}}}"],
};
const explicit: TerminalProfile = {
...guessed,
id: "e2e-cursor-explicit",
name: "Cursor explicit",
icon: "cursor",
};
const profileSeed = await seedTerminalProfiles([guessed, explicit]);
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
const menuIconPath = (name: string) =>
menu
.getByRole("menuitem", { name })
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
const guessedMenuPath = await menuIconPath(guessed.name);
const explicitMenuPath = await menuIconPath(explicit.name);
await menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles").click();
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
const settingsIconPath = (id: string) =>
page
.getByTestId(`terminal-profile-row-${id}`)
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
const guessedSettingsPath = await settingsIconPath(guessed.id);
const explicitSettingsPath = await settingsIconPath(explicit.id);
expect(explicitMenuPath).not.toBeNull();
expect(explicitSettingsPath).not.toBeNull();
expect.soft(guessedMenuPath, "New tab Cursor profile icon").toBe(explicitMenuPath);
expect.soft(guessedSettingsPath, "Settings Cursor profile icon").toBe(explicitSettingsPath);
} finally {
await profileSeed.restore();
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// No-Flash Transition Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab transitions (no flash)", () => {
test("New agent tab transition has no blank intermediate tab state", async ({
page,
withWorkspace,
}) => {
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
await isolatedWorkspace.navigateTo();
await pressNewTabShortcut(page);
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
// Sample the single New → Agent replacement, not the separate action that
// creates the New tab in the first place.
const snapshots = await sampleTabsDuringTransition(page, async () => {
await page.getByTestId("workspace-new-tab-agent").filter({ visible: true }).first().click();
});
// Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) {
expect(snapshot.length).toBeGreaterThanOrEqual(1);
}
// Replacement is atomic: the set of identities changes once, while its size stays fixed.
const counts = snapshots.map((snapshot) => snapshot.length);
const initialCount = counts[0] ?? 0;
expect(counts.every((count) => count === initialCount)).toBe(true);
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
});
});