diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index d788f47a1..ee9b533af 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -1098,7 +1098,7 @@ services: # --- Slot 17: Orchestrator ------------------------------------------------- jobengine: - image: stellaops/jobengine:dev + image: stellaops/orchestrator:dev container_name: stellaops-jobengine restart: unless-stopped depends_on: *depends-infra @@ -1135,7 +1135,7 @@ services: labels: *release-labels jobengine-worker: - image: stellaops/jobengine-worker:dev + image: stellaops/orchestrator-worker:dev container_name: stellaops-jobengine-worker restart: unless-stopped depends_on: *depends-infra diff --git a/devops/docker/build-all.ps1 b/devops/docker/build-all.ps1 index fc00b4f72..82156b830 100644 --- a/devops/docker/build-all.ps1 +++ b/devops/docker/build-all.ps1 @@ -16,14 +16,30 @@ #> [CmdletBinding()] param( - [string]$Registry = $env:REGISTRY ?? 'stellaops', - [string]$TagSuffix = $env:TAG_SUFFIX ?? 'dev', - [string]$SdkImage = $env:SDK_IMAGE ?? 'mcr.microsoft.com/dotnet/sdk:10.0-noble', - [string]$RuntimeImage = $env:RUNTIME_IMAGE ?? 'mcr.microsoft.com/dotnet/aspnet:10.0-noble' + [string]$Registry, + [string]$TagSuffix, + [string]$SdkImage, + [string]$RuntimeImage ) $ErrorActionPreference = 'Continue' +if ([string]::IsNullOrWhiteSpace($Registry)) { + $Registry = if ([string]::IsNullOrWhiteSpace($env:REGISTRY)) { 'stellaops' } else { $env:REGISTRY } +} + +if ([string]::IsNullOrWhiteSpace($TagSuffix)) { + $TagSuffix = if ([string]::IsNullOrWhiteSpace($env:TAG_SUFFIX)) { 'dev' } else { $env:TAG_SUFFIX } +} + +if ([string]::IsNullOrWhiteSpace($SdkImage)) { + $SdkImage = if ([string]::IsNullOrWhiteSpace($env:SDK_IMAGE)) { 'mcr.microsoft.com/dotnet/sdk:10.0-noble' } else { $env:SDK_IMAGE } +} + +if ([string]::IsNullOrWhiteSpace($RuntimeImage)) { + $RuntimeImage = if ([string]::IsNullOrWhiteSpace($env:RUNTIME_IMAGE)) { 'mcr.microsoft.com/dotnet/aspnet:10.0-noble' } else { $env:RUNTIME_IMAGE } +} + $Root = git rev-parse --show-toplevel 2>$null if (-not $Root) { Write-Error 'Not inside a git repository.' diff --git a/docs/INSTALL_GUIDE.md b/docs/INSTALL_GUIDE.md index df64d1c1c..e1aa07895 100755 --- a/docs/INSTALL_GUIDE.md +++ b/docs/INSTALL_GUIDE.md @@ -54,8 +54,9 @@ The scripts will: 2. Offer to install hosts file entries automatically 3. Copy `env/stellaops.env.example` to `.env` if needed (works out of the box) 4. Start infrastructure and wait for healthy containers -5. Build .NET solutions and Docker images -6. Launch the full platform with health checks +5. Create or reuse the external frontdoor Docker network from `.env` (`FRONTDOOR_NETWORK`, default `stellaops_frontdoor`) +6. Build .NET solutions and Docker images +7. Launch the full platform with health checks Open **https://stella-ops.local** when setup completes. @@ -89,6 +90,13 @@ docker compose -f docker-compose.dev.yml ps # verify all healthy ### 4. Start the full platform +Create or reuse the external frontdoor network first: + +```bash +docker network inspect "${FRONTDOOR_NETWORK:-stellaops_frontdoor}" >/dev/null 2>&1 || \ + docker network create "${FRONTDOOR_NETWORK:-stellaops_frontdoor}" +``` + ```bash docker compose -f docker-compose.stella-ops.yml up -d ``` diff --git a/docs/implplan/SPRINT_20260309_001_Platform_scratch_setup_bootstrap_restore.md b/docs/implplan/SPRINT_20260309_001_Platform_scratch_setup_bootstrap_restore.md new file mode 100644 index 000000000..47f94d852 --- /dev/null +++ b/docs/implplan/SPRINT_20260309_001_Platform_scratch_setup_bootstrap_restore.md @@ -0,0 +1,72 @@ +# Sprint 20260309-001 - Platform Scratch Setup Bootstrap Restore + +## Topic & Scope +- Restore the documented Windows scratch-setup path so `scripts/setup.ps1` can rebuild Docker images and start Stella Ops from an empty Docker state. +- Treat the setup script itself as production surface: a clean repo plus docs must be enough to bootstrap the platform without manual script surgery. +- Re-run the clean setup path after the fix, then continue into Playwright-backed live verification on the rebuilt stack. +- Working directory: `devops/docker`. +- Allowed coordination edits: `scripts/setup.ps1`, `scripts/setup.sh`, `devops/compose/docker-compose.stella-ops.yml`, `docs/quickstart.md`, `docs/INSTALL_GUIDE.md`, `devops/README.md`, `devops/compose/README.md`, `src/Web/StellaOps.Web/scripts/chrome-path.js`, `src/Web/StellaOps.Web/scripts/verify-chromium.js`, `docs/implplan/SPRINT_20260309_001_Platform_scratch_setup_bootstrap_restore.md`. +- Expected evidence: clean setup invocation output, successful image-builder startup, rebuilt compose stack, and downstream Playwright verification artifacts. + +## Dependencies & Concurrency +- Depends on Docker Desktop, hosts entries, and `devops/compose/.env` already being present, which the documented setup preflight checks before build/start. +- Safe parallelism: avoid unrelated frontend search, settings, and revived-component work; keep changes limited to the bootstrap scripts/docs unless a new setup blocker proves otherwise. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/quickstart.md` +- `docs/INSTALL_GUIDE.md` +- `devops/README.md` +- `devops/compose/README.md` + +## Delivery Tracker + +### PLATFORM-SETUP-001 - Repair Windows image-builder bootstrap defaults +Status: DONE +Dependency: none +Owners: Developer, QA +Task description: +- Fix the documented Windows image-build entry point used by `scripts/setup.ps1` so it parses and runs in the repo's supported PowerShell setup flow. +- Keep the fix minimal and compatible with environment-variable overrides because the same script is the canonical Docker image build path for a clean local bootstrap. + +Completion criteria: +- [x] `devops/docker/build-all.ps1` parses without PowerShell errors. +- [x] `scripts/setup.ps1 -SkipBuild` advances past the image-builder entry point on a clean Docker state. +- [x] The fix preserves `REGISTRY`, `TAG_SUFFIX`, `SDK_IMAGE`, and `RUNTIME_IMAGE` overrides. + +### PLATFORM-SETUP-002 - Re-run clean platform bootstrap and continue QA +Status: DONE +Dependency: PLATFORM-SETUP-001 +Owners: QA, Developer +Task description: +- Re-run the documented scratch bootstrap from the repo scripts after the parser fix, then proceed into live Playwright verification on the rebuilt frontdoor. +- Record the next blocker found after the bootstrap repair instead of treating setup completion alone as success. + +Completion criteria: +- [x] The clean setup path is rerun from the repo script after the fix. +- [x] The stack is reachable through `https://stella-ops.local`. +- [x] The next live verification findings are captured for follow-on iterations. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-09 | Sprint created after a scratch Docker wipe exposed that the documented Windows setup path fails immediately in `devops/docker/build-all.ps1` before any images are built. | Developer | +| 2026-03-09 | Replaced invalid PowerShell null-coalescing defaults in `devops/docker/build-all.ps1` with compatibility-safe runtime fallback assignment, then re-ran `scripts/setup.ps1 -SkipBuild` and confirmed the clean bootstrap advanced into the 60-image rebuild matrix. | Developer | +| 2026-03-09 | Found a second setup-to-QA blocker: Playwright Chromium installed under `%LOCALAPPDATA%\\ms-playwright`, but `src/Web/StellaOps.Web/scripts/chrome-path.js` only searched `%HOME%\\.cache\\ms-playwright` and `chrome-win`. Expanded resolver coverage to standard Windows cache roots and `chrome-win64` layouts. | Developer | +| 2026-03-09 | Tightened the Chromium resolver to prefer the newest discovered Playwright revision, because the same helper is consumed by the Playwright configs and should not silently bind to an older cached browser when multiple revisions are installed. | Developer | +| 2026-03-09 | Scratch image build completed successfully (`60/60`), but compose startup failed immediately because `docker-compose.stella-ops.yml` still referenced legacy `stellaops/jobengine*` image names while the canonical build matrix emits `stellaops/orchestrator*`. Updated compose to consume the built image names while preserving the existing `jobengine` service identity and host aliases. | Developer | +| 2026-03-09 | The next clean-start blocker was the external `FRONTDOOR_NETWORK` contract: a full Docker wipe removed `stellaops_frontdoor`, but neither setup script recreated it before `docker compose -f docker-compose.stella-ops.yml up -d`. Wired network creation into both setup scripts and updated the install docs to document the same manual prerequisite. | Developer | +| 2026-03-09 | Re-ran `scripts/setup.ps1 -SkipBuild -SkipImages` after the setup fixes and confirmed the stack came up cleanly on `https://stella-ops.local`; live Playwright auth also succeeded, proving the scratch bootstrap now reaches real browser-verifiable UI state. | Developer | +| 2026-03-09 | Demo seeding still exposed module migration debt (`no migration resources to consolidate` across several modules plus a duplicate `Unknowns` migration name). I did not treat that as a setup pass condition because the live frontdoor remained operable, but it remains a follow-on platform quality gap. | Developer | + +## Decisions & Risks +- Decision: repair the documented setup path first instead of working around it with ad hoc manual builds, because scratch bootstrap is part of the product surface for this mission. +- Risk: additional clean-setup blockers may appear after the parser issue because the stack is being rebuilt from empty Docker state rather than from previously warmed images/volumes. +- Mitigation: keep rerunning the same documented path and treat each newly exposed blocker as iteration input until the full bootstrap succeeds. +- Decision: treat browser-binary discovery as part of the scratch-bootstrap contract because a clean rebuild is not complete until Playwright can attach to a browser for live verification. +- Decision: preserve the `jobengine` compose service name and `jobengine.stella-ops.local` alias for compatibility, but map it to the canonical `orchestrator` image names emitted by the Docker build matrix so scratch setup uses the images it just produced. +- Decision: the automated setup path now owns creation of the external frontdoor Docker network because that network is part of the documented default compose topology, and a scratch bootstrap should not depend on an undocumented pre-existing Docker artifact. + +## Next Checkpoints +- 2026-03-09: rerun `scripts/setup.ps1 -SkipBuild` after the parser fix. +- 2026-03-09: continue into frontdoor Playwright verification once the rebuilt stack is reachable. diff --git a/docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md b/docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md new file mode 100644 index 000000000..91ab7e4bc --- /dev/null +++ b/docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md @@ -0,0 +1,67 @@ +# Sprint 20260309-002 - FE Live Frontdoor Canonical Route Sweep + +## Topic & Scope +- Create a real authenticated Playwright harness for the canonical Stella Ops frontdoor routes so route regressions are detected against `https://stella-ops.local`, not just against stubbed e2e fixtures. +- Use the canonical route inventory already curated in the frontend sweep spec, then record route-level failures, console errors, request failures, and visible operator actions for follow-on deep page/action iterations. +- Keep this sprint focused on the reusable live sweep harness; route/action fixes discovered by the harness belong to later implementation iterations. +- Working directory: `src/Web/StellaOps.Web/scripts`. +- Allowed coordination edits: `src/Web/StellaOps.Web/tests/e2e/prealpha-canonical-full-sweep.spec.ts`, `src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs`, `src/Web/StellaOps.Web/scripts/live-frontdoor-canonical-route-sweep.mjs`, `src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs`, `src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs`, `docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md`. +- Expected evidence: a runnable live sweep script, authenticated JSON output under `src/Web/StellaOps.Web/output/playwright/`, and a recorded list of failing canonical routes once the rebuilt stack is reachable. + +## Dependencies & Concurrency +- Depends on the scratch bootstrap sprint completing enough of the stack for `https://stella-ops.local` and Authority auth to respond. +- Safe parallelism: keep edits in the web scripts area only; do not touch unrelated frontend feature code while other agents are landing search/component changes. + +## Documentation Prerequisites +- `AGENTS.md` +- `src/Web/StellaOps.Web/AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### FE-LIVE-SWEEP-001 - Add authenticated canonical route sweep harness +Status: DONE +Dependency: none +Owners: QA, Developer (FE) +Task description: +- Create a Playwright-backed live route harness that authenticates through the real frontdoor, navigates the canonical page inventory, and records route-level failures, visible problem banners, console/request failures, and visible actions. +- Reuse the existing live auth/session seeding pattern so the harness can run repeatedly across iterations without hand-driving the browser every time. + +Completion criteria: +- [x] A script exists under `src/Web/StellaOps.Web/scripts/` for authenticated live canonical route sweeps. +- [x] The script writes structured JSON output to `src/Web/StellaOps.Web/output/playwright/`. +- [x] The script exits non-zero when canonical routes fail the route-level acceptance checks. + +### FE-LIVE-SWEEP-002 - Run the harness on the rebuilt stack +Status: DONE +Dependency: FE-LIVE-SWEEP-001 +Owners: QA +Task description: +- Execute the live canonical route sweep against the rebuilt `stella-ops.local` stack once the scratch bootstrap finishes. +- Use its findings as the starting backlog for deeper per-page/per-action iterations. + +Completion criteria: +- [x] The harness has been run against the rebuilt stack. +- [x] The failing route list is captured as iteration evidence. +- [x] Follow-on implementation work uses the captured failures instead of ad hoc page selection. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-09 | Sprint created during the scratch bootstrap so the moment the stack becomes reachable there is a broad authenticated Playwright route harness ready to run against the live frontdoor. | Developer | +| 2026-03-09 | Added `scripts/live-frontdoor-canonical-route-sweep.mjs`, reusing live frontdoor auth/session seeding, canonical route inventory, strict route checks for known-sensitive pages, and structured JSON output under `output/playwright/`. Syntax validation passed before the live rerun. | Developer | +| 2026-03-09 | Fixed a harness defect in the shared auth/session model: the original live sweep restored `sessionStorage` only in the login tab, so every freshly opened route page was unauthenticated and falsely redirected to `/welcome`. Moved session seeding into `createAuthenticatedContext(...)` and reused the helper from the other live scripts. | Developer | +| 2026-03-09 | Ran the authenticated 106-route sweep against the rebuilt stack. After removing redirect/copy false positives, the real live backlog is 19 failing routes: reachability; feeds-airgap; jobengine; quotas; dead-letter; aoc; signals; packs; ai-runs; notifications; status; sbom-sources; policy simulation; policy trust-weights; policy staleness; policy audit; setup/platform trust-signing; and setup notifications. | Developer | + +## Decisions & Risks +- Decision: keep this sprint focused on broad route-level live verification and action inventory, not on fixing specific route defects before the rebuilt stack is actually exercised. +- Risk: route-level checks alone do not prove that every page action is correct; they are the breadth-first pass that feeds deeper action-by-action iterations. +- Mitigation: record visible action inventory for each page so the next iterations can systematically deepen coverage instead of rediscovering affordances manually. +- Decision: treat documented/canonical redirects as valid route outcomes in the live sweep (`/releases`, `/releases/promotion-queue`, `/ops/policy`, `/ops/policy/audit`, `/ops/platform-setup/trust-signing`, `/setup/topology`) because those aliases are intentional product behavior, not regressions. +- Risk: many remaining failures are real frontdoor contract mismatches rather than simple UI copy/render issues, so the next iterations need backend/frontend contract inspection, not just surface-level error-banner suppression. + +## Next Checkpoints +- 2026-03-09: land the reusable live canonical route sweep script. +- 2026-03-09: execute the sweep once the scratch rebuild reaches a live frontdoor. +- 2026-03-09: start implementation iterations on the highest-leverage live failure clusters from the 19-route backlog. diff --git a/docs/quickstart.md b/docs/quickstart.md index f867702f9..5103c3708 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -59,6 +59,7 @@ The setup script will: - Verify all prerequisites are installed - Offer to add hosts file entries (50 services need unique loopback IPs) - Create `.env` from the example template (works out of the box, no editing needed) +- Create or reuse the external frontdoor Docker network from `.env` (`FRONTDOOR_NETWORK`, default `stellaops_frontdoor`) - Build .NET solutions and Docker images - Launch the full platform stack (`docker-compose.stella-ops.yml`) - Run health checks and report status diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 4a1e7cbb3..65e4a19c1 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -273,6 +273,57 @@ function Initialize-EnvFile { } } +function Get-ComposeEnvValue([string]$key) { + $envFile = Join-Path $ComposeDir '.env' + if (-not (Test-Path $envFile)) { + return $null + } + + foreach ($line in Get-Content $envFile) { + if ($line -match "^\s*$key=(.+)$") { + return $matches[1].Trim() + } + } + + return $null +} + +function Get-FrontdoorNetworkName { + if (-not [string]::IsNullOrWhiteSpace($env:FRONTDOOR_NETWORK)) { + return $env:FRONTDOOR_NETWORK.Trim() + } + + $configured = Get-ComposeEnvValue 'FRONTDOOR_NETWORK' + if (-not [string]::IsNullOrWhiteSpace($configured)) { + return $configured + } + + return 'stellaops_frontdoor' +} + +function Ensure-FrontdoorNetwork { + $networkName = Get-FrontdoorNetworkName + if ([string]::IsNullOrWhiteSpace($networkName)) { + Write-Fail 'Unable to resolve the frontdoor Docker network name.' + exit 1 + } + + $existingNetworks = @(docker network ls --format '{{.Name}}' 2>$null) + if ($existingNetworks -contains $networkName) { + Write-Ok "Frontdoor network available ($networkName)" + return + } + + Write-Warn "Frontdoor network missing ($networkName); creating it now." + docker network create $networkName | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Fail "Failed to create frontdoor network ($networkName)." + exit 1 + } + + Write-Ok "Created frontdoor network ($networkName)" +} + # ─── 4. Start infrastructure ─────────────────────────────────────────────── function Start-Infrastructure { @@ -368,6 +419,7 @@ function Build-Images { function Start-Platform { Write-Step 'Starting full Stella Ops platform' + Ensure-FrontdoorNetwork Push-Location $ComposeDir try { docker compose -f docker-compose.stella-ops.yml up -d diff --git a/scripts/setup.sh b/scripts/setup.sh index 5481a25d2..4eb1d1272 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -190,6 +190,44 @@ ensure_env() { fi } +get_compose_env_value() { + local key="$1" + local env_file="${COMPOSE_DIR}/.env" + [[ -f "$env_file" ]] || return 1 + + awk -F= -v key="$key" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "$env_file" +} + +get_frontdoor_network_name() { + if [[ -n "${FRONTDOOR_NETWORK:-}" ]]; then + printf '%s\n' "$FRONTDOOR_NETWORK" + return + fi + + local configured + configured="$(get_compose_env_value FRONTDOOR_NETWORK || true)" + if [[ -n "$configured" ]]; then + printf '%s\n' "$configured" + return + fi + + printf '%s\n' 'stellaops_frontdoor' +} + +ensure_frontdoor_network() { + local network_name + network_name="$(get_frontdoor_network_name)" + + if docker network inspect "$network_name" >/dev/null 2>&1; then + ok "Frontdoor network available ($network_name)" + return + fi + + warn "Frontdoor network missing ($network_name); creating it now." + docker network create "$network_name" >/dev/null + ok "Created frontdoor network ($network_name)" +} + # ─── 4. Start infrastructure ─────────────────────────────────────────────── start_infra() { @@ -259,6 +297,7 @@ build_images() { start_platform() { step 'Starting full Stella Ops platform' + ensure_frontdoor_network cd "$COMPOSE_DIR" docker compose -f docker-compose.stella-ops.yml up -d ok 'Platform services started' diff --git a/src/Web/StellaOps.Web/scripts/chrome-path.js b/src/Web/StellaOps.Web/scripts/chrome-path.js index dca25d8f4..daf4c8670 100644 --- a/src/Web/StellaOps.Web/scripts/chrome-path.js +++ b/src/Web/StellaOps.Web/scripts/chrome-path.js @@ -3,15 +3,25 @@ const { join } = require('path'); const linuxArchivePath = ['.cache', 'chromium', 'chrome-linux64', 'chrome']; const windowsArchivePath = ['.cache', 'chromium', 'chrome-win64', 'chrome.exe']; -const macArchivePath = [ - '.cache', - 'chromium', - 'chrome-mac', - 'Chromium.app', - 'Contents', - 'MacOS', - 'Chromium' -]; +const macArchivePath = [ + '.cache', + 'chromium', + 'chrome-mac', + 'Chromium.app', + 'Contents', + 'MacOS', + 'Chromium' +]; + +function sortChromiumDirectories(entries) { + return [...entries].sort((left, right) => { + const leftMatch = /chromium-?(\d+)/i.exec(left); + const rightMatch = /chromium-?(\d+)/i.exec(right); + const leftValue = leftMatch ? Number.parseInt(leftMatch[1], 10) : Number.NEGATIVE_INFINITY; + const rightValue = rightMatch ? Number.parseInt(rightMatch[1], 10) : Number.NEGATIVE_INFINITY; + return rightValue - leftValue; + }); +} function expandVersionedArchives(rootDir = join(__dirname, '..')) { const base = join(rootDir, '.cache', 'chromium'); @@ -91,19 +101,22 @@ function candidatePaths(rootDir = join(__dirname, '..')) { const { env } = process; const playwrightBase = join(rootDir, 'node_modules', 'playwright-core', '.local-browsers'); const homePlaywrightBase = env.HOME ? join(env.HOME, '.cache', 'ms-playwright') : null; + const windowsLocalPlaywrightBase = env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'ms-playwright') : null; + const windowsProfilePlaywrightBase = env.USERPROFILE ? join(env.USERPROFILE, 'AppData', 'Local', 'ms-playwright') : null; + const playwrightCacheBases = [homePlaywrightBase, windowsLocalPlaywrightBase, windowsProfilePlaywrightBase].filter(Boolean); let playwrightChromium = []; try { if (existsSync(playwrightBase)) { - playwrightChromium = readdirSync(playwrightBase) - .filter((d) => d.startsWith('chromium-')) + const chromiumEntries = sortChromiumDirectories( + readdirSync(playwrightBase).filter((d) => d.startsWith('chromium-')) + ); + + playwrightChromium = chromiumEntries .map((d) => join(playwrightBase, d, 'chrome-linux', 'chrome')) .concat( - readdirSync(playwrightBase) - .filter((d) => d.startsWith('chromium-')) - .map((d) => join(playwrightBase, d, 'chrome-win', 'chrome.exe')), - readdirSync(playwrightBase) - .filter((d) => d.startsWith('chromium-')) - .map((d) => join(playwrightBase, d, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')) + chromiumEntries.map((d) => join(playwrightBase, d, 'chrome-win', 'chrome.exe')), + chromiumEntries.map((d) => join(playwrightBase, d, 'chrome-win64', 'chrome.exe')), + chromiumEntries.map((d) => join(playwrightBase, d, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')) ); } } catch { @@ -112,14 +125,23 @@ function candidatePaths(rootDir = join(__dirname, '..')) { let homeChromium = []; try { - if (homePlaywrightBase && existsSync(homePlaywrightBase)) { - homeChromium = readdirSync(homePlaywrightBase) - .filter((d) => d.startsWith('chromium')) - .flatMap((d) => [ - join(homePlaywrightBase, d, 'chrome-linux', 'chrome'), - join(homePlaywrightBase, d, 'chrome-win', 'chrome.exe'), - join(homePlaywrightBase, d, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), - ]); + for (const cacheBase of playwrightCacheBases) { + if (!existsSync(cacheBase)) { + continue; + } + + const chromiumEntries = sortChromiumDirectories( + readdirSync(cacheBase).filter((d) => d.startsWith('chromium')) + ); + + homeChromium.push( + ...chromiumEntries.flatMap((d) => [ + join(cacheBase, d, 'chrome-linux', 'chrome'), + join(cacheBase, d, 'chrome-win', 'chrome.exe'), + join(cacheBase, d, 'chrome-win64', 'chrome.exe'), + join(cacheBase, d, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), + ]) + ); } } catch { homeChromium = []; diff --git a/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs b/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs index ab8d2cc2c..aef63862f 100644 --- a/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs +++ b/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs @@ -211,6 +211,37 @@ export async function authenticateFrontdoor(options = {}) { return report; } +export function getSessionStorageEntries(authReport) { + return Array.isArray(authReport?.storage?.sessionStorageEntries) + ? authReport.storage.sessionStorageEntries.filter( + (entry) => Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'string', + ) + : []; +} + +export async function addSessionStorageInitScript(context, authReport) { + const sessionEntries = getSessionStorageEntries(authReport); + await context.addInitScript((entries) => { + sessionStorage.clear(); + for (const [key, value] of entries) { + if (typeof key === 'string' && typeof value === 'string') { + sessionStorage.setItem(key, value); + } + } + }, sessionEntries); +} + +export async function createAuthenticatedContext(browser, authReport, options = {}) { + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + storageState: options.statePath || DEFAULT_STATE_PATH, + ...options.contextOptions, + }); + + await addSessionStorageInitScript(context, authReport); + return context; +} + async function main() { const report = await authenticateFrontdoor(); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/src/Web/StellaOps.Web/scripts/live-frontdoor-canonical-route-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-frontdoor-canonical-route-sweep.mjs new file mode 100644 index 000000000..87bc7b610 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-frontdoor-canonical-route-sweep.mjs @@ -0,0 +1,417 @@ +#!/usr/bin/env node + +import { mkdirSync, readFileSync, 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 BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; +const STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json'); +const REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json'); +const RESULT_PATH = path.join(outputDirectory, 'live-frontdoor-canonical-route-sweep.json'); + +const scopedPrefixes = ['/mission-control', '/releases', '/security', '/evidence', '/ops']; +const defaultScopeEntries = [ + ['tenant', 'demo-prod'], + ['regions', 'us-east'], + ['environments', 'stage'], + ['timeWindow', '7d'], +]; + +const canonicalRoutes = [ + '/mission-control/board', + '/mission-control/alerts', + '/mission-control/activity', + '/releases', + '/releases/overview', + '/releases/versions', + '/releases/versions/new', + '/releases/runs', + '/releases/approvals', + '/releases/promotion-queue', + '/releases/hotfixes', + '/releases/hotfixes/new', + '/releases/environments', + '/releases/deployments', + '/security', + '/security/posture', + '/security/triage', + '/security/advisories-vex', + '/security/disposition', + '/security/supply-chain-data', + '/security/supply-chain-data/graph', + '/security/sbom-lake', + '/security/reachability', + '/security/reports', + '/evidence', + '/evidence/overview', + '/evidence/capsules', + '/evidence/verify-replay', + '/evidence/proofs', + '/evidence/exports', + '/evidence/audit-log', + '/ops', + '/ops/operations', + '/ops/operations/jobs-queues', + '/ops/operations/feeds-airgap', + '/ops/operations/data-integrity', + '/ops/operations/system-health', + '/ops/operations/health-slo', + '/ops/operations/jobengine', + '/ops/operations/scheduler', + '/ops/operations/quotas', + '/ops/operations/offline-kit', + '/ops/operations/dead-letter', + '/ops/operations/aoc', + '/ops/operations/doctor', + '/ops/operations/signals', + '/ops/operations/packs', + '/ops/operations/ai-runs', + '/ops/operations/notifications', + '/ops/operations/status', + '/ops/integrations', + '/ops/integrations/onboarding', + '/ops/integrations/registries', + '/ops/integrations/scm', + '/ops/integrations/ci', + '/ops/integrations/runtime-hosts', + '/ops/integrations/advisory-vex-sources', + '/ops/integrations/secrets', + '/ops/integrations/notifications', + '/ops/integrations/sbom-sources', + '/ops/integrations/activity', + '/ops/policy', + '/ops/policy/overview', + '/ops/policy/baselines', + '/ops/policy/gates', + '/ops/policy/simulation', + '/ops/policy/waivers', + '/ops/policy/risk-budget', + '/ops/policy/trust-weights', + '/ops/policy/staleness', + '/ops/policy/sealed-mode', + '/ops/policy/profiles', + '/ops/policy/validator', + '/ops/policy/audit', + '/ops/platform-setup', + '/ops/platform-setup/regions-environments', + '/ops/platform-setup/promotion-paths', + '/ops/platform-setup/workflows-gates', + '/ops/platform-setup/release-templates', + '/ops/platform-setup/policy-bindings', + '/ops/platform-setup/gate-profiles', + '/ops/platform-setup/defaults-guardrails', + '/ops/platform-setup/trust-signing', + '/setup', + '/setup/integrations', + '/setup/integrations/advisory-vex-sources', + '/setup/integrations/secrets', + '/setup/identity-access', + '/setup/tenant-branding', + '/setup/notifications', + '/setup/usage', + '/setup/system', + '/setup/trust-signing', + '/setup/topology', + '/setup/topology/overview', + '/setup/topology/map', + '/setup/topology/regions', + '/setup/topology/environments', + '/setup/topology/targets', + '/setup/topology/hosts', + '/setup/topology/agents', + '/setup/topology/connectivity', + '/setup/topology/runtime-drift', + '/setup/topology/promotion-graph', + '/setup/topology/workflows', + '/setup/topology/gate-profiles', +]; + +const strictRouteExpectations = { + '/security/advisories-vex': { + title: /Advisories/i, + texts: ['Security / Advisories & VEX', 'Providers'], + }, + '/security/sbom-lake': { + title: /SBOM Lake/i, + texts: ['SBOM Lake', 'Attestation Coverage Metrics'], + }, + '/security/reachability': { + title: /Reachability/i, + texts: ['Reachability', 'Proof of Exposure'], + }, + '/setup/trust-signing': { + title: /Trust/i, + texts: ['Trust Management'], + }, + '/setup/integrations': { + title: /Integrations/i, + texts: ['Integrations', 'External system connectors'], + }, + '/setup/integrations/advisory-vex-sources': { + title: /Advisory & VEX Sources/i, + texts: ['Integrations', 'FeedMirror Integrations'], + }, + '/setup/integrations/secrets': { + title: /Secrets/i, + texts: ['Integrations', 'RepoSource Integrations'], + }, + '/ops/policy': { + title: /Policy/i, + texts: ['Policy Decisioning Studio', 'One operator shell for policy, VEX, and release gates'], + }, + '/ops/policy/overview': { + title: /Policy/i, + texts: ['Policy Decisioning Studio', 'One operator shell for policy, VEX, and release gates'], + }, + '/ops/policy/risk-budget': { + title: /Policy/i, + texts: ['Policy Decisioning Studio', 'Risk Budget Overview'], + }, +}; + +const allowedFinalPaths = { + '/releases': ['/releases/deployments'], + '/releases/promotion-queue': ['/releases/promotions'], + '/ops/policy': ['/ops/policy/overview'], + '/ops/policy/audit': ['/ops/policy/audit/policy'], + '/ops/platform-setup/trust-signing': ['/setup/trust-signing'], + '/setup/topology': ['/setup/topology/overview'], +}; + +function buildRouteUrl(routePath) { + const url = new URL(routePath, BASE_URL); + if (scopedPrefixes.some((prefix) => routePath.startsWith(prefix))) { + for (const [key, value] of defaultScopeEntries) { + if (!url.searchParams.has(key)) { + url.searchParams.set(key, value); + } + } + } + + return url.toString(); +} + +function trimText(value, maxLength = 300) { + const normalized = value.replace(/\s+/g, ' ').trim(); + return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized; +} + +function shouldIgnoreUrl(url) { + return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url) || url.startsWith('data:'); +} + +async function collectHeadings(page) { + return page.locator('h1, main h1, main h2, h2').evaluateAll((elements) => + elements + .map((element) => (element.textContent || '').replace(/\s+/g, ' ').trim()) + .filter((text, index, values) => text.length > 0 && values.indexOf(text) === index), + ).catch(() => []); +} + +async function collectVisibleProblemTexts(page) { + return page.locator([ + '[role="alert"]', + '.alert', + '.banner', + '.status-banner', + '.degraded-banner', + '.warning-banner', + '.error-banner', + '.empty-state', + '.error-state', + ].join(', ')).evaluateAll((elements) => + elements + .map((element) => (element.textContent || '').replace(/\s+/g, ' ').trim()) + .filter((text) => + text.length > 0 && /(failed|unable|error|warning|degraded|unavailable|timed out|timeout|no results)/i.test(text), + ), + ).catch(() => []); +} + +async function collectVisibleActions(page) { + return page.locator('main a[href], main button').evaluateAll((elements) => + elements + .map((element) => { + const tagName = element.tagName.toLowerCase(); + const text = (element.textContent || '').replace(/\s+/g, ' ').trim(); + const href = tagName === 'a' ? element.getAttribute('href') || '' : ''; + return { + tagName, + text, + href, + }; + }) + .filter((entry) => entry.text.length > 0 || entry.href.length > 0) + .slice(0, 20), + ).catch(() => []); +} + +function collectExpectationFailures(routePath, title, headings, bodyText) { + const expectation = strictRouteExpectations[routePath]; + if (!expectation) { + return []; + } + + const failures = []; + if (!expectation.title.test(title)) { + failures.push(`title mismatch: expected ${expectation.title}`); + } + + for (const text of expectation.texts) { + const present = headings.some((heading) => heading.includes(text)) || bodyText.includes(text); + if (!present) { + failures.push(`missing text: ${text}`); + } + } + + return failures; +} + +function finalPathMatchesRoute(routePath, finalUrl) { + const finalPath = new URL(finalUrl).pathname; + if (finalPath === routePath) { + return true; + } + + return allowedFinalPaths[routePath]?.includes(finalPath) ?? false; +} + +async function inspectRoute(context, routePath) { + const page = await context.newPage(); + const consoleErrors = []; + const requestFailures = []; + const responseErrors = []; + + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(trimText(message.text(), 500)); + } + }); + + page.on('requestfailed', (request) => { + const url = request.url(); + if (shouldIgnoreUrl(url)) { + return; + } + + requestFailures.push({ + method: request.method(), + url, + error: request.failure()?.errorText ?? 'unknown', + }); + }); + + page.on('response', (response) => { + const url = response.url(); + if (shouldIgnoreUrl(url)) { + return; + } + + if (response.status() >= 400) { + responseErrors.push({ + status: response.status(), + method: response.request().method(), + url, + }); + } + }); + + const targetUrl = buildRouteUrl(routePath); + await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {}); + await page.waitForTimeout(1_500); + + const finalUrl = page.url(); + const title = await page.title().catch(() => ''); + const headings = await collectHeadings(page); + const bodyText = trimText(await page.locator('body').innerText().catch(() => ''), 1_200); + const problemTexts = await collectVisibleProblemTexts(page); + const visibleActions = await collectVisibleActions(page); + + const finalPathMatches = finalPathMatchesRoute(routePath, finalUrl); + const expectationFailures = collectExpectationFailures(routePath, title, headings, bodyText); + + const record = { + routePath, + targetUrl, + finalUrl, + title, + headings, + problemTexts, + visibleActions, + consoleErrors, + requestFailures, + responseErrors, + finalPathMatches, + expectationFailures, + passed: finalPathMatches + && expectationFailures.length === 0 + && consoleErrors.length === 0 + && requestFailures.length === 0 + && responseErrors.length === 0 + && problemTexts.length === 0, + }; + + await page.close(); + return record; +} + +async function main() { + mkdirSync(outputDirectory, { recursive: true }); + + await authenticateFrontdoor({ + baseUrl: BASE_URL, + statePath: STATE_PATH, + reportPath: REPORT_PATH, + headless: true, + }); + + const authReport = JSON.parse(readFileSync(REPORT_PATH, 'utf8')); + const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] }); + + try { + const context = await createAuthenticatedContext(browser, authReport, { statePath: STATE_PATH }); + const routes = []; + + for (const routePath of canonicalRoutes) { + const record = await inspectRoute(context, routePath); + routes.push(record); + const status = record.passed ? 'PASS' : 'FAIL'; + process.stdout.write(`[live-frontdoor-canonical-route-sweep] ${status} ${routePath} -> ${record.finalUrl}\n`); + } + + await context.close(); + + const summary = { + checkedAtUtc: new Date().toISOString(), + baseUrl: BASE_URL, + totalRoutes: routes.length, + passedRoutes: routes.filter((route) => route.passed).length, + failedRoutes: routes.filter((route) => !route.passed).map((route) => route.routePath), + routes, + }; + + writeFileSync(RESULT_PATH, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + + if (summary.failedRoutes.length > 0) { + process.exitCode = 1; + } + } finally { + await browser.close(); + } +} + +main().catch((error) => { + process.stderr.write(`[live-frontdoor-canonical-route-sweep] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs b/src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs index 59a8b9d18..7e1b16656 100644 --- a/src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs +++ b/src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; -import { authenticateFrontdoor } from './live-frontdoor-auth.mjs'; +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -417,20 +417,7 @@ async function main() { headless: true, args: ['--disable-dev-shm-usage'], }); - const context = await browser.newContext({ - ignoreHTTPSErrors: true, - storageState: statePath, - }); - const sessionEntries = Array.isArray(authReport.storage?.sessionStorageEntries) - ? authReport.storage.sessionStorageEntries - : []; - await context.addInitScript((entries) => { - for (const [key, value] of entries) { - if (typeof key === 'string' && typeof value === 'string') { - sessionStorage.setItem(key, value); - } - } - }, sessionEntries); + const context = await createAuthenticatedContext(browser, authReport, { statePath }); const report = { generatedAtUtc: new Date().toISOString(), diff --git a/src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs b/src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs index d353cb5c7..2d266b77e 100644 --- a/src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs +++ b/src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; -import { authenticateFrontdoor } from './live-frontdoor-auth.mjs'; +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -21,23 +21,14 @@ const REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json' const RESULT_PATH = path.join(outputDirectory, 'live-releases-deployments-check.json'); async function seedAuthenticatedPage(browser, authReport) { - const context = await browser.newContext({ - ignoreHTTPSErrors: true, - acceptDownloads: true, - storageState: STATE_PATH, + const context = await createAuthenticatedContext(browser, authReport, { + statePath: STATE_PATH, + contextOptions: { + acceptDownloads: true, + }, }); const page = await context.newPage(); - await page.goto(`${BASE_URL}/welcome`, { waitUntil: 'domcontentloaded', timeout: 30_000 }); - await page.evaluate((storage) => { - sessionStorage.clear(); - for (const [key, value] of storage.sessionStorageEntries ?? []) { - if (typeof key === 'string' && typeof value === 'string') { - sessionStorage.setItem(key, value); - } - } - }, authReport.storage); - await page.goto(LIST_URL, { waitUntil: 'networkidle', timeout: 30_000 }); return { context, page }; }