mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +00:00
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.
This commit is contained in:
@@ -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}`,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -57,6 +57,66 @@ export async function addOfflineHostAndReload(
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function addConnectedHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; port: number },
|
||||
): Promise<void> {
|
||||
await addConnectedHostsAndReload(page, [input]);
|
||||
}
|
||||
|
||||
export async function addConnectedHostsAndReload(
|
||||
page: Page,
|
||||
inputs: Array<{ serverId: string; label: string; port: number }>,
|
||||
): 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 }) => {
|
||||
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 }> = raw ? JSON.parse(raw) : [];
|
||||
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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
234
packages/app/e2e/sidebar-project-grouping.spec.ts
Normal file
234
packages/app/e2e/sidebar-project-grouping.spec.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addConnectedHostAndReload,
|
||||
addConnectedHostsAndReload,
|
||||
waitForConnectedHost,
|
||||
} from "./helpers/hosts";
|
||||
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, type TempDirectory } from "./helpers/workspace";
|
||||
|
||||
const SECONDARY_HOST_ID = "project-grouping-secondary";
|
||||
const SECONDARY_HOST_LABEL = "Secondary Host";
|
||||
const LEGACY_PRIMARY_HOST_ID = "project-grouping-legacy-primary";
|
||||
const LEGACY_SECONDARY_HOST_ID = "project-grouping-legacy-secondary";
|
||||
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
|
||||
|
||||
interface HostProject {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface CrossHostProject {
|
||||
secondaryHost: IsolatedHostDaemon;
|
||||
primary: HostProject;
|
||||
secondary: HostProject;
|
||||
}
|
||||
|
||||
interface ReconciledCrossHostProject extends CrossHostProject {
|
||||
primaryHost: IsolatedHostDaemon;
|
||||
}
|
||||
|
||||
async function createProject(
|
||||
client: SeedDaemonClient,
|
||||
repo: TempDirectory,
|
||||
serverId: string,
|
||||
): Promise<HostProject> {
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create project on ${serverId}`);
|
||||
}
|
||||
return {
|
||||
serverId,
|
||||
projectId: created.workspace.projectId,
|
||||
workspaceId: created.workspace.id,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectOneProjectContainsBothWorkspaces(
|
||||
page: Page,
|
||||
fixture: CrossHostProject,
|
||||
): Promise<void> {
|
||||
const primaryWorkspace = page.getByTestId(
|
||||
`sidebar-workspace-row-${fixture.primary.serverId}:${fixture.primary.workspaceId}`,
|
||||
);
|
||||
const secondaryWorkspace = page.getByTestId(
|
||||
`sidebar-workspace-row-${fixture.secondary.serverId}:${fixture.secondary.workspaceId}`,
|
||||
);
|
||||
await expect(primaryWorkspace).toBeVisible({ timeout: 30_000 });
|
||||
await expect(secondaryWorkspace).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(page.locator('[data-testid^="sidebar-project-row-"]')).toHaveCount(1);
|
||||
}
|
||||
|
||||
async function readPersistedProjectGroupKey(host: IsolatedHostDaemon): Promise<unknown> {
|
||||
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
|
||||
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
return projects[0]?.projectGroupKey;
|
||||
}
|
||||
|
||||
async function removePersistedProjectGroupKeys(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.projectGroupKey;
|
||||
}
|
||||
await writeFile(projectsPath, JSON.stringify(projects));
|
||||
const persisted = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(persisted.every((project) => !("projectGroupKey" in project))).toBe(true);
|
||||
}
|
||||
|
||||
async function createReconciliationFixture(): Promise<{
|
||||
fixture: ReconciledCrossHostProject;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const primaryHost = await startIsolatedHostDaemon(LEGACY_PRIMARY_HOST_ID);
|
||||
const secondaryHost = await startIsolatedHostDaemon(LEGACY_SECONDARY_HOST_ID);
|
||||
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 {
|
||||
const primary = await createProject(primaryClient, primaryRepo, primaryHost.serverId);
|
||||
const secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
|
||||
await primaryClient.close();
|
||||
await secondaryClient.close();
|
||||
await removePersistedProjectGroupKeys(primaryHost);
|
||||
await removePersistedProjectGroupKeys(secondaryHost);
|
||||
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
|
||||
return {
|
||||
fixture: { primaryHost, secondaryHost, primary, secondary },
|
||||
cleanup: async () => {
|
||||
await primaryHost.close().catch(() => undefined);
|
||||
await secondaryHost.close().catch(() => undefined);
|
||||
await primaryRepo.cleanup().catch(() => undefined);
|
||||
await secondaryRepo.cleanup().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
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);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const test = base.extend<{
|
||||
crossHostProject: CrossHostProject;
|
||||
reconciledCrossHostProject: ReconciledCrossHostProject;
|
||||
}>({
|
||||
crossHostProject: async ({ page: _page }, provide) => {
|
||||
const secondaryHost = await startIsolatedHostDaemon(SECONDARY_HOST_ID);
|
||||
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: HostProject | null = null;
|
||||
let secondary: HostProject | null = null;
|
||||
|
||||
try {
|
||||
primary = await createProject(primaryClient, primaryRepo, getServerId());
|
||||
secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
|
||||
await provide({ secondaryHost, primary, secondary });
|
||||
} 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 resource = await createReconciliationFixture();
|
||||
try {
|
||||
await provide(resource.fixture);
|
||||
} finally {
|
||||
await resource.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Sidebar project grouping", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("groups projects with the same Git remote across hosts", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
expect(crossHostProject.primary.projectId).not.toBe(crossHostProject.secondary.projectId);
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: crossHostProject.secondaryHost.serverId,
|
||||
label: SECONDARY_HOST_LABEL,
|
||||
port: crossHostProject.secondaryHost.port,
|
||||
});
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: crossHostProject.secondaryHost.serverId,
|
||||
endpoint: `localhost:${crossHostProject.secondaryHost.port}`,
|
||||
});
|
||||
await expectOneProjectContainsBothWorkspaces(page, crossHostProject);
|
||||
});
|
||||
|
||||
test("groups persisted projects missing group keys after app boot", async ({
|
||||
page,
|
||||
reconciledCrossHostProject,
|
||||
}) => {
|
||||
expect(reconciledCrossHostProject.primary.projectId).not.toBe(
|
||||
reconciledCrossHostProject.secondary.projectId,
|
||||
);
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostsAndReload(page, [
|
||||
{
|
||||
serverId: reconciledCrossHostProject.primaryHost.serverId,
|
||||
label: "Legacy Primary Host",
|
||||
port: reconciledCrossHostProject.primaryHost.port,
|
||||
},
|
||||
{
|
||||
serverId: reconciledCrossHostProject.secondaryHost.serverId,
|
||||
label: "Legacy Secondary Host",
|
||||
port: reconciledCrossHostProject.secondaryHost.port,
|
||||
},
|
||||
]);
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: reconciledCrossHostProject.primaryHost.serverId,
|
||||
endpoint: `localhost:${reconciledCrossHostProject.primaryHost.port}`,
|
||||
});
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: reconciledCrossHostProject.secondaryHost.serverId,
|
||||
endpoint: `localhost:${reconciledCrossHostProject.secondaryHost.port}`,
|
||||
});
|
||||
await expectOneProjectContainsBothWorkspaces(page, reconciledCrossHostProject);
|
||||
await expect
|
||||
.poll(() => readPersistedProjectGroupKey(reconciledCrossHostProject.primaryHost))
|
||||
.toBe("remote:github.com/paseo-e2e/grouped-project");
|
||||
await expect
|
||||
.poll(() => readPersistedProjectGroupKey(reconciledCrossHostProject.secondaryHost))
|
||||
.toBe("remote:github.com/paseo-e2e/grouped-project");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user