Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

1206 lines
54 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `
<div class="ldr-collapsible-log-panel">
<div id="log-panel-toggle">
<i class="ldr-toggle-icon"></i>
</div>
<div id="log-panel-content">
<div id="console-log-container"></div>
</div>
</div>
<template id="console-log-entry-template">
<div class="ldr-console-log-entry">
<span class="ldr-log-timestamp"></span>
<span class="ldr-log-badge"></span>
<span class="ldr-log-message"></span>
</div>
</template>
`;
// 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 = `
<div class="ldr-collapsible-log-panel">
<div class="ldr-log-panel-header" id="log-panel-toggle">
<i class="fas fa-chevron-right ldr-toggle-icon"></i>
</div>
<div class="ldr-log-panel-content collapsed" id="log-panel-content">
<div class="ldr-log-controls">
<div class="ldr-log-filter">
<div class="ldr-filter-buttons">
<button class="ldr-small-btn ldr-selected" data-filter-type="all" onclick="window.filterLogsByType('all')">All <span class="ldr-filter-count" data-filter-count="all">0</span></button>
<button class="ldr-small-btn" data-filter-type="milestone" onclick="window.filterLogsByType('milestone')">Milestones <span class="ldr-filter-count" data-filter-count="milestone">0</span></button>
<button class="ldr-small-btn" data-filter-type="info" onclick="window.filterLogsByType('info')">Info <span class="ldr-filter-count" data-filter-count="info">0</span></button>
<button class="ldr-small-btn" data-filter-type="warning" onclick="window.filterLogsByType('warning')">Warning <span class="ldr-filter-count" data-filter-count="warning">0</span></button>
<button class="ldr-small-btn" data-filter-type="error" onclick="window.filterLogsByType('error')">Errors <span class="ldr-filter-count" data-filter-count="error">0</span></button>
</div>
</div>
<button id="log-autoscroll-button" class="ldr-selected"></button>
</div>
<div class="ldr-console-log" id="console-log-container"></div>
</div>
</div>
<template id="console-log-entry-template">
<div class="ldr-console-log-entry">
<span class="ldr-log-timestamp"></span>
<span class="ldr-log-badge"></span>
<span class="ldr-log-message"></span>
</div>
</template>
`;
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 =
'<div class="ldr-empty-log-message">No logs available.</div>';
// 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 = `
<div class="ldr-collapsible-log-panel">
<div class="ldr-log-panel-header" id="log-panel-toggle">
<i class="fas fa-chevron-right ldr-toggle-icon"></i>
</div>
<div class="ldr-log-panel-content collapsed" id="log-panel-content">
<div class="ldr-log-controls">
<div class="ldr-log-filter">
<div class="ldr-filter-buttons">
<button class="ldr-small-btn ldr-selected" data-filter-type="all" onclick="window.filterLogsByType('all')">All <span class="ldr-filter-count" data-filter-count="all">0</span></button>
<button class="ldr-small-btn" data-filter-type="milestone" onclick="window.filterLogsByType('milestone')">Milestones <span class="ldr-filter-count" data-filter-count="milestone">0</span></button>
<button class="ldr-small-btn" data-filter-type="info" onclick="window.filterLogsByType('info')">Info <span class="ldr-filter-count" data-filter-count="info">0</span></button>
<button class="ldr-small-btn" data-filter-type="warning" onclick="window.filterLogsByType('warning')">Warning <span class="ldr-filter-count" data-filter-count="warning">0</span></button>
</div>
</div>
<button id="log-autoscroll-button" class="ldr-selected"></button>
</div>
<div class="ldr-console-log" id="console-log-container"></div>
</div>
</div>
<template id="console-log-entry-template">
<div class="ldr-console-log-entry">
<span class="ldr-log-timestamp"></span>
<span class="ldr-log-badge"></span>
<span class="ldr-log-message"></span>
</div>
</template>
`;
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 <span class="ldr-filter-count" data-filter-count="error">0</span>';
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.
});