0}
>
+ { if (el) el.indeterminate = somePodSelected; }}
+ onChange={() => {}}
+ onClick={(e) => { e.stopPropagation(); togglePod(pod, containers); }}
+ className="shrink-0"
+ aria-label={`Select all containers of ${pod}`}
+ />
{pod}
- {selectedCount > 0 ? `${selectedCount}/${containers.length} selected` : `${containers.length} ctr`}
+ {allPodSelected ? 'all' : somePodSelected ? `${selectedCount}/${containers.length}` : `${containers.length} ctr`}
{isExpanded && (
diff --git a/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx b/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx
index f0f1073..1a6c2c9 100644
--- a/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx
+++ b/frontend/src/components/common/__tests__/PodContainerSelect.test.jsx
@@ -48,4 +48,24 @@ describe('PodContainerSelect', () => {
fireEvent.click(screen.getByLabelText('Remove api-1/api'));
expect(onChange).toHaveBeenCalledWith([]);
});
+
+ it('lets the user select all containers of a pod via the pod checkbox', () => {
+ const onChange = vi.fn();
+ render(
);
+
+ fireEvent.click(screen.getByRole('combobox'));
+ fireEvent.click(screen.getByLabelText('Select all containers of web-1'));
+
+ expect(onChange).toHaveBeenCalledWith(['web-1/main', 'web-1/sidecar']);
+ });
+
+ it('clears all containers of a pod when deselecting the pod checkbox', () => {
+ const onChange = vi.fn();
+ render(
);
+
+ fireEvent.click(screen.getByRole('combobox'));
+ fireEvent.click(screen.getByLabelText('Select all containers of web-1'));
+
+ expect(onChange).toHaveBeenCalledWith([]);
+ });
});
From 8b722ffa9cff146904e9d7ef55088a8d31d5bbc4 Mon Sep 17 00:00:00 2001
From: Esysc <>
Date: Wed, 9 Sep 2026 09:35:11 +0200
Subject: [PATCH 3/9] feat: wrap long log lines in LogViewer
- Switch log rows from whitespace-nowrap to whitespace-pre-wrap break-words
so long lines wrap vertically instead of overflowing horizontally
- Hide horizontal overflow on the scroll container
- Let rows size naturally (min-height instead of fixed height) so wrapped
lines are fully visible
- Replace absolute-position virtualization with inline spacers so variable
height rows don't overlap
- Add LogViewer component tests
---
frontend/src/components/logs/LogViewer.jsx | 67 +++++++---------
.../logs/__tests__/LogViewer.test.jsx | 78 +++++++++++++++++++
2 files changed, 106 insertions(+), 39 deletions(-)
create mode 100644 frontend/src/components/logs/__tests__/LogViewer.test.jsx
diff --git a/frontend/src/components/logs/LogViewer.jsx b/frontend/src/components/logs/LogViewer.jsx
index d4a0664..ec5d8ca 100644
--- a/frontend/src/components/logs/LogViewer.jsx
+++ b/frontend/src/components/logs/LogViewer.jsx
@@ -79,7 +79,6 @@ export function LogViewer({
};
// Compute the visible slice from the current scroll position.
- const totalHeight = logs.length * ROW_HEIGHT;
const firstVisible = Math.floor(viewport.scrollTop / ROW_HEIGHT);
const visibleCount = Math.ceil(viewport.height / ROW_HEIGHT) + OVERSCAN * 2;
const start = Math.max(0, firstVisible - OVERSCAN);
@@ -111,7 +110,7 @@ export function LogViewer({
{/* Log Content */}
{logs.length === 0 ? (
@@ -120,43 +119,33 @@ export function LogViewer({
) : (
<>
-
-
- {visibleLogs.map((log, idx) => {
- const absoluteIdx = start + idx;
- return (
-
-
- [{log.pod}]
-
- {log.level && (
-
- {log.level.toUpperCase()}
-
- )}
-
- {log.message || log.text || ''}
-
-
- );
- })}
-
-
+
+ {visibleLogs.map((log, idx) => {
+ const absoluteIdx = start + idx;
+ return (
+
+
+ [{log.pod}]
+
+ {log.level && (
+
+ {log.level.toUpperCase()}
+
+ )}
+
+ {log.message || log.text || ''}
+
+
+ );
+ })}
+
>
)}
diff --git a/frontend/src/components/logs/__tests__/LogViewer.test.jsx b/frontend/src/components/logs/__tests__/LogViewer.test.jsx
new file mode 100644
index 0000000..79f7e78
--- /dev/null
+++ b/frontend/src/components/logs/__tests__/LogViewer.test.jsx
@@ -0,0 +1,78 @@
+// Generated by AI (UnitTest MCP)
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { LogViewer } from '../LogViewer';
+
+const sampleLogs = [
+ { pod: 'web-1', level: 'info', message: 'Server started on port 8080' },
+ { pod: 'web-1', level: 'error', message: 'Connection refused to database' },
+ { pod: 'api-1', level: 'warn', message: 'Slow query detected' }
+];
+
+describe('LogViewer', () => {
+ it('renders the empty state when there are no logs', () => {
+ render(
);
+ expect(screen.getByText('No logs to display yet.')).toBeInTheDocument();
+ });
+
+ it('renders log messages', () => {
+ render(
);
+ expect(screen.getByText('Server started on port 8080')).toBeInTheDocument();
+ expect(screen.getByText('Connection refused to database')).toBeInTheDocument();
+ expect(screen.getByText('Slow query detected')).toBeInTheDocument();
+ });
+
+ it('renders pod names in brackets', () => {
+ render(
);
+ expect(screen.getAllByText('[web-1]')).toHaveLength(2);
+ expect(screen.getByText('[api-1]')).toBeInTheDocument();
+ });
+
+ it('renders level badges in uppercase', () => {
+ render(
);
+ expect(screen.getByText('INFO')).toBeInTheDocument();
+ expect(screen.getByText('ERROR')).toBeInTheDocument();
+ expect(screen.getByText('WARN')).toBeInTheDocument();
+ });
+
+ it('applies wrapping classes to log rows', () => {
+ const { container } = render(
);
+ const row = container.querySelector('.whitespace-pre-wrap');
+ expect(row).not.toBeNull();
+ expect(row.className).toContain('break-words');
+ });
+
+ it('renders jump to top and jump to bottom buttons', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: /jump to top/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /jump to bottom/i })).toBeInTheDocument();
+ });
+
+ it('scrolls to bottom when jump to bottom is clicked', () => {
+ const { container } = render(
);
+ const scrollContainer = container.querySelector('.overflow-y-auto');
+ const scrollTo = vi.fn();
+ scrollContainer.scrollTo = scrollTo;
+
+ fireEvent.click(screen.getByRole('button', { name: /jump to bottom/i }));
+
+ expect(scrollTo).toHaveBeenCalledWith({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
+ });
+
+ it('scrolls to top when jump to top is clicked', () => {
+ const { container } = render(
);
+ const scrollContainer = container.querySelector('.overflow-y-auto');
+ const scrollTo = vi.fn();
+ scrollContainer.scrollTo = scrollTo;
+
+ fireEvent.click(screen.getByRole('button', { name: /jump to top/i }));
+
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
+ });
+
+ it('renders fallback text when only text is provided', () => {
+ const logs = [{ pod: 'db-1', text: 'raw log line without message' }];
+ render(
);
+ expect(screen.getByText('raw log line without message')).toBeInTheDocument();
+ });
+});
From 7ea315419aa3a9e904229fb6546ed50a0cb31c1c Mon Sep 17 00:00:00 2001
From: Esysc <>
Date: Wed, 9 Sep 2026 09:36:45 +0200
Subject: [PATCH 4/9] docs: add review_test protective gate to unit test
instructions
---
.../instructions/unittestMcp.instructions.md | 39 ++++++++++++++-----
1 file changed, 30 insertions(+), 9 deletions(-)
diff --git a/.github/instructions/unittestMcp.instructions.md b/.github/instructions/unittestMcp.instructions.md
index 3a715a9..ed2ffce 100644
--- a/.github/instructions/unittestMcp.instructions.md
+++ b/.github/instructions/unittestMcp.instructions.md
@@ -33,11 +33,12 @@ When the user asks for tests on **multiple files** (e.g., "generate tests for th
2. Read the source file
3. Write the test file. **The very first line must be the AI attribution comment** (see "AI attribution comment" rule below). Write this comment line *before* any imports or other code.
4. Run `get_errors` on the test file and fix every lint/compile error
-5. Call `run_tests` with `include_coverage=true` — tests must pass
-6. Meet the coverage target (see Coverage improvement loop)
-7. **Only now** move to the next file and repeat from step 1
+5. Call `review_test` on the test file and **resolve every `block` finding** by fixing the test. Address `warn` findings or note why they stand.
+6. Call `run_tests` with `include_coverage=true` — tests must pass
+7. Meet the coverage target (see Coverage improvement loop)
+8. **Only now** move to the next file and repeat from step 1
-If you catch yourself about to call `generate_test` a second time before step 6 has completed for the previous file, **STOP** and finish the previous file first. This applies even when the files are structurally similar — every file gets its own full cycle.
+If you catch yourself about to call `generate_test` a second time before step 7 has completed for the previous file, **STOP** and finish the previous file first. This applies even when the files are structurally similar — every file gets its own full cycle.
## MCP tools
@@ -48,6 +49,7 @@ If you catch yourself about to call `generate_test` a second time before step 6
| #tool:unittest-mcp `generate_tests_batch` | Scan a **folder** to find source files that need tests. |
| #tool:unittest-mcp `inspect_coverage` | Read existing coverage artifacts without re-running tests. Never use as validation for current changes. Pass `source_file` for per-file detail only after fresh coverage exists or when explicitly inspecting existing artifacts. |
| #tool:unittest-mcp `find_test_files` | Discover test files in a directory. |
+| #tool:unittest-mcp `review_test` | Run the local protective-test gate (static analysis) on a single test file. Call after writing/editing a test; **resolve every `block` finding** before reporting done. |
## Rules
@@ -78,8 +80,9 @@ If you catch yourself about to call `generate_test` a second time before step 6
- **Run tests + coverage / validate coverage:** Call `run_tests` with `include_coverage=true`. This is the only tool that validates the current code by executing tests.
- **Run tests + inspect coverage gaps:** Call `run_tests` with `include_coverage=true`, then call `inspect_coverage` with the same `root_dir` only if you need uncovered-line/branch detail from that fresh run.
- **Run tests with coverage (no explicit inspect request):** Call `run_tests` with `include_coverage=true`. If `coverage.met` is `false`, call `inspect_coverage` automatically. Do not offer coverage work as optional when a coverage target applies.
-- **Create tests for a single file:** Call `generate_test` **immediately as your first action**. Do NOT read the source or test file first (exception: Python — brief `file_search` to check test folder layout is OK). Detection: request mentions a specific file with extension (e.g., `user.ts`, `service.py`).
+- **Create tests for a single file:** Call `generate_test` **immediately as your first action**. Do NOT read the source or test file first (exception: Python — brief `file_search` to check test folder layout is OK). Detection: request mentions a specific file with extension (e.g., `user.ts`, `service.py`). You **may** pass `test_type` when the intent is obvious (React component/ReactView → `integration`; pure helper → `unit`), but you are **not** required to read source to decide — otherwise omit it and the server resolves `auto`.
- **Improve an existing test file:** Call `generate_test` with both `source_file_path` and `test_file_path`. Do NOT read the test file before calling. Apply additive improvements only.
+- **Review/validate a test file (protective gate):** Call `review_test` with `test_file_path` (and `source_file_path` when known). Use it after writing or editing any test file, and whenever the user asks to "review", "validate", or "check" a test. Resolve every `block` finding before reporting done.
- **Create tests for a folder/multiple files:** See the **"CRITICAL — Process multiple files ONE AT A TIME"** section at the top of this document. For folder requests, call `generate_tests_batch` first to discover the file list, then process each file sequentially through its full cycle before starting the next.
- **Inspect existing coverage only:** Call `inspect_coverage` immediately only when the user explicitly asks to read existing coverage artifacts or coverage has already been generated in the current workflow. Do not use it to answer whether current tests pass.
@@ -101,8 +104,15 @@ Use the repo's existing test location conventions (`__tests__`, `tests/`, coloca
2. Synthesize concrete test code from the guidance.
3. Create or update the test file. **Line 1 must be the AI attribution comment** — see Rule 4 for the exact text and per-language syntax. This is a required acceptance criterion, not an optional nicety.
4. Run `get_errors` and iteratively fix **all** lint/compile errors until none remain. Do this **before** proceeding.
-5. Run tests via `run_tests` with `include_coverage=true` and follow the coverage improvement loop (Section 5) until target coverage is met for the requested scope.
-6. Do **not** end with "Want me to add coverage-focused tests?" when coverage is below target — coverage completion is required before reporting done.
+5. Call `review_test` on the test file and **resolve every `block` finding** before proceeding — these are the over-mocking, coercion, and weak-assertion patterns the gate exists to stop. Treat `warn` findings as improvements to make or consciously justify.
+6. Run tests via `run_tests` with `include_coverage=true` and follow the coverage improvement loop (Section 5) until target coverage is met for the requested scope.
+7. Do **not** end with "Want me to add coverage-focused tests?" when coverage is below target — coverage completion is required before reporting done.
+
+**Protective-test acceptance criteria (satisfy these before reporting done):**
+- Render the **real** first-party component/ReactView with its real children, hooks, and context — do **not** mock first-party components, hooks, context, or pure helpers.
+- Mock **only** external/platform/network boundaries — network/data/ARM, host-platform services & hooks resolved from a host channel/registry/DI container absent in the test env (symptom: a "No service registered"/missing-provider error), jsdom-unsupported browser APIs, and nondeterminism (time/random/uuid). **First-party app code is never a boundary.** If a platform/UI module cannot run in jsdom, use a faithful double that preserves labels, roles, values, and interactions (never `() => null` or stub `
`s).
+- **Reuse shared mocks; hoist recurring ones.** Before adding a boundary mock, check whether the repo already declares it globally (Jest `setupFilesAfterEnv`/`testSetup`, pytest `conftest.py`, .NET base fixtures) and existing test helpers, and reuse those; if the same boundary mock recurs across files, hoist it to that shared setup instead of redeclaring it per file.
+- Assert exact values with `toStrictEqual` — one assertion on the whole object, not many single-field asserts (it also catches type / `undefined` / sparse-array mismatches `toEqual` misses); do **not** use `toMatchObject`/`*Containing` as the primary assertion (one unavoidably-varying field, e.g. a timestamped name, may use `expect.stringContaining`); no `as any`/`as unknown as`; assert the value the code returns, not that a function "was called"; keep tests isolated (no mock/variable shared across tests); each test must fail if the behavior under test breaks; the test title must match what it asserts.
**Path heuristics:** To find the test file from source, examine the repo's existing test structure (`__tests__`, `tests/`, colocated). To find the source from a test file, remove `.test.`/`.spec.`, move out of `__tests__` (JS/TS), `tests/` (Python), or remove `Tests` suffix (C#).
@@ -119,7 +129,7 @@ Use the repo's existing test location conventions (`__tests__`, `tests/`, coloca
### 5. Coverage improvement loop
-Do NOT report task completion until coverage for the requested scope meets the configured target (from MCP settings/tool response).
+Do NOT report task completion until **all** hold for the requested scope: (a) coverage meets the configured target (from MCP settings/tool response), (b) `review_test` reports **zero `block` findings** on every test file you created or changed, and (c) you have answered `review_test`'s self-review checklist. Coverage percentage alone is not sufficient — a high-coverage test that mocks first-party code or asserts nothing still fails the gate.
**Important:** When testing a single file, start with `run_tests` using `include_coverage=true`, `scope='file'`, and an explicit `test_pattern` whenever possible. If the result still needs uncovered-line detail, then call `inspect_coverage` with `source_file` for that same source file. Do not call `inspect_coverage` first when validating current changes.
@@ -151,7 +161,18 @@ REPEAT (max 5 iterations):
- When `source_file` is provided, LCOV is preferred (has line-level detail) over Istanbul summary.
- Interpret the per-file data directly to identify low-coverage files or target specific uncovered paths.
-### 7. General rules
+### 7. `review_test` — protective-test gate
+
+`review_test` runs a deterministic static analysis over a single test file and returns structured findings. It does **not** execute tests; run it **in addition to** `run_tests`, after the file is lint-clean.
+
+- **When:** after writing or editing any test file, and whenever the user asks to review/validate/check a test.
+- **Inputs:** `test_file_path` (required), `source_file_path` (optional but improves results), `framework`/`language` (optional).
+- **Findings:** each has a `severity` — `block` (must fix), `warn` (should fix), or `info` — plus a `ruleId`, `message`, and often a `suggestedFix`. A finding blocks only when the pattern destroys the test's protection: mocking the module under test (`no-mock-system-under-test`), an `as any`/`as unknown as` cast on the value being asserted or the value a mock returns, and a partial matcher that is a test's only value assertion. The same patterns elsewhere — setup casts, casts used to exercise invalid input, and partial matchers pinning call arguments — are reported as `warn`. Remaining rules cover literal `as T` data coercion, was-called-only assertions, render-without-interaction, and pointless constant snapshots.
+- **Self-review checklist:** `review_test` also returns one question per semantic dimension the static rules cannot judge (assertion strength, mocking hygiene, encapsulation, structure, determinism, setup-to-assertion ratio, what NOT to test). You are already the reviewing model — answer each against the test you wrote and fix any failure before reporting done.
+- **Acceptance:** **resolve every `block` finding** before reporting done by fixing the test. Do not add protective-test suppression comments to generated tests.
+- `review_test` is complementary to `run_tests`: a test can pass and still fail the gate (e.g., it mocks the system under test). Both must be green before you report done.
+
+### 8. General rules
- If 3 consecutive test runs fail, re-read config and retry once.
- If source path inference fails, ask the user.
From cc6eb07bc90d047447f80d50001f95dd5f1293cc Mon Sep 17 00:00:00 2001
From: Esysc <>
Date: Wed, 9 Sep 2026 10:09:47 +0200
Subject: [PATCH 5/9] fix: measure real row heights in LogViewer virtualization
Addresses review feedback: the fixed ROW_HEIGHT virtualization drifted when
lines wrapped. Now each rendered row is measured via a callback ref and its
real height feeds prefix sums that drive the visible slice and spacers, so
wrapped rows contribute their actual size to the scroll geometry.
- Key measurements by log id (stable across filter changes)
- Binary-search the visible slice over measured prefix sums
- Reset measurements when the underlying log set changes
- Re-pin to the tail after measurements change total height while following
- Add test verifying measured heights are reflected in scroll geometry
---
frontend/src/components/logs/LogViewer.jsx | 89 ++++++++++++++++---
.../logs/__tests__/LogViewer.test.jsx | 41 +++++++++
2 files changed, 120 insertions(+), 10 deletions(-)
diff --git a/frontend/src/components/logs/LogViewer.jsx b/frontend/src/components/logs/LogViewer.jsx
index ec5d8ca..a2a2d69 100644
--- a/frontend/src/components/logs/LogViewer.jsx
+++ b/frontend/src/components/logs/LogViewer.jsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState, useMemo } from 'react';
import PropTypes from 'prop-types';
const ROW_HEIGHT = 20;
@@ -18,6 +18,10 @@ export function LogViewer({
const containerRef = useRef(null);
const followedRef = useRef(true);
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 });
+ // Measured row heights keyed by log id, so wrapped rows contribute their
+ // real height to the virtualized geometry. Stored in state so render-phase
+ // computations (prefix sums) can read them without touching a ref.
+ const [rowHeights, setRowHeights] = useState(() => new Map());
// Track scroll position and viewport size. Mark whether user is pinned to
// the bottom so incoming logs only scroll if they're still at the tail.
@@ -45,6 +49,17 @@ export function LogViewer({
};
}, [autoScroll]);
+ // Reset measurements when the underlying log set changes (e.g. filter
+ // changes or a fresh stream) so stale heights never apply to new content.
+ // Adjusting state during render is the React-sanctioned pattern for
+ // deriving state from a prop change.
+ const [prevFirstId, setPrevFirstId] = useState(null);
+ const firstId = logs[0]?.id;
+ if (firstId !== prevFirstId) {
+ setPrevFirstId(firstId);
+ setRowHeights(new Map());
+ }
+
// Jump to the tail whenever new logs arrive while pinned to the bottom.
const prevLengthRef = useRef(0);
useEffect(() => {
@@ -57,6 +72,14 @@ export function LogViewer({
followedRef.current = true;
}, [logs]);
+ // Re-pin to the tail after measurements change the total height while the
+ // user is still following the stream.
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el || !followedRef.current) return;
+ el.scrollTop = el.scrollHeight;
+ }, [rowHeights]);
+
const scrollToTop = () => {
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
};
@@ -78,12 +101,57 @@ export function LogViewer({
return colors[level?.toLowerCase()] || 'text-gray-300';
};
- // Compute the visible slice from the current scroll position.
- const firstVisible = Math.floor(viewport.scrollTop / ROW_HEIGHT);
- const visibleCount = Math.ceil(viewport.height / ROW_HEIGHT) + OVERSCAN * 2;
- const start = Math.max(0, firstVisible - OVERSCAN);
- const end = Math.min(logs.length, start + visibleCount);
- const visibleLogs = logs.slice(start, end);
+ // Record a rendered row's real height so wrapped lines contribute their
+ // actual size to the virtualized geometry.
+ const measureRow = (el, id) => {
+ if (!el) return;
+ const h = el.offsetHeight;
+ setRowHeights((prev) => {
+ if (prev.get(id) === h) return prev;
+ const next = new Map(prev);
+ next.set(id, h);
+ return next;
+ });
+ };
+
+ // Prefix sums over measured heights (fallback to ROW_HEIGHT for rows not
+ // yet rendered/measured) so wrapped rows contribute their real height.
+ const prefix = useMemo(() => {
+ const arr = new Float64Array(logs.length + 1);
+ for (let i = 0; i < logs.length; i++) {
+ arr[i + 1] = arr[i] + (rowHeights.get(logs[i].id) ?? ROW_HEIGHT);
+ }
+ return arr;
+ }, [logs, rowHeights]);
+
+ const totalHeight = prefix[logs.length];
+
+ // Locate the visible slice: binary search for the first row at/after the
+ // scroll offset, then walk forward until the viewport + overscan budget.
+ const visSlice = useMemo(() => {
+ if (logs.length === 0) {
+ return { start: 0, end: 0, padTop: 0, padBottom: 0 };
+ }
+ let lo = 0;
+ let hi = logs.length;
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >> 1;
+ if (prefix[mid] <= viewport.scrollTop) lo = mid;
+ else hi = mid - 1;
+ }
+ const start = Math.max(0, lo - OVERSCAN);
+ const budget = viewport.height + OVERSCAN * ROW_HEIGHT;
+ let end = start;
+ while (end < logs.length && prefix[end + 1] - prefix[start] <= budget) end++;
+ return {
+ start,
+ end,
+ padTop: prefix[start],
+ padBottom: totalHeight - prefix[end]
+ };
+ }, [prefix, totalHeight, viewport.scrollTop, viewport.height, logs.length]);
+
+ const visibleLogs = logs.slice(visSlice.start, visSlice.end);
return (
@@ -119,12 +187,13 @@ export function LogViewer({
) : (
<>
-
+
{visibleLogs.map((log, idx) => {
- const absoluteIdx = start + idx;
+ const absoluteIdx = visSlice.start + idx;
return (
measureRow(el, log.id ?? absoluteIdx)}
className="flex gap-2 hover:bg-gray-900/50 whitespace-pre-wrap break-words"
style={{ minHeight: ROW_HEIGHT, lineHeight: '20px' }}
>
@@ -145,7 +214,7 @@ export function LogViewer({
);
})}
-
+
>
)}
diff --git a/frontend/src/components/logs/__tests__/LogViewer.test.jsx b/frontend/src/components/logs/__tests__/LogViewer.test.jsx
index 79f7e78..1674c49 100644
--- a/frontend/src/components/logs/__tests__/LogViewer.test.jsx
+++ b/frontend/src/components/logs/__tests__/LogViewer.test.jsx
@@ -75,4 +75,45 @@ describe('LogViewer', () => {
render(
);
expect(screen.getByText('raw log line without message')).toBeInTheDocument();
});
+
+ it('accounts for measured row heights in the scroll geometry', () => {
+ const logs = Array.from({ length: 30 }, (_, i) => ({
+ id: i,
+ pod: 'web-1',
+ level: 'info',
+ message: `line ${i}`
+ }));
+
+ // jsdom reports no layout, so give rows a default height and simulate one
+ // wrapped row being taller than ROW_HEIGHT.
+ const proto = HTMLElement.prototype;
+ const original = Object.getOwnPropertyDescriptor(proto, 'offsetHeight');
+ Object.defineProperty(proto, 'offsetHeight', {
+ configurable: true,
+ get() { return 20; }
+ });
+
+ try {
+ const { container, rerender } = render(
);
+ const rows = container.querySelectorAll('.whitespace-pre-wrap');
+ Object.defineProperty(rows[0], 'offsetHeight', { value: 60, configurable: true });
+
+ rerender(
);
+
+ const spacers = container.querySelectorAll('[aria-hidden="true"]');
+ const topSpacer = parseFloat(spacers[0].style.height);
+ const bottomSpacer = parseFloat(spacers[1].style.height);
+ const renderedRows = container.querySelectorAll('.whitespace-pre-wrap');
+ const renderedHeight = Array.from(renderedRows).reduce(
+ (sum, row) => sum + row.offsetHeight,
+ 0
+ );
+
+ // 29 rows at 20px + 1 wrapped row at 60px = 640px total scroll height.
+ expect(topSpacer + renderedHeight + bottomSpacer).toBe(640);
+ } finally {
+ if (original) Object.defineProperty(proto, 'offsetHeight', original);
+ else delete proto.offsetHeight;
+ }
+ });
});
From b9aa6587b401e2ed9ae7d99fb9ebb0c425032356 Mon Sep 17 00:00:00 2001
From: Esysc <>
Date: Wed, 9 Sep 2026 10:54:23 +0200
Subject: [PATCH 6/9] fix: merge StreamConfig updates into a single onChange
call
Addresses review feedback: the empty-selection and single whole-pod branches
called updateConfig twice in a row. Since each call derives from the same
render-time config and forwards to setConfig, React applied only the last
call, so container was never actually cleared. Merge each pair into one
onChange call, matching the multi-select branch.
- Add StreamConfig tests covering clear, multi-select, and single-select
---
.../src/components/stream/StreamConfig.jsx | 6 +-
.../stream/__tests__/StreamConfig.test.jsx | 69 +++++++++++++++++++
2 files changed, 71 insertions(+), 4 deletions(-)
create mode 100644 frontend/src/components/stream/__tests__/StreamConfig.test.jsx
diff --git a/frontend/src/components/stream/StreamConfig.jsx b/frontend/src/components/stream/StreamConfig.jsx
index 4571b0f..ae6509a 100644
--- a/frontend/src/components/stream/StreamConfig.jsx
+++ b/frontend/src/components/stream/StreamConfig.jsx
@@ -44,15 +44,13 @@ const StreamConfigComponent = ({
selected={selected}
onChange={(newSelected) => {
if (newSelected.length === 0) {
- updateConfig('container', '');
- updateConfig('query', '.');
+ onChange({ ...config, container: '', query: '.' });
return;
}
// A single whole-pod selection keeps using the plain "query" field
// for backward compatibility with saved configs and manual regex entry.
if (newSelected.length === 1 && !newSelected[0].includes('/')) {
- updateConfig('container', '');
- updateConfig('query', newSelected[0]);
+ onChange({ ...config, container: '', query: newSelected[0] });
return;
}
onChange({ ...config, container: newSelected.join(','), query: '.' });
diff --git a/frontend/src/components/stream/__tests__/StreamConfig.test.jsx b/frontend/src/components/stream/__tests__/StreamConfig.test.jsx
new file mode 100644
index 0000000..96f188e
--- /dev/null
+++ b/frontend/src/components/stream/__tests__/StreamConfig.test.jsx
@@ -0,0 +1,69 @@
+// Generated by AI (UnitTest MCP)
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { StreamConfig } from '../StreamConfig';
+
+const autocomplete = {
+ namespaces: ['default'],
+ options: [
+ { pod: 'web-1', containers: ['main', 'sidecar'] },
+ { pod: 'api-1', containers: ['api'] },
+ ],
+};
+
+const baseConfig = {
+ namespace: 'default',
+ query: '.',
+ container: '',
+};
+
+describe('StreamConfig', () => {
+ it('clears container and resets query when the selection is emptied', () => {
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByLabelText('Remove web-1/main'));
+
+ expect(onChange).toHaveBeenCalledWith({ ...baseConfig, container: '', query: '.' });
+ });
+
+ it('stores multiple selected containers as a comma-separated container value', () => {
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('combobox'));
+ fireEvent.click(screen.getByLabelText('Select all containers of web-1'));
+
+ expect(onChange).toHaveBeenCalledWith({
+ ...baseConfig,
+ container: 'web-1/main,web-1/sidecar',
+ query: '.',
+ });
+ });
+
+ it('stores a single container selection in the container field', () => {
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('combobox'));
+ fireEvent.click(screen.getByText('api-1'));
+ fireEvent.mouseDown(screen.getByText('api'));
+
+ expect(onChange).toHaveBeenCalledWith({
+ ...baseConfig,
+ container: 'api-1/api',
+ query: '.',
+ });
+ });
+});
From abc4c3b3e56ba6c8aa073e657e1d0fd92f4bee16 Mon Sep 17 00:00:00 2001
From: Esysc <>
Date: Wed, 9 Sep 2026 10:54:44 +0200
Subject: [PATCH 7/9] test: cover comma-separated multi-container filters in
logUtils
Addresses review feedback: the new comma-separated multi-token behavior in
compileLogFilters was untested. Add cases asserting that multiple
pod/container tokens (and mixed plain tokens) build an alternation that
matches any of the containers.
---
frontend/src/utils/__tests__/logUtils.test.js | 24 +++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/frontend/src/utils/__tests__/logUtils.test.js b/frontend/src/utils/__tests__/logUtils.test.js
index 9dbd2b2..02a0240 100644
--- a/frontend/src/utils/__tests__/logUtils.test.js
+++ b/frontend/src/utils/__tests__/logUtils.test.js
@@ -95,6 +95,30 @@ describe('compileLogFilters', () => {
const filters = compileLogFilters({ query: '.', container: 'api-pod/api' });
expect(filterLogs(sampleLogs, filters)).toHaveLength(2);
});
+
+ it('matches any of multiple comma-separated pod/container tokens', () => {
+ const logs = [
+ { message: 'a', pod: 'api-pod', container: 'api' },
+ { message: 'b', pod: 'web-pod', container: 'nginx' },
+ { message: 'c', pod: 'db-pod', container: 'postgres' },
+ ];
+ const filters = compileLogFilters({ query: '.', container: 'api-pod/api,web-pod/nginx' });
+ const result = filterLogs(logs, filters);
+ expect(result).toHaveLength(2);
+ expect(result.map(log => log.container).sort()).toEqual(['api', 'nginx']);
+ });
+
+ it('handles mixed pod/container and plain container tokens', () => {
+ const logs = [
+ { message: 'a', pod: 'api-pod', container: 'api' },
+ { message: 'b', pod: 'web-pod', container: 'nginx' },
+ { message: 'c', pod: 'db-pod', container: 'postgres' },
+ ];
+ const filters = compileLogFilters({ query: '.', container: 'api-pod/api,postgres' });
+ const result = filterLogs(logs, filters);
+ expect(result).toHaveLength(2);
+ expect(result.map(log => log.container).sort()).toEqual(['api', 'postgres']);
+ });
});
describe('buildPodColorMap', () => {
From ec04ba750d09cf761fd6f579f1a3559ddac3a431 Mon Sep 17 00:00:00 2001
From: Esysc <>
Date: Wed, 9 Sep 2026 10:55:59 +0200
Subject: [PATCH 8/9] docs(changelog): document multi-container monitoring and
word wrap
---
CHANGELOG.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9cbc94..dabb695 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,10 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Select multiple containers across different pods at once without reconnecting
+- Select an entire pod (all of its containers) with a single checkbox, including a partial (indeterminate) selection state
+- Word-wrap long log lines vertically instead of overflowing horizontally
+
### Changed
+- Pod/container picker is now multi-select with removable chips
+- Changing the selected containers automatically reconnects the stream
+- Log viewer measures real row heights so wrapped lines scroll accurately
+
### Fixed
+- Clearing the container selection now actually clears the stored container value
+- Virtualized log rendering accounts for variable-height (wrapped) rows, preventing scroll drift and skipped rows
+
## [0.6.0] - 2026-08-10
### Added
From 7455e3f045eb3101e9836b5261060006e5104ef3 Mon Sep 17 00:00:00 2001
From: Esysc <>
Date: Wed, 9 Sep 2026 10:56:43 +0200
Subject: [PATCH 9/9] docs(changelog): date 0.7.0 release for today
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dabb695..249b1b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [0.7.0] - 2026-09-09
### Added