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:
@@ -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]}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user