diff --git a/frontend/src/__tests__/api-history.test.ts b/frontend/src/__tests__/api-history.test.ts index ba8b95bd..b29a81e7 100644 --- a/frontend/src/__tests__/api-history.test.ts +++ b/frontend/src/__tests__/api-history.test.ts @@ -85,6 +85,14 @@ describe('API History Module', () => { ); }); + test('passes the paging cursor after the filters (issue #676)', async () => { + (apiRequest as jest.Mock).mockResolvedValue({}); + + await getHistory({ provider: 'aws', cursor: 'djEuYS4x' }); + + expect(apiRequest).toHaveBeenCalledWith('/history?provider=aws&cursor=djEuYS4x'); + }); + test('handles empty filters object', async () => { (apiRequest as jest.Mock).mockResolvedValue([]); diff --git a/frontend/src/__tests__/history-load-more.test.ts b/frontend/src/__tests__/history-load-more.test.ts new file mode 100644 index 00000000..58193757 --- /dev/null +++ b/frontend/src/__tests__/history-load-more.test.ts @@ -0,0 +1,187 @@ +/** + * Load more on the History view (issue #676): the API pages with has_more / + * next_cursor (#674); the UI appends each page and rebuilds both tables, so + * the Approval Queue covers every loaded page. + */ + +import { loadHistory, loadMoreHistory, setupHistoryHandlers } from '../history'; +import { historyTruncationMessage, mergeHistoryPages } from '../history-paging'; + +jest.mock('../api', () => ({ + getHistory: jest.fn(), + getConfig: jest.fn().mockResolvedValue({ global: {} }), + listAccounts: jest.fn().mockResolvedValue([]), + getDeploymentInfo: jest.fn().mockResolvedValue({}), +})); +jest.mock('../navigation', () => ({ switchTab: jest.fn() })); +jest.mock('../utils', () => ({ + formatCurrency: jest.fn((val) => `$${val || 0}`), + formatDate: jest.fn((val) => (val ? new Date(val).toLocaleDateString() : '')), + formatTerm: jest.fn((years) => (years == null ? '' : `${years} Year${years === 1 ? '' : 's'}`)), + escapeHtml: jest.fn((str) => str || ''), + escapeHtmlAttr: jest.fn((str: string | null | undefined) => str || ''), + amortizedMonthly: jest.fn(() => 0), +})); +jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn() })); +jest.mock('../toast', () => ({ showToast: jest.fn() })); +jest.mock('../state', () => ({ + getCurrentUser: jest.fn(), + getCurrentProvider: jest.fn().mockReturnValue('aws'), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), + getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}), + setPurchaseHistoryColumnFilter: jest.fn(), + getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}), + setApprovalQueueColumnFilter: jest.fn(), +})); +jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) })); + +import * as api from '../api'; +import { showToast } from '../toast'; +import type { HistoryPurchase, HistoryResponse } from '../types'; + +const getHistory = api.getHistory as jest.Mock; + +function row(id: string, status = 'completed', ts = '2026-10-01T00:00:00Z'): HistoryPurchase { + return { + purchase_id: id, timestamp: ts, provider: 'aws', service: 'ec2', resource_type: 'm5.large', + region: 'us-east-1', count: 1, term: 1, upfront_cost: 0, monthly_cost: 1, estimated_savings: 1, status, + }; +} + +function page(purchases: HistoryPurchase[], extra: Partial = {}): HistoryResponse { + return { summary: { total_purchases: 99 }, purchases, ...extra }; +} + +function setupDOM(): void { + document.body.innerHTML = ` + + +
+ `; +} + +const loadMoreWrap = (): HTMLElement => document.getElementById('history-load-more')!; +const historyIds = (): string[] => Array.from(document.querySelectorAll('#history-list tr[data-execution-id]')) + .map(tr => tr.dataset['executionId'] || ''); +const queueText = (): string => document.getElementById('purchases-approval-queue')!.textContent || ''; + +describe('History Load more (issue #676)', () => { + beforeEach(() => { + setupDOM(); + jest.clearAllMocks(); + }); + + test('stays hidden when the server does not page', async () => { + getHistory.mockResolvedValueOnce(page([row('a')])); + await loadHistory(); + expect(loadMoreWrap().hidden).toBe(true); + }); + + test('appends the second page with the same filters plus the cursor, then hides', async () => { + getHistory.mockResolvedValueOnce(page([row('a'), row('b')], { truncated: true, has_more: true, next_cursor: 'c1' })); + await loadHistory(); + expect(loadMoreWrap().hidden).toBe(false); + expect(document.getElementById('history-truncation-notice')!.hidden).toBe(true); + + getHistory.mockResolvedValueOnce(page([row('c', 'failed')], { summary: { total_purchases: 1 }, has_more: false })); + await loadMoreHistory(); + + const firstFilters = getHistory.mock.calls[0][0]; + expect(getHistory.mock.calls[1][0]).toEqual({ ...firstFilters, cursor: 'c1' }); + expect(firstFilters.provider).toBe('aws'); + expect(historyIds()).toEqual(['a', 'b', 'c']); + expect(loadMoreWrap().hidden).toBe(true); + // Summary cards keep the first page's totals. + expect(document.getElementById('history-summary')!.textContent).toContain('99'); + }); + + test('builds the Approval Queue from every loaded page', async () => { + getHistory.mockResolvedValueOnce(page([row('p1', 'pending')], { has_more: true, next_cursor: 'c1' })); + await loadHistory(); + getHistory.mockResolvedValueOnce(page([row('p2', 'notified')], { has_more: false })); + await loadMoreHistory(); + const queueRows = document.querySelectorAll('#purchases-approval-queue tbody tr'); + expect(queueRows).toHaveLength(2); + }); + + test('a row repeated on a later page replaces the stale copy', async () => { + getHistory.mockResolvedValueOnce(page([row('x', 'pending'), row('a')], { has_more: true, next_cursor: 'c1' })); + await loadHistory(); + expect(queueText()).not.toContain('No pending approvals'); + getHistory.mockResolvedValueOnce(page([row('x', 'expired')], { has_more: false })); + await loadMoreHistory(); + expect(historyIds()).toEqual(['a', 'x']); + expect(queueText()).toContain('No pending approvals'); + }); + + test('a reload drops the cursor and discards a Load more response still in flight', async () => { + getHistory.mockResolvedValueOnce(page([row('a')], { has_more: true, next_cursor: 'c1' })); + await loadHistory(); + let resolveMore!: (v: HistoryResponse) => void; + getHistory.mockReturnValueOnce(new Promise(r => { resolveMore = r; })); + const more = loadMoreHistory(); + const btn = document.getElementById('history-load-more-btn') as HTMLButtonElement; + expect(btn.disabled).toBe(true); + // A second click while the page is in flight does not fetch again. + await loadMoreHistory(); + expect(getHistory).toHaveBeenCalledTimes(2); + + getHistory.mockResolvedValueOnce(page([row('n')])); + await loadHistory(); + resolveMore(page([row('stale')], { has_more: false })); + await more; + expect(historyIds()).toEqual(['n']); + expect(loadMoreWrap().hidden).toBe(true); + }); + + test('a failed page keeps the rows and the button for a retry', async () => { + getHistory.mockResolvedValueOnce(page([row('a')], { has_more: true, next_cursor: 'c1' })); + await loadHistory(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + getHistory.mockRejectedValueOnce(new Error('boom')); + await loadMoreHistory(); + expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error', message: expect.stringContaining('boom') })); + expect(historyIds()).toEqual(['a']); + const btn = document.getElementById('history-load-more-btn') as HTMLButtonElement; + expect(loadMoreWrap().hidden).toBe(false); + expect(btn.disabled).toBe(false); + + getHistory.mockResolvedValueOnce(page([row('b')], { has_more: false })); + await loadMoreHistory(); + expect(getHistory.mock.calls[2][0].cursor).toBe('c1'); + expect(historyIds()).toEqual(['a', 'b']); + }); + + test('the button is wired by setupHistoryHandlers', async () => { + setupHistoryHandlers(); + getHistory.mockResolvedValueOnce(page([row('a')], { has_more: true, next_cursor: 'c1' })); + await loadHistory(); + getHistory.mockResolvedValueOnce(page([row('b')], { has_more: false })); + document.getElementById('history-load-more-btn')!.click(); + await new Promise(r => setTimeout(r, 0)); + expect(getHistory.mock.calls[1][0].cursor).toBe('c1'); + }); +}); + +describe('history-paging helpers', () => { + test('mergeHistoryPages keeps rows without a purchase_id', () => { + const anon = { ...row('z'), purchase_id: undefined }; + expect(mergeHistoryPages([anon, row('a')], [anon, row('a', 'failed')]).map(p => p.status)) + .toEqual(['completed', 'completed', 'failed']); + }); + + test('banner text follows has_more', () => { + expect(historyTruncationMessage({ truncated: false })).toBe(''); + expect(historyTruncationMessage({ truncated: true, limit: 100, executions_limit: 100 })).toContain('Narrow the date range'); + expect(historyTruncationMessage({ truncated: true, has_more: true, next_cursor: 'c' })).toBe(''); + expect(historyTruncationMessage({ truncated: true, has_more: false, actionable_executions_limit: 1000 })) + .toContain('capped at 1000'); + // has_more without a usable cursor cannot page: fall back to the old text. + expect(historyTruncationMessage({ truncated: true, has_more: true, limit: 5 })).toContain('most recent 5'); + }); +}); diff --git a/frontend/src/api/history.ts b/frontend/src/api/history.ts index 9e5ec37d..b8668344 100644 --- a/frontend/src/api/history.ts +++ b/frontend/src/api/history.ts @@ -21,6 +21,7 @@ export async function getHistory(filters: HistoryFilters = {}): Promise 0) params.set('account_ids', filters.account_ids.join(',')); + if (filters.cursor) params.set('cursor', filters.cursor); const queryString = params.toString(); return apiRequest(`/history${queryString ? '?' + queryString : ''}`); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b6014d3b..75df4eb2 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -294,6 +294,8 @@ export interface HistoryFilters { provider?: Provider; planId?: string; account_ids?: string[]; + /** next_cursor from the previous page, to fetch the rows after it. */ + cursor?: string; } // Config types diff --git a/frontend/src/history-paging.ts b/frontend/src/history-paging.ts new file mode 100644 index 00000000..da8d8b4a --- /dev/null +++ b/frontend/src/history-paging.ts @@ -0,0 +1,77 @@ +/** + * "Load more" paging for the Purchase History view (issue #676). + * + * The History API pages completed purchases and terminal executions with a + * keyset cursor (issue #674): a response with `has_more: true` carries a + * `next_cursor`, and repeating the request with `cursor=` returns + * the rows after it. Pending and in-flight executions are not paged; the first + * page lists all of them. Servers that predate paging omit `has_more`, so the + * control below stays hidden and the old "results are capped" banner remains. + * + * history.ts owns the fetch and the rendering; this module holds the pure + * helpers (cursor, row merge, banner text) and the button's DOM state. + */ + +import type { HistoryPurchase, HistoryResponse } from './types'; + +/** The cursor for the next page, or null when no further page can be fetched. */ +export function nextHistoryCursor(data: HistoryResponse): string | null { + if (data.has_more !== true) return null; + return typeof data.next_cursor === 'string' && data.next_cursor !== '' ? data.next_cursor : null; +} + +/** + * Append a newly fetched page to the rows already loaded. + * + * Rows are matched by purchase_id and the newest response wins: an approval + * listed as pending on page 1 can be swept to expired before a later page is + * read, and that later page then lists the same execution again in its new + * state. Keeping both would show one execution twice with conflicting + * statuses. Rows without a purchase_id cannot be matched and are kept as is. + */ +export function mergeHistoryPages( + loaded: HistoryPurchase[], + page: HistoryPurchase[], +): HistoryPurchase[] { + const incoming = new Set(); + for (const p of page) { + if (p.purchase_id) incoming.add(p.purchase_id); + } + const kept = loaded.filter(p => !p.purchase_id || !incoming.has(p.purchase_id)); + return [...kept, ...page]; +} + +/** + * Text of the "results capped" banner for a History response, or '' to hide + * it. + * + * - No `has_more` field: the server cannot page, so keep the #630 wording that + * tells the user to narrow the window. + * - `has_more` with a cursor: older rows are one click away, so the Load more + * button replaces the banner. + * - Truncated without more pages: rows paging cannot reach, i.e. the + * actionable class hit its cap or a listing failed. + */ +export function historyTruncationMessage(data: HistoryResponse): string { + if (!data.truncated) return ''; + if (nextHistoryCursor(data)) return ''; + if (data.has_more === false) { + return `Some rows could not be listed: pending and in-progress executions are capped at ${data.actionable_executions_limit ?? '?'}, or part of the history failed to load. Pending approvals and totals may be incomplete. Narrow the date range or filters, or reload, to see them.`; + } + return `Results are capped at the most recent ${data.limit ?? '?'} completed purchases and ${data.executions_limit ?? '?'} pending, failed or other non-completed executions. Older rows are not shown, so pending approvals and totals may be incomplete. Narrow the date range or filters to see them.`; +} + +/** + * Show or hide the Load more control below the history table. `busy` disables + * the button while a page is in flight so a double click cannot fetch the + * same cursor twice. + */ +export function renderLoadMoreControl(visible: boolean, busy = false): void { + const wrap = document.getElementById('history-load-more'); + if (wrap) wrap.hidden = !visible; + const btn = document.getElementById('history-load-more-btn') as HTMLButtonElement | null; + if (!btn) return; + btn.disabled = busy; + btn.textContent = busy ? 'Loading…' : 'Load more'; + btn.setAttribute('aria-busy', busy ? 'true' : 'false'); +} diff --git a/frontend/src/history.ts b/frontend/src/history.ts index dede41d3..87f6d3e0 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -11,6 +11,12 @@ import { switchTab } from './navigation'; import { confirmDialog } from './confirmDialog'; import { approvalDetailsUnavailableMessage, buildApprovalDetailsBody } from './approval-details'; import { showToast } from './toast'; +import { + historyTruncationMessage, + mergeHistoryPages, + nextHistoryCursor, + renderLoadMoreControl, +} from './history-paging'; import { renderApprovalQueueLink, wireApprovalQueueLinks } from './history-queue-link'; import { getCurrentUser } from './state'; import { canAccess } from './permissions'; @@ -48,6 +54,15 @@ const busyHistoryExecutions = new Set(); let historyMutationEpoch = 0; let activeStatusFilter: StatusFilter = 'all'; +// Load more paging (issue #676). historyLoadGeneration is bumped by every +// first-page load, so a Load more response that lands after the user changed +// a filter (or after a mutation reload) is dropped instead of being appended +// to the new result set. historyPaging holds the filters of the first page and +// the cursor of the last page loaded; null when there is nothing more to load. +let historyLoadGeneration = 0; +let historyPaging: { filters: api.HistoryFilters; cursor: string } | null = null; +let loadMoreInFlight = false; + // _fourEyesMode mirrors GlobalConfig.require_different_approver (issue #1005). // Gates the inline Approve button so a creator can't approve their own pending // purchase when dual-control is on. Starts ON, matching the fail-closed @@ -170,6 +185,7 @@ export function applyExecutionDeepLink(): boolean { * + Load History button — those stay here. */ export function setupHistoryHandlers(): void { + document.getElementById('history-load-more-btn')?.addEventListener('click', () => void loadMoreHistory()); state.subscribeProvider(() => void loadHistory()); state.subscribeAccount(() => void loadHistory()); // Re-render both tables when the amortize toggle flips (issue #1112). @@ -260,6 +276,7 @@ export function initHistoryDateRange(): void { */ export async function viewPlanHistory(planId: string): Promise { const requestEpoch = historyMutationEpoch; + resetHistoryPaging(); // skipDefaultLoad: the tab's own unscoped 7-day fetch would land after the // plan-scoped one below and overwrite it, and its date-range seeding is // exactly what the doc comment above says not to do here. @@ -269,8 +286,10 @@ export async function viewPlanHistory(planId: string): Promise { if (!canAccess('view', 'purchases')) return; try { + const filters: api.HistoryFilters = { planId }; + const generation = historyLoadGeneration; const [data] = await Promise.all([ - api.getHistory({ planId }) as unknown as Promise, + api.getHistory(filters) as unknown as Promise, refreshFourEyesMode(), ]); if (requestEpoch !== historyMutationEpoch) return; @@ -280,6 +299,7 @@ export async function viewPlanHistory(planId: string): Promise { renderApprovalQueue(purchases); renderHistoryList(purchases); snapDateInputsToPurchases(purchases); + updateHistoryPaging(generation, filters, data); } catch (error) { if (requestEpoch !== historyMutationEpoch) return; console.error('Failed to load plan history:', error); @@ -324,6 +344,8 @@ function snapDateInputsToPurchases(purchases: HistoryPurchase[]): void { */ export async function loadHistory(): Promise { const requestEpoch = historyMutationEpoch; + resetHistoryPaging(); + const generation = historyLoadGeneration; // Issue #344 T3: skeleton rows for the purchase-history table. 8 // rows matches the typical first-page row count so the skeleton // doesn't shrink dramatically when real data arrives. Column count @@ -373,6 +395,7 @@ export async function loadHistory(): Promise { const purchases = data.purchases || []; renderApprovalQueue(purchases); renderHistoryList(purchases); + updateHistoryPaging(generation, filters, data); } catch (error) { if (requestEpoch !== historyMutationEpoch) return; console.error('Failed to load history:', error); @@ -391,20 +414,76 @@ export async function loadHistory(): Promise { } } +/** + * Forget the previous result set's cursor and hide Load more. Called at the + * start of every first-page load; bumping the generation also invalidates a + * Load more request still in flight for the previous result set. + */ +function resetHistoryPaging(): void { + historyLoadGeneration++; + historyPaging = null; + renderLoadMoreControl(false); +} + +/** Remember where the next page starts and show Load more when there is one. */ +function updateHistoryPaging(generation: number, filters: api.HistoryFilters, data: HistoryResponse): void { + if (generation !== historyLoadGeneration) return; + const cursor = nextHistoryCursor(data); + historyPaging = cursor ? { filters, cursor } : null; + renderLoadMoreControl(historyPaging !== null); +} + +/** + * Fetch the page after the last one loaded, with the first page's filters, + * and append it (issue #676). Both tables re-render from every loaded row, so + * the Approval Queue is built from all loaded pages. The summary cards keep + * the first page's totals: a later page's summary covers only that page on + * servers without whole-scope totals (#675). + */ +export async function loadMoreHistory(): Promise { + const paging = historyPaging; + if (!paging || loadMoreInFlight) return; + const generation = historyLoadGeneration; + const requestEpoch = historyMutationEpoch; + loadMoreInFlight = true; + renderLoadMoreControl(true, true); + try { + const data = await (api.getHistory({ ...paging.filters, cursor: paging.cursor }) as unknown as Promise); + if (generation !== historyLoadGeneration) return; + // A mutation landed mid-flight and its reload will replace the rows; keep + // the cursor so the button still works if that reload has not started. + if (requestEpoch !== historyMutationEpoch) { + renderLoadMoreControl(true); + return; + } + const purchases = mergeHistoryPages(lastPurchases, data.purchases || []); + renderTruncationNotices(data); + renderApprovalQueue(purchases); + renderHistoryList(purchases); + updateHistoryPaging(generation, paging.filters, data); + } catch (error) { + if (generation !== historyLoadGeneration) return; + console.error('Failed to load more history:', error); + showToast({ message: `Failed to load more history: ${(error as Error).message}`, kind: 'error' }); + renderLoadMoreControl(true); + } finally { + loadMoreInFlight = false; + } +} + /** * Show or hide the "results capped" banner above the approval queue and the * history table. The API sets `truncated` when a fetch hit its row cap, in - * which case pending approvals and the summary totals can under-report. + * which case pending approvals and the summary totals can under-report. When + * the API can page (#674), Load more replaces the banner for rows it can reach. */ function renderTruncationNotices(data: HistoryResponse): void { - const message = data.truncated - ? `Results are capped at the most recent ${data.limit ?? '?'} completed purchases and ${data.executions_limit ?? '?'} pending, failed or other non-completed executions. Older rows are not shown, so pending approvals and totals may be incomplete. Narrow the date range or filters to see them.` - : ''; + const message = historyTruncationMessage(data); for (const id of ['purchases-truncation-notice', 'history-truncation-notice']) { const el = document.getElementById(id); if (!el) continue; el.textContent = message; - el.hidden = !data.truncated; + el.hidden = message === ''; } } diff --git a/frontend/src/index.html b/frontend/src/index.html index 1a2c654b..d5511c3c 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -256,6 +256,10 @@

Purchase History

+ + diff --git a/frontend/src/styles/tables.css b/frontend/src/styles/tables.css index 305c05cb..980be759 100644 --- a/frontend/src/styles/tables.css +++ b/frontend/src/styles/tables.css @@ -79,6 +79,17 @@ tr.history-row-highlight:hover { margin-bottom: 1.5rem; } +/* Load more under the Purchase History table (issue #676) */ +.history-load-more { + display: flex; + justify-content: center; + margin-top: 1rem; +} + +.history-load-more[hidden] { + display: none; +} + /* Planned Purchases Section */ #planned-purchases-header { margin-top: 2rem; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a7ffc11a..f557af53 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -204,6 +204,18 @@ export interface HistoryResponse { limit?: number; /** Fixed row cap on non-completed executions. */ executions_limit?: number; + /** + * Row cap on actionable executions (pending, approved, running, ...). + * They are not paged: the first page lists them all, up to this cap. + */ + actionable_executions_limit?: number; + /** + * True when older rows follow this page (issue #674). Absent on servers + * that predate paging, which the UI treats as "cannot page". + */ + has_more?: boolean; + /** Opaque position to pass back as `cursor`; set only with has_more. */ + next_cursor?: string; } export interface HistorySummary { diff --git a/frontend/tests-e2e/history-load-more.spec.ts b/frontend/tests-e2e/history-load-more.spec.ts new file mode 100644 index 00000000..17ea9ca3 --- /dev/null +++ b/frontend/tests-e2e/history-load-more.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +// Issue #676: when GET /api/history reports has_more, Load more fetches the +// next page with the cursor and appends it under the first page's rows. +function row(id: string, status: string, daysAgo: number) { + return { + purchase_id: `${id}-0000-0000-0000-000000000000`, timestamp: new Date(Date.now() - daysAgo * 86400000).toISOString(), + provider: 'aws', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 1, + upfront_cost: 0, monthly_cost: 10, estimated_savings: 2, status, account_id: 'acct-001', + }; +} + +test('Load more appends the next history page and hides once has_more is false', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await seedAuth(page); + await mockApi(page); + const cursors: (string | null)[] = []; + await page.route('**/api/history**', async route => { + const url = new URL(route.request().url()); + if (url.pathname !== '/api/history') return route.fallback(); + const cursor = url.searchParams.get('cursor'); + cursors.push(cursor); + await route.fulfill({ json: cursor === 'page-2' + ? { summary: { total_purchases: 1 }, purchases: [row('cccccccc', 'failed', 3)], truncated: false, has_more: false } + : { summary: { total_purchases: 3 }, purchases: [row('aaaaaaaa', 'pending', 1), row('bbbbbbbb', 'completed', 2)], + truncated: true, limit: 2, executions_limit: 2, has_more: true, next_cursor: 'page-2' } }); + }); + + await page.goto('/purchases/history'); + const rows = page.locator('#history-list tbody tr'); + await expect(rows).toHaveCount(2); + const button = page.locator('#history-load-more-btn'); + await expect(button).toBeVisible(); + await expect(page.locator('#history-truncation-notice')).toBeHidden(); + + await button.click(); + await expect(rows).toHaveCount(3); + await expect(page.locator('#history-list tbody tr[data-execution-id^="cccccccc"]')).toHaveCount(1); + await expect(button).toBeHidden(); + expect(cursors.filter(c => c !== null)).toEqual(['page-2']); + await expect(page.locator('#history-summary')).toContainText('3'); + expect(errors).toEqual([]); +});