415 lines
15 KiB
JavaScript
415 lines
15 KiB
JavaScript
/**
|
|
* Unit tests for containers_page.js filter/sort functions
|
|
* Run with: npx vitest run tests/frontend/containers_page.test.js
|
|
*
|
|
* These tests verify the pure functions used for filtering, sorting, and
|
|
* searching containers in the containers page.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
// Pure filter/sort functions extracted for testing (same logic as containers_page.js)
|
|
const containerUtils = {
|
|
parseSearchTokens(query) {
|
|
const filters = [];
|
|
let freeText = query;
|
|
|
|
const tokenRegex = /(\w+):(\S+)/g;
|
|
let match;
|
|
|
|
while ((match = tokenRegex.exec(query)) !== null) {
|
|
filters.push({ key: match[1], value: match[2] });
|
|
freeText = freeText.replace(match[0], '').trim();
|
|
}
|
|
|
|
return { filters, freeText };
|
|
},
|
|
|
|
containerHasPort(container, port) {
|
|
if (!container.ports) return false;
|
|
const portStr = container.ports.raw || JSON.stringify(container.ports);
|
|
return portStr.includes(port);
|
|
},
|
|
|
|
sortContainers(containers, sortBy) {
|
|
const [field, direction] = sortBy.split('-');
|
|
const mult = direction === 'desc' ? -1 : 1;
|
|
|
|
return [...containers].sort((a, b) => {
|
|
let valA, valB;
|
|
|
|
switch (field) {
|
|
case 'name':
|
|
valA = a.name.toLowerCase();
|
|
valB = b.name.toLowerCase();
|
|
break;
|
|
case 'host':
|
|
valA = a.host_name.toLowerCase();
|
|
valB = b.host_name.toLowerCase();
|
|
break;
|
|
case 'status':
|
|
const statusOrder = { running: 0, restarting: 1, paused: 2, created: 3, exited: 4, dead: 5 };
|
|
valA = statusOrder[a.state] ?? 99;
|
|
valB = statusOrder[b.state] ?? 99;
|
|
break;
|
|
case 'updated':
|
|
valA = new Date(a.last_update_at || 0).getTime();
|
|
valB = new Date(b.last_update_at || 0).getTime();
|
|
break;
|
|
default:
|
|
valA = a.name.toLowerCase();
|
|
valB = b.name.toLowerCase();
|
|
}
|
|
|
|
if (valA < valB) return -1 * mult;
|
|
if (valA > valB) return 1 * mult;
|
|
return 0;
|
|
});
|
|
},
|
|
|
|
smartSearch(containers, query) {
|
|
const tokens = this.parseSearchTokens(query);
|
|
|
|
return containers.filter(c => {
|
|
for (const token of tokens.filters) {
|
|
switch (token.key) {
|
|
case 'host':
|
|
if (!c.host_name.toLowerCase().includes(token.value.toLowerCase())) return false;
|
|
break;
|
|
case 'status':
|
|
case 'state':
|
|
if (c.state !== token.value.toLowerCase()) return false;
|
|
break;
|
|
case 'health':
|
|
if (c.health !== token.value.toLowerCase()) return false;
|
|
break;
|
|
case 'image':
|
|
if (!c.image?.toLowerCase().includes(token.value.toLowerCase())) return false;
|
|
break;
|
|
case 'port':
|
|
if (!this.containerHasPort(c, token.value)) return false;
|
|
break;
|
|
case 'project':
|
|
case 'stack':
|
|
if (!c.compose_project?.toLowerCase().includes(token.value.toLowerCase())) return false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (tokens.freeText) {
|
|
const searchStr = tokens.freeText.toLowerCase();
|
|
const searchable = [
|
|
c.name,
|
|
c.host_name,
|
|
c.image,
|
|
c.compose_project,
|
|
c.container_id?.substring(0, 12)
|
|
].filter(Boolean).join(' ').toLowerCase();
|
|
|
|
if (!searchable.includes(searchStr)) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
},
|
|
|
|
formatRelativeTime(date) {
|
|
if (!date) return '—';
|
|
const now = new Date();
|
|
const d = new Date(date);
|
|
const diff = Math.floor((now - d) / 1000);
|
|
|
|
if (diff < 60) return 'À l\'instant';
|
|
if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`;
|
|
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`;
|
|
return `il y a ${Math.floor(diff / 86400)} j`;
|
|
},
|
|
|
|
escapeHtml(text) {
|
|
if (!text) return '';
|
|
return String(text)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
};
|
|
|
|
// Test data
|
|
const mockContainers = [
|
|
{
|
|
id: 1,
|
|
host_id: 'host1',
|
|
host_name: 'dev.lab.home',
|
|
host_ip: '192.168.1.10',
|
|
container_id: 'abc123def456',
|
|
name: 'nginx-proxy',
|
|
image: 'nginx:latest',
|
|
state: 'running',
|
|
status: 'Up 2 hours',
|
|
health: 'healthy',
|
|
ports: { raw: '0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp' },
|
|
labels: { 'com.docker.compose.project': 'webstack' },
|
|
compose_project: 'webstack',
|
|
last_update_at: new Date().toISOString()
|
|
},
|
|
{
|
|
id: 2,
|
|
host_id: 'host1',
|
|
host_name: 'dev.lab.home',
|
|
host_ip: '192.168.1.10',
|
|
container_id: 'xyz789abc012',
|
|
name: 'postgres-db',
|
|
image: 'postgres:15',
|
|
state: 'running',
|
|
status: 'Up 3 hours',
|
|
health: 'healthy',
|
|
ports: { raw: '0.0.0.0:5432->5432/tcp' },
|
|
labels: { 'com.docker.compose.project': 'database' },
|
|
compose_project: 'database',
|
|
last_update_at: new Date(Date.now() - 3600000).toISOString()
|
|
},
|
|
{
|
|
id: 3,
|
|
host_id: 'host2',
|
|
host_name: 'prod.lab.home',
|
|
host_ip: '192.168.1.20',
|
|
container_id: 'mno456pqr789',
|
|
name: 'api-server',
|
|
image: 'myapp:v1.2.0',
|
|
state: 'exited',
|
|
status: 'Exited (1) 5 minutes ago',
|
|
health: null,
|
|
ports: null,
|
|
labels: {},
|
|
compose_project: null,
|
|
last_update_at: new Date(Date.now() - 7200000).toISOString()
|
|
},
|
|
{
|
|
id: 4,
|
|
host_id: 'host2',
|
|
host_name: 'prod.lab.home',
|
|
host_ip: '192.168.1.20',
|
|
container_id: 'stu012vwx345',
|
|
name: 'redis-cache',
|
|
image: 'redis:7-alpine',
|
|
state: 'paused',
|
|
status: 'Paused',
|
|
health: 'none',
|
|
ports: { raw: '0.0.0.0:6379->6379/tcp' },
|
|
labels: {},
|
|
compose_project: 'cache',
|
|
last_update_at: new Date(Date.now() - 1800000).toISOString()
|
|
}
|
|
];
|
|
|
|
describe('containerUtils', () => {
|
|
describe('parseSearchTokens', () => {
|
|
it('should parse single token', () => {
|
|
const result = containerUtils.parseSearchTokens('host:dev');
|
|
expect(result.filters).toHaveLength(1);
|
|
expect(result.filters[0]).toEqual({ key: 'host', value: 'dev' });
|
|
expect(result.freeText).toBe('');
|
|
});
|
|
|
|
it('should parse multiple tokens', () => {
|
|
const result = containerUtils.parseSearchTokens('host:dev status:running');
|
|
expect(result.filters).toHaveLength(2);
|
|
expect(result.filters[0]).toEqual({ key: 'host', value: 'dev' });
|
|
expect(result.filters[1]).toEqual({ key: 'status', value: 'running' });
|
|
});
|
|
|
|
it('should parse mixed tokens and free text', () => {
|
|
const result = containerUtils.parseSearchTokens('nginx host:dev');
|
|
expect(result.filters).toHaveLength(1);
|
|
expect(result.filters[0]).toEqual({ key: 'host', value: 'dev' });
|
|
expect(result.freeText).toBe('nginx');
|
|
});
|
|
|
|
it('should handle empty query', () => {
|
|
const result = containerUtils.parseSearchTokens('');
|
|
expect(result.filters).toHaveLength(0);
|
|
expect(result.freeText).toBe('');
|
|
});
|
|
|
|
it('should handle free text only', () => {
|
|
const result = containerUtils.parseSearchTokens('nginx proxy');
|
|
expect(result.filters).toHaveLength(0);
|
|
expect(result.freeText).toBe('nginx proxy');
|
|
});
|
|
});
|
|
|
|
describe('containerHasPort', () => {
|
|
it('should find port in raw ports string', () => {
|
|
const container = { ports: { raw: '0.0.0.0:8080->80/tcp' } };
|
|
expect(containerUtils.containerHasPort(container, '8080')).toBe(true);
|
|
});
|
|
|
|
it('should not find missing port', () => {
|
|
const container = { ports: { raw: '0.0.0.0:8080->80/tcp' } };
|
|
expect(containerUtils.containerHasPort(container, '9999')).toBe(false);
|
|
});
|
|
|
|
it('should handle null ports', () => {
|
|
const container = { ports: null };
|
|
expect(containerUtils.containerHasPort(container, '8080')).toBe(false);
|
|
});
|
|
|
|
it('should handle container without ports property', () => {
|
|
const container = {};
|
|
expect(containerUtils.containerHasPort(container, '8080')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('sortContainers', () => {
|
|
it('should sort by name ascending', () => {
|
|
const sorted = containerUtils.sortContainers(mockContainers, 'name-asc');
|
|
expect(sorted[0].name).toBe('api-server');
|
|
expect(sorted[1].name).toBe('nginx-proxy');
|
|
expect(sorted[2].name).toBe('postgres-db');
|
|
expect(sorted[3].name).toBe('redis-cache');
|
|
});
|
|
|
|
it('should sort by name descending', () => {
|
|
const sorted = containerUtils.sortContainers(mockContainers, 'name-desc');
|
|
expect(sorted[0].name).toBe('redis-cache');
|
|
expect(sorted[3].name).toBe('api-server');
|
|
});
|
|
|
|
it('should sort by host ascending', () => {
|
|
const sorted = containerUtils.sortContainers(mockContainers, 'host-asc');
|
|
expect(sorted[0].host_name).toBe('dev.lab.home');
|
|
expect(sorted[1].host_name).toBe('dev.lab.home');
|
|
expect(sorted[2].host_name).toBe('prod.lab.home');
|
|
});
|
|
|
|
it('should sort by status (running first)', () => {
|
|
const sorted = containerUtils.sortContainers(mockContainers, 'status-asc');
|
|
expect(sorted[0].state).toBe('running');
|
|
expect(sorted[1].state).toBe('running');
|
|
expect(sorted[2].state).toBe('paused');
|
|
expect(sorted[3].state).toBe('exited');
|
|
});
|
|
|
|
it('should not mutate original array', () => {
|
|
const original = [...mockContainers];
|
|
containerUtils.sortContainers(mockContainers, 'name-desc');
|
|
expect(mockContainers[0].name).toBe(original[0].name);
|
|
});
|
|
});
|
|
|
|
describe('smartSearch', () => {
|
|
it('should filter by host token', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, 'host:dev');
|
|
expect(result).toHaveLength(2);
|
|
expect(result.every(c => c.host_name.includes('dev'))).toBe(true);
|
|
});
|
|
|
|
it('should filter by status token', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, 'status:running');
|
|
expect(result).toHaveLength(2);
|
|
expect(result.every(c => c.state === 'running')).toBe(true);
|
|
});
|
|
|
|
it('should filter by health token', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, 'health:healthy');
|
|
expect(result).toHaveLength(2);
|
|
expect(result.every(c => c.health === 'healthy')).toBe(true);
|
|
});
|
|
|
|
it('should filter by image token', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, 'image:postgres');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('postgres-db');
|
|
});
|
|
|
|
it('should filter by port token', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, 'port:5432');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('postgres-db');
|
|
});
|
|
|
|
it('should filter by project token', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, 'project:webstack');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('nginx-proxy');
|
|
});
|
|
|
|
it('should filter by free text (name)', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, 'nginx');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('nginx-proxy');
|
|
});
|
|
|
|
it('should combine multiple tokens', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, 'host:dev status:running');
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
|
|
it('should combine token and free text', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, 'nginx host:dev');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('nginx-proxy');
|
|
});
|
|
|
|
it('should return all containers for empty query', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, '');
|
|
expect(result).toHaveLength(4);
|
|
});
|
|
|
|
it('should be case insensitive', () => {
|
|
const result = containerUtils.smartSearch(mockContainers, 'NGINX');
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('formatRelativeTime', () => {
|
|
it('should return "À l\'instant" for recent times', () => {
|
|
const now = new Date();
|
|
expect(containerUtils.formatRelativeTime(now)).toBe('À l\'instant');
|
|
});
|
|
|
|
it('should format minutes', () => {
|
|
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
expect(containerUtils.formatRelativeTime(fiveMinAgo)).toBe('il y a 5 min');
|
|
});
|
|
|
|
it('should format hours', () => {
|
|
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
|
expect(containerUtils.formatRelativeTime(twoHoursAgo)).toBe('il y a 2 h');
|
|
});
|
|
|
|
it('should format days', () => {
|
|
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
|
|
expect(containerUtils.formatRelativeTime(threeDaysAgo)).toBe('il y a 3 j');
|
|
});
|
|
|
|
it('should handle null', () => {
|
|
expect(containerUtils.formatRelativeTime(null)).toBe('—');
|
|
});
|
|
});
|
|
|
|
describe('escapeHtml', () => {
|
|
it('should escape HTML entities', () => {
|
|
expect(containerUtils.escapeHtml('<script>')).toBe('<script>');
|
|
});
|
|
|
|
it('should escape ampersands', () => {
|
|
expect(containerUtils.escapeHtml('foo & bar')).toBe('foo & bar');
|
|
});
|
|
|
|
it('should escape quotes', () => {
|
|
expect(containerUtils.escapeHtml('"quoted"')).toBe('"quoted"');
|
|
});
|
|
|
|
it('should handle empty string', () => {
|
|
expect(containerUtils.escapeHtml('')).toBe('');
|
|
});
|
|
|
|
it('should handle null', () => {
|
|
expect(containerUtils.escapeHtml(null)).toBe('');
|
|
});
|
|
});
|
|
});
|