Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

537 lines
22 KiB
JavaScript

#!/usr/bin/env node
/**
* Settings Pages UI Tests
*
* Tests for the settings dashboard, tabs, and configuration options.
*
* Run: node test_settings_pages_ci.js
*/
const { setupTest, teardownTest, TestResults, log, delay, waitForVisible, navigateTo, withTimeout } = require('./test_lib');
// Open /settings/ on a clean "All Settings" tab. navigateTo() no-ops when the
// page is already on /settings/, so a prior test's active tab leaks in (e.g.
// the 'llm' tab, which hides search settings). Force the 'all' tab so every
// setting renders before the Input assertions read the DOM.
async function openSettingsAllTab(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
await page.waitForSelector('#settings-content .ldr-settings-item', { timeout: 15000 });
// Require the 'all' tab to exist so a missing tab fails here with context,
// rather than silently proceeding to an opaque downstream timeout.
await page.waitForSelector('.ldr-settings-tab[data-tab="all"]', { timeout: 5000 });
const isActive = await page.$eval('.ldr-settings-tab[data-tab="all"]', el => el.classList.contains('active'));
if (!isActive) {
await page.click('.ldr-settings-tab[data-tab="all"]');
await page.waitForFunction(
() => document.querySelector('.ldr-settings-tab[data-tab="all"]')?.classList.contains('active') === true,
{ timeout: 5000 }
).catch(() => {});
}
}
// llm.provider and search.tool both render as custom dropdowns (custom_dropdown.html):
// a hidden input[name=<key>] carries the form value inside a .ldr-custom-dropdown,
// and the dropdown JS resolves the selection asynchronously after load. Assert a
// real value ends up selected. The .catch on the value-wait lets the assertion
// below report a specific `value=""` message rather than a raw timeout.
async function assertCustomDropdownSelected(page, name, label) {
await page.waitForSelector(`#settings-content [name="${name}"]`, { timeout: 15000 });
await page.waitForFunction(
(n) => (document.querySelector(`[name="${n}"]`)?.value || '').length > 0,
{ timeout: 10000 }, name
).catch(() => {});
const result = await page.evaluate((n) => {
const hidden = document.querySelector(`[name="${n}"]`);
return { value: hidden?.value || '', hasDropdown: !!hidden?.closest('.ldr-custom-dropdown') };
}, name);
const passed = result.value.length > 0 && result.hasDropdown;
return {
passed,
message: passed
? `${label} renders a real selection ("${result.value}") in a custom dropdown`
: `${label} incomplete (value="${result.value}", customDropdown=${result.hasDropdown})`
};
}
// ============================================================================
// Settings Page Structure Tests
// ============================================================================
const SettingsPageTests = {
async settingsPageLoads(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
const result = await page.evaluate(() => {
return {
hasSettingsContent: !!document.querySelector('.settings-container, .ldr-settings, #settings, .settings-dashboard'),
hasForm: !!document.querySelector('form'),
hasTabs: !!document.querySelector('.nav-tabs, .tab-list, [role="tablist"], .settings-tabs'),
pageTitle: document.title,
hasAnyInputs: document.querySelectorAll('input, select, textarea').length > 0
};
});
const passed = result.hasSettingsContent || result.hasAnyInputs;
return {
passed,
message: passed
? `Settings page loaded (content=${result.hasSettingsContent}, form=${result.hasForm}, tabs=${result.hasTabs})`
: 'Settings page failed to load expected content'
};
},
async settingsTabsExist(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
const result = await page.evaluate(() => {
const tabs = document.querySelectorAll('.nav-tabs .nav-link, .tab-list button, [role="tab"], .settings-tab');
const tabTexts = Array.from(tabs).map(t => t.textContent?.trim());
// Also check for category sections if tabs aren't used
const categories = document.querySelectorAll('.settings-category, .setting-section, h2, h3');
const categoryTexts = Array.from(categories).map(c => c.textContent?.trim()).filter(t => t && t.length < 50);
return {
tabCount: tabs.length,
tabNames: tabTexts.slice(0, 10),
categoryCount: categories.length,
categoryNames: categoryTexts.slice(0, 10)
};
});
const hasTabs = result.tabCount > 0;
const hasCategories = result.categoryCount > 0;
return {
passed: hasTabs || hasCategories,
message: hasTabs
? `Found ${result.tabCount} tabs: ${result.tabNames.join(', ')}`
: hasCategories
? `Found ${result.categoryCount} setting categories`
: 'No tabs or categories found'
};
},
async settingsTabNavigation(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
const tabs = await page.$$('.nav-tabs .nav-link, [role="tab"]');
if (tabs.length < 2) {
return { passed: null, skipped: true, message: 'Not enough tabs to test navigation' };
}
try {
// Click second tab using evaluate to avoid stale element issues
await page.evaluate(() => {
const tabElements = document.querySelectorAll('.nav-tabs .nav-link, [role="tab"]');
if (tabElements[1]) {
tabElements[1].click();
}
});
// Wait for page to stabilize
await delay(1000);
// Wait for network to settle in case of navigation
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 5000 }).catch(() => {});
const result = await page.evaluate(() => {
const activeTab = document.querySelector('.nav-link.active, [role="tab"][aria-selected="true"]');
const visiblePanel = document.querySelector('.tab-pane.active, .tab-pane.show, [role="tabpanel"]:not([hidden])');
return {
hasActiveTab: !!activeTab,
activeTabText: activeTab?.textContent?.trim(),
hasVisiblePanel: !!visiblePanel
};
});
return {
passed: result.hasActiveTab,
message: result.hasActiveTab
? `Tab navigation works (active: "${result.activeTabText}")`
: 'Tab navigation not working properly'
};
} catch (err) {
// Handle navigation or context destruction gracefully
if (err.message && (err.message.includes('context') || err.message.includes('navigation') || err.message.includes('Target closed'))) {
return { passed: null, skipped: true, message: 'Tab click caused page navigation - skipping' };
}
return { passed: null, skipped: true, message: `Tab click failed: ${err.message}` };
}
}
};
// ============================================================================
// Settings Input Tests
// ============================================================================
const SettingsInputTests = {
async modelProviderSetting(page, baseUrl) {
// llm.provider renders as a custom dropdown; assert a *real* provider
// ends up selected (the old test returned passed:true for any input).
await openSettingsAllTab(page, baseUrl);
return assertCustomDropdownSelected(page, 'llm.provider', 'Provider setting');
},
async searchEngineSetting(page, baseUrl) {
// search.tool also renders as a custom dropdown. The old test's
// select[name*=search] matched an unrelated country <select> (e.g.
// SearXNG region); target the real search.tool field instead.
await openSettingsAllTab(page, baseUrl);
return assertCustomDropdownSelected(page, 'search.tool', 'Search engine setting');
},
async temperatureSetting(page, baseUrl) {
// llm.temperature renders as a range slider (settings_form.html). The
// old test passed as long as any temperature input existed; assert the
// real control contract: type=range with a numeric value within [min,max].
await openSettingsAllTab(page, baseUrl);
await page.waitForSelector('#settings-content [name="llm.temperature"]', { timeout: 15000 });
const result = await page.evaluate(() => {
const el = document.querySelector('[name="llm.temperature"]');
if (!el) return { exists: false };
return { exists: true, type: el.type, value: el.value, min: el.min, max: el.max, step: el.step };
});
if (!result.exists) {
return { passed: false, message: 'llm.temperature input not rendered' };
}
const val = parseFloat(result.value), min = parseFloat(result.min),
max = parseFloat(result.max), step = parseFloat(result.step);
const passed = result.type === 'range'
&& Number.isFinite(val) && Number.isFinite(min) && Number.isFinite(max)
&& val >= min && val <= max
&& Number.isFinite(step) && step > 0; // a real slider has a positive step
return {
passed,
message: passed
? `Temperature is a range slider (value=${result.value} in [${result.min}, ${result.max}], step=${result.step})`
: `Temperature contract failed (type=${result.type}, value=${result.value}, min=${result.min}, max=${result.max}, step=${result.step})`
};
},
async apiKeyFieldMasked(page, baseUrl) {
// API-key settings render as password inputs (settings_form.html,
// ui_element == "password"). Assert *every* rendered api_key field is
// masked, not just that one exists (the old test checked only the first).
await openSettingsAllTab(page, baseUrl);
await page.waitForSelector('#settings-content input[name$=".api_key"]', { timeout: 15000 });
const result = await page.evaluate(() => {
const fields = Array.from(document.querySelectorAll('#settings-content input[name$=".api_key"]'));
const unmasked = fields.filter(f => f.type !== 'password').map(f => f.name);
return { count: fields.length, unmasked };
});
const passed = result.count > 0 && result.unmasked.length === 0;
return {
passed,
message: passed
? `All ${result.count} api_key fields are masked (type=password)`
: `${result.unmasked.length}/${result.count} api_key fields NOT masked: ${result.unmasked.slice(0, 3).join(', ')}`
};
}
};
// ============================================================================
// Settings Action Tests
// ============================================================================
const SettingsActionTests = {
async saveButtonExists(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
const result = await page.evaluate(() => {
// Note: :contains() is not valid CSS, removed from selector
const saveButtonsByCss = document.querySelectorAll(
'button[type="submit"], ' +
'.btn-save, ' +
'[onclick*="save"], ' +
'button.btn-primary'
);
// More thorough search - find buttons by text content
const allButtons = Array.from(document.querySelectorAll('button, input[type="submit"]'));
const saveBtn = allButtons.find(b =>
b.textContent?.toLowerCase().includes('save') ||
b.value?.toLowerCase().includes('save')
);
return {
hasSaveButton: !!saveBtn || saveButtonsByCss.length > 0,
buttonText: saveBtn?.textContent?.trim() || saveBtn?.value,
buttonCount: allButtons.filter(b =>
b.textContent?.toLowerCase().includes('save') ||
b.value?.toLowerCase().includes('save')
).length
};
});
return {
passed: result.hasSaveButton,
message: result.hasSaveButton
? `Save button found ("${result.buttonText}")`
: 'No save button found'
};
},
async resetButtonExists(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
const result = await page.evaluate(() => {
const allButtons = Array.from(document.querySelectorAll('button, input[type="button"], input[type="reset"]'));
const resetBtn = allButtons.find(b =>
b.textContent?.toLowerCase().includes('reset') ||
b.textContent?.toLowerCase().includes('default') ||
b.value?.toLowerCase().includes('reset')
);
return {
hasResetButton: !!resetBtn,
buttonText: resetBtn?.textContent?.trim() || resetBtn?.value
};
});
if (!result.hasResetButton) {
return { passed: null, skipped: true, message: 'No reset/defaults button found' };
}
return {
passed: true,
message: `Reset button found ("${result.buttonText}")`
};
},
async searchFilterExists(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 search/filter input on settings page' };
}
return {
passed: true,
message: `Settings search/filter found (placeholder: "${result.placeholder}")`
};
}
};
// ============================================================================
// Settings Status Tests
// ============================================================================
const SettingsStatusTests = {
async warningsDisplay(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
const result = await page.evaluate(() => {
const warnings = document.querySelectorAll(
'.alert-warning, ' +
'.warning, ' +
'.config-warning, ' +
'[class*="warning"]'
);
const warningTexts = Array.from(warnings)
.map(w => w.textContent?.trim())
.filter(t => t && t.length < 200);
return {
warningCount: warnings.length,
warningTexts: warningTexts.slice(0, 3)
};
});
// This is informational - either warnings or no warnings is OK
return {
passed: true,
message: result.warningCount > 0
? `${result.warningCount} configuration warning(s) displayed`
: 'No configuration warnings (good configuration)'
};
},
async ollamaStatusIndicator(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
// Wait for settings form to render before checking for ollama elements
try {
await page.waitForSelector('[name="llm.provider"], input[name*="api_key"]', { timeout: 15000 });
} catch {
// Form didn't render — skip
}
const result = await page.evaluate(() => {
const ollamaStatus = document.querySelector(
'.ollama-status, ' +
'[data-ollama-status], ' +
'#ollama-status, ' +
'.status-indicator[class*="ollama"]'
);
const ollamaSection = document.querySelector('[class*="ollama"], [id*="ollama"]');
return {
hasStatusIndicator: !!ollamaStatus,
hasOllamaSection: !!ollamaSection,
statusText: ollamaStatus?.textContent?.trim()
};
});
if (!result.hasStatusIndicator && !result.hasOllamaSection) {
return { passed: null, skipped: true, message: 'No Ollama status indicator found' };
}
return {
passed: true,
message: result.hasStatusIndicator
? `Ollama status: "${result.statusText}"`
: 'Ollama section present on page'
};
},
async availableModelsApiWorks(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
const result = await page.evaluate(async (url) => {
try {
const response = await fetch(`${url}/settings/api/available-models`);
if (!response.ok) return { ok: false, status: response.status };
const data = await response.json();
return {
ok: true,
hasProviders: Object.keys(data).length > 0,
providers: Object.keys(data).slice(0, 5)
};
} catch (e) {
return { ok: false, error: e.message };
}
}, baseUrl);
return {
passed: result.ok,
message: result.ok
? `Available models API works (providers: ${result.providers?.join(', ') || 'none'})`
: `Available models API failed: ${result.error || 'status ' + result.status}`
};
},
async availableSearchEnginesApiWorks(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
const result = await page.evaluate(async (url) => {
try {
const response = await fetch(`${url}/settings/api/available-search-engines`);
if (!response.ok) return { ok: false, status: response.status };
const data = await response.json();
const engines = Array.isArray(data) ? data : Object.keys(data);
return {
ok: true,
engineCount: engines.length,
engines: engines.slice(0, 5)
};
} catch (e) {
return { ok: false, error: e.message };
}
}, baseUrl);
return {
passed: result.ok,
message: result.ok
? `Search engines API works (${result.engineCount} engines: ${result.engines?.join(', ')})`
: `Search engines API failed: ${result.error || 'status ' + result.status}`
};
}
};
// ============================================================================
// Main Test Runner
// ============================================================================
async function main() {
log.section('Settings Pages Tests');
const ctx = await setupTest({ authenticate: true });
const results = new TestResults('Settings Pages Tests');
const { page } = ctx;
const { baseUrl } = ctx.config;
const subTestTimeout = ctx.config.isCI ? 60000 : 30000;
async function run(category, name, testFn) {
try {
const result = await withTimeout(
testFn(page, baseUrl),
subTestTimeout,
`${category}/${name}`
);
if (result.skipped) {
results.skip(category, name, result.message);
} else {
results.add(category, name, result.passed, result.message);
}
} catch (error) {
results.add(category, name, false, `Error: ${error.message}`);
}
}
try {
// Page Structure Tests
log.section('Page Structure');
await run('Structure', 'Settings Page Loads', (p, u) => SettingsPageTests.settingsPageLoads(p, u));
await run('Structure', 'Settings Tabs Exist', (p, u) => SettingsPageTests.settingsTabsExist(p, u));
await run('Structure', 'Tab Navigation', (p, u) => SettingsPageTests.settingsTabNavigation(p, u));
// Input Tests
log.section('Settings Inputs');
await run('Inputs', 'Model Provider Setting', (p, u) => SettingsInputTests.modelProviderSetting(p, u));
await run('Inputs', 'Search Engine Setting', (p, u) => SettingsInputTests.searchEngineSetting(p, u));
await run('Inputs', 'Temperature Setting', (p, u) => SettingsInputTests.temperatureSetting(p, u));
await run('Inputs', 'API Key Field Masked', (p, u) => SettingsInputTests.apiKeyFieldMasked(p, u));
// Action Tests
log.section('Settings Actions');
await run('Actions', 'Save Button Exists', (p, u) => SettingsActionTests.saveButtonExists(p, u));
await run('Actions', 'Reset Button Exists', (p, u) => SettingsActionTests.resetButtonExists(p, u));
await run('Actions', 'Search Filter Exists', (p, u) => SettingsActionTests.searchFilterExists(p, u));
// Status Tests
log.section('Settings Status & APIs');
await run('Status', 'Warnings Display', (p, u) => SettingsStatusTests.warningsDisplay(p, u));
await run('Status', 'Ollama Status Indicator', (p, u) => SettingsStatusTests.ollamaStatusIndicator(p, u));
await run('API', 'Available Models API', (p, u) => SettingsStatusTests.availableModelsApiWorks(p, u));
await run('API', 'Available Search Engines API', (p, u) => SettingsStatusTests.availableSearchEnginesApiWorks(p, u));
} catch (error) {
log.error(`Fatal error: ${error.message}`);
console.error(error.stack);
} finally {
results.print();
results.save();
await teardownTest(ctx);
process.exit(results.exitCode());
}
}
// Run if executed directly
if (require.main === module) {
main().catch(error => {
console.error('Test runner failed:', error);
process.exit(1);
});
}
module.exports = { SettingsPageTests, SettingsInputTests, SettingsActionTests, SettingsStatusTests };