Three Playwright E2E failures, all test-side timing/data bugs (no
feature defects found):
- AuditGridColumnTests.ColumnOrderAndWidths_PersistAcrossReload: read
sessionStorage synchronously right after Mouse.UpAsync, racing the
async OnColumnResized/OnColumnReordered JS->.NET->JS save round-trip.
Now polls (WaitForFunctionAsync) for the storage keys and for the
reorder re-render to settle; also hardens the flaky ReorderDrag test.
- SiteCallsPageTests.FilterNarrowing_ChannelFilterShrinksGrid: the
Target-keyword #sc-search @bind committed via the Query click's own
blur, racing change vs click on the circuit so Search() sometimes
ran with a stale empty filter. Commit the value with an explicit,
fully-awaited DispatchEventAsync('change') and use the retrying
ToHaveCount assertion for the negative row checks.
- SiteCallsPageTests.RetryClickThrough_OnParkedRow: seeded SourceSite
'plant-a' is not a real cluster site (site-a/b/c), so the relay had
no ClusterClient route and only resolved on the 10s inner Ask
timeout - past the 5s toast wait. Seed a live site (site-a) for a
fast NotParked round-trip and give the toast a 15s wait.
Playwright E2E suite: 60 passed, 0 failed, 0 skipped.
334 lines
16 KiB
C#
334 lines
16 KiB
C#
using Microsoft.Playwright;
|
|
using Xunit;
|
|
|
|
namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls;
|
|
|
|
/// <summary>
|
|
/// End-to-end coverage for the central Site Calls page (Site Call Audit #22,
|
|
/// follow-ups Task 6).
|
|
///
|
|
/// <para>
|
|
/// Each test seeds its own <c>SiteCalls</c> rows directly into the running
|
|
/// cluster's configuration database via <see cref="SiteCallDataSeeder"/>,
|
|
/// exercises the UI through Playwright, then best-effort deletes the rows by
|
|
/// their <c>Target</c> prefix. The Site Calls page reads the <c>SiteCalls</c>
|
|
/// table through the <c>SiteCallAuditActor</c> (a pure read-from-table mirror),
|
|
/// so a directly-INSERTed row surfaces exactly as a telemetry-ingested row
|
|
/// would — the same seeding model the Audit Log E2E tests use. The pattern
|
|
/// keeps each test self-contained without touching
|
|
/// <c>infra/mssql/seed-config.sql</c>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Scenarios covered (per the Task 6 brief):
|
|
/// <list type="bullet">
|
|
/// <item><c>PageLoads</c> — the page renders for a Deployment-role user.</item>
|
|
/// <item><c>FilterNarrowing</c> — a channel filter narrows the results grid.</item>
|
|
/// <item><c>DrillIn</c> — the "View audit history" link deep-links into the
|
|
/// Audit Log pre-filtered to the call's TrackedOperationId.</item>
|
|
/// <item><c>RetryDiscardVisibility</c> — Retry/Discard appear only on Parked
|
|
/// rows, never on Failed (or other) rows.</item>
|
|
/// <item><c>RetryClickThrough</c> — clicking Retry on a Parked row confirms
|
|
/// the dialog, relays to the owning site, and surfaces an outcome toast.</item>
|
|
/// </list>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
|
|
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
|
|
/// matching the established <c>ScadaLink.ConfigurationDatabase.Tests</c> idiom.
|
|
/// </para>
|
|
/// </summary>
|
|
[Collection("Playwright")]
|
|
public class SiteCallsPageTests
|
|
{
|
|
private const string SiteCallsUrl = "/site-calls/report";
|
|
|
|
private readonly PlaywrightFixture _fixture;
|
|
|
|
public SiteCallsPageTests(PlaywrightFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the Target-keyword search box and commits the value to the server
|
|
/// as its own discrete circuit message before the caller clicks Query.
|
|
/// <para>
|
|
/// The <c>#sc-search</c> input is a Blazor <c>@bind</c>
|
|
/// (commit-on-<c>change</c>): <see cref="ILocator.FillAsync"/> only fires
|
|
/// <c>input</c> events, and the <c>change</c> that actually updates
|
|
/// <c>_targetFilter</c> on the server fires on blur. The original test
|
|
/// relied on the Query <c>ClickAsync</c> itself to blur the field — that
|
|
/// makes the <c>change</c> (blur) and the <c>click</c> a single, near-
|
|
/// simultaneous gesture and races them over the SignalR circuit: when the
|
|
/// <c>click</c> is processed before the <c>change</c> has updated
|
|
/// <c>_targetFilter</c>, <c>Search()</c> runs with a stale (empty) keyword
|
|
/// and the grid returns unfiltered rows.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="ILocator.DispatchEventAsync"/> raises the <c>change</c> as a
|
|
/// fully-awaited action of its own, so its circuit message is enqueued and
|
|
/// sent before the later Query <c>ClickAsync</c>'s message. The SignalR
|
|
/// connection delivers messages in send order and the Blazor circuit
|
|
/// processes them sequentially, so <c>_targetFilter</c> is guaranteed
|
|
/// committed before <c>Search()</c> runs — the two are no longer one
|
|
/// racing gesture.
|
|
/// </para>
|
|
/// </summary>
|
|
private static async Task SetSearchKeywordAsync(IPage page, string keyword)
|
|
{
|
|
var search = page.Locator("#sc-search");
|
|
await search.FillAsync(keyword);
|
|
// Commit the @bind as a discrete change event — not a blur side effect
|
|
// of the subsequent Query click.
|
|
await search.DispatchEventAsync("change");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PageLoads_ForDeploymentUser()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
Assert.Contains(SiteCallsUrl, page.Url);
|
|
await Assertions.Expect(page.Locator("h4:has-text('Site Calls')")).ToBeVisibleAsync();
|
|
// The filter card's Query button is the page's primary action.
|
|
await Assertions.Expect(page.Locator("[data-test='site-calls-query']")).ToBeVisibleAsync();
|
|
}
|
|
|
|
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
|
|
private const string DbUnavailableSkipReason =
|
|
"SiteCallDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
|
|
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.";
|
|
|
|
[SkippableFact]
|
|
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
|
|
{
|
|
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var runId = Guid.NewGuid().ToString("N");
|
|
var targetPrefix = $"playwright-test/sc-filter/{runId}/";
|
|
var apiId = Guid.NewGuid();
|
|
var dbId = Guid.NewGuid();
|
|
var now = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
// One ApiOutbound row, one DbOutbound row — distinct Targets.
|
|
await SiteCallDataSeeder.InsertSiteCallAsync(
|
|
trackedOperationId: apiId, channel: "ApiOutbound", target: targetPrefix + "api",
|
|
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
|
|
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
|
|
await SiteCallDataSeeder.InsertSiteCallAsync(
|
|
trackedOperationId: dbId, channel: "DbOutbound", target: targetPrefix + "db",
|
|
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
|
|
createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Unfiltered query: both seeded rows appear (the Target keyword scopes
|
|
// to this run so unrelated cluster rows do not interfere).
|
|
await SetSearchKeywordAsync(page, targetPrefix + "api");
|
|
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Only the ApiOutbound row matches the exact target keyword. The
|
|
// grid filters with an exact Target match, so the db row must be
|
|
// absent — use the retrying ToHaveCount assertion so the negative
|
|
// check waits out the post-query re-render rather than reading a
|
|
// point-in-time count.
|
|
await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToBeVisibleAsync();
|
|
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToHaveCountAsync(0);
|
|
|
|
// Now filter by Channel = DbOutbound with the db target — the row flips.
|
|
await SetSearchKeywordAsync(page, targetPrefix + "db");
|
|
await page.Locator("#sc-channel").SelectOptionAsync("DbOutbound");
|
|
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToBeVisibleAsync();
|
|
await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToHaveCountAsync(0);
|
|
}
|
|
finally
|
|
{
|
|
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task DrillIn_ViewAuditHistory_NavigatesToPreFilteredAuditLog()
|
|
{
|
|
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var runId = Guid.NewGuid().ToString("N");
|
|
var targetPrefix = $"playwright-test/sc-drill-in/{runId}/";
|
|
var trackedId = Guid.NewGuid();
|
|
var now = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
await SiteCallDataSeeder.InsertSiteCallAsync(
|
|
trackedOperationId: trackedId, channel: "ApiOutbound", target: targetPrefix + "endpoint",
|
|
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
|
|
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await SetSearchKeywordAsync(page, targetPrefix + "endpoint");
|
|
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The row carries a "View audit history" link whose href is the
|
|
// canonical correlationId deep-link — the TrackedOperationId IS the
|
|
// audit CorrelationId.
|
|
var link = page.Locator($"a[data-test='audit-link-{trackedId}']");
|
|
await Assertions.Expect(link).ToBeVisibleAsync();
|
|
var href = await link.GetAttributeAsync("href");
|
|
Assert.Equal($"/audit/log?correlationId={trackedId}", href);
|
|
|
|
// Following the link lands on the Audit Log page with the query-string
|
|
// drill-in context intact.
|
|
await link.ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
Assert.Contains($"correlationId={trackedId}", page.Url);
|
|
await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
|
|
}
|
|
finally
|
|
{
|
|
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task RetryDiscard_VisibleOnlyOnParkedRows()
|
|
{
|
|
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var runId = Guid.NewGuid().ToString("N");
|
|
var targetPrefix = $"playwright-test/sc-actions/{runId}/";
|
|
var parkedId = Guid.NewGuid();
|
|
var failedId = Guid.NewGuid();
|
|
var now = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
// One Parked row (actionable) and one Failed row (terminal — not
|
|
// actionable from central).
|
|
await SiteCallDataSeeder.InsertSiteCallAsync(
|
|
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
|
|
sourceSite: "plant-a", status: "Parked", retryCount: 3,
|
|
lastError: "HTTP 503 from ERP", httpStatus: 503,
|
|
createdAtUtc: now, updatedAtUtc: now);
|
|
await SiteCallDataSeeder.InsertSiteCallAsync(
|
|
trackedOperationId: failedId, channel: "DbOutbound", target: targetPrefix + "failed",
|
|
sourceSite: "plant-a", status: "Failed", retryCount: 1,
|
|
lastError: "constraint violation",
|
|
createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Query the parked row first.
|
|
await SetSearchKeywordAsync(page, targetPrefix + "parked");
|
|
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
|
|
await Assertions.Expect(parkedRow).ToBeVisibleAsync();
|
|
// The Parked row exposes both Retry and Discard.
|
|
await Assertions.Expect(parkedRow.Locator("button:has-text('Retry')")).ToBeVisibleAsync();
|
|
await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync();
|
|
|
|
// Now the Failed row — Retry/Discard are absent.
|
|
await SetSearchKeywordAsync(page, targetPrefix + "failed");
|
|
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
var failedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "failed" });
|
|
await Assertions.Expect(failedRow).ToBeVisibleAsync();
|
|
Assert.Equal(0, await failedRow.Locator("button:has-text('Retry')").CountAsync());
|
|
Assert.Equal(0, await failedRow.Locator("button:has-text('Discard')").CountAsync());
|
|
}
|
|
finally
|
|
{
|
|
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast()
|
|
{
|
|
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var runId = Guid.NewGuid().ToString("N");
|
|
var targetPrefix = $"playwright-test/sc-retry-click/{runId}/";
|
|
var parkedId = Guid.NewGuid();
|
|
var now = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
// A single Parked row — the only status from which Retry/Discard can
|
|
// be relayed to the owning site. Unlike the display-only tests above,
|
|
// this one actually relays to the owning site, so the SourceSite must
|
|
// be a *real* site identifier from the running cluster (site-a) and
|
|
// not the cosmetic "plant-a" label: an unknown site has no registered
|
|
// ClusterClient, so CentralCommunicationActor drops the envelope
|
|
// without replying and the relay only resolves on the 10s inner Ask
|
|
// timeout — too slow for the toast assertion below. Relayed to a live
|
|
// site, the site finds no parked S&F message for this freshly-seeded
|
|
// GUID and replies a fast NotParked ack, which still surfaces a toast.
|
|
await SiteCallDataSeeder.InsertSiteCallAsync(
|
|
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
|
|
sourceSite: "site-a", status: "Parked", retryCount: 3,
|
|
lastError: "HTTP 503 from ERP", httpStatus: 503,
|
|
createdAtUtc: now, updatedAtUtc: now);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await SetSearchKeywordAsync(page, targetPrefix + "parked");
|
|
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
|
|
await Assertions.Expect(parkedRow).ToBeVisibleAsync();
|
|
|
|
// Click Retry — this opens the confirmation dialog (DialogHost modal).
|
|
await parkedRow.Locator("button:has-text('Retry')").ClickAsync();
|
|
|
|
// Confirm the relay in the dialog footer ("Confirm" — the non-danger
|
|
// label; Discard would render "Delete").
|
|
var confirmButton = page.Locator(".modal-footer button:has-text('Confirm')");
|
|
await Assertions.Expect(confirmButton).ToBeVisibleAsync();
|
|
await confirmButton.ClickAsync();
|
|
|
|
// The relay outcome surfaces on a toast — Applied, NotParked or, if
|
|
// the owning site is offline in this environment, SiteUnreachable.
|
|
// We only assert that an outcome toast appears (exactly one — the
|
|
// single-toast contract), not which one, since the live cluster
|
|
// state determines the outcome. The wait is generous (15s): the
|
|
// relay round-trips to the site over ClusterClient, and a worst-case
|
|
// path can sit on the 10s inner relay timeout before the response —
|
|
// and the toast itself auto-dismisses 5s after it appears, so the
|
|
// assertion must catch it inside that window.
|
|
var toast = page.Locator(".toast");
|
|
await Assertions.Expect(toast).ToBeVisibleAsync(
|
|
new() { Timeout = 15_000 });
|
|
Assert.Equal(1, await toast.CountAsync());
|
|
}
|
|
finally
|
|
{
|
|
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
|
}
|
|
}
|
|
}
|