diff --git a/docker/verify-pending-updates.sh b/docker/verify-pending-updates.sh new file mode 100755 index 00000000..95472e2e --- /dev/null +++ b/docker/verify-pending-updates.sh @@ -0,0 +1,212 @@ +#!/bin/bash +set -uo pipefail + +# Live gate for the PER-SITE ARTIFACT REVISION that backs the pending-updates indicator +# (design docs/plans/2026-09-14-pending-updates-design.md, plan task 10). Drives the real +# CLI -> Traefik -> active central -> ArtifactDeploymentService path, so every assertion +# below is end-to-end against the running rig. +# +# Steps: +# A site list carries artifactRevisionHash / artifactsDeployedAt (the columns are live) +# CAVEAT: the management API serializer omits null properties, so on a site that has +# never had an artifact deploy the two keys are ABSENT rather than present-and-null. +# A therefore asserts the shape (absent, or a well-formed sha256) and B is the proof. +# B deploy artifacts fleet-wide -> every site stamped sha256:<64 hex>, deployed-at is now +# C create a throwaway shared script -> hashes UNCHANGED until a deploy, then ALL CHANGED +# D delete it, redeploy -> every hash is byte-identical to B again (determinism) +# E Central UI /admin/sites answers 200 and serves the Artifacts badge markup (soft) +# +# NOTE on step E: the Sites page badge and the rail-footer indicator are Blazor Server +# interactive content — the badge text only lands after the circuit's post-render +# LoadUpdatesAsync, which curl never sees. E is therefore a reachability + static-markup +# smoke only; the real browser coverage is +# tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs. +# +# Usage: bash docker/verify-pending-updates.sh +# SCADABRIDGE_URL=http://localhost:9000 bash docker/verify-pending-updates.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CLI="${SCADABRIDGE_CLI:-$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge}" +URL="${SCADABRIDGE_URL:-http://localhost:9000}" +A=(--url "$URL" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}") +PROBE_NAME="PendingUpdatesProbe_$(date +%s)" + +PASS=0; FAIL=0 +pass() { PASS=$((PASS+1)); echo " PASS $*"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL $*"; } + +[ -x "$CLI" ] || { echo "CLI not found at $CLI — build it: dotnet build src/ZB.MOM.WW.ScadaBridge.CLI"; exit 1; } + +# The CLI's JSON envelope varies by command (bare array vs {"data": [...]}); normalise once. +UNWRAP=' +import json,sys +d=json.load(sys.stdin) +if isinstance(d, dict): + d = d.get("data", d) +' + +# Prints one "\t\t\t" line per site, id-sorted. +site_rows() { + "$CLI" site list "${A[@]}" 2>/dev/null | python3 -c "$UNWRAP"' +for s in sorted(d, key=lambda x: x["id"]): + print("%s\t%s\t%s\t%s" % (s["id"], s.get("siteIdentifier"), + s.get("artifactRevisionHash") or "-", + s.get("artifactsDeployedAt") or "-")) +' +} + +deploy_all() { + "$CLI" site deploy-artifacts "${A[@]}" > /tmp/pu-deploy.$$ 2>&1 + local rc=$? + if [ $rc -ne 0 ]; then + echo " deploy-artifacts exited $rc:"; sed 's/^/ /' /tmp/pu-deploy.$$ + fi + rm -f /tmp/pu-deploy.$$ + return $rc +} + +echo "=== ScadaBridge pending-updates live gate ===" +echo "management url: $URL" + +echo "" +echo "=== A: site list exposes the artifact-revision columns ===" +RAW=$("$CLI" site list "${A[@]}" 2>&1) +OUT=$(printf '%s' "$RAW" | python3 -c "$UNWRAP"' +import re +bad=[] +stamped=0 +for s in d: + h=s.get("artifactRevisionHash") + at=s.get("artifactsDeployedAt") + if h is None and at is None: + continue # never deployed: both keys omitted together + if h is not None and at is not None and re.fullmatch(r"sha256:[0-9a-f]{64}", h): + stamped+=1 + continue + bad.append("%s(hash=%r,at=%r)" % (s.get("siteIdentifier"), h, at)) +print("EMPTY" if not d else + ("OK %d site(s), %d already stamped" % (len(d), stamped) if not bad + else "malformed on " + ",".join(bad))) +' 2>&1) +case "$OUT" in + OK*) pass "A ${OUT#OK }: artifact-revision fields present and well-formed (or absent = never deployed)" ;; + EMPTY) fail "A site list returned no sites — run docker/seed-sites.sh"; echo "$RAW" | head -3; exit 1 ;; + *) fail "A $OUT"; echo "$RAW" | head -5; exit 1 ;; +esac +SITE_COUNT=$(site_rows | wc -l | tr -d ' ') +echo " $SITE_COUNT sites" + +echo "" +echo "=== B: fleet-wide deploy stamps every site ===" +deploy_all && pass "B0 site deploy-artifacts (fleet-wide) succeeded" \ + || fail "B0 site deploy-artifacts (fleet-wide) failed" +B_ROWS=$(site_rows) +OUT=$(printf '%s\n' "$B_ROWS" | python3 -c ' +import sys,re,datetime +bad_hash=[]; bad_time=[] +now=datetime.datetime.now(datetime.timezone.utc) +rows=[l.rstrip("\n").split("\t") for l in sys.stdin if l.strip()] +for sid,ident,h,at in rows: + if not re.fullmatch(r"sha256:[0-9a-f]{64}", h or ""): + bad_hash.append("%s=%r" % (ident, h)) + try: + t=datetime.datetime.fromisoformat((at or "").replace("Z","+00:00")) + if t.tzinfo is None: t=t.replace(tzinfo=datetime.timezone.utc) + age=(now-t).total_seconds() + if not (-60 <= age <= 300): bad_time.append("%s=%.0fs old" % (ident, age)) + except Exception: + bad_time.append("%s=%r" % (ident, at)) +print("HASH " + ("OK" if not bad_hash else ",".join(bad_hash))) +print("TIME " + ("OK" if not bad_time else ",".join(bad_time))) +') +HASH_LINE=$(printf '%s\n' "$OUT" | sed -n 's/^HASH //p') +TIME_LINE=$(printf '%s\n' "$OUT" | sed -n 's/^TIME //p') +[ "$HASH_LINE" = "OK" ] && pass "B1 every site hash is sha256:<64 hex> (71 chars)" \ + || fail "B1 bad hash(es): $HASH_LINE" +[ "$TIME_LINE" = "OK" ] && pass "B2 every artifactsDeployedAt is within the last 5 minutes" \ + || fail "B2 stale/unparseable deployed-at: $TIME_LINE" + +echo "" +echo "=== C: a new shared script moves the hash — but only on deploy ===" +CREATE=$("$CLI" shared-script create "${A[@]}" --name "$PROBE_NAME" \ + --code 'var pendingUpdatesProbe = 1; return pendingUpdatesProbe;' 2>&1) +# `shared-script create` echoes the created entity, so the id comes straight back. +PROBE_ID=$(printf '%s' "$CREATE" | python3 -c "$UNWRAP"' +print(d.get("id","") if isinstance(d, dict) else "") +' 2>/dev/null) +if [ -n "$PROBE_ID" ]; then + pass "C0 created shared script $PROBE_NAME (id=$PROBE_ID)" +else + fail "C0 shared-script create failed: $(printf '%s' "$CREATE" | head -2)" + exit 1 +fi + +# Cleanup even if a later assertion aborts the run: the rig must not keep the probe. +cleanup() { + if [ -n "${PROBE_ID:-}" ]; then + "$CLI" shared-script delete "${A[@]}" --id "$PROBE_ID" >/dev/null 2>&1 && PROBE_ID="" + fi +} +trap cleanup EXIT + +C_BEFORE=$(site_rows) +# Compare hash columns only — artifactsDeployedAt is untouched here by definition. +if [ "$(printf '%s\n' "$C_BEFORE" | cut -f1,3)" = "$(printf '%s\n' "$B_ROWS" | cut -f1,3)" ]; then + pass "C1 hashes UNCHANGED by the create (the stamp only moves on deploy)" +else + fail "C1 a hash moved without a deploy" + diff <(printf '%s\n' "$B_ROWS" | cut -f1,3) <(printf '%s\n' "$C_BEFORE" | cut -f1,3) | sed 's/^/ /' +fi + +deploy_all && pass "C2 redeploy after the create succeeded" || fail "C2 redeploy after the create failed" +C_ROWS=$(site_rows) +UNCHANGED=$(diff <(printf '%s\n' "$B_ROWS" | cut -f1,3) <(printf '%s\n' "$C_ROWS" | cut -f1,3) >/dev/null && echo yes || echo no) +if [ "$UNCHANGED" = "no" ]; then + # Every site must have moved, not just some. + SAME=$(join -t$'\t' <(printf '%s\n' "$B_ROWS" | cut -f1,2,3) <(printf '%s\n' "$C_ROWS" | cut -f1,3) \ + | awk -F'\t' '$3==$4 {print $2}') + [ -z "$SAME" ] && pass "C3 EVERY site's hash changed after deploying the new shared script" \ + || fail "C3 hash did not move for: $(echo $SAME)" +else + fail "C3 no site's hash changed after deploying the new shared script" +fi + +echo "" +echo "=== D: deleting it again restores the exact B hashes (determinism) ===" +"$CLI" shared-script delete "${A[@]}" --id "$PROBE_ID" >/dev/null 2>&1 \ + && { pass "D0 deleted shared script $PROBE_NAME"; PROBE_ID=""; } \ + || fail "D0 shared-script delete failed" +deploy_all && pass "D1 redeploy after the delete succeeded" || fail "D1 redeploy after the delete failed" +D_ROWS=$(site_rows) +if diff <(printf '%s\n' "$B_ROWS" | cut -f1,3) <(printf '%s\n' "$D_ROWS" | cut -f1,3) >/dev/null; then + pass "D2 every hash is byte-identical to B — the hash is content-addressed, not deploy-addressed" +else + fail "D2 hashes did not return to their B values" + diff <(printf '%s\n' "$B_ROWS" | cut -f1,3) <(printf '%s\n' "$D_ROWS" | cut -f1,3) | sed 's/^/ /' +fi + +echo "" +echo "=== E: Central UI Sites page reachability (soft — see the header note) ===" +BODY=$(curl -sS -w '\n%{http_code}' "$URL/admin/sites" 2>/dev/null) +CODE=$(printf '%s' "$BODY" | tail -1) +if [ "$CODE" = "200" ]; then + pass "E1 GET /admin/sites -> 200" + if printf '%s' "$BODY" | grep -q 'Re-check pending updates'; then + pass "E2 prerendered markup carries the Re-check pending updates control" + else + echo " NOTE E2 the Re-check control is not in the prerendered HTML (interactive render) — covered by PendingUpdatesTests" + fi +else + echo " NOTE E1 GET /admin/sites -> $CODE (login enabled on this rig?) — covered by PendingUpdatesTests" +fi + +echo "" +echo "=== Summary ===" +printf '%-4s %-14s %-14s %-14s %-14s\n' "id" "site" "B hash" "C hash" "D hash" +join -t$'\t' <(join -t$'\t' <(printf '%s\n' "$B_ROWS" | cut -f1,2,3) <(printf '%s\n' "$C_ROWS" | cut -f1,3)) \ + <(printf '%s\n' "$D_ROWS" | cut -f1,3) \ + | awk -F'\t' '{printf "%-4s %-14s %-14s %-14s %-14s\n", $1, $2, substr($3,8,12), substr($4,8,12), substr($5,8,12)}' +echo "" +echo "PASS $PASS / FAIL $FAIL" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs new file mode 100644 index 00000000..4c0e3c0f --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs @@ -0,0 +1,145 @@ +using System.Text.RegularExpressions; +using Microsoft.Playwright; +using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; +using Xunit; +using Xunit.Abstractions; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests; + +/// +/// Real-browser coverage for the pending-updates surfaces (design +/// docs/plans/2026-09-14-pending-updates-design.md, plan task 10): the rail-footer +/// indicator, the Sites page per-site Artifacts badge and its Re-check control, and the +/// rail nav's scroll pane. +/// +/// +/// These are the browser half of the feature's live gate — docker/verify-pending-updates.sh +/// proves the server side (the hash is stamped, moves with content, and is deterministic) through +/// the CLI, but the badge text and the indicator only land after the Blazor circuit's post-render +/// load, which curl never sees. +/// +/// +/// +/// The indicator is role-gated to Deployer/Administrator; the fixture's multi-role user has +/// both, and the rig runs with login disabled (every session carries every role), so it renders +/// either way. +/// +/// +[Collection("Playwright")] +public class PendingUpdatesTests +{ + /// Terminal states of the rail indicator — anything other than "still checking". + private static readonly Regex SettledIndicator = new("Up to date|Pending updates|Updates unavailable"); + + private readonly PlaywrightFixture _fixture; + private readonly ITestOutputHelper _output; + + public PendingUpdatesTests(PlaywrightFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + [SkippableFact] + public async Task RailFooter_ShowsPendingUpdatesIndicator_AndSettles() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.SetViewportSizeAsync(1280, 900); + await page.GotoAsync(PlaywrightFixture.BaseUrl); + + // The block itself paints on the first render — before any snapshot exists it reads + // "Checking for updates…", which is exactly the state the component is designed to show + // rather than holding the layout behind a cold fleet flatten. + var indicator = page.Locator(".rail-updates"); + await Assertions.Expect(indicator).ToBeVisibleAsync(new() { Timeout = 10_000 }); + await Assertions.Expect(indicator).ToContainTextAsync( + new Regex("Checking for updates|Up to date|Pending updates|Updates unavailable"), + new() { Timeout = 10_000 }); + + // The cache's single-flight evaluation lands well inside a minute on the rig; the + // indicator must not sit on "Checking…" forever. + await Assertions.Expect(indicator).ToContainTextAsync(SettledIndicator, new() { Timeout = 60_000 }); + + var text = (await indicator.InnerTextAsync()).Trim(); + _output.WriteLine($"rail indicator settled on: {text.ReplaceLineEndings(" | ")}"); + Assert.False( + text.Contains("Updates unavailable", StringComparison.Ordinal), + $"the fleet snapshot could not be read — indicator says: {text}"); + } + + [SkippableFact] + public async Task SitesPage_ShowsAnArtifactsBadgePerSite_AndARecheckControl() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.SetViewportSizeAsync(1280, 900); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await Assertions.Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync(); + + // One card per site; the badge hangs off the card's site-identifier line, so there must be + // at least as many badges as cards. + var cards = page.Locator("div.card-body h5.card-title"); + await Assertions.Expect(cards.First).ToBeVisibleAsync(new() { Timeout = 15_000 }); + int cardCount = await cards.CountAsync(); + Assert.True(cardCount >= 1, "expected at least one site card — run docker/seed-sites.sh"); + + var badges = page.Locator("[aria-label^='Artifacts:']"); + await Assertions.Expect(badges.First).ToBeVisibleAsync(new() { Timeout = 60_000 }); + int badgeCount = await badges.CountAsync(); + Assert.True( + badgeCount >= cardCount, + $"expected an Artifacts badge on every site card: {cardCount} cards but {badgeCount} badges"); + + // The badge must settle on a real verdict rather than staying on "checking…". + var settled = page.Locator( + "[aria-label='Artifacts: current'], [aria-label='Artifacts: out of date'], [aria-label='Artifacts: never deployed']"); + await Assertions.Expect(settled.First).ToBeVisibleAsync(new() { Timeout = 60_000 }); + var badgeText = (await settled.First.InnerTextAsync()).Trim(); + _output.WriteLine($"{badgeCount} Artifacts badge(s) over {cardCount} site card(s); first reads: {badgeText}"); + Assert.Matches("Artifacts current|Artifacts out of date|Artifacts never deployed", badgeText); + + // Re-check re-evaluates the whole fleet through the same cache, forced. + var recheck = page.Locator("button[aria-label='Re-check pending updates']"); + await Assertions.Expect(recheck).ToBeVisibleAsync(); + await Assertions.Expect(recheck).ToBeEnabledAsync(new() { Timeout = 60_000 }); + await recheck.ClickAsync(); + + // After the forced re-evaluation the badges are still there and still settled — a failed + // re-check would degrade them to "Artifacts: unavailable". + await Assertions.Expect(settled.First).ToBeVisibleAsync(new() { Timeout = 60_000 }); + Assert.Equal(0, await page.Locator("[aria-label='Artifacts: unavailable']").CountAsync()); + } + + [SkippableFact] + public async Task NavRail_HasAScrollPane_AtDesktopWidth() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + var page = await _fixture.NewAuthenticatedPageAsync(); + // The rule is scoped to lg+ (min-width: 992px); below that the rail is inline and + // scrolls with the page, so the pane deliberately does not apply. + await page.SetViewportSizeAsync(1280, 900); + await page.GotoAsync(PlaywrightFixture.BaseUrl); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await PlaywrightFixture.ExpandAllNavSectionsAsync(page); + + var scroll = page.Locator("div.rail-scroll"); + Assert.Equal(1, await scroll.CountAsync()); + + var overflowY = await scroll.EvaluateAsync("el => getComputedStyle(el).overflowY"); + Assert.Equal("auto", overflowY); + + // Informational rather than asserted: scrollHeight >= clientHeight holds for any element, + // and whether the nav actually overflows depends on the signed-in user's visible sections. + var metrics = await scroll.EvaluateAsync( + "el => [el.scrollHeight, el.clientHeight]"); + Assert.True( + metrics[0] >= metrics[1], + $"rail scroll pane has scrollHeight {metrics[0]} < clientHeight {metrics[1]}"); + } +}