#!/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