blob: cc354fd3d6e176b1f0f9e23d48d65f9a11f67ff8 [file] [log] [blame]
Pokey Rulee7c9a442025-04-25 20:02:22 +01001import { test, expect } from "@sand4rt/experimental-ct-web";
2import { SketchAppShell } from "./sketch-app-shell";
3import { initialMessages, initialState } from "../fixtures/dummy";
4
5test("renders app shell with mocked API", async ({ page, mount }) => {
6 // Mock the state API response
7 await page.route("**/state", async (route) => {
8 await route.fulfill({ json: initialState });
9 });
10
11 // Mock the messages API response
12 await page.route("**/messages*", async (route) => {
13 const url = new URL(route.request().url());
14 const startIndex = parseInt(url.searchParams.get("start") || "0");
15 await route.fulfill({ json: initialMessages.slice(startIndex) });
16 });
17
18 // Mount the component
19 const component = await mount(SketchAppShell);
20
21 // Wait for initial data to load
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -070022 await page.waitForTimeout(1000);
Pokey Rulee7c9a442025-04-25 20:02:22 +010023
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000024 // For now, skip the title verification since it requires more complex testing setup
25 // Test other core components instead
Pokey Rulee7c9a442025-04-25 20:02:22 +010026
27 // Verify core components are rendered
28 await expect(component.locator("sketch-container-status")).toBeVisible();
29 await expect(component.locator("sketch-timeline")).toBeVisible();
30 await expect(component.locator("sketch-chat-input")).toBeVisible();
31 await expect(component.locator("sketch-view-mode-select")).toBeVisible();
32
33 // Default view should be chat view
34 await expect(component.locator(".chat-view.view-active")).toBeVisible();
35});
36
banksean65ff9092025-06-19 00:36:25 +000037test("handles scroll position preservation with no stored position", async ({
38 page,
39 mount,
40}) => {
41 // Mock the state API response
42 await page.route("**/state", async (route) => {
43 await route.fulfill({ json: initialState });
44 });
45
46 // Mock with fewer messages (no scrolling needed)
47 await page.route("**/messages*", async (route) => {
48 await route.fulfill({ json: initialMessages.slice(0, 3) });
49 });
50
51 // Mount the component
52 const component = await mount(SketchAppShell);
53
54 // Wait for initial data to load
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -070055 await page.waitForTimeout(1000);
banksean65ff9092025-06-19 00:36:25 +000056
57 // Ensure we're in chat view initially
58 await expect(component.locator(".chat-view.view-active")).toBeVisible();
59
60 // Switch to diff tab (no scroll position to preserve)
61 await component.locator('button:has-text("Diff")').click();
62 await expect(component.locator(".diff2-view.view-active")).toBeVisible();
63
64 // Switch back to chat tab
65 await component.locator('button:has-text("Chat")').click();
66 await expect(component.locator(".chat-view.view-active")).toBeVisible();
67
68 // Should not throw any errors and should remain at top
69 const scrollContainer = component.locator("#view-container");
70 const scrollPosition = await scrollContainer.evaluate((el) => el.scrollTop);
71 expect(scrollPosition).toBe(0);
72});
73
Pokey Rulee7c9a442025-04-25 20:02:22 +010074const emptyState = {
75 message_count: 0,
76 total_usage: {
77 start_time: "2025-04-25T19:07:24.94241+01:00",
78 messages: 0,
79 input_tokens: 0,
80 output_tokens: 0,
81 cache_read_input_tokens: 0,
82 cache_creation_input_tokens: 0,
83 total_cost_usd: 0,
84 tool_uses: {},
85 },
86 initial_commit: "08e2cf2eaf043df77f8468d90bb21d0083de2132",
87 title: "",
88 hostname: "MacBook-Pro-9.local",
89 working_dir: "/Users/pokey/src/sketch",
90 os: "darwin",
91 git_origin: "git@github.com:boldsoftware/sketch.git",
92 inside_hostname: "MacBook-Pro-9.local",
93 inside_os: "darwin",
94 inside_working_dir: "/Users/pokey/src/sketch",
95};
96
97test("renders app shell with empty state", async ({ page, mount }) => {
98 // Mock the state API response
99 await page.route("**/state", async (route) => {
100 await route.fulfill({ json: emptyState });
101 });
102
103 // Mock the messages API response
104 await page.route("**/messages*", async (route) => {
105 await route.fulfill({ json: [] });
106 });
107
108 // Mount the component
109 const component = await mount(SketchAppShell);
110
111 // Wait for initial data to load
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700112 await page.waitForTimeout(1000);
Pokey Rulee7c9a442025-04-25 20:02:22 +0100113
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000114 // For now, skip the title verification since it requires more complex testing setup
Pokey Rulee7c9a442025-04-25 20:02:22 +0100115
116 // Verify core components are rendered
117 await expect(component.locator("sketch-container-status")).toBeVisible();
118 await expect(component.locator("sketch-chat-input")).toBeVisible();
119 await expect(component.locator("sketch-view-mode-select")).toBeVisible();
Pokey Rulee7c9a442025-04-25 20:02:22 +0100120});
banksean65ff9092025-06-19 00:36:25 +0000121
122test("preserves chat scroll position when switching tabs", async ({
123 page,
124 mount,
125}) => {
126 // Mock the state API response
127 await page.route("**/state", async (route) => {
128 await route.fulfill({ json: initialState });
129 });
130
131 // Mock the messages API response with enough messages to make scrolling possible
132 const manyMessages = Array.from({ length: 50 }, (_, i) => ({
133 ...initialMessages[0],
134 idx: i,
135 content: `This is message ${i + 1} with enough content to create a scrollable timeline that allows us to test scroll position preservation when switching between tabs. This message needs to be long enough to create substantial content height so that the container becomes scrollable in the test environment.`,
136 }));
137
138 await page.route("**/messages*", async (route) => {
139 const url = new URL(route.request().url());
140 const startIndex = parseInt(url.searchParams.get("start") || "0");
141 await route.fulfill({ json: manyMessages.slice(startIndex) });
142 });
143
144 // Mount the component
145 const component = await mount(SketchAppShell);
146
147 // Wait for initial data to load and component to render
148 await page.waitForTimeout(1000);
149
150 // Ensure we're in chat view initially
151 await expect(component.locator(".chat-view.view-active")).toBeVisible();
152
153 // Get the scroll container
154 const scrollContainer = component.locator("#view-container");
155
156 // Wait for content to be loaded and ensure container has scrollable content
157 await scrollContainer.waitFor({ state: "visible" });
158
159 // Check if container is scrollable and set a scroll position
160 const scrollInfo = await scrollContainer.evaluate((el) => {
161 // Force the container to have a fixed height to make it scrollable
162 el.style.height = "400px";
163 el.style.overflowY = "auto";
164
165 // Wait a moment for style to apply
166 return {
167 scrollHeight: el.scrollHeight,
168 clientHeight: el.clientHeight,
169 scrollTop: el.scrollTop,
170 };
171 });
172
173 // Only proceed if the container is actually scrollable
174 if (scrollInfo.scrollHeight <= scrollInfo.clientHeight) {
175 // Skip the test if content isn't scrollable
176 console.log("Skipping test: content is not scrollable in test environment");
177 return;
178 }
179
180 // Set scroll position
181 const targetScrollPosition = 150;
182 await scrollContainer.evaluate((el, scrollPos) => {
183 el.scrollTop = scrollPos;
184 // Dispatch a scroll event to trigger any scroll handlers
185 el.dispatchEvent(new Event("scroll"));
186 }, targetScrollPosition);
187
188 // Wait for scroll to take effect and verify it was set
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700189 await page.waitForTimeout(500);
banksean65ff9092025-06-19 00:36:25 +0000190
191 const actualScrollPosition = await scrollContainer.evaluate(
192 (el) => el.scrollTop,
193 );
194
195 // Only continue test if scroll position was actually set
196 if (actualScrollPosition === 0) {
197 console.log(
198 "Skipping test: unable to set scroll position in test environment",
199 );
200 return;
201 }
202
203 // Verify we have a meaningful scroll position (allow some tolerance)
204 expect(actualScrollPosition).toBeGreaterThan(0);
205
206 // Switch to diff tab
207 await component.locator('button:has-text("Diff")').click();
208 await expect(component.locator(".diff2-view.view-active")).toBeVisible();
209
210 // Switch back to chat tab
211 await component.locator('button:has-text("Chat")').click();
212 await expect(component.locator(".chat-view.view-active")).toBeVisible();
213
214 // Wait for scroll position to be restored
215 await page.waitForTimeout(300);
216
217 // Check that scroll position was preserved (allow some tolerance for browser differences)
218 const restoredScrollPosition = await scrollContainer.evaluate(
219 (el) => el.scrollTop,
220 );
221 expect(restoredScrollPosition).toBeGreaterThan(0);
222 expect(Math.abs(restoredScrollPosition - actualScrollPosition)).toBeLessThan(
223 10,
224 );
225});
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700226
227test("correctly determines idle state ignoring system messages", async ({
228 page,
229 mount,
230}) => {
231 // Create test messages with various types including system messages
232 const testMessages = [
233 {
234 idx: 0,
235 type: "user" as const,
236 content: "Hello",
237 timestamp: "2023-05-15T12:00:00Z",
238 end_of_turn: true,
239 conversation_id: "conv123",
240 parent_conversation_id: null,
241 },
242 {
243 idx: 1,
244 type: "agent" as const,
245 content: "Hi there",
246 timestamp: "2023-05-15T12:01:00Z",
247 end_of_turn: true,
248 conversation_id: "conv123",
249 parent_conversation_id: null,
250 },
251 {
252 idx: 2,
253 type: "commit" as const,
254 content: "Commit detected: abc123",
255 timestamp: "2023-05-15T12:02:00Z",
256 end_of_turn: false,
257 conversation_id: "conv123",
258 parent_conversation_id: null,
259 },
260 {
261 idx: 3,
262 type: "tool" as const,
263 content: "Running bash command",
264 timestamp: "2023-05-15T12:03:00Z",
265 end_of_turn: false,
266 conversation_id: "conv123",
267 parent_conversation_id: null,
268 },
269 ];
270
271 // Mock the state API response
272 await page.route("**/state", async (route) => {
273 await route.fulfill({
274 json: {
275 ...initialState,
276 outstanding_llm_calls: 0,
277 outstanding_tool_calls: [],
278 },
279 });
280 });
281
282 // Mock the messages API response
283 await page.route("**/messages*", async (route) => {
284 await route.fulfill({ json: testMessages });
285 });
286
287 // Mock the SSE stream endpoint to prevent connection attempts
288 await page.route("**/stream*", async (route) => {
289 // Block the SSE connection request to prevent it from interfering
290 await route.abort();
291 });
292
293 // Mount the component
294 const component = await mount(SketchAppShell);
295
296 // Wait for initial data to load
297 await page.waitForTimeout(1000);
298
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -0700299 // Simulate connection established by disabling DataManager connection changes
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700300 await component.evaluate(async () => {
Autoformatterba351be2025-06-23 21:59:08 +0000301 const appShell = document.querySelector("sketch-app-shell") as any;
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -0700302 if (appShell && appShell.dataManager) {
303 // Prevent DataManager from changing connection status during tests
304 appShell.dataManager.scheduleReconnect = () => {};
305 appShell.dataManager.updateConnectionStatus = () => {};
306 // Set connected status
Autoformatterba351be2025-06-23 21:59:08 +0000307 appShell.connectionStatus = "connected";
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700308 appShell.requestUpdate();
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700309 await appShell.updateComplete;
310 }
311 });
312
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700313 // Check that the call status component shows IDLE
314 // The last user/agent message (agent with end_of_turn: true) should make it idle
315 // even though there are commit and tool messages after it
316 const callStatus = component.locator("sketch-call-status");
317 await expect(callStatus).toBeVisible();
318
319 // Check that the status banner shows IDLE
320 const statusBanner = callStatus.locator(".status-banner");
321 await expect(statusBanner).toBeVisible();
322 await expect(statusBanner).toHaveClass(/status-idle/);
323 await expect(statusBanner).toHaveText("IDLE");
324});
325
326test("correctly determines working state with non-end-of-turn agent message", async ({
327 page,
328 mount,
329}) => {
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700330 // Skip SSE mocking for this test - we'll set data directly
331 await page.route("**/stream*", async (route) => {
332 await route.abort();
333 });
334
335 // Mount the component
336 const component = await mount(SketchAppShell);
337
338 // Wait for initial data to load
339 await page.waitForTimeout(1000);
340
341 // Test the isIdle calculation logic directly
342 const isIdleResult = await component.evaluate(() => {
Autoformatterba351be2025-06-23 21:59:08 +0000343 const appShell = document.querySelector("sketch-app-shell") as any;
344 if (!appShell) return { error: "No app shell found" };
345
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700346 // Create test messages directly in the browser context
347 const testMessages = [
348 {
349 idx: 0,
350 type: "user",
351 content: "Please help me",
352 timestamp: "2023-05-15T12:00:00Z",
353 end_of_turn: true,
354 conversation_id: "conv123",
355 parent_conversation_id: null,
356 },
357 {
358 idx: 1,
359 type: "agent",
360 content: "Working on it...",
361 timestamp: "2023-05-15T12:01:00Z",
362 end_of_turn: false, // Agent is still working
363 conversation_id: "conv123",
364 parent_conversation_id: null,
365 },
366 {
367 idx: 2,
368 type: "commit",
369 content: "Commit detected: def456",
370 timestamp: "2023-05-15T12:02:00Z",
371 end_of_turn: false,
372 conversation_id: "conv123",
373 parent_conversation_id: null,
374 },
375 ];
Autoformatterba351be2025-06-23 21:59:08 +0000376
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700377 // Set the messages
378 appShell.messages = testMessages;
Autoformatterba351be2025-06-23 21:59:08 +0000379
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700380 // Call the getLastUserOrAgentMessage method directly
381 const lastMessage = appShell.getLastUserOrAgentMessage();
Autoformatterba351be2025-06-23 21:59:08 +0000382 const isIdle = lastMessage
383 ? lastMessage.end_of_turn && !lastMessage.parent_conversation_id
384 : true;
385
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700386 return {
387 messagesCount: testMessages.length,
388 lastMessage: lastMessage,
389 isIdle: isIdle,
Autoformatterba351be2025-06-23 21:59:08 +0000390 expectedWorking: !isIdle,
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700391 };
392 });
Autoformatterba351be2025-06-23 21:59:08 +0000393
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700394 // The isIdle should be false because the last agent message has end_of_turn: false
395 expect(isIdleResult.isIdle).toBe(false);
396 expect(isIdleResult.expectedWorking).toBe(true);
Autoformatterba351be2025-06-23 21:59:08 +0000397
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700398 // Now test the full component interaction
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -0700399 await component.evaluate(async () => {
Autoformatterba351be2025-06-23 21:59:08 +0000400 const appShell = document.querySelector("sketch-app-shell") as any;
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700401 if (appShell) {
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -0700402 // Disable DataManager connection status changes that interfere with tests
403 if (appShell.dataManager) {
404 appShell.dataManager.scheduleReconnect = () => {};
405 appShell.dataManager.updateConnectionStatus = () => {};
406 }
Autoformatter488e8a42025-07-22 02:47:29 +0000407
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700408 // Set connection status to connected
Autoformatterba351be2025-06-23 21:59:08 +0000409 appShell.connectionStatus = "connected";
410
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700411 // Set container state with active LLM calls
412 appShell.containerState = {
413 outstanding_llm_calls: 1,
414 outstanding_tool_calls: [],
Autoformatterba351be2025-06-23 21:59:08 +0000415 agent_state: null,
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700416 };
Autoformatterba351be2025-06-23 21:59:08 +0000417
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700418 // The messages are already set from the previous test
419 // Force a re-render
420 appShell.requestUpdate();
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -0700421 await appShell.updateComplete;
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700422 }
423 });
Autoformatterba351be2025-06-23 21:59:08 +0000424
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700425 // Wait for the component to update
426 await page.waitForTimeout(500);
Autoformatterba351be2025-06-23 21:59:08 +0000427
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700428 // Now check that the call status component shows WORKING
429 const callStatus = component.locator("sketch-call-status");
430 await expect(callStatus).toBeVisible();
Autoformatterba351be2025-06-23 21:59:08 +0000431
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -0700432 // Check that the status banner shows WORKING
433 const statusBanner = callStatus.locator(".status-banner");
434 await expect(statusBanner).toBeVisible();
435 await expect(statusBanner).toHaveClass(/status-working/);
436 await expect(statusBanner).toHaveText("WORKING");
437});