Harden live Playwright action sweeps for cold-loaded surfaces

This commit is contained in:
master
2026-03-11 09:44:42 +02:00
parent ae09af4e65
commit 7a1c090f2e
3 changed files with 89 additions and 32 deletions

View File

@@ -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.

View File

@@ -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&regions=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&regions=us-east&environments=stage&timeWindow=7d&region=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&regions=us-east&environments=stage&timeWindow=7d&region=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&regions=us-east&environments=stage&timeWindow=7d&region=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',

View File

@@ -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&regions=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);