diff --git a/frontend/src/__tests__/inventory.test.ts b/frontend/src/__tests__/inventory.test.ts index 756f4a364..fa8121edc 100644 --- a/frontend/src/__tests__/inventory.test.ts +++ b/frontend/src/__tests__/inventory.test.ts @@ -563,6 +563,38 @@ describe('loadCoverageBreakdown — fetch + render flow', () => { expect(barTh!.textContent).toBe('Coverage bar'); expect(barTh!.getAttribute('aria-label')).toBe('Coverage bar'); }); + + // The bar carries no text of its own, so the value has to reach assistive + // tech some other way (issue #1777). + test('coverage bar announces its percentage, and an absent figure renders no bar', async () => { + (api.getCoverageBreakdown as jest.Mock).mockResolvedValue({ + providers: [ + makeProviderSection('aws', [ + { service: 'ec2', covered_monthly: 100, on_demand_monthly: 100, coverage_pct: 50 }, + { service: 'opensearch', covered_monthly: 0, on_demand_monthly: 0, coverage_pct: null }, + ], 50), + makeProviderSection('azure', null, null), + makeProviderSection('gcp', null, null), + ], + }); + + await loadCoverageBreakdown(); + + const rows = document.getElementById('coverage-providers')!.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(2); + + const bar = rows[0]!.querySelector('.coverage-bar')!; + expect(bar).not.toBeNull(); + expect(bar.getAttribute('role')).toBe('img'); + expect(bar.getAttribute('aria-label')).toBe('50.0% covered'); + expect(bar.querySelector('.coverage-bar-fill')!.style.width).toBe('50%'); + + // Absent is not zero: no track at all, and an explicit placeholder so the + // cell does not read as a rendering failure. + const absentCell = rows[1]!.querySelector('.coverage-bar-cell')!; + expect(absentCell.querySelector('.coverage-bar')).toBeNull(); + expect(absentCell.querySelector('.coverage-bar-absent')!.textContent).toBe('N/A'); + }); }); // ────────────────────────────────────────────── diff --git a/frontend/src/inventory.ts b/frontend/src/inventory.ts index 061dae1bf..a63ddf4de 100644 --- a/frontend/src/inventory.ts +++ b/frontend/src/inventory.ts @@ -494,6 +494,9 @@ function buildServiceRow(row: CoverageServiceRow): HTMLTableRowElement { if (row.coverage_pct !== null && row.coverage_pct !== undefined) { const bar = document.createElement('div'); bar.className = 'coverage-bar'; + // The bar carries no text, so screen readers need the value spelled out. + bar.setAttribute('role', 'img'); + bar.setAttribute('aria-label', `${row.coverage_pct.toFixed(1)}% covered`); const fill = document.createElement('div'); fill.className = 'coverage-bar-fill'; // Clamp to [0, 100] so a misconfigured value can't overflow. @@ -501,6 +504,14 @@ function buildServiceRow(row: CoverageServiceRow): HTMLTableRowElement { fill.style.width = `${pct}%`; bar.appendChild(fill); barTd.appendChild(bar); + } else { + // No coverage figure: say so rather than leaving the cell blank, which + // reads as a rendering failure. A 0%-width bar is not an option -- it + // would claim we measured zero coverage. + const absent = document.createElement('span'); + absent.className = 'coverage-bar-absent'; + absent.textContent = 'N/A'; + barTd.appendChild(absent); } tr.appendChild(barTd); diff --git a/frontend/src/styles/tables.css b/frontend/src/styles/tables.css index 05e55bed2..291bcf07c 100644 --- a/frontend/src/styles/tables.css +++ b/frontend/src/styles/tables.css @@ -175,3 +175,32 @@ tr.history-row-highlight:hover { background: #f8d7da; color: #721c24; } + +/* Coverage breakdown bar, Inventory & Coverage -> Coverage (issue #1777). + * buildServiceRow() in inventory.ts builds the track/fill divs and sets the + * fill width inline from coverage_pct; without these rules both collapsed to + * height 0 and the column rendered blank. */ +.coverage-bar-cell { + width: 160px; + vertical-align: middle; +} + +.coverage-bar { + height: 8px; + background: var(--cudly-border); + border-radius: var(--cudly-r-full); + overflow: hidden; +} + +.coverage-bar-fill { + height: 100%; + background: var(--cudly-success); + border-radius: inherit; +} + +/* No coverage figure for this service. Distinguishes "unknown" from a 0% + * bar, which would claim we measured zero coverage. */ +.coverage-bar-absent { + color: var(--cudly-text-subtle); + font-size: var(--cudly-fs-xs); +} diff --git a/frontend/tests-e2e/coverage-bar.spec.ts b/frontend/tests-e2e/coverage-bar.spec.ts new file mode 100644 index 000000000..eaed53925 --- /dev/null +++ b/frontend/tests-e2e/coverage-bar.spec.ts @@ -0,0 +1,144 @@ +/** + * Coverage-bar rendering smoke for Inventory & Coverage -> Coverage (issue #1777). + * + * The reported failure is "the Coverage bar column renders empty". The markup + * was always there: buildServiceRow() appends + * `div.coverage-bar > div.coverage-bar-fill` with an inline `width: %`. + * What was missing were the CSS rules for those two classes, so both divs + * resolved to height 0 with no background and the cell looked blank. + * + * That is invisible to jsdom, which does not resolve stylesheets into layout: + * the jest suite asserts on the DOM structure and stays green while the user + * sees nothing. The assertions below therefore run against the real bundle in + * Chromium and measure what the browser actually paints -- box height and + * background colour -- not what the DOM contains. + */ + +import { test, expect, type Page, type Locator } from '@playwright/test'; +import { mockApi, seedAuth, COVERAGE } from './fixtures/recs'; + +/** The AWS section is the only one the fixture gives services to. */ +const AWS_TABLE = '.coverage-provider-card:has(h3:text-is("AWS")) .coverage-service-table'; + +interface PaintedBox { + width: number; + height: number; + background: string; +} + +/** What Chromium actually paints for an element. */ +async function painted(locator: Locator): Promise { + return locator.evaluate((el) => { + const rect = el.getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + background: getComputedStyle(el).backgroundColor, + }; + }); +} + +/** + * A colour the user can see. Chromium reports every resolved background as + * `rgb(...)` or `rgba(...)`, and an unset one as `rgba(0, 0, 0, 0)`, so the + * check reduces to "opaque, or alpha above zero". + */ +function isVisibleColour(background: string): boolean { + const rgba = /^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/.exec(background); + if (rgba) return Number(rgba[1]) > 0; + return background.startsWith('rgb('); +} + +async function openCoverage(page: Page): Promise { + await seedAuth(page); + await mockApi(page); + await page.goto('/inventory/coverage'); + await expect(page.locator(`${AWS_TABLE} tbody tr`)).toHaveCount( + COVERAGE.providers[0]!.services!.length, + ); +} + +/** + * Rows carrying a numeric coverage_pct, in fixture order. The specs below + * loop over this, so an empty list would let them pass without asserting + * anything -- pinned here once rather than in each test. + */ +const NUMERIC_ROWS = COVERAGE.providers[0]!.services!.filter( + (s): s is typeof s & { coverage_pct: number } => s.coverage_pct !== null, +); + +test('the fixture supplies rows to assert on', () => { + expect(NUMERIC_ROWS.map((s) => s.coverage_pct)).toEqual([100, 62.5, 0]); +}); + +test('every row with a coverage percentage paints a visible bar', async ({ page }) => { + await openCoverage(page); + + for (const [index, service] of NUMERIC_ROWS.entries()) { + const cell = page.locator(`${AWS_TABLE} tbody tr`).nth(index).locator('.coverage-bar-cell'); + const track = cell.locator('.coverage-bar'); + const fill = track.locator('.coverage-bar-fill'); + + await expect(track, `${service.service}: track present`).toHaveCount(1); + + const trackBox = await painted(track); + const fillBox = await painted(fill); + + // The defect: both divs existed but collapsed to height 0, so the cell + // was blank regardless of the percentage. + expect(trackBox.height, `${service.service}: track height`).toBeGreaterThan(0); + expect(trackBox.width, `${service.service}: track width`).toBeGreaterThan(0); + expect(fillBox.height, `${service.service}: fill height`).toBeGreaterThan(0); + + // A zero-height box is the obvious failure; a transparent one is the + // same outcome for the user. + expect( + isVisibleColour(trackBox.background), + `${service.service}: track background ${trackBox.background}`, + ).toBe(true); + expect( + isVisibleColour(fillBox.background), + `${service.service}: fill background ${fillBox.background}`, + ).toBe(true); + } +}); + +test('bar fill width tracks the coverage percentage, including 0% and 100%', async ({ page }) => { + await openCoverage(page); + + for (const [index, service] of NUMERIC_ROWS.entries()) { + const row = page.locator(`${AWS_TABLE} tbody tr`).nth(index); + const track = row.locator('.coverage-bar'); + const fill = track.locator('.coverage-bar-fill'); + + const trackBox = await painted(track); + const fillBox = await painted(fill); + const ratio = (fillBox.width / trackBox.width) * 100; + + // Precision 0 is a half-point tolerance, enough to absorb sub-pixel + // rounding on the fractional row (62.5%) without letting a wrong bar pass. + expect(ratio, `${service.service}: fill ratio`).toBeCloseTo(service.coverage_pct, 0); + + // The numeric column and the bar must agree. + await expect(row.locator('td').nth(3)).toHaveText(`${service.coverage_pct.toFixed(1)}%`); + } +}); + +test('a row with no coverage figure renders no bar rather than an empty 0% bar', async ({ page }) => { + await openCoverage(page); + + const absentIndex = COVERAGE.providers[0]!.services!.findIndex((s) => s.coverage_pct === null); + expect(absentIndex, 'fixture carries a null-coverage row').toBeGreaterThanOrEqual(0); + + const row = page.locator(`${AWS_TABLE} tbody tr`).nth(absentIndex); + await expect(row.locator('td').nth(3)).toHaveText('N/A'); + + // Absent is not zero: no track, so the row cannot be read as "0% covered". + await expect(row.locator('.coverage-bar')).toHaveCount(0); + + // The cell still says something, so it is distinguishable from the defect + // this spec pins (a cell that is blank because the CSS is missing). + const placeholder = row.locator('.coverage-bar-cell .coverage-bar-absent'); + await expect(placeholder).toHaveCount(1); + await expect(placeholder).toHaveText('N/A'); +}); diff --git a/frontend/tests-e2e/fixtures/recs.ts b/frontend/tests-e2e/fixtures/recs.ts index e6bda3562..080df9146 100644 --- a/frontend/tests-e2e/fixtures/recs.ts +++ b/frontend/tests-e2e/fixtures/recs.ts @@ -93,6 +93,29 @@ export const ACCOUNTS = [ { id: 'acct-201', name: 'GCP Sandbox (acct-201)', provider: 'gcp', external_id: 'project-yyyy' }, ]; +/** + * Coverage breakdown fixture for the Inventory & Coverage -> Coverage sub-tab + * (issue #1777). Rows deliberately span the boundaries where a percentage-driven + * bar breaks: fully covered (100), a mid value, zero coverage with real + * on-demand spend (0), and no usage at all (null -- absent, not zero). + */ +export const COVERAGE = { + providers: [ + { + provider: 'aws', + overall_coverage_pct: 54.2, + services: [ + { service: 'ec2', covered_monthly: 4000, on_demand_monthly: 0, coverage_pct: 100 }, + { service: 'rds', covered_monthly: 500, on_demand_monthly: 300, coverage_pct: 62.5 }, + { service: 'lambda', covered_monthly: 0, on_demand_monthly: 220, coverage_pct: 0 }, + { service: 'opensearch', covered_monthly: 0, on_demand_monthly: 0, coverage_pct: null }, + ], + }, + { provider: 'azure', overall_coverage_pct: null, services: null }, + { provider: 'gcp', overall_coverage_pct: null, services: null }, + ], +}; + export const SUMMARY = { total_recommendations: RECS.length, total_upfront_cost: RECS.reduce((s, r) => s + r.upfront_cost, 0), @@ -302,6 +325,19 @@ export async function mockApi(page: Page): Promise { await route.fulfill({ status: 405, body: '' }); }); + // Inventory & Coverage. The Coverage sub-tab reads /inventory/coverage; the + // default Active-commitments sub-tab reads /inventory/commitments and is hit + // whenever a test lands on /inventory without a sub-tab segment. + await page.route('**/api/inventory/coverage**', async (route) => { + record(route); + await jsonRoute(route, COVERAGE); + }); + + await page.route('**/api/inventory/commitments**', async (route) => { + record(route); + await jsonRoute(route, { commitments: [] }); + }); + // Per-id detail — covers the row-click drawer. Returns a benign empty // payload so accidental row clicks (e.g. while clicking a checkbox) // do not crash on a 404.