' +
- '' + total + ' nodes across ' + roles.length + ' roles' +
- '
' +
- '
' +
- '
' +
- '
Role
' +
- '
Count
' +
- '
Share
' +
- '
Distribution
' +
- '
w/ Skew
' +
- '
Median |skew|
' +
- '
Mean |skew|
' +
- '
Severity
' +
- '
' +
- '' + rows + '' +
- '
';
- }
-
- registerPage('roles', { init: init, destroy: destroy });
-})();
diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js
index 297c0e11..a2c997e8 100644
--- a/test-e2e-playwright.js
+++ b/test-e2e-playwright.js
@@ -2382,40 +2382,53 @@ async function run() {
assert(hasHslPolyline, 'At least one live-packet-trace polyline should have hsl() stroke color from hash');
});
- // --- Roles page (issue #818): renders distribution + per-role skew ---
- await test('Roles page renders distribution table from /api/analytics/roles', async () => {
- await page.goto(BASE + '/#/roles', { waitUntil: 'domcontentloaded' });
- // Wait for roles-page.js to mount and the table to render.
- await page.waitForSelector('.roles-page[data-page="roles"]', { timeout: 10000 });
- await page.waitForFunction(() => {
- var el = document.querySelector('#rolesContent');
- if (!el) return false;
- // Either the table renders, or the empty-state message appears.
- return !!el.querySelector('#rolesTable') || /No roles to show|Failed to load/.test(el.textContent);
- }, { timeout: 10000 });
- var hasTable = await page.$('#rolesTable');
- if (!hasTable) {
- // Empty fixture is acceptable; at least the page must NOT show the
- // generic "Page not yet implemented" placeholder (the bug we fixed).
- var bodyText = await page.evaluate(() => document.body.innerText);
- assert(!/Page not yet implemented/i.test(bodyText), 'Roles page must not show "Page not yet implemented" placeholder');
- return;
- }
- // With data: header columns and at least one body row must be present.
- var headers = await page.$$eval('#rolesTable thead th', ths => ths.map(t => t.textContent.trim()));
- assert(headers.includes('Role'), 'Roles table must have a Role column, got ' + JSON.stringify(headers));
- assert(headers.some(h => /Median/.test(h)), 'Roles table must have a Median |skew| column, got ' + JSON.stringify(headers));
- var rowCount = await page.$$eval('#rolesTable tbody tr', rs => rs.length);
- assert(rowCount > 0, 'Roles table should have at least one row when API returns data');
- // API contract sanity check: shape matches the page's expectations.
- var apiOk = await page.evaluate(async () => {
- var r = await fetch('/api/analytics/roles');
- if (!r.ok) return { ok: false, status: r.status };
- var j = await r.json();
- return { ok: true, hasRoles: Array.isArray(j.roles), hasTotal: typeof j.totalNodes === 'number' };
+ // --- Roles folded into Analytics (issue #1085) ---
+ // Acceptance criteria:
+ // 1. "Roles" link does NOT exist in top nav
+ // 2. Analytics page has a "Roles" tab with the same content
+ // 3. Old #/roles URL redirects to #/analytics?tab=roles
+ await test('Roles fold-in (#1085): no "Roles" link in top nav', async () => {
+ await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('nav.top-nav .nav-links', { timeout: 10000 });
+ var hasRolesLink = await page.evaluate(() => {
+ var links = document.querySelectorAll('nav.top-nav .nav-links a.nav-link[data-route="roles"]');
+ return links.length > 0;
});
- assert(apiOk.ok, '/api/analytics/roles must return 200, got ' + JSON.stringify(apiOk));
- assert(apiOk.hasRoles && apiOk.hasTotal, '/api/analytics/roles response must have {roles:[], totalNodes:n}, got ' + JSON.stringify(apiOk));
+ assert(!hasRolesLink, 'Top nav must NOT contain a "Roles" link (data-route="roles")');
+ });
+
+ await test('Roles fold-in (#1085): Analytics page has a "Roles" tab', async () => {
+ await page.goto(BASE + '/#/analytics', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#analyticsTabs', { timeout: 10000 });
+ var rolesTab = await page.$('#analyticsTabs .tab-btn[data-tab="roles"]');
+ assert(rolesTab, 'Analytics tabs must include a [data-tab="roles"] button');
+ var label = await page.evaluate(el => el.textContent.trim(), rolesTab);
+ assert(/roles/i.test(label), 'Roles tab label must say "Roles", got ' + JSON.stringify(label));
+ // Click the tab and verify the same Roles content renders.
+ await page.click('#analyticsTabs [data-tab="roles"]');
+ // Wait for the tab to settle on real content: either the populated
+ // table (#rolesTable) or the explicit empty-state. "Loading" and
+ // "Failed to load" are NOT acceptable terminal states (#1085 polish).
+ await page.waitForFunction(() => {
+ var el = document.getElementById('analyticsContent');
+ if (!el) return false;
+ if (el.querySelector('#rolesTable')) return true;
+ if (/No roles to show/i.test(el.textContent)) return true;
+ return false;
+ }, { timeout: 10000 });
+ var bodyText = await page.evaluate(() => document.getElementById('analyticsContent').innerText);
+ assert(!/Page not yet implemented/i.test(bodyText), 'Roles tab must not show SPA placeholder');
+ assert(!/Failed to load/i.test(bodyText), 'Roles tab must not show "Failed to load" terminal state');
+ assert(!/Loading…/.test(bodyText), 'Roles tab must not be stuck on "Loading…"');
+ });
+
+ await test('Roles fold-in (#1085): old #/roles URL redirects to #/analytics?tab=roles', async () => {
+ await page.goto(BASE + '/#/roles', { waitUntil: 'domcontentloaded' });
+ // Allow router to process the redirect.
+ await page.waitForFunction(() => /^#\/analytics(\?|$)/.test(location.hash), { timeout: 5000 });
+ var hash = await page.evaluate(() => location.hash);
+ assert(/^#\/analytics\?/.test(hash), 'After visiting #/roles, hash must redirect to #/analytics?…, got ' + hash);
+ assert(/[?&]tab=roles(&|$)/.test(hash), 'Redirect must carry tab=roles, got ' + hash);
});
// --- Geofilter draft: save/load/download buttons (issue #819, rule 18) ---