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:
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user