/** * 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 = `
`; } 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
) 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); }); });