293 lines
13 KiB
TypeScript
293 lines
13 KiB
TypeScript
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,
|
|
);
|
|
},
|
|
);
|