diff --git a/docs/plans/2026-06-06-playwright-coverage-fill-wave4.md b/docs/plans/2026-06-06-playwright-coverage-fill-wave4.md new file mode 100644 index 00000000..e4366fcc --- /dev/null +++ b/docs/plans/2026-06-06-playwright-coverage-fill-wave4.md @@ -0,0 +1,577 @@ +# Playwright Coverage Fill — Wave 4 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:** Close the cross-cutting edge-case gaps (duplicate-name, cancel, empty-state, filter-combination, pagination) on the six already-covered Central UI pages — Sites, Templates, LDAP mappings, Audit Log, Site Calls, Notification Report — by **extending** their existing test suites, ending green with zero residue. + +**Architecture:** Each task adds 1–2 edge `[SkippableFact]`s to an existing test class (no new test files), driving real behavior against the live 8-node docker cluster, outcome- and selector-grounded against the **actual** page code-behind (the ⚠ validation-behavior protocol). Fixtures/seeders are reused; only three tiny *test-side* helpers are added (2 CLI verbs + 1 seeder generalization). **No app-code changes, no new `data-test` hooks, no `docker/deploy.sh` rebuild** — every edge surface already has a stable selector once the two un-wired "Escape closes" legs are dropped. + +**Tech Stack:** xUnit + `Xunit.SkippableFact`, Microsoft.Playwright (remote Chromium `ws://localhost:3000`), Microsoft.Data.SqlClient (direct-SQL seeders), the `scadabridge` CLI. TFM `net10.0`, `Nullable=enable`, `TreatWarningsAsErrors=true`. + +--- + +## Grounding facts (verified during planning — do NOT re-derive) + +**Harness conventions (reuse verbatim):** +- All classes are `[Collection("Playwright")]` (serial via `ICollectionFixture`); ctor injects `PlaywrightFixture _fixture`. +- Auth: `await _fixture.NewAuthenticatedPageAsync()` → logs in `multi-role`/`password` (has Administrator + Designer + Deployer + Viewer; satisfies every page below). Browser base URL `PlaywrightFixture.BaseUrl` = `http://scadabridge-traefik`; CLI from host `http://localhost:9000`. +- Toast assertion: web-first `page.Locator(".toast")` with `ToHaveCountAsync(1)`. +- Danger confirm: `.modal-footer .btn-danger` (text "Delete"); plain confirm `.modal-footer .btn-primary` (text "Confirm"). +- Ephemeral fixture naming: `CliRunner.UniqueName("kind")` → `zztest--<8hex>`; direct-SQL seeders use a per-run marker (`playwright-test//{runId}/…` for audit/site-calls `Target`, `zztest-notif-…-{runId}` for the notification `ListName`). +- **Two skip-gate idioms — match the one already in each file:** `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason)` (CLI probe — used by SiteCrudTests, TemplateCrudTests, LdapMappingCrudTests, AuditLogPageTests) **vs.** `Skip.IfNot(await .IsAvailableAsync(), DbUnavailableSkipReason)` (direct-SQL probe — used by SiteCallsPageTests, NotificationActionTests, AuditGridColumnTests). Do not mix. +- Text inputs `@bind` commit on **change/blur, not Enter** — after `FillAsync` into a search box, call `DispatchEventAsync("change")` before clicking Query (the existing `SetSearchKeywordAsync`/`SetSubjectKeywordAsync`/`SetSearchKeywordAsync` helpers already do this). `SelectOptionAsync` fires `change` itself. +- **Prerender-hydration race (Wave-3 gotcha, still live):** the UI uses InteractiveServer WITH prerendering, so an input can be "visible" before its `@bind` handler is wired. For any test that fills a field then acts WITHOUT an intervening server roundtrip, force a confirmed roundtrip first (e.g. assert an Apply/Query result, or a prior validation message) before relying on the committed value. Most Wave-4 tests click Query/Apply (a roundtrip) right after filling, so they are naturally safe; flag in code where they aren't. + +**No-residue rule:** best-effort teardown in `finally`/`DisposeAsync`, keyed on `zztest-*` / the run marker. Tests that collide against the real seeded `site-a` persist nothing (the create is rejected). The mutating cancel/edit tests create their own `zztest-*` entity and delete it. Wave ends with the residue scan → zero. + +**Cadence constraint:** one shared Playwright browser + one cluster + one build → every cluster-running implementer is serialized. Test tasks are therefore `Parallelizable with: none`; only the two non-cluster prep tasks (0, 1) may be dispatched concurrently with each other. Reviewers (read-only) may still overlap with the next implementer per subagent-driven-development. + +### Per-area verified selectors & actual behavior + +**Sites** (`/admin/sites`, form `/admin/sites/create` & `/admin/sites/{id}/edit`, single `SiteForm.razor`): +- Identifier input: `label:has-text('Identifier') + input.form-control.form-control-sm` (**`disabled` in edit mode**). Name: `label:has-text('Name') + input.form-control.form-control-sm`. Description: `label:has-text('Description') + input.form-control.form-control-sm`. +- Save: `button.btn.btn-success.btn-sm:has-text('Save')`. Cancel: `button:has-text('Cancel')` (== Back; both call `GoBack()` → `/admin/sites`, no dirty-check, no persist). +- Error surface: `div.text-danger.small.mt-2` (single element, reused for all errors). `SaveSite()`: blank name → `"Name is required."`; create-mode blank identifier → `"Identifier is required."`; **duplicate identifier (or name) → caught `DbUpdateException` → `_formError = $"Save failed: {ex.Message}"`, stays on `/admin/sites/create`.** Assert `"Save failed"` substring, NOT the literal. +- **No URL validation** (form accepts any string; only >500 chars would error). `Name` is ALSO unique-indexed → keep the dup test's Name distinct from the collision target. +- CLI: `ResolveSiteIdAsync(identifier)` (throws if absent), `DeleteSiteAsync(id)` (best-effort). **No `CreateSiteAsync`** — create via UI. + +**Templates** (`/design/templates`, create `/design/templates/create`, edit `/design/templates/{id}`): +- Create Name: `div.mb-3:has(label:has-text('Name')) input.form-control`. Create button: `button.btn.btn-success:has-text('Create')`. Create error: `div.text-danger.small` (inline; stays on `/design/templates/create`). Create Cancel: `button.btn.btn-outline-secondary:has-text('Cancel')` → `/design/templates`. +- **Duplicate base-template name** → DB unique index (`HasIndex(t=>t.Name).IsUnique().HasFilter("[IsDerived]=0")`), no friendly pre-check in `TemplateService.CreateTemplateAsync` → error lands inline in `div.text-danger.small`. Assert the div is visible/non-empty + URL still `/create`; do NOT assert a literal. +- Edit page attribute modal: trigger `button.btn.btn-primary.btn-sm:has-text('Add Attribute')`; existing-attr edit via row dropdown `button[aria-label="More actions for {attr.Name}"]` → `button.dropdown-item:has-text('Edit…')`. Modal = `.modal.show.d-block:not(.fade)` (page-local, no `fade`); title `h6.modal-title` = "Add Attribute"/"Edit Attribute". Modal fields: Name `input.form-control @bind=_attrName` (**`readonly` when editing**), Data Type `select.form-select`, Value `input.form-control`. Footer save button text is **"Save" when editing, "Add" when adding** → `.modal.show.d-block:not(.fade) .modal-footer button.btn-success.btn-sm:has-text('Save')`. Attr rows: `table td:has-text('{name}')`. +- **Delete-blocked-by-instances:** header Delete `button.btn.btn-outline-danger.btn-sm:has-text('Delete')` → DialogHost confirm `.modal-footer .btn-danger:has-text('Delete')` (this DialogHost modal HAS `fade`) → server guard fails → **`.toast`** containing `Cannot delete template '{name}': {n} instance(s) reference it (...)`. A **non-deployed** instance referencing the template is sufficient to trigger the block. +- Reuse **`DeploymentFixture`** (`IClassFixture` + `[Collection("Playwright")]`, exactly as `DeploymentActionTests`): exposes `bool Available`, `int TemplateId`, `int SiteAId`, `int AreaId`, `Task<(int Id,string UniqueName)> CreateInstanceAsync()` (creates, does NOT deploy). CLI: `CreateTemplateAsync`, `DeleteTemplateAsync`, `ListTemplateIdsByNamePrefixAsync`, `AddAttributeAsync`, `DeleteInstanceAsync`. + +**LDAP** (`/admin/ldap-mappings`, form `/admin/ldap-mappings/create` & `/{id}/edit`, single `LdapMappingForm.razor`): +- Group input: `label:has-text('LDAP Group Name') + input.form-control.form-control-sm`. Role select: `div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm` (options `""`,`Administrator`,`Designer`,`Deployer`,`Viewer`). Save: `button.btn.btn-success.btn-sm:has-text('Save')`. Cancel: `button.btn.btn-outline-secondary.btn-sm:has-text('Cancel')`. +- Error surface: `div.text-danger.small.mt-2` (single element). `SaveMapping()`: blank group → `"LDAP Group Name is required."` (checked first, returns); blank role → `"Role is required."`. **Duplicate group → DB unique index (`HasIndex(m=>m.LdapGroupName).IsUnique()`), no pre-check → caught → `"Save failed: {ex.Message}"`.** Assert `"Save failed"` substring. +- Rows: `page.Locator("tr", new(){ HasText = group })`. Mapping delete = list kebab `button.dropdown-item.text-danger` (**no confirm dialog**). +- CLI verbs exist (`security role-mapping create|list|delete`) but no typed helper today — **Task 0 adds them.** + +**Audit Log** (`/audit/log` — `AuditLogPage`, filter bar + results grid + drill-down drawer): +- Filter bar (`[data-test='audit-filter-bar']`): channel `[data-test='filter-channel-select']` (`SelectOptionAsync("ApiOutbound"|"ApiInbound"|"DbOutbound"|…)`, `""`=all), site multi `[data-test='filter-site-ms']`, time-range `#audit-time-range` (`Last5Minutes`/`LastHour`(default)/`Last24Hours`/`Custom`), custom `#audit-from`/`#audit-to` (only when `Custom`), execution-id text `#audit-execution-id` (exact GUID), target text `#audit-target` (contains), **Apply `[data-test='filter-apply']`**, Clear `[data-test='filter-clear']` (resets model only — does NOT re-query). +- **Grid does NOT auto-load on bare visit** — click Apply, or arrive with a drill-in querystring. Grid `[data-test='audit-results-grid']`; rows `[data-test='grid-row-{EventId}']` (and `[data-test^='grid-row-']` for counting); empty-state cell literal **`No audit events match the current filter.`** (no `data-test`). Page size **100** (no test override). Page indicator text `Page N · M rows`. +- Drawer: open by clicking a row; container `[data-test='audit-drilldown-drawer']`; backdrop `[data-test='drawer-backdrop']` (click closes); **X `[data-test='drawer-close']`**; footer `[data-test='drawer-close-footer']` (closes). **No Escape handler** (the keydown handler lives only on the sibling `ExecutionDetailModal`). cURL button `[data-test='copy-as-curl']` renders **iff `channel ∈ {ApiOutbound, ApiInbound}`** (`IsApiChannel`); a `DbOutbound`/`Notification` row's drawer omits it. +- `AuditDataSeeder` (table **`AuditLog`**): `InsertAuditEventAsync(eventId, occurredAtUtc, channel, kind, status, sourceSiteId?, target?, actor?, correlationId?, executionId?, parentExecutionId?, httpStatus?, …)`, `DeleteByTargetPrefixAsync(targetPrefix)`, `IsAvailableAsync()`. `DbUnavailableSkipReason` const lives in `AuditGridColumnTests.cs` — but **AuditLogPageTests uses `ClusterAvailability.SkipReason`** (match the file). Default time-range `LastHour`, so seed rows at "now". + +**Site Calls** (`/site-calls/report` — `SiteCallsReport`): +- Filters: status `#sc-status` (`""`=All, `Submitted`/`Forwarded`/`Attempted`/`Delivered`/`Parked`/`Failed`/`Discarded`), channel `#sc-channel` (`ApiOutbound`/`DbOutbound`), site `#sc-site`, node `#sc-node` (exact), from/to `#sc-from`/`#sc-to`, target keyword `#sc-search` (**exact match**; use `SetSearchKeywordAsync` to commit), stuck-only `#sc-stuck-only`. **Query button `[data-test='site-calls-query']`** (resets to page 1). **Auto-loads on visit** (`OnInitializedAsync → RefreshAll`), so narrow by the run marker to isolate seeded rows. +- Grid: rows `tbody tr`; status badge `span.badge:has-text('{Status}')`. **Empty state literals** `No site calls` / `No cached calls match the current filters.` (no `data-test`). Page size **50**. +- **Pagination (confirmed wired):** prev `[data-test='site-calls-prev']` (text "Previous", disabled on page 1), next `[data-test='site-calls-next']` (text "Next", **enabled only when the page is exactly full = 50 rows**). Keyset order `CreatedAtUtc DESC, TrackedOperationId DESC`. Page indicator `Page {N} · {rows} rows`. Pager footer renders only when rows exist. +- `SiteCallDataSeeder` (table **`SiteCalls`**): `InsertSiteCallAsync(trackedOperationId, channel, target, sourceSite, status, retryCount, createdAtUtc, updatedAtUtc, lastError?, httpStatus?, terminalAtUtc?)`, `DeleteByTargetPrefixAsync(prefix)`, `IsAvailableAsync()`, `ConnectionString`. **`DbUnavailableSkipReason`** const is in `SiteCallsPageTests.cs`. **`FilterPermittedAsync` drops rows whose `SourceSite` is outside the user's permitted set → seed with `sourceSite:"site-a"`.** `Target` is exact-match, so **51 rows sharing ONE identical target value** are all selected by one `#sc-search` keyword (the deterministic pagination isolator). Stagger `createdAtUtc` for strict keyset order. + +**Notification Report** (`/notifications/report` — `NotificationReport`, single file, `RequireDeployment`): +- Filters: status `#no-status` (`""`=All,`Pending`/`Retrying`/`Delivered`/`Parked`/`Discarded`), type `#no-type` (`Email`), site `#no-site`, list `#no-list` (exact `ListName`), node `#no-node` / `[data-test='notif-filter-node']`, from/to `#no-from`/`#no-to`, subject keyword `#no-search` (substring; use `SetSubjectKeywordAsync`), **stuck-only `#no-stuck-only`** (label "Stuck only"). **Query `button.btn-primary:has-text('Query')`** (no `data-test`; resets to page 1). Clear `button.btn-outline-secondary.btn-sm:has-text('Clear')`. **Auto-loads on visit.** Page size **50**. +- Grid rows `tbody tr`. Stuck badge `span.badge.bg-warning.text-dark:has-text('Stuck')`; stuck row highlight `tr.table-warning`. Empty-state literals `No notifications` / `No notifications match the current filters.` +- **Detail modal: open by DOUBLE-CLICK on a non-Actions row cell** (`@ondblclick`; Actions cell stops propagation). Container `.modal.show.d-block`; title `.modal-title:has-text('Notification Detail')`; **X `button.btn-close[aria-label="Close"]`**; footer `.modal-footer button.btn-outline-secondary:has-text('Close')`; **backdrop click closes** (outer `.modal` `@onclick`, inner dialog stops propagation). **No Escape handler.** +- Pager renders **only when `_totalCount > 50`**: prev `button.btn-outline-secondary.btn-sm:has-text('Previous')` (disabled page 1), next `…:has-text('Next')` (disabled when current page < 50 rows). Indicator `Page {N} of {pages} · {total} total`. +- `NotificationDataSeeder` (table **`Notifications`**): today only `InsertParkedNotificationAsync(...)` (hard-codes `Status="Parked"`, `CreatedAt=now`). **`IsStuck` is derived: `Status ∈ {Pending,Retrying} && CreatedAt < now − 10min`** → a Parked@now row is NEVER stuck. **Task 1 generalizes the seeder** so a back-dated `Retrying` row can be seeded for the positive stuck test. `DeleteByMarkerAsync(listName)`, `IsAvailableAsync()`, `DbUnavailableSkipReason` const in `NotificationActionTests.cs`. + +### Scope changes vs. the design (and why) +- **DROP** Sites "invalid Akka/gRPC URL" — no URL validation exists in `SiteForm`. +- **DROP** Audit Log pagination — page size is 100 with no test-facing override; a 101-row seed is disproportionate, and the pagination *pattern* is covered by Site Calls (keyset) and Notification (page-number). Documented, not silent. +- **DROP** the "Escape closes" leg on both the Audit drawer and the Notification detail modal — neither is wired in the app. Replace with the X / backdrop / footer-Close paths that ARE wired (no app change this wave). +- Everything else in the design's Wave-4 list is kept and grounded above. + +--- + +## Task 0: CLI role-mapping helpers + round-trip test + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 1 + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs` +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs` + +**Why:** the LDAP duplicate-group test (Task 5) needs to seed a pre-existing mapping and clean it up deterministically by id. The CLI verbs exist (`security role-mapping create|list|delete`); add typed wrappers mirroring the existing `Create…/Delete…` split (provision throws, teardown best-effort). + +**Step 1 — add helpers to `CliRunner.Helpers.cs`** (mirror `CreateExternalSystemAsync`/`DeleteExternalSystemAsync` style; reuse the existing private `RequireId(JsonDocument)` and `BestEffortAsync(group, verb, id)`): + +```csharp +/// Creates an LDAP→role mapping; returns its id. Throws on failure. +public static async Task CreateRoleMappingAsync(string ldapGroup, string role = "Designer") +{ + using var doc = await RunJsonAsync("security", "role-mapping", "create", + "--ldap-group", ldapGroup, "--role", role); + return RequireId(doc); +} + +/// Best-effort delete of an LDAP→role mapping (teardown). +public static Task DeleteRoleMappingAsync(int id) + => BestEffortAsync("security role-mapping", "delete", id, group2: "role-mapping"); +``` + +If `BestEffortAsync`'s signature doesn't accommodate the two-token group (`security role-mapping`), follow whatever the existing role-mapping teardown in `LdapMappingCrudTests` already does (`CliRunner.RunAsync("security","role-mapping","delete","--id",id)`) and wrap it in a try/catch that swallows. Match the file's existing idiom exactly — read the surrounding helpers before writing. + +**Step 2 — add a round-trip test to `CliRunnerHelpersTests.cs`** (mirror `CreateThenDeleteApiMethod_RoundTrips`): + +```csharp +[SkippableFact] +public async Task CreateThenDeleteRoleMapping_RoundTrips() +{ + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + var group = CliRunner.UniqueName("grp"); + var id = await CliRunner.CreateRoleMappingAsync(group, "Designer"); + try + { + Assert.True(id > 0); + using var list = await CliRunner.RunJsonAsync("security", "role-mapping", "list"); + Assert.Contains(list.RootElement.EnumerateArray(), + e => e.TryGetProperty("ldapGroupName", out var n) && n.GetString() == group); + } + finally { await CliRunner.DeleteRoleMappingAsync(id); } +} +``` + +**Step 3 — run:** `dotnet test --filter CreateThenDeleteRoleMapping_RoundTrips` (from the test project dir). Expected: PASS (or SKIPPED if cluster down — then it cannot be validated; note that and proceed, the downstream Task 5 will exercise it). + +**Step 4 — commit:** `test(playwright): add CLI role-mapping create/delete helpers (Wave 4 prep)`. + +**Acceptance:** helpers compile under `TreatWarningsAsErrors`; round-trip passes against the live cluster; `CreateRoleMappingAsync` returns a positive id; teardown leaves no `zztest-grp-*` mapping. + +--- + +## Task 1: Generalize NotificationDataSeeder for non-Parked / back-dated rows + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 0 + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationDataSeeder.cs` + +**Why:** the positive "Stuck only" test (Task 11) needs a row that is actually stuck — `Status ∈ {Pending,Retrying}` AND `CreatedAt < now − 10min`. The current `InsertParkedNotificationAsync` hard-codes `Status="Parked"`, `CreatedAt=now` (never stuck). Add a general insert and refactor the existing method to delegate (DRY; no behavior change for existing callers). + +**Step 1 — read** `NotificationDataSeeder.cs` fully to learn the exact INSERT column list and the existing parameter names. + +**Step 2 — add a general method** and make `InsertParkedNotificationAsync` call it. Add `status` and `createdAt` parameters (default to the current Parked@now behavior): + +```csharp +/// Inserts one notification row with explicit status/created-at (for stuck/age edge cases). +public static async Task InsertNotificationAsync( + Guid notificationId, string listNameMarker, string subject, + string status, DateTimeOffset createdAt, + int retryCount = 0, string? lastError = "SMTP 451 transient failure (seeded)", + CancellationToken ct = default) +{ + // identical body to the existing InsertParkedNotificationAsync INSERT, but with + // @status = status and @createdAt = createdAt (and @siteEnqueuedAt = createdAt). + // Keep every other NOT-NULL column exactly as the existing method sets it. +} + +public static Task InsertParkedNotificationAsync( + Guid notificationId, string listNameMarker, string subject, + int retryCount = 0, string? lastError = "SMTP 451 transient failure (seeded)", + CancellationToken ct = default) + => InsertNotificationAsync(notificationId, listNameMarker, subject, + status: "Parked", createdAt: DateTimeOffset.UtcNow, retryCount, lastError, ct); +``` + +Preserve the existing method's public signature so Tasks 9/10's existing callers and the current tests are untouched. Confirm the `Notifications` INSERT does NOT require a terminal/`TerminalAtUtc` column for a non-terminal status (the existing INSERT column list is the source of truth — do not add columns). + +**Step 3 — build:** `dotnet build` (must be clean under `TreatWarningsAsErrors`). No standalone test — this is a test-helper refactor exercised by Tasks 10/11. + +**Step 4 — commit:** `test(playwright): generalize NotificationDataSeeder for status/created-at (Wave 4 prep)`. + +**Acceptance:** builds clean; `InsertParkedNotificationAsync` behavior is byte-identical to before (delegates with Parked/now); the new method allows `status:"Retrying", createdAt: now-15min`. + +--- + +## Task 2: Sites edge — duplicate identifier + cancel-from-edit + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (cluster/browser) + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs` + +**Existing coverage to NOT duplicate:** create-page Cancel→list, Save-without-name→`.text-danger`, Back→list, full create/edit/delete round-trip. The new value is **duplicate-identifier** and **cancel-from-EDIT-with-a-changed-field**. + +**Step 1 — `Create_DuplicateIdentifier_ShowsSaveFailedError`** (`[SkippableFact]`, `ClusterAvailability` gate). Collide against the always-present seeded `site-a` identifier (the failed create persists nothing → zero residue): + +- `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);` +- Confirm the collision target exists: `await CliRunner.ResolveSiteIdAsync("site-a")` (it throws if absent — let that surface; site-a is a core cluster seed). +- `var page = await _fixture.NewAuthenticatedPageAsync();` → `GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create")`. +- Fill Identifier = `"site-a"` (the duplicate), Name = `$"zztest-dup-{Guid.NewGuid():N}"[..16]` (**distinct** so the unique-Name index can't be the thing that trips). Leave node addresses blank. +- Click Save (`button.btn.btn-success.btn-sm:has-text('Save')`). +- Assert (web-first): `Expect(page.Locator("div.text-danger.small.mt-2")).ToContainTextAsync("Save failed")` AND `Expect(page).ToHaveURLAsync(new Regex("/admin/sites/create"))`. (Do NOT assert the raw DB message.) +- No teardown needed (nothing persisted). Optionally a `finally` safety sweep: best-effort `DeleteSiteAsync(ResolveSiteIdAsync(name))` swallowing the "not found" throw. + +**Step 2 — `EditCancel_DiscardsChanges`** (`[SkippableFact]`). Create a `zztest-site` via UI, edit a field, Cancel, verify the original persists: + +- Gate; `var hex = Guid.NewGuid().ToString("N")[..8]; var ident = $"zztest-{hex}"; var name = $"zztest-site-{hex}";` +- Create via UI (mirror the round-trip test's create steps: fill Identifier+Name, Save, wait for `/admin/sites`). +- Open edit: locate the site card (`div.card` HasText=name) → its Edit button (`button.btn.btn-outline-primary.btn-sm:has-text('Edit')`); wait for `/{id}/edit` (URL contains `/edit`). +- Change the Description input to `"zztest-CANCELLED-EDIT"`; click `button:has-text('Cancel')`; wait for `/admin/sites` (excludePath `/edit`). +- Re-open the same card's Edit; assert the Description input value is the ORIGINAL (empty): `Expect(descInput).ToHaveValueAsync("")`. (Proves Cancel discarded.) +- `finally`: `try { await CliRunner.DeleteSiteAsync(await CliRunner.ResolveSiteIdAsync(ident)); } catch { }`. + +**Step 3 — run:** `dotnet test --filter "Create_DuplicateIdentifier_ShowsSaveFailedError|EditCancel_DiscardsChanges"`. Expected: 2 PASS. + +**Step 4 — commit:** `test(playwright): Sites duplicate-identifier + cancel-from-edit edge cases (Wave 4)`. + +**Acceptance:** both pass; site-a untouched; no `zztest-site-*` residue (`site list` clean). + +--- + +## Task 3: Templates edge p1 — duplicate name + create-cancel + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs` + +**Existing coverage to NOT duplicate:** create→add-attribute→delete round-trip (happy path). + +**Step 1 — `CreateTemplate_DuplicateName_ShowsInlineError`** (`[SkippableFact]`, `ClusterAvailability` gate). Seed an existing base template by name via CLI, then UI-attempt the duplicate: + +- `var name = CliRunner.UniqueName("tmpl");` +- `var seededId = await CliRunner.CreateTemplateAsync(name);` (this is the existing-name source). +- try: navigate `/design/templates/create`; fill Name = `name` (the duplicate) via `div.mb-3:has(label:has-text('Name')) input.form-control`; click `button.btn.btn-success:has-text('Create')`. +- Assert: `Expect(page.Locator("div.text-danger.small")).ToBeVisibleAsync()` AND not empty (`ToContainTextAsync` of a non-empty match, or assert `.Count >= 1` + text length) AND `Expect(page).ToHaveURLAsync(new Regex("/design/templates/create"))`. (Behavior confirmed: DB unique index, no friendly pre-check → caught into `_formError`. Do NOT assert a literal — capture whatever surfaces.) +- `finally`: `await CliRunner.DeleteTemplateAsync(seededId);` and sweep any `ListTemplateIdsByNamePrefixAsync(name)` leftovers. + +> **⚠ validation-behavior protocol:** before finalizing, the implementer confirms the duplicate create does NOT silently succeed-and-navigate. If (contrary to the EF unique index) the cluster allows it and navigates to `/design/templates/{id}`, re-scope this to the **attribute** duplicate instead (which IS friendly-guarded: add attribute `Val`, add `Val` again → modal `div.text-danger.small` reading `Attribute 'Val' already exists on template '{name}'.`). The EF index strongly implies the template-name path works; this is the documented fallback. + +**Step 2 — `CreateTemplate_Cancel_ReturnsToListWithoutCreating`** (`[SkippableFact]`): + +- `var name = CliRunner.UniqueName("tmpl");` +- Navigate `/design/templates/create`; fill Name = `name`; click `button.btn.btn-outline-secondary:has-text('Cancel')`; wait for `/design/templates` (excludePath `/create`). +- Assert no template with that name exists: `Assert.Empty(await CliRunner.ListTemplateIdsByNamePrefixAsync(name))`. +- No teardown needed (nothing created); defensive sweep in `finally` anyway. + +**Step 3 — run:** `dotnet test --filter "CreateTemplate_DuplicateName_ShowsInlineError|CreateTemplate_Cancel_ReturnsToListWithoutCreating"`. Expected: 2 PASS. + +**Step 4 — commit:** `test(playwright): Templates duplicate-name + create-cancel edge cases (Wave 4)`. + +**Acceptance:** both pass; zero `zztest-tmpl-*` residue (`template list` clean). + +--- + +## Task 4: Templates edge p2 — edit attribute + delete-blocked-by-instance + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs` + +**Blocked by:** Task 3 (same file — sequential edits). + +**Setup:** the delete-blocked test reuses **`DeploymentFixture`**. Add `IClassFixture` to the class and accept it in the ctor (`public class TemplateCrudTests(PlaywrightFixture pw, DeploymentFixture cluster)` with `[Collection("Playwright")]` + `IClassFixture` — mirror `DeploymentActionTests`). If the class is currently a non-primary-ctor form, adapt to keep `_fixture` working and add `_cluster`. + +**Step 1 — `EditAttribute_PersistsChange`** (`[SkippableFact]`, `ClusterAvailability` gate). Create a template + attribute via CLI, edit the attribute's Value in the UI modal, verify persisted: + +- `var name = CliRunner.UniqueName("tmpl"); var id = await CliRunner.CreateTemplateAsync(name); await CliRunner.AddAttributeAsync(id, "Val", "Double");` +- try: navigate `/design/templates/{id}`; open the `Val` row dropdown `button[aria-label="More actions for Val"]` → `button.dropdown-item:has-text('Edit…')`; assert modal `.modal.show.d-block:not(.fade)` with title "Edit Attribute"; confirm Name input is `readonly`; set the Value input to `"42.5"`; click `.modal.show.d-block:not(.fade) .modal-footer button.btn-success.btn-sm:has-text('Save')`. +- Assert the modal closes and the change persisted: web-first wait for the modal to detach, then assert the attribute row reflects the new value (`table tr:has(td:has-text('Val'))` contains `42.5`), OR re-read via `LoadAsync` round-trip — the simplest deterministic check is to assert the row's value cell text. If the table doesn't render the value column, fall back to a toast/no-error assertion + reload-and-reopen the modal asserting the Value input now reads `42.5`. +- `finally`: `await CliRunner.DeleteTemplateAsync(id);`. + +**Step 2 — `DeleteTemplate_WithInstance_IsBlocked`** (`[SkippableFact]`). Use `DeploymentFixture` (a template with an instance referencing it): + +- `Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);` +- `var (instId, _) = await _cluster.CreateInstanceAsync();` (creates a non-deployed instance referencing `_cluster.TemplateId` — sufficient to block). +- try: navigate `/design/templates/{_cluster.TemplateId}`; click header Delete `button.btn.btn-outline-danger.btn-sm:has-text('Delete')`; confirm `.modal-footer .btn-danger:has-text('Delete')`. +- Assert: `Expect(page.Locator(".toast")).ToContainTextAsync("instance(s) reference it")` (web-first) — the block message `Cannot delete template '{name}': {n} instance(s) reference it (...)`. Also assert the template still exists: still on the detail page (URL still `/design/templates/{id}`) or `Assert.NotEmpty(await CliRunner.ListTemplateIdsByNamePrefixAsync(_cluster's template name))` if its name is exposed; the URL check is simplest. +- `finally`: `await CliRunner.DeleteInstanceAsync(instId);` (the fixture's `DisposeAsync` sweeps the template + any leftover instances). + +**Step 3 — run:** `dotnet test --filter "EditAttribute_PersistsChange|DeleteTemplate_WithInstance_IsBlocked"`. Expected: 2 PASS. + +**Step 4 — commit:** `test(playwright): Templates edit-attribute + delete-blocked-by-instance edge cases (Wave 4)`. + +**Acceptance:** both pass; the `DeploymentFixture` template restored to instance-free; zero `zztest-*` residue. + +--- + +## Task 5: LDAP edge — missing-field + duplicate group + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs` + +**Blocked by:** Task 0 (CLI role-mapping helpers). + +**Existing coverage to NOT duplicate:** create→edit(role)→delete round-trip. + +**Step 1 — `Save_MissingGroupName_ShowsRequiredError`** (`[SkippableFact]`, `ClusterAvailability` gate). The `SaveMapping` handler checks group-blank FIRST: + +- Navigate `/admin/ldap-mappings/create`; leave the group input blank; select a role (`SelectOptionAsync` on `div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm` → `"Designer"`); click Save (`button.btn.btn-success.btn-sm:has-text('Save')`). +- Assert: `Expect(page.Locator("div.text-danger.small.mt-2")).ToHaveTextAsync("LDAP Group Name is required.")` AND URL still `/admin/ldap-mappings/create`. +- (Optional second leg in the same test — `Role is required.`: fill the group with a `zztest-grp` value, clear the role back to `""` via `SelectOptionAsync("")`, Save → assert the div now reads `"Role is required."`. Keep the prerender-race in mind: the prior Save already proved the circuit is live, so the second fill+Save is safe.) +- No persistence (both validations return early) → no teardown. + +**Step 2 — `Create_DuplicateGroup_ShowsSaveFailedError`** (`[SkippableFact]`). CLI-seed a mapping, UI-attempt the duplicate: + +- `var group = CliRunner.UniqueName("grp"); var seededId = await CliRunner.CreateRoleMappingAsync(group, "Designer");` +- try: navigate `/admin/ldap-mappings/create`; fill group input = `group` (exact same string); select role `"Viewer"`; click Save. +- Assert: `Expect(page.Locator("div.text-danger.small.mt-2")).ToContainTextAsync("Save failed")` AND URL still `/admin/ldap-mappings/create`. (DB unique index on `LdapGroupName`, no pre-check.) +- `finally`: `await CliRunner.DeleteRoleMappingAsync(seededId);`. + +**Step 3 — run:** `dotnet test --filter "Save_MissingGroupName_ShowsRequiredError|Create_DuplicateGroup_ShowsSaveFailedError"`. Expected: 2 PASS. + +**Step 4 — commit:** `test(playwright): LDAP missing-field + duplicate-group edge cases (Wave 4)`. + +**Acceptance:** both pass; no `zztest-grp-*` mapping residue (`security role-mapping list` clean). + +--- + +## Task 6: Audit Log edge p1 — filter combination + empty-after-apply + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs` + +**Gate:** match the file — `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason)`. Seeding uses `AuditDataSeeder` with a per-run `targetPrefix = $"playwright-test/wave4-audit/{runId}/"`. + +**Existing coverage to NOT duplicate:** channel-narrowing (single channel), drawer JSON pretty-print, copy-as-curl visible for ApiInbound (positive), correlation/execution drill-ins. The new value is **multi-filter combination** and **empty-after-apply**. + +**Step 1 — `FilterCombination_ChannelPlusTarget_NarrowsToMatch`** (`[SkippableFact]`). Seed two rows differing in channel; apply channel+target together: + +- Seed row 1: `InsertAuditEventAsync(eventId: Guid.NewGuid(), occurredAtUtc: now, channel: "ApiOutbound", kind: "ApiCall", status: "Success", target: targetPrefix + "match", …)`. +- Seed row 2: `…channel:"DbOutbound", kind:"DbWrite", target: targetPrefix + "other", …`. +- Navigate `/audit/log`; set channel `[data-test='filter-channel-select']` → `"ApiOutbound"`; type the exact `targetPrefix + "match"` into `#audit-target`; click `[data-test='filter-apply']`. +- Assert: the ApiOutbound/match row is visible (`[data-test='grid-row-{row1 eventId}']`) and the DbOutbound row count is 0 (`page.Locator($"[data-test='grid-row-{row2 eventId}']")` → `ToHaveCountAsync(0)`). This proves the two filters AND together. +- `finally`: `await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);`. + +**Step 2 — `EmptyResults_AfterApply_ShowsEmptyState`** (`[SkippableFact]`). A fresh random executionId GUID matches nothing: + +- Navigate `/audit/log`; type a fresh `Guid.NewGuid().ToString()` into `#audit-execution-id` (exact-match); click `[data-test='filter-apply']`. +- Assert: zero rows (`page.Locator("[data-test^='grid-row-']")` → `ToHaveCountAsync(0)`) AND the empty-state literal is visible: `Expect(page.Locator("[data-test='audit-results-grid']")).ToContainTextAsync("No audit events match the current filter.")`. +- No seeding/teardown needed (guaranteed-empty by construction). + +**Step 3 — run:** `dotnet test --filter "FilterCombination_ChannelPlusTarget_NarrowsToMatch|EmptyResults_AfterApply_ShowsEmptyState"`. Expected: 2 PASS. + +**Step 4 — commit:** `test(playwright): Audit Log filter-combination + empty-state edge cases (Wave 4)`. + +**Acceptance:** both pass; no `playwright-test/wave4-audit/*` rows remain (`DeleteByTargetPrefixAsync` ran). + +--- + +## Task 7: Audit Log edge p2 — non-API row omits cURL + drawer close (X/backdrop/footer) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs` + +**Blocked by:** Task 6 (same file). + +**Step 1 — `NonApiRow_Drawer_OmitsCopyAsCurl`** (`[SkippableFact]`). The cURL button renders iff channel ∈ {ApiOutbound, ApiInbound}: + +- Seed one `DbOutbound`/`DbWrite` row with `target: targetPrefix + "dbrow"`. +- Navigate `/audit/log`; type `targetPrefix + "dbrow"` into `#audit-target`; Apply; click the row `[data-test='grid-row-{eventId}']`. +- Assert drawer open (`Expect(page.Locator("[data-test='audit-drilldown-drawer']")).ToBeVisibleAsync()`) AND cURL absent: `page.Locator("[data-test='copy-as-curl']")` → `ToHaveCountAsync(0)`. (Existing test already proves the POSITIVE for ApiInbound; this is the negative.) +- `finally`: delete by prefix. + +**Step 2 — `Drawer_CloseControls_DismissTheDrawer`** (`[SkippableFact]`). Verify the X (and footer) close paths (Escape is NOT wired — intentionally omitted; see plan header): + +- Seed one `ApiOutbound` row; navigate; filter by its target; Apply; click the row to open the drawer. +- Assert drawer visible; click the X `[data-test='drawer-close']`; assert drawer gone (`ToHaveCountAsync(0)` on `[data-test='audit-drilldown-drawer']`). +- Re-open the row; click the footer Close `[data-test='drawer-close-footer']`; assert drawer gone again. (Two close affordances in one test; optionally add the backdrop `[data-test='drawer-backdrop']` path as a third.) +- Add a code comment: `// Escape-to-close is not wired on AuditDrilldownDrawer (only ExecutionDetailModal has a keydown handler); covering X/footer/backdrop, the paths that ARE wired.` +- `finally`: delete by prefix. + +**Step 3 — run:** `dotnet test --filter "NonApiRow_Drawer_OmitsCopyAsCurl|Drawer_CloseControls_DismissTheDrawer"`. Expected: 2 PASS. + +**Step 4 — commit:** `test(playwright): Audit Log non-API-no-cURL + drawer-close edge cases (Wave 4)`. + +**Acceptance:** both pass; no audit residue. + +--- + +## Task 8: Site Calls edge — status filter + empty state + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs` + +**Gate:** `Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason)` (the const is local to this file). Marker `targetPrefix = $"playwright-test/wave4-sc/{runId}/"`. Seed with `sourceSite:"site-a"` (permitted) so rows survive `FilterPermittedAsync`. + +**Existing coverage to NOT duplicate:** channel-narrowing, audit drill-in link, Retry/Discard visibility + click-throughs. The new value is **status filter** and **empty state**. + +**Step 1 — `StatusFilter_NarrowsToSelectedStatus`** (`[SkippableFact]`). Seed a Parked row and a Delivered row sharing the prefix; filter status=Parked: + +- Seed row P: `InsertSiteCallAsync(Guid.NewGuid(), channel:"ApiOutbound", target: targetPrefix + "parked", sourceSite:"site-a", status:"Parked", retryCount:1, createdAtUtc: now, updatedAtUtc: now)`. +- Seed row D: `…target: targetPrefix + "delivered", status:"Delivered", …`. +- Navigate `/site-calls/report`; select `#sc-status` → `"Parked"`; set `#sc-search` to the EXACT `targetPrefix + "parked"` via `SetSearchKeywordAsync`; click `[data-test='site-calls-query']`. +- Assert: the Parked marker row is visible (`tbody tr` HasText = `targetPrefix + "parked"`), its status badge reads `Parked`; then change `#sc-search` to `targetPrefix + "delivered"` + status still Parked + Query → 0 rows (the Delivered row is excluded by the status filter). This proves status narrows. +- `finally`: `await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);`. + +**Step 2 — `EmptyState_NoMatch_ShowsEmptyCard`** (`[SkippableFact]`). A per-run GUID target matches nothing: + +- Navigate `/site-calls/report`; set `#sc-search` to `$"playwright-test/wave4-sc-empty/{Guid.NewGuid():N}/none"` via `SetSearchKeywordAsync`; click Query. +- Assert: zero data rows AND empty-state literal visible: `Expect(page.Locator(".card-body")).ToContainTextAsync("No cached calls match the current filters.")` (or `text=No site calls`). No seeding/teardown. + +**Step 3 — run:** `dotnet test --filter "StatusFilter_NarrowsToSelectedStatus|EmptyState_NoMatch_ShowsEmptyCard"`. Expected: 2 PASS. + +**Step 4 — commit:** `test(playwright): Site Calls status-filter + empty-state edge cases (Wave 4)`. + +**Acceptance:** both pass; no `playwright-test/wave4-sc/*` rows remain. + +--- + +## Task 9: Site Calls keyset pagination + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs` + +**Blocked by:** Task 8 (same file). + +**Key mechanic:** page size 50; Next enabled only when the current page is EXACTLY full (50). `Target` is exact-match → seed **51 rows sharing ONE identical target string** so one `#sc-search` keyword selects all 51; stagger `createdAtUtc` for strict keyset order; `sourceSite:"site-a"`. + +**Step 1 — `Pagination_KeysetNextAndPrev_TraversesPages`** (`[SkippableFact]`, `SiteCallDataSeeder` gate): + +- `var sharedTarget = $"playwright-test/wave4-scpage/{runId}/row";` +- Seed 51 rows in a loop (one connection-per-call is acceptable for a live-cluster fact): `for (int i = 0; i < 51; i++) await SiteCallDataSeeder.InsertSiteCallAsync(Guid.NewGuid(), "ApiOutbound", sharedTarget, "site-a", "Delivered", 0, now.AddSeconds(-i), now.AddSeconds(-i));` — identical target, descending createdAt so the keyset order is deterministic. +- Navigate `/site-calls/report`; set `#sc-search` = `sharedTarget` via `SetSearchKeywordAsync`; click `[data-test='site-calls-query']`. +- Assert page 1: `Expect(page.Locator("tbody tr")).ToHaveCountAsync(50)`; page indicator contains `Page 1`; prev `[data-test='site-calls-prev']` is `disabled`; next `[data-test='site-calls-next']` is enabled. +- Click Next; assert page 2: 1 row (`ToHaveCountAsync(1)`), indicator `Page 2`, prev now enabled, next now disabled (short page). +- Click Prev; assert back to 50 rows / `Page 1` / prev disabled. (Confirms the cursor stack round-trips.) +- `finally`: `await SiteCallDataSeeder.DeleteByTargetPrefixAsync($"playwright-test/wave4-scpage/{runId}/");`. + +> Use web-first `Expect(...).ToBeDisabledAsync()`/`ToBeEnabledAsync()` for the button states and `ToHaveCountAsync` for rows — never `WaitForTimeout` + read. The `_loading` flag also disables the buttons mid-fetch, so assert the row count first (which waits for the new page) then the button states. + +**Step 2 — run:** `dotnet test --filter Pagination_KeysetNextAndPrev_TraversesPages`. Expected: PASS (allow generous timeouts; 51 seeds + two page fetches). + +**Step 3 — commit:** `test(playwright): Site Calls keyset pagination edge case (Wave 4)`. + +**Acceptance:** passes; all 51 seeded rows removed (`DeleteByTargetPrefixAsync`). + +--- + +## Task 10: Notification Report edge p1 — filter combination + detail modal + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs` + +**Blocked by:** Task 1 (seeder generalization — though these two cases use the existing Parked insert, keep the dependency so the seeder change lands first and the file builds once). + +**Gate:** `Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason)` (const local to file). Marker `var marker = $"zztest-notif-wave4-{runId}";` (the `ListName`). + +**Existing coverage to NOT duplicate:** Retry/Discard click-throughs. New value: **filter combo** and **detail-modal open/close**. + +**Step 1 — `FilterCombination_StatusPlusList_NarrowsToMatch`** (`[SkippableFact]`). Seed two Parked rows under the marker with distinct subjects; combine list+subject (or list+status) filters: + +- Seed: `InsertParkedNotificationAsync(Guid.NewGuid(), marker, subject: "wave4-alpha")` and `…(…, marker, subject: "wave4-beta")`. +- Navigate `/notifications/report`; set `#no-list` = `marker` (exact); set `#no-status` → `"Parked"`; set `#no-search` = `"wave4-alpha"` via `SetSubjectKeywordAsync`; click Query (`button.btn-primary:has-text('Query')`). +- Assert: the `wave4-alpha` row visible, `wave4-beta` row count 0 (combined filters AND). Optionally flip `#no-search` to `wave4-beta` + Query → beta visible, alpha gone. +- `finally`: `await NotificationDataSeeder.DeleteByMarkerAsync(marker);`. + +**Step 2 — `DetailModal_OpenOnDblClick_CloseViaXAndFooter`** (`[SkippableFact]`). Modal opens on double-click of a non-Actions cell: + +- Seed one Parked row (`subject:"wave4-detail"`, marker); navigate; narrow by `#no-list`=marker + Query; locate the row (`tbody tr` HasText `wave4-detail`). +- Double-click the row's subject cell (`row.Locator("td", new(){ HasText = "wave4-detail" })` → `DblClickAsync()`); assert modal `.modal.show.d-block` visible with `.modal-title:has-text('Notification Detail')`. +- Close via X `button.btn-close[aria-label="Close"]`; assert modal gone (`ToHaveCountAsync(0)` on `.modal.show.d-block`). +- Re-open (double-click); close via footer `.modal-footer button.btn-outline-secondary:has-text('Close')`; assert gone. Comment that Escape-close is not wired (only X / footer / backdrop are). +- `finally`: `DeleteByMarkerAsync(marker)`. + +**Step 3 — run:** `dotnet test --filter "FilterCombination_StatusPlusList_NarrowsToMatch|DetailModal_OpenOnDblClick_CloseViaXAndFooter"`. Expected: 2 PASS. + +**Step 4 — commit:** `test(playwright): Notification Report filter-combo + detail-modal edge cases (Wave 4)`. + +**Acceptance:** both pass; no `zztest-notif-wave4-*` rows remain. + +--- + +## Task 11: Notification Report edge p2 — stuck-only + pagination + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs` + +**Blocked by:** Task 1 (uses the generalized seeder) and Task 10 (same file). + +**Step 1 — `StuckOnlyFilter_NarrowsToStuckRows`** (`[SkippableFact]`). Seed one genuinely-stuck row (Retrying + back-dated) and one Parked@now row under one marker; toggle Stuck-only: + +- `var stuckCreatedAt = DateTimeOffset.UtcNow.AddMinutes(-15);` (older than the 10-min threshold). +- Seed stuck: `InsertNotificationAsync(Guid.NewGuid(), marker, "wave4-stuck", status:"Retrying", createdAt: stuckCreatedAt);` +- Seed non-stuck: `InsertParkedNotificationAsync(Guid.NewGuid(), marker, "wave4-fresh");` +- Navigate; set `#no-list` = marker; Query → assert both rows visible (2). +- Check `#no-stuck-only` (`CheckAsync`); click Query. +- Assert: only `wave4-stuck` visible (and it carries the stuck badge `span.badge.bg-warning.text-dark:has-text('Stuck')` / `tr.table-warning`); `wave4-fresh` row count 0. +- `finally`: `DeleteByMarkerAsync(marker)`. + +> **⚠ validation-behavior protocol:** confirm the seeded Retrying+back-dated row is classified stuck by the running app (the threshold default is 10 min; 15 min gives margin). If the cluster's `StuckAgeThreshold` is configured higher, increase the back-date. If — contrary to the code read — `Notifications` requires a terminal/`TerminalAtUtc`-style column for stuck classification, fall back to the **negative** form: toggling Stuck-only ON drops the seeded Parked row to zero (empty state). Prefer the positive assertion. + +**Step 2 — `Pagination_PageNumberNextAndPrev_TraversesPages`** (`[SkippableFact]`). Page size 50; pager renders only when `_totalCount > 50`. Seed 51 rows under one marker + shared subject token: + +- `for (int i = 0; i < 51; i++) await NotificationDataSeeder.InsertParkedNotificationAsync(Guid.NewGuid(), marker, subject: $"wave4-page-{i:D2}");` (same marker → one cleanup; distinct subjects fine — filter by `#no-list`=marker selects all 51). +- Navigate; set `#no-list` = marker; Query. +- Assert page 1: 50 rows (`tbody tr` `ToHaveCountAsync(50)`), indicator contains `Page 1`, prev `button.btn-outline-secondary.btn-sm:has-text('Previous')` disabled, next `…:has-text('Next')` enabled. +- Click Next; assert page 2: 1 row, `Page 2`, prev enabled, next disabled. +- Click Prev; back to 50 / `Page 1`. +- `finally`: `DeleteByMarkerAsync(marker)`. + +**Step 3 — run:** `dotnet test --filter "StuckOnlyFilter_NarrowsToStuckRows|Pagination_PageNumberNextAndPrev_TraversesPages"`. Expected: 2 PASS. + +**Step 4 — commit:** `test(playwright): Notification Report stuck-only + pagination edge cases (Wave 4)`. + +**Acceptance:** both pass; no `zztest-notif-wave4-*` rows remain. + +--- + +## Task 12: Wave 4 verification + residue check + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Blocked by:** Tasks 0–11. + +**Files:** none (verification only; may touch the plan's `.tasks.json` to mark complete). + +**Step 1 — full build:** `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` → clean under `TreatWarningsAsErrors=true`. + +**Step 2 — full suite:** run the entire Playwright test project against the live cluster. Expected: **0 failed**; new Wave-4 facts pass; skips logged (only the genuinely-environmental ones — e.g. any pre-existing SMTP no-op skip). Capture the pass/fail/skip tally. + +**Step 3 — residue scan (must be zero):** +- `scadabridge … site list` → no `zztest-*` site. +- `scadabridge … template list` → no `zztest-tmpl-*`. +- `scadabridge … instance list` → no `zztest-*` instance (the `DeploymentFixture` template restored to instance-free). +- `scadabridge … security role-mapping list` → no `zztest-grp-*`. +- Direct-SQL marker scan: no `playwright-test/wave4-%` rows in `AuditLog` or `SiteCalls`; no `zztest-notif-wave4-%` `ListName` in `Notifications`. +- `site-a` left exactly as found (the duplicate-identifier test persisted nothing; no test mutated site-a). + +**Step 4 — app-diff guard:** `git diff --stat` shows **only** files under `tests/…PlaywrightTests/` and `docs/plans/…wave4*` — **zero `src/` changes** (no `data-test` hooks were needed this wave). If any `src/` file changed, that's a defect — investigate before completing. + +**Step 5 — mark complete:** update `…-wave4.md.tasks.json` statuses → `completed`; commit `docs(plans): mark Wave 4 tasks complete`. + +**Acceptance:** full suite 0-failed; zero residue; clean build; no `src/` changes; `site-a` unchanged. + +--- + +## Scope guard (YAGNI) + +No new fixtures, no new test files, no page-object framework, no CI/runner/parallelization changes, no visual-regression. The only non-test additions are two CLI helpers (Task 0) and one seeder generalization (Task 1). **No app-code changes and no `docker/deploy.sh` rebuild** — confirmed every edge surface has an existing stable selector after dropping the two un-wired Escape legs and the disproportionate Audit-pagination case. + +## Success criteria + +All 6 already-covered pages gain their duplicate/cancel/empty/filter-combo/pagination edge assertions (~18 new `[SkippableFact]`s across 10 test tasks); the full suite stays green with logged skips; zero residue; clean build. Wave 4 closes the coverage-fill effort. diff --git a/docs/plans/2026-06-06-playwright-coverage-fill-wave4.md.tasks.json b/docs/plans/2026-06-06-playwright-coverage-fill-wave4.md.tasks.json new file mode 100644 index 00000000..6c79c80f --- /dev/null +++ b/docs/plans/2026-06-06-playwright-coverage-fill-wave4.md.tasks.json @@ -0,0 +1,21 @@ +{ + "planPath": "docs/plans/2026-06-06-playwright-coverage-fill-wave4.md", + "lastUpdated": "2026-06-07T00:00:00Z", + "nativeTaskIdBase": 112, + "status": "pending", + "tasks": [ + {"id": 0, "nativeId": 112, "subject": "Task 0: CLI role-mapping helpers + round-trip test", "status": "pending"}, + {"id": 1, "nativeId": 113, "subject": "Task 1: Generalize NotificationDataSeeder for status/created-at", "status": "pending"}, + {"id": 2, "nativeId": 114, "subject": "Task 2: Sites edge - duplicate identifier + cancel-from-edit", "status": "pending"}, + {"id": 3, "nativeId": 115, "subject": "Task 3: Templates edge p1 - duplicate name + create-cancel", "status": "pending"}, + {"id": 4, "nativeId": 116, "subject": "Task 4: Templates edge p2 - edit attribute + delete-blocked-by-instance", "status": "pending", "blockedBy": [3]}, + {"id": 5, "nativeId": 117, "subject": "Task 5: LDAP edge - missing-field + duplicate group", "status": "pending", "blockedBy": [0]}, + {"id": 6, "nativeId": 118, "subject": "Task 6: Audit Log edge p1 - filter combination + empty-after-apply", "status": "pending"}, + {"id": 7, "nativeId": 119, "subject": "Task 7: Audit Log edge p2 - non-API no cURL + drawer close", "status": "pending", "blockedBy": [6]}, + {"id": 8, "nativeId": 120, "subject": "Task 8: Site Calls edge - status filter + empty state", "status": "pending"}, + {"id": 9, "nativeId": 121, "subject": "Task 9: Site Calls keyset pagination", "status": "pending", "blockedBy": [8]}, + {"id": 10, "nativeId": 122, "subject": "Task 10: Notification Report edge p1 - filter combination + detail modal", "status": "pending", "blockedBy": [1]}, + {"id": 11, "nativeId": 123, "subject": "Task 11: Notification Report edge p2 - stuck-only + pagination", "status": "pending", "blockedBy": [1, 10]}, + {"id": 12, "nativeId": 124, "subject": "Task 12: Wave 4 verification + residue check", "status": "pending", "blockedBy": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]} + ] +}