#!/usr/bin/env node /** * Settings Interactions UI Tests * * Tests for settings page interactions including tabs, search, * toggles, inputs, and save functionality. * * Run: node test_settings_interactions_ci.js */ const { setupTest, teardownTest, TestResults, log, navigateTo, withTimeout } = require('./test_lib'); // ============================================================================ // Settings Page Structure Tests // ============================================================================ const SettingsPageTests = { async settingsPageLoads(page, baseUrl) { await navigateTo(page, `${baseUrl}/settings`); const result = await page.evaluate(() => { const hasContent = document.body.textContent.length > 100; const title = document.title.toLowerCase(); const hasSettingsContent = title.includes('settings') || title.includes('configuration') || !!document.querySelector('.settings, #settings, [class*="settings"]'); return { hasContent, hasSettingsContent, title, url: window.location.href }; }); return { passed: result.hasContent, message: `Settings page loads (title: "${result.title}")` }; }, async settingsSearchFilter(page, baseUrl) { await navigateTo(page, `${baseUrl}/settings`); const result = await page.evaluate(() => { const searchInput = document.querySelector( 'input[type="search"], ' + 'input[placeholder*="search"], ' + 'input[placeholder*="filter"], ' + '#settings-search, ' + '.settings-filter' ); return { hasSearch: !!searchInput, placeholder: searchInput?.placeholder }; }); if (!result.hasSearch) { return { passed: null, skipped: true, message: 'No settings search found' }; } return { passed: true, message: `Settings search: placeholder="${result.placeholder}"` }; }, async settingsSearchFiltering(page, baseUrl) { // #settings-search re-renders #settings-content with a filtered subset // (settings.js::handleSearchInput filters allSettings by key/name/ // description/category) and restores the full active tab when cleared. // The old test typed 'model', never asserted the count actually // changed, and always returned passed:true. await navigateTo(page, `${baseUrl}/settings`); await page.waitForSelector('#settings-content .ldr-settings-item', { timeout: 15000 }); const count = () => page.$$eval('#settings-content .ldr-settings-item', els => els.length); const initial = await count(); if (initial === 0) { return { passed: false, message: 'No settings rendered to filter' }; } // A term matching nothing collapses the rendered list. The .catch // tolerates the wait timing out — the count assertion below reports the // actual diff, a clearer failure than a bare waitForFunction timeout. await page.type('#settings-search', 'zzznomatchzzz'); await page.waitForFunction( () => document.querySelectorAll('#settings-content .ldr-settings-item').length === 0, { timeout: 5000 } ).catch(() => {}); const filtered = await count(); // ...and clearing it restores the list. Set value + dispatch a real // 'input' event (what handleSearchInput listens for) — more portable // than triple-click + Backspace across headless targets. await page.$eval('#settings-search', el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await page.waitForFunction( (n) => document.querySelectorAll('#settings-content .ldr-settings-item').length === n, { timeout: 5000 }, initial ).catch(() => {}); const restored = await count(); const passed = filtered < initial && restored === initial; return { passed, message: passed ? `Search filters list (${initial} → ${filtered} on no-match → ${restored} restored)` : `Filter contract failed (initial=${initial}, filtered=${filtered}, restored=${restored})` }; } }; // ============================================================================ // Settings Tabs Tests // ============================================================================ const SettingsTabsTests = { async settingsTabsExist(page, baseUrl) { await navigateTo(page, `${baseUrl}/settings`); const result = await page.evaluate(() => { const tabs = document.querySelectorAll( '.tab, ' + '.nav-tab, ' + '[role="tab"], ' + '.settings-tab, ' + '.nav-link' ); const tabTexts = Array.from(tabs).slice(0, 10).map(t => t.textContent?.trim()); return { tabCount: tabs.length, tabs: tabTexts }; }); if (result.tabCount === 0) { return { passed: null, skipped: true, message: 'No settings tabs found' }; } return { passed: true, message: `${result.tabCount} tabs: ${result.tabs.join(', ')}` }; }, async tabNavigationWorks(page, baseUrl) { // Tabs are .ldr-settings-tab[data-tab]; clicking one adds .active and // re-renders #settings-content in place (settings.js:3930-3976) — there // is no page navigation and no .tab-content/.tab-pane element, so the // old probe matched nothing and the catch hardcoded passed:true. await navigateTo(page, `${baseUrl}/settings`); await page.waitForSelector('#settings-content .ldr-settings-item', { timeout: 15000 }); await page.waitForSelector('.ldr-settings-tab[data-tab="llm"]', { timeout: 5000 }); // Precondition: the dashboard opens on the 'all' tab. Make it explicit // so a changed default fails here with a clear message. const start = await page.evaluate(() => ({ allActive: document.querySelector('.ldr-settings-tab[data-tab="all"]')?.classList.contains('active'), itemCount: document.querySelectorAll('#settings-content .ldr-settings-item').length, })); if (start.allActive !== true) { return { passed: false, message: `Expected 'all' tab active on load (allActive=${start.allActive})` }; } // Switch to 'llm' — a strict subset of 'all', so the rendered item set // must shrink (proves the content actually re-rendered, not just the // tab class flipping). await page.click('.ldr-settings-tab[data-tab="llm"]'); await page.waitForFunction( () => document.querySelector('.ldr-settings-tab[data-tab="llm"]')?.classList.contains('active') === true, { timeout: 5000 } ); const state = await page.evaluate(() => ({ llmActive: document.querySelector('.ldr-settings-tab[data-tab="llm"]')?.classList.contains('active'), allActive: document.querySelector('.ldr-settings-tab[data-tab="all"]')?.classList.contains('active'), itemCount: document.querySelectorAll('#settings-content .ldr-settings-item').length, })); const reRendered = state.itemCount < start.itemCount; const passed = state.llmActive === true && state.allActive === false && reRendered; return { passed, message: passed ? `Tab switch works (llm active, all deactivated, items ${start.itemCount}→${state.itemCount})` : `Tab switch failed (llmActive=${state.llmActive}, allActive=${state.allActive}, items ${start.itemCount}→${state.itemCount})` }; }, async specificTabsPresent(page, baseUrl) { // The dashboard renders a fixed set of category tabs as // .ldr-settings-tab[data-tab] (settings_dashboard.html). Assert the // real data-tab set rather than fuzzy-matching body text. await navigateTo(page, `${baseUrl}/settings`); await page.waitForSelector('.ldr-settings-tab[data-tab]', { timeout: 10000 }); const tabs = await page.$$eval('.ldr-settings-tab[data-tab]', els => els.map(e => e.getAttribute('data-tab'))); const expected = ['llm', 'search', 'report', 'app', 'notifications']; const missing = expected.filter(t => !tabs.includes(t)); return { passed: missing.length === 0, message: missing.length === 0 ? `All expected category tabs present: ${tabs.join(', ')}` : `Missing category tabs: ${missing.join(', ')} (present: ${tabs.join(', ')})` }; } }; // ============================================================================ // Settings Controls Tests // ============================================================================ const SettingsControlsTests = { async textInputSettings(page, baseUrl) { // Real rendered text settings are input.ldr-settings-input[type=text] // inside #settings-content (settings_form.html). The old selector list // (.settings input etc.) matched nothing → permanent skip. await navigateTo(page, `${baseUrl}/settings`); await page.waitForSelector('#settings-content .ldr-settings-item', { timeout: 15000 }); const result = await page.evaluate(() => { const inputs = document.querySelectorAll('#settings-content input.ldr-settings-input[type="text"][name]'); const first = inputs[0]; return { count: inputs.length, firstName: first?.name }; }); return { passed: result.count > 0, message: result.count > 0 ? `Text settings inputs: ${result.count} (first: "${result.firstName}")` : 'No .ldr-settings-input[type=text] rendered in #settings-content' }; }, async numberInputSettings(page, baseUrl) { await navigateTo(page, `${baseUrl}/settings`); await page.waitForSelector('#settings-content .ldr-settings-item', { timeout: 15000 }); const result = await page.evaluate(() => { const inputs = document.querySelectorAll('#settings-content input[type="number"][name]'); const first = inputs[0]; return { count: inputs.length, firstName: first?.name, min: first?.min, max: first?.max, step: first?.step }; }); return { passed: result.count > 0, message: result.count > 0 ? `Number settings inputs: ${result.count} (first: "${result.firstName}", min=${result.min}, max=${result.max}, step=${result.step})` : 'No number inputs rendered in #settings-content' }; }, async toggleSwitchSettings(page, baseUrl) { // Real setting checkboxes are input.ldr-settings-checkbox // (settings_form.html), each paired with a .ldr-checkbox-hidden-fallback. // The old broad selectors matched nothing → permanent skip. await navigateTo(page, `${baseUrl}/settings`); await page.waitForSelector('#settings-content .ldr-settings-item', { timeout: 15000 }); const result = await page.evaluate(() => { const boxes = document.querySelectorAll('#settings-content input.ldr-settings-checkbox[name]'); const first = boxes[0]; return { count: boxes.length, firstName: first?.name, checked: first?.checked }; }); return { passed: result.count > 0, message: result.count > 0 ? `Setting checkboxes: ${result.count} (first: "${result.firstName}", checked=${result.checked})` : 'No .ldr-settings-checkbox rendered in #settings-content' }; }, async dropdownSelectSettings(page, baseUrl) { // Dropdown settings render either as a plain // or — for llm.provider / llm.model / search.tool — as a custom // .ldr-custom-dropdown widget (components/custom_dropdown.html). The // default config only has the custom kind (no plain , if present, must have options; custom dropdowns // populate their list lazily, so only their presence is asserted. const selectsOk = result.selectCount === 0 || result.optionCount > 0; return { passed: total > 0 && selectsOk, message: total > 0 ? `Dropdown settings: ${result.selectCount}