* fix(app): keep an open workspace menu from unmounting its own trigger The sidebar kebab is revealed by hover, and on a compact layout the menu it opens is a sheet that slides up over the row it came from. The row un-hovers, the trailing overlay unmounts the trigger, and the menu's open state goes with it — nothing is left that can close the surface already presented in the portal, so a full-screen backdrop swallows every click until reload. Lift the menu's open state to the component that decides whether to render the kebab, and keep the kebab rendered while the menu is up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(app): follow the sidebar and host badge redesigns in the e2e suite Three specs were still driving surfaces that moved out from under them, so CI has been red on main since the sidebar polish landed. Display preferences became one row per decision with the options a page down, so grouping and the host filter need that page walked first. Five specs had their own copy of the two-line dance; it lives in the sidebar helper now, and the host filter has its own opener next to the menu it belongs to. The host badge is plain secondary text on the meta line and its identity colour lives on the server icon alone, so the helpers stop asserting a tinted label and a pill fill that the redesign removed. Alignment rails are compared across canvas glyph metrics and SVG bounding boxes, so the residual depends on the host's font; a 1px and a 0.5px tolerance both landed on the wrong side of it in CI. Two pixels still catches the whole- pixel offsets these rails encode. The compact sheet spec also read its rails while the panel was still sliding in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(app): let a browser e2e run take a private Metro cache Metro's on-disk cache is shared by every checkout on the machine. Editing one app source file rebuilds that module against everyone else's cached output and the Unistyles styles can come back desynced, which surfaces as alignment specs failing with real-looking pixel numbers that go away when the edit is reverted. E2E_METRO_CACHE_VERSION namespaces a run's cache without deleting anyone else's entries, so the cache can be ruled out before a layout failure is believed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(app): stop the chat scroll helper from hanging on a stalled sampler `scrollChatAwayFromBottom` waited on a promise that could only ever resolve from a `requestAnimationFrame` sampler started by a `wheel` listener. rAF does not fire while the page is occluded, which is reachable on a CI runner, so the sampler could stall on its first frame and the wait never returned — the test died on its own 240s timeout with nothing to read. A missed wheel event did the same. Settling the scroll before measuring is an optimization; the assertion after it is what decides the test. Sample on a timer with a deadline so a stall falls through to that assertion instead of consuming the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(app): remove layout geometry e2e coverage Rail and pixel-alignment assertions depend on host rendering details and should not define the browser E2E contract. Preserve the surrounding behavior coverage while dropping the Metro cache escape hatch that only supported diagnosing those assertions. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
552 lines
17 KiB
TypeScript
552 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 { expect, type Page } from "@playwright/test";
|
|
import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
|
import { metroTest as test } from "../support/fixtures";
|
|
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
|
import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader";
|
|
import {
|
|
createNodeWebSocketFactory,
|
|
type NodeWebSocketFactory,
|
|
} from "../support/helpers/node-ws-factory";
|
|
import { withDisabledE2ESpeechEnv } from "../support/helpers/speech-env";
|
|
import {
|
|
expectNewWorkspaceProjectSelected,
|
|
openGlobalNewWorkspaceComposer,
|
|
selectNewWorkspaceProject,
|
|
submitNewWorkspaceEmpty,
|
|
} from "../support/helpers/new-workspace";
|
|
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
|
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
|
import { getVisibleWorkspaceAgentTabIds } from "../support/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 selectSidebarStatusGrouping(page);
|
|
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();
|
|
}
|
|
});
|
|
});
|