blob: 314c5a070a5d1366eaf9f6eb3f38ecbaa5c2ee17 [file] [log] [blame]
import { test, expect } from "@sand4rt/experimental-ct-web";
import { SketchTimeline } from "./sketch-timeline";
import { AgentMessage } from "../types";
// Mock DataManager class that mimics the real DataManager interface
class MockDataManager {
private eventListeners: Map<string, Array<(...args: any[]) => void>> =
new Map();
private isInitialLoadComplete: boolean = false;
constructor() {
this.eventListeners.set("initialLoadComplete", []);
}
addEventListener(event: string, callback: (...args: any[]) => void): void {
const listeners = this.eventListeners.get(event) || [];
listeners.push(callback);
this.eventListeners.set(event, listeners);
}
removeEventListener(event: string, callback: (...args: any[]) => void): void {
const listeners = this.eventListeners.get(event) || [];
const index = listeners.indexOf(callback);
if (index > -1) {
listeners.splice(index, 1);
}
}
getIsInitialLoadComplete(): boolean {
return this.isInitialLoadComplete;
}
triggerInitialLoadComplete(
messageCount: number = 0,
expectedCount: number = 0,
): void {
this.isInitialLoadComplete = true;
const listeners = this.eventListeners.get("initialLoadComplete") || [];
// Call each listener with the event data object as expected by the component
listeners.forEach((listener) => {
try {
listener({ messageCount, expectedCount });
} catch (e) {
console.error("Error in event listener:", e);
}
});
}
}
// Helper function to create mock timeline messages
function createMockMessage(props: Partial<AgentMessage> = {}): AgentMessage {
return {
idx: props.idx || 0,
type: props.type || "agent",
content: props.content || "Hello world",
timestamp: props.timestamp || "2023-05-15T12:00:00Z",
elapsed: props.elapsed || 1500000000, // 1.5 seconds in nanoseconds
end_of_turn: props.end_of_turn || false,
conversation_id: props.conversation_id || "conv123",
tool_calls: props.tool_calls || [],
commits: props.commits || [],
usage: props.usage,
hide_output: props.hide_output || false,
...props,
};
}
// Extend window interface for test globals
declare global {
interface Window {
scrollCalled?: boolean;
testEventFired?: boolean;
testEventDetail?: any;
}
}
// Helper function to create an array of mock messages
function createMockMessages(count: number): AgentMessage[] {
return Array.from({ length: count }, (_, i) =>
createMockMessage({
idx: i,
content: `Message ${i + 1}`,
type: i % 3 === 0 ? "user" : "agent",
timestamp: new Date(Date.now() - (count - i) * 60000).toISOString(),
}),
);
}
test("renders empty state when no messages", async ({ mount }) => {
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages: [],
dataManager: mockDataManager,
},
});
await expect(timeline.locator("[data-testid='welcome-box']")).toBeVisible();
await expect(
timeline.locator("[data-testid='welcome-box-title']"),
).toContainText("How to use Sketch");
});
test("renders messages when provided", async ({ mount }) => {
const messages = createMockMessages(5);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
await expect(
timeline.locator("[data-testid='timeline-container']"),
).toBeVisible();
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
});
test("shows thinking indicator when agent is active", async ({ mount }) => {
const messages = createMockMessages(3);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
llmCalls: 1,
toolCalls: ["thinking"],
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
console.log("Set isInitialLoadComplete to true");
console.log("llmCalls:", element.llmCalls);
console.log("toolCalls:", element.toolCalls);
console.log(
"isInitialLoadComplete:",
(element as any).isInitialLoadComplete,
);
element.requestUpdate();
return element.updateComplete;
});
// Debug: Check if the element exists and what its computed style is
const indicatorExists = await timeline
.locator("[data-testid='thinking-indicator']")
.count();
console.log("Thinking indicator exists:", indicatorExists);
if (indicatorExists > 0) {
const style = await timeline
.locator("[data-testid='thinking-indicator']")
.evaluate((el) => {
const computed = window.getComputedStyle(el);
return {
display: computed.display,
visibility: computed.visibility,
opacity: computed.opacity,
className: el.className,
};
});
console.log("Thinking indicator style:", style);
}
// Wait for the component to render with a longer timeout
await expect(
timeline.locator("[data-testid='thinking-indicator']"),
).toBeVisible({ timeout: 10000 });
await expect(
timeline.locator("[data-testid='thinking-bubble']"),
).toBeVisible();
await expect(timeline.locator("[data-testid='thinking-dot']")).toHaveCount(3);
});
test("filters out messages with hide_output flag", async ({ mount }) => {
const messages = [
createMockMessage({ idx: 0, content: "Visible message 1" }),
createMockMessage({ idx: 1, content: "Hidden message", hide_output: true }),
createMockMessage({ idx: 2, content: "Visible message 2" }),
];
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
// Should only show 2 visible messages
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(2);
// Verify the hidden message is not rendered by checking each visible message individually
const visibleMessages = timeline.locator("sketch-timeline-message");
await expect(visibleMessages.nth(0)).toContainText("Visible message 1");
await expect(visibleMessages.nth(1)).toContainText("Visible message 2");
// Check that no message contains the hidden text
const firstMessageText = await visibleMessages.nth(0).textContent();
const secondMessageText = await visibleMessages.nth(1).textContent();
expect(firstMessageText).not.toContain("Hidden message");
expect(secondMessageText).not.toContain("Hidden message");
});
// Viewport Management Tests
test("limits initial message count based on initialMessageCount property", async ({
mount,
}) => {
const messages = createMockMessages(50);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
initialMessageCount: 10,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
// Should only render the most recent 10 messages initially
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
// Should show the most recent messages (41-50)
await expect(
timeline.locator("sketch-timeline-message").first(),
).toContainText("Message 41");
await expect(
timeline.locator("sketch-timeline-message").last(),
).toContainText("Message 50");
});
test("handles viewport expansion correctly", async ({ mount }) => {
const messages = createMockMessages(50);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
initialMessageCount: 10,
loadChunkSize: 5,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
// Initially shows 10 messages
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
// Simulate expanding viewport by setting visibleMessageStartIndex
await timeline.evaluate((element: SketchTimeline) => {
(element as any).visibleMessageStartIndex = 5;
element.requestUpdate();
return element.updateComplete;
});
// Should now show 15 messages (10 initial + 5 chunk)
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(15);
// Should show messages 36-50
await expect(
timeline.locator("sketch-timeline-message").first(),
).toContainText("Message 36");
await expect(
timeline.locator("sketch-timeline-message").last(),
).toContainText("Message 50");
});
test("resetViewport method resets to most recent messages", async ({
mount,
}) => {
const messages = createMockMessages(50);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
initialMessageCount: 10,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
// Expand viewport
await timeline.evaluate((element: SketchTimeline) => {
(element as any).visibleMessageStartIndex = 10;
element.requestUpdate();
return element.updateComplete;
});
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(20);
// Reset viewport
await timeline.evaluate((element: SketchTimeline) => {
element.resetViewport();
return element.updateComplete;
});
// Should be back to initial count
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
await expect(
timeline.locator("sketch-timeline-message").first(),
).toContainText("Message 41");
});
// Scroll State Management Tests
test("shows jump-to-latest button when not pinned to latest", async ({
mount,
}) => {
const messages = createMockMessages(10);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
// Initially should be pinned to latest (button hidden)
await expect(timeline.locator("#jump-to-latest.floating")).not.toBeVisible();
// Simulate floating state
await timeline.evaluate((element: SketchTimeline) => {
(element as any).scrollingState = "floating";
element.requestUpdate();
return element.updateComplete;
});
// Button should now be visible - wait longer for CSS classes to apply
await expect(timeline.locator("#jump-to-latest.floating")).toBeVisible({
timeout: 10000,
});
});
test("jump-to-latest button calls scroll method", async ({ mount }) => {
const messages = createMockMessages(10);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
// Initialize the scroll tracking flag and set to floating state to show button
await timeline.evaluate((element: SketchTimeline) => {
// Initialize tracking flag
(window as any).scrollCalled = false;
// Set floating state
(element as any).scrollingState = "floating";
// Mock the scroll method
(element as any).scrollToBottomWithRetry = async function () {
(window as any).scrollCalled = true;
return Promise.resolve();
};
element.requestUpdate();
return element.updateComplete;
});
// Verify button is visible before clicking - wait longer for CSS classes to apply
await expect(timeline.locator("#jump-to-latest.floating")).toBeVisible({
timeout: 10000,
});
// Click the jump to latest button
await timeline.locator("#jump-to-latest").click();
// Check if scroll was called
const wasScrollCalled = await timeline.evaluate(
() => (window as any).scrollCalled,
);
expect(wasScrollCalled).toBe(true);
});
// Loading State Tests
test("shows loading indicator when loading older messages", async ({
mount,
}) => {
const messages = createMockMessages(10);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
dataManager: mockDataManager,
},
});
// Set initial load complete first, then simulate loading older messages
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
(element as any).isLoadingOlderMessages = true;
element.requestUpdate();
return element.updateComplete;
});
await expect(
timeline.locator("[data-testid='loading-indicator']"),
).toBeVisible();
await expect(
timeline.locator("[data-testid='loading-spinner']"),
).toBeVisible();
await expect(
timeline.locator("[data-testid='loading-indicator']"),
).toContainText("Loading older messages...");
});
test("hides loading indicator when not loading", async ({ mount }) => {
const messages = createMockMessages(10);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
dataManager: mockDataManager,
},
});
// Set initial load complete so no loading indicator is shown
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
// Should not show loading indicator by default
await expect(
timeline.locator("[data-testid='loading-indicator']"),
).not.toBeVisible();
});
// Memory Management and Cleanup Tests
test("handles scroll container changes properly", async ({ mount }) => {
const messages = createMockMessages(5);
const timeline = await mount(SketchTimeline, {
props: {
messages,
},
});
// Initialize call counters in window
await timeline.evaluate(() => {
(window as any).addListenerCalls = 0;
(window as any).removeListenerCalls = 0;
});
// Set first container
await timeline.evaluate((element: SketchTimeline) => {
const mockContainer1 = {
addEventListener: () => {
(window as any).addListenerCalls =
((window as any).addListenerCalls || 0) + 1;
},
removeEventListener: () => {
(window as any).removeListenerCalls =
((window as any).removeListenerCalls || 0) + 1;
},
isConnected: true,
scrollTop: 0,
scrollHeight: 1000,
clientHeight: 500,
};
(element as any).scrollContainer = { value: mockContainer1 };
element.requestUpdate();
return element.updateComplete;
});
// Change to second container (should clean up first)
await timeline.evaluate((element: SketchTimeline) => {
const mockContainer2 = {
addEventListener: () => {
(window as any).addListenerCalls =
((window as any).addListenerCalls || 0) + 1;
},
removeEventListener: () => {
(window as any).removeListenerCalls =
((window as any).removeListenerCalls || 0) + 1;
},
isConnected: true,
scrollTop: 0,
scrollHeight: 1000,
clientHeight: 500,
};
(element as any).scrollContainer = { value: mockContainer2 };
element.requestUpdate();
return element.updateComplete;
});
// Get the call counts
const addListenerCalls = await timeline.evaluate(
() => (window as any).addListenerCalls || 0,
);
const removeListenerCalls = await timeline.evaluate(
() => (window as any).removeListenerCalls || 0,
);
// Should have called addEventListener twice and removeEventListener once
expect(addListenerCalls).toBeGreaterThan(0);
expect(removeListenerCalls).toBeGreaterThan(0);
});
test("cancels loading operations on viewport reset", async ({ mount }) => {
const messages = createMockMessages(50);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
dataManager: mockDataManager,
},
});
// Set initial load complete and then loading older messages state
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
(element as any).isLoadingOlderMessages = true;
(element as any).loadingAbortController = new AbortController();
element.requestUpdate();
return element.updateComplete;
});
// Verify loading state - should show only the "loading older messages" indicator
await expect(
timeline.locator("[data-testid='loading-indicator']"),
).toContainText("Loading older messages...");
// Reset viewport (should cancel loading)
await timeline.evaluate((element: SketchTimeline) => {
element.resetViewport();
return element.updateComplete;
});
// Loading should be cancelled
const isLoading = await timeline.evaluate(
(element: SketchTimeline) => (element as any).isLoadingOlderMessages,
);
expect(isLoading).toBe(false);
await expect(
timeline.locator("[data-testid='loading-indicator']"),
).not.toBeVisible();
});
// Message Filtering and Ordering Tests
test("displays messages in correct order (most recent at bottom)", async ({
mount,
}) => {
const messages = [
createMockMessage({
idx: 0,
content: "First message",
timestamp: "2023-01-01T10:00:00Z",
}),
createMockMessage({
idx: 1,
content: "Second message",
timestamp: "2023-01-01T11:00:00Z",
}),
createMockMessage({
idx: 2,
content: "Third message",
timestamp: "2023-01-01T12:00:00Z",
}),
];
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
const messageElements = timeline.locator("sketch-timeline-message");
// Check order
await expect(messageElements.nth(0)).toContainText("First message");
await expect(messageElements.nth(1)).toContainText("Second message");
await expect(messageElements.nth(2)).toContainText("Third message");
});
test("handles previousMessage prop correctly for message context", async ({
mount,
}) => {
const messages = [
createMockMessage({ idx: 0, content: "First message", type: "user" }),
createMockMessage({ idx: 1, content: "Second message", type: "agent" }),
createMockMessage({ idx: 2, content: "Third message", type: "user" }),
];
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
// Check that messages have the expected structure
// The first message should not have a previous message context
// The second message should have the first as previous, etc.
const messageElements = timeline.locator("sketch-timeline-message");
await expect(messageElements).toHaveCount(3);
// All messages should be rendered
await expect(messageElements.nth(0)).toContainText("First message");
await expect(messageElements.nth(1)).toContainText("Second message");
await expect(messageElements.nth(2)).toContainText("Third message");
});
// Event Handling Tests
test("handles show-commit-diff events from message components", async ({
mount,
}) => {
const messages = [
createMockMessage({
idx: 0,
content: "Message with commit",
commits: [
{
hash: "abc123def456",
subject: "Test commit",
body: "Test commit body",
pushed_branch: "main",
},
],
}),
];
const timeline = await mount(SketchTimeline, {
props: {
messages,
},
});
// Listen for the bubbled event
await timeline.evaluate((element) => {
element.addEventListener("show-commit-diff", (event: CustomEvent) => {
window.testEventFired = true;
window.testEventDetail = event.detail;
});
});
// Simulate the event being fired from a message component
await timeline.evaluate((element) => {
const event = new CustomEvent("show-commit-diff", {
detail: { commitHash: "abc123def456" },
bubbles: true,
composed: true,
});
element.dispatchEvent(event);
});
// Check that event was handled
const wasEventFired = await timeline.evaluate(() => window.testEventFired);
const detail = await timeline.evaluate(() => window.testEventDetail);
expect(wasEventFired).toBe(true);
expect(detail?.commitHash).toBe("abc123def456");
});
// Edge Cases and Error Handling
test("handles empty filteredMessages gracefully", async ({ mount }) => {
// All messages are hidden - this will still render timeline structure
// because component only shows welcome box when messages.length === 0
const messages = [
createMockMessage({ idx: 0, content: "Hidden 1", hide_output: true }),
createMockMessage({ idx: 1, content: "Hidden 2", hide_output: true }),
];
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
// Should render the timeline structure but with no visible messages
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(0);
// Should not show welcome box when messages array has content (even if all hidden)
await expect(
timeline.locator("[data-testid='welcome-box']"),
).not.toBeVisible();
// Should not show loading indicator
await expect(
timeline.locator("[data-testid='loading-indicator']"),
).not.toBeVisible();
// Timeline container exists but may not be visible due to CSS
await expect(
timeline.locator("[data-testid='timeline-container']"),
).toBeAttached();
});
test("handles message array updates correctly", async ({ mount }) => {
const initialMessages = createMockMessages(5);
const mockDataManager = new MockDataManager();
const timeline = await mount(SketchTimeline, {
props: {
messages: initialMessages,
dataManager: mockDataManager,
},
});
// Directly set the isInitialLoadComplete state to bypass the event system for testing
await timeline.evaluate((element: SketchTimeline) => {
(element as any).isInitialLoadComplete = true;
element.requestUpdate();
return element.updateComplete;
});
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(5);
// Update with more messages
const moreMessages = createMockMessages(10);
await timeline.evaluate(
(element: SketchTimeline, newMessages: AgentMessage[]) => {
element.messages = newMessages;
element.requestUpdate();
return element.updateComplete;
},
moreMessages,
);
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(10);
// Update with fewer messages
const fewerMessages = createMockMessages(3);
await timeline.evaluate(
(element: SketchTimeline, newMessages: AgentMessage[]) => {
element.messages = newMessages;
element.requestUpdate();
return element.updateComplete;
},
fewerMessages,
);
await expect(timeline.locator("sketch-timeline-message")).toHaveCount(3);
});
test("messageKey method generates unique keys correctly", async ({ mount }) => {
const timeline = await mount(SketchTimeline);
const message1 = createMockMessage({ idx: 1, tool_calls: [] });
const message2 = createMockMessage({ idx: 2, tool_calls: [] });
const message3 = createMockMessage({
idx: 1,
tool_calls: [
{
tool_call_id: "call_123",
name: "test",
input: "{}",
result_message: createMockMessage({ idx: 99, content: "result" }),
},
],
});
const key1 = await timeline.evaluate(
(element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
message1,
);
const key2 = await timeline.evaluate(
(element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
message2,
);
const key3 = await timeline.evaluate(
(element: SketchTimeline, msg: AgentMessage) => element.messageKey(msg),
message3,
);
// Keys should be unique
expect(key1).not.toBe(key2);
expect(key1).not.toBe(key3);
expect(key2).not.toBe(key3);
// Keys should include message index
expect(key1).toContain("message-1");
expect(key2).toContain("message-2");
expect(key3).toContain("message-1");
// Message with tool call should have different key than without
expect(key1).not.toBe(key3);
});