diff --git a/devops/docker/Dockerfile.console b/devops/docker/Dockerfile.console index 64a29fbad..197044c6d 100644 --- a/devops/docker/Dockerfile.console +++ b/devops/docker/Dockerfile.console @@ -13,6 +13,8 @@ ENV npm_config_fund=false npm_config_audit=false SOURCE_DATE_EPOCH=1704067200 WORKDIR /app COPY ${APP_DIR}/package*.json ./ RUN npm install --no-progress +# Angular resolves the docs-content asset mapping from repo-root docs/. +COPY docs/ /docs/ COPY ${APP_DIR}/ ./ RUN npm run build -- --configuration=production --output-path=${DIST_DIR} diff --git a/docs/INSTALL_GUIDE.md b/docs/INSTALL_GUIDE.md index 7904e4095..14b4089e4 100755 --- a/docs/INSTALL_GUIDE.md +++ b/docs/INSTALL_GUIDE.md @@ -55,8 +55,8 @@ The scripts will: 3. Copy `env/stellaops.env.example` to `.env` if needed (works out of the box) 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 dependency trees such as `node_modules` are excluded) -7. Launch the full platform with health checks and wait for the first-user frontdoor bootstrap path (`/welcome`, `/envsettings.json`, OIDC discovery, `/connect/authorize`) before reporting success +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 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 2a68fbea4..7037bfc84 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. A full setup now waits for the first-user frontdoor bootstrap path as well: `/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, 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. 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_002_Platform_scratch_iteration_004_setup_solution_discovery_guard.md b/docs/implplan/SPRINT_20260312_002_Platform_scratch_iteration_004_setup_solution_discovery_guard.md new file mode 100644 index 000000000..1348decb3 --- /dev/null +++ b/docs/implplan/SPRINT_20260312_002_Platform_scratch_iteration_004_setup_solution_discovery_guard.md @@ -0,0 +1,92 @@ +# Sprint 20260312_002 - Platform Scratch Iteration 004 Setup Solution Discovery Guard + +## Topic & Scope +- Wipe Stella-owned runtime state again and rerun the documented setup path from zero. +- Treat setup itself as a first-user contract: if the documented bootstrap touches generated artifacts as if they were source-owned modules, fix that root cause before continuing into UI QA. +- Rebuild and re-enter Playwright route/action coverage only after setup converges cleanly from the wipe. +- Working directory: `.`. +- Expected evidence: zero-state wipe proof, setup failure root cause, setup-script repair, rerun setup evidence, and the next live Playwright results. + +## Dependencies & Concurrency +- Depends on the clean worktree baseline after `509b97a1a`, `19b9c90a8`, and `d8d313306`. +- 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-ITER4-001 - Reproduce scratch setup from zero 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 blocking setup failure is captured with concrete evidence. + +### PLATFORM-SCRATCH-ITER4-002 - Root-cause and repair generated-solution discovery +Status: DONE +Dependency: PLATFORM-SCRATCH-ITER4-001 +Owners: 3rd line support, Architect, Developer +Task description: +- Diagnose why the documented setup path is trying to build generated docs sample solutions from `dist/`, apply a clean source/discovery fix in the shared solution builders, and document the rule. + +Completion criteria: +- [x] Generated output trees are excluded from solution discovery on both Windows and Linux setup paths. +- [x] The setup docs state that generated trees are skipped. +- [x] Scratch setup is rerun from the same zero-state workflow and no longer fails on generated docs sample solutions. + +### PLATFORM-SCRATCH-ITER4-003 - Resume first-user Playwright route/action audit after clean setup +Status: DONE +Dependency: PLATFORM-SCRATCH-ITER4-002 +Owners: QA +Task description: +- Once setup succeeds from zero state, rerun the first-user Playwright route/action audit and continue the normal diagnose/fix/retest loop for any live defects that remain. + +Completion criteria: +- [x] Fresh route sweep evidence is captured on the post-fix scratch stack. +- [x] Fresh action sweep evidence is captured before any additional fixes. +- [x] Any newly exposed defects are enumerated before repair work begins. + +### PLATFORM-SCRATCH-ITER4-004 - Group post-setup route and action fixes before the next reset +Status: DONE +Dependency: PLATFORM-SCRATCH-ITER4-003 +Owners: QA, 3rd line support, Architect, Developer +Task description: +- Fix the grouped defects exposed by the resumed audit in one iteration: docs handoff rendering, trust/setup scope preservation, notification setup navigation accessibility, and harness gaps that were misclassifying live behavior during scratch verification. + +Completion criteria: +- [x] Docs search handoffs render shipped markdown even when a module doc contains malformed fenced blocks. +- [x] Trust/signing and setup-notifications tabs preserve scope query state through all tested navigations. +- [x] The resumed scratch-stack aggregate audit and the targeted ops-policy rerun both pass on the repaired build. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-12 | Sprint created after the next zero-state setup rerun failed during solution discovery. | QA | +| 2026-03-12 | Reproduced the scratch failure from a full wipe: `scripts/setup.ps1` reached `scripts/build-all-solutions.ps1`, discovered `src/Web/StellaOps.Web/dist/stellaops-web/browser/docs-content/modules/router/samples/Examples.Router.sln`, and failed because the generated docs sample solution is not valid under the repo CPM rules. | QA / 3rd line support | +| 2026-03-12 | Confirmed the next grouped root causes before fixing: shipped console images omitted `docs-content` because `devops/docker/Dockerfile.console` never copied repo `docs/`, and zero-state setup left `timeline` / `cartographer` unhealthy until a manual restart, so the setup scripts need bounded post-compose convergence instead of reporting success from a partially settled stack. | QA / 3rd line support / Architect | +| 2026-03-12 | Repaired shared setup discovery to skip generated `dist`, `coverage`, and `output` trees on both PowerShell and shell paths; updated setup docs and reran the scratch bootstrap from zero state through the full `36/36` solution build matrix without rediscovering generated sample solutions. | 3rd line support / Developer | +| 2026-03-12 | Resumed the first-user Playwright audit on the rebuilt scratch stack and captured a clean aggregate baseline: canonical route sweep `111/111`, aggregate live audit `20/20` suites passed, plus the targeted `ops-policy` rerun passed with `failedActionCount=0` and `runtimeIssueCount=0`. | QA | +| 2026-03-12 | Grouped the post-setup fixes exposed during the resumed audit: hardened shipped docs markdown rendering against malformed fences, corrected the malformed Advisory AI module doc, preserved scope/query state across trust and notifications setup shells, and tightened several Playwright harnesses so they wait for resolved UI state instead of reporting false negatives during cold-load scratch verification. | QA / Architect / Developer | +| 2026-03-12 | Verified the grouped fixes with focused Angular coverage `42/42`, `npm run build`, live dist sync into `compose_console-dist`, targeted live sweeps for search, trust/admin, notifications/watchlist, topology, evidence export, release promotion, and a clean rerun of `live-ops-policy-action-sweep.mjs`. | QA / Developer | + +## Decisions & Risks +- Decision: generated output trees under `src/**` are not source-owned build inputs and must be excluded at the shared solution discovery layer, not with one-off exceptions in setup callers. +- Risk: copied docs samples can reappear after future web builds. The exclusion rule therefore covers `dist`, `coverage`, and `output` globally instead of naming a single sample path. +- Decision: direct `/docs/*` routes are part of the shipped frontdoor contract, so the console image must package repo docs during Angular builds rather than relying on local dist copies or manual volume sync. +- Decision: scratch setup should absorb one bounded restart pass for services that remain unhealthy after first compose boot; manual container restarts are not an acceptable first-user recovery path. +- Decision: shipped docs rendering must tolerate malformed fenced blocks in module markdown, because a single malformed doc must not turn global search knowledge handoffs into blank or broken user routes. +- Decision: setup shell tabs and sub-tabs are part of the scoped frontdoor contract; query state must be merged through trust and notifications navigation instead of being silently dropped on tab changes. +- Decision: once an uncovered menu-adjacent route or action is manually exercised during QA, it belongs in the Playwright sweeps so future scratch iterations verify it automatically rather than rediscovering it manually. + +## Next Checkpoints +- Start the next scratch reset iteration from zero Stella-owned runtime state again. +- Keep extending the aggregate Playwright coverage so fewer manual rediscoveries survive into later setup cycles. diff --git a/docs/modules/advisory-ai/chat-interface.md b/docs/modules/advisory-ai/chat-interface.md index 412acaa18..41609cad5 100644 --- a/docs/modules/advisory-ai/chat-interface.md +++ b/docs/modules/advisory-ai/chat-interface.md @@ -363,7 +363,8 @@ AdvisoryAI: ### Vulnerability Investigation -```n User: What is the status of CVE-2023-44487 in our environment? +```text +User: What is the status of CVE-2023-44487 in our environment? Assistant: CVE-2023-44487 (HTTP/2 Rapid Reset) **is reachable** in your environment [reach:api-gateway:grpc.Server]. @@ -376,12 +377,13 @@ Assistant: CVE-2023-44487 (HTTP/2 Rapid Reset) **is reachable** in your environm **Recommended actions:** [Accept Risk]{action:approve,cve_id=CVE-2023-44487} [Create VEX]{action:create_vex,product=api-gateway,vulnerability=CVE-2023-44487,status=affected} -```n +``` --- ### Action Execution -```n User: Please create a VEX statement marking CVE-2023-44487 as affected for api-gateway. +```text +User: Please create a VEX statement marking CVE-2023-44487 as affected for api-gateway. Assistant: I will create a VEX statement with the following details: @@ -391,7 +393,7 @@ Assistant: I will create a VEX statement with the following details: - **Justification:** Vulnerable code is reachable [Create VEX Statement]{action:create_vex,product=api-gateway,vulnerability=CVE-2023-44487,status=affected,justification=vulnerable_code_present} -```n +``` --- ## Configuration diff --git a/docs/modules/ui/README.md b/docs/modules/ui/README.md index ef3d7f070..37ee65c6a 100644 --- a/docs/modules/ui/README.md +++ b/docs/modules/ui/README.md @@ -8,6 +8,10 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runtime posture, and admin workflows. +## Latest updates (2026-03-12) +- Console container builds now copy the repo `docs/` tree into the Angular build stage so `docs-content` is bundled into shipped images and direct `/docs/*` routes resolve on the live frontdoor instead of only in local dist copies. +- Live search route verification now treats knowledge-card handoffs as failed unless the destination documentation page renders real content, preventing blank docs routes from slipping through route-only checks. + ## Latest updates (2026-03-10) - Hardened revived `Ops > Policy > Simulation` direct-entry surfaces so coverage, lint, promotion-gate, and diff routes restore stable defaults when host wiring omits pack/version/environment inputs. - Coverage now hydrates on first render instead of waiting for a second interaction, preventing blank direct-route states on `/ops/policy/simulation/coverage`. diff --git a/scripts/build-all-solutions.ps1 b/scripts/build-all-solutions.ps1 index 21c1f0b89..4c3f96edd 100644 --- a/scripts/build-all-solutions.ps1 +++ b/scripts/build-all-solutions.ps1 @@ -5,7 +5,9 @@ .DESCRIPTION Discovers all *.sln files under src/ (excluding the root StellaOps.sln) - and runs dotnet build on each. Pass -Test to also run dotnet test. + and skips generated output trees such as node_modules, bin, obj, dist, + coverage, and output before running dotnet build on each. Pass -Test to + also run dotnet test. .PARAMETER Test Also run dotnet test on each solution after building. @@ -147,10 +149,12 @@ if ($StopRepoHostProcesses) { Stop-RepoHostProcesses -Root $repoRoot } +$excludedSolutionRootPattern = '[\\/](node_modules|bin|obj|dist|coverage|output)[\\/]' + $solutions = Get-ChildItem -Path $srcDir -Filter '*.sln' -Recurse | Where-Object { $_.Name -ne 'StellaOps.sln' -and - $_.FullName -notmatch '[\\/](node_modules|bin|obj)[\\/]' + $_.FullName -notmatch $excludedSolutionRootPattern } | Sort-Object FullName diff --git a/scripts/build-all-solutions.sh b/scripts/build-all-solutions.sh index f1114985d..408e01740 100644 --- a/scripts/build-all-solutions.sh +++ b/scripts/build-all-solutions.sh @@ -62,10 +62,10 @@ if $STOP_REPO_HOST_PROCESSES; then stop_repo_host_processes fi -# Discover solutions (exclude root StellaOps.sln) +# Discover repo-owned solutions only; skip generated output trees. mapfile -t SOLUTIONS < <( find "$SRC_DIR" \ - \( -path '*/node_modules/*' -o -path '*/bin/*' -o -path '*/obj/*' \) -prune -o \ + \( -path '*/node_modules/*' -o -path '*/bin/*' -o -path '*/obj/*' -o -path '*/dist/*' -o -path '*/coverage/*' -o -path '*/output/*' \) -prune -o \ -name '*.sln' ! -name 'StellaOps.sln' -print | sort ) diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index afdf6df46..32f55baf3 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -142,6 +142,166 @@ function Get-ServiceHttpProbeUrl([string]$serviceName, [int]$containerPort, [str return "http://${probeHost}:$($Matches.port)$path" } +function Get-ComposeServiceRecords([string[]]$composeFiles) { + $records = @() + $seenContainers = @{} + + foreach ($composeFile in $composeFiles) { + $composePath = if ([System.IO.Path]::IsPathRooted($composeFile)) { + $composeFile + } else { + Join-Path $ComposeDir $composeFile + } + + $expectedServices = Get-ComposeExpectedServices $composePath + $services = Get-ComposeServices $composePath + if ($expectedServices.Count -gt 0) { + $allowed = @{} + foreach ($name in $expectedServices) { + $allowed[$name.ToLowerInvariant()] = $true + } + + $services = $services | Where-Object { + $service = "$($_.Service)".ToLowerInvariant() + $service -and $allowed.ContainsKey($service) + } + } + + foreach ($svc in $services) { + $name = "$($svc.Name)" + if (-not $name -or $seenContainers.ContainsKey($name)) { + continue + } + + $seenContainers[$name] = $true + $records += [pscustomobject]@{ + ComposeFile = $composePath + Service = "$($svc.Service)" + Name = $name + State = "$($svc.State)".ToLowerInvariant() + Health = "$($svc.Health)".ToLowerInvariant() + } + } + } + + return $records +} + +function Wait-ForComposeConvergence( + [string[]]$composeFiles, + [string]$successMessage, + [int]$maxWaitSeconds = 180, + [int]$restartAfterSeconds = 45, + [int]$pollSeconds = 5, + [switch]$RestartStalledServices +) { + $restartedServices = @{} + $elapsed = 0 + + while ($elapsed -lt $maxWaitSeconds) { + $records = Get-ComposeServiceRecords $composeFiles + if ($records.Count -gt 0) { + $pending = @() + $blocking = @() + + foreach ($record in $records) { + if ($record.State -ne 'running') { + $blocking += $record + continue + } + + if (-not $record.Health -or $record.Health -eq 'healthy') { + continue + } + + if ($record.Health -eq 'starting') { + $pending += $record + continue + } + + $blocking += $record + } + + if ($blocking.Count -eq 0 -and $pending.Count -eq 0 -and $elapsed -gt $pollSeconds) { + Write-Ok $successMessage + return $true + } + + if ($RestartStalledServices -and $elapsed -ge $restartAfterSeconds -and $blocking.Count -gt 0) { + $restartGroups = @{} + + foreach ($record in $blocking) { + $restartKey = "$($record.ComposeFile)|$($record.Service)" + if ($restartedServices.ContainsKey($restartKey)) { + continue + } + + if (-not $restartGroups.ContainsKey($record.ComposeFile)) { + $restartGroups[$record.ComposeFile] = New-Object System.Collections.Generic.List[string] + } + + $restartGroups[$record.ComposeFile].Add($record.Service) + $restartedServices[$restartKey] = $true + } + + foreach ($group in $restartGroups.GetEnumerator()) { + $servicesToRestart = @($group.Value | Sort-Object -Unique) + if ($servicesToRestart.Count -eq 0) { + continue + } + + Write-Warn "Restarting stalled services from $($group.Key): $($servicesToRestart -join ', ')" + Push-Location $ComposeDir + try { + docker compose -f $group.Key restart @servicesToRestart | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Ok "Restarted stalled services: $($servicesToRestart -join ', ')" + } else { + Write-Warn "Failed to restart stalled services: $($servicesToRestart -join ', ')" + } + } + finally { + Pop-Location + } + } + } + } + + Start-Sleep -Seconds $pollSeconds + $elapsed += $pollSeconds + } + + $finalRecords = Get-ComposeServiceRecords $composeFiles + $blockingSummary = @( + $finalRecords | ForEach-Object { + if ($_.State -ne 'running') { + "$($_.Name) (state=$($_.State))" + } + elseif ($_.Health -and $_.Health -ne 'healthy' -and $_.Health -ne 'starting') { + "$($_.Name) (health=$($_.Health))" + } + } + ) | Where-Object { $_ } + + $pendingSummary = @( + $finalRecords | Where-Object { + $_.State -eq 'running' -and $_.Health -eq 'starting' + } | ForEach-Object { + "$($_.Name) (health=starting)" + } + ) + + if ($blockingSummary.Count -gt 0) { + Write-Warn "Timed out waiting for compose convergence after ${maxWaitSeconds}s. Blocking services: $($blockingSummary -join ', ')" + } elseif ($pendingSummary.Count -gt 0) { + Write-Warn "Timed out waiting for compose convergence after ${maxWaitSeconds}s. Still starting: $($pendingSummary -join ', ')" + } else { + Write-Warn "Timed out waiting for compose convergence after ${maxWaitSeconds}s." + } + + return $false +} + # ─── 1. Check prerequisites ──────────────────────────────────────────────── function Test-Prerequisites { @@ -365,44 +525,7 @@ function Start-Infrastructure { } Write-Host ' Waiting for containers to become healthy...' -ForegroundColor Gray - $maxWait = 120 - $elapsed = 0 - while ($elapsed -lt $maxWait) { - $expectedServices = Get-ComposeExpectedServices 'docker-compose.dev.yml' - $services = Get-ComposeServices 'docker-compose.dev.yml' - if ($expectedServices.Count -gt 0) { - $allowed = @{} - foreach ($name in $expectedServices) { - $allowed[$name.ToLowerInvariant()] = $true - } - - $services = $services | Where-Object { - $service = "$($_.Service)".ToLowerInvariant() - $service -and $allowed.ContainsKey($service) - } - } - if ($services.Count -gt 0) { - $allHealthy = $true - foreach ($svc in $services) { - $state = "$($svc.State)".ToLowerInvariant() - $health = "$($svc.Health)".ToLowerInvariant() - if ($state -ne 'running') { - $allHealthy = $false - continue - } - if ($health -and $health -ne 'healthy') { - $allHealthy = $false - } - } - if ($allHealthy -and $elapsed -gt 5) { - Write-Ok 'All infrastructure containers healthy' - return - } - } - Start-Sleep -Seconds 5 - $elapsed += 5 - } - Write-Warn "Timed out waiting for healthy status after ${maxWait}s. Check with: docker compose -f docker-compose.dev.yml ps" + [void](Wait-ForComposeConvergence -composeFiles @('docker-compose.dev.yml') -successMessage 'All infrastructure containers healthy' -maxWaitSeconds 120) } finally { Pop-Location @@ -465,6 +588,13 @@ function Start-Platform { finally { Pop-Location } + + [void](Wait-ForComposeConvergence ` + -composeFiles @('docker-compose.stella-ops.yml') ` + -successMessage 'Platform services converged from zero-state startup' ` + -RestartStalledServices ` + -maxWaitSeconds 180 ` + -restartAfterSeconds 45) } function Test-ExpectedHttpStatus([string]$url, [int[]]$allowedStatusCodes, [int]$timeoutSeconds = 5, [int]$attempts = 6, [int]$retryDelaySeconds = 2) { diff --git a/scripts/setup.sh b/scripts/setup.sh index a6366a526..b023dc84c 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -70,6 +70,179 @@ service_http_probe_url() { printf 'http://%s:%s%s' "$host" "$host_port" "$path" } +get_compose_service_records() { + local seen_names="" + local compose_file compose_path expected_services services_json line service name state health + + for compose_file in "$@"; do + if [[ "${compose_file}" = /* ]]; then + compose_path="${compose_file}" + else + compose_path="${COMPOSE_DIR}/${compose_file}" + fi + + [[ -f "${compose_path}" ]] || continue + + expected_services="$(docker compose -f "${compose_path}" config --services 2>/dev/null || true)" + services_json="$(docker compose -f "${compose_path}" ps --format json 2>/dev/null || true)" + [[ -n "${services_json}" ]] || continue + + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + service=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Service',''))" 2>/dev/null || true) + name=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Name',''))" 2>/dev/null || true) + state=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('State',''))" 2>/dev/null || true) + health=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Health',''))" 2>/dev/null || true) + + if [[ -n "${expected_services}" ]] && ! printf '%s\n' "${expected_services}" | grep -Fxq "${service}"; then + continue + fi + + [[ -n "${name}" ]] || continue + if printf '%s\n' "${seen_names}" | grep -Fxq "${name}"; then + continue + fi + + seen_names="${seen_names} +${name}" + printf '%s|%s|%s|%s|%s\n' "${compose_path}" "${service}" "${name}" "${state,,}" "${health,,}" + done <<< "${services_json}" + done +} + +wait_for_compose_convergence() { + local success_message="$1" + local restart_stalled="${2:-false}" + local max_wait="${3:-180}" + local restart_after="${4:-45}" + shift 4 + local compose_files=("$@") + + local elapsed=0 + local poll_seconds=5 + local restarted_services="" + + while (( elapsed < max_wait )); do + local records + records="$(get_compose_service_records "${compose_files[@]}")" + if [[ -n "${records}" ]]; then + local pending="" + local blocking="" + local record compose_file service name state health + + while IFS= read -r record; do + [[ -z "${record}" ]] && continue + IFS='|' read -r compose_file service name state health <<< "${record}" + + if [[ "${state}" != "running" ]]; then + blocking="${blocking} +${compose_file}|${service}|${name}|state=${state}" + continue + fi + + if [[ -z "${health}" || "${health}" == "healthy" ]]; then + continue + fi + + if [[ "${health}" == "starting" ]]; then + pending="${pending} +${compose_file}|${service}|${name}|health=starting" + continue + fi + + blocking="${blocking} +${compose_file}|${service}|${name}|health=${health}" + done <<< "${records}" + + if [[ -z "${blocking//$'\n'/}" && -z "${pending//$'\n'/}" && ${elapsed} -gt ${poll_seconds} ]]; then + ok "${success_message}" + return 0 + fi + + if [[ "${restart_stalled}" == "true" && ${elapsed} -ge ${restart_after} && -n "${blocking//$'\n'/}" ]]; then + local restart_targets="" + while IFS= read -r record; do + [[ -z "${record}" ]] && continue + IFS='|' read -r compose_file service _ <<< "${record}" + local restart_key="${compose_file}|${service}" + if printf '%s\n' "${restarted_services}" | grep -Fxq "${restart_key}"; then + continue + fi + + restarted_services="${restarted_services} +${restart_key}" + restart_targets="${restart_targets} +${compose_file}|${service}" + done <<< "${blocking}" + + local compose_to_restart unique_compose_files service_to_restart + local -a services_for_compose + unique_compose_files=$(printf '%s\n' "${restart_targets}" | awk -F'|' 'NF { print $1 }' | sort -u) + + while IFS= read -r compose_to_restart; do + [[ -z "${compose_to_restart}" ]] && continue + services_for_compose=() + while IFS= read -r service_to_restart; do + [[ -z "${service_to_restart}" ]] && continue + services_for_compose+=("${service_to_restart}") + done < <(printf '%s\n' "${restart_targets}" | awk -F'|' -v cf="${compose_to_restart}" 'NF && $1 == cf { print $2 }' | sort -u) + + if (( ${#services_for_compose[@]} == 0 )); then + continue + fi + + warn "Restarting stalled services from ${compose_to_restart}: ${services_for_compose[*]}" + ( + cd "${COMPOSE_DIR}" && + docker compose -f "${compose_to_restart}" restart "${services_for_compose[@]}" >/dev/null + ) && ok "Restarted stalled services: ${services_for_compose[*]}" || \ + warn "Failed to restart stalled services: ${services_for_compose[*]}" + done <<< "${unique_compose_files}" + fi + fi + + sleep "${poll_seconds}" + elapsed=$((elapsed + poll_seconds)) + done + + local final_records + final_records="$(get_compose_service_records "${compose_files[@]}")" + local final_blocking="" + local final_pending="" + local record compose_file service name state health + + while IFS= read -r record; do + [[ -z "${record}" ]] && continue + IFS='|' read -r compose_file service name state health <<< "${record}" + + if [[ "${state}" != "running" ]]; then + final_blocking="${final_blocking} +${name} (state=${state})" + continue + fi + + if [[ -n "${health}" && "${health}" != "healthy" ]]; then + if [[ "${health}" == "starting" ]]; then + final_pending="${final_pending} +${name} (health=starting)" + else + final_blocking="${final_blocking} +${name} (health=${health})" + fi + fi + done <<< "${final_records}" + + if [[ -n "${final_blocking//$'\n'/}" ]]; then + warn "Timed out waiting for compose convergence after ${max_wait}s. Blocking services: $(printf '%s\n' "${final_blocking}" | awk 'NF { print }' | paste -sd ', ' -)" + elif [[ -n "${final_pending//$'\n'/}" ]]; then + warn "Timed out waiting for compose convergence after ${max_wait}s. Still starting: $(printf '%s\n' "${final_pending}" | awk 'NF { print }' | paste -sd ', ' -)" + else + warn "Timed out waiting for compose convergence after ${max_wait}s." + fi + + return 1 +} + # ─── 1. Check prerequisites ──────────────────────────────────────────────── check_prerequisites() { @@ -264,27 +437,7 @@ start_infra() { docker compose -f docker-compose.dev.yml up -d echo ' Waiting for containers to become healthy...' - local max_wait=120 - local elapsed=0 - while (( elapsed < max_wait )); do - local all_healthy=true - while IFS= read -r line; do - [[ -z "$line" ]] && continue - local health; health=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Health',''))" 2>/dev/null || true) - if [[ -n "$health" && "$health" != "healthy" ]]; then - all_healthy=false - fi - done < <(docker compose -f docker-compose.dev.yml ps --format json 2>/dev/null) - - if [[ "$all_healthy" == "true" && $elapsed -gt 5 ]]; then - ok 'All infrastructure containers healthy' - cd "$ROOT" - return - fi - sleep 5 - elapsed=$((elapsed + 5)) - done - warn "Timed out waiting for healthy status after ${max_wait}s." + wait_for_compose_convergence 'All infrastructure containers healthy' false 120 45 docker-compose.dev.yml || true cd "$ROOT" } @@ -330,6 +483,7 @@ start_platform() { docker compose -f docker-compose.stella-ops.yml up -d ok 'Platform services started' cd "$ROOT" + wait_for_compose_convergence 'Platform services converged from zero-state startup' true 180 45 docker-compose.stella-ops.yml || true } http_status() { diff --git a/src/Web/StellaOps.Web/scripts/live-evidence-export-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-evidence-export-action-sweep.mjs index 3e396a0dd..0577fefa7 100644 --- a/src/Web/StellaOps.Web/scripts/live-evidence-export-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-evidence-export-action-sweep.mjs @@ -129,6 +129,28 @@ async function setViewMode(page, mode) { await page.waitForTimeout(1_500); } +async function waitForBundleListState(page) { + await page.waitForFunction( + () => { + const loadingTexts = Array.from(document.querySelectorAll('body *')) + .map((node) => (node.textContent || '').trim()) + .filter(Boolean); + if (loadingTexts.some((text) => /loading/i.test(text) && text.toLowerCase().includes('bundle'))) { + return false; + } + + const bundleCards = document.querySelectorAll('.bundle-card').length; + const emptyState = Array.from(document.querySelectorAll('.empty-state, .empty-panel, [data-testid="empty-state"]')) + .map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()) + .find((text) => text.length > 0); + + return bundleCards > 0 || Boolean(emptyState); + }, + null, + { timeout: 15_000 }, + ).catch(() => {}); +} + async function main() { mkdirSync(outputDirectory, { recursive: true }); @@ -161,11 +183,7 @@ async function main() { const exportedToast = await captureSnapshot(page, 'export-center-after-stellabundle'); await page.getByRole('button', { name: 'View bundle details' }).click({ timeout: 10_000 }); await page.waitForTimeout(2_000); - await page.waitForFunction( - () => document.querySelectorAll('.bundle-card').length > 0, - null, - { timeout: 10_000 }, - ).catch(() => {}); + await waitForBundleListState(page); const routedSearchValue = await page.locator('input[placeholder="Search by image or bundle ID..."]').inputValue().catch(() => ''); const routedBundleCardCount = await page.locator('.bundle-card').count().catch(() => 0); results.push({ @@ -209,6 +227,7 @@ async function main() { await gotoRoute(page, '/evidence/exports/bundles'); await setViewMode(page, 'operator'); + await waitForBundleListState(page); const bundleCards = page.locator('.bundle-card'); const bundleCount = await bundleCards.count(); let bundleDownload = null; 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 6432f1ed6..894c1bcaa 100644 --- a/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs +++ b/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs @@ -85,11 +85,16 @@ async function clickLinkAndVerify(page, route, linkName, expectedPath) { }; } +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + async function locateNav(page, label) { + const labelPattern = new RegExp(`(^|\\s)${escapeRegex(label)}(\\s|$)`, 'i'); const candidates = [ - page.getByRole('link', { name: label }).first(), - page.getByRole('tab', { name: label }).first(), - page.getByRole('button', { name: label }).first(), + page.getByRole('tab', { name: labelPattern }).first(), + page.getByRole('link', { name: labelPattern }).first(), + page.getByRole('button', { name: labelPattern }).first(), ]; for (const locator of candidates) { @@ -103,7 +108,12 @@ async function locateNav(page, label) { async function clickNavAndVerify(page, route, label, expectedPath) { await navigate(page, route); - const locator = await locateNav(page, label); + let locator = await locateNav(page, label); + if (!locator) { + await page.waitForTimeout(1_000); + await settle(page); + locator = await locateNav(page, label); + } if (!locator) { return { action: `nav:${label}`, diff --git a/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs index 4edf98c49..93e71a4ef 100644 --- a/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs @@ -382,12 +382,129 @@ async function clickFirstAvailableButton(page, route, names) { }; } +async function verifyFirstAvailableButton(page, route, names) { + await navigate(page, route); + const target = await waitForAnyButton(page, names); + if (!target) { + return { + action: `button:${names.join('|')}`, + ok: false, + reason: 'missing-button', + snapshot: await captureSnapshot(page, `missing-button:${names.join('|')}`), + }; + } + + const { name, locator } = target; + return { + action: `button:${name}`, + ok: true, + disabled: await locator.isDisabled().catch(() => false), + snapshot: await captureSnapshot(page, `present-button:${name}`), + }; +} + +async function waitForPolicySimulationReady(page) { + await page.waitForFunction( + () => { + const heading = Array.from(document.querySelectorAll('h1, h2, [data-testid="page-title"], .page-title')) + .map((node) => (node.textContent || '').trim()) + .find((text) => text.length > 0); + if (!heading || !heading.toLowerCase().includes('policy decisioning studio')) { + return false; + } + + const buttons = Array.from(document.querySelectorAll('button')) + .map((button) => (button.textContent || '').trim()) + .filter(Boolean); + return buttons.includes('View Results') || buttons.includes('Enable') || buttons.includes('Enable Shadow Mode'); + }, + null, + { timeout: 12_000 }, + ).catch(() => {}); +} + +async function waitForViewResultsEnabled(page, timeoutMs = 12_000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const viewButton = page.getByRole('button', { name: 'View Results' }).first(); + if ((await viewButton.count()) > 0 && !(await viewButton.isDisabled().catch(() => true))) { + return true; + } + + await page.waitForTimeout(300); + } + + return false; +} + +async function resolveShadowModeActionState(page, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs; + let enableStableSince = null; + + while (Date.now() < deadline) { + if (await waitForViewResultsEnabled(page, 900)) { + return { mode: 'view-ready' }; + } + + const enableTarget = await waitForEnabledButton(page, ['Enable Shadow Mode', /^Enable$/], 750); + const disableButton = await waitForButton(page, 'Disable', 0, 750); + if (disableButton && !(await disableButton.isDisabled().catch(() => true))) { + enableStableSince = null; + await page.waitForTimeout(500); + continue; + } + + if (enableTarget) { + if (enableStableSince === null) { + enableStableSince = Date.now(); + } else if (Date.now() - enableStableSince >= 1_500) { + return { mode: 'enable-required' }; + } + } else { + enableStableSince = null; + } + + await page.waitForTimeout(300); + } + + return { mode: 'timeout' }; +} + +async function clickShadowEnableButton(page) { + for (let attempt = 0; attempt < 5; attempt += 1) { + const primaryLocator = page.getByRole('button', { name: 'Enable Shadow Mode' }).first(); + const fallbackLocator = page.getByRole('button', { name: /^Enable$/ }).first(); + const locator = (await primaryLocator.count()) > 0 ? primaryLocator : fallbackLocator; + + if ((await locator.count()) === 0) { + await page.waitForTimeout(300); + continue; + } + + try { + await locator.click({ timeout: 10_000 }); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/detached|not attached|not stable/i.test(message)) { + throw error; + } + + await page.waitForTimeout(300); + } + } + + return false; +} + async function exerciseShadowResults(page) { const route = '/ops/policy/simulation'; await navigate(page, route); + await waitForPolicySimulationReady(page); - const viewButton = page.getByRole('button', { name: 'View Results' }).first(); - if ((await viewButton.count()) === 0) { + const viewButton = await waitForButton(page, 'View Results', 0, 12_000); + if (!viewButton) { return { action: 'button:View Results', ok: false, @@ -402,28 +519,52 @@ async function exerciseShadowResults(page) { let restoredDisabledState = false; if (initiallyDisabled) { - const enableTarget = await waitForEnabledButton(page, ['Enable Shadow Mode', /^Enable$/], 12_000); - if (!enableTarget) { - return { - action: 'button:View Results', - ok: false, - reason: 'disabled-without-enable', - initiallyDisabled, - snapshot: await captureSnapshot(page, 'policy-simulation:view-results-disabled'), - }; - } + const becameReadyWithoutToggle = await waitForViewResultsEnabled(page, 15_000); + if (!becameReadyWithoutToggle) { + const actionState = await resolveShadowModeActionState(page); + if (actionState.mode === 'enable-required') { + const enableTarget = await waitForEnabledButton(page, ['Enable Shadow Mode', /^Enable$/], 5_000); + if (!enableTarget) { + return { + action: 'button:View Results', + ok: false, + reason: 'enable-button-missing-after-wait', + initiallyDisabled, + snapshot: await captureSnapshot(page, 'policy-simulation:enable-button-missing'), + }; + } - await enableTarget.locator.click({ timeout: 10_000 }); - enabledInFlow = true; - await page.waitForFunction(() => { - const buttons = Array.from(document.querySelectorAll('button')); - const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results'); - return button instanceof HTMLButtonElement && !button.disabled; - }, null, { timeout: 12_000 }).catch(() => {}); - steps.push({ - step: 'enable-shadow-mode', - snapshot: await captureSnapshot(page, 'policy-simulation:enabled-shadow-mode'), - }); + const enableClicked = await clickShadowEnableButton(page); + if (!enableClicked) { + return { + action: 'button:View Results', + ok: false, + reason: 'enable-button-detached-during-click', + initiallyDisabled, + snapshot: await captureSnapshot(page, 'policy-simulation:enable-button-detached'), + }; + } + + enabledInFlow = true; + await page.waitForFunction(() => { + const buttons = Array.from(document.querySelectorAll('button')); + const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results'); + return button instanceof HTMLButtonElement && !button.disabled; + }, null, { timeout: 12_000 }).catch(() => {}); + steps.push({ + step: 'enable-shadow-mode', + snapshot: await captureSnapshot(page, 'policy-simulation:enabled-shadow-mode'), + }); + } else if (actionState.mode !== 'view-ready') { + return { + action: 'button:View Results', + ok: false, + reason: 'disabled-without-enable', + initiallyDisabled, + snapshot: await captureSnapshot(page, 'policy-simulation:view-results-disabled'), + }; + } + } } const activeViewButton = page.getByRole('button', { name: 'View Results' }).first(); @@ -729,7 +870,7 @@ async function main() { await runAction(page, '/ops/policy/simulation', 'button:View Results', () => exerciseShadowResults(page)), await runAction(page, '/ops/policy/simulation', 'button:Enable|Disable', () => - clickFirstAvailableButton(page, '/ops/policy/simulation', ['Enable', 'Disable'])), + verifyFirstAvailableButton(page, '/ops/policy/simulation', ['Enable', 'Disable'])), await runAction(page, '/ops/policy/simulation', 'link:Simulation Console', () => clickLink(context, page, '/ops/policy/simulation', 'Simulation Console')), await runAction(page, '/ops/policy/simulation', 'link:Coverage', () => 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 29cc4e898..c03a8db78 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 @@ -56,6 +56,32 @@ async function clickNext(page) { await page.getByRole('button', { name: 'Next ->' }).click(); } +async function clickStableButton(page, name) { + for (let attempt = 0; attempt < 5; attempt += 1) { + const button = page.getByRole('button', { name }).first(); + await button.waitFor({ state: 'visible', timeout: 10_000 }); + const disabled = await button.isDisabled().catch(() => true); + if (disabled) { + await page.waitForTimeout(300); + continue; + } + + try { + await button.click({ noWaitAfter: true, timeout: 10_000 }); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/not attached|detached|intercepts pointer events|element is not stable/i.test(message)) { + throw error; + } + + await page.waitForTimeout(300); + } + } + + throw new Error(`Unable to click ${name} after repeated retries.`); +} + async function main() { mkdirSync(outputDirectory, { recursive: true }); @@ -166,10 +192,7 @@ async function main() { { timeout: 30_000 }, ); - await page.getByRole('button', { name: 'Submit Promotion Request' }).click({ - noWaitAfter: true, - timeout: 10_000, - }); + await clickStableButton(page, 'Submit Promotion Request'); const submitResponse = await submitResponsePromise; result.promoteResponse = promoteResponse ?? { status: submitResponse.status(), diff --git a/src/Web/StellaOps.Web/scripts/live-search-result-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-search-result-action-sweep.mjs index 189d617af..a0d0a23ca 100644 --- a/src/Web/StellaOps.Web/scripts/live-search-result-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-search-result-action-sweep.mjs @@ -99,17 +99,33 @@ async function waitForDestinationContent(page) { await settle(page, 1500); if (!page.url().includes('/docs/')) { - return; + return { + docsContentLoaded: true, + docsContentPreview: '', + }; } - await page.waitForFunction( + const docsContentLoaded = await page.waitForFunction( () => { - const main = document.querySelector('main'); - return typeof main?.textContent === 'string' && main.textContent.replace(/\s+/g, ' ').trim().length > 64; + const docsContent = document.querySelector('.docs-viewer__content, [data-testid="docs-content"]'); + const text = typeof docsContent?.textContent === 'string' + ? docsContent.textContent.replace(/\s+/g, ' ').trim() + : ''; + return text.length > 64; }, undefined, { timeout: 10_000 }, - ).catch(() => {}); + ).then(() => true).catch(() => false); + + const docsContentPreview = await page.locator('.docs-viewer__content, [data-testid="docs-content"]').first() + .textContent() + .then((text) => text?.replace(/\s+/g, ' ').trim().slice(0, 240) ?? '') + .catch(() => ''); + + return { + docsContentLoaded, + docsContentPreview, + }; } async function waitForSearchResolution(page, timeoutMs = 15_000) { @@ -195,12 +211,14 @@ async function executePrimaryAction(page, predicateLabel) { process.stdout.write(`[live-search-result-action-sweep] click domain=${domain} label="${actionLabel}" index=${index}\n`); await actionButton.click({ timeout: 10_000 }).catch(() => {}); process.stdout.write(`[live-search-result-action-sweep] clicked domain=${domain} url=${page.url()}\n`); - await waitForDestinationContent(page); + const destination = await waitForDestinationContent(page); process.stdout.write(`[live-search-result-action-sweep] settled domain=${domain} url=${page.url()}\n`); return { matchedDomain: domain, actionLabel, url: page.url(), + destination, + snapshot: await snapshot(page, `${domain}:destination`), }; } @@ -369,6 +387,14 @@ function collectFailures(results) { failures.push(`${result.label}: primary knowledge action stayed on a non-canonical docs route (${result.knowledgeAction.url}).`); } + if ( + expectations.requireKnowledgeAction && + result.knowledgeAction?.url?.includes('/docs/') && + result.knowledgeAction?.destination?.docsContentLoaded !== true + ) { + failures.push(`${result.label}: primary knowledge action landed on a docs route without rendered documentation content.`); + } + if (expectations.requireApiCopyCard) { const apiCard = result.latestResponse?.cards?.find((card) => card.actions?.[0]?.label === 'Copy Curl'); diff --git a/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs index fe5b2af6c..d0f6f6556 100644 --- a/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs @@ -358,14 +358,23 @@ async function clickEnvironmentDetailTab(page, tabLabel, expectedText) { }; } -async function verifyEmptyInventoryState(page, route, expectedText) { +async function verifyInventorySurface(page, route, expectedText) { await navigate(page, route); - const ok = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false); + const readiness = routeReadiness(route); + const bodyText = (await page.locator('body').textContent().catch(() => '')).replace(/\s+/g, ' ').trim(); + const emptyStateVisible = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false); + const populatedMarkerVisible = Boolean( + readiness?.markers + .filter((marker) => marker !== expectedText) + .find((marker) => bodyText.includes(marker)), + ); return { - action: `empty-state:${route}`, - ok, + action: `inventory-state:${route}`, + ok: emptyStateVisible || populatedMarkerVisible, finalUrl: page.url(), - snapshot: await captureSnapshot(page, `after:empty-state:${route}`), + emptyStateVisible, + populatedMarkerVisible, + snapshot: await captureSnapshot(page, `after:inventory-state:${route}`), }; } @@ -601,9 +610,9 @@ async function main() { environment: 'stage', }, })], - ['/setup/topology/targets', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/targets', 'No targets for current filters.')], - ['/setup/topology/hosts', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/hosts', 'No hosts for current filters.')], - ['/setup/topology/agents', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/agents', 'No groups for current filters.')], + ['/setup/topology/targets', (currentPage) => verifyInventorySurface(currentPage, '/setup/topology/targets', 'No targets for current filters.')], + ['/setup/topology/hosts', (currentPage) => verifyInventorySurface(currentPage, '/setup/topology/hosts', 'No hosts for current filters.')], + ['/setup/topology/agents', (currentPage) => verifyInventorySurface(currentPage, '/setup/topology/agents', 'No groups for current filters.')], ]; const summary = { diff --git a/src/Web/StellaOps.Web/scripts/live-user-reported-admin-trust-check.mjs b/src/Web/StellaOps.Web/scripts/live-user-reported-admin-trust-check.mjs index 24dc56444..c653247da 100644 --- a/src/Web/StellaOps.Web/scripts/live-user-reported-admin-trust-check.mjs +++ b/src/Web/StellaOps.Web/scripts/live-user-reported-admin-trust-check.mjs @@ -33,6 +33,21 @@ async function navigate(page, route) { await settle(page); } +async function waitForDocsContent(page) { + await settle(page, 1500); + await page.waitForFunction( + () => { + const docsContent = document.querySelector('.docs-viewer__content, [data-testid="docs-content"]'); + const text = typeof docsContent?.textContent === 'string' + ? docsContent.textContent.replace(/\s+/g, ' ').trim() + : ''; + return text.length > 64; + }, + null, + { timeout: 12_000 }, + ).catch(() => {}); +} + async function snapshot(page, label) { const heading = await page.locator('h1, h2, [data-testid="page-title"], .page-title').first().innerText().catch(() => ''); const alerts = await page @@ -351,6 +366,7 @@ async function main() { console.log('[live-user-reported-admin-trust-check] docs'); await navigate(page, '/docs/modules/platform/architecture-overview.md'); + await waitForDocsContent(page); results.push({ action: 'docs:architecture-overview', snapshot: await snapshot(page, 'docs:architecture-overview'), diff --git a/src/Web/StellaOps.Web/scripts/live-watchlist-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-watchlist-action-sweep.mjs index 86bd7cb1b..0ba7c171c 100644 --- a/src/Web/StellaOps.Web/scripts/live-watchlist-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-watchlist-action-sweep.mjs @@ -132,6 +132,28 @@ async function waitForMessage(page, text) { ); } +async function waitForAlertsResolution(page) { + await page.waitForFunction( + () => { + const loadingText = Array.from(document.querySelectorAll('body *')) + .map((node) => (node.textContent || '').trim()) + .find((text) => text === 'Loading watchlist alerts...'); + if (loadingText) { + return false; + } + + const alertRows = document.querySelectorAll('tr[data-testid="alert-row"]').length; + const emptyState = Array.from(document.querySelectorAll('.empty-state')) + .map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()) + .find((text) => text.includes('No alerts match the current scope')); + + return alertRows > 0 || Boolean(emptyState); + }, + null, + { timeout: 20_000 }, + ).catch(() => {}); +} + async function main() { await mkdir(outputDir, { recursive: true }); @@ -298,6 +320,7 @@ async function main() { if (alertsTab) { await alertsTab.click({ timeout: 10_000 }); await settle(page); + await waitForAlertsResolution(page); const alertRows = await page.locator('tr[data-testid="alert-row"]').count(); const emptyState = (await page.locator('.empty-state').first().textContent().catch(() => '')).trim(); results.push({ diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts index 68082bc07..2b64fe760 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts @@ -5,7 +5,8 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router, ActivatedRoute } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, RouterLink, provideRouter } from '@angular/router'; import { of, throwError } from 'rxjs'; import { NotificationDashboardComponent } from './notification-dashboard.component'; import { NOTIFIER_API } from '../../../core/api/notifier.client'; @@ -15,7 +16,6 @@ describe('NotificationDashboardComponent', () => { let component: NotificationDashboardComponent; let fixture: ComponentFixture; let mockApi: jasmine.SpyObj; - let mockRouter: jasmine.SpyObj; const mockRules: NotifierRule[] = [ { @@ -56,7 +56,7 @@ describe('NotificationDashboardComponent', () => { name: 'email-ops', type: 'Email', enabled: true, - config: { toAddresses: ['ops@example.com'] }, + config: { toAddresses: ['ops@example.com'], fromAddress: 'stella@example.com' }, createdAt: '2025-01-01T00:00:00Z', }, ]; @@ -79,7 +79,6 @@ describe('NotificationDashboardComponent', () => { 'listChannels', 'getDeliveryStats', ]); - mockRouter = jasmine.createSpyObj('Router', ['navigate']); mockApi.listRules.and.returnValue(of({ items: mockRules, total: 2 })); mockApi.listChannels.and.returnValue(of({ items: mockChannels, total: 2 })); @@ -96,8 +95,8 @@ describe('NotificationDashboardComponent', () => { await TestBed.configureTestingModule({ imports: [NotificationDashboardComponent], providers: [ + provideRouter([]), { provide: NOTIFIER_API, useValue: mockApi }, - { provide: Router, useValue: mockRouter }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, ], }).compileComponents(); @@ -286,6 +285,23 @@ describe('NotificationDashboardComponent', () => { expect(compiled.querySelector('.tab-navigation')).toBeTruthy(); }); + it('hides decorative tab icons from accessible names', () => { + const compiled = fixture.nativeElement as HTMLElement; + const icons = Array.from(compiled.querySelectorAll('.tab-navigation .tab-icon')); + + expect(icons.length).toBe(component.tabs.length); + expect(icons.every((icon) => icon.getAttribute('aria-hidden') === 'true')).toBeTrue(); + }); + + it('labels top-level tabs with their plain text names', () => { + const compiled = fixture.nativeElement as HTMLElement; + const tabButtons = Array.from(compiled.querySelectorAll('.tab-navigation .tab-button')); + + expect(tabButtons.map((tab) => tab.getAttribute('aria-label'))).toEqual( + component.tabs.map((tab) => tab.label), + ); + }); + it('should display stat cards with values', () => { const compiled = fixture.nativeElement as HTMLElement; expect(compiled.textContent).toContain('150'); // totalSent @@ -300,6 +316,18 @@ describe('NotificationDashboardComponent', () => { expect(compiled.querySelector('.sub-navigation')).toBeTruthy(); }); + it('preserves query params through top and config sub-navigation', () => { + component.setActiveTab('config'); + fixture.detectChanges(); + + const navLinks = fixture.debugElement + .queryAll(By.directive(RouterLink)) + .map((debugElement) => debugElement.injector.get(RouterLink)); + + expect(navLinks.length).toBe(10); + expect(navLinks.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + }); + it('should display sub-navigation when delivery tab is active', () => { component.setActiveTab('delivery'); fixture.detectChanges(); @@ -308,6 +336,18 @@ describe('NotificationDashboardComponent', () => { expect(compiled.querySelector('.sub-navigation')).toBeTruthy(); }); + it('preserves query params through delivery sub-navigation', () => { + component.setActiveTab('delivery'); + fixture.detectChanges(); + + const navLinks = fixture.debugElement + .queryAll(By.directive(RouterLink)) + .map((debugElement) => debugElement.injector.get(RouterLink)); + + expect(navLinks.length).toBe(8); + expect(navLinks.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + }); + it('should display error banner when error exists', () => { component['error'].set('Test error message'); fixture.detectChanges(); diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts index 8eb90ef6f..8cbc9f3f0 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts @@ -110,11 +110,13 @@ interface ConfigSubTab { [class.active]="activeTab() === tab.id" [attr.aria-selected]="activeTab() === tab.id" [attr.aria-controls]="'panel-' + tab.id" + [attr.aria-label]="tab.label" role="tab" [routerLink]="tab.route" + queryParamsHandling="merge" routerLinkActive="active" (click)="setActiveTab(tab.id)"> - {{ tab.icon }} + {{ tab.label }} } @@ -127,6 +129,8 @@ interface ConfigSubTab { {{ subTab.label }} @@ -137,8 +141,8 @@ interface ConfigSubTab { @if (activeTab() === 'delivery') { } diff --git a/src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.spec.ts b/src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.spec.ts new file mode 100644 index 000000000..1d67bd616 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.spec.ts @@ -0,0 +1,47 @@ +import { renderMarkdownDocument } from './docs-markdown'; + +describe('renderMarkdownDocument', () => { + function resolveDocsLink(target: string): string | null { + return target.startsWith('http') ? target : `/docs/${target.replace(/^\.\//, '')}`; + } + + it('renders malformed inline code fences without stalling the document parser', () => { + const markdown = [ + '# AdvisoryAI chat', + '', + '```n User: What is the status of CVE-2023-44487?', + '', + 'Assistant: It is reachable in the current environment.', + '```n', + '', + 'Follow-up paragraph.', + ].join('\n'); + + const rendered = renderMarkdownDocument(markdown, 'modules/advisory-ai/chat-interface.md', resolveDocsLink); + + expect(rendered.title).toBe('AdvisoryAI chat'); + expect(rendered.headings.map((heading) => heading.text)).toEqual(['AdvisoryAI chat']); + expect(rendered.html).toContain('User: What is the status of CVE-2023-44487?'); + expect(rendered.html).toContain('Assistant: It is reachable in the current environment.'); + expect(rendered.html).toContain('

Follow-up paragraph.

'); + }); + + it('preserves valid fenced code blocks and inline links', () => { + const markdown = [ + '## Example', + '', + 'Visit [the docs](./README.md).', + '', + '```yaml', + 'enabled: true', + '```', + ].join('\n'); + + const rendered = renderMarkdownDocument(markdown, 'README.md', resolveDocsLink); + + expect(rendered.headings[0]).toEqual({ level: 2, text: 'Example', id: 'example' }); + expect(rendered.html).toContain('href="/docs/README.md"'); + expect(rendered.html).toContain('language-yaml'); + expect(rendered.html).toContain('enabled: true'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.ts b/src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.ts new file mode 100644 index 000000000..8a83c55c9 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.ts @@ -0,0 +1,220 @@ +export interface DocsHeading { + level: number; + text: string; + id: string; +} + +export interface RenderedDocument { + title: string; + headings: DocsHeading[]; + html: string; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function slugifyHeading(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[`*_~]/g, '') + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); +} + +function renderInlineMarkdown(value: string, currentDocPath: string, resolveDocsLink: (target: string, currentDocPath: string) => string | null): string { + let rendered = escapeHtml(value); + + rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, target: string) => { + const resolvedHref = resolveDocsLink(target, currentDocPath) ?? '#'; + const isExternal = /^[a-z][a-z0-9+.-]*:\/\//i.test(resolvedHref); + const attributes = isExternal ? ' target="_blank" rel="noopener"' : ''; + return `${escapeHtml(label)}`; + }); + + rendered = rendered.replace(/`([^`]+)`/g, '$1'); + rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '$1'); + rendered = rendered.replace(/\*([^*]+)\*/g, '$1'); + + return rendered; +} + +function isCodeFence(line: string): boolean { + return /^```/.test(line.trim()); +} + +function parseFenceStart(line: string): { language: string; inlineContent: string } { + const fenceContent = line.trim().slice(3).trimStart(); + if (!fenceContent) { + return { language: '', inlineContent: '' }; + } + + const match = fenceContent.match(/^([^\s]+)(?:\s+(.*))?$/); + if (!match) { + return { language: '', inlineContent: fenceContent }; + } + + const [, token, remainder = ''] = match; + const normalizedToken = token.toLowerCase(); + + if (normalizedToken === 'n' && remainder.trim().length > 0) { + return { language: '', inlineContent: remainder.trim() }; + } + + if (['text', 'txt', 'json', 'yaml', 'yml', 'bash', 'sh', 'shell', 'powershell', 'ps1', 'http', 'sql', 'xml', 'html', 'css', 'ts', 'tsx', 'js', 'jsx', 'csharp', 'cs', 'md', 'markdown'].includes(normalizedToken)) { + return { language: token, inlineContent: remainder.trim() }; + } + + return { language: '', inlineContent: fenceContent }; +} + +function pushParagraph(parts: string[], paragraphLines: string[], currentDocPath: string, resolveDocsLink: (target: string, currentDocPath: string) => string | null): void { + if (paragraphLines.length === 0) { + return; + } + + parts.push(`

${renderInlineMarkdown(paragraphLines.join(' '), currentDocPath, resolveDocsLink)}

`); +} + +export function renderMarkdownDocument( + markdown: string, + currentDocPath: string, + resolveDocsLink: (target: string, currentDocPath: string) => string | null, +): RenderedDocument { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const parts: string[] = []; + const headings: DocsHeading[] = []; + let title = 'Documentation'; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + + if (!line.trim()) { + index += 1; + continue; + } + + if (isCodeFence(line)) { + const { language, inlineContent } = parseFenceStart(line); + index += 1; + const codeLines: string[] = []; + if (inlineContent) { + codeLines.push(inlineContent); + } + + while (index < lines.length && !isCodeFence(lines[index])) { + codeLines.push(lines[index]); + index += 1; + } + if (index < lines.length) { + index += 1; + } + + parts.push( + `
${escapeHtml(codeLines.join('\n'))}
`, + ); + continue; + } + + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + const level = headingMatch[1].length; + const text = headingMatch[2].trim(); + const id = slugifyHeading(text); + if (headings.length === 0 && text) { + title = text; + } + headings.push({ level, text, id }); + parts.push(`${renderInlineMarkdown(text, currentDocPath, resolveDocsLink)}`); + index += 1; + continue; + } + + if (/^\s*>\s?/.test(line)) { + const quoteLines: string[] = []; + while (index < lines.length && /^\s*>\s?/.test(lines[index])) { + quoteLines.push(lines[index].replace(/^\s*>\s?/, '').trim()); + index += 1; + } + + parts.push( + `
${quoteLines.map((entry) => `

${renderInlineMarkdown(entry, currentDocPath, resolveDocsLink)}

`).join('')}
`, + ); + continue; + } + + if (/^\s*[-*]\s+/.test(line)) { + const items: string[] = []; + while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) { + items.push(lines[index].replace(/^\s*[-*]\s+/, '').trim()); + index += 1; + } + + parts.push(`
    ${items.map((item) => `
  • ${renderInlineMarkdown(item, currentDocPath, resolveDocsLink)}
  • `).join('')}
`); + continue; + } + + if (/^\s*\d+\.\s+/.test(line)) { + const items: string[] = []; + while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index])) { + items.push(lines[index].replace(/^\s*\d+\.\s+/, '').trim()); + index += 1; + } + + parts.push(`
    ${items.map((item) => `
  1. ${renderInlineMarkdown(item, currentDocPath, resolveDocsLink)}
  2. `).join('')}
`); + continue; + } + + if (/^\|/.test(line)) { + const tableLines: string[] = []; + while (index < lines.length && /^\|/.test(lines[index])) { + tableLines.push(lines[index]); + index += 1; + } + + parts.push(`
${escapeHtml(tableLines.join('\n'))}
`); + continue; + } + + const paragraphLines: string[] = []; + while ( + index < lines.length && + lines[index].trim() && + !/^(#{1,6})\s+/.test(lines[index]) && + !isCodeFence(lines[index]) && + !/^\s*>\s?/.test(lines[index]) && + !/^\s*[-*]\s+/.test(lines[index]) && + !/^\s*\d+\.\s+/.test(lines[index]) && + !/^\|/.test(lines[index]) + ) { + paragraphLines.push(lines[index].trim()); + index += 1; + } + + if (paragraphLines.length > 0) { + pushParagraph(parts, paragraphLines, currentDocPath, resolveDocsLink); + continue; + } + + pushParagraph(parts, [line.trim()], currentDocPath, resolveDocsLink); + index += 1; + } + + if (parts.length === 0) { + parts.push('

No rendered documentation content is available for this entry.

'); + } + + return { + title, + headings, + html: parts.join('\n'), + }; +} diff --git a/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts index 5fa408d2a..0319df7e5 100644 --- a/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts @@ -11,177 +11,7 @@ import { parseDocsUrl, resolveDocsLink, } from '../../core/navigation/docs-route'; - -interface DocsHeading { - level: number; - text: string; - id: string; -} - -interface RenderedDocument { - title: string; - headings: DocsHeading[]; - html: string; -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function slugifyHeading(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[`*_~]/g, '') - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-'); -} - -function renderInlineMarkdown(value: string, currentDocPath: string): string { - let rendered = escapeHtml(value); - - rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, target: string) => { - const resolvedHref = resolveDocsLink(target, currentDocPath) ?? '#'; - const isExternal = /^[a-z][a-z0-9+.-]*:\/\//i.test(resolvedHref); - const attributes = isExternal ? ' target="_blank" rel="noopener"' : ''; - return `${escapeHtml(label)}`; - }); - - rendered = rendered.replace(/`([^`]+)`/g, '$1'); - rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '$1'); - rendered = rendered.replace(/\*([^*]+)\*/g, '$1'); - - return rendered; -} - -function renderMarkdownDocument(markdown: string, currentDocPath: string): RenderedDocument { - const lines = markdown.replace(/\r\n/g, '\n').split('\n'); - const parts: string[] = []; - const headings: DocsHeading[] = []; - let title = 'Documentation'; - let index = 0; - - while (index < lines.length) { - const line = lines[index]; - - if (!line.trim()) { - index++; - continue; - } - - const codeFenceMatch = line.match(/^```(\w+)?\s*$/); - if (codeFenceMatch) { - const language = codeFenceMatch[1]?.trim() ?? ''; - index++; - const codeLines: string[] = []; - while (index < lines.length && !/^```/.test(lines[index])) { - codeLines.push(lines[index]); - index++; - } - if (index < lines.length) { - index++; - } - - parts.push( - `
${escapeHtml(codeLines.join('\n'))}
`, - ); - continue; - } - - const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); - if (headingMatch) { - const level = headingMatch[1].length; - const text = headingMatch[2].trim(); - const id = slugifyHeading(text); - if (headings.length === 0 && text) { - title = text; - } - headings.push({ level, text, id }); - parts.push(`${renderInlineMarkdown(text, currentDocPath)}`); - index++; - continue; - } - - if (/^\s*>\s?/.test(line)) { - const quoteLines: string[] = []; - while (index < lines.length && /^\s*>\s?/.test(lines[index])) { - quoteLines.push(lines[index].replace(/^\s*>\s?/, '').trim()); - index++; - } - - parts.push( - `
${quoteLines.map((entry) => `

${renderInlineMarkdown(entry, currentDocPath)}

`).join('')}
`, - ); - continue; - } - - if (/^\s*[-*]\s+/.test(line)) { - const items: string[] = []; - while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) { - items.push(lines[index].replace(/^\s*[-*]\s+/, '').trim()); - index++; - } - - parts.push(`
    ${items.map((item) => `
  • ${renderInlineMarkdown(item, currentDocPath)}
  • `).join('')}
`); - continue; - } - - if (/^\s*\d+\.\s+/.test(line)) { - const items: string[] = []; - while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index])) { - items.push(lines[index].replace(/^\s*\d+\.\s+/, '').trim()); - index++; - } - - parts.push(`
    ${items.map((item) => `
  1. ${renderInlineMarkdown(item, currentDocPath)}
  2. `).join('')}
`); - continue; - } - - if (/^\|/.test(line)) { - const tableLines: string[] = []; - while (index < lines.length && /^\|/.test(lines[index])) { - tableLines.push(lines[index]); - index++; - } - - parts.push(`
${escapeHtml(tableLines.join('\n'))}
`); - continue; - } - - const paragraphLines: string[] = []; - while ( - index < lines.length && - lines[index].trim() && - !/^(#{1,6})\s+/.test(lines[index]) && - !/^```/.test(lines[index]) && - !/^\s*>\s?/.test(lines[index]) && - !/^\s*[-*]\s+/.test(lines[index]) && - !/^\s*\d+\.\s+/.test(lines[index]) && - !/^\|/.test(lines[index]) - ) { - paragraphLines.push(lines[index].trim()); - index++; - } - - parts.push(`

${renderInlineMarkdown(paragraphLines.join(' '), currentDocPath)}

`); - } - - if (parts.length === 0) { - parts.push('

No rendered documentation content is available for this entry.

'); - } - - return { - title, - headings, - html: parts.join('\n'), - }; -} +import { DocsHeading, renderMarkdownDocument, slugifyHeading } from './docs-markdown'; @Component({ selector: 'app-docs-viewer', @@ -492,7 +322,7 @@ export class DocsViewerComponent { return; } - const rendered = renderMarkdownDocument(markdown, path); + const rendered = renderMarkdownDocument(markdown, path, resolveDocsLink); this.title.set(rendered.title); this.headings.set(rendered.headings); this.resolvedAssetPath.set(candidate); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.spec.ts index bc9f6bde2..af8460edd 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter, Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { provideRouter, Router, RouterLink } from '@angular/router'; import { of } from 'rxjs'; import { TRUST_API, type TrustApi } from '../../core/api/trust.client'; @@ -35,9 +36,9 @@ describe('TrustAdminComponent', () => { }; beforeEach(async () => { - trustApi = jasmine.createSpyObj('TrustApi', [ + trustApi = jasmine.createSpyObj('TrustApi', [ 'getAdministrationOverview', - ]); + ]) as unknown as jasmine.SpyObj; trustApi.getAdministrationOverview.and.returnValue(of(overviewFixture)); await TestBed.configureTestingModule({ @@ -90,4 +91,15 @@ describe('TrustAdminComponent', () => { expect(component.activeTab()).toBe('overview'); }); + + it('preserves scope query params on every trust shell tab', () => { + fixture.detectChanges(); + + const tabLinks = fixture.debugElement + .queryAll(By.directive(RouterLink)) + .map((debugElement) => debugElement.injector.get(RouterLink)); + + expect(tabLinks.length).toBe(9); + expect(tabLinks.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts index 79c993df3..2ac60a83f 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts @@ -122,6 +122,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ [class.trust-admin__tab--active]="activeTab() === 'overview'" routerLink="overview" [queryParams]="{}" + queryParamsHandling="merge" role="tab" [attr.aria-selected]="activeTab() === 'overview'" > @@ -131,6 +132,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ class="trust-admin__tab" [class.trust-admin__tab--active]="activeTab() === 'keys'" routerLink="keys" + queryParamsHandling="merge" role="tab" [attr.aria-selected]="activeTab() === 'keys'" > @@ -143,6 +145,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ class="trust-admin__tab" [class.trust-admin__tab--active]="activeTab() === 'issuers'" routerLink="issuers" + queryParamsHandling="merge" role="tab" [attr.aria-selected]="activeTab() === 'issuers'" > @@ -152,6 +155,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ class="trust-admin__tab" [class.trust-admin__tab--active]="activeTab() === 'certificates'" routerLink="certificates" + queryParamsHandling="merge" role="tab" [attr.aria-selected]="activeTab() === 'certificates'" > @@ -164,6 +168,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ class="trust-admin__tab" [class.trust-admin__tab--active]="activeTab() === 'watchlist'" routerLink="watchlist" + queryParamsHandling="merge" role="tab" [attr.aria-selected]="activeTab() === 'watchlist'" > @@ -173,6 +178,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ class="trust-admin__tab" [class.trust-admin__tab--active]="activeTab() === 'audit'" routerLink="audit" + queryParamsHandling="merge" role="tab" [attr.aria-selected]="activeTab() === 'audit'" > @@ -182,6 +188,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ class="trust-admin__tab" [class.trust-admin__tab--active]="activeTab() === 'airgap'" routerLink="airgap" + queryParamsHandling="merge" role="tab" [attr.aria-selected]="activeTab() === 'airgap'" > @@ -191,6 +198,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ class="trust-admin__tab" [class.trust-admin__tab--active]="activeTab() === 'incidents'" routerLink="incidents" + queryParamsHandling="merge" role="tab" [attr.aria-selected]="activeTab() === 'incidents'" > @@ -200,6 +208,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ class="trust-admin__tab" [class.trust-admin__tab--active]="activeTab() === 'analytics'" routerLink="analytics" + queryParamsHandling="merge" role="tab" [attr.aria-selected]="activeTab() === 'analytics'" > diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index 599a75ae2..6a9586f51 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -13,9 +13,11 @@ "src/app/core/console/console-status.service.spec.ts", "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/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", + "src/app/features/docs/docs-markdown.spec.ts", "src/app/features/evidence-export/evidence-bundles.component.spec.ts", "src/app/features/evidence-export/export-center.component.spec.ts", "src/app/features/evidence-export/provenance-visualization.component.spec.ts", @@ -28,6 +30,7 @@ "src/app/features/policy-simulation/simulation-dashboard.component.spec.ts", "src/app/features/registry-admin/components/plan-audit.component.spec.ts", "src/app/features/registry-admin/registry-admin.component.spec.ts", + "src/app/features/trust-admin/trust-admin.component.spec.ts", "src/app/features/triage/services/ttfs-telemetry.service.spec.ts", "src/app/features/triage/triage-workspace.component.spec.ts", "src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",