mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
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:
parent
81ac0a9371
commit
cd85a2109a
2 changed files with 406 additions and 10 deletions
386
web/src/client/components/session-list.test.ts
Normal file
386
web/src/client/components/session-list.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,14 +5,16 @@ import { vi } from 'vitest';
|
||||||
export { waitForElement } from '@/test/utils/lit-test-utils';
|
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(
|
export async function typeInInput(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
selector: string,
|
selector: string,
|
||||||
text: string
|
text: string
|
||||||
): Promise<void> {
|
): 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`);
|
if (!input) throw new Error(`Input with selector ${selector} not found`);
|
||||||
|
|
||||||
input.value = text;
|
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(
|
export async function clickElement(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
selector: string
|
selector: string
|
||||||
): Promise<void> {
|
): 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`);
|
if (!target) throw new Error(`Element with selector ${selector} not found`);
|
||||||
|
|
||||||
target.click();
|
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(
|
export function getTextContent(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
selector: string
|
selector: string
|
||||||
): string | null {
|
): string | null {
|
||||||
const target = element.shadowRoot!.querySelector(selector);
|
const target = element.shadowRoot
|
||||||
|
? element.shadowRoot.querySelector(selector)
|
||||||
|
: element.querySelector(selector);
|
||||||
return target?.textContent?.trim() || null;
|
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(
|
export function elementExists(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
selector: string
|
selector: string
|
||||||
): boolean {
|
): 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>(
|
export function getAllElements<T extends Element = Element>(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
selector: string
|
selector: string
|
||||||
): T[] {
|
): 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue