623c6d0fe8
The gate could pass vacuously. Every site-list read went through a bare `ROWS=$(site_rows)` with neither the exit status nor the row count checked, and an empty result satisfied every downstream assertion for free: no rows means no malformed hash for B1/B2 to report, and a diff of two empty strings is no difference at all for C1/D2. A down management API would therefore have read as a clean gate. Reads now go through read_rows, which fails loudly on a non-zero CLI exit (site_rows exits 2 on an empty list, and pipefail surfaces a failed call) and aborts unless the line count equals the site count established in step A — keeping the running tally so the abort still prints PASS/FAIL. Verified against an unreachable URL: FAIL 1, exit 1. The join(1)/cut/diff comparisons assumed lexicographic id order matches numeric order, which breaks once a site id reaches 10 alongside single-digit ids (and BSD join silently drops the unmatched rows rather than complaining). Replaced with a python3 comparison keyed by integer id that also asserts the id SETS match, so a site appearing or vanishing between two reads invalidates the comparison instead of quietly shrinking it. Same for the summary table. Also: set -euo pipefail like every sibling script (the cmd && pass || fail guards keep -e safe, and -e does not cover var=$(cmd) — hence the explicit checks above); the cleanup trap is armed BEFORE the shared-script create and falls back to finding the probe by its unique name, so a create that succeeds server-side but whose id we fail to parse can no longer orphan a probe; mktemp instead of /tmp/pu-deploy.$$. PendingUpdatesTests: the rail-scroll check asserted scrollHeight >= clientHeight, which holds for every element. It now asserts what the pane is actually for — the pane is bounded by the viewport and the rail footer holding the indicator stays on screen, so a nav that grows the rail instead of scrolling fails. Live values are genuinely overflowing (scrollHeight 1196 vs clientHeight 680, footer bottom 765 of 900), so the assertion has real content. Re-run against the rig: PASS 13 / FAIL 0; Playwright 3/3. Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
161 lines
8.2 KiB
C#
161 lines
8.2 KiB
C#
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);
|
|
|
|
// The point of the pane is that a long nav scrolls INSIDE it instead of growing the rail
|
|
// and carrying the footer off-screen. Assert that: the pane is bounded by the viewport,
|
|
// and the rail footer (which holds the indicator this feature adds) stays within it.
|
|
// scrollHeight >= clientHeight is NOT asserted — that holds for every element.
|
|
var metrics = await scroll.EvaluateAsync<int[]>(
|
|
"el => [el.scrollHeight, el.clientHeight, Math.round(el.getBoundingClientRect().bottom)]");
|
|
_output.WriteLine(
|
|
$"rail-scroll: scrollHeight={metrics[0]} clientHeight={metrics[1]} bottom={metrics[2]} (viewport 900)");
|
|
Assert.True(
|
|
metrics[1] > 0 && metrics[1] <= 900,
|
|
$"rail scroll pane should be bounded by the 900px viewport, clientHeight was {metrics[1]}");
|
|
Assert.True(
|
|
metrics[2] <= 900,
|
|
$"rail scroll pane overflows the viewport (bottom {metrics[2]} > 900) — the nav is growing the rail instead of scrolling");
|
|
|
|
// The footer the indicator lives in must be on-screen, below the pane.
|
|
var footer = page.Locator(".rail-updates");
|
|
var footerBottom = await footer.EvaluateAsync<int>(
|
|
"el => Math.round(el.getBoundingClientRect().bottom)");
|
|
Assert.True(
|
|
footerBottom <= 900,
|
|
$"rail footer is off-screen (bottom {footerBottom} > 900) — the scroll pane is not containing the nav");
|
|
}
|
|
}
|