# 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 `