diff --git a/frontend/src/components/StructuredLogViewer.tsx b/frontend/src/components/StructuredLogViewer.tsx
index a2ee33ee..d1db7df1 100644
--- a/frontend/src/components/StructuredLogViewer.tsx
+++ b/frontend/src/components/StructuredLogViewer.tsx
@@ -68,6 +68,13 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
useEffect(() => { followingRef.current = following; }, [following]);
useEffect(() => {
+ // Reset state accumulated from the previous stack before connecting.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setRows([]);
+ rowIdRef.current = 0;
+ setFollowing(true);
+ followingRef.current = true;
+
const cleanStackName = stackName.replace(/\.(yml|yaml)$/, '');
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
diff --git a/frontend/src/components/__tests__/StructuredLogViewer.test.tsx b/frontend/src/components/__tests__/StructuredLogViewer.test.tsx
new file mode 100644
index 00000000..478c5369
--- /dev/null
+++ b/frontend/src/components/__tests__/StructuredLogViewer.test.tsx
@@ -0,0 +1,181 @@
+/**
+ * Unit tests for StructuredLogViewer's log-row lifecycle, specifically that
+ * switching stacks clears the old stack's committed rows, closes the old
+ * WebSocket, resets auto-follow, and preserves the level filter.
+ */
+import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import StructuredLogViewer from '../StructuredLogViewer';
+
+class MockWS {
+ static instances: MockWS[] = [];
+ url: string;
+ onopen: (() => void) | null = null;
+ onmessage: ((e: { data: string }) => void) | null = null;
+ onclose: (() => void) | null = null;
+ onerror: ((e?: unknown) => void) | null = null;
+ send = vi.fn();
+ close = vi.fn();
+ constructor(url: string) { this.url = url; MockWS.instances.push(this); }
+ static reset() { MockWS.instances = []; }
+}
+
+beforeEach(() => {
+ MockWS.reset();
+ vi.stubGlobal('WebSocket', MockWS);
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+ cb(0);
+ return 0;
+ });
+ localStorage.setItem('sencho-active-node', '');
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ localStorage.removeItem('sencho-active-node');
+ vi.clearAllMocks();
+});
+
+describe('StructuredLogViewer', () => {
+ it('renders initial empty state and builds the correct WebSocket URL', () => {
+ const { container } = render();
+ expect(container.textContent).toContain('Waiting for log output');
+ expect(MockWS.instances).toHaveLength(1);
+ expect(MockWS.instances[0].url).toContain('/api/stacks/test-stack/logs');
+ });
+
+ it('renders log lines with timestamp, level badge, and message', async () => {
+ const { container } = render();
+ await act(async () => {
+ MockWS.instances[0].onopen?.();
+ MockWS.instances[0].onmessage?.({ data: '2025-01-01T12:00:00Z ERROR something-failed' });
+ });
+
+ expect(container.textContent).toContain('something-failed');
+ // Level badge is lowercase in the DOM (CSS text-transform handles visual).
+ expect(container.textContent).toContain('err');
+
+ // Timestamp: computed in local time.
+ const ts = new Date('2025-01-01T12:00:00Z');
+ const pad = (n: number) => String(n).padStart(2, '0');
+ const expectedTs = `${pad(ts.getHours())}:${pad(ts.getMinutes())}:${pad(ts.getSeconds())}`;
+ expect(container.textContent).toContain(expectedTs);
+ });
+
+ it('clears committed rows when the stack changes', async () => {
+ const { rerender, container } = render();
+ await act(async () => {
+ MockWS.instances[0].onopen?.();
+ MockWS.instances[0].onmessage?.({ data: '2025-01-01T10:00:00Z INFO only-in-stack-a\n' });
+ });
+
+ expect(container.textContent).toContain('only-in-stack-a');
+
+ rerender();
+ await act(async () => {
+ MockWS.instances[1].onopen?.();
+ MockWS.instances[1].onmessage?.({ data: '2025-01-01T11:00:00Z ERROR only-in-stack-b\n' });
+ });
+
+ expect(container.textContent).not.toContain('only-in-stack-a');
+ expect(container.textContent).toContain('only-in-stack-b');
+ });
+
+ it('closes the old WebSocket on stack switch', () => {
+ const { rerender } = render();
+ const oldWs = MockWS.instances[0];
+ expect(oldWs.close).not.toHaveBeenCalled();
+
+ rerender();
+ expect(oldWs.close).toHaveBeenCalled();
+ });
+
+ it('downloads only the current stack rows after a switch', async () => {
+ let capturedBlob: Blob | null = null;
+ vi.stubGlobal('URL', {
+ ...URL,
+ createObjectURL: vi.fn((blob: Blob) => {
+ capturedBlob = blob;
+ return 'blob:fake';
+ }),
+ revokeObjectURL: vi.fn(),
+ });
+
+ const { rerender } = render();
+ await act(async () => {
+ MockWS.instances[0].onopen?.();
+ MockWS.instances[0].onmessage?.({ data: '2025-01-01T10:00:00Z INFO from-stack-a\n' });
+ });
+
+ rerender();
+ await act(async () => {
+ MockWS.instances[1].onopen?.();
+ MockWS.instances[1].onmessage?.({ data: '2025-01-01T11:00:00Z ERROR from-stack-b\n' });
+ });
+
+ const downloadBtn = screen.getByLabelText('Download logs');
+ await userEvent.click(downloadBtn);
+
+ expect(capturedBlob).not.toBeNull();
+ const text = await capturedBlob!.text();
+ expect(text).toContain('from-stack-b');
+ expect(text).not.toContain('from-stack-a');
+ });
+
+ it('preserves the level filter across stack switches', async () => {
+ const { rerender, container } = render();
+
+ // Click the "warn" filter button.
+ await userEvent.click(screen.getByText('warn'));
+
+ rerender();
+ await act(async () => {
+ MockWS.instances[1].onopen?.();
+ MockWS.instances[1].onmessage?.({
+ data: '2025-01-01T10:00:00Z INFO should-be-hidden\n2025-01-01T11:00:00Z WARNING should-be-visible\n',
+ });
+ });
+
+ expect(container.textContent).not.toContain('should-be-hidden');
+ expect(container.textContent).toContain('should-be-visible');
+ });
+
+ it('resets auto-follow when the stack changes', async () => {
+ const { rerender, container } = render();
+ await act(async () => {
+ MockWS.instances[0].onopen?.();
+ });
+
+ const scrollContainer = document.querySelector('.overflow-y-auto') as HTMLElement;
+ expect(scrollContainer).not.toBeNull();
+
+ Object.defineProperty(scrollContainer, 'scrollHeight', { configurable: true, value: 500 });
+ Object.defineProperty(scrollContainer, 'clientHeight', { configurable: true, value: 200 });
+ scrollContainer.scrollTop = 100;
+ await act(async () => {
+ fireEvent.scroll(scrollContainer);
+ });
+
+ expect(container.textContent).toContain('resume follow');
+
+ rerender();
+ await act(async () => {
+ MockWS.instances[1].onopen?.();
+ });
+
+ expect(container.textContent).toContain('following');
+ });
+
+ it('strips .yml and .yaml suffixes from the WebSocket URL', () => {
+ const { rerender } = render();
+ expect(MockWS.instances[0].url).toContain('/api/stacks/my-stack/logs');
+ expect(MockWS.instances[0].url).not.toContain('.yml');
+
+ rerender();
+ expect(MockWS.instances[1].url).toContain('/api/stacks/another-stack/logs');
+ expect(MockWS.instances[1].url).not.toContain('.yaml');
+ });
+});