From 617881feab943e9b849b7f566bfafc51a083e6cd Mon Sep 17 00:00:00 2001 From: webtecnica Date: Thu, 10 Sep 2026 23:18:44 -0300 Subject: [PATCH] test: drive the auxiliary loader behaviorally instead of grepping panels.js (#7521) --- ...ue7521_aux_picker_endpoint_error_groups.py | 158 ++++++++++++++---- 1 file changed, 130 insertions(+), 28 deletions(-) diff --git a/tests/test_issue7521_aux_picker_endpoint_error_groups.py b/tests/test_issue7521_aux_picker_endpoint_error_groups.py index 1a7d204d..2f51e48c 100644 --- a/tests/test_issue7521_aux_picker_endpoint_error_groups.py +++ b/tests/test_issue7521_aux_picker_endpoint_error_groups.py @@ -8,6 +8,10 @@ rendered by the main picker in ``static/ui.js``). The auxiliary-task pickers in unreachable models endpoint silently disappeared from both the provider and the model selects — the user could neither see the provider nor the reason it had no models. + +Every test here EXECUTES the production code (extracted from ``static/panels.js`` +and driven with stubs) and asserts on the resulting option lists — no +source-string greps. """ import json @@ -19,20 +23,21 @@ import pytest ROOT = Path(__file__).resolve().parents[1] PANELS_JS_PATH = ROOT / "static" / "panels.js" -PANELS_JS = PANELS_JS_PATH.read_text(encoding="utf-8") NODE = shutil.which("node") -# Shared node prelude: read panels.js, pull a top-level ``function name(...)`` -# out of the bundle by brace matching, and evaluate it in isolation (same -# technique as tests/test_auxiliary_models_settings.py). +# Shared node prelude: read panels.js, pull top-level ``function`` bodies out of +# the bundle by brace matching, and evaluate them in isolation (same technique +# as tests/test_auxiliary_models_settings.py), plus a minimal element stub good +# enough to drive the option builders and the auxiliary loader. NODE_PRELUDE = r""" const fs = require('fs'); const src = fs.readFileSync(process.argv[1], 'utf8'); function extract(name){ - const re = new RegExp('function\\s+' + name + '\\s*\\('); - const start = src.search(re); - if(start < 0) throw new Error(name + ' not found'); + const re = new RegExp('(async\\s+)?function\\s+' + name + '\\s*\\('); + const match = re.exec(src); + if(!match) throw new Error(name + ' not found'); + const start = match.index; let i = src.indexOf('{', start); let depth = 0; while(i < src.length){ @@ -50,22 +55,30 @@ function extract(name){ return src.slice(start, i + 1); } +const _byId = {}; function _stubElement(tag){ return { - tagName: String(tag || 'option').toUpperCase(), + tagName: String(tag || 'div').toUpperCase(), children: [], dataset: {}, + style: {}, + attrs: {}, disabled: false, selected: false, _value: '', _text: '', _html: '', + _id: '', + set id(v){ this._id = v; if(v) _byId[v] = this; }, + get id(){ return this._id; }, set value(v){ this._value = v; }, get value(){ return this._value; }, set textContent(v){ this._text = v; }, get textContent(){ return this._text; }, set innerHTML(v){ this._html = v; if(v === '') this.children = []; }, get innerHTML(){ return this._html; }, + setAttribute(name, value){ this.attrs[name] = value; }, + addEventListener(){}, appendChild(child){ this.children.push(child); return child; }, insertBefore(child, ref){ const idx = this.children.indexOf(ref); @@ -77,11 +90,44 @@ function _stubElement(tag){ } global.document = { createElement: (tag) => _stubElement(tag) }; +global.window = global; global.t = (key) => key; +global.esc = (value) => String(value); +global.li = () => ''; +global.$ = (id) => _byId[id] || null; +global.__byId = _byId; +""" + +# Loader prelude: the real helpers _loadAuxiliaryModels() calls, plus the module +# state it owns and no-op stubs for the collaborators a headless run cannot touch. +LOADER_PRELUDE = r""" +eval(extract('_modelBareNameForProvider')); +eval(extract('_auxSelectStyle')); +eval(extract('_auxTaskLabelFromMeta')); +eval(extract('_normalizeAuxiliaryTasks')); +eval(extract('_auxProvidersFromModelGroups')); +eval(extract('_buildAuxProviderOptions')); +eval(extract('_buildAuxModelOptions')); +eval(extract('_onAuxProviderChange')); +eval(extract('_loadAuxiliaryModels')); + +let _auxTasks = []; +let _auxProviders = []; +let _auxOriginalConfig = {}; +let _mainAdvancedConfig = null; + +function _bindMainAdvancedOptionsButton(){} +function _openAuxAdvancedOptions(){} +function _markAuxDirty(){} +function _onAuxModelChange(){} +async function _applyAuxModels(){} +function showToast(){} +async function showConfirmDialog(){ return false; } """ def _run_node(script: str) -> dict: + assert NODE is not None proc = subprocess.run( [NODE, "-e", script, str(PANELS_JS_PATH)], capture_output=True, @@ -92,20 +138,6 @@ def _run_node(script: str) -> dict: return json.loads(proc.stdout.strip().splitlines()[-1]) -def test_aux_load_builds_providers_from_shared_helper(): - """panels.js must build the auxiliary provider list in one named helper.""" - assert "function _auxProvidersFromModelGroups" in PANELS_JS, ( - "Missing _auxProvidersFromModelGroups() in panels.js" - ) - assert "_auxProviders=_auxProvidersFromModelGroups(groups)" in PANELS_JS, ( - "Auxiliary load flow must build the provider list through the helper" - ) - legacy_inline_filter = "_auxProviders=groups.filter(g=>g.provider&&((g.models&&g.models.length>0)" - assert legacy_inline_filter not in PANELS_JS, ( - "The zero-model group filter must live in _auxProvidersFromModelGroups, not inline" - ) - - @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_endpoint_error_group_survives_the_zero_model_filter(): """A group with models_endpoint_error must be kept; empty groups without it must not.""" @@ -123,12 +155,14 @@ const groups = [ ]; const providers = _auxProvidersFromModelGroups(groups); +const broken = providers.find((p) => p.slug === 'custom:broken-proxy') || {}; +const healthy = providers.find((p) => p.slug === 'openai-codex') || {}; console.log(JSON.stringify({ slugs: providers.map((p) => p.slug), names: providers.map((p) => p.name), - brokenError: (providers.find((p) => p.slug === 'custom:broken-proxy') || {}).modelsEndpointError || null, - brokenModels: (providers.find((p) => p.slug === 'custom:broken-proxy') || {}).models || null, - healthyModels: ((providers.find((p) => p.slug === 'openai-codex') || {}).models || []).length, + brokenError: broken.modelsEndpointError || null, + brokenModels: broken.models || null, + healthyModels: (healthy.models || []).length, })); """ ) @@ -147,6 +181,74 @@ console.log(JSON.stringify({ assert result["healthyModels"] == 1 +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_auxiliary_load_keeps_endpoint_error_provider_in_both_selects(): + """Driving the real loader must leave the endpoint-error provider in the rendered selects.""" + script = ( + NODE_PRELUDE + + LOADER_PRELUDE + + r""" +const message = 'Models endpoint unreachable for broken-proxy; verify base_url.'; + +async function api(path){ + if(path === '/api/models'){ + return {groups:[ + {provider:'openai-codex', provider_id:'openai-codex', models:[{id:'gpt-5.5', label:'GPT-5.5'}]}, + {provider:'Broken Proxy', provider_id:'custom:broken-proxy', models:[], + models_endpoint_error:{kind:'network', code:null, message}}, + {provider:'Empty Extra', provider_id:'custom:empty-extra', models:[], extra_models:[]}, + ]}; + } + if(path === '/api/model/auxiliary'){ + return {main:{}, tasks:[ + {task:'vision', label:'Vision', description:'image analysis', + provider:'custom:broken-proxy', model:'broken/manual'}, + ]}; + } + throw new Error('unexpected api call: ' + path); +} + +const container = document.createElement('div'); +container.id = 'auxModelsContainer'; + +(async () => { + await _loadAuxiliaryModels(); + const provSel = $('aux-prov-vision'); + const modelSel = $('aux-model-vision'); + const firstRow = container.children[0]; + console.log(JSON.stringify({ + rowCount: container.children.length, + rowChildCount: firstRow ? firstRow.children.length : 0, + providerValues: provSel ? provSel.children.map((o) => o.value) : null, + providerSelected: provSel ? provSel.children.filter((o) => o.selected).map((o) => o.value) : null, + modelValues: modelSel ? modelSel.children.map((o) => o.value) : null, + modelTexts: modelSel ? modelSel.children.map((o) => o.textContent) : null, + modelSelected: modelSel ? modelSel.children.filter((o) => o.selected).map((o) => o.value) : null, + hints: modelSel ? modelSel.children + .filter((o) => o.dataset && o.dataset.modelsEndpointError === '1') + .map((o) => ({disabled: !!(o.disabled), text: o.textContent})) : null, + })); +})(); +""" + ) + result = _run_node(script) + + assert result["rowCount"] == 1, "one auxiliary task row must be rendered" + assert result["providerValues"] == ["auto", "openai-codex", "custom:broken-proxy"], ( + "the rendered provider select must offer the endpoint-error provider and drop the plain empty group" + ) + assert "custom:empty-extra" not in result["providerValues"] + assert result["providerSelected"] == ["custom:broken-proxy"], ( + "the configured provider must stay selected so an Apply cannot silently rewrite it to auto" + ) + assert result["hints"] == [ + {"disabled": True, "text": "\u26a0 Models endpoint unreachable for broken-proxy; verify base_url."} + ], "the rendered model select must explain the empty list with the provider endpoint error" + assert result["modelValues"] == ["", "", "broken/manual", "__custom__"] + assert result["modelSelected"] == ["broken/manual"], "the configured model must stay selected" + assert result["modelTexts"][1] == "\u26a0 Models endpoint unreachable for broken-proxy; verify base_url." + + @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_model_select_surfaces_provider_endpoint_error(): """The model select must explain the empty list instead of looking model-less.""" @@ -187,9 +289,9 @@ console.log(JSON.stringify({ assert result["canonical"] == "broken/manual", "configured model must stay canonical for the broken provider" assert result["firstValue"] == "", "the auto placeholder must stay the first option" assert result["firstDisabled"] is False, "the auto placeholder must remain selectable" - assert result["hints"] == [{"value": "", "disabled": True, "text": "\u26a0 " + "Models endpoint returned 502 for broken-proxy; see logs."}], ( - "the model select must carry a disabled hint with the provider endpoint error message" - ) + assert result["hints"] == [ + {"value": "", "disabled": True, "text": "\u26a0 Models endpoint returned 502 for broken-proxy; see logs."} + ], "the model select must carry a disabled hint with the provider endpoint error message" assert result["customPresent"] is True, "the custom-model escape hatch must stay available" assert result["configuredPreserved"] is True, "the configured model must stay selected" assert result["allowedHints"] == 1, "a provider with models must still surface its endpoint error"