mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
347 lines
14 KiB
TypeScript
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 });
|
|
});
|
|
});
|