/** * Tests for components/context-overflow.js * * Specifically locks the AbortController race-fix: when a new * loadContextData() supersedes an in-flight one, the older fetch's * AbortSignal must fire so its (slower) response cannot overwrite * the newer one's render. */ beforeAll(async () => { // Mark as a test environment so the file's auto-init doesn't fire // (it would query DOM elements we haven't built yet). window.__VITEST_TEST__ = true; // Stub Chart constructor — happy-dom can't run Chart.js, and we only // care that the page wires it up correctly. Records last-call args // so tests can assert on the chart options (annotations, etc.). globalThis.Chart = class { constructor(ctx, config) { globalThis.Chart.lastCall = { ctx, config }; globalThis.Chart.allCalls ||= []; globalThis.Chart.allCalls.push({ ctx, config }); } destroy() {} }; // Stub URLValidator — used by the scatter chart's onClick handler. globalThis.URLValidator = { safeAssign: () => {} }; // Set up the minimum DOM the loader touches when called directly. document.body.innerHTML = `
`; await import('@js/components/context-overflow.js'); }); afterAll(() => { delete window.__VITEST_TEST__; }); describe('loadContextData — abort-on-supersede', () => { it('aborts the in-flight fetch when a newer call starts', async () => { let firstAbortFired = false; // First call: never-resolving fetch. We capture its AbortSignal. // Second call should trigger abort on the first. let firstSignal; const fetchMock = vi.fn((url, opts) => { if (!firstSignal) { firstSignal = opts.signal; firstSignal.addEventListener('abort', () => { firstAbortFired = true; }); return new Promise(() => {}); // never settles } // Second call: resolve with a minimal valid payload so // the loader's success path completes without throwing. return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'success', overview: { truncation_rate: 0, truncated_requests: 0, requests_with_context_data: 0, total_requests: 0, avg_tokens_truncated: 0, }, token_summary: { total_requests: 0 }, model_stats: [], recent_truncated: [], chart_data: [], context_limits: [], all_requests: [], pagination: { page: 1, total_pages: 1 }, }), }); }); global.fetch = fetchMock; const ctrl = window.contextOverflowController; // Fire-and-forget the first; it never resolves on its own. ctrl.loadContextData('30d'); // Newer call — should abort the first. await ctrl.loadContextData('7d'); expect(firstAbortFired).toBe(true); // Second fetch was actually issued (not just an abort). expect(fetchMock).toHaveBeenCalledTimes(2); // Both fetches were given AbortSignals (defensive contract check). expect(fetchMock.mock.calls[0][1]).toHaveProperty('signal'); expect(fetchMock.mock.calls[1][1]).toHaveProperty('signal'); }); it('passes the period through to the request URL', async () => { const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'success', overview: { truncation_rate: 0, truncated_requests: 0, requests_with_context_data: 0, total_requests: 0, avg_tokens_truncated: 0, }, token_summary: { total_requests: 0 }, model_stats: [], recent_truncated: [], chart_data: [], context_limits: [], all_requests: [], pagination: { page: 1, total_pages: 1 }, }), })); global.fetch = fetchMock; await window.contextOverflowController.loadContextData('1y', 3); const url = fetchMock.mock.calls[0][0]; expect(url).toContain('period=1y'); expect(url).toContain('page=3'); }); }); describe('displayContextOverflowSection — restored UI features', () => { // Set up the static DOM elements that the page expects (these live in // context_overflow.html outside the dynamic context-overflow-section). function setupPageDom() { document.body.innerHTML = `
`; } function richPayload() { return { status: 'success', overview: { truncation_rate: 5, truncated_requests: 2, requests_with_context_data: 10, total_requests: 12, avg_tokens_truncated: 150, }, token_summary: { total_requests: 12 }, model_stats: [ { model: 'gpt-4', provider: 'openai', total_requests: 10, truncated_count: 2, truncation_rate: 20, avg_context_limit: 8192, }, ], model_token_stats: [ { model: 'gpt-4', provider: 'openai', min_prompt: 100, avg_prompt: 4000, max_prompt: 7500, avg_response_time_ms: 1200, }, ], recent_truncated: [], chart_data: [ { timestamp: new Date().toISOString(), prompt_tokens: 4000, actual_prompt_tokens: 4000, context_limit: 8192, research_phase: 'search', research_id: 'r1', model: 'gpt-4', response_time_ms: 1200, }, ], context_limits: [], phase_breakdown: [ { phase: 'search', count: 5, total_tokens: 20000, avg_tokens: 4000 }, ], current_context_window: 4096, all_requests: [], pagination: { page: 1, total_pages: 1 }, }; } it('renders phase chart card and summary table when phase_breakdown is provided', async () => { setupPageDom(); global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(richPayload()), })); await window.contextOverflowController.loadContextData('30d'); // Phase card was added to the dynamic section expect(document.getElementById('phase-chart')).not.toBeNull(); expect(document.getElementById('phase-summary-table')).not.toBeNull(); // Summary table populated from phase_breakdown expect(document.getElementById('phase-summary-table').innerHTML) .toContain('search'); }); it('renders latency chart card', async () => { setupPageDom(); global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(richPayload()), })); await window.contextOverflowController.loadContextData('30d'); expect(document.getElementById('latency-chart')).not.toBeNull(); }); it('renders per-model token stats grid (min/avg/max prompt) when model_token_stats provided', async () => { setupPageDom(); global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(richPayload()), })); await window.contextOverflowController.loadContextData('30d'); const modelStatsHtml = document.getElementById('model-stats').innerHTML; // Min/avg/max prompt labels appear, indicating the restored grid renders expect(modelStatsHtml).toContain('Min Prompt'); expect(modelStatsHtml).toContain('Avg Prompt'); expect(modelStatsHtml).toContain('Max Prompt'); // Context utilization progress bar expect(modelStatsHtml).toContain('Context utilization'); // Average response time line expect(modelStatsHtml).toContain('1.2s'); }); it('passes context-limit annotations to the scatter chart so reference lines render', async () => { setupPageDom(); globalThis.Chart.allCalls = []; global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(richPayload()), })); await window.contextOverflowController.loadContextData('30d'); // Find the context chart call (looks for time-axis scatter chart with // an annotation block — distinguishes it from latency / phase charts). const contextChartCall = globalThis.Chart.allCalls.find(c => c.config?.type === 'scatter' && c.config?.options?.scales?.x?.type === 'time' ); expect(contextChartCall).toBeDefined(); const annotations = contextChartCall.config.options.plugins.annotation.annotations; // limit_8192 annotation exists AND has the right yMin/yMax values // (presence-only check would pass even if the line was rendered at 0) expect(annotations.limit_8192).toBeDefined(); expect(annotations.limit_8192.yMin).toBe(8192); expect(annotations.limit_8192.yMax).toBe(8192); expect(annotations.limit_8192.borderDash).toEqual([6, 4]); // current_setting line at the configured num_ctx (4096), distinct // styling (solid line, no dash) so users can tell it from historical expect(annotations.current_setting).toBeDefined(); expect(annotations.current_setting.yMin).toBe(4096); expect(annotations.current_setting.borderDash).toBeUndefined(); }); it('separates noLimitData into its own bucket instead of conflating with cautionData', async () => { setupPageDom(); globalThis.Chart.allCalls = []; const payload = richPayload(); // Two points: one with a limit (lands in safeData), one without (must // land in noLimitData, NOT cautionData) payload.chart_data = [ { ...payload.chart_data[0], original_prompt_tokens: 1000, context_limit: 8192 }, { ...payload.chart_data[0], original_prompt_tokens: 1000, context_limit: null }, ]; global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(payload), })); await window.contextOverflowController.loadContextData('30d'); const contextChartCall = globalThis.Chart.allCalls.find(c => c.config?.type === 'scatter' && c.config?.options?.scales?.x?.type === 'time' ); const datasets = contextChartCall.config.data.datasets; const noLimitDataset = datasets.find(d => d.label === 'No context limit reported'); const cautionDataset = datasets.find(d => d.label?.startsWith('Caution')); // Point with no limit goes to its own dataset, not caution expect(noLimitDataset.data).toHaveLength(1); expect(noLimitDataset.data[0].context_limit).toBeNull(); expect(cautionDataset.data).toHaveLength(0); }); it('detects local providers using canonical names (lmstudio/llamacpp without underscores)', async () => { // Regression guard against the LOCAL_PROVIDERS underscore mismatch. // The TokenUsage.model_provider canonical values per default_settings.json // are 'lmstudio' / 'llamacpp' — the chart must mark these as local. setupPageDom(); globalThis.Chart.allCalls = []; const payload = richPayload(); payload.chart_data = [ { ...payload.chart_data[0], provider: 'lmstudio', original_prompt_tokens: 1000, context_limit: 8192 }, { ...payload.chart_data[0], provider: 'llamacpp', original_prompt_tokens: 1000, context_limit: 8192 }, { ...payload.chart_data[0], provider: 'openai', original_prompt_tokens: 1000, context_limit: 8192 }, ]; global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(payload), })); await window.contextOverflowController.loadContextData('30d'); const contextChartCall = globalThis.Chart.allCalls.find(c => c.config?.type === 'scatter' && c.config?.options?.scales?.x?.type === 'time' ); // All 3 points are <50% util → they all land in safeData const safeDataset = contextChartCall.config.data.datasets.find(d => d.label?.startsWith('Safe')); const points = safeDataset.data; expect(points).toHaveLength(3); const lmstudioPoint = points.find(p => p.provider === 'lmstudio'); const llamacppPoint = points.find(p => p.provider === 'llamacpp'); const openaiPoint = points.find(p => p.provider === 'openai'); expect(lmstudioPoint.isLocal).toBe(true); expect(llamacppPoint.isLocal).toBe(true); expect(openaiPoint.isLocal).toBe(false); }); it('hides the no-truncation banner when chart_data has any >80% util request even if truncated_requests is 0', async () => { // Empty-state guard: a request at 85% utilisation that the backend // hasn't yet flagged as truncated is still a problem the user should // see, so the "all clear" banner must not show. setupPageDom(); const payload = richPayload(); payload.overview.truncated_requests = 0; // backend says no truncation events payload.overview.requests_with_context_data = 1; payload.chart_data = [ { ...payload.chart_data[0], original_prompt_tokens: 7000, // 7000 / 8192 = 85.4% > 80% context_limit: 8192, }, ]; global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(payload), })); await window.contextOverflowController.loadContextData('30d'); const banner = document.getElementById('empty-no-truncation'); // banner.style.display defaults to '' before any code touches it; // after displayContextData runs the banner is set to 'none' for this case expect(banner.style.display).toBe('none'); }); });