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
@@ -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]}");
}
}