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
18 changes: 18 additions & 0 deletions frontend/src/__tests__/apikeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,24 @@ describe('API Keys Module', () => {
expect(container?.innerHTML).toContain('Never');
});

test('one malformed last_used_at degrades to one cell instead of blanking the table (#136)', async () => {
const mockKeys = [
{ id: 'key-bad', name: 'Bad Timestamp', key_prefix: 'bad111', is_active: true,
created_at: '2024-01-15T10:00:00Z', last_used_at: 'not-a-date' },
{ id: 'key-ok', name: 'Leaked Key', key_prefix: 'ok2222', is_active: true,
created_at: '2024-01-15T10:00:00Z', last_used_at: '2024-01-16T15:30:00Z' },
];

(api.getApiKeys as jest.Mock).mockResolvedValue({ api_keys: mockKeys });
await loadApiKeys();

const container = document.getElementById('apikeys-list');
const rows = container?.querySelectorAll('tbody tr');
expect(rows).toHaveLength(2);
expect(rows?.[0]?.textContent).toContain('--');
expect(container?.querySelectorAll('.revoke-key-btn')).toHaveLength(2);
});

// #492: populated last_used_at renders relative time with ISO title for hover
test('renders relative time with ISO title for populated last_used_at (issue #492)', async () => {
const recentTs = new Date(Date.now() - 2 * 3600 * 1000).toISOString(); // 2 hours ago
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/__tests__/html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,5 +791,13 @@ describe('HTML Structure', () => {
// Inert until updateUserUI shows it with the signed-in email.
expect(el?.hasAttribute('hidden')).toBe(true);
});

test('every target="_blank" link sets rel="noopener noreferrer" (#136)', () => {
const links = Array.from(document.querySelectorAll<HTMLAnchorElement>('a[target="_blank"]'));
expect(links.length).toBeGreaterThan(0);
for (const link of links) {
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
}
});
});
});
21 changes: 21 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,27 @@ describe('Plans Module', () => {
expect(list?.innerHTML).toContain('ec2');
});

test.each([
[{ current_step: 2, total_steps: 4 }, '2/4 steps'],
[{ current_step: 0, total_steps: 0 }, '--'],
[{ current_step: 0 }, '--'],
])('ramp progress %j renders %s instead of a fabricated 0/1 (#136)', async (steps, expected) => {
(api.getPlans as jest.Mock).mockResolvedValue({
plans: [{
id: 'plan-1', name: 'Ramp Plan', enabled: true, auto_purchase: true,
services: { ec2: { provider: 'aws', service: 'ec2', enabled: true, term: 1, payment: 'all-upfront', coverage: 80 } },
ramp_schedule: { type: 'weekly', percent_per_step: 25, step_interval_days: 7, ...steps },
}],
});
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] });

await loadPlans();

const progress = Array.from(document.querySelectorAll('#plans-list .plan-detail'))
.find((el) => el.querySelector('.plan-detail-label')?.textContent === 'Progress');
expect(progress?.querySelector('.plan-detail-value')?.textContent).toBe(expected);
});

test('multi-SP plan summary lists every plan-type covered (issue #131)', async () => {
// Pre-fix: extractPlanInfo took serviceValues[0], so a plan
// covering both Compute SP and SageMaker SP rendered only
Expand Down
79 changes: 76 additions & 3 deletions frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Recommendations module tests
*/
import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, displaySavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS, isHomogeneousSelection, renderUsageSparkline, loadColumnFilters, saveColumnFilters, resetColumnFiltersState, spGroupKey, groupSpCellKeys, formatCapacity } from '../recommendations';
import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, displaySavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS, isHomogeneousSelection, renderUsageSparkline, loadColumnFilters, saveColumnFilters, resetColumnFiltersState, spGroupKey, groupSpCellKeys, formatCapacity, scaleRecForCapacity, weightedDisplaySavingsPct } from '../recommendations';
import type { CostPeriod } from '../state';

// Mock the api module
Expand Down Expand Up @@ -2107,16 +2107,50 @@ describe('Recommendations Module', () => {
});

test('totals row weighted effective % uses on_demand_cost when monthly_cost is null', async () => {
const azureVm = { provider: 'azure' as const, service: 'compute', payment: 'monthly' };
const recs = [
makeRec('ta', { savings: 100, upfront_cost: 0, monthly_cost: null, on_demand_cost: 200 }),
makeRec('tb', { savings: 100, upfront_cost: 0, monthly_cost: 50 }),
{ ...makeRec('ta', { savings: 100, upfront_cost: 0, monthly_cost: null, on_demand_cost: 200 }), ...azureVm },
{ ...makeRec('tb', { savings: 100, upfront_cost: 0, monthly_cost: 50 }), ...azureVm },
];
await openPurchaseModal(recs);

// ta: 100/200; tb (Azure, reconstructed): 100/(50+100+0) -> 200 + 150 baseline.
const pctCell = document.getElementById('purchase-modal-total-eff-pct');
expect(pctCell?.textContent).toContain('57.1%');
});

test('totals Eff. % skips AWS rows whose own Eff. % is an em-dash (#136)', async () => {
// tb is AWS with no on_demand_cost: its row shows an em-dash, so the
// discredited reconstruction must not leak into the total.
const recs = [
makeRec('ta', { savings: 100, upfront_cost: 0, monthly_cost: null, on_demand_cost: 200 }),
makeRec('tb', { savings: 100, upfront_cost: 0, monthly_cost: 50 }),
];
await openPurchaseModal(recs);

const rowPcts = Array.from(document.querySelectorAll('#purchase-details tbody tr'))
.map((tr) => tr.querySelector('td:nth-last-child(3)')?.textContent);
expect(rowPcts).toEqual(['50.0%', '—']);
expect(document.getElementById('purchase-modal-total-eff-pct')?.textContent).toBe('50.0%');
});

test('totals Eff. % is an em-dash when no selected row has a known Eff. %', async () => {
await openPurchaseModal([makeRec('tb', { savings: 100, upfront_cost: 0, monthly_cost: 50 })]);

expect(document.getElementById('purchase-modal-total-eff-pct')?.textContent).toBe('—');
});

test('totals Eff. % uses the provider savings_percentage the rows display', async () => {
const recs = [
{ ...makeRec('ta', { savings: 30, upfront_cost: 0, monthly_cost: 70 }), savings_percentage: 30 },
{ ...makeRec('tb', { savings: 50, upfront_cost: 0, monthly_cost: null, on_demand_cost: 100 }), savings_percentage: 50 },
];
await openPurchaseModal(recs);

// ta implied baseline 100 at 30%, tb baseline 100 at 50% -> 40%.
expect(document.getElementById('purchase-modal-total-eff-pct')?.textContent).toBe('40.0%');
});

test('clearPurchaseModalRecommendations also clears checked state', async () => {
await openPurchaseModal([makeRec('c1'), makeRec('c2')]);

Expand Down Expand Up @@ -3361,6 +3395,19 @@ describe('Bundle B: sticky bottom action box', () => {
expect(hint.textContent).toContain('Plans require one provider, service, term, and payment');
});

test.each([
[33.33, '100'],
[50, '50'],
[150, '100'],
[0, '100'],
['abc', '100'],
])('stored bulk-purchase capacity %p seeds the Capacity input as %s (#136)', async (stored, expected) => {
localStorageMock.getItem.mockImplementation((key: string) =>
key === 'cudly.recommendations.bulkPurchase.v1' ? JSON.stringify({ capacity: stored }) : null);
await loadRecommendations();
expect((document.getElementById('bulk-purchase-capacity') as HTMLInputElement).value).toBe(expected);
});

test('Capacity input value persists across re-render (mount-once-then-update)', async () => {
await loadRecommendations();
const cap = document.getElementById('bulk-purchase-capacity') as HTMLInputElement;
Expand Down Expand Up @@ -8466,3 +8513,29 @@ describe('Issue #1419: Opportunities Account column shows display name via listA
expect(html).toContain('acct-uuid-no-match');
});
});

describe('capacity scaling keeps Eff. % invariant (#136)', () => {
const rec = {
id: 'cap', provider: 'azure', service: 'vm', resource_type: 'D2s_v3', region: 'eastus',
count: 4, term: 1, payment: 'no-upfront', savings: 900, upfront_cost: 0,
monthly_cost: 1000, on_demand_cost: 1900,
} as unknown as LocalRecommendation;

test('on_demand_cost scales with count like savings and upfront', () => {
const scaled = scaleRecForCapacity(rec, 50)!;
expect(scaled.count).toBe(2);
expect(scaled.savings).toBe(450);
expect(scaled.on_demand_cost).toBe(950);
});

test.each([25, 50, 75, 100])('Eff. %% at %i%% capacity equals the unscaled 47.4%%', (capacity) => {
const scaled = scaleRecForCapacity(rec, capacity)!;
expect(displaySavingsPct(scaled)!.toFixed(1)).toBe('47.4');
expect(weightedDisplaySavingsPct([scaled])!.toFixed(1)).toBe('47.4');
});

test('an absent on_demand_cost stays absent', () => {
const scaled = scaleRecForCapacity({ ...rec, on_demand_cost: null } as LocalRecommendation, 50)!;
expect(scaled.on_demand_cost).toBeNull();
});
});
21 changes: 21 additions & 0 deletions frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,27 @@ describe('reshape recommendations table', () => {
expect(chips[1]?.textContent).toContain('$35.00/mo');
});

it('renders an unknown utilization as a neutral em-dash instead of throwing (#136)', async () => {
const mockGet = api.getReshapeRecommendations as jest.Mock;
const { utilization_percent: _omit, ...withoutUtil } = baseRec;
mockGet.mockResolvedValueOnce({
recommendations: [withoutUtil, { ...baseRec, source_ri_id: 'ri-def', utilization_percent: 97 }],
recs_staleness: '',
recs_collected_at: null,
});

await loadReshapeRecommendations();

const rows = tableContainer.querySelectorAll<HTMLTableRowElement>('tbody tr');
expect(rows).toHaveLength(2);
const unknown = rows[0]!.querySelectorAll('td')[4]!;
expect(unknown.textContent).toBe('—');
expect(unknown.className).toBe('');
const known = rows[1]!.querySelectorAll('td')[4]!;
expect(known.textContent).toBe('97.0%');
expect(known.className).toBe('util-green');
});

it('renders an em-dash in the Alternatives column when the rec has no alternative_targets', async () => {
const mockGet = api.getReshapeRecommendations as jest.Mock;
mockGet.mockResolvedValueOnce({ recommendations: [{ ...baseRec }], recs_staleness: '', recs_collected_at: null }); // no alternative_targets
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/apikeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ export async function loadApiKeys(): Promise<void> {
* failed fetch. Uses textContent (no innerHTML) to stay XSS-safe and
* matches the patterns used by other modules (e.g. dashboard.ts).
*/
/**
* ISO timestamp for a title attribute, or '' for an unparseable value:
* toISOString throws a RangeError on an Invalid Date, and that throw inside
* the row map blanked the whole keys table, Revoke buttons included (#136).
*/
function isoOrEmpty(value: string): string {
const d = new Date(value);
return isNaN(d.getTime()) ? '' : d.toISOString();
}

function renderApiKeysListError(container: HTMLElement | null, message: string): void {
if (!container) return;
container.replaceChildren();
Expand Down Expand Up @@ -129,7 +139,7 @@ export function renderApiKeysList(): void {
<td><code>${escapeHtml(key.key_prefix)}...</code></td>
<td><span class="badge ${statusClass}">${statusText}</span></td>
<td>${formatDateTime(key.created_at)}</td>
<td>${key.last_used_at ? `<span title="${escapeHtml(new Date(key.last_used_at).toISOString())}">${escapeHtml(formatRelativeTime(key.last_used_at))}</span>` : '<span class="text-muted">Never</span>'}</td>
<td>${key.last_used_at ? `<span title="${escapeHtml(isoOrEmpty(key.last_used_at))}">${escapeHtml(formatRelativeTime(key.last_used_at) || '--')}</span>` : '<span class="text-muted">Never</span>'}</td>
<td class="apikeys-count-cell">${countWindow}</td>
<td class="apikeys-count-cell">${countTotal}</td>
<td>${key.expires_at ? formatDateTime(key.expires_at) : '<span class="text-muted">Never</span>'}</td>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ <h1>CUDly</h1>
<div id="user-info">
<button type="button" id="user-email-display" aria-label="Profile" hidden></button>
<span id="user-role-display" class="role-badge"></span>
<a href="/docs/" target="_blank" class="header-link">API Docs</a>
<a href="/docs/" target="_blank" rel="noopener noreferrer" class="header-link">API Docs</a>
<a id="feedback-link" href="#" class="header-link feedback-link">Feedback</a>
<button id="logout-btn">Logout</button>
</div>
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,13 @@ async function loadPlanAccountNames(planId: string, cardEl: Element): Promise<vo
// actions that require an account (Add Purchases, Edit) are suppressed
// — only History and Delete remain — and a read-only "Unassigned" badge
// is shown so operators can identify and re-scope the plan.
/** "n/m steps", or "--" when the step count is missing or not positive (#136). */
function formatRampProgress(ramp: { current_step?: number; total_steps?: number }): string {
const total = ramp.total_steps;
if (typeof total !== 'number' || !Number.isFinite(total) || total <= 0) return '--';
return `${ramp.current_step || 0}/${total} steps`;
}

function renderPlanCard(plan: BackendPlan, canManagePlan: boolean, canDeletePlan: boolean, canAddPurchases: boolean): string {
const info = extractPlanInfo(plan);
const status = getStatusBadge(plan.enabled, plan.auto_purchase);
Expand Down Expand Up @@ -1145,7 +1152,7 @@ function renderPlanCard(plan: BackendPlan, canManagePlan: boolean, canDeletePlan
</div>
<div class="plan-detail">
<span class="plan-detail-label">Progress</span>
<span class="plan-detail-value">${rampSchedule.current_step || 0}/${rampSchedule.total_steps || 1} steps</span>
<span class="plan-detail-value">${formatRampProgress(rampSchedule)}</span>
</div>
${showNextDate ? `
<div class="plan-detail">
Expand Down
Loading
Loading