diff --git a/frontend-modern/browser-tests/patrol-rule-removal-final.cjs b/frontend-modern/browser-tests/patrol-rule-removal-final.cjs new file mode 100644 index 0000000000..c8437098ba --- /dev/null +++ b/frontend-modern/browser-tests/patrol-rule-removal-final.cjs @@ -0,0 +1,39 @@ +// Final affected confirmation content: real phone reversal, desktop viewport, +// and long-reason pointer/keyboard review. No parent or stopped test replay. +const fs = require('node:fs'); +const path = require('node:path'); +const { + journey, + report, + output, +} = require('/workspace/frontend-modern/browser-tests/patrol-rule-removal.cjs'); +(async () => { + try { + await journey('/workspace/frontend-modern', 'webkit', 320); + await journey('/workspace/frontend-modern', 'chromium', 1365, false, true, true); + await journey('/workspace/frontend-modern', 'webkit', 320, false, true, 'long'); + report.result = 'passed'; + } catch (error) { + report.result = 'failed'; + report.failure = error.message; + throw error; + } finally { + report.cleanup = { + all_cases_closed: report.cases.every( + (c) => c.cleanup.browser_closed && c.cleanup.server_closed, + ), + }; + fs.writeFileSync(path.join(output, 'final-result.json'), JSON.stringify(report, null, 2)); + } + console.log( + JSON.stringify({ + result: report.result, + cases: report.cases.length, + captures: report.captures.length, + cleanup: report.cleanup, + }), + ); +})().catch((error) => { + console.error(error.stack); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/patrol-rule-removal-long-final.cjs b/frontend-modern/browser-tests/patrol-rule-removal-long-final.cjs new file mode 100644 index 0000000000..8f4d80baf3 --- /dev/null +++ b/frontend-modern/browser-tests/patrol-rule-removal-long-final.cjs @@ -0,0 +1,38 @@ +// Complete the previously unmeasured scroll controls only. Mobile WebKit admits +// keyboard scrolling, not mouse wheel; desktop Chromium admits pointer wheel. +const fs = require('node:fs'); +const path = require('node:path'); +const { + journey, + report, + output, +} = require('/workspace/frontend-modern/browser-tests/patrol-rule-removal.cjs'); +(async () => { + try { + await journey('/workspace/frontend-modern', 'webkit', 320, false, true, 'long'); + await journey('/workspace/frontend-modern', 'chromium', 1365, false, true, 'long'); + report.result = 'passed'; + } catch (error) { + report.result = 'failed'; + report.failure = error.message; + throw error; + } finally { + report.cleanup = { + all_cases_closed: report.cases.every( + (c) => c.cleanup.browser_closed && c.cleanup.server_closed, + ), + }; + fs.writeFileSync(path.join(output, 'long-final-result.json'), JSON.stringify(report, null, 2)); + } + console.log( + JSON.stringify({ + result: report.result, + cases: report.cases.length, + captures: report.captures.length, + cleanup: report.cleanup, + }), + ); +})().catch((error) => { + console.error(error.stack); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/patrol-rule-removal.cjs b/frontend-modern/browser-tests/patrol-rule-removal.cjs index c343f2bbe8..bc0a577435 100644 --- a/frontend-modern/browser-tests/patrol-rule-removal.cjs +++ b/frontend-modern/browser-tests/patrol-rule-removal.cjs @@ -326,7 +326,10 @@ async function journey(root, engine, width, parent = false, resume = false, visu return route.fulfill({ json }); }); const capture = async (name) => { - const file = path.join(output, `${parent ? 'parent' : engine}-${width}-${name}.png`); + const file = path.join( + output, + `${parent ? 'parent' : engine}-${width}-${visualOnly === 'long' ? 'long-' : ''}${name}.png`, + ); await page.screenshot({ path: file, fullPage: !visualOnly, @@ -403,6 +406,65 @@ async function journey(root, engine, width, parent = false, resume = false, visu actions.y + actions.height <= 901, 'Confirmation action must not be clipped: ' + JSON.stringify({ box, actions }), ); + if (visualOnly === 'long') { + const region = dialog.getByRole('region', { name: 'Rule scope and reason' }); + const initial = await region.evaluate((e) => ({ + top: e.scrollTop, + height: e.clientHeight, + scrollHeight: e.scrollHeight, + })); + assert.equal(initial.top, 0, 'Safe autofocus must not scroll past the rule scope'); + assert( + initial.scrollHeight > initial.height, + 'Long reason needs a real scrolling region', + ); + for (const text of ['Keep VM', 'fixture-keep', 'capacity', expected]) { + const scopeBox = await region.getByText(text, { exact: true }).boundingBox(); + const regionBox = await region.boundingBox(); + assert( + scopeBox && + regionBox && + scopeBox.y >= regionBox.y && + scopeBox.y + scopeBox.height <= regionBox.y + regionBox.height + 1, + 'The scope and exact ID are initially in view: ' + text, + ); + } + const input = + engine === 'webkit' && width === 320 ? 'keyboard PageDown' : 'pointer wheel'; + if (input === 'keyboard PageDown') { + // Playwright mobile WebKit does not implement mouse wheel. Use + // its supported native keyboard scroll on the focusable region. + await region.focus(); + await page.keyboard.press('PageDown'); + } else { + await region.hover(); + await page.mouse.wheel(0, 3000); + } + await page.waitForFunction( + () => document.querySelector('[aria-label="Rule scope and reason"]').scrollTop > 0, + ); + const scrolled = await region.evaluate((e) => e.scrollTop); + await capture('long-reason-scrolled'); + const footerAfter = await dialog + .getByRole('button', { name: 'Remove this rule' }) + .boundingBox(); + assert.deepEqual(footerAfter, actions, 'The action footer must not move with the reason'); + await region.focus(); + await page.keyboard.press('Home'); + await page.waitForFunction( + () => document.querySelector('[aria-label="Rule scope and reason"]').scrollTop === 0, + ); + result.longReason = { + initial, + scrollInput: input, + scrollPosition: scrolled, + keyboardHomeReturned: true, + stableFooter: true, + }; + result.checks.push( + 'Long reason supports the recorded scroll input and keyboard Home, with initial scope/ID visible and a stable safe footer.', + ); + } result.dialogs.push({ name, ruleId: expected, box, viewport: { width, height: 900 } }); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' }); diff --git a/frontend-modern/browser-verification/22122ef8054826c5.json b/frontend-modern/browser-verification/22122ef8054826c5.json new file mode 100644 index 0000000000..2b2bf8bb49 --- /dev/null +++ b/frontend-modern/browser-verification/22122ef8054826c5.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "base_sha": "e90b10f9f2a8b75249eb78327d302ae27c39b528", + "verified_at": "2026-10-05T18:27:23Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/api/patrol.ts", + "frontend-modern/src/components/AI/FindingsPanel.tsx", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", + "frontend-modern/src/features/patrol/PatrolSuppressionRules.tsx", + "frontend-modern/src/utils/apiClient.ts" + ], + "content_sha256": { + "frontend-modern/src/api/patrol.ts": "5c58ef600ede08b04ccb8acd70d14d70ade52cb048f25e1075df328fe2e8324f", + "frontend-modern/src/components/AI/FindingsPanel.tsx": "ae16913c0761aee84ec808a4be9d2782576e7afea78f64ea0b134d5d9e43f375", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "fa21aa6871c0930e23531bfa27614644871a73a353fd1971aebca1f4dcd1bc0e", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "f011f93aa9950cf10ce93317f5baf9e25161b39b03ec1a124da6fdbfc20cc249", + "frontend-modern/src/features/patrol/PatrolSuppressionRules.tsx": "6efd4b42234cdc432507786bb3c014262dbc02be400b9029f9af1e5bac871f35", + "frontend-modern/src/utils/apiClient.ts": "56dc80b15c0e7854ee16ece8b902c9de519e54f6110d55dc2c7e735d72214295" + }, + "routes": [ + "/patrol", + "/patrol/activity#patrol-suppression-rules" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 320, + "height": 900 + } + ], + "states": [ + "Production Patrol surface, clients and Router mounted with real CSS in the offline browser fixture; API responses and tenant/session inputs are synthetic. No installed or reporter deletion is claimed.", + "Assigned parent 53ec5fae creates a permanent narrow rule but has no manual-rule reversal surface. Existing Inbox and Finding options creator links lead to Activity in the repaired source.", + "Final-content WebKit 320: manual list, scoped confirmation, cancel, exact-ID removal/readback, preserved unrelated/finding-backed decisions, wildcard confirmation, uncertain response, explicit reload, revoked access and organisation invalidation.", + "Final-content Chromium 1365 viewport: exact manual identity and all-resource/all-category confirmation are visible with initial Cancel focus. Desktop deletion and strict transport checks were completed before the scroll-only markup repair; their client/handler content is unchanged.", + "Long multiline reason at 320 dark WebKit and 1365 light Chromium: heading and action footer stay visible, initial resource/category/rule ID remain in view, reason scrolls independently. WebKit native keyboard PageDown and Home; Chromium pointer wheel and keyboard Home.", + "Full-page screenshots omitted fixed confirmation panels. Separately inspected viewport captures and bounding boxes establish visual readability. No horizontal overflow, page errors or off-origin calls; browser/server closed in all cases." + ], + "interactions": [ + "Written reason required by the real Inbox Create rule form; narrow POST with synthetic CSRF and exact tenant, then link opens the collection even if Router state precedes pushState.", + "Escape/Cancel performs no DELETE and returns focus. Confirm verifies the same manual row with a fresh GET, sends one exact-ID DELETE and requires readback absence; unrelated rules and historic dismissals remain.", + "Finding options Create rule from this has a labelled reason field, Reopen distinction and the same Activity link without creating a second rule.", + "Lost DELETE response never retries or reports success; explicit reload reconciles. 403 fresh read stops before DELETE; org switch invalidates confirmation and old list with no cross-tenant action.", + "Measured final confirmation boxes within desktop and narrow viewports; finished finite entrance animations for viewport inspection. Long-reason scroll leaves the footer fixed, and keyboard Home restores the initial scope.", + "Playwright 1.56.1 matches the integration lock; Chromium 141.0.7390.37 and WebKit 26.0. Mobile WebKit wheel is unsupported; its failed aggregate remains evidence, with completed cases retained and the outstanding scroll check completed using supported keyboard input, without replaying their mutations." + ] +}