/**
* 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.
});