/** * 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 _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'); }); });