Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body>.
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,
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/__tests__/purchase-modal-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /<h2 id="purchase-modal-title">([^<]*)<\/h2>/.exec(html)?.[1];
expect(title).toBe('Review Purchase');
});

test('direct-execute confirm dialog makes no cancellation promise', async () => {
const rec: LocalRecommendation = { ...buildRows()[0]!, provider: 'aws', service: 'ec2', term: 1 };
(state.getRecommendations as jest.Mock).mockReturnValue([rec]);
Expand Down
22 changes: 12 additions & 10 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { setupRIExchangeHandlers, saveAutomationSettings } from './riexchange';
import { showToast } from './toast';
import { formatPaymentAdjustmentNotice } from './commitmentOptions';
import { confirmDialog } from './confirmDialog';
import { buildCommitmentSummary, withIntro } from './commitmentSummary';
import { buildCommitmentSummary, commitmentTotals, executeButtonLabel, executeConfirmLabel, withIntro } from './commitmentSummary';
import { handlePurchaseDeeplink } from './purchases-deeplink';
import { handleArcheraDeeplink, openArcheraOfferModal } from './archera';
import { closeModal } from './modal';
Expand Down Expand Up @@ -432,8 +432,7 @@ export async function handleExecutePurchase(): Promise<void> {
// - Approval path: low-friction, non-destructive.
// - Direct-execute path: red destructive dialog with cost callout and
// immediate-charge callout; no cancellation promise (issue #251).
const summary = buildCommitmentSummary(
recsWithPayment.map((r) => ({
const summaryLines = recsWithPayment.map((r) => ({
service: r.service,
provider: r.provider,
region: r.region,
Expand All @@ -444,25 +443,28 @@ export async function handleExecutePurchase(): Promise<void> {
upfront: r.upfront_cost,
monthly: r.monthly_cost ?? null,
...(r.cloud_account_id ? { account: getAccountName(r.cloud_account_id) } : {}),
})),
}));
const totals = commitmentTotals(summaryLines);
const summary = buildCommitmentSummary(
summaryLines,
isDirect ? {} : { upfrontLabel: 'Upfront on approval' },
);
const ok = isDirect
? await confirmDialog({
title: `Execute ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} now?`,
body: withIntro('This charges the amount shown under "Charged today" immediately and bypasses the approval step. The monthly amount, if any, is billed for the whole term.', summary),
confirmLabel: 'Execute Purchase Now',
confirmLabel: executeConfirmLabel(true, totals.upfront),
destructive: true,
})
: await confirmDialog({
title: `Send ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} for approval?`,
body: withIntro('This emails an approval request to the configured approver. Nothing is charged until the approver clicks the link in that email.', summary),
confirmLabel: 'Send for approval',
confirmLabel: executeConfirmLabel(false, totals.upfront),
destructive: false,
});

if (!ok) {
setExecutePurchaseSubmitting(executeBtn, false, getExecuteMode() === 'direct' ? 'Execute Purchase Now' : 'Send for Approval');
setExecutePurchaseSubmitting(executeBtn, false, executeButtonLabel(getExecuteMode() === 'direct'));
return;
}

Expand Down Expand Up @@ -560,7 +562,7 @@ export async function handleExecutePurchase(): Promise<void> {
const verb = isDirect ? 'execute' : 'send for approval';
showToast({ message: `Failed to ${verb} purchase: ${err.message}`, kind: 'error' });
} finally {
setExecutePurchaseSubmitting(executeBtn, false, getExecuteMode() === 'direct' ? 'Execute Purchase Now' : 'Send for Approval');
setExecutePurchaseSubmitting(executeBtn, false, executeButtonLabel(getExecuteMode() === 'direct'));
}
}

Expand Down Expand Up @@ -604,14 +606,14 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
destructive: false,
});
if (!ok) {
setExecutePurchaseSubmitting(executeBtn, false, 'Send for Approval');
setExecutePurchaseSubmitting(executeBtn, false, executeButtonLabel(false));
return;
}

try {
await submitFanOutBuckets(buckets);
} finally {
setExecutePurchaseSubmitting(executeBtn, false, 'Send for Approval');
setExecutePurchaseSubmitting(executeBtn, false, executeButtonLabel(false));
}
}

Expand Down
14 changes: 14 additions & 0 deletions frontend/src/commitmentSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ export function commitmentTotals(lines: readonly CommitmentLine[]): CommitmentTo
return { upfront, monthly, termTotal };
}

/** Label of the purchase modal's primary button; one source for every place that sets it. */
export function executeButtonLabel(direct: boolean): string {
return direct ? 'Execute Purchase Now' : 'Send for Approval';
}

/**
* Confirm-dialog button: the same verb as the modal button plus the upfront
* amount, so the highest-stakes click states the money involved.
*/
export function executeConfirmLabel(direct: boolean, upfront: number): string {
const amount = formatCurrency(upfront, '$', 2);
return `${executeButtonLabel(direct)} (${amount} ${direct ? 'charged today' : 'upfront on approval'})`;
}

export function paymentLabel(payment: string): string {
switch (payment.replace(/_/g, '-')) {
case 'upfront': // Azure spelling of all-upfront
Expand Down
Loading
Loading