forked from different-ai/openwork
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathresponsive-session-layout.e2e.test.ts
More file actions
292 lines (275 loc) · 13 KB
/
Copy pathresponsive-session-layout.e2e.test.ts
File metadata and controls
292 lines (275 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
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,
);
},
);