/** * Tests for components/logpanel.js * * Verifies fixes for the "blank log panel on first load" bug: * 1. When the logs API returns [], loadLogsForResearch must not * overwrite live entries that arrived via socket events during * the fetch. * 2. dataset.loaded must NOT be set after an empty API response, so * a future toggle (or pre-fetch) re-fetches. * 3. dataset.loaded IS set after a successful non-empty fetch, so * subsequent toggles don't re-fetch. * 4. When the API returns entries while live socket entries already * exist, the fetched batch is merged via addLogEntryToPanel * (which dedupes) instead of clobbering with innerHTML. */ let logPanel; let emptyCounts; beforeAll(async () => { // logpanel.js destructures window.LdrLogHelpers at IIFE-time. await import('@js/utils/log-helpers.js'); // log-helpers.js is an IIFE that only exposes its surface via // window.LdrLogHelpers — it has no module exports, so we can't // destructure from the import. Wire the helper through window // so the beforeEach below can call it. emptyCounts = window.LdrLogHelpers.emptyCounts; // Stubs the IIFE expects to find on window. window.escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, ''); window.URLBuilder = { researchLogs: (id) => `/api/research/${id}/logs`, historyLogCount: (id) => `/api/research/${id}/log_count`, }; // Pretend we're on a research page so the auto-initialize path runs. // Spread doesn't copy non-enumerable props off the Location prototype, so // explicitly include `search` and `hash` — initializeLogPanel reads them // for its debug-flag check (logpanel.js:321). Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, pathname: '/', search: '', hash: '' }, }); await import('@js/components/logpanel.js'); logPanel = window.logPanel; }); beforeEach(() => { // Build the minimal DOM the panel queries by id. document.body.innerHTML = `
`; // Reset shared state between tests. if (window._logPanelState) { window._logPanelState.queuedLogs = []; window._logPanelState.expanded = false; window._logPanelState.logCount = 0; window._logPanelState.counts = emptyCounts(); window._logPanelState.currentFilter = 'all'; window._logPanelState.autoscroll = true; // Force re-binding of click handlers in tests that call initialize(); // tests that only exercise loadLogs/addLog don't rely on this. window._logPanelState.initialized = false; window._logPanelState.connectedResearchId = null; } }); /** * Build the full log-panel DOM (filter buttons, autoscroll button, etc.) * inside an optional research-page wrapper, then call logPanel.initialize so * the click handlers from initializeLogPanel get bound. * * @param {Object} opts * @param {'progress'|'results'|null} [opts.page] - Wrap the panel in a * research page container so initializeLogPanel sees a research page. * 'progress' makes the toggle handler take the new CSS-flex branch from * PR #3851; 'results' takes the legacy autoscroll-hide branch. * @param {string} [opts.researchId] - Passed through to initialize(); * each call uses a fresh ID to bypass the same-ID early return. */ function setupPanelDom({ page = 'progress', researchId } = {}) { // Reset the document body, then optionally wrap the panel in a research // page container so initializeLogPanel sees a research page. document.body.innerHTML = `
`; if (page === 'progress' || page === 'results') { const wrapper = document.createElement('div'); wrapper.id = page === 'progress' ? 'research-progress' : 'research-results'; const panel = document.querySelector('.ldr-collapsible-log-panel'); document.body.insertBefore(wrapper, panel); wrapper.appendChild(panel); } // Each test uses a fresh research ID so initialize() doesn't short-circuit // on the same-ID check at logpanel.js:44. const rid = researchId || `rid-${Math.random().toString(36).slice(2)}`; logPanel.initialize(rid); } function makeLiveEntry(message, type = 'info') { // Mimic what addLogEntryToPanel produces in the DOM. The optional // `type` argument lets the per-category prune tests seed entries // of a specific category into the container directly. const entry = document.createElement('div'); entry.className = 'ldr-console-log-entry'; entry.dataset.logId = `live-${message}`; entry.dataset.logType = type; const span = document.createElement('span'); span.className = 'ldr-log-message'; span.textContent = message; entry.appendChild(span); return entry; } describe('loadLogsForResearch — empty API response', () => { it('does not clobber live socket-driven entries when API returns []', async () => { const container = document.getElementById('console-log-container'); container.appendChild(makeLiveEntry('socket-arrived-A')); container.appendChild(makeLiveEntry('socket-arrived-B')); // Simulate empty API response. globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([]) }) ); await logPanel.loadLogs('test-research-1'); // Live entries must still be in the DOM. const entries = container.querySelectorAll('.ldr-console-log-entry'); expect(entries.length).toBe(2); // The empty-state placeholder must NOT have replaced them. expect(container.querySelector('.ldr-empty-log-message')).toBeNull(); }); it('writes the empty placeholder when the container has no live entries', async () => { globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([]) }) ); await logPanel.loadLogs('test-research-2'); const container = document.getElementById('console-log-container'); expect(container.querySelector('.ldr-empty-log-message')).not.toBeNull(); }); it('does not set dataset.loaded after an empty response', async () => { const panelContent = document.getElementById('log-panel-content'); // Pretend a previous successful load set this. delete panelContent.dataset.loaded; globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([]) }) ); await logPanel.loadLogs('test-research-3'); // Empty response must leave dataset.loaded unset so a retry can happen. expect(panelContent.dataset.loaded).toBeUndefined(); }); }); describe('loadLogsForResearch — non-empty API response', () => { it('sets dataset.loaded after a successful non-empty fetch', async () => { const panelContent = document.getElementById('log-panel-content'); globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([ { timestamp: new Date().toISOString(), message: 'hello', log_type: 'info' }, ]), }) ); await logPanel.loadLogs('test-research-4'); expect(panelContent.dataset.loaded).toBe('true'); }); it('merges via addLogEntryToPanel when live entries already exist', async () => { const container = document.getElementById('console-log-container'); container.appendChild(makeLiveEntry('live-only')); globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([ { timestamp: new Date().toISOString(), message: 'fetched', log_type: 'info' }, ]), }) ); await logPanel.loadLogs('test-research-5'); // The live entry must survive (not overwritten by innerHTML reset). const messages = Array.from( container.querySelectorAll('.ldr-log-message') ).map((el) => el.textContent); expect(messages).toContain('live-only'); }); }); describe('loadLogsForResearch — in-flight deduplication', () => { it('skips a duplicate fetch while one is already in flight', async () => { // Hold the first fetch open until we explicitly resolve it, so the // second call lands while the first is still pending. let resolveFirst; const firstResponse = new Promise((resolve) => { resolveFirst = resolve; }); const fetchSpy = vi.fn(() => firstResponse); globalThis.fetch = fetchSpy; const firstCall = logPanel.loadLogs('test-research-dedup'); // While first is in flight, kick off a second call — it must be a no-op. const secondCall = logPanel.loadLogs('test-research-dedup'); await secondCall; // Only one fetch should have happened so far. expect(fetchSpy).toHaveBeenCalledTimes(1); // Resolve the first call so it can finish cleanly. resolveFirst({ json: () => Promise.resolve([]) }); await firstCall; }); it('clears the in-flight flag after completion so future calls can run', async () => { globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([]) }) ); await logPanel.loadLogs('test-research-cleared-1'); // Second call after the first completes must execute (not be deduped). await logPanel.loadLogs('test-research-cleared-2'); expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); it('clears dataset.loading even when fetch rejects', async () => { // If a refactor drops the `finally` block that clears // dataset.loading, a single network error would permanently lock // the panel into "skipping duplicate" mode for the rest of the // page lifetime — exactly the silent-blank-panel class of bug // this PR is fixing. const panelContent = document.getElementById('log-panel-content'); globalThis.fetch = vi.fn(() => Promise.reject(new Error('net down'))); await logPanel.loadLogs('test-research-throws'); expect(panelContent.dataset.loading).toBeUndefined(); // A follow-up call must actually fire fetch again, not be deduped. globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([]) }) ); await logPanel.loadLogs('test-research-throws'); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); }); describe('addConsoleLog — placeholder removal', () => { it('removes the empty-state placeholder when adding a live entry', () => { const container = document.getElementById('console-log-container'); container.innerHTML = '
No logs available.
'; // Force the panel into an expanded state so addConsoleLog goes // straight to addLogEntryToPanel rather than queuing. window._logPanelState.expanded = true; logPanel.addLog('first live log', 'info'); // Placeholder is gone, real entry took its place. expect(container.querySelector('.ldr-empty-log-message')).toBeNull(); expect(container.querySelector('.ldr-console-log-entry')).not.toBeNull(); }); }); // Ordering invariants for the #2610 fix (PR #3850). The log panel uses // `flex-direction: column-reverse` so DOM end == visual top. happy-dom // does not render CSS, so these tests assert on DOM order directly -- // the contract being locked in is "DOM order is chronological // oldest -> newest", and the CSS flip is taken as given. // // Mirrors of source-side constants (kept inline because both are `const` // inside the IIFE in logpanel.js and not exported). If either source // constant changes, update here: // MAX_LOG_ENTRIES src/local_deep_research/web/static/js/components/logpanel.js:21 // DEDUP_WINDOW src/local_deep_research/web/static/js/components/logpanel.js // (the `existingEntries.length - 10` lower bound in // addLogEntryToPanel's dedup-by-content scan) const MAX_LOG_ENTRIES = 500; const DEDUP_WINDOW = 10; describe('addLog / loadLogs — ordering invariants', () => { function messageTextsInDomOrder(container) { return Array.from(container.querySelectorAll('.ldr-log-message')).map( (el) => el.textContent ); } beforeEach(() => { // Drive entries through addLogEntryToPanel rather than the queue. window._logPanelState.expanded = true; // Fake all timers — addLogEntryToPanel queues a setTimeout(autoscroll, 0) // per call. Under parallel vitest load the prune test (501 calls) // would pile up 501 real-timer tasks and time out at 5s. The tests // here assert DOM contents, not scroll behavior, so leaving the // autoscroll setTimeouts queued (unflushed) is intentional. vi.useFakeTimers(); }); afterEach(() => { // Drop any queued autoscroll setTimeouts before switching back to // real timers, so they don't leak into a subsequent test. vi.clearAllTimers(); vi.useRealTimers(); // Vitest isolates globals between files, but ordering changes // within this file should not expose latent reliance on a prior // test's fetch mock. delete globalThis.fetch; }); it('inserts live entries in chronological DOM order (newest at DOM end)', () => { const container = document.getElementById('console-log-container'); vi.setSystemTime(new Date('2026-05-08T12:00:00Z')); logPanel.addLog('first', 'info'); vi.setSystemTime(new Date('2026-05-08T12:00:01Z')); logPanel.addLog('second', 'info'); vi.setSystemTime(new Date('2026-05-08T12:00:02Z')); logPanel.addLog('third', 'info'); expect(messageTextsInDomOrder(container)).toEqual([ 'first', 'second', 'third', ]); // data-log-time-ms must be monotonically non-decreasing oldest -> newest. const times = Array.from( container.querySelectorAll('.ldr-console-log-entry') ).map((el) => Number(el.dataset.logTimeMs)); expect(times).toEqual([...times].sort((a, b) => a - b)); }); it('merges late-arriving older history into chronological position', async () => { const container = document.getElementById('console-log-container'); // Two live entries arrive first (recent times). vi.setSystemTime(new Date('2026-05-08T12:00:00Z')); logPanel.addLog('live-A', 'info'); vi.setSystemTime(new Date('2026-05-08T12:00:01Z')); logPanel.addLog('live-B', 'info'); // Then loadLogs returns one historical entry whose timestamp is // older than both live entries. The merge path routes through // addLogEntryToPanel, which must insert it before live-A. globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([ { timestamp: '2026-05-08T11:59:00Z', message: 'historical', log_type: 'info', }, ]), }) ); await logPanel.loadLogs('test-research-ordering-merge'); expect(messageTextsInDomOrder(container)).toEqual([ 'historical', 'live-A', 'live-B', ]); }); // Explicit timeout: this test is genuinely heavy, not hung. 501 inserts // each run addLogEntryToPanel's full-container querySelectorAll scans // (dedup window, chronological insert position, prune check) — O(n²) // DOM work that takes ~2.6-2.9s in happy-dom on a dev machine, so the // 5s vitest default leaves no headroom under parallel CI load. #4304 // already fixed the *timer* pileup (501 real setTimeout(autoscroll, 0) // tasks) by faking all timers; what remains is honest compute, so a // bigger budget is the right lever now — unlike #4299, which proposed // it while the timer bug was still the root cause. The test must fill // to the real MAX_LOG_ENTRIES cap to exercise the prune, so the work // can't be reduced without weakening the assertion. // Note: this is the all-info case. With all-info inserts the new // per-category prune ordering is observationally identical to the // old head-drop behavior (info is dropped first, so the oldest info // entry is what comes off the head). Per-category prune ordering -- // the part the priority order actually changes -- is exercised by // the `pruneToCap -- per-category ordered prune` describe block // lower in this file. it('drops the oldest info entry when the cap is exceeded by info-only inserts', { timeout: 20000 }, () => { const container = document.getElementById('console-log-container'); // One insert over the cap. The live-insert prune in // addLogEntryToPanel must drop the oldest entry, not the newest. const totalInserts = MAX_LOG_ENTRIES + 1; const base = new Date('2026-05-08T12:00:00Z').getTime(); for (let i = 0; i < totalInserts; i++) { vi.setSystemTime(new Date(base + i * 1000)); logPanel.addLog(`msg-${i}`, 'info'); } const entries = container.querySelectorAll('.ldr-console-log-entry'); expect(entries.length).toBe(MAX_LOG_ENTRIES); const messages = messageTextsInDomOrder(container); // Oldest (msg-0) was pruned; msg-1 is now the oldest in DOM, // msg-${totalInserts - 1} is the newest. expect(messages).not.toContain('msg-0'); expect(messages[0]).toBe('msg-1'); expect(messages[messages.length - 1]).toBe(`msg-${totalInserts - 1}`); }); it('dedupes a duplicate inside the 10-newest window', () => { const container = document.getElementById('console-log-container'); vi.setSystemTime(new Date('2026-05-08T12:00:00Z')); logPanel.addLog('dup-msg', 'info'); vi.setSystemTime(new Date('2026-05-08T12:00:01Z')); logPanel.addLog('dup-msg', 'info'); // Only one DOM entry, with a duplicate-counter badge. const entries = container.querySelectorAll('.ldr-console-log-entry'); expect(entries.length).toBe(1); expect(entries[0].dataset.counter).toBe('2'); const badge = entries[0].querySelector('.ldr-duplicate-counter'); expect(badge).not.toBeNull(); expect(badge.textContent).toBe('(2×)'); }); it('does not dedupe a duplicate that has fallen outside the 10-newest window', () => { const container = document.getElementById('console-log-container'); // DEDUP_WINDOW + 1 distinct messages: msg-0 ends up at DOM index 0 // (oldest), msg-${DEDUP_WINDOW} at the newest end. The // dedup-by-content scan only covers the DEDUP_WINDOW newest, so // msg-0 is one slot outside it. const distinctCount = DEDUP_WINDOW + 1; const base = new Date('2026-05-08T12:00:00Z').getTime(); for (let i = 0; i < distinctCount; i++) { vi.setSystemTime(new Date(base + i * 1000)); logPanel.addLog(`msg-${i}`, 'info'); } // Re-add msg-0 with a fresh timestamp so dedup-by-id misses // (different id -> ${timestamp}-${hash}). Dedup-by-content would // catch it only if msg-0 were in the DEDUP_WINDOW newest. vi.setSystemTime(new Date(base + distinctCount * 1000)); logPanel.addLog('msg-0', 'info'); const entries = container.querySelectorAll('.ldr-console-log-entry'); expect(entries.length).toBe(distinctCount + 1); // The two msg-0 entries sit at the chronological extremes. const messages = messageTextsInDomOrder(container); expect(messages[0]).toBe('msg-0'); expect(messages[messages.length - 1]).toBe('msg-0'); }); }); // Content-dedup applies only to info entries. Warning / error / milestone // entries are diagnostic — collapsing repeated retries or repeated failures // into a single `(N×)` counter strips the recency signal (you can no longer // tell *when* the last failure happened) and hides forward progress. The // id-based dedup upstream still catches exact retransmits with the same id // for those categories; this describe block locks in the content-dedup // bypass for non-info. describe('addLog — content dedup bypass for non-info categories', () => { let container; function messageTextsInDomOrder(c) { return Array.from(c.querySelectorAll('.ldr-log-message')).map( (el) => el.textContent ); } beforeEach(() => { container = document.getElementById('console-log-container'); window._logPanelState.expanded = true; vi.useFakeTimers(); }); afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); }); it('inserts two identical warning entries without collapsing them', () => { vi.setSystemTime(new Date('2026-05-08T12:00:00Z')); logPanel.addLog('retry-failed', 'warning'); vi.setSystemTime(new Date('2026-05-08T12:00:01Z')); logPanel.addLog('retry-failed', 'warning'); const entries = container.querySelectorAll('.ldr-console-log-entry'); expect(entries.length).toBe(2); // Each entry stays at the initial counter of 1 (no increment). // The `(2×)` badge is the dedup-collapse signal — it must be // absent because the bypass path doesn't touch the dedup scan. entries.forEach((entry) => { expect(entry.dataset.counter).toBe('1'); expect(entry.querySelector('.ldr-duplicate-counter')).toBeNull(); }); const messages = messageTextsInDomOrder(container); expect(messages).toEqual(['retry-failed', 'retry-failed']); }); it('inserts two identical error entries without collapsing them', () => { vi.setSystemTime(new Date('2026-05-08T12:00:00Z')); logPanel.addLog('lookup failed', 'error'); vi.setSystemTime(new Date('2026-05-08T12:00:01Z')); logPanel.addLog('lookup failed', 'error'); const entries = container.querySelectorAll('.ldr-console-log-entry'); expect(entries.length).toBe(2); entries.forEach((entry) => { expect(entry.dataset.counter).toBe('1'); expect(entry.querySelector('.ldr-duplicate-counter')).toBeNull(); }); const messages = messageTextsInDomOrder(container); expect(messages).toEqual(['lookup failed', 'lookup failed']); }); it('still collapses repeated info entries into a (N×) badge', () => { // Regression guard: the dedup bypass must not accidentally disable // dedup for info entries, which is what motivated the loop in the // first place. vi.setSystemTime(new Date('2026-05-08T12:00:00Z')); logPanel.addLog('heartbeat', 'info'); vi.setSystemTime(new Date('2026-05-08T12:00:01Z')); logPanel.addLog('heartbeat', 'info'); const entries = container.querySelectorAll('.ldr-console-log-entry'); expect(entries.length).toBe(1); // Counter increments to 2 because the second addLog took the dedup // branch and incremented the existing entry's counter. expect(entries[0].dataset.counter).toBe('2'); const badge = entries[0].querySelector('.ldr-duplicate-counter'); expect(badge).not.toBeNull(); expect(badge.textContent).toBe('(2×)'); }); it('still inserts each milestone entry independently', () => { // Pre-existing contract: identical milestones already had a // `logType !== 'milestone'` guard inside the dedup scan, so they // already rendered twice. The new bypass keeps that behavior. vi.setSystemTime(new Date('2026-05-08T12:00:00Z')); logPanel.addLog('research started', 'milestone'); vi.setSystemTime(new Date('2026-05-08T12:00:01Z')); logPanel.addLog('research started', 'milestone'); const entries = container.querySelectorAll('.ldr-console-log-entry'); expect(entries.length).toBe(2); entries.forEach((entry) => { // No counter increment, no `(2×)` badge. expect(entry.dataset.counter).toBe('1'); expect(entry.querySelector('.ldr-duplicate-counter')).toBeNull(); }); }); it('bypass holds even when warnings share a recent message with a preceding info', () => { // Defensive: the dedup scan keys on both message *and* logType, so // a warning with the same text as a recent info would not have // been caught by the old scan either. This locks in the explicit // no-dedup for non-info regardless of what is in the recent window. vi.setSystemTime(new Date('2026-05-08T12:00:00Z')); logPanel.addLog('connection refused', 'info'); vi.setSystemTime(new Date('2026-05-08T12:00:01Z')); logPanel.addLog('connection refused', 'warning'); vi.setSystemTime(new Date('2026-05-08T12:00:02Z')); logPanel.addLog('connection refused', 'warning'); // All three survive; neither warning has the dedup badge. const entries = container.querySelectorAll('.ldr-console-log-entry'); expect(entries.length).toBe(3); const infoEntry = entries[0]; expect(infoEntry.dataset.logType).toBe('info'); expect(infoEntry.dataset.counter).toBe('1'); expect(infoEntry.querySelector('.ldr-duplicate-counter')).toBeNull(); const warningEntries = [entries[1], entries[2]]; warningEntries.forEach((entry) => { expect(entry.dataset.logType).toBe('warning'); expect(entry.dataset.counter).toBe('1'); expect(entry.querySelector('.ldr-duplicate-counter')).toBeNull(); }); }); }); /** * Toggle handler tests — locks in the contract introduced by PR #3851. * * The fix replaced a JS height calc with a CSS flex layout scoped to * `#research-progress`. The toggle handler now toggles a `.ldr-expanded` * class and clears any inline `style.height` on the progress page; on * non-progress pages it falls back to `style.height = 'auto'` and hides the * autoscroll button. These tests guard against a future refactor silently * re-introducing a JS-driven height formula or dropping the autoscroll-hide * branch. * * Note: CSS layout (no scrollbar at viewport heights, panel fills available * space) cannot be validated in happy-dom and remains a manual browser check. */ describe('toggle handler — progress page', () => { it('toggles .ldr-expanded on/off across two clicks', () => { setupPanelDom({ page: 'progress' }); const panel = document.querySelector('.ldr-collapsible-log-panel'); const toggle = document.getElementById('log-panel-toggle'); toggle.click(); expect(panel.classList.contains('ldr-expanded')).toBe(true); toggle.click(); expect(panel.classList.contains('ldr-expanded')).toBe(false); }); it('clears any inline style.height when expanding', () => { setupPanelDom({ page: 'progress' }); const panel = document.querySelector('.ldr-collapsible-log-panel'); // Simulate a stale inline height left over from the old JS-calc code // path. Expanding on a progress page must clear it so the new CSS // flex layout can size the panel. panel.style.height = '500px'; document.getElementById('log-panel-toggle').click(); expect(panel.style.height).toBe(''); }); it('enables autoscroll on first expand', () => { setupPanelDom({ page: 'progress' }); document.getElementById('log-panel-toggle').click(); // The handler sets autoscroll=false then calls toggleAutoscroll(), // which flips it to true. Locking this in guards against a refactor // that drops the toggleAutoscroll() call. expect(window._logPanelState.autoscroll).toBe(true); }); }); describe('toggle handler — non-progress page', () => { it('does not add .ldr-expanded when there is no #research-progress', () => { setupPanelDom({ page: 'results' }); const panel = document.querySelector('.ldr-collapsible-log-panel'); document.getElementById('log-panel-toggle').click(); expect(panel.classList.contains('ldr-expanded')).toBe(false); }); it('sets style.height to auto on expand', () => { setupPanelDom({ page: 'results' }); const panel = document.querySelector('.ldr-collapsible-log-panel'); document.getElementById('log-panel-toggle').click(); expect(panel.style.height).toBe('auto'); }); it('hides the autoscroll button on expand', () => { setupPanelDom({ page: 'results' }); document.getElementById('log-panel-toggle').click(); const autoscrollButton = document.getElementById('log-autoscroll-button'); expect(autoscrollButton.style.display).toBe('none'); }); }); describe('filter buttons', () => { // Helper: locate a filter button via its inner count span instead of // its full textContent (which now includes the live count badge). const findButton = (filterType) => document.querySelector( `.ldr-log-filter .ldr-filter-buttons button:has([data-filter-count="${filterType}"])` ); it('moves .ldr-selected to the clicked button', () => { setupPanelDom({ page: 'progress' }); const allBtn = findButton('all'); const errorsBtn = findButton('error'); expect(allBtn.classList.contains('ldr-selected')).toBe(true); errorsBtn.click(); expect(allBtn.classList.contains('ldr-selected')).toBe(false); expect(errorsBtn.classList.contains('ldr-selected')).toBe(true); }); it('updates _logPanelState.currentFilter to the clicked type', () => { setupPanelDom({ page: 'progress' }); const errorsBtn = findButton('error'); errorsBtn.click(); // The data-filter-type attribute is the canonical source of // truth for the click handler now (so we don't have to parse // the button label, which includes the count badge text). expect(window._logPanelState.currentFilter).toBe('error'); }); it('hides entries whose log type does not match the filter', () => { setupPanelDom({ page: 'progress' }); // Seed the container with one info and one error entry so we can // verify the filter actually toggles display on each. window._logPanelState.expanded = true; logPanel.addLog('an info message', 'info'); logPanel.addLog('an error message', 'error'); const container = document.getElementById('console-log-container'); const entries = container.querySelectorAll('.ldr-console-log-entry'); expect(entries.length).toBe(2); const errorsBtn = findButton('error'); errorsBtn.click(); const infoEntry = container.querySelector('.ldr-log-info'); const errorEntry = container.querySelector('.ldr-log-error'); expect(infoEntry.style.display).toBe('none'); expect(errorEntry.style.display).toBe(''); }); it("falls back to the button's label text (excluding the badge) when data-filter-type is missing", () => { // Regression guard for a code-review catch on #4898: the legacy // fallback used the full button.textContent, which now contains // the live count badge (e.g. "Errors 0"). That's not a valid // filter type and would slip through checkLogVisibility's // default (show everything). The fallback must read only the // label, so a legacy button labelled "Errors" still resolves // to the singular/plural-accepted 'errors' filter and works // as before. // // We build the DOM by hand here (instead of setupPanelDom) so // we can inject the legacy button *before* initialize() binds // click handlers — appending a button afterwards leaves it // un-handled and would exercise a never-reached path. document.body.innerHTML = `
`; const wrapper = document.createElement('div'); wrapper.id = 'research-progress'; const panel = document.querySelector('.ldr-collapsible-log-panel'); document.body.insertBefore(wrapper, panel); wrapper.appendChild(panel); // Inject the legacy button BEFORE initialize() runs so the // click handler in initializeLogPanel binds to it. const legacy = document.createElement('button'); legacy.className = 'ldr-small-btn'; // No data-filter-type — simulating a button that survived a // partial migration. The count badge is still inside. legacy.innerHTML = 'Errors 0'; document.querySelector('.ldr-filter-buttons').appendChild(legacy); logPanel.initialize(`rid-legacy-${Math.random().toString(36).slice(2)}`); legacy.click(); // 'errors' (plural) is accepted by checkLogVisibility as an // alias for 'error' (utils/log-helpers.js). The point is that // currentFilter must be exactly the label text — not // 'errors 0', which would slip through every case and // silently fall through to show-all. expect(window._logPanelState.currentFilter).toBe('errors'); // And filterLogsByType must have applied the visible state // change, not silently fall through to show-all. expect(legacy.classList.contains('ldr-selected')).toBe(true); }); }); describe('queued logs', () => { it('queues logs added while collapsed when no toggle handler is bound', () => { // No initialize() call → no auto-expand handler, so the synthetic // toggle.click() inside addConsoleLog is a no-op and the queue // accumulates. This is the path that triggers when logs arrive // before the panel finishes initializing. const container = document.getElementById('console-log-container'); logPanel.addLog('queued before init', 'info'); expect(window._logPanelState.queuedLogs.length).toBe(1); expect(container.querySelector('.ldr-console-log-entry')).toBeNull(); }); it('drains the queue when the panel is expanded', () => { setupPanelDom({ page: 'progress' }); // Pre-seed a queued entry, simulating a log that arrived while the // panel was still collapsed. window._logPanelState.queuedLogs.push({ id: 'pre-queued-1', time: new Date().toISOString(), message: 'pre-queued', type: 'info', metadata: { type: 'info' }, }); expect(window._logPanelState.queuedLogs.length).toBe(1); document.getElementById('log-panel-toggle').click(); expect(window._logPanelState.queuedLogs.length).toBe(0); const container = document.getElementById('console-log-container'); expect(container.querySelector('.ldr-console-log-entry')).not.toBeNull(); }); it('bypasses the queue when the panel is already expanded', () => { setupPanelDom({ page: 'progress' }); window._logPanelState.expanded = true; logPanel.addLog('direct', 'info'); expect(window._logPanelState.queuedLogs.length).toBe(0); const container = document.getElementById('console-log-container'); expect(container.querySelector('.ldr-console-log-entry')).not.toBeNull(); }); }); describe('per-category counters', () => { function getFilterCount(key) { const el = document.querySelector( `.ldr-filter-count[data-filter-count="${key}"]` ); if (!el) return null; // textContent is the rendered string ("0", "1", ...). const trimmed = el.textContent.trim(); return trimmed === '' ? 0 : parseInt(trimmed, 10); } afterEach(() => { // Safety net for the prune-cap test below, which enables fake // timers to keep addLogEntryToPanel's per-call setTimeout(autoscroll, // 0) (logpanel.js:1138) from piling 501 real-timer tasks. Restore // real timers here so a failure mid-test can't leak faked timers // into a neighbour. No-op for the real-timer tests in this block; // mirrors the cleanup half of the `ordering invariants` / // `content dedup bypass` sibling blocks (including the fetch-mock // teardown, since the batch-load test below mocks fetch). The // beforeEach half is intentionally omitted because the batch-load // test depends on a real setTimeout(resolve, 10). vi.clearAllTimers(); vi.useRealTimers(); delete globalThis.fetch; }); it('increments the matching category count when addLog is called', () => { setupPanelDom({ page: 'progress' }); window._logPanelState.expanded = true; // Use distinct messages so the 10-newest content dedup doesn't // collapse adjacent inserts of the same text. logPanel.addLog('hello info one', 'info'); logPanel.addLog('hello info two', 'info'); logPanel.addLog('boom error', 'error'); logPanel.addLog('milestone!', 'milestone'); expect(window._logPanelState.counts.info).toBe(2); expect(window._logPanelState.counts.error).toBe(1); expect(window._logPanelState.counts.milestone).toBe(1); expect(window._logPanelState.counts.warning).toBe(0); }); it('updates the filter button badges after addLog', () => { setupPanelDom({ page: 'progress' }); window._logPanelState.expanded = true; logPanel.addLog('a-info', 'info'); logPanel.addLog('b-warning', 'warning'); logPanel.addLog('c-warning', 'warning'); logPanel.addLog('d-error', 'error'); expect(getFilterCount('info')).toBe(1); expect(getFilterCount('warning')).toBe(2); expect(getFilterCount('error')).toBe(1); expect(getFilterCount('milestone')).toBe(0); // 'all' badge is the sum of the per-category counts. expect(getFilterCount('all')).toBe(4); }); it('renders a "0" badge for never-touched categories', () => { setupPanelDom({ page: 'progress' }); // No addLog calls. The badges should all read "0" so the user // can see at a glance that no entries of that type exist. expect(getFilterCount('info')).toBe(0); expect(getFilterCount('milestone')).toBe(0); expect(getFilterCount('warning')).toBe(0); expect(getFilterCount('error')).toBe(0); expect(getFilterCount('all')).toBe(0); }); it('does not double-count when a duplicate is deduped', () => { setupPanelDom({ page: 'progress' }); window._logPanelState.expanded = true; logPanel.addLog('same-msg', 'info'); logPanel.addLog('same-msg', 'info'); // Content dedup collapses the second insert, so the per-category // counter must NOT bump. expect(window._logPanelState.counts.info).toBe(1); expect(getFilterCount('info')).toBe(1); }); // Explicit timeout + faked timers: this test fills the DOM to the real // MAX_LOG_ENTRIES cap (501 inserts) to exercise the live prune path. // Each addLog -> addLogEntryToPanel queues a setTimeout(autoscroll, 0) // (logpanel.js:1138) AND runs a full-container querySelectorAll // dedup/insert/scan -- O(n^2) DOM work (~2.6-2.9s in happy-dom). Without // faked timers the 501 real setTimeout tasks pile onto the timer queue // and blow the 5s default. Same flake + same fix as the `ordering // invariants` prune test (see release notes 1.8.1 / #4304). Faking timers // here rather than in a beforeEach because the batch-load test below // depends on a real setTimeout(resolve, 10). The assertions read DOM // contents only, so leaving the autoscroll timers queued (unflushed) is // fine. it('decrements the matching category count when the cap prunes an entry', { timeout: 20000 }, () => { vi.useFakeTimers(); // Stub fetch so initialize()'s fire-and-forget loadLogs (kicked off // inside setupPanelDom) is hermetic. happy-dom ships a real native // fetch, so without a mock loadLogs connects to URLBuilder.researchLogs // and rejects on ECONNREFUSED; its catch arm then writes an error div // into the container. That only stays harmless because this body is // synchronous -- the rejection can't interleave with the assertions. // [] takes the empty-response no-op path; the 501 addLog calls are the // real subject. The block afterEach deletes the mock. globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([]), }) ); setupPanelDom({ page: 'progress' }); window._logPanelState.expanded = true; // Fill the DOM up to the cap with distinct info entries. const base = new Date('2026-05-08T12:00:00Z').getTime(); for (let i = 0; i < MAX_LOG_ENTRIES; i++) { vi.setSystemTime(new Date(base + i * 1000)); logPanel.addLog(`info-${i}`, 'info'); } // One more insert triggers the prune path. The head (oldest // info-0) is removed and the per-category counter drops by one. logPanel.addLog('info-overflow', 'info'); expect(window._logPanelState.counts.info).toBe(MAX_LOG_ENTRIES); expect(getFilterCount('info')).toBe(MAX_LOG_ENTRIES); expect(getFilterCount('all')).toBe(MAX_LOG_ENTRIES); }); it('recomputes counts from the DOM after batch load', async () => { // Setup fetch BEFORE setupPanelDom — initialize() inside setupPanelDom // triggers its own loadLogs that will race our explicit call otherwise. globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([ { timestamp: '2026-05-08T12:00:00Z', message: 'i1', log_type: 'info' }, { timestamp: '2026-05-08T12:00:01Z', message: 'i2', log_type: 'info' }, { timestamp: '2026-05-08T12:00:02Z', message: 'e1', log_type: 'error' }, { timestamp: '2026-05-08T12:00:03Z', message: 'm1', log_type: 'milestone' }, ]), }) ); setupPanelDom({ page: 'progress' }); window._logPanelState.expanded = true; // Wait for initialize()'s internal loadLogs to settle so our explicit // call below is not deduped by the in-flight guard. await new Promise((resolve) => setTimeout(resolve, 10)); await logPanel.loadLogs('test-research-batch-counters'); // Counters must reflect the 4 entries just inserted. expect(window._logPanelState.counts.info).toBe(2); expect(window._logPanelState.counts.error).toBe(1); expect(window._logPanelState.counts.milestone).toBe(1); expect(window._logPanelState.counts.warning).toBe(0); expect(getFilterCount('all')).toBe(4); }); }); describe('pruneToCap — per-category ordered prune', () => { function seedEntry(container, message, type, index) { const entry = makeLiveEntry(message, type); // logpanel.js sorts by dataset.logTimeMs for chronological ordering. // We use a synthetic timestamp so insertion order matches DOM order. entry.dataset.logTimeMs = String(index); container.appendChild(entry); return entry; } it('drops info entries before any other category', () => { const container = document.getElementById('console-log-container'); // 3 info + 1 warning + 1 error; cap=2. seedEntry(container, 'info-0', 'info', 0); seedEntry(container, 'info-1', 'info', 1); seedEntry(container, 'info-2', 'info', 2); seedEntry(container, 'warn-0', 'warning', 3); seedEntry(container, 'err-0', 'error', 4); const removed = logPanel._pruneToCap(container, 2); // 3 removals needed (5 -> 2). All should be info entries. expect(removed.length).toBe(3); for (const r of removed) expect(r).toBe('info'); expect(container.children.length).toBe(2); // The warning and error must survive. const survivingTypes = Array.from(container.children).map( (c) => c.dataset.logType ); expect(survivingTypes).toEqual(['warning', 'error']); }); it('drops milestone entries after info is exhausted', () => { const container = document.getElementById('console-log-container'); seedEntry(container, 'info-0', 'info', 0); seedEntry(container, 'info-1', 'info', 1); seedEntry(container, 'milestone-0', 'milestone', 2); seedEntry(container, 'warn-0', 'warning', 3); seedEntry(container, 'err-0', 'error', 4); // cap=2 -> need 3 removals. 2 info first, then the milestone. const removed = logPanel._pruneToCap(container, 2); expect(removed).toEqual(['info', 'info', 'milestone']); expect(container.children.length).toBe(2); const surviving = Array.from(container.children).map( (c) => c.dataset.logType ); expect(surviving).toEqual(['warning', 'error']); }); it('preserves old warnings and errors even when the cap is blown by a flood of info', () => { const container = document.getElementById('console-log-container'); // 1 early error, 1 early warning, then 1000 info entries. seedEntry(container, 'early-error', 'error', 0); seedEntry(container, 'early-warning', 'warning', 1); for (let i = 0; i < 1000; i++) { seedEntry(container, `info-${i}`, 'info', 2 + i); } // cap=200. We need to drop 1002 - 200 = 802 entries. The two // diagnostic entries are the oldest, but the ordered prune must // protect them: all 802 drops must be info entries. const removed = logPanel._pruneToCap(container, 200); expect(removed.length).toBe(802); expect(removed.every((r) => r === 'info')).toBe(true); expect(container.children.length).toBe(200); const survivingMessages = Array.from( container.querySelectorAll('.ldr-log-message') ).map((el) => el.textContent); expect(survivingMessages).toContain('early-error'); expect(survivingMessages).toContain('early-warning'); }); it('falls back to dropping warnings then errors when no info/milestone are left', () => { const container = document.getElementById('console-log-container'); seedEntry(container, 'warn-0', 'warning', 0); seedEntry(container, 'warn-1', 'warning', 1); seedEntry(container, 'err-0', 'error', 2); // cap=1 -> need 2 removals. No info/milestone, so fall back to // dropping the oldest warning first, then the next oldest // warning, before touching the error. Errors are the most // diagnostic category, so they're the last to be dropped. const removed = logPanel._pruneToCap(container, 1); expect(removed).toEqual(['warning', 'warning']); expect(container.children.length).toBe(1); expect(container.firstElementChild.dataset.logType).toBe('error'); expect( container.firstElementChild.querySelector('.ldr-log-message') .textContent ).toBe('err-0'); }); it('is a no-op when already under the cap', () => { const container = document.getElementById('console-log-container'); seedEntry(container, 'info-0', 'info', 0); seedEntry(container, 'err-0', 'error', 1); const removed = logPanel._pruneToCap(container, 10); expect(removed).toEqual([]); expect(container.children.length).toBe(2); }); it('ignores placeholder children (.ldr-empty-log-message / spinner / error) when pruning', () => { const container = document.getElementById('console-log-container'); // Simulate the loadLogsForResearch moment where a transient // spinner is still in the container alongside incoming entries. const spinner = document.createElement('div'); spinner.className = 'ldr-loading-spinner'; spinner.textContent = 'Loading...'; container.appendChild(spinner); // Seed one of every category so each priority bucket is exercised. seedEntry(container, 'info-A', 'info', 0); seedEntry(container, 'milestone-A', 'milestone', 1); seedEntry(container, 'warn-A', 'warning', 2); seedEntry(container, 'err-A', 'error', 3); // Cap of 2 means 2 of the 4 log entries must be dropped. // The helper must walk the priority order (info, milestone, // warning, error) AND must NOT count or remove the placeholder. // If it had counted the spinner, only one entry would be // dropped and the test would fail on .removed.length. const removed = logPanel._pruneToCap(container, 2); expect(removed).toEqual(['info', 'milestone']); // Placeholder is left in place; entry nodes are kept too. expect(container.querySelector('.ldr-loading-spinner')).toBe(spinner); const survivingLogEntries = container.querySelectorAll('.ldr-console-log-entry'); expect(survivingLogEntries.length).toBe(2); expect(survivingLogEntries[0].dataset.logType).toBe('warning'); expect(survivingLogEntries[1].dataset.logType).toBe('error'); // Total DOM children = surviving 2 entries + 1 placeholder. expect(container.children.length).toBe(3); expect(container.children[0]).toBe(spinner); }); // The "no-op when already under the cap" case is covered by // `it('is a no-op when already under the cap', ...)` above; no // need to duplicate it here. });