feat: service proxy, health monitor, hover card, and homepage section

Built-in service proxy with branch-based URLs, service health
monitoring, workspace hover card with service status, and
"Forget about ports" homepage section.
This commit is contained in:
Mohamed Boudra
2026-04-01 10:31:15 +07:00
parent 806079c7ad
commit 5b3ec572cb
39 changed files with 3776 additions and 498 deletions
+4 -1
View File
@@ -14,6 +14,9 @@ type WorkspaceSetupDaemonClient = {
createPaseoWorktree(
input: { cwd: string; worktreeSlug?: string },
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
listTerminals(
cwd?: string,
): Promise<{ cwd?: string; terminals: Array<{ id: string; name: string }>; requestId: string }>;
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
};
@@ -95,7 +98,7 @@ export async function createWorkspaceFromSidebar(page: Page, repoPath: string):
}
export async function expectSetupPanel(page: Page): Promise<void> {
await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-setup-panel")).toBeVisible({ timeout: 30_000 });
}
export async function expectSetupStatus(
@@ -0,0 +1,134 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import {
connectWorkspaceSetupClient,
createWorkspaceFromSidebar,
expectSetupPanel,
expectSetupStatus,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
import type { Page } from "@playwright/test";
// ---------------------------------------------------------------------------
// Composable helpers
// ---------------------------------------------------------------------------
/** Waits for the globe icon to appear on a workspace row (proves services are running). */
async function expectGlobeIcon(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-globe-icon")).toBeVisible({ timeout: 30_000 });
}
/** Hovers the workspace row (by visible name) and waits for the hover card to appear. */
async function expectHoverCard(page: Page, workspaceName: string): Promise<void> {
const row = page.getByRole("button", { name: workspaceName }).first();
await row.hover();
await expect(page.getByTestId("workspace-hover-card")).toBeVisible({ timeout: 10_000 });
}
/** Asserts that a service row with the given name exists in the hover card. */
async function expectServiceInCard(page: Page, serviceName: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card");
await expect(card.getByTestId(`hover-card-service-${serviceName}`)).toBeVisible({
timeout: 10_000,
});
}
/** Asserts the service status dot indicates "running". */
async function expectServiceRunning(page: Page, serviceName: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card");
await expect(
card.getByTestId(`hover-card-service-status-${serviceName}`),
).toHaveAttribute("aria-label", "Running", { timeout: 10_000 });
}
/** Asserts the hover card contains the workspace name. */
async function expectWorkspaceNameInCard(page: Page, name: string): Promise<void> {
const card = page.getByTestId("workspace-hover-card");
await expect(card.getByTestId("hover-card-workspace-name")).toContainText(name, {
timeout: 10_000,
});
}
/** Moves the mouse away from the sidebar and asserts the hover card disappears. */
async function expectHoverCardDismissed(page: Page): Promise<void> {
// Move mouse to the center of the viewport (away from sidebar)
const viewport = page.viewportSize();
await page.mouse.move((viewport?.width ?? 1280) / 2, (viewport?.height ?? 720) / 2);
await expect(page.getByTestId("workspace-hover-card")).not.toBeVisible({ timeout: 10_000 });
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe("Workspace hover card", () => {
test("shows hover card with services when hovering a workspace with running services", async ({
page,
}) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("hovercard-svc-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
},
services: {
web: {
command:
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
await openHomeWithProject(page, repo.path);
await createWorkspaceFromSidebar(page, repo.path);
// Wait for setup to complete and workspace to be usable
await expectSetupPanel(page);
await expectSetupStatus(page, "Completed");
await waitForWorkspaceTabsVisible(page);
// Wait for the globe icon — proves services are running and client has the data
await expectGlobeIcon(page);
// Read the workspace name from the page header (the mnemonic name, e.g. "upbeat-crab")
const workspaceHeader = page.getByTestId("workspace-tabs-row");
await expect(workspaceHeader).toBeVisible({ timeout: 10_000 });
// The workspace name is the second workspace row button in the sidebar under the worktree project
// We can find it by looking for the workspace row that has the globe icon next to it
const globeIcon = page.getByTestId("workspace-globe-icon");
const workspaceRow = page.locator('[data-testid^="sidebar-workspace-row-"]', {
has: globeIcon,
});
const workspaceName =
(await workspaceRow.locator("button").first().innerText()).trim() || "workspace";
// Hover the workspace row — hover card should appear
await expectHoverCard(page, workspaceName);
// Assert the card shows the workspace name
await expectWorkspaceNameInCard(page, workspaceName);
// Assert the "web" service entry exists in the card
await expectServiceInCard(page, "web");
// Assert the status dot shows "running"
await expectServiceRunning(page, "web");
// Assert the service row is a link (has role="link")
const card = page.getByTestId("workspace-hover-card");
const serviceLink = card.getByRole("link", { name: "web service" });
await expect(serviceLink).toBeVisible({ timeout: 10_000 });
// Move mouse away — card should dismiss
await expectHoverCardDismissed(page);
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -66,15 +66,18 @@ test.describe("Workspace setup streaming", () => {
await expectSetupLogContains(page, "setup complete");
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
if ((await explorerToggle.getAttribute("aria-expanded")) !== "true") {
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
await explorerToggle.click();
}
await expect(explorerToggle).toHaveAttribute("aria-expanded", "true", { timeout: 30_000 });
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
timeout: 30_000,
});
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
@@ -194,4 +197,96 @@ test.describe("Workspace setup streaming", () => {
await repo.cleanup();
}
});
test("launches service terminals after setup completes", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-svc-ui-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
},
services: {
web: {
command:
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
await openHomeWithProject(page, repo.path);
await createWorkspaceFromSidebar(page, repo.path);
await expectSetupPanel(page);
await expectSetupStatus(page, "Completed");
await waitForWorkspaceTabsVisible(page);
// Wait for the service terminal tab to appear in the tabs bar
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]', {
hasText: "web",
});
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
// Click the service terminal tab
await terminalTab.click();
// Verify the terminal surface rendered
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({ timeout: 10_000 });
// Verify the terminal output contains "listening on" (xterm renders text in .xterm-rows)
await expect(page.locator(".xterm-rows").first()).toContainText("listening on", {
timeout: 30_000,
});
} finally {
await client.close();
await repo.cleanup();
}
});
test("launches workspace services after setup completes", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-services-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
},
services: {
editor: {
command: "npm run dev",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-services",
});
await completed;
await expect
.poll(async () => {
const terminals = await client.listTerminals(workspace.id);
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
})
.toMatchObject({
id: expect.any(String),
name: "editor",
});
} finally {
await client.close();
await repo.cleanup();
}
});
});