Complete scratch iteration 004 setup and grouped route-action fixes

This commit is contained in:
master
2026-03-12 19:28:42 +02:00
parent d8d3133060
commit 317e55e623
26 changed files with 1124 additions and 304 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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() {

View File

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

View File

@@ -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}`,

View File

@@ -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', () =>

View File

@@ -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(),

View File

@@ -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');

View File

@@ -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 = {

View File

@@ -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'),

View File

@@ -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({

View File

@@ -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<NotificationDashboardComponent>;
let mockApi: jasmine.SpyObj<any>;
let mockRouter: jasmine.SpyObj<Router>;
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();

View File

@@ -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)">
<span class="tab-icon">{{ tab.icon }}</span>
<span class="tab-icon" aria-hidden="true">{{ tab.icon }}</span>
<span class="tab-label">{{ tab.label }}</span>
</a>
}
@@ -127,6 +129,8 @@ interface ConfigSubTab {
<a
class="sub-tab-button"
[routerLink]="subTab.route"
[attr.aria-label]="subTab.label"
queryParamsHandling="merge"
routerLinkActive="active">
{{ subTab.label }}
</a>
@@ -137,8 +141,8 @@ interface ConfigSubTab {
<!-- Delivery Sub-Navigation -->
@if (activeTab() === 'delivery') {
<nav class="sub-navigation" role="tablist">
<a class="sub-tab-button" routerLink="delivery" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">History</a>
<a class="sub-tab-button" routerLink="delivery/analytics" routerLinkActive="active">Analytics</a>
<a class="sub-tab-button" routerLink="delivery" queryParamsHandling="merge" aria-label="History" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">History</a>
<a class="sub-tab-button" routerLink="delivery/analytics" queryParamsHandling="merge" aria-label="Analytics" routerLinkActive="active">Analytics</a>
</nav>
}

View File

@@ -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('<p>Follow-up paragraph.</p>');
});
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');
});
});

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 `<a href="${escapeHtml(resolvedHref)}"${attributes}>${escapeHtml(label)}</a>`;
});
rendered = rendered.replace(/`([^`]+)`/g, '<code>$1</code>');
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
rendered = rendered.replace(/\*([^*]+)\*/g, '<em>$1</em>');
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(`<p>${renderInlineMarkdown(paragraphLines.join(' '), currentDocPath, resolveDocsLink)}</p>`);
}
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(
`<pre class="docs-viewer__code"><code class="language-${escapeHtml(language)}">${escapeHtml(codeLines.join('\n'))}</code></pre>`,
);
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(`<h${level} id="${id}">${renderInlineMarkdown(text, currentDocPath, resolveDocsLink)}</h${level}>`);
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(
`<blockquote>${quoteLines.map((entry) => `<p>${renderInlineMarkdown(entry, currentDocPath, resolveDocsLink)}</p>`).join('')}</blockquote>`,
);
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(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath, resolveDocsLink)}</li>`).join('')}</ul>`);
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(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath, resolveDocsLink)}</li>`).join('')}</ol>`);
continue;
}
if (/^\|/.test(line)) {
const tableLines: string[] = [];
while (index < lines.length && /^\|/.test(lines[index])) {
tableLines.push(lines[index]);
index += 1;
}
parts.push(`<pre class="docs-viewer__table-fallback">${escapeHtml(tableLines.join('\n'))}</pre>`);
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('<p>No rendered documentation content is available for this entry.</p>');
}
return {
title,
headings,
html: parts.join('\n'),
};
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 `<a href="${escapeHtml(resolvedHref)}"${attributes}>${escapeHtml(label)}</a>`;
});
rendered = rendered.replace(/`([^`]+)`/g, '<code>$1</code>');
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
rendered = rendered.replace(/\*([^*]+)\*/g, '<em>$1</em>');
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(
`<pre class="docs-viewer__code"><code class="language-${escapeHtml(language)}">${escapeHtml(codeLines.join('\n'))}</code></pre>`,
);
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(`<h${level} id="${id}">${renderInlineMarkdown(text, currentDocPath)}</h${level}>`);
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(
`<blockquote>${quoteLines.map((entry) => `<p>${renderInlineMarkdown(entry, currentDocPath)}</p>`).join('')}</blockquote>`,
);
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(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath)}</li>`).join('')}</ul>`);
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(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath)}</li>`).join('')}</ol>`);
continue;
}
if (/^\|/.test(line)) {
const tableLines: string[] = [];
while (index < lines.length && /^\|/.test(lines[index])) {
tableLines.push(lines[index]);
index++;
}
parts.push(`<pre class="docs-viewer__table-fallback">${escapeHtml(tableLines.join('\n'))}</pre>`);
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(`<p>${renderInlineMarkdown(paragraphLines.join(' '), currentDocPath)}</p>`);
}
if (parts.length === 0) {
parts.push('<p>No rendered documentation content is available for this entry.</p>');
}
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);

View File

@@ -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', [
trustApi = jasmine.createSpyObj('TrustApi', [
'getAdministrationOverview',
]);
]) as unknown as jasmine.SpyObj<TrustApi>;
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();
});
});

View File

@@ -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'"
>

View File

@@ -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",