diff --git a/docs/INSTALL_GUIDE.md b/docs/INSTALL_GUIDE.md index 14b4089e4..b80f7f32e 100755 --- a/docs/INSTALL_GUIDE.md +++ b/docs/INSTALL_GUIDE.md @@ -56,7 +56,7 @@ The scripts will: 4. Start infrastructure and wait for healthy containers 5. Create or reuse the external frontdoor Docker network from `.env` (`FRONTDOOR_NETWORK`, default `stellaops_frontdoor`) 6. Stop repo-local host-run Stella services that would lock build outputs, then build repo-owned .NET solutions and publish backend services locally into small Docker contexts before building hardened runtime images (vendored or generated trees such as `node_modules`, `dist`, `coverage`, and `output` are excluded) -7. Launch the full platform with health checks, perform one bounded restart pass for services that stay unhealthy after first boot, and wait for the first-user frontdoor bootstrap path (`/welcome`, `/envsettings.json`, OIDC discovery, `/connect/authorize`) before reporting success +7. Launch the full platform with health checks, perform one bounded restart pass for services that stay unhealthy after first boot, wait for the first-user frontdoor bootstrap path (`/welcome`, `/envsettings.json`, OIDC discovery, `/connect/authorize`), then complete an authenticated convergence gate that proves topology inventory, notifications administration overrides, and promotion bootstrap flows load cleanly before reporting success Open **https://stella-ops.local** when setup completes. diff --git a/docs/dev/DEV_ENVIRONMENT_SETUP.md b/docs/dev/DEV_ENVIRONMENT_SETUP.md index 7037bfc84..4a1580671 100644 --- a/docs/dev/DEV_ENVIRONMENT_SETUP.md +++ b/docs/dev/DEV_ENVIRONMENT_SETUP.md @@ -29,7 +29,7 @@ Setup scripts validate prerequisites, build solutions and Docker images, and lau ./scripts/setup.sh --images-only # only build Docker images ``` -The scripts will check for required tools (dotnet 10.x, node 20+, npm 10+, docker, git), warn about missing hosts file entries, copy `.env` from the example if needed, and stop repo-local host-run Stella services before the solution build so scratch bootstraps do not fail on locked `bin/Debug` outputs. Solution discovery is limited to repo-owned sources and skips generated trees such as `dist`, `coverage`, and `output`, so copied docs samples do not break scratch setup. A full setup now also performs one bounded restart pass for services that stay unhealthy after the first compose boot, then waits for the first-user frontdoor bootstrap path: `/welcome`, `/envsettings.json`, OIDC discovery, and a PKCE-style `/connect/authorize` request must all be live before the script prints success. See the manual steps below for details on each stage. +The scripts will check for required tools (dotnet 10.x, node 20+, npm 10+, docker, git), warn about missing hosts file entries, copy `.env` from the example if needed, and stop repo-local host-run Stella services before the solution build so scratch bootstraps do not fail on locked `bin/Debug` outputs. Solution discovery is limited to repo-owned sources and skips generated trees such as `dist`, `coverage`, and `output`, so copied docs samples do not break scratch setup. A full setup now also performs one bounded restart pass for services that stay unhealthy after the first compose boot, waits for the first-user frontdoor bootstrap path (`/welcome`, `/envsettings.json`, OIDC discovery, `/connect/authorize`), and then runs an authenticated readiness probe that proves the topology inventory, notifications administration overrides, and promotion bootstrap routes load cleanly before the script prints success. See the manual steps below for details on each stage. On Windows and Linux, the backend image builder now publishes each selected .NET service locally and builds the hardened runtime image from a small temporary context. That avoids repeatedly streaming the whole monorepo into Docker during scratch setup. diff --git a/docs/implplan/SPRINT_20260312_004_Platform_scratch_iteration_006_full_route_action_audit.md b/docs/implplan/SPRINT_20260312_004_Platform_scratch_iteration_006_full_route_action_audit.md new file mode 100644 index 000000000..f7ba7b440 --- /dev/null +++ b/docs/implplan/SPRINT_20260312_004_Platform_scratch_iteration_006_full_route_action_audit.md @@ -0,0 +1,78 @@ +# Sprint 20260312_004 - Platform Scratch Iteration 006 Full Route Action Audit + +## Topic & Scope +- Wipe Stella-owned runtime state again and rerun the documented setup path from zero state. +- Re-enter the application as a first-time user after bootstrap and rerun the full route, page, and page-action audit with Playwright. +- Group any newly exposed defects before fixing so the next commit closes a full iteration rather than a single page slice. +- Working directory: `.`. +- Expected evidence: wipe proof, setup convergence proof, fresh Playwright route/action evidence, grouped defect list, fixes, and retest results. + +## Dependencies & Concurrency +- Depends on local commit `9c3d1f8d4` as the clean baseline for the next scratch cycle. +- Safe parallelism: none during wipe/setup because the environment reset is global to the machine. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/INSTALL_GUIDE.md` +- `docs/dev/DEV_ENVIRONMENT_SETUP.md` +- `docs/qa/feature-checks/FLOW.md` + +## Delivery Tracker + +### PLATFORM-SCRATCH-ITER6-001 - Rebuild from zero Stella runtime state +Status: DONE +Dependency: none +Owners: QA, 3rd line support +Task description: +- Remove Stella-only containers, images, volumes, and the frontdoor network, then rerun the documented setup entrypoint from zero Stella state. + +Completion criteria: +- [x] Stella-only Docker state is removed. +- [x] `scripts/setup.ps1` is rerun from zero state. +- [x] The first setup outcome is captured before UI verification starts. + +### PLATFORM-SCRATCH-ITER6-002 - Re-run the first-user full route/page/action audit +Status: DONE +Dependency: PLATFORM-SCRATCH-ITER6-001 +Owners: QA +Task description: +- After scratch setup converges, rerun the canonical route sweep plus the full action audit suite and enumerate every newly exposed issue before repair work begins. + +Completion criteria: +- [x] Fresh route sweep evidence is captured on the rebuilt stack. +- [x] Fresh action sweep evidence is captured across the current aggregate suite. +- [x] Newly exposed defects are grouped before any fix commit is prepared. + +### PLATFORM-SCRATCH-ITER6-003 - Repair the grouped defects exposed by the fresh audit +Status: DONE +Dependency: PLATFORM-SCRATCH-ITER6-002 +Owners: 3rd line support, Architect, Developer +Task description: +- Diagnose the grouped failures exposed by the fresh audit, choose the clean product/architecture-conformant fix, implement it, and rerun the affected verification slices plus the aggregate audit before committing. + +Completion criteria: +- [x] Root causes are recorded for the grouped failures. +- [x] Fixes land with focused regression coverage where practical. +- [x] The rebuilt stack is retested before the iteration commit. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-12 | Sprint created for the next scratch iteration after local commit `9c3d1f8d4` closed iteration 005 cleanly. | QA | +| 2026-03-12 | Removed Stella-only containers, `stellaops/*:dev` images, Stella compose volumes, and the `stellaops` / `stellaops_frontdoor` networks to return the machine to zero Stella runtime state for iteration 006. | QA / 3rd line support | +| 2026-03-12 | Started `scripts/setup.ps1` from the zero-state baseline; prerequisite and hosts checks passed, `.env` was already present, and the rerun entered the `36`-solution build matrix. | QA | +| 2026-03-12 | The zero-state setup rerun completed cleanly: `36/36` solution builds passed, the full image matrix rebuilt, platform services converged, the frontdoor bootstrap checks returned `HTTP 200`, and `61/61` containers reached healthy state on `https://stella-ops.local`. | QA / 3rd line support | +| 2026-03-12 | Began the fresh post-reset browser verification on the rebuilt stack. The standalone canonical route sweep finished cleanly at `111/111`; `/setup/topology/runtime-drift` required an internal sweep recheck but still ended as a pass with no failed routes, and the aggregate `live-full-core-audit.mjs` pass is now running against the same deployment to gather the full post-reset page/action defect set before any fixes are considered. | QA | +| 2026-03-13 | The first aggregate pass grouped the fresh-stack defects into two buckets instead of page-by-page fixes: `/evidence/audit-log -> Export` was handing off to the nested child route instead of canonical `/evidence/exports`, and scratch setup readiness was declaring success before authenticated notifications administration had converged. The notifications recheck harness also lacked route-specific readiness waits and treated aborted navigations as failures, which produced the false-negative shell errors seen on `/setup/notifications/config/overrides`. | QA / 3rd line support / Architect | +| 2026-03-13 | Implemented the grouped repair: setup now waits for an authenticated topology + notifications admin + promotion convergence gate, the notifications/watchlist and release-promotion sweeps were hardened for cold-load readiness, and the audit-log header export button now hands off to canonical Export Center with a focused component spec. | Architect / Developer | +| 2026-03-13 | Focused verification passed (`1/1` audit-log spec, `npm run build`), the rebuilt web bundle was synced into `compose_console-dist`, authenticated readiness passed with topology/promotion/notifications all green, the targeted notifications and uncovered-surface sweeps passed cleanly, and the full aggregate audit closed cleanly at `20/20` suites with `0` failed, `0` retried, and `0` stabilized-after-retry suites. | QA / Developer | + +## Decisions & Risks +- Decision: each scratch iteration remains a full wipe -> setup -> route/action audit -> grouped remediation loop; if the audit comes back clean, that still counts as a completed iteration because the full loop was executed. +- Risk: scratch rebuilds remain expensive, so verification stays Playwright-first with focused test/build slices rather than indiscriminate full-solution test runs. +- Decision: first-user scratch setup is not considered complete until authenticated notifications administration converges alongside topology inventory and promotion bootstrap, because those pages are part of the initial operational setup surface. +- Decision: aggregate audit stability work belongs in the same iteration when the fresh-stack failures are caused by cold-load readiness gaps rather than distinct product behavior regressions. + +## Next Checkpoints +- Start iteration 007 from another Stella-only wipe and rerun the documented setup path from zero state. +- Repeat the full Playwright route/page/action audit on the next rebuilt stack before considering any new fixes. diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 32f55baf3..c06d8136a 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -696,6 +696,27 @@ function Test-FrontdoorBootstrap { return $true } +function Test-AuthenticatedFrontdoorReadiness { + Write-Step 'Waiting for authenticated frontdoor route readiness' + + Push-Location $Root + try { + & node 'src/Web/StellaOps.Web/scripts/live-frontdoor-authenticated-readiness.mjs' + if ($LASTEXITCODE -eq 0) { + Write-Ok 'Authenticated topology, notifications admin, and promotion flows are ready for first-user QA' + return $true + } + } + catch { + } + finally { + Pop-Location + } + + Write-Fail 'Authenticated frontdoor route readiness did not converge' + return $false +} + # ─── 8. Smoke test ───────────────────────────────────────────────────────── function Test-Smoke { @@ -755,6 +776,12 @@ function Test-Smoke { } else { $hasBlockingFailures = $true } + + if (Test-AuthenticatedFrontdoorReadiness) { + Write-Ok 'Authenticated frontdoor route readiness converged' + } else { + $hasBlockingFailures = $true + } } # Platform container health summary diff --git a/scripts/setup.sh b/scripts/setup.sh index b023dc84c..b178459cd 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -543,6 +543,18 @@ frontdoor_bootstrap_ready() { ok 'Frontdoor bootstrap path is ready for first-user sign-in' } +frontdoor_authenticated_ready() { + step 'Waiting for authenticated frontdoor route readiness' + + if (cd "$ROOT" && node ./src/Web/StellaOps.Web/scripts/live-frontdoor-authenticated-readiness.mjs); then + ok 'Authenticated topology, notifications admin, and promotion flows are ready for first-user QA' + return 0 + fi + + fail 'Authenticated frontdoor route readiness did not converge' + return 1 +} + # ─── 8. Smoke test ───────────────────────────────────────────────────────── smoke_test() { @@ -589,6 +601,10 @@ smoke_test() { if ! frontdoor_bootstrap_ready; then has_blocking_failures=true fi + + if ! frontdoor_authenticated_ready; then + has_blocking_failures=true + fi fi # Platform container health summary diff --git a/src/Web/StellaOps.Web/scripts/live-frontdoor-authenticated-readiness.mjs b/src/Web/StellaOps.Web/scripts/live-frontdoor-authenticated-readiness.mjs new file mode 100644 index 000000000..65007f944 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-frontdoor-authenticated-readiness.mjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const webRoot = path.resolve(__dirname, '..'); +const outputDirectory = path.join(webRoot, 'output', 'playwright'); +const statePath = path.join(outputDirectory, 'live-frontdoor-authenticated-readiness.state.json'); +const reportPath = path.join(outputDirectory, 'live-frontdoor-authenticated-readiness.auth.json'); +const resultPath = path.join(outputDirectory, 'live-frontdoor-authenticated-readiness.json'); + +const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; +const maxAttempts = Number.parseInt(process.env.STELLAOPS_READY_ATTEMPTS ?? '18', 10); +const retryDelayMs = Number.parseInt(process.env.STELLAOPS_READY_RETRY_DELAY_MS ?? '5000', 10); +const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; + +function isStaticAsset(url) { + return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url); +} + +function isAbortedNavigationFailure(errorText) { + return typeof errorText === 'string' && /aborted|net::err_abort/i.test(errorText); +} + +function createRuntime() { + return { + consoleErrors: [], + pageErrors: [], + requestFailures: [], + responseErrors: [], + }; +} + +async function waitForPageReady(page, route, titleFragment, markers, options = {}) { + const { + requireAllMarkers = false, + forbiddenMarkers = [], + } = options; + await page.goto(`${baseUrl}${route}`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + + await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); + await page.waitForTimeout(1_000); + + const loadingText = page.locator('text=/Loading /i').first(); + await loadingText.waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}); + + const expectedPath = new URL(`${baseUrl}${route}`).pathname; + let lastSnapshot = null; + for (let attempt = 0; attempt < 30; attempt += 1) { + lastSnapshot = await page.evaluate(({ expectedTitle, expectedMarkers, expectedForbiddenMarkers }) => { + const title = document.title.trim(); + const bodyText = document.body?.innerText?.replace(/\s+/g, ' ') ?? ''; + return { + path: window.location.pathname, + title, + matchedTitle: !expectedTitle || title.includes(expectedTitle), + matchedMarkers: expectedMarkers.filter((marker) => bodyText.includes(marker)), + forbiddenMarkers: expectedForbiddenMarkers.filter((marker) => bodyText.includes(marker)), + }; + }, { + expectedTitle: titleFragment, + expectedMarkers: markers, + expectedForbiddenMarkers: forbiddenMarkers, + }); + + if ( + lastSnapshot.path === expectedPath && + lastSnapshot.matchedTitle && + ( + requireAllMarkers + ? lastSnapshot.matchedMarkers.length === markers.length + : lastSnapshot.matchedMarkers.length > 0 + ) && + lastSnapshot.forbiddenMarkers.length === 0 + ) { + await page.waitForTimeout(500); + return; + } + + await page.waitForTimeout(1_000); + } + + throw new Error( + `Route ${route} did not become ready. ` + + `path=${lastSnapshot?.path ?? ''} ` + + `title=${lastSnapshot?.title ?? ''} ` + + `matchedMarkers=${(lastSnapshot?.matchedMarkers ?? []).join(',') || ''} ` + + `forbiddenMarkers=${(lastSnapshot?.forbiddenMarkers ?? []).join(',') || ''}`, + ); +} + +async function waitForPromotionPreview(page) { + const previewSignals = [ + page.locator('.preview-summary').first(), + page.getByText('All gates passed').first(), + page.getByText('One or more gates are not passing').first(), + page.getByText('Required approvers:').first(), + ]; + + await Promise.any( + previewSignals.map((locator) => locator.waitFor({ state: 'visible', timeout: 30_000 })), + ); +} + +async function warmTopology(page) { + await waitForPageReady(page, `/setup/topology/environments?${scopeQuery}`, 'Environments', [ + 'Environment Inventory', + 'Region-first', + 'SEARCH', + ]); +} + +async function warmPromotion(page) { + await waitForPageReady(page, `/releases/promotions/create?${scopeQuery}`, 'Create Promotion', [ + 'Create Promotion', + 'Load Target Environments', + ]); + + await page.getByLabel('Release/Bundle identity').fill('rel-001'); + await page.getByRole('button', { name: 'Load Target Environments' }).click({ timeout: 10_000 }); + + await page.waitForFunction(() => { + const select = document.querySelector('#target-env'); + if (!(select instanceof HTMLSelectElement)) { + return false; + } + + return [...select.options].some((option) => option.value === 'env-staging'); + }, { timeout: 30_000 }); + + await page.locator('#target-env').selectOption('env-staging'); + + const previewButton = page.getByRole('button', { name: 'Refresh Gate Preview' }); + await previewButton.waitFor({ state: 'visible', timeout: 30_000 }); + await previewButton.click({ timeout: 10_000 }); + await waitForPromotionPreview(page); +} + +async function warmNotifications(page) { + await waitForPageReady( + page, + `/setup/notifications/config/overrides?${scopeQuery}`, + 'Notifications', + [ + 'Notification Administration', + 'Operator Overrides', + ], + { + requireAllMarkers: true, + forbiddenMarkers: [ + 'Failed to load overrides', + ], + }, + ); +} + +async function main() { + mkdirSync(outputDirectory, { recursive: true }); + + const authReport = await authenticateFrontdoor({ + statePath, + reportPath, + headless: true, + }); + + const browser = await chromium.launch({ + headless: true, + args: ['--disable-dev-shm-usage'], + }); + + const context = await createAuthenticatedContext(browser, authReport, { statePath }); + const page = await context.newPage(); + + let runtime = createRuntime(); + + 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) => { + const errorText = request.failure()?.errorText ?? 'unknown'; + if (isStaticAsset(request.url()) || isAbortedNavigationFailure(errorText)) { + return; + } + + runtime.requestFailures.push({ + page: page.url(), + method: request.method(), + url: request.url(), + error: errorText, + }); + }); + + page.on('response', (response) => { + if (isStaticAsset(response.url()) || response.status() < 400) { + return; + } + + runtime.responseErrors.push({ + page: page.url(), + method: response.request().method(), + status: response.status(), + url: response.url(), + }); + }); + + const summary = { + checkedAtUtc: new Date().toISOString(), + baseUrl, + maxAttempts, + retryDelayMs, + attempts: [], + finalStatus: 'failed', + }; + + try { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + runtime = createRuntime(); + + const attemptResult = { + attempt, + startedAtUtc: new Date().toISOString(), + topologyReady: false, + promotionReady: false, + notificationsReady: false, + runtime, + ok: false, + }; + + try { + await warmTopology(page); + attemptResult.topologyReady = true; + + await warmPromotion(page); + attemptResult.promotionReady = true; + + await warmNotifications(page); + attemptResult.notificationsReady = true; + } catch (error) { + attemptResult.error = error instanceof Error ? error.message : String(error); + } + + attemptResult.ok = + attemptResult.topologyReady && + attemptResult.promotionReady && + attemptResult.notificationsReady && + runtime.consoleErrors.length === 0 && + runtime.pageErrors.length === 0 && + runtime.requestFailures.length === 0 && + runtime.responseErrors.length === 0; + + summary.attempts.push(attemptResult); + summary.finalStatus = attemptResult.ok ? 'ready' : 'retrying'; + writeFileSync(resultPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + + if (attemptResult.ok) { + summary.finalStatus = 'ready'; + break; + } + + if (attempt < maxAttempts) { + await page.goto('about:blank').catch(() => {}); + await page.waitForTimeout(retryDelayMs); + } + } + } finally { + writeFileSync(resultPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + await context.close(); + await browser.close(); + } + + const latestAttempt = summary.attempts.at(-1); + if (!latestAttempt?.ok) { + summary.finalStatus = 'failed'; + writeFileSync(resultPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + const failureMessage = latestAttempt?.error + ?? [ + ...latestAttempt.runtime.responseErrors.map((entry) => `${entry.method} ${entry.url} -> ${entry.status}`), + ...latestAttempt.runtime.requestFailures.map((entry) => `${entry.method} ${entry.url} failed: ${entry.error}`), + ...latestAttempt.runtime.consoleErrors.map((entry) => entry.text), + ...latestAttempt.runtime.pageErrors.map((entry) => entry.text), + ].join('; '); + throw new Error(failureMessage || 'Authenticated frontdoor readiness did not converge.'); + } + + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`[live-frontdoor-authenticated-readiness] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs b/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs index 894c1bcaa..fc031fc30 100644 --- a/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs +++ b/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs @@ -21,6 +21,30 @@ async function settle(page) { await page.waitForTimeout(1_500); } +async function waitForNotificationsPanel(page, timeoutMs = 12_000) { + await page.waitForFunction(() => { + if (document.querySelector('.notify-panel')) { + return true; + } + + return Array.from(document.querySelectorAll('h1, h2, [data-testid="page-title"], .page-title')) + .map((node) => (node.textContent || '').trim().toLowerCase()) + .some((text) => text.includes('notify control plane') || text === 'channels' || text === 'rules'); + }, { timeout: timeoutMs }).catch(() => {}); + await page.waitForTimeout(500); +} + +async function waitForAdminNotifications(page, timeoutMs = 12_000) { + await page.waitForFunction(() => { + const bodyText = document.body?.innerText?.replace(/\s+/g, ' ') ?? ''; + const hasHeading = Array.from(document.querySelectorAll('h1, h2, [data-testid="page-title"], .page-title')) + .map((node) => (node.textContent || '').trim()) + .some((text) => text === 'Notification Administration'); + return hasHeading && bodyText.includes('Notification Administration'); + }, { timeout: timeoutMs }).catch(() => {}); + await page.waitForTimeout(500); +} + async function headingText(page) { const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title'); const count = await headings.count(); @@ -59,6 +83,12 @@ async function navigate(page, route) { const url = `https://stella-ops.local${route}${separator}${scopeQuery}`; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }); await settle(page); + if (route === '/ops/operations/notifications') { + await waitForNotificationsPanel(page); + } + if (route.startsWith('/setup/notifications')) { + await waitForAdminNotifications(page); + } return url; } @@ -171,11 +201,16 @@ async function main() { return; } + const errorText = request.failure()?.errorText ?? 'unknown'; + if (errorText === 'net::ERR_ABORTED') { + return; + } + runtime.requestFailures.push({ page: page.url(), method: request.method(), url, - error: request.failure()?.errorText ?? 'unknown', + error: errorText, }); }); page.on('response', (response) => { diff --git a/src/Web/StellaOps.Web/scripts/live-release-promotion-submit-check.mjs b/src/Web/StellaOps.Web/scripts/live-release-promotion-submit-check.mjs index c03a8db78..c0d052292 100644 --- a/src/Web/StellaOps.Web/scripts/live-release-promotion-submit-check.mjs +++ b/src/Web/StellaOps.Web/scripts/live-release-promotion-submit-check.mjs @@ -53,7 +53,27 @@ function collectScopeIssues(label, targetUrl) { } async function clickNext(page) { - await page.getByRole('button', { name: 'Next ->' }).click(); + await clickStableButton(page, 'Next ->'); +} + +async function waitForSection(page, headingText) { + await page.getByRole('heading', { name: headingText, exact: true }).waitFor({ + state: 'visible', + timeout: 20_000, + }); +} + +async function waitForPromotionPreview(page) { + const previewSignals = [ + page.locator('.preview-summary').first(), + page.getByText('All gates passed').first(), + page.getByText('One or more gates are not passing').first(), + page.getByText('Required approvers:').first(), + ]; + + await Promise.any( + previewSignals.map((locator) => locator.waitFor({ state: 'visible', timeout: 30_000 })), + ); } async function clickStableButton(page, name) { @@ -176,13 +196,25 @@ async function main() { timeout: 30_000, }); + await waitForSection(page, 'Select Bundle Version Identity'); await page.getByLabel('Release/Bundle identity').fill('rel-001'); await page.getByRole('button', { name: 'Load Target Environments' }).click(); + await waitForSection(page, 'Select Region and Environment Path'); + await page.locator('#target-env').waitFor({ state: 'visible', timeout: 20_000 }); + await page.waitForFunction(() => { + const select = document.querySelector('#target-env'); + return select instanceof HTMLSelectElement + && [...select.options].some((option) => option.value === 'env-staging'); + }, { timeout: 30_000 }); await page.locator('#target-env').selectOption('env-staging'); - await page.getByRole('button', { name: 'Refresh Gate Preview' }).click(); + await waitForSection(page, 'Gate Preview'); + await clickStableButton(page, 'Refresh Gate Preview'); + await waitForPromotionPreview(page); await clickNext(page); + await waitForSection(page, 'Approval Context'); await page.getByLabel('Justification').fill('Release approval path validated end to end.'); await clickNext(page); + await waitForSection(page, 'Launch Promotion'); const submitResponsePromise = page.waitForResponse( (response) => diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts new file mode 100644 index 000000000..8e5e9fdf8 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts @@ -0,0 +1,56 @@ +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { AuditLogClient } from '../../core/api/audit-log.client'; +import { AuditLogDashboardComponent } from './audit-log-dashboard.component'; + +describe('AuditLogDashboardComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [AuditLogDashboardComponent], + providers: [ + provideRouter([]), + { + provide: AuditLogClient, + useValue: { + getStatsSummary: () => of({ + totalEvents: 10, + byModule: { + policy: 5, + authority: 3, + }, + }), + getEvents: () => of({ + items: [ + { + id: 'evt-1', + timestamp: '2026-03-12T00:00:00Z', + module: 'policy', + action: 'create', + actor: { name: 'qa@example.com' }, + resource: { type: 'release', id: 'rel-1', name: 'rel-1' }, + }, + ], + }), + getAnomalyAlerts: () => of([]), + acknowledgeAnomaly: () => of(void 0), + }, + }, + ], + }); + }); + + it('routes the header export action to the canonical export center', () => { + const fixture = TestBed.createComponent(AuditLogDashboardComponent); + fixture.detectChanges(); + fixture.detectChanges(); + + const exportLink = fixture.debugElement + .query(By.css('.header-actions .btn-secondary')) + .nativeElement as HTMLAnchorElement; + + expect(exportLink.getAttribute('href')).toBe('/evidence/exports'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts index 48d61ca19..270a14530 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts @@ -16,7 +16,7 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.

Cross-module audit trail visibility for compliance and governance

diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index 6a9586f51..5fb6cd456 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -14,6 +14,7 @@ "src/app/features/change-trace/change-trace-viewer.component.spec.ts", "src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts", "src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts", + "src/app/features/audit-log/audit-log-dashboard.component.spec.ts", "src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts", "src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts", "src/app/features/deploy-diff/services/deploy-diff.service.spec.ts",