test: add frontend tests for session-list component

- Add comprehensive tests for session-list component
- Update test helpers to support light DOM components
- Test session display, creation, and management
- 16/20 tests passing
This commit is contained in:
Peter Steinberger 2025-06-24 19:43:53 +02:00
parent 81ac0a9371
commit cd85a2109a
2 changed files with 406 additions and 10 deletions

View file

@ -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<typeof setupFetchMock>;
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<SessionList>(html`
<session-list .authClient=${mockAuthClient}></session-list>
`);
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();
});
});
});

View file

@ -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<void> {
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<void> {
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<T extends Element = Element>(
element: HTMLElement,
selector: string
): T[] {
return Array.from(element.shadowRoot!.querySelectorAll<T>(selector));
return element.shadowRoot
? Array.from(element.shadowRoot.querySelectorAll<T>(selector))
: Array.from(element.querySelectorAll<T>(selector));
}
/**