Compare commits
13 Commits
efb3efe6dc
...
4a993d76da
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a993d76da | |||
| 46bc2288bf | |||
| 4a2b41155b | |||
| 1122a05d04 | |||
| 1b2290d468 | |||
| 828d035221 | |||
| d80737978d | |||
| 839770d503 | |||
| 66b503df4d | |||
| 348c01c91a | |||
| 137de66d20 | |||
| 1afb3670c5 | |||
| b4b38fe52a |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-06-playwright-coverage-fill-wave2.md",
|
||||
"lastUpdated": "2026-06-06T00:00:00Z",
|
||||
"nativeTaskIdBase": 90,
|
||||
"status": "completed",
|
||||
"tasks": [
|
||||
{"id": 0, "nativeId": 90, "subject": "Task 0: CLI helper ListAreaIdsByNamePrefixAsync", "status": "completed"},
|
||||
{"id": 1, "nativeId": 91, "subject": "Task 1: DeploymentsRealtimeTests (SignalR push + pause/refresh)", "status": "completed"},
|
||||
{"id": 2, "nativeId": 92, "subject": "Task 2: ParkedMessagesActionTests (controls guard)", "status": "completed"},
|
||||
{"id": 3, "nativeId": 93, "subject": "Task 3: SiteCalls Discard click-through", "status": "completed"},
|
||||
{"id": 4, "nativeId": 94, "subject": "Task 4: DebugView data-test hooks + cluster rebuild", "status": "completed"},
|
||||
{"id": 5, "nativeId": 95, "subject": "Task 5: DebugViewTests (controls + connect-resolves)", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 6, "nativeId": 96, "subject": "Task 6: TopologyAreaTests part 1 (create + rename)", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 7, "nativeId": 97, "subject": "Task 7: TopologyAreaTests part 2 (move + diff)", "status": "completed", "blockedBy": [6]},
|
||||
{"id": 8, "nativeId": 98, "subject": "Task 8: Wave 2 verification + residue check", "status": "completed", "blockedBy": [0, 1, 2, 3, 4, 5, 6, 7]}
|
||||
]
|
||||
}
|
||||
@@ -71,7 +71,8 @@
|
||||
<div class="row mb-3 g-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_selectedSiteId" @bind:after="LoadInstancesForSite" disabled="@_connected">
|
||||
<select class="form-select form-select-sm" data-test="debug-site-select"
|
||||
@bind="_selectedSiteId" @bind:after="LoadInstancesForSite" disabled="@_connected">
|
||||
<option value="0">Select site...</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
@@ -81,7 +82,8 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Instance</label>
|
||||
<select class="form-select form-select-sm" @bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged" disabled="@_connected">
|
||||
<select class="form-select form-select-sm" data-test="debug-instance-select"
|
||||
@bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged" disabled="@_connected">
|
||||
<option value="0">Select instance...</option>
|
||||
@foreach (var inst in _siteInstances)
|
||||
{
|
||||
|
||||
@@ -237,6 +237,52 @@ public static partial class CliRunner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ids of all areas on <paramref name="siteId"/> whose <c>name</c>
|
||||
/// starts with <paramref name="prefix"/>, via <c>site area list --site-id</c>.
|
||||
/// Used to delete areas a test created through the UI (where the new id is never
|
||||
/// surfaced to the test).
|
||||
///
|
||||
/// <para>
|
||||
/// Response shape: each element of the returned JSON array carries an integer
|
||||
/// <c>id</c> and a string <c>name</c> (empirically verified against the dev
|
||||
/// cluster — same shapes as the other create/list helpers in this file).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="siteId">Owning site id.</param>
|
||||
/// <param name="prefix">
|
||||
/// Name prefix to filter by (ordinal comparison). Typically the full unique name
|
||||
/// produced by <see cref="UniqueName"/> so exactly one id is returned.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The ids of every area on <paramref name="siteId"/> whose name starts with
|
||||
/// <paramref name="prefix"/>; empty if no areas match.
|
||||
/// </returns>
|
||||
public static async Task<IReadOnlyList<int>> ListAreaIdsByNamePrefixAsync(int siteId, string prefix)
|
||||
{
|
||||
using var doc = await RunJsonAsync(
|
||||
"site", "area", "list",
|
||||
"--site-id", siteId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
var ids = new List<int>();
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var area in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (area.TryGetProperty("name", out var name)
|
||||
&& name.ValueKind == JsonValueKind.String
|
||||
&& (name.GetString()?.StartsWith(prefix, StringComparison.Ordinal) ?? false)
|
||||
&& area.TryGetProperty("id", out var id)
|
||||
&& id.TryGetInt32(out var areaId))
|
||||
{
|
||||
ids.Add(areaId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort delete of a site via <c>site delete</c> for teardown; swallows
|
||||
/// any failure.
|
||||
|
||||
+27
@@ -84,4 +84,31 @@ public class CliRunnerHelpersTests
|
||||
await CliRunner.DeleteApiMethodAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A freshly created area is discoverable by name prefix via
|
||||
/// <see cref="CliRunner.ListAreaIdsByNamePrefixAsync"/>, confirming the round-trip
|
||||
/// used by UI tests that need to tear down areas whose ids are never surfaced.
|
||||
/// Exercises <see cref="CliRunner.CreateAreaAsync"/>,
|
||||
/// <see cref="CliRunner.ListAreaIdsByNamePrefixAsync"/>, and
|
||||
/// <see cref="CliRunner.DeleteAreaAsync"/>.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task ListAreaIdsByNamePrefix_FindsCreatedArea()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var siteId = await CliRunner.ResolveSiteIdAsync("site-a");
|
||||
var name = CliRunner.UniqueName("listarea");
|
||||
var areaId = await CliRunner.CreateAreaAsync(siteId, name);
|
||||
try
|
||||
{
|
||||
var ids = await CliRunner.ListAreaIdsByNamePrefixAsync(siteId, name);
|
||||
Assert.Contains(areaId, ids);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteAreaAsync(areaId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// E2E coverage for the Debug View page (<c>/deployment/debug-view</c>), which
|
||||
/// live-streams a single instance's attribute values and alarm states from its
|
||||
/// owning site. Two facts: the controls always render with Connect correctly
|
||||
/// gated, and connecting a freshly-deployed instance resolves to a terminal state
|
||||
/// without hanging.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The Enabled-only dropdown + deploy precondition (verified against
|
||||
/// <c>DebugView.razor::LoadInstancesForSite</c>):</b> the instance <c><select></c>
|
||||
/// lists ONLY instances whose config state is <c>Enabled</c>
|
||||
/// (<c>i.State == InstanceState.Enabled</c>). A freshly-minted instance is
|
||||
/// <c>NotDeployed</c> and would never appear, so Fact B first drives the instance
|
||||
/// to <c>Enabled</c> over the CLI (<c>instance deploy</c>) before it can be picked
|
||||
/// in the dropdown. Both <c><select></c>s carry the <c>data-test</c> hooks
|
||||
/// (<c>debug-site-select</c> / <c>debug-instance-select</c>, option value = entity
|
||||
/// id); Fact A asserts those hooks are served, which doubles as proof the cluster
|
||||
/// rebuild that added them actually took.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Tolerant terminal-state assertion (validation-behavior protocol):</b> whether
|
||||
/// <c>Connect</c> reaches a <b>Live</b> badge depends on the owning site returning a
|
||||
/// snapshot for the instance. Reading <c>DebugView.razor::Connect</c>: it awaits
|
||||
/// <c>DebugStreamService.StartStreamAsync</c> (a ClusterClient snapshot round-trip);
|
||||
/// on success it flips <c>_connected = true</c> (Live badge + success toast), and on
|
||||
/// any exception it surfaces a <c>Connect failed: …</c> error <c>.toast</c>. Either
|
||||
/// way <c>_connecting</c> resets, so the click always resolves — it never hangs. The
|
||||
/// protocol therefore first waits on a TERMINAL state (Live badge OR an error toast)
|
||||
/// within a generous window. OBSERVED reality on this live cluster (4 runs): Connect
|
||||
/// RELIABLY reaches <b>Live</b> in ~1s with a <c>Success: Streaming …</c> toast (never
|
||||
/// an error), so — as the protocol permits — Fact B is tightened past the tolerant
|
||||
/// floor to assert the Live badge, then Disconnect → Disconnected badge + selects
|
||||
/// re-enabled. The tolerant OR-wait is kept as a belt-and-braces floor so a one-off
|
||||
/// snapshot hiccup degrades to a clear missing-Live assertion failure, not a hang.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class DebugViewTests : IClassFixture<DeploymentFixture>
|
||||
{
|
||||
private readonly PlaywrightFixture _pw;
|
||||
private readonly DeploymentFixture _cluster;
|
||||
|
||||
public DebugViewTests(PlaywrightFixture pw, DeploymentFixture cluster)
|
||||
{
|
||||
_pw = pw;
|
||||
_cluster = cluster;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task DebugView_ControlsRender_ConnectGatedOnSelection()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/debug-view");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Debug View')")).ToBeVisibleAsync();
|
||||
// The data-test hooks from Task 4 must be served (proves the rebuild took).
|
||||
await Assertions.Expect(page.Locator("[data-test='debug-site-select']")).ToHaveCountAsync(1);
|
||||
await Assertions.Expect(page.Locator("[data-test='debug-instance-select']")).ToHaveCountAsync(1);
|
||||
|
||||
// No site/instance selected -> Connect is disabled.
|
||||
var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')");
|
||||
await Assertions.Expect(connect).ToBeDisabledAsync();
|
||||
await Assertions.Expect(page.Locator("span.badge[aria-label='Connection state: Disconnected']"))
|
||||
.ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task DebugView_ConnectEnabledInstance_ResolvesAndDisconnect()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
await CliRunner.DeployInstanceAsync(instanceId); // -> Enabled, so it lists in the dropdown
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/debug-view");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.Locator("[data-test='debug-site-select']")
|
||||
.SelectOptionAsync(new SelectOptionValue { Value = _cluster.SiteAId.ToString() });
|
||||
var instanceOption = page.Locator("[data-test='debug-instance-select'] option",
|
||||
new() { HasText = uniqueName });
|
||||
await Assertions.Expect(instanceOption).ToHaveCountAsync(1, new() { Timeout = 10_000 });
|
||||
await page.Locator("[data-test='debug-instance-select']")
|
||||
.SelectOptionAsync(new SelectOptionValue { Value = instanceId.ToString() });
|
||||
|
||||
var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')");
|
||||
await Assertions.Expect(connect).ToBeEnabledAsync(new() { Timeout = 10_000 });
|
||||
await connect.ClickAsync();
|
||||
|
||||
// Outcome-tolerant terminal-state wait: Connect resolves to Live badge OR an error
|
||||
// toast within a generous window rather than hanging. The OR-form is the tolerant
|
||||
// floor required by the validation-behavior protocol.
|
||||
//
|
||||
// OBSERVED on this live cluster (4 runs): Connect RELIABLY reaches Live — the site's
|
||||
// snapshot round-trip succeeds for a freshly-deployed zztest instance in ~1s and the
|
||||
// page shows the Live badge plus a "Success: Streaming <uniqueName>" toast every time
|
||||
// (never an error toast). Because Live is reliable, the protocol permits tightening:
|
||||
// after the tolerant wait below resolves, this fact asserts the Live badge directly,
|
||||
// then Disconnect → Disconnected badge + selects re-enabled. The OR-locator stays as a
|
||||
// belt-and-braces floor so a one-off snapshot hiccup degrades to a clear assertion
|
||||
// failure on the missing Live badge rather than a 25s hang.
|
||||
var terminal = page.Locator("span.badge[aria-label='Connection state: Live'], .toast");
|
||||
await Assertions.Expect(terminal.First).ToBeVisibleAsync(new() { Timeout = 25_000 });
|
||||
|
||||
// Tightened to the observed-reliable Live outcome. Short timeout: the OR-floor
|
||||
// above already resolved, so the Live badge is either present now or a toast
|
||||
// resolved instead (the error path) — in which case this fails fast rather than
|
||||
// burning the full window waiting for a badge that will never appear.
|
||||
await Assertions.Expect(page.Locator("span.badge[aria-label='Connection state: Live']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 2_000 });
|
||||
|
||||
await page.Locator("button.btn-outline-danger.btn-sm:has-text('Disconnect')").ClickAsync();
|
||||
await Assertions.Expect(page.Locator("span.badge[aria-label='Connection state: Disconnected']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(page.Locator("[data-test='debug-site-select']")).ToBeEnabledAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// E2E coverage for the Deployment Status page's real-time, push-based update
|
||||
/// model (Tier 2). The page (<c>/deployment/deployments</c>) is NOT polled: in
|
||||
/// <c>OnInitializedAsync</c> it subscribes to
|
||||
/// <c>IDeploymentStatusNotifier.StatusChanged</c> — a process singleton the
|
||||
/// DeploymentManager raises on every deployment-record status write — and reloads
|
||||
/// the table on each notification, which Blazor Server pushes to the browser over
|
||||
/// its SignalR circuit. So with the page already open, a CLI <c>instance deploy</c>
|
||||
/// makes the instance's row surface with no manual Refresh: the row simply
|
||||
/// appearing IS the proof the push path works.
|
||||
///
|
||||
/// <para>
|
||||
/// Each fact mints a fresh ephemeral instance on the real <c>site-a</c> (via
|
||||
/// <see cref="DeploymentFixture.CreateInstanceAsync"/>) and deletes it in a
|
||||
/// <c>finally</c>. The minted <c>uniqueName</c> is unique, so its row can never
|
||||
/// pre-exist — the row's appearance is attributable solely to the deploy under test.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why Fact B's negative is deterministic:</b> the Pause button toggles
|
||||
/// <c>_autoRefresh</c>, and <c>OnDeploymentStatusChanged</c> early-returns on
|
||||
/// <c>!_autoRefresh</c>, so a paused page deterministically ignores the
|
||||
/// <c>StatusChanged</c> notification. The pause click round-trips over the SignalR
|
||||
/// circuit (and we wait for the button to flip to "Resume" before deploying), so
|
||||
/// <c>_autoRefresh</c> is committed <c>false</c> server-side BEFORE the deploy fires
|
||||
/// — the suppression is not a race. Refresh calls <c>LoadDataAsync</c> directly,
|
||||
/// bypassing the pause guard, so the row surfaces on the explicit reload.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class DeploymentsRealtimeTests : IClassFixture<DeploymentFixture>
|
||||
{
|
||||
private readonly PlaywrightFixture _pw;
|
||||
private readonly DeploymentFixture _cluster;
|
||||
|
||||
public DeploymentsRealtimeTests(PlaywrightFixture pw, DeploymentFixture cluster)
|
||||
{
|
||||
_pw = pw;
|
||||
_cluster = cluster;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task DeployingInstance_PushesRowWithoutManualRefresh()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
// Open the page FIRST so the StatusChanged subscription is live before we deploy.
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/deployments");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Deployment Status')")).ToBeVisibleAsync();
|
||||
|
||||
// Deploy over the CLI — this writes deployment records and raises StatusChanged,
|
||||
// which the page reloads on (push). No Refresh click here: the row appearing IS
|
||||
// the proof the SignalR push path works.
|
||||
await CliRunner.DeployInstanceAsync(instanceId);
|
||||
|
||||
var row = page.Locator("table tbody tr", new() { HasText = uniqueName });
|
||||
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 25_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task PausedUpdates_SuppressPush_RefreshRestoresRow()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/deployments");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
// Confirm the page loaded before interacting (symmetric with Fact A) — a failed
|
||||
// load then surfaces as a clear heading-miss rather than a confusing Pause timeout.
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Deployment Status')")).ToBeVisibleAsync();
|
||||
|
||||
// Pause — the click round-trips over the circuit, so _autoRefresh is committed
|
||||
// false before the deploy below. The button flipping to "Resume" proves it.
|
||||
await page.Locator("button[aria-label='Pause auto-refresh']").ClickAsync();
|
||||
await Assertions.Expect(page.Locator("button[aria-label='Resume auto-refresh']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 5_000 });
|
||||
|
||||
await CliRunner.DeployInstanceAsync(instanceId);
|
||||
|
||||
// Paused: StatusChanged is ignored, so the row is NOT auto-added. The push chain
|
||||
// (deploy write → StatusChanged → InvokeAsync re-render → SignalR diff) completes
|
||||
// well under a second on a healthy cluster, so a 2s settle is ample to let any
|
||||
// erroneous push manifest before we assert absence. This is the one deliberate
|
||||
// fixed wait — a negative real-time assertion has no DOM event to await on the
|
||||
// "stayed absent" path. The Refresh-restores assertion below is the independent
|
||||
// positive guard if a loaded cluster ever makes this settle empirically tight.
|
||||
var row = page.Locator("table tbody tr", new() { HasText = uniqueName });
|
||||
await page.WaitForTimeoutAsync(2_000);
|
||||
await Assertions.Expect(row).ToHaveCountAsync(0);
|
||||
|
||||
// Refresh bypasses the pause (LoadDataAsync) -> the row surfaces.
|
||||
await page.Locator("button[aria-label='Refresh deployments']").ClickAsync();
|
||||
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// E2E coverage for the Topology page's area authoring surface — creating an area
|
||||
/// from the toolbar "+ Area" dialog (the site-picker variant), the inline
|
||||
/// double-click rename on an area node's label, the right-click "Move to Area…"
|
||||
/// flows for both an area (<c>MoveAreaDialog</c>) and an instance
|
||||
/// (<c>MoveInstanceDialog</c>), and the read-only "Diff" comparison
|
||||
/// (<c>DiffDialog</c>) for a deployed instance.
|
||||
///
|
||||
/// <para>
|
||||
/// Every fact opens the page through <see cref="OpenStableTopologyAsync"/>, which
|
||||
/// FIRST unchecks the <c>#live-updates</c> switch. The page reloads the whole tree
|
||||
/// on a 15s timer; left on, that reload collapses freshly-expanded nodes and tears
|
||||
/// down any open dialog or in-progress rename input mid-interaction. With it off
|
||||
/// the tree stays stable for the duration of the action. The helper then clicks
|
||||
/// <c>Expand all areas</c> so nested area nodes are rendered and locatable.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Toolbar create: "+ Area" opens <c>CreateAreaDialog</c> in its site-picker mode,
|
||||
/// where the FIRST <c><select></c> is the Site picker (option value = site id)
|
||||
/// and the second is the parent area. Success closes the dialog, raises an
|
||||
/// "Area '…' created." toast, and renders the new node.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Inline rename: double-clicking an area's <c>span.tv-label</c> swaps it for a
|
||||
/// rename input. <b>Enter commits, Escape reverts, and blur CANCELS</b> — so a
|
||||
/// committing fact fills the input then presses Enter on that SAME input, never
|
||||
/// clicking elsewhere first (a click would blur and silently discard the edit).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Areas these tests create/rename are ephemeral and cleaned up in a
|
||||
/// <c>finally</c>: the rename facts create their own area over the CLI (known id →
|
||||
/// delete by id), while the toolbar-create fact never learns the new id from the
|
||||
/// UI, so it sweeps by name prefix
|
||||
/// (<see cref="CliRunner.ListAreaIdsByNamePrefixAsync"/> →
|
||||
/// <see cref="CliRunner.DeleteAreaAsync"/>). A CLI read-back after each action
|
||||
/// confirms the change actually persisted, not just that the toast appeared.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Move flows: both "Move to Area…" context items open a one-<c><select></c>
|
||||
/// modal whose options carry the area name as their text (root-level areas render
|
||||
/// without indentation, so an exact visible-text match resolves). Unlike a text
|
||||
/// input, an HTML <c><select></c> fires <c>onchange</c> immediately on
|
||||
/// selection, so <see cref="ILocator.SelectOptionAsync(SelectOptionValue, FrameSelectOptionOptions)"/>
|
||||
/// commits the <c>@bind</c> directly (no <c>DispatchEventAsync</c> dance). The
|
||||
/// area dialog title is "Move area '…' to…" (<c>h6</c>) and the instance dialog
|
||||
/// "Move '…' to…" (<c>h6</c>); both succeed with a single "moved" toast.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Diff: the instance "Diff" item is <c>disabled</c> for a <c>NotDeployed</c>
|
||||
/// instance, so the Diff fact deploys first (over the CLI) to enable it. The
|
||||
/// resulting <c>DiffDialog</c> is computed centrally (no site relay), so the
|
||||
/// comparison is deterministic for a deployed instance; its title is
|
||||
/// "Deployment Diff — <uniqueName>" (<c>h5</c>, em-dash) and it closes via
|
||||
/// the footer Close button.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class TopologyAreaTests : IClassFixture<DeploymentFixture>
|
||||
{
|
||||
private readonly PlaywrightFixture _pw;
|
||||
private readonly DeploymentFixture _cluster;
|
||||
|
||||
public TopologyAreaTests(PlaywrightFixture pw, DeploymentFixture cluster)
|
||||
{
|
||||
_pw = pw;
|
||||
_cluster = cluster;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CreateArea_ViaToolbar_AppearsInTreeAndPersists()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var areaName = CliRunner.UniqueName("topoarea");
|
||||
try
|
||||
{
|
||||
var page = await OpenStableTopologyAsync(_pw);
|
||||
|
||||
await page.Locator("button:has-text('+ Area')").ClickAsync();
|
||||
var dialog = page.Locator(".modal.show:has(h6.modal-title:has-text('New Area'))");
|
||||
await Assertions.Expect(dialog).ToBeVisibleAsync();
|
||||
|
||||
// First select = Site (option value = site.Id); then the name; then Create.
|
||||
await dialog.Locator("select").First.SelectOptionAsync(
|
||||
new SelectOptionValue { Value = _cluster.SiteAId.ToString() });
|
||||
await dialog.Locator("input[placeholder='Area name']").FillAsync(areaName);
|
||||
await dialog.Locator("button.btn.btn-primary.btn-sm:has-text('Create')").ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(page.Locator($"span.tv-label:has-text('{areaName}')"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
|
||||
var ids = await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, areaName);
|
||||
Assert.Single(ids);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var id in await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, areaName))
|
||||
await CliRunner.DeleteAreaAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task RenameArea_EnterCommits_PersistsNewName()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var original = CliRunner.UniqueName("rnarea");
|
||||
var renamed = CliRunner.UniqueName("rndone");
|
||||
var areaId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, original);
|
||||
try
|
||||
{
|
||||
var page = await OpenStableTopologyAsync(_pw);
|
||||
|
||||
var label = page.Locator($"span.tv-label:has-text('{original}')");
|
||||
await Assertions.Expect(label).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await label.DblClickAsync();
|
||||
|
||||
var input = page.Locator($"input[aria-label='Rename {original}']");
|
||||
await Assertions.Expect(input).ToBeVisibleAsync();
|
||||
await input.FillAsync(renamed);
|
||||
// The input is @bind="_renameBuffer" with the default onchange trigger, which a
|
||||
// raw Enter keystroke does NOT fire (onchange only fires on blur in the browser) —
|
||||
// and blur CANCELS the rename. So dispatch a `change` event to flush the bound
|
||||
// value to the server WITHOUT losing focus, then press Enter to run CommitRename
|
||||
// against the now-updated _renameBuffer.
|
||||
await input.DispatchEventAsync("change");
|
||||
await input.PressAsync("Enter"); // commit WITHOUT blurring (blur would cancel)
|
||||
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(page.Locator($"span.tv-label:has-text('{renamed}')"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
|
||||
Assert.Single(await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, renamed));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteAreaAsync(areaId);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task RenameArea_Escape_RevertsToOriginalLabel()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var original = CliRunner.UniqueName("esarea");
|
||||
var areaId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, original);
|
||||
try
|
||||
{
|
||||
var page = await OpenStableTopologyAsync(_pw);
|
||||
|
||||
var label = page.Locator($"span.tv-label:has-text('{original}')");
|
||||
await Assertions.Expect(label).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await label.DblClickAsync();
|
||||
|
||||
var input = page.Locator($"input[aria-label='Rename {original}']");
|
||||
await Assertions.Expect(input).ToBeVisibleAsync();
|
||||
await input.FillAsync(CliRunner.UniqueName("discarded"));
|
||||
await input.PressAsync("Escape");
|
||||
|
||||
await Assertions.Expect(input).ToHaveCountAsync(0, new() { Timeout = 5_000 });
|
||||
await Assertions.Expect(page.Locator($"span.tv-label:has-text('{original}')")).ToBeVisibleAsync();
|
||||
Assert.Single(await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, original));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteAreaAsync(areaId);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task MoveArea_UnderAnotherArea_ShowsMovedToast()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var parentName = CliRunner.UniqueName("mvpar");
|
||||
var childName = CliRunner.UniqueName("mvchild");
|
||||
var parentId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, parentName);
|
||||
var childId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, childName);
|
||||
try
|
||||
{
|
||||
var page = await OpenStableTopologyAsync(_pw);
|
||||
|
||||
var childRow = page.Locator("div.tv-row", new() { HasText = childName });
|
||||
await Assertions.Expect(childRow).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await childRow.ScrollIntoViewIfNeededAsync();
|
||||
await childRow.ClickAsync(new() { Button = MouseButton.Right });
|
||||
await page.Locator(".dropdown-menu.show button.dropdown-item:has-text('Move to Area')").ClickAsync();
|
||||
|
||||
// MoveAreaDialog title is "Move area '<name>' to…" (h6) — distinct from the
|
||||
// instance dialog ("Move '<name>' to…"), so scope by the "Move area" text.
|
||||
var dialog = page.Locator(".modal.show:has(h6.modal-title:has-text('Move area'))");
|
||||
await Assertions.Expect(dialog).ToBeVisibleAsync();
|
||||
// Root-level area options render without indentation, so the parent's name is
|
||||
// the option's exact visible text. A <select> commits @bind on change, so the
|
||||
// selection alone flushes _targetParentId (no DispatchEventAsync needed).
|
||||
await dialog.Locator("select").SelectOptionAsync(new SelectOptionValue { Label = parentName });
|
||||
await dialog.Locator("button.btn.btn-primary.btn-sm:has-text('Move')").ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteAreaAsync(childId);
|
||||
await CliRunner.DeleteAreaAsync(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task MoveInstance_ToArea_ShowsMovedToast()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var areaName = CliRunner.UniqueName("mvtgt");
|
||||
var areaId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, areaName);
|
||||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
var page = await OpenStableTopologyAsync(_pw);
|
||||
|
||||
var row = page.Locator("div.tv-row", new() { HasText = uniqueName });
|
||||
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await row.ScrollIntoViewIfNeededAsync();
|
||||
await row.ClickAsync(new() { Button = MouseButton.Right });
|
||||
await page.Locator(".dropdown-menu.show button.dropdown-item:has-text('Move to Area')").ClickAsync();
|
||||
|
||||
// MoveInstanceDialog title is "Move '<name>' to…" (h6); the leading "Move '"
|
||||
// distinguishes it from the area dialog ("Move area '…'").
|
||||
var dialog = page.Locator(".modal.show:has(h6.modal-title:has-text(\"Move '\"))");
|
||||
await Assertions.Expect(dialog).ToBeVisibleAsync();
|
||||
await dialog.Locator("select").SelectOptionAsync(new SelectOptionValue { Label = areaName });
|
||||
await dialog.Locator("button.btn.btn-primary.btn-sm:has-text('Move')").ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||||
await CliRunner.DeleteAreaAsync(areaId);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Diff_OnDeployedInstance_OpensDialog()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
// Deploy leaves the instance in a state != NotDeployed, which enables the
|
||||
// otherwise-disabled "Diff" context item.
|
||||
await CliRunner.DeployInstanceAsync(instanceId);
|
||||
|
||||
var page = await OpenStableTopologyAsync(_pw);
|
||||
|
||||
var row = page.Locator("div.tv-row", new() { HasText = uniqueName });
|
||||
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await row.ScrollIntoViewIfNeededAsync();
|
||||
await row.ClickAsync(new() { Button = MouseButton.Right });
|
||||
// :has-text('Diff') won't match "Debug View"; the menu's only "Diff" item is the diff action.
|
||||
await page.Locator(".dropdown-menu.show button.dropdown-item:has-text('Diff')").ClickAsync();
|
||||
|
||||
// DiffDialog markup is `.modal.fade.show.d-block`; `.modal.show` substring-matches.
|
||||
// Its title is an h5 (the move dialogs use h6) and reads "Deployment Diff — <uniqueName>".
|
||||
var diff = page.Locator(".modal.show:has(h5.modal-title:has-text('Deployment Diff'))");
|
||||
await Assertions.Expect(diff).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(diff.Locator("h5.modal-title")).ToContainTextAsync(uniqueName);
|
||||
await diff.Locator("button.btn.btn-secondary.btn-sm:has-text('Close')").ClickAsync();
|
||||
await Assertions.Expect(diff).ToHaveCountAsync(0, new() { Timeout = 5_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the Topology page and returns a page whose tree is stable and
|
||||
/// fully expanded: it unchecks the <c>#live-updates</c> switch (stopping the 15s
|
||||
/// timer that would otherwise reload the tree and collapse expansions / tear down
|
||||
/// open dialogs and rename inputs), then clicks <c>Expand all areas</c> so nested
|
||||
/// area nodes are rendered and locatable.
|
||||
/// </summary>
|
||||
private static async Task<IPage> OpenStableTopologyAsync(PlaywrightFixture pw)
|
||||
{
|
||||
var page = await pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/topology");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
var live = page.Locator("#live-updates");
|
||||
if (await live.IsCheckedAsync()) await live.UncheckAsync(); // stop the 15s tree reload
|
||||
await page.Locator("button[aria-label='Expand all areas']").ClickAsync();
|
||||
return page;
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end action and filter-control gating tests for the central Parked Messages page
|
||||
/// (<c>/monitoring/parked-messages</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why this is distinct from <see cref="ParkedMessagesTests"/>:</b> the existing render
|
||||
/// guard verifies that the singleton-backed cross-cluster query resolves (renders the results
|
||||
/// table or the empty-state card) rather than hanging. This class targets the DETERMINISTIC
|
||||
/// filter-control gating behaviour that is verifiable regardless of whether any parked rows
|
||||
/// exist in the live environment:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The Query button is disabled until a site is selected.</description></item>
|
||||
/// <item><description>The Clear button is disabled until at least one filter is active,
|
||||
/// and is re-disabled after clicking Clear.</description></item>
|
||||
/// </list>
|
||||
/// A third, tolerant fact exercises the conditional bulk-action bar (Retry/Discard selected)
|
||||
/// that appears when at least one row checkbox is checked. Parked store-and-forward rows live
|
||||
/// in the SITE's local SQLite buffer — there is no central table to seed — so that fact
|
||||
/// performs an early-return no-op when no rows happen to be present, which is both expected
|
||||
/// and acceptable in this environment.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Gated on <see cref="ClusterAvailability"/> via <c>Skip.IfNot</c>: when the cluster is
|
||||
/// unreachable the facts report as Skipped (not Failed), matching the established suite idiom.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class ParkedMessagesActionTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public ParkedMessagesActionTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Query button must be disabled on page load (no site selected) and become
|
||||
/// enabled once a site is selected from the site dropdown.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task QueryButton_DisabledUntilSiteSelected()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/monitoring/parked-messages");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Parked Messages')")).ToBeVisibleAsync();
|
||||
|
||||
var query = page.Locator("button.btn.btn-primary.btn-sm:has-text('Query')");
|
||||
await Assertions.Expect(query).ToBeDisabledAsync();
|
||||
|
||||
await page.Locator("#pm-filter-site").SelectOptionAsync("site-a");
|
||||
// Selecting a site enables Query (and kicks off its own search — tolerated).
|
||||
await Assertions.Expect(query).ToBeEnabledAsync(new() { Timeout = 5_000 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Clear button must be disabled on page load (no active filters) and become
|
||||
/// enabled once any filter is changed. Clicking Clear must re-disable it.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task ClearButton_DisabledUntilFilterSet_ThenResets()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/monitoring/parked-messages");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var clear = page.Locator("button.btn.btn-outline-secondary.btn-sm:has-text('Clear')");
|
||||
await Assertions.Expect(clear).ToBeDisabledAsync();
|
||||
|
||||
// Setting any filter (Age) flips HasActiveFilters -> Clear enables.
|
||||
await page.Locator("#pm-filter-age").SelectOptionAsync("LastHour");
|
||||
await Assertions.Expect(clear).ToBeEnabledAsync(new() { Timeout = 5_000 });
|
||||
|
||||
await clear.ClickAsync();
|
||||
await Assertions.Expect(clear).ToBeDisabledAsync(new() { Timeout = 5_000 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When at least one parked row is present, checking its row checkbox must reveal
|
||||
/// the bulk action bar with Retry selected and Discard selected buttons. If no rows
|
||||
/// are present (the common case in a clean test environment — parked rows are not
|
||||
/// seedable from central), the test exits early as a tolerated no-op.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task SelectingParkedRow_RevealsBulkRetryDiscardBar_WhenRowsPresent()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/monitoring/parked-messages");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.Locator("#pm-filter-site").SelectOptionAsync("site-a");
|
||||
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Query')").ClickAsync();
|
||||
|
||||
// Wait for the query to resolve (table OR empty-state card).
|
||||
var resolved = page.Locator("table.parked-table, div.card-body:has-text('No parked messages')");
|
||||
await Assertions.Expect(resolved.First).ToBeVisibleAsync(new() { Timeout = 20_000 });
|
||||
|
||||
// Parked S&F rows are not seedable, so rows may be absent in this environment. Only
|
||||
// assert the action affordances when at least one row rendered.
|
||||
var rows = page.Locator("tr.parked-row");
|
||||
if (await rows.CountAsync() == 0)
|
||||
{
|
||||
return; // No parked messages at site-a — bulk-bar affordance can't be exercised.
|
||||
}
|
||||
|
||||
await rows.First.Locator("input.form-check-input").CheckAsync();
|
||||
await Assertions.Expect(page.Locator("button:has-text('Retry selected')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("button:has-text('Discard selected')")).ToBeVisibleAsync();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.SiteCalls;
|
||||
/// 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>
|
||||
/// <item><c>DiscardClickThrough</c> — clicking Discard on a Parked row confirms
|
||||
/// the danger dialog ("Delete"), relays to the owning site, and surfaces an
|
||||
/// outcome toast.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
@@ -328,4 +331,60 @@ public class SiteCallsPageTests
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <see cref="RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast"/>
|
||||
/// but exercises the Discard path: clicking Discard opens a <em>danger</em>
|
||||
/// confirm modal (Dialog.ConfirmAsync with <c>danger: true</c>), whose footer
|
||||
/// button is labelled "Delete" (not "Confirm"). Confirming relays a discard to
|
||||
/// the owning site and <c>ShowRelayOutcome</c> surfaces exactly one outcome toast.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task DiscardClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast()
|
||||
{
|
||||
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/sc-discard-click/{runId}/";
|
||||
var parkedId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// Parked + real site-a so the discard relay resolves fast (NotParked ack for this
|
||||
// freshly-seeded GUID), surfacing 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();
|
||||
|
||||
// Discard opens the danger confirm modal.
|
||||
await parkedRow.Locator("button:has-text('Discard')").ClickAsync();
|
||||
|
||||
// Danger confirm — labelled "Delete" (Dialog.ConfirmAsync(..., danger: true)).
|
||||
var deleteButton = page.Locator(".modal-footer .btn-danger");
|
||||
await Assertions.Expect(deleteButton).ToBeVisibleAsync();
|
||||
await Assertions.Expect(deleteButton).ToHaveTextAsync("Delete");
|
||||
await deleteButton.ClickAsync();
|
||||
|
||||
// One outcome toast (Applied / NotParked / SiteUnreachable — tolerant).
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user