Files
hermes-webui/tests/test_issue3691_model_picker_show_all.py
T
nesquena-hermes 7c21fe4a93 fix(sessions): invalidate session-list cache on CLI-visibility toggle + de-flake node test timeouts
ROOT FIX (product bug): POST /api/settings never invalidated the session-list
cache, so toggling show_cli_sessions/show_cron_sessions could serve a stale
sidebar for up to the cache TTL when the settings-file mtime stamp collided with
a cached entry. The handler now clears the cache directly on those toggles. This
is also the root cause of the recurring test_gateway_sync CI flake (session
absent from /api/sessions right after the visibility toggle).

DEFENSE-IN-DEPTH: cleanup_test_sessions fixture now resets show_cli_sessions to
default BEFORE each test too, not only in teardown.

FLAKE FIX (test infra): bump 31 node-subprocess correctness-harness timeouts from
10s -> 30s across 23 test files. Node spawn + V8 warmup under parallel CI shard
load can exceed 10s, causing TimeoutExpired flakes (e.g. test_1325 on #4445).
Only real node subprocess.run() sites changed — mock signatures, HTTP urlopen,
and deliberate timeout-behavior tests (test_api_timeout, timeout=0/1/2) untouched.
2026-06-18 22:21:03 +00:00

1678 lines
58 KiB
Python

"""Regression tests for #3691: provider-agnostic model-picker overflow groups."""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import types
import urllib.request
from pathlib import Path
import pytest
import api.config as config
REPO = Path(__file__).resolve().parents[1]
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
class _FakeResponse:
def __init__(self, payload: dict):
self._buf = json.dumps(payload).encode()
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def read(self) -> bytes:
return self._buf
@pytest.fixture(autouse=True)
def _clear_models_cache():
try:
config.invalidate_models_cache()
except Exception:
pass
yield
try:
config.invalidate_models_cache()
except Exception:
pass
def _openrouter_group() -> dict:
return next(g for g in config.get_available_models()["groups"] if g["provider_id"] == "openrouter")
def test_populate_model_dropdown_persists_extra_models_for_picker_runtime():
assert "dataset.extraModels=JSON.stringify(g.extra_models)" in UI_JS, (
"populateModelDropdown() must persist extra_models onto the optgroup so "
"renderModelDropdown() can search hidden overflow models before expansion."
)
def test_native_model_selectors_include_overflow_extra_models():
"""The non-picker native <select> model selectors (Settings / Cron / Profile /
Auxiliary) must include g.extra_models, not just g.models — otherwise the
server-side overflow split (#3691) silently hides every model beyond the first
15 for any large provider in those selectors."""
assert PANELS_JS.count("extra_models") >= 4, (
"Settings/Cron/Profile/Auxiliary model selectors must each include g.extra_models "
"so large-provider catalogs aren't truncated to the visible-15 picker cap."
)
assert "[...(g.models||[]),...(g.extra_models||[])]" in PANELS_JS, (
"The composer-mirroring native selector must concat models + extra_models."
)
def test_show_all_row_uses_i18n_key():
assert "t('model_show_all_models',hiddenCount)" in UI_JS, (
"The synthetic overflow row must use an i18n key instead of hardcoded English."
)
assert I18N_JS.count("model_show_all_models:") >= 10, (
"model_show_all_models should be defined across the shipped locale blocks."
)
assert "Mostrar todos los {0} modelos" in I18N_JS
assert "Afficher tous les {0} modèles" in I18N_JS
def test_openrouter_overflow_preserves_hidden_tail(monkeypatch):
monkeypatch.setattr(
config,
"cfg",
{
"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"},
"providers": {"openrouter": {"api_key": "sk-or-test-key"}},
},
raising=False,
)
fake_pkg = types.ModuleType("hermes_cli")
fake_pkg.__path__ = []
fake_models = types.ModuleType("hermes_cli.models")
fake_models.fetch_openrouter_models = lambda: [
("anthropic/claude-sonnet-4.6", ""),
("openai/gpt-4o", ""),
]
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
payload = {
"data": [
{
"id": f"vendor{i}/overflow-{i}:free",
"name": f"Overflow {i}",
"supported_parameters": [],
"pricing": {"prompt": "0", "completion": "0"},
}
for i in range(40)
]
}
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: _FakeResponse(payload))
group = _openrouter_group()
total = len(group["models"]) + len(group.get("extra_models", []))
capped_total = 2 + config._OPENROUTER_FREE_TIER_AUGMENT_CAP
assert len(group["models"]) == config._MODEL_PICKER_VISIBLE_TARGET
assert total == capped_total, "OpenRouter overflow models must move into extra_models within the capped augmentation budget."
assert any(m["id"] == "vendor29/overflow-29:free" for m in group.get("extra_models", [])), (
"The last capped free-tier model should land in extra_models once the visible picker cap is reached."
)
assert all(m["id"] != "vendor30/overflow-30:free" for bucket in ("models", "extra_models") for m in group.get(bucket, [])), (
"Free-tier augmentation must stop at the configured cap instead of continuing through the whole live payload."
)
def test_deduplicate_model_ids_includes_extra_models():
groups = [
{
"provider": "Alpha",
"provider_id": "alpha",
"models": [{"id": "shared/model", "label": "Shared Model"}],
"extra_models": [{"id": "alpha/only-extra", "label": "Alpha Extra"}],
},
{
"provider": "Beta",
"provider_id": "beta",
"models": [{"id": "beta/visible", "label": "Beta Visible"}],
"extra_models": [{"id": "shared/model", "label": "Shared Model"}],
},
]
config._deduplicate_model_ids(groups)
assert groups[0]["models"][0]["id"] == "shared/model"
assert groups[1]["extra_models"][0]["id"] == "@beta:shared/model"
assert groups[1]["extra_models"][0]["label"] == "Shared Model (Beta)"
def test_openrouter_free_tier_selection_stays_visible_when_selected_id_is_bare():
ordered = [
{"id": f"@openrouter:vendor/model-{idx}", "label": f"Model {idx}"}
for idx in range(config._MODEL_PICKER_VISIBLE_TARGET)
]
ordered.append({"id": "@openrouter:vendor/selected-model:free", "label": "Selected Free"})
visible, extra = config._split_picker_overflow_models(
ordered,
selected_model_id="vendor/selected-model:free",
provider_id="openrouter",
threshold=config._MODEL_PICKER_OVERFLOW_THRESHOLD,
target=config._MODEL_PICKER_VISIBLE_TARGET,
)
assert any(m["id"] == "@openrouter:vendor/selected-model:free" for m in visible), (
"A bare OpenRouter :free selection must stay visible when the selected model is in overflow."
)
assert all(m["id"] != "@openrouter:vendor/selected-model:free" for m in extra)
_DROPDOWN_DRIVER = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[2], 'utf8');
function extractFunc(name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\(');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found');
let openParen = ui.indexOf('(', start);
let i = openParen + 1;
let parenDepth = 1;
while (parenDepth > 0 && i < ui.length) {
if (ui[i] === '(') parenDepth++;
else if (ui[i] === ')') parenDepth--;
i++;
}
i = ui.indexOf('{', i);
let depth = 1;
i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
return ui.slice(start, i);
}
function extractConst(name) {
const re = new RegExp('const\\s+' + name + '\\s*=');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found as const');
const eqIdx = ui.indexOf('=', start + name.length);
let i = ui.indexOf('{', eqIdx);
if (i < 0) throw new Error(name + ' arrow body not found');
let depth = 1;
i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
if (ui[i] === ';') i++;
return ui.slice(start, i);
}
function makeClassList(initial) {
const set = new Set(initial || []);
return {
_set: set,
add(cls) { set.add(cls); },
remove(cls) { set.delete(cls); },
contains(cls) { return set.has(cls); },
toggle(cls, force) {
if (force === true) { set.add(cls); return true; }
if (force === false) { set.delete(cls); return false; }
if (set.has(cls)) { set.delete(cls); return false; }
set.add(cls);
return true;
},
};
}
function defineClassName(node) {
Object.defineProperty(node, 'className', {
get() { return [...node.classList._set].join(' '); },
set(v) { node.classList = makeClassList(String(v || '').split(/\s+/).filter(Boolean)); },
});
}
function makeNode(tag) {
const node = {
tagName: String(tag || '').toUpperCase(),
children: [],
dataset: {},
style: {},
parentElement: null,
textContent: '',
value: '',
tabIndex: 0,
onclick: null,
_listeners: {},
_innerHTML: '',
appendChild(child) {
child.parentElement = this;
this.children.push(child);
if (this.tagName === 'OPTGROUP' && this._ownerSelect && child.tagName === 'OPTION') {
this._ownerSelect.options.push(child);
}
return child;
},
addEventListener(type, handler) { this._listeners[type] = handler; },
querySelector(selector) { return this._qs ? this._qs[selector] || null : null; },
setAttribute(name, value) { this[name] = value; },
focus() { this._focused = true; },
};
node.classList = makeClassList();
defineClassName(node);
Object.defineProperty(node, 'innerHTML', {
get() { return this._innerHTML; },
set(v) {
this._innerHTML = String(v || '');
this.children = [];
this._qs = {};
if (this.tagName === 'DIV' && this._innerHTML.includes('model-search-input')) {
const input = makeNode('input');
input.className = 'model-search-input';
const clear = makeNode('button');
clear.className = 'model-search-clear';
this._qs['.model-search-input'] = input;
this._qs['.model-search-clear'] = clear;
} else if (this.tagName === 'DIV' && this._innerHTML.includes('model-custom-input')) {
const input = makeNode('input');
input.className = 'model-custom-input';
const btn = makeNode('button');
btn.className = 'model-custom-btn';
this._qs['.model-custom-input'] = input;
this._qs['.model-custom-btn'] = btn;
}
},
});
return node;
}
function makeOption(value, label, parent) {
const opt = makeNode('option');
opt.value = value;
opt.textContent = label || value;
opt.parentElement = parent || null;
return opt;
}
function makeSelect(groups, selectedValue) {
const sel = { id: 'modelSelect', children: [], options: [], value: selectedValue || '' };
for (const group of groups || []) {
const og = makeNode('optgroup');
og.label = group.provider || '';
og.dataset.provider = group.provider_id || '';
og._ownerSelect = sel;
if (group.extra_models) og.dataset.extraModels = JSON.stringify(group.extra_models);
for (const model of group.models || []) {
og.appendChild(makeOption(model.id, model.label || model.id, og));
}
sel.children.push(og);
sel.options.push(...og.children);
}
return sel;
}
function snapshot(dd) {
// Recurse into collapsible group bodies (#4279): rows + the show-all expander
// now live inside `.model-group-body` wrappers rather than as direct children
// of the dropdown, so a flat children map would miss them.
const out = [];
const walk = (node) => {
for (const child of (node.children || [])) {
out.push({
className: child.className,
textContent: child.textContent,
html: child._innerHTML || '',
});
if (child.children && child.children.length) walk(child);
}
};
walk(dd);
return out;
}
// Find a node anywhere in the dropdown subtree whose innerHTML matches.
function findInTree(dd, pred) {
const stack = [...(dd.children || [])];
while (stack.length) {
const n = stack.shift();
if (pred(n)) return n;
if (n.children && n.children.length) stack.push(...n.children);
}
return null;
}
const payload = JSON.parse(process.argv[3]);
const dropdown = makeNode('div');
dropdown.classList.add('open');
const modelSelect = makeSelect(payload.groups, payload.selectedValue || payload.groups[0].models[0].id);
function $(id) {
if (id === 'composerModelDropdown') return dropdown;
if (id === 'modelSelect') return modelSelect;
return null;
}
const window = { _configuredModelBadges: payload.configuredBadges || {} };
const document = { createElement(tag) { return makeNode(tag); } };
function esc(v) { return String(v || ''); }
function t(key, ...args) {
if (key === 'model_show_all_models') return `Show all ${args[0]} models`;
return key;
}
function li() { return 'x'; }
function getModelLabel(v) { return String(v || ''); }
function _providerFromModelValue(v) {
const value = String(v || '');
if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':'));
return '';
}
function _normalizeConfiguredModelKey(v) { return String(v || '').toLowerCase(); }
function _getConfiguredModelBadge(value, badgeMap) { return badgeMap[value] || null; }
function closeModelDropdown() {}
function selectModelFromDropdown() {}
for (const name of [
'_readModelOverflowData',
'_appendOverflowOptionsToGroup',
'renderModelDropdown',
]) {
eval(extractFunc(name));
}
renderModelDropdown();
const initial = snapshot(dropdown);
// The show-all expander now lives inside a `.model-group-body` wrapper (#4279),
// so search the whole subtree rather than only direct children.
const initialShowAllRow = findInTree(dropdown, node => String(node._innerHTML || '').includes('Show all'));
const searchInput = dropdown.children[1].querySelector('.model-search-input');
searchInput.value = payload.searchTerm;
searchInput._listeners.input();
const searched = snapshot(dropdown);
initialShowAllRow.onclick({ stopPropagation() {} });
const searchInputAfterExpand = dropdown.children[1].querySelector('.model-search-input');
searchInputAfterExpand.value = '';
searchInputAfterExpand._listeners.input();
const expanded = snapshot(dropdown);
process.stdout.write(JSON.stringify({
initial,
searched,
expanded,
optionCountAfterExpand: modelSelect.children[0].children.length,
hiddenDatasetAfterExpand: modelSelect.children[0].dataset.extraModels || '',
}));
"""
_INPLACE_DRIVER = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[2], 'utf8');
function extractFunc(name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\(');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found');
let openParen = ui.indexOf('(', start);
let i = openParen + 1;
let parenDepth = 1;
while (parenDepth > 0 && i < ui.length) {
if (ui[i] === '(') parenDepth++;
else if (ui[i] === ')') parenDepth--;
i++;
}
i = ui.indexOf('{', i);
let depth = 1;
i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
return ui.slice(start, i);
}
function extractConst(name) {
const re = new RegExp('const\\s+' + name + '\\s*=');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found as const');
const eqIdx = ui.indexOf('=', start + name.length);
let i = ui.indexOf('{', eqIdx);
if (i < 0) throw new Error(name + ' arrow body not found');
let depth = 1;
i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
if (ui[i] === ';') i++;
return ui.slice(start, i);
}
// Extended DOM globals to enable in-place expansion path
const CSS = { escape: s => String(s || '').replace(/[^a-zA-Z0-9_-]/g, '\\$&') };
const requestAnimationFrame = fn => { fn(); return 0; };
function makeClassList(initial) {
const set = new Set(initial || []);
return {
_set: set,
add(cls) { set.add(cls); },
remove(cls) { set.delete(cls); },
contains(cls) { return set.has(cls); },
toggle(cls, force) {
if (force === true) { set.add(cls); return true; }
if (force === false) { set.delete(cls); return false; }
if (set.has(cls)) { set.delete(cls); return false; }
set.add(cls);
return true;
},
};
}
function defineClassName(node) {
Object.defineProperty(node, 'className', {
get() { return [...node.classList._set].join(' '); },
set(v) { node.classList = makeClassList(String(v || '').split(/\s+/).filter(Boolean)); },
});
}
function makeNode(tag) {
const node = {
tagName: String(tag || '').toUpperCase(),
children: [],
dataset: {},
style: {},
parentElement: null,
textContent: '',
value: '',
tabIndex: 0,
onclick: null,
_listeners: {},
_innerHTML: '',
appendChild(child) {
child.parentElement = this;
this.children.push(child);
if (this.tagName === 'OPTGROUP' && this._ownerSelect && child.tagName === 'OPTION') {
this._ownerSelect.options.push(child);
}
return child;
},
insertBefore(newChild, refChild) {
newChild.parentElement = this;
const idx = refChild ? this.children.indexOf(refChild) : -1;
if (idx >= 0) {
this.children.splice(idx, 0, newChild);
} else {
this.children.push(newChild);
}
return newChild;
},
remove() {
if (this.parentElement) {
const idx = this.parentElement.children.indexOf(this);
if (idx >= 0) this.parentElement.children.splice(idx, 1);
}
},
addEventListener(type, handler) { this._listeners[type] = handler; },
querySelector(selector) {
// Try the _qs cache first
if (this._qs && this._qs[selector]) return this._qs[selector];
// Handle attribute selectors and descendant selectors
return querySelectorAllImpl(this, selector)[0] || null;
},
querySelectorAll(selector) {
return querySelectorAllImpl(this, selector);
},
setAttribute(name, value) { this[name] = value; },
focus() { this._focused = true; },
};
Object.defineProperty(node, 'offsetTop', {
value: 0,
});
Object.defineProperty(node, 'scrollTop', {
get() { return this._scrollTop || 0; },
set(v) { this._scrollTop = v; },
});
Object.defineProperty(node, 'previousElementSibling', {
get() {
if (!this.parentElement) return null;
const idx = this.parentElement.children.indexOf(this);
return idx > 0 ? this.parentElement.children[idx - 1] : null;
},
});
node.classList = makeClassList();
defineClassName(node);
Object.defineProperty(node, 'innerHTML', {
get() { return this._innerHTML; },
set(v) {
this._innerHTML = String(v || '');
this.children = [];
this._qs = {};
if (this.tagName === 'DIV' && this._innerHTML.includes('model-search-input')) {
const input = makeNode('input');
input.className = 'model-search-input';
const clear = makeNode('button');
clear.className = 'model-search-clear';
this._qs['.model-search-input'] = input;
this._qs['.model-search-clear'] = clear;
} else if (this.tagName === 'DIV' && this._innerHTML.includes('model-custom-input')) {
const input = makeNode('input');
input.className = 'model-custom-input';
const btn = makeNode('button');
btn.className = 'model-custom-btn';
this._qs['.model-custom-input'] = input;
this._qs['.model-custom-btn'] = btn;
}
},
});
return node;
}
function querySelectorAllImpl(node, selector) {
const results = [];
const stack = [node];
while (stack.length) {
const n = stack.shift();
if (n.children && n.children.length) {
stack.push(...n.children);
}
// Simple class selector: .foo
if (selector.startsWith('.') && !selector.includes('[') && !selector.includes(' ')) {
const className = selector.slice(1);
if (n.className && n.className.includes(className)) {
results.push(n);
}
}
// Attribute selector: .foo[data-bar="baz"]
else if (selector.includes('[') && !selector.includes(' ')) {
const match = selector.match(/^\.([^\[]+)\[data-([^\]=]+)="([^\]]+)"\]$/);
if (match) {
const [, className, dataKey, dataVal] = match;
if (n.className && n.className.includes(className) &&
n.dataset && n.dataset[dataKey] === dataVal) {
results.push(n);
}
}
}
// Descendant selector: .foo .bar
else if (selector.includes(' ')) {
const parts = selector.split(' ').filter(Boolean);
if (parts.length === 2) {
const [parentSel, childSel] = parts;
// Find all ancestors matching parentSel
let parent = n.parentElement;
let hasParent = false;
while (parent) {
if (isMatch(parent, parentSel)) {
hasParent = true;
break;
}
parent = parent.parentElement;
}
// If we found a matching ancestor, check if this node matches childSel
if (hasParent && isMatch(n, childSel)) {
results.push(n);
}
}
}
}
return results;
}
function isMatch(node, selector) {
// Simple class selector: .foo
if (selector.startsWith('.') && !selector.includes('[')) {
const className = selector.slice(1);
return node.className && node.className.includes(className);
}
// Attribute selector: .foo[data-bar="baz"]
if (selector.includes('[')) {
const match = selector.match(/^\.([^\[]+)\[data-([^\]=]+)="([^\]]+)"\]$/);
if (match) {
const [, className, dataKey, dataVal] = match;
return node.className && node.className.includes(className) &&
node.dataset && node.dataset[dataKey] === dataVal;
}
}
return false;
}
function makeOption(value, label, parent) {
const opt = makeNode('option');
opt.value = value;
opt.textContent = label || value;
opt.parentElement = parent || null;
return opt;
}
function makeSelect(groups, selectedValue) {
const sel = { id: 'modelSelect', children: [], options: [], value: selectedValue || '' };
for (const group of groups || []) {
const og = makeNode('optgroup');
og.label = group.provider || '';
og.dataset.provider = group.provider_id || '';
og._ownerSelect = sel;
if (group.extra_models) og.dataset.extraModels = JSON.stringify(group.extra_models);
for (const model of group.models || []) {
og.appendChild(makeOption(model.id, model.label || model.id, og));
}
sel.children.push(og);
sel.options.push(...og.children);
}
return sel;
}
function snapshot(dd) {
const out = [];
const walk = (node) => {
for (const child of (node.children || [])) {
out.push({
className: child.className,
textContent: child.textContent,
html: child._innerHTML || '',
});
if (child.children && child.children.length) walk(child);
}
};
walk(dd);
return out;
}
function findInTree(dd, pred) {
const stack = [...(dd.children || [])];
while (stack.length) {
const n = stack.shift();
if (pred(n)) return n;
if (n.children && n.children.length) stack.push(...n.children);
}
return null;
}
const payload = JSON.parse(process.argv[3]);
const dropdown = makeNode('div');
dropdown.classList.add('open');
const modelSelect = makeSelect(payload.groups, payload.selectedValue || payload.groups[0].models[0].id);
function $(id) {
if (id === 'composerModelDropdown') return dropdown;
if (id === 'modelSelect') return modelSelect;
return null;
}
const window = { _configuredModelBadges: payload.configuredBadges || {} };
const document = { createElement(tag) { return makeNode(tag); } };
function esc(v) { return String(v || ''); }
function t(key, ...args) {
if (key === 'model_show_all_models') return `Show all ${args[0]} models`;
return key;
}
function li() { return 'x'; }
function getModelLabel(v) { return String(v || ''); }
function _providerFromModelValue(v) {
const value = String(v || '');
if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':'));
return '';
}
function _normalizeConfiguredModelKey(v) { return String(v || '').toLowerCase(); }
function _getConfiguredModelBadge(value, badgeMap) { return badgeMap[value] || null; }
function closeModelDropdown() {}
function selectModelFromDropdown() {}
for (const name of [
'_readModelOverflowData',
'_appendOverflowOptionsToGroup',
'renderModelDropdown',
]) {
eval(extractFunc(name));
}
eval(extractConst('_expandOverflowGroup'));
renderModelDropdown();
const initial = snapshot(dropdown);
const initialShowAllRow = findInTree(dropdown, node => String(node._innerHTML || '').includes('Show all'));
// Click show-all FIRST (before any search) so the in-place path runs on a
// fresh DOM with .model-opt-more still present. Searching first would trigger
// a full re-render that removes .model-opt-more, making the stale onclick
// reference fall into the full-rerender fallback instead of in-place.
initialShowAllRow.onclick({ stopPropagation() {} });
const expanded = snapshot(dropdown);
// Now type a search, then clear it, to verify the hiddenByDefault sync
// keeps the group fully expanded through the search→clear cycle.
const searchInput = dropdown.children[1].querySelector('.model-search-input');
searchInput.value = payload.searchTerm;
searchInput._listeners.input();
const searched = snapshot(dropdown);
searchInput.value = '';
searchInput._listeners.input();
const cleared = snapshot(dropdown);
// After clearing, measure the rendered group body — not the hidden <select>.
// _appendOverflowOptionsToGroup appends to the <select> unconditionally, so
// counting <option> elements there is always 4 whether or not the
// hiddenByDefault/hiddenCount sync is present. The regression the fix prevents
// is the rendered dropdown snapping back to the capped view + a fresh "Show all"
// expander, so we must check the live DOM rows inside the .model-group-body.
const groupWrapper = querySelectorAllImpl(dropdown, '.model-group-body[data-group="openrouter"]')[0] || null;
const clearedRenderedModelCount = groupWrapper ? querySelectorAllImpl(groupWrapper, '.model-opt').length : -1;
const clearedHasMoreButton = groupWrapper ? querySelectorAllImpl(groupWrapper, '.model-opt-more').length > 0 : false;
process.stdout.write(JSON.stringify({
inPlacePath: true,
initialShowAll: initial.some(item => String(item.html || '').includes('Show all')),
expandedHasShowAll: expanded.some(item => String(item.html || '').includes('Show all')),
clearedHasShowAll: cleared.some(item => String(item.html || '').includes('Show all')),
clearedRenderedModelCount,
clearedHasMoreButton,
}));
"""
_INPLACE_ENDPOINT_ERROR_DRIVER = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[2], 'utf8');
function extractFunc(name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\(');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found');
let openParen = ui.indexOf('(', start);
let i = openParen + 1;
let parenDepth = 1;
while (parenDepth > 0 && i < ui.length) {
if (ui[i] === '(') parenDepth++;
else if (ui[i] === ')') parenDepth--;
i++;
}
i = ui.indexOf('{', i);
let depth = 1;
i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
return ui.slice(start, i);
}
const CSS = { escape: s => String(s || '').replace(/[^a-zA-Z0-9_-]/g, '\\$&') };
const requestAnimationFrame = fn => { fn(); return 0; };
function makeClassList(initial) {
const set = new Set(initial || []);
return {
_set: set,
add(cls) { set.add(cls); },
remove(cls) { set.delete(cls); },
contains(cls) { return set.has(cls); },
toggle(cls, force) {
if (force === true) { set.add(cls); return true; }
if (force === false) { set.delete(cls); return false; }
if (set.has(cls)) { set.delete(cls); return false; }
set.add(cls);
return true;
},
};
}
function defineClassName(node) {
Object.defineProperty(node, 'className', {
get() { return [...node.classList._set].join(' '); },
set(v) { node.classList = makeClassList(String(v || '').split(/\s+/).filter(Boolean)); },
});
}
function makeNode(tag) {
const node = {
tagName: String(tag || '').toUpperCase(),
children: [],
dataset: {},
style: {},
parentElement: null,
textContent: '',
value: '',
tabIndex: 0,
onclick: null,
_listeners: {},
_innerHTML: '',
appendChild(child) {
child.parentElement = this;
this.children.push(child);
if (this.tagName === 'OPTGROUP' && this._ownerSelect && child.tagName === 'OPTION') {
this._ownerSelect.options.push(child);
}
return child;
},
addEventListener(type, handler) { this._listeners[type] = handler; },
querySelector(selector) { return this._qs ? this._qs[selector] || null : null; },
setAttribute(name, value) { this[name] = value; },
focus() { this._focused = true; },
};
node.classList = makeClassList();
defineClassName(node);
Object.defineProperty(node, 'innerHTML', {
get() { return this._innerHTML; },
set(v) {
this._innerHTML = String(v || '');
this.children = [];
this._qs = {};
if (this.tagName === 'DIV' && this._innerHTML.includes('model-search-input')) {
const input = makeNode('input');
input.className = 'model-search-input';
const clear = makeNode('button');
clear.className = 'model-search-clear';
this._qs['.model-search-input'] = input;
this._qs['.model-search-clear'] = clear;
} else if (this.tagName === 'DIV' && this._innerHTML.includes('model-custom-input')) {
const input = makeNode('input');
input.className = 'model-custom-input';
const btn = makeNode('button');
btn.className = 'model-custom-btn';
this._qs['.model-custom-input'] = input;
this._qs['.model-custom-btn'] = btn;
}
},
});
return node;
}
function makeOption(value, label, parent) {
const opt = makeNode('option');
opt.value = value;
opt.textContent = label || value;
opt.parentElement = parent || null;
return opt;
}
function makeSelect(groups, selectedValue) {
const sel = { id: 'modelSelect', children: [], options: [], value: selectedValue || '' };
for (const group of groups || []) {
const og = makeNode('optgroup');
og.label = group.provider || '';
og.dataset.provider = group.provider_id || '';
og._ownerSelect = sel;
if (group.extra_models) og.dataset.extraModels = JSON.stringify(group.extra_models);
if (group.modelsEndpointError) og.dataset.modelsEndpointError = JSON.stringify(group.modelsEndpointError);
for (const model of group.models || []) {
og.appendChild(makeOption(model.id, model.label || model.id, og));
}
sel.children.push(og);
sel.options.push(...og.children);
}
return sel;
}
const payload = JSON.parse(process.argv[3]);
const dropdown = makeNode('div');
dropdown.classList.add('open');
const modelSelect = makeSelect(payload.groups, payload.selectedValue || payload.groups[0].models[0].id);
function $(id) {
if (id === 'composerModelDropdown') return dropdown;
if (id === 'modelSelect') return modelSelect;
return null;
}
const window = { _configuredModelBadges: payload.configuredBadges || {} };
const document = { createElement(tag) { return makeNode(tag); } };
function esc(v) { return String(v || ''); }
function t(key, ...args) {
if (key === 'model_show_all_models') return `Show all ${args[0]} models`;
return key;
}
function li() { return 'x'; }
function getModelLabel(v) { return String(v || ''); }
function _providerFromModelValue(v) {
const value = String(v || '');
if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':'));
return '';
}
function _normalizeConfiguredModelKey(v) { return String(v || '').toLowerCase(); }
function _getConfiguredModelBadge(value, badgeMap) { return badgeMap[value] || null; }
function closeModelDropdown() {}
function selectModelFromDropdown() {}
for (const name of [
'_readModelOverflowData',
'_appendOverflowOptionsToGroup',
'renderModelDropdown',
]) {
eval(extractFunc(name));
}
renderModelDropdown();
// Target the errored group's wrapper specifically by data-group attribute.
// A plain walk() that overwrites on every .model-group-body ends on the last
// wrapper in DOM order (the selected/open Anthropic group), giving a false
// "open" result regardless of whether _hasEndpointError fired.
let errWrap = null;
const walk = (node) => {
for (const child of (node.children || [])) {
if (child.className && child.className.includes('model-group-body') &&
child.dataset && child.dataset.group === 'openrouter') {
errWrap = child;
}
if (child.children && child.children.length) walk(child);
}
};
walk(dropdown);
process.stdout.write(JSON.stringify({
foundWrapper: !!errWrap,
groupRendersOpen: !!errWrap && errWrap.style.display !== 'none',
}));
"""
_INPLACE_PREEXISTING_DRIVER = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[2], 'utf8');
function extractFunc(name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\(');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found');
let openParen = ui.indexOf('(', start);
let i = openParen + 1;
let parenDepth = 1;
while (parenDepth > 0 && i < ui.length) {
if (ui[i] === '(') parenDepth++;
else if (ui[i] === ')') parenDepth--;
i++;
}
i = ui.indexOf('{', i);
let depth = 1;
i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
return ui.slice(start, i);
}
function extractConst(name) {
const re = new RegExp('const\\s+' + name + '\\s*=');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found as const');
const eqIdx = ui.indexOf('=', start + name.length);
let i = ui.indexOf('{', eqIdx);
if (i < 0) throw new Error(name + ' arrow body not found');
let depth = 1;
i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
if (ui[i] === ';') i++;
return ui.slice(start, i);
}
const CSS = { escape: s => String(s || '').replace(/[^a-zA-Z0-9_-]/g, '\\$&') };
const requestAnimationFrame = fn => { fn(); return 0; };
function makeClassList(initial) {
const set = new Set(initial || []);
return {
_set: set,
add(cls) { set.add(cls); },
remove(cls) { set.delete(cls); },
contains(cls) { return set.has(cls); },
toggle(cls, force) {
if (force === true) { set.add(cls); return true; }
if (force === false) { set.delete(cls); return false; }
if (set.has(cls)) { set.delete(cls); return false; }
set.add(cls);
return true;
},
};
}
function defineClassName(node) {
Object.defineProperty(node, 'className', {
get() { return [...node.classList._set].join(' '); },
set(v) { node.classList = makeClassList(String(v || '').split(/\s+/).filter(Boolean)); },
});
}
function makeNode(tag) {
const node = {
tagName: String(tag || '').toUpperCase(),
children: [],
dataset: {},
style: {},
parentElement: null,
textContent: '',
value: '',
tabIndex: 0,
onclick: null,
_listeners: {},
_innerHTML: '',
appendChild(child) {
child.parentElement = this;
this.children.push(child);
if (this.tagName === 'OPTGROUP' && this._ownerSelect && child.tagName === 'OPTION') {
this._ownerSelect.options.push(child);
}
return child;
},
insertBefore(newChild, refChild) {
newChild.parentElement = this;
const idx = refChild ? this.children.indexOf(refChild) : -1;
if (idx >= 0) {
this.children.splice(idx, 0, newChild);
} else {
this.children.push(newChild);
}
return newChild;
},
remove() {
if (this.parentElement) {
const idx = this.parentElement.children.indexOf(this);
if (idx >= 0) this.parentElement.children.splice(idx, 1);
}
},
addEventListener(type, handler) { this._listeners[type] = handler; },
querySelector(selector) {
// Try the _qs cache first
if (this._qs && this._qs[selector]) return this._qs[selector];
// Handle attribute selectors and descendant selectors
return querySelectorAllImpl(this, selector)[0] || null;
},
querySelectorAll(selector) {
return querySelectorAllImpl(this, selector);
},
setAttribute(name, value) { this[name] = value; },
focus() { this._focused = true; },
};
Object.defineProperty(node, 'offsetTop', {
value: 0,
});
Object.defineProperty(node, 'scrollTop', {
get() { return this._scrollTop || 0; },
set(v) { this._scrollTop = v; },
});
Object.defineProperty(node, 'previousElementSibling', {
get() {
if (!this.parentElement) return null;
const idx = this.parentElement.children.indexOf(this);
return idx > 0 ? this.parentElement.children[idx - 1] : null;
},
});
node.classList = makeClassList();
defineClassName(node);
Object.defineProperty(node, 'innerHTML', {
get() { return this._innerHTML; },
set(v) {
this._innerHTML = String(v || '');
this.children = [];
this._qs = {};
if (this.tagName === 'DIV' && this._innerHTML.includes('model-search-input')) {
const input = makeNode('input');
input.className = 'model-search-input';
const clear = makeNode('button');
clear.className = 'model-search-clear';
this._qs['.model-search-input'] = input;
this._qs['.model-search-clear'] = clear;
} else if (this.tagName === 'DIV' && this._innerHTML.includes('model-custom-input')) {
const input = makeNode('input');
input.className = 'model-custom-input';
const btn = makeNode('button');
btn.className = 'model-custom-btn';
this._qs['.model-custom-input'] = input;
this._qs['.model-custom-btn'] = btn;
}
},
});
return node;
}
function querySelectorAllImpl(node, selector) {
const results = [];
const stack = [node];
while (stack.length) {
const n = stack.shift();
if (n.children && n.children.length) {
stack.push(...n.children);
}
if (selector.startsWith('.') && !selector.includes('[') && !selector.includes(' ')) {
const className = selector.slice(1);
if (n.className && n.className.includes(className)) {
results.push(n);
}
}
else if (selector.includes('[') && !selector.includes(' ')) {
const match = selector.match(/^\.([^\[]+)\[data-([^\]=]+)="([^\]]+)"\]$/);
if (match) {
const [, className, dataKey, dataVal] = match;
if (n.className && n.className.includes(className) &&
n.dataset && n.dataset[dataKey] === dataVal) {
results.push(n);
}
}
}
else if (selector.includes(' ')) {
const parts = selector.split(' ').filter(Boolean);
if (parts.length === 2) {
const [parentSel, childSel] = parts;
let parent = n.parentElement;
let hasParent = false;
while (parent) {
if (isMatch(parent, parentSel)) {
hasParent = true;
break;
}
parent = parent.parentElement;
}
if (hasParent && isMatch(n, childSel)) {
results.push(n);
}
}
}
// Bare tag-name selector: e.g. 'option'. Required so
// _appendOverflowOptionsToGroup's querySelectorAll('option') finds
// pre-injected <option> elements — without this, existingByValue stays
// empty and the function never returns 0, so the extraModels.length guard
// is never exercised.
else if (!selector.startsWith('.') && !selector.includes('[') && !selector.includes(' ')) {
if (n.tagName && n.tagName === selector.toUpperCase()) {
results.push(n);
}
}
}
return results;
}
function isMatch(node, selector) {
if (selector.startsWith('.') && !selector.includes('[')) {
const className = selector.slice(1);
return node.className && node.className.includes(className);
}
if (selector.includes('[')) {
const match = selector.match(/^\.([^\[]+)\[data-([^\]=]+)="([^\]]+)"\]$/);
if (match) {
const [, className, dataKey, dataVal] = match;
return node.className && node.className.includes(className) &&
node.dataset && node.dataset[dataKey] === dataVal;
}
}
if (!selector.startsWith('.') && !selector.includes('[')) {
return node.tagName && node.tagName === selector.toUpperCase();
}
return false;
}
function makeOption(value, label, parent) {
const opt = makeNode('option');
opt.value = value;
opt.textContent = label || value;
opt.parentElement = parent || null;
return opt;
}
function makeSelect(groups, selectedValue) {
const sel = {
id: 'modelSelect', tagName: 'SELECT', children: [], options: [], value: selectedValue || '',
querySelectorAll(selector) { return querySelectorAllImpl(this, selector); },
querySelector(selector) { return querySelectorAllImpl(this, selector)[0] || null; },
};
for (const group of groups || []) {
const og = makeNode('optgroup');
og.label = group.provider || '';
og.dataset.provider = group.provider_id || '';
og._ownerSelect = sel;
og.parentNode = sel;
if (group.extra_models) og.dataset.extraModels = JSON.stringify(group.extra_models);
for (const model of group.models || []) {
og.appendChild(makeOption(model.id, model.label || model.id, og));
}
sel.children.push(og);
sel.options.push(...og.children);
}
return sel;
}
function findInTree(dd, pred) {
const stack = [...(dd.children || [])];
while (stack.length) {
const n = stack.shift();
if (pred(n)) return n;
if (n.children && n.children.length) stack.push(...n.children);
}
return null;
}
const payload = JSON.parse(process.argv[3]);
const dropdown = makeNode('div');
dropdown.classList.add('open');
const modelSelect = makeSelect(payload.groups, payload.selectedValue || payload.groups[0].models[0].id);
// Pre-inject one overflow model as an <option> in the optgroup to simulate
// _ensureModelOptionInDropdown having already added it. Both overflow models
// are pre-injected so _appendOverflowOptionsToGroup returns 0 new appends,
// exercising the extraModels.length guard (not the return-value guard).
if (payload.preexistingModelIds) {
const og = modelSelect.children[0];
for (const mid of payload.preexistingModelIds) {
const preexisting = payload.groups[0].extra_models.find(m => m.id === mid);
if (preexisting && og) {
const opt = makeOption(preexisting.id, preexisting.label || preexisting.id, og);
og.appendChild(opt);
}
}
}
function $(id) {
if (id === 'composerModelDropdown') return dropdown;
if (id === 'modelSelect') return modelSelect;
return null;
}
const window = { _configuredModelBadges: payload.configuredBadges || {} };
const document = { createElement(tag) { return makeNode(tag); } };
function esc(v) { return String(v || ''); }
function t(key, ...args) {
if (key === 'model_show_all_models') return `Show all ${args[0]} models`;
return key;
}
function li() { return 'x'; }
function getModelLabel(v) { return String(v || ''); }
function _providerFromModelValue(v) {
const value = String(v || '');
if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':'));
return '';
}
function _normalizeConfiguredModelKey(v) { return String(v || '').toLowerCase(); }
function _getConfiguredModelBadge(value, badgeMap) { return badgeMap[value] || null; }
function closeModelDropdown() {}
function selectModelFromDropdown() {}
for (const name of [
'_readModelOverflowData',
'_appendOverflowOptionsToGroup',
'renderModelDropdown',
]) {
eval(extractFunc(name));
}
eval(extractConst('_expandOverflowGroup'));
renderModelDropdown();
const initialShowAllRow = findInTree(dropdown, node => String(node._innerHTML || '').includes('Show all'));
initialShowAllRow.onclick({ stopPropagation() {} });
// Check if preexisting models are now visible - look through all innerHTML or textContent
const idsToFind = new Set(payload.preexistingModelIds || []);
const foundIds = new Set();
let showAllGone = false;
const walk = (node, depth=0) => {
for (const mid of idsToFind) {
if (node._innerHTML && node._innerHTML.includes(mid)) {
foundIds.add(mid);
}
if (node.textContent && String(node.textContent).includes(mid)) {
foundIds.add(mid);
}
}
if (node.children && node.children.length) {
for (const child of node.children) walk(child, depth+1);
}
};
walk(dropdown);
const preexistingVisible = foundIds.size === idsToFind.size;
const expanded = findInTree(dropdown, node => String(node._innerHTML || '').includes('Show all'));
showAllGone = !expanded;
process.stdout.write(JSON.stringify({
preexistingVisible,
showAllGone,
}));
"""
@pytest.fixture(scope="module")
def _driver_paths(tmp_path_factory):
driver_dir = tmp_path_factory.mktemp("issue3691_drivers")
dropdown_path = driver_dir / "driver.js"
dropdown_path.write_text(_DROPDOWN_DRIVER, encoding="utf-8")
inplace_path = driver_dir / "driver_inplace.js"
inplace_path.write_text(_INPLACE_DRIVER, encoding="utf-8")
endpoint_error_path = driver_dir / "driver_endpoint_error.js"
endpoint_error_path.write_text(_INPLACE_ENDPOINT_ERROR_DRIVER, encoding="utf-8")
preexisting_path = driver_dir / "driver_preexisting.js"
preexisting_path.write_text(_INPLACE_PREEXISTING_DRIVER, encoding="utf-8")
return {
"dropdown": str(dropdown_path),
"inplace": str(inplace_path),
"endpoint_error": str(endpoint_error_path),
"preexisting": str(preexisting_path),
}
@pytest.fixture(scope="module")
def _dropdown_driver_path(tmp_path_factory):
path = tmp_path_factory.mktemp("issue3691_dropdown_driver") / "driver.js"
path.write_text(_DROPDOWN_DRIVER, encoding="utf-8")
return str(path)
def _run_dropdown_driver(driver_path: str, payload: dict | None = None) -> dict:
if payload is None:
payload = {
"groups": [
{
"provider": "OpenRouter",
"provider_id": "openrouter",
"models": [
{"id": "openrouter/visible-one", "label": "Visible One"},
{"id": "openrouter/visible-two", "label": "Visible Two"},
],
"extra_models": [
{"id": "openrouter/overflow-one", "label": "Overflow One"},
{"id": "openrouter/overflow-two", "label": "Overflow Two"},
],
}
],
"searchTerm": "overflow-two",
}
result = subprocess.run(
[NODE, driver_path, str(REPO / "static" / "ui.js"), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}")
return json.loads(result.stdout)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_runtime_picker_shows_generic_expander_and_searches_hidden_overflow(_dropdown_driver_path):
out = _run_dropdown_driver(_dropdown_driver_path)
initial_html = "\n".join(item["html"] for item in out["initial"])
searched_html = "\n".join(item["html"] for item in out["searched"])
expanded_html = "\n".join(item["html"] for item in out["expanded"])
assert "Show all 2 models" in initial_html, (
"The picker should render a synthetic show-all row when extra_models are present."
)
assert "openrouter/overflow-two" in searched_html, (
"Filtering must already match hidden overflow models before the group is expanded."
)
assert "Show all 2 models" not in expanded_html, (
"Once expanded, the synthetic row should disappear and the live options should take over."
)
assert out["optionCountAfterExpand"] == 4, (
"Expanding a capped group must append the hidden models into the live optgroup."
)
assert out["hiddenDatasetAfterExpand"] == "[]", (
"After expansion the optgroup should no longer advertise a hidden overflow tail."
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_runtime_picker_preserves_backend_decorated_nous_heading_without_double_count(
_dropdown_driver_path,
):
payload = {
"groups": [
{
"provider": "Nous (2 of 4)",
"provider_id": "nous",
"models": [
{"id": "@nous:visible-one", "label": "Visible One"},
{"id": "@nous:visible-two", "label": "Visible Two"},
],
"extra_models": [
{"id": "@nous:hidden-one", "label": "Hidden One"},
{"id": "@nous:hidden-two", "label": "Hidden Two"},
],
}
],
"searchTerm": "",
}
out = _run_dropdown_driver(_dropdown_driver_path, payload)
# The decorated overflow count ("Nous (2 of 4)") belongs on the GROUP HEADING
# (rendered via textContent), NOT stamped onto every per-row provider chip.
heading_text = "\n".join(item["textContent"] for item in out["initial"])
row_html = "\n".join(item["html"] for item in out["initial"])
assert "Nous (2 of 4) (4)" not in heading_text, (
"Backend-decorated Nous headings must not get a second frontend count suffix."
)
assert "Nous (2 of 4)" in heading_text, (
"The picker should preserve the backend-crafted Nous heading verbatim when overflow exists."
)
# Regression (#3691 row-chip leak): the per-row provider chip must NEVER carry
# the "(N of M)" overflow count. As of the collapsible-groups UX pass, the
# per-row provider chip is also suppressed entirely when a row sits under its
# own provider heading (the heading already names the provider, so repeating it
# on every row is pure noise) — the chip is kept only for hoisted/search rows.
assert "(2 of 4)" not in row_html, (
"The per-row provider chip must not carry the overflow count; it belongs on the heading only."
)
assert 'class="model-opt-provider">Nous (2 of 4)<' not in row_html, (
"A row chip must never show the decorated overflow label."
)
# Under its own provider heading, rows carry NO redundant provider chip.
assert 'class="model-opt-provider"' not in row_html, (
"Rows under their own provider heading should not repeat the provider chip (de-noise UX)."
)
# After expand, the heading must not double-count ("Nous (2 of 4) (4)") — it should
# strip the backend decoration and show the plain rendered-row count. (#3691)
expanded_heading_text = "\n".join(item["textContent"] for item in out["expanded"])
assert "(2 of 4) (4)" not in expanded_heading_text, (
"Expanded heading must not append a second count onto the decorated overflow label."
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_runtime_picker_excludes_configured_hidden_models_from_show_all_count(
_dropdown_driver_path,
):
payload = {
"groups": [
{
"provider": "OpenRouter",
"provider_id": "openrouter",
"models": [
{"id": "openrouter/visible-one", "label": "Visible One"},
],
"extra_models": [
{"id": "openrouter/overflow-one", "label": "Overflow One"},
{"id": "openrouter/overflow-two", "label": "Overflow Two"},
],
}
],
"configuredBadges": {
"openrouter/overflow-two": {
"label": "Primary",
"role": "primary",
"provider": "openrouter",
}
},
"searchTerm": "",
}
out = _run_dropdown_driver(_dropdown_driver_path, payload)
initial_html = "\n".join(item["html"] for item in out["initial"])
assert "Show all 1 models" in initial_html, (
"Configured overflow models should be excluded from the provider-group "
"show-all count once they are lifted into the Configured section."
)
assert "Show all 2 models" not in initial_html, (
"The show-all label must not over-report configured hidden overflow models."
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_runtime_inplace_expand_then_search_clear_preserves_expanded_group(_driver_paths):
"""Test that in-place expansion path preserves expanded state through search→clear cycle.
Verifies the _modelData.hiddenByDefault flip (ui.js:2378-2381) and hiddenCount=0
sync (ui.js:2383-2385) so the group doesn't snap back to capped view after clearing.
"""
payload = {
"groups": [
# A second group is selected so the openrouter group is NOT the
# selected group and can only remain expanded via the hiddenByDefault/
# _prevHasSearch machinery being tested — not via _selectedGroupKey.
{
"provider": "Anthropic",
"provider_id": "anthropic",
"models": [
{"id": "anthropic/claude", "label": "Claude"},
],
"extra_models": [],
},
{
"provider": "OpenRouter",
"provider_id": "openrouter",
"models": [
{"id": "openrouter/visible-one", "label": "Visible One"},
{"id": "openrouter/visible-two", "label": "Visible Two"},
],
"extra_models": [
{"id": "openrouter/overflow-one", "label": "Overflow One"},
{"id": "openrouter/overflow-two", "label": "Overflow Two"},
],
},
],
"selectedValue": "anthropic/claude",
"searchTerm": "overflow",
}
result = subprocess.run(
[NODE, _driver_paths["inplace"], str(REPO / "static" / "ui.js"), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node inplace driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}")
out = json.loads(result.stdout)
assert out["initialShowAll"], "Overflow group should show 'Show all' row initially"
assert not out["expandedHasShowAll"], "After in-place expansion, 'Show all' row should be gone"
assert not out["clearedHasShowAll"], "After search→clear, 'Show all' row should still be gone"
assert not out["clearedHasMoreButton"], (
"After search→clear, no 'Show all' expander should reappear inside the group body"
)
assert out["clearedRenderedModelCount"] == 4, (
"After search→clear following in-place expansion, all 4 models must remain rendered "
"in the .model-group-body (2 visible + 2 overflow); a missing hiddenByDefault sync "
"causes the group to snap back to 2 capped rows"
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_runtime_inplace_endpoint_error_group_renders_open(_driver_paths):
"""Test that groups with endpoint error render open by default.
Verifies the _hasEndpointError gate (ui.js:2552-2555) that opens groups when models
failed to fetch, so the user sees the error hint.
"""
payload = {
"groups": [
{
"provider": "OpenRouter",
"provider_id": "openrouter",
"models": [
{"id": "openrouter/visible-one", "label": "Visible One"},
],
"extra_models": [
{"id": "openrouter/overflow-one", "label": "Overflow One"},
{"id": "openrouter/overflow-two", "label": "Overflow Two"},
],
"modelsEndpointError": {"message": "fetch failed"},
},
# A second group whose model is selected so the openrouter group is NOT
# the selected group and won't auto-open via _selectedGroupKey matching.
# Without _hasEndpointError the openrouter wrapper gets display:none.
{
"provider": "Anthropic",
"provider_id": "anthropic",
"models": [
{"id": "anthropic/claude-3-5-sonnet", "label": "Claude 3.5 Sonnet"},
],
"extra_models": [],
},
],
"selectedValue": "anthropic/claude-3-5-sonnet",
"searchTerm": "",
}
result = subprocess.run(
[NODE, _driver_paths["endpoint_error"], str(REPO / "static" / "ui.js"), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node endpoint_error driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}")
out = json.loads(result.stdout)
assert out["foundWrapper"], (
"Expected a .model-group-body wrapper to be rendered"
)
assert out["groupRendersOpen"], (
"Groups with endpoint error should render open by default so the user sees the error hint"
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_runtime_inplace_expand_with_preexisting_options_reveals_them(_driver_paths):
"""Test that in-place expansion reveals pre-existing overflow options.
Verifies the extraModels.length guard (ui.js:2325) which prevents bailing on 0
newly-created options when some overflow models already exist as <option>s.
"""
payload = {
"groups": [
{
"provider": "OpenRouter",
"provider_id": "openrouter",
"models": [
{"id": "openrouter/visible-one", "label": "Visible One"},
],
"extra_models": [
{"id": "openrouter/overflow-one", "label": "Overflow One"},
{"id": "openrouter/overflow-two", "label": "Overflow Two"},
],
}
],
"preexistingModelIds": ["openrouter/overflow-one", "openrouter/overflow-two"],
"searchTerm": "",
}
result = subprocess.run(
[NODE, _driver_paths["preexisting"], str(REPO / "static" / "ui.js"), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node preexisting driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}")
out = json.loads(result.stdout)
assert out["preexistingVisible"], (
"Pre-existing overflow option should be revealed after in-place expansion"
)
assert out["showAllGone"], (
"After expansion, the 'Show all' row should be gone even when some overflow options pre-existed"
)