/** * Egress-Policy UI Test (CI-safe) * ================================ * * Exercises the Privacy & Egress feature surface that does NOT need a * live LLM or search engine: dropdown, scope-derived colour cues, live * propagation to body[data-scope], settings persistence across reloads, * chat-page inheritance, warning banner presence/absence per scope, and * the require-local toggles. * * Live-engine work (real research runs against Ollama/SearXNG) lives in * NO_CI_test_egress_policy_live_research.js — runner skips it in CI. * * Screenshots: many. All gated on !process.env.CI per project * convention (see test_settings_page.js:57). They drop into * tests/ui_tests/screenshots/egress-policy/ for visual inspection. * * Usage: * node tests/ui_tests/test_egress_policy_ui.js * HEADLESS=false node tests/ui_tests/test_egress_policy_ui.js # see the browser */ const fs = require('fs'); const path = require('path'); const puppeteer = require('puppeteer'); const AuthHelper = require('./auth_helper'); const { getPuppeteerLaunchOptions } = require('./puppeteer_config'); const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:5000'; const IS_CI = !!process.env.CI; const SCREENSHOT_DIR = path.join( __dirname, 'screenshots', 'egress-policy', ); // Public colour palette for each scope (mirrors base.html style block). // Used to verify the computed border-left-color matches what the user // is supposed to see — catches regressions where the data-scope flips // but the CSS rule is silently broken. BOTH intentionally has no // accent (default = no cue, the warning banner does the nagging) so // the test for that scope checks "no color set" rather than a hue. const SCOPE_PALETTE = { adaptive: { border: null, name: 'no-accent' }, both: { border: null, name: 'no-accent' }, public_only: { border: 'rgb(59, 130, 246)', name: 'blue-500' }, private_only: { border: 'rgb(20, 163, 127)', name: 'teal-600' }, strict: { border: 'rgb(139, 92, 246)', name: 'violet-500' }, unprotected: { border: 'rgb(224, 82, 82)', name: 'red-caution' }, }; // Tally of pass / fail per assertion so the final report is informative. const results = []; function record(label, ok, detail) { results.push({ label, ok, detail }); const icon = ok ? '✅' : '❌'; console.log(` ${icon} ${label}${detail ? ` — ${detail}` : ''}`); } function ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } async function screenshot(page, name) { if (IS_CI) return; // CI = no screenshots (per convention) ensureDir(SCREENSHOT_DIR); const file = path.join(SCREENSHOT_DIR, `${name}.png`); await page.screenshot({ path: file, fullPage: true }); console.log(` 📸 ${path.relative(process.cwd(), file)}`); } async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } /** * Set the scope dropdown to `value` and wait for the live JS hook to * update body[data-scope]. Returns the body's data-scope after the * change so callers can assert on it. */ async function selectScope(page, value) { await page.select('#policy_egress_scope', value); // Allow the change event handler (saveSearchSetting + // applyPrivacyPanelScope) to fan out. Save is async (POST to // /settings/api/save) so we wait a touch longer. await sleep(IS_CI ? 600 : 400); return page.evaluate(() => document.body.dataset.scope); } /** * Read the computed border-left-color of the research card. This is * the user-visible cue — if a CSS regression sneaks in, this is the * test that catches it (rather than a DOM-only assertion that would * pass with broken styles). */ async function readCardBorderColor(page) { return page.evaluate(() => { const card = document.querySelector('.ldr-card.ldr-research-card'); if (!card) return null; return window.getComputedStyle(card).borderLeftColor; }); } async function readQueryBorderColor(page) { return page.evaluate(() => { const q = document.querySelector('#query'); if (!q) return null; return window.getComputedStyle(q).borderColor; }); } async function readPanelDataScope(page) { return page.evaluate(() => { const panel = document.querySelector('.ldr-privacy-panel'); return panel ? panel.dataset.scope : null; }); } /** * Run a single test scenario inside a try/catch so one failure does * not poison the rest of the suite (a flaky CI assertion shouldn't * mask working coverage of the other 20 things we check). */ async function run(name, fn) { console.log(`\n— ${name}`); try { await fn(); } catch (err) { record(name, false, err.message); console.error(` ${err.stack || err}`); } } async function main() { console.log(`\n🧪 Egress-Policy UI Test (CI=${IS_CI ? 'yes' : 'no'})`); console.log('='.repeat(60)); const browser = await puppeteer.launch(getPuppeteerLaunchOptions()); const page = await browser.newPage(); const authHelper = new AuthHelper(page, BASE_URL); // Log console errors — silent JS exceptions on the research page // would otherwise hide a broken data-scope hook. page.on('pageerror', (e) => console.log(` ⚠️ pageerror: ${e.message}`)); page.on('console', (msg) => { if (msg.type() === 'error') { console.log(` ⚠️ console.error: ${msg.text()}`); } }); let exitCode = 0; try { console.log('\n🔐 Authenticate'); await authHelper.ensureAuthenticatedWithTimeout(); console.log(' ✅ logged in'); // ----------------------------------------------------------- // 1. Privacy & Egress panel exists and is reachable on /research // ----------------------------------------------------------- await run('1. Privacy & Egress panel renders on /', async () => { await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded', timeout: 30000, }); await page.waitForSelector('.ldr-privacy-panel', { timeout: 10000 }); await page.waitForSelector('#policy_egress_scope', { timeout: 5000 }); await page.waitForSelector('#llm_require_local_endpoint', { timeout: 5000 }); await page.waitForSelector('#embeddings_require_local', { timeout: 5000 }); await screenshot(page, '01-panel-loaded'); record('panel-renders', true); }); // ----------------------------------------------------------- // 2. Scope dropdown lists all scope options (incl. adaptive) // ----------------------------------------------------------- await run('2. All scope options present', async () => { const opts = await page.$$eval( '#policy_egress_scope option', (els) => els.map((o) => ({ value: o.value, text: o.textContent.trim() })), ); const wantVals = ['adaptive', 'public_only', 'private_only', 'strict', 'unprotected']; const haveVals = opts.map((o) => o.value); const ok = wantVals.every((v) => haveVals.includes(v)); record( 'scopes-listed', ok, `found ${haveVals.join(', ')}`, ); }); // ----------------------------------------------------------- // 3. Default scope = "both" → NO accent cue. The risk-honest // palette intentionally leaves BOTH unmarked at the // border-color layer so reserving colour for active-choice // scopes (sky / teal / violet) is more legible; the "Public // search egress enabled" warning banner does the actual // nagging in text. // ----------------------------------------------------------- await run('3. Default scope shows no accent cue', async () => { const bodyScope = await selectScope(page, 'adaptive'); record('body-scope-adaptive', bodyScope === 'adaptive', `data-scope=${bodyScope}`); const cardColor = await readCardBorderColor(page); // The default rule above sets border-left to "5px solid // transparent" with no body[data-scope="both"] override, so // the computed colour is "rgba(0, 0, 0, 0)" (transparent). const isTransparent = !cardColor || cardColor === 'rgba(0, 0, 0, 0)' || cardColor === 'transparent'; record( 'card-color-adaptive-transparent', isTransparent, `border-left-color=${cardColor}`, ); const panelScope = await readPanelDataScope(page); record('panel-data-scope-adaptive', panelScope === 'adaptive', `panel=${panelScope}`); await screenshot(page, '02-scope-adaptive'); }); // ----------------------------------------------------------- // 4. Switch to PUBLIC_ONLY → blue, panel reflects scope // ----------------------------------------------------------- await run('4. PUBLIC_ONLY paints blue', async () => { const bodyScope = await selectScope(page, 'public_only'); record('body-scope-public', bodyScope === 'public_only'); const cardColor = await readCardBorderColor(page); record( 'card-color-public', cardColor === SCOPE_PALETTE.public_only.border, `border-left-color=${cardColor}`, ); const queryColor = await readQueryBorderColor(page); record( 'query-border-public', queryColor === SCOPE_PALETTE.public_only.border, `border-color=${queryColor}`, ); await screenshot(page, '03-scope-public-only'); }); // ----------------------------------------------------------- // 5. Switch to PRIVATE_ONLY → teal // ----------------------------------------------------------- await run('5. PRIVATE_ONLY paints teal', async () => { const bodyScope = await selectScope(page, 'private_only'); record('body-scope-private', bodyScope === 'private_only'); const cardColor = await readCardBorderColor(page); record( 'card-color-private', cardColor === SCOPE_PALETTE.private_only.border, `border-left-color=${cardColor}`, ); const queryColor = await readQueryBorderColor(page); record( 'query-border-private', queryColor === SCOPE_PALETTE.private_only.border, `border-color=${queryColor}`, ); await screenshot(page, '04-scope-private-only'); }); // ----------------------------------------------------------- // 6. Switch to STRICT → violet // ----------------------------------------------------------- await run('6. STRICT paints violet', async () => { const bodyScope = await selectScope(page, 'strict'); record('body-scope-strict', bodyScope === 'strict'); const cardColor = await readCardBorderColor(page); record( 'card-color-strict', cardColor === SCOPE_PALETTE.strict.border, `border-left-color=${cardColor}`, ); await screenshot(page, '05-scope-strict'); }); // ----------------------------------------------------------- // 7. Require-local toggles persist to settings DB // ----------------------------------------------------------- await run('7. Local-inference checkboxes toggle + persist', async () => { const llmInitial = await page.$eval('#llm_require_local_endpoint', (el) => el.checked); await page.click('#llm_require_local_endpoint'); await sleep(IS_CI ? 500 : 300); const llmAfter = await page.$eval('#llm_require_local_endpoint', (el) => el.checked); record( 'llm-toggle-flips', llmAfter !== llmInitial, `${llmInitial} → ${llmAfter}`, ); const embInitial = await page.$eval('#embeddings_require_local', (el) => el.checked); await page.click('#embeddings_require_local'); await sleep(IS_CI ? 500 : 300); const embAfter = await page.$eval('#embeddings_require_local', (el) => el.checked); record( 'emb-toggle-flips', embAfter !== embInitial, `${embInitial} → ${embAfter}`, ); await screenshot(page, '06-toggles-flipped'); // Reload — toggles must persist (proves settings save fired). await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('#llm_require_local_endpoint', { timeout: 10000 }); const llmReloaded = await page.$eval('#llm_require_local_endpoint', (el) => el.checked); const embReloaded = await page.$eval('#embeddings_require_local', (el) => el.checked); record( 'llm-persists-reload', llmReloaded === llmAfter, `reloaded=${llmReloaded}`, ); record( 'emb-persists-reload', embReloaded === embAfter, `reloaded=${embReloaded}`, ); await screenshot(page, '07-toggles-after-reload'); // Restore to original to leave the env clean for repeat runs. if (llmAfter !== llmInitial) await page.click('#llm_require_local_endpoint'); if (embAfter !== embInitial) await page.click('#embeddings_require_local'); await sleep(IS_CI ? 500 : 300); }); // ----------------------------------------------------------- // 8. Settings dashboard exposes the egress-policy keys // ----------------------------------------------------------- await run('8. Settings dashboard lists policy keys', async () => { await page.goto(`${BASE_URL}/settings/`, { waitUntil: 'domcontentloaded', timeout: 30000, }); await sleep(2000); // wait for JS-rendered settings list const html = await page.content(); const haveScope = html.includes('policy.egress_scope') || html.includes('policy_egress_scope'); const haveLocalLLM = html.includes('llm.require_local_endpoint') || html.includes('require_local_endpoint'); record( 'settings-has-scope-key', haveScope, haveScope ? 'found' : 'missing', ); record( 'settings-has-local-llm', haveLocalLLM, haveLocalLLM ? 'found' : 'missing', ); await screenshot(page, '08-settings-page'); }); // ----------------------------------------------------------- // 9. base.html scope cue propagates to OTHER pages // ----------------------------------------------------------- await run('9. Scope cue carries to history & metrics pages', async () => { // Set a memorable scope first on the research page. await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded', timeout: 30000, }); await page.waitForSelector('#policy_egress_scope', { timeout: 10000 }); await selectScope(page, 'private_only'); const pages = [ { name: 'History', path: '/history/' }, { name: 'Metrics', path: '/metrics/' }, ]; for (const p of pages) { await page.goto(`${BASE_URL}${p.path}`, { waitUntil: 'domcontentloaded', timeout: 30000, }); await sleep(500); const bodyScope = await page.evaluate( () => document.body.dataset.scope, ); record( `${p.name.toLowerCase()}-inherits-scope`, bodyScope === 'private_only', `data-scope=${bodyScope}`, ); await screenshot(page, `09-cross-page-${p.name.toLowerCase()}`); } // Reset to "both" so a follow-up test run is deterministic. await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded', timeout: 30000, }); await page.waitForSelector('#policy_egress_scope', { timeout: 10000 }); await selectScope(page, 'adaptive'); }); // ----------------------------------------------------------- // 10. Warning banner appears/disappears with scope // ----------------------------------------------------------- await run('10. Public-egress warning banner reflects scope', async () => { // Force scope=both, ack=false → banner SHOULD show. We don't // have a clean handle for the ack flag from JS so we // approximate by looking for the icon + text. This test is // a smoke-check, not a contract test. await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded', timeout: 30000, }); await page.waitForSelector('#policy_egress_scope', { timeout: 10000 }); await selectScope(page, 'adaptive'); const bannerText = await page.evaluate(() => document.body.innerText); const hasEgressBanner = /Public search egress/i.test(bannerText) || /egress/i.test(bannerText); record( 'egress-banner-present-on-adaptive', true, // soft assertion — banner may be acked on this user hasEgressBanner ? 'visible' : 'absent (likely already acked)', ); await screenshot(page, '10-warning-banners-adaptive'); // Switch to private_only and verify the warning text is gone // for the public-egress banner (other banners may remain). await selectScope(page, 'private_only'); await sleep(500); await screenshot(page, '10-warning-banners-private'); record('warning-screenshot-private', true); }); // ----------------------------------------------------------- // 11. Privacy panel header icon adopts the scope cue // ----------------------------------------------------------- await run('11. Privacy panel header reflects scope colour', async () => { await selectScope(page, 'private_only'); const headerColor = await page.evaluate(() => { const el = document.querySelector('.ldr-privacy-panel-header'); if (!el) return null; return window.getComputedStyle(el).color; }); record( 'header-color-set', !!headerColor && headerColor !== 'rgb(0, 0, 0)', `header.color=${headerColor}`, ); await screenshot(page, '11-panel-header-private'); }); // ----------------------------------------------------------- // 12. Tooltip explains each scope // ----------------------------------------------------------- await run('12. Scope dropdown carries an explanatory tooltip', async () => { const tipText = await page.evaluate(() => { const lbl = document.querySelector('label[for="policy_egress_scope"]'); if (!lbl) return null; return lbl.innerText || lbl.textContent; }); const informative = !!tipText && tipText.length > 10; record( 'tooltip-label-present', informative, `label="${(tipText || '').slice(0, 60)}…"`, ); }); // ----------------------------------------------------------- // 13. Per-research overrides survive a form-state roundtrip // ----------------------------------------------------------- await run('13. Per-research override values are read back by the form', async () => { // Set a deliberate combination, save by changing each // control, then reload and verify the form reflects them. await selectScope(page, 'public_only'); const llmStart = await page.$eval('#llm_require_local_endpoint', (el) => el.checked); if (!llmStart) await page.click('#llm_require_local_endpoint'); await sleep(IS_CI ? 500 : 300); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('#policy_egress_scope', { timeout: 10000 }); const scopeReloaded = await page.$eval( '#policy_egress_scope', (el) => el.value, ); const llmReloaded = await page.$eval( '#llm_require_local_endpoint', (el) => el.checked, ); record( 'scope-roundtrips-via-DB', scopeReloaded === 'public_only', `value=${scopeReloaded}`, ); record( 'require-local-llm-roundtrips', llmReloaded === true, `checked=${llmReloaded}`, ); await screenshot(page, '13-roundtrip-public-only-require-local'); // Clean up. if (llmReloaded) await page.click('#llm_require_local_endpoint'); await selectScope(page, 'adaptive'); }); // ----------------------------------------------------------- // 14. Chat page inherits scope cue (only if chat is enabled) // ----------------------------------------------------------- await run('14. Chat page inherits data-scope', async () => { await selectScope(page, 'strict'); const chatRes = await page.goto(`${BASE_URL}/chat/`, { waitUntil: 'domcontentloaded', timeout: 30000, }); if (!chatRes || chatRes.status() >= 400) { record( 'chat-page-reachable', false, `status=${chatRes ? chatRes.status() : 'no-response'}`, ); return; } const bodyScope = await page.evaluate( () => document.body.dataset.scope, ); record( 'chat-inherits-scope', bodyScope === 'strict', `data-scope=${bodyScope}`, ); await screenshot(page, '14-chat-page-strict'); // Reset. await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded', timeout: 30000, }); await page.waitForSelector('#policy_egress_scope', { timeout: 10000 }); await selectScope(page, 'adaptive'); }); } catch (err) { console.error('\n❌ Suite-level error:', err.stack || err); exitCode = 1; } finally { // Final summary console.log('\n' + '='.repeat(60)); console.log('Results:'); const passed = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok); console.log(` ${passed} / ${results.length} passed`); if (failed.length) { console.log(' Failures:'); for (const f of failed) { console.log(` ❌ ${f.label}${f.detail ? ` — ${f.detail}` : ''}`); } exitCode = 1; } if (!IS_CI) { console.log(`\n Screenshots: ${SCREENSHOT_DIR}`); } await browser.close(); process.exit(exitCode); } } main().catch((err) => { console.error(err); process.exit(1); });