7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
242 lines
11 KiB
JavaScript
242 lines
11 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|