Files
ScadaBridge/docs/plans/2026-06-05-playwright-coverage-expansion.md
T
Joseph Doherty b540015fbd docs(tests): implementation plan for Playwright coverage expansion
16 task-by-task steps: shared CliRunner + ClusterAvailability skip infra,
DeploymentFixture + deploy/enable/disable/delete suites, notification
retry/discard + parked-messages query, Transport Import round-trip, Site/
Template/LDAP CRUD round-trips, nav render hardening, Health KPI guard, and a
no-residue verification pass. Co-located .tasks.json for resumable execution.
2026-06-05 09:52:12 -04:00

29 KiB
Raw Blame History

Playwright Coverage Expansion Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.

Goal: Add ~15 functional Playwright E2E tests + a shared CLI fixture + a standardized skip policy to close the gaps from the 2026-06-05 coverage audit, against the live 8-node docker cluster.

Architecture: New tests live in the existing tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests project, share the existing serial [Collection("Playwright")] + PlaywrightFixture (remote Chromium at ws://localhost:3000, app at http://scadabridge-traefik). State-changing tests provision ephemeral zztest-* fixtures by shelling out to the scadabridge CLI (host → http://localhost:9000, multi-role/password) and assert outcome-tolerantly (a confirm-dialog → a single .toast appears; the relay outcome may be success or a fast error). Every DB/cluster-dependent test uses SkippableFact + a shared availability probe and a logged skip summary.

Tech Stack: C# net10.0, xunit + Xunit.SkippableFact, Microsoft.Playwright, Microsoft.Data.SqlClient, the scadabridge CLI (src/ZB.MOM.WW.ScadaBridge.CLI). TreatWarningsAsErrors=true — all new code must be warning-clean and nullable-correct.


Shared reference (used by many tasks — read once)

Project paths

  • Test project dir: tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/ (abbrev. TESTS/).
  • CLI project: src/ZB.MOM.WW.ScadaBridge.CLI/ZB.MOM.WW.ScadaBridge.CLI.csproj.
  • Existing seeders to mirror: TESTS/Audit/AuditDataSeeder.cs, TESTS/SiteCalls/SiteCallDataSeeder.cs.
  • Existing canonical mutating test to copy: TESTS/SiteCalls/SiteCallsPageTests.cs (RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast).
  • Fixture/collection: TESTS/PlaywrightFixture.cs ([CollectionDefinition("Playwright")], BaseUrl, NewAuthenticatedPageAsync(), ExpandAllNavSectionsAsync()).

Global UI selectors (from a full source sweep — use these literally)

  • Confirm dialog (global DialogHost): modal .modal.show.d-block; footer .modal-footer; confirm button = .modal-footer .btn-danger (text Delete, danger) or .modal-footer .btn-primary (text Confirm, non-danger); cancel = .modal-footer .btn-outline-secondary.
  • Toast (ToastNotification): .toast (each .toast.show[role=alert]); body .toast-body. Assert .toast visible (Timeout 15_000) and CountAsync()==1.
  • Tree (Topology/Templates): nodes are li[role=treeitem] containing div.tv-row; label in span.tv-label; right-click the row to open the context menu .dropdown-menu.show; items are button.dropdown-item (delete is button.dropdown-item.text-danger).
  • Card/table kebab (Sites/LdapMappings): click button[aria-label^="More actions"] (text ) → .dropdown-menu.

CLI quick facts

  • Invoke: dotnet <CLI.dll> --url http://localhost:9000 --username multi-role --password password --format json <verb> <args>. Exit 0 ok, 1 error; JSON on stdout.
  • Verbs: site list/create --name --identifier/delete --id/area create --site-id --name/area delete --id; template create --name [--description]/delete --id/attribute add --template-id --name --data-type/list; instance create --name --template-id --site-id [--area-id]/deploy --id/enable --id/disable --id/delete --id; bundle export --output --passphrase --source-environment/import.

Naming + teardown convention

  • All provisioned entities named zztest-<8hex> (Guid.NewGuid().ToString("N")[..8]). Teardown is best-effort (swallow exceptions), in finally.

Skip pattern (rec 7)

  • Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); at the top of every DB/cluster test.

Task 0: Add CLI ProjectReference to the test project

Classification: trivial Estimated implement time: ~2 min Parallelizable with: none (foundation)

Files:

  • Modify: TESTS/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.csproj

Step 1: Add a ProjectReference to the CLI so its build output (the scadabridge dll) is always present for the test process to invoke. Use ReferenceOutputAssembly="false" so the CLI's types are NOT linked into the test assembly (we only need its build artifact), but Private/build-ordering pulls the build:

<ItemGroup>
  <ProjectReference Include="..\..\src\ZB.MOM.WW.ScadaBridge.CLI\ZB.MOM.WW.ScadaBridge.CLI.csproj"
                    ReferenceOutputAssembly="false" />
</ItemGroup>

Step 2: Run dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests → expect success; confirm src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/ZB.MOM.WW.ScadaBridge.CLI.dll exists. Step 3: Commit: test(e2e): reference CLI project so tests can shell out to it.


Task 1: CliRunner core (subprocess + JSON + availability probe)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 3, Task 13, Task 14

Files:

  • Create: TESTS/Cluster/CliRunner.cs
  • Create: TESTS/Cluster/ClusterAvailability.cs
  • Test: TESTS/Cluster/CliRunnerSmokeTests.cs

Step 1 — Write the failing smoke test:

using Xunit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;

[Collection("Playwright")]
public class CliRunnerSmokeTests
{
    [SkippableFact]
    public async Task SiteList_ReturnsJsonArray()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
        using var doc = await CliRunner.RunJsonAsync("site", "list");
        Assert.True(doc.RootElement.ValueKind is System.Text.Json.JsonValueKind.Array
                    or System.Text.Json.JsonValueKind.Object);
    }
}

Step 2: Run dotnet test --filter CliRunnerSmokeTests → FAIL (CliRunner not defined).

Step 3 — Implement CliRunner:

  • Resolve the CLI dll: walk up from AppContext.BaseDirectory to the repo root, then src/ZB.MOM.WW.ScadaBridge.CLI/bin/<config>/net10.0/ZB.MOM.WW.ScadaBridge.CLI.dll. Allow SCADABRIDGE_CLI_DLL env override. Throw a clear message if not found.
  • RunAsync(params string[] args): Process.Start dotnet <dll> --url <url> --username multi-role --password password --format json <args...>; capture stdout+stderr; await WaitForExitAsync() with a 60s timeout (kill + throw on timeout); on non-zero exit throw InvalidOperationException($"CLI {string.Join(' ', args)} exited {code}: {stderr}"). Return stdout.
  • RunJsonAsync(...): JsonDocument.Parse(await RunAsync(...)).
  • URL constant http://localhost:9000; allow SCADABRIDGE_MANAGEMENT_URL override (matches CLI's own env var).

Step 4 — Implement ClusterAvailability:

public static class ClusterAvailability
{
    public const string SkipReason = "Cluster/MSSQL unavailable — start the docker cluster (bash docker/deploy.sh) to run E2E.";
    private static bool? _cached;
    public static async Task<bool> IsAvailableAsync()
    {
        if (_cached is { } c) return c;
        try { using var _ = await CliRunner.RunJsonAsync("site", "list"); _cached = true; }
        catch { _cached = false; }
        return _cached.Value;
    }
}

Step 5: Run the smoke test → PASS (cluster up). Verify it SKIPS (not fails) if you stop the cluster (optional manual check; do not leave the cluster down). Step 6: Commit: test(e2e): add CliRunner + ClusterAvailability probe.


Task 2: CliRunner typed fixture helpers

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 3, Task 13, Task 14 Blocked by: Task 1

Files:

  • Modify: TESTS/Cluster/CliRunner.cs
  • Test: TESTS/Cluster/CliRunnerHelpersTests.cs

Step 1 — Failing test (round-trips a real template via the CLI):

[SkippableFact]
public async Task CreateThenDeleteTemplate_RoundTrips()
{
    Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
    var name = CliRunner.UniqueName("tmpl");
    int id = await CliRunner.CreateTemplateAsync(name);
    try
    {
        var ids = await CliRunner.ListTemplateIdsByNamePrefixAsync(name);
        Assert.Contains(id, ids);
    }
    finally { await CliRunner.DeleteTemplateAsync(id); }
}

Step 2: Run → FAIL.

Step 3 — Implement helpers (each parses the JSON the CLI returns to extract the new integer id; inspect a live template create JSON shape first via dotnet <dll> ... template create --name probe then delete it, to confirm the id field name — likely id or templateId):

  • static string UniqueName(string kind) => $"zztest-{kind}-{Guid.NewGuid():N}"[..N] (keep it short).
  • Task<int> CreateTemplateAsync(string name, string? description=null)
  • Task AddAttributeAsync(int templateId, string name, string dataType="Double")
  • Task<int> CreateInstanceAsync(string name, int templateId, int siteId, int? areaId=null)
  • Task<int> CreateAreaAsync(int siteId, string name)
  • Task<int> ResolveSiteIdAsync(string identifier) (run site list, find by identifier/siteIdentifier == "site-a", return its id)
  • Task DeployInstanceAsync/EnableInstanceAsync/DisableInstanceAsync/DeleteInstanceAsync(int id)
  • Task DeleteTemplateAsync(int id) / DeleteAreaAsync(int id) / DeleteSiteAsync(int id)
  • Task<IReadOnlyList<int>> ListTemplateIdsByNamePrefixAsync(string prefix) (run template list, filter name StartsWith prefix)
  • Task BundleExportAsync(string outputPath, int templateId, string passphrase, string sourceEnvironment) (use bundle export --output <p> --passphrase <pp> --source-environment <env> plus whatever selector flag scopes to one template — inspect bundle export --help; if it only supports --all/--include-dependencies, export --all into a throwaway-clean cluster slice is unsafe, so prefer a template selector flag; if none exists, note it and fall back to --all with the understanding the bundle may carry more — see Task 9 risk note).
  • All helpers swallow nothing on create (must throw on failure) but Delete* helpers swallow exceptions (best-effort teardown).

Step 4: Run → PASS. Step 5: Commit: test(e2e): add CliRunner typed fixture helpers.


Task 3: Standardize skip policy + skip-count logging (rec 7)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2, Task 13, Task 14

Files:

  • Create: TESTS/Cluster/SkipLogCollectionFixture.cs (or extend PlaywrightFixture)
  • Modify: TESTS/Audit/AuditLogPageTests.cs (convert the 11 throw-on-unavailable guards to SkippableFact + Skip.IfNot)

Step 1: In AuditLogPageTests.cs, replace each [Fact] whose body throws InvalidOperationException when AuditDataSeeder.IsAvailableAsync() is false with [SkippableFact] + Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); (mirror AuditGridColumnTests). Keep all assertions otherwise unchanged. Step 2: Add skip logging: implement IDisposable/IAsyncLifetime on a collection fixture (or in PlaywrightFixture.DisposeAsync) that, at assembly teardown, writes one line to the console: Console.WriteLine($"[E2E] Skipped {SkipTracker.Count} cluster/DB-dependent tests — {ClusterAvailability.SkipReason}"); where SkipTracker is a static counter incremented by a tiny helper SkipUnlessAvailable() that the tests call. (Simplest: a static int on ClusterAvailability bumped inside IsAvailableAsync when it returns false; log it in fixture dispose.) Step 3: Run dotnet test --filter AuditLogPageTests with the cluster UP → all pass (0 skipped, log line shows 0). Step 4: Commit: test(e2e): standardize DB-dependent tests on SkippableFact + skip logging.


Task 4: DeploymentFixture (ephemeral template + area on real site-a)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 8, Task 9 (different files) Blocked by: Task 2

Files:

  • Create: TESTS/Deployment/DeploymentFixture.cs

Why site-a (not a throwaway site): Deploy/Enable/Disable relay to the owning site over ClusterClient. An unknown site identifier has no registered ClusterClient, so the relay resolves only on a slow 10s Ask timeout and never produces a fast toast (see the explicit comment in SiteCallsPageTests.RetryClickThrough...). So the ephemeral instance must live on a real, running site — use site-a.

Step 1: Implement an IAsyncLifetime fixture (NOT a collection fixture — instantiate per test class so the shared template/area are created once for the deployment suite):

  • InitializeAsync: if !await ClusterAvailability.IsAvailableAsync() return (tests will skip). Else resolve SiteAId = await CliRunner.ResolveSiteIdAsync("site-a"); create TemplateId = await CliRunner.CreateTemplateAsync(UniqueName("deploytmpl")); await CliRunner.AddAttributeAsync(TemplateId, "Value", "Double") (so it validates); AreaId = await CliRunner.CreateAreaAsync(SiteAId, UniqueName("area")).
  • Task<int> CreateInstanceAsync()CliRunner.CreateInstanceAsync(UniqueName("inst"), TemplateId, SiteAId, AreaId).
  • DisposeAsync: best-effort delete the area + template (instances are deleted by the tests; also list+delete any leftover zztest-inst-* on site-a as a safety net). Step 2: No standalone test — it's exercised by Tasks 57. Build only (dotnet build TESTS). Step 3: Commit: test(e2e): add DeploymentFixture (ephemeral instance on site-a).

Task 5: DeploymentActionTests.Deploy

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (shares DeploymentFixture file is read-only; but tests in same class serialize) Blocked by: Task 4

Files:

  • Create: TESTS/Deployment/DeploymentActionTests.cs

Step 1 — Test (TDD: write, watch it fail to compile, then it should pass against the live cluster):

[Collection("Playwright")]
public class DeploymentActionTests : IClassFixture<DeploymentFixture>, IAsyncLifetime
{
    private readonly PlaywrightFixture _pw;            // via collection
    private readonly DeploymentFixture _cluster;
    public DeploymentActionTests(PlaywrightFixture pw, DeploymentFixture cluster){ _pw=pw; _cluster=cluster; }
    public Task InitializeAsync() => Task.CompletedTask;
    public Task DisposeAsync() => Task.CompletedTask;

    [SkippableFact]
    public async Task Deploy_Instance_ShowsOutcomeToast()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
        int instanceId = await _cluster.CreateInstanceAsync();
        try
        {
            var page = await _pw.NewAuthenticatedPageAsync();
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/topology");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
            // Expand to the instance; locate its tree row by the unique name, right-click → Deploy.
            var row = page.Locator("li[role=treeitem] .tv-row", new(){ HasText = /* instance unique name */ });
            await ExpandToInstanceAsync(page, row);     // helper: click ancestor toggles until visible
            await row.ClickAsync(new(){ Button = MouseButton.Right });
            await page.Locator(".dropdown-menu.show button.dropdown-item", new(){ HasText="Deploy" }).ClickAsync();
            // Deploy has NO confirm dialog → outcome toast directly.
            var toast = page.Locator(".toast");
            await Assertions.Expect(toast).ToBeVisibleAsync(new(){ Timeout = 15_000 });
            Assert.Equal(1, await toast.CountAsync());
        }
        finally { await CliRunner.DeleteInstanceAsync(instanceId); }
    }
}
  • Note: capture the instance unique name from CreateInstanceAsync (extend the fixture to return (int id, string name) so the test can locate the row). Adjust the helper accordingly.
  • ExpandToInstanceAsync: click the site-a node then the area node toggles (.tv-row chevrons) so the instance row renders; or use the page's Expand toolbar button (button[aria-label="Expand all areas"]) — simpler: click Expand, then locate the row.

Step 2: Run dotnet test --filter Deploy_Instance → PASS (toast appears; outcome may be Deployed or a fast error — tolerant). Step 3: Commit: test(e2e): cover Topology Deploy action.


Task 6: DeploymentActionTests.Enable + Disable

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (same file as Task 5 — sequential) Blocked by: Task 5

Files: Modify TESTS/Deployment/DeploymentActionTests.cs

  • Enable_Instance_ShowsOutcomeToast: deploy first (Enable only shows when state==Disabled; flow depends on cluster state). Simpler and outcome-tolerant: right-click → if Enable item present click it, else click Disable; assert toast. To keep it deterministic, target Disable: after a Deploy the instance is Enabled, so Disable is offered. Disable opens a confirm dialog (danger → .modal-footer .btn-danger text Delete); click it, then assert toast.
  • Disable_Instance_ShowsOutcomeToast: deploy → right-click → Disable → confirm .modal-footer .btn-danger → assert single .toast.
  • Each test creates its own instance via _cluster.CreateInstanceAsync() and deletes it in finally.

Step: Run filter → PASS. Commit: test(e2e): cover Topology Enable/Disable actions.


Task 7: DeploymentActionTests.Delete

Classification: standard Estimated implement time: ~4 min Parallelizable with: none (same file) Blocked by: Task 6

Files: Modify TESTS/Deployment/DeploymentActionTests.cs

  • Delete_Instance_RemovesFromTree: create instance → Topology → locate row → right-click → Delete (button.dropdown-item.text-danger) → confirm .modal-footer .btn-danger (text Delete) → assert the row with that instance name is no longer visible (await Assertions.Expect(row).ToHaveCountAsync(0) within a timeout). No CLI delete needed in finally (the UI deleted it) but call CliRunner.DeleteInstanceAsync best-effort anyway in case the UI delete failed.

Step: Run → PASS. Commit: test(e2e): cover Topology Delete action.


Task 8: NotificationOutbox retry/discard + ParkedMessages query (rec 2)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 4, Task 9 Blocked by: Task 2

Files:

  • Create: TESTS/Notifications/NotificationDataSeeder.cs (mirror SiteCallDataSeeder; INSERT a Parked row into the central Notifications table — read the Notifications EF entity at src/ZB.MOM.WW.ScadaBridge.Commons/Entities/.../Notification*.cs + its migration to get exact columns; tag a unique field, e.g. a zztest-* recipient/list/target, for teardown).
  • Create: TESTS/Notifications/NotificationActionTests.cs
  • Create: TESTS/Monitoring/ParkedMessagesTests.cs

Notification retry/discard (seedable — central singleton, fast):

  • Retry_ParkedNotification_ShowsOutcomeToast: seed a Parked Notifications row with a unique marker → /notifications/report → filter to status Parked / search the marker → Query → find row → click row's Retry (button.btn-outline-success) → confirm dialog non-danger .modal-footer .btn-primary (text Confirm) → assert single .toast. Teardown: delete the seeded row.
  • Discard_ParkedNotification_ShowsOutcomeToast: same but Discard (button.btn-outline-danger) → confirm danger .modal-footer .btn-danger (text Delete) → toast.

ParkedMessages query (NOT seedable — site SQLite over Akka Ask; deterministic render test):

  • ParkedMessages_QueryForSite_RendersWithoutHang: /monitoring/parked-messages → select #pm-filter-site = site-a (option value = SiteIdentifier) → click Query (button.btn-primary text Query) → assert that within a timeout EITHER table.parked-table OR an empty-state element renders (i.e. the singleton-backed query resolved, not hung). This guards the Akka-Ask hang class without needing to seed site state. (Document in a comment why retry/discard isn't exercised here: parked S&F messages live in site SQLite and can't be seeded from central SQL.)

Steps: TDD each (write → fails to compile → passes live). Run dotnet test --filter "NotificationActionTests|ParkedMessagesTests" → PASS. Commit: test(e2e): cover notification retry/discard + parked-messages query.


Task 9: Transport Import round-trip (rec 3)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 4, Task 8 Blocked by: Task 2

Files:

  • Create: TESTS/Transport/TransportImportTests.cs

De-risk file upload FIRST (Step 0): Playwright SetInputFilesAsync(hostPath) over a remote browser connection streams the host file to the container — verify with a 3-line spike before building the rest (upload the exported bundle to #bundle-input, assert [data-testid="manifest-summary"] appears). If streaming fails, fall back to writing the bundle to a path the container can read (a shared mount) and note it.

Test ImportSyntheticBundle_AppliesAndShowsAuditDrillIn:

  1. Skip.IfNot(...cluster...). var env = CliRunner.UniqueName("env"); var tmplName = CliRunner.UniqueName("imp");
  2. CLI: int tmplId = await CliRunner.CreateTemplateAsync(tmplName); await CliRunner.AddAttributeAsync(tmplId,"Value","Double");
  3. CLI: var bundlePath = Path.Combine(Path.GetTempPath(), $"{tmplName}.scadabundle"); await CliRunner.BundleExportAsync(bundlePath, tmplId, passphrase:"pw-"+env, sourceEnvironment:env);
  4. Delete the source template via CLI so the import sees it as a NEW item (not Modified): await CliRunner.DeleteTemplateAsync(tmplId);
  5. UI: /design/transport/importSetInputFiles("#bundle-input", bundlePath) → Next → #import-passphrase = "pw-"+envUnlock → diff step (new template renders Add, no blockers) → Next → #confirm-env type env (must equal Manifest.SourceEnvironment) → Apply Import (button.btn-danger).
  6. Assert [data-testid="result-summary"] text contains Import complete. and the Audit trail → link href starts /audit/configuration?bundleImportId=.
  7. finally: foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(tmplName)) await CliRunner.DeleteTemplateAsync(id); and File.Delete(bundlePath) best-effort.

Step: Run dotnet test --filter TransportImportTests → PASS; verify no residual zztest-imp-* templates (template list). Commit: test(e2e): cover Transport Import apply round-trip.


Task 10: Site CRUD round-trip (rec 6)

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 11, Task 12, Task 13, Task 14 Blocked by: Task 2 (safety-net teardown only)

Files: Modify TESTS/SiteCrudTests.cs

  • CreateEditDelete_Site_RoundTrips: /admin/sites/create → fill (by <label>-anchored inputs / h6 Node A/B-scoped placeholders): Name=zztest-site-<hex>, Identifier=zztest-<hex>, Description, and one Node A Akka + gRPC address (any well-formed value — akka.tcp://scadabridge@zz:5000/user/site-communication, http://zz:8083) → Save → assert the new card (.card with .card-title text == name) appears on /admin/sites → Edit → change Description → Save → kebab Delete → confirm .modal-footer .btn-danger → assert the card is gone. finally: best-effort CliRunner delete by resolving the site id via site list on the identifier.

Step: Run → PASS. Commit: test(e2e): cover Site create/edit/delete round-trip.


Task 11: Template CRUD round-trip (rec 6)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 10, Task 12, Task 13, Task 14 Blocked by: Task 2 (safety-net teardown)

Files: Create TESTS/Design/TemplateCrudTests.cs

  • CreateAddAttributeDelete_Template_RoundTrips: /design/templates/create → Name=zztest-tmpl-<hex>Create (button.btn-success) → lands on /design/templates/{id} → Attributes tab → Add Attribute → modal: Name=Val, Data Type select → Add (submit text is Add for new) → assert the attribute row appears → header Delete (button.btn-outline-danger) → confirm .modal-footer .btn-danger → assert redirect to /design/templates and the node is gone. finally: ListTemplateIdsByNamePrefixAsync("zztest-tmpl-")→delete.

Step: Run → PASS. Commit: test(e2e): cover Template create/add-attribute/delete round-trip.


Task 12: LDAP mapping CRUD round-trip (rec 6)

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 10, Task 11, Task 13, Task 14 Blocked by: none (pure UI; safety-net via direct SQL optional)

Files: Create TESTS/Admin/LdapMappingCrudTests.cs

  • CreateEditDelete_LdapMapping_RoundTrips: /admin/ldap-mappings/create → LDAP Group Name (label-anchored input)=zztest-grp-<hex> → Role select (.form-select) = DesignerSave → assert the row (tr whose group-name td == the group) appears on /admin/ldap-mappings → Edit → change Role to Viewer → Save → kebab Delete. Important: LdapMappings Delete has NO confirm dialog (the page doesn't inject IDialogService) — clicking Delete removes the row immediately. Assert the row disappears (do NOT wait for a modal). finally: best-effort delete (UI did it; optional SQL net keyed on the unique group name).

Step: Run → PASS. Commit: test(e2e): cover LDAP mapping create/edit/delete round-trip.


Task 13: Navigation render-assertion hardening (rec 4)

Classification: small Estimated implement time: ~5 min Parallelizable with: Task 10, Task 11, Task 12, Task 14 Blocked by: none

Files: Modify TESTS/NavigationTests.cs

  • Add a per-route expected-heading map (route → expected h1/h4/h5 text), e.g. /admin/sites→"Site Management", /notifications/report→"Notification Report", /design/templates→"Templates", /deployment/topology→(its heading), /monitoring/health→(its heading), etc. (Read each target page's heading to fill the map exactly.)
  • In the shared ClickNavAndWait helper (or each Theory), after asserting the URL, also await Assertions.Expect(page.Locator("h1,h4,h5", new(){ HasText = expectedHeading })).ToBeVisibleAsync(). This catches a route that 500s after navigation.
  • Keep all existing Theory cases; just strengthen the assertion. No new test methods.

Step: Run dotnet test --filter NavigationTests → PASS (all routes render). Commit: test(e2e): assert destination renders, not just URL, in nav tests.


Task 14: Health KPI load test (rec 5)

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 10, Task 11, Task 12, Task 13 Blocked by: none

Files: Create TESTS/Monitoring/HealthDashboardTests.cs

  • KpiTiles_ResolveToValues_NotDegradePlaceholder: /monitoring/health → wait for load → assert the three KPI groups resolved to values (not the em-dash degrade):
    • Notification-Outbox (inlined, no data-test): .card whose .text-muted label is Queue Depth → its sibling h3 text matches ^\d+$ (and != "—"). Repeat for Stuck, Parked.
    • Audit tiles: [data-test='audit-kpi-volume'] (+ error-rate, backlog) text is non-empty/non-.
    • Site-Call tiles: [data-test='site-call-kpi-buffered'] (+ stuck, parked).
    • Use a generous per-tile wait (the page polls/loads async). This is the direct regression guard for the singleton-hang class.

Step: Run → PASS. Commit: test(e2e): assert Health KPI tiles resolve (singleton-hang guard).


Task 15: Full-suite verification + no-residue check

Classification: small Estimated implement time: ~4 min Parallelizable with: none (final) Blocked by: all

Steps:

  1. dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests → expect 0 failed; note passed/skipped counts and confirm the skip-summary log line prints.
  2. dotnet <CLI.dll> ... site list / template list / instance list --site-id <site-a> → grep for zztest- → expect none (clean teardown). If any leak, fix the offending test's finally.
  3. Commit any cleanup. Final message summarizing new coverage (count of added tests, the recs covered).

Notes / open risks (surface, don't silently absorb)

  • CLI JSON id field names (Task 2): confirm the actual field (id vs templateId/instanceId/siteId) from a live create response before finalizing the parsers.
  • bundle export single-template selection (Task 2/9): verify the CLI supports scoping the export to one template; if it only does --all, adjust Task 9 (export a minimal cluster or accept a larger bundle and assert only the zztest template landed).
  • Remote file upload (Task 9 Step 0): de-risk SetInputFiles over the WS-connected browser before building the full import test.
  • Deploy side-effects on site-a (Tasks 57): ephemeral instances are deployed to the real site-a and deleted in finally; the no-residue check (Task 15) is the backstop.
  • Outcome tolerance: deploy/enable/disable/retry/discard assert a toast, not a specific outcome — the live cluster decides Deployed/Applied vs SiteUnreachable/NotParked. This is intentional (matches the existing SiteCalls relay test).