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

47 KiB
Raw Blame History

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 12). 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 12):

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

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

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

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

Shared infrastructure

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

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

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

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

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

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

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

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

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

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

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

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

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

Fact: Create_AddRecipient_RemoveRecipient_Delete_RoundTrips (one cohesive UI round-trip; [SkippableFact], gate on ClusterAvailability.IsAvailableAsync()):

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

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

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


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

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

Files:

  • Create: tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmtpConfigTests.cs

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

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

Facts:

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

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


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

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

Files:

  • Create: tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs

Selectors (verbatim):

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

Fact: Create_Delete_RoundTrips (gate IsAvailableAsync; name = UniqueName("extsys"); finally → resolve by ListExternalSystemIdsByNamePrefixAsync(name) + DeleteExternalSystemAsync):

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

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

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


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

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

Files:

  • Create: tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs

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

Selectors (verbatim):

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

Fact A: CliCreated_Connection_DeletesViaTree (gate IsAvailableAsync):

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

Fact B: CreateButton_GatedOnNodeSelection (no mutation):

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

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


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

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

Files:

  • Create: tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/SharedScriptCrudTests.cs

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

Selectors (verbatim):

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

Fact A: CliCreated_Script_DeletesViaCard (gate IsAvailableAsync; name = UniqueName("script"); int id = await CliRunner.CreateSharedScriptAsync(name); finally → DeleteSharedScriptAsync(id)):

  1. Navigate /design/shared-scripts; assert heading; assert the card filtered by name is visible (use the filter input input[placeholder='Filter by name or code…'] if the list is long).
  2. Scope to that card's .dropdown; kebab → .text-danger Delete; confirm .modal-title:has-text('Delete Shared Script'); .modal-footer .btn-danger.
  3. Assert .toast count 1 (body contains deleted) and the card → ToHaveCountAsync(0).

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

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


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

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

Files:

  • Create: tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ApiMethodFormTests.cs

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

Selectors (verbatim):

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

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

Fact B: CliCreated_Method_VisibleAndDeletes (gate IsAvailableAsync; name = UniqueName("method"); int id = await CliRunner.CreateApiMethodAsync(name); finally → DeleteApiMethodAsync(id)):

  1. Navigate /design/external-systems; click the Inbound API Methods tab; assert the method card filtered by name is visible (and shows code:has-text('POST /api/{name}')). Use the tab's filter input if present.
  2. Scope to that card's .dropdown; kebab → .text-danger Delete; confirm .modal-footer .btn-danger.
  3. Assert .toast count 1 (body Deleted.) and the card → ToHaveCountAsync(0).

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


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

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

Files:

  • Create: tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/EventLogsTests.cs

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

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

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

Fact B: EventLogs_Search_RendersTableOrEmptyState (tolerant) — from Fact A's state, click Search; then assert tolerant terminal: EITHER the results table is visible OR td:has-text('No events found.') is visible, within ~15 s:

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: for i == 0, a JSON string > 1024 chars (e.g. "{\"blob\":\"" + new string('x', 1100) + "\"}"); for others a short "{\"k\":\"v\"}".
  • @bundleId: DBNull.Value for the bulk rows; bundleId for the bundleRows extra rows (insert those in a second loop, EntityType = marker, EntityName = marker + "-bundle-" + i).
  • Verify the exact column names + types against AuditDataSeeder / the EF model (AuditLogEntries: User, Action, EntityType, EntityId, EntityName, AfterStateJson nullable, Timestamp datetimeoffset, BundleImportId uniqueidentifier nullable). If User/Action are reserved words, bracket them (they are bracketed above).

No test of its own — it is exercised by Tasks 1011. (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_AndSearchNarrowsmarker = "zzCfgAudit-" + Guid.NewGuid().ToString("N")[..8]; seed SeedAsync(marker, Guid.NewGuid(), bulkCount: 3, bundleRows: 0); navigate /audit/configuration; assert heading + that the grid shows rows (tbody tr count ≥ 1). Then type marker into #audit-filter-entity-type, click Search; assert every visible data row contains marker (assert a tr:has-text('{marker}-0') visible and the total in the footer equals the seeded count — span:has-text('(3 total)')).

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

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


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

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

Files:

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

Selectors (verbatim):

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

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

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


Task 12: Wave 3 verification + residue check

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

Files: none (verification only).

Steps:

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