47 KiB
Playwright Coverage Fill — Wave 3 Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Add Tier-3 config-CRUD-breadth E2E coverage to the ScadaBridge Central UI Playwright suite — 9 new test suites across Notifications, Design (External Systems / Data Connections / Shared Scripts / API Methods), Event Logs, and Configuration Audit — leaving the suite green with zero residue.
Architecture: New xunit [Collection("Playwright")] suites in tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests, driven against the live 8-node docker cluster (browser URL http://scadabridge-traefik, CLI from host http://localhost:9000). Fixtures and state are CLI-provisioned (zztest-* ephemeral, read-back-verified); the one report-style page that needs central rows (/audit/configuration) gets a small direct-SQL seeder mirroring the existing AuditDataSeeder. Monaco-backed entities are CLI-seeded so the editor stays out of the critical path.
Tech Stack: xunit + Xunit.SkippableFact, Microsoft.Playwright (remote Chromium ws://localhost:3000), Microsoft.Data.SqlClient, the ScadaBridge CLI (scadabridge.dll). TFM net10.0, Nullable=enable, TreatWarningsAsErrors=true.
Wave-3-specific notes (read before starting)
NO app-code change. NO docker/deploy.sh rebuild. Unlike Wave 2, Wave 3 adds zero data-test hooks — every target page already exposes stable selectors (id/aria-label/visible text), and row assertions are content/count based. The only non-test artifacts are CLI helpers (test project) and one new DB seeder (test project). The running cluster image already serves everything these tests touch. (If the cluster happens to be down, tests skip via the ClusterAvailability gate — start it with bash docker/deploy.sh and wait for /health/ready 200 before running.)
Cadence constraint (identical to Waves 1–2). There is one shared Playwright browser (PlaywrightFixture, single [Collection("Playwright")], serial), one cluster, one build. Therefore every task that runs tests must be executed serially — each task is marked Parallelizable with: none even though file sets are disjoint. The executor must NOT dispatch two test-running implementers concurrently. Within a task, the implementer runs only its own dotnet test --filter.
Established conventions to honor (verbatim, from Waves 1–2):
[Collection("Playwright")]on every class; class fixtures viaIClassFixture<TFixture>.- First line of every state-touching fact:
Skip.IfNot(<available>, ClusterAvailability.SkipReason);—<available>is_cluster.Availablewhen a fixture is used, elseawait ClusterAvailability.IsAvailableAsync(). - Auth:
await _pw.NewAuthenticatedPageAsync()(cookie-via-fetch; never type into the login form — this respects the standing "no credentials into forms" constraint). - URLs:
$"{PlaywrightFixture.BaseUrl}/..."(BaseUrl =http://scadabridge-traefik). - Ephemeral names:
CliRunner.UniqueName("<kind>")→zztest-<kind>-<8hex>. - Toast (web-first):
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });then optionally assert.toast-bodytext. Toasts auto-dismiss at 5 s — assert promptly. - Danger confirm dialog:
.modal-footer .btn-danger(textDelete). Non-danger confirm:button:has-text('Confirm')(.btn-primary). - Per-row kebab: scope to the row/card
.dropdown; togglebutton[aria-label^='More actions']; item.dropdown-menu button.dropdown-itemfiltered by text (danger item also has.text-danger). - Best-effort teardown in
finally: provision helpers throw;Delete*helpers swallow. PlaywrightFixturecaps live contexts at 4 — keep it; do not raise it.
Validation-behavior protocol (the ⚠ items). Before asserting a specific failure/empty/disabled state, the implementer reads the page code-behind to confirm the actual behavior and asserts THAT. Where the app's real behavior is weaker than the design's wish (e.g. SMTP has no delete verb; clipboard fails on a non-secure origin), assert the real surfaced behavior and leave a one-line code comment noting the gap. The exact behaviors are already captured per-task below — but verify before asserting.
Page → route → role quick map (all reachable by multi-role):
| Page | Route | Policy |
|---|---|---|
| Notification Lists | /notifications/lists (+ /create, /{id}/edit) |
RequireDesign |
| SMTP Configuration | /notifications/smtp |
RequireAdmin |
| Notification KPIs | /notifications/kpis |
RequireDeployment |
| External Systems | /design/external-systems (+ /create, /{id}/edit) |
RequireDesign |
| Data Connections | /design/connections (tree) (+ /create?siteId=) |
RequireDesign |
| Shared Scripts | /design/shared-scripts (+ /create, /{id}/edit) |
RequireDesign |
| API Method form | /design/api-methods/create; methods listed on /design/external-systems → "Inbound API Methods" tab |
RequireDesign |
| Site Event Logs | /monitoring/event-logs |
RequireDeployment |
| Configuration Audit Log | /audit/configuration (+ ?bundleImportId={guid}) |
OperationalAudit (Admin or Viewer) |
Shared infrastructure
CLI helper extensions — tests/.../Cluster/CliRunner.Helpers.cs
Already exist (reuse as-is): UniqueName, CreateTemplateAsync, AddAttributeAsync, CreateAreaAsync, CreateInstanceAsync, CreateDataConnectionAsync(int siteId, string name, string protocol="OpcUa", string? primaryConfig=null), CreateApiMethodAsync(string name, string script="return null;"), DeployInstanceAsync, EnableInstanceAsync, DisableInstanceAsync, ResolveSiteIdAsync, GetInstanceDocumentAsync, ListTemplateIdsByNamePrefixAsync, ListAreaIdsByNamePrefixAsync, DeleteInstanceAsync, DeleteTemplateAsync, DeleteAreaAsync, DeleteSiteAsync, DeleteDataConnectionAsync, DeleteApiMethodAsync, ResolveApiKeyIdByNameAsync, CreateApiKeyAsync, DeleteApiKeyAsync.
To ADD (Task 0) — mirror the existing throw-vs-swallow split; new int-delete teardowns delegate to BestEffortAsync("<group>", "delete", id); new creates use RequireId(doc, "<command>"). JSON shapes are arrays of PascalCase entity objects.
// ── Provision (throw) ──────────────────────────────────────────────────────
public static async Task<int> CreateExternalSystemAsync(
string name, string endpointUrl = "https://example.invalid/api", string authType = "ApiKey")
{
using var doc = await RunJsonAsync(
"external-system", "create",
"--name", name, "--endpoint-url", endpointUrl, "--auth-type", authType);
return RequireId(doc, "external-system create");
}
public static async Task<int> CreateNotificationListAsync(string name, string emails = "noreply@example.invalid")
{
using var doc = await RunJsonAsync("notification", "create", "--name", name, "--emails", emails);
return RequireId(doc, "notification create");
}
public static async Task<int> CreateSharedScriptAsync(string name, string code = "return null;")
{
using var doc = await RunJsonAsync("shared-script", "create", "--name", name, "--code", code);
return RequireId(doc, "shared-script create");
}
// ── Verify / read-back (throw) — resolve a UI-created entity's id for teardown ─
public static async Task<IReadOnlyList<int>> ListExternalSystemIdsByNamePrefixAsync(string prefix)
{
using var doc = await RunJsonAsync("external-system", "list");
return IdsWhereNameStartsWith(doc, prefix);
}
public static async Task<IReadOnlyList<int>> ListNotificationListIdsByNamePrefixAsync(string prefix)
{
using var doc = await RunJsonAsync("notification", "list");
return IdsWhereNameStartsWith(doc, prefix);
}
// Small shared parser (add once; mirrors the inline logic already in
// ListTemplateIdsByNamePrefixAsync). Case-insensitive on the "name"/"id" keys.
private static IReadOnlyList<int> IdsWhereNameStartsWith(JsonDocument doc, string prefix)
{
var ids = new List<int>();
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var el in doc.RootElement.EnumerateArray())
{
if (el.TryGetProperty("name", out var name) is false &&
el.TryGetProperty("Name", out name) is false) continue;
if (name.ValueKind != JsonValueKind.String) continue;
if (!(name.GetString()?.StartsWith(prefix, StringComparison.Ordinal) ?? false)) continue;
if ((el.TryGetProperty("id", out var id) || el.TryGetProperty("Id", out id)) &&
id.TryGetInt32(out var n)) ids.Add(n);
}
}
return ids;
}
// ── Teardown (best-effort) ─────────────────────────────────────────────────
public static Task DeleteExternalSystemAsync(int id) => BestEffortAsync("external-system", "delete", id);
public static Task DeleteNotificationListAsync(int id) => BestEffortAsync("notification", "delete", id);
public static Task DeleteSharedScriptAsync(int id) => BestEffortAsync("shared-script", "delete", id);
Verify before relying on it:
RequireId/BestEffortAsyncare the existing private partials — confirm their exact names/signatures in the file before calling. IfListTemplateIdsByNamePrefixAsyncalready inlines a name-match loop, you MAY reuse its style instead ofIdsWhereNameStartsWith, but DRY it into the one private helper. CLI JSON casing is System.Text.Json default (PascalCaseId/Name); the case-insensitive parser above is belt-and-braces.
New DB seeder — tests/.../Audit/ConfigAuditDataSeeder.cs (Task 9)
/audit/configuration reads the AuditLogEntries table (entity AuditLogEntry) via CentralUiRepository — NOT the AuditLog table the existing AuditDataSeeder writes. So a new seeder is required. Mirror AuditDataSeeder's idiom exactly: PlaywrightDbConnection.ConnectionString, Microsoft.Data.SqlClient, parameterized INSERT, IsAvailableAsync() probe, best-effort DeleteByMarkerAsync. AuditLogEntries columns: Id (identity — omit), User, Action, EntityType, EntityId, EntityName, AfterStateJson (nullable), Timestamp (datetimeoffset), BundleImportId (uniqueidentifier, nullable). Read AuditDataSeeder.cs first and copy its structure verbatim, changing only the table/columns. Full spec is in Task 9.
Task 0: CLI helpers (ExternalSystem / NotificationList / SharedScript) + round-trip tests
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (CLI round-trip tests hit the cluster)
Files:
- Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs - Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs
Step 1: Add the helpers exactly as specified in "Shared infrastructure → CLI helper extensions" above (3 Create, 2 List-by-prefix, 3 Delete, 1 private parser). Match the existing file's partial class CliRunner style and the throw/swallow split.
Step 2: Add round-trip tests to CliRunnerHelpersTests.cs, mirroring the existing CreateThenDeleteApiMethod_RoundTrips pattern (gate → UniqueName → create → assert id>0 (and, for the list-by-prefix pair, Assert.Contains(id, await ...ListIdsByNamePrefix...)) → best-effort delete in finally):
[SkippableFact]
public async Task CreateThenDeleteExternalSystem_RoundTrips()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
var name = CliRunner.UniqueName("extsys");
var id = await CliRunner.CreateExternalSystemAsync(name);
try
{
Assert.True(id > 0);
Assert.Contains(id, await CliRunner.ListExternalSystemIdsByNamePrefixAsync(name));
}
finally { await CliRunner.DeleteExternalSystemAsync(id); }
}
[SkippableFact]
public async Task CreateThenDeleteNotificationList_RoundTrips()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
var name = CliRunner.UniqueName("notiflist");
var id = await CliRunner.CreateNotificationListAsync(name);
try
{
Assert.True(id > 0);
Assert.Contains(id, await CliRunner.ListNotificationListIdsByNamePrefixAsync(name));
}
finally { await CliRunner.DeleteNotificationListAsync(id); }
}
[SkippableFact]
public async Task CreateThenDeleteSharedScript_RoundTrips()
{
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
var id = await CliRunner.CreateSharedScriptAsync(CliRunner.UniqueName("script"));
try { Assert.True(id > 0); }
finally { await CliRunner.DeleteSharedScriptAsync(id); }
}
Step 3: Build + run.
Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "FullyQualifiedName~CliRunnerHelpersTests" -v minimal
Expected: the 3 new tests pass (cluster up) or skip (cluster down); zero build warnings (TreatWarningsAsErrors=true). Confirm zero zztest-* residue afterward (the finally deletes; the round-trips also prove delete works).
Step 4: Commit.
git add tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs \
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs
git commit -m "test(playwright): add external-system/notification-list/shared-script CLI helpers (Wave 3 foundation)"
Task 1: NotificationKpisTests (read-only render + tiles + refresh)
Classification: small Estimated implement time: ~3 min Parallelizable with: none
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationKpisTests.cs
Selectors (verbatim): heading h4:has-text('Notification KPIs'). 5 tiles render <small class="text-muted">LABEL</small> for labels: Queue Depth, Stuck, Parked, Delivered (last interval), Oldest Pending Age. On KPI backend failure the whole tile row is replaced by div.alert.alert-warning text KPIs unavailable: …. Refresh button: button.btn.btn-outline-secondary.btn-sm:has-text('Refresh'), disabled while _loading, with an inline span.spinner-border during load. Null age renders — (em dash).
This page is pure-read — no mutation, no fixture, no teardown. Gate on ClusterAvailability.IsAvailableAsync().
Facts:
KpisPage_RendersTilesOrError— navigate; assert heading; then tolerant: assert EITHER all 5 tile labels are visible OR thealert-warning"KPIs unavailable" is visible. (The labels render whenever_kpiError == null; the alert replaces them otherwise.)var tiles = page.Locator("small.text-muted"); var ok = await page.Locator("small.text-muted:has-text('Queue Depth')").IsVisibleAsync() && await page.Locator("small.text-muted:has-text('Oldest Pending Age')").IsVisibleAsync(); var err = await page.Locator(".alert.alert-warning:has-text('KPIs unavailable')").IsVisibleAsync(); Assert.True(ok || err);KpisPage_RefreshReenables— assert the Refresh button is enabled, click it, then assert it is enabled again within ~10 s (ToBeEnabledAsync(new(){Timeout=10_000})). (It disables + shows a spinner mid-load; web-first re-enable proves the refresh round-trip completed without hanging.)
Run: dotnet test ... --filter "FullyQualifiedName~NotificationKpisTests" → green/skip. Commit: test(playwright): add NotificationKpis render + refresh coverage (Wave 3).
Task 2: NotificationListCrudTests (UI create → add/remove recipient → delete)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none blockedBy: Task 0
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs
Selectors (verbatim):
- List page
/notifications/lists: headingh4:has-text('Notification Lists'); add viabutton.btn.btn-primary.btn-sm:has-text('Add Notification List'). Rows are a<table>; locate a row by name:page.Locator("tr").Filter(new(){ HasText = name }). Per-row buttons: Editbutton.btn-outline-primary.btn-sm:has-text('Edit'), Deletebutton.btn-outline-danger.btn-sm:has-text('Delete'). - Create form
/notifications/lists/create: headingAdd Notification List; oneinput.form-control(the list name — recipients section is edit-only). Savebutton.btn-success:has-text('Save'). On success → redirect to/notifications/lists(NO toast). Empty name → inlinediv.text-danger.small:has-text('Name required.'). - Edit form
/notifications/lists/{id}/edit: headingEdit Notification List. Now there are TWO "Name" text inputs (list + recipient) plus oneinput[type=email]. Scope the recipient form to the card containing the email input. Recipient inputs: withinpage.Locator(".card").Filter(new(){ Has = page.Locator("input[type=email]") })use the text input (input[type=text].form-control) for recipient name andinput[type=email]for email; Add buttonbutton.btn-success:has-text('Add'). Empty recipient → inlinetext-danger.small:has-text('Name and email required.'). - Recipients table rows: locate by email text. Remove button (per recipient row, NO confirm):
button.btn-outline-danger.btn-sm:has-text('Delete')scoped to that recipient row. - List delete: row Delete → confirm dialog title
.modal-title:has-text('Delete')/ bodyDelete notification list '<NAME>'?→ confirm.modal-footer .btn-danger(textDelete) → success toast.toast(bodyDeleted.) + row gone.
Fact: Create_AddRecipient_RemoveRecipient_Delete_RoundTrips (one cohesive UI round-trip; [SkippableFact], gate on ClusterAvailability.IsAvailableAsync()):
var name = CliRunner.UniqueName("notiflist");— try/finally with best-effort CLI teardown (resolve by prefix → delete):finally { foreach (var id in await CliRunner.ListNotificationListIdsByNamePrefixAsync(name)) await CliRunner.DeleteNotificationListAsync(id); }- Navigate
/notifications/lists/create, fill the singleinput.form-controlwithname, click Save,WaitForPathAsync(page, "/notifications/lists", excludePath:"/create"), assert the row appears. - Click the row's Edit; wait for heading
Edit Notification List. In the recipient card fill name =zzrec+ email =zzrec@example.invalid, click Add; assert a recipients-table row with that email is visible. - Click that recipient row's Delete (no confirm); assert the recipient row count for that email →
ToHaveCountAsync(0). - Go back to
/notifications/lists(navigate or a Back control), click the list row's Delete; assert confirm dialog visible; click.modal-footer .btn-danger; assert.toastcount 1 (bodyDeleted.) and the row →ToHaveCountAsync(0, new(){Timeout=10_000}).
Also add a tiny negative fact CreateForm_EmptyName_ShowsInlineError — navigate to /create, click Save with the name blank, assert div.text-danger.small:has-text('Name required.') visible; no navigation. (Mutates nothing.)
Run --filter "FullyQualifiedName~NotificationListCrudTests" → green/skip. Commit: test(playwright): add NotificationList CRUD + recipient + validation coverage (Wave 3).
Task 3: SmtpConfigTests (validation gate + cancel + tolerant save round-trip)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs
⚠ Shared-singleton caution (validation-behavior protocol — read SmtpConfiguration.razor @code first). SMTP config is shared central state with no delete verb (CLI or UI). Therefore: do not perform any save that creates or net-changes a config you cannot restore. The valuable deterministic assertions mutate nothing.
Selectors (verbatim): heading h4:has-text('SMTP Configuration'). Empty state → button.btn-primary.btn-sm:has-text('Add SMTP configuration'). Existing configs render as cards with header <strong>{host}</strong> + button.btn-outline-primary.btn-sm:has-text('Edit'); the Credentials row shows (stored) or (not set). Form fields (bind-only, no ids — locate by form-label text/column): Host input[type=text], Port input[type=number], Auth Type select, TLS select, Credentials input[type=password], From Address input[type=email]. Save button.btn-success:has-text('Save'); Cancel button.btn-outline-secondary:has-text('Cancel'). Required = Host + From → inline div.text-danger.small:has-text('Host and From Address are required.'). Successful save → toast SMTP configuration saved.
Facts:
SmtpForm_MissingRequired_ShowsInlineError(always safe, no mutation) — open the form (clickAdd SMTP configurationif present, else the first card'sEdit); clear the Host input (and From if needed); click Save; assertdiv.text-danger.small:has-text('Host and From Address are required.')visible; then click Cancel and assert the form closed (the Save button is no longer visible). This proves the required-field gate without a successful save.SmtpPage_RendersConfigOrEmptyState— assert heading; then tolerant: EITHER a config card with aCredentialsvalue of(stored)/(not set)is visible, OR the empty-stateNo SMTP configuration set.text is visible.SmtpEdit_NoopSave_ShowsSavedToast(conditional, gated) — apply the protocol: readStartEditin the code-behind. Only ifStartEditloads the stored_credentialsinto the form (so a Save with the field untouched rewrites the same value — a true no-op): probenotification smtp listvia the CLI; if a config already exists, click that card'sEdit, change nothing, click Save, assert.toastcount 1 (bodySMTP configuration saved.). Belt-and-braces: snapshot Host/Port/AuthType/TLS/From vianotification smtp listbefore and restore vianotification smtp updateafter (best-effort) even though the no-op shouldn't change them. IfStartEditdoes NOT populate_credentials(saving blank would wipe the stored secret), skip this fact —Skip.If(true, "SMTP no-op save would risk wiping shared credentials; see code comment")— and document the gap in a comment. Do not guess; the implementer must read the handler.
Run --filter "FullyQualifiedName~SmtpConfigTests" → green/skip. Commit: test(playwright): add SMTP config validation + render coverage (Wave 3).
Task 4: ExternalSystemCrudTests (UI create → card → UI delete)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none blockedBy: Task 0
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs
Selectors (verbatim):
- List
/design/external-systems: headingh4:has-text('Integration Definitions'); default tab is External Systems. Addbutton.btn-primary.btn-sm:has-text('Add External System'). Cards:div.cardwith titleh5.card-title:has-text('{name}'). Per-card kebabbutton.btn-outline-secondary.btn-sm[aria-label^='More actions']; delete item.dropdown-menu button.dropdown-item.text-danger(textDelete). Confirm dialog titleDelete External System; confirm.modal-footer .btn-danger; success toast bodyDeleted. - Form
/design/external-systems/create: headingAdd External System. Nameinput[type=text].form-control(first), Endpoint URL the nextinput[type=text].form-control— disambiguate via labels (label:has-text('Name') ...,label:has-text('Endpoint URL') ...) or order. Auth Type defaultsApiKey. Savebutton.btn-success:has-text('Save')→ redirect to list (NO toast). Empty name/URL → inlinediv.text-danger.small:has-text('Name and URL required.').
Fact: Create_Delete_RoundTrips (gate IsAvailableAsync; name = UniqueName("extsys"); finally → resolve by ListExternalSystemIdsByNamePrefixAsync(name) + DeleteExternalSystemAsync):
- Navigate
/design/external-systems/create; fill Name =name, Endpoint URL =https://example.invalid/api; Save. WaitForPathAsync(page, "/design/external-systems", excludePath:"/create"); assert the carddiv.cardfiltered bynameis visible.- Scope to that card's
.dropdown; click the kebab; click the.text-dangerDelete item; assert confirm.modal-title:has-text('Delete External System'); click.modal-footer .btn-danger. - Assert
.toastcount 1 (bodyDeleted.) and the card →ToHaveCountAsync(0, new(){Timeout=10_000}).
Negative fact CreateForm_EmptyFields_ShowsInlineError — navigate to /create, Save with both fields blank, assert text-danger.small:has-text('Name and URL required.'); no navigation.
Run --filter "FullyQualifiedName~ExternalSystemCrudTests" → green/skip. Commit: test(playwright): add ExternalSystem CRUD + validation coverage (Wave 3).
Task 5: DataConnectionCrudTests (CLI-create → tree render + UI delete; create-form gating)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs
Why CLI-create: the /design/connections/create form requires the OpcUaEndpointEditor sub-fields (a valid endpoint URL etc.) which is brittle to drive. So CLI-create the connection on site-a and exercise the UI delete (the high-value mutating path) + the tree render. A second fact covers the create-form's disabled→enabled "+ Connection" gating without submitting.
Selectors (verbatim):
- Tree
/design/connections: headingh4:has-text('Connections'). Nodes renderspan.tv-labelwith the name; connection nodes addspan.badge.bg-info(protocol). The "+ Connection" buttonbutton.btn-primary.btn-sm:has-text('+ Connection')isdisableduntil a node is selected. Per-node kebabbutton.btn-link.btn-sm.dc-kebab[aria-label^='More actions']— the kebab is CSSopacity:0until row hover/focus, soHover()the node row (or the kebab) before clicking, or click withnew(){ Force = true }. Connection-node menu:.dropdown-item.text-danger(Delete). Site-node menu: itemAdd Connection here. Delete confirm dialog titleDelete Connection; confirm.modal-footer .btn-danger; success toast bodyConnection '{name}' deleted. - Search:
input[placeholder='Search sites or connections...'](live) — optional, to narrow the tree to the zztest connection. "Bulk actions" dropdown →Expand allto ensure the connection node is visible under its site.
Fact A: CliCreated_Connection_DeletesViaTree (gate IsAvailableAsync):
var siteId = await CliRunner.ResolveSiteIdAsync("site-a"); var name = CliRunner.UniqueName("dconn"); int id = await CliRunner.CreateDataConnectionAsync(siteId, name);— try/finally →DeleteDataConnectionAsync(id)(best-effort; the happy path deletes via UI, so this is a safety net).- Verify the CLI create succeeds with no
--primary-config. Ifdata-connection createrejects a missing primary config, pass a minimal OPC UA endpoint JSON asprimaryConfig, e.g."{\"EndpointUrl\":\"opc.tcp://zz:4840\"}"— readCreateDataConnectionAsync+ the CLI'sdata-connection createvalidation first and supply the smallest config the server accepts.
- Verify the CLI create succeeds with no
- Navigate
/design/connections; assert heading. Use the search box (typename) or "Bulk actions → Expand all" to surface the node. Assert aspan.tv-label:has-text('{name}')is visible. - Hover the connection node row; click its kebab (
[aria-label^='More actions'], scoped to that node;Force=trueif needed); click the.dropdown-item.text-dangerDelete; assert confirm.modal-title:has-text('Delete Connection'); click.modal-footer .btn-danger. - Assert
.toastcount 1 (body containsdeleted) andspan.tv-label:has-text('{name}')→ToHaveCountAsync(0, new(){Timeout=10_000}).
Fact B: CreateButton_GatedOnNodeSelection (no mutation):
- Navigate
/design/connections; assert the "+ Connection" button isdisabledinitially; click a site node'sspan.tv-label(any existing site, e.g. via the first.tv-label); assert "+ Connection" becomes enabled. (Proves the selection gating without entering the create form.) If the tree is empty (no sites) the test shouldSkip— butsite-aexists on the live cluster, so it won't be.
Run --filter "FullyQualifiedName~DataConnectionCrudTests" → green/skip. Commit: test(playwright): add DataConnection tree-delete + create-gating coverage (Wave 3).
Task 6: SharedScriptCrudTests (CLI-create → card render → UI delete; form renders)
Classification: standard Estimated implement time: ~4 min Parallelizable with: none blockedBy: Task 0
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/SharedScriptCrudTests.cs
Why CLI-create: the form uses a Monaco editor (brittle). CLI-create the script, exercise UI list render + UI delete; a second fact asserts the create form renders (no submit).
Selectors (verbatim):
- List
/design/shared-scripts: headingh4:has-text('Shared Scripts'). Addbutton.btn-primary.btn-sm:has-text('New Script'). Cardsdiv.cardtitleh5.card-title:has-text('{name}'). Kebabbutton.btn-outline-secondary.btn-sm[aria-label^='More actions']; delete.dropdown-item.text-danger. Confirm titleDelete Shared Script; confirm.modal-footer .btn-danger; success toast bodyScript '{name}' deleted. - Form
/design/shared-scripts/create: headingNew Shared Script. Nameinput[type=text].form-control.form-control-sm(@bind, disabled on edit only). Tabsbutton.nav-link(Code,Parameters,Return type). Monaco container.monaco-editor. Buttons: Savebutton.btn-success.btn-sm:has-text('Save'), Cancelbutton.btn-outline-secondary.btn-sm:has-text('Cancel'). Do NOT click "Test Run" / "Run" (fires real I/O).
Fact A: CliCreated_Script_DeletesViaCard (gate IsAvailableAsync; name = UniqueName("script"); int id = await CliRunner.CreateSharedScriptAsync(name); finally → DeleteSharedScriptAsync(id)):
- Navigate
/design/shared-scripts; assert heading; assert the card filtered bynameis visible (use the filter inputinput[placeholder='Filter by name or code…']if the list is long). - Scope to that card's
.dropdown; kebab →.text-dangerDelete; confirm.modal-title:has-text('Delete Shared Script');.modal-footer .btn-danger. - Assert
.toastcount 1 (body containsdeleted) and the card →ToHaveCountAsync(0).
Fact B: CreateForm_Renders (no submit, no Monaco typing) — navigate /design/shared-scripts/create; assert heading New Shared Script, the Name input visible+enabled, the Code/Parameters/Return type tabs visible, and a .monaco-editor mounted (ToHaveCountAsync(>=1) via ToBeVisibleAsync on .monaco-editor — generous timeout, Monaco mounts via JS interop). Mutates nothing.
Run --filter "FullyQualifiedName~SharedScriptCrudTests" → green/skip. Commit: test(playwright): add SharedScript list/delete + form-render coverage (Wave 3).
Task 7: ApiMethodFormTests (validation gate + CLI-create visibility + UI delete)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ApiMethodFormTests.cs
Why mixed: the form requires a Monaco script; the validation gate (Name filled, script empty → error) tests the form WITHOUT Monaco. Visibility + delete use a CLI-created method (helper CreateApiMethodAsync already exists).
Selectors (verbatim):
- Form
/design/api-methods/create: headingh4:has-text('Add API Method'). Nameinput[type=text].form-control(@bind, disabled on edit). Timeoutinput[type=number](default 30). Monaco.monaco-editor. Savebutton.btn-success:has-text('Save'). Empty name OR empty script → inlinediv.text-danger.small:has-text('Name and script required.'). - Saved methods appear on
/design/external-systemsunder the Inbound API Methods tab: tab buttonbutton.nav-link:has-text('Inbound API Methods'); method cardh5.card-title:has-text('{name}')pluscode:has-text('POST /api/{name}'). Card kebab[aria-label^='More actions']; delete.dropdown-item.text-danger; confirm titleDelete(Delete API method '{name}'?); confirm.modal-footer .btn-danger; success toast bodyDeleted.
Fact A: CreateForm_NameWithoutScript_ShowsInlineError (no mutation, no Monaco) — navigate /design/api-methods/create; fill Name = zzapimethod; leave the Monaco editor empty; click Save; assert div.text-danger.small:has-text('Name and script required.') visible; no navigation.
Fact B: CliCreated_Method_VisibleAndDeletes (gate IsAvailableAsync; name = UniqueName("method"); int id = await CliRunner.CreateApiMethodAsync(name); finally → DeleteApiMethodAsync(id)):
- Navigate
/design/external-systems; click theInbound API Methodstab; assert the method card filtered bynameis visible (and showscode:has-text('POST /api/{name}')). Use the tab's filter input if present. - Scope to that card's
.dropdown; kebab →.text-dangerDelete; confirm.modal-footer .btn-danger. - Assert
.toastcount 1 (bodyDeleted.) and the card →ToHaveCountAsync(0).
Run --filter "FullyQualifiedName~ApiMethodFormTests" → green/skip. Commit: test(playwright): add ApiMethod validation + visibility + delete coverage (Wave 3).
Task 8: EventLogsTests (render + Search-gating + tolerant query)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/EventLogsTests.cs
⚠ Not seedable. Event logs live in site-local SQLite and are fetched via an Akka Ask to a live site — there is no central table, no CLI/DB seed path. So this is a render + controls + tolerant-query test, gated on a live cluster (ClusterAvailability.IsAvailableAsync()). Do NOT assert specific rows.
Selectors (verbatim): heading h4:has-text('Site Event Logs'). Site select #filter-site (aria-label='Site'; first option Select site...; option value = SiteIdentifier). Severity #filter-severity (options All/Info/Warning/Error). Search button button.btn-primary.btn-sm:has-text('Search') — disabled while no site is selected (disabled=@(IsNullOrEmpty(_selectedSiteId) || _searching)). Results table appears only after a search; empty state td:has-text('No events found.'). Row expand button button[aria-label='View full message'] ↔ Hide full message. Pagination: button.btn-outline-primary.btn-sm:has-text('Load more') or sentinel span:has-text('End of results'). No toast, no copy button on this page.
Fact A: EventLogs_SearchGatedOnSiteSelection (deterministic) — navigate; assert heading; assert #filter-site has >1 option (sites populated); assert the Search button is disabled; select site-a by value (await page.Locator("#filter-site").SelectOptionAsync(new SelectOptionValue { Value = "site-a" }); — the option value is the SiteIdentifier site-a); assert Search becomes enabled.
Fact B: EventLogs_Search_RendersTableOrEmptyState (tolerant) — from Fact A's state, click Search; then assert tolerant terminal: EITHER the results table is visible OR td:has-text('No events found.') is visible, within ~15 s:
var settled = page.Locator("table tbody tr, td:has-text('No events found.')");
await Assertions.Expect(settled.First).ToBeVisibleAsync(new() { Timeout = 15_000 });
Then, only if at least one real data row exists (await page.Locator("button[aria-label='View full message']").CountAsync() > 0), exercise the row expand: click the first View full message, assert it flips to Hide full message (the inline <pre> appears). If no rows, skip the expand assert (documented). Do not assert Severity-filter result counts (no seeded data to make it deterministic) — at most assert selecting a Severity + Search still resolves to the same tolerant terminal.
Run --filter "FullyQualifiedName~EventLogsTests" → green/skip. Commit: test(playwright): add EventLogs render + search-gating coverage (Wave 3).
Task 9: ConfigAuditDataSeeder (direct-SQL seeder for AuditLogEntries)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/ConfigAuditDataSeeder.cs
Spec. Read tests/.../Audit/AuditDataSeeder.cs first and copy its idiom verbatim (connection from PlaywrightDbConnection.ConnectionString, Microsoft.Data.SqlClient, IsAvailableAsync() probe, parameterized INSERT, best-effort DeleteBy…Async that swallows). Change only the table/columns to AuditLogEntries.
Public surface:
public static class ConfigAuditDataSeeder
{
public static Task<bool> IsAvailableAsync(); // mirror AuditDataSeeder.IsAvailableAsync (open SqlConnection, true/false)
// Seeds `bulkCount` rows (default 55) tagged EntityType = marker so a UI filter
// isolates exactly this run's rows (deterministic pagination over 50/page).
// Row 0 gets a >1024-char AfterStateJson (drives the large-state MODAL).
// `bundleRows` rows additionally carry BundleImportId = bundleId (drives the chip drill-in);
// they also carry EntityType = marker so DeleteByMarkerAsync cleans them up.
public static Task SeedAsync(string marker, Guid bundleId, int bulkCount = 55, int bundleRows = 2);
public static Task DeleteByMarkerAsync(string marker); // DELETE FROM AuditLogEntries WHERE EntityType = @marker (best-effort)
}
INSERT shape (let identity assign Id; Timestamp = DateTimeOffset.UtcNow.AddSeconds(-i) so order is stable and recent):
INSERT INTO [AuditLogEntries] ([User],[Action],[EntityType],[EntityId],[EntityName],[AfterStateJson],[Timestamp],[BundleImportId])
VALUES (@user, @action, @entityType, @entityId, @entityName, @afterState, @ts, @bundleId);
@user = marker + "-user",@action = "Update",@entityType = marker,@entityId = marker + "-eid-" + i,@entityName = marker + "-" + i.@afterState: fori == 0, a JSON string > 1024 chars (e.g."{\"blob\":\"" + new string('x', 1100) + "\"}"); for others a short"{\"k\":\"v\"}".@bundleId:DBNull.Valuefor the bulk rows;bundleIdfor thebundleRowsextra rows (insert those in a second loop, EntityType = marker, EntityName =marker + "-bundle-" + i).- Verify the exact column names + types against
AuditDataSeeder/ the EF model (AuditLogEntries:User,Action,EntityType,EntityId,EntityName,AfterStateJsonnullable,Timestampdatetimeoffset,BundleImportIduniqueidentifier nullable). IfUser/Actionare reserved words, bracket them (they are bracketed above).
No test of its own — it is exercised by Tasks 10–11. (Build-only verification here.)
Run: dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests → clean. Commit: test(playwright): add ConfigAuditDataSeeder (AuditLogEntries direct-SQL seeder) (Wave 3).
Task 10: AuditConfigurationTests — part 1 (render / search-narrows / pagination)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none blockedBy: Task 9
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs
Gate: Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), AuditConfigDbUnavailableSkipReason) — reuse the DB-unavailable reason string idiom from the Audit tests (it references localhost:1433 / SCADABRIDGE_PLAYWRIGHT_DB). Each fact seeds in a try and cleans in finally via DeleteByMarkerAsync.
Selectors (verbatim): heading h4:has-text('Configuration Audit Log'). The grid auto-loads on visit (no Search needed). Filters: User #audit-filter-user, Entity Type #audit-filter-entity-type, Action #audit-filter-action, From #audit-filter-from, To #audit-filter-to. Search button.btn-primary.btn-sm:has-text('Search') (always enabled unless _searching). Clear button:has-text('Clear filters'). Rows: tbody tr (locate by content tr:has-text('{entityName}')). Empty: td:has-text('No audit entries found.'). Pagination footer: span:has-text('Page') (text Page X of Y (N total)); Previous button:has-text('Previous') (disabled when page 1); Next button:has-text('Next') (disabled on last page). Page size = 50.
Fact A: Grid_AutoLoads_AndSearchNarrows — marker = "zzCfgAudit-" + Guid.NewGuid().ToString("N")[..8]; seed SeedAsync(marker, Guid.NewGuid(), bulkCount: 3, bundleRows: 0); navigate /audit/configuration; assert heading + that the grid shows rows (tbody tr count ≥ 1). Then type marker into #audit-filter-entity-type, click Search; assert every visible data row contains marker (assert a tr:has-text('{marker}-0') visible and the total in the footer equals the seeded count — span:has-text('(3 total)')).
Fact B: Pagination_PrevDisabledOnPage1_NextPaginates — seed SeedAsync(marker, Guid.NewGuid(), bulkCount: 55, bundleRows: 0); navigate; filter Entity Type = marker; Search → footer Page 1 of 2 (span:has-text('Page 1 of 2')), (55 total). Assert button:has-text('Previous') is disabled and button:has-text('Next') is enabled. Click Next; assert footer Page 2 of 2, Previous now enabled and Next now disabled. (Filtering by the unique marker makes _totalCount deterministic regardless of other rows on the cluster.)
Run --filter "FullyQualifiedName~AuditConfigurationTests" (part 1 facts) → green/skip. Commit: test(playwright): add Configuration Audit render/search/pagination coverage (Wave 3).
Task 11: AuditConfigurationTests — part 2 (large-state modal / copy toast / bundle chip)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none blockedBy: Task 10
Files:
- Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs(append facts to the Task-10 class)
Selectors (verbatim):
- Large-state modal: the State cell of a row whose
AfterStateJson> 1024 chars showsbutton.btn-outline-info.btn-sm:has-text('View in modal')(aria-label='Open state details in modal for audit entry {id}'). Click → modaldiv.modal.showwithh5.modal-titletextAudit entry {id} — {EntityType} stateand a<pre>of pretty JSON. Close via footerbutton.btn-outline-secondary.btn-sm:has-text('Close')(or header.modal .btn-close) → modal removed from DOM. (Small-state rows instead show aView/Hideinline toggle — not used here.) - Copy entity id:
button[aria-label^='Copy entity ID'](glyph📋,title='Copy entity ID'). On click → toast. ⚠ http-origin caveat: the browser hitshttp://scadabridge-traefik(non-secure, non-localhost), sonavigator.clipboardis unavailable → the handler throws → an error toastCopy failed.fires. Either way a.toastappears, so assert.toastcount 1 tolerantly (do not assert which message) and note the caveat in a comment. - Bundle chip drill-in: navigate
/audit/configuration?bundleImportId={guid}→ chipspan.badge.bg-primary:has-text('Filtered by Bundle Import:')+ a<code>showing the first 8 chars; only that bundle's rows show. Clear buttonbutton[aria-label='Clear Bundle Import filter']→ chip removed, navigates to bare/audit/configuration.
Fact C: LargeState_OpensAndClosesModal — marker, seed SeedAsync(marker, Guid.NewGuid(), bulkCount: 1, bundleRows: 0) (row 0 has the >1024 AfterStateJson); navigate; filter Entity Type = marker; Search; in the row tr:has-text('{marker}-0') click View in modal; assert div.modal.show .modal-title:has-text(' state') visible; click the modal footer Close; assert div.modal.show → ToHaveCountAsync(0).
Fact D: CopyEntityId_ShowsToast (tolerant) — same seed; navigate; filter + Search; click the first button[aria-label^='Copy entity ID']; assert .toast count 1 (ToHaveCountAsync(1, new(){Timeout=15_000})) — tolerant of success (Copied to clipboard.) vs the non-secure-origin error (Copy failed.). Comment the caveat.
Fact E: BundleImportId_DrillIn_ChipFiltersAndClears — var bundleId = Guid.NewGuid(); seed SeedAsync(marker, bundleId, bulkCount: 1, bundleRows: 2); navigate /audit/configuration?bundleImportId={bundleId} (the page auto-loads pre-filtered via OnParametersSetAsync); assert the chip span.badge.bg-primary:has-text('Filtered by Bundle Import:') is visible and exactly the 2 bundle rows show (tr:has-text('{marker}-bundle-') count 2; the bulk row {marker}-0 is absent); click button[aria-label='Clear Bundle Import filter']; assert the chip → ToHaveCountAsync(0) and the grid reloads (bulk row now present).
Run --filter "FullyQualifiedName~AuditConfigurationTests" (all facts) → green/skip. Commit: test(playwright): add Configuration Audit modal/copy/bundle-chip coverage (Wave 3).
Task 12: Wave 3 verification + residue check
Classification: small Estimated implement time: ~4 min Parallelizable with: none blockedBy: Task 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
Files: none (verification only).
Steps:
- Full build:
dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests→ 0 warnings (TreatWarningsAsErrors=true). - Full suite:
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests -v minimal→ 0 failed; skips logged bySkipSummaryReporter(acceptable only if the cluster/DB is down — otherwise expect the new facts to pass). Record the passed/failed/skipped tallies. - Residue scan (zero expected): via CLI, confirm no
zztest-*leftovers:external-system list,notification list,shared-script list,data-connection list --site-id <site-a>,api-method list→ nozztest-*names.instance list --site-id <site-a>/template list→ nozztest-*.- Config-audit DB rows: confirm
DeleteByMarkerAsyncran (nozzCfgAudit-*rows linger —SELECT COUNT(*) FROM AuditLogEntries WHERE EntityType LIKE 'zzCfgAudit-%'= 0). SMTP config left as found (no new config created).
- App diff is empty:
git statusshows only new/changed files undertests/...anddocs/plans/...— nosrc/changes (Wave 3 adds no app code). Confirm. - Update the resume memory note (Waves 1–3 done) — see Execution Handoff.
No commit unless step 5 edits a tracked doc; the per-task commits already cover the code.
Task graph (blockedBy)
0 (CLI helpers) ───────────► 2, 4, 6
9 (seeder) ────────────────► 10 ──► 11
12 (verify) ◄── 0,1,2,3,4,5,6,7,8,9,10,11
All test-running tasks are Parallelizable with: none (shared browser+cluster → serial execution). Suggested order: 0 → 1 → 8 → 2 → 3 → 4 → 5 → 6 → 7 → 9 → 10 → 11 → 12 (foundation first; read-only KPIs/EventLogs early as low-risk warm-ups; AuditConfiguration last as it owns the new seeder).
Success criteria (per-wave gate)
Wave 3 is "done" only when: the 9 new suites (+ helper round-trips) pass against the live cluster (skips logged when it's down), the full suite stays at 0 failed, dotnet build is clean under TreatWarningsAsErrors=true, zero zztest-* / zzCfgAudit-* residue, SMTP config left as found, and git diff touches no src/ (no app-code change, no rebuild).