Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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);
});
});