Files
openwork/evals/specs/cross-workspace-split-view.e2e.test.ts

455 lines
23 KiB
TypeScript

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<string, unknown> {
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<WorkspaceListing> {
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<string> {
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<SplitCandidate> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
return await evalIn(app, `Boolean(document.querySelector("[data-session-menu-open-split]"))`) === true;
}
async function clickOpenSplit(app: Surface): Promise<void> {
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<void> {
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<void> {
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<SplitFacts> {
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);
},
);