1042 lines
50 KiB
Markdown
1042 lines
50 KiB
Markdown
# Playwright Coverage Fill — Wave 2 (Tier 2: Real-time / Relay) Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or
|
||
> superpowers-extended-cc:subagent-driven-development for same-session execution) to
|
||
> implement this plan task-by-task.
|
||
|
||
**Goal:** Close the Tier 2 (real-time / relay) coverage gaps from the
|
||
[2026-06-06 coverage-fill design](2026-06-06-playwright-coverage-fill-design.md) —
|
||
Deployments SignalR push, Topology area lifecycle, DebugView streaming, ParkedMessages
|
||
action controls, and the SiteCalls Discard click-through — as a green, zero-residue
|
||
increment in the existing Playwright harness.
|
||
|
||
**Architecture:** New `[Collection("Playwright")]` test classes (+ one `SiteCallsPageTests`
|
||
extension) drive the live 8-node docker cluster through the remote Chromium at
|
||
`ws://localhost:3000`. State is provisioned with the `scadabridge` CLI via ephemeral
|
||
`zztest-*` fixtures (reusing `DeploymentFixture`); persistence is verified by CLI
|
||
read-back and durable DOM outcomes. Real-time tests drive **real** behavior with
|
||
generous web-first timeouts and outcome tolerance (per design decision D4). The only
|
||
app-code change is **two additive `data-test` attributes** on `DebugView.razor`.
|
||
|
||
**Tech Stack:** xunit + `Xunit.SkippableFact`, Microsoft.Playwright, the ScadaBridge CLI
|
||
(`scadabridge.dll`), Blazor Server (SignalR), Bootstrap. TFM `net10.0`, `Nullable=enable`,
|
||
`TreatWarningsAsErrors=true`.
|
||
|
||
---
|
||
|
||
## Orientation — read before starting (applies to every task)
|
||
|
||
**Harness conventions (carried from Wave 1 — do not re-derive):**
|
||
- Every test class is `[Collection("Playwright")]` (serial; `maxParallelThreads=1`).
|
||
- Cluster-gated tests are `[SkippableFact]` + `Skip.IfNot(<available>, <skipReason>)`.
|
||
- Cluster-only tests: `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason)`.
|
||
- Fixture-backed tests: `Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason)`.
|
||
- Browser base URL is `PlaywrightFixture.BaseUrl` (`http://scadabridge-traefik`, the
|
||
Docker-internal hostname). The CLI from the host uses `http://localhost:9000`.
|
||
- Auth: `await _fixture.NewAuthenticatedPageAsync()` (multi-role / password — **do not type
|
||
credentials into any login form; the helper POSTs the cookie**).
|
||
- Pages created via `NewPageAsync`/`NewAuthenticatedPageAsync` are context-capped at 4
|
||
(`PlaywrightFixture`); **keep that cap** — it bounds Blazor circuit pressure.
|
||
- Toasts: web-first `await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 })`
|
||
(atomic retry — survives the relay round-trip and the toast's ~5s auto-dismiss; never a
|
||
`ToBeVisible` + `CountAsync` TOCTOU pair).
|
||
- Danger confirm = `.modal-footer .btn-danger` (text "Delete"); non-danger confirm =
|
||
`.modal-footer .btn-primary` / `button:has-text('Confirm')`.
|
||
- Fixture naming = `CliRunner.UniqueName("<kind>")` → `zztest-<kind>-<8hex>`.
|
||
- Teardown is best-effort in `finally`/`DisposeAsync`, keyed on `zztest-*`; never mask the
|
||
test's own failure.
|
||
- Use the **validation-behavior protocol** for every ⚠ item: read the page code-behind to
|
||
learn the *actual* behavior before asserting it; where reality differs from the spec, assert
|
||
reality and leave a code comment. Documented fallback for an irreducibly-flaky real-time
|
||
test: downgrade **that test only** to a render+controls guard.
|
||
|
||
**Cadence constraint (discovered during Wave 1 — honor it during execution):** there is one
|
||
shared Playwright browser, one cluster, and one test-project build. **Serialize every
|
||
Playwright-running implementer.** `Parallelizable with:` is set to `none` on all
|
||
cluster/browser tasks for that reason; only read-only reviewers overlap with a build. The
|
||
field documents intent — the executor still dispatches these sequentially.
|
||
|
||
**The single cluster rebuild:** Task 4 adds the only app-code change (2 `data-test`
|
||
attributes on `DebugView.razor`). The running image will not serve them until rebuilt.
|
||
**Run Task 4 early** and rebuild with `bash docker/deploy.sh` so every later task runs
|
||
against the current image (Wave 1 lost time to a stale image — do not repeat it).
|
||
|
||
**TDD per task:** follow superpowers-extended-cc:test-driven-development — write the test,
|
||
run it against the live cluster, watch it fail/pass, commit. Each task ends with a focused
|
||
`dotnet test --filter` run and a commit.
|
||
|
||
**Build / run commands (from repo root `/Users/dohertj2/Desktop/ScadaBridge`):**
|
||
- Build: `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.csproj`
|
||
- Run one class: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/ --filter "FullyQualifiedName~<ClassName>"`
|
||
- Rebuild cluster: `bash docker/deploy.sh`
|
||
|
||
---
|
||
|
||
## Task 0: CLI helper — `ListAreaIdsByNamePrefixAsync`
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (round-trip test runs the CLI against the cluster)
|
||
|
||
**Files:**
|
||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs`
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs`
|
||
|
||
**Why:** the Topology create-area test (Task 6) creates an area through the **UI**, so it
|
||
never learns the new area's id and cannot delete it by id in teardown. This helper lists
|
||
`site area list --site-id` and returns the ids of areas whose `name` carries a given prefix
|
||
— mirroring the existing `ListTemplateIdsByNamePrefixAsync`. (`site area list` returns a JSON
|
||
array of objects with `id` and `name`; verify those exact field names from one live
|
||
`dotnet <scadabridge.dll> --url http://localhost:9000 --username multi-role --password password --format json site area list --site-id <siteAId>`
|
||
before finalizing.)
|
||
|
||
**Step 1: Write the failing round-trip test** in `CliRunnerHelpersTests.cs`:
|
||
|
||
```csharp
|
||
[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);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run it to watch it fail** (method not defined):
|
||
`dotnet test tests/.../PlaywrightTests/ --filter "FullyQualifiedName~ListAreaIdsByNamePrefix"` → FAIL (compile).
|
||
|
||
**Step 3: Add the helper** to `CliRunner.Helpers.cs` (near `ListTemplateIdsByNamePrefixAsync`):
|
||
|
||
```csharp
|
||
/// <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).
|
||
/// </summary>
|
||
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;
|
||
}
|
||
```
|
||
|
||
**Step 4: Run the test → PASS.**
|
||
|
||
**Step 5: Commit** — `git add` the two files; message e.g.
|
||
`test(e2e): add ListAreaIdsByNamePrefixAsync CLI helper for UI-created area teardown`.
|
||
|
||
---
|
||
|
||
## Task 1: `DeploymentsRealtimeTests` — SignalR push + pause/refresh
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentsRealtimeTests.cs`
|
||
- Reference (no edit): `Deployment/DeploymentFixture.cs`,
|
||
`src/.../Components/Pages/Deployment/Deployments.razor`
|
||
|
||
**Page facts (verified against `Deployments.razor`):**
|
||
- `@page "/deployment/deployments"`, heading `h4:has-text('Deployment Status')`.
|
||
- Updates are **push**, not polled: the page subscribes to
|
||
`IDeploymentStatusNotifier.StatusChanged` and reloads on every deployment-record status
|
||
write (no timer). So with the page open, a CLI `instance deploy` makes the row appear
|
||
with **no manual refresh** — this is the regression the push test guards.
|
||
- Pause button: `button[aria-label='Pause auto-refresh']` (text "⏸ Pause updates"); after a
|
||
click it flips to `aria-label='Resume auto-refresh'` (text "▶ Resume updates"). While
|
||
paused, `OnDeploymentStatusChanged` early-returns on `!_autoRefresh`, so a status change
|
||
is **deterministically** ignored (the pause is committed over the circuit before the CLI
|
||
deploy fires — not a race).
|
||
- Refresh button: `button[aria-label='Refresh deployments']` → `LoadDataAsync` runs
|
||
regardless of pause state.
|
||
- Each row renders the instance's `UniqueName` (column 2, via `GetInstanceName`). Empty
|
||
state: "No deployments recorded." A fresh `zztest` instance's `UniqueName` is unique, so
|
||
its row cannot pre-exist.
|
||
|
||
**Class skeleton:** `IClassFixture<DeploymentFixture>` + `PlaywrightFixture` (mirror
|
||
`DeploymentActionTests`). Row locator: `page.Locator("table tbody tr", new() { HasText = uniqueName })`.
|
||
|
||
**Step 1 — Test A: push appends the row (no manual refresh).**
|
||
|
||
```csharp
|
||
[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 page.Reload() / Refresh click here: the row
|
||
// appearing is the proof that 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);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2 — Test B: pause suppresses the push; Refresh restores it.**
|
||
|
||
```csharp
|
||
[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);
|
||
|
||
// 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. Settle briefly to
|
||
// give any (erroneous) push time to manifest, then assert absence.
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3:** Run `--filter "FullyQualifiedName~DeploymentsRealtimeTests"` → both PASS.
|
||
(If Test B's negative proves flaky in practice, the deterministic `Resume` flip + the
|
||
Refresh-restores half are the load-bearing assertions; the `ToHaveCount(0)` may be relaxed
|
||
per the validation-behavior fallback. It should not be flaky — pause is committed first.)
|
||
|
||
**Step 4: Commit** — `test(e2e): Deployments page pushes deploy rows via SignalR; pause suppresses, Refresh restores`.
|
||
|
||
---
|
||
|
||
## Task 2: `ParkedMessagesActionTests` — filter-controls + action affordance guard
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesActionTests.cs`
|
||
- Reference (no edit): `Monitoring/ParkedMessagesTests.cs` (the existing render-without-hang
|
||
test — **do not duplicate it**), `src/.../Components/Pages/Monitoring/ParkedMessages.razor`
|
||
|
||
**Why this shape (verified against `ParkedMessages.razor`):** parked store-and-forward rows
|
||
live in the **site's** SQLite buffer (not central MS SQL) and are unseedable, so there is no
|
||
deterministic click-through. The Retry/Discard affordances are NOT inline per-row buttons —
|
||
they appear only (a) in the row-click **drawer** footer (`button.btn.btn-outline-success` /
|
||
`button.btn.btn-outline-danger`) and (b) in the **bulk-action bar** ("Retry selected" /
|
||
"Discard selected") shown when `_selectedIds.Count > 0`. This test therefore guards the
|
||
**deterministic filter-control gating** plus a **conditional** bulk-bar reveal, which is
|
||
distinct from the existing "resolves without hang" test.
|
||
|
||
**Page facts:**
|
||
- Heading `h4:has-text('Parked Messages')`. Site select `#pm-filter-site` (option values =
|
||
`SiteIdentifier`, e.g. `site-a`). Age select `#pm-filter-age`.
|
||
- Query button: `button.btn.btn-primary.btn-sm:has-text('Query')`, `disabled` when
|
||
`string.IsNullOrEmpty(_selectedSiteId)`. Selecting a site also fires `Search()` itself.
|
||
- Clear button: `button.btn.btn-outline-secondary.btn-sm:has-text('Clear')`, `disabled`
|
||
when `!HasActiveFilters`.
|
||
- Rows: `tr.parked-row`; bulk bar appears after checking a row checkbox.
|
||
|
||
**Step 1 — Test A: Query gates on site selection.**
|
||
|
||
```csharp
|
||
[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 });
|
||
}
|
||
```
|
||
|
||
**Step 2 — Test B: Clear gates on active filters.**
|
||
|
||
```csharp
|
||
[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 });
|
||
}
|
||
```
|
||
|
||
**Step 3 — Test C (tolerant): when rows exist, selecting one reveals the bulk Retry/Discard bar.**
|
||
|
||
```csharp
|
||
[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) — same terminal-state wait
|
||
// as the existing render test.
|
||
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();
|
||
}
|
||
```
|
||
|
||
(Class: `[Collection("Playwright")]`, ctor takes `PlaywrightFixture _fixture` — mirror
|
||
`ParkedMessagesTests`.)
|
||
|
||
**Step 4:** Run `--filter "FullyQualifiedName~ParkedMessagesActionTests"` → all PASS (Test C
|
||
likely no-ops with no parked rows — acceptable; it asserts affordances only when data exists).
|
||
|
||
**Step 5: Commit** — `test(e2e): ParkedMessages filter-control gating + conditional bulk action-bar guard`.
|
||
|
||
---
|
||
|
||
## Task 3: Extend `SiteCallsPageTests` — Discard click-through
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs`
|
||
- Reference (no edit): `src/.../Components/Pages/SiteCalls/SiteCallsReport.razor.cs`
|
||
|
||
**Why only SiteCalls:** `NotificationActionTests` already has BOTH Retry and Discard
|
||
click-throughs — so the design's "symmetric Discard click-through" reduces to SiteCalls,
|
||
which today has Retry (`RetryClickThrough_OnParkedRow_...`) but not Discard.
|
||
|
||
**Behavior (verified in `SiteCallsReport.razor.cs::DiscardSiteCall`):** Discard opens
|
||
`Dialog.ConfirmAsync(..., danger: true)` → footer button is `.modal-footer .btn-danger`
|
||
("Delete"); on confirm it relays to the owning site and `ShowRelayOutcome` raises exactly
|
||
one toast. The seeded row must use `sourceSite: "site-a"` (a real site) so the relay
|
||
resolves fast (an unknown site sits on the 10s inner Ask timeout) — exactly as the existing
|
||
Retry click-through does.
|
||
|
||
**Step 1: Add the fact** (place it right after `RetryClickThrough_OnParkedRow_...`; it is a
|
||
near-mirror — seed a Parked `site-a` row, click **Discard**, confirm via the danger button,
|
||
assert one toast):
|
||
|
||
```csharp
|
||
[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);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2:** Run `--filter "FullyQualifiedName~SiteCallsPageTests.DiscardClickThrough"` → PASS.
|
||
|
||
**Step 3: Commit** — `test(e2e): SiteCalls Discard click-through on a Parked row surfaces a relay outcome toast`.
|
||
|
||
---
|
||
|
||
## Task 4: `DebugView.razor` — add `data-test` hooks (+ rebuild cluster)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor`
|
||
|
||
**Why:** the Site and Instance `<select>`s have no `id`, `aria-label`, or other stable
|
||
selector — only layout-coupled `col-md-*` classes. The design (D3) sanctions a minimal,
|
||
additive `data-test` hook exactly here. This is the **only** app-code change in Wave 2; keep
|
||
it its own commit so the app diff stays auditable/revertable, and **rebuild** so the running
|
||
image serves it.
|
||
|
||
**Step 1: Add two attributes** (no behavior change — purely additive). On the Site select
|
||
(`@bind="_selectedSiteId"`):
|
||
|
||
```razor
|
||
<select class="form-select form-select-sm" data-test="debug-site-select"
|
||
@bind="_selectedSiteId" @bind:after="LoadInstancesForSite" disabled="@_connected">
|
||
```
|
||
|
||
On the Instance select (`@bind="_selectedInstanceId"`):
|
||
|
||
```razor
|
||
<select class="form-select form-select-sm" data-test="debug-instance-select"
|
||
@bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged" disabled="@_connected">
|
||
```
|
||
|
||
(Connect/Disconnect buttons and the Live/Disconnected badges already have usable selectors —
|
||
`button.btn.btn-primary.btn-sm:has-text('Connect')`, `button.btn-outline-danger.btn-sm:has-text('Disconnect')`,
|
||
`span.badge.bg-success[aria-label='Connection state: Live']`,
|
||
`span.badge.bg-secondary[aria-label='Connection state: Disconnected']` — so no further hooks.)
|
||
|
||
**Step 2: Build the app** to confirm the razor still compiles:
|
||
`dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`.
|
||
|
||
**Step 3: Rebuild + redeploy the cluster** so the served HTML includes the hooks:
|
||
`bash docker/deploy.sh` (wait for it to finish and the cluster to come healthy).
|
||
|
||
**Step 4: Verify the hook is served** — e.g. authenticate and confirm the attribute is
|
||
present on `/deployment/debug-view` (a quick Playwright `Expect(page.Locator("[data-test='debug-site-select']")).ToHaveCountAsync(1)`
|
||
inside Task 5's first test is sufficient; no separate test needed here).
|
||
|
||
**Step 5: Commit** — `feat(ui): add data-test hooks to DebugView site/instance selects (additive, test-only)`.
|
||
|
||
---
|
||
|
||
## Task 5: `DebugViewTests` — controls guard + connect-resolves-without-hang ⚠
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Depends on:** Task 4 (hooks must be deployed)
|
||
|
||
**Files:**
|
||
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTests.cs`
|
||
- Reference (no edit): `Deployment/DeploymentFixture.cs`,
|
||
`src/.../Components/Pages/Deployment/DebugView.razor`
|
||
|
||
**⚠ Validation-behavior protocol applies.** The instance dropdown lists only **Enabled**
|
||
instances (`i.State == InstanceState.Enabled`), so the live path needs a deployed instance
|
||
(`DeploymentFixture.CreateInstanceAsync` → `CliRunner.DeployInstanceAsync` → Enabled).
|
||
Whether `Connect` reaches a **Live** badge for a freshly-deployed `zztest` instance depends
|
||
on the site returning a snapshot. The robust, outcome-tolerant assertion is **"Connect
|
||
resolves to a terminal state (Live badge OR an error toast) without hanging"** — mirroring
|
||
the ParkedMessages "resolves without hang" philosophy. If Connect reliably reaches Live in
|
||
this environment, the implementer may tighten to assert the Live badge + the snapshot region;
|
||
if it cannot, the controls-guard test (Step 1) is the guaranteed coverage and the live test
|
||
stays at the terminal-state assertion. Read `DebugView.razor::Connect` before tightening.
|
||
|
||
**Class:** `IClassFixture<DeploymentFixture>` + `PlaywrightFixture`.
|
||
|
||
**Step 1 — Test A (always green): controls + Connect gating.**
|
||
|
||
```csharp
|
||
[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();
|
||
// Disconnected badge is shown initially.
|
||
await Assertions.Expect(page.Locator("span.badge[aria-label='Connection state: Disconnected']"))
|
||
.ToBeVisibleAsync();
|
||
}
|
||
```
|
||
|
||
**Step 2 — Test B (⚠ tolerant): Connect an enabled instance → resolves without hang; Disconnect re-enables selects.**
|
||
|
||
```csharp
|
||
[SkippableFact]
|
||
public async Task DebugView_ConnectEnabledInstance_ResolvesAndDisconnect()
|
||
{
|
||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||
|
||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||
try
|
||
{
|
||
// Deploy → Enabled, so the instance appears in the (Enabled-only) dropdown.
|
||
await CliRunner.DeployInstanceAsync(instanceId);
|
||
|
||
var page = await _pw.NewAuthenticatedPageAsync();
|
||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/debug-view");
|
||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||
|
||
// Select site-a by its numeric Id (the <option> value is site.Id), then the instance.
|
||
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: Connect resolves to a terminal state — the Live badge (snapshot
|
||
// arrived) OR an error toast (the connect path surfaced a problem) — within a
|
||
// generous window, rather than hanging. Either proves the streaming path resolved.
|
||
var terminal = page.Locator(
|
||
"span.badge[aria-label='Connection state: Live'], .toast");
|
||
await Assertions.Expect(terminal.First).ToBeVisibleAsync(new() { Timeout = 25_000 });
|
||
|
||
// If we reached Live, the snapshot region is the tables OR the "Waiting for snapshot"
|
||
// spinner — assert tolerantly and then Disconnect; if Connect errored instead, the
|
||
// Disconnect button won't be present, so guard on the Live badge.
|
||
if (await page.Locator("span.badge[aria-label='Connection state: Live']").CountAsync() > 0)
|
||
{
|
||
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 });
|
||
// Disconnect re-enables the selects (disabled only while connected).
|
||
await Assertions.Expect(page.Locator("[data-test='debug-site-select']")).ToBeEnabledAsync();
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3:** Run `--filter "FullyQualifiedName~DebugViewTests"` → both PASS (Test A always;
|
||
Test B tolerant). Apply the protocol: if Live is reliable, tighten Test B; if Connect can't
|
||
reach Live, the terminal-state assertion + Test A carry the coverage — add a code comment
|
||
noting which path the live cluster takes.
|
||
|
||
**Step 4: Commit** — `test(e2e): DebugView controls/Connect gating + tolerant connect-resolves-without-hang`.
|
||
|
||
---
|
||
|
||
## Task 6: `TopologyAreaTests` (part 1) — create area + inline rename
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Depends on:** Task 0 (`ListAreaIdsByNamePrefixAsync`)
|
||
|
||
**Files:**
|
||
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs`
|
||
- Reference (no edit): `src/.../Components/Pages/Deployment/Topology.razor`,
|
||
`CreateAreaDialog.razor`, `Deployment/DeploymentFixture.cs`,
|
||
`Deployment/DeploymentActionTests.cs` (for the tree-navigation idiom)
|
||
|
||
**Page facts (verified against `Topology.razor` + dialogs):**
|
||
- `@page "/deployment/topology"`. **Live-updates timer** reloads the tree every 15s,
|
||
collapsing expansions and tearing down open dialogs/menus/rename inputs. **Every Topology
|
||
test must first uncheck `#live-updates`** (exactly as `DeploymentActionTests.OpenInstanceContextMenuAsync` does).
|
||
- Toolbar "+ Area": `button:has-text('+ Area')` → opens `CreateAreaDialog` with
|
||
`RequireSitePicker=true`. Dialog: `.modal.show.d-block`, title `h6.modal-title:has-text('New Area')`;
|
||
first `<select>` = Site (`@bind="_siteId"`, option value = site.Id), second `<select>` =
|
||
Parent area; name input `input[placeholder='Area name']`; footer Create `button.btn.btn-primary.btn-sm:has-text('Create')`,
|
||
Cancel `button:has-text('Cancel')`. Success → dialog closes + toast "Area '<name>' created."
|
||
and the new node renders in the tree (`span.tv-label:has-text('<name>')`).
|
||
- Site context-menu "Add Area": right-click the **site** `div.tv-row` → `.dropdown-menu.show`
|
||
→ `button.dropdown-item:has-text('Add Area')` → same dialog but `RequireSitePicker=false`
|
||
(site preset; only the name input is needed).
|
||
- Inline area rename: double-click the area's `span.tv-label` (`@ondblclick="BeginRename"`)
|
||
→ input `input[aria-label='Rename <label>']`. **Enter** commits (`OnRenameKeyDown`),
|
||
**Escape** reverts, **blur cancels** (so fill, then `Press("Enter")` on the same input —
|
||
never click away first). Success → toast "Area renamed to '<new>'." + the label updates.
|
||
- Tree nav helper (copy the idiom from `DeploymentActionTests`): navigate → uncheck
|
||
`#live-updates` → click `button[aria-label='Expand all areas']` → rows are `div.tv-row`,
|
||
labels `span.tv-label`.
|
||
|
||
**Fixture:** reuse `IClassFixture<DeploymentFixture>` (gives `SiteAId`, a `zztest` template,
|
||
a `zztest` area, and on-demand instances). Areas these tests create/rename are ephemeral —
|
||
delete them by id in `finally` (rename targets created via `CliRunner.CreateAreaAsync`) or by
|
||
prefix (UI-created areas via `CliRunner.ListAreaIdsByNamePrefixAsync` + `DeleteAreaAsync`).
|
||
|
||
**Add a private nav helper** in the class:
|
||
|
||
```csharp
|
||
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;
|
||
}
|
||
```
|
||
|
||
**Step 1 — Test A: create an area via the toolbar "+ Area" (site picker).**
|
||
|
||
```csharp
|
||
[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();
|
||
|
||
// Outcome: toast + the new node renders in the tree.
|
||
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 });
|
||
|
||
// CLI read-back: the area persisted on site-a.
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2 — Test B: inline rename commits on Enter (with CLI read-back).**
|
||
|
||
```csharp
|
||
[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);
|
||
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 });
|
||
|
||
// CLI read-back: the rename persisted.
|
||
Assert.Single(await CliRunner.ListAreaIdsByNamePrefixAsync(_cluster.SiteAId, renamed));
|
||
}
|
||
finally
|
||
{
|
||
await CliRunner.DeleteAreaAsync(areaId);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3 — Test C: Escape reverts the rename.**
|
||
|
||
```csharp
|
||
[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");
|
||
|
||
// Reverted: input gone, original label still rendered, name unchanged on the server.
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 4:** Run `--filter "FullyQualifiedName~TopologyAreaTests"` → all PASS.
|
||
|
||
**Step 5: Commit** — `test(e2e): Topology create-area (toolbar) + inline rename (Enter commits, Escape reverts) with CLI read-back`.
|
||
|
||
---
|
||
|
||
## Task 7: `TopologyAreaTests` (part 2) — move area, move instance, diff dialog ⚠
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
**Depends on:** Task 6 (same file)
|
||
|
||
**Files:**
|
||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/TopologyAreaTests.cs`
|
||
- Reference (no edit): `MoveAreaDialog.razor`, `MoveInstanceDialog.razor`, `DiffDialog.razor`,
|
||
`Topology.razor`
|
||
|
||
**Page facts:**
|
||
- Right-click a `div.tv-row` → `.dropdown-menu.show` context menu. **Area** menu items:
|
||
"Add Sub-area", "Create Instance here", "Move to Area…", "Rename…", "Delete". **Instance**
|
||
menu items: "Deploy"/"Redeploy", "Disable"/"Enable", "Configure", "Debug View", "Diff",
|
||
"Move to Area…", "Delete". (Items are `button.dropdown-item`; "Diff" is
|
||
`disabled` when the instance is `NotDeployed`.)
|
||
- `MoveAreaDialog`: title `h6.modal-title:has-text("Move area '<name>' to…")`; one `<select>`
|
||
(`@bind="_targetParentId"`, options include "(Site root)"); footer Move
|
||
`button.btn.btn-primary.btn-sm:has-text('Move')`. Success → toast "Area '<name>' moved."
|
||
- `MoveInstanceDialog`: title `h6.modal-title:has-text("Move '<name>' to…")`; one `<select>`
|
||
(`@bind="_targetAreaId"`, options include "(No area — site root)"); footer Move. Success →
|
||
toast "Instance '<name>' moved."
|
||
- `DiffDialog`: `.modal.fade.show.d-block`, title `h5.modal-title:has-text('Deployment Diff —
|
||
<uniqueName>')`, a badge ("Stale — changes pending" or "Current"), Close
|
||
`button.btn.btn-secondary.btn-sm:has-text('Close')`. `ShowDiff` computes the comparison
|
||
centrally (no site relay), so it is deterministic for a deployed instance.
|
||
|
||
**Step 1 — Test D: move one area under another area.** Create two root areas (parent, child)
|
||
via CLI; in the UI, right-click the child → "Move to Area…" → select the **parent area** in
|
||
the dialog → Move; assert one toast. (This exercises the reparenting path
|
||
`_targetParentId = <non-null int>`, which is richer than the `null` site-root path; the move
|
||
success toast fires only inside `SubmitMoveArea`'s `IsSuccess` branch, so toast-count-1 is
|
||
itself persistence evidence — CLI read-back is optional and omitted.) Cleanup deletes the
|
||
child then the parent in `finally` (child first, since after the move it is nested under the
|
||
parent and the parent's delete would fail while it has a sub-area). Method name:
|
||
`MoveArea_UnderAnotherArea_ShowsMovedToast`.
|
||
|
||
```csharp
|
||
[SkippableFact]
|
||
public async Task MoveArea_ToSiteRoot_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);
|
||
// CreateAreaAsync has no parent param; create child then move it under parent via UI is
|
||
// circular — instead create the child as a plain area and move it "to Area <parent>".
|
||
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();
|
||
|
||
var dialog = page.Locator(".modal.show:has(h6.modal-title:has-text('Move area'))");
|
||
await Assertions.Expect(dialog).ToBeVisibleAsync();
|
||
// Move it under the parent area (select the parent option by its label).
|
||
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
|
||
{
|
||
// Delete child first (it may now be nested under parent), then parent.
|
||
await CliRunner.DeleteAreaAsync(childId);
|
||
await CliRunner.DeleteAreaAsync(parentId);
|
||
}
|
||
}
|
||
```
|
||
|
||
(Note for the implementer: if `MoveAreaDialog`'s `<select>` binds option **values** to ids,
|
||
prefer `SelectOptionValue { Value = parentId.ToString() }` over the label form — confirm
|
||
which works against the live DOM and keep the one that does.)
|
||
|
||
**Step 2 — Test E: move an instance to an area.** Mint an instance under no area
|
||
(`DeploymentFixture.CreateInstanceAsync`), create a target area via CLI; in the UI,
|
||
right-click the instance → "Move to Area…" → pick the area → Move; assert one toast.
|
||
|
||
```csharp
|
||
[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();
|
||
|
||
var dialog = page.Locator(".modal.show:has(h6.modal-title:has-text('Move \\'"))"); // "Move '<name>' to…"
|
||
await Assertions.Expect(page.Locator(".modal.show:has-text('Move')")).ToBeVisibleAsync();
|
||
await page.Locator(".modal.show select").SelectOptionAsync(new SelectOptionValue { Label = areaName });
|
||
await page.Locator(".modal.show 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);
|
||
}
|
||
}
|
||
```
|
||
|
||
(The `MoveInstanceDialog` title is `Move '<name>' to…` — locate it by a stable substring such
|
||
as `.modal.show:has-text('Move')` scoped to the instance name, or add the dialog-title text
|
||
match the implementer confirms against the DOM. Keep the locator that resolves uniquely.)
|
||
|
||
**Step 3 — Test F (⚠ deterministic): the Diff dialog opens for a deployed instance.** Mint an
|
||
instance, deploy it (→ not `NotDeployed`, so "Diff" is enabled), right-click → "Diff", assert
|
||
the `DiffDialog` opens with its title and a Close button, then close it.
|
||
|
||
```csharp
|
||
[SkippableFact]
|
||
public async Task Diff_OnDeployedInstance_OpensDialog()
|
||
{
|
||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||
|
||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||
try
|
||
{
|
||
await CliRunner.DeployInstanceAsync(instanceId); // leaves Diff enabled (state != NotDeployed)
|
||
|
||
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('Diff')").ClickAsync();
|
||
|
||
// The diff is computed centrally (no site relay) → deterministic for a deployed
|
||
// instance. Assert the dialog and its title, then close.
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 4:** Run `--filter "FullyQualifiedName~TopologyAreaTests"` → all six facts PASS.
|
||
Apply the validation-behavior protocol to the move-dialog `<select>` option-selection (value
|
||
vs. label) and the move-instance dialog title locator — confirm against the live DOM and keep
|
||
the form that resolves. If the area-`Move` dialog cannot be driven reliably, the create/rename
|
||
facts (Task 6) plus move-instance + diff still deliver the Topology coverage; downgrade only
|
||
the irreducible item with a code comment.
|
||
|
||
**Step 5: Commit** — `test(e2e): Topology move-area, move-instance, and Diff-dialog (deployed instance)`.
|
||
|
||
---
|
||
|
||
## Task 8: Wave 2 verification + residue check
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min (mostly the full-suite run)
|
||
**Parallelizable with:** none
|
||
**Depends on:** Tasks 0–7
|
||
|
||
**Files:** none (verification only).
|
||
|
||
**Step 1: Clean build** (warnings are errors):
|
||
`dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.csproj`
|
||
→ 0 warnings, 0 errors.
|
||
|
||
**Step 2: Full suite** against the live cluster:
|
||
`dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/` → expect
|
||
**0 failed** (skips are logged by `SkipSummaryReporter`). Wave 1 baseline was 96 passed;
|
||
Wave 2 adds ~13 facts → ~109 passed (exact count depends on tolerant no-ops like
|
||
ParkedMessages Test C). Confirm no Wave-1 regressions.
|
||
|
||
**Step 3: Residue check** — confirm no `zztest-*` survivors and site-a left as found:
|
||
- `dotnet <scadabridge.dll> ... --format json site area list --site-id <siteAId>` → no
|
||
`zztest-*` areas remain.
|
||
- `... instance list --site-id <siteAId>` → no `zztest-inst-*` / `zztest-cfginst-*` etc.
|
||
- `... template list` → no `zztest-*` templates.
|
||
- The DB-seeding SiteCalls Discard test cleans by `targetPrefix`; confirm none linger if MSSQL
|
||
is reachable.
|
||
|
||
**Step 4: Confirm the app change is inert** — `git diff` the `DebugView.razor` change is
|
||
exactly the two additive `data-test` attributes (no behavioral edits), and the page renders
|
||
and connects identically. (Task 5 Test A already asserts the hooks are served.)
|
||
|
||
**Step 5: Per-wave gate met?** New tests pass, full suite at 0 failed, zero residue, build
|
||
clean, `data-test` additions verified non-behavioral. If yes, Wave 2 is shippable. Do **not**
|
||
push unless the user explicitly asks (`/pushit`).
|
||
|
||
**Step 6: Update the resume memory** (`playwright-coverage-fill-resume.md`) to mark Wave 2
|
||
complete and point Wave 3 as next, mirroring the Wave 1 note. Commit any plan/tasks bookkeeping.
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
| Task | Theme | New/▲ facts | Files |
|
||
|---|---|---|---|
|
||
| 0 | CLI helper `ListAreaIdsByNamePrefixAsync` | +1 round-trip | `CliRunner.Helpers.cs`, `CliRunnerHelpersTests.cs` |
|
||
| 1 | Deployments SignalR push + pause/refresh | +2 | `DeploymentsRealtimeTests.cs` (new) |
|
||
| 2 | ParkedMessages filter/action controls guard | +3 | `ParkedMessagesActionTests.cs` (new) |
|
||
| 3 | SiteCalls Discard click-through | +1 | `SiteCallsPageTests.cs` (▲) |
|
||
| 4 | DebugView `data-test` hooks (+ rebuild) | — | `DebugView.razor` (▲, app) |
|
||
| 5 | DebugView controls + connect-resolves | +2 | `DebugViewTests.cs` (new) |
|
||
| 6 | Topology create-area + inline rename | +3 | `TopologyAreaTests.cs` (new) |
|
||
| 7 | Topology move-area/instance + diff | +3 | `TopologyAreaTests.cs` (▲) |
|
||
| 8 | Verification + residue | — | none |
|
||
|
||
**Total: ~15 new facts across 5 new files + 2 modified test files + 1 inert app change.**
|
||
Wave ends green, zero residue, one cluster rebuild (Task 4). Waves 3–4 follow in their own
|
||
plans, one at a time, per the design's "one wave at a time" intent.
|