diff --git a/static/panels.js b/static/panels.js
index 0e479af9..976c3287 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -6911,6 +6911,47 @@ function switchSettingsSection(name,opts){
if(opts&&opts.fromSidebarItem)_closeMobileSidebarAfterPanelSelection();
}
+function _normalizeSettingsSearchText(value) {
+ return String(value || '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .toLowerCase();
+}
+
+function _extractSettingsDescriptionText(field, labelEl) {
+ const chunks = [];
+ const settingsSearch = (field.dataset && field.dataset.settingsSearch) || '';
+ if (settingsSearch) chunks.push(settingsSearch);
+ field.querySelectorAll('[data-i18n]').forEach(node => {
+ if (labelEl && (node === labelEl || labelEl.contains(node))) return;
+ const key = node.dataset ? node.dataset.i18n : null;
+ if (key) chunks.push(t(key));
+ });
+ return chunks.join(' ');
+}
+
+function _extractSettingsValueText(field) {
+ const chunks = [];
+ const controls = [...field.querySelectorAll('select, input, textarea')];
+ controls.forEach(control => {
+ const tagName = (control.tagName || '').toLowerCase();
+ if (tagName === 'select') {
+ control.querySelectorAll('option').forEach(option => {
+ if (option.dataset && option.dataset.i18n) {
+ chunks.push(t(option.dataset.i18n));
+ } else {
+ chunks.push(option.textContent);
+ }
+ });
+ return;
+ }
+ const type = (control.getAttribute && control.getAttribute('type')) || control.type || '';
+ if (tagName === 'input' && ['checkbox', 'radio', 'file', 'submit', 'reset', 'button'].includes(type)) return;
+ if (control.value) chunks.push(control.value);
+ });
+ return chunks.join(' ');
+}
+
async function _buildSettingsIndex() {
if (_settingsIndex) return;
// Memoize the in-flight build so concurrent searches share one pass; the
@@ -6920,6 +6961,9 @@ async function _buildSettingsIndex() {
// Ensure lazy-loaded panes are populated before reading the DOM
await Promise.all([loadProvidersPanel(), loadPluginsPanel(), loadExtensionsPanel()]);
const index = [];
+ const add = (entry) => {
+ index.push({ ...entry, _settingsSearchIndex: index.length });
+ };
const sectionMap = {
settingsPaneConversation: 'conversation',
settingsPaneAppearance: 'appearance',
@@ -6943,31 +6987,96 @@ async function _buildSettingsIndex() {
const labelEl = field.querySelector('label[data-i18n], label [data-i18n], label');
if (!labelEl) return;
const i18nKey = labelEl.dataset ? labelEl.dataset.i18n : undefined;
- const label = (i18nKey && t(i18nKey)) || labelEl.textContent.trim();
- if (label) {
- const searchBlob = [label, field.textContent, field.dataset ? field.dataset.settingsSearch : '']
- .filter(Boolean)
- .join(' ')
- .replace(/\s+/g, ' ')
- .trim();
- index.push({ label, searchBlob, sectionKey, i18nKey, el: field });
- }
+ const titleText = (i18nKey && t(i18nKey)) || labelEl.textContent.trim();
+ if (!titleText) return;
+ const valueText = _normalizeSettingsSearchText(_extractSettingsValueText(field));
+ const descriptionText = _normalizeSettingsSearchText(_extractSettingsDescriptionText(field, labelEl));
+ const searchBlob = [titleText, valueText, descriptionText, field.textContent]
+ .filter(Boolean)
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ add({
+ label: titleText,
+ titleText,
+ valueText,
+ descriptionText,
+ searchBlob,
+ sectionKey,
+ i18nKey,
+ el: field,
+ });
});
if (sectionKey === 'providers') {
pane.querySelectorAll('.provider-card').forEach(card => {
const cardName = ((card.querySelector('.provider-card-name') || {}).textContent || '').trim();
- if (cardName) index.push({ label: cardName, sectionKey, el: card, cardName });
+ if (cardName) {
+ const titleText = cardName;
+ const valueText = _normalizeSettingsSearchText(_extractSettingsValueText(card));
+ const descriptionText = _normalizeSettingsSearchText(_extractSettingsDescriptionText(card));
+ const searchBlob = [cardName, valueText, descriptionText, card.textContent]
+ .filter(Boolean)
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ add({
+ label: cardName,
+ titleText,
+ valueText,
+ descriptionText,
+ searchBlob,
+ sectionKey,
+ el: card,
+ cardName,
+ });
+ }
card.querySelectorAll('.provider-card-field').forEach(field => {
const fieldLabel = ((field.querySelector('.provider-card-label') || {}).textContent || '').trim();
const label = [cardName, fieldLabel].filter(Boolean).join(' ');
- if (label) index.push({ label, sectionKey, el: field, cardName, fieldLabel });
+ if (!label) return;
+ const valueText = _normalizeSettingsSearchText(_extractSettingsValueText(field));
+ const descriptionText = _normalizeSettingsSearchText(_extractSettingsDescriptionText(field));
+ const searchBlob = [label, valueText, descriptionText, field.textContent]
+ .filter(Boolean)
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ add({
+ label,
+ titleText: label,
+ valueText,
+ descriptionText,
+ searchBlob,
+ sectionKey,
+ el: field,
+ cardName,
+ fieldLabel,
+ });
});
});
}
if (sectionKey === 'plugins') {
pane.querySelectorAll('.plugin-card').forEach(card => {
const cardName = ((card.querySelector('.provider-card-name') || {}).textContent || '').trim();
- if (cardName) index.push({ label: cardName, sectionKey, el: card, cardName });
+ if (!cardName) return;
+ const titleText = cardName;
+ const valueText = _normalizeSettingsSearchText(_extractSettingsValueText(card));
+ const descriptionText = _normalizeSettingsSearchText(_extractSettingsDescriptionText(card));
+ const searchBlob = [cardName, valueText, descriptionText, card.textContent]
+ .filter(Boolean)
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ add({
+ label: cardName,
+ titleText,
+ valueText,
+ descriptionText,
+ searchBlob,
+ sectionKey,
+ el: card,
+ cardName,
+ });
});
}
}
@@ -6998,16 +7107,26 @@ async function filterSettings(query) {
system: t('settings_tab_system') || 'System',
help: t('settings_tab_help') || 'Help',
};
- const matches = (_settingsIndex || []).filter(entry =>
- (entry.searchBlob || entry.label).toLowerCase().includes(q)
- );
+ const matches = (_settingsIndex || []).map((entry) => {
+ const score = _scoreSettingsSearchMatch(entry, q);
+ return score ? { entry, score, index: entry._settingsSearchIndex } : null;
+ }).filter(Boolean);
if (!matches.length) {
resultsEl.innerHTML = `
${esc(t('settings_search_no_results') || 'No settings found.')}
`;
resultsEl.style.display = '';
return;
}
resultsEl.innerHTML = '';
- for (const m of matches.slice(0, 12)) {
+ matches.sort((left, right) => {
+ if (left.score.bucketIndex !== right.score.bucketIndex) {
+ return left.score.bucketIndex - right.score.bucketIndex;
+ }
+ if (left.score.matchIndex !== right.score.matchIndex) {
+ return left.score.matchIndex - right.score.matchIndex;
+ }
+ return left.index - right.index;
+ });
+ for (const { entry: m } of matches.slice(0, 12)) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'settings-search-result';
@@ -7026,6 +7145,29 @@ async function filterSettings(query) {
resultsEl.style.display = '';
}
+function _scoreSettingsSearchMatch(entry, q) {
+ const query = (q || '').toLowerCase().trim();
+ if (!query) return null;
+ const buckets = [
+ ['titleText', 0],
+ ['valueText', 1],
+ ['descriptionText', 2],
+ ['searchBlob', 3],
+ ];
+ for (const [bucketName, bucketIndex] of buckets) {
+ const hay = _normalizeSettingsSearchText(entry[bucketName]);
+ if (!hay) continue;
+ const matchIndex = hay.indexOf(query);
+ if (matchIndex < 0) continue;
+ return {
+ bucketIndex,
+ matchType: matchIndex === 0 ? 'prefix' : 'contains',
+ matchIndex,
+ };
+ }
+ return null;
+}
+
function _navigateToSettingsField(entry) {
// The panes were populated when the index was built, so skip the tab-switch
// lazy reload: loadProvidersPanel()/loadPluginsPanel() rebuild the pane DOM
diff --git a/tests/test_3850_settings_search.py b/tests/test_3850_settings_search.py
index 465129f2..7c5bf62d 100644
--- a/tests/test_3850_settings_search.py
+++ b/tests/test_3850_settings_search.py
@@ -50,6 +50,15 @@ class TestSettingsSearch:
assert "searchBlob" in body, (
"_buildSettingsIndex must store a searchBlob for settings fields"
)
+ assert "titleText" in body, (
+ "_buildSettingsIndex must store title bucket text"
+ )
+ assert "valueText" in body, (
+ "_buildSettingsIndex must store value bucket text"
+ )
+ assert "descriptionText" in body, (
+ "_buildSettingsIndex must store description bucket text"
+ )
def test_panels_js_has_filter_settings_function(self):
"""panels.js must contain filterSettings function."""
@@ -57,12 +66,15 @@ class TestSettingsSearch:
"panels.js must contain filterSettings(query) function"
)
body = PANELS_JS[PANELS_JS.find("function filterSettings(query)"):]
- assert "(entry.searchBlob || entry.label).toLowerCase().includes(q)" in body, (
- "filterSettings must do case-insensitive substring matching over the search blob"
+ assert "_scoreSettingsSearchMatch" in body, (
+ "filterSettings must call _scoreSettingsSearchMatch for ranking"
)
assert "esc(m.label)" in body, (
"filterSettings must keep rendering the visible label text"
)
+ assert ".sort(" in body, (
+ "filterSettings must order matches deterministically"
+ )
def test_panels_js_has_navigate_to_field_function(self):
"""panels.js must contain _navigateToSettingsField function."""
@@ -198,7 +210,11 @@ class TestSettingsSearch:
"""Plugins pane entries must index plugin cards by plugin name."""
idx = PANELS_JS.find("function _buildSettingsIndex()")
assert idx >= 0, "_buildSettingsIndex not found"
- body = PANELS_JS[idx:idx + 3500]
+ end = PANELS_JS.find("function _resolveSettingsField(entry)", idx)
+ body = PANELS_JS[idx:end]
+ assert "if (sectionKey === 'plugins')" in body, (
+ "_buildSettingsIndex should include a plugins section branch"
+ )
assert "pane.querySelectorAll('.plugin-card')" in body, (
"_buildSettingsIndex must scan plugin cards so Plugins search is not empty"
)
@@ -243,8 +259,11 @@ class TestSettingsSearchReviewFixes:
label[data-i18n]. Otherwise most checkbox settings are unsearchable."""
idx = PANELS_JS.find("function _buildSettingsIndex")
assert idx >= 0, "_buildSettingsIndex not found"
- body = PANELS_JS[idx:idx + 1600]
- assert "label[data-i18n], label [data-i18n], label" in body, (
+ body = PANELS_JS[idx:idx + 2600]
+ assert "field.querySelector(" in body, (
+ "field index query should run a querySelector against field"
+ )
+ assert "label[data-i18n]" in body, (
"field index query must include 'label [data-i18n]' (span-in-label) "
"and plain 'label' so toggle settings are searchable"
)
diff --git a/tests/test_5148_settings_search_busy_input_terms.py b/tests/test_5148_settings_search_busy_input_terms.py
index 8122dbfd..36942530 100644
--- a/tests/test_5148_settings_search_busy_input_terms.py
+++ b/tests/test_5148_settings_search_busy_input_terms.py
@@ -24,24 +24,24 @@ class TestBusyInputSettingsSearchTerms:
body = PANELS_JS[idx:]
assert "searchBlob" in body, "_buildSettingsIndex must build a searchBlob"
assert "field.textContent" in body, "_buildSettingsIndex must include field text in searchBlob"
- assert "field.dataset ? field.dataset.settingsSearch : ''" in body, (
- "_buildSettingsIndex must include supplemental search terms in searchBlob"
+ assert "settingsSearch" in body, (
+ "_buildSettingsIndex must include supplemental data-settings-search terms in searchBlob"
)
def test_filter_settings_uses_search_blob_and_keeps_label_rendering(self):
- """filterSettings must match searchBlob, render labels, and keep the cap."""
+ """filterSettings must rank by source and keep visible labels."""
idx = PANELS_JS.find("function filterSettings(query)")
assert idx >= 0, "filterSettings not found"
body = PANELS_JS[idx:]
- assert "(entry.searchBlob || entry.label).toLowerCase().includes(q)" in body, (
- "filterSettings must search the richer blob instead of label-only text"
- )
assert "esc(m.label)" in body, (
"filterSettings must keep rendering the visible label"
)
assert ".slice(0, 12)" in body, (
"filterSettings must keep the existing 12-result cap"
)
- assert ".sort(" not in body, (
- "filterSettings must not introduce ranking or reordering"
+ assert ".sort(" in body, (
+ "filterSettings must apply deterministic ranking"
+ )
+ assert "_scoreSettingsSearchMatch" in body, (
+ "filterSettings must score ranked matches by source"
)
diff --git a/tests/test_5149_settings_search_ranking.py b/tests/test_5149_settings_search_ranking.py
new file mode 100644
index 00000000..6bb05d43
--- /dev/null
+++ b/tests/test_5149_settings_search_ranking.py
@@ -0,0 +1,619 @@
+"""Browserless behavioral coverage for settings search ranking."""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parent.parent
+PANELS_JS = ROOT / "static" / "panels.js"
+NODE = shutil.which("node")
+
+pytestmark = pytest.mark.skipif(
+ NODE is None,
+ reason="node is required to execute browserless settings-search ranking harness",
+)
+
+
+_DRIVER = r"""
+const fs = require('fs');
+
+const panelsPath = process.argv[2];
+const scenario = JSON.parse(process.argv[3] || '{}');
+const src = fs.readFileSync(panelsPath, 'utf8');
+
+function extractSearchFunctions(source) {
+ const start = source.indexOf('function _normalizeSettingsSearchText(value)');
+ if (start < 0) throw new Error('missing settings search helpers');
+ const endMarker = 'function _navigateToSettingsField(entry)';
+ const end = source.indexOf(endMarker, start);
+ if (end < 0) throw new Error('missing end marker before _navigateToSettingsField');
+ return source.slice(start, end);
+}
+
+function normalizeText(value) {
+ return String(value || '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .toLowerCase();
+}
+
+function parseDataAttributeName(name) {
+ return String(name || '')
+ .split('-')
+ .map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))
+ .join('');
+}
+
+function createClassList(owner) {
+ const tokens = new Set();
+ const update = (value) => {
+ owner._className = Array.from(tokens).join(' ');
+ };
+ return {
+ contains(name) {
+ return tokens.has(name);
+ },
+ add(name) {
+ if (!name) return;
+ const token = String(name).trim();
+ if (!token) return;
+ tokens.add(token);
+ update();
+ },
+ remove(name) {
+ tokens.delete(String(name));
+ update();
+ },
+ toggle(name, force) {
+ const token = String(name);
+ const enabled = force !== undefined ? force : !tokens.has(token);
+ if (enabled) tokens.add(token);
+ else tokens.delete(token);
+ update();
+ return enabled;
+ },
+ _set(value) {
+ tokens.clear();
+ for (const token of String(value || '').split(/\s+/)) {
+ if (token) tokens.add(token);
+ }
+ update();
+ },
+ };
+}
+
+class FakeElement {
+ constructor(tagName = 'div') {
+ this.tagName = String(tagName || '').toLowerCase();
+ this.children = [];
+ this.parentElement = null;
+ this.parentNode = null;
+ this.attributes = {};
+ this.dataset = {};
+ this.style = {};
+ this.classList = createClassList(this);
+ this.classList._set('');
+ this._text = '';
+ this._innerHTML = '';
+ this._className = '';
+ this.value = '';
+ }
+
+ set id(value) {
+ this._id = String(value || '');
+ }
+
+ get id() {
+ return this._id || '';
+ }
+
+ set className(value) {
+ this._className = String(value || '');
+ this.classList._set(this._className);
+ }
+
+ get className() {
+ return this._className;
+ }
+
+ get textContent() {
+ const childText = this.children.map((child) => child.textContent).filter(Boolean).join(' ');
+ return [this._text, childText].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
+ }
+
+ set textContent(value) {
+ this._text = String(value || '');
+ }
+
+ set innerHTML(value) {
+ this._innerHTML = String(value || '');
+ }
+
+ get innerHTML() {
+ return this._innerHTML;
+ }
+
+ setAttribute(name, value) {
+ const key = String(name || '');
+ const v = String(value || '');
+ this.attributes[key] = v;
+ if (key.startsWith('data-')) {
+ this.dataset[parseDataAttributeName(key.slice(5))] = v;
+ }
+ if (key === 'class') this.className = v;
+ if (key === 'id') this.id = v;
+ }
+
+ getAttribute(name) {
+ if (name === 'class') return this.className;
+ if (name === 'id') return this.id;
+ return this.attributes[String(name)] || null;
+ }
+
+ appendChild(child) {
+ if (!child) return child;
+ this.children.push(child);
+ child.parentElement = this;
+ child.parentNode = this;
+ return child;
+ }
+
+ contains(node) {
+ let current = node;
+ while (current) {
+ if (current === this) return true;
+ current = current.parentElement;
+ }
+ return false;
+ }
+
+ closest(selector) {
+ let current = this;
+ while (current) {
+ if (matchesSelector(current, selector)) return current;
+ current = current.parentElement;
+ }
+ return null;
+ }
+
+ querySelectorAll(selector) {
+ return querySelectorAll(this, selector);
+ }
+
+ querySelector(selector) {
+ const found = this.querySelectorAll(selector);
+ return found[0] || null;
+ }
+
+ addEventListener() {}
+}
+
+function allNodes(root) {
+ const nodes = [root];
+ for (const child of root.children || []) {
+ nodes.push(...allNodes(child));
+ }
+ return nodes;
+}
+
+function isNodeMatch(node, selector) {
+ if (!node || !selector) return false;
+ const tagOnly = selector.match(/^([a-z0-9-]+)$/i);
+ if (selector === '*') return true;
+ if (selector === '[data-i18n]') {
+ return !!(node.dataset && node.dataset.i18n);
+ }
+ if (selector.startsWith('[') && selector.endsWith(']')) {
+ const body = selector.slice(1, -1);
+ const [key, expected] = body.split('=').map((it) => it.trim());
+ const actual = key.startsWith('data-')
+ ? (node.dataset ? node.dataset[parseDataAttributeName(key.slice(5))] : null)
+ : (node.getAttribute ? node.getAttribute(key) : null);
+ if (expected === undefined) return !!actual;
+ const expectedValue = expected.replace(/^["']|["']$/g, '');
+ return actual === expectedValue;
+ }
+ if (selector.startsWith('.')) {
+ return node.classList.contains(selector.slice(1));
+ }
+ const withAttr = selector.match(/^([a-z0-9-]+)\[([^\]]+)\]$/i);
+ if (withAttr) {
+ const tag = withAttr[1].toLowerCase();
+ const attr = withAttr[2];
+ if (node.tagName !== tag) return false;
+ if (attr === 'data-i18n') return !!(node.dataset && node.dataset.i18n);
+ return !!node.getAttribute(attr);
+ }
+ return node.tagName === selector.toLowerCase();
+}
+
+function matchesSelector(node, selector) {
+ const value = selector.trim();
+ if (!value.includes(' ')) return isNodeMatch(node, value);
+ const [ancestorSelector, targetSelector] = value.split(/\s+/);
+ if (!ancestorSelector || !targetSelector) return false;
+ const ancestors = allNodes(node);
+ for (const candidate of ancestors) {
+ if (!isNodeMatch(candidate, ancestorSelector)) continue;
+ for (const child of allNodes(candidate).slice(1)) {
+ if (isNodeMatch(child, targetSelector)) return true;
+ }
+ }
+ return false;
+}
+
+function querySelectorAll(node, selector) {
+ const parts = selector.split(',').map((part) => part.trim()).filter(Boolean);
+ const found = [];
+ const seen = new Set();
+ for (const part of parts) {
+ if (part.includes(' ')) {
+ const [ancestorSelector, targetSelector] = part.split(/\s+/);
+ for (const candidate of allNodes(node)) {
+ if (!isNodeMatch(candidate, ancestorSelector)) continue;
+ for (const child of allNodes(candidate).slice(1)) {
+ if (!isNodeMatch(child, targetSelector)) continue;
+ if (!seen.has(child)) {
+ seen.add(child);
+ found.push(child);
+ }
+ }
+ }
+ continue;
+ }
+ for (const candidate of allNodes(node)) {
+ if (!isNodeMatch(candidate, part)) continue;
+ if (!seen.has(candidate)) {
+ seen.add(candidate);
+ found.push(candidate);
+ }
+ }
+ }
+ return found;
+}
+
+const registry = new Map();
+
+function register(node) {
+ if (node && node.id) registry.set(node.id, node);
+ return node;
+}
+
+function $(id) {
+ return registry.get(id) || null;
+}
+
+function createElement(tagName) {
+ return new FakeElement(tagName);
+}
+
+function makePane(id) {
+ return register(Object.assign(createElement('div'), { id }));
+}
+
+function makeLabel(text) {
+ const label = createElement('label');
+ label.textContent = text;
+ return label;
+}
+
+function makeSettingsField({
+ labelText,
+ descriptionText = '',
+ descriptionI18nKey = '',
+ settingsSearch = '',
+ options = [],
+}) {
+ const field = createElement('div');
+ field.className = 'settings-field';
+ if (settingsSearch) field.dataset.settingsSearch = settingsSearch;
+ const label = makeLabel(labelText);
+ field.appendChild(label);
+ if (options.length) {
+ const select = createElement('select');
+ for (const optionText of options) {
+ const option = createElement('option');
+ option.textContent = optionText;
+ select.appendChild(option);
+ }
+ field.appendChild(select);
+ }
+ if (descriptionText || descriptionI18nKey) {
+ const description = createElement('div');
+ if (descriptionI18nKey) description.dataset.i18n = descriptionI18nKey;
+ description.textContent = descriptionText || '';
+ field.appendChild(description);
+ }
+ return field;
+}
+
+function makeProviderCard(name) {
+ const card = createElement('div');
+ card.className = 'provider-card';
+ const cardName = createElement('div');
+ cardName.className = 'provider-card-name';
+ cardName.textContent = name;
+ card.appendChild(cardName);
+ return card;
+}
+
+function makeProviderField(cardName, fieldLabel, valueText) {
+ const field = createElement('div');
+ field.className = 'provider-card-field';
+ const label = createElement('div');
+ label.className = 'provider-card-label';
+ label.textContent = fieldLabel;
+ field.appendChild(label);
+ if (valueText) {
+ const input = createElement('input');
+ input.value = valueText;
+ field.appendChild(input);
+ }
+ return field;
+}
+
+function makePluginCard(name) {
+ const card = createElement('div');
+ card.className = 'provider-card plugin-card';
+ const cardName = createElement('div');
+ cardName.className = 'provider-card-name';
+ cardName.textContent = name;
+ card.appendChild(cardName);
+ return card;
+}
+
+function setupDom(mode) {
+ const conversation = makePane('settingsPaneConversation');
+ const providers = makePane('settingsPaneProviders');
+ const plugins = makePane('settingsPanePlugins');
+ register(createElement('div')).id = 'settingsPaneAppearance';
+ register(createElement('div')).id = 'settingsPanePreferences';
+ register(createElement('div')).id = 'settingsPaneExtensions';
+ register(createElement('div')).id = 'settingsPaneSystem';
+ register(createElement('div')).id = 'settingsPaneHelp';
+ const settingsSearch = createElement('input');
+ settingsSearch.id = 'settingsSearch';
+ register(settingsSearch);
+ const results = createElement('div');
+ results.id = 'settingsSearchResults';
+ register(results);
+
+ if (mode === 'title-vs-description') {
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Blob field',
+ descriptionText: 'priority appears only in this plain-text fallback',
+ }));
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Descriptor-only field',
+ descriptionI18nKey: 'settings_desc_priority_only',
+ }));
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Priority title field',
+ descriptionI18nKey: 'settings_desc_irrelevant',
+ }));
+ } else if (mode === 'value-vs-description') {
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Blob value field',
+ descriptionText: 'rank only lives in this plain-text fallback text',
+ }));
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Value-later field',
+ descriptionI18nKey: 'settings_desc_rank_only',
+ }));
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Value field',
+ options: ['rank-value option'],
+ }));
+ } else if (mode === 'same-tier-order') {
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Description-later-contains',
+ descriptionI18nKey: 'settings_desc_needle_contains_late',
+ }));
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Description-prefix',
+ descriptionI18nKey: 'settings_desc_needle_prefix',
+ }));
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Description-earlier-contains',
+ descriptionI18nKey: 'settings_desc_needle_contains_early',
+ }));
+ } else if (mode === 'label-rendering') {
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Visible Label',
+ options: ['token-label-option'],
+ descriptionText: 'description with token-label-option',
+ }));
+ } else if (mode === 'supplemental-term') {
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Supplemental alias field',
+ descriptionText: 'description without the alias query',
+ settingsSearch: 'steer alias query',
+ }));
+ } else if (mode === 'twelve-result-cap') {
+ for (let i = 0; i < 13; i++) {
+ conversation.appendChild(makeSettingsField({
+ labelText: `Entry ${String(i + 1).padStart(2, '0')}`,
+ descriptionText: `shared token for cap ${i}`,
+ }));
+ }
+ } else if (mode === 'provider-plugin') {
+ conversation.appendChild(makeSettingsField({
+ labelText: 'Conversation field',
+ descriptionText: 'provider plugin terms plugin',
+ }));
+ const providerCard = makeProviderCard('Provider Alpha');
+ providerCard.appendChild(makeProviderField('provider', 'API Key', 'sk-test'));
+ providers.appendChild(providerCard);
+ plugins.appendChild(makePluginCard('Plugin Sample'));
+ }
+
+ return {
+ conversation,
+ providers,
+ plugins,
+ };
+}
+
+function runScenario(command) {
+ setupDom(command);
+ return new Promise(async (resolve, reject) => {
+ try {
+ const block = extractSearchFunctions(src);
+ eval(block);
+ globalThis.$ = $;
+ globalThis.document = {
+ createElement: createElement,
+ };
+ globalThis.t = (key) => {
+ const translations = {
+ settings_tab_conversation: 'Conversation',
+ settings_tab_appearance: 'Appearance',
+ settings_tab_preferences: 'Preferences',
+ providers_tab_title: 'Providers',
+ settings_tab_plugins: 'Plugins',
+ settings_tab_extensions: 'Extensions',
+ settings_tab_system: 'System',
+ settings_tab_help: 'Help',
+ settings_desc_priority_only: 'this contains priority in the descriptor',
+ settings_desc_irrelevant: 'nothing about query there',
+ settings_desc_rank_only: 'contains rank token in descriptor only',
+ settings_desc_needle_prefix: 'needle appears at the start of this description',
+ settings_desc_needle_contains_early: 'this field has needle near the front',
+ settings_desc_needle_contains_late: 'this field waits a while before it reaches needle',
+ };
+ return translations[key] || String(key || '');
+ };
+ globalThis.esc = (value) => String(value || '');
+ globalThis.loadProvidersPanel = async () => undefined;
+ globalThis.loadPluginsPanel = async () => undefined;
+ globalThis.loadExtensionsPanel = async () => undefined;
+ globalThis._settingsIndex = null;
+ globalThis._settingsIndexPromise = null;
+ globalThis._settingsSearchSeq = 0;
+
+ let query = '';
+ if (command === 'title-vs-description') query = 'priority';
+ else if (command === 'value-vs-description') query = 'rank';
+ else if (command === 'same-tier-order') query = 'needle';
+ else if (command === 'label-rendering') query = 'token-label-option';
+ else if (command === 'supplemental-term') query = 'steer alias';
+ else if (command === 'twelve-result-cap') query = 'shared';
+ else if (command === 'provider-plugin') query = scenario.query || 'plugin';
+ await filterSettings(query);
+
+ const results = $('settingsSearchResults');
+ const labels = [];
+ const html = results.innerHTML || '';
+ for (const child of results.children || []) {
+ if (!child || typeof child.innerHTML !== 'string') continue;
+ const re = /([^<]*)<\/span>/g;
+ let match;
+ while ((match = re.exec(child.innerHTML)) !== null) labels.push(match[1]);
+ }
+ resolve({
+ query,
+ labels,
+ html,
+ resultCount: (results.children || []).length,
+ noResults: html.includes('settings-search-empty'),
+ });
+ } catch (err) {
+ reject(err);
+ }
+ });
+}
+
+(async () => {
+ const payload = await runScenario(scenario.command);
+ process.stdout.write(JSON.stringify(payload));
+})();
+"""
+
+
+@pytest.fixture(scope="module")
+def driver_path(tmp_path_factory):
+ return str(tmp_path_factory.mktemp("settings-search-ranking-driver") / "driver.js")
+
+
+@pytest.fixture
+def driver_file(driver_path):
+ Path(driver_path).write_text(_DRIVER, encoding="utf-8")
+ return driver_path
+
+
+def _run_driver(driver_file, command):
+ process = subprocess.run(
+ [
+ NODE,
+ driver_file,
+ str(PANELS_JS),
+ json.dumps(command if isinstance(command, dict) else {"command": command}),
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=60,
+ )
+ if process.returncode != 0:
+ raise RuntimeError(process.stderr.strip() or process.stdout.strip())
+ return json.loads(process.stdout)
+
+
+def test_title_matches_outrank_descriptor_only(driver_file):
+ payload = _run_driver(driver_file, "title-vs-description")
+ assert payload["labels"][0] == "Priority title field"
+ assert payload["labels"][1] == "Descriptor-only field"
+ assert payload["labels"][2] == "Blob field"
+ assert not payload["noResults"]
+
+
+def test_value_or_option_match_outranks_descriptor_match(driver_file):
+ payload = _run_driver(driver_file, "value-vs-description")
+ assert payload["labels"][0] == "Value field"
+ assert payload["labels"][1] == "Value-later field"
+ assert payload["labels"][2] == "Blob value field"
+ assert not payload["noResults"]
+
+
+def test_same_tier_order_uses_prefix_and_earlier_index(driver_file):
+ payload = _run_driver(driver_file, "same-tier-order")
+ assert payload["labels"][0] == "Description-prefix"
+ assert payload["labels"][1] == "Description-earlier-contains"
+ assert payload["labels"][2] == "Description-later-contains"
+
+
+def test_rendered_label_is_visible_field_title(driver_file):
+ payload = _run_driver(driver_file, "label-rendering")
+ assert payload["labels"][0] == "Visible Label"
+
+
+def test_supplemental_search_terms_remain_behaviorally_searchable(driver_file):
+ payload = _run_driver(driver_file, "supplemental-term")
+ assert payload["labels"] == ["Supplemental alias field"]
+
+
+def test_results_cap_still_applies_to_ranked_matches(driver_file):
+ payload = _run_driver(driver_file, "twelve-result-cap")
+ assert payload["resultCount"] == 12
+ assert not payload["noResults"]
+
+
+def test_provider_and_plugin_cards_remain_searchable(driver_file):
+ provider_payload = _run_driver(
+ driver_file,
+ {"command": "provider-plugin", "query": "alpha"},
+ )
+ field_payload = _run_driver(
+ driver_file,
+ {"command": "provider-plugin", "query": "key"},
+ )
+ plugin_payload = _run_driver(
+ driver_file,
+ {"command": "provider-plugin", "query": "plugin"},
+ )
+ assert "Provider Alpha" in provider_payload["labels"]
+ assert "Provider Alpha API Key" in field_payload["labels"]
+ assert "Plugin Sample" in plugin_payload["labels"]