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
377 lines
15 KiB
JavaScript
377 lines
15 KiB
JavaScript
/**
|
|
* 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 = `
|
|
<div id="loading"></div>
|
|
<div id="content"></div>
|
|
`;
|
|
|
|
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 = `
|
|
<div id="loading"></div>
|
|
<div id="content"></div>
|
|
<div id="empty-no-data"></div>
|
|
<div id="empty-no-truncation"></div>
|
|
<div id="empty-no-context-data"></div>
|
|
<div id="warning-banner"></div>
|
|
<span id="warning-rate"></span>
|
|
<div id="context-overflow-section"></div>
|
|
<tbody id="requests-tbody"></tbody>
|
|
<span id="pagination-info"></span>
|
|
<button id="pagination-prev"></button>
|
|
<button id="pagination-next"></button>
|
|
`;
|
|
}
|
|
|
|
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');
|
|
});
|
|
});
|