test: drive the auxiliary loader behaviorally instead of grepping panels.js (#7521)
This commit is contained in:
1 parent
dc1d41db04
commit
617881feab
1 file changed
+130
-28
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user