feat: add sqlite storage and terminal ui

This commit is contained in:
Mohamed Boudra
2026-03-29 23:20:18 +07:00
parent e5fd61f131
commit 7d02e24d84
189 changed files with 17868 additions and 4663 deletions
+196
View File
@@ -0,0 +1,196 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { createTempGitRepo } from "./workspace";
// ─── Navigation ────────────────────────────────────────────────────────────
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
/** Navigate to a workspace and wait for the tab bar to appear. */
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), cwd);
await page.goto(route);
await waitForTabBar(page);
}
// ─── Tab bar queries ───────────────────────────────────────────────────────
/** Wait for the workspace tab bar to be visible. */
export async function waitForTabBar(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
timeout: 30_000,
});
}
/** Return all tab test IDs currently in the tab bar. */
export async function getTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator('[data-testid^="workspace-tab-"]');
const count = await tabs.count();
const ids: string[] = [];
for (let i = 0; i < count; i++) {
const testId = await tabs.nth(i).getAttribute("data-testid");
if (testId) ids.push(testId);
}
return ids;
}
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
const ids = await getTabTestIds(page);
return ids.filter((id) => id.includes(kind)).length;
}
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
export async function getActiveTabTestId(page: Page): Promise<string | null> {
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
const activeTab = page.locator('[data-testid^="workspace-tab-"][aria-selected="true"]').first();
if (await activeTab.isVisible().catch(() => false)) {
return activeTab.getAttribute("data-testid");
}
// Fallback: the tab with focused styling
return null;
}
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Click the '+' button in the tab bar to open a new launcher tab. */
export async function clickNewTabButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-tab");
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Press Cmd+T (macOS) to open a new tab. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
await page.keyboard.press("Meta+t");
}
// ─── Launcher panel assertions ─────────────────────────────────────────────
/** Wait for the launcher panel to render with its primary tiles. */
export async function waitForLauncherPanel(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible({
timeout: 15_000,
});
}
/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */
export async function assertProviderTilesVisible(page: Page): Promise<void> {
await expect(page.getByText("Terminal Agents", { exact: true }).first()).toBeVisible({
timeout: 10_000,
});
}
/** Assert the launcher panel has a "New Chat" tile. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();
}
/** Assert the launcher panel has a "Terminal" tile. */
export async function assertTerminalTileVisible(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible();
}
// ─── Launcher tile clicks ──────────────────────────────────────────────────
/** Click the "New Chat" tile on the launcher panel. */
export async function clickNewChat(page: Page): Promise<void> {
const button = page.getByRole("button", { name: "New Chat" }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the "Terminal" tile on the launcher panel. */
export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByRole("button", { name: "Terminal" }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */
export async function clickProviderTile(page: Page, providerLabel: string): Promise<void> {
const tile = page.getByRole("button", { name: providerLabel }).first();
await expect(tile).toBeVisible({ timeout: 10_000 });
await tile.click();
}
// ─── Tab title assertions ──────────────────────────────────────────────────
/** Wait for any tab in the bar to display the given title text. */
export async function waitForTabWithTitle(
page: Page,
title: string | RegExp,
timeout = 30_000,
): Promise<void> {
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
await expect(page.locator('[data-testid^="workspace-tab-"]').filter({ hasText: matcher }).first())
.toBeVisible({ timeout });
}
/** Assert the new-tab '+' button is visible and there is only one. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-tab");
// There might be multiple panes, each with a "+" button
// But within a single pane there should only be one
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
// ─── No-flash measurement ──────────────────────────────────────────────────
/**
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
* Returns elapsed milliseconds.
*/
export async function measureTileTransition(
page: Page,
clickAction: () => Promise<void>,
successLocator: ReturnType<Page["locator"]>,
timeout = 5_000,
): Promise<number> {
const start = Date.now();
await clickAction();
await expect(successLocator).toBeVisible({ timeout });
return Date.now() - start;
}
/**
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
* Returns all unique snapshots observed.
*/
export async function sampleTabsDuringTransition(
page: Page,
action: () => Promise<void>,
durationMs = 2_000,
intervalMs = 30,
): Promise<string[][]> {
const snapshots: string[][] = [];
const startSampling = async () => {
const start = Date.now();
while (Date.now() - start < durationMs) {
snapshots.push(await getTabTestIds(page));
await page.waitForTimeout(intervalMs);
}
};
const samplingPromise = startSampling();
await action();
await samplingPromise;
return snapshots;
}
// ─── Workspace setup ───────────────────────────────────────────────────────
/** Create a temp git repo and return its path with a cleanup function. */
export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType<typeof createTempGitRepo> {
return createTempGitRepo(prefix);
}
+343
View File
@@ -0,0 +1,343 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
gotoWorkspace,
waitForLauncherPanel,
assertProviderTilesVisible,
assertNewChatTileVisible,
assertTerminalTileVisible,
assertSingleNewTabButton,
clickNewTabButton,
pressNewTabShortcut,
clickNewChat,
clickTerminal,
clickProviderTile,
countTabsOfKind,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
sampleTabsDuringTransition,
} from "./helpers/launcher";
import {
connectTerminalClient,
waitForTerminalContent,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./helpers/terminal-perf";
// ─── Shared state ──────────────────────────────────────────────────────────
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("launcher-e2e-");
});
test.afterAll(async () => {
if (tempRepo) await tempRepo.cleanup();
});
// ═══════════════════════════════════════════════════════════════════════════
// Launcher Tab Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Launcher tab", () => {
test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({
page,
}) => {
await gotoWorkspace(page, tempRepo.path);
await pressNewTabShortcut(page);
await waitForLauncherPanel(page);
await assertNewChatTileVisible(page);
await assertTerminalTileVisible(page);
await assertProviderTilesVisible(page);
});
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await pressNewTabShortcut(page);
await waitForLauncherPanel(page);
const countAfterFirst = await countTabsOfKind(page, "launcher");
await pressNewTabShortcut(page);
await waitForLauncherPanel(page);
const countAfterSecond = await countTabsOfKind(page, "launcher");
expect(countAfterSecond).toBe(countAfterFirst + 1);
});
test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
const tabsBefore = await getTabTestIds(page);
const launcherCountBefore = tabsBefore.filter((id) => id.includes("launcher")).length;
await clickNewChat(page);
// Draft composer should appear (the agent message input)
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
// Launcher tab should have been replaced (not added alongside)
const tabsAfter = await getTabTestIds(page);
const launcherCountAfter = tabsAfter.filter((id) => id.includes("launcher")).length;
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
expect(launcherCountAfter).toBe(launcherCountBefore - 1);
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
// Total tab count should stay the same (replaced, not added)
expect(tabsAfter.length).toBe(tabsBefore.length);
});
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
const tabsBefore = await getTabTestIds(page);
await clickTerminal(page);
// Terminal surface should appear
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
// Tab count stays the same (in-place replacement)
const tabsAfter = await getTabTestIds(page);
expect(tabsAfter.length).toBe(tabsBefore.length);
// The launcher tab is gone, a terminal tab exists
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
});
test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
const tabsBefore = await getTabTestIds(page);
// Click the first visible provider tile under "Terminal Agents"
const providerTiles = page.locator('[role="button"]').filter({
has: page.locator("text=Terminal Agents").locator("..").locator(".."),
});
// Try clicking any provider tile — find the first one after the "Terminal Agents" label
const terminalAgentsLabel = page.getByText("Terminal Agents", { exact: true }).first();
await expect(terminalAgentsLabel).toBeVisible({ timeout: 10_000 });
// The provider grid follows the label. Click the first provider tile.
const providerGrid = terminalAgentsLabel.locator("~ *").first();
const firstProvider = providerGrid.getByRole("button").first();
if (await firstProvider.isVisible().catch(() => false)) {
await firstProvider.click();
} else {
// Fallback: look for any provider button after the section label
const allButtons = page.getByRole("button");
const count = await allButtons.count();
let clicked = false;
for (let i = 0; i < count; i++) {
const btn = allButtons.nth(i);
const text = await btn.innerText().catch(() => "");
// Skip known non-provider buttons
if (["New Chat", "Terminal", "More", "+"].includes(text.trim())) continue;
if (!text.trim()) continue;
await btn.click();
clicked = true;
break;
}
if (!clicked) {
test.skip(true, "No provider tiles available");
return;
}
}
// Should see an agent panel (terminal surface or agent stream)
const agentOrTerminal = page.locator(
'[data-testid="terminal-surface"], [data-testid^="agent-"]',
);
await expect(agentOrTerminal.first()).toBeVisible({ timeout: 30_000 });
// Tab count stays the same (replaced, not added)
const tabsAfter = await getTabTestIds(page);
expect(tabsAfter.length).toBe(tabsBefore.length);
});
test("tab bar shows a single + button per pane", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await assertSingleNewTabButton(page);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Title Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Terminal title propagation", () => {
let client: TerminalPerfDaemonClient;
test.beforeAll(async () => {
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) await client.close();
});
test("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "title-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
// Navigate to workspace and open the terminal
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Send OSC 0 (set window title) escape sequence
const testTitle = `E2E-Title-${Date.now()}`;
await terminal
.first()
.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 client.killTerminal(terminalId).catch(() => {});
}
});
test("title debouncing coalesces rapid changes", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "debounce-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().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 terminal
.first()
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
}
await terminal
.first()
.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 client.killTerminal(terminalId).catch(() => {});
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// No-Flash Transition Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Launcher transitions (no flash)", () => {
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(
page,
() => clickNewChat(page),
2_000,
30,
);
// 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 increase (no duplicate flash from add-then-remove)
const counts = snapshots.map((s) => s.length);
const maxCount = Math.max(...counts);
const initialCount = counts[0] ?? 0;
// Allow at most +1 transient tab (tolerance for React render batching)
expect(maxCount).toBeLessThanOrEqual(initialCount + 1);
});
test("Terminal transition completes within visual budget", async ({ page }) => {
test.setTimeout(30_000);
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
const elapsed = await measureTileTransition(
page,
() => clickTerminal(page),
terminal.first(),
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 Chat click → composer appears without launcher flash", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composer,
10_000,
);
// Draft replacement 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);
});
});