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