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

51 KiB
Raw Permalink Blame History

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 12 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<PlaywrightFixture>); 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-<kind>-<8hex>; direct-SQL seeders use a per-run marker (playwright-test/<scenario>/{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 <Seeder>.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<DeploymentFixture> + [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/logAuditLogPage, 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/reportSiteCallsReport):

  • 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/reportNotificationReport, 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)):

/// <summary>Creates an LDAP→role mapping; returns its id. Throws on failure.</summary>
public static async Task<int> CreateRoleMappingAsync(string ldapGroup, string role = "Designer")
{
    using var doc = await RunJsonAsync("security", "role-mapping", "create",
        "--ldap-group", ldapGroup, "--role", role);
    return RequireId(doc);
}

/// <summary>Best-effort delete of an LDAP→role mapping (teardown).</summary>
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):

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

/// <summary>Inserts one notification row with explicit status/created-at (for stuck/age edge cases).</summary>
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<DeploymentFixture> to the class and accept it in the ctor (public class TemplateCrudTests(PlaywrightFixture pw, DeploymentFixture cluster) with [Collection("Playwright")] + IClassFixture<DeploymentFixture> — 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 011.

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.