Restore project grouping across hosts (#2565)

* fix(projects): restore cross-host grouping

Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.

* fix(projects): keep ambiguous clones separate

A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.

* fix(projects): preserve host-local project identity

* fix(projects): strengthen grouping identity

* test(app): update project host shape contract

* fix(projects): preserve selected host identity

* fix(projects): close grouped routing gaps

* fix(projects): stabilize legacy subproject grouping

* fix(projects): preserve selected project identity

* fix(projects): isolate legacy host identity

* fix(projects): preserve symlinked bootstrap roots

* fix(projects): reconcile grouped custom names

* fix(projects): stabilize settings and path identity

* fix(projects): normalize default remote ports

* fix(projects): harden host and remote identity

* fix(projects): preserve host-scoped actions

* fix(projects): preserve remote path semantics

* fix(projects): model SCP remote semantics

* fix(projects): keep settings on routed host

* fix(projects): preserve SSH URL path semantics

* fix(projects): separate identity from presentation

* test(projects): use grouped identity in UI specs

* fix(projects): harden grouped project routing

* test(projects): expect framed settings route

* fix(projects): preserve exact placement identity

* fix(projects): harden grouping identity and routing

* fix(projects): restore project key as grouping identity

Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.

* fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.

* test(projects): assert host-scoped project keys

* fix(projects): preserve project key compatibility

Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.

* fix(projects): harden project reference identity

* fix(projects): retain archived reference errors

* fix(projects): scope legacy paths to hosts

* test(app): model persisted project keys

* fix(projects): retain absolute scp users

* fix(projects): resolve legacy mutation aliases

* fix(projects): preserve conservative identity boundaries

* fix(projects): preserve legacy placement identity

* test(projects): assert legacy placement identity

* test(projects): expect canonical Windows keys

* fix(projects): separate bootstrap identity fields

* fix(projects): scope legacy paths by host

* fix(projects): stabilize opaque project identity

* fix(projects): preserve remaining opaque identities

* fix(projects): classify forward-slash UNC roots

* fix(projects): preserve opaque selection keys

* fix(projects): frame host-local project keys

* fix(projects): canonicalize framed identities

* test(projects): isolate repeated remote fixtures

* fix(projects): keep project key as UI identity

* fix(projects): reconcile hydrated project references

* fix(projects): preserve archived project identity

* refactor(projects): group projects from project records, not workspaces

Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.

A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.

Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.

* test(projects): seed projects alongside workspaces in fixtures

Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.

Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.

* fix(projects): keep derived project identity stable

Four gaps left by shrinking the key derivation:

Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.

Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.

Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.

Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.

* fix(projects): read scheme remotes as URLs, not scp form

An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.

Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
Mohamed Boudra
2026-07-29 23:36:31 +02:00
committed by GitHub
parent 9e5accee3c
commit 20e46e3e3f
113 changed files with 3073 additions and 2038 deletions
+21 -24
View File
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { test, expect } from "./fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -13,36 +13,21 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
waitForConnectedHost,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { buildSeededHost } from "./helpers/daemon-registry";
import { addOfflineHostAndReload } from "./helpers/hosts";
import {
addConnectedHostAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
const SECONDARY_HOST_ID = "add-project-flow-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
const registryEntry = buildSeededHost({
serverId: host.serverId,
label: SECONDARY_HOST_LABEL,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ key, entry }) => {
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
);
await page.reload();
}
async function expectProjectDirectory(pathname: string): Promise<void> {
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
}
@@ -149,7 +134,11 @@ test.describe("Add Project command-center flow", () => {
test("keyboard selection chooses the second host", async ({ page }) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -167,7 +156,11 @@ test.describe("Add Project command-center flow", () => {
page,
}) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -211,7 +204,11 @@ test.describe("Add Project command-center flow", () => {
try {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -207,7 +207,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
@@ -293,7 +293,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
+19 -15
View File
@@ -32,12 +32,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
await archiveItem.click();
}
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
async function removeProjectFromSidebar(page: Page, projectKey: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
@@ -45,7 +45,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
// user-confirmed removal proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`);
await expect(removeItem).toBeVisible({ timeout: 10_000 });
await removeItem.click();
}
@@ -152,9 +152,9 @@ test.describe("Project with no workspaces persists", () => {
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectId}`,
`sidebar-project-new-workspace-row-${workspace.projectKey}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
@@ -195,9 +195,10 @@ test.describe("Project with no workspaces persists", () => {
test.describe("Project remove", () => {
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
let readdedProjectId: string | null = null;
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -206,7 +207,7 @@ test.describe("Project remove", () => {
timeout: 30_000,
});
await removeProjectFromSidebar(page, workspace.projectId);
await removeProjectFromSidebar(page, workspace.projectKey);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
@@ -220,21 +221,24 @@ test.describe("Project remove", () => {
const readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
const readdedProjectId = readded.project?.projectId ?? "";
readdedProjectId = readded.project?.projectId ?? "";
const readdedProjectKey = readded.project?.projectKey ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readdedProjectKey).toBe(workspace.projectKey);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`),
).toBeVisible({ timeout: 30_000 });
} finally {
if (readdedProjectId) {
await workspace.client.removeProject(readdedProjectId).catch(() => undefined);
}
await workspace.cleanup();
}
});
@@ -37,17 +37,6 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca
return page.getByTestId(`add-project-flow-method-${method}`);
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
await expect(currentPage).toBeVisible({ timeout: 30_000 });
+63
View File
@@ -57,6 +57,69 @@ export async function addOfflineHostAndReload(
await page.reload();
}
export async function addConnectedHostAndReload(
page: Page,
input: { serverId: string; label: string; port: number; primaryLabel?: string },
): Promise<void> {
await addConnectedHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
}
export async function addConnectedHostsAndReload(
page: Page,
inputs: Array<{ serverId: string; label: string; port: number }>,
options?: { primaryLabel?: string },
): Promise<void> {
const connectedHosts = inputs.map((input) =>
buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
}),
);
await page.evaluate(
({ hosts, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
if (primaryLabel && registry[0]) registry[0].label = primaryLabel;
for (const host of hosts) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
hosts: connectedHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: options?.primaryLabel,
},
);
await page.reload();
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
export interface IsolatedHostDaemon {
serverId: string;
port: number;
paseoHome: string;
restart(): Promise<void>;
close(): Promise<void>;
}
@@ -133,6 +134,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
return {
serverId,
port,
paseoHome,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);
+15 -4
View File
@@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick<
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "inspectWorkspaceRecovery"
| "listProjects"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -29,6 +30,7 @@ type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
export interface OpenedProject {
workspaceId: string;
projectId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
@@ -45,10 +47,19 @@ function requireWorkspace(payload: WorkspacePayload) {
return payload.workspace;
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
async function openedProjectFromWorkspace(
client: NewWorkspaceDaemonClient,
workspace: WorkspaceDescriptor,
): Promise<OpenedProject> {
const payload = await client.listProjects();
const project = payload.projects.find((candidate) => candidate.projectId === workspace.projectId);
if (!project?.projectKey) {
throw new Error(`Project ${workspace.projectId} has no project key`);
}
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectId: workspace.projectId,
projectKey: project.projectKey,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
@@ -107,7 +118,7 @@ export async function openProjectViaDaemon(
source: { kind: "directory", path: repoPath },
}),
);
return openedProjectFromWorkspace(workspace);
return openedProjectFromWorkspace(client, workspace);
}
export async function archiveWorkspaceFromDaemon(
@@ -149,7 +160,7 @@ export async function createWorktreeViaDaemon(
worktreeSlug: input.slug,
});
const workspace = requireWorkspace(payload);
return openedProjectFromWorkspace(workspace);
return openedProjectFromWorkspace(client, workspace);
}
export async function openNewWorkspaceComposer(
@@ -0,0 +1,131 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts";
import { openProjectSettings } from "./project-settings";
import { waitForSidebarHydration } from "./workspace-ui";
const PROJECT_VISIBILITY_TIMEOUT = 30_000;
function projectGroup(page: Page, projectName: string): Locator {
return page.getByRole("group", { name: projectName, exact: true });
}
function projectGroupWithWorkspace(
page: Page,
projectName: string,
workspaceName: string,
): Locator {
return projectGroup(page, projectName).filter({ hasText: workspaceName });
}
export async function openProjectDirectory(page: Page): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
}
export async function openProjectDirectoryWithHosts(
page: Page,
input: {
hosts: Array<{ serverId: string; label: string; port: number }>;
primaryLabel?: string;
},
): Promise<void> {
await gotoAppShell(page);
await addConnectedHostsAndReload(page, input.hosts, { primaryLabel: input.primaryLabel });
for (const host of input.hosts) {
await waitForConnectedHost(page, {
serverId: host.serverId,
endpoint: `localhost:${host.port}`,
});
}
await waitForSidebarHydration(page);
}
export async function expectProjectContainsWorkspaces(
page: Page,
input: { projectName: string; workspaceNames: string[] },
): Promise<void> {
const group = projectGroup(page, input.projectName);
await expect(group).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
for (const workspaceName of input.workspaceNames) {
await expect(group.getByRole("button").filter({ hasText: workspaceName })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
}
export async function expectSeparateProjects(
page: Page,
projects: Array<{ projectName: string; workspaceName: string }>,
): Promise<void> {
for (const project of projects) {
await expect(
projectGroupWithWorkspace(page, project.projectName, project.workspaceName),
).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT });
}
}
export async function beginWorkspaceFromProject(page: Page, projectName: string): Promise<void> {
const group = projectGroup(page, projectName);
await expect(group).toBeVisible({ timeout: PROJECT_VISIBILITY_TIMEOUT });
await group.hover();
await group.getByLabel(`Create a new workspace for ${projectName}`).click();
await expect(page.getByRole("button", { name: "Workspace project", exact: true })).toContainText(
projectName,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
export async function selectWorkspaceHost(page: Page, hostName: string): Promise<void> {
await page.getByRole("button", { name: "Host", exact: true }).click();
await page.getByRole("button", { name: hostName, exact: true }).click();
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
}
export async function createWorkspaceWithoutAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Create", exact: true }).click();
}
export async function expectProjectWorkspaceCountForHost(
page: Page,
input: { projectName: string; hostName: string; count: number },
): Promise<void> {
const group = projectGroup(page, input.projectName);
await expect(group.getByRole("button").filter({ hasText: input.hostName })).toHaveCount(
input.count,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
export async function renameProject(page: Page, name: string): Promise<void> {
await page.getByRole("button", { name: "Rename project", exact: true }).click();
await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name);
await page.getByRole("button", { name: "Save project name", exact: true }).click();
await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
export async function openGroupedProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/general$/);
await page.getByRole("button", { name: "Projects", exact: true }).click();
await expect(page).toHaveURL(/\/settings\/projects$/);
await openProjectSettings(page, projectName);
}
export async function switchProjectSettingsHost(page: Page, hostName: string): Promise<void> {
const projectSettings = page.getByRole("main");
await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click();
await page.getByRole("button", { name: hostName, exact: true }).click();
await expect(
projectSettings.getByRole("button", { name: "Switch host", exact: true }),
).toContainText(hostName);
}
export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> {
await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText(
projectName,
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
);
}
@@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
const projectId = new URL(page.url()).searchParams.get("projectId");
expect(projectId).not.toBeNull();
return projectId!;
}
@@ -16,6 +16,7 @@ type SessionRequest = Record<string, unknown> & { type?: string; requestId?: str
export interface FakeScheduleHostWorkspace {
serverId: string;
projectId: string;
projectKey: string;
projectDisplayName: string;
workspace: Record<string, unknown>;
}
@@ -120,6 +121,7 @@ export async function buildFakeScheduleHostWorkspace(
return {
serverId: "schedule-fake-host",
projectId,
projectKey: workspace.projectKey,
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
workspace: {
...baseWorkspace,
@@ -139,6 +141,7 @@ export async function installFakeScheduleHost(input: {
port: string;
serverId: string;
workspace: Record<string, unknown>;
project: Pick<FakeScheduleHostWorkspace, "projectId" | "projectKey" | "projectDisplayName">;
schedules?: FakeScheduleSummary[];
}): Promise<void> {
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
@@ -154,6 +157,7 @@ export async function installFakeScheduleHost(input: {
features: {
providersSnapshot: true,
workspaceMultiplicity: true,
projectList: true,
projectAdd: true,
projectRemove: true,
workspaceRecovery: true,
@@ -197,6 +201,22 @@ export async function installFakeScheduleHost(input: {
}),
);
return;
case "project.list.request":
ws.send(
buildSessionMessage("project.list.response", {
requestId,
projects: [
{
projectId: input.project.projectId,
projectKey: input.project.projectKey,
projectDisplayName: input.project.projectDisplayName,
projectRootPath: String(input.workspace.projectRootPath),
projectKind: "git",
},
],
}),
);
return;
case "fetch_agents_request":
ws.send(
buildSessionMessage("fetch_agents_response", {
+17 -1
View File
@@ -13,6 +13,11 @@ export interface SeedWorkspaceDescriptor {
workspaceDirectory: string;
}
interface SeedProjectDescriptor {
projectId: string;
projectKey?: string;
}
/**
* The general-purpose E2E daemon client used to seed and drive state out of
* band (workspaces, agents, terminals) while the UI is exercised through the
@@ -25,15 +30,18 @@ export interface SeedDaemonClient {
addProject(cwd: string): Promise<{
project: {
projectId: string;
projectKey?: string;
projectDisplayName: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
renameProject(projectId: string, customName: string | null): Promise<void>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
createWorkspace(input: {
source:
| { kind: "directory"; path: string; projectId?: string }
@@ -179,8 +187,10 @@ export interface SeededWorkspace {
workspaceId: string;
workspaceName: string;
workspaceDirectory: string;
/** Stable project identity the daemon groups workspaces under. */
/** Host-local identity used by daemon project operations. */
projectId: string;
/** Opaque cross-host key used by grouped project UI. */
projectKey: string;
/** Project label the UI shows (owner/repo for known remotes, else basename). */
projectDisplayName: string;
cleanup(): Promise<void>;
@@ -208,6 +218,11 @@ export async function seedWorkspace(options: {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
const listed = await client.listProjects();
const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId);
if (!descriptor?.projectKey) {
throw new Error(`Created project ${workspace.projectId} has no project key`);
}
return {
client,
repoPath: project.path,
@@ -215,6 +230,7 @@ export async function seedWorkspace(options: {
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
projectId: workspace.projectId,
projectKey: descriptor.projectKey,
projectDisplayName: workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);
@@ -64,6 +64,13 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
const descriptor = (await client.listProjects()).projects.find(
(project) => project.projectId === createdWorkspace.workspace?.projectId,
);
const projectKey = descriptor?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
}
return {
prompt,
promptPreview,
@@ -75,6 +82,7 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
workspaceName: createdWorkspace.workspace.name,
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
projectId: createdWorkspace.workspace.projectId,
projectKey,
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
@@ -165,7 +165,7 @@ test.describe("New workspace Codex mode preferences", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
@@ -221,7 +221,7 @@ test.describe("New workspace Codex mode preferences", () => {
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
@@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: firstProject.projectId,
projectKey: firstProject.projectKey,
projectDisplayName: firstProject.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
@@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => {
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceProject(page, {
projectKey: secondProject.projectId,
projectKey: secondProject.projectKey,
projectDisplayName: secondProject.projectDisplayName,
});
+12 -10
View File
@@ -179,13 +179,13 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 });
// Project A's row icon opens New Workspace with A preselected.
await openNewWorkspaceComposer(page, {
projectKey: projectA.projectId,
projectKey: projectA.projectKey,
projectDisplayName: projectA.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
@@ -194,7 +194,9 @@ test.describe("New workspace entry points", () => {
// manualProjectKey is what the reused 'new' screen must reset when the next
// route-driven navigation targets a different project.
await page.getByTestId("new-workspace-project-picker-trigger").click();
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
const optionC = page.getByTestId(
`new-workspace-project-picker-option-${projectC.projectKey}`,
);
await expect(optionC).toBeVisible({ timeout: 30_000 });
await optionC.click();
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
@@ -203,7 +205,7 @@ test.describe("New workspace entry points", () => {
// because the stale manual choice (C) was reset on the route change. If the
// reset were missing, the trigger would still read C.
await openNewWorkspaceComposer(page, {
projectKey: projectB.projectId,
projectKey: projectB.projectKey,
projectDisplayName: projectB.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
@@ -226,8 +228,8 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (the per-row icon would preselect it too).
@@ -236,7 +238,7 @@ test.describe("New workspace entry points", () => {
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const nonGitOption = page.getByTestId(
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
`new-workspace-project-picker-option-${nonGitProject.projectKey}`,
);
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
await nonGitOption.click();
@@ -249,7 +251,7 @@ test.describe("New workspace entry points", () => {
// Switching to the git project on the same screen reveals the Isolation row.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${gitProject.projectId}`,
`new-workspace-project-picker-option-${gitProject.projectKey}`,
);
await expect(gitOption).toBeVisible({ timeout: 30_000 });
await gitOption.click();
@@ -126,7 +126,7 @@ test.describe("New Workspace mode cycle safety", () => {
// so its handler is still registered when we cycle here.
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
+7
View File
@@ -195,6 +195,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const localProjectIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
const localGithubFixtures = new Set<LocalGhPrFixture>();
@@ -212,9 +213,13 @@ test.describe("New workspace flow", () => {
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
for (const projectId of localProjectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
}
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
localProjectIds.clear();
await client?.close().catch(() => undefined);
});
@@ -830,6 +835,7 @@ test.describe("New workspace flow", () => {
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
localProjectIds.add(openedProject.projectId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -881,6 +887,7 @@ test.describe("New workspace flow", () => {
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
localProjectIds.add(openedProject.projectId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -1,8 +1,10 @@
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { waitForConnectedHost } from "./helpers/hosts";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
@@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({
dialogOpenResult: projectPickerFixture.projectPath,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({
dialogOpenResult: null,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -163,6 +163,7 @@ test.describe("Schedules", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
schedules: [
buildFakeHostSchedule({
id: scheduleId,
@@ -205,7 +205,7 @@ test.describe("Schedules project target", () => {
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
const projectTrigger = page.getByTestId("schedule-project-trigger");
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
@@ -245,6 +245,7 @@ test.describe("Schedules project target", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
});
await addFakeScheduleHostAndReload({
page,
@@ -277,6 +278,7 @@ test.describe("Schedules project target", () => {
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
});
await gotoAppShell(page);
@@ -313,7 +315,7 @@ test.describe("Schedules project target", () => {
await expectSettled(hostTrigger);
await projectTrigger.click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
await expect(projectTrigger).toContainText(workspace.projectDisplayName);
await expectSettled(projectTrigger);
@@ -335,8 +337,7 @@ test.describe("Schedules project target", () => {
await expectSettled(projectTrigger);
await projectTrigger.click();
await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0);
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
await page.getByTestId(`schedule-project-option-${fakeHost.projectKey}`).click();
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(/select model/i);
@@ -362,7 +363,7 @@ test.describe("Schedules project target", () => {
await page.getByTestId("schedules-empty-new").click();
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click();
await selectModelByLabel(page, "Ten second stream");
await expect(
page.getByText("Off keeps each run's workspace in the sidebar for inspection."),
+6 -6
View File
@@ -53,8 +53,8 @@ test.describe("Model B sidebar shape", () => {
// Both projects are expandable parents — the non-git one is NOT flattened
// into a bare workspace link.
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Each parent shows both of its workspace rows underneath.
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
@@ -65,12 +65,12 @@ test.describe("Model B sidebar shape", () => {
// Both projects show a per-row New workspace icon (revealed on hover): the
// git project can branch off a worktree, and the non-git project can add
// another workspace because the host supports workspaceMultiplicity.
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
await projectRow(page, gitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({
timeout: 30_000,
});
await projectRow(page, nonGitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
await projectRow(page, nonGitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({
timeout: 30_000,
});
@@ -0,0 +1,404 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { test as base } from "./fixtures";
import {
beginWorkspaceFromProject,
createWorkspaceWithoutAgent,
expectProjectContainsWorkspaces,
expectProjectSettingsName,
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
openProjectDirectory,
openProjectDirectoryWithHosts,
renameProject,
selectWorkspaceHost,
switchProjectSettingsHost,
} from "./helpers/project-grouping";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_LABEL = "Secondary Host";
const LEGACY_PRIMARY_HOST_LABEL = "Legacy Primary Host";
const LEGACY_SECONDARY_HOST_LABEL = "Legacy Secondary Host";
const GROUPED_PROJECT_NAME = "paseo-e2e/grouped-project";
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
const SUBDIRECTORY = path.join("packages", "app");
const REPO_FILES = [{ path: path.join(SUBDIRECTORY, "package.json"), content: "{}\n" }];
interface HostConnection {
serverId: string;
label: string;
port: number;
}
interface ProjectDirectoryScenario {
hosts: HostConnection[];
primaryLabel?: string;
}
interface CreatedProject {
projectId: string;
}
async function createProject(
client: SeedDaemonClient,
input: {
projectPath: string;
serverId: string;
workspaceName: string;
projectName?: string;
},
): Promise<CreatedProject> {
const created = await client.createWorkspace({
source: { kind: "directory", path: input.projectPath },
title: input.workspaceName,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project on ${input.serverId}`);
}
if (input.projectName) {
await client.renameProject(created.workspace.projectId, input.projectName);
}
return { projectId: created.workspace.projectId };
}
async function removePersistedProjectKeys(host: IsolatedHostDaemon): Promise<void> {
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
for (const project of projects) delete project.projectKey;
await writeFile(projectsPath, JSON.stringify(projects));
}
const test = base.extend<{
crossHostProject: ProjectDirectoryScenario;
reconciledCrossHostProject: ProjectDirectoryScenario;
rootAndSubdirectoryProjects: ProjectDirectoryScenario;
crossHostSubdirectoryProject: ProjectDirectoryScenario;
sameHostClones: ProjectDirectoryScenario;
}>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon("project-grouping-secondary");
const primaryRepo = await createTempGitRepo("grouped-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: CreatedProject | null = null;
let secondary: CreatedProject | null = null;
try {
primary = await createProject(primaryClient, {
projectPath: primaryRepo.path,
serverId: getServerId(),
workspaceName: "Primary workspace",
projectName: GROUPED_PROJECT_NAME,
});
secondary = await createProject(secondaryClient, {
projectPath: secondaryRepo.path,
serverId: secondaryHost.serverId,
workspaceName: "Secondary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await provide({
primaryLabel: PRIMARY_HOST_LABEL,
hosts: [
{
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
reconciledCrossHostProject: async ({ page: _page }, provide) => {
const primaryHost = await startIsolatedHostDaemon("project-grouping-legacy-primary");
const secondaryHost = await startIsolatedHostDaemon("project-grouping-legacy-secondary");
const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient({ port: primaryHost.port });
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
try {
await createProject(primaryClient, {
projectPath: primaryRepo.path,
serverId: primaryHost.serverId,
workspaceName: "Recovered primary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await createProject(secondaryClient, {
projectPath: secondaryRepo.path,
serverId: secondaryHost.serverId,
workspaceName: "Recovered secondary workspace",
projectName: GROUPED_PROJECT_NAME,
});
await primaryClient.close();
await secondaryClient.close();
await removePersistedProjectKeys(primaryHost);
await removePersistedProjectKeys(secondaryHost);
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
await provide({
hosts: [
{
serverId: primaryHost.serverId,
label: LEGACY_PRIMARY_HOST_LABEL,
port: primaryHost.port,
},
{
serverId: secondaryHost.serverId,
label: LEGACY_SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryHost.close().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
}
},
rootAndSubdirectoryProjects: async ({ page: _page }, provide) => {
const repo = await createTempGitRepo("grouped-root-subdir-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const client = await connectSeedClient();
const projects: CreatedProject[] = [];
try {
projects.push(
await createProject(client, {
projectPath: repo.path,
serverId: getServerId(),
workspaceName: "Repository root workspace",
projectName: "Repository root project",
}),
);
projects.push(
await createProject(client, {
projectPath: path.join(repo.path, SUBDIRECTORY),
serverId: getServerId(),
workspaceName: "App package workspace",
projectName: "App package project",
}),
);
await provide({ hosts: [] });
} finally {
for (const project of projects) {
await client.removeProject(project.projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
},
crossHostSubdirectoryProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon("project-grouping-subdir-secondary");
const primaryRepo = await createTempGitRepo("grouped-subdir-primary-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const secondaryRepo = await createTempGitRepo("grouped-subdir-secondary-", {
originUrl: SHARED_REMOTE_URL,
files: REPO_FILES,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: CreatedProject | null = null;
let secondary: CreatedProject | null = null;
try {
primary = await createProject(primaryClient, {
projectPath: path.join(primaryRepo.path, SUBDIRECTORY),
serverId: getServerId(),
workspaceName: "Primary app workspace",
projectName: GROUPED_PROJECT_NAME,
});
secondary = await createProject(secondaryClient, {
projectPath: path.join(secondaryRepo.path, SUBDIRECTORY),
serverId: secondaryHost.serverId,
workspaceName: "Secondary app workspace",
projectName: GROUPED_PROJECT_NAME,
});
await provide({
primaryLabel: PRIMARY_HOST_LABEL,
hosts: [
{
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
},
],
});
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
sameHostClones: async ({ page: _page }, provide) => {
const firstRepo = await createTempGitRepo("grouped-clone-first-", {
originUrl: SHARED_REMOTE_URL,
});
const secondRepo = await createTempGitRepo("grouped-clone-second-", {
originUrl: SHARED_REMOTE_URL,
});
const client = await connectSeedClient();
const projects: CreatedProject[] = [];
try {
projects.push(
await createProject(client, {
projectPath: firstRepo.path,
serverId: getServerId(),
workspaceName: "First clone workspace",
projectName: "First clone",
}),
);
projects.push(
await createProject(client, {
projectPath: secondRepo.path,
serverId: getServerId(),
workspaceName: "Second clone workspace",
projectName: "Second clone",
}),
);
await provide({ hosts: [] });
} finally {
for (const project of projects) {
await client.removeProject(project.projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await firstRepo.cleanup().catch(() => undefined);
await secondRepo.cleanup().catch(() => undefined);
}
},
});
async function openScenario(
page: Parameters<typeof openProjectDirectory>[0],
scenario: ProjectDirectoryScenario,
): Promise<void> {
if (scenario.hosts.length === 0) {
await openProjectDirectory(page);
return;
}
await openProjectDirectoryWithHosts(page, scenario);
}
test.describe("Sidebar project grouping", () => {
test.describe.configure({ timeout: 120_000 });
test("groups projects with the same Git remote across hosts", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Primary workspace", "Secondary workspace"],
});
});
test("groups persisted projects missing project keys after boot", async ({
page,
reconciledCrossHostProject,
}) => {
await openScenario(page, reconciledCrossHostProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Recovered primary workspace", "Recovered secondary workspace"],
});
});
test("keeps a repository root and its subdirectory as separate projects", async ({
page,
rootAndSubdirectoryProjects,
}) => {
await openScenario(page, rootAndSubdirectoryProjects);
await expectSeparateProjects(page, [
{ projectName: "Repository root project", workspaceName: "Repository root workspace" },
{ projectName: "App package project", workspaceName: "App package workspace" },
]);
});
test("groups the same repository subdirectory across hosts", async ({
page,
crossHostSubdirectoryProject,
}) => {
await openScenario(page, crossHostSubdirectoryProject);
await expectProjectContainsWorkspaces(page, {
projectName: GROUPED_PROJECT_NAME,
workspaceNames: ["Primary app workspace", "Secondary app workspace"],
});
});
test("keeps two clones of the same repository on one host separate", async ({
page,
sameHostClones,
}) => {
await openScenario(page, sameHostClones);
await expectSeparateProjects(page, [
{ projectName: "First clone", workspaceName: "First clone workspace" },
{ projectName: "Second clone", workspaceName: "Second clone workspace" },
]);
});
test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => {
await openScenario(page, crossHostProject);
await openGroupedProjectSettings(page, GROUPED_PROJECT_NAME);
await renameProject(page, "Primary-only project name");
await expectProjectSettingsName(page, "Primary-only project name");
await switchProjectSettingsHost(page, SECONDARY_HOST_LABEL);
await expectProjectSettingsName(page, GROUPED_PROJECT_NAME);
});
test("creates a workspace on the selected host from a grouped project", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await selectWorkspaceHost(page, SECONDARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: SECONDARY_HOST_LABEL,
count: 2,
});
});
});
+1 -1
View File
@@ -219,7 +219,7 @@ test.describe("Half-screen desktop layout", () => {
}
await gotoAppShell(page);
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click();
await waitForSidebarWorkspace(page, lastWorkspaceId);
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
@@ -32,8 +32,12 @@ interface RestartDaemonClient {
name: string;
status: string;
workspaceDirectory: string;
projectId: string;
}>;
}>;
listProjects(): Promise<{
projects: Array<{ projectId: string; projectKey?: string }>;
}>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
agent: {
@@ -461,9 +465,16 @@ test.describe("Workspace model restart regressions", () => {
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
const reconciledProjectKey = (await client.listProjects()).projects.find(
(project) => project.projectId === seeded.projectId,
);
if (!reconciledProjectKey?.projectKey) {
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
}
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: reconciledProjectKey.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
@@ -84,7 +84,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -136,7 +136,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -182,7 +182,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -190,7 +190,7 @@ test.describe("Workspace multiplicity creation flow", () => {
await waitForSidebarHydration(page);
// Model B: a non-git project is an expandable parent like any other, with
// its single workspace already rendered as its own row underneath.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
@@ -210,7 +210,7 @@ test.describe("Workspace multiplicity creation flow", () => {
// Both the original and the new workspace render as distinct rows under
// the same expandable parent.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({