51 KiB
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 viaICollectionFixture<PlaywrightFixture>); ctor injectsPlaywrightFixture _fixture. - Auth:
await _fixture.NewAuthenticatedPageAsync()→ logs inmulti-role/password(has Administrator + Designer + Deployer + Viewer; satisfies every page below). Browser base URLPlaywrightFixture.BaseUrl=http://scadabridge-traefik; CLI from hosthttp://localhost:9000. - Toast assertion: web-first
page.Locator(".toast")withToHaveCountAsync(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-callsTarget,zztest-notif-…-{runId}for the notificationListName). - 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
@bindcommit on change/blur, not Enter — afterFillAsyncinto a search box, callDispatchEventAsync("change")before clicking Query (the existingSetSearchKeywordAsync/SetSubjectKeywordAsync/SetSearchKeywordAsynchelpers already do this).SelectOptionAsyncfireschangeitself. - Prerender-hydration race (Wave-3 gotcha, still live): the UI uses InteractiveServer WITH prerendering, so an input can be "visible" before its
@bindhandler 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(disabledin 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 callGoBack()→/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) → caughtDbUpdateException→_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).
Nameis ALSO unique-indexed → keep the dup test's Name distinct from the collision target. - CLI:
ResolveSiteIdAsync(identifier)(throws if absent),DeleteSiteAsync(id)(best-effort). NoCreateSiteAsync— 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 inTemplateService.CreateTemplateAsync→ error lands inline indiv.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 dropdownbutton[aria-label="More actions for {attr.Name}"]→button.dropdown-item:has-text('Edit…'). Modal =.modal.show.d-block:not(.fade)(page-local, nofade); titleh6.modal-title= "Add Attribute"/"Edit Attribute". Modal fields: Nameinput.form-control @bind=_attrName(readonlywhen editing), Data Typeselect.form-select, Valueinput.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 HASfade) → server guard fails →.toastcontainingCannot 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 asDeploymentActionTests): exposesbool 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 kebabbutton.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 whenCustom), 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 literalNo audit events match the current filter.(nodata-test). Page size 100 (no test override). Page indicator textPage 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 siblingExecutionDetailModal). cURL button[data-test='copy-as-curl']renders iffchannel ∈ {ApiOutbound, ApiInbound}(IsApiChannel); aDbOutbound/Notificationrow's drawer omits it. AuditDataSeeder(tableAuditLog):InsertAuditEventAsync(eventId, occurredAtUtc, channel, kind, status, sourceSiteId?, target?, actor?, correlationId?, executionId?, parentExecutionId?, httpStatus?, …),DeleteByTargetPrefixAsync(targetPrefix),IsAvailableAsync().DbUnavailableSkipReasonconst lives inAuditGridColumnTests.cs— but AuditLogPageTests usesClusterAvailability.SkipReason(match the file). Default time-rangeLastHour, 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; useSetSearchKeywordAsyncto 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 badgespan.badge:has-text('{Status}'). Empty state literalsNo site calls/No cached calls match the current filters.(nodata-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 orderCreatedAtUtc DESC, TrackedOperationId DESC. Page indicatorPage {N} · {rows} rows. Pager footer renders only when rows exist. SiteCallDataSeeder(tableSiteCalls):InsertSiteCallAsync(trackedOperationId, channel, target, sourceSite, status, retryCount, createdAtUtc, updatedAtUtc, lastError?, httpStatus?, terminalAtUtc?),DeleteByTargetPrefixAsync(prefix),IsAvailableAsync(),ConnectionString.DbUnavailableSkipReasonconst is inSiteCallsPageTests.cs.FilterPermittedAsyncdrops rows whoseSourceSiteis outside the user's permitted set → seed withsourceSite:"site-a".Targetis exact-match, so 51 rows sharing ONE identical target value are all selected by one#sc-searchkeyword (the deterministic pagination isolator). StaggercreatedAtUtcfor 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(exactListName), node#no-node/[data-test='notif-filter-node'], from/to#no-from/#no-to, subject keyword#no-search(substring; useSetSubjectKeywordAsync), stuck-only#no-stuck-only(label "Stuck only"). Querybutton.btn-primary:has-text('Query')(nodata-test; resets to page 1). Clearbutton.btn-outline-secondary.btn-sm:has-text('Clear'). Auto-loads on visit. Page size 50. - Grid rows
tbody tr. Stuck badgespan.badge.bg-warning.text-dark:has-text('Stuck'); stuck row highlighttr.table-warning. Empty-state literalsNo 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'); Xbutton.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: prevbutton.btn-outline-secondary.btn-sm:has-text('Previous')(disabled page 1), next…:has-text('Next')(disabled when current page < 50 rows). IndicatorPage {N} of {pages} · {total} total. NotificationDataSeeder(tableNotifications): today onlyInsertParkedNotificationAsync(...)(hard-codesStatus="Parked",CreatedAt=now).IsStuckis derived:Status ∈ {Pending,Retrying} && CreatedAt < now − 10min→ a Parked@now row is NEVER stuck. Task 1 generalizes the seeder so a back-datedRetryingrow can be seeded for the positive stuck test.DeleteByMarkerAsync(listName),IsAvailableAsync(),DbUnavailableSkipReasonconst inNotificationActionTests.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")ANDExpect(page).ToHaveURLAsync(new Regex("/admin/sites/create")). (Do NOT assert the raw DB message.) - No teardown needed (nothing persisted). Optionally a
finallysafety sweep: best-effortDeleteSiteAsync(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.cardHasText=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"; clickbutton: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) viadiv.mb-3:has(label:has-text('Name')) input.form-control; clickbutton.btn.btn-success:has-text('Create'). - Assert:
Expect(page.Locator("div.text-danger.small")).ToBeVisibleAsync()AND not empty (ToContainTextAsyncof a non-empty match, or assert.Count >= 1+ text length) ANDExpect(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 anyListTemplateIdsByNamePrefixAsync(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 attributeVal, addValagain → modaldiv.text-danger.smallreadingAttribute '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; clickbutton.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
finallyanyway.
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 theValrow dropdownbutton[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 isreadonly; 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'))contains42.5), OR re-read viaLoadAsyncround-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 reads42.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 Deletebutton.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 messageCannot delete template '{name}': {n} instance(s) reference it (...). Also assert the template still exists: still on the detail page (URL still/design/templates/{id}) orAssert.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'sDisposeAsyncsweeps 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 (SelectOptionAsyncondiv.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 azztest-grpvalue, clear the role back to""viaSelectOptionAsync(""), 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 onLdapGroupName, 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 exacttargetPrefix + "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 freshGuid.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/DbWriterow withtarget: targetPrefix + "dbrow". - Navigate
/audit/log; typetargetPrefix + "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
ApiOutboundrow; 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-searchto the EXACTtargetPrefix + "parked"viaSetSearchKeywordAsync; click[data-test='site-calls-query']. - Assert: the Parked marker row is visible (
tbody trHasText =targetPrefix + "parked"), its status badge readsParked; then change#sc-searchtotargetPrefix + "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-searchto$"playwright-test/wave4-sc-empty/{Guid.NewGuid():N}/none"viaSetSearchKeywordAsync; click Query. - Assert: zero data rows AND empty-state literal visible:
Expect(page.Locator(".card-body")).ToContainTextAsync("No cached calls match the current filters.")(ortext=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=sharedTargetviaSetSearchKeywordAsync; click[data-test='site-calls-query']. - Assert page 1:
Expect(page.Locator("tbody tr")).ToHaveCountAsync(50); page indicator containsPage 1; prev[data-test='site-calls-prev']isdisabled; next[data-test='site-calls-next']is enabled. - Click Next; assert page 2: 1 row (
ToHaveCountAsync(1)), indicatorPage 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 andToHaveCountAsyncfor rows — neverWaitForTimeout+ read. The_loadingflag 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"viaSetSubjectKeywordAsync; click Query (button.btn-primary:has-text('Query')). - Assert: the
wave4-alpharow visible,wave4-betarow count 0 (combined filters AND). Optionally flip#no-searchtowave4-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 trHasTextwave4-detail). - Double-click the row's subject cell (
row.Locator("td", new(){ HasText = "wave4-detail" })→DblClickAsync()); assert modal.modal.show.d-blockvisible 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-stuckvisible (and it carries the stuck badgespan.badge.bg-warning.text-dark:has-text('Stuck')/tr.table-warning);wave4-freshrow 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
StuckAgeThresholdis configured higher, increase the back-date. If — contrary to the code read —Notificationsrequires 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 trToHaveCountAsync(50)), indicator containsPage 1, prevbutton.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→ nozztest-*site.scadabridge … template list→ nozztest-tmpl-*.scadabridge … instance list→ nozztest-*instance (theDeploymentFixturetemplate restored to instance-free).scadabridge … security role-mapping list→ nozztest-grp-*.- Direct-SQL marker scan: no
playwright-test/wave4-%rows inAuditLogorSiteCalls; nozztest-notif-wave4-%ListNameinNotifications. site-aleft 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.