From 7a1c090f2e0be945b52a8e31e26ca18d94261158 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 11 Mar 2026 09:44:42 +0200 Subject: [PATCH] Harden live Playwright action sweeps for cold-loaded surfaces --- ...wright_auth_and_changed_surface_recheck.md | 3 + .../live-mission-control-action-sweep.mjs | 55 +++++++++------- .../scripts/live-ops-policy-action-sweep.mjs | 63 ++++++++++++++++--- 3 files changed, 89 insertions(+), 32 deletions(-) diff --git a/docs/implplan/SPRINT_20260308_024_FE_live_frontdoor_playwright_auth_and_changed_surface_recheck.md b/docs/implplan/SPRINT_20260308_024_FE_live_frontdoor_playwright_auth_and_changed_surface_recheck.md index b4b23d3a9..f67a169b0 100644 --- a/docs/implplan/SPRINT_20260308_024_FE_live_frontdoor_playwright_auth_and_changed_surface_recheck.md +++ b/docs/implplan/SPRINT_20260308_024_FE_live_frontdoor_playwright_auth_and_changed_surface_recheck.md @@ -60,6 +60,8 @@ Completion criteria: | 2026-03-08 | Added reusable live-auth and changed-surface Playwright helpers under `src/Web/StellaOps.Web/scripts`, using the real Authority login flow and persisted session storage evidence so browser verification no longer depends on stub auth. | Codex | | 2026-03-08 | Refined the changed-surface sweep to exercise real page actions: mission-board navigation, registry-admin audit-tab routing, Evidence Threads PURL search/empty-result flow, and missing-detail/back-navigation handling. | Codex | | 2026-03-08 | Refreshed live evidence after the auth and contract fixes: mission-control, advisories/VEX, policy overview, Evidence Threads, timeline, deploy diff guard state, change trace, and registry-admin routes now complete without confirmed frontdoor defects in the scoped sweep. | Codex | +| 2026-03-11 | Reused the live-auth path after a full scratch rebuild and reran the canonical frontdoor sweep on the fresh stack; authenticated route coverage passed `111/111`, proving the rebuilt environment was stable enough for deeper action verification instead of only presence checks. | Codex | +| 2026-03-11 | Investigated fresh-stack action failures on mission-control and ops/policy and confirmed they were Playwright harness false positives, not product regressions: the pages lazy-rendered valid controls after the original selectors/timing windows had already declared failure. Hardened the sweeps with bounded element waits and product-specific selector disambiguation, then reran both slices cleanly with `0` failed actions and `0` runtime issues. | Codex | ## Decisions & Risks - Current scratch probes proved the compose bootstrap Authority account exists and can reach the real `/connect/authorize` login page, but they are too ad hoc for sustained iteration. @@ -67,6 +69,7 @@ Completion criteria: - The changed-surface harness needed product-aware checks to avoid false negatives: registry-admin is identified by its workspace heading rather than the surrounding Integrations shell heading; Evidence Threads is PURL-driven and must be exercised through search plus missing-detail guard flows instead of phantom row clicks; deploy diff without digests is a guarded state, not a broken route. - The compose demo stack currently exposes no seeded EvidenceLocker thread rows, so the live browser pass covers empty-result and missing-detail flows while positive-path detail normalization remains covered by focused frontend tests. - If the real auth/session flow changes under parallel agent work, the live-auth helper must be updated instead of falling back to stub auth. +- Decision: live Playwright sweeps for cold-loaded pages must poll for expected controls within bounded time and prefer product-specific href disambiguation over generic first-match selectors, otherwise QA will mislabel lazy-rendered routes as product defects. ## Next Checkpoints - Carry the same real-auth Playwright path into the next page/action iteration instead of regressing into status-code sweeps. diff --git a/src/Web/StellaOps.Web/scripts/live-mission-control-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-mission-control-action-sweep.mjs index 4111cab66..ce7d2d32e 100644 --- a/src/Web/StellaOps.Web/scripts/live-mission-control-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-mission-control-action-sweep.mjs @@ -15,6 +15,7 @@ const authStatePath = path.join(outputDir, 'live-mission-control-action-sweep.st const authReportPath = path.join(outputDir, 'live-mission-control-action-sweep.auth.json'); const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; const STEP_TIMEOUT_MS = 30_000; +const ELEMENT_WAIT_MS = 8_000; function isStaticAsset(url) { return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url); @@ -86,7 +87,7 @@ function attachRuntimeObservers(page, runtime) { async function settle(page) { await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); - await page.waitForTimeout(1_000); + await page.waitForTimeout(1_500); } async function headingText(page) { @@ -136,29 +137,35 @@ async function navigate(page, route) { await settle(page); } -async function resolveLink(page, options) { - if (options.hrefIncludes) { - const candidates = page.locator(`a[href*="${options.hrefIncludes}"]`); - const count = await candidates.count(); - for (let index = 0; index < count; index += 1) { - const candidate = candidates.nth(index); - const text = ((await candidate.innerText().catch(() => '')) || '').trim(); - if (!options.name || text === options.name || text.includes(options.name)) { - return candidate; +async function resolveLink(page, options, timeoutMs = ELEMENT_WAIT_MS) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (options.hrefIncludes) { + const candidates = page.locator(`a[href*="${options.hrefIncludes}"]`); + const count = await candidates.count(); + for (let index = 0; index < count; index += 1) { + const candidate = candidates.nth(index); + const text = ((await candidate.innerText().catch(() => '')) || '').trim(); + if (!options.name || text === options.name || text.includes(options.name)) { + return candidate; + } } } - } - if (options.name) { - const roleLocator = page.getByRole('link', { name: options.name }).first(); - if ((await roleLocator.count()) > 0) { - return roleLocator; + if (options.name) { + const roleLocator = page.getByRole('link', { name: options.name }).first(); + if ((await roleLocator.count()) > 0) { + return roleLocator; + } + + const textLocator = page.locator('a', { hasText: options.name }).first(); + if ((await textLocator.count()) > 0) { + return textLocator; + } } - const textLocator = page.locator('a', { hasText: options.name }).first(); - if ((await textLocator.count()) > 0) { - return textLocator; - } + await page.waitForTimeout(250); } return null; @@ -279,21 +286,24 @@ async function main() { { action: 'link:Stage detail', name: 'Detail', - hrefIncludes: '/setup/topology/environments/stage/posture', + hrefIncludes: + '/setup/topology/environments/stage/posture?tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d®ion=us-east&environment=stage', expectedPath: '/setup/topology/environments/stage/posture', expectQuery: { environment: 'stage', region: 'us-east' }, }, { action: 'link:Stage findings', name: 'Findings', - hrefIncludes: 'environment=stage', + hrefIncludes: + '/security/findings?tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d®ion=us-east&environment=stage', expectedPath: '/security/findings', expectQuery: { environment: 'stage', region: 'us-east' }, }, { action: 'link:Risk table open stage', name: 'Open', - hrefIncludes: '/setup/topology/environments/stage/posture', + hrefIncludes: + '/setup/topology/environments/stage/posture?tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d®ion=us-east&environment=stage', expectedPath: '/setup/topology/environments/stage/posture', expectQuery: { environment: 'stage', region: 'us-east' }, }, @@ -310,6 +320,7 @@ async function main() { { action: 'link:Watchlist alert', name: 'Identity watchlist alert requires signer review', + hrefIncludes: 'alertId=alert-001&returnTo=%2Fmission-control%2Falerts', expectedPath: '/setup/trust-signing/watchlist/alerts', expectQuery: { alertId: 'alert-001', diff --git a/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs index c94c1e8d0..a0e9e5d2b 100644 --- a/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs @@ -15,6 +15,7 @@ const authStatePath = path.join(outputDir, 'live-ops-policy-action-sweep.state.j const authReportPath = path.join(outputDir, 'live-ops-policy-action-sweep.auth.json'); const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; const STEP_TIMEOUT_MS = 45_000; +const ELEMENT_WAIT_MS = 8_000; async function settle(page) { await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); @@ -179,9 +180,55 @@ async function findNavigationTarget(page, name, index = 0) { return null; } +async function waitForNavigationTarget(page, name, index = 0, timeoutMs = ELEMENT_WAIT_MS) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const target = await findNavigationTarget(page, name, index); + if (target) { + return target; + } + + await page.waitForTimeout(250); + } + + return null; +} + +async function waitForButton(page, name, index = 0, timeoutMs = ELEMENT_WAIT_MS) { + const deadline = Date.now() + timeoutMs; + const locator = page.getByRole('button', { name }).nth(index); + + while (Date.now() < deadline) { + if ((await locator.count()) > 0) { + return locator; + } + + await page.waitForTimeout(250); + } + + return null; +} + +async function waitForAnyButton(page, names, timeoutMs = ELEMENT_WAIT_MS) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + for (const name of names) { + const locator = page.getByRole('button', { name }).first(); + if ((await locator.count()) > 0) { + return { name, locator }; + } + } + + await page.waitForTimeout(250); + } + + return null; +} + async function clickLink(context, page, route, name, index = 0) { await navigate(page, route); - const target = await findNavigationTarget(page, name, index); + const target = await waitForNavigationTarget(page, name, index); if (!target) { return { action: `link:${name}`, @@ -221,8 +268,8 @@ async function clickLink(context, page, route, name, index = 0) { async function clickButton(page, route, name, index = 0) { await navigate(page, route); - const locator = page.getByRole('button', { name }).nth(index); - if ((await locator.count()) === 0) { + const locator = await waitForButton(page, name, index); + if (!locator) { return { action: `button:${name}`, ok: false, @@ -261,13 +308,9 @@ async function clickButton(page, route, name, index = 0) { async function clickFirstAvailableButton(page, route, names) { await navigate(page, route); - - for (const name of names) { - const locator = page.getByRole('button', { name }).first(); - if ((await locator.count()) === 0) { - continue; - } - + const target = await waitForAnyButton(page, names); + if (target) { + const { name, locator } = target; const disabledBeforeClick = await locator.isDisabled().catch(() => false); const startUrl = page.url(); const downloadPromise = page.waitForEvent('download', { timeout: 4_000 }).catch(() => null);