Cmd/Ctrl+T now opens the focused pane's launcher, while explicit target shortcuts bypass the menu. Dispatcher ownership is scoped to the app tree so retained workspaces cannot intercept commands.
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { test, expect } from "../support/fixtures";
|
|
import {
|
|
gotoWorkspace,
|
|
assertNewChatTileVisible,
|
|
assertNewTabMenuTriggerVisible,
|
|
assertSingleNewTabButton,
|
|
openNewTabMenuWithShortcut,
|
|
pressDirectNewTabShortcut,
|
|
clickNewChat,
|
|
clickNewTerminal,
|
|
countTabsOfKind,
|
|
getTabTestIds,
|
|
waitForTabWithTitle,
|
|
measureTileTransition,
|
|
sampleTabsDuringTransition,
|
|
expectTabTitleFits,
|
|
terminalSurfaceLocator,
|
|
} from "../support/helpers/launcher";
|
|
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
|
|
import {
|
|
expectTerminalSurfaceVisible,
|
|
setupDeterministicPrompt,
|
|
} from "../support/helpers/terminal-perf";
|
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
|
|
|
// ─── Shared state ──────────────────────────────────────────────────────────
|
|
|
|
let workspace: SeededWorkspace;
|
|
|
|
test.beforeAll(async () => {
|
|
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await workspace?.cleanup();
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Tab Creation Tests
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe("Tab creation", () => {
|
|
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";
|
|
|
|
await page.keyboard.press(`${modifier}+Comma`);
|
|
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
|
|
|
|
await page.keyboard.press(`${modifier}+t`);
|
|
await expect(page.getByRole("menuitem", { name: /New agent/ })).toHaveCount(0);
|
|
|
|
await page.goto(workspaceUrl);
|
|
await assertNewTabMenuTriggerVisible(page);
|
|
const countBefore = await countTabsOfKind(page, "draft");
|
|
await pressDirectNewTabShortcut(page, "a");
|
|
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
|
|
});
|
|
|
|
test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
const countBefore = await countTabsOfKind(page, "draft");
|
|
|
|
await openNewTabMenuWithShortcut(page);
|
|
|
|
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
|
|
await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible();
|
|
});
|
|
|
|
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
|
|
const countBefore = await countTabsOfKind(page, "draft");
|
|
|
|
await pressDirectNewTabShortcut(page, "a");
|
|
await expect
|
|
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
|
.toBe(countBefore + 1);
|
|
const countAfterFirst = await countTabsOfKind(page, "draft");
|
|
|
|
await pressDirectNewTabShortcut(page, "a");
|
|
await expect
|
|
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
|
.toBe(countAfterFirst + 1);
|
|
});
|
|
|
|
test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
await openNewTabMenuWithShortcut(page);
|
|
|
|
const agent = page.getByRole("menuitem", { name: /New agent/ });
|
|
const terminal = page.getByRole("menuitem", { name: /New terminal/ });
|
|
const changes = page.getByRole("menuitem", { name: /Changes/ });
|
|
const files = page.getByRole("menuitem", { name: /Files/ });
|
|
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(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`));
|
|
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
|
await expect(agent).toBeFocused();
|
|
|
|
await page.keyboard.press("ArrowDown");
|
|
await expect(terminal).toBeFocused();
|
|
await page.keyboard.press("ArrowUp");
|
|
await expect(agent).toBeFocused();
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
|
|
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
|
|
"aria-expanded",
|
|
"false",
|
|
);
|
|
});
|
|
|
|
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
|
|
await clickNewChat(page);
|
|
|
|
await expectComposerVisible(page);
|
|
|
|
const tabsAfter = await getTabTestIds(page);
|
|
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
|
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
|
|
test.setTimeout(45_000);
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
|
|
await clickNewTerminal(page);
|
|
|
|
await expectTerminalSurfaceVisible(page);
|
|
|
|
const tabsAfter = await getTabTestIds(page);
|
|
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
|
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test("tab bar shows action buttons per pane", async ({ page }) => {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
await assertSingleNewTabButton(page);
|
|
await assertNewChatTileVisible(page);
|
|
await assertNewTabMenuTriggerVisible(page);
|
|
});
|
|
|
|
test("never shades the New tab button", async ({ page }) => {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
|
|
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
|
|
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
|
|
const newTabButtonInScroll = await scrollArea
|
|
.getByTestId("workspace-new-tab-menu-trigger")
|
|
.count();
|
|
const scrollShades = await tabRow
|
|
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
|
|
.count();
|
|
|
|
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
|
|
await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Terminal Title Tests
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe("Terminal title propagation", () => {
|
|
// OSC title escape sequence propagation is inherently flaky — the terminal
|
|
// must process the sequence, emit a title change event, and the tab bar
|
|
// must re-render before the assertion deadline. Allow retries.
|
|
test.describe.configure({ retries: 2 });
|
|
|
|
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
|
|
const result = await workspace.client.createTerminal(
|
|
workspace.repoPath,
|
|
"title-test",
|
|
undefined,
|
|
{
|
|
workspaceId: workspace.workspaceId,
|
|
},
|
|
);
|
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
|
const terminalId = result.terminal.id;
|
|
|
|
try {
|
|
// Navigate to workspace and open a terminal
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
await clickNewTerminal(page);
|
|
|
|
await expectTerminalSurfaceVisible(page);
|
|
await terminalSurfaceLocator(page).click();
|
|
|
|
await setupDeterministicPrompt(page);
|
|
|
|
// Send OSC 0 (set window title) escape sequence
|
|
const testTitle = `E2E-Title-${Date.now()}`;
|
|
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
|
|
delay: 0,
|
|
});
|
|
|
|
// Wait for the tab to reflect the new title
|
|
await waitForTabWithTitle(page, testTitle, 15_000);
|
|
} finally {
|
|
await workspace.client.killTerminal(terminalId).catch(() => {});
|
|
}
|
|
});
|
|
|
|
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
|
|
const result = await workspace.client.createTerminal(
|
|
workspace.repoPath,
|
|
"debounce-test",
|
|
undefined,
|
|
{
|
|
workspaceId: workspace.workspaceId,
|
|
},
|
|
);
|
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
|
const terminalId = result.terminal.id;
|
|
|
|
try {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
await clickNewTerminal(page);
|
|
|
|
await expectTerminalSurfaceVisible(page);
|
|
await terminalSurfaceLocator(page).click();
|
|
|
|
await setupDeterministicPrompt(page);
|
|
|
|
// Fire many rapid title changes — only the last should stick
|
|
const finalTitle = `Final-${Date.now()}`;
|
|
for (let i = 0; i < 5; i++) {
|
|
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
|
|
delay: 0,
|
|
});
|
|
}
|
|
await terminalSurfaceLocator(page).pressSequentially(
|
|
`printf '\\033]0;${finalTitle}\\007'\n`,
|
|
{ delay: 0 },
|
|
);
|
|
|
|
// The tab should eventually settle on the final title
|
|
await waitForTabWithTitle(page, finalTitle, 15_000);
|
|
} finally {
|
|
await workspace.client.killTerminal(terminalId).catch(() => {});
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// No-Flash Transition Tests
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe("Tab transitions (no flash)", () => {
|
|
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
|
|
// Sample tabs at high frequency across the transition
|
|
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
|
|
|
|
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
|
for (const snapshot of snapshots) {
|
|
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
|
}
|
|
|
|
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
|
|
// When running in-suite, previous tests may have created tabs on the shared workspace,
|
|
// so we allow +2 tolerance for accumulated state and React render batching.
|
|
const counts = snapshots.map((snapshot) => snapshot.length);
|
|
const maxCount = Math.max(...counts);
|
|
const initialCount = counts[0] ?? 0;
|
|
|
|
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
|
|
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
|
|
2,
|
|
);
|
|
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
|
});
|
|
|
|
test("Terminal transition completes within visual budget", async ({ page }) => {
|
|
test.setTimeout(30_000);
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
|
|
const elapsed = await measureTileTransition(
|
|
page,
|
|
() => clickNewTerminal(page),
|
|
terminalSurfaceLocator(page),
|
|
20_000,
|
|
);
|
|
|
|
// Terminal surface should appear within a reasonable budget.
|
|
// Note: terminal creation involves a server round-trip, so we allow more time
|
|
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
|
expect(elapsed).toBeLessThan(5_000);
|
|
});
|
|
|
|
test("New agent tab click shows composer without flash", async ({ page }) => {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
|
|
const elapsed = await measureTileTransition(
|
|
page,
|
|
() => clickNewChat(page),
|
|
composerLocator(page),
|
|
10_000,
|
|
);
|
|
|
|
// Draft creation is fully in-memory — should be fast
|
|
// We use a generous budget here because CI can be slow, but the key assertion
|
|
// is that no blank/flash frame appears (tested above).
|
|
expect(elapsed).toBeLessThan(3_000);
|
|
});
|
|
});
|