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

434 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```xml
<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:**
```csharp
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`:**
```csharp
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):**
```csharp
[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):**
```csharp
[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/import``SetInputFiles("#bundle-input", bundlePath)` → Next → `#import-passphrase` = `"pw-"+env``Unlock` → 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`) = `Designer``Save` → 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).