From b9aa1dbe24cfd54f9460aba7e03e91439fe22b10 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 06:35:05 +0200 Subject: [PATCH] Add live mission control action sweep --- ...03_FE_mission_control_live_action_sweep.md | 64 +++ .../live-mission-control-action-sweep.mjs | 371 ++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 docs/implplan/SPRINT_20260310_003_FE_mission_control_live_action_sweep.md create mode 100644 src/Web/StellaOps.Web/scripts/live-mission-control-action-sweep.mjs diff --git a/docs/implplan/SPRINT_20260310_003_FE_mission_control_live_action_sweep.md b/docs/implplan/SPRINT_20260310_003_FE_mission_control_live_action_sweep.md new file mode 100644 index 000000000..aa1313cd6 --- /dev/null +++ b/docs/implplan/SPRINT_20260310_003_FE_mission_control_live_action_sweep.md @@ -0,0 +1,64 @@ +# Sprint 20260310-003 - FE Mission Control Live Action Sweep + +## Topic & Scope +- Add a reusable authenticated Playwright sweep for the Mission Control board, alerts, and activity surfaces on the real `https://stella-ops.local` frontdoor. +- Verify the high-signal cross-product links from Mission Control resolve to the correct downstream page with the expected scoped state, instead of relying on broad route checks alone. +- Keep the work confined to live QA automation so the next scratch iterations can rerun Mission Control behavioral checks without manual clicking. +- Working directory: `src/Web/StellaOps.Web/scripts`. +- Allowed coordination edits: `docs/implplan/SPRINT_20260310_003_FE_mission_control_live_action_sweep.md`. +- Expected evidence: a runnable live Mission Control action sweep script plus JSON evidence under `src/Web/StellaOps.Web/output/playwright/`. + +## Dependencies & Concurrency +- Depends on the rebuilt stack being authenticated and reachable through `https://stella-ops.local`. +- Safe parallelism: do not touch unrelated Mission Control feature code or router/search implementation streams during this QA-only iteration. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md` + +## Delivery Tracker + +### FE-MISSION-LIVE-001 - Add Mission Control action sweep harness +Status: DONE +Dependency: none +Owners: QA, Developer (FE) +Task description: +- Create a focused live Playwright harness that authenticates through the real frontdoor and exercises the important Mission Control actions on the board, alerts, and activity pages. +- The harness must verify that the links resolve to the correct downstream paths and preserve the scoped stage/us-east context where applicable. + +Completion criteria: +- [x] A script exists under `src/Web/StellaOps.Web/scripts/` for live Mission Control action sweeps. +- [x] The script writes structured JSON output to `src/Web/StellaOps.Web/output/playwright/`. +- [x] The script exits non-zero when any Mission Control action or runtime contract fails. + +### FE-MISSION-LIVE-002 - Verify Mission Control board, alerts, and activity actions +Status: DONE +Dependency: FE-MISSION-LIVE-001 +Owners: QA +Task description: +- Execute the Mission Control action sweep against the rebuilt stack and verify the primary board summary links, regional stage links, alert drilldowns, and activity drilldowns. +- Distinguish product defects from harness selection mistakes before escalating any result into implementation work. + +Completion criteria: +- [x] The board links for releases, approvals, security, data integrity, topology, and scoped stage drilldowns are verified live. +- [x] The alerts and activity drilldowns are verified live. +- [x] The final live run completes with zero failed actions and zero runtime issues. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created for the next live behavioral pass after the canonical frontdoor sweep reached 111/111 passing routes and the policy/release/search slices were already rechecked. | Developer | +| 2026-03-10 | Added `scripts/live-mission-control-action-sweep.mjs` to exercise Mission Control board summary links, scoped stage environment links, alert drilldowns, and activity drilldowns through the authenticated frontdoor. | Developer | +| 2026-03-10 | Initial run surfaced a harness defect: the stage findings check selected the first `Findings` link (`dev`) rather than the intended `stage` row, and the auth helper emitted a harmless `about:blank` sessionStorage page error. Tightened the selector and filtered the known false-positive runtime noise. | Developer | +| 2026-03-10 | Reran the live Mission Control sweep successfully. Board, alerts, and activity actions now verify cleanly with `failedActionCount=0` and `runtimeIssueCount=0`. | Developer | + +## Decisions & Risks +- Decision: treat Mission Control as a first-class action surface with its own live sweep, because it fans out into releases, security, evidence, topology, and trust workflows and can hide scoped-link regressions that route-level sweeps miss. +- Decision: filter the auth-helper `about:blank` sessionStorage page error in this harness because it is not a user-visible runtime failure and would otherwise pollute live action evidence. +- Risk: the Mission Control sweep still covers representative actions rather than every repeated row/link permutation on the board. +- Mitigation: keep the harness reusable and extend it in later iterations as additional board actions or state-specific flows are promoted into the live backlog. + +## Next Checkpoints +- Commit the Mission Control live sweep as a standalone QA iteration. +- Continue expanding live action coverage into the next high-density page family on the rebuilt stack. 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 new file mode 100644 index 000000000..035964d4d --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-mission-control-action-sweep.mjs @@ -0,0 +1,371 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outputDir = path.join(webRoot, 'output', 'playwright'); +const outputPath = path.join(outputDir, 'live-mission-control-action-sweep.json'); +const authStatePath = path.join(outputDir, 'live-mission-control-action-sweep.state.json'); +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; + +function isStaticAsset(url) { + return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url); +} + +function createRuntime() { + return { + consoleErrors: [], + pageErrors: [], + requestFailures: [], + responseErrors: [], + }; +} + +function attachRuntimeObservers(page, runtime) { + page.on('console', (message) => { + if (message.type() === 'error') { + runtime.consoleErrors.push({ + page: page.url(), + text: message.text(), + }); + } + }); + + page.on('pageerror', (error) => { + if (page.url() === 'about:blank' && String(error).includes('sessionStorage')) { + return; + } + + runtime.pageErrors.push({ + page: page.url(), + text: error instanceof Error ? error.message : String(error), + }); + }); + + page.on('requestfailed', (request) => { + if (isStaticAsset(request.url())) { + return; + } + + runtime.requestFailures.push({ + page: page.url(), + method: request.method(), + url: request.url(), + error: request.failure()?.errorText ?? 'unknown', + }); + }); + + page.on('response', (response) => { + if (isStaticAsset(response.url())) { + return; + } + + if (response.status() >= 400) { + runtime.responseErrors.push({ + page: page.url(), + method: response.request().method(), + status: response.status(), + url: response.url(), + }); + } + }); +} + +async function settle(page) { + await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); + await page.waitForTimeout(1_000); +} + +async function headingText(page) { + const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title'); + const count = await headings.count(); + for (let index = 0; index < Math.min(count, 4); index += 1) { + const text = (await headings.nth(index).innerText().catch(() => '')).trim(); + if (text) { + return text; + } + } + + return ''; +} + +async function captureSnapshot(page, label) { + const alerts = await page + .locator('[role="alert"], .mat-mdc-snack-bar-container, .toast, .notification, .error-banner') + .evaluateAll((nodes) => + nodes + .map((node) => (node.textContent || '').trim().replace(/\s+/g, ' ')) + .filter(Boolean) + .slice(0, 5), + ) + .catch(() => []); + + return { + label, + url: page.url(), + title: await page.title(), + heading: await headingText(page), + alerts, + }; +} + +async function persistSummary(summary) { + summary.lastUpdatedAtUtc = new Date().toISOString(); + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); +} + +async function navigate(page, route) { + const separator = route.includes('?') ? '&' : '?'; + await page.goto(`https://stella-ops.local${route}${separator}${scopeQuery}`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + 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; + } + } + } + + 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; + } + } + + return null; +} + +async function clickExpectedLink(page, route, options) { + await navigate(page, route); + const locator = await resolveLink(page, options); + if (!locator) { + return { + action: options.action, + ok: false, + reason: 'missing-link', + snapshot: await captureSnapshot(page, `missing:${options.action}`), + }; + } + + await locator.click({ timeout: 10_000 }); + await settle(page); + const currentUrl = new URL(page.url()); + const expectedPath = options.expectedPath; + const searchParams = currentUrl.searchParams; + + let ok = currentUrl.pathname === expectedPath; + if (ok && options.expectQuery) { + for (const [key, value] of Object.entries(options.expectQuery)) { + if (searchParams.get(key) !== value) { + ok = false; + break; + } + } + } + + return { + action: options.action, + ok, + finalUrl: page.url(), + snapshot: await captureSnapshot(page, `after:${options.action}`), + }; +} + +async function runAction(page, route, options) { + const startedAtUtc = new Date().toISOString(); + const startedAt = Date.now(); + process.stdout.write(`[live-mission-control-action-sweep] START ${route} -> ${options.action}\n`); + + try { + const result = await Promise.race([ + clickExpectedLink(page, route, options), + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Timed out after ${STEP_TIMEOUT_MS}ms.`)), STEP_TIMEOUT_MS); + }), + ]); + + const completed = { + ...result, + startedAtUtc, + durationMs: Date.now() - startedAt, + }; + process.stdout.write( + `[live-mission-control-action-sweep] DONE ${route} -> ${options.action} ok=${completed.ok} durationMs=${completed.durationMs}\n`, + ); + return completed; + } catch (error) { + const failed = { + action: options.action, + ok: false, + reason: 'exception', + error: error instanceof Error ? error.message : String(error), + startedAtUtc, + durationMs: Date.now() - startedAt, + snapshot: await captureSnapshot(page, `failure:${options.action}`), + }; + process.stdout.write( + `[live-mission-control-action-sweep] FAIL ${route} -> ${options.action} error=${failed.error} durationMs=${failed.durationMs}\n`, + ); + return failed; + } +} + +async function main() { + await mkdir(outputDir, { recursive: true }); + + const authReport = await authenticateFrontdoor({ + statePath: authStatePath, + reportPath: authReportPath, + }); + + const browser = await chromium.launch({ + headless: true, + args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'], + }); + + const context = await createAuthenticatedContext(browser, authReport, { + statePath: authStatePath, + }); + const runtime = createRuntime(); + context.on('page', (page) => attachRuntimeObservers(page, runtime)); + + const page = await context.newPage(); + attachRuntimeObservers(page, runtime); + + const summary = { + generatedAtUtc: new Date().toISOString(), + results: [], + runtime, + }; + + const actionGroups = [ + { + route: '/mission-control/board', + actions: [ + { action: 'link:View all', name: 'View all', expectedPath: '/releases/runs' }, + { action: 'link:Review', name: 'Review', expectedPath: '/releases/approvals' }, + { action: 'link:Risk detail', name: 'Risk detail', expectedPath: '/security' }, + { action: 'link:Ops detail', name: 'Ops detail', expectedPath: '/ops/operations/data-integrity' }, + { action: 'link:All environments', name: 'All environments', expectedPath: '/setup/topology/environments' }, + { + action: 'link:Stage detail', + name: 'Detail', + hrefIncludes: '/setup/topology/environments/stage/posture', + expectedPath: '/setup/topology/environments/stage/posture', + expectQuery: { environment: 'stage', region: 'us-east' }, + }, + { + action: 'link:Stage findings', + name: 'Findings', + hrefIncludes: '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', + expectedPath: '/setup/topology/environments/stage/posture', + expectQuery: { environment: 'stage', region: 'us-east' }, + }, + ], + }, + { + route: '/mission-control/alerts', + actions: [ + { + action: 'link:Approvals blocked', + name: '3 approvals blocked by policy gate evidence freshness', + expectedPath: '/releases/approvals', + }, + { + action: 'link:Watchlist alert', + name: 'Identity watchlist alert requires signer review', + expectedPath: '/setup/trust-signing/watchlist/alerts', + expectQuery: { alertId: 'alert-001', tab: 'alerts', scope: 'tenant' }, + }, + { + action: 'link:Waivers expiring', + name: '2 waivers expiring within 24h', + expectedPath: '/security/disposition', + }, + { + action: 'link:Feed freshness degraded', + name: 'Feed freshness degraded for advisory ingest', + expectedPath: '/ops/operations/data-integrity', + }, + ], + }, + { + route: '/mission-control/activity', + actions: [ + { action: 'link:Open Runs', name: 'Open Runs', expectedPath: '/releases/runs' }, + { action: 'link:Open Capsules', name: 'Open Capsules', expectedPath: '/evidence/capsules' }, + { action: 'link:Open Audit Log', name: 'Open Audit Log', expectedPath: '/evidence/audit-log' }, + ], + }, + ]; + + for (const group of actionGroups) { + const actions = []; + for (const action of group.actions) { + actions.push(await runAction(page, group.route, action)); + } + + summary.results.push({ + route: group.route, + actions, + }); + await persistSummary(summary); + } + + await context.close(); + await browser.close(); + + const failedActionCount = summary.results + .flatMap((entry) => entry.actions) + .filter((entry) => !entry.ok).length; + const runtimeIssueCount = runtime.consoleErrors.length + + runtime.pageErrors.length + + runtime.requestFailures.length + + runtime.responseErrors.length; + + summary.failedActionCount = failedActionCount; + summary.runtimeIssueCount = runtimeIssueCount; + await persistSummary(summary); + + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + + if (failedActionCount > 0 || runtimeIssueCount > 0) { + process.exit(1); + } +} + +main().catch((error) => { + process.stderr.write(`[live-mission-control-action-sweep] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +});