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
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 `<`. 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('<script>');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user