test(rig): verify-pending-updates.sh + Playwright smoke for the indicator and Sites badge

Rig rebuilt from this branch (image built at d8979664; migration
20260914173147_AddSiteArtifactRevision applied by central-b on boot).

verify-pending-updates.sh drives the CLI end to end: the site list carries the
artifact-revision fields, a fleet-wide deploy stamps every site with a
sha256:<64 hex> hash and a fresh timestamp, creating a shared script leaves the
stamp alone until the next deploy and then moves EVERY site's hash, and deleting
it again returns every hash byte-for-byte to its pre-create value — so the hash
is content-addressed, not deploy-addressed. PASS 13 / FAIL 0.

Step A asserts the SHAPE rather than key presence: the management API serializer
omits null properties, so on a never-deployed site the two keys are absent, not
present-and-null. Step E is a soft reachability check only; the badge text and
the rail indicator land after the Blazor circuit's post-render load.

PendingUpdatesTests covers that browser half — the rail footer settles off
"Checking for updates…", the Sites page carries an Artifacts badge per site card
plus a working Re-check control, and the rail nav's scroll pane computes
overflow-y: auto at lg+. 3/3 passed against the rig (rail read "Up to date",
3 badges over 3 cards, all "Artifacts current").

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-14 14:10:24 -04:00
parent 94e1d71ba2
commit a672f91d90
2 changed files with 357 additions and 0 deletions
+212
View File
@@ -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 "<siteId>\t<identifier>\t<hash|->\t<deployedAt|->" 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
@@ -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;
/// <summary>
/// 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.
///
/// <para>
/// These are the browser half of the feature's live gate — <c>docker/verify-pending-updates.sh</c>
/// 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.
/// </para>
///
/// <para>
/// The indicator is role-gated to Deployer/Administrator; the fixture's <c>multi-role</c> user has
/// both, and the rig runs with login disabled (every session carries every role), so it renders
/// either way.
/// </para>
/// </summary>
[Collection("Playwright")]
public class PendingUpdatesTests
{
/// <summary>Terminal states of the rail indicator — anything other than "still checking".</summary>
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<string>("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<int[]>(
"el => [el.scrollHeight, el.clientHeight]");
Assert.True(
metrics[0] >= metrics[1],
$"rail scroll pane has scrollHeight {metrics[0]} < clientHeight {metrics[1]}");
}
}