Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
728 lines
22 KiB
JavaScript
728 lines
22 KiB
JavaScript
/**
|
|
* Tests for dashboard_core.js - DashboardCore class.
|
|
*
|
|
* This tests the real exported module for coverage.
|
|
*
|
|
* Covers:
|
|
* - Authentication flow (login, logout, checkAuthStatus)
|
|
* - API calls with auth headers
|
|
* - WebSocket connection and message handling
|
|
* - Data management (hosts, tasks, schedules, alerts)
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { DashboardCore } from '../../app/dashboard_core.js';
|
|
import {
|
|
createMockResponse,
|
|
MockWebSocket,
|
|
localStorageMock
|
|
} from './setup.js';
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - Authentication
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - Authentication', () => {
|
|
let core;
|
|
let mockFetch;
|
|
let mockStorage;
|
|
|
|
beforeEach(() => {
|
|
mockStorage = {
|
|
getItem: vi.fn(() => null),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn(),
|
|
clear: vi.fn()
|
|
};
|
|
|
|
mockFetch = vi.fn();
|
|
|
|
core = new DashboardCore({
|
|
apiBase: 'http://localhost',
|
|
storage: mockStorage,
|
|
fetch: mockFetch,
|
|
WebSocket: MockWebSocket
|
|
});
|
|
});
|
|
|
|
describe('getAuthHeaders', () => {
|
|
it('returns headers without auth when no token', () => {
|
|
core.accessToken = null;
|
|
const headers = core.getAuthHeaders();
|
|
|
|
expect(headers['Content-Type']).toBe('application/json');
|
|
expect(headers['Authorization']).toBeUndefined();
|
|
});
|
|
|
|
it('returns headers with Bearer token when authenticated', () => {
|
|
core.accessToken = 'test-token-123';
|
|
const headers = core.getAuthHeaders();
|
|
|
|
expect(headers['Authorization']).toBe('Bearer test-token-123');
|
|
});
|
|
});
|
|
|
|
describe('checkAuthStatus', () => {
|
|
it('returns true when authenticated', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse({
|
|
setup_required: false,
|
|
authenticated: true,
|
|
user: { username: 'testuser', role: 'admin' }
|
|
}));
|
|
|
|
const result = await core.checkAuthStatus();
|
|
|
|
expect(result).toBe(true);
|
|
expect(core.currentUser).toEqual({ username: 'testuser', role: 'admin' });
|
|
});
|
|
|
|
it('returns false when setup required', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse({
|
|
setup_required: true,
|
|
authenticated: false,
|
|
user: null
|
|
}));
|
|
|
|
const result = await core.checkAuthStatus();
|
|
|
|
expect(result).toBe(false);
|
|
expect(core.setupRequired).toBe(true);
|
|
});
|
|
|
|
it('returns false when not authenticated', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse({
|
|
setup_required: false,
|
|
authenticated: false,
|
|
user: null
|
|
}));
|
|
|
|
const result = await core.checkAuthStatus();
|
|
|
|
expect(result).toBe(false);
|
|
expect(core.currentUser).toBeNull();
|
|
});
|
|
|
|
it('returns false on network error', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
|
|
const result = await core.checkAuthStatus();
|
|
|
|
expect(result).toBe(false);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('calls onAuthStateChanged callback when authenticated', async () => {
|
|
const callback = vi.fn();
|
|
core.onAuthStateChanged = callback;
|
|
|
|
mockFetch.mockResolvedValue(createMockResponse({
|
|
setup_required: false,
|
|
authenticated: true,
|
|
user: { username: 'testuser' }
|
|
}));
|
|
|
|
await core.checkAuthStatus();
|
|
|
|
expect(callback).toHaveBeenCalledWith({
|
|
authenticated: true,
|
|
user: { username: 'testuser' }
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('login', () => {
|
|
it('stores token on successful login', async () => {
|
|
// First call for login, second for checkAuthStatus
|
|
mockFetch
|
|
.mockResolvedValueOnce(createMockResponse({
|
|
access_token: 'new-jwt-token',
|
|
token_type: 'bearer',
|
|
expires_in: 86400
|
|
}))
|
|
.mockResolvedValueOnce(createMockResponse({
|
|
setup_required: false,
|
|
authenticated: true,
|
|
user: { username: 'testuser' }
|
|
}));
|
|
|
|
const result = await core.login('testuser', 'password123');
|
|
|
|
expect(result).toBe(true);
|
|
expect(core.accessToken).toBe('new-jwt-token');
|
|
expect(mockStorage.setItem).toHaveBeenCalledWith('accessToken', 'new-jwt-token');
|
|
});
|
|
|
|
it('returns false on invalid credentials', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
mockFetch.mockResolvedValue(createMockResponse({ detail: 'Invalid credentials' }, 401));
|
|
|
|
const result = await core.login('wrong', 'credentials');
|
|
|
|
expect(result).toBe(false);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('calls onNotification callback on success', async () => {
|
|
const callback = vi.fn();
|
|
core.onNotification = callback;
|
|
|
|
mockFetch
|
|
.mockResolvedValueOnce(createMockResponse({ access_token: 'token' }))
|
|
.mockResolvedValueOnce(createMockResponse({ authenticated: true, user: {} }));
|
|
|
|
await core.login('user', 'pass');
|
|
|
|
expect(callback).toHaveBeenCalledWith('Connexion réussie', 'success');
|
|
});
|
|
|
|
it('calls onNotification callback on error', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const callback = vi.fn();
|
|
core.onNotification = callback;
|
|
|
|
mockFetch.mockResolvedValue(createMockResponse({ detail: 'Bad password' }, 401));
|
|
|
|
await core.login('user', 'wrong');
|
|
|
|
expect(callback).toHaveBeenCalledWith('Bad password', 'error');
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
it('clears token and user data', () => {
|
|
core.accessToken = 'some-token';
|
|
core.currentUser = { username: 'test' };
|
|
|
|
core.logout();
|
|
|
|
expect(core.accessToken).toBeNull();
|
|
expect(core.currentUser).toBeNull();
|
|
expect(mockStorage.removeItem).toHaveBeenCalledWith('accessToken');
|
|
});
|
|
|
|
it('closes WebSocket connection', () => {
|
|
core.ws = new MockWebSocket('ws://localhost/ws');
|
|
const closeSpy = vi.spyOn(core.ws, 'close');
|
|
|
|
core.logout();
|
|
|
|
expect(closeSpy).toHaveBeenCalled();
|
|
expect(core.ws).toBeNull();
|
|
});
|
|
|
|
it('calls onAuthStateChanged callback', () => {
|
|
const callback = vi.fn();
|
|
core.onAuthStateChanged = callback;
|
|
|
|
core.logout();
|
|
|
|
expect(callback).toHaveBeenCalledWith({ authenticated: false, user: null });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - API Calls
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - API Calls', () => {
|
|
let core;
|
|
let mockFetch;
|
|
|
|
beforeEach(() => {
|
|
mockFetch = vi.fn();
|
|
core = new DashboardCore({
|
|
apiBase: 'http://localhost',
|
|
fetch: mockFetch
|
|
});
|
|
core.accessToken = 'test-token';
|
|
});
|
|
|
|
describe('apiCall', () => {
|
|
it('makes authenticated request', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse({ data: 'test' }));
|
|
|
|
const result = await core.apiCall('/api/test');
|
|
|
|
expect(result).toEqual({ data: 'test' });
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'http://localhost/api/test',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Authorization': 'Bearer test-token'
|
|
})
|
|
})
|
|
);
|
|
});
|
|
|
|
it('throws on HTTP error', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
mockFetch.mockResolvedValue(createMockResponse({ error: 'Not found' }, 404));
|
|
|
|
await expect(core.apiCall('/api/notfound')).rejects.toThrow('HTTP 404');
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('passes custom options', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse({ success: true }));
|
|
|
|
await core.apiCall('/api/test', { method: 'POST', body: '{}' });
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'http://localhost/api/test',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: '{}'
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - WebSocket
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - WebSocket', () => {
|
|
let core;
|
|
|
|
beforeEach(() => {
|
|
core = new DashboardCore({
|
|
apiBase: 'http://localhost',
|
|
WebSocket: MockWebSocket
|
|
});
|
|
});
|
|
|
|
describe('connectWebSocket', () => {
|
|
it('creates WebSocket connection', () => {
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
expect(core.ws).toBeInstanceOf(MockWebSocket);
|
|
expect(core.ws.url).toBe('ws://localhost/ws');
|
|
});
|
|
|
|
it('sets up event handlers', () => {
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
expect(core.ws.onopen).toBeDefined();
|
|
expect(core.ws.onmessage).toBeDefined();
|
|
expect(core.ws.onclose).toBeDefined();
|
|
expect(core.ws.onerror).toBeDefined();
|
|
});
|
|
|
|
it('warns if WebSocket class not available', () => {
|
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const coreNoWs = new DashboardCore({
|
|
apiBase: 'http://localhost',
|
|
WebSocket: null
|
|
});
|
|
// Override the class to null after construction
|
|
coreNoWs.WebSocketClass = null;
|
|
|
|
coreNoWs.connectWebSocket('ws://localhost/ws');
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith('WebSocket not available');
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('uses default URL when none provided', () => {
|
|
core.connectWebSocket();
|
|
|
|
expect(core.ws).toBeInstanceOf(MockWebSocket);
|
|
});
|
|
|
|
it('triggers onopen handler', async () => {
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
// MockWebSocket auto-connects after a tick
|
|
await new Promise(r => setTimeout(r, 10));
|
|
|
|
// onopen should have been called (check via readyState)
|
|
expect(core.ws.readyState).toBe(MockWebSocket.OPEN);
|
|
});
|
|
|
|
it('handles onmessage with valid JSON', () => {
|
|
core.hosts = [];
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
// Simulate receiving a message
|
|
core.ws._receiveMessage({ type: 'host_updated', data: { id: 'h1', status: 'online' } });
|
|
|
|
expect(core.hosts.length).toBe(1);
|
|
});
|
|
|
|
it('handles onmessage with invalid JSON gracefully', () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
// Simulate receiving invalid JSON - should not throw
|
|
expect(() => {
|
|
core.ws.onmessage({ data: 'not valid json {' });
|
|
}).not.toThrow();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('handles onclose', () => {
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
// Should not throw
|
|
expect(() => {
|
|
core.ws.onclose({ code: 1000, reason: 'Normal' });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('handles onerror', () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
// Should not throw
|
|
expect(() => {
|
|
core.ws.onerror({ error: new Error('Test error') });
|
|
}).not.toThrow();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('handleWebSocketMessage', () => {
|
|
beforeEach(() => {
|
|
core.hosts = [{ id: 'host-1', name: 'server1', status: 'unknown' }];
|
|
core.tasks = [];
|
|
core.schedules = [{ id: 'sched-1', name: 'Daily Backup', last_status: 'pending' }];
|
|
core.alerts = [];
|
|
});
|
|
|
|
it('handles host_updated event', () => {
|
|
const callback = vi.fn();
|
|
core.onHostsUpdated = callback;
|
|
|
|
core.handleWebSocketMessage({
|
|
type: 'host_updated',
|
|
data: { id: 'host-1', status: 'online' }
|
|
});
|
|
|
|
expect(core.hosts[0].status).toBe('online');
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles task_started event', () => {
|
|
const callback = vi.fn();
|
|
core.onTasksUpdated = callback;
|
|
|
|
core.handleWebSocketMessage({
|
|
type: 'task_started',
|
|
data: { id: 'task-1', playbook: 'test.yml' }
|
|
});
|
|
|
|
expect(core.tasks.length).toBe(1);
|
|
expect(core.tasks[0].id).toBe('task-1');
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles task_updated event', () => {
|
|
core.tasks = [{ id: 'task-1', status: 'running' }];
|
|
|
|
core.handleWebSocketMessage({
|
|
type: 'task_updated',
|
|
data: { id: 'task-1', status: 'completed', progress: 100 }
|
|
});
|
|
|
|
expect(core.tasks[0].status).toBe('completed');
|
|
expect(core.tasks[0].progress).toBe(100);
|
|
});
|
|
|
|
it('handles schedule_completed event', () => {
|
|
const callback = vi.fn();
|
|
core.onSchedulesUpdated = callback;
|
|
|
|
core.handleWebSocketMessage({
|
|
type: 'schedule_completed',
|
|
data: { schedule_id: 'sched-1', status: 'success' }
|
|
});
|
|
|
|
expect(core.schedules[0].last_status).toBe('success');
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles alert_created event', () => {
|
|
const callback = vi.fn();
|
|
core.onAlertsUpdated = callback;
|
|
|
|
core.handleWebSocketMessage({
|
|
type: 'alert_created',
|
|
data: { id: 1, message: 'Test alert' }
|
|
});
|
|
|
|
expect(core.alerts.length).toBe(1);
|
|
expect(core.alertsUnread).toBe(1);
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles hosts_synced event', () => {
|
|
const callback = vi.fn();
|
|
core.onHostsUpdated = callback;
|
|
|
|
core.handleWebSocketMessage({ type: 'hosts_synced', data: {} });
|
|
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('ignores unknown message types', () => {
|
|
// Should not throw
|
|
core.handleWebSocketMessage({ type: 'unknown_type', data: {} });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - Data Management
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - Data Management', () => {
|
|
let core;
|
|
|
|
beforeEach(() => {
|
|
core = new DashboardCore({});
|
|
});
|
|
|
|
describe('updateHostInList', () => {
|
|
it('updates existing host', () => {
|
|
core.hosts = [{ id: 'host-1', name: 'server1', status: 'unknown' }];
|
|
|
|
core.updateHostInList({ id: 'host-1', status: 'online' });
|
|
|
|
expect(core.hosts[0].status).toBe('online');
|
|
expect(core.hosts[0].name).toBe('server1'); // Preserved
|
|
});
|
|
|
|
it('adds new host if not found', () => {
|
|
core.hosts = [];
|
|
|
|
core.updateHostInList({ id: 'host-new', name: 'new-server' });
|
|
|
|
expect(core.hosts.length).toBe(1);
|
|
expect(core.hosts[0].id).toBe('host-new');
|
|
});
|
|
|
|
it('ignores invalid data', () => {
|
|
core.hosts = [];
|
|
|
|
core.updateHostInList(null);
|
|
core.updateHostInList({});
|
|
|
|
expect(core.hosts.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('handleTaskUpdate', () => {
|
|
it('updates existing task', () => {
|
|
core.tasks = [{ id: 'task-1', status: 'running' }];
|
|
|
|
core.handleTaskUpdate({ id: 'task-1', status: 'completed' });
|
|
|
|
expect(core.tasks[0].status).toBe('completed');
|
|
});
|
|
|
|
it('adds new task at beginning', () => {
|
|
core.tasks = [{ id: 'task-old' }];
|
|
|
|
core.handleTaskUpdate({ id: 'task-new', status: 'pending' });
|
|
|
|
expect(core.tasks.length).toBe(2);
|
|
expect(core.tasks[0].id).toBe('task-new');
|
|
});
|
|
});
|
|
|
|
describe('handleAlertCreated', () => {
|
|
it('adds alert and increments unread count', () => {
|
|
core.alerts = [];
|
|
core.alertsUnread = 0;
|
|
|
|
core.handleAlertCreated({ id: 1, message: 'Test' });
|
|
|
|
expect(core.alerts.length).toBe(1);
|
|
expect(core.alertsUnread).toBe(1);
|
|
});
|
|
|
|
it('limits alerts to 200', () => {
|
|
core.alerts = Array.from({ length: 200 }, (_, i) => ({ id: i }));
|
|
|
|
core.handleAlertCreated({ id: 999, message: 'New' });
|
|
|
|
expect(core.alerts.length).toBe(200);
|
|
expect(core.alerts[0].id).toBe(999);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - Utility
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - Utility', () => {
|
|
let core;
|
|
|
|
beforeEach(() => {
|
|
core = new DashboardCore({});
|
|
});
|
|
|
|
describe('escapeHtml', () => {
|
|
it('escapes HTML special characters', () => {
|
|
const result = core.escapeHtml('<script>alert("xss")</script>');
|
|
|
|
expect(result).not.toContain('<script>');
|
|
expect(result).toContain('<');
|
|
expect(result).toContain('>');
|
|
});
|
|
|
|
it('returns empty string for null/undefined', () => {
|
|
expect(core.escapeHtml(null)).toBe('');
|
|
expect(core.escapeHtml(undefined)).toBe('');
|
|
});
|
|
|
|
it('handles normal text', () => {
|
|
expect(core.escapeHtml('Hello World')).toBe('Hello World');
|
|
});
|
|
|
|
it('escapes quotes', () => {
|
|
const result = core.escapeHtml('He said "hello" & \'goodbye\'');
|
|
|
|
expect(result).toContain('&');
|
|
});
|
|
});
|
|
|
|
describe('_getDefaultWsUrl', () => {
|
|
it('returns ws:// URL for http', () => {
|
|
// In jsdom, window.location.protocol is typically http:
|
|
const url = core._getDefaultWsUrl();
|
|
|
|
expect(url).toMatch(/^wss?:\/\//);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - Constructor
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - Constructor', () => {
|
|
it('loads token from storage on init', () => {
|
|
const mockStorage = {
|
|
getItem: vi.fn(() => 'stored-token'),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn()
|
|
};
|
|
|
|
const core = new DashboardCore({ storage: mockStorage });
|
|
|
|
expect(core.accessToken).toBe('stored-token');
|
|
});
|
|
|
|
it('starts with null token if storage empty', () => {
|
|
const mockStorage = {
|
|
getItem: vi.fn(() => null),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn()
|
|
};
|
|
|
|
const core = new DashboardCore({ storage: mockStorage });
|
|
|
|
expect(core.accessToken).toBeNull();
|
|
});
|
|
|
|
it('uses default apiBase in browser environment', () => {
|
|
// In test environment, window.location.origin may not be set
|
|
const core = new DashboardCore({});
|
|
|
|
expect(core.apiBase).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: Dashboard UI helpers (main.js) - Ad-Hoc widget regression
|
|
// ============================================================================
|
|
|
|
describe('main.js - Ad-Hoc widget regression', () => {
|
|
it('computes counters from task log status and shows load more when more items exist locally', async () => {
|
|
// Minimal DOM for widget
|
|
document.body.innerHTML = `
|
|
<div id="adhoc-widget-history"></div>
|
|
<button id="adhoc-widget-load-more" class="hidden"></button>
|
|
<div id="adhoc-widget-success"></div>
|
|
<div id="adhoc-widget-failed"></div>
|
|
<div id="adhoc-widget-total"></div>
|
|
<div id="adhoc-widget-count"></div>
|
|
`;
|
|
|
|
// Import side-effects are heavy; instead, instantiate the real DashboardManager by requiring main.js.
|
|
// main.js defines DashboardManager on the global scope.
|
|
await import('../../app/main.js');
|
|
|
|
// Create instance and stub required methods used by widget
|
|
const dash = new window.DashboardManager();
|
|
dash.escapeHtml = (s) => String(s ?? '');
|
|
dash.formatTimeAgo = () => 'À l\'instant';
|
|
dash.viewTaskLogContent = vi.fn();
|
|
dash.showAdHocConsole = vi.fn();
|
|
|
|
// Emulate loaded adhoc task logs
|
|
dash.adhocWidgetLimit = 2;
|
|
dash.adhocWidgetOffset = 0;
|
|
dash.adhocWidgetLogs = [
|
|
{ id: 'l1', status: 'completed', task_name: 'Ad-hoc: docker version', target: 'h1', created_at: new Date().toISOString(), duration_seconds: 1.2 },
|
|
{ id: 'l2', status: 'failed', task_name: 'Ad-hoc: docker ps', target: 'h2', created_at: new Date().toISOString(), duration_seconds: 2.3 },
|
|
{ id: 'l3', status: 'completed', task_name: 'Ad-hoc: cat /etc/os-release', target: 'h3', created_at: new Date().toISOString(), duration_seconds: 0.8 },
|
|
];
|
|
dash.adhocWidgetTotalCount = 3;
|
|
dash.adhocWidgetHasMore = false;
|
|
|
|
dash.renderAdhocWidget();
|
|
|
|
expect(document.getElementById('adhoc-widget-success').textContent).toBe('2');
|
|
expect(document.getElementById('adhoc-widget-failed').textContent).toBe('1');
|
|
expect(document.getElementById('adhoc-widget-total').textContent).toBe('3');
|
|
expect(document.getElementById('adhoc-widget-count').textContent).toContain('3');
|
|
// Since only 2 are displayed but 3 are loaded, load-more must be visible.
|
|
expect(document.getElementById('adhoc-widget-load-more').classList.contains('hidden')).toBe(false);
|
|
});
|
|
|
|
it('does not crash structured playbook viewer when parsedOutput.msg is an object', async () => {
|
|
await import('../../app/main.js');
|
|
|
|
const dash = new window.DashboardManager();
|
|
dash.escapeHtml = (s) => String(s ?? '');
|
|
|
|
const parsedOutput = {
|
|
plays: [
|
|
{
|
|
name: 'Test Play',
|
|
tasks: [
|
|
{
|
|
name: 'Task 1',
|
|
hostResults: [
|
|
{
|
|
hostname: 'host1',
|
|
status: 'ok',
|
|
parsedOutput: { msg: { hello: 'world', code: 200 } }
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
],
|
|
recap: {
|
|
host1: { ok: 1, changed: 0, failed: 0, skipped: 0, unreachable: 0 }
|
|
},
|
|
metadata: { playbookName: 'dummy.yml' },
|
|
stats: { totalHosts: 1, totalTasks: 1 }
|
|
};
|
|
|
|
expect(() => dash.renderTaskHierarchy(parsedOutput)).not.toThrow();
|
|
|
|
const html = dash.renderTaskHierarchy(parsedOutput);
|
|
expect(html).toContain('host1');
|
|
expect(html).toContain('"hello"');
|
|
});
|
|
});
|