Merge branch 'investigate/portless' into dev
# Conflicts: # packages/app/e2e/helpers/workspace-setup.ts # packages/app/src/components/sidebar-workspace-list.tsx # packages/app/src/contexts/session-context.tsx # packages/app/src/hooks/use-sidebar-workspaces-list.test.ts # packages/app/src/screens/workspace/workspace-tab-menu.ts # packages/app/src/stores/workspace-setup-store.ts # packages/app/src/stores/workspace-tabs-store.ts # packages/app/src/utils/sidebar-project-row-model.test.ts # packages/app/src/utils/sidebar-shortcuts.test.ts # packages/app/src/utils/workspace-tab-identity.ts # packages/server/src/server/bootstrap.ts # packages/server/src/server/worktree-session.ts
This commit is contained in:
@@ -4,17 +4,7 @@ import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
|
||||
type WorkspaceSetupProgressPayload = {
|
||||
status: "running" | "completed" | "failed";
|
||||
detail: { commands: string[]; log: string };
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type WorkspaceSetupRawMessage = {
|
||||
type: string;
|
||||
payload?: WorkspaceSetupProgressPayload;
|
||||
};
|
||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
|
||||
type WorkspaceSetupDaemonClient = {
|
||||
connect(): Promise<void>;
|
||||
@@ -67,9 +57,15 @@ type WorkspaceSetupDaemonClient = {
|
||||
terminals: Array<{ id: string; cwd: string; name: string }>;
|
||||
error?: string | null;
|
||||
}>;
|
||||
subscribeRawMessages(handler: (message: WorkspaceSetupRawMessage) => void): () => void;
|
||||
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
||||
};
|
||||
export type { WorkspaceSetupDaemonClient, WorkspaceSetupProgressPayload };
|
||||
|
||||
export type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
export type { WorkspaceSetupDaemonClient };
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
@@ -334,9 +330,6 @@ export async function waitForWorkspaceSetupProgress(
|
||||
if (message.type !== "workspace_setup_progress") {
|
||||
return;
|
||||
}
|
||||
if (!message.payload) {
|
||||
return;
|
||||
}
|
||||
if (!predicate(message.payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceFromSidebar,
|
||||
createWorkspaceThroughDaemon,
|
||||
expectSetupLogContains,
|
||||
expectSetupPanel,
|
||||
expectSetupStatus,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
waitForWorkspaceSetupProgress,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
test.describe("Workspace setup streaming", () => {
|
||||
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-open-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await createWorkspaceFromSidebar(page, repo.path);
|
||||
|
||||
await expectSetupPanel(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => {
|
||||
const setupTriggerPath = `/tmp/setup-trigger-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-ui-flow-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: [
|
||||
`sh -c 'while [ ! -f "${setupTriggerPath}" ]; do sleep 0.2; done; echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'`,
|
||||
],
|
||||
},
|
||||
},
|
||||
files: [{ path: "src/index.ts", content: "export const ready = true;\n" }],
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await createWorkspaceFromSidebar(page, repo.path);
|
||||
|
||||
await expectSetupPanel(page);
|
||||
await expectSetupStatus(page, "Running");
|
||||
await writeFile(setupTriggerPath, "start\n");
|
||||
await expectSetupLogContains(page, "starting setup");
|
||||
await expectSetupLogContains(page, "loading dependencies");
|
||||
await expectSetupStatus(page, "Completed");
|
||||
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-label")) === "Open explorer") {
|
||||
await explorerToggle.click();
|
||||
}
|
||||
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({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await rm(setupTriggerPath, { force: true });
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-success-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const initialRunning = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log === "",
|
||||
);
|
||||
const runningWithOutput = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
|
||||
);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-success",
|
||||
});
|
||||
|
||||
const initialPayload = await initialRunning;
|
||||
const runningPayload = await runningWithOutput;
|
||||
const completedPayload = await completed;
|
||||
|
||||
expect(initialPayload.detail.log).toBe("");
|
||||
expect(runningPayload.detail.log).toContain("starting setup");
|
||||
expect(completedPayload.detail.log).toContain("setup complete");
|
||||
expect(completedPayload.error).toBeNull();
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("streams a failed setup snapshot when setup fails", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-failure-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-failure",
|
||||
});
|
||||
|
||||
const failedPayload = await failed;
|
||||
expect(failedPayload.detail.log).toContain("starting setup");
|
||||
expect(failedPayload.detail.log).toContain("setup failed");
|
||||
expect(failedPayload.error).toMatch(/failed/i);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-none-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
payload.detail.commands.length === 0 &&
|
||||
payload.detail.log === "",
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-none",
|
||||
});
|
||||
|
||||
const completedPayload = await completed;
|
||||
expect(completedPayload.error).toBeNull();
|
||||
expect(completedPayload.detail.commands).toEqual([]);
|
||||
expect(completedPayload.detail.log).toBe("");
|
||||
} finally {
|
||||
await client.close();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user