diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index f72cd84a7..9a296bec6 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -285,6 +285,58 @@ describe('Dashboard Module', () => { expect(upcomingList?.innerHTML).toContain('Step 1 of 4'); }); + test('shows an inline error with Retry, not the empty state, when the upcoming fetch fails (#713)', async () => { + (api.getDashboardSummary as jest.Mock).mockResolvedValue({ + potential_monthly_savings: 1000, + by_service: {} + }); + (api.getUpcomingPurchases as jest.Mock).mockRejectedValueOnce(new Error('API error: 503 Service Unavailable')); + + await loadDashboard(); + + const upcomingList = document.getElementById('upcoming-list'); + expect(upcomingList?.textContent).not.toContain('No upcoming scheduled purchases'); + expect(upcomingList?.querySelector('.error')?.textContent).toContain('Failed to load upcoming purchases'); + // The summary is unaffected by the upcoming failure. + expect(document.getElementById('summary')?.querySelector('.error')).toBeNull(); + + (api.getUpcomingPurchases as jest.Mock).mockResolvedValueOnce({ + purchases: [{ + execution_id: 'exec-1', plan_id: 'plan-1', plan_name: 'Retry Plan', + scheduled_date: '2025-01-15', provider: 'aws', service: 'ec2', + step_number: 1, total_steps: 2, estimated_savings: 120, + }] + }); + const retry = upcomingList?.querySelector('button[data-action="retry-upcoming"]') as HTMLButtonElement; + expect(retry).not.toBeNull(); + retry.click(); + await new Promise(r => setTimeout(r, 0)); + + expect(api.getUpcomingPurchases).toHaveBeenCalledTimes(2); + expect(api.getDashboardSummary).toHaveBeenCalledTimes(1); + expect(upcomingList?.innerHTML).toContain('Retry Plan'); + expect(upcomingList?.querySelector('.error')).toBeNull(); + }); + + test('upcoming error is announced and Retry keeps keyboard focus in the widget (#713)', async () => { + (api.getDashboardSummary as jest.Mock).mockResolvedValue({ potential_monthly_savings: 1000, by_service: {} }); + (api.getUpcomingPurchases as jest.Mock).mockRejectedValueOnce(new Error('API error: 503')); + await loadDashboard(); + + const upcomingList = document.getElementById('upcoming-list') as HTMLElement; + expect(upcomingList.querySelector('.error')?.getAttribute('role')).toBe('alert'); + + (api.getUpcomingPurchases as jest.Mock).mockResolvedValueOnce({ purchases: [] }); + const retry = upcomingList.querySelector('button[data-action="retry-upcoming"]') as HTMLButtonElement; + retry.focus(); + retry.click(); + // Retry replaces its own button; focus must land on the container, not
. + expect(upcomingList.getAttribute('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(upcomingList); + await new Promise(r => setTimeout(r, 0)); + expect(document.activeElement).toBe(upcomingList); + }); + test('shows empty message when no upcoming purchases', async () => { (api.getDashboardSummary as jest.Mock).mockResolvedValue({ potential_monthly_savings: 1000, @@ -1651,6 +1703,101 @@ describe('Dashboard Module', () => { expect(totalLine).toMatch(/\$400/); }); + describe('stable per-service colours and matching legend (#700)', () => { + type ChartCfg = { + data: { labels: string[]; datasets: { label: string; backgroundColor: string[] }[] }; + options: { plugins: { legend: { labels: { generateLabels?: (c: unknown) => { text: string; fillStyle: string }[] } } } }; + }; + const lastCfg = (): ChartCfg => { + const calls = (Chart as unknown as jest.Mock).mock.calls; + return calls[calls.length - 1]?.[1] as ChartCfg; + }; + const colourOf = (cfg: ChartCfg, svc: string): string => { + const ds = cfg.data.datasets.find((d) => d.label === 'Lowest option'); + return ds?.backgroundColor[cfg.data.labels.indexOf(svc)] ?? ''; + }; + + test('a service keeps its colour when its rank changes between renders', () => { + buildDOM(); + renderSavingsByService([rec('ec2', 500), rec('rds', 300), rec('elasticache', 100)]); + const before = lastCfg(); + expect(before.data.labels[0]).toBe('ec2'); + const ec2Before = colourOf(before, 'ec2'); + const rdsBefore = colourOf(before, 'rds'); + const cacheBefore = colourOf(before, 'elasticache'); + + // Data refresh: rds overtakes ec2, elasticache jumps to rank 2. + renderSavingsByService([rec('ec2', 120), rec('rds', 900), rec('elasticache', 400)]); + const after = lastCfg(); + expect(after.data.labels[0]).toBe('rds'); + expect(colourOf(after, 'ec2')).toBe(ec2Before); + expect(colourOf(after, 'rds')).toBe(rdsBefore); + expect(colourOf(after, 'elasticache')).toBe(cacheBefore); + expect(new Set([ec2Before, rdsBefore, cacheBefore]).size).toBe(3); + }); + + test('every known service slug has a distinct colour per family, twins share one', () => { + const { serviceBarColor } = require('../dashboard') as { serviceBarColor: (s: string) => string }; + const families: string[][] = [ + ['compute', 'ec2', 'vm'], + ['relational-db', 'rds', 'sql', 'cloudsql'], + ['cache', 'elasticache', 'redis', 'memorystore'], + ['savingsplans', 'savings-plans', 'savings-plans-compute', 'savings-plans-ec2instance', 'savings-plans-sagemaker', 'savings-plans-database'], + ['search', 'opensearch'], + ['data-warehouse', 'redshift'], + ['memorydb'], + ]; + const colours = families.map((slugs) => { + const set = new Set(slugs.map((x) => serviceBarColor(x))); + expect([...set]).toHaveLength(1); + return [...set][0]; + }); + expect(new Set(colours).size).toBe(families.length); + }); + + test('unknown slugs never reuse a known family colour', () => { + const { serviceBarColor } = require('../dashboard') as { serviceBarColor: (s: string) => string }; + // Fallback colours must not reuse a known family's colour. + const known = new Set(['compute', 'relational-db', 'cache', 'savingsplans', 'search', 'data-warehouse', 'memorydb'].map((x) => serviceBarColor(x))); + for (const unknown of ['alpha', 'zeta-service', 'nosuch', 'x1', 'foo-bar']) { + expect(known.has(serviceBarColor(unknown))).toBe(false); + } + }); + + test('an unknown slug gets a deterministic colour from the name hash', () => { + const { serviceBarColor } = require('../dashboard') as { serviceBarColor: (s: string) => string }; + expect(serviceBarColor('mystery-service')).toBe(serviceBarColor('mystery-service')); + expect(serviceBarColor('Mystery-Service')).toBe(serviceBarColor('mystery-service')); + // The hash must actually vary: a fixed slot for every unknown slug is a bug. + const seen = new Set(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((x) => serviceBarColor(`svc-${x}`))); + expect(seen.size).toBeGreaterThan(1); + }); + + test('legend swatches carry a border so the pale Upside swatch stays visible', () => { + buildDOM(); + renderSavingsByService([rec('ec2', 500)]); + const cfg = lastCfg(); + const items = cfg.options.plugins.legend.labels.generateLabels?.({ data: { datasets: cfg.data.datasets }, isDatasetVisible: () => true }) as unknown as { strokeStyle: string; lineWidth: number }[]; + for (const item of items) { + expect(item.lineWidth).toBeGreaterThanOrEqual(1); + expect(item.strokeStyle).toBe('#546e7a'); + } + }); + + test('legend swatches use neutral tones matching the three bar layers, not the first bar colour', () => { + buildDOM(); + renderSavingsByService([rec('ec2', 500), rec('rds', 300)]); + const cfg = lastCfg(); + const gen = cfg.options.plugins.legend.labels.generateLabels; + expect(typeof gen).toBe('function'); + const items = gen?.({ data: { datasets: cfg.data.datasets }, isDatasetVisible: () => true }) ?? []; + expect(items.map((i) => i.text)).toEqual(['Current / Committed', 'Lowest option', 'Upside']); + const barColours = cfg.data.datasets.flatMap((d) => d.backgroundColor); + for (const item of items) expect(barColours).not.toContain(item.fillStyle); + expect(new Set(items.map((i) => i.fillStyle)).size).toBe(3); + }); + }); + test('tooltip shows the committed value for a service with a commitment but no recommendation (#752)', () => { buildDOM(); renderSavingsByService( diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index f8b91b880..d9d6ccaee 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -314,6 +314,38 @@ describe('Issue #251: Execute Now makes no cancellation promise', () => { expect(text).not.toContain('Charged today'); }); + test('confirm button names the action and the upfront amount in both modes (#701)', async () => { + const rec: LocalRecommendation = { ...buildRows()[0]!, provider: 'aws', service: 'ec2', term: 1 }; + (state.getRecommendations as jest.Mock).mockReturnValue([rec]); + const amount = formatCurrency(rec.upfront_cost, '$', 2); + const confirmButtonLabel = (): string => + ((confirmDialog as jest.Mock).mock.calls[0]![0] as { confirmLabel: string }).confirmLabel; + + (confirmDialog as jest.Mock).mockClear(); + await openPurchaseModal([rec]); + const modalBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + const approvalVerb = modalBtn.textContent; + modalBtn.click(); + await flush(); + expect(confirmButtonLabel()).toBe(`${approvalVerb} (${amount} upfront on approval)`); + + (confirmDialog as jest.Mock).mockClear(); + (document.getElementById('close-purchase-modal-btn') as HTMLButtonElement).click(); + await openPurchaseModal([rec]); + (document.getElementById('execute-mode-direct') as HTMLInputElement).click(); + const directVerb = (document.getElementById('execute-purchase-btn') as HTMLButtonElement).textContent; + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + expect(confirmButtonLabel()).toBe(`${directVerb} (${amount} charged today)`); + }); + + test('the purchase modal title is not the generic "Configure Purchase" (#701)', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const html = (require('fs') as typeof import('fs')).readFileSync(require('path').join(__dirname, '..', 'index.html'), 'utf8'); + const title = /