Files
api/web/__test__/components/Logs/SingleLogViewer.test.ts
Eli Bosley af5ca11860 Feat/vue (#1655)
<!-- 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>
2025-09-08 10:04:49 -04:00

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('&lt;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');
});
});
});