chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
@@ -0,0 +1,299 @@
/**
* Tests for the expandable observation detail in chat.js live steps.
*
* Observation progress events carry the (bounded) full tool output in
* `data.content` alongside the one-line `data.message`. The live step
* handler must append the detail beneath the message — mirroring what
* `_compose_chat_step_content` persists to chat_progress_steps on the
* backend, so the live accordion and a post-reload session render
* identically (symmetry invariant). Non-observation phases must NOT
* compose: `metadata["content"]` is reused there for large payloads
* (full synthesized answers) that don't belong in step rows.
*
* `handleProgressUpdate` is private to the chat IIFE, so we drive it the
* way production does: load a session whose messages response reports an
* in-progress research, capture the progress callback that chat.js hands
* to window.socket.subscribeToResearch, and invoke it with progress
* events.
*/
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
const SESSION_ID = 'sess-1';
const RESEARCH_ID = 'research-1';
function buildChatDom() {
// Static literal (no interpolation) so no-unsanitized/property is
// satisfied; SESSION_ID must match this meta content.
document.head.innerHTML = `
<meta name="chat-session-id" content="sess-1">
`;
document.body.innerHTML = `
<div id="chat-welcome"></div>
<textarea id="chat-input"></textarea>
<button id="send-btn"></button>
<div id="chat-title"></div>
<div id="chat-messages" role="log"></div>
<template id="thinking-template">
<div class="ldr-chat-message ldr-chat-message-assistant ldr-chat-message-thinking" role="status">
<div class="ldr-chat-message-avatar"><i class="fas fa-robot"></i></div>
<div class="ldr-chat-message-content">
<div class="ldr-chat-thinking-text" hidden></div>
<div class="ldr-chat-thinking-dots"><span></span><span></span><span></span></div>
</div>
</div>
</template>
<template id="message-template">
<div class="ldr-chat-message">
<div class="ldr-chat-message-avatar"><i class="fas fa-user"></i></div>
<div class="ldr-chat-message-content">
<div class="ldr-chat-message-text"></div>
<div class="ldr-chat-message-meta"><span class="ldr-chat-message-time"></span></div>
</div>
</div>
</template>
`;
}
const json = (payload) => ({
ok: true,
status: 200,
json: async () => payload,
});
const flush = async (n = 10) => {
for (let i = 0; i < n; i++) await Promise.resolve();
};
/** The step elements inside the live accordion. */
function liveSteps() {
return document.querySelectorAll(
'.ldr-chat-steps-group[data-live="true"] .ldr-chat-message-step'
);
}
describe('chat.js — live step observation detail (data.content)', () => {
let progressCb;
beforeEach(async () => {
vi.resetModules();
progressCb = null;
buildChatDom();
window.api = { getCsrfToken: () => '' };
window.ui = { renderMarkdown: (t) => t };
window.socket = {
subscribeToResearch: vi.fn((id, cb) => {
progressCb = cb;
}),
unsubscribeFromResearch: vi.fn(),
getSocketInstance: vi.fn(() => ({
on: vi.fn(),
off: vi.fn(),
})),
};
// Session restore: one prior user message + an in-progress
// research, so loadSession() subscribes to progress events.
globalThis.fetch = vi.fn(async (url) => {
const u = String(url);
if (u.includes(`/api/chat/sessions/${SESSION_ID}/messages`)) {
return json({
success: true,
messages: [
{
id: 'm1',
role: 'user',
message_type: 'chat',
content: 'hello',
created_at: '2026-07-11T00:00:00Z',
},
],
has_more: false,
in_progress_research_id: RESEARCH_ID,
});
}
if (u.includes(`/api/chat/sessions/${SESSION_ID}`)) {
return json({
success: true,
session: { id: SESSION_ID, title: 'Test session' },
});
}
return json({ success: true, sessions: [] });
});
await import('@js/components/chat.js');
await flush();
expect(window.socket.subscribeToResearch).toHaveBeenCalledWith(
RESEARCH_ID,
expect.any(Function)
);
expect(progressCb).toBeTypeOf('function');
});
afterEach(() => {
delete globalThis.fetch;
});
it('appends data.content beneath the message for observation steps', () => {
const message = '📄 From the web (SearXNG): [1] Some Title (http://a.com) Snip';
const detail = '[1] Some Title (http://a.com)\nThe full snippet body with much more text.';
progressCb({
research_id: RESEARCH_ID,
phase: 'observation',
message,
content: detail,
progress: 42,
});
const steps = liveSteps();
expect(steps.length).toBe(1);
const text = steps[0].querySelector('.ldr-chat-message-text').textContent;
expect(text).toBe(message + '\n\n' + detail);
});
it('keeps the bare message when an observation has no content', () => {
const message = '📄 From PubMed: no results';
progressCb({
research_id: RESEARCH_ID,
phase: 'observation',
message,
progress: 42,
});
const steps = liveSteps();
expect(steps.length).toBe(1);
expect(
steps[0].querySelector('.ldr-chat-message-text').textContent
).toBe(message);
});
it('does NOT compose content for non-observation step phases', () => {
const message = 'Generating final answer…';
progressCb({
research_id: RESEARCH_ID,
phase: 'output_generation',
message,
content: 'the entire synthesized answer — must not land in the step',
progress: 90,
});
const steps = liveSteps();
expect(steps.length).toBe(1);
expect(
steps[0].querySelector('.ldr-chat-message-text').textContent
).toBe(message);
});
it('keeps the thinking-bubble preview on the short message only', () => {
progressCb({
research_id: RESEARCH_ID,
phase: 'observation',
message: '📄 From arXiv: [2] Paper',
content: '[2] Paper (http://arxiv.org/x)\nAbstract text',
progress: 42,
});
const preview = document.querySelector('.ldr-chat-thinking-text');
expect(preview).not.toBeNull();
expect(preview.textContent).toBe('📄 From arXiv: [2] Paper');
expect(preview.textContent).not.toContain('Abstract text');
});
});
describe('chat.js — reloaded step icons use the persisted phase', () => {
/**
* Persisted step rows now carry composed observation detail — raw web
* text that can contain words like "failed". The reload icon must come
* from the row's `phase` field, not the content heuristic, so a
* successful result row never renders with the error icon.
*/
function stepIcons() {
return [
...document.querySelectorAll(
'.ldr-chat-steps-group .ldr-chat-message-step .ldr-chat-message-avatar i'
),
].map((i) => i.className);
}
beforeEach(async () => {
vi.resetModules();
buildChatDom();
window.api = { getCsrfToken: () => '' };
window.ui = { renderMarkdown: (t) => t };
window.socket = {
subscribeToResearch: vi.fn(),
unsubscribeFromResearch: vi.fn(),
getSocketInstance: vi.fn(() => ({ on: vi.fn(), off: vi.fn() })),
};
// Completed session: no in-progress research, two persisted steps —
// one observation whose detail contains failure-y web text, one
// legacy row without a phase (pre-phase-column fallback).
globalThis.fetch = vi.fn(async (url) => {
const u = String(url);
if (u.includes(`/api/chat/sessions/${SESSION_ID}/messages`)) {
return json({
success: true,
messages: [
{
id: 'm1',
role: 'user',
message_type: 'chat',
content: 'hello',
created_at: '2026-07-11T00:00:00Z',
},
{
id: 'step-1',
role: 'assistant',
message_type: 'step',
phase: 'observation',
content:
'📄 From PubMed: [1] Titration study\n\n'
+ '[1] Titration study (http://p.com)\n'
+ 'The first titration attempt failed; the '
+ 'second failed titration was repeated.',
created_at: '2026-07-11T00:00:01Z',
},
{
id: 'step-2',
role: 'assistant',
message_type: 'step',
content: 'Starting research: "hello"',
created_at: '2026-07-11T00:00:02Z',
},
],
has_more: false,
in_progress_research_id: null,
});
}
if (u.includes(`/api/chat/sessions/${SESSION_ID}`)) {
return json({
success: true,
session: { id: SESSION_ID, title: 'Test session' },
});
}
return json({ success: true, sessions: [] });
});
await import('@js/components/chat.js');
await flush();
});
afterEach(() => {
delete globalThis.fetch;
});
it('uses the phase icon for observation rows despite "failed" in the detail', () => {
const icons = stepIcons();
expect(icons.length).toBe(2);
expect(icons[0]).toContain('fa-eye'); // observation, NOT fa-circle-xmark
expect(icons[0]).not.toContain('fa-circle-xmark');
});
it('falls back to the content heuristic for rows without a phase', () => {
const icons = stepIcons();
expect(icons[1]).toContain('fa-play'); // "starting research" heuristic
});
});
@@ -0,0 +1,152 @@
/**
* Tests for the per-message Copy action in chat.js (#4659 / PR #4689).
*
* The Copy/Retry/Delete affordances are wired through a single delegated
* click listener on #chat-messages (handleMessageActionClick), so we drive
* them by building the real action-button DOM and dispatching clicks — the
* handlers themselves are private to the chat IIFE.
*
* Focus: handleCopyMessage clipboard write + the transient icon swap, and a
* regression guard for the rapid-double-click timer race (clicking copy
* twice within 1.2s must NOT leave the button stuck on the checkmark).
*/
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
function buildChatDom() {
document.body.innerHTML = `
<div id="chat-welcome"></div>
<textarea id="chat-input"></textarea>
<button id="send-btn"></button>
<div id="chat-title"></div>
<div id="chat-messages" role="log"></div>
`;
}
/** Build an assistant message + action row matching _appendMessageActions. */
function addCopyableMessage(copyContent = 'hello world') {
const messages = document.getElementById('chat-messages');
const msg = document.createElement('div');
msg.className = 'ldr-chat-message ldr-chat-message-assistant';
const content = document.createElement('div');
content.className = 'ldr-chat-message-content';
const actions = document.createElement('div');
actions.className = 'ldr-chat-message-actions';
actions.dataset.copyContent = copyContent;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ldr-chat-msg-action ldr-chat-msg-action-copy';
btn.dataset.action = 'copy';
btn.setAttribute('aria-label', 'Copy response');
const icon = document.createElement('i');
icon.className = 'fas fa-copy';
btn.appendChild(icon);
actions.appendChild(btn);
content.appendChild(actions);
msg.appendChild(content);
messages.appendChild(msg);
return { btn, icon };
}
const flush = async (n = 6) => {
for (let i = 0; i < n; i++) await Promise.resolve();
};
const click = (el) =>
el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
describe('chat.js — per-message Copy action', () => {
let writeText;
beforeEach(async () => {
vi.resetModules();
buildChatDom();
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
Object.defineProperty(window, 'isSecureContext', {
value: true,
configurable: true,
});
// Keep init()'s awaited session restore from throwing.
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ sessions: [] }),
});
window.ui = { renderMarkdown: (t) => t };
window.api = { getCsrfToken: () => '' };
window.socket = {
on: vi.fn(),
subscribeToResearch: vi.fn(),
unsubscribeFromResearch: vi.fn(),
};
// Import AFTER the DOM + globals exist so the IIFE's init() finds
// #chat-messages and attaches the delegated click listener.
await import('@js/components/chat.js');
await flush();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
document.body.innerHTML = '';
});
it('writes the message content to the clipboard and shows a checkmark', async () => {
const { btn, icon } = addCopyableMessage('copy me please');
click(icon);
await flush();
expect(writeText).toHaveBeenCalledWith('copy me please');
expect(icon.className).toBe('fas fa-check');
expect(btn.getAttribute('aria-label')).toBe('Copied');
});
it('restores the original icon and label after the 1.2s window', async () => {
vi.useFakeTimers();
const { btn, icon } = addCopyableMessage();
click(icon);
await flush();
expect(icon.className).toBe('fas fa-check');
vi.advanceTimersByTime(1200);
expect(icon.className).toBe('fas fa-copy');
expect(btn.getAttribute('aria-label')).toBe('Copy response');
});
it('rapid double-click does not leave the icon stuck on the checkmark', async () => {
// Regression guard: before the fix the second click captured the
// checkmark as the "original", so the later restore timer reverted
// the icon to the checkmark and it stayed stuck.
vi.useFakeTimers();
const { icon } = addCopyableMessage();
click(icon);
await flush();
vi.advanceTimersByTime(500); // second click well within the window
click(icon);
await flush();
expect(icon.className).toBe('fas fa-check');
// Let every scheduled restore fire.
vi.advanceTimersByTime(2000);
expect(icon.className).toBe('fas fa-copy');
});
it('falls back to execCommand when the async clipboard is unavailable', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
});
const exec = vi.fn().mockReturnValue(true);
document.execCommand = exec;
const { icon } = addCopyableMessage('legacy path');
click(icon);
await flush();
expect(exec).toHaveBeenCalledWith('copy');
expect(icon.className).toBe('fas fa-check');
});
});
@@ -0,0 +1,130 @@
/**
* Tests for components/checkbox_handler.js
*
* Tests the checkbox-hidden input fallback pattern that ensures
* both checked and unchecked states are submitted in HTML forms.
*/
let handler;
beforeAll(async () => {
await import('@js/components/checkbox_handler.js');
handler = window.checkboxHandler;
});
describe('CheckboxHandler', () => {
let form;
beforeEach(() => {
form = document.createElement('form');
document.body.appendChild(form);
});
afterEach(() => {
form.remove();
});
describe('setupCheckbox', () => {
it('disables hidden input when checkbox is checked', () => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = true;
checkbox.setAttribute('data-hidden-fallback', 'hidden-1');
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = 'hidden-1';
hidden.value = 'false';
form.appendChild(checkbox);
form.appendChild(hidden);
handler.setupCheckbox(checkbox);
expect(hidden.disabled).toBe(true);
});
it('enables hidden input when checkbox is unchecked', () => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = false;
checkbox.setAttribute('data-hidden-fallback', 'hidden-2');
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = 'hidden-2';
hidden.value = 'false';
form.appendChild(checkbox);
form.appendChild(hidden);
handler.setupCheckbox(checkbox);
expect(hidden.disabled).toBe(false);
});
it('toggles hidden input on change event', () => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = false;
checkbox.setAttribute('data-hidden-fallback', 'hidden-3');
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = 'hidden-3';
form.appendChild(checkbox);
form.appendChild(hidden);
handler.setupCheckbox(checkbox);
expect(hidden.disabled).toBe(false);
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));
expect(hidden.disabled).toBe(true);
checkbox.checked = false;
checkbox.dispatchEvent(new Event('change'));
expect(hidden.disabled).toBe(false);
});
});
describe('cleanup', () => {
it('removes event listener from checkbox', () => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.setAttribute('data-hidden-fallback', 'hidden-4');
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = 'hidden-4';
form.appendChild(checkbox);
form.appendChild(hidden);
handler.setupCheckbox(checkbox);
expect(checkbox._checkboxHandlerCleanup).toBeTypeOf('function');
handler.cleanup(checkbox);
expect(checkbox._checkboxHandlerCleanup).toBeUndefined();
});
});
describe('prepareFormSubmission', () => {
it('syncs all checkbox-hidden pairs before submit', () => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = true;
checkbox.setAttribute('data-hidden-fallback', 'hidden-5');
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = 'hidden-5';
hidden.disabled = false;
form.appendChild(checkbox);
form.appendChild(hidden);
handler.prepareFormSubmission(form);
expect(hidden.disabled).toBe(true);
});
});
});
@@ -0,0 +1,51 @@
/**
* Tests for components/context-overflow-shared.js
*
* Pure HTML renderer for the truncation status badge shown in
* context-overflow details. The single branch (count > 0 vs not) drives
* which CSS variable the badge uses, so a regression here would change
* the surfaced color and wording.
*/
import '@js/components/context-overflow-shared.js';
const { renderTruncationBadge } = window.contextOverflowShared;
describe('renderTruncationBadge', () => {
it('returns the "No truncation" badge when count is 0', () => {
const html = renderTruncationBadge(0);
expect(html).toContain('--success-color');
expect(html).toContain('No truncation');
expect(html).not.toContain('--error-color');
});
it('returns the "Yes" badge with the count when count > 0', () => {
const html = renderTruncationBadge(5);
expect(html).toContain('--error-color');
expect(html).toContain('Yes (5 requests)');
});
it('treats null as 0 (no truncation)', () => {
expect(renderTruncationBadge(null)).toContain('No truncation');
});
it('treats undefined as 0 (no truncation)', () => {
expect(renderTruncationBadge(undefined)).toContain('No truncation');
});
it('treats a non-numeric string as 0 (no truncation)', () => {
// Number('foo') => NaN => falsy => || 0
expect(renderTruncationBadge('foo')).toContain('No truncation');
});
it('coerces a numeric string into a number for the count', () => {
const html = renderTruncationBadge('42');
expect(html).toContain('--error-color');
expect(html).toContain('Yes (42 requests)');
});
it('treats negative numbers as no truncation (-3 is truthy but > 0 fails)', () => {
// Number(-3) === -3 (truthy, so || 0 keeps -3), but the > 0 branch fails => returns success.
expect(renderTruncationBadge(-3)).toContain('No truncation');
});
});
@@ -0,0 +1,376 @@
/**
* 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');
});
});
+340
View File
@@ -0,0 +1,340 @@
/**
* Tests for components/custom_dropdown.js
*
* Tests the custom dropdown component's core behavior:
* setup, filtering, selection, keyboard navigation, and cleanup.
*/
import '@js/security/xss-protection.js';
import '@js/components/custom_dropdown.js';
const setupCustomDropdown = window.setupCustomDropdown;
const updateDropdownOptions = window.updateDropdownOptions;
describe('setupCustomDropdown', () => {
let input, hiddenInput, dropdownList, onSelect, options;
beforeEach(() => {
input = document.createElement('input');
input.type = 'text';
input.id = 'test-dropdown-input';
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.id = 'test-dropdown-input_hidden';
dropdownList = document.createElement('div');
dropdownList.id = 'test-dropdown-list';
const wrapper = document.createElement('div');
wrapper.appendChild(input);
wrapper.appendChild(hiddenInput);
wrapper.appendChild(dropdownList);
document.body.appendChild(wrapper);
onSelect = vi.fn();
options = [
{ value: 'gpt4', label: 'GPT-4' },
{ value: 'claude', label: 'Claude' },
{ value: 'llama', label: 'Llama 3' },
{ value: 'mistral', label: 'Mistral' },
];
});
afterEach(() => {
const wrapper = input.closest('div');
if (wrapper && wrapper.parentNode) {
wrapper.remove();
}
// Clean up any detached dropdown lists
const detached = document.getElementById('test-dropdown-list');
if (detached) detached.remove();
});
it('returns control functions', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
expect(dd.updateDropdown).toBeTypeOf('function');
expect(dd.showDropdown).toBeTypeOf('function');
expect(dd.hideDropdown).toBeTypeOf('function');
expect(dd.destroy).toBeTypeOf('function');
expect(dd.setValue).toBeTypeOf('function');
dd.destroy();
});
it('initially hides the dropdown', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
expect(dropdownList.style.display).toBe('none');
dd.destroy();
});
it('shows dropdown on input click', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
expect(dropdownList.style.display).toBe('block');
dd.destroy();
});
it('populates dropdown with all options on click', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
expect(items.length).toBe(4);
dd.destroy();
});
it('filters options when typing', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.value = 'Cl';
input.dispatchEvent(new Event('input'));
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
expect(items.length).toBe(1);
expect(items[0].getAttribute('data-value')).toBe('claude');
dd.destroy();
});
it('shows "no results" when nothing matches', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.value = 'zzzzz';
input.dispatchEvent(new Event('input'));
expect(dropdownList.querySelector('.ldr-custom-dropdown-no-results')).not.toBeNull();
dd.destroy();
});
it('calls onSelect when an option is clicked', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
items[1].click(); // Click "Claude"
expect(onSelect).toHaveBeenCalledWith('claude', options[1]);
dd.destroy();
});
it('updates input value on selection', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
items[0].click(); // Click "GPT-4"
expect(input.value).toBe('GPT-4');
dd.destroy();
});
it('updates hidden input on selection', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
items[0].click();
expect(hiddenInput.value).toBe('gpt4');
dd.destroy();
});
it('hides dropdown after selection', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
items[0].click();
expect(dropdownList.style.display).toBe('none');
dd.destroy();
});
it('hides dropdown on Escape key', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
expect(dropdownList.style.display).toBe('block');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(dropdownList.style.display).toBe('none');
dd.destroy();
});
it('sets ARIA attributes for accessibility', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
// Initially closed
expect(input.getAttribute('aria-expanded')).toBe('false');
// Open
input.click();
expect(input.getAttribute('aria-expanded')).toBe('true');
// Items have role="option"
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
items.forEach(item => {
expect(item.getAttribute('role')).toBe('option');
});
dd.destroy();
});
describe('setValue', () => {
it('sets value by matching option value', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
dd.setValue('mistral');
expect(input.value).toBe('Mistral');
expect(hiddenInput.value).toBe('mistral');
dd.destroy();
});
it('calls onSelect callback', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
dd.setValue('claude');
expect(onSelect).toHaveBeenCalledWith('claude', options[1]);
dd.destroy();
});
it('clears input for unknown value when custom values disallowed', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect, false);
dd.setValue('unknown-model');
expect(input.value).toBe('');
dd.destroy();
});
it('sets raw value when custom values are allowed', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect, true);
dd.setValue('custom-model');
expect(input.value).toBe('custom-model');
dd.destroy();
});
});
describe('destroy', () => {
it('cleans up event listeners and registry', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
dd.destroy();
// After destroy, input events should not trigger dropdown
input.click();
// Dropdown should stay hidden (no listener to open it)
expect(dropdownList.style.display).toBe('none');
});
});
describe('keyboard navigation', () => {
it('ArrowDown opens dropdown and selects first item', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(dropdownList.style.display).toBe('block');
const active = dropdownList.querySelector('.active');
expect(active).not.toBeNull();
dd.destroy();
});
it('Enter selects highlighted item', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
// Open and select first
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
// Select it
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(onSelect).toHaveBeenCalled();
dd.destroy();
});
});
describe('group headers', () => {
const grouped = [
{ value: 'fav1', label: 'Pinned One', group_label: 'Favorites' },
{ value: 'arxiv', label: 'ArXiv', group_label: 'Academic' },
{ value: 'pubmed', label: 'PubMed', group_label: 'Academic' },
{ value: 'tavily', label: 'Tavily', group_label: 'API key' },
];
it('renders one non-selectable header per band, in order', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.click();
const headers = dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header');
expect(Array.from(headers).map(h => h.textContent)).toEqual([
'Favorites', 'Academic', 'API key',
]);
headers.forEach(h =>
expect(h.classList.contains('ldr-custom-dropdown-item')).toBe(false)
);
dd.destroy();
});
it('keeps the selectable option count equal to items (headers excluded)', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.click();
const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item');
expect(items.length).toBe(4);
dd.destroy();
});
it('shows a band header once even when it has multiple items', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.click();
const academic = Array.from(
dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header')
).filter(h => h.textContent === 'Academic');
expect(academic.length).toBe(1);
dd.destroy();
});
it('hides a band header when filtering removes all its items', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.value = 'arxiv';
input.dispatchEvent(new Event('input'));
const headerTexts = Array.from(
dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header')
).map(h => h.textContent);
expect(headerTexts).toEqual(['Academic']);
dd.destroy();
});
it('renders no headers when options have no group_label', () => {
const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect);
input.click();
const headers = dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header');
expect(headers.length).toBe(0);
dd.destroy();
});
it('marks headers as presentational so assistive tech skips them', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.click();
const header = dropdownList.querySelector('.ldr-custom-dropdown-group-header');
expect(header.getAttribute('role')).toBe('presentation');
expect(header.getAttribute('aria-hidden')).toBe('true');
dd.destroy();
});
it('keyboard navigation lands on an item, never a header', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
// First ArrowDown must select the first ITEM (fav1), not the
// 'Favorites' header that precedes it.
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
const active = dropdownList.querySelector('.active');
expect(active.classList.contains('ldr-custom-dropdown-item')).toBe(true);
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(onSelect).toHaveBeenCalledWith('fav1', expect.objectContaining({ value: 'fav1' }));
dd.destroy();
});
it('renders both favorite stars and band headers when both apply', () => {
const onFavoriteToggle = vi.fn();
const dd = setupCustomDropdown(
input, dropdownList, () => grouped, onSelect, false, 'No results found.', onFavoriteToggle
);
input.click();
expect(dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header').length).toBe(3);
expect(dropdownList.querySelectorAll('.ldr-dropdown-favorite-star').length).toBe(4);
dd.destroy();
});
it('updateDropdownOptions re-renders band headers for the open list', () => {
const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect);
input.click();
const newOptions = [
{ value: 'wiki', label: 'Wikipedia', group_label: 'No API key' },
{ value: 'serper', label: 'Serper', group_label: 'API key' },
];
updateDropdownOptions(input, newOptions);
const headerTexts = Array.from(
dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header')
).map(h => h.textContent);
expect(headerTexts).toEqual(['No API key', 'API key']);
expect(dropdownList.querySelectorAll('.ldr-custom-dropdown-item').length).toBe(2);
dd.destroy();
});
});
});
describe('updateDropdownOptions', () => {
it('does nothing for null input', () => {
expect(() => updateDropdownOptions(null, [])).not.toThrow();
});
});
@@ -0,0 +1,133 @@
/**
* Tests for components/fallback/formatting.js
*
* Tests the fallback formatting utilities that provide basic
* implementations when the main formatting module is unavailable.
*/
// Ensure main formatting is NOT loaded (so fallback activates)
// We need ResearchStates for formatStatus
window.RESEARCH_STATUS = {
IN_PROGRESS: 'in_progress',
COMPLETED: 'completed',
FAILED: 'failed',
SUSPENDED: 'suspended',
CANCELLED: 'cancelled',
QUEUED: 'queued',
PENDING: 'pending',
ERROR: 'error',
};
window.RESEARCH_TERMINAL_STATES = new Set([
'completed', 'suspended', 'failed', 'error', 'cancelled',
]);
let fallbackFormatting;
beforeAll(async () => {
// Load ResearchStates
await import('@js/config/constants.js');
// Delete any existing formatting to force fallback to activate
delete window.formatting;
// Load fallback
await import('@js/components/fallback/formatting.js');
fallbackFormatting = window.formatting;
});
describe('fallback formatting', () => {
describe('formatDate', () => {
it('returns N/A for falsy input', () => {
expect(fallbackFormatting.formatDate(null)).toBe('N/A');
expect(fallbackFormatting.formatDate('')).toBe('N/A');
expect(fallbackFormatting.formatDate(undefined)).toBe('N/A');
});
it('formats a valid ISO date', () => {
const result = fallbackFormatting.formatDate('2025-06-15T10:30:00Z');
expect(result).toContain('2025');
expect(result).toContain('15');
});
it('returns original string for invalid date', () => {
const result = fallbackFormatting.formatDate('not-a-date');
// The function returns the original string on error
expect(result).toBeDefined();
});
});
describe('formatMode', () => {
it('maps known modes', () => {
expect(fallbackFormatting.formatMode('quick')).toBe('Quick Summary');
expect(fallbackFormatting.formatMode('detailed')).toBe('Detailed Report');
expect(fallbackFormatting.formatMode('standard')).toBe('Standard Research');
expect(fallbackFormatting.formatMode('advanced')).toBe('Advanced Research');
});
it('returns Unknown for falsy input', () => {
expect(fallbackFormatting.formatMode(null)).toBe('Unknown');
expect(fallbackFormatting.formatMode('')).toBe('Unknown');
});
it('returns raw value for unknown modes', () => {
expect(fallbackFormatting.formatMode('custom')).toBe('custom');
});
});
describe('formatDuration', () => {
it('returns N/A for falsy input (except 0)', () => {
expect(fallbackFormatting.formatDuration(null)).toBe('N/A');
expect(fallbackFormatting.formatDuration(undefined)).toBe('N/A');
});
it('formats seconds only when < 60', () => {
expect(fallbackFormatting.formatDuration(45)).toBe('45s');
});
it('formats minutes and seconds', () => {
expect(fallbackFormatting.formatDuration(125)).toBe('2m 5s');
});
it('handles zero seconds', () => {
expect(fallbackFormatting.formatDuration(0)).toBe('0s');
});
});
describe('formatFileSize', () => {
it('returns N/A for falsy input (except 0)', () => {
expect(fallbackFormatting.formatFileSize(null)).toBe('N/A');
expect(fallbackFormatting.formatFileSize(undefined)).toBe('N/A');
});
it('formats bytes', () => {
expect(fallbackFormatting.formatFileSize(500)).toBe('500.0 B');
});
it('formats kilobytes', () => {
expect(fallbackFormatting.formatFileSize(1024)).toBe('1.0 KB');
});
it('formats megabytes', () => {
expect(fallbackFormatting.formatFileSize(1048576)).toBe('1.0 MB');
});
it('formats gigabytes', () => {
expect(fallbackFormatting.formatFileSize(1073741824)).toBe('1.0 GB');
});
it('handles zero bytes', () => {
expect(fallbackFormatting.formatFileSize(0)).toBe('0.0 B');
});
it('formats with decimal precision', () => {
expect(fallbackFormatting.formatFileSize(1536)).toBe('1.5 KB');
});
});
describe('formatStatus', () => {
it('delegates to ResearchStates.formatStatus', () => {
expect(fallbackFormatting.formatStatus('completed')).toBe('Completed');
expect(fallbackFormatting.formatStatus('in_progress')).toBe('In Progress');
});
});
});
+226
View File
@@ -0,0 +1,226 @@
/**
* Tests for components/history_search.js
*
* Covers the public API exposed via window.HistorySearch:
* - triggerIndexing: convert-all then index-start sequence + error handling
* - semanticSearchHistory: POST shape + error mapping
* - renderSemanticResults: empty-state and SemanticSearch delegation
*
* Polling (startPolling) holds private module state (isIndexing,
* pollErrorCount, indexingPollInterval) and uses real setInterval — testing
* it directly here would leak state between tests, so we exercise the
* setup-and-error paths and trust integration coverage for the loop body.
*/
// Stubs MUST be in place before the module is imported, because the IIFE
// auto-runs initSemanticSearch() at the bottom (DOM is "loaded" in tests).
window.URLS = {
LIBRARY_API: {
RESEARCH_HISTORY_COLLECTION: '/library/api/research-history/collection',
RESEARCH_HISTORY_CONVERT_ALL: '/library/api/research-history/convert-all',
COLLECTION_INDEX_START: '/library/api/collections/{id}/index/start',
COLLECTION_INDEX_STATUS: '/library/api/collections/{id}/index/status',
COLLECTION_SEARCH: '/library/api/collections/{id}/search',
},
};
window.URLBuilder = {
build: (template, id) => template.replace('{id}', id),
};
window.ResearchStates = {
isTerminal: (s) => ['completed', 'failed', 'cancelled'].includes(s),
isCompleted: (s) => s === 'completed',
isFailed: (s) => s === 'failed',
isCancelled: (s) => s === 'cancelled',
};
window.api = { getCsrfToken: () => 'test-token' };
// Initial init fetch — return a collection ID so triggerIndexing won't bail
const initialFetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
success: true,
collection_id: 'coll-1',
indexed_documents: 0,
total_documents: 10,
}),
});
globalThis.fetch = initialFetchMock;
let HS;
beforeAll(async () => {
await import('@js/components/history_search.js');
HS = window.HistorySearch;
// Wait for the auto-init's fetches to settle
await new Promise(r => setTimeout(r, 0));
});
beforeEach(() => {
// Reset to a no-op fetch; each test installs what it needs
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
});
describe('HistorySearch namespace', () => {
it('exposes the expected functions', () => {
expect(typeof HS.toggleSemanticPanel).toBe('function');
expect(typeof HS.triggerIndexing).toBe('function');
expect(typeof HS.semanticSearchHistory).toBe('function');
expect(typeof HS.renderSemanticResults).toBe('function');
expect(typeof HS.getSemanticCollectionId).toBe('function');
});
it('caches collection ID from initial loadIndexingStatus', () => {
// initialFetchMock returned collection_id 'coll-1'
expect(HS.getSemanticCollectionId()).toBe('coll-1');
});
});
describe('HistorySearch.semanticSearchHistory', () => {
it('returns [] for empty query', async () => {
const r = await HS.semanticSearchHistory('');
expect(r).toEqual([]);
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it('POSTs to the collection search endpoint with query and CSRF token', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
success: true,
results: [{ research_id: 'r1', similarity: 0.8 }],
}),
});
const results = await HS.semanticSearchHistory('hello world');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, opts] = globalThis.fetch.mock.calls[0];
expect(url).toContain('coll-1');
expect(url).toContain('search');
expect(opts.method).toBe('POST');
expect(opts.headers['X-CSRFToken']).toBe('test-token');
expect(JSON.parse(opts.body)).toEqual({ query: 'hello world', limit: 20 });
expect(results).toHaveLength(1);
});
it('throws when the API returns success: false', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: false, error: 'Index missing' }),
});
await expect(HS.semanticSearchHistory('q')).rejects.toThrow('Index missing');
});
it('throws on non-OK responses', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({}),
});
await expect(HS.semanticSearchHistory('q')).rejects.toThrow(/500/);
});
it('returns empty array when results field is missing', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const r = await HS.semanticSearchHistory('q');
expect(r).toEqual([]);
});
});
describe('HistorySearch.renderSemanticResults', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('does nothing when the history-items container is missing', () => {
// No container in DOM
expect(() => HS.renderSemanticResults([{ research_id: 'a' }], 'q')).not.toThrow();
});
it('renders an empty-state message for null results', () => {
const c = document.createElement('div');
c.id = 'history-items';
document.body.appendChild(c);
HS.renderSemanticResults(null, 'q');
expect(c.querySelector('.ldr-empty-state')).not.toBeNull();
});
it('renders an empty-state message for an empty array', () => {
const c = document.createElement('div');
c.id = 'history-items';
document.body.appendChild(c);
HS.renderSemanticResults([], 'q');
expect(c.querySelector('.ldr-empty-state')).not.toBeNull();
expect(c.textContent).toContain('No matching results');
});
it('delegates each result to SemanticSearch.createSemanticResultCard', () => {
const c = document.createElement('div');
c.id = 'history-items';
document.body.appendChild(c);
const fakeCard = () => {
const el = document.createElement('div');
el.className = 'fake-card';
return el;
};
const savedSS = window.SemanticSearch;
window.SemanticSearch = {
createSemanticResultCard: vi.fn().mockImplementation(fakeCard),
};
try {
HS.renderSemanticResults(
[{ research_id: 'a' }, { research_id: 'b' }],
'q'
);
expect(window.SemanticSearch.createSemanticResultCard).toHaveBeenCalledTimes(2);
expect(c.querySelectorAll('.fake-card')).toHaveLength(2);
} finally {
window.SemanticSearch = savedSS;
}
});
});
describe('HistorySearch.toggleSemanticPanel', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('toggles display style and chevron icon class', () => {
const content = document.createElement('div');
content.id = 'semantic-panel-content';
content.style.display = 'block';
const toggle = document.createElement('i');
toggle.id = 'semantic-panel-toggle';
toggle.className = 'fas fa-chevron-down';
const header = document.createElement('div');
header.id = 'semantic-panel-header';
header.setAttribute('aria-expanded', 'true');
document.body.append(content, toggle, header);
HS.toggleSemanticPanel();
expect(content.style.display).toBe('none');
expect(toggle.className).toBe('fas fa-chevron-right');
expect(header.getAttribute('aria-expanded')).toBe('false');
HS.toggleSemanticPanel();
expect(content.style.display).toBe('block');
expect(toggle.className).toBe('fas fa-chevron-down');
expect(header.getAttribute('aria-expanded')).toBe('true');
});
it('does nothing when expected elements are missing', () => {
expect(() => HS.toggleSemanticPanel()).not.toThrow();
});
});
+273
View File
@@ -0,0 +1,273 @@
/**
* Tests for components/library_search.js
*
* Tests the LibrarySearch utilities:
* fileTypeIcon, initLibrarySearch, getIndexedCollectionIds,
* getDefaultCollectionId, searchAllCollections (deduplication logic),
* renderSemanticResults.
*/
// Stub URLBuilder before loading the module
window.URLBuilder = {
build: (template, id) => template.replace('{id}', id),
documentPage: (id) => `/library/document/${id}`,
};
window.URLS = {
LIBRARY_API: {
COLLECTION_SEARCH: '/library/api/collections/{id}/search',
},
};
import '@js/components/library_search.js';
const LS = window.LibrarySearch;
describe('LibrarySearch', () => {
describe('initLibrarySearch', () => {
it('stores default collection ID', () => {
LS.initLibrarySearch('default-coll', []);
expect(LS.getDefaultCollectionId()).toBe('default-coll');
});
it('stores collections data', () => {
LS.initLibrarySearch(null, [
{ id: 'a', indexed_document_count: 5 },
{ id: 'b', indexed_document_count: 0 },
{ id: 'c', indexed_document_count: 10 },
]);
expect(LS.getIndexedCollectionIds()).toEqual(['a', 'c']);
});
it('handles null/undefined args', () => {
LS.initLibrarySearch(null, null);
expect(LS.getDefaultCollectionId()).toBeNull();
expect(LS.getIndexedCollectionIds()).toEqual([]);
});
});
describe('getIndexedCollectionIds', () => {
it('filters out collections with 0 indexed documents', () => {
LS.initLibrarySearch(null, [
{ id: 'a', indexed_document_count: 5 },
{ id: 'b', indexed_document_count: 0 },
{ id: 'c' }, // missing field — treated as 0
]);
expect(LS.getIndexedCollectionIds()).toEqual(['a']);
});
it('returns empty array for no collections', () => {
LS.initLibrarySearch(null, []);
expect(LS.getIndexedCollectionIds()).toEqual([]);
});
});
describe('LIBRARY_CARD_CONFIG', () => {
const config = LS.getLibraryCardConfig();
it('getId returns document_id', () => {
expect(config.getId({ document_id: 'doc-1' })).toBe('doc-1');
expect(config.getId({})).toBe('');
});
it('getTitle returns title or "Untitled"', () => {
expect(config.getTitle({ title: 'My Doc' })).toBe('My Doc');
expect(config.getTitle({})).toBe('Untitled');
});
it('getUrl builds document page URL', () => {
expect(config.getUrl({ document_id: 'abc' })).toBe('/library/document/abc');
});
it('getUrl returns # for missing document_id', () => {
expect(config.getUrl({})).toBe('#');
});
it('getBadges returns file type badge', () => {
const badges = config.getBadges({ file_type: 'pdf' });
expect(badges).toHaveLength(1);
expect(badges[0].label).toBe('PDF');
expect(badges[0].icon).toBe('file-pdf');
});
it('getBadges returns default DOC badge when no file type', () => {
const badges = config.getBadges({});
expect(badges[0].label).toBe('DOC');
expect(badges[0].icon).toBe('file');
});
it('getBadges maps known file types to icons', () => {
expect(config.getBadges({ file_type: 'txt' })[0].icon).toBe('file-alt');
expect(config.getBadges({ file_type: 'md' })[0].icon).toBe('file-code');
expect(config.getBadges({ file_type: 'markdown' })[0].icon).toBe('file-code');
expect(config.getBadges({ file_type: 'html' })[0].icon).toBe('file-code');
});
it('getDate returns created_at', () => {
expect(config.getDate({ created_at: '2025-01-15' })).toBe('2025-01-15');
expect(config.getDate({})).toBeNull();
});
it('getSubtitle returns domain', () => {
expect(config.getSubtitle({ domain: 'example.com' })).toBe('example.com');
expect(config.getSubtitle({})).toBeNull();
});
});
describe('searchAllCollections deduplication', () => {
let originalFetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('returns empty array for empty collection list', async () => {
const result = await LS.searchAllCollections([], 'query');
expect(result).toEqual([]);
});
it('deduplicates results by document_id, keeping highest similarity', async () => {
// Mock fetch to return overlapping results from two collections
globalThis.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
success: true,
results: [
{ document_id: 'doc1', similarity: 0.5 },
{ document_id: 'doc2', similarity: 0.9 },
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
success: true,
results: [
{ document_id: 'doc1', similarity: 0.8 }, // higher than first
{ document_id: 'doc3', similarity: 0.6 },
],
}),
});
const result = await LS.searchAllCollections(['c1', 'c2'], 'test');
// Should have 3 unique docs
expect(result).toHaveLength(3);
// doc1 should have similarity 0.8 (the higher one)
const doc1 = result.find(r => r.document_id === 'doc1');
expect(doc1.similarity).toBe(0.8);
});
it('sorts results by similarity descending', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
success: true,
results: [
{ document_id: 'a', similarity: 0.3 },
{ document_id: 'b', similarity: 0.9 },
{ document_id: 'c', similarity: 0.6 },
],
}),
});
const result = await LS.searchAllCollections(['c1'], 'test');
expect(result.map(r => r.document_id)).toEqual(['b', 'c', 'a']);
});
it('handles failed search responses gracefully', async () => {
globalThis.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: false }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
success: true,
results: [{ document_id: 'd1', similarity: 0.7 }],
}),
});
const result = await LS.searchAllCollections(['c1', 'c2'], 'test');
expect(result).toHaveLength(1);
expect(result[0].document_id).toBe('d1');
});
it('catches per-collection fetch errors', async () => {
globalThis.fetch = vi.fn()
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
success: true,
results: [{ document_id: 'd1', similarity: 0.5 }],
}),
});
const result = await LS.searchAllCollections(['c1', 'c2'], 'test');
expect(result).toHaveLength(1);
});
it('respects limit parameter', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
success: true,
results: Array.from({ length: 30 }, (_, i) => ({
document_id: `doc${i}`,
similarity: 1 - i * 0.01,
})),
}),
});
const result = await LS.searchAllCollections(['c1'], 'test', 5);
expect(result).toHaveLength(5);
});
it('skips results without document_id', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
success: true,
results: [
{ document_id: 'a', similarity: 0.5 },
{ similarity: 0.9 }, // missing document_id
],
}),
});
const result = await LS.searchAllCollections(['c1'], 'test');
expect(result).toHaveLength(1);
expect(result[0].document_id).toBe('a');
});
});
describe('renderSemanticResults', () => {
it('renders empty state when no results', () => {
const container = document.createElement('div');
LS.renderSemanticResults([], container, 'query');
expect(container.querySelector('.ldr-empty-state')).not.toBeNull();
});
it('renders empty state for null results', () => {
const container = document.createElement('div');
LS.renderSemanticResults(null, container, 'query');
expect(container.querySelector('.ldr-empty-state')).not.toBeNull();
});
it('does nothing when container is null', () => {
expect(() => LS.renderSemanticResults([], null, 'q')).not.toThrow();
});
it('renders fallback message when SemanticSearch module not loaded', () => {
const container = document.createElement('div');
// SemanticSearch not loaded in tests
LS.renderSemanticResults([{ document_id: '1' }], container, 'q');
expect(container.textContent).toContain('Search module not loaded');
});
});
});
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
/**
* Tests for components/news.js
*
* Tests the news utility functions: formatTimeAgo, debounce, createTag,
* showEmptyState, showLoadingState, showErrorState, showModal/hideModal.
*/
import '@js/components/news.js';
describe('NewsUtils', () => {
describe('formatTimeAgo', () => {
const now = new Date('2025-06-15T12:00:00Z');
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterEach(() => {
vi.useRealTimers();
});
it('returns "just now" for < 60 seconds', () => {
const time = new Date(now.getTime() - 30 * 1000);
expect(window.formatTimeAgo(time)).toBe('just now');
});
it('returns minutes ago for < 1 hour', () => {
const time = new Date(now.getTime() - 5 * 60 * 1000);
expect(window.formatTimeAgo(time)).toBe('5 minutes ago');
});
it('returns hours ago for < 1 day', () => {
const time = new Date(now.getTime() - 3 * 60 * 60 * 1000);
expect(window.formatTimeAgo(time)).toBe('3 hours ago');
});
it('returns days ago for < 1 week', () => {
const time = new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000);
expect(window.formatTimeAgo(time)).toBe('4 days ago');
});
it('returns date string for older timestamps', () => {
const time = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const result = window.formatTimeAgo(time);
// Should be a date string, not "X ago"
expect(result).not.toContain('ago');
});
});
describe('createTag', () => {
it('creates a span element with the text', () => {
const tag = window.createTag('javascript');
expect(tag.tagName).toBe('SPAN');
expect(tag.textContent).toBe('javascript');
});
it('uses default ldr-tag class', () => {
const tag = window.createTag('test');
expect(tag.className).toBe('ldr-tag');
});
it('accepts custom class name', () => {
const tag = window.createTag('test', 'custom-class');
expect(tag.className).toBe('custom-class');
});
it('uses textContent (XSS-safe)', () => {
const tag = window.createTag('<script>alert(1)</script>');
expect(tag.innerHTML).not.toContain('<script>');
expect(tag.textContent).toBe('<script>alert(1)</script>');
});
});
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('delays function execution', () => {
const fn = vi.fn();
const debounced = window.debounce(fn, 100);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});
it('cancels previous calls when called again', () => {
const fn = vi.fn();
const debounced = window.debounce(fn, 100);
debounced();
vi.advanceTimersByTime(50);
debounced();
vi.advanceTimersByTime(50);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(fn).toHaveBeenCalledTimes(1);
});
it('passes arguments to debounced function', () => {
const fn = vi.fn();
const debounced = window.debounce(fn, 50);
debounced('a', 'b', 'c');
vi.advanceTimersByTime(50);
expect(fn).toHaveBeenCalledWith('a', 'b', 'c');
});
});
describe('showEmptyState', () => {
it('renders empty state HTML in container', () => {
const container = document.createElement('div');
window.showEmptyState(container, 'No data');
expect(container.querySelector('.ldr-empty-state')).not.toBeNull();
expect(container.textContent).toContain('No data');
});
it('uses default newspaper icon', () => {
const container = document.createElement('div');
window.showEmptyState(container, 'Empty');
expect(container.innerHTML).toContain('fa-newspaper');
});
it('accepts custom icon', () => {
const container = document.createElement('div');
window.showEmptyState(container, 'Empty', 'fas fa-search');
expect(container.innerHTML).toContain('fa-search');
});
it('escapes HTML in message', () => {
const container = document.createElement('div');
window.showEmptyState(container, '<script>xss</script>');
expect(container.innerHTML).not.toContain('<script>');
});
});
describe('showLoadingState', () => {
it('renders loading state HTML', () => {
const container = document.createElement('div');
window.showLoadingState(container);
expect(container.querySelector('.ldr-loading-placeholder')).not.toBeNull();
expect(container.textContent).toContain('Loading...');
});
it('accepts custom message', () => {
const container = document.createElement('div');
window.showLoadingState(container, 'Fetching data...');
expect(container.textContent).toContain('Fetching data...');
});
it('escapes HTML in message', () => {
const container = document.createElement('div');
window.showLoadingState(container, '<img src=x onerror=alert(1)>');
expect(container.innerHTML).not.toContain('<img');
});
});
describe('showErrorState', () => {
it('renders error state HTML', () => {
const container = document.createElement('div');
window.showErrorState(container, 'Connection failed');
expect(container.querySelector('.ldr-error-state')).not.toBeNull();
expect(container.textContent).toContain('Connection failed');
});
it('escapes HTML in message', () => {
const container = document.createElement('div');
window.showErrorState(container, '<script>xss</script>');
expect(container.innerHTML).not.toContain('<script>');
});
});
describe('showModal / hideModal', () => {
let modal;
beforeEach(() => {
modal = document.createElement('div');
modal.id = 'test-modal';
modal.style.display = 'none';
document.body.appendChild(modal);
});
afterEach(() => {
modal.remove();
document.body.style.overflow = '';
});
it('shows the modal by ID', () => {
window.showModal('test-modal');
expect(modal.style.display).toBe('flex');
});
it('locks body scroll when shown', () => {
window.showModal('test-modal');
expect(document.body.style.overflow).toBe('hidden');
});
it('hides the modal by ID', () => {
window.showModal('test-modal');
window.hideModal('test-modal');
expect(modal.style.display).toBe('none');
});
it('restores body scroll when hidden', () => {
window.showModal('test-modal');
window.hideModal('test-modal');
expect(document.body.style.overflow).toBe('auto');
});
it('does nothing for non-existent modal', () => {
expect(() => window.showModal('nonexistent')).not.toThrow();
expect(() => window.hideModal('nonexistent')).not.toThrow();
});
});
});
@@ -0,0 +1,139 @@
/**
* Tests for the agent-thinking panel renderer in components/progress.js
* (the `updateAgentThinking` function).
*
* Regression coverage for the "Using web_search" display bug
* (PR #4470 / fix/display-actual-engine-name):
*
* The LangGraph strategy puts the friendly engine name in the progress
* event's `data.message` (e.g. `🔍 Searching DuckDuckGo: "..."`) while
* keeping the stable tool id in `data.tool` ("web_search"). The renderer
* must surface the friendly message, not "Using web_search".
*
* The MCP strategy puts the friendly label in `data.message`
* (`ACTION: Using DuckDuckGo - "..."`) AND also supplies `data.arguments`.
* The renderer must show the message verbatim without duplicating the
* query that's already embedded in it.
*
* When no message is supplied, the renderer falls back to
* `Using ${data.tool}` (+ args), so the panel never renders blank.
*/
let progressComponent;
beforeAll(async () => {
// progress.js is an IIFE; importing it runs the module and exposes
// window.progressComponent (which now includes updateAgentThinking).
await import('@js/components/progress.js');
progressComponent = window.progressComponent;
});
beforeEach(() => {
// updateAgentThinking queries these two ids and appends step nodes.
document.body.innerHTML = `
<div id="agent-thinking-panel" style="display: none;">
<div id="agent-thinking-content"></div>
</div>
`;
});
function renderToolCall(data) {
progressComponent.updateAgentThinking({ phase: 'tool_call', ...data });
const step = document
.getElementById('agent-thinking-content')
.querySelector('.ldr-agent-step-content');
return step ? step.textContent : null;
}
describe('updateAgentThinking - tool_call rendering', () => {
it('shows the LangGraph friendly message instead of "Using web_search"', () => {
const content = renderToolCall({
tool: 'web_search', // stable id, must NOT be the display source
message: '🔍 Searching DuckDuckGo: "climate policy"',
iteration: 1,
});
expect(content).toBe('🔍 Searching DuckDuckGo: "climate policy"');
expect(content).not.toContain('web_search');
expect(content).not.toContain('Using ');
});
it('shows the MCP friendly message without duplicating the query', () => {
const content = renderToolCall({
tool: 'DuckDuckGo',
message: 'ACTION: Using DuckDuckGo - "climate policy"',
arguments: { query: 'climate policy' },
});
expect(content).toBe('ACTION: Using DuckDuckGo - "climate policy"');
// The query is embedded in the message; it must appear exactly once
// (no extra `\nQuery: "..."` appended from data.arguments).
const occurrences = content.split('climate policy').length - 1;
expect(occurrences).toBe(1);
expect(content).not.toContain('Query:');
});
it('falls back to "Using <tool>" with query when no message is present', () => {
const content = renderToolCall({
tool: 'web_search',
arguments: { query: 'climate policy' },
});
expect(content).toBe('Using web_search\nQuery: "climate policy"');
});
it('falls back to "Using unknown" when neither message nor tool is present', () => {
const content = renderToolCall({});
expect(content).toBe('Using unknown');
});
});
function renderObservation(data) {
progressComponent.updateAgentThinking({ phase: 'observation', ...data });
const step = document
.getElementById('agent-thinking-content')
.querySelector('.ldr-agent-step-content');
return step ? step.textContent : null;
}
describe('updateAgentThinking - observation rendering', () => {
it('keeps the "From {engine}" attribution and appends data.content beneath it', () => {
const content = renderObservation({
message: '📄 From the web (SearXNG): [1] Some Title (http://a.com) Snip',
content: '[1] Some Title (http://a.com)\nThe full snippet body.',
});
expect(content).toBe(
'📄 From the web (SearXNG): [1] Some Title (http://a.com) Snip\n'
+ '[1] Some Title (http://a.com)\nThe full snippet body.'
);
});
it('shows the message alone when no content is attached (short outputs)', () => {
const content = renderObservation({
message: '📄 From PubMed: No results.',
});
expect(content).toBe('📄 From PubMed: No results.');
});
it('falls back to content when no message is present', () => {
const content = renderObservation({
content: 'raw tool output only',
});
expect(content).toBe('raw tool output only');
});
it('truncates the composed result to 800 chars', () => {
const content = renderObservation({
message: '📄 From arXiv: long',
content: 'z'.repeat(1000),
});
expect(content.length).toBe(803); // 800 + '...'
expect(content.endsWith('...')).toBe(true);
expect(content.startsWith('📄 From arXiv: long\n')).toBe(true);
});
});
+241
View File
@@ -0,0 +1,241 @@
/**
* Tests for components/research.js — the "a model must be selected" guard on
* the New Research form's submit path.
*
* research.js is a self-contained IIFE that wires everything up in
* initializeResearch() on DOMContentLoaded and exports nothing. happy-dom has
* already fired DOMContentLoaded by the time the module imports, so the
* listener never auto-runs (see followup.test.js for the same mechanic). We
* build the real form DOM first, dispatch DOMContentLoaded ourselves to drive
* the real initialization (which registers the real submit handler + the real
* FormValidator wiring), then submit the real form to exercise the guard.
*
* Asserting the inline error *text* — not just "no request was sent" — is
* deliberate: it proves the model field was actually registered with the
* validator, so the test breaks if that wiring is removed, not only if the
* submit-time guard is.
*
* The fixture wraps the model field in the real .ldr-advanced-options-panel
* because in production that field lives inside the (collapsible) Advanced
* Options panel — the guard has to reveal it for the error to be seen.
*/
import '@js/config/urls.js'; // window.URLS (URLS.API.START_RESEARCH)
import '@js/utils/alert-helpers.js'; // window.LdrAlertHelpers (used by showSafeAlert)
import '@js/security/xss-protection.js'; // showSafeAlert, safeUpdateButton, createSafeLoadingOverlay
import '@js/utils/form-validation.js'; // FormValidator, formValidators
import '@js/components/custom_dropdown.js'; // setupCustomDropdown (used by initializeDropdowns)
const START_RESEARCH = '/api/start_research';
const CHAT_SESSIONS = '/api/chat/sessions';
const AVAILABLE_MODELS = '/settings/api/available-models';
function buildForm() {
document.body.innerHTML = `
<form id="research-form">
<div id="research-alert" role="alert" style="display:none"></div>
<textarea id="query" name="query"></textarea>
<label class="ldr-mode-option"><input type="radio" name="research_mode" value="quick" checked></label>
<label class="ldr-mode-option"><input type="radio" name="research_mode" value="detailed"></label>
<label class="ldr-mode-option"><input type="radio" name="research_mode" value="chat"></label>
<button type="button" class="ldr-advanced-options-toggle ldr-open" aria-expanded="true">
<i class="fas fa-chevron-up"></i><span class="sr-only"></span>
</button>
<div class="ldr-advanced-options-panel ldr-expanded" id="advanced-options-panel" role="group">
<select id="model_provider"><option value="OLLAMA" selected>Ollama</option></select>
<input type="text" id="model">
<input type="hidden" id="model_hidden" value="">
<div id="model-dropdown"><div id="model-dropdown-list"></div></div>
<button type="button" id="model-refresh"></button>
<input type="text" id="search_engine">
<input type="hidden" id="search_engine_hidden" value="searxng">
<div id="search-engine-dropdown"><div id="search-engine-dropdown-list"></div></div>
<button type="button" id="search_engine-refresh"></button>
<select id="strategy"><option value="source-based" selected>source-based</option></select>
<input id="iterations" value="2">
<input id="questions_per_iteration" value="3">
</div>
<button type="submit" id="start-research-btn"><span></span></button>
</form>
`;
}
let fetchMock;
// Number of GET /available-models requests fired synchronously during init.
// Both loadModelOptions(false) call sites run synchronously inside
// initializeResearch (before any await), so a synchronous capture right after
// dispatch deterministically distinguishes "loaded once" (1) from the old
// duplicate-concurrent-load bug (2).
let initAvailableModelsCalls;
beforeAll(async () => {
// Keep init's model/search-engine loads and any submit request happy; the
// responses deliberately return a non-success status so neither the
// research success handler nor the chat success handler navigates
// (sets window.location.href) during a test.
fetchMock = vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
status: 'error',
message: 'stubbed — no navigation',
providers: {},
provider_options: [],
models: [],
engines: [],
}),
text: () => Promise.resolve(''),
})
);
globalThis.fetch = fetchMock;
window.api = { getCsrfToken: () => 'test-csrf' };
window.RESEARCH_STATUS = { QUEUED: 'queued', IN_PROGRESS: 'in_progress' };
buildForm();
await import('@js/components/research.js');
// Drive the real initializeResearch(): caches DOM refs, wires the submit
// handler and the query + model validators, applies the advanced-panel
// state.
document.dispatchEvent(new Event('DOMContentLoaded'));
// Capture the model-list request count synchronously, before any async
// reload path can add to it (see initAvailableModelsCalls above).
initAvailableModelsCalls = fetchMock.mock.calls.filter(
([u]) => u === AVAILABLE_MODELS
).length;
// Let the fire-and-forget model/search-engine loads settle.
await Promise.resolve();
await Promise.resolve();
});
beforeEach(() => {
fetchMock.mockClear();
// A non-empty query so the query guard (which runs first) always passes and
// the model guard is the thing under test. Individual tests override this.
document.getElementById('query').value = 'What is quantum computing?';
document.getElementById('model').value = '';
document.getElementById('model_hidden').value = '';
// Reset mode back to quick (a research mode).
document.querySelector('input[name="research_mode"][value="quick"]').checked = true;
document.querySelector('input[name="research_mode"][value="detailed"]').checked = false;
document.querySelector('input[name="research_mode"][value="chat"]').checked = false;
// Reset the advanced panel to expanded and clear stale validation state.
document.getElementById('advanced-options-panel').classList.add('ldr-expanded');
const modelInput = document.getElementById('model');
modelInput.classList.remove('ldr-field-invalid');
modelInput.removeAttribute('aria-invalid');
const btn = document.getElementById('start-research-btn');
btn.disabled = false;
document.querySelectorAll('.ldr-loading-overlay').forEach((o) => o.remove());
});
function submitForm() {
document
.getElementById('research-form')
.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
function callsTo(url) {
return fetchMock.mock.calls.filter(([u]) => u === url);
}
describe('research form — model-required guard', () => {
it('blocks submission, shows an inline error, and focuses the model field when no model is selected', () => {
document.getElementById('model_hidden').value = '';
submitForm();
// No research request was sent.
expect(callsTo(START_RESEARCH)).toHaveLength(0);
// The model field is flagged invalid...
const modelInput = document.getElementById('model');
expect(modelInput.getAttribute('aria-invalid')).toBe('true');
// ...with the expected inline message (proves the validator wiring)...
const errorEl = document.getElementById('model-error');
expect(errorEl).not.toBeNull();
expect(errorEl.textContent).toContain('Please select or enter a model.');
// ...and the field is focused so the user is taken straight to it.
expect(document.activeElement).toBe(modelInput);
});
it('treats a whitespace-only model as empty', () => {
document.getElementById('model_hidden').value = ' ';
submitForm();
expect(callsTo(START_RESEARCH)).toHaveLength(0);
expect(document.getElementById('model').getAttribute('aria-invalid')).toBe('true');
});
it('expands the collapsed Advanced Options panel so the model error is visible', () => {
// Simulate a returning user who had collapsed the panel: the model
// field (and its error <div>) would otherwise be inside a hidden
// subtree, giving zero feedback on submit.
const panel = document.getElementById('advanced-options-panel');
panel.classList.remove('ldr-expanded');
document.getElementById('model_hidden').value = '';
submitForm();
expect(panel.classList.contains('ldr-expanded')).toBe(true);
expect(document.getElementById('model-error').textContent).toContain(
'Please select or enter a model.'
);
expect(callsTo(START_RESEARCH)).toHaveLength(0);
});
it('submits the research request once a model is present', () => {
document.getElementById('model_hidden').value = 'qwen3:4b';
submitForm();
const calls = callsTo(START_RESEARCH);
expect(calls).toHaveLength(1);
// The chosen model is included in the request payload.
const body = JSON.parse(calls[0][1].body);
expect(body.model).toBe('qwen3:4b');
expect(body.query).toBe('What is quantum computing?');
});
it('does NOT require a model in chat mode (chat never sends the model field)', () => {
document.querySelector('input[name="research_mode"][value="quick"]').checked = false;
document.querySelector('input[name="research_mode"][value="chat"]').checked = true;
document.getElementById('model_hidden').value = ''; // no model selected
submitForm();
// Not blocked by the model guard: the field is not flagged...
expect(document.getElementById('model').getAttribute('aria-invalid')).toBeNull();
// ...and the chat path runs (hits the chat-session endpoint, not the
// research endpoint).
expect(callsTo(CHAT_SESSIONS)).toHaveLength(1);
expect(callsTo(START_RESEARCH)).toHaveLength(0);
});
it('blocks on an empty query even when a model is selected', () => {
document.getElementById('query').value = '';
document.getElementById('model_hidden').value = 'qwen3:4b';
submitForm();
expect(callsTo(START_RESEARCH)).toHaveLength(0);
expect(document.getElementById('query').getAttribute('aria-invalid')).toBe('true');
});
});
describe('research form — model list loading', () => {
it('fetches the model list only once per page load (no duplicate concurrent request)', () => {
// Regression guard: initializeResearch() and setupEventListeners() each
// used to fire their own loadModelOptions(false), landing two concurrent
// /available-models requests on every load and contending for a cold
// Ollama — a primary reason the model dropdown came up empty.
expect(initAvailableModelsCalls).toBe(1);
});
});
+204
View File
@@ -0,0 +1,204 @@
/**
* Tests for components/semantic_search.js
*
* Focus on the pure data-shaping helpers exposed via window.SemanticSearch:
* - buildTieredResults: 3-tier merge with dedup + sort
* - isSafeExternalUrl: URL scheme validation (security-critical)
*
* createSemanticResultCard and renderSnippet do DOM/markdown work and depend
* on optional libraries (marked, DOMPurify) — exercised via integration in
* library-search tests already.
*/
import '@js/components/semantic_search.js';
const SS = window.SemanticSearch;
describe('SemanticSearch.buildTieredResults', () => {
it('returns empty tiers for empty inputs', () => {
const r = SS.buildTieredResults([], []);
expect(r.tier1).toEqual([]);
expect(r.tier2).toEqual([]);
expect(r.tier3).toEqual([]);
});
it('puts text-only matches in tier2 (preserving original order)', () => {
const text = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const r = SS.buildTieredResults(text, []);
expect(r.tier1).toEqual([]);
expect(r.tier3).toEqual([]);
expect(r.tier2.map(x => x.historyItem.id)).toEqual(['a', 'b', 'c']);
});
it('puts semantic-only matches in tier3', () => {
const sem = [
{ research_id: 'x', similarity: 0.5 },
{ research_id: 'y', similarity: 0.8 },
];
const r = SS.buildTieredResults([], sem);
expect(r.tier1).toEqual([]);
expect(r.tier2).toEqual([]);
expect(r.tier3).toHaveLength(2);
});
it('places items appearing in both tiers in tier1 with semanticMatch populated', () => {
const text = [{ id: 'a' }, { id: 'b' }];
const sem = [
{ research_id: 'a', similarity: 0.7, snippet: 'hi' },
{ research_id: 'c', similarity: 0.9 },
];
const r = SS.buildTieredResults(text, sem);
expect(r.tier1).toHaveLength(1);
expect(r.tier1[0].historyItem.id).toBe('a');
expect(r.tier1[0].semanticMatch.similarity).toBe(0.7);
expect(r.tier1[0].semanticMatch.snippet).toBe('hi');
// 'b' was text-only → tier2
expect(r.tier2.map(x => x.historyItem.id)).toEqual(['b']);
// 'c' was semantic-only → tier3
expect(r.tier3.map(x => x.semanticResult.research_id)).toEqual(['c']);
});
it('sorts tier1 by similarity DESC', () => {
const text = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const sem = [
{ research_id: 'a', similarity: 0.3 },
{ research_id: 'b', similarity: 0.9 },
{ research_id: 'c', similarity: 0.6 },
];
const r = SS.buildTieredResults(text, sem);
expect(r.tier1.map(x => x.historyItem.id)).toEqual(['b', 'c', 'a']);
});
it('sorts tier3 by similarity DESC', () => {
const sem = [
{ research_id: 'a', similarity: 0.2 },
{ research_id: 'b', similarity: 0.95 },
{ research_id: 'c', similarity: 0.5 },
];
const r = SS.buildTieredResults([], sem);
expect(r.tier3.map(x => x.semanticResult.research_id)).toEqual(['b', 'c', 'a']);
});
it('dedups semantic results by ID, keeping the highest similarity', () => {
const sem = [
{ research_id: 'dup', similarity: 0.3 },
{ research_id: 'dup', similarity: 0.8 },
{ research_id: 'dup', similarity: 0.5 },
];
const r = SS.buildTieredResults([], sem);
expect(r.tier3).toHaveLength(1);
expect(r.tier3[0].semanticResult.similarity).toBe(0.8);
});
it('skips semantic results missing the configured ID key', () => {
const sem = [
{ research_id: 'a', similarity: 0.5 },
{ similarity: 0.9 }, // missing research_id
{ research_id: '', similarity: 0.7 }, // empty falsy
];
const r = SS.buildTieredResults([], sem);
expect(r.tier3).toHaveLength(1);
expect(r.tier3[0].semanticResult.research_id).toBe('a');
});
it('honors custom textIdKey and semanticIdKey', () => {
const text = [{ doc_id: 'x' }];
const sem = [{ id: 'x', similarity: 0.5 }];
const r = SS.buildTieredResults(text, sem, {
textIdKey: 'doc_id',
semanticIdKey: 'id',
});
expect(r.tier1).toHaveLength(1);
expect(r.tier1[0].historyItem.doc_id).toBe('x');
});
it('uses default keys when options is undefined', () => {
const text = [{ id: 'k' }];
const sem = [{ research_id: 'k', similarity: 0.5 }];
const r = SS.buildTieredResults(text, sem);
expect(r.tier1).toHaveLength(1);
});
it('coerces IDs to strings for matching (number vs string)', () => {
const text = [{ id: 42 }];
const sem = [{ research_id: '42', similarity: 0.5 }];
const r = SS.buildTieredResults(text, sem);
expect(r.tier1).toHaveLength(1);
});
it('defaults snippet to empty string when missing', () => {
const text = [{ id: 'a' }];
const sem = [{ research_id: 'a', similarity: 0.5 }];
const r = SS.buildTieredResults(text, sem);
expect(r.tier1[0].semanticMatch.snippet).toBe('');
});
});
describe('SemanticSearch.isSafeExternalUrl', () => {
let savedValidator;
beforeEach(() => {
// Force the fallback path so we test the inline scheme list,
// not URLValidator's behavior (covered by url-validator tests).
savedValidator = window.URLValidator;
delete window.URLValidator;
});
afterEach(() => {
if (savedValidator !== undefined) window.URLValidator = savedValidator;
});
it('accepts http and https URLs', () => {
expect(SS.isSafeExternalUrl('http://example.com')).toBe(true);
expect(SS.isSafeExternalUrl('https://example.com/path?q=1')).toBe(true);
});
it('rejects javascript: URLs', () => {
expect(SS.isSafeExternalUrl('javascript:alert(1)')).toBe(false);
});
it('rejects data: URLs', () => {
expect(SS.isSafeExternalUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
});
it('rejects vbscript: URLs', () => {
expect(SS.isSafeExternalUrl('vbscript:msgbox(1)')).toBe(false);
});
it('rejects about:, blob:, file: schemes', () => {
expect(SS.isSafeExternalUrl('about:blank')).toBe(false);
expect(SS.isSafeExternalUrl('blob:https://example.com/abc')).toBe(false);
expect(SS.isSafeExternalUrl('file:///etc/passwd')).toBe(false);
});
it('rejects non-string input', () => {
expect(SS.isSafeExternalUrl(null)).toBe(false);
expect(SS.isSafeExternalUrl(undefined)).toBe(false);
expect(SS.isSafeExternalUrl(42)).toBe(false);
expect(SS.isSafeExternalUrl({})).toBe(false);
});
it('rejects empty string', () => {
expect(SS.isSafeExternalUrl('')).toBe(false);
});
it('is case-insensitive against scheme obfuscation', () => {
expect(SS.isSafeExternalUrl('JaVaScRiPt:alert(1)')).toBe(false);
});
it('rejects relative URLs (no scheme)', () => {
expect(SS.isSafeExternalUrl('/relative/path')).toBe(false);
expect(SS.isSafeExternalUrl('example.com')).toBe(false);
});
it('delegates to URLValidator when present', () => {
window.URLValidator = {
isSafeUrl: vi.fn().mockReturnValue(true),
};
expect(SS.isSafeExternalUrl('https://example.com')).toBe(true);
expect(window.URLValidator.isSafeUrl).toHaveBeenCalledWith(
'https://example.com',
{ allowMailto: false }
);
});
});
@@ -0,0 +1,197 @@
/**
* Regression tests for processModelData in components/settings.js
*
* Issue #3800: LM Studio model dropdown was empty because the frontend's
* processModelData had hardcoded if-blocks for only ollama_models,
* openai_models, anthropic_models, and openai_endpoint_models. The fix
* (PR #3942) replaces the four hardcoded blocks with a generic loop
* that lifts every <provider>_models array.
*
* Strategy
* --------
* processModelData is defined inside an IIFE in settings.js and never
* exposed on window, so we cannot import settings.js and call it. Instead
* we read the file as text, locate the loop block (the chunk between
* `if (data.providers) {` and its matching `}`), and execute that exact
* source against a synthetic `data` object inside a new Function. This
* tests the literal shipped code, not a re-implementation.
*
* If the loop block can't be located (e.g. someone refactors the function
* shape later), the locator throws and every test fails loudly — that is
* the intended failure mode, not a fallback.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const SETTINGS_PATH = resolve(
__dirname,
'../../../src/local_deep_research/web/static/js/components/settings.js',
);
/**
* Extract the full `if (data.providers) { ... }` statement (guard
* included) from settings.js by brace-matching. We need the guard so
* that the missing-providers test exercises the same null-safety as
* production.
*/
function extractProvidersLoop(source) {
const marker = 'if (data.providers) {';
const start = source.indexOf(marker);
if (start < 0) {
throw new Error(
'Could not find `if (data.providers) {` in settings.js — ' +
'the fix may have been refactored. Update this test.',
);
}
const openBrace = start + marker.length - 1; // index of `{`
let depth = 0;
let i = openBrace;
for (; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) break;
}
}
if (depth !== 0) {
throw new Error('Brace mismatch when extracting providers loop.');
}
// Return the whole statement including the `if (...)` guard.
return source.slice(start, i + 1);
}
const SOURCE = readFileSync(SETTINGS_PATH, 'utf8');
const LOOP_BODY = extractProvidersLoop(SOURCE);
/**
* Run the extracted loop body against `data`, returning the populated
* formattedModels array. Mirrors the surrounding processModelData
* scaffolding (formattedModels declaration, SafeLogger stub).
*/
function runLoop(data) {
const formattedModels = [];
const SafeLogger = { log: () => {}, warn: () => {}, error: () => {} };
// The loop body references `data`, `formattedModels`, and `SafeLogger`.
// eslint-disable-next-line no-new-func
const fn = new Function(
'data', 'formattedModels', 'SafeLogger',
LOOP_BODY,
);
fn(data, formattedModels, SafeLogger);
return formattedModels;
}
describe('processModelData providers loop (issue #3800)', () => {
it('lifts lmstudio_models tagged as LMSTUDIO (the bug)', () => {
const data = {
providers: {
lmstudio_models: [
{ value: 'qwen-7b', label: 'qwen-7b' },
{ value: 'phi-3', label: 'Phi 3' },
],
},
};
const out = runLoop(data);
expect(out).toHaveLength(2);
expect(out[0]).toEqual({
value: 'qwen-7b',
label: 'qwen-7b',
provider: 'LMSTUDIO',
});
expect(out[1].provider).toBe('LMSTUDIO');
});
it('lifts llamacpp_models tagged as LLAMACPP', () => {
const data = {
providers: {
llamacpp_models: [
{ value: 'llama-3-8b.gguf', label: 'Llama 3 8B' },
],
},
};
const out = runLoop(data);
expect(out).toEqual([
{
value: 'llama-3-8b.gguf',
label: 'Llama 3 8B',
provider: 'LLAMACPP',
},
]);
});
it('still handles ollama_models (backwards compat)', () => {
const data = {
providers: {
ollama_models: [{ value: 'llama3', label: 'Llama 3' }],
},
};
const out = runLoop(data);
expect(out).toEqual([
{ value: 'llama3', label: 'Llama 3', provider: 'OLLAMA' },
]);
});
it('preserves OPENAI_ENDPOINT casing (underscore in tag)', () => {
const data = {
providers: {
openai_endpoint_models: [
{ value: 'gpt-x', label: 'gpt-x' },
],
},
};
const out = runLoop(data);
expect(out[0].provider).toBe('OPENAI_ENDPOINT');
});
it('lifts multiple providers in one response', () => {
const data = {
providers: {
ollama_models: [{ value: 'llama3', label: 'Llama 3' }],
lmstudio_models: [{ value: 'qwen-7b', label: 'qwen-7b' }],
openai_models: [{ value: 'gpt-4', label: 'GPT-4' }],
},
};
const out = runLoop(data);
const providers = out.map(m => m.provider).sort();
expect(providers).toEqual(['LMSTUDIO', 'OLLAMA', 'OPENAI']);
expect(out).toHaveLength(3);
});
it('handles empty providers dict without errors', () => {
const out = runLoop({ providers: {} });
expect(out).toEqual([]);
});
it('handles missing providers (undefined) without errors', () => {
const out = runLoop({});
expect(out).toEqual([]);
});
it('skips empty arrays', () => {
const out = runLoop({ providers: { lmstudio_models: [] } });
expect(out).toEqual([]);
});
it('skips non-array values (defensive)', () => {
const out = runLoop({
providers: {
lmstudio_models: 'not an array',
ollama_models: [{ value: 'a', label: 'A' }],
},
});
expect(out).toHaveLength(1);
expect(out[0].provider).toBe('OLLAMA');
});
it('ignores keys without _models suffix', () => {
const out = runLoop({
providers: {
some_other_key: [{ value: 'x', label: 'x' }],
lmstudio_models: [{ value: 'qwen', label: 'qwen' }],
},
});
expect(out).toHaveLength(1);
expect(out[0].provider).toBe('LMSTUDIO');
});
});
@@ -0,0 +1,130 @@
/**
* Tests for components/subscription-manager.js — renderSubscriptionCard.
*
* The card is built by template-string interpolation, so the assertions
* focus on data correctness (escaping, branching) rather than CSS class
* spelling. Covers active vs paused, optional folder/notes, and the
* data-subscription-id round-trip used by the action buttons.
*
* Kept in its own file (instead of extending subscription-manager.test.js)
* so it can land alongside / independently of PR #4297 without merge
* conflicts on a freshly-created test file.
*/
// Load xss-protection.js first so window.escapeHtml is defined: the card
// renderer escapes user fields via the global helper (the class no longer
// ships its own escapeHtml). In production, base.html loads xss-protection.js
// before subscription-manager.js with matching `defer` ordering.
import '@js/security/xss-protection.js';
import '@js/components/subscription-manager.js';
let manager;
beforeAll(() => {
// The module wires up its singleton inside a DOMContentLoaded listener;
// dispatch it manually if happy-dom already fired before the dynamic
// import settled.
if (!window.subscriptionManager) {
document.dispatchEvent(new Event('DOMContentLoaded'));
}
manager = window.subscriptionManager;
});
function makeSubscription(overrides = {}) {
// A baseline subscription that exercises the always-present fields.
// Use a far-future next_refresh so the computed timeUntil is stable
// (e.g. "365d" rather than something time-sensitive).
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
return {
id: 'sub-abc-123',
query_or_topic: 'AI safety',
refresh_interval_minutes: 60,
next_refresh: new Date(Date.now() + oneYearMs).toISOString(),
status: 'active',
...overrides,
};
}
describe('subscriptionManager.renderSubscriptionCard', () => {
it('includes core fields (id, query, interval) in the output', () => {
const html = manager.renderSubscriptionCard(makeSubscription());
expect(html).toContain('data-subscription-id="sub-abc-123"');
expect(html).toContain('AI safety');
expect(html).toContain('Every 60 min');
});
it('shows a Pause button + pause icon when status is active', () => {
const html = manager.renderSubscriptionCard(
makeSubscription({ status: 'active' })
);
expect(html).toContain('title="Pause"');
expect(html).toContain('bi-pause');
expect(html).not.toContain('title="Resume"');
expect(html).not.toContain('bi-play');
});
it('shows a Resume button + play icon when status is not active (paused)', () => {
const html = manager.renderSubscriptionCard(
makeSubscription({ status: 'paused' })
);
expect(html).toContain('title="Resume"');
expect(html).toContain('bi-play');
expect(html).not.toContain('title="Pause"');
});
it('omits the folder span when subscription has no folder', () => {
const html = manager.renderSubscriptionCard(
makeSubscription({ folder: undefined })
);
expect(html).not.toContain('bi-folder');
});
it('renders the folder span when a folder is set', () => {
const html = manager.renderSubscriptionCard(
makeSubscription({ folder: 'Research' })
);
expect(html).toContain('bi-folder');
expect(html).toContain('Research');
});
it('omits the notes paragraph when no notes are provided', () => {
const html = manager.renderSubscriptionCard(
makeSubscription({ notes: undefined })
);
// The notes branch is the only <p> in the card template.
expect(html).not.toContain('<p ');
});
it('renders notes when provided', () => {
const html = manager.renderSubscriptionCard(
makeSubscription({ notes: 'Important topic' })
);
expect(html).toContain('Important topic');
expect(html).toContain('<p ');
});
it('escapes HTML in user-supplied fields (XSS protection)', () => {
const html = manager.renderSubscriptionCard(
makeSubscription({
query_or_topic: '<script>alert(1)</script>',
folder: '<img src=x>',
notes: '<svg onload=alert(1)>',
})
);
// Local escapeHtml uses div+textContent+innerHTML, which escapes
// `<` to `&lt;`. The literal `<script>` substring must not appear.
expect(html).not.toContain('<script>');
expect(html).not.toContain('<img src=x>');
expect(html).not.toContain('<svg onload=alert(1)>');
// The escaped form should be present.
expect(html).toContain('&lt;script&gt;');
});
it('embeds the formatTimeUntil output near "Next:"', () => {
// Sanity check that the formatTimeUntil method is being called and
// its output is interpolated into the card. The exact number depends
// on system time, so just assert that a unit suffix follows "Next:".
const html = manager.renderSubscriptionCard(makeSubscription());
expect(html).toMatch(/Next:\s*\d+[mhd]/);
});
});
@@ -0,0 +1,69 @@
/**
* Tests for components/subscription-manager.js — formatTimeUntil.
*
* Pure math that powers the "Next refresh" label on subscription cards.
* Boundary behavior (≤0 collapses to "Now", minute/hour/day transitions)
* has bitten this kind of code before; lock it down here so future
* refactors of the display label don't silently flip a day boundary.
*/
import '@js/components/subscription-manager.js';
let formatTimeUntil;
beforeAll(() => {
// The module wires up its singleton inside a DOMContentLoaded listener.
// happy-dom usually fires the lifecycle event before the import settles,
// so dispatch it manually if the singleton isn't there yet.
if (!window.subscriptionManager) {
document.dispatchEvent(new Event('DOMContentLoaded'));
}
formatTimeUntil = window.subscriptionManager.formatTimeUntil.bind(
window.subscriptionManager
);
});
describe('subscriptionManager.formatTimeUntil', () => {
it('returns "Now" for 0 ms', () => {
expect(formatTimeUntil(0)).toBe('Now');
});
it('returns "Now" for negative ms (overdue subscription)', () => {
expect(formatTimeUntil(-5000)).toBe('Now');
expect(formatTimeUntil(-86_400_000)).toBe('Now');
});
it('returns minutes for sub-hour durations', () => {
expect(formatTimeUntil(2 * 60 * 1000)).toBe('2m');
expect(formatTimeUntil(59 * 60 * 1000)).toBe('59m');
});
it('returns "0m" for sub-minute positive durations', () => {
// 30 seconds — too short for a minute, but > 0 so not "Now".
expect(formatTimeUntil(30_000)).toBe('0m');
});
it('returns hours once at the 1-hour boundary', () => {
expect(formatTimeUntil(60 * 60 * 1000)).toBe('1h');
expect(formatTimeUntil(23 * 60 * 60 * 1000)).toBe('23h');
});
it('returns days once at the 24-hour boundary', () => {
expect(formatTimeUntil(24 * 60 * 60 * 1000)).toBe('1d');
expect(formatTimeUntil(7 * 24 * 60 * 60 * 1000)).toBe('7d');
});
it('handles very large durations without overflow', () => {
// 999 days
expect(formatTimeUntil(999 * 24 * 60 * 60 * 1000)).toBe('999d');
});
it('floors at unit boundaries — 25h reports as 1d, not 1h', () => {
expect(formatTimeUntil(25 * 60 * 60 * 1000)).toBe('1d');
});
it('floors just under the next unit (59m59s stays minutes)', () => {
const ms = 59 * 60 * 1000 + 59 * 1000;
expect(formatTimeUntil(ms)).toBe('59m');
});
});