docs(tests): implementation plan for Playwright coverage expansion
16 task-by-task steps: shared CliRunner + ClusterAvailability skip infra, DeploymentFixture + deploy/enable/disable/delete suites, notification retry/discard + parked-messages query, Transport Import round-trip, Site/ Template/LDAP CRUD round-trips, nav render hardening, Health KPI guard, and a no-residue verification pass. Co-located .tasks.json for resumable execution.
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
# Playwright Coverage Expansion 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:** Add ~15 functional Playwright E2E tests + a shared CLI fixture + a standardized skip policy to close the gaps from the 2026-06-05 coverage audit, against the live 8-node docker cluster.
|
||||
|
||||
**Architecture:** New tests live in the existing `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` project, share the existing serial `[Collection("Playwright")]` + `PlaywrightFixture` (remote Chromium at `ws://localhost:3000`, app at `http://scadabridge-traefik`). State-changing tests provision ephemeral `zztest-*` fixtures by shelling out to the `scadabridge` CLI (host → `http://localhost:9000`, `multi-role`/`password`) and assert **outcome-tolerantly** (a confirm-dialog → a single `.toast` appears; the relay outcome may be success or a fast error). Every DB/cluster-dependent test uses `SkippableFact` + a shared availability probe and a logged skip summary.
|
||||
|
||||
**Tech Stack:** C# net10.0, xunit + `Xunit.SkippableFact`, Microsoft.Playwright, Microsoft.Data.SqlClient, the `scadabridge` CLI (`src/ZB.MOM.WW.ScadaBridge.CLI`). `TreatWarningsAsErrors=true` — all new code must be warning-clean and nullable-correct.
|
||||
|
||||
---
|
||||
|
||||
## Shared reference (used by many tasks — read once)
|
||||
|
||||
**Project paths**
|
||||
- Test project dir: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/` (abbrev. `TESTS/`).
|
||||
- CLI project: `src/ZB.MOM.WW.ScadaBridge.CLI/ZB.MOM.WW.ScadaBridge.CLI.csproj`.
|
||||
- Existing seeders to mirror: `TESTS/Audit/AuditDataSeeder.cs`, `TESTS/SiteCalls/SiteCallDataSeeder.cs`.
|
||||
- Existing canonical mutating test to copy: `TESTS/SiteCalls/SiteCallsPageTests.cs` (`RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast`).
|
||||
- Fixture/collection: `TESTS/PlaywrightFixture.cs` (`[CollectionDefinition("Playwright")]`, `BaseUrl`, `NewAuthenticatedPageAsync()`, `ExpandAllNavSectionsAsync()`).
|
||||
|
||||
**Global UI selectors (from a full source sweep — use these literally)**
|
||||
- Confirm dialog (global `DialogHost`): modal `.modal.show.d-block`; footer `.modal-footer`; confirm button = `.modal-footer .btn-danger` (text `Delete`, danger) or `.modal-footer .btn-primary` (text `Confirm`, non-danger); cancel = `.modal-footer .btn-outline-secondary`.
|
||||
- Toast (`ToastNotification`): `.toast` (each `.toast.show[role=alert]`); body `.toast-body`. Assert `.toast` visible (Timeout 15_000) and `CountAsync()==1`.
|
||||
- Tree (Topology/Templates): nodes are `li[role=treeitem]` containing `div.tv-row`; label in `span.tv-label`; **right-click** the row to open the context menu `.dropdown-menu.show`; items are `button.dropdown-item` (delete is `button.dropdown-item.text-danger`).
|
||||
- Card/table kebab (Sites/LdapMappings): click `button[aria-label^="More actions"]` (text `⋮`) → `.dropdown-menu`.
|
||||
|
||||
**CLI quick facts**
|
||||
- Invoke: `dotnet <CLI.dll> --url http://localhost:9000 --username multi-role --password password --format json <verb> <args>`. Exit `0` ok, `1` error; JSON on stdout.
|
||||
- Verbs: `site list`/`create --name --identifier`/`delete --id`/`area create --site-id --name`/`area delete --id`; `template create --name [--description]`/`delete --id`/`attribute add --template-id --name --data-type`/`list`; `instance create --name --template-id --site-id [--area-id]`/`deploy --id`/`enable --id`/`disable --id`/`delete --id`; `bundle export --output --passphrase --source-environment`/`import`.
|
||||
|
||||
**Naming + teardown convention**
|
||||
- All provisioned entities named `zztest-<8hex>` (`Guid.NewGuid().ToString("N")[..8]`). Teardown is best-effort (swallow exceptions), in `finally`.
|
||||
|
||||
**Skip pattern (rec 7)**
|
||||
- `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);` at the top of every DB/cluster test.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Add CLI ProjectReference to the test project
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (foundation)
|
||||
|
||||
**Files:**
|
||||
- Modify: `TESTS/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.csproj`
|
||||
|
||||
**Step 1:** Add a `ProjectReference` to the CLI so its build output (the `scadabridge` dll) is always present for the test process to invoke. Use `ReferenceOutputAssembly="false"` so the CLI's types are NOT linked into the test assembly (we only need its build artifact), but `Private`/build-ordering pulls the build:
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.ScadaBridge.CLI\ZB.MOM.WW.ScadaBridge.CLI.csproj"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
```
|
||||
**Step 2:** Run `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` → expect success; confirm `src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/ZB.MOM.WW.ScadaBridge.CLI.dll` exists.
|
||||
**Step 3:** Commit: `test(e2e): reference CLI project so tests can shell out to it`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `CliRunner` core (subprocess + JSON + availability probe)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3, Task 13, Task 14
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Cluster/CliRunner.cs`
|
||||
- Create: `TESTS/Cluster/ClusterAvailability.cs`
|
||||
- Test: `TESTS/Cluster/CliRunnerSmokeTests.cs`
|
||||
|
||||
**Step 1 — Write the failing smoke test:**
|
||||
```csharp
|
||||
using Xunit;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class CliRunnerSmokeTests
|
||||
{
|
||||
[SkippableFact]
|
||||
public async Task SiteList_ReturnsJsonArray()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
using var doc = await CliRunner.RunJsonAsync("site", "list");
|
||||
Assert.True(doc.RootElement.ValueKind is System.Text.Json.JsonValueKind.Array
|
||||
or System.Text.Json.JsonValueKind.Object);
|
||||
}
|
||||
}
|
||||
```
|
||||
**Step 2:** Run `dotnet test --filter CliRunnerSmokeTests` → FAIL (CliRunner not defined).
|
||||
|
||||
**Step 3 — Implement `CliRunner`:**
|
||||
- Resolve the CLI dll: walk up from `AppContext.BaseDirectory` to the repo root, then `src/ZB.MOM.WW.ScadaBridge.CLI/bin/<config>/net10.0/ZB.MOM.WW.ScadaBridge.CLI.dll`. Allow `SCADABRIDGE_CLI_DLL` env override. Throw a clear message if not found.
|
||||
- `RunAsync(params string[] args)`: `Process.Start` `dotnet <dll> --url <url> --username multi-role --password password --format json <args...>`; capture stdout+stderr; `await WaitForExitAsync()` with a 60s timeout (kill + throw on timeout); on non-zero exit throw `InvalidOperationException($"CLI {string.Join(' ', args)} exited {code}: {stderr}")`. Return stdout.
|
||||
- `RunJsonAsync(...)`: `JsonDocument.Parse(await RunAsync(...))`.
|
||||
- URL constant `http://localhost:9000`; allow `SCADABRIDGE_MANAGEMENT_URL` override (matches CLI's own env var).
|
||||
|
||||
**Step 4 — Implement `ClusterAvailability`:**
|
||||
```csharp
|
||||
public static class ClusterAvailability
|
||||
{
|
||||
public const string SkipReason = "Cluster/MSSQL unavailable — start the docker cluster (bash docker/deploy.sh) to run E2E.";
|
||||
private static bool? _cached;
|
||||
public static async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
if (_cached is { } c) return c;
|
||||
try { using var _ = await CliRunner.RunJsonAsync("site", "list"); _cached = true; }
|
||||
catch { _cached = false; }
|
||||
return _cached.Value;
|
||||
}
|
||||
}
|
||||
```
|
||||
**Step 5:** Run the smoke test → PASS (cluster up). Verify it SKIPS (not fails) if you stop the cluster (optional manual check; do not leave the cluster down).
|
||||
**Step 6:** Commit: `test(e2e): add CliRunner + ClusterAvailability probe`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `CliRunner` typed fixture helpers
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3, Task 13, Task 14
|
||||
**Blocked by:** Task 1
|
||||
|
||||
**Files:**
|
||||
- Modify: `TESTS/Cluster/CliRunner.cs`
|
||||
- Test: `TESTS/Cluster/CliRunnerHelpersTests.cs`
|
||||
|
||||
**Step 1 — Failing test (round-trips a real template via the CLI):**
|
||||
```csharp
|
||||
[SkippableFact]
|
||||
public async Task CreateThenDeleteTemplate_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var name = CliRunner.UniqueName("tmpl");
|
||||
int id = await CliRunner.CreateTemplateAsync(name);
|
||||
try
|
||||
{
|
||||
var ids = await CliRunner.ListTemplateIdsByNamePrefixAsync(name);
|
||||
Assert.Contains(id, ids);
|
||||
}
|
||||
finally { await CliRunner.DeleteTemplateAsync(id); }
|
||||
}
|
||||
```
|
||||
**Step 2:** Run → FAIL.
|
||||
|
||||
**Step 3 — Implement helpers** (each parses the JSON the CLI returns to extract the new integer `id`; inspect a live `template create` JSON shape first via `dotnet <dll> ... template create --name probe` then delete it, to confirm the id field name — likely `id` or `templateId`):
|
||||
- `static string UniqueName(string kind) => $"zztest-{kind}-{Guid.NewGuid():N}"[..N]` (keep it short).
|
||||
- `Task<int> CreateTemplateAsync(string name, string? description=null)`
|
||||
- `Task AddAttributeAsync(int templateId, string name, string dataType="Double")`
|
||||
- `Task<int> CreateInstanceAsync(string name, int templateId, int siteId, int? areaId=null)`
|
||||
- `Task<int> CreateAreaAsync(int siteId, string name)`
|
||||
- `Task<int> ResolveSiteIdAsync(string identifier)` (run `site list`, find by `identifier`/`siteIdentifier` == "site-a", return its `id`)
|
||||
- `Task DeployInstanceAsync/EnableInstanceAsync/DisableInstanceAsync/DeleteInstanceAsync(int id)`
|
||||
- `Task DeleteTemplateAsync(int id)` / `DeleteAreaAsync(int id)` / `DeleteSiteAsync(int id)`
|
||||
- `Task<IReadOnlyList<int>> ListTemplateIdsByNamePrefixAsync(string prefix)` (run `template list`, filter `name` StartsWith prefix)
|
||||
- `Task BundleExportAsync(string outputPath, int templateId, string passphrase, string sourceEnvironment)` (use `bundle export --output <p> --passphrase <pp> --source-environment <env>` plus whatever selector flag scopes to one template — inspect `bundle export --help`; if it only supports `--all`/`--include-dependencies`, export `--all` into a throwaway-clean cluster slice is unsafe, so prefer a template selector flag; if none exists, note it and fall back to `--all` with the understanding the bundle may carry more — see Task 9 risk note).
|
||||
- All helpers swallow nothing on create (must throw on failure) but `Delete*` helpers swallow exceptions (best-effort teardown).
|
||||
|
||||
**Step 4:** Run → PASS. **Step 5:** Commit: `test(e2e): add CliRunner typed fixture helpers`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Standardize skip policy + skip-count logging (rec 7)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 1, Task 2, Task 13, Task 14
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Cluster/SkipLogCollectionFixture.cs` (or extend `PlaywrightFixture`)
|
||||
- Modify: `TESTS/Audit/AuditLogPageTests.cs` (convert the 11 throw-on-unavailable guards to `SkippableFact` + `Skip.IfNot`)
|
||||
|
||||
**Step 1:** In `AuditLogPageTests.cs`, replace each `[Fact]` whose body throws `InvalidOperationException` when `AuditDataSeeder.IsAvailableAsync()` is false with `[SkippableFact]` + `Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);` (mirror `AuditGridColumnTests`). Keep all assertions otherwise unchanged.
|
||||
**Step 2:** Add skip logging: implement `IDisposable`/`IAsyncLifetime` on a collection fixture (or in `PlaywrightFixture.DisposeAsync`) that, at assembly teardown, writes one line to the console:
|
||||
`Console.WriteLine($"[E2E] Skipped {SkipTracker.Count} cluster/DB-dependent tests — {ClusterAvailability.SkipReason}");` where `SkipTracker` is a static counter incremented by a tiny helper `SkipUnlessAvailable()` that the tests call. (Simplest: a `static int` on `ClusterAvailability` bumped inside `IsAvailableAsync` when it returns false; log it in fixture dispose.)
|
||||
**Step 3:** Run `dotnet test --filter AuditLogPageTests` with the cluster UP → all pass (0 skipped, log line shows 0). **Step 4:** Commit: `test(e2e): standardize DB-dependent tests on SkippableFact + skip logging`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `DeploymentFixture` (ephemeral template + area on real site-a)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8, Task 9 (different files)
|
||||
**Blocked by:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Deployment/DeploymentFixture.cs`
|
||||
|
||||
**Why site-a (not a throwaway site):** Deploy/Enable/Disable relay to the owning site over ClusterClient. An *unknown* site identifier has no registered ClusterClient, so the relay resolves only on a slow 10s Ask timeout and never produces a fast toast (see the explicit comment in `SiteCallsPageTests.RetryClickThrough...`). So the ephemeral instance must live on a **real, running** site — use `site-a`.
|
||||
|
||||
**Step 1:** Implement an `IAsyncLifetime` fixture (NOT a collection fixture — instantiate per test class so the shared template/area are created once for the deployment suite):
|
||||
- `InitializeAsync`: if `!await ClusterAvailability.IsAvailableAsync()` return (tests will skip). Else resolve `SiteAId = await CliRunner.ResolveSiteIdAsync("site-a")`; create `TemplateId = await CliRunner.CreateTemplateAsync(UniqueName("deploytmpl"))`; `await CliRunner.AddAttributeAsync(TemplateId, "Value", "Double")` (so it validates); `AreaId = await CliRunner.CreateAreaAsync(SiteAId, UniqueName("area"))`.
|
||||
- `Task<int> CreateInstanceAsync()` → `CliRunner.CreateInstanceAsync(UniqueName("inst"), TemplateId, SiteAId, AreaId)`.
|
||||
- `DisposeAsync`: best-effort delete the area + template (instances are deleted by the tests; also list+delete any leftover `zztest-inst-*` on site-a as a safety net).
|
||||
**Step 2:** No standalone test — it's exercised by Tasks 5–7. Build only (`dotnet build TESTS`). **Step 3:** Commit: `test(e2e): add DeploymentFixture (ephemeral instance on site-a)`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `DeploymentActionTests.Deploy`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (shares DeploymentFixture file is read-only; but tests in same class serialize)
|
||||
**Blocked by:** Task 4
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Deployment/DeploymentActionTests.cs`
|
||||
|
||||
**Step 1 — Test (TDD: write, watch it fail to compile, then it should pass against the live cluster):**
|
||||
```csharp
|
||||
[Collection("Playwright")]
|
||||
public class DeploymentActionTests : IClassFixture<DeploymentFixture>, IAsyncLifetime
|
||||
{
|
||||
private readonly PlaywrightFixture _pw; // via collection
|
||||
private readonly DeploymentFixture _cluster;
|
||||
public DeploymentActionTests(PlaywrightFixture pw, DeploymentFixture cluster){ _pw=pw; _cluster=cluster; }
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Deploy_Instance_ShowsOutcomeToast()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
int instanceId = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/topology");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
// Expand to the instance; locate its tree row by the unique name, right-click → Deploy.
|
||||
var row = page.Locator("li[role=treeitem] .tv-row", new(){ HasText = /* instance unique name */ });
|
||||
await ExpandToInstanceAsync(page, row); // helper: click ancestor toggles until visible
|
||||
await row.ClickAsync(new(){ Button = MouseButton.Right });
|
||||
await page.Locator(".dropdown-menu.show button.dropdown-item", new(){ HasText="Deploy" }).ClickAsync();
|
||||
// Deploy has NO confirm dialog → outcome toast directly.
|
||||
var toast = page.Locator(".toast");
|
||||
await Assertions.Expect(toast).ToBeVisibleAsync(new(){ Timeout = 15_000 });
|
||||
Assert.Equal(1, await toast.CountAsync());
|
||||
}
|
||||
finally { await CliRunner.DeleteInstanceAsync(instanceId); }
|
||||
}
|
||||
}
|
||||
```
|
||||
- Note: capture the instance unique name from `CreateInstanceAsync` (extend the fixture to return `(int id, string name)` so the test can locate the row). Adjust the helper accordingly.
|
||||
- `ExpandToInstanceAsync`: click the site-a node then the area node toggles (`.tv-row` chevrons) so the instance row renders; or use the page's `Expand` toolbar button (`button[aria-label="Expand all areas"]`) — simpler: click Expand, then locate the row.
|
||||
|
||||
**Step 2:** Run `dotnet test --filter Deploy_Instance` → PASS (toast appears; outcome may be Deployed or a fast error — tolerant). **Step 3:** Commit: `test(e2e): cover Topology Deploy action`.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `DeploymentActionTests.Enable` + `Disable`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (same file as Task 5 — sequential)
|
||||
**Blocked by:** Task 5
|
||||
|
||||
**Files:** Modify `TESTS/Deployment/DeploymentActionTests.cs`
|
||||
|
||||
- `Enable_Instance_ShowsOutcomeToast`: deploy first (Enable only shows when state==Disabled; flow depends on cluster state). Simpler and outcome-tolerant: right-click → if `Enable` item present click it, else click `Disable`; assert toast. To keep it deterministic, target **Disable**: after a Deploy the instance is `Enabled`, so `Disable` is offered. `Disable` opens a confirm dialog (danger → `.modal-footer .btn-danger` text `Delete`); click it, then assert toast.
|
||||
- `Disable_Instance_ShowsOutcomeToast`: deploy → right-click → `Disable` → confirm `.modal-footer .btn-danger` → assert single `.toast`.
|
||||
- Each test creates its own instance via `_cluster.CreateInstanceAsync()` and deletes it in `finally`.
|
||||
|
||||
**Step:** Run filter → PASS. Commit: `test(e2e): cover Topology Enable/Disable actions`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: `DeploymentActionTests.Delete`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (same file)
|
||||
**Blocked by:** Task 6
|
||||
|
||||
**Files:** Modify `TESTS/Deployment/DeploymentActionTests.cs`
|
||||
|
||||
- `Delete_Instance_RemovesFromTree`: create instance → Topology → locate row → right-click → `Delete` (`button.dropdown-item.text-danger`) → confirm `.modal-footer .btn-danger` (text `Delete`) → assert the row with that instance name is no longer visible (`await Assertions.Expect(row).ToHaveCountAsync(0)` within a timeout). No CLI delete needed in `finally` (the UI deleted it) but call `CliRunner.DeleteInstanceAsync` best-effort anyway in case the UI delete failed.
|
||||
|
||||
**Step:** Run → PASS. Commit: `test(e2e): cover Topology Delete action`.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: NotificationOutbox retry/discard + ParkedMessages query (rec 2)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 4, Task 9
|
||||
**Blocked by:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Notifications/NotificationDataSeeder.cs` (mirror `SiteCallDataSeeder`; INSERT a `Parked` row into the central `Notifications` table — read the Notifications EF entity at `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/.../Notification*.cs` + its migration to get exact columns; tag a unique field, e.g. a `zztest-*` recipient/list/target, for teardown).
|
||||
- Create: `TESTS/Notifications/NotificationActionTests.cs`
|
||||
- Create: `TESTS/Monitoring/ParkedMessagesTests.cs`
|
||||
|
||||
**Notification retry/discard (seedable — central singleton, fast):**
|
||||
- `Retry_ParkedNotification_ShowsOutcomeToast`: seed a `Parked` Notifications row with a unique marker → `/notifications/report` → filter to status Parked / search the marker → Query → find row → click row's `Retry` (`button.btn-outline-success`) → confirm dialog non-danger `.modal-footer .btn-primary` (text `Confirm`) → assert single `.toast`. Teardown: delete the seeded row.
|
||||
- `Discard_ParkedNotification_ShowsOutcomeToast`: same but `Discard` (`button.btn-outline-danger`) → confirm danger `.modal-footer .btn-danger` (text `Delete`) → toast.
|
||||
|
||||
**ParkedMessages query (NOT seedable — site SQLite over Akka Ask; deterministic render test):**
|
||||
- `ParkedMessages_QueryForSite_RendersWithoutHang`: `/monitoring/parked-messages` → select `#pm-filter-site` = `site-a` (option value = SiteIdentifier) → click `Query` (`button.btn-primary` text `Query`) → assert that within a timeout EITHER `table.parked-table` OR an empty-state element renders (i.e. the singleton-backed query resolved, not hung). This guards the Akka-Ask hang class without needing to seed site state. (Document in a comment why retry/discard isn't exercised here: parked S&F messages live in site SQLite and can't be seeded from central SQL.)
|
||||
|
||||
**Steps:** TDD each (write → fails to compile → passes live). Run `dotnet test --filter "NotificationActionTests|ParkedMessagesTests"` → PASS. Commit: `test(e2e): cover notification retry/discard + parked-messages query`.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Transport Import round-trip (rec 3)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 4, Task 8
|
||||
**Blocked by:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Transport/TransportImportTests.cs`
|
||||
|
||||
**De-risk file upload FIRST (Step 0):** Playwright `SetInputFilesAsync(hostPath)` over a remote browser connection streams the host file to the container — verify with a 3-line spike before building the rest (upload the exported bundle to `#bundle-input`, assert `[data-testid="manifest-summary"]` appears). If streaming fails, fall back to writing the bundle to a path the container can read (a shared mount) and note it.
|
||||
|
||||
**Test `ImportSyntheticBundle_AppliesAndShowsAuditDrillIn`:**
|
||||
1. `Skip.IfNot(...cluster...)`. `var env = CliRunner.UniqueName("env");` `var tmplName = CliRunner.UniqueName("imp");`
|
||||
2. CLI: `int tmplId = await CliRunner.CreateTemplateAsync(tmplName); await CliRunner.AddAttributeAsync(tmplId,"Value","Double");`
|
||||
3. CLI: `var bundlePath = Path.Combine(Path.GetTempPath(), $"{tmplName}.scadabundle"); await CliRunner.BundleExportAsync(bundlePath, tmplId, passphrase:"pw-"+env, sourceEnvironment:env);`
|
||||
4. **Delete the source template via CLI** so the import sees it as a NEW item (not Modified): `await CliRunner.DeleteTemplateAsync(tmplId);`
|
||||
5. UI: `/design/transport/import` → `SetInputFiles("#bundle-input", bundlePath)` → Next → `#import-passphrase` = `"pw-"+env` → `Unlock` → diff step (new template renders `Add`, no blockers) → Next → `#confirm-env` type `env` (must equal `Manifest.SourceEnvironment`) → `Apply Import` (`button.btn-danger`).
|
||||
6. Assert `[data-testid="result-summary"]` text contains `Import complete.` and the `Audit trail →` link href starts `/audit/configuration?bundleImportId=`.
|
||||
7. `finally`: `foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(tmplName)) await CliRunner.DeleteTemplateAsync(id);` and `File.Delete(bundlePath)` best-effort.
|
||||
|
||||
**Step:** Run `dotnet test --filter TransportImportTests` → PASS; verify no residual `zztest-imp-*` templates (`template list`). Commit: `test(e2e): cover Transport Import apply round-trip`.
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Site CRUD round-trip (rec 6)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 11, Task 12, Task 13, Task 14
|
||||
**Blocked by:** Task 2 (safety-net teardown only)
|
||||
|
||||
**Files:** Modify `TESTS/SiteCrudTests.cs`
|
||||
|
||||
- `CreateEditDelete_Site_RoundTrips`: `/admin/sites/create` → fill (by `<label>`-anchored inputs / `h6 Node A/B`-scoped placeholders): Name=`zztest-site-<hex>`, Identifier=`zztest-<hex>`, Description, and one Node A Akka + gRPC address (any well-formed value — `akka.tcp://scadabridge@zz:5000/user/site-communication`, `http://zz:8083`) → `Save` → assert the new card (`.card` with `.card-title` text == name) appears on `/admin/sites` → Edit → change Description → Save → kebab `⋮` → `Delete` → confirm `.modal-footer .btn-danger` → assert the card is gone. `finally`: best-effort `CliRunner` delete by resolving the site id via `site list` on the identifier.
|
||||
|
||||
**Step:** Run → PASS. Commit: `test(e2e): cover Site create/edit/delete round-trip`.
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Template CRUD round-trip (rec 6)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10, Task 12, Task 13, Task 14
|
||||
**Blocked by:** Task 2 (safety-net teardown)
|
||||
|
||||
**Files:** Create `TESTS/Design/TemplateCrudTests.cs`
|
||||
|
||||
- `CreateAddAttributeDelete_Template_RoundTrips`: `/design/templates/create` → Name=`zztest-tmpl-<hex>` → `Create` (`button.btn-success`) → lands on `/design/templates/{id}` → Attributes tab → `Add Attribute` → modal: Name=`Val`, Data Type select → `Add` (submit text is `Add` for new) → assert the attribute row appears → header `Delete` (`button.btn-outline-danger`) → confirm `.modal-footer .btn-danger` → assert redirect to `/design/templates` and the node is gone. `finally`: `ListTemplateIdsByNamePrefixAsync("zztest-tmpl-")`→delete.
|
||||
|
||||
**Step:** Run → PASS. Commit: `test(e2e): cover Template create/add-attribute/delete round-trip`.
|
||||
|
||||
---
|
||||
|
||||
## Task 12: LDAP mapping CRUD round-trip (rec 6)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 10, Task 11, Task 13, Task 14
|
||||
**Blocked by:** none (pure UI; safety-net via direct SQL optional)
|
||||
|
||||
**Files:** Create `TESTS/Admin/LdapMappingCrudTests.cs`
|
||||
|
||||
- `CreateEditDelete_LdapMapping_RoundTrips`: `/admin/ldap-mappings/create` → LDAP Group Name (label-anchored input)=`zztest-grp-<hex>` → Role select (`.form-select`) = `Designer` → `Save` → assert the row (`tr` whose group-name `td` == the group) appears on `/admin/ldap-mappings` → Edit → change Role to `Viewer` → Save → kebab `⋮` → `Delete`. **Important:** LdapMappings Delete has **NO confirm dialog** (the page doesn't inject `IDialogService`) — clicking `Delete` removes the row immediately. Assert the row disappears (do NOT wait for a modal). `finally`: best-effort delete (UI did it; optional SQL net keyed on the unique group name).
|
||||
|
||||
**Step:** Run → PASS. Commit: `test(e2e): cover LDAP mapping create/edit/delete round-trip`.
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Navigation render-assertion hardening (rec 4)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10, Task 11, Task 12, Task 14
|
||||
**Blocked by:** none
|
||||
|
||||
**Files:** Modify `TESTS/NavigationTests.cs`
|
||||
|
||||
- Add a per-route expected-heading map (route → expected `h1/h4/h5` text), e.g. `/admin/sites`→"Site Management", `/notifications/report`→"Notification Report", `/design/templates`→"Templates", `/deployment/topology`→(its heading), `/monitoring/health`→(its heading), etc. (Read each target page's heading to fill the map exactly.)
|
||||
- In the shared `ClickNavAndWait` helper (or each Theory), after asserting the URL, also `await Assertions.Expect(page.Locator("h1,h4,h5", new(){ HasText = expectedHeading })).ToBeVisibleAsync()`. This catches a route that 500s after navigation.
|
||||
- Keep all existing Theory cases; just strengthen the assertion. No new test methods.
|
||||
|
||||
**Step:** Run `dotnet test --filter NavigationTests` → PASS (all routes render). Commit: `test(e2e): assert destination renders, not just URL, in nav tests`.
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Health KPI load test (rec 5)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 10, Task 11, Task 12, Task 13
|
||||
**Blocked by:** none
|
||||
|
||||
**Files:** Create `TESTS/Monitoring/HealthDashboardTests.cs`
|
||||
|
||||
- `KpiTiles_ResolveToValues_NotDegradePlaceholder`: `/monitoring/health` → wait for load → assert the three KPI groups resolved to values (not the `—` em-dash degrade):
|
||||
- Notification-Outbox (inlined, no data-test): `.card` whose `.text-muted` label is `Queue Depth` → its sibling `h3` text matches `^\d+$` (and `!= "—"`). Repeat for `Stuck`, `Parked`.
|
||||
- Audit tiles: `[data-test='audit-kpi-volume']` (+ `error-rate`, `backlog`) text is non-empty/non-`—`.
|
||||
- Site-Call tiles: `[data-test='site-call-kpi-buffered']` (+ `stuck`, `parked`).
|
||||
- Use a generous per-tile wait (the page polls/loads async). This is the direct regression guard for the singleton-hang class.
|
||||
|
||||
**Step:** Run → PASS. Commit: `test(e2e): assert Health KPI tiles resolve (singleton-hang guard)`.
|
||||
|
||||
---
|
||||
|
||||
## Task 15: Full-suite verification + no-residue check
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (final)
|
||||
**Blocked by:** all
|
||||
|
||||
**Steps:**
|
||||
1. `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` → expect **0 failed**; note passed/skipped counts and confirm the skip-summary log line prints.
|
||||
2. `dotnet <CLI.dll> ... site list` / `template list` / `instance list --site-id <site-a>` → grep for `zztest-` → expect **none** (clean teardown). If any leak, fix the offending test's `finally`.
|
||||
3. Commit any cleanup. Final message summarizing new coverage (count of added tests, the recs covered).
|
||||
|
||||
---
|
||||
|
||||
## Notes / open risks (surface, don't silently absorb)
|
||||
- **CLI JSON id field names** (Task 2): confirm the actual field (`id` vs `templateId`/`instanceId`/`siteId`) from a live `create` response before finalizing the parsers.
|
||||
- **`bundle export` single-template selection** (Task 2/9): verify the CLI supports scoping the export to one template; if it only does `--all`, adjust Task 9 (export a minimal cluster or accept a larger bundle and assert only the zztest template landed).
|
||||
- **Remote file upload** (Task 9 Step 0): de-risk `SetInputFiles` over the WS-connected browser before building the full import test.
|
||||
- **Deploy side-effects on site-a** (Tasks 5–7): ephemeral instances are deployed to the real site-a and deleted in `finally`; the no-residue check (Task 15) is the backstop.
|
||||
- **Outcome tolerance:** deploy/enable/disable/retry/discard assert *a* toast, not a specific outcome — the live cluster decides Deployed/Applied vs SiteUnreachable/NotParked. This is intentional (matches the existing SiteCalls relay test).
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-05-playwright-coverage-expansion.md",
|
||||
"lastUpdated": "2026-06-05T00:00:00Z",
|
||||
"nativeTaskIdBase": 57,
|
||||
"tasks": [
|
||||
{"id": 0, "nativeId": 57, "subject": "Task 0: Add CLI ProjectReference to test project", "status": "pending"},
|
||||
{"id": 1, "nativeId": 58, "subject": "Task 1: CliRunner core + ClusterAvailability probe", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "nativeId": 59, "subject": "Task 2: CliRunner typed fixture helpers", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeId": 60, "subject": "Task 3: Standardize skip policy + skip-count logging", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "nativeId": 61, "subject": "Task 4: DeploymentFixture (ephemeral instance on site-a)", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 5, "nativeId": 62, "subject": "Task 5: DeploymentActionTests.Deploy", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "nativeId": 63, "subject": "Task 6: DeploymentActionTests.Enable + Disable", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "nativeId": 64, "subject": "Task 7: DeploymentActionTests.Delete", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "nativeId": 65, "subject": "Task 8: Notification retry/discard + ParkedMessages query", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 9, "nativeId": 66, "subject": "Task 9: Transport Import round-trip", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 10, "nativeId": 67, "subject": "Task 10: Site CRUD round-trip", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 11, "nativeId": 68, "subject": "Task 11: Template CRUD round-trip", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 12, "nativeId": 69, "subject": "Task 12: LDAP mapping CRUD round-trip", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 13, "nativeId": 70, "subject": "Task 13: Navigation render-assertion hardening", "status": "pending"},
|
||||
{"id": 14, "nativeId": 71, "subject": "Task 14: Health KPI load test", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 15, "nativeId": 72, "subject": "Task 15: Full-suite verification + no-residue check", "status": "pending", "blockedBy": [3, 7, 8, 9, 10, 11, 12, 13, 14]}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user