Add live mission control action sweep

This commit is contained in:
master
2026-03-10 06:35:05 +02:00
parent ff4cd7e999
commit b9aa1dbe24
2 changed files with 435 additions and 0 deletions

View File

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

View File

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