7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
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
244 lines
10 KiB
JavaScript
244 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Link Analytics Full Page UI Test (shard: link-analytics)
|
|
*
|
|
* Verifies the rendered /metrics/links page:
|
|
* - page loads without console errors or uncaught exceptions
|
|
* - the domain list renders with the expected CSS selectors
|
|
* - the inline-script wrapper (link_analytics.html:797-802) preserved
|
|
* after the surgical extraction of updateEnhancedDomainList to
|
|
* /static/js/pages/link_analytics_render.js — i.e. the function
|
|
* is callable and the rendered number in the "Recent Researches
|
|
* (N total)" header matches the API response (catches the exact
|
|
* bug class of commit 12a1b11b0 on valid data).
|
|
*
|
|
* Existing LinkAnalyticsTests in test_metrics_dashboard_ci.js:474-552
|
|
* is smoke-only (never expands a domain, never sees the header). This
|
|
* shard is the deeper coverage complement.
|
|
*
|
|
* Run: node test_link_analytics_full.js
|
|
*/
|
|
|
|
const { setupTest, teardownTest, TestResults, log, navigateTo, withTimeout } = require('./test_lib');
|
|
|
|
const LinkAnalyticsFullTests = {
|
|
/**
|
|
* Render check: #domain-list must populate with at least one
|
|
* .ldr-domain-item-expanded, plus the frequency and diversity badges.
|
|
* The page must render clean — the inline-script wrapper depends on
|
|
* updateEnhancedDomainList being on window, and any throw would
|
|
* break the render path silently. Console/pageerror capture is
|
|
* handled centrally by the `run` wrapper in main(), not here.
|
|
*/
|
|
async pageLoadsCleanAndRendersList(page, baseUrl) {
|
|
await navigateTo(page, `${baseUrl}/metrics/links`);
|
|
|
|
// #domain-list is populated by updateEnhancedDomainList (now in
|
|
// link_analytics_render.js). Wait for at least one rendered item.
|
|
await page.waitForSelector('#domain-list .ldr-domain-item-expanded', {
|
|
timeout: 15000,
|
|
});
|
|
|
|
const result = await page.evaluate(() => {
|
|
const list = document.getElementById('domain-list');
|
|
const items = list?.querySelectorAll('.ldr-domain-item-expanded') ?? [];
|
|
const firstHeader = list?.querySelector('.ldr-domain-header') ?? null;
|
|
return {
|
|
itemCount: items.length,
|
|
hasFrequencyBadge: !!list?.querySelector('.ldr-frequency'),
|
|
hasDiversityBadge: !!list?.querySelector('.ldr-diversity'),
|
|
firstDomainText: firstHeader?.querySelector('.ldr-domain-name')?.textContent?.trim() ?? null,
|
|
};
|
|
});
|
|
|
|
return {
|
|
passed: result.itemCount > 0 && result.hasFrequencyBadge && result.hasDiversityBadge,
|
|
message: `${result.itemCount} domain(s) rendered, first="${result.firstDomainText}"`,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Expand the first domain and verify the "Recent Researches (N total)"
|
|
* header is present. The (N total) value must be a number — if the
|
|
* Number() coercion from commit 12a1b11b0 regressed on valid data,
|
|
* N would be `NaN` and this assertion catches it.
|
|
*/
|
|
async recentResearchesHeaderShowsNumericTotal(page, baseUrl) {
|
|
await navigateTo(page, `${baseUrl}/metrics/links`);
|
|
|
|
await page.waitForSelector('#domain-list .ldr-domain-item-expanded', {
|
|
timeout: 15000,
|
|
});
|
|
|
|
// Domains always render expanded in this layout (.ldr-domain-item-expanded
|
|
// is the row class), so the research-links block is already in the DOM
|
|
// if the domain has any recent researches. Find one with links present.
|
|
const result = await page.evaluate(() => {
|
|
const titles = Array.from(document.querySelectorAll('.ldr-research-links-title'));
|
|
const match = titles.find(t => /Recent Researches\s*\(/.test(t.textContent || ''));
|
|
if (!match) return { found: false };
|
|
const text = match.textContent.trim();
|
|
// Header looks like "Recent Researches (N total)". Capture N.
|
|
// Restrict N to digits to avoid super-linear backtracking.
|
|
const m = text.match(/Recent Researches\s*\((\d+)\s+total\)/);
|
|
return {
|
|
found: true,
|
|
rawText: text,
|
|
totalToken: m ? m[1] : null,
|
|
};
|
|
});
|
|
|
|
if (!result.found) {
|
|
return { passed: null, skipped: true, message: 'No "Recent Researches" header present (no domains had recent researches in test data)' };
|
|
}
|
|
|
|
// totalToken must be a valid base-10 number — proves Number() coercion
|
|
// succeeded on a valid input (the inverse of the XSS test which proves
|
|
// it produces NaN on a bad input).
|
|
const isNumeric = /^\d+$/.test(result.totalToken || '');
|
|
return {
|
|
passed: isNumeric,
|
|
message: `header raw="${result.rawText}", totalToken="${result.totalToken}", numeric=${isNumeric}`,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Verify the frequency badge text is well-formed: "📊 N uses (N%)" with
|
|
* numeric N. Catches the same Number()-coercion bug class as the header
|
|
* test, on a separate field path.
|
|
*/
|
|
async frequencyBadgeWellFormed(page, baseUrl) {
|
|
await navigateTo(page, `${baseUrl}/metrics/links`);
|
|
await page.waitForSelector('.ldr-frequency', { timeout: 15000 });
|
|
|
|
const result = await page.evaluate(() => {
|
|
const badges = Array.from(document.querySelectorAll('.ldr-frequency'));
|
|
const sample = badges.slice(0, 3).map(b => b.textContent.trim());
|
|
const allMatch = badges.every(b => /📊\s+\d+\s+uses\s+\(\d+(?:\.\d+)?%\)/.test(b.textContent || ''));
|
|
return { sample, allMatch, count: badges.length };
|
|
});
|
|
|
|
return {
|
|
passed: result.allMatch && result.count > 0,
|
|
message: `${result.count} frequency badges, allMatch=${result.allMatch}, sample=${JSON.stringify(result.sample)}`,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Verify no <script> element was injected into the rendered list —
|
|
* the runtime mirror of the Vitest XSS regression. If a payload ever
|
|
* survives the Number()/escapeHtml() barrier, this catches it in a
|
|
* real browser against live API data.
|
|
*/
|
|
async noScriptElementLeakedIntoList(page, baseUrl) {
|
|
await navigateTo(page, `${baseUrl}/metrics/links`);
|
|
await page.waitForSelector('#domain-list .ldr-domain-item-expanded', { timeout: 15000 });
|
|
|
|
const result = await page.evaluate(() => {
|
|
const list = document.getElementById('domain-list');
|
|
return {
|
|
scriptCount: list?.querySelectorAll('script').length ?? 0,
|
|
eventHandlers: Array.from(list?.querySelectorAll('*') ?? [])
|
|
.filter(el => Array.from(el.attributes).some(a => a.name.startsWith('on') && a.name !== 'onmouseover' && a.name !== 'onmouseout'))
|
|
.map(el => ({ tag: el.tagName, attrs: Array.from(el.attributes).filter(a => a.name.startsWith('on')).map(a => a.name) })),
|
|
};
|
|
});
|
|
|
|
return {
|
|
passed: result.scriptCount === 0 && result.eventHandlers.length === 0,
|
|
message: `script elements in #domain-list: ${result.scriptCount}; unexpected event handlers: ${JSON.stringify(result.eventHandlers)}`,
|
|
};
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Main Test Runner
|
|
// ============================================================================
|
|
async function main() {
|
|
log.section('Link Analytics Full Page Tests');
|
|
|
|
const ctx = await setupTest({ authenticate: true });
|
|
const results = new TestResults('Link Analytics Full Page Tests');
|
|
const { page } = ctx;
|
|
const { baseUrl } = ctx.config;
|
|
|
|
// Capture browser-side errors across the entire test run. Each test
|
|
// receives this array; the verdict for any test that observed the
|
|
// navigation lifecycle is "fail" if errors is non-empty.
|
|
const browserErrors = [];
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
browserErrors.push(`console.error: ${msg.text()}`);
|
|
}
|
|
});
|
|
page.on('pageerror', (err) => {
|
|
browserErrors.push(`pageerror: ${err.message}`);
|
|
});
|
|
|
|
const subTestTimeout = ctx.config.isCI ? 60000 : 30000;
|
|
async function run(category, name, testFn) {
|
|
// Snapshot browser errors before the test starts. After testFn
|
|
// resolves, the delta is attributed to THIS test — not shared
|
|
// across tests, not stale. If the delta is non-empty, the test
|
|
// fails regardless of its own verdict, because a console error
|
|
// or pageerror during render signals the inline-script wrapper
|
|
// or extracted module is broken in the real browser.
|
|
const before = browserErrors.length;
|
|
let result;
|
|
try {
|
|
result = await withTimeout(
|
|
testFn(page, baseUrl),
|
|
subTestTimeout,
|
|
`${category}/${name}`
|
|
);
|
|
} catch (error) {
|
|
results.add(category, name, false, `Error: ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
const newErrors = browserErrors.slice(before);
|
|
if (result.skipped) {
|
|
results.skip(category, name, result.message);
|
|
return;
|
|
}
|
|
if (newErrors.length > 0) {
|
|
results.add(
|
|
category,
|
|
name,
|
|
false,
|
|
`${result.message} | ${newErrors.length} browser error(s): ${newErrors.join('; ')}`
|
|
);
|
|
return;
|
|
}
|
|
results.add(category, name, result.passed, result.message);
|
|
}
|
|
|
|
try {
|
|
await run('Render', 'Page Loads Clean And Renders List',
|
|
(p, u) => LinkAnalyticsFullTests.pageLoadsCleanAndRendersList(p, u));
|
|
await run('Render', 'Recent Researches Header Shows Numeric Total',
|
|
(p, u) => LinkAnalyticsFullTests.recentResearchesHeaderShowsNumericTotal(p, u));
|
|
await run('Render', 'Frequency Badge Well Formed',
|
|
(p, u) => LinkAnalyticsFullTests.frequencyBadgeWellFormed(p, u));
|
|
await run('Security', 'No Script Element Leaked Into List',
|
|
(p, u) => LinkAnalyticsFullTests.noScriptElementLeakedIntoList(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());
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch(error => {
|
|
console.error('Test runner failed:', error);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = { LinkAnalyticsFullTests };
|