diff --git a/packages/webview/src/component/pods/PodLogs.spec.ts b/packages/webview/src/component/pods/PodLogs.spec.ts index 66d422ac..f620a2c2 100644 --- a/packages/webview/src/component/pods/PodLogs.spec.ts +++ b/packages/webview/src/component/pods/PodLogs.spec.ts @@ -16,17 +16,17 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import { render } from '@testing-library/svelte'; -import { RemoteMocks } from '/@/tests/remote-mocks'; -import { API_POD_LOGS, type PodLogsChunk, type PodLogsApi } from '@kubernetes-dashboard/channels'; -import { StreamsMocks } from '/@/tests/stream-mocks'; -import { FakeStreamObject } from '/@/stream/util/fake-stream-object.svelte'; -import PodLogs from './PodLogs.svelte'; +import { API_POD_LOGS, type PodLogsApi, type PodLogsChunk } from '@kubernetes-dashboard/channels'; import type { V1Pod } from '@kubernetes/client-node'; -import TerminalWindow from '/@/component/terminal/TerminalWindow.svelte'; -import type { Terminal } from '@xterm/xterm'; import { EmptyScreen } from '@podman-desktop/ui-svelte'; +import { fireEvent, render } from '@testing-library/svelte'; +import type { Terminal } from '@xterm/xterm'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import PodLogs from './PodLogs.svelte'; +import TerminalWindow from '/@/component/terminal/TerminalWindow.svelte'; +import { FakeStreamObject } from '/@/stream/util/fake-stream-object.svelte'; +import { RemoteMocks } from '/@/tests/remote-mocks'; +import { StreamsMocks } from '/@/tests/stream-mocks'; vi.mock(import('../terminal/TerminalWindow.svelte')); vi.mock(import('@podman-desktop/ui-svelte')); @@ -36,6 +36,36 @@ const streamMocks = new StreamsMocks(); const streamPodLogsMock = new FakeStreamObject(); +// Helper to create a mock terminal +function createMockTerminal(): Terminal { + return { + write: vi.fn(), + dispose: vi.fn(), + clear: vi.fn(), + } as unknown as Terminal; +} + +// Helper to create a pod with the specified containers +function createPod(containerNames: string[]): V1Pod { + return { + metadata: { + name: 'podName', + namespace: 'namespace', + }, + spec: { + containers: containerNames.map(name => ({ name })), + }, + } as V1Pod; +} + +// Helper to setup terminal mock with binding +function setupTerminalMock(terminal: Terminal): void { + vi.mocked(TerminalWindow).mockImplementation((_, props) => { + props.terminal = terminal; + return {}; + }); +} + beforeEach(() => { vi.resetAllMocks(); streamMocks.reset(); @@ -45,123 +75,308 @@ beforeEach(() => { remoteMocks.mock(API_POD_LOGS, {} as unknown as PodLogsApi); }); -describe('pod with one container', async () => { - let pod: V1Pod; - beforeEach(() => { - pod = { - metadata: { - name: 'podName', +describe('PodLogs', () => { + describe('EmptyScreen display', () => { + test.each([ + { containerCount: 1, containers: ['containerName'] }, + { containerCount: 2, containers: ['cnt1', 'containerName2'] }, + ])('should display "No Log" with $containerCount container(s) when no logs received', ({ containers }) => { + const pod = createPod(containers); + render(PodLogs, { object: pod }); + + // EmptyScreen is now conditionally rendered (not hidden via prop) + expect(EmptyScreen).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + title: 'No Log', + message: `Log output of Pod ${pod.metadata?.name}`, + }), + ); + }); + + test('should hide EmptyScreen when logs are received', () => { + const pod = createPod(['containerName']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + render(PodLogs, { object: pod }); + + // EmptyScreen is rendered initially + expect(EmptyScreen).toHaveBeenCalled(); + const initialCallCount = vi.mocked(EmptyScreen).mock.calls.length; + + streamPodLogsMock.sendData({ + podName: 'podName', namespace: 'namespace', - }, - spec: { - containers: [ - { - name: 'containerName', - }, - ], - }, - } as V1Pod; - }); + containerName: 'containerName', + data: 'some logs', + }); - test('display No Logwith no logs', async () => { - render(PodLogs, { object: pod }); - expect(EmptyScreen).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - hidden: false, - }), - ); + // EmptyScreen should not be called again after logs are received + // (it's conditionally rendered with {#if noLogs}) + expect(vi.mocked(EmptyScreen).mock.calls.length).toBe(initialCallCount); + }); }); - test('write received logs to the terminal', async () => { - const mockedTerminal: Terminal = { - write: vi.fn(), - dispose: vi.fn(), - clear: vi.fn(), - } as unknown as Terminal; - vi.mocked(TerminalWindow).mockImplementation((_, props) => { - props.terminal = mockedTerminal; - return {}; + describe('log output formatting', () => { + test('should format single container logs without prefix', () => { + const pod = createPod(['containerName']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + render(PodLogs, { object: pod }); + + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'containerName', + data: 'simple log line', + }); + + expect(mockedTerminal.write).toHaveBeenCalled(); + const writtenLog = vi.mocked(mockedTerminal.write).mock.calls[0][0] as string; + expect(writtenLog).toContain('simple log line\r'); + expect(writtenLog).not.toContain('|'); }); - render(PodLogs, { object: pod }); - expect(mockedTerminal.write).not.toHaveBeenCalledWith(); - expect(mockedTerminal.clear).toHaveBeenCalled(); - expect(TerminalWindow).toHaveBeenCalled(); - streamPodLogsMock.sendData({ - podName: 'podName', - namespace: 'namespace', - containerName: 'containerName', - data: 'some logs', + test('should format multi-container logs with colored prefix and padding', () => { + const pod = createPod(['cnt1', 'containerName2']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + render(PodLogs, { object: pod }); + + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'cnt1', + data: 'log from cnt1', + }); + + expect(mockedTerminal.write).toHaveBeenCalled(); + const writtenLog = vi.mocked(mockedTerminal.write).mock.calls[0][0] as string; + expect(writtenLog).toContain('\u001b[36mcnt1\u001b[0m|log from cnt1'); }); - expect(mockedTerminal.write).toHaveBeenCalledWith('some logs\r'); - expect(EmptyScreen).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - hidden: true, - }), - ); - }); -}); -describe('pod with two containers', async () => { - let pod: V1Pod; - beforeEach(() => { - pod = { - metadata: { - name: 'podName', + test('should apply log level colorization', () => { + const pod = createPod(['containerName']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + render(PodLogs, { object: pod }); + + streamPodLogsMock.sendData({ + podName: 'podName', namespace: 'namespace', - }, - spec: { - containers: [ - { - name: 'cnt1', - }, - { - name: 'containerName2', - }, - ], - }, - } as V1Pod; - }); + containerName: 'containerName', + data: 'info: Application started', + }); + + const writtenLog = vi.mocked(mockedTerminal.write).mock.calls[0][0] as string; + // Should contain ANSI color code for info: (cyan) + expect(writtenLog).toContain('\u001b[36m'); + expect(writtenLog).toContain('\u001b[0m'); + expect(writtenLog).toContain('Application started'); + }); + + test('should apply JSON colorization when colorfulOutputType is FULL', () => { + const pod = createPod(['containerName']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); - test('display No Logwith no logs', async () => { - render(PodLogs, { object: pod }); - expect(EmptyScreen).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - hidden: false, - }), - ); + // Set localStorage to FULL mode for JSON colorization + localStorage.setItem('podlogs.terminal.colorful-output', 'full'); + + render(PodLogs, { object: pod }); + + // Send JSON log data + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'containerName', + data: '{"level":"info","message":"test","count":42}', + }); + + const writtenLog = vi.mocked(mockedTerminal.write).mock.calls[0][0] as string; + // Should contain ANSI color codes for JSON elements (braces in yellow, numbers in green) + expect(writtenLog).toContain('\u001b[33m{\u001b[0m'); // yellow brace + expect(writtenLog).toContain('\u001b[32m42\u001b[0m'); // green number + }); + + test('should handle malformed JSON gracefully', () => { + const pod = createPod(['containerName']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + render(PodLogs, { object: pod }); + + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'containerName', + data: '{"level":"info"', // incomplete JSON + }); + + const writtenLog = vi.mocked(mockedTerminal.write).mock.calls[0][0] as string; + // Should still output the log without crashing - may have ANSI codes but text preserved + expect(writtenLog).toContain('level'); + expect(writtenLog).toContain('info'); + }); + + test.each([ + { level: 'error:', color: '\u001b[31;1m', desc: 'error (bright red)' }, + { level: 'warn:', color: '\u001b[33m', desc: 'warn (yellow)' }, + { level: 'debug:', color: '\u001b[32m', desc: 'debug (green)' }, + ])('should colorize $desc log level', ({ level, color }) => { + const pod = createPod(['containerName']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + render(PodLogs, { object: pod }); + + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'containerName', + data: `${level} Some message`, + }); + + const writtenLog = vi.mocked(mockedTerminal.write).mock.calls[0][0] as string; + expect(writtenLog).toContain(color); + expect(writtenLog).toContain('\u001b[0m'); + }); + + test('should pad shorter container names to align with longest', () => { + const pod = createPod(['a', 'longername']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + render(PodLogs, { object: pod }); + + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'a', + data: 'short name log', + }); + + const writtenLog = vi.mocked(mockedTerminal.write).mock.calls[0][0] as string; + const nineSpaces = ' '; // 9 spaces to pad 'a' to length of 'longername' + expect(writtenLog).equals(nineSpaces + '\u001b[36ma\u001b[0m|short name log\r'); + }); + + test('should apply FULL colorization when set, regardless of content', async () => { + const pod = createPod(['containerName']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + const { container } = render(PodLogs, { object: pod }); + + // Open settings menu and select FULL colorization + const settingsButton = container.querySelector('button[name="terminal-settings-button"]') as HTMLButtonElement; + await fireEvent.click(settingsButton); + const select = container.querySelector('select[name="colorful-output"]') as HTMLSelectElement; + await fireEvent.change(select, { target: { value: 'full' } }); + + // Send JSON lines + const jsonLines = Array.from( + { length: 20 }, + (_, i) => `{"timestamp":"2025-11-18T10:00:0${i}Z","level":"info","message":"Line ${i + 1}"}`, + ).join('\n'); + + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'containerName', + data: jsonLines, + }); + + const calls = vi.mocked(mockedTerminal.write).mock.calls; + // First call should have JSON colorization (yellow braces) + expect(calls[0][0]).toContain('\u001b[33m{\u001b[0m'); + }); + + test('should not colorize when colorfulOutputType is NONE', async () => { + const pod = createPod(['containerName']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + const { container } = render(PodLogs, { object: pod }); + + // Open settings menu and select NONE colorization + const settingsButton = container.querySelector('button[name="terminal-settings-button"]') as HTMLButtonElement; + await fireEvent.click(settingsButton); + const select = container.querySelector('select[name="colorful-output"]') as HTMLSelectElement; + await fireEvent.change(select, { target: { value: 'none' } }); + + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'containerName', + data: 'error: Something went wrong', + }); + + const writtenLog = vi.mocked(mockedTerminal.write).mock.calls[0][0] as string; + // Should NOT contain ANSI color codes + expect(writtenLog).not.toContain('\u001b['); + expect(writtenLog).toContain('error: Something went wrong'); + }); }); - test('write received logs to the terminal v2', async () => { - const mockedTerminal: Terminal = { - write: vi.fn(), - dispose: vi.fn(), - clear: vi.fn(), - } as unknown as Terminal; - vi.mocked(TerminalWindow).mockImplementation((_, props) => { - props.terminal = mockedTerminal; - return {}; + describe('terminal initialization', () => { + test('should clear terminal and create TerminalWindow on mount', () => { + const pod = createPod(['containerName']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + render(PodLogs, { object: pod }); + + expect(mockedTerminal.clear).toHaveBeenCalled(); + expect(TerminalWindow).toHaveBeenCalled(); }); - render(PodLogs, { object: pod }); - expect(mockedTerminal.write).not.toHaveBeenCalledWith(); - expect(mockedTerminal.clear).toHaveBeenCalled(); - expect(TerminalWindow).toHaveBeenCalled(); + }); - streamPodLogsMock.sendData({ - podName: 'podName', - namespace: 'namespace', - containerName: 'cnt1', - data: 'some logs', + describe('multi-container prefix colors', () => { + test('should apply colored prefixes for each container', async () => { + const pod = createPod(['container1', 'container2', 'container3']); + const mockedTerminal = createMockTerminal(); + setupTerminalMock(mockedTerminal); + + render(PodLogs, { object: pod }); + + // Wait for all 3 container subscriptions to be set up (async onMount) + await streamPodLogsMock.waitForSubscriptions(3); + + // Send log from first container + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'container1', + data: 'log from container1', + }); + + // Send log from second container + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'container2', + data: 'log from container2', + }); + + // Send log from third container + streamPodLogsMock.sendData({ + podName: 'podName', + namespace: 'namespace', + containerName: 'container3', + data: 'log from container3', + }); + + const calls = vi.mocked(mockedTerminal.write).mock.calls; + // Each container should have a colored prefix with pipe separator + // Colors cycle: cyan, yellow, green + expect(calls[0][0]).toContain('\u001b[36mcontainer1\u001b[0m|log from container1'); + expect(calls[1][0]).toContain('\u001b[33mcontainer2\u001b[0m|log from container2'); + expect(calls[2][0]).toContain('\u001b[32mcontainer3\u001b[0m|log from container3'); }); - expect(mockedTerminal.write).toHaveBeenCalledWith(' \u001b[36mcnt1\u001b[0m|some logs\r'); - expect(EmptyScreen).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - hidden: true, - }), - ); }); }); diff --git a/packages/webview/src/component/pods/PodLogs.svelte b/packages/webview/src/component/pods/PodLogs.svelte index dda0f09d..d21a2751 100644 --- a/packages/webview/src/component/pods/PodLogs.svelte +++ b/packages/webview/src/component/pods/PodLogs.svelte @@ -1,12 +1,21 @@ -