* 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.
548 lines
17 KiB
TypeScript
548 lines
17 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import net from "node:net";
|
|
import { test, expect, type Page } from "@playwright/test";
|
|
import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
|
import { buildSeededHost } from "./helpers/daemon-registry";
|
|
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
|
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
|
import {
|
|
expectNewWorkspaceProjectSelected,
|
|
openGlobalNewWorkspaceComposer,
|
|
selectNewWorkspaceProject,
|
|
submitNewWorkspaceEmpty,
|
|
} from "./helpers/new-workspace";
|
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
|
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
|
|
|
const LEGACY_AGENT_ID = "10000000-0000-4000-8000-000000000001";
|
|
const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
|
|
interface RestartDaemonClient {
|
|
connect(): Promise<void>;
|
|
close(): Promise<void>;
|
|
fetchWorkspaces(): Promise<{
|
|
entries: Array<{
|
|
id: string;
|
|
name: string;
|
|
status: string;
|
|
workspaceDirectory: string;
|
|
projectId: string;
|
|
}>;
|
|
}>;
|
|
listProjects(): Promise<{
|
|
projects: Array<{ projectId: string; projectKey?: string }>;
|
|
}>;
|
|
fetchAgents(options?: { scope?: "active" }): Promise<{
|
|
entries: Array<{
|
|
agent: {
|
|
id: string;
|
|
workspaceId?: string;
|
|
status: string;
|
|
};
|
|
}>;
|
|
}>;
|
|
}
|
|
|
|
interface RestartDaemonClientConfig {
|
|
url: string;
|
|
clientId: string;
|
|
clientType: "cli";
|
|
appVersion: string;
|
|
webSocketFactory: NodeWebSocketFactory;
|
|
}
|
|
|
|
interface SeededRestartHome {
|
|
paseoHome: string;
|
|
cwd: string;
|
|
projectId: string;
|
|
projectDisplayName: string;
|
|
workspaceA: string;
|
|
workspaceB: string;
|
|
cleanup(): void;
|
|
}
|
|
|
|
interface StartedDaemon {
|
|
port: number;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
function nowIso(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
async function seedRestartHome(): Promise<SeededRestartHome> {
|
|
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-playwright-restart-home-"));
|
|
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-playwright-restart-cwd-"));
|
|
const projectsDir = path.join(paseoHome, "projects");
|
|
const agentDir = path.join(paseoHome, "agents", projectDirNameFromCwd(cwd));
|
|
mkdirSync(projectsDir, { recursive: true });
|
|
mkdirSync(agentDir, { recursive: true });
|
|
|
|
const projectDisplayName = path.basename(cwd);
|
|
const project = {
|
|
projectId: `prj_restart_${randomUUID().slice(0, 8)}`,
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: projectDisplayName,
|
|
customName: null,
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
archivedAt: null,
|
|
};
|
|
const workspaceA = {
|
|
workspaceId: "wks_restart_a",
|
|
projectId: project.projectId,
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "Original restart workspace",
|
|
title: null,
|
|
branch: null,
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
archivedAt: null,
|
|
};
|
|
const workspaceB = {
|
|
workspaceId: "wks_restart_b",
|
|
projectId: project.projectId,
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "Restart sibling workspace",
|
|
title: null,
|
|
branch: null,
|
|
createdAt: "2026-03-02T00:00:00.000Z",
|
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
|
archivedAt: null,
|
|
};
|
|
|
|
writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify([project]));
|
|
writeFileSync(
|
|
path.join(projectsDir, "workspaces.json"),
|
|
JSON.stringify([workspaceA, workspaceB]),
|
|
);
|
|
writeFileSync(
|
|
path.join(agentDir, `${LEGACY_AGENT_ID}.json`),
|
|
JSON.stringify({
|
|
id: LEGACY_AGENT_ID,
|
|
provider: "codex",
|
|
cwd,
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
lastActivityAt: "2026-03-01T12:00:00.000Z",
|
|
lastUserMessageAt: null,
|
|
title: "Legacy cwd-only running agent",
|
|
labels: {},
|
|
lastStatus: "running",
|
|
lastModeId: "default",
|
|
config: null,
|
|
runtimeInfo: { provider: "codex", sessionId: null },
|
|
persistence: null,
|
|
archivedAt: null,
|
|
}),
|
|
);
|
|
|
|
return {
|
|
paseoHome,
|
|
cwd,
|
|
projectId: project.projectId,
|
|
projectDisplayName,
|
|
workspaceA: workspaceA.workspaceId,
|
|
workspaceB: workspaceB.workspaceId,
|
|
cleanup: () => {
|
|
rmSync(paseoHome, { recursive: true, force: true });
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
function projectDirNameFromCwd(cwd: string): string {
|
|
const { root } = path.win32.parse(cwd);
|
|
const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, "");
|
|
const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, "");
|
|
const prefix = sanitizedRoot ? `${sanitizedRoot}-` : "";
|
|
if (!withoutRoot) {
|
|
return sanitizedRoot || "root";
|
|
}
|
|
return prefix + withoutRoot.replace(/[\\/]+/g, "-");
|
|
}
|
|
|
|
async function getAvailablePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
server.close(() => reject(new Error("Failed to acquire port")));
|
|
return;
|
|
}
|
|
server.close(() => resolve(address.port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
|
const startedAt = Date.now();
|
|
let lastConnectionError: unknown = null;
|
|
while (Date.now() - startedAt < 20_000) {
|
|
if (child.exitCode !== null) {
|
|
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
|
|
}
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const socket = net.connect(port, "127.0.0.1", () => {
|
|
socket.end();
|
|
resolve();
|
|
});
|
|
socket.setTimeout(1000, () => {
|
|
socket.destroy();
|
|
reject(new Error(`Connection timed out to daemon port ${port}`));
|
|
});
|
|
socket.on("error", reject);
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
lastConnectionError = error;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Restart test daemon did not listen on ${port}. Last error: ${
|
|
lastConnectionError instanceof Error
|
|
? lastConnectionError.message
|
|
: String(lastConnectionError)
|
|
}`,
|
|
);
|
|
}
|
|
|
|
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
child.kill("SIGTERM");
|
|
const timeout = setTimeout(() => {
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
child.kill("SIGKILL");
|
|
}
|
|
}, 5000);
|
|
try {
|
|
await once(child, "exit");
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async function startRestartDaemon(input: {
|
|
paseoHome: string;
|
|
origin: string;
|
|
}): Promise<StartedDaemon> {
|
|
const port = await getAvailablePort();
|
|
if (port === 6767 || String(port) === process.env.E2E_DAEMON_PORT) {
|
|
return startRestartDaemon(input);
|
|
}
|
|
|
|
const serverDir = path.resolve(__dirname, "../../server");
|
|
const tsxBin = execSync("which tsx").toString().trim();
|
|
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
|
cwd: serverDir,
|
|
env: withDisabledE2ESpeechEnv({
|
|
...process.env,
|
|
PASEO_HOME: input.paseoHome,
|
|
PASEO_SERVER_ID: SERVER_ID,
|
|
PASEO_LISTEN: `127.0.0.1:${port}`,
|
|
PASEO_CORS_ORIGINS: input.origin,
|
|
PASEO_RELAY_ENABLED: "0",
|
|
PASEO_NODE_ENV: "development",
|
|
NODE_ENV: "development",
|
|
}),
|
|
stdio: ["ignore", "ignore", "pipe"],
|
|
detached: false,
|
|
});
|
|
let stderr = "";
|
|
child.stderr?.on("data", (chunk: Buffer) => {
|
|
stderr += chunk.toString("utf8");
|
|
stderr = stderr.split("\n").slice(-40).join("\n");
|
|
});
|
|
|
|
try {
|
|
await waitForServer(port, child);
|
|
} catch (error) {
|
|
await stopProcess(child);
|
|
throw new Error(
|
|
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
|
|
return {
|
|
port,
|
|
close: () => stopProcess(child),
|
|
};
|
|
}
|
|
|
|
async function connectRestartDaemonClient(port: number): Promise<RestartDaemonClient> {
|
|
const DaemonClient = await loadDaemonClientConstructor<
|
|
RestartDaemonClientConfig,
|
|
RestartDaemonClient
|
|
>();
|
|
const client = new DaemonClient({
|
|
url: `ws://127.0.0.1:${port}/ws`,
|
|
clientId: `restart-playwright-${randomUUID()}`,
|
|
clientType: "cli",
|
|
appVersion: loadAppVersion(),
|
|
webSocketFactory: createNodeWebSocketFactory(),
|
|
});
|
|
await client.connect();
|
|
return client;
|
|
}
|
|
|
|
function loadAppVersion(): string {
|
|
const packageJson = JSON.parse(
|
|
readFileSync(path.resolve(__dirname, "../package.json"), "utf8"),
|
|
) as { version?: unknown };
|
|
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
|
throw new Error("Missing app package version");
|
|
}
|
|
return packageJson.version;
|
|
}
|
|
|
|
async function seedBrowserForDaemon(page: Page, input: { serverId: string; port: number }) {
|
|
await page.route(/:(6767)\b/, (route) => route.abort());
|
|
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
|
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
|
});
|
|
await page.route(
|
|
"**/*",
|
|
async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/html",
|
|
body: "<!doctype html><html><body>storage seed</body></html>",
|
|
});
|
|
},
|
|
{ times: 1 },
|
|
);
|
|
await page.goto("/");
|
|
|
|
const host = buildSeededHost({
|
|
serverId: input.serverId,
|
|
endpoint: `127.0.0.1:${input.port}`,
|
|
label: "restart daemon",
|
|
nowIso: nowIso(),
|
|
});
|
|
await page.evaluate(
|
|
({ daemon, preferences }) => {
|
|
localStorage.setItem("@paseo:e2e", "1");
|
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
|
localStorage.removeItem("@paseo:settings");
|
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
|
},
|
|
{
|
|
daemon: host,
|
|
preferences: {
|
|
serverId: input.serverId,
|
|
provider: "codex",
|
|
providerPreferences: {
|
|
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
|
|
const pathname = new URL(page.url()).pathname;
|
|
const match = pathname.match(
|
|
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
|
|
);
|
|
if (!match?.[1]) return null;
|
|
return decodeWorkspaceIdFromPathSegment(match[1]);
|
|
}
|
|
|
|
async function expectWorkspaceRowHasOnlyIndicator(
|
|
page: Page,
|
|
input: { serverId: string; workspaceId: string; indicator: string },
|
|
) {
|
|
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
|
|
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
|
|
if (indicator === input.indicator) {
|
|
await expect(locator).toBeVisible({ timeout: 30_000 });
|
|
} else {
|
|
await expect(locator).toHaveCount(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function expectWorkspaceRowDoesNotShowIndicator(
|
|
page: Page,
|
|
input: { serverId: string; workspaceId: string; indicator: string },
|
|
) {
|
|
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
await expect(
|
|
row.locator(`[data-testid="workspace-status-indicator-${input.indicator}"]`),
|
|
).toHaveCount(0, { timeout: 5_000 });
|
|
}
|
|
|
|
async function expectWorkspaceRowInStatusBucket(
|
|
page: Page,
|
|
input: { serverId: string; workspaceId: string; bucket: string },
|
|
) {
|
|
await page.getByTestId("sidebar-display-preferences-menu").click();
|
|
await page.getByTestId("sidebar-grouping-status").click();
|
|
await expect(
|
|
page
|
|
.getByTestId(`sidebar-status-group-rows-${input.bucket}`)
|
|
.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
}
|
|
|
|
async function fetchLegacyAgent(client: RestartDaemonClient) {
|
|
const agents = await client.fetchAgents({ scope: "active" });
|
|
return agents.entries.find(hasLegacyAgentId)?.agent ?? null;
|
|
}
|
|
|
|
function hasLegacyAgentId(entry: { agent: { id: string } }): boolean {
|
|
return entry.agent.id === LEGACY_AGENT_ID;
|
|
}
|
|
|
|
async function fetchWorkspaceStatuses(
|
|
client: RestartDaemonClient,
|
|
workspaceIds: string[],
|
|
): Promise<Record<string, string>> {
|
|
const workspaces = await client.fetchWorkspaces();
|
|
const wantedWorkspaceIds = new Set(workspaceIds);
|
|
const statuses: Record<string, string> = {};
|
|
for (const workspace of workspaces.entries) {
|
|
if (wantedWorkspaceIds.has(workspace.id)) {
|
|
statuses[workspace.id] = workspace.status;
|
|
}
|
|
}
|
|
return statuses;
|
|
}
|
|
|
|
test.describe("Workspace model restart regressions", () => {
|
|
test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const seeded = await seedRestartHome();
|
|
const origin = new URL(baseURL ?? "http://localhost").origin;
|
|
const daemon = await startRestartDaemon({ paseoHome: seeded.paseoHome, origin });
|
|
const serverId = SERVER_ID;
|
|
const client = await connectRestartDaemonClient(daemon.port);
|
|
|
|
try {
|
|
await seedBrowserForDaemon(page, { serverId, port: daemon.port });
|
|
|
|
await expect
|
|
.poll(() => fetchLegacyAgent(client))
|
|
.toMatchObject({
|
|
id: LEGACY_AGENT_ID,
|
|
workspaceId: seeded.workspaceA,
|
|
status: "running",
|
|
});
|
|
|
|
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
|
await waitForSidebarHydration(page);
|
|
await expectWorkspaceRowHasOnlyIndicator(page, {
|
|
serverId,
|
|
workspaceId: seeded.workspaceA,
|
|
indicator: "running",
|
|
});
|
|
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
|
serverId,
|
|
workspaceId: seeded.workspaceB,
|
|
indicator: "running",
|
|
});
|
|
await expect
|
|
.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: reconciledProjectKey.projectKey,
|
|
projectDisplayName: seeded.projectDisplayName,
|
|
});
|
|
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
|
await submitNewWorkspaceEmpty(page);
|
|
|
|
await expect
|
|
.poll(() => {
|
|
const workspaceId = parseWorkspaceIdFromPageUrl(page, serverId);
|
|
return workspaceId && workspaceId !== seeded.workspaceA ? workspaceId : null;
|
|
})
|
|
.not.toBeNull();
|
|
const createdWorkspaceId = parseWorkspaceIdFromPageUrl(page, serverId);
|
|
if (!createdWorkspaceId) {
|
|
throw new Error(`Expected browser to navigate to created workspace, got ${page.url()}`);
|
|
}
|
|
|
|
await expect
|
|
.poll(() =>
|
|
fetchWorkspaceStatuses(client, [
|
|
seeded.workspaceA,
|
|
seeded.workspaceB,
|
|
createdWorkspaceId,
|
|
]),
|
|
)
|
|
.toEqual({
|
|
[seeded.workspaceA]: "running",
|
|
[seeded.workspaceB]: "done",
|
|
[createdWorkspaceId]: "done",
|
|
});
|
|
|
|
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
|
serverId,
|
|
workspaceId: seeded.workspaceB,
|
|
indicator: "running",
|
|
});
|
|
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
|
serverId,
|
|
workspaceId: createdWorkspaceId,
|
|
indicator: "running",
|
|
});
|
|
await expectWorkspaceRowInStatusBucket(page, {
|
|
serverId,
|
|
workspaceId: seeded.workspaceA,
|
|
bucket: "running",
|
|
});
|
|
await expectWorkspaceRowInStatusBucket(page, {
|
|
serverId,
|
|
workspaceId: seeded.workspaceB,
|
|
bucket: "done",
|
|
});
|
|
await expectWorkspaceRowInStatusBucket(page, {
|
|
serverId,
|
|
workspaceId: createdWorkspaceId,
|
|
bucket: "done",
|
|
});
|
|
await expect
|
|
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
|
.toEqual([]);
|
|
|
|
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
|
await expect
|
|
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
|
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
|
|
} finally {
|
|
await client.close().catch(() => undefined);
|
|
await daemon.close();
|
|
seeded.cleanup();
|
|
}
|
|
});
|
|
});
|