Harden live Playwright action sweeps for cold-loaded surfaces
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user