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
1206 lines
54 KiB
JavaScript
1206 lines
54 KiB
JavaScript
/**
|
||
* 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.
|
||
});
|