From e6432011a22b041a6b0fe74c27391c5e86e73856 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 00:27:22 +0000 Subject: [PATCH 1/5] fix(frontend): keep the bulk purchase modal open after a partial failure and retry only failed buckets When some buckets of a multi-bucket ("fan-out") purchase failed, the modal closed and the selection was cleared before the outcome was classified, and the toast truncated the reasons (3) and labels (5). Users had to find and reselect the failed rows in a large table, at the risk of resubmitting the buckets that had already gone through. On any bucket failure the modal now stays open: - each bucket section shows "Submitted: awaiting approval [from X]" / "Submitted: joined existing request ..." or "Failed: "; - submitted buckets are dimmed and their payment selects are locked; - a banner (role=alert) states how many were submitted and failed, that submitted buckets will not be sent again, and that closing the dialog does not cancel them; - the fan-out state is narrowed to the failed buckets, so the submit button becomes "Retry N failed" and resubmits only those. A failed bucket's payment can still be changed before retrying. The sticky failure toast is skipped in this mode (the modal has the full detail, and the toast covered the Retry button), and the Archera offer waits until the purchase modal closes. A fully successful retry closes the modal as before. Buckets get a stable id (copies from getFanOutBuckets() lose identity) and sections carry data-bucket-id so results map back to their section. Callers without ids keep the previous close-and-toast behavior. Refs #757 Co-Authored-By: Claude Opus 5.5 --- frontend/src/app.ts | 50 +++++++++++++++-- frontend/src/fanout-retry.ts | 97 +++++++++++++++++++++++++++++++++ frontend/src/recommendations.ts | 22 +++++++- frontend/src/styles/modals.css | 26 +++++++++ 4 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 frontend/src/fanout-retry.ts diff --git a/frontend/src/app.ts b/frontend/src/app.ts index c73b9658e..ce4759aac 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -22,6 +22,7 @@ import { buildCommitmentSummary, commitmentTotals, executeButtonLabel, executeCo import { handlePurchaseDeeplink } from './purchases-deeplink'; import { handleArcheraDeeplink, openArcheraOfferModal } from './archera'; import { closeModal } from './modal'; +import { showFanOutRetry, fanOutRetryLabel, type FanOutBucketOutcome } from './fanout-retry'; /** * describeDuplicateSubmit words the outcome of a submit the backend collapsed @@ -613,10 +614,36 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise { try { await submitFanOutBuckets(buckets); } finally { - setExecutePurchaseSubmitting(executeBtn, false, executeButtonLabel(false)); + // Issue #757: after a partial failure the modal stays open in retry mode. + setExecutePurchaseSubmitting(executeBtn, false, fanOutRetryLabel() ?? executeButtonLabel(false)); } } +/** + * Per-bucket outcome for the retry view (issue #757). Uses the same + * success rule as the toast: a fulfilled POST whose approval email failed + * is a failure. + */ +function fanOutOutcomes( + buckets: FanOutBucket[], + results: PromiseSettledResult[], +): FanOutBucketOutcome[] { + return results.map((r, i) => { + const bucket = buckets[i]!; + if (r.status === 'rejected') { + return { bucket, ok: false, detail: r.reason instanceof Error ? r.reason.message : String(r.reason) }; + } + if (r.value.email_sent === false || r.value.status === 'failed') { + return { bucket, ok: false, detail: r.value.email_reason || 'approval email did not send' }; + } + if (r.value.duplicate) { + return { bucket, ok: true, detail: `joined existing request ${r.value.execution_id.slice(0, 8)}; ${duplicateState(r.value.status)}` }; + } + const to = r.value.approval_recipient ? ` from ${r.value.approval_recipient}` : ''; + return { bucket, ok: true, detail: `awaiting approval${to}` }; + }); +} + async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { // Fire all POSTs in parallel via allSettled so one failure doesn't // cascade. Each bucket's recs are already scaled by its capacity %; @@ -655,9 +682,15 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { ); const succeeded = fulfilled.length - submissionFailures.length; const failed = results.length - succeeded; - closePurchaseModal(); - clearFanOutBuckets(); - clearPurchaseModalRecommendations(); + // Issue #757: on any failure keep the modal open with per-bucket status + // and a retry limited to the failed buckets, instead of closing it and + // making the user reselect rows (and risk resubmitting the successes). + const retrying = failed > 0 && showFanOutRetry(fanOutOutcomes(buckets, results)); + if (!retrying) { + closePurchaseModal(); + clearFanOutBuckets(); + clearPurchaseModalRecommendations(); + } // Disclose payment-option coercions across every bucket that reached the // backend (#1503). Collected from all fulfilled responses, not just the @@ -712,7 +745,10 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { timeout: 15_000, }); } - } else { + } else if (!retrying) { + // Issue #757: skipped while the modal stays open for a retry. The modal + // then shows every bucket's status and reason in full, and a sticky toast + // would sit on top of its Retry button. // Prefix each failure with its bucket's account so the user knows what to // retry when one account's bucket fails and another's succeeds (issue #333). const failureMsgs = results @@ -772,7 +808,9 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { // async email-link execution completes. Skipping the all-fail path keeps // the modal from layering on top of an error toast for a user who has // nothing to insure yet. - if (succeeded > 0) { + // Not while the purchase modal stays open for a retry (issue #757): the + // offer would stack on top of it. It fires once the retry completes. + if (succeeded > 0 && !retrying) { openArcheraOfferModal('purchase'); } diff --git a/frontend/src/fanout-retry.ts b/frontend/src/fanout-retry.ts new file mode 100644 index 000000000..13cce3747 --- /dev/null +++ b/frontend/src/fanout-retry.ts @@ -0,0 +1,97 @@ +/** + * Partial bulk-purchase failure recovery (issue #757). + * + * A multi-bucket ("fan-out") purchase is one POST per bucket. When some of + * them fail, the modal used to close and clear the selection, leaving the + * user to find and reselect the failed rows in a large table, with a risk of + * resubmitting the buckets that already went through. Instead the modal now + * stays open: every bucket section shows whether it was submitted or failed + * (with the reason), the submitted ones are locked, and the submit button + * becomes "Retry N failed", which resubmits only the failed buckets. + */ + +import { getFanOutBuckets, retainFanOutBuckets, type FanOutBucket } from './recommendations'; + +export interface FanOutBucketOutcome { + bucket: FanOutBucket; + ok: boolean; + /** Short status for the bucket section, e.g. the failure reason. */ + detail: string; +} + +const RESULT_ID = 'fanout-result'; + +function sectionFor(container: HTMLElement, id: string): HTMLElement | null { + for (const el of container.querySelectorAll('section.fanout-bucket')) { + if (el.dataset['bucketId'] === id) return el; + } + return null; +} + +function markSection(section: HTMLElement, outcome: FanOutBucketOutcome): void { + section.querySelector('.fanout-bucket-result')?.remove(); + section.classList.toggle('fanout-bucket-submitted', outcome.ok); + section.classList.toggle('fanout-bucket-failed', !outcome.ok); + const line = document.createElement('p'); + line.className = `fanout-bucket-result ${outcome.ok ? 'fanout-bucket-result-ok' : 'fanout-bucket-result-failed'}`; + line.textContent = outcome.ok ? `Submitted: ${outcome.detail}` : `Failed: ${outcome.detail}`; + section.querySelector('h4')?.after(line); + if (outcome.ok) { + // Submitted buckets are final: lock their controls so nothing suggests + // they will be sent again. + section.querySelectorAll('select').forEach((s) => { s.disabled = true; }); + } +} + +/** + * Keeps the fan-out modal open after a partial failure and narrows it to the + * failed buckets. Returns false (caller closes the modal as before) when + * nothing failed, when the buckets carry no ids, or when the modal is not + * rendered. + */ +export function showFanOutRetry(outcomes: readonly FanOutBucketOutcome[]): boolean { + const failed = outcomes.filter((o) => !o.ok); + if (failed.length === 0) return false; + if (!outcomes.every((o) => o.bucket.id)) return false; + const container = document.getElementById('purchase-details'); + if (!container || !container.querySelector('section.fanout-bucket')) return false; + + for (const o of outcomes) { + const section = sectionFor(container, o.bucket.id!); + if (section) markSection(section, o); + } + + retainFanOutBuckets(new Set(failed.map((o) => o.bucket.id!))); + + let banner = document.getElementById(RESULT_ID); + if (!banner) { + banner = document.createElement('div'); + banner.id = RESULT_ID; + banner.className = 'warning-message fanout-result'; + banner.setAttribute('role', 'alert'); + const summary = document.getElementById('fanout-summary'); + if (summary) summary.after(banner); + else container.prepend(banner); + } + // Count submitted sections across attempts, so a second partial failure + // still reports everything that already went through. + const submitted = container.querySelectorAll('section.fanout-bucket-submitted').length; + const head = `${submitted} bucket${submitted === 1 ? '' : 's'} submitted, ${failed.length} failed.`; + banner.textContent = submitted > 0 + ? `${head} Submitted buckets will not be sent again, and closing this dialog does not cancel them. Retry sends only the failed bucket${failed.length === 1 ? '' : 's'}.` + : `${head} Nothing was submitted. Check the reasons below, then retry.`; + // jsdom has no scrollIntoView; browsers always do. + if (typeof banner.scrollIntoView === 'function') banner.scrollIntoView({ block: 'nearest' }); + return true; +} + +/** + * Submit-button label while a retry is pending, or null when the modal is + * not in retry mode. The banner only exists in retry mode: opening the modal + * again rebuilds #purchase-details and drops it. + */ +export function fanOutRetryLabel(): string | null { + if (!document.getElementById(RESULT_ID)) return null; + const n = getFanOutBuckets()?.length ?? 0; + return n > 0 ? `Retry ${n} failed` : null; +} diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 307ff2d7a..00a68dab8 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -4256,6 +4256,12 @@ export interface FanOutBucket { capacityPercent: number; recs: LocalRecommendation[]; // scaled by capacityPercent paymentSource: 'override' | 'toolbar'; + /** + * Stable per-modal identity (issue #757). getFanOutBuckets() hands out + * copies, so the submit path needs this to map a POST result back to its + * bucket and section after a partial failure. + */ + id?: string; } // Fan-out modal state. app.ts's Send-for-Approval click reads these @@ -4285,6 +4291,18 @@ export function clearFanOutBuckets(): void { currentFanOutBuckets = null; } +/** + * Narrows the fan-out modal to the buckets with the given ids (issue #757): + * after a partial failure only the failed buckets stay submittable, so a + * retry cannot resubmit the ones that already went through. Re-renders the + * modal header so the email count and totals describe the retry. + */ +export function retainFanOutBuckets(ids: ReadonlySet): void { + if (!currentFanOutBuckets) return; + currentFanOutBuckets = currentFanOutBuckets.filter((b) => b.id !== undefined && ids.has(b.id)); + refreshFanOutSummary(); +} + // currentExecuteMode holds the mode selected by the execute-mode toggle in // the purchase modal (issue #289). "direct" means the session holder has // execute-any/execute-own and explicitly chose to bypass the approval email. @@ -4422,7 +4440,7 @@ async function openFanOutModal( const buckets: FanOutBucket[] = bucketEntries .filter(([_key, recs]) => recs.length > 0) - .map(([_key, recs]) => { + .map(([_key, recs], index) => { const r = recs[0]!; const seed = resolveBucketPaymentSeed(recs, toolbar, overridesByAccount); // SP buckets carry the canonical bucket key as `service` so the @@ -4467,6 +4485,7 @@ async function openFanOutModal( paymentSource: resolvedPaymentSource, capacityPercent: toolbar.capacity, recs: resolvedRecs, + id: `bucket-${index}`, }; }); currentFanOutBuckets = buckets; @@ -4580,6 +4599,7 @@ function computeFanOutTotals(buckets: FanOutBucket[]): { totalCount: number; tot function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { const section = document.createElement('section'); section.className = 'fanout-bucket form-section'; + if (b.id) section.dataset['bucketId'] = b.id; // Service label: SP bucket → "Savings Plans (Compute + SageMaker)" // listing only the plan types actually present in this bucket; diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css index ac40fa77d..6505068ab 100644 --- a/frontend/src/styles/modals.css +++ b/frontend/src/styles/modals.css @@ -514,3 +514,29 @@ max-width: none; padding: 0.5rem 0 0; } + +/* Issue #757: per-bucket outcome after a partial bulk-purchase failure. The + * fan-out modal stays open; submitted buckets are dimmed and locked, failed + * ones are flagged with their reason and are the only ones a retry sends. */ +.fanout-bucket-result { + font-weight: 600; + margin: 0.25rem 0; +} + +.fanout-bucket-result-ok { + color: var(--cudly-success-fg); +} + +.fanout-bucket-result-failed { + color: var(--cudly-error-fg); +} + +/* Dim the locked details but keep the status line at full contrast. */ +.fanout-bucket-submitted > :not(.fanout-bucket-result) { + opacity: 0.7; +} + +.fanout-bucket-failed { + border-left: 3px solid var(--cudly-error); + padding-left: 0.75rem; +} From 5d93e345eabb09a1ea1c26ec8d6c713200c5a142 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 00:27:22 +0000 Subject: [PATCH 2/5] test(frontend): cover partial bulk purchase failure and retry Jest: the modal stays open with per-bucket status, submitted buckets are locked, the button reads "Retry N failed", a retry posts only the failed buckets and closes the modal (and opens the Archera offer) once all succeed, a second partial failure keeps earlier successes counted, an all-failed run also stays open, buckets without ids keep the old behavior, and failure reasons cannot inject markup. Playwright: three providers, Azure fails once, the dialog shows the failure, Retry 1 failed posts only the Azure bucket and closes. Refs #757 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/app-fanout-retry.test.ts | 164 ++++++++++++++++++ .../tests-e2e/fanout-partial-failure.spec.ts | 58 +++++++ 2 files changed, 222 insertions(+) create mode 100644 frontend/src/__tests__/app-fanout-retry.test.ts create mode 100644 frontend/tests-e2e/fanout-partial-failure.spec.ts diff --git a/frontend/src/__tests__/app-fanout-retry.test.ts b/frontend/src/__tests__/app-fanout-retry.test.ts new file mode 100644 index 000000000..559cfe651 --- /dev/null +++ b/frontend/src/__tests__/app-fanout-retry.test.ts @@ -0,0 +1,164 @@ +/** + * Issue #757: a partial bulk (fan-out) purchase failure keeps the modal open + * with per-bucket status and a retry that resubmits only the failed buckets. + */ +const showToast = jest.fn(); +const closePurchaseModal = jest.fn(); +const clearFanOutBuckets = jest.fn(); +const openArcheraOfferModal = jest.fn(); +let current: Array> | null = null; + +jest.mock('../toast', () => ({ showToast: (...a: unknown[]) => showToast(...a) })); +jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn().mockResolvedValue(true) })); +jest.mock('../api', () => ({ executePurchase: jest.fn() })); +jest.mock('../state', () => ({})); +jest.mock('../auth', () => ({})); +jest.mock('../dashboard', () => ({ loadDashboard: jest.fn(), setupDashboardHandlers: jest.fn() })); +jest.mock('../navigation', () => ({})); +jest.mock('../plans', () => ({ closePurchaseModal: () => closePurchaseModal() })); +jest.mock('../settings', () => ({})); +jest.mock('../users', () => ({})); +jest.mock('../apikeys', () => ({})); +jest.mock('../history', () => ({})); +jest.mock('../modules/savings-history', () => ({})); +jest.mock('../riexchange', () => ({})); +jest.mock('../purchases-deeplink', () => ({})); +jest.mock('../archera', () => ({ handleArcheraDeeplink: jest.fn(), openArcheraOfferModal: (k: string) => openArcheraOfferModal(k) })); +jest.mock('../modal', () => ({ closeModal: jest.fn() })); +jest.mock('../recommendations', () => ({ + setupRecommendationsHandlers: jest.fn(), + getPurchaseModalRecommendations: () => [], + clearPurchaseModalRecommendations: jest.fn(), + getFanOutBuckets: () => (current ? current.map((b) => ({ ...b })) : null), + clearFanOutBuckets: () => { clearFanOutBuckets(); current = null; }, + retainFanOutBuckets: (ids: Set) => { current = (current ?? []).filter((b) => ids.has(b['id'] as string)); }, + getExecuteMode: () => '', + clearExecuteMode: jest.fn(), + fanOutBucketAccountLabel: (b: { id?: string }) => `acct-${b.id}`, + getAccountName: () => 'acct', +})); + +import { handleExecutePurchase } from '../app'; +import * as api from '../api'; + +const bucket = (id: string, service: string): Record => ({ + id, provider: 'aws', service, term: 1, payment: 'no-upfront', capacityPercent: 100, paymentSource: 'toolbar', + recs: [{ service }], +}); + +const section = (id: string): HTMLElement => + document.querySelector(`section.fanout-bucket[data-bucket-id="${id}"]`)!; +const btn = (): HTMLButtonElement => document.getElementById('execute-purchase-btn') as HTMLButtonElement; +const postedServices = (): string[] => + (api.executePurchase as jest.Mock).mock.calls.map((c) => (c[0] as Array<{ service: string }>)[0]!.service); + +beforeEach(() => { + jest.clearAllMocks(); + current = [bucket('bucket-0', 'ec2'), bucket('bucket-1', 'rds'), bucket('bucket-2', 'elasticache')]; + document.body.innerHTML = ` +
+
+ ${current.map((b) => `

${b['service']}

`).join('')} +
+ `; +}); + +describe('fan-out partial failure retry (issue #757)', () => { + test('keeps the modal open, marks each bucket, and narrows to the failed ones', async () => { + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true, approval_recipient: 'boss@example.com' }) + .mockRejectedValueOnce(new Error('quota exceeded')) + .mockResolvedValueOnce({ execution_id: 'cccc0000-1', status: 'failed', email_sent: false, email_reason: 'SES throttled' }); + + await handleExecutePurchase(); + + expect(closePurchaseModal).not.toHaveBeenCalled(); + expect(clearFanOutBuckets).not.toHaveBeenCalled(); + expect(openArcheraOfferModal).not.toHaveBeenCalled(); + + expect(section('bucket-0').classList.contains('fanout-bucket-submitted')).toBe(true); + expect(section('bucket-0').querySelector('.fanout-bucket-result')!.textContent) + .toBe('Submitted: awaiting approval from boss@example.com'); + expect(section('bucket-0').querySelector('select')!.disabled).toBe(true); + expect(section('bucket-1').querySelector('.fanout-bucket-result')!.textContent).toBe('Failed: quota exceeded'); + expect(section('bucket-2').querySelector('.fanout-bucket-result')!.textContent).toBe('Failed: SES throttled'); + expect(section('bucket-1').querySelector('select')!.disabled).toBe(false); + + const banner = document.getElementById('fanout-result')!; + expect(banner.getAttribute('role')).toBe('alert'); + expect(banner.textContent).toContain('1 bucket submitted, 2 failed.'); + expect(banner.textContent).toContain('Submitted buckets will not be sent again'); + + expect(current!.map((b) => b['id'])).toEqual(['bucket-1', 'bucket-2']); + expect(btn().textContent).toBe('Retry 2 failed'); + expect(btn().disabled).toBe(false); + // The modal carries the details; no sticky failure toast covers it. + expect(showToast.mock.calls.map((c) => (c[0] as { message: string }).message).join('\n')).not.toMatch(/failed/); + }); + + test('retry resubmits only the failed buckets and closes the modal once all succeed', async () => { + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }) + .mockRejectedValueOnce(new Error('quota exceeded')) + .mockRejectedValueOnce(new Error('timeout')); + await handleExecutePurchase(); + expect(postedServices()).toEqual(['ec2', 'rds', 'elasticache']); + + (api.executePurchase as jest.Mock).mockClear(); + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'bbbb0000-1', status: 'pending', email_sent: true }) + .mockResolvedValueOnce({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true }); + await handleExecutePurchase(); + + expect(postedServices()).toEqual(['rds', 'elasticache']); + expect(closePurchaseModal).toHaveBeenCalledTimes(1); + expect(openArcheraOfferModal).toHaveBeenCalledWith('purchase'); + }); + + test('a second partial failure keeps the earlier successes marked and counted', async () => { + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }) + .mockRejectedValueOnce(new Error('e1')) + .mockRejectedValueOnce(new Error('e2')); + await handleExecutePurchase(); + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'bbbb0000-1', status: 'completed', duplicate: true }) + .mockRejectedValueOnce(new Error('e3')); + await handleExecutePurchase(); + + expect(section('bucket-0').classList.contains('fanout-bucket-submitted')).toBe(true); + expect(section('bucket-1').querySelector('.fanout-bucket-result')!.textContent) + .toBe('Submitted: joined existing request bbbb0000; it was already executed'); + expect(section('bucket-2').querySelector('.fanout-bucket-result')!.textContent).toBe('Failed: e3'); + expect(document.getElementById('fanout-result')!.textContent).toContain('2 buckets submitted, 1 failed.'); + expect(btn().textContent).toBe('Retry 1 failed'); + }); + + test('all buckets failing also stays open for a retry', async () => { + (api.executePurchase as jest.Mock).mockRejectedValue(new Error('down')); + await handleExecutePurchase(); + expect(closePurchaseModal).not.toHaveBeenCalled(); + expect(document.getElementById('fanout-result')!.textContent).toContain('Nothing was submitted.'); + expect(btn().textContent).toBe('Retry 3 failed'); + }); + + test('buckets without ids (no rendered fan-out modal) keep the old close behavior', async () => { + current = [{ ...bucket('x', 'ec2'), id: undefined }, { ...bucket('y', 'rds'), id: undefined }]; + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }) + .mockRejectedValueOnce(new Error('boom')); + await handleExecutePurchase(); + expect(closePurchaseModal).toHaveBeenCalledTimes(1); + expect(document.getElementById('fanout-result')).toBeNull(); + }); + + test('bucket failure reason text cannot inject markup', async () => { + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }) + .mockRejectedValueOnce(new Error('')) + .mockResolvedValueOnce({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true }); + await handleExecutePurchase(); + expect(section('bucket-1').querySelector('img')).toBeNull(); + expect(section('bucket-1').textContent).toContain(''); + }); +}); diff --git a/frontend/tests-e2e/fanout-partial-failure.spec.ts b/frontend/tests-e2e/fanout-partial-failure.spec.ts new file mode 100644 index 000000000..f094b8489 --- /dev/null +++ b/frontend/tests-e2e/fanout-partial-failure.spec.ts @@ -0,0 +1,58 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +// Issue #757: when some buckets of a bulk purchase fail, the modal stays open +// with per-bucket status, and "Retry N failed" resubmits only those buckets. + +async function openFanOut(page: Page): Promise { + await page.route('**/api/accounts/*/service-overrides', (route) => route.fulfill({ json: [] })); + await page.goto('/opportunities'); + for (const id of ['r01', 'r12', 'r16']) { + await page.locator(`tr.recommendation-row[data-rec-id="${id}"]`) + .getByRole('checkbox', { name: /^Select / }).check(); + } + await page.locator('#bulk-purchase-btn').click(); + await expect(page.locator('#fanout-summary')).toBeVisible(); + await expect(page.locator('section.fanout-bucket')).toHaveCount(3); +} + +async function send(page: Page, label: RegExp | string): Promise { + await page.locator('#execute-purchase-btn').filter({ hasText: label }).click(); + await page.locator('.modal-confirm-backdrop').getByRole('button', { name: 'Send all for approval' }).click(); +} + +test('partial failure keeps the modal open and retries only the failed bucket', async ({ page }) => { + await seedAuth(page); + await mockApi(page); + const posted: string[] = []; + let azureAttempts = 0; + await page.route('**/api/purchases/execute', async (route) => { + const body = JSON.parse(route.request().postData() ?? '{}') as { recommendations: Array<{ provider: string }> }; + const provider = body.recommendations[0]!.provider; + posted.push(provider); + if (provider === 'azure' && azureAttempts++ === 0) { + await route.fulfill({ status: 502, json: { error: 'Azure reservation API unavailable' } }); + return; + } + await route.fulfill({ json: { execution_id: `exec-${provider}-${posted.length}`, status: 'pending', email_sent: true } }); + }); + + await openFanOut(page); + await send(page, 'Send for Approval'); + + const modal = page.getByRole('dialog', { name: 'Review Purchase' }); + await expect(modal).toBeVisible(); + await expect(page.locator('#fanout-result')).toContainText('2 buckets submitted, 1 failed.'); + const failed = page.locator('section.fanout-bucket.fanout-bucket-failed'); + await expect(failed).toHaveCount(1); + await expect(failed).toContainText('AZURE'); + await expect(failed.locator('.fanout-bucket-result')).toContainText('Failed:'); + await expect(page.locator('section.fanout-bucket.fanout-bucket-submitted')).toHaveCount(2); + await expect(page.locator('section.fanout-bucket-submitted select').first()).toBeDisabled(); + await expect(page.locator('#execute-purchase-btn')).toHaveText('Retry 1 failed'); + expect([...posted].sort()).toEqual(['aws', 'azure', 'gcp']); + + await send(page, 'Retry 1 failed'); + await expect(modal).toBeHidden(); + expect(posted.slice(3)).toEqual(['azure']); +}); From ea0a590f90c27cd29c10587a26da1f721e4032f3 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 21:43:02 +0000 Subject: [PATCH 3/5] fix(frontend): match the bulk-approval confirm label in the fan-out retry e2e Main's #1362 renamed the fan-out confirm button from "Send all for approval" to "Send for Approval ($X upfront on approval)", naming the combined upfront of the buckets being sent. The partial-failure spec still clicked the old label and timed out. It now matches the new label with the amount as a pattern, since the first send and the retry carry different totals. Refs #757 Co-Authored-By: Claude Opus 5.5 --- frontend/tests-e2e/fanout-partial-failure.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/tests-e2e/fanout-partial-failure.spec.ts b/frontend/tests-e2e/fanout-partial-failure.spec.ts index f094b8489..df1ae9d41 100644 --- a/frontend/tests-e2e/fanout-partial-failure.spec.ts +++ b/frontend/tests-e2e/fanout-partial-failure.spec.ts @@ -18,7 +18,9 @@ async function openFanOut(page: Page): Promise { async function send(page: Page, label: RegExp | string): Promise { await page.locator('#execute-purchase-btn').filter({ hasText: label }).click(); - await page.locator('.modal-confirm-backdrop').getByRole('button', { name: 'Send all for approval' }).click(); + // The confirm button names the combined upfront of the buckets being sent (#707). + await page.locator('.modal-confirm-backdrop') + .getByRole('button', { name: /^Send for Approval \(\$[\d,.]+ upfront on approval\)$/ }).click(); } test('partial failure keeps the modal open and retries only the failed bucket', async ({ page }) => { From fa3d6bc2dff64d4c7e30e9365f8cdc101c51fd87 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 23:15:14 +0000 Subject: [PATCH 4/5] fix(frontend): use the semibold weight token for the fan-out bucket status Main's typography foundation (#1378) forbids literal font weights in modals.css that duplicate a token. The fan-out status line used a literal 600; use var(--cudly-fw-semibold) instead, same weight. Refs #757 Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/modals.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css index ff2802cfd..461eda9f9 100644 --- a/frontend/src/styles/modals.css +++ b/frontend/src/styles/modals.css @@ -539,7 +539,7 @@ * fan-out modal stays open; submitted buckets are dimmed and locked, failed * ones are flagged with their reason and are the only ones a retry sends. */ .fanout-bucket-result { - font-weight: 600; + font-weight: var(--cudly-fw-semibold); margin: 0.25rem 0; } From e2858b94d29482e1fa8612a2bab2756c9ccadc61 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 23:42:16 +0000 Subject: [PATCH 5/5] fix(frontend): only enter fan-out retry mode while the submitting modal is open showFanOutRetry only checked that #purchase-details still held fan-out sections. Closing the modal (Escape or Cancel) while the POSTs were in flight leaves those sections in the hidden modal, so a failed bucket put its banner there and suppressed the failure toast: the user got no notice at all. Bucket ids were also per-index and reused on every open, so results settling after the user opened a new bulk modal marked its sections submitted and narrowed its buckets to the old failed ids. Bucket ids now carry the purchase-modal open token, and retry mode is entered only when #purchase-modal is visible and still holds the submitted bucket ids. Otherwise the toasts fire as on main. If a newer purchase modal is open, it is not closed or cleared. The #1503 payment-adjustment toast also uses the default timeout in retry mode instead of staying on top of the Retry button. Refs #757 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/app-fanout-retry.test.ts | 91 +++++++++++++++++-- .../__tests__/purchase-modal-submit.test.ts | 27 ++++++ frontend/src/app.ts | 20 ++-- frontend/src/fanout-retry.ts | 38 +++++++- frontend/src/recommendations.ts | 11 ++- 5 files changed, 167 insertions(+), 20 deletions(-) diff --git a/frontend/src/__tests__/app-fanout-retry.test.ts b/frontend/src/__tests__/app-fanout-retry.test.ts index 559cfe651..3300e23c8 100644 --- a/frontend/src/__tests__/app-fanout-retry.test.ts +++ b/frontend/src/__tests__/app-fanout-retry.test.ts @@ -55,14 +55,37 @@ const postedServices = (): string[] => beforeEach(() => { jest.clearAllMocks(); current = [bucket('bucket-0', 'ec2'), bucket('bucket-1', 'rds'), bucket('bucket-2', 'elasticache')]; - document.body.innerHTML = ` -
-
- ${current.map((b) => `

${b['service']}

`).join('')} -
- `; + renderModal(current); }); +/** Renders the open purchase modal with one section per bucket. */ +function renderModal(buckets: Array>): void { + document.body.innerHTML = ` + `; +} + +/** What Escape/Cancel does: hide the modal and clear its buckets (onClose). */ +function closeModalMidSubmit(): void { + document.getElementById('purchase-modal')!.classList.add('hidden'); + current = null; +} + +/** executePurchase calls that stay pending until the test settles them. */ +function deferredPosts(): Array<{ resolve: (v: unknown) => void; reject: (e: Error) => void }> { + const posts: Array<{ resolve: (v: unknown) => void; reject: (e: Error) => void }> = []; + (api.executePurchase as jest.Mock).mockImplementation(() => new Promise((resolve, reject) => { posts.push({ resolve, reject }); })); + return posts; +} + +const flush = async (): Promise => { for (let i = 0; i < 10; i++) await Promise.resolve(); }; +const toastMessages = (): string[] => showToast.mock.calls.map((c) => (c[0] as { message: string }).message); + describe('fan-out partial failure retry (issue #757)', () => { test('keeps the modal open, marks each bucket, and narrows to the failed ones', async () => { (api.executePurchase as jest.Mock) @@ -152,6 +175,62 @@ describe('fan-out partial failure retry (issue #757)', () => { expect(document.getElementById('fanout-result')).toBeNull(); }); + test('closing the modal mid-submit still reports a failed bucket in a toast, as before', async () => { + const posts = deferredPosts(); + const run = handleExecutePurchase(); + await flush(); + expect(posts).toHaveLength(3); + + closeModalMidSubmit(); + posts[0]!.resolve({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }); + posts[1]!.reject(new Error('quota exceeded')); + posts[2]!.resolve({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true }); + await run; + + expect(toastMessages()).toContainEqual(expect.stringMatching(/^2 of 3 submitted · 1 failed: acct-bucket-1: quota exceeded/)); + expect(document.getElementById('fanout-result')).toBeNull(); + expect(document.querySelector('.fanout-bucket-result')).toBeNull(); + expect(openArcheraOfferModal).toHaveBeenCalledWith('purchase'); + }); + + test('results from a closed modal leave a newly opened bulk modal untouched', async () => { + const posts = deferredPosts(); + const run = handleExecutePurchase(); + await flush(); + + closeModalMidSubmit(); + const next = [bucket('next-0', 'ec2'), bucket('next-1', 'rds')]; + current = next; + renderModal(next); + + posts[0]!.resolve({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }); + posts[1]!.reject(new Error('quota exceeded')); + posts[2]!.resolve({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true }); + await run; + + expect(current!.map((b) => b['id'])).toEqual(['next-0', 'next-1']); + expect(document.getElementById('purchase-modal')!.classList.contains('hidden')).toBe(false); + expect(document.getElementById('fanout-result')).toBeNull(); + expect(document.querySelector('.fanout-bucket-submitted, .fanout-bucket-failed, .fanout-bucket-result')).toBeNull(); + expect([...document.querySelectorAll('select')].some((s) => (s as HTMLSelectElement).disabled)).toBe(false); + expect(closePurchaseModal).not.toHaveBeenCalled(); + expect(clearFanOutBuckets).not.toHaveBeenCalled(); + expect(toastMessages()).toContainEqual(expect.stringMatching(/^2 of 3 submitted · 1 failed: acct-bucket-1: quota exceeded/)); + }); + + test('the payment-adjustment notice is not sticky while the modal is in retry mode', async () => { + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true, payment_adjustments: [{ provider: 'aws', service: 'ec2', requested_payment_option: 'no-upfront', applied_payment_option: 'all-upfront' }] }) + .mockRejectedValueOnce(new Error('quota exceeded')) + .mockRejectedValueOnce(new Error('timeout')); + await handleExecutePurchase(); + + expect(document.getElementById('fanout-result')).not.toBeNull(); + const notice = showToast.mock.calls.map((c) => c[0] as { kind: string; timeout?: number | null }).find((o) => o.kind === 'warning'); + expect(notice).toBeDefined(); + expect(notice!.timeout).not.toBeNull(); + }); + test('bucket failure reason text cannot inject markup', async () => { (api.executePurchase as jest.Mock) .mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true }) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index 070003975..adf36a15e 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -202,6 +202,33 @@ describe('Issue #331: Escape discards purchase state like the explicit close but expect(body[0]!['id']).toBe('single-only'); }); + test('each fan-out modal open gets fresh bucket ids, so results from an earlier open cannot match it (issue #757)', async () => { + const [ec2Rec, rdsRec] = fanOutEligibleRows(); + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'no-upfront' } }); + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, recommendations: [ec2Rec, rdsRec], regions: [], + }); + (state.getRecommendations as jest.Mock).mockReturnValue([ec2Rec, rdsRec]); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([ec2Rec, rdsRec]); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['fo-ec2', 'fo-rds'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const first = getFanOutBuckets()!.map((b) => b.id); + lastRegisteredOnClose()(); + + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const second = getFanOutBuckets()!.map((b) => b.id); + + expect(first).toHaveLength(2); + expect(second).toHaveLength(2); + expect(new Set([...first, ...second]).size).toBe(4); + const rendered = [...document.querySelectorAll('section.fanout-bucket')].map((s) => s.dataset['bucketId']); + expect(rendered).toEqual(second); + }); + test('Escape on a single-bucket modal discards its selection too (reverse direction)', async () => { const staleRec = buildRows()[0]!; // v-3-all await openPurchaseModal([staleRec]); diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 852a28125..351ced585 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -23,7 +23,7 @@ import { handlePurchaseDeeplink } from './purchases-deeplink'; import { handleRIExchangeDeeplink } from './riexchange-deeplink'; import { handleArcheraDeeplink, openArcheraOfferModal } from './archera'; import { closeModal } from './modal'; -import { showFanOutRetry, fanOutRetryLabel, type FanOutBucketOutcome } from './fanout-retry'; +import { showFanOutRetry, fanOutRetryLabel, purchaseModalReplaced, type FanOutBucketOutcome } from './fanout-retry'; import { wireTablistKeyboard } from './lib/tablist'; import { setOpenAccountsHandler } from './first-run-empty-state'; import { setupMobileNav } from './nav/mobile-nav'; @@ -739,8 +739,13 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { // Issue #757: on any failure keep the modal open with per-bucket status // and a retry limited to the failed buckets, instead of closing it and // making the user reselect rows (and risk resubmitting the successes). + // If the user closed the modal while the POSTs were in flight, report by + // toast as before. If they already opened another purchase modal, leave + // that one alone: it is not the one these results belong to. + // (Checked before showFanOutRetry, which narrows the modal's buckets.) + const replaced = purchaseModalReplaced(buckets); const retrying = failed > 0 && showFanOutRetry(fanOutOutcomes(buckets, results)); - if (!retrying) { + if (!retrying && !replaced) { closePurchaseModal(); clearFanOutBuckets(); clearPurchaseModalRecommendations(); @@ -755,7 +760,9 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { fulfilled.flatMap((r) => r.value.payment_adjustments ?? []), ); if (fanOutNotice) { - showToast({ message: fanOutNotice, kind: 'warning', timeout: null }); + // Sticky as before, except in retry mode, where it would cover the + // modal's Retry button; it then expires on the default timeout. + showToast({ message: fanOutNotice, kind: 'warning', ...(retrying ? {} : { timeout: null }) }); } if (failed === 0) { @@ -862,9 +869,10 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { // async email-link execution completes. Skipping the all-fail path keeps // the modal from layering on top of an error toast for a user who has // nothing to insure yet. - // Not while the purchase modal stays open for a retry (issue #757): the - // offer would stack on top of it. It fires once the retry completes. - if (succeeded > 0 && !retrying) { + // Not while a purchase modal is open (issue #757): the retry view, or a + // newer modal the user opened before these POSTs settled. The offer would + // stack on top of it. In retry mode it fires once a retry closes the modal. + if (succeeded > 0 && !retrying && !replaced) { openArcheraOfferModal('purchase'); } diff --git a/frontend/src/fanout-retry.ts b/frontend/src/fanout-retry.ts index 13cce3747..3245d028c 100644 --- a/frontend/src/fanout-retry.ts +++ b/frontend/src/fanout-retry.ts @@ -43,16 +43,46 @@ function markSection(section: HTMLElement, outcome: FanOutBucketOutcome): void { } } +function purchaseModalOpen(): boolean { + const modal = document.getElementById('purchase-modal'); + return modal !== null && !modal.classList.contains('hidden'); +} + +/** + * Whether the purchase modal is still open on the buckets that were just + * submitted. Bucket ids are unique per modal open, so this is false once the + * user closed the modal (Escape or Cancel) while the POSTs were in flight, + * and also once a new purchase modal has replaced it. + */ +function modalHoldsBuckets(buckets: readonly FanOutBucket[]): boolean { + if (!purchaseModalOpen()) return false; + const current = getFanOutBuckets(); + if (!current) return false; + const ids = new Set(current.map((b) => b.id)); + return buckets.every((b) => b.id !== undefined && ids.has(b.id)); +} + +/** + * True when a different purchase modal is open than the one these buckets + * were submitted from: the user closed it and opened another before the + * POSTs settled. The caller must then leave the modal alone instead of + * closing it or clearing its state. + */ +export function purchaseModalReplaced(buckets: readonly FanOutBucket[]): boolean { + if (!buckets.every((b) => b.id)) return false; + return purchaseModalOpen() && !modalHoldsBuckets(buckets); +} + /** * Keeps the fan-out modal open after a partial failure and narrows it to the - * failed buckets. Returns false (caller closes the modal as before) when - * nothing failed, when the buckets carry no ids, or when the modal is not - * rendered. + * failed buckets. Returns false (caller reports by toast and closes the modal + * as before) when nothing failed, when the buckets carry no ids, or when the + * modal that submitted them is no longer open. */ export function showFanOutRetry(outcomes: readonly FanOutBucketOutcome[]): boolean { const failed = outcomes.filter((o) => !o.ok); if (failed.length === 0) return false; - if (!outcomes.every((o) => o.bucket.id)) return false; + if (!modalHoldsBuckets(outcomes.map((o) => o.bucket))) return false; const container = document.getElementById('purchase-details'); if (!container || !container.querySelector('section.fanout-bucket')) return false; diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index b33c0e017..fbc5dbe7a 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -4356,9 +4356,10 @@ export interface FanOutBucket { recs: LocalRecommendation[]; // scaled by capacityPercent paymentSource: 'override' | 'toolbar'; /** - * Stable per-modal identity (issue #757). getFanOutBuckets() hands out - * copies, so the submit path needs this to map a POST result back to its - * bucket and section after a partial failure. + * Identity within one modal open (issue #757), unique across opens. + * getFanOutBuckets() hands out copies, so the submit path needs this to + * map a POST result back to its bucket and section after a partial + * failure, and to tell whether that modal is still the open one. */ id?: string; } @@ -4584,7 +4585,9 @@ async function openFanOutModal( paymentSource: resolvedPaymentSource, capacityPercent: toolbar.capacity, recs: resolvedRecs, - id: `bucket-${index}`, + // Unique per open, so results that settle after this modal closed + // cannot match the sections of a newer one. + id: `bucket-${openToken}-${index}`, }; }); currentFanOutBuckets = buckets;