项目文件夹

文件
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

583 行
24 KiB
JavaScript

/**
* 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);
});