mirror of
https://github.com/unraid/api.git
synced 2026-01-10 02:30:02 -06:00
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Introduced Docker management UI components: Overview, Logs, Console, Preview, and Edit. - Added responsive Card/Detail layouts with grouping, bulk actions, and tabs. - New UnraidToaster component and global toaster configuration. - Component auto-mounting improved with async loading and multi-selector support. - UI/UX - Overhauled theme system (light/dark tokens, primary/orange accents) and added theme variants. - Header OS version now includes integrated changelog modal. - Registration displays warning states; multiple visual polish updates. - API - CPU load now includes percentGuest and percentSteal metrics. - Chores - Migrated web app to Vite; updated artifacts and manifests. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: mdatelle <mike@datelle.net> Co-authored-by: Michael Datelle <mdatelle@icloud.com>
426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
import { nextTick } from 'vue';
|
|
import { useQuery } from '@vue/apollo-composable';
|
|
import { flushPromises, mount } from '@vue/test-utils';
|
|
|
|
import { AnsiUp } from 'ansi_up';
|
|
import DOMPurify from 'isomorphic-dompurify';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import SingleLogViewer from '~/components/Logs/SingleLogViewer.vue';
|
|
import { createMockLogFileQuery, createMockUseQuery } from '../../helpers/apollo-mocks';
|
|
|
|
// Mock the UI components
|
|
vi.mock('@unraid/ui', () => ({
|
|
Button: { template: '<button><slot /></button>' },
|
|
Tooltip: { template: '<div><slot /></div>' },
|
|
TooltipContent: { template: '<div><slot /></div>' },
|
|
TooltipProvider: { template: '<div><slot /></div>' },
|
|
TooltipTrigger: { template: '<div><slot /></div>' },
|
|
}));
|
|
|
|
// Mock the GraphQL query
|
|
vi.mock('@vue/apollo-composable', () => ({
|
|
useApolloClient: vi.fn(() => ({
|
|
client: {
|
|
query: vi.fn(),
|
|
},
|
|
})),
|
|
useQuery: vi.fn(),
|
|
}));
|
|
|
|
// Mock the theme store
|
|
vi.mock('~/store/theme', () => ({
|
|
useThemeStore: vi.fn(() => ({
|
|
darkMode: false,
|
|
})),
|
|
}));
|
|
|
|
describe('SingleLogViewer - ANSI Color Support', () => {
|
|
let ansiConverter: AnsiUp;
|
|
|
|
beforeEach(() => {
|
|
// Create a fresh converter instance for each test
|
|
ansiConverter = new AnsiUp();
|
|
ansiConverter.use_classes = true;
|
|
ansiConverter.escape_html = true;
|
|
});
|
|
|
|
describe('ANSI to HTML Conversion', () => {
|
|
it('should convert ANSI color codes to CSS classes', () => {
|
|
const testCases = [
|
|
{
|
|
input: '\x1b[31mRed text\x1b[0m',
|
|
expected: '<span class="ansi-red-fg">Red text</span>',
|
|
description: 'red foreground',
|
|
},
|
|
{
|
|
input: '\x1b[32mGreen text\x1b[0m',
|
|
expected: '<span class="ansi-green-fg">Green text</span>',
|
|
description: 'green foreground',
|
|
},
|
|
{
|
|
input: '\x1b[33mYellow text\x1b[0m',
|
|
expected: '<span class="ansi-yellow-fg">Yellow text</span>',
|
|
description: 'yellow foreground',
|
|
},
|
|
{
|
|
input: '\x1b[34mBlue text\x1b[0m',
|
|
expected: '<span class="ansi-blue-fg">Blue text</span>',
|
|
description: 'blue foreground',
|
|
},
|
|
{
|
|
input: '\x1b[91mBright red\x1b[0m',
|
|
expected: '<span class="ansi-bright-red-fg">Bright red</span>',
|
|
description: 'bright red foreground',
|
|
},
|
|
{
|
|
input: '\x1b[41mRed background\x1b[0m',
|
|
expected: '<span class="ansi-red-bg">Red background</span>',
|
|
description: 'red background',
|
|
},
|
|
{
|
|
input: '\x1b[1mBold text\x1b[0m',
|
|
expected: '<span style="font-weight:bold">Bold text</span>',
|
|
description: 'bold text (ansi_up uses inline style for bold)',
|
|
},
|
|
{
|
|
input: '\x1b[3mItalic text\x1b[0m',
|
|
expected: '<span style="font-style:italic">Italic text</span>',
|
|
description: 'italic text (ansi_up uses inline style for italic)',
|
|
},
|
|
{
|
|
input: '\x1b[4mUnderlined text\x1b[0m',
|
|
expected: '<span style="text-decoration:underline">Underlined text</span>',
|
|
description: 'underlined text (ansi_up uses inline style for underline)',
|
|
},
|
|
];
|
|
|
|
testCases.forEach(({ input, expected, description }) => {
|
|
const result = ansiConverter.ansi_to_html(input);
|
|
expect(result, `Failed for ${description}`).toBe(expected);
|
|
});
|
|
});
|
|
|
|
it('should handle multiple ANSI codes in one string', () => {
|
|
const input = '\x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m';
|
|
const expected =
|
|
'<span class="ansi-red-fg">Red</span> <span class="ansi-green-fg">Green</span> <span class="ansi-blue-fg">Blue</span>';
|
|
const result = ansiConverter.ansi_to_html(input);
|
|
expect(result).toBe(expected);
|
|
});
|
|
|
|
it('should handle nested ANSI codes', () => {
|
|
const input = '\x1b[1m\x1b[31mBold Red Text\x1b[0m';
|
|
const result = ansiConverter.ansi_to_html(input);
|
|
// ansi_up uses inline style for bold
|
|
expect(result).toContain('font-weight:bold');
|
|
expect(result).toContain('ansi-red-fg');
|
|
});
|
|
|
|
it('should escape HTML entities for security', () => {
|
|
const input = '\x1b[31m<img src=x onerror=alert(1)>\x1b[0m';
|
|
const result = ansiConverter.ansi_to_html(input);
|
|
expect(result).not.toContain('<img');
|
|
expect(result).toContain('<img');
|
|
});
|
|
});
|
|
|
|
describe('DOMPurify Sanitization', () => {
|
|
it('should preserve CSS classes after sanitization', () => {
|
|
const htmlWithClasses = '<span class="ansi-red-fg">Red text</span>';
|
|
const sanitized = DOMPurify.sanitize(htmlWithClasses, {
|
|
ALLOWED_TAGS: ['span', 'br'],
|
|
ALLOWED_ATTR: ['class'],
|
|
});
|
|
expect(sanitized).toBe(htmlWithClasses);
|
|
});
|
|
|
|
it('should remove inline styles when configured', () => {
|
|
const htmlWithStyles = '<span style="color: red;">Red text</span>';
|
|
const sanitized = DOMPurify.sanitize(htmlWithStyles, {
|
|
ALLOWED_TAGS: ['span', 'br'],
|
|
ALLOWED_ATTR: ['class'], // Note: 'style' is not allowed
|
|
});
|
|
expect(sanitized).toBe('<span>Red text</span>');
|
|
});
|
|
|
|
it('should remove dangerous tags while preserving safe content', () => {
|
|
const dangerous = '<span class="ansi-red-fg">Safe</span><img src="x" onerror="alert(1)">';
|
|
const sanitized = DOMPurify.sanitize(dangerous, {
|
|
ALLOWED_TAGS: ['span', 'br'],
|
|
ALLOWED_ATTR: ['class'],
|
|
});
|
|
expect(sanitized).toBe('<span class="ansi-red-fg">Safe</span>');
|
|
});
|
|
|
|
it('should handle complex nested structures', () => {
|
|
const complex = '<span class="ansi-bold"><span class="ansi-red-fg">Bold Red</span></span>';
|
|
const sanitized = DOMPurify.sanitize(complex, {
|
|
ALLOWED_TAGS: ['span', 'br'],
|
|
ALLOWED_ATTR: ['class'],
|
|
});
|
|
expect(sanitized).toBe(complex);
|
|
});
|
|
});
|
|
|
|
describe('CSS Class Definitions', () => {
|
|
it('should have CSS rules for all standard ANSI colors', async () => {
|
|
// Mock useQuery to return empty data for this test
|
|
// @ts-expect-error Mock implementation for testing
|
|
vi.mocked(useQuery).mockReturnValue(createMockUseQuery());
|
|
|
|
const wrapper = mount(SingleLogViewer, {
|
|
props: {
|
|
logFilePath: '/test/log.txt',
|
|
lineCount: 100,
|
|
autoScroll: false,
|
|
},
|
|
global: {
|
|
stubs: {
|
|
Button: true,
|
|
Tooltip: true,
|
|
TooltipContent: true,
|
|
TooltipProvider: true,
|
|
TooltipTrigger: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Wait for component to mount
|
|
await nextTick();
|
|
|
|
// Check that the component mounts without errors
|
|
expect(wrapper.exists()).toBe(true);
|
|
|
|
wrapper.unmount();
|
|
});
|
|
});
|
|
|
|
describe('Integration Tests', () => {
|
|
beforeEach(() => {
|
|
// Reset mocks before each test
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should properly render ANSI colored log content', async () => {
|
|
// Create mock data
|
|
const content = '\x1b[31m[ERROR]\x1b[0m Failed to connect\n\x1b[32m[SUCCESS]\x1b[0m Connected';
|
|
const mockQuery = createMockLogFileQuery(content, 2, 1);
|
|
|
|
// Mock useQuery to return our data
|
|
// @ts-expect-error Mock implementation for testing
|
|
vi.mocked(useQuery).mockReturnValue(mockQuery);
|
|
|
|
const wrapper = mount(SingleLogViewer, {
|
|
props: {
|
|
logFilePath: '/test/log.txt',
|
|
lineCount: 100,
|
|
autoScroll: false,
|
|
},
|
|
});
|
|
|
|
// Wait for the component to mount and process initial data
|
|
await wrapper.vm.$nextTick();
|
|
|
|
// Trigger the watcher by modifying the result
|
|
// @ts-expect-error Accessing mock properties
|
|
if (mockQuery.result.value) {
|
|
// @ts-expect-error Modifying mock properties
|
|
mockQuery.result.value = {
|
|
logFile: {
|
|
content,
|
|
totalLines: 2,
|
|
startLine: 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Wait for watchers to process
|
|
await wrapper.vm.$nextTick();
|
|
await flushPromises();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
// Get the pre element that contains the log content
|
|
const preElement = wrapper.find('pre.hljs');
|
|
expect(preElement.exists()).toBe(true);
|
|
|
|
// Check that the rendered HTML contains the CSS classes
|
|
const html = preElement.html();
|
|
if (!html.includes('ansi-red-fg')) {
|
|
console.log('Pre element HTML:', html);
|
|
console.log('Full wrapper HTML:', wrapper.html());
|
|
}
|
|
expect(html).toContain('ansi-red-fg');
|
|
expect(html).toContain('[ERROR]');
|
|
expect(html).toContain('ansi-green-fg');
|
|
expect(html).toContain('[SUCCESS]');
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('should handle log content with mixed ANSI and plain text', async () => {
|
|
const content = 'Plain text \x1b[33mWarning\x1b[0m more plain text';
|
|
const mockQuery = createMockLogFileQuery(content, 1, 1);
|
|
// @ts-expect-error Mock implementation for testing
|
|
vi.mocked(useQuery).mockReturnValue(mockQuery);
|
|
|
|
const wrapper = mount(SingleLogViewer, {
|
|
props: {
|
|
logFilePath: '/test/log.txt',
|
|
lineCount: 100,
|
|
autoScroll: false,
|
|
},
|
|
});
|
|
|
|
// Wait for mount and trigger the watcher
|
|
await wrapper.vm.$nextTick();
|
|
|
|
// @ts-expect-error Accessing mock properties
|
|
if (mockQuery.result.value) {
|
|
// @ts-expect-error Modifying mock properties
|
|
mockQuery.result.value = {
|
|
logFile: {
|
|
content,
|
|
totalLines: 1,
|
|
startLine: 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Wait for processing
|
|
await wrapper.vm.$nextTick();
|
|
await flushPromises();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
const preElement = wrapper.find('pre.hljs');
|
|
expect(preElement.exists()).toBe(true);
|
|
|
|
const html = preElement.html();
|
|
expect(html).toContain('Plain text');
|
|
expect(html).toContain('ansi-yellow-fg');
|
|
expect(html).toContain('Warning');
|
|
expect(html).toContain('more plain text');
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it('should apply client-side filtering while preserving ANSI colors', async () => {
|
|
const content =
|
|
'\x1b[31m[ERROR]\x1b[0m Connection failed\n\x1b[32m[INFO]\x1b[0m Connected\n\x1b[31m[ERROR]\x1b[0m Timeout';
|
|
const mockQuery = createMockLogFileQuery(content, 3, 1);
|
|
// @ts-expect-error Mock implementation for testing
|
|
vi.mocked(useQuery).mockReturnValue(mockQuery);
|
|
|
|
const wrapper = mount(SingleLogViewer, {
|
|
props: {
|
|
logFilePath: '/test/log.txt',
|
|
lineCount: 100,
|
|
autoScroll: false,
|
|
clientFilter: 'ERROR',
|
|
},
|
|
});
|
|
|
|
// Wait for mount and trigger the watcher
|
|
await wrapper.vm.$nextTick();
|
|
|
|
// @ts-expect-error Accessing mock properties
|
|
if (mockQuery.result.value) {
|
|
// @ts-expect-error Modifying mock properties
|
|
mockQuery.result.value = {
|
|
logFile: {
|
|
content,
|
|
totalLines: 3,
|
|
startLine: 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Wait for processing
|
|
await wrapper.vm.$nextTick();
|
|
await flushPromises();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
const preElement = wrapper.find('pre.hljs');
|
|
expect(preElement.exists()).toBe(true);
|
|
|
|
const html = preElement.html();
|
|
// Should contain ERROR lines with red color
|
|
expect(html).toContain('ansi-red-fg');
|
|
expect(html).toContain('ERROR');
|
|
// Should not contain INFO line (due to filter)
|
|
expect(html).not.toContain('INFO');
|
|
expect(html).not.toContain('ansi-green-fg');
|
|
|
|
wrapper.unmount();
|
|
});
|
|
});
|
|
|
|
describe('Performance Tests', () => {
|
|
it('should handle large amounts of ANSI colored text efficiently', () => {
|
|
const lines = 1000;
|
|
const largeInput = Array(lines)
|
|
.fill(null)
|
|
.map((_, i) => {
|
|
const colors = ['31', '32', '33', '34', '35', '36'];
|
|
const color = colors[i % colors.length];
|
|
return `\x1b[${color}mLine ${i}: Some log message with color\x1b[0m`;
|
|
})
|
|
.join('\n');
|
|
|
|
const result = ansiConverter.ansi_to_html(largeInput);
|
|
|
|
// Should contain the expected number of color spans
|
|
const colorMatches = result.match(/class="ansi-/g);
|
|
expect(colorMatches).toHaveLength(lines);
|
|
});
|
|
|
|
it('should efficiently sanitize large HTML with many CSS classes', () => {
|
|
const lines = 1000;
|
|
const largeHtml = Array(lines)
|
|
.fill(null)
|
|
.map((_, i) => `<span class="ansi-red-fg">Line ${i}</span>`)
|
|
.join('\n');
|
|
|
|
const sanitized = DOMPurify.sanitize(largeHtml, {
|
|
ALLOWED_TAGS: ['span', 'br'],
|
|
ALLOWED_ATTR: ['class'],
|
|
});
|
|
|
|
// Should preserve all spans
|
|
const spanMatches = sanitized.match(/<span/g);
|
|
expect(spanMatches).toHaveLength(lines);
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle empty input gracefully', () => {
|
|
const result = ansiConverter.ansi_to_html('');
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
it('should handle input with no ANSI codes', () => {
|
|
const plainText = 'This is plain text without any colors';
|
|
const result = ansiConverter.ansi_to_html(plainText);
|
|
expect(result).toBe(plainText);
|
|
});
|
|
|
|
it('should handle malformed ANSI codes', () => {
|
|
const malformed = '\x1b[999mInvalid color code\x1b[0m';
|
|
// Should not throw an error
|
|
expect(() => ansiConverter.ansi_to_html(malformed)).not.toThrow();
|
|
});
|
|
|
|
it('should handle incomplete ANSI sequences', () => {
|
|
const incomplete = '\x1b[31mRed text without reset';
|
|
const result = ansiConverter.ansi_to_html(incomplete);
|
|
expect(result).toContain('ansi-red-fg');
|
|
});
|
|
|
|
it('should handle ANSI codes at the beginning and end of lines', () => {
|
|
const input = '\x1b[31mStart\nMiddle\nEnd\x1b[0m';
|
|
const result = ansiConverter.ansi_to_html(input);
|
|
expect(result).toContain('ansi-red-fg');
|
|
});
|
|
});
|
|
});
|