import { expect } from "vitest"; import { control, evalIn, go, waitFor } from "@openwork/behaviors"; import type { Surface } from "@openwork/cdp"; import { screenshot } from "@openwork/test-evidence"; import { localMysqlIsRunning, localRedisIsRunning, needs, sleep, test, } from "@openwork/testkit"; import { bootCrossWorkspaceSplitView } from "../../worlds/cross-workspace-split-view.ts"; const e2eTestsEnabled = process.env.OPENWORK_EVAL_E2E_TESTS === "1"; const daytonaEnabled = process.env.OPENWORK_EVAL_DAYTONA === "1"; const configuredDen = Boolean(process.env.OPENWORK_EVAL_DEN_API_URL?.trim()); const localServicesRequired = !daytonaEnabled && !configuredDen; const mysqlOpen = await localMysqlIsRunning(); const redisOpen = await localRedisIsRunning(); const runnable = e2eTestsEnabled && (!localServicesRequired || (mysqlOpen && redisOpen)); const skipSuffix = !e2eTestsEnabled ? " skipped — needs: set OPENWORK_EVAL_E2E_TESTS=1" : localServicesRequired && !mysqlOpen ? " skipped — needs MySQL on 127.0.0.1:3306" : localServicesRequired && !redisOpen ? " skipped — needs Redis on 127.0.0.1:6379" : ""; type WorkspaceListing = { ids: string[]; activeId: string | null; }; type SplitCandidate = { workspaceId: string; sessionId: string; title: string; }; type SplitFacts = { layout: string; primarySessionId: string; secondarySessionId: string; primaryLayoutWorkspaceId: string; secondaryLayoutWorkspaceId: string; primaryPaneWorkspaceId: string; secondaryPaneWorkspaceId: string; primarySurfaceWorkspaceId: string; secondarySurfaceWorkspaceId: string; primaryWorkspaceName: string; secondaryWorkspaceName: string; primaryResourceWorkspaceId: string; secondaryResourceWorkspaceId: string; primaryOwnsSecondarySurface: boolean; secondaryOwnsPrimarySurface: boolean; primaryUnavailable: boolean; secondaryUnavailable: boolean; }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function parseWorkspaceListing(value: unknown): WorkspaceListing { if (!isRecord(value) || value.ok !== true || !Array.isArray(value.ids)) { throw new Error(`Invalid workspace listing: ${JSON.stringify(value)}`); } return { ids: value.ids.filter((id): id is string => typeof id === "string"), activeId: typeof value.activeId === "string" ? value.activeId : null, }; } function parseSplitFacts(value: unknown): SplitFacts { if (!isRecord(value)) throw new Error(`Invalid split facts: ${JSON.stringify(value)}`); const text = (key: string) => typeof value[key] === "string" ? value[key] : ""; return { layout: text("layout"), primarySessionId: text("primarySessionId"), secondarySessionId: text("secondarySessionId"), primaryLayoutWorkspaceId: text("primaryLayoutWorkspaceId"), secondaryLayoutWorkspaceId: text("secondaryLayoutWorkspaceId"), primaryPaneWorkspaceId: text("primaryPaneWorkspaceId"), secondaryPaneWorkspaceId: text("secondaryPaneWorkspaceId"), primarySurfaceWorkspaceId: text("primarySurfaceWorkspaceId"), secondarySurfaceWorkspaceId: text("secondarySurfaceWorkspaceId"), primaryWorkspaceName: text("primaryWorkspaceName"), secondaryWorkspaceName: text("secondaryWorkspaceName"), primaryResourceWorkspaceId: text("primaryResourceWorkspaceId"), secondaryResourceWorkspaceId: text("secondaryResourceWorkspaceId"), primaryOwnsSecondarySurface: value.primaryOwnsSecondarySurface === true, secondaryOwnsPrimarySurface: value.secondaryOwnsPrimarySurface === true, primaryUnavailable: value.primaryUnavailable === true, secondaryUnavailable: value.secondaryUnavailable === true, }; } async function listWorkspaces(app: Surface): Promise { return parseWorkspaceListing(await evalIn(app, `(async () => { const info = await window.__OPENWORK_ELECTRON__?.invokeDesktop?.("openworkServerInfo"); if (!info?.running || !info.baseUrl) return { ok: false, ids: [], activeId: null }; const response = await fetch(String(info.baseUrl).replace(/\\\/+$/, "") + "/workspaces", { headers: { Authorization: "Bearer " + String(info.ownerToken ?? info.clientToken ?? "") }, signal: AbortSignal.timeout(15000), }); const body = await response.json(); const items = Array.isArray(body?.items) ? body.items : []; return { ok: response.ok, ids: items.map((item) => String(item?.id ?? "")).filter(Boolean), activeId: typeof body?.activeId === "string" ? body.activeId : null, }; })()`, { awaitPromise: true, timeoutMs: 20_000 })); } async function createWorkspace(app: Surface, path: string): Promise { const before = await listWorkspaces(app); await control(app, "workspace.create", { path }, { timeoutMs: 90_000 }); await waitFor(app, `(() => { const active = localStorage.getItem("openwork.react.activeWorkspace") ?? ""; return Boolean(active) && active !== ${JSON.stringify(before.activeId ?? "")}; })()`, { timeoutMs: 90_000, label: `workspace ${path} selected` }); const after = await listWorkspaces(app); const created = after.ids.find((id) => !before.ids.includes(id)) ?? after.activeId; if (!created) throw new Error(`workspace.create produced no workspace id for ${path}`); return created; } async function createSessionInWorkspace(app: Surface, workspaceId: string, title: string): Promise { await go(app, `/workspace/${workspaceId}/session`, { timeoutMs: 60_000 }); await waitFor(app, `(localStorage.getItem("openwork.react.activeWorkspace") ?? "") === ${JSON.stringify(workspaceId)}`, { timeoutMs: 60_000, label: `workspace ${workspaceId} active before creating ${title}`, }); const deadline = Date.now() + 90_000; let created: unknown = null; let lastError: unknown = null; while (Date.now() < deadline) { try { created = await control(app, "session.create_task", undefined, { timeoutMs: 30_000 }); if (typeof created === "string" && created.startsWith("ses_")) break; lastError = new Error(`session.create_task returned ${JSON.stringify(created)}`); } catch (error) { lastError = error; } await sleep(500); } if (typeof created !== "string" || !created.startsWith("ses_")) { const detail = lastError instanceof Error ? lastError.message : String(lastError); throw new Error(`session.create_task did not return a session id for ${title}: ${detail}`); } await control(app, "session.rename", { sessionId: created, title }, { timeoutMs: 30_000 }); const candidate = { workspaceId, sessionId: created, title }; await waitForSessionRow(app, candidate); return candidate; } async function waitForSessionRow(app: Surface, candidate: SplitCandidate): Promise { await waitFor(app, `Boolean(document.querySelector(${JSON.stringify( `[data-sidebar-session-id="${candidate.sessionId}"][data-sidebar-session-workspace-id="${candidate.workspaceId}"]`, )}))`, { timeoutMs: 60_000, label: `sidebar row for ${candidate.title}`, }); } async function openSessionRoute(app: Surface, candidate: SplitCandidate): Promise { await go(app, `/workspace/${candidate.workspaceId}/session/${candidate.sessionId}`, { timeoutMs: 60_000 }); await waitFor(app, `(() => { const surface = document.querySelector("[data-session-surface-id]"); return (localStorage.getItem("openwork.react.activeWorkspace") ?? "") === ${JSON.stringify(candidate.workspaceId)} && surface?.getAttribute("data-session-surface-id") === ${JSON.stringify(candidate.sessionId)}; })()`, { timeoutMs: 60_000, label: `visible session ${candidate.title}` }); } async function closeMenus(app: Surface): Promise { await evalIn(app, `(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true })); document.body.click(); return true; })()`); } async function openContextMenuForSession(app: Surface, candidate: SplitCandidate): Promise { await closeMenus(app); const opened = await evalIn(app, `(() => { const row = document.querySelector(${JSON.stringify( `[data-sidebar-session-id="${candidate.sessionId}"][data-sidebar-session-workspace-id="${candidate.workspaceId}"]`, )}); if (!(row instanceof HTMLElement)) return false; const target = row.querySelector(${JSON.stringify(`[data-session-tab-id="${candidate.sessionId}"]`)}) ?? row; if (!(target instanceof HTMLElement)) return false; row.scrollIntoView({ block: "center" }); const rect = target.getBoundingClientRect(); target.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2, buttons: 2, clientX: rect.left + Math.min(24, Math.max(1, rect.width / 2)), clientY: rect.top + Math.min(12, Math.max(1, rect.height / 2)), })); return true; })()`); expect(opened).toBe(true); await waitFor(app, `Boolean(document.querySelector('[role="menu"]'))`, { timeoutMs: 15_000, label: `context menu rendered for ${candidate.title}`, }); } async function splitMenuVisible(app: Surface): Promise { return await evalIn(app, `Boolean(document.querySelector("[data-session-menu-open-split]"))`) === true; } async function clickOpenSplit(app: Surface): Promise { const clicked = await evalIn(app, `(() => { const item = document.querySelector("[data-session-menu-open-split]"); if (!(item instanceof HTMLElement)) return false; item.click(); return true; })()`); expect(clicked).toBe(true); } async function closeSecondaryPane(app: Surface): Promise { const clicked = await evalIn(app, `(() => { const button = document.querySelector('button[aria-label="Close secondary split pane"]'); if (!(button instanceof HTMLButtonElement)) return false; button.click(); return true; })()`); expect(clicked).toBe(true); await waitFor(app, `!document.querySelector('[data-workbench-pane="secondary"]')`, { timeoutMs: 15_000, label: "secondary split pane closes", }); } async function openCrossWorkspaceSplitFromPalette(app: Surface, candidate: SplitCandidate): Promise { await closeMenus(app); await evalIn(app, `(() => { window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", code: "KeyK", metaKey: true, ctrlKey: true, bubbles: true, cancelable: true, })); return true; })()`); await waitFor(app, `Boolean(document.querySelector('[data-command-palette-item="open-in-split-view"]'))`, { timeoutMs: 15_000, label: "Open in split view command is visible", }); await evalIn(app, `document.querySelector('[data-command-palette-item="open-in-split-view"]')?.click()`); const candidateSelector = `[data-command-palette-item="split-session:${candidate.workspaceId}:${candidate.sessionId}"]`; await waitFor(app, `Boolean(document.querySelector(${JSON.stringify(candidateSelector)}))`, { timeoutMs: 15_000, label: "cross-workspace session is listed in split picker", }); await evalIn(app, `document.querySelector(${JSON.stringify(candidateSelector)})?.click()`); } async function readSplitFacts(app: Surface, primary: SplitCandidate, secondary: SplitCandidate): Promise { return parseSplitFacts(await evalIn(app, `(() => { const context = window.__openworkControl?.context?.(); const layout = context?.conversations?.layout; const primaryPane = document.querySelector('[data-workbench-pane="primary"]'); const secondaryPane = document.querySelector('[data-workbench-pane="secondary"]'); const primarySurface = primaryPane?.querySelector('[data-session-surface-id="${primary.sessionId}"]'); const secondarySurface = secondaryPane?.querySelector('[data-session-surface-id="${secondary.sessionId}"]'); const resources = Array.isArray(context?.resources) ? context.resources : []; const primaryResource = resources.find((resource) => resource?.kind === "session" && resource?.state?.pane === "primary" && resource?.state?.visible === true); const secondaryResource = resources.find((resource) => resource?.kind === "session" && resource?.state?.pane === "secondary" && resource?.state?.visible === true); return { layout: layout?.kind ?? "", primarySessionId: layout?.primarySessionId ?? layout?.sessionId ?? "", secondarySessionId: layout?.secondarySessionId ?? "", primaryLayoutWorkspaceId: layout?.primaryWorkspaceId ?? layout?.workspaceId ?? "", secondaryLayoutWorkspaceId: layout?.secondaryWorkspaceId ?? "", primaryPaneWorkspaceId: primaryPane?.getAttribute("data-workbench-workspace-id") ?? "", secondaryPaneWorkspaceId: secondaryPane?.getAttribute("data-workbench-workspace-id") ?? "", primarySurfaceWorkspaceId: primarySurface?.getAttribute("data-session-surface-workspace-id") ?? "", secondarySurfaceWorkspaceId: secondarySurface?.getAttribute("data-session-surface-workspace-id") ?? "", primaryWorkspaceName: primaryPane?.querySelector('[data-workbench-pane-header="primary"]') ?.getAttribute("data-workbench-pane-workspace-name") ?? "", secondaryWorkspaceName: secondaryPane?.querySelector('[data-workbench-pane-header="secondary"]') ?.getAttribute("data-workbench-pane-workspace-name") ?? "", primaryResourceWorkspaceId: primaryResource?.state?.workspaceId ?? "", secondaryResourceWorkspaceId: secondaryResource?.state?.workspaceId ?? "", primaryOwnsSecondarySurface: Boolean(primaryPane?.querySelector( '[data-session-surface-id="${secondary.sessionId}"]', )), secondaryOwnsPrimarySurface: Boolean(secondaryPane?.querySelector( '[data-session-surface-id="${primary.sessionId}"]', )), primaryUnavailable: Boolean(primaryPane?.querySelector('[data-workbench-pane-unavailable]')), secondaryUnavailable: Boolean(secondaryPane?.querySelector('[data-workbench-pane-unavailable]')), }; })()`)); } test.skipIf(!runnable)( `same-workspace and cross-workspace split sessions retain visible ownership${skipSuffix}`, { timeout: 10 * 60_000 }, async ({ evidence, place }) => { needs({ optIn: ["OPENWORK_EVAL_E2E_TESTS"] }); const runId = `${Date.now().toString(36)}-${process.pid}`; const primaryTitle = `Primary workspace anchor ${runId}`; const sameWorkspaceTitle = `Primary workspace peer ${runId}`; const crossWorkspaceTitle = `Secondary workspace peer ${runId}`; await using stack = new AsyncDisposableStack(); const world = await bootCrossWorkspaceSplitView(stack, place, { adminEmail: `split-view-admin-${runId}@openwork.test`, workspacePath: `/tmp/openwork-cross-workspace-split-${runId}-a`, sessionTitles: [primaryTitle, sameWorkspaceTitle], }); const app = world.desktop; const workspaceA = app.workspaceId; if (!workspaceA) throw new Error("World app did not resolve a primary workspace id."); const seededPrimary = world.sessions[0]; const seededSameWorkspacePeer = world.sessions[1]; if (!seededPrimary || !seededSameWorkspacePeer) { throw new Error("The split-view world did not seed both primary-workspace sessions."); } const primary = { workspaceId: workspaceA, ...seededPrimary }; const sameWorkspacePeer = { workspaceId: workspaceA, ...seededSameWorkspacePeer }; const workspaceB = await createWorkspace(app, `/tmp/openwork-cross-workspace-split-${runId}-b`); const crossWorkspacePeer = await createSessionInWorkspace(app, workspaceB, crossWorkspaceTitle); expect(primary.workspaceId).not.toBe(crossWorkspacePeer.workspaceId); await openSessionRoute(app, primary); await waitForSessionRow(app, sameWorkspacePeer); await waitForSessionRow(app, crossWorkspacePeer); await openContextMenuForSession(app, sameWorkspacePeer); expect(await splitMenuVisible(app)).toBe(true); await clickOpenSplit(app); await waitFor(app, `Boolean(document.querySelector('[data-workbench-pane="secondary"]'))`, { timeoutMs: 30_000, label: "same-workspace secondary pane opens", }); const sameWorkspaceMountDiagnostic = await evalIn(app, `(() => { const panes = [...document.querySelectorAll('[data-workbench-pane]')]; return panes.map((pane) => ({ pane: pane.getAttribute('data-workbench-pane'), workspaceId: pane.getAttribute('data-workbench-workspace-id'), surfaces: [...pane.querySelectorAll('[data-session-surface-id]')].map((surface) => ({ sessionId: surface.getAttribute('data-session-surface-id'), workspaceId: surface.getAttribute('data-session-surface-workspace-id'), })), text: (pane.textContent ?? '').replace(/\\s+/g, ' ').trim().slice(0, 500), })); })()`); expect( await evalIn(app, `Boolean(document.querySelector( '[data-workbench-pane="secondary"] [data-session-surface-id="${sameWorkspacePeer.sessionId}"]' ))`), `same-workspace mount diagnostic: ${JSON.stringify(sameWorkspaceMountDiagnostic)}`, ).toBe(true); await waitFor(app, `Boolean(document.querySelector( '[data-workbench-pane="secondary"][data-workbench-workspace-id="${workspaceA}"] [data-session-surface-id="${sameWorkspacePeer.sessionId}"]' ))`, { timeoutMs: 60_000, label: "same-workspace split renders" }); const sameWorkspaceFacts = await readSplitFacts(app, primary, sameWorkspacePeer); expect(sameWorkspaceFacts.primaryPaneWorkspaceId).toBe(workspaceA); expect(sameWorkspaceFacts.secondaryPaneWorkspaceId).toBe(workspaceA); expect(sameWorkspaceFacts.primarySurfaceWorkspaceId).toBe(workspaceA); expect(sameWorkspaceFacts.secondarySurfaceWorkspaceId).toBe(workspaceA); expect(sameWorkspaceFacts.primaryLayoutWorkspaceId).toBe(workspaceA); expect(sameWorkspaceFacts.secondaryLayoutWorkspaceId).toBe(workspaceA); expect(sameWorkspaceFacts.secondaryPaneWorkspaceId).not.toBe(workspaceB); expect(sameWorkspaceFacts.primaryOwnsSecondarySurface).toBe(false); expect(sameWorkspaceFacts.secondaryOwnsPrimarySurface).toBe(false); expect(sameWorkspaceFacts.primaryUnavailable).toBe(false); expect(sameWorkspaceFacts.secondaryUnavailable).toBe(false); evidence.recordAssertionEvidence( "Same-workspace split remains available and renders both sessions in their owning workspace", JSON.stringify(sameWorkspaceFacts), sameWorkspaceFacts.layout === "split" && sameWorkspaceFacts.primarySessionId === primary.sessionId && sameWorkspaceFacts.secondarySessionId === sameWorkspacePeer.sessionId && sameWorkspaceFacts.primaryPaneWorkspaceId === workspaceA && sameWorkspaceFacts.secondaryPaneWorkspaceId === workspaceA && sameWorkspaceFacts.primarySurfaceWorkspaceId === workspaceA && sameWorkspaceFacts.secondarySurfaceWorkspaceId === workspaceA && sameWorkspaceFacts.primaryLayoutWorkspaceId === workspaceA && sameWorkspaceFacts.secondaryLayoutWorkspaceId === workspaceA && sameWorkspaceFacts.secondaryPaneWorkspaceId !== workspaceB && !sameWorkspaceFacts.primaryOwnsSecondarySurface && !sameWorkspaceFacts.secondaryOwnsPrimarySurface && !sameWorkspaceFacts.primaryUnavailable && !sameWorkspaceFacts.secondaryUnavailable, ); await closeSecondaryPane(app); expect(await evalIn(app, `document.querySelector('[data-session-surface-id="${primary.sessionId}"]') !== null`)).toBe(true); expect(await evalIn(app, `document.querySelector('[data-session-surface-id="${sameWorkspacePeer.sessionId}"]') === null`)).toBe(true); await openContextMenuForSession(app, crossWorkspacePeer); expect(await splitMenuVisible(app)).toBe(true); await closeMenus(app); await openCrossWorkspaceSplitFromPalette(app, crossWorkspacePeer); await waitFor(app, `Boolean(document.querySelector( '[data-workbench-pane="secondary"][data-workbench-workspace-id="${workspaceB}"] [data-session-surface-id="${crossWorkspacePeer.sessionId}"]' ))`, { timeoutMs: 60_000, label: "cross-workspace palette split renders" }); const crossWorkspaceFacts = await readSplitFacts(app, primary, crossWorkspacePeer); expect(crossWorkspaceFacts.layout).toBe("split"); expect(crossWorkspaceFacts.primaryPaneWorkspaceId).toBe(workspaceA); expect(crossWorkspaceFacts.primaryPaneWorkspaceId).not.toBe(workspaceB); expect(crossWorkspaceFacts.secondaryPaneWorkspaceId).toBe(workspaceB); expect(crossWorkspaceFacts.secondaryPaneWorkspaceId).not.toBe(workspaceA); expect(crossWorkspaceFacts.primarySurfaceWorkspaceId).toBe(workspaceA); expect(crossWorkspaceFacts.secondarySurfaceWorkspaceId).toBe(workspaceB); expect(crossWorkspaceFacts.primaryLayoutWorkspaceId).toBe(workspaceA); expect(crossWorkspaceFacts.secondaryLayoutWorkspaceId).toBe(workspaceB); expect(crossWorkspaceFacts.primaryResourceWorkspaceId).toBe(workspaceA); expect(crossWorkspaceFacts.secondaryResourceWorkspaceId).toBe(workspaceB); expect(crossWorkspaceFacts.primaryOwnsSecondarySurface).toBe(false); expect(crossWorkspaceFacts.secondaryOwnsPrimarySurface).toBe(false); expect(crossWorkspaceFacts.primaryUnavailable).toBe(false); expect(crossWorkspaceFacts.secondaryUnavailable).toBe(false); expect(crossWorkspaceFacts.primaryWorkspaceName).not.toBe(""); expect(crossWorkspaceFacts.secondaryWorkspaceName).not.toBe(""); expect(crossWorkspaceFacts.primaryWorkspaceName).not.toBe(crossWorkspaceFacts.secondaryWorkspaceName); evidence.recordAssertionEvidence( "The command palette renders two sessions from different workspaces with correct visible ownership", JSON.stringify(crossWorkspaceFacts), crossWorkspaceFacts.layout === "split" && crossWorkspaceFacts.primarySessionId === primary.sessionId && crossWorkspaceFacts.secondarySessionId === crossWorkspacePeer.sessionId && crossWorkspaceFacts.primaryPaneWorkspaceId === workspaceA && crossWorkspaceFacts.primaryPaneWorkspaceId !== workspaceB && crossWorkspaceFacts.secondaryPaneWorkspaceId === workspaceB && crossWorkspaceFacts.secondaryPaneWorkspaceId !== workspaceA && crossWorkspaceFacts.primarySurfaceWorkspaceId === workspaceA && crossWorkspaceFacts.secondarySurfaceWorkspaceId === workspaceB && crossWorkspaceFacts.primaryLayoutWorkspaceId === workspaceA && crossWorkspaceFacts.secondaryLayoutWorkspaceId === workspaceB && crossWorkspaceFacts.primaryResourceWorkspaceId === workspaceA && crossWorkspaceFacts.secondaryResourceWorkspaceId === workspaceB && crossWorkspaceFacts.primaryWorkspaceName !== crossWorkspaceFacts.secondaryWorkspaceName && !crossWorkspaceFacts.primaryOwnsSecondarySurface && !crossWorkspaceFacts.secondaryOwnsPrimarySurface && !crossWorkspaceFacts.primaryUnavailable && !crossWorkspaceFacts.secondaryUnavailable, ); await screenshot(app); }, );