# Playwright Coverage Fill — Wave 3 Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

**Goal:** Add Tier-3 config-CRUD-breadth E2E coverage to the ScadaBridge Central UI Playwright suite — 9 new test suites across Notifications, Design (External Systems / Data Connections / Shared Scripts / API Methods), Event Logs, and Configuration Audit — leaving the suite green with zero residue.

**Architecture:** New xunit `[Collection("Playwright")]` suites in `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests`, driven against the live 8-node docker cluster (browser URL `http://scadabridge-traefik`, CLI from host `http://localhost:9000`). Fixtures and state are CLI-provisioned (`zztest-*` ephemeral, read-back-verified); the one report-style page that needs central rows (`/audit/configuration`) gets a small direct-SQL seeder mirroring the existing `AuditDataSeeder`. Monaco-backed entities are CLI-seeded so the editor stays out of the critical path.

**Tech Stack:** xunit + `Xunit.SkippableFact`, Microsoft.Playwright (remote Chromium `ws://localhost:3000`), Microsoft.Data.SqlClient, the ScadaBridge CLI (`scadabridge.dll`). TFM net10.0, `Nullable=enable`, `TreatWarningsAsErrors=true`.

---

## Wave-3-specific notes (read before starting)

**NO app-code change. NO `docker/deploy.sh` rebuild.** Unlike Wave 2, Wave 3 adds **zero** `data-test` hooks — every target page already exposes stable selectors (`id`/`aria-label`/visible text), and row assertions are content/count based. The only non-test artifacts are CLI helpers (test project) and one new DB seeder (test project). The running cluster image already serves everything these tests touch. (If the cluster happens to be down, tests skip via the `ClusterAvailability` gate — start it with `bash docker/deploy.sh` and wait for `/health/ready` 200 before running.)

**Cadence constraint (identical to Waves 1–2).** There is **one** shared Playwright browser (`PlaywrightFixture`, single `[Collection("Playwright")]`, serial), **one** cluster, **one** build. Therefore **every task that runs tests must be executed serially** — each task is marked `Parallelizable with: none` even though file sets are disjoint. The executor must NOT dispatch two test-running implementers concurrently. Within a task, the implementer runs only its own `dotnet test --filter`.

**Established conventions to honor (verbatim, from Waves 1–2):**
- `[Collection("Playwright")]` on every class; class fixtures via `IClassFixture<TFixture>`.
- First line of every state-touching fact: `Skip.IfNot(<available>, ClusterAvailability.SkipReason);` — `<available>` is `_cluster.Available` when a fixture is used, else `await ClusterAvailability.IsAvailableAsync()`.
- Auth: `await _pw.NewAuthenticatedPageAsync()` (cookie-via-fetch; **never** type into the login form — this respects the standing "no credentials into forms" constraint).
- URLs: `$"{PlaywrightFixture.BaseUrl}/..."` (BaseUrl = `http://scadabridge-traefik`).
- Ephemeral names: `CliRunner.UniqueName("<kind>")` → `zztest-<kind>-<8hex>`.
- Toast (web-first): `await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });` then optionally assert `.toast-body` text. **Toasts auto-dismiss at 5 s — assert promptly.**
- Danger confirm dialog: `.modal-footer .btn-danger` (text `Delete`). Non-danger confirm: `button:has-text('Confirm')` (`.btn-primary`).
- Per-row kebab: scope to the row/card `.dropdown`; toggle `button[aria-label^='More actions']`; item `.dropdown-menu button.dropdown-item` filtered by text (danger item also has `.text-danger`).
- Best-effort teardown in `finally`: provision helpers throw; `Delete*` helpers swallow.
- `PlaywrightFixture` caps live contexts at 4 — keep it; do not raise it.

**Validation-behavior protocol (the ⚠ items).** Before asserting a specific failure/empty/disabled state, the implementer reads the page code-behind to confirm the actual behavior and asserts THAT. Where the app's real behavior is weaker than the design's wish (e.g. SMTP has no delete verb; clipboard fails on a non-secure origin), assert the real surfaced behavior and leave a one-line code comment noting the gap. The exact behaviors are already captured per-task below — but verify before asserting.

**Page → route → role quick map (all reachable by `multi-role`):**

| Page | Route | Policy |
|---|---|---|
| Notification Lists | `/notifications/lists` (+ `/create`, `/{id}/edit`) | RequireDesign |
| SMTP Configuration | `/notifications/smtp` | RequireAdmin |
| Notification KPIs | `/notifications/kpis` | RequireDeployment |
| External Systems | `/design/external-systems` (+ `/create`, `/{id}/edit`) | RequireDesign |
| Data Connections | `/design/connections` (tree) (+ `/create?siteId=`) | RequireDesign |
| Shared Scripts | `/design/shared-scripts` (+ `/create`, `/{id}/edit`) | RequireDesign |
| API Method form | `/design/api-methods/create`; methods listed on `/design/external-systems` → "Inbound API Methods" tab | RequireDesign |
| Site Event Logs | `/monitoring/event-logs` | RequireDeployment |
| Configuration Audit Log | `/audit/configuration` (+ `?bundleImportId={guid}`) | OperationalAudit (Admin or Viewer) |

---

## Shared infrastructure

### CLI helper extensions — `tests/.../Cluster/CliRunner.Helpers.cs`

**Already exist (reuse as-is):** `UniqueName`, `CreateTemplateAsync`, `AddAttributeAsync`, `CreateAreaAsync`, `CreateInstanceAsync`, `CreateDataConnectionAsync(int siteId, string name, string protocol="OpcUa", string? primaryConfig=null)`, `CreateApiMethodAsync(string name, string script="return null;")`, `DeployInstanceAsync`, `EnableInstanceAsync`, `DisableInstanceAsync`, `ResolveSiteIdAsync`, `GetInstanceDocumentAsync`, `ListTemplateIdsByNamePrefixAsync`, `ListAreaIdsByNamePrefixAsync`, `DeleteInstanceAsync`, `DeleteTemplateAsync`, `DeleteAreaAsync`, `DeleteSiteAsync`, `DeleteDataConnectionAsync`, `DeleteApiMethodAsync`, `ResolveApiKeyIdByNameAsync`, `CreateApiKeyAsync`, `DeleteApiKeyAsync`.

**To ADD (Task 0)** — mirror the existing throw-vs-swallow split; new int-delete teardowns delegate to `BestEffortAsync("<group>", "delete", id)`; new creates use `RequireId(doc, "<command>")`. JSON shapes are arrays of PascalCase entity objects.

```csharp
// ── Provision (throw) ──────────────────────────────────────────────────────
public static async Task<int> CreateExternalSystemAsync(
    string name, string endpointUrl = "https://example.invalid/api", string authType = "ApiKey")
{
    using var doc = await RunJsonAsync(
        "external-system", "create",
        "--name", name, "--endpoint-url", endpointUrl, "--auth-type", authType);
    return RequireId(doc, "external-system create");
}

public static async Task<int> CreateNotificationListAsync(string name, string emails = "noreply@example.invalid")
{
    using var doc = await RunJsonAsync("notification", "create", "--name", name, "--emails", emails);
    return RequireId(doc, "notification create");
}

public static async Task<int> CreateSharedScriptAsync(string name, string code = "return null;")
{
    using var doc = await RunJsonAsync("shared-script", "create", "--name", name, "--code", code);
    return RequireId(doc, "shared-script create");
}

// ── Verify / read-back (throw) — resolve a UI-created entity's id for teardown ─
public static async Task<IReadOnlyList<int>> ListExternalSystemIdsByNamePrefixAsync(string prefix)
{
    using var doc = await RunJsonAsync("external-system", "list");
    return IdsWhereNameStartsWith(doc, prefix);
}

public static async Task<IReadOnlyList<int>> ListNotificationListIdsByNamePrefixAsync(string prefix)
{
    using var doc = await RunJsonAsync("notification", "list");
    return IdsWhereNameStartsWith(doc, prefix);
}

// Small shared parser (add once; mirrors the inline logic already in
// ListTemplateIdsByNamePrefixAsync). Case-insensitive on the "name"/"id" keys.
private static IReadOnlyList<int> IdsWhereNameStartsWith(JsonDocument doc, string prefix)
{
    var ids = new List<int>();
    if (doc.RootElement.ValueKind == JsonValueKind.Array)
    {
        foreach (var el in doc.RootElement.EnumerateArray())
        {
            if (el.TryGetProperty("name", out var name) is false &&
                el.TryGetProperty("Name", out name) is false) continue;
            if (name.ValueKind != JsonValueKind.String) continue;
            if (!(name.GetString()?.StartsWith(prefix, StringComparison.Ordinal) ?? false)) continue;
            if ((el.TryGetProperty("id", out var id) || el.TryGetProperty("Id", out id)) &&
                id.TryGetInt32(out var n)) ids.Add(n);
        }
    }
    return ids;
}

// ── Teardown (best-effort) ─────────────────────────────────────────────────
public static Task DeleteExternalSystemAsync(int id) => BestEffortAsync("external-system", "delete", id);
public static Task DeleteNotificationListAsync(int id) => BestEffortAsync("notification", "delete", id);
public static Task DeleteSharedScriptAsync(int id)     => BestEffortAsync("shared-script", "delete", id);
```

> **Verify before relying on it:** `RequireId` / `BestEffortAsync` are the existing private partials — confirm their exact names/signatures in the file before calling. If `ListTemplateIdsByNamePrefixAsync` already inlines a name-match loop, you MAY reuse its style instead of `IdsWhereNameStartsWith`, but DRY it into the one private helper. CLI JSON casing is System.Text.Json default (PascalCase `Id`/`Name`); the case-insensitive parser above is belt-and-braces.

### New DB seeder — `tests/.../Audit/ConfigAuditDataSeeder.cs` (Task 9)

`/audit/configuration` reads the **`AuditLogEntries`** table (entity `AuditLogEntry`) via `CentralUiRepository` — **NOT** the `AuditLog` table the existing `AuditDataSeeder` writes. So a new seeder is required. Mirror `AuditDataSeeder`'s idiom exactly: `PlaywrightDbConnection.ConnectionString`, `Microsoft.Data.SqlClient`, parameterized INSERT, `IsAvailableAsync()` probe, best-effort `DeleteByMarkerAsync`. `AuditLogEntries` columns: `Id` (identity — omit), `User`, `Action`, `EntityType`, `EntityId`, `EntityName`, `AfterStateJson` (nullable), `Timestamp` (datetimeoffset), `BundleImportId` (uniqueidentifier, nullable). Read `AuditDataSeeder.cs` first and copy its structure verbatim, changing only the table/columns. Full spec is in Task 9.

---

## Task 0: CLI helpers (ExternalSystem / NotificationList / SharedScript) + round-trip tests

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (CLI round-trip tests hit the cluster)

**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs`
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs`

**Step 1: Add the helpers** exactly as specified in "Shared infrastructure → CLI helper extensions" above (3 Create, 2 List-by-prefix, 3 Delete, 1 private parser). Match the existing file's `partial class CliRunner` style and the throw/swallow split.

**Step 2: Add round-trip tests** to `CliRunnerHelpersTests.cs`, mirroring the existing `CreateThenDeleteApiMethod_RoundTrips` pattern (gate → `UniqueName` → create → assert id>0 (and, for the list-by-prefix pair, `Assert.Contains(id, await ...ListIdsByNamePrefix...)`) → best-effort delete in `finally`):

```csharp
[SkippableFact]
public async Task CreateThenDeleteExternalSystem_RoundTrips()
{
    Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
    var name = CliRunner.UniqueName("extsys");
    var id = await CliRunner.CreateExternalSystemAsync(name);
    try
    {
        Assert.True(id > 0);
        Assert.Contains(id, await CliRunner.ListExternalSystemIdsByNamePrefixAsync(name));
    }
    finally { await CliRunner.DeleteExternalSystemAsync(id); }
}

[SkippableFact]
public async Task CreateThenDeleteNotificationList_RoundTrips()
{
    Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
    var name = CliRunner.UniqueName("notiflist");
    var id = await CliRunner.CreateNotificationListAsync(name);
    try
    {
        Assert.True(id > 0);
        Assert.Contains(id, await CliRunner.ListNotificationListIdsByNamePrefixAsync(name));
    }
    finally { await CliRunner.DeleteNotificationListAsync(id); }
}

[SkippableFact]
public async Task CreateThenDeleteSharedScript_RoundTrips()
{
    Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
    var id = await CliRunner.CreateSharedScriptAsync(CliRunner.UniqueName("script"));
    try { Assert.True(id > 0); }
    finally { await CliRunner.DeleteSharedScriptAsync(id); }
}
```

**Step 3: Build + run.**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "FullyQualifiedName~CliRunnerHelpersTests" -v minimal`
Expected: the 3 new tests **pass** (cluster up) or **skip** (cluster down); zero build warnings (`TreatWarningsAsErrors=true`). Confirm zero `zztest-*` residue afterward (the `finally` deletes; the round-trips also prove delete works).

**Step 4: Commit.**
```bash
git add tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs \
        tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs
git commit -m "test(playwright): add external-system/notification-list/shared-script CLI helpers (Wave 3 foundation)"
```

---

## Task 1: NotificationKpisTests (read-only render + tiles + refresh)

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationKpisTests.cs`

**Selectors (verbatim):** heading `h4:has-text('Notification KPIs')`. 5 tiles render `<small class="text-muted">LABEL</small>` for labels: `Queue Depth`, `Stuck`, `Parked`, `Delivered (last interval)`, `Oldest Pending Age`. On KPI backend failure the whole tile row is replaced by `div.alert.alert-warning` text `KPIs unavailable: …`. Refresh button: `button.btn.btn-outline-secondary.btn-sm:has-text('Refresh')`, `disabled` while `_loading`, with an inline `span.spinner-border` during load. Null age renders `—` (em dash).

**This page is pure-read — no mutation, no fixture, no teardown.** Gate on `ClusterAvailability.IsAvailableAsync()`.

**Facts:**
- **`KpisPage_RendersTilesOrError`** — navigate; assert heading; then **tolerant**: assert EITHER all 5 tile labels are visible OR the `alert-warning` "KPIs unavailable" is visible. (The labels render whenever `_kpiError == null`; the alert replaces them otherwise.)
  ```csharp
  var tiles = page.Locator("small.text-muted");
  var ok = await page.Locator("small.text-muted:has-text('Queue Depth')").IsVisibleAsync()
        && await page.Locator("small.text-muted:has-text('Oldest Pending Age')").IsVisibleAsync();
  var err = await page.Locator(".alert.alert-warning:has-text('KPIs unavailable')").IsVisibleAsync();
  Assert.True(ok || err);
  ```
- **`KpisPage_RefreshReenables`** — assert the Refresh button is enabled, click it, then assert it is enabled again within ~10 s (`ToBeEnabledAsync(new(){Timeout=10_000})`). (It disables + shows a spinner mid-load; web-first re-enable proves the refresh round-trip completed without hanging.)

**Run:** `dotnet test ... --filter "FullyQualifiedName~NotificationKpisTests"` → green/skip. **Commit:** `test(playwright): add NotificationKpis render + refresh coverage (Wave 3)`.

---

## Task 2: NotificationListCrudTests (UI create → add/remove recipient → delete)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**blockedBy:** Task 0

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs`

**Selectors (verbatim):**
- List page `/notifications/lists`: heading `h4:has-text('Notification Lists')`; add via `button.btn.btn-primary.btn-sm:has-text('Add Notification List')`. Rows are a `<table>`; locate a row by name: `page.Locator("tr").Filter(new(){ HasText = name })`. Per-row buttons: Edit `button.btn-outline-primary.btn-sm:has-text('Edit')`, Delete `button.btn-outline-danger.btn-sm:has-text('Delete')`.
- Create form `/notifications/lists/create`: heading `Add Notification List`; **one** `input.form-control` (the list name — recipients section is edit-only). Save `button.btn-success:has-text('Save')`. On success → redirect to `/notifications/lists` (NO toast). Empty name → inline `div.text-danger.small:has-text('Name required.')`.
- Edit form `/notifications/lists/{id}/edit`: heading `Edit Notification List`. Now there are TWO "Name" text inputs (list + recipient) plus one `input[type=email]`. **Scope the recipient form to the card containing the email input.** Recipient inputs: within `page.Locator(".card").Filter(new(){ Has = page.Locator("input[type=email]") })` use the text input (`input[type=text].form-control`) for recipient name and `input[type=email]` for email; Add button `button.btn-success:has-text('Add')`. Empty recipient → inline `text-danger.small:has-text('Name and email required.')`.
- Recipients table rows: locate by email text. Remove button (per recipient row, **NO confirm**): `button.btn-outline-danger.btn-sm:has-text('Delete')` scoped to that recipient row.
- List delete: row Delete → confirm dialog title `.modal-title:has-text('Delete')` / body `Delete notification list '<NAME>'?` → confirm `.modal-footer .btn-danger` (text `Delete`) → success toast `.toast` (body `Deleted.`) + row gone.

**Fact: `Create_AddRecipient_RemoveRecipient_Delete_RoundTrips`** (one cohesive UI round-trip; `[SkippableFact]`, gate on `ClusterAvailability.IsAvailableAsync()`):
1. `var name = CliRunner.UniqueName("notiflist");` — try/finally with best-effort CLI teardown (resolve by prefix → delete):
   ```csharp
   finally
   {
       foreach (var id in await CliRunner.ListNotificationListIdsByNamePrefixAsync(name))
           await CliRunner.DeleteNotificationListAsync(id);
   }
   ```
2. Navigate `/notifications/lists/create`, fill the single `input.form-control` with `name`, click Save, `WaitForPathAsync(page, "/notifications/lists", excludePath:"/create")`, assert the row appears.
3. Click the row's Edit; wait for heading `Edit Notification List`. In the recipient card fill name = `zzrec` + email = `zzrec@example.invalid`, click Add; assert a recipients-table row with that email is visible.
4. Click that recipient row's Delete (no confirm); assert the recipient row count for that email → `ToHaveCountAsync(0)`.
5. Go back to `/notifications/lists` (navigate or a Back control), click the list row's Delete; assert confirm dialog visible; click `.modal-footer .btn-danger`; assert `.toast` count 1 (body `Deleted.`) and the row → `ToHaveCountAsync(0, new(){Timeout=10_000})`.

**Also add a tiny negative fact `CreateForm_EmptyName_ShowsInlineError`** — navigate to `/create`, click Save with the name blank, assert `div.text-danger.small:has-text('Name required.')` visible; no navigation. (Mutates nothing.)

**Run** `--filter "FullyQualifiedName~NotificationListCrudTests"` → green/skip. **Commit:** `test(playwright): add NotificationList CRUD + recipient + validation coverage (Wave 3)`.

---

## Task 3: SmtpConfigTests (validation gate + cancel + tolerant save round-trip)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs`

**⚠ Shared-singleton caution (validation-behavior protocol — read `SmtpConfiguration.razor` @code first).** SMTP config is shared central state with **no delete verb** (CLI or UI). Therefore: **do not perform any save that creates or net-changes a config you cannot restore.** The valuable deterministic assertions mutate nothing.

**Selectors (verbatim):** heading `h4:has-text('SMTP Configuration')`. Empty state → `button.btn-primary.btn-sm:has-text('Add SMTP configuration')`. Existing configs render as cards with header `<strong>{host}</strong>` + `button.btn-outline-primary.btn-sm:has-text('Edit')`; the `Credentials` row shows `(stored)` or `(not set)`. Form fields (bind-only, no ids — locate by `form-label` text/column): Host `input[type=text]`, Port `input[type=number]`, Auth Type `select`, TLS `select`, Credentials `input[type=password]`, From Address `input[type=email]`. Save `button.btn-success:has-text('Save')`; Cancel `button.btn-outline-secondary:has-text('Cancel')`. Required = Host + From → inline `div.text-danger.small:has-text('Host and From Address are required.')`. Successful save → toast `SMTP configuration saved.`

**Facts:**
- **`SmtpForm_MissingRequired_ShowsInlineError`** (always safe, no mutation) — open the form (click `Add SMTP configuration` if present, else the first card's `Edit`); clear the Host input (and From if needed); click Save; assert `div.text-danger.small:has-text('Host and From Address are required.')` visible; then click Cancel and assert the form closed (the Save button is no longer visible). This proves the required-field gate without a successful save.
- **`SmtpPage_RendersConfigOrEmptyState`** — assert heading; then tolerant: EITHER a config card with a `Credentials` value of `(stored)`/`(not set)` is visible, OR the empty-state `No SMTP configuration set.` text is visible.
- **`SmtpEdit_NoopSave_ShowsSavedToast`** *(conditional, gated)* — apply the protocol: read `StartEdit` in the code-behind. **Only if** `StartEdit` loads the stored `_credentials` into the form (so a Save with the field untouched rewrites the same value — a true no-op): probe `notification smtp list` via the CLI; if a config already exists, click that card's `Edit`, change nothing, click Save, assert `.toast` count 1 (body `SMTP configuration saved.`). Belt-and-braces: snapshot Host/Port/AuthType/TLS/From via `notification smtp list` before and restore via `notification smtp update` after (best-effort) even though the no-op shouldn't change them. **If `StartEdit` does NOT populate `_credentials`** (saving blank would wipe the stored secret), **skip this fact** — `Skip.If(true, "SMTP no-op save would risk wiping shared credentials; see code comment")` — and document the gap in a comment. Do not guess; the implementer must read the handler.

**Run** `--filter "FullyQualifiedName~SmtpConfigTests"` → green/skip. **Commit:** `test(playwright): add SMTP config validation + render coverage (Wave 3)`.

---

## Task 4: ExternalSystemCrudTests (UI create → card → UI delete)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**blockedBy:** Task 0

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs`

**Selectors (verbatim):**
- List `/design/external-systems`: heading `h4:has-text('Integration Definitions')`; default tab is External Systems. Add `button.btn-primary.btn-sm:has-text('Add External System')`. Cards: `div.card` with title `h5.card-title:has-text('{name}')`. Per-card kebab `button.btn-outline-secondary.btn-sm[aria-label^='More actions']`; delete item `.dropdown-menu button.dropdown-item.text-danger` (text `Delete`). Confirm dialog title `Delete External System`; confirm `.modal-footer .btn-danger`; success toast body `Deleted.`
- Form `/design/external-systems/create`: heading `Add External System`. Name `input[type=text].form-control` (first), Endpoint URL the next `input[type=text].form-control` — disambiguate via labels (`label:has-text('Name') ...`, `label:has-text('Endpoint URL') ...`) or order. Auth Type defaults `ApiKey`. Save `button.btn-success:has-text('Save')` → redirect to list (NO toast). Empty name/URL → inline `div.text-danger.small:has-text('Name and URL required.')`.

**Fact: `Create_Delete_RoundTrips`** (gate `IsAvailableAsync`; `name = UniqueName("extsys")`; finally → resolve by `ListExternalSystemIdsByNamePrefixAsync(name)` + `DeleteExternalSystemAsync`):
1. Navigate `/design/external-systems/create`; fill Name = `name`, Endpoint URL = `https://example.invalid/api`; Save.
2. `WaitForPathAsync(page, "/design/external-systems", excludePath:"/create")`; assert the card `div.card` filtered by `name` is visible.
3. Scope to that card's `.dropdown`; click the kebab; click the `.text-danger` Delete item; assert confirm `.modal-title:has-text('Delete External System')`; click `.modal-footer .btn-danger`.
4. Assert `.toast` count 1 (body `Deleted.`) and the card → `ToHaveCountAsync(0, new(){Timeout=10_000})`.

**Negative fact `CreateForm_EmptyFields_ShowsInlineError`** — navigate to `/create`, Save with both fields blank, assert `text-danger.small:has-text('Name and URL required.')`; no navigation.

**Run** `--filter "FullyQualifiedName~ExternalSystemCrudTests"` → green/skip. **Commit:** `test(playwright): add ExternalSystem CRUD + validation coverage (Wave 3)`.

---

## Task 5: DataConnectionCrudTests (CLI-create → tree render + UI delete; create-form gating)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs`

**Why CLI-create:** the `/design/connections/create` form requires the `OpcUaEndpointEditor` sub-fields (a valid endpoint URL etc.) which is brittle to drive. So **CLI-create** the connection on `site-a` and exercise the **UI delete** (the high-value mutating path) + the tree render. A second fact covers the create-form's disabled→enabled "+ Connection" gating without submitting.

**Selectors (verbatim):**
- Tree `/design/connections`: heading `h4:has-text('Connections')`. Nodes render `span.tv-label` with the name; connection nodes add `span.badge.bg-info` (protocol). The "+ Connection" button `button.btn-primary.btn-sm:has-text('+ Connection')` is `disabled` until a node is selected. Per-node kebab `button.btn-link.btn-sm.dc-kebab[aria-label^='More actions']` — **the kebab is CSS `opacity:0` until row hover/focus**, so `Hover()` the node row (or the kebab) before clicking, or click with `new(){ Force = true }`. Connection-node menu: `.dropdown-item.text-danger` (Delete). Site-node menu: item `Add Connection here`. Delete confirm dialog title `Delete Connection`; confirm `.modal-footer .btn-danger`; success toast body `Connection '{name}' deleted.`
- Search: `input[placeholder='Search sites or connections...']` (live) — optional, to narrow the tree to the zztest connection. "Bulk actions" dropdown → `Expand all` to ensure the connection node is visible under its site.

**Fact A: `CliCreated_Connection_DeletesViaTree`** (gate `IsAvailableAsync`):
1. `var siteId = await CliRunner.ResolveSiteIdAsync("site-a"); var name = CliRunner.UniqueName("dconn"); int id = await CliRunner.CreateDataConnectionAsync(siteId, name);` — try/finally → `DeleteDataConnectionAsync(id)` (best-effort; the happy path deletes via UI, so this is a safety net).
   - **Verify the CLI create succeeds with no `--primary-config`.** If `data-connection create` rejects a missing primary config, pass a minimal OPC UA endpoint JSON as `primaryConfig`, e.g. `"{\"EndpointUrl\":\"opc.tcp://zz:4840\"}"` — read `CreateDataConnectionAsync` + the CLI's `data-connection create` validation first and supply the smallest config the server accepts.
2. Navigate `/design/connections`; assert heading. Use the search box (type `name`) or "Bulk actions → Expand all" to surface the node. Assert a `span.tv-label:has-text('{name}')` is visible.
3. Hover the connection node row; click its kebab (`[aria-label^='More actions']`, scoped to that node; `Force=true` if needed); click the `.dropdown-item.text-danger` Delete; assert confirm `.modal-title:has-text('Delete Connection')`; click `.modal-footer .btn-danger`.
4. Assert `.toast` count 1 (body contains `deleted`) and `span.tv-label:has-text('{name}')` → `ToHaveCountAsync(0, new(){Timeout=10_000})`.

**Fact B: `CreateButton_GatedOnNodeSelection`** (no mutation):
- Navigate `/design/connections`; assert the "+ Connection" button is `disabled` initially; click a site node's `span.tv-label` (any existing site, e.g. via the first `.tv-label`); assert "+ Connection" becomes enabled. (Proves the selection gating without entering the create form.) If the tree is empty (no sites) the test should `Skip` — but `site-a` exists on the live cluster, so it won't be.

**Run** `--filter "FullyQualifiedName~DataConnectionCrudTests"` → green/skip. **Commit:** `test(playwright): add DataConnection tree-delete + create-gating coverage (Wave 3)`.

---

## Task 6: SharedScriptCrudTests (CLI-create → card render → UI delete; form renders)

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**blockedBy:** Task 0

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/SharedScriptCrudTests.cs`

**Why CLI-create:** the form uses a Monaco editor (brittle). CLI-create the script, exercise UI list render + UI delete; a second fact asserts the create form renders (no submit).

**Selectors (verbatim):**
- List `/design/shared-scripts`: heading `h4:has-text('Shared Scripts')`. Add `button.btn-primary.btn-sm:has-text('New Script')`. Cards `div.card` title `h5.card-title:has-text('{name}')`. Kebab `button.btn-outline-secondary.btn-sm[aria-label^='More actions']`; delete `.dropdown-item.text-danger`. Confirm title `Delete Shared Script`; confirm `.modal-footer .btn-danger`; success toast body `Script '{name}' deleted.`
- Form `/design/shared-scripts/create`: heading `New Shared Script`. Name `input[type=text].form-control.form-control-sm` (`@bind`, disabled on edit only). Tabs `button.nav-link` (`Code`, `Parameters`, `Return type`). Monaco container `.monaco-editor`. Buttons: Save `button.btn-success.btn-sm:has-text('Save')`, Cancel `button.btn-outline-secondary.btn-sm:has-text('Cancel')`. **Do NOT click "Test Run" / "Run"** (fires real I/O).

**Fact A: `CliCreated_Script_DeletesViaCard`** (gate `IsAvailableAsync`; `name = UniqueName("script"); int id = await CliRunner.CreateSharedScriptAsync(name);` finally → `DeleteSharedScriptAsync(id)`):
1. Navigate `/design/shared-scripts`; assert heading; assert the card filtered by `name` is visible (use the filter input `input[placeholder='Filter by name or code…']` if the list is long).
2. Scope to that card's `.dropdown`; kebab → `.text-danger` Delete; confirm `.modal-title:has-text('Delete Shared Script')`; `.modal-footer .btn-danger`.
3. Assert `.toast` count 1 (body contains `deleted`) and the card → `ToHaveCountAsync(0)`.

**Fact B: `CreateForm_Renders`** (no submit, no Monaco typing) — navigate `/design/shared-scripts/create`; assert heading `New Shared Script`, the Name input visible+enabled, the `Code`/`Parameters`/`Return type` tabs visible, and a `.monaco-editor` mounted (`ToHaveCountAsync(>=1)` via `ToBeVisibleAsync` on `.monaco-editor` — generous timeout, Monaco mounts via JS interop). Mutates nothing.

**Run** `--filter "FullyQualifiedName~SharedScriptCrudTests"` → green/skip. **Commit:** `test(playwright): add SharedScript list/delete + form-render coverage (Wave 3)`.

---

## Task 7: ApiMethodFormTests (validation gate + CLI-create visibility + UI delete)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ApiMethodFormTests.cs`

**Why mixed:** the form requires a Monaco script; the **validation gate** (Name filled, script empty → error) tests the form WITHOUT Monaco. Visibility + delete use a CLI-created method (helper `CreateApiMethodAsync` already exists).

**Selectors (verbatim):**
- Form `/design/api-methods/create`: heading `h4:has-text('Add API Method')`. Name `input[type=text].form-control` (`@bind`, disabled on edit). Timeout `input[type=number]` (default 30). Monaco `.monaco-editor`. Save `button.btn-success:has-text('Save')`. Empty name OR empty script → inline `div.text-danger.small:has-text('Name and script required.')`.
- Saved methods appear on `/design/external-systems` under the **Inbound API Methods** tab: tab button `button.nav-link:has-text('Inbound API Methods')`; method card `h5.card-title:has-text('{name}')` plus `code:has-text('POST /api/{name}')`. Card kebab `[aria-label^='More actions']`; delete `.dropdown-item.text-danger`; confirm title `Delete` (`Delete API method '{name}'?`); confirm `.modal-footer .btn-danger`; success toast body `Deleted.`

**Fact A: `CreateForm_NameWithoutScript_ShowsInlineError`** (no mutation, no Monaco) — navigate `/design/api-methods/create`; fill Name = `zzapimethod`; leave the Monaco editor empty; click Save; assert `div.text-danger.small:has-text('Name and script required.')` visible; no navigation.

**Fact B: `CliCreated_Method_VisibleAndDeletes`** (gate `IsAvailableAsync`; `name = UniqueName("method"); int id = await CliRunner.CreateApiMethodAsync(name);` finally → `DeleteApiMethodAsync(id)`):
1. Navigate `/design/external-systems`; click the `Inbound API Methods` tab; assert the method card filtered by `name` is visible (and shows `code:has-text('POST /api/{name}')`). Use the tab's filter input if present.
2. Scope to that card's `.dropdown`; kebab → `.text-danger` Delete; confirm `.modal-footer .btn-danger`.
3. Assert `.toast` count 1 (body `Deleted.`) and the card → `ToHaveCountAsync(0)`.

**Run** `--filter "FullyQualifiedName~ApiMethodFormTests"` → green/skip. **Commit:** `test(playwright): add ApiMethod validation + visibility + delete coverage (Wave 3)`.

---

## Task 8: EventLogsTests (render + Search-gating + tolerant query)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/EventLogsTests.cs`

**⚠ Not seedable.** Event logs live in **site-local SQLite** and are fetched via an Akka `Ask` to a live site — there is no central table, no CLI/DB seed path. So this is a **render + controls + tolerant-query** test, gated on a live cluster (`ClusterAvailability.IsAvailableAsync()`). Do NOT assert specific rows.

**Selectors (verbatim):** heading `h4:has-text('Site Event Logs')`. Site select `#filter-site` (`aria-label='Site'`; first option `Select site...`; option `value` = SiteIdentifier). Severity `#filter-severity` (options `All`/`Info`/`Warning`/`Error`). Search button `button.btn-primary.btn-sm:has-text('Search')` — **`disabled` while no site is selected** (`disabled=@(IsNullOrEmpty(_selectedSiteId) || _searching)`). Results table appears only after a search; empty state `td:has-text('No events found.')`. Row expand button `button[aria-label='View full message']` ↔ `Hide full message`. Pagination: `button.btn-outline-primary.btn-sm:has-text('Load more')` or sentinel `span:has-text('End of results')`. **No toast, no copy button on this page.**

**Fact A: `EventLogs_SearchGatedOnSiteSelection`** (deterministic) — navigate; assert heading; assert `#filter-site` has >1 option (sites populated); assert the Search button is `disabled`; select `site-a` by value (`await page.Locator("#filter-site").SelectOptionAsync(new SelectOptionValue { Value = "site-a" });` — the option value is the SiteIdentifier `site-a`); assert Search becomes enabled.

**Fact B: `EventLogs_Search_RendersTableOrEmptyState`** (tolerant) — from Fact A's state, click Search; then assert **tolerant terminal**: EITHER the results `table` is visible OR `td:has-text('No events found.')` is visible, within ~15 s:
```csharp
var settled = page.Locator("table tbody tr, td:has-text('No events found.')");
await Assertions.Expect(settled.First).ToBeVisibleAsync(new() { Timeout = 15_000 });
```
Then, **only if** at least one real data row exists (`await page.Locator("button[aria-label='View full message']").CountAsync() > 0`), exercise the row expand: click the first `View full message`, assert it flips to `Hide full message` (the inline `<pre>` appears). If no rows, skip the expand assert (documented). Do not assert Severity-filter result counts (no seeded data to make it deterministic) — at most assert selecting a Severity + Search still resolves to the same tolerant terminal.

**Run** `--filter "FullyQualifiedName~EventLogsTests"` → green/skip. **Commit:** `test(playwright): add EventLogs render + search-gating coverage (Wave 3)`.

---

## Task 9: ConfigAuditDataSeeder (direct-SQL seeder for AuditLogEntries)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/ConfigAuditDataSeeder.cs`

**Spec.** Read `tests/.../Audit/AuditDataSeeder.cs` first and copy its idiom verbatim (connection from `PlaywrightDbConnection.ConnectionString`, `Microsoft.Data.SqlClient`, `IsAvailableAsync()` probe, parameterized INSERT, best-effort `DeleteBy…Async` that swallows). Change only the table/columns to `AuditLogEntries`.

Public surface:
```csharp
public static class ConfigAuditDataSeeder
{
    public static Task<bool> IsAvailableAsync();      // mirror AuditDataSeeder.IsAvailableAsync (open SqlConnection, true/false)

    // Seeds `bulkCount` rows (default 55) tagged EntityType = marker so a UI filter
    // isolates exactly this run's rows (deterministic pagination over 50/page).
    // Row 0 gets a >1024-char AfterStateJson (drives the large-state MODAL).
    // `bundleRows` rows additionally carry BundleImportId = bundleId (drives the chip drill-in);
    // they also carry EntityType = marker so DeleteByMarkerAsync cleans them up.
    public static Task SeedAsync(string marker, Guid bundleId, int bulkCount = 55, int bundleRows = 2);

    public static Task DeleteByMarkerAsync(string marker);   // DELETE FROM AuditLogEntries WHERE EntityType = @marker  (best-effort)
}
```

INSERT shape (let identity assign `Id`; `Timestamp` = `DateTimeOffset.UtcNow.AddSeconds(-i)` so order is stable and recent):
```sql
INSERT INTO [AuditLogEntries] ([User],[Action],[EntityType],[EntityId],[EntityName],[AfterStateJson],[Timestamp],[BundleImportId])
VALUES (@user, @action, @entityType, @entityId, @entityName, @afterState, @ts, @bundleId);
```
- `@user = marker + "-user"`, `@action = "Update"`, `@entityType = marker`, `@entityId = marker + "-eid-" + i`, `@entityName = marker + "-" + i`.
- `@afterState`: for `i == 0`, a JSON string > 1024 chars (e.g. `"{\"blob\":\"" + new string('x', 1100) + "\"}"`); for others a short `"{\"k\":\"v\"}"`.
- `@bundleId`: `DBNull.Value` for the bulk rows; `bundleId` for the `bundleRows` extra rows (insert those in a second loop, EntityType = marker, EntityName = `marker + "-bundle-" + i`).
- Verify the exact column names + types against `AuditDataSeeder` / the EF model (`AuditLogEntries`: `User`, `Action`, `EntityType`, `EntityId`, `EntityName`, `AfterStateJson` nullable, `Timestamp` datetimeoffset, `BundleImportId` uniqueidentifier nullable). If `User`/`Action` are reserved words, bracket them (they are bracketed above).

**No test of its own** — it is exercised by Tasks 10–11. (Build-only verification here.)

**Run:** `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` → clean. **Commit:** `test(playwright): add ConfigAuditDataSeeder (AuditLogEntries direct-SQL seeder) (Wave 3)`.

---

## Task 10: AuditConfigurationTests — part 1 (render / search-narrows / pagination)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**blockedBy:** Task 9

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs`

**Gate:** `Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), AuditConfigDbUnavailableSkipReason)` — reuse the DB-unavailable reason string idiom from the Audit tests (it references `localhost:1433` / `SCADABRIDGE_PLAYWRIGHT_DB`). Each fact seeds in a `try` and cleans in `finally` via `DeleteByMarkerAsync`.

**Selectors (verbatim):** heading `h4:has-text('Configuration Audit Log')`. The grid **auto-loads on visit** (no Search needed). Filters: User `#audit-filter-user`, Entity Type `#audit-filter-entity-type`, Action `#audit-filter-action`, From `#audit-filter-from`, To `#audit-filter-to`. Search `button.btn-primary.btn-sm:has-text('Search')` (always enabled unless `_searching`). Clear `button:has-text('Clear filters')`. Rows: `tbody tr` (locate by content `tr:has-text('{entityName}')`). Empty: `td:has-text('No audit entries found.')`. Pagination footer: `span:has-text('Page')` (text `Page X of Y (N total)`); Previous `button:has-text('Previous')` (**disabled when page 1**); Next `button:has-text('Next')` (**disabled on last page**). Page size = 50.

**Fact A: `Grid_AutoLoads_AndSearchNarrows`** — `marker = "zzCfgAudit-" + Guid.NewGuid().ToString("N")[..8]`; seed `SeedAsync(marker, Guid.NewGuid(), bulkCount: 3, bundleRows: 0)`; navigate `/audit/configuration`; assert heading + that the grid shows rows (`tbody tr` count ≥ 1). Then type `marker` into `#audit-filter-entity-type`, click Search; assert every visible data row contains `marker` (assert a `tr:has-text('{marker}-0')` visible and the total in the footer equals the seeded count — `span:has-text('(3 total)')`).

**Fact B: `Pagination_PrevDisabledOnPage1_NextPaginates`** — seed `SeedAsync(marker, Guid.NewGuid(), bulkCount: 55, bundleRows: 0)`; navigate; filter Entity Type = `marker`; Search → footer `Page 1 of 2` (`span:has-text('Page 1 of 2')`), `(55 total)`. Assert `button:has-text('Previous')` is `disabled` and `button:has-text('Next')` is enabled. Click Next; assert footer `Page 2 of 2`, `Previous` now enabled and `Next` now `disabled`. (Filtering by the unique marker makes `_totalCount` deterministic regardless of other rows on the cluster.)

**Run** `--filter "FullyQualifiedName~AuditConfigurationTests"` (part 1 facts) → green/skip. **Commit:** `test(playwright): add Configuration Audit render/search/pagination coverage (Wave 3)`.

---

## Task 11: AuditConfigurationTests — part 2 (large-state modal / copy toast / bundle chip)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**blockedBy:** Task 10

**Files:**
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs` (append facts to the Task-10 class)

**Selectors (verbatim):**
- **Large-state modal:** the State cell of a row whose `AfterStateJson` > 1024 chars shows `button.btn-outline-info.btn-sm:has-text('View in modal')` (`aria-label='Open state details in modal for audit entry {id}'`). Click → modal `div.modal.show` with `h5.modal-title` text `Audit entry {id} — {EntityType} state` and a `<pre>` of pretty JSON. Close via footer `button.btn-outline-secondary.btn-sm:has-text('Close')` (or header `.modal .btn-close`) → modal removed from DOM. (Small-state rows instead show a `View`/`Hide` inline toggle — not used here.)
- **Copy entity id:** `button[aria-label^='Copy entity ID']` (glyph `📋`, `title='Copy entity ID'`). On click → toast. **⚠ http-origin caveat:** the browser hits `http://scadabridge-traefik` (non-secure, non-localhost), so `navigator.clipboard` is unavailable → the handler throws → an **error** toast `Copy failed.` fires. Either way a `.toast` appears, so assert `.toast` count 1 **tolerantly** (do not assert which message) and note the caveat in a comment.
- **Bundle chip drill-in:** navigate `/audit/configuration?bundleImportId={guid}` → chip `span.badge.bg-primary:has-text('Filtered by Bundle Import:')` + a `<code>` showing the first 8 chars; only that bundle's rows show. Clear button `button[aria-label='Clear Bundle Import filter']` → chip removed, navigates to bare `/audit/configuration`.

**Fact C: `LargeState_OpensAndClosesModal`** — `marker`, seed `SeedAsync(marker, Guid.NewGuid(), bulkCount: 1, bundleRows: 0)` (row 0 has the >1024 AfterStateJson); navigate; filter Entity Type = `marker`; Search; in the row `tr:has-text('{marker}-0')` click `View in modal`; assert `div.modal.show .modal-title:has-text(' state')` visible; click the modal footer `Close`; assert `div.modal.show` → `ToHaveCountAsync(0)`.

**Fact D: `CopyEntityId_ShowsToast`** (tolerant) — same seed; navigate; filter + Search; click the first `button[aria-label^='Copy entity ID']`; assert `.toast` count 1 (`ToHaveCountAsync(1, new(){Timeout=15_000})`) — tolerant of success (`Copied to clipboard.`) vs the non-secure-origin error (`Copy failed.`). Comment the caveat.

**Fact E: `BundleImportId_DrillIn_ChipFiltersAndClears`** — `var bundleId = Guid.NewGuid();` seed `SeedAsync(marker, bundleId, bulkCount: 1, bundleRows: 2)`; navigate `/audit/configuration?bundleImportId={bundleId}` (the page auto-loads pre-filtered via `OnParametersSetAsync`); assert the chip `span.badge.bg-primary:has-text('Filtered by Bundle Import:')` is visible and exactly the 2 bundle rows show (`tr:has-text('{marker}-bundle-')` count 2; the bulk row `{marker}-0` is absent); click `button[aria-label='Clear Bundle Import filter']`; assert the chip → `ToHaveCountAsync(0)` and the grid reloads (bulk row now present).

**Run** `--filter "FullyQualifiedName~AuditConfigurationTests"` (all facts) → green/skip. **Commit:** `test(playwright): add Configuration Audit modal/copy/bundle-chip coverage (Wave 3)`.

---

## Task 12: Wave 3 verification + residue check

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**blockedBy:** Task 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11

**Files:** none (verification only).

**Steps:**
1. **Full build:** `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` → 0 warnings (`TreatWarningsAsErrors=true`).
2. **Full suite:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests -v minimal` → **0 failed**; skips logged by `SkipSummaryReporter` (acceptable only if the cluster/DB is down — otherwise expect the new facts to pass). Record the passed/failed/skipped tallies.
3. **Residue scan (zero expected):** via CLI, confirm no `zztest-*` leftovers:
   - `external-system list`, `notification list`, `shared-script list`, `data-connection list --site-id <site-a>`, `api-method list` → no `zztest-*` names.
   - `instance list --site-id <site-a>` / `template list` → no `zztest-*`.
   - Config-audit DB rows: confirm `DeleteByMarkerAsync` ran (no `zzCfgAudit-*` rows linger — `SELECT COUNT(*) FROM AuditLogEntries WHERE EntityType LIKE 'zzCfgAudit-%'` = 0). SMTP config left as found (no new config created).
4. **App diff is empty:** `git status` shows only new/changed files under `tests/...` and `docs/plans/...` — **no `src/` changes** (Wave 3 adds no app code). Confirm.
5. Update the resume memory note (Waves 1–3 done) — see Execution Handoff.

**No commit** unless step 5 edits a tracked doc; the per-task commits already cover the code.

---

## Task graph (blockedBy)

```
0 (CLI helpers) ───────────► 2, 4, 6
9 (seeder) ────────────────► 10 ──► 11
12 (verify) ◄── 0,1,2,3,4,5,6,7,8,9,10,11
```
All test-running tasks are `Parallelizable with: none` (shared browser+cluster → serial execution). Suggested order: **0 → 1 → 8 → 2 → 3 → 4 → 5 → 6 → 7 → 9 → 10 → 11 → 12** (foundation first; read-only KPIs/EventLogs early as low-risk warm-ups; AuditConfiguration last as it owns the new seeder).

## Success criteria (per-wave gate)

Wave 3 is "done" only when: the 9 new suites (+ helper round-trips) pass against the live cluster (skips logged when it's down), the full suite stays at **0 failed**, `dotnet build` is clean under `TreatWarningsAsErrors=true`, **zero `zztest-*` / `zzCfgAudit-*` residue**, SMTP config left as found, and `git diff` touches **no `src/`** (no app-code change, no rebuild).
