import { expect } from "vitest"; import { control, evalIn, go, listSessions, seedSessions, waitFor } from "@openwork/behaviors"; import type { Surface } from "@openwork/cdp"; import { setViewport } from "@openwork/cdp"; import { localMysqlIsRunning, localRedisIsRunning, needs, app as launchApp, createAdmin, createOrg, server, test, } from "@openwork/testkit"; 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" : ""; const sessionTitles = ["Responsive primary chat", "Responsive split chat"]; async function openSessionRoute(app: Surface, workspaceId: string, sessionId: string) { await go(app, `/workspace/${workspaceId}/session/${sessionId}`); await waitFor(app, `Boolean(document.querySelector( '[data-session-surface-id="${sessionId}"]' ))`, { timeoutMs: 60_000, label: "primary session route" }); } async function openSessionInSplit(app: Surface, workspaceId: string, sessionId: string) { const opened = await evalIn(app, `(() => { const row = document.querySelector( '[data-sidebar-session-id="${sessionId}"][data-sidebar-session-workspace-id="${workspaceId}"]' ); if (!(row instanceof HTMLElement)) return false; row.scrollIntoView({ block: "center" }); const target = row.querySelector('[data-session-tab-id="${sessionId}"]') ?? row; if (!(target instanceof HTMLElement)) return false; 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('[data-session-menu-open-split]'))`, { timeoutMs: 15_000, label: "Open in split view menu item", }); 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 pressPaneKey(app: Surface, pane: "chat" | "split" | "panel", key: string) { const pressed = await evalIn(app, `(() => { const tab = document.querySelector('[data-narrow-pane="${pane}"]'); if (!(tab instanceof HTMLElement)) return false; tab.focus(); tab.dispatchEvent(new KeyboardEvent("keydown", { key: ${JSON.stringify(key)}, bubbles: true })); return true; })()`); expect(pressed).toBe(true); } async function openFilesPanel(app: Surface) { const openedMenu = await evalIn(app, `(() => { const button = document.querySelector('button[aria-label="More actions"]'); if (!(button instanceof HTMLButtonElement)) return false; button.click(); return true; })()`); expect(openedMenu).toBe(true); await waitFor(app, `Boolean([...document.querySelectorAll('[role="menuitem"]')] .find((item) => (item.textContent ?? '').trim().startsWith('Files')))`, { timeoutMs: 15_000, label: "Files menu item", }); const openedPanel = await evalIn(app, `(() => { const item = [...document.querySelectorAll('[role="menuitem"]')] .find((candidate) => (candidate.textContent ?? '').trim().startsWith('Files')); if (!(item instanceof HTMLElement)) return false; item.click(); return true; })()`); expect(openedPanel).toBe(true); } test.skipIf(!runnable)( `narrow session panes stay selectable, synchronized, and inside the viewport${skipSuffix}`, { timeout: 600_000 }, async ({ evidence, place }) => { needs({ optIn: ["OPENWORK_EVAL_E2E_TESTS"] }); await using stack = new AsyncDisposableStack(); const den = stack.use(await server({ place, provision: false, web: true })); await createAdmin(den, {}); stack.use(await createOrg(den, "acme")); const app = stack.use(await launchApp({ den, place, as: "admin" })); await seedSessions(app, sessionTitles); const workspaceId = app.workspaceId; if (!workspaceId) throw new Error("The responsive session world did not resolve a workspace."); const sessions = await listSessions(app); const primary = sessions.find((session) => session.title === sessionTitles[0]); const secondary = sessions.find((session) => session.title === sessionTitles[1]); if (!primary || !secondary) { throw new Error(`The responsive session world did not expose both seeded sessions: ${JSON.stringify(sessions)}`); } await openSessionRoute(app, workspaceId, primary.sessionId); await openSessionInSplit(app, workspaceId, secondary.sessionId); await waitFor(app, `Boolean(document.querySelector( '[data-workbench-pane="secondary"] [data-session-surface-id="${secondary.sessionId}"]' ))`, { timeoutMs: 60_000, label: "desktop split session" }); await setViewport(app, { width: 390, height: 844, deviceScaleFactor: 1 }); await waitFor(app, `(() => { const selected = document.querySelector('[data-narrow-pane][aria-selected="true"]'); const secondary = document.querySelector('[data-session-surface-id="${secondary.sessionId}"]'); const primary = document.querySelector('[data-session-surface-id="${primary.sessionId}"]'); return selected?.getAttribute('data-narrow-pane') === 'split' && Boolean(secondary) && !primary; })()`, { timeoutMs: 30_000, label: "focused split becomes the narrow visible pane" }); const initialNarrowFacts = await evalIn(app, `(() => { const tabs = [...document.querySelectorAll('[data-narrow-pane]')]; const active = tabs.find((tab) => tab.getAttribute('aria-selected') === 'true'); const activePanel = active ? document.getElementById(active.getAttribute('aria-controls') ?? '') : null; const switcher = document.querySelector('[data-narrow-pane-switcher]'); const switcherRect = switcher?.getBoundingClientRect(); const panelRect = activePanel?.getBoundingClientRect(); return { selected: active?.getAttribute('data-narrow-pane') ?? '', tabStops: tabs.filter((tab) => tab.getAttribute('tabindex') === '0').length, inactiveStops: tabs.filter((tab) => tab.getAttribute('aria-selected') === 'false' && tab.getAttribute('tabindex') !== '-1').length, controlsResolve: tabs.every((tab) => Boolean(document.getElementById(tab.getAttribute('aria-controls') ?? ''))), labelledByActiveTab: activePanel?.getAttribute('aria-labelledby') === active?.id, minimumTarget: Math.min(...tabs.map((tab) => tab.getBoundingClientRect().height)), switcherInsideViewport: Boolean(switcherRect && switcherRect.left >= 0 && switcherRect.right <= window.innerWidth && switcherRect.top >= 0 && switcherRect.bottom <= window.innerHeight), panelInsideViewport: Boolean(panelRect && panelRect.left >= 0 && panelRect.right <= window.innerWidth && panelRect.top >= 0 && panelRect.bottom <= window.innerHeight), documentWidth: document.documentElement.scrollWidth, viewportWidth: window.innerWidth, }; })()`); expect(initialNarrowFacts).toMatchObject({ selected: "split", tabStops: 1, inactiveStops: 0, controlsResolve: true, labelledByActiveTab: true, switcherInsideViewport: true, panelInsideViewport: true, documentWidth: 390, viewportWidth: 390, }); expect(initialNarrowFacts).toHaveProperty("minimumTarget"); if (typeof initialNarrowFacts !== "object" || initialNarrowFacts === null) { throw new Error(`Invalid narrow layout facts: ${JSON.stringify(initialNarrowFacts)}`); } const minimumTarget = Reflect.get(initialNarrowFacts, "minimumTarget"); expect(typeof minimumTarget === "number" && minimumTarget >= 44).toBe(true); evidence.recordAssertionEvidence( "Narrow split focus stays synchronized and the tab widget remains reachable", JSON.stringify(initialNarrowFacts), true, ); await pressPaneKey(app, "split", "ArrowLeft"); await waitFor(app, `(() => { const selected = document.querySelector('[data-narrow-pane][aria-selected="true"]'); return selected?.getAttribute('data-narrow-pane') === 'chat' && Boolean(document.querySelector('[data-session-surface-id="${primary.sessionId}"]')) && !document.querySelector('[data-session-surface-id="${secondary.sessionId}"]'); })()`, { timeoutMs: 30_000, label: "ArrowLeft selects primary chat" }); const draft = "Keep this narrow-screen draft"; const focusedComposer = await evalIn(app, `(() => { const editor = document.querySelector( '[data-session-surface-id="${primary.sessionId}"] [contenteditable="true"][data-lexical-editor="true"]' ); if (!(editor instanceof HTMLElement)) return false; editor.focus(); return document.activeElement === editor; })()`); expect(focusedComposer).toBe(true); await app.client.send("Input.insertText", { text: draft }); await waitFor(app, `(document.querySelector( '[data-session-surface-id="${primary.sessionId}"] [contenteditable="true"][data-lexical-editor="true"]' )?.textContent ?? '').includes(${JSON.stringify(draft)})`, { timeoutMs: 15_000, label: "narrow primary draft", }); const focusSecondaryResult = await control(app, "session.open", { sessionId: secondary.sessionId }); expect(focusSecondaryResult).toMatchObject({ ok: true, reused: "secondary-pane" }); await waitFor(app, `document.querySelector('[data-narrow-pane="split"]')?.getAttribute('aria-selected') === 'true' && Boolean(document.querySelector('[data-session-surface-id="${secondary.sessionId}"]'))`, { timeoutMs: 30_000, label: "session.open reveals narrow split", }); await pressPaneKey(app, "split", "ArrowLeft"); await waitFor(app, `(document.querySelector( '[data-session-surface-id="${primary.sessionId}"] [contenteditable="true"][data-lexical-editor="true"]' )?.textContent ?? '').includes(${JSON.stringify(draft)})`, { timeoutMs: 30_000, label: "draft survives narrow pane remount", }); evidence.recordAssertionEvidence( "Existing session focus actions reveal the requested narrow pane without losing the other chat draft", `session.open revealed ${secondary.sessionId}; returning to ${primary.sessionId} preserved the draft.`, true, ); await openFilesPanel(app); await waitFor(app, `document.querySelector('[data-narrow-pane="panel"]')?.getAttribute('aria-selected') === 'true' && Boolean(document.getElementById('narrow-session-pane-panel'))`, { timeoutMs: 30_000, label: "tools panel becomes the narrow visible pane", }); const panelFacts = await evalIn(app, `(() => { const panel = document.getElementById('narrow-session-pane-panel'); const rect = panel?.getBoundingClientRect(); return { panelInsideViewport: Boolean(rect && rect.left >= 0 && rect.right <= window.innerWidth && rect.top >= 0 && rect.bottom <= window.innerHeight), chatHidden: document.getElementById('narrow-session-pane-chat')?.hidden === true, splitHidden: document.getElementById('narrow-session-pane-split')?.hidden === true, documentWidth: document.documentElement.scrollWidth, viewportWidth: window.innerWidth, }; })()`); expect(panelFacts).toEqual({ panelInsideViewport: true, chatHidden: true, splitHidden: true, documentWidth: 390, viewportWidth: 390, }); const closedPanel = await evalIn(app, `(() => { const close = document.querySelector('#narrow-session-pane-panel button[aria-label="Close panel"]'); if (!(close instanceof HTMLButtonElement)) return false; close.click(); return true; })()`); expect(closedPanel).toBe(true); await waitFor(app, `!document.querySelector('[data-narrow-pane="panel"]') && document.querySelector('[data-narrow-pane="chat"]')?.getAttribute('aria-selected') === 'true' && Boolean(document.querySelector('[data-session-surface-id="${primary.sessionId}"]'))`, { timeoutMs: 30_000, label: "closing selected panel falls back to chat", }); evidence.recordAssertionEvidence( "The in-flow tools pane stays inside the viewport and closing it returns to chat", JSON.stringify(panelFacts), true, ); }, );