diff --git a/docs/plans/2026-06-06-playwright-coverage-fill-wave2.md b/docs/plans/2026-06-06-playwright-coverage-fill-wave2.md new file mode 100644 index 00000000..3848fe48 --- /dev/null +++ b/docs/plans/2026-06-06-playwright-coverage-fill-wave2.md @@ -0,0 +1,1036 @@ +# 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(, )`. + - 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("")` → `zztest--<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~"` +- 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 --url http://localhost:9000 --username multi-role --password password --format json site area list --site-id ` +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 +/// +/// Returns the ids of all areas on whose name starts with +/// , via site area list --site-id. Used to delete areas a +/// test created through the UI (where the new id is never surfaced to the test). +/// +public static async Task> 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(); + 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` + `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 ` +``` + +On the Instance select (`@bind="_selectedInstanceId"`): + +```razor +` = Site (`@bind="_siteId"`, option value = site.Id), second `` + (`@bind="_targetParentId"`, options include "(Site root)"); footer Move + `button.btn.btn-primary.btn-sm:has-text('Move')`. Success → toast "Area '' moved." +- `MoveInstanceDialog`: title `h6.modal-title:has-text("Move '' to…")`; one `` 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 '' 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 '' 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 `