Key workspace status and ownership by ID, not directory
Multiple workspaces can share one checkout directory. Agent and terminal status, sidebar tabs, and ownership now attribute strictly by workspace ID, so a running or blocked agent in one workspace no longer shows up on its same-directory siblings. A one-time startup migration backfills workspace IDs onto legacy agents, and every agent and terminal is created with one thereafter. Directory lookups remain only for genuinely folder-derived git facts (branch, diff) and the archive-by-path adapter.
This commit is contained in:
@@ -23,7 +23,7 @@ async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, () => {
|
||||
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")));
|
||||
@@ -34,6 +34,14 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
const RESERVED_LOCAL_PORTS = new Set([
|
||||
// Default developer daemon.
|
||||
6767,
|
||||
// OpenCode's default local server port. Some provider probes can spawn it
|
||||
// during daemon startup, so the E2E daemon must not choose the same port.
|
||||
61680,
|
||||
]);
|
||||
|
||||
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
|
||||
const lines: string[] = [];
|
||||
return {
|
||||
@@ -486,7 +494,7 @@ async function awaitRelayReady(
|
||||
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
|
||||
for (;;) {
|
||||
const port = await getAvailablePort();
|
||||
if (!excludedPorts.has(port)) {
|
||||
if (!excludedPorts.has(port) && !RESERVED_LOCAL_PORTS.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -677,8 +685,8 @@ export default async function globalSetup() {
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
await loadEnvTestFile(repoRoot);
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const metroPort = await getAvailablePort();
|
||||
const port = await getAvailablePortExcluding(new Set());
|
||||
const metroPort = await getAvailablePortExcluding(new Set([port]));
|
||||
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
|
||||
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface IdleAgentSeedClient {
|
||||
model: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
}): Promise<{ id: string }>;
|
||||
waitForAgentUpsert(
|
||||
@@ -66,6 +67,7 @@ export async function createIdleAgent(
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "bypassPermissions",
|
||||
cwd: input.cwd,
|
||||
workspaceId: opened.workspace.id,
|
||||
title: input.title,
|
||||
});
|
||||
const snapshot = await client.waitForAgentUpsert(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
@@ -47,9 +48,18 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
|
||||
url: resolveDaemonWsUrl(),
|
||||
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: options.appVersion,
|
||||
appVersion: options.appVersion ?? loadAppVersion(),
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export async function seedMockAgentWorkspace(
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: options.title,
|
||||
modeId: options.modeId ?? "load-test",
|
||||
model: options.model ?? "ten-second-stream",
|
||||
|
||||
@@ -14,7 +14,9 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "createPaseoWorktree"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "openProject"
|
||||
| "patchDaemonConfig"
|
||||
>;
|
||||
|
||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||
@@ -314,18 +316,18 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
assertHeader?: boolean;
|
||||
},
|
||||
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
|
||||
// Wait for URL to redirect to the newly created workspace.
|
||||
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
|
||||
let workspaceId: string | null = null;
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
// URL is the source of truth so concurrent sidebar rows cannot satisfy this.
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Listens for outbound WebSocket "session" frames of a given inner message type
|
||||
* and accumulates them. The returned array is populated in-place as frames arrive.
|
||||
*/
|
||||
export function captureWsSessionFrames<T extends Record<string, unknown>>(
|
||||
page: Page,
|
||||
messageType: string,
|
||||
extract: (inner: Record<string, unknown>) => T,
|
||||
): T[] {
|
||||
const captured: T[] = [];
|
||||
page.on("websocket", (ws) => {
|
||||
ws.on("framesent", (frame) => {
|
||||
const raw = frame.payload;
|
||||
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
||||
try {
|
||||
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
|
||||
if (outer.type === "session" && outer.message?.type === messageType) {
|
||||
captured.push(extract(outer.message));
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON and binary frames.
|
||||
}
|
||||
});
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
export function renameModalInput(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-input`);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,11 @@ export interface SeedDaemonClient {
|
||||
terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
listTerminals(cwd?: string): Promise<{
|
||||
listTerminals(
|
||||
cwd?: string,
|
||||
requestId?: string,
|
||||
options?: { workspaceId?: string },
|
||||
): Promise<{
|
||||
terminals: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -80,7 +84,18 @@ export interface SeedDaemonClient {
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||
entries: Array<{
|
||||
agent: {
|
||||
id: string;
|
||||
provider: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
model: string | null;
|
||||
currentModeId: string | null;
|
||||
status: string;
|
||||
title?: string | null;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
fetchRecentProviderSessions(options: {
|
||||
cwd: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { createIdleAgent, expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId: string }) {
|
||||
@@ -17,8 +17,13 @@ async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
|
||||
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
}
|
||||
|
||||
test.describe("Workspace agent tab rename", () => {
|
||||
test("right-click rename sends update_agent_request and updates the tab label", async ({
|
||||
test("right-click rename persists the agent title and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -32,12 +37,6 @@ test.describe("Workspace agent tab rename", () => {
|
||||
title: initialTitle,
|
||||
});
|
||||
|
||||
const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({
|
||||
agentId: String(inner.agentId ?? ""),
|
||||
name: String(inner.name ?? ""),
|
||||
requestId: String(inner.requestId ?? ""),
|
||||
}));
|
||||
|
||||
await openAgentInWorkspace(page, agent);
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||
@@ -62,12 +61,7 @@ test.describe("Workspace agent tab rename", () => {
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(tab).toContainText(renamed, { timeout: 15_000 });
|
||||
|
||||
expect(updateFrames.length).toBeGreaterThan(0);
|
||||
const lastFrame = updateFrames.at(-1)!;
|
||||
expect(lastFrame.agentId).toBe(agent.id);
|
||||
expect(lastFrame.name).toBe(renamed);
|
||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||
await expect.poll(() => fetchAgentTitle(workspace.client, agent.id)).toBe(renamed);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,135 +1,35 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface WorkspaceTabProbeRecord {
|
||||
at: number;
|
||||
tabs: Array<{
|
||||
testId: string;
|
||||
text: string;
|
||||
ariaLabel: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CapturedCreateAgentFrame {
|
||||
initialPrompt: string | null;
|
||||
configTitle: string | null;
|
||||
}
|
||||
|
||||
async function installWorkspaceTabProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
type ProbeRecord = WorkspaceTabProbeRecord;
|
||||
type ProbeWindow = Window & {
|
||||
__workspaceTabTitleProbe?: { records: ProbeRecord[]; stop: () => void };
|
||||
};
|
||||
|
||||
const win = window as ProbeWindow;
|
||||
win.__workspaceTabTitleProbe?.stop();
|
||||
|
||||
const records: ProbeRecord[] = [];
|
||||
const isVisible = (element: Element): element is HTMLElement => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden"
|
||||
);
|
||||
};
|
||||
const snapshot = () => {
|
||||
records.push({
|
||||
at: performance.now(),
|
||||
tabs: Array.from(document.querySelectorAll('[data-testid^="workspace-tab-"]'))
|
||||
.filter(isVisible)
|
||||
.map((element) => ({
|
||||
testId: element.getAttribute("data-testid") ?? "",
|
||||
text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
|
||||
ariaLabel: element.getAttribute("aria-label") ?? "",
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
snapshot();
|
||||
const observer = new MutationObserver(snapshot);
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-label", "class", "data-testid", "style"],
|
||||
});
|
||||
const interval = window.setInterval(snapshot, 20);
|
||||
win.__workspaceTabTitleProbe = {
|
||||
records,
|
||||
stop: () => {
|
||||
observer.disconnect();
|
||||
window.clearInterval(interval);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readWorkspaceTabProbe(page: Page): Promise<WorkspaceTabProbeRecord[]> {
|
||||
return page.evaluate(() => {
|
||||
type ProbeWindow = Window & {
|
||||
__workspaceTabTitleProbe?: { records: WorkspaceTabProbeRecord[]; stop: () => void };
|
||||
};
|
||||
const probe = (window as ProbeWindow).__workspaceTabTitleProbe;
|
||||
probe?.stop();
|
||||
return probe?.records ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
function recordHasTabLabel(record: WorkspaceTabProbeRecord, label: string): boolean {
|
||||
return record.tabs.some((tab) => tab.text.includes(label) || tab.ariaLabel.includes(label));
|
||||
}
|
||||
|
||||
function createFrameStartsWithPrompt(
|
||||
frame: CapturedCreateAgentFrame,
|
||||
promptTitle: string,
|
||||
): boolean {
|
||||
return frame.initialPrompt?.startsWith(promptTitle) ?? false;
|
||||
}
|
||||
|
||||
function countCreateFramesForPrompt(
|
||||
frames: CapturedCreateAgentFrame[],
|
||||
promptTitle: string,
|
||||
): number {
|
||||
return frames.filter((frame) => createFrameStartsWithPrompt(frame, promptTitle)).length;
|
||||
}
|
||||
|
||||
function tabHasLoadingTitle(tab: WorkspaceTabProbeRecord["tabs"][number]): boolean {
|
||||
return /Loading agent title|Loading\.\.\./.test(`${tab.text} ${tab.ariaLabel}`);
|
||||
}
|
||||
|
||||
function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean {
|
||||
return record.tabs.some(tabHasLoadingTitle);
|
||||
}
|
||||
|
||||
async function waitForCreatedAgentId(client: SeedDaemonClient, cwd: string): Promise<string> {
|
||||
async function waitForCreatedAgentId(
|
||||
client: SeedDaemonClient,
|
||||
input: { cwd: string; workspaceId: string },
|
||||
): Promise<string> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries
|
||||
.filter((entry) => entry.agent.cwd === cwd)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.agent.cwd === input.cwd && entry.agent.workspaceId === input.workspaceId,
|
||||
)
|
||||
.map((entry) => entry.agent.id);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toHaveLength(1);
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
const agent = result.entries.find((entry) => entry.agent.cwd === cwd);
|
||||
const agent = result.entries.find(
|
||||
(entry) => entry.agent.cwd === input.cwd && entry.agent.workspaceId === input.workspaceId,
|
||||
);
|
||||
if (!agent) {
|
||||
throw new Error(`Expected one created agent in ${cwd}`);
|
||||
throw new Error(`Expected one created agent in ${input.cwd}`);
|
||||
}
|
||||
return agent.agent.id;
|
||||
}
|
||||
@@ -142,43 +42,17 @@ async function fetchActiveAgentTitle(
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
}
|
||||
|
||||
async function waitForPromptTabAgentActions(page: Page, promptTitle: string): Promise<void> {
|
||||
const promptTab = page.getByRole("button", { name: promptTitle }).first();
|
||||
await expect(promptTab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
await promptTab.click({ button: "right" });
|
||||
const renameAction = page.getByText("Rename", { exact: true }).first();
|
||||
if (await renameAction.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press("Escape");
|
||||
return;
|
||||
}
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
throw new Error("Prompt tab did not expose agent tab actions after create handoff");
|
||||
}
|
||||
|
||||
test.describe("Workspace agent title handoff", () => {
|
||||
test("keeps the prompt as the optimistic tab title until the generated title arrives", async ({
|
||||
test("shows the prompt tab title and replaces it when the daemon title updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-title-handoff-" });
|
||||
|
||||
try {
|
||||
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||
configTitle: typeof config.title === "string" ? config.title : null,
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
@@ -186,21 +60,39 @@ test.describe("Workspace agent title handoff", () => {
|
||||
|
||||
const promptTitle = "Investigate optimistic tab title handoff";
|
||||
const generatedTitle = "Generated Handoff Title";
|
||||
await installWorkspaceTabProbe(page);
|
||||
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
|
||||
await agentCreatedDelay.waitForCreateRequest();
|
||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
|
||||
const agentId = await waitForCreatedAgentId(workspace.client, workspace.repoPath);
|
||||
await expect
|
||||
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(1);
|
||||
expect(createFrames.at(-1)).toEqual({
|
||||
initialPrompt: `${promptTitle}\n\nMake the UI state deterministic.`,
|
||||
configTitle: null,
|
||||
await expect(page.getByRole("button", { name: promptTitle }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(/Loading agent title|Loading\.\.\./).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
const agentId = await waitForCreatedAgentId(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
|
||||
await waitForPromptTabAgentActions(page, promptTitle);
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0);
|
||||
agentCreatedDelay.release();
|
||||
|
||||
const agentTab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
|
||||
await expect(agentTab).toBeVisible({ timeout: 15_000 });
|
||||
await expect
|
||||
.poll(() => fetchActiveAgentTitle(workspace.client, agentId), { timeout: 10_000 })
|
||||
.toBe(promptTitle);
|
||||
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
|
||||
await agentTab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(
|
||||
page.getByText(/Loading agent title|Loading\.\.\./).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await workspace.client.updateAgent(agentId, { name: generatedTitle });
|
||||
await expect
|
||||
@@ -209,12 +101,8 @@ test.describe("Workspace agent title handoff", () => {
|
||||
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const records = await readWorkspaceTabProbe(page);
|
||||
expect(records.some((record) => recordHasTabLabel(record, promptTitle))).toBe(true);
|
||||
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
|
||||
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
|
||||
} finally {
|
||||
agentCreatedDelay.release();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
@@ -9,13 +9,25 @@ import {
|
||||
waitForTerminalContent,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
interface CreateAgentFrame {
|
||||
initialPrompt: string | null;
|
||||
interface CreatedAgentCwdAssertion {
|
||||
workspaceId: string;
|
||||
cwd: string | null;
|
||||
}
|
||||
|
||||
function cwdForPrompt(frames: CreateAgentFrame[], prompt: string): string | null {
|
||||
return frames.find((frame) => frame.initialPrompt === prompt)?.cwd ?? null;
|
||||
async function fetchSingleAgentForWorkspace(
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<CreatedAgentCwdAssertion | null> {
|
||||
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId);
|
||||
if (agents.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const [agent] = agents;
|
||||
return {
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: agent.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
@@ -36,37 +48,36 @@ test.describe("Workspace cwd correctness", () => {
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
|
||||
test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => {
|
||||
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const createAgentFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||
cwd: typeof config.cwd === "string" ? config.cwd : null,
|
||||
};
|
||||
});
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const message = `cwd draft create ${Date.now()}`;
|
||||
await expect(composer).toBeEditable({ timeout: 15_000 });
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await clickNewChat(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const message = `cwd draft create ${Date.now()}`;
|
||||
await expect(composer).toBeEditable({ timeout: 15_000 });
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => cwdForPrompt(createAgentFrames, message), { timeout: 30_000 })
|
||||
.toBe(workspace.repoPath);
|
||||
await expect
|
||||
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
|
||||
.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||
|
||||
@@ -1,24 +1,140 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
|
||||
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
import {
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectWorkspaceBacking,
|
||||
submitNewWorkspaceEmpty,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceHeader, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
||||
|
||||
type NewWorkspaceDaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
type WorkspaceIndicator = "attention" | "done" | "failed" | "loading" | "needs_input" | "running";
|
||||
|
||||
interface CreateAgentFrame extends Record<string, unknown> {
|
||||
initialPrompt: string | null;
|
||||
workspaceId: string | null;
|
||||
provider: string | null;
|
||||
cwd: string | null;
|
||||
interface CreatedAgentAssertion {
|
||||
workspaceId: string;
|
||||
provider: string;
|
||||
cwd: string;
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
function createFrameForPrompt(frames: CreateAgentFrame[], prompt: string): CreateAgentFrame | null {
|
||||
return frames.find((frame) => frame.initialPrompt === prompt) ?? null;
|
||||
async function fetchCreatedAgentForWorkspace(
|
||||
seeded: SeededWorkspace,
|
||||
workspaceId: string,
|
||||
): Promise<CreatedAgentAssertion | null> {
|
||||
const result = await seeded.client.fetchAgents({ scope: "active" });
|
||||
const agents = result.entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.cwd === seeded.repoPath && agent.workspaceId === workspaceId);
|
||||
if (agents.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const [agent] = agents;
|
||||
return {
|
||||
workspaceId,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
modeId: agent.currentModeId,
|
||||
model: agent.model,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWorkspaceName(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchWorkspaces();
|
||||
return result.entries.find((entry) => entry.id === workspaceId)?.name ?? null;
|
||||
}
|
||||
|
||||
async function fetchWorkspaceStatuses(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceIds: string[],
|
||||
): Promise<Record<string, string | null>> {
|
||||
const result = await client.fetchWorkspaces();
|
||||
const entriesById = new Map(result.entries.map((entry) => [entry.id, entry.status]));
|
||||
return Object.fromEntries(
|
||||
workspaceIds.map((workspaceId) => [workspaceId, entriesById.get(workspaceId) ?? null]),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promise<string | null> {
|
||||
const result = await seeded.client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.status ?? null;
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-grouping-selector").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
function statusGroupRows(page: import("@playwright/test").Page, bucket: string) {
|
||||
return page.getByTestId(`sidebar-status-group-rows-${bucket}`);
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowInStatusBucket(
|
||||
page: import("@playwright/test").Page,
|
||||
input: { rowTestId: string; bucket: string },
|
||||
) {
|
||||
await expect(statusGroupRows(page, input.bucket).getByTestId(input.rowTestId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowNotInStatusBuckets(
|
||||
page: import("@playwright/test").Page,
|
||||
input: { rowTestId: string; buckets: string[] },
|
||||
) {
|
||||
for (const bucket of input.buckets) {
|
||||
await expect(statusGroupRows(page, bucket).getByTestId(input.rowTestId)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowHasOnlyIndicator(
|
||||
page: import("@playwright/test").Page,
|
||||
input: { rowTestId: string; indicator: WorkspaceIndicator },
|
||||
) {
|
||||
const row = page.getByTestId(input.rowTestId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
for (const indicator of [
|
||||
"attention",
|
||||
"done",
|
||||
"failed",
|
||||
"loading",
|
||||
"needs_input",
|
||||
"running",
|
||||
] satisfies WorkspaceIndicator[]) {
|
||||
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: import("@playwright/test").Page,
|
||||
input: { rowTestId: string; indicator: WorkspaceIndicator },
|
||||
) {
|
||||
const row = page.getByTestId(input.rowTestId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
row.locator(`[data-testid="workspace-status-indicator-${input.indicator}"]`),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
test.describe("Workspace model regressions", () => {
|
||||
@@ -34,14 +150,17 @@ test.describe("Workspace model regressions", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("same-directory workspace does not inherit legacy cwd-only agent tabs", async ({ page }) => {
|
||||
test("same-directory workspace does not show agents owned by another workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "workspace-legacy-agents-" });
|
||||
|
||||
try {
|
||||
const legacyAgent = await seeded.client.createAgent({
|
||||
const ownedAgent = await seeded.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: seeded.repoPath,
|
||||
title: "Legacy cwd-only agent",
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Agent owned by original workspace",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
@@ -62,7 +181,7 @@ test.describe("Workspace model regressions", () => {
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await expect
|
||||
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
||||
.toContain(`workspace-tab-agent_${legacyAgent.id}`);
|
||||
.toContain(`workspace-tab-agent_${ownedAgent.id}`);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
@@ -74,21 +193,6 @@ test.describe("Workspace model regressions", () => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "workspace-new-agent-model-",
|
||||
});
|
||||
const createFrames = captureWsSessionFrames<CreateAgentFrame>(
|
||||
page,
|
||||
"create_agent_request",
|
||||
(inner) => {
|
||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||
workspaceId: typeof inner.workspaceId === "string" ? inner.workspaceId : null,
|
||||
provider: typeof config.provider === "string" ? config.provider : null,
|
||||
cwd: typeof config.cwd === "string" ? config.cwd : null,
|
||||
modeId: typeof config.modeId === "string" ? config.modeId : null,
|
||||
model: typeof config.model === "string" ? config.model : null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const secondWorkspace = await seeded.client.createWorkspace({
|
||||
@@ -98,6 +202,7 @@ test.describe("Workspace model regressions", () => {
|
||||
if (!secondWorkspace.workspace) {
|
||||
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
|
||||
}
|
||||
const workspace = secondWorkspace.workspace;
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
@@ -110,7 +215,7 @@ test.describe("Workspace model regressions", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
await gotoWorkspace(page, secondWorkspace.workspace.id);
|
||||
await gotoWorkspace(page, workspace.id);
|
||||
await clickNewChat(page);
|
||||
|
||||
await expectComposerVisible(page);
|
||||
@@ -121,12 +226,11 @@ test.describe("Workspace model regressions", () => {
|
||||
0,
|
||||
);
|
||||
await expect
|
||||
.poll(() => createFrameForPrompt(createFrames, prompt), {
|
||||
.poll(() => fetchCreatedAgentForWorkspace(seeded, workspace.id), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toEqual({
|
||||
initialPrompt: prompt,
|
||||
workspaceId: secondWorkspace.workspace.id,
|
||||
workspaceId: workspace.id,
|
||||
provider: "mock",
|
||||
cwd: seeded.repoPath,
|
||||
modeId: "load-test",
|
||||
@@ -136,4 +240,291 @@ test.describe("Workspace model regressions", () => {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("local same-directory workspace with an initial prompt shows a generated title", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "workspace-generated-local-title-",
|
||||
});
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: [{ provider: "mock", model: "ten-second-stream" }],
|
||||
},
|
||||
});
|
||||
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceBacking(page, "local");
|
||||
await submitNewWorkspacePrompt(page, "Fix login bug");
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
const createdRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(() => fetchWorkspaceName(client, createdWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe("Fix login bug");
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: "Fix login bug",
|
||||
subtitle: seeded.projectDisplayName,
|
||||
});
|
||||
await expect(createdRow).toContainText("Fix login bug", { timeout: 30_000 });
|
||||
expect(createdWorkspace.workspaceDirectory).toBe(seeded.workspaceDirectory);
|
||||
} finally {
|
||||
await client
|
||||
.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: previousConfig.config.metadataGeneration.providers,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("running agent in one same-directory workspace only shows the loader on its owning row", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "workspace-same-cwd-running-",
|
||||
});
|
||||
|
||||
try {
|
||||
const secondWorkspace = await seeded.client.createWorkspace({
|
||||
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
|
||||
title: "Existing sibling workspace",
|
||||
});
|
||||
if (!secondWorkspace.workspace) {
|
||||
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
|
||||
}
|
||||
const secondWorkspaceId = secondWorkspace.workspace.id;
|
||||
|
||||
const runningAgent = await seeded.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Running agent",
|
||||
modeId: "load-test",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "stay running",
|
||||
});
|
||||
await seeded.client.waitForAgentUpsert(
|
||||
runningAgent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
15_000,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
const firstRowTestId = `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`;
|
||||
const secondRowTestId = `sidebar-workspace-row-${serverId}:${secondWorkspaceId}`;
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceBacking(page, "local");
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
const createdRowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
|
||||
await expect
|
||||
.poll(() => fetchAgentStatus(seeded, runningAgent.id), { timeout: 10_000 })
|
||||
.toBe("running");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
fetchWorkspaceStatuses(client, [
|
||||
seeded.workspaceId,
|
||||
secondWorkspaceId,
|
||||
createdWorkspace.workspaceId,
|
||||
]),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toEqual({
|
||||
[seeded.workspaceId]: "running",
|
||||
[secondWorkspaceId]: "done",
|
||||
[createdWorkspace.workspaceId]: "done",
|
||||
});
|
||||
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
|
||||
await switchSidebarToStatusGrouping(page);
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
bucket: "running",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
bucket: "done",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
bucket: "done",
|
||||
});
|
||||
await expectWorkspaceRowNotInStatusBuckets(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
buckets: ["running", "needs_input", "attention"],
|
||||
});
|
||||
await expectWorkspaceRowNotInStatusBuckets(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
buckets: ["running", "needs_input", "attention"],
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pending permission in one same-directory workspace marks only its own row needing input", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "workspace-same-cwd-permission-",
|
||||
});
|
||||
|
||||
try {
|
||||
const secondWorkspace = await seeded.client.createWorkspace({
|
||||
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
|
||||
title: "Permission sibling workspace",
|
||||
});
|
||||
if (!secondWorkspace.workspace) {
|
||||
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
|
||||
}
|
||||
const secondWorkspaceId = secondWorkspace.workspace.id;
|
||||
|
||||
const agent = await seeded.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Permission agent",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await seeded.client.sendAgentMessage(agent.id, "Emit synthetic plan approval.");
|
||||
const parked = await seeded.client.waitForFinish(agent.id, 15_000);
|
||||
expect(parked.status).toBe("permission");
|
||||
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
const firstRowTestId = `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`;
|
||||
const secondRowTestId = `sidebar-workspace-row-${serverId}:${secondWorkspaceId}`;
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
indicator: "needs_input",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
indicator: "needs_input",
|
||||
});
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceBacking(page, "local");
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
const createdRowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
fetchWorkspaceStatuses(client, [
|
||||
seeded.workspaceId,
|
||||
secondWorkspaceId,
|
||||
createdWorkspace.workspaceId,
|
||||
]),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toEqual({
|
||||
[seeded.workspaceId]: "needs_input",
|
||||
[secondWorkspaceId]: "done",
|
||||
[createdWorkspace.workspaceId]: "done",
|
||||
});
|
||||
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
indicator: "needs_input",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
indicator: "needs_input",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
indicator: "needs_input",
|
||||
});
|
||||
|
||||
await switchSidebarToStatusGrouping(page);
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
bucket: "needs_input",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
bucket: "done",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
bucket: "done",
|
||||
});
|
||||
await expectWorkspaceRowNotInStatusBuckets(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
buckets: ["running", "needs_input", "attention"],
|
||||
});
|
||||
await expectWorkspaceRowNotInStatusBuckets(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
buckets: ["running", "needs_input", "attention"],
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
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 {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspaceEmpty,
|
||||
} from "./helpers/new-workspace";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
||||
|
||||
const LEGACY_AGENT_ID = "legacy-cwd-only-agent";
|
||||
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;
|
||||
}>;
|
||||
}>;
|
||||
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: {
|
||||
...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_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_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-grouping-selector").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}`);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,67 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
|
||||
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
|
||||
async function fetchTerminalTitle(
|
||||
workspace: SeededWorkspace,
|
||||
terminalId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const terminal = result.terminals.find((entry) => entry.id === terminalId);
|
||||
return terminal?.title ?? null;
|
||||
}
|
||||
|
||||
async function waitForCreatedTerminalId(workspace: SeededWorkspace): Promise<string> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
return result.terminals.map((entry) => entry.id);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toHaveLength(1);
|
||||
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const terminal = result.terminals[0];
|
||||
if (!terminal) {
|
||||
throw new Error("Expected one created terminal");
|
||||
}
|
||||
return terminal.id;
|
||||
}
|
||||
|
||||
test.describe("Workspace terminal tab rename", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "workspace-terminal-rename-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await harness?.cleanup();
|
||||
});
|
||||
|
||||
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
|
||||
test("right-click rename persists the terminal title and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
|
||||
terminalId: String(inner.terminalId ?? ""),
|
||||
title: String(inner.title ?? ""),
|
||||
requestId: String(inner.requestId ?? ""),
|
||||
}));
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
|
||||
let terminalId: string | null = null;
|
||||
|
||||
await withTerminalInApp(page, harness, { name: "rename-target" }, async (terminal) => {
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminal.id}`).first();
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
terminalId = await waitForCreatedTerminalId(workspace);
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminal.id}`)).toBeVisible({
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminal.id}-rename`);
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminal.id}`;
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -45,12 +70,14 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
|
||||
|
||||
expect(renameFrames.length).toBeGreaterThan(0);
|
||||
const lastFrame = renameFrames.at(-1)!;
|
||||
expect(lastFrame.terminalId).toBe(terminal.id);
|
||||
expect(lastFrame.title).toBe("My Renamed Terminal");
|
||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||
});
|
||||
await expect
|
||||
.poll(() => fetchTerminalTitle(workspace, terminalId!))
|
||||
.toBe("My Renamed Terminal");
|
||||
} finally {
|
||||
if (terminalId) {
|
||||
await workspace.client.killTerminal(terminalId).catch(() => undefined);
|
||||
}
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user