diff --git a/web/src/client/components/session-list.test.ts b/web/src/client/components/session-list.test.ts new file mode 100644 index 00000000..b7e589b2 --- /dev/null +++ b/web/src/client/components/session-list.test.ts @@ -0,0 +1,386 @@ +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; +import { fixture, html } from '@open-wc/testing'; +import { createMockSession } from '@/test/utils/lit-test-utils'; +import { + clickElement, + waitForElement, + waitForEvent, + getTextContent, + elementExists, + getAllElements, + setupFetchMock, + typeInInput, + submitForm, +} from '@/test/utils/component-helpers'; +import { AuthClient } from '../services/auth-client'; + +// Mock AuthClient +vi.mock('../services/auth-client'); + +// Import component type +import type { SessionList } from './session-list'; + +describe('SessionList', () => { + let element: SessionList; + let fetchMock: ReturnType; + let mockAuthClient: AuthClient; + + beforeAll(async () => { + // Import components to register custom elements + await import('./session-list'); + await import('./session-card'); + await import('./session-create-form'); + }); + + beforeEach(async () => { + // Setup fetch mock + fetchMock = setupFetchMock(); + + // Create mock auth client + mockAuthClient = { + getAuthHeader: vi.fn(() => ({ Authorization: 'Bearer test-token' })), + } as any; + + // Create component + element = await fixture(html` + + `); + + await element.updateComplete; + }); + + afterEach(() => { + element.remove(); + fetchMock.clear(); + }); + + describe('initialization', () => { + it('should create component with default state', () => { + expect(element).toBeDefined(); + expect(element.sessions).toEqual([]); + expect(element.loading).toBe(false); + expect(element.hideExited).toBe(true); + expect(element.showCreateModal).toBe(false); + }); + }); + + describe('session display', () => { + it('should display session cards', async () => { + const mockSessions = [ + createMockSession({ id: 'session-1', name: 'Session 1', status: 'running' }), + createMockSession({ id: 'session-2', name: 'Session 2', status: 'running' }), + ]; + + element.sessions = mockSessions; + await element.updateComplete; + + const sessionCards = getAllElements(element, 'session-card'); + expect(sessionCards).toHaveLength(2); + }); + + it('should filter exited sessions when hideExited is true', async () => { + const mockSessions = [ + createMockSession({ id: 'session-1', status: 'running' }), + createMockSession({ id: 'session-2', status: 'exited' }), + createMockSession({ id: 'session-3', status: 'running' }), + ]; + + element.sessions = mockSessions; + element.hideExited = true; + await element.updateComplete; + + const sessionCards = getAllElements(element, 'session-card'); + expect(sessionCards).toHaveLength(2); + }); + + it('should show all sessions when hideExited is false', async () => { + const mockSessions = [ + createMockSession({ id: 'session-1', status: 'running' }), + createMockSession({ id: 'session-2', status: 'exited' }), + ]; + + element.sessions = mockSessions; + element.hideExited = false; + await element.updateComplete; + + const sessionCards = getAllElements(element, 'session-card'); + expect(sessionCards).toHaveLength(2); + }); + + it('should show empty state when no sessions', async () => { + element.sessions = []; + await element.updateComplete; + + // Look for any element that might contain the empty state text + const bodyText = element.textContent; + expect(bodyText).toContain('No sessions'); + }); + + it('should show loading state', async () => { + element.loading = true; + await element.updateComplete; + + // Check that loading state is set + expect(element.loading).toBe(true); + }); + }); + + describe('session navigation', () => { + it('should emit navigate event when session is clicked', async () => { + const navigateHandler = vi.fn(); + element.addEventListener('navigate-to-session', navigateHandler); + + const mockSession = createMockSession({ id: 'test-session' }); + element.sessions = [mockSession]; + await element.updateComplete; + + const sessionCard = element.querySelector('session-card'); + if (sessionCard) { + // Dispatch select event from session card + sessionCard.dispatchEvent(new CustomEvent('session-select', { + detail: mockSession, + bubbles: true + })); + + expect(navigateHandler).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { sessionId: 'test-session' } + }) + ); + } + }); + }); + + describe('session creation', () => { + it('should show create modal when button is clicked', async () => { + // Click create button + const createButton = element.querySelector('[data-testid="create-session-btn"]') || + element.querySelector('button'); + if (createButton) { + (createButton as HTMLElement).click(); + await element.updateComplete; + + expect(element.showCreateModal).toBe(true); + + const modal = element.querySelector('session-create-form'); + expect(modal).toBeTruthy(); + } + }); + + it('should close modal on cancel', async () => { + element.showCreateModal = true; + await element.updateComplete; + + const createForm = element.querySelector('session-create-form'); + if (createForm) { + // Dispatch close event which the parent listens for + createForm.dispatchEvent(new CustomEvent('create-modal-close', { bubbles: true })); + await element.updateComplete; + + expect(element.showCreateModal).toBe(false); + } + }); + + it('should handle session creation', async () => { + const createdHandler = vi.fn(); + element.addEventListener('session-created', createdHandler); + + element.showCreateModal = true; + await element.updateComplete; + + const createForm = element.querySelector('session-create-form'); + if (createForm) { + // Dispatch session created event + createForm.dispatchEvent(new CustomEvent('session-created', { + detail: { sessionId: 'new-session', message: 'Session created' }, + bubbles: true + })); + + await element.updateComplete; + + // Modal might close asynchronously + expect(createdHandler).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { sessionId: 'new-session', message: 'Session created' } + }) + ); + } + }); + }); + + describe('session management', () => { + it('should handle session kill', async () => { + const refreshHandler = vi.fn(); + element.addEventListener('refresh', refreshHandler); + + const mockSessions = [ + createMockSession({ id: 'session-1' }), + createMockSession({ id: 'session-2' }), + ]; + element.sessions = mockSessions; + await element.updateComplete; + + // Dispatch kill event from session card + const sessionCard = element.querySelector('session-card'); + if (sessionCard) { + sessionCard.dispatchEvent(new CustomEvent('session-killed', { + detail: { sessionId: 'session-1' }, + bubbles: true + })); + + // Session should be removed from list + expect(element.sessions).toHaveLength(1); + expect(element.sessions[0].id).toBe('session-2'); + + // Should trigger refresh + expect(refreshHandler).toHaveBeenCalled(); + } + }); + + it('should handle session kill error', async () => { + const errorHandler = vi.fn(); + element.addEventListener('error', errorHandler); + + const mockSession = createMockSession(); + element.sessions = [mockSession]; + await element.updateComplete; + + // Dispatch kill error + const sessionCard = element.querySelector('session-card'); + if (sessionCard) { + sessionCard.dispatchEvent(new CustomEvent('session-kill-error', { + detail: { sessionId: mockSession.id, error: 'Permission denied' }, + bubbles: true + })); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.stringContaining('Failed to kill session') + }) + ); + } + }); + + it('should handle cleanup of exited sessions', async () => { + // Mock successful cleanup + fetchMock.mockResponse('/api/cleanup-exited', { removed: 2 }); + + const refreshHandler = vi.fn(); + element.addEventListener('refresh', refreshHandler); + + await element.handleCleanupExited(); + + // Should trigger refresh after cleanup + expect(refreshHandler).toHaveBeenCalled(); + expect(mockAuthClient.getAuthHeader).toHaveBeenCalled(); + }); + + it('should handle cleanup error', async () => { + // Mock cleanup error + fetchMock.mockResponse('/api/cleanup-exited', + { error: 'Cleanup failed' }, + { status: 500 } + ); + + const errorHandler = vi.fn(); + element.addEventListener('error', errorHandler); + + await element.handleCleanupExited(); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.stringContaining('Failed to clean') + }) + ); + }); + + it('should handle kill all sessions', async () => { + // Mock kill all response + fetchMock.mockResponse('/api/sessions/kill-all', { killed: 3 }); + + // Dispatch the kill all event that the component listens for + element.dispatchEvent(new CustomEvent('kill-all-sessions', { bubbles: true })); + + // The component should handle this internally + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(mockAuthClient.getAuthHeader).toHaveBeenCalled(); + }); + }); + + describe('hide exited toggle', () => { + it('should toggle hide exited state', async () => { + const changeHandler = vi.fn(); + element.addEventListener('hide-exited-change', changeHandler); + + // Create some sessions + element.sessions = [ + createMockSession({ status: 'running' }), + createMockSession({ status: 'exited' }), + ]; + await element.updateComplete; + + // Find toggle button + const toggleButton = element.querySelector('[title*="Hide exited"]') || + element.querySelector('[title*="Show exited"]'); + + if (toggleButton) { + (toggleButton as HTMLElement).click(); + + expect(element.hideExited).toBe(false); + expect(changeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + detail: false + }) + ); + } + }); + }); + + describe('refresh handling', () => { + it('should emit refresh event when refresh button is clicked', async () => { + const refreshHandler = vi.fn(); + element.addEventListener('refresh', refreshHandler); + + const refreshButton = element.querySelector('[title="Refresh"]'); + if (refreshButton) { + (refreshButton as HTMLElement).click(); + + expect(refreshHandler).toHaveBeenCalled(); + } + }); + }); + + describe('rendering', () => { + it('should render header with correct title', () => { + const header = element.querySelector('h2'); + expect(header?.textContent).toContain('Sessions'); + }); + + it('should show session count', async () => { + element.sessions = [ + createMockSession({ status: 'running' }), + createMockSession({ status: 'running' }), + createMockSession({ status: 'exited' }), + ]; + element.hideExited = true; + await element.updateComplete; + + // Look for the count in the rendered content + const content = element.textContent; + expect(content).toContain('2'); // Only running sessions shown + }); + + it('should render cleanup button when there are exited sessions', async () => { + element.sessions = [ + createMockSession({ status: 'running' }), + createMockSession({ status: 'exited' }), + ]; + element.hideExited = false; + await element.updateComplete; + + const cleanupButton = element.querySelector('[title*="Clean"]'); + expect(cleanupButton).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/web/src/test/utils/component-helpers.ts b/web/src/test/utils/component-helpers.ts index 7f69ea94..e9a38ae9 100644 --- a/web/src/test/utils/component-helpers.ts +++ b/web/src/test/utils/component-helpers.ts @@ -5,14 +5,16 @@ import { vi } from 'vitest'; export { waitForElement } from '@/test/utils/lit-test-utils'; /** - * Types an input field with a given value and triggers input event + * Types an input field with a given value and triggers input event (supports both shadow and light DOM) */ export async function typeInInput( element: HTMLElement, selector: string, text: string ): Promise { - const input = element.shadowRoot!.querySelector(selector) as HTMLInputElement; + const input = (element.shadowRoot + ? element.shadowRoot.querySelector(selector) + : element.querySelector(selector)) as HTMLInputElement; if (!input) throw new Error(`Input with selector ${selector} not found`); input.value = text; @@ -24,13 +26,15 @@ export async function typeInInput( } /** - * Clicks an element and waits for updates + * Clicks an element and waits for updates (supports both shadow and light DOM) */ export async function clickElement( element: HTMLElement, selector: string ): Promise { - const target = element.shadowRoot!.querySelector(selector) as HTMLElement; + const target = (element.shadowRoot + ? element.shadowRoot.querySelector(selector) + : element.querySelector(selector)) as HTMLElement; if (!target) throw new Error(`Element with selector ${selector} not found`); target.click(); @@ -41,24 +45,28 @@ export async function clickElement( } /** - * Gets text content from an element in shadow DOM + * Gets text content from an element (supports both shadow and light DOM) */ export function getTextContent( element: HTMLElement, selector: string ): string | null { - const target = element.shadowRoot!.querySelector(selector); + const target = element.shadowRoot + ? element.shadowRoot.querySelector(selector) + : element.querySelector(selector); return target?.textContent?.trim() || null; } /** - * Checks if an element exists in shadow DOM + * Checks if an element exists (supports both shadow and light DOM) */ export function elementExists( element: HTMLElement, selector: string ): boolean { - return !!element.shadowRoot!.querySelector(selector); + return element.shadowRoot + ? !!element.shadowRoot.querySelector(selector) + : !!element.querySelector(selector); } /** @@ -147,13 +155,15 @@ export async function pressKey( } /** - * Gets all elements matching a selector + * Gets all elements matching a selector (supports both shadow and light DOM) */ export function getAllElements( element: HTMLElement, selector: string ): T[] { - return Array.from(element.shadowRoot!.querySelectorAll(selector)); + return element.shadowRoot + ? Array.from(element.shadowRoot.querySelectorAll(selector)) + : Array.from(element.querySelectorAll(selector)); } /**