diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a98df276fd..48bc844e21 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -6794,6 +6794,11 @@ or run controls, while settled historical records must not be mislabeled as expired actionable reviews. For an aged, receipt-pending execution, the same responsive review dialog may expose an audit-recovery disclosure to an eligible local administrator. It must +show the receipt-pending state to every viewer as an unknown outcome, with a +non-mutating in-dialog re-read of the same action and a warning not to create a +second plan while the first outcome is unknown. The re-read remains available +before the recovery window and in read-only sessions; a failed read retains the +unknown state and never implies that the action was sent again. Recovery must first instruct the operator to check the resource's actual state outside the action record, then require a written reason and an explicit acknowledgement before a fresh action read and guarded force-fail call. Desktop and phone diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 8e70cef3a7..4701e6cbba 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2964,7 +2964,10 @@ rollback or automatic retry. An executing action whose durable dispatch attempt remains `receipt_pending` for more than the server's one-hour reconciliation window is still an unknown -operational outcome. The Actions review may offer the already-authorized local +operational outcome. At any age, the Actions review labels that wait as receipt +pending, warns against a new plan, and can re-read the same action by GET without +dispatching or closing it; this is available even to read-only viewers. The +Actions review may offer the already-authorized local audit force-fail recourse only after direct resource-state checking, a reason, acknowledgement and a fresh detail read. This terminalizes the audit row inconclusively; it does not stop or retry the agent operation. The shared diff --git a/frontend-modern/browser-tests/action-receipt-wait.cjs b/frontend-modern/browser-tests/action-receipt-wait.cjs new file mode 100644 index 0000000000..c0ec48ae07 --- /dev/null +++ b/frontend-modern/browser-tests/action-receipt-wait.cjs @@ -0,0 +1,142 @@ +// Browser proof for the production action dialog's non-mutating receipt re-read. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + const artifacts = path.join(root, 'browser-tests', 'action-receipt-proof'); + fs.mkdirSync(artifacts, { recursive: true }); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + server: { host: '127.0.0.1', port: 5202, strictPort: true }, + }); + let browser; + try { + await server.listen(); + browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] }); + const results = []; + for (const width of [1365, 390]) { + let mode = 'pending'; + const page = await browser.newPage({ viewport: { width, height: width === 390 ? 844 : 900 } }); + const pageErrors = []; + const actionRequests = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.route('**/*', (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5202') return route.abort(); + if (url.pathname === '/api/security/status') { + return route.fulfill({ json: { + hasAuthentication: true, + requiresAuth: true, + settingsCapabilities: { authenticationWrite: true }, + } }); + } + if (url.pathname === '/api/actions/action-fixture-1') { + actionRequests.push({ method: route.request().method(), mode }); + if (mode === 'error') return route.fulfill({ status: 503, json: { error: 'fixture read failed' } }); + if (mode === 'complete') { + return route.fulfill({ json: { + audit: { + id: 'action-fixture-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + state: 'completed', + decisionRevision: 1, + request: { + requestId: 'request-fixture-1', resourceId: 'docker:container:edge', + capabilityName: 'update', reason: 'Update edge to its latest image.', requestedBy: 'operator', + }, + plan: { + actionId: 'action-fixture-1', requestId: 'request-fixture-1', allowed: true, + requiresApproval: false, rollbackAvailable: false, + expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(), + planHash: 'sha256:fixture-plan', policyDecision: { status: 'resolved', authorities: [] }, + }, + result: { + success: true, + actionResultV2: { + execution: { status: 'succeeded' }, + verification: { status: 'verified', evidenceClass: 'agent' }, + compensation: { status: 'not_attempted', support: 'unavailable' }, + }, + }, + }, + events: [], + attempt: { id: 'attempt-fixture-1', actionId: 'action-fixture-1', state: 'receipt_recorded', + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), dispatchCount: 1 }, + receipt: { receivedAt: new Date().toISOString() }, + } }); + } + return route.fulfill({ json: { + audit: { + id: 'action-fixture-1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + state: 'executing', decisionRevision: 1, + request: { requestId: 'request-fixture-1', resourceId: 'docker:container:edge', + capabilityName: 'update', reason: 'Update edge to its latest image.', requestedBy: 'operator' }, + plan: { actionId: 'action-fixture-1', requestId: 'request-fixture-1', allowed: true, + requiresApproval: false, rollbackAvailable: false, expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(), + planHash: 'sha256:fixture-plan', policyDecision: { status: 'resolved', authorities: [] } }, + }, + events: [], + attempt: { id: 'attempt-fixture-1', actionId: 'action-fixture-1', state: 'receipt_pending', + createdAt: new Date(Date.now() - 5 * 60_000).toISOString(), + updatedAt: new Date(Date.now() - 5 * 60_000).toISOString(), dispatchCount: 1 }, + } }); + } + if (url.pathname.startsWith('/api/')) return route.abort(); + return route.continue(); + }); + await page.goto('http://127.0.0.1:5202/browser-tests/action-receipt-wait.html', { + waitUntil: 'domcontentloaded', timeout: 120_000, + }); + const dialog = page.getByRole('dialog', { name: 'Update' }); + await dialog.waitFor(); + await page.getByText('Receipt pending', { exact: true }).waitFor(); + assert.match(await page.getByTestId('action-receipt-waiting').innerText(), /Do not start the action again/); + assert.equal(await page.getByTestId('action-stuck-recovery').count(), 0); + await page.waitForTimeout(400); // Let the dialog entrance transition finish before capture. + await page.screenshot({ path: path.join(artifacts, `pending-${width}.png`) }); + + const refresh = page.getByRole('button', { name: 'Check for receipt' }); + if (width === 390) { + await refresh.focus(); + await page.keyboard.press('Enter'); + } else { + await refresh.click(); + } + await page.waitForFunction(() => document.querySelector('[data-testid="action-receipt-waiting"]') !== null); + assert.equal(actionRequests.length, 1); + mode = 'error'; + await refresh.click(); + await page.getByRole('alert').filter({ hasText: 'No new action was sent' }).waitFor(); + await page.waitForTimeout(400); + await page.screenshot({ path: path.join(artifacts, `read-error-${width}.png`) }); + + mode = 'complete'; + await refresh.click(); + await page.getByText('Completed', { exact: true }).waitFor(); + assert.equal(await page.getByTestId('action-receipt-waiting').count(), 0); + await page.waitForTimeout(400); + await page.screenshot({ path: path.join(artifacts, `completed-${width}.png`) }); + assert.equal(actionRequests.length, 3); + assert.ok(actionRequests.every((request) => request.method === 'GET'), JSON.stringify(actionRequests)); + assert.deepEqual(pageErrors, []); + const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: innerWidth })); + assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions)); + results.push({ width, actionRequests, pageErrors, dimensions }); + await page.close(); + } + console.log(JSON.stringify({ result: 'passed', browser: browser.version(), playwright: '1.56.1', results })); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/action-receipt-wait.html b/frontend-modern/browser-tests/action-receipt-wait.html new file mode 100644 index 0000000000..87ed8c4775 --- /dev/null +++ b/frontend-modern/browser-tests/action-receipt-wait.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/frontend-modern/browser-tests/action-receipt-wait.tsx b/frontend-modern/browser-tests/action-receipt-wait.tsx new file mode 100644 index 0000000000..f5bf6d820b --- /dev/null +++ b/frontend-modern/browser-tests/action-receipt-wait.tsx @@ -0,0 +1,73 @@ +// Mount the production action dialog with a synthetic, recent receipt wait. +// The browser runner supplies only bounded read-only API responses. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { ActionReviewDialog } from '../src/features/actions/ActionReviewDialog'; +import type { ActionDetailResponse } from '../src/types/actionAudit'; +import '../src/index.css'; + +const now = Date.now(); +const initial: ActionDetailResponse = { + audit: { + id: 'action-fixture-1', + createdAt: new Date(now - 6 * 60_000).toISOString(), + updatedAt: new Date(now - 5 * 60_000).toISOString(), + state: 'executing', + decisionRevision: 1, + request: { + requestId: 'request-fixture-1', + resourceId: 'docker:container:edge', + capabilityName: 'update', + reason: 'Update edge to its latest image.', + requestedBy: 'operator', + }, + plan: { + actionId: 'action-fixture-1', + requestId: 'request-fixture-1', + allowed: true, + requiresApproval: false, + approvalPolicy: 'none', + approvalRequirement: { + version: 1, + floor: 'none', + quorum: 0, + disallowRequester: false, + }, + rollbackAvailable: false, + expiresAt: new Date(now + 30 * 60_000).toISOString(), + planHash: 'sha256:fixture-plan', + policyDecision: { + version: 1, + status: 'resolved', + decisionId: 'fixture-policy', + actionId: 'action-fixture-1', + scope: { orgId: 'org-fixture', resourceId: 'docker:container:edge', capabilityName: 'update' }, + authorities: [], + approvalRequirement: { + version: 1, + floor: 'none', + quorum: 0, + disallowRequester: false, + }, + planningAllowed: true, + requiresApproval: false, + }, + }, + verificationOutcome: { status: 'unknown' }, + }, + events: [], + attempt: { + id: 'attempt-fixture-1', + actionId: 'action-fixture-1', + state: 'receipt_pending', + createdAt: new Date(now - 5 * 60_000).toISOString(), + updatedAt: new Date(now - 5 * 60_000).toISOString(), + dispatchCount: 1, + }, +}; + +const [selected, setSelected] = createSignal(initial); +render( + () => {}} onChanged={setSelected} />, + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 497683d9b3..dcc2cbd298 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,18 +1,16 @@ { "version": 1, - "base_sha": "498e2b12a22510520f9df7a510105d6775ea0522", - "verified_at": "2026-09-29T16:42:43Z", + "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c", + "verified_at": "2026-09-29T19:09:52Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", - "frontend-modern/src/types/resource.ts" + "frontend-modern/src/features/actions/ActionReviewDialog.tsx" ], "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "fc3c71f792c91abe62475be2a341fbedc729dd079b31e2245b5769f969fa6b12", - "frontend-modern/src/types/resource.ts": "4e3b64a3799aad0d357b9d8898dd2daf9a557e05c529df3650d2f6d03eb8ca56" + "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81" }, "routes": [ - "/browser-tests/pbs-mixed-source-1723.html (mock-backed current ProxmoxBackupServersTable)" + "/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)" ], "viewports": [ { @@ -25,26 +23,75 @@ } ], "states": [ - "PBS API + corroborated Agent with token-auth nodeName absent", - "same-host PVE API joined alongside a PVE-only name collision", - "correlated host row temporarily omitted", - "correlated host row restored" + "fresh executing/receipt_pending, including after a same-action re-read", + "failed status re-read with unknown outcome preserved", + "completed action after a recorded receipt" ], "interactions": [ - "desktop disclosure-button keyboard focus and Enter; phone row click", - "open History and select 24h, 6h, 1h and 12h across topology changes", - "join PVE API, omit host row, restore host row" + "desktop pointer and phone keyboard Check for receipt", + "GET same action under pending, read error and completion; no POST", + "desktop and phone layout and horizontal-overflow checks" ], - "command": "pulse-worker-browser frontend-modern/node_modules/proof-pbs-1723.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", + "command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/proof-pbs-1723.cjs", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-run.log", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/result.json", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-390.png" + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png" ], - "notes": "Fresh offline browser run on supplied main 498e2b12a2 plus unchanged #1723 runtime repair. At desktop and phone widths, History stayed selected with eight plotted paths through initial PBS + Agent, same-host PVE join, transient host omission and restoration. All eight captured history requests targeted agent:agent-uuid, never the PBS service key; joined screenshots were visually inspected. Mock-backed proof is not installed, second-topology or reporter acceptance.", + "notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest.", "prior_verifications": [ + { + "version": 1, + "base_sha": "498e2b12a22510520f9df7a510105d6775ea0522", + "verified_at": "2026-09-29T16:42:43Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", + "frontend-modern/src/types/resource.ts" + ], + "content_sha256": { + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "fc3c71f792c91abe62475be2a341fbedc729dd079b31e2245b5769f969fa6b12", + "frontend-modern/src/types/resource.ts": "4e3b64a3799aad0d357b9d8898dd2daf9a557e05c529df3650d2f6d03eb8ca56" + }, + "routes": [ + "/browser-tests/pbs-mixed-source-1723.html (mock-backed current ProxmoxBackupServersTable)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "PBS API + corroborated Agent with token-auth nodeName absent", + "same-host PVE API joined alongside a PVE-only name collision", + "correlated host row temporarily omitted", + "correlated host row restored" + ], + "interactions": [ + "desktop disclosure-button keyboard focus and Enter; phone row click", + "open History and select 24h, 6h, 1h and 12h across topology changes", + "join PVE API, omit host row, restore host row" + ], + "command": "pulse-worker-browser frontend-modern/node_modules/proof-pbs-1723.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", + "artifacts": [ + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/proof-pbs-1723.cjs", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-run.log", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/result.json", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-390.png" + ], + "notes": "Fresh offline browser run on supplied main 498e2b12a2 plus unchanged #1723 runtime repair. At desktop and phone widths, History stayed selected with eight plotted paths through initial PBS + Agent, same-host PVE join, transient host omission and restoration. All eight captured history requests targeted agent:agent-uuid, never the PBS service key; joined screenshots were visually inspected. Mock-backed proof is not installed, second-topology or reporter acceptance.", + "preserved_candidate_source": "25d6ab2e7a23adf3c74e0c071dfd40e799d2d83d" + }, { "version": 1, "base_sha": "fcbb7e58ad514ac146f666f49075039d40d829e2", @@ -112,6 +159,5 @@ "Loaded /alerts/notifications, /settings/system-general, /settings/system-relay and /settings/system/billing/plan on the Community stack and checked nav items, panels and links" ] } - ], - "preserved_candidate_source": "25d6ab2e7a23adf3c74e0c071dfd40e799d2d83d" + ] } diff --git a/frontend-modern/src/features/actions/ActionReviewDialog.tsx b/frontend-modern/src/features/actions/ActionReviewDialog.tsx index c340468348..7b1a26319a 100644 --- a/frontend-modern/src/features/actions/ActionReviewDialog.tsx +++ b/frontend-modern/src/features/actions/ActionReviewDialog.tsx @@ -43,12 +43,15 @@ export const ActionReviewDialog: Component<{ }> = (props) => { const [busy, setBusy] = createSignal(false); const [error, setError] = createSignal(''); + const [receiptRefreshError, setReceiptRefreshError] = createSignal(''); const [clock, setClock] = createSignal(Date.now()); const [canForceFail, setCanForceFail] = createSignal(false); const [recoveryOpen, setRecoveryOpen] = createSignal(false); const [recoveryReason, setRecoveryReason] = createSignal(''); const [recoveryConfirmed, setRecoveryConfirmed] = createSignal(false); const audit = () => props.detail?.audit; + const receiptWaiting = () => + audit()?.state === 'executing' && props.detail?.attempt?.state === 'receipt_pending'; const originDestination = () => getActionOriginDestination(audit()?.origin); const resource = createMemo(() => { const record = audit(); @@ -64,6 +67,7 @@ export const ActionReviewDialog: Component<{ ); createEffect(() => { const detail = props.detail; + setReceiptRefreshError(''); setRecoveryOpen(false); setRecoveryReason(''); setRecoveryConfirmed(false); @@ -177,6 +181,21 @@ export const ActionReviewDialog: Component<{ await props.onChanged?.(detail); }; + const refreshReceipt = async () => { + const actionId = audit()?.id; + if (!actionId || !receiptWaiting() || busy()) return; + setBusy(true); + setReceiptRefreshError(''); + try { + const latest = await ResourceActionsAPI.getAction(actionId); + await props.onChanged?.(latest); + } catch { + setReceiptRefreshError('Could not re-read this action. No new action was sent.'); + } finally { + setBusy(false); + } + }; + const decide = async (outcome: 'approved' | 'rejected') => { const action = audit(); if (!action || busy()) return; @@ -369,8 +388,14 @@ export const ActionReviewDialog: Component<{

{formatActionName(record().request.capabilityName)}

- - {getActionAuditStatePresentation(record()).label} + + {receiptWaiting() + ? 'Receipt pending' + : getActionAuditStatePresentation(record()).label}

@@ -401,6 +426,36 @@ export const ActionReviewDialog: Component<{

+ +
+

+ Waiting for the agent receipt +

+

+ Pulse sent this action once, but its outcome is not known yet. The agent may + still be working or may have finished. Do not start the action again from a new + plan. Check the actual resource if the wait persists. +

+ + + + +
+
{ + it('re-reads a fresh receipt wait without dispatching again and shows a recorded completion', async () => { + const current = waitingDetail(new Date(Date.now() - 5 * 60 * 1000).toISOString()); + const completed = { + ...current, + audit: { ...current.audit, state: 'completed' as const }, + attempt: { ...current.attempt!, state: 'receipt_recorded' as const }, + }; + vi.mocked(ResourceActionsAPI.getAction).mockResolvedValueOnce(completed); + const [selected, setSelected] = createSignal(current); + render(() => ( + + )); + + expect(screen.getByText('Receipt pending')).toBeVisible(); + expect(screen.getByTestId('action-receipt-waiting')).toHaveTextContent( + 'Do not start the action again from a new plan.', + ); + fireEvent.click(screen.getByRole('button', { name: 'Check for receipt' })); + + await waitFor(() => expect(screen.getByText('Completed', { exact: true })).toBeVisible()); + expect(screen.queryByTestId('action-receipt-waiting')).toBeNull(); + expect(ResourceActionsAPI.getAction).toHaveBeenCalledWith('action-1'); + expect(ResourceActionsAPI.executeAction).not.toHaveBeenCalled(); + expect(ResourceActionsAPI.forceFailAction).not.toHaveBeenCalled(); + }); + + it('keeps an unknown outcome visible when receipt re-read fails', async () => { + const current = waitingDetail(new Date(Date.now() - 5 * 60 * 1000).toISOString()); + vi.mocked(ResourceActionsAPI.getAction).mockRejectedValueOnce(new Error('offline')); + render(() => ); + + fireEvent.click(screen.getByRole('button', { name: 'Check for receipt' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Could not re-read this action. No new action was sent.', + ); + expect(screen.getByText('Receipt pending')).toBeVisible(); + expect(ResourceActionsAPI.executeAction).not.toHaveBeenCalled(); + }); + it('lets an admin close an aged receipt wait only after a direct-check reason and acknowledgement', async () => { const current = waitingDetail(); const terminalAudit: ActionAuditRecord = { @@ -210,6 +249,7 @@ describe('ActionReviewDialog trust gates', () => { const fresh = waitingDetail(new Date(Date.now() - 5 * 60 * 1000).toISOString()); const freshView = render(() => ); await waitFor(() => expect(SecurityAPI.getStatus).toHaveBeenCalled()); + expect(screen.getByTestId('action-receipt-waiting')).toBeVisible(); expect(screen.queryByTestId('action-stuck-recovery')).toBeNull(); freshView.unmount(); @@ -221,6 +261,7 @@ describe('ActionReviewDialog trust gates', () => { const readOnly = { ...waitingDetail(), readOnly: true }; const readOnlyView = render(() => ); + expect(screen.getByRole('button', { name: 'Check for receipt' })).toBeEnabled(); expect(screen.queryByTestId('action-stuck-recovery')).toBeNull(); readOnlyView.unmount();