Files
ScadaBridge/docs/plans/2026-06-06-playwright-coverage-fill-wave2.md

50 KiB
Raw Permalink Blame History

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 — 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:

[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):

/// <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: Commitgit 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).

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

[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: Committest(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.

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

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

[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: Committest(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):

[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: Committest(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"):

<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"):

<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: Commitfeat(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.CreateInstanceAsyncCliRunner.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.

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

[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: Committest(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 '' 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.showbutton.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 ''." + 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:

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

[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).

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

[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: Committest(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 '' 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 '' 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.

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

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

[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: Committest(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 07

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 inertgit 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 34 follow in their own plans, one at a time, per the design's "one wave at a time" intent.