Integrate pending action receipt review improvement

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-29 20:22:12 +01:00
8 changed files with 402 additions and 26 deletions
@@ -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
@@ -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
@@ -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;
});
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/action-receipt-wait.tsx"></script>
</body>
</html>
@@ -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(
() => <ActionReviewDialog detail={selected()} onClose={() => {}} onChanged={setSelected} />,
document.getElementById('root')!,
);
+69 -23
View File
@@ -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"
]
}
@@ -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<{
<h2 id="action-review-title" class="text-xl font-semibold">
{formatActionName(record().request.capabilityName)}
</h2>
<MetadataBadge tone={getActionAuditStatePresentation(record()).tone}>
{getActionAuditStatePresentation(record()).label}
<MetadataBadge
tone={
receiptWaiting() ? 'warning' : getActionAuditStatePresentation(record()).tone
}
>
{receiptWaiting()
? 'Receipt pending'
: getActionAuditStatePresentation(record()).label}
</MetadataBadge>
</div>
<p class="mt-1 text-sm text-muted">
@@ -401,6 +426,36 @@ export const ActionReviewDialog: Component<{
</Button>
</header>
<div class="overflow-y-auto px-5 py-4">
<Show when={receiptWaiting()}>
<section
aria-labelledby="action-receipt-waiting-heading"
data-testid="action-receipt-waiting"
class="mb-4 rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm dark:bg-amber-950/40"
>
<h3 id="action-receipt-waiting-heading" class="font-semibold">
Waiting for the agent receipt
</h3>
<p class="mt-2">
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.
</p>
<Button
variant="secondary"
size="sm"
class="mt-3"
isLoading={busy()}
onClick={() => void refreshReceipt()}
>
Check for receipt
</Button>
<Show when={receiptRefreshError()}>
<p role="alert" class="mt-2 text-red-800 dark:text-red-200">
{receiptRefreshError()}
</p>
</Show>
</section>
</Show>
<ActionDecisionPacket audit={record()} detail={props.detail ?? undefined} />
<Show when={canOfferRecovery()}>
<section
@@ -143,6 +143,45 @@ const waitingDetail = (updatedAt = '2026-07-12T00:02:00Z'): ActionDetailResponse
};
describe('ActionReviewDialog trust gates', () => {
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(() => (
<ActionReviewDialog detail={selected()} onClose={vi.fn()} onChanged={setSelected} />
));
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(() => <ActionReviewDialog detail={current} onClose={vi.fn()} />);
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(() => <ActionReviewDialog detail={fresh} onClose={vi.fn()} />);
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(() => <ActionReviewDialog detail={readOnly} onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Check for receipt' })).toBeEnabled();
expect(screen.queryByTestId('action-stuck-recovery')).toBeNull();
readOnlyView.unmount();