Files
paseo/packages/app/e2e/sidebar-project-grouping.spec.ts
T
Mohamed BoudraandGitHub 20e46e3e3f 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.
2026-07-29 23:36:31 +02:00

405 lines
14 KiB
TypeScript

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,
});
});
});