Compare commits
23 Commits
4a993d76da
...
1eece71c76
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eece71c76 | |||
| c6b682c82f | |||
| 8f63ef08eb | |||
| 037184f213 | |||
| 0b71712ee1 | |||
| 6975988ab4 | |||
| 6523499ddb | |||
| 90ef6e8dc5 | |||
| 1ecce58437 | |||
| 8e65fc51e5 | |||
| e2a6453fb6 | |||
| 6861f5c14c | |||
| 22a440bddf | |||
| 4cfe950232 | |||
| 1536cdb884 | |||
| 3b1f76b7df | |||
| d03aa3c556 | |||
| 9c36036f2a | |||
| b48741f903 | |||
| 7bc40b96db | |||
| d61c9212d6 | |||
| c7ab17cda5 | |||
| e5bd8d9707 |
@@ -0,0 +1,539 @@
|
||||
# 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 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.
|
||||
|
||||
```csharp
|
||||
// ── 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 `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`):
|
||||
|
||||
```csharp
|
||||
[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.**
|
||||
```bash
|
||||
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.)
|
||||
```csharp
|
||||
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):
|
||||
```csharp
|
||||
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 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`: 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:
|
||||
```csharp
|
||||
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:
|
||||
```csharp
|
||||
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):
|
||||
```sql
|
||||
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 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 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_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:**
|
||||
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 minimal` → **0 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 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).
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-06-playwright-coverage-fill-wave3.md",
|
||||
"lastUpdated": "2026-06-06T00:00:00Z",
|
||||
"nativeTaskIdBase": 99,
|
||||
"status": "completed",
|
||||
"tasks": [
|
||||
{"id": 0, "nativeId": 99, "subject": "Task 0: CLI helpers (ExternalSystem/NotificationList/SharedScript) + round-trip tests", "status": "completed"},
|
||||
{"id": 1, "nativeId": 100, "subject": "Task 1: NotificationKpisTests (read-only render + tiles + refresh)", "status": "completed"},
|
||||
{"id": 2, "nativeId": 101, "subject": "Task 2: NotificationListCrudTests (create + recipients + delete)", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 3, "nativeId": 102, "subject": "Task 3: SmtpConfigTests (validation gate + cancel + tolerant save)", "status": "completed"},
|
||||
{"id": 4, "nativeId": 103, "subject": "Task 4: ExternalSystemCrudTests (UI create + delete)", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 5, "nativeId": 104, "subject": "Task 5: DataConnectionCrudTests (CLI-create + tree delete + gating)", "status": "completed"},
|
||||
{"id": 6, "nativeId": 105, "subject": "Task 6: SharedScriptCrudTests (CLI-create + card delete + form render)", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 7, "nativeId": 106, "subject": "Task 7: ApiMethodFormTests (validation + visibility + delete)", "status": "completed"},
|
||||
{"id": 8, "nativeId": 107, "subject": "Task 8: EventLogsTests (render + search-gating + tolerant query)", "status": "completed"},
|
||||
{"id": 9, "nativeId": 108, "subject": "Task 9: ConfigAuditDataSeeder (AuditLogEntries direct-SQL seeder)", "status": "completed"},
|
||||
{"id": 10, "nativeId": 109, "subject": "Task 10: AuditConfigurationTests part 1 (render/search/pagination)", "status": "completed", "blockedBy": [9]},
|
||||
{"id": 11, "nativeId": 110, "subject": "Task 11: AuditConfigurationTests part 2 (modal/copy/bundle chip)", "status": "completed", "blockedBy": [10]},
|
||||
{"id": 12, "nativeId": 111, "subject": "Task 12: Wave 3 verification + residue check", "status": "completed", "blockedBy": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]}
|
||||
]
|
||||
}
|
||||
@@ -61,9 +61,28 @@ public sealed class ApiKeyCrudTests : IClassFixture<ApiSurfaceFixture>
|
||||
var nameInput = page.Locator("input[type='text'].form-control.form-control-sm").First;
|
||||
await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
var saveButton = page.GetByRole(AriaRole.Button, new() { Name = "Save" });
|
||||
|
||||
// Same under-load hydration race as Create_NoMethods (see that test): this page is
|
||||
// InteractiveServer with prerendering, so the form renders (and is "visible") before
|
||||
// blazor.web.js hydrates the component and attaches its @bind onchange / @onchange
|
||||
// handlers over the SignalR circuit. Under full-suite load that hydration lands late,
|
||||
// so a name fill / checkbox check dispatched against the not-yet-live circuit is
|
||||
// silently dropped — SaveKey then short-circuits on the empty _formName (or empty
|
||||
// method set) and no key is created, so the token panel never appears.
|
||||
//
|
||||
// PROVE the circuit is interactive with an observable server round-trip first: clicking
|
||||
// Save on the empty form forces SaveKey to run and render "Name is required." (harmless,
|
||||
// creates nothing). Once that message appears the component is hydrated and its handlers
|
||||
// are live, so the subsequent name fill + checkbox check are guaranteed to round-trip.
|
||||
await saveButton.ClickAsync();
|
||||
await Assertions.Expect(page.Locator("div.text-danger.small"))
|
||||
.ToContainTextAsync("Name is required.", new() { Timeout = 15_000 });
|
||||
|
||||
await nameInput.FillAsync(keyName);
|
||||
await nameInput.DispatchEventAsync("change");
|
||||
await page.Locator($"#method-access-{_api.MethodId}").CheckAsync();
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
await saveButton.ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator("[data-test='created-token']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
@@ -129,16 +148,35 @@ public sealed class ApiKeyCrudTests : IClassFixture<ApiSurfaceFixture>
|
||||
var nameInput = page.Locator("input[type='text'].form-control.form-control-sm").First;
|
||||
await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// Fill the name but leave ALL method checkboxes unchecked so only the method rule fires.
|
||||
// The name input uses Blazor @bind (onchange), which only commits on blur — blur the input
|
||||
// and let the change round-trip to the server (NetworkIdle) so _formName is populated before
|
||||
// Save, otherwise the name-required rule short-circuits first.
|
||||
await nameInput.FillAsync(CliRunner.UniqueName("apikey"));
|
||||
await nameInput.BlurAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
|
||||
var saveButton = page.GetByRole(AriaRole.Button, new() { Name = "Save" });
|
||||
var error = page.Locator("div.text-danger.small");
|
||||
|
||||
// ROOT CAUSE of the under-load flake: the page is InteractiveServer with prerendering,
|
||||
// so the name <input> renders (and is "visible") from the prerendered HTML BEFORE
|
||||
// blazor.web.js hydrates this component and attaches its @bind onchange handler over the
|
||||
// SignalR circuit. Under full-suite load (circuit pressure from the 4-context cap) that
|
||||
// hydration lands late, so a name FillAsync + change dispatched against the not-yet-live
|
||||
// circuit is silently dropped — _formName stays empty server-side and SaveKey short-
|
||||
// circuits to "Name is required." (the rule checked BEFORE the methods rule in
|
||||
// ApiKeyForm.SaveKey). In isolation the circuit hydrates fast enough that this never bites.
|
||||
//
|
||||
// Fix: PROVE the circuit is interactive with an observable server round-trip before we
|
||||
// rely on the name commit. Clicking Save with an empty name forces SaveKey to run on the
|
||||
// server and render the "Name is required." message — a circuit-driven re-render. Waiting
|
||||
// for that message guarantees the component is hydrated and its onchange handler is live.
|
||||
// Only THEN do we fill the name + dispatch change (now guaranteed to round-trip) and Save
|
||||
// again, so _formName is committed and the methods rule — not the name rule — fires.
|
||||
await saveButton.ClickAsync();
|
||||
await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(error).ToContainTextAsync("Name is required.");
|
||||
|
||||
// Circuit is now provably interactive. Commit the name as its own discrete change message
|
||||
// (ordered before the Save click over the in-order SignalR channel), leaving ALL method
|
||||
// checkboxes unchecked so only the method rule can fire.
|
||||
await nameInput.FillAsync(CliRunner.UniqueName("apikey"));
|
||||
await nameInput.DispatchEventAsync("change");
|
||||
await saveButton.ClickAsync();
|
||||
|
||||
await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(error).ToContainTextAsync("Select at least one API method for this key.");
|
||||
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the Configuration Audit Log page
|
||||
/// (<c>/audit/configuration</c>, <c>OperationalAudit</c> policy — the
|
||||
/// <c>multi-role</c> test user carries Admin + Viewer so it can reach the page).
|
||||
/// The page reads the central <c>AuditLogEntries</c> table; each fact seeds its
|
||||
/// own rows via <see cref="ConfigAuditDataSeeder"/> under a unique per-test
|
||||
/// <c>marker</c> (stamped as <c>EntityType</c>), filters the grid by that marker
|
||||
/// so the <c>(N total)</c> pagination footer is deterministic regardless of the
|
||||
/// other rows the live cluster produces, then best-effort deletes the marker's
|
||||
/// rows in <c>finally</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// This part (Wave 3, Task 10) covers render / search-narrows / pagination. The
|
||||
/// class is intentionally structured so Task 11 can APPEND modal/copy/chip facts
|
||||
/// to it without restructuring: shared constants (the DB-unavailable skip reason,
|
||||
/// the page route) and the seeded-test gate idiom live here once.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The seeded tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
|
||||
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
|
||||
/// matching the established <see cref="AuditGridColumnTests"/> idiom.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class AuditConfigurationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Route to the Configuration Audit Log page. The results grid only renders
|
||||
/// after a Search (a bare visit, without a <c>?bundleImportId=</c> query param,
|
||||
/// does not auto-fetch), so each fact issues a marker-scoped Search.
|
||||
/// </summary>
|
||||
private const string ConfigAuditUrl = "/audit/configuration";
|
||||
|
||||
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
|
||||
private const string DbUnavailableSkipReason =
|
||||
"AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
|
||||
"or set SCADABRIDGE_PLAYWRIGHT_DB to a reachable connection string.";
|
||||
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public AuditConfigurationTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a fresh per-test marker: a <c>zzCfgAudit-</c> prefix plus a short
|
||||
/// GUID slice. Stamped as <c>EntityType</c> on every seeded row so a UI filter
|
||||
/// on Entity Type isolates exactly this run's rows (deterministic totals) and
|
||||
/// the <c>finally</c> cleanup never touches cluster-produced rows.
|
||||
/// </summary>
|
||||
private static string NewMarker() => "zzCfgAudit-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Grid_RendersAndSearchNarrows()
|
||||
{
|
||||
Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var marker = NewMarker();
|
||||
try
|
||||
{
|
||||
// 3 rows, all EntityType = marker, no bundle rows → exactly 3 match.
|
||||
await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 3, bundleRows: 0);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The page renders its heading and filter controls. The results table
|
||||
// (the whole @if (_entries != null) block) does NOT render until a
|
||||
// Search is issued — the page only auto-fetches when arriving with a
|
||||
// ?bundleImportId= query param, which a bare visit lacks — so the
|
||||
// Entity Type filter is present and ready to drive the first query.
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Configuration Audit Log')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("#audit-filter-entity-type")).ToBeVisibleAsync();
|
||||
|
||||
// Filter by the unique marker so only this run's 3 rows remain, then
|
||||
// Search to load the grid.
|
||||
await page.Locator("#audit-filter-entity-type").FillAsync(marker);
|
||||
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Seeded rows are isolated: a known row is visible AND the footer total
|
||||
// is exactly 3 (filtering by the unique marker makes this deterministic
|
||||
// regardless of the other rows the live cluster holds).
|
||||
await Assertions.Expect(page.Locator($"tr:has-text('{marker}-0')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("span:has-text('(3 total)')")).ToBeVisibleAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Pagination_PrevDisabledOnPage1_NextPaginates()
|
||||
{
|
||||
Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var marker = NewMarker();
|
||||
try
|
||||
{
|
||||
// 55 rows → 2 pages at 50/page.
|
||||
await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 55, bundleRows: 0);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Filter by the marker so the totals/page-count reflect only this run.
|
||||
await page.Locator("#audit-filter-entity-type").FillAsync(marker);
|
||||
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Page 1 of 2: Previous disabled, Next enabled.
|
||||
await Assertions.Expect(page.Locator("span:has-text('Page 1 of 2')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("span:has-text('(55 total)')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Previous')")).ToBeDisabledAsync();
|
||||
await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')")).ToBeEnabledAsync();
|
||||
|
||||
// Advance to the last page: Previous enabled, Next disabled.
|
||||
await page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("span:has-text('Page 2 of 2')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Previous')")).ToBeEnabledAsync();
|
||||
await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')")).ToBeDisabledAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task LargeState_OpensAndClosesModal()
|
||||
{
|
||||
Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var marker = NewMarker();
|
||||
try
|
||||
{
|
||||
// 1 bulk row, no bundle rows. The seeder gives bulk row index 0 a
|
||||
// > 1024-char AfterStateJson, so its State cell renders the large-state
|
||||
// "View in modal" button (small-state rows show an inline View/Hide
|
||||
// toggle instead, which we are not exercising here).
|
||||
await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 1, bundleRows: 0);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Filter by the unique marker and Search so only this run's single
|
||||
// large-state row populates the grid.
|
||||
await page.Locator("#audit-filter-entity-type").FillAsync(marker);
|
||||
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Open the modal from the seeded large-state row. The marker is unique,
|
||||
// so a page-wide "View in modal" locator resolves to exactly this row.
|
||||
await page.Locator("button.btn-outline-info.btn-sm:has-text('View in modal')").ClickAsync();
|
||||
|
||||
// The modal renders with a title like "Audit entry {id} — {EntityType} state".
|
||||
await Assertions.Expect(page.Locator("div.modal.show .modal-title")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("div.modal.show .modal-title")).ToContainTextAsync("state");
|
||||
|
||||
// Closing via the footer Close button removes the modal from the DOM.
|
||||
await page.Locator(".modal.show button.btn-outline-secondary.btn-sm:has-text('Close')").ClickAsync();
|
||||
await Assertions.Expect(page.Locator("div.modal.show")).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CopyEntityId_ShowsToast()
|
||||
{
|
||||
Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var marker = NewMarker();
|
||||
try
|
||||
{
|
||||
// 1 bulk row (non-empty EntityId) so the row renders the 📋 copy button.
|
||||
await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 1, bundleRows: 0);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.Locator("#audit-filter-entity-type").FillAsync(marker);
|
||||
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Click the copy-entity-ID button on the (only) seeded row.
|
||||
await page.Locator("button[aria-label^='Copy entity ID']").First.ClickAsync();
|
||||
|
||||
// Assert a toast appears, TOLERANTLY of which one. The cluster is served
|
||||
// over a non-secure, non-localhost http origin (http://scadabridge-traefik),
|
||||
// so navigator.clipboard is unavailable: CopyAsync's writeText throws and
|
||||
// the handler shows the ERROR toast "Copy failed." instead of the success
|
||||
// toast "Copied to clipboard.". Either path produces exactly one .toast, so
|
||||
// we assert the count only and deliberately do NOT assert the message text.
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task BundleImportId_DrillIn_ChipFiltersAndClears()
|
||||
{
|
||||
Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var marker = NewMarker();
|
||||
var bundleId = Guid.NewGuid();
|
||||
try
|
||||
{
|
||||
// 1 bulk row (EntityName "{marker}-0", BundleImportId NULL) + 2 bundle
|
||||
// rows (EntityName "{marker}-bundle-0/1", BundleImportId = bundleId), all
|
||||
// EntityType = marker so the finally cleanup removes every row.
|
||||
await ConfigAuditDataSeeder.SeedAsync(marker, bundleId, bulkCount: 1, bundleRows: 2);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
// Arriving WITH a ?bundleImportId= query param auto-loads the grid
|
||||
// pre-filtered (OnParametersSetAsync fires FetchPage because the param
|
||||
// differs from the initial null) — no Search click needed.
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/configuration?bundleImportId={bundleId}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The filter chip is shown for the active bundle import.
|
||||
await Assertions.Expect(page.Locator("span.badge.bg-primary:has-text('Filtered by Bundle Import:')")).ToBeVisibleAsync();
|
||||
|
||||
// Exactly the 2 bundle rows show (EntityName "{marker}-bundle-0/1"). The
|
||||
// bulk row (EntityName "{marker}-0", BundleImportId NULL) is filtered out.
|
||||
// The "{marker}-0" token is NOT a substring of "{marker}-bundle-0", so the
|
||||
// two locators distinguish the bundle rows from the bulk row unambiguously.
|
||||
await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = $"{marker}-bundle-" }))
|
||||
.ToHaveCountAsync(2, new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = $"{marker}-0" }))
|
||||
.ToHaveCountAsync(0);
|
||||
|
||||
// Clearing the filter removes the chip and navigates to the bare route;
|
||||
// because BundleImportId goes from the guid back to null, the page
|
||||
// re-fetches and reloads the grid with all entries (chip gone, rows ≥ 1).
|
||||
await page.Locator("button[aria-label='Clear Bundle Import filter']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("span.badge.bg-primary:has-text('Filtered by Bundle Import:')")).ToHaveCountAsync(0);
|
||||
await Assertions.Expect(page.Locator("tbody tr").First).ToBeVisibleAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Direct-SQL seeding helper for the Configuration Audit Log Playwright E2E tests
|
||||
/// (Wave 3). The <c>/audit/configuration</c> page reads the <c>AuditLogEntries</c>
|
||||
/// table (entity <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry"/>)
|
||||
/// via <c>CentralUiRepository</c> — a different table from the canonical
|
||||
/// <c>AuditLog</c> table that <see cref="AuditDataSeeder"/> writes — so this seeder
|
||||
/// targets <c>AuditLogEntries</c> specifically.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors <see cref="AuditDataSeeder"/>'s connection handling and best-effort
|
||||
/// cleanup idiom: it opens a <see cref="SqlConnection"/> to
|
||||
/// <see cref="PlaywrightDbConnection.ConnectionString"/> (the running Docker
|
||||
/// cluster's <c>ScadaBridgeConfig</c> DB), inserts its own rows at setup time, and
|
||||
/// best-effort deletes them at teardown.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Every seeded row carries <c>EntityType = marker</c> (a unique per-test prefix
|
||||
/// derived from the test name + a GUID), so a UI filter on Entity Type isolates
|
||||
/// exactly this run's rows for deterministic pagination at 50/page, and the
|
||||
/// teardown <c>DELETE</c> never touches rows the cluster itself produced. Column
|
||||
/// names/types are verified against <c>ScadaBridgeDbContextModelSnapshot</c>:
|
||||
/// <c>AuditLogEntries</c> with <c>Id</c> (int identity), <c>User</c>/<c>Action</c>/
|
||||
/// <c>EntityType</c>/<c>EntityId</c>/<c>EntityName</c> (nvarchar, required),
|
||||
/// <c>AfterStateJson</c> (nvarchar(max), nullable), <c>Timestamp</c>
|
||||
/// (datetimeoffset), <c>BundleImportId</c> (uniqueidentifier, nullable).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class ConfigAuditDataSeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Connection string for the running cluster's configuration DB.
|
||||
/// Delegates to <see cref="PlaywrightDbConnection.ConnectionString"/>.
|
||||
/// </summary>
|
||||
public static string ConnectionString => PlaywrightDbConnection.ConnectionString;
|
||||
|
||||
private const string InsertSql = @"
|
||||
INSERT INTO [AuditLogEntries]
|
||||
([User],[Action],[EntityType],[EntityId],[EntityName],[AfterStateJson],[Timestamp],[BundleImportId])
|
||||
VALUES (@user, @action, @entityType, @entityId, @entityName, @afterState, @ts, @bundleId);";
|
||||
|
||||
/// <summary>
|
||||
/// Probe whether the configuration DB is reachable. Tests gate their per-test
|
||||
/// setup on this; when the cluster is down the test fails with a clear
|
||||
/// "MSSQL unavailable" message instead of an opaque SqlException. Mirrors
|
||||
/// <see cref="AuditDataSeeder.IsAvailableAsync"/>.
|
||||
/// </summary>
|
||||
public static async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds rows into <c>AuditLogEntries</c>. Inserts <paramref name="bulkCount"/>
|
||||
/// bulk rows (all <c>EntityType = marker</c>, <c>BundleImportId = NULL</c>) plus
|
||||
/// <paramref name="bundleRows"/> bundle rows (also <c>EntityType = marker</c>,
|
||||
/// but <c>BundleImportId = bundleId</c>). Row index 0 of the bulk set carries a
|
||||
/// > 1024-char <c>AfterStateJson</c> to drive the large-state modal on the
|
||||
/// page; the rest carry a tiny JSON blob. Timestamps step back one second per
|
||||
/// row from "now" so rows are recent and ordered.
|
||||
/// </summary>
|
||||
/// <param name="marker">Unique per-test prefix; isolates this run's rows.</param>
|
||||
/// <param name="bundleId">Bundle import id stamped on the extra bundle rows.</param>
|
||||
/// <param name="bulkCount">Number of bulk rows (default 55 — spans two pages at 50/page).</param>
|
||||
/// <param name="bundleRows">Number of extra rows carrying the bundle import id (default 2).</param>
|
||||
public static async Task SeedAsync(string marker, Guid bundleId, int bulkCount = 55, int bundleRows = 2)
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
// Bulk rows: all EntityType = marker, BundleImportId NULL. Row 0 gets a
|
||||
// large AfterStateJson (> 1024 chars) to drive the large-state modal.
|
||||
for (var i = 0; i < bulkCount; i++)
|
||||
{
|
||||
var afterState = i == 0
|
||||
? "{\"blob\":\"" + new string('x', 1100) + "\"}"
|
||||
: "{\"k\":\"v\"}";
|
||||
|
||||
await InsertRowAsync(
|
||||
connection,
|
||||
user: marker + "-user",
|
||||
action: "Update",
|
||||
entityType: marker,
|
||||
entityId: marker + "-eid-" + i,
|
||||
entityName: marker + "-" + i,
|
||||
afterStateJson: afterState,
|
||||
timestamp: DateTimeOffset.UtcNow.AddSeconds(-i),
|
||||
bundleImportId: null);
|
||||
}
|
||||
|
||||
// Bundle rows: EntityType = marker (so DeleteByMarkerAsync cleans them too)
|
||||
// AND BundleImportId = bundleId, to drive the ?bundleImportId= chip drill-in.
|
||||
for (var i = 0; i < bundleRows; i++)
|
||||
{
|
||||
await InsertRowAsync(
|
||||
connection,
|
||||
user: marker + "-user",
|
||||
action: "Update",
|
||||
entityType: marker,
|
||||
entityId: marker + "-eid-bundle-" + i,
|
||||
entityName: marker + "-bundle-" + i,
|
||||
afterStateJson: "{\"k\":\"v\"}",
|
||||
timestamp: DateTimeOffset.UtcNow.AddSeconds(-(bulkCount + i)),
|
||||
bundleImportId: bundleId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort cleanup. Deletes every <c>AuditLogEntries</c> row whose
|
||||
/// <c>EntityType</c> equals <paramref name="marker"/>. Swallows all errors — the
|
||||
/// marker carries a GUID so the rows are unique to this test run and tests
|
||||
/// should not fail teardown. Mirrors
|
||||
/// <see cref="AuditDataSeeder.DeleteByTargetPrefixAsync"/>.
|
||||
/// </summary>
|
||||
public static async Task DeleteByMarkerAsync(string marker)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM [AuditLogEntries] WHERE [EntityType] = @marker";
|
||||
cmd.Parameters.AddWithValue("@marker", marker);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — the marker carries a GUID so the rows are unique to
|
||||
// this test run and won't collide on the next pass.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InsertRowAsync(
|
||||
SqlConnection connection,
|
||||
string user,
|
||||
string action,
|
||||
string entityType,
|
||||
string entityId,
|
||||
string entityName,
|
||||
string afterStateJson,
|
||||
DateTimeOffset timestamp,
|
||||
Guid? bundleImportId)
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = InsertSql;
|
||||
cmd.Parameters.AddWithValue("@user", user);
|
||||
cmd.Parameters.AddWithValue("@action", action);
|
||||
cmd.Parameters.AddWithValue("@entityType", entityType);
|
||||
cmd.Parameters.AddWithValue("@entityId", entityId);
|
||||
cmd.Parameters.AddWithValue("@entityName", entityName);
|
||||
cmd.Parameters.AddWithValue("@afterState", (object?)afterStateJson ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@ts", timestamp);
|
||||
cmd.Parameters.AddWithValue("@bundleId", (object?)bundleImportId ?? DBNull.Value);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
@@ -435,6 +435,85 @@ public static partial class CliRunner
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an external system via <c>external-system create</c> and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
/// <param name="name">External system name (typically from <see cref="UniqueName"/>).</param>
|
||||
/// <param name="endpointUrl">Endpoint base URL (defaults to an unreachable placeholder).</param>
|
||||
/// <param name="authType">Auth type token; one of <c>ApiKey</c> or <c>BasicAuth</c>.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The CLI failed, or the response did not carry an integer <c>id</c>.
|
||||
/// </exception>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a notification list via <c>notification create</c> and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
/// <param name="name">Notification list name (typically from <see cref="UniqueName"/>).</param>
|
||||
/// <param name="emails">Comma-separated recipient emails.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The CLI failed, or the response did not carry an integer <c>id</c>.
|
||||
/// </exception>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a shared script via <c>shared-script create</c> and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
/// <param name="name">Shared script name (typically from <see cref="UniqueName"/>).</param>
|
||||
/// <param name="code">Script body.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The CLI failed, or the response did not carry an integer <c>id</c>.
|
||||
/// </exception>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ids of all external systems whose <c>name</c> starts with
|
||||
/// <paramref name="prefix"/>, via <c>external-system list</c>. Used to delete an
|
||||
/// external system a test created through the UI (where the new id is never surfaced).
|
||||
/// </summary>
|
||||
/// <param name="prefix">Name prefix to filter by (ordinal comparison).</param>
|
||||
public static async Task<IReadOnlyList<int>> ListExternalSystemIdsByNamePrefixAsync(string prefix)
|
||||
{
|
||||
using var doc = await RunJsonAsync("external-system", "list");
|
||||
return IdsWhereNameStartsWith(doc, prefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ids of all notification lists whose <c>name</c> starts with
|
||||
/// <paramref name="prefix"/>, via <c>notification list</c>. Used to delete a
|
||||
/// notification list a test created through the UI (where the new id is never surfaced).
|
||||
/// </summary>
|
||||
/// <param name="prefix">Name prefix to filter by (ordinal comparison).</param>
|
||||
public static async Task<IReadOnlyList<int>> ListNotificationListIdsByNamePrefixAsync(string prefix)
|
||||
{
|
||||
using var doc = await RunJsonAsync("notification", "list");
|
||||
return IdsWhereNameStartsWith(doc, prefix);
|
||||
}
|
||||
|
||||
/// <summary>Best-effort delete of an external system via <c>external-system delete</c> for teardown.</summary>
|
||||
public static Task DeleteExternalSystemAsync(int id) => BestEffortAsync("external-system", "delete", id);
|
||||
|
||||
/// <summary>Best-effort delete of a notification list via <c>notification delete</c> for teardown.</summary>
|
||||
public static Task DeleteNotificationListAsync(int id) => BestEffortAsync("notification", "delete", id);
|
||||
|
||||
/// <summary>Best-effort delete of a shared script via <c>shared-script delete</c> for teardown.</summary>
|
||||
public static Task DeleteSharedScriptAsync(int id) => BestEffortAsync("shared-script", "delete", id);
|
||||
|
||||
/// <summary>
|
||||
/// Exports a Transport bundle scoped to a single template via
|
||||
/// <c>bundle export</c>.
|
||||
@@ -534,6 +613,31 @@ public static partial class CliRunner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the integer ids of every element in a JSON array document whose
|
||||
/// <c>name</c> starts with <paramref name="prefix"/> (ordinal comparison),
|
||||
/// tolerating both camelCase (<c>id</c>/<c>name</c>) and PascalCase
|
||||
/// (<c>Id</c>/<c>Name</c>) keys. Shared by the external-system and
|
||||
/// notification-list list helpers, whose <c>list</c> responses use PascalCase.
|
||||
/// </summary>
|
||||
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) && !el.TryGetProperty("Name", out name)) 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a required integer <c>id</c> from a create-command response,
|
||||
/// throwing a descriptive error if it is missing or non-integral.
|
||||
|
||||
+54
@@ -111,4 +111,58 @@ public class CliRunnerHelpersTests
|
||||
await CliRunner.DeleteAreaAsync(areaId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A freshly created external system is discoverable by name prefix and is cleanly
|
||||
/// deleted in teardown, exercising <see cref="CliRunner.CreateExternalSystemAsync"/>,
|
||||
/// <see cref="CliRunner.ListExternalSystemIdsByNamePrefixAsync"/>, and
|
||||
/// <see cref="CliRunner.DeleteExternalSystemAsync"/> as a round-trip.
|
||||
/// </summary>
|
||||
[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); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A freshly created notification list is discoverable by name prefix and is cleanly
|
||||
/// deleted in teardown, exercising <see cref="CliRunner.CreateNotificationListAsync"/>,
|
||||
/// <see cref="CliRunner.ListNotificationListIdsByNamePrefixAsync"/>, and
|
||||
/// <see cref="CliRunner.DeleteNotificationListAsync"/> as a round-trip.
|
||||
/// </summary>
|
||||
[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); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A freshly created shared script returns a positive id and is cleanly deleted in
|
||||
/// teardown, exercising <see cref="CliRunner.CreateSharedScriptAsync"/> and
|
||||
/// <see cref="CliRunner.DeleteSharedScriptAsync"/> as a round-trip.
|
||||
/// </summary>
|
||||
[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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class ApiMethodFormTests
|
||||
{
|
||||
private readonly PlaywrightFixture _pw;
|
||||
|
||||
public ApiMethodFormTests(PlaywrightFixture pw)
|
||||
{
|
||||
_pw = pw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Add-API-Method form's required-field gate fires when Name is present but the
|
||||
/// script is empty. This deliberately exercises the validation WITHOUT touching the
|
||||
/// Monaco editor (brittle to type into): fill only the Name, click Save, and expect the
|
||||
/// inline "Name and script required." error. The form does not navigate, so nothing is
|
||||
/// saved — this fixture name never reaches the database.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CreateForm_NameWithoutScript_ShowsInlineError()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/api-methods/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Add API Method')")).ToBeVisibleAsync();
|
||||
|
||||
// The form has several text inputs (Name + SchemaBuilder/parameter rows that also
|
||||
// carry .form-control), so anchor the Name input to its labelled mb-3 wrapper and
|
||||
// require .form-control (the peer ExternalSystemCrudTests pattern). The count guard
|
||||
// makes any future selector ambiguity fail loudly instead of filling the wrong field.
|
||||
var nameInput = page.Locator("div.mb-3:has(label:has-text('Name')) input[type=text].form-control");
|
||||
await Assertions.Expect(nameInput).ToHaveCountAsync(1);
|
||||
await nameInput.FillAsync("zzapimethod");
|
||||
|
||||
// Leave the Monaco script empty and submit — the gate should reject it.
|
||||
await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator("div.text-danger.small:has-text('Name and script required.')"))
|
||||
.ToBeVisibleAsync();
|
||||
|
||||
// The form must not have navigated away.
|
||||
await Assertions.Expect(page).ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/design/api-methods/create$"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-create an inbound API method, confirm it renders on the External Systems page's
|
||||
/// "Inbound API Methods" tab, then delete it via the card kebab and confirm dialog. The
|
||||
/// create/edit form is Monaco-driven (brittle), so authoring happens via the CLI; the UI
|
||||
/// delete round-trip is the behavior under test. A best-effort CLI delete in finally
|
||||
/// guarantees no zztest-method-* row leaks if the UI path fails mid-way.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CliCreated_Method_VisibleAndDeletes()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var name = CliRunner.UniqueName("method");
|
||||
int id = await CliRunner.CreateApiMethodAsync(name);
|
||||
|
||||
try
|
||||
{
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/external-systems");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Switch to the Inbound API Methods tab; only its panel renders, so the card
|
||||
// locator below stays unambiguous against External System / DB Connection cards.
|
||||
// The tab switch is a Blazor SignalR re-render (@if (_tab == "inbound")), so let
|
||||
// the circuit settle before querying the freshly-rendered panel.
|
||||
await page.Locator("button.nav-link:has-text('Inbound API Methods')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var card = page.Locator("div.card").Filter(new() { HasText = name });
|
||||
await Assertions.Expect(card).ToHaveCountAsync(1, new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(card.Locator($"code:has-text('POST /api/{name}')")).ToBeVisibleAsync();
|
||||
|
||||
// Scope the kebab + delete item to the card's .dropdown for strict-mode safety.
|
||||
var dropdown = card.Locator(".dropdown");
|
||||
await dropdown.Locator("button[aria-label^='More actions']").ClickAsync();
|
||||
await dropdown.Locator(".dropdown-menu button.dropdown-item.text-danger").ClickAsync();
|
||||
|
||||
// Confirm we are on the right dialog before clicking danger. The api-method delete
|
||||
// uses the generic "Delete" title (ConfirmAsync("Delete", "Delete API method '{name}'?")).
|
||||
await Assertions.Expect(page.Locator(".modal-title:has-text('Delete')")).ToBeVisibleAsync();
|
||||
await page.Locator(".modal-footer .btn-danger").ClickAsync();
|
||||
|
||||
// Single web-first assertion on the success toast — toasts auto-dismiss, so we
|
||||
// do NOT chase the toast body in a second sequential check.
|
||||
await Assertions.Expect(page.Locator(".toast", new() { HasText = "Deleted." }))
|
||||
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
|
||||
// The card must be gone.
|
||||
await Assertions.Expect(page.Locator("div.card").Filter(new() { HasText = name }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort teardown: the happy path deletes via the UI, so this finds
|
||||
// nothing; it only fires if the UI path failed mid-way.
|
||||
await CliRunner.DeleteApiMethodAsync(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class DataConnectionCrudTests
|
||||
{
|
||||
private readonly PlaywrightFixture _pw;
|
||||
|
||||
public DataConnectionCrudTests(PlaywrightFixture pw)
|
||||
{
|
||||
_pw = pw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-create a data connection on site-a, then exercise the high-value UI delete
|
||||
/// path: the connection renders as a child node in the TreeView, the per-node kebab
|
||||
/// → Delete → confirm dialog removes it, a success toast fires, and the node is gone.
|
||||
/// The create form requires a brittle OPC UA endpoint sub-editor, so we seed via the
|
||||
/// CLI (a minimal create needs no primary config) and only drive the delete via the UI.
|
||||
/// The best-effort <c>finally</c> is a safety net: the happy path deletes via the UI.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CliCreated_Connection_DeletesViaTree()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var siteId = await CliRunner.ResolveSiteIdAsync("site-a");
|
||||
var name = CliRunner.UniqueName("dconn");
|
||||
var id = await CliRunner.CreateDataConnectionAsync(siteId, name);
|
||||
|
||||
try
|
||||
{
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/connections");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Connections')")).ToBeVisibleAsync();
|
||||
|
||||
// Reveal all nodes so the new connection (a child of its site) is in the tree.
|
||||
// Scope the "Expand all" click to the Bulk-actions dropdown we just opened, so it
|
||||
// can't multi-match any other "Expand all" on the page.
|
||||
var bulkDropdown = page.Locator(".dropdown")
|
||||
.Filter(new() { Has = page.Locator("button.dropdown-toggle:has-text('Bulk actions')") });
|
||||
await bulkDropdown.Locator("button.dropdown-toggle:has-text('Bulk actions')").ClickAsync();
|
||||
await bulkDropdown.Locator(".dropdown-menu .dropdown-item:has-text('Expand all')").ClickAsync();
|
||||
|
||||
var node = page.Locator("span.tv-label", new() { HasText = name });
|
||||
await Assertions.Expect(node).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
|
||||
// Scope ALL dropdown interaction to THIS node's own .dc-node-actions wrapper (the
|
||||
// per-node div.dropdown in DataConnections.razor that holds the kebab + its menu),
|
||||
// keyed by the unique aria-label. This avoids any reliance on Bootstrap's .show —
|
||||
// multiple connection-node menus (or the TreeView's separate ContextMenu) can carry
|
||||
// .show at once, which would multi-match under Playwright strict mode.
|
||||
var nodeActions = page.Locator(".dc-node-actions")
|
||||
.Filter(new() { Has = page.Locator($"button[aria-label='More actions for {name}']") });
|
||||
|
||||
// The kebab is opacity:0 until its row is hovered; hovering first makes the
|
||||
// reveal deterministic, then a forced click sidesteps any residual flake.
|
||||
await node.HoverAsync();
|
||||
await nodeActions.Locator("button.dc-kebab").ClickAsync(new() { Force = true });
|
||||
|
||||
await nodeActions.Locator(".dropdown-menu .dropdown-item.text-danger:has-text('Delete')")
|
||||
.ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".modal-title:has-text('Delete Connection')")).ToBeVisibleAsync();
|
||||
await page.Locator(".modal-footer .btn-danger").ClickAsync();
|
||||
|
||||
// Single web-first assertion on the success toast — toasts auto-dismiss at 5s,
|
||||
// so we do NOT chase the toast body in a second sequential check.
|
||||
await Assertions.Expect(page.Locator(".toast", new() { HasText = "deleted" }))
|
||||
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
|
||||
// The node must be gone from the tree.
|
||||
await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = name }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort teardown: the happy path deletes via the UI, so this finds
|
||||
// nothing; it only fires if the UI path failed mid-way.
|
||||
await CliRunner.DeleteDataConnectionAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The "+ Connection" button is gated on a tree-node selection: it starts disabled
|
||||
/// and only enables once a site (or connection) node is selected. Mutates nothing.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CreateButton_GatedOnNodeSelection()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/connections");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Connections')")).ToBeVisibleAsync();
|
||||
|
||||
var addButton = page.Locator("button.btn.btn-primary.btn-sm:has-text('+ Connection')");
|
||||
await Assertions.Expect(addButton).ToBeDisabledAsync();
|
||||
|
||||
// Selecting a site node (site-a exists on the live cluster) satisfies the gate.
|
||||
// Site-level labels carry the extra `fw-semibold` class (connection labels don't),
|
||||
// so this targets a SITE node specifically rather than any tree label.
|
||||
await page.Locator("span.tv-label.fw-semibold").First.ClickAsync();
|
||||
|
||||
await Assertions.Expect(addButton).ToBeEnabledAsync();
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class ExternalSystemCrudTests
|
||||
{
|
||||
private readonly PlaywrightFixture _pw;
|
||||
|
||||
public ExternalSystemCrudTests(PlaywrightFixture pw)
|
||||
{
|
||||
_pw = pw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full create → card → delete round-trip for an external system via the Central UI.
|
||||
/// Uses CliRunner for best-effort teardown so no zztest-extsys-* definition leaks on
|
||||
/// failure (the happy path already deletes via the UI).
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Create_Delete_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var name = CliRunner.UniqueName("extsys");
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/external-systems/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Add External System')")).ToBeVisibleAsync();
|
||||
|
||||
// ExternalSystemForm.razor has THREE input[type=text].form-control fields
|
||||
// (Name, Endpoint URL, and Auth Config JSON), so index-based selection is
|
||||
// fragile. Anchor each fill to its own div.mb-3 wrapper via the field label
|
||||
// so the selectors survive field reordering and uniquely match one element.
|
||||
await page.Locator("div.mb-3:has(label:has-text('Name')) input[type=text].form-control")
|
||||
.FillAsync(name);
|
||||
await page.Locator("div.mb-3:has(label:has-text('Endpoint URL')) input[type=text].form-control")
|
||||
.FillAsync("https://example.invalid/api");
|
||||
|
||||
await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
|
||||
|
||||
// Save redirects back to the list (no toast).
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/design/external-systems", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The new card must be present (and unique) before we act on it.
|
||||
var card = page.Locator("div.card").Filter(new() { HasText = name });
|
||||
await Assertions.Expect(card).ToHaveCountAsync(1, new() { Timeout = 10_000 });
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
// Scope the kebab + delete item to the card's .dropdown for strict-mode safety.
|
||||
var cardDropdown = card.Locator(".dropdown");
|
||||
await cardDropdown.Locator("button[aria-label^='More actions']").ClickAsync();
|
||||
|
||||
await cardDropdown.Locator(".dropdown-menu button.dropdown-item.text-danger").ClickAsync();
|
||||
|
||||
// Confirm the delete dialog.
|
||||
await Assertions.Expect(page.Locator(".modal-title:has-text('Delete External System')")).ToBeVisibleAsync();
|
||||
await page.Locator(".modal-footer .btn-danger").ClickAsync();
|
||||
|
||||
// Single web-first assertion on the success toast — toasts auto-dismiss at 5s,
|
||||
// so we do NOT chase .toast-body in a second sequential check.
|
||||
await Assertions.Expect(page.Locator(".toast", new() { HasText = "Deleted." }))
|
||||
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
|
||||
// The card must be gone.
|
||||
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort teardown: the happy path deletes via the UI, so this finds
|
||||
// nothing; it only fires if the UI path failed mid-way.
|
||||
foreach (var id in await CliRunner.ListExternalSystemIdsByNamePrefixAsync(name))
|
||||
await CliRunner.DeleteExternalSystemAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saving the create form with both fields blank surfaces the inline validation
|
||||
/// error and keeps the user on the create page. Mutates nothing.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CreateForm_EmptyFields_ShowsInlineError()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/external-systems/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator("div.text-danger.small:has-text('Name and URL required.')"))
|
||||
.ToBeVisibleAsync();
|
||||
await Assertions.Expect(page)
|
||||
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/create"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class SharedScriptCrudTests
|
||||
{
|
||||
private readonly PlaywrightFixture _pw;
|
||||
|
||||
public SharedScriptCrudTests(PlaywrightFixture pw)
|
||||
{
|
||||
_pw = pw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-create a shared script, then exercise the Central UI list render + UI delete
|
||||
/// round-trip. The create/edit form is driven by a Monaco editor (brittle to type),
|
||||
/// so authoring happens via the CLI; the UI delete is the behavior under test. A
|
||||
/// best-effort CLI delete in finally guarantees no zztest-script-* definition leaks
|
||||
/// if the UI path fails mid-way (the happy path already deletes via the UI).
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CliCreated_Script_DeletesViaCard()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var name = CliRunner.UniqueName("script");
|
||||
int id = await CliRunner.CreateSharedScriptAsync(name);
|
||||
|
||||
try
|
||||
{
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/shared-scripts");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Shared Scripts')")).ToBeVisibleAsync();
|
||||
|
||||
// Narrow the list so the card locator stays unambiguous even with many scripts.
|
||||
await page.Locator("input[placeholder='Filter by name or code…']").FillAsync(name);
|
||||
|
||||
var card = page.Locator("div.card").Filter(new() { HasText = name });
|
||||
await Assertions.Expect(card).ToHaveCountAsync(1, new() { Timeout = 10_000 });
|
||||
|
||||
// Scope the kebab + delete item to the card's .dropdown for strict-mode safety.
|
||||
var dropdown = card.Locator(".dropdown");
|
||||
await dropdown.Locator("button[aria-label^='More actions']").ClickAsync();
|
||||
await dropdown.Locator(".dropdown-menu button.dropdown-item.text-danger").ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".modal-title:has-text('Delete Shared Script')")).ToBeVisibleAsync();
|
||||
await page.Locator(".modal-footer .btn-danger").ClickAsync();
|
||||
|
||||
// Single web-first assertion on the success toast — toasts auto-dismiss, so we
|
||||
// do NOT chase the toast body in a second sequential check.
|
||||
await Assertions.Expect(page.Locator(".toast", new() { HasText = "deleted" }))
|
||||
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
|
||||
// The card must be gone.
|
||||
await Assertions.Expect(page.Locator("div.card").Filter(new() { HasText = name }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort teardown: the happy path deletes via the UI, so this finds
|
||||
// nothing; it only fires if the UI path failed mid-way.
|
||||
await CliRunner.DeleteSharedScriptAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The create form renders: heading, the (enabled) Name input, the editor tabs, and
|
||||
/// the Monaco editor surface. Asserts render only — does NOT click Save, Check Syntax,
|
||||
/// or Test Run (Test Run fires real I/O). Mutates nothing.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CreateForm_Renders()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/shared-scripts/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('New Shared Script')")).ToBeVisibleAsync();
|
||||
|
||||
var nameInput = page.Locator("input[type=text].form-control.form-control-sm");
|
||||
await Assertions.Expect(nameInput).ToBeVisibleAsync();
|
||||
await Assertions.Expect(nameInput).ToBeEnabledAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator("button.nav-link:has-text('Code')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("button.nav-link:has-text('Parameters')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("button.nav-link:has-text('Return type')")).ToBeVisibleAsync();
|
||||
|
||||
// Monaco mounts asynchronously via JS interop — give it a generous timeout.
|
||||
await Assertions.Expect(page.Locator(".monaco-editor")).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end render + control-gating tests for the Site Event Logs page
|
||||
/// (<c>/monitoring/event-logs</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why this is a render + controls + tolerant-query suite (no row assertions):</b>
|
||||
/// Site event logs live in the SITE's local SQLite store and are fetched on demand via
|
||||
/// an Akka <c>Ask</c> round-trip to a live site — there is no central table, and no CLI
|
||||
/// or DB seed path. A clean site may legitimately have zero logged events, so these
|
||||
/// facts must never assert on specific event rows. Instead they verify the two things
|
||||
/// that are deterministic regardless of site state:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The Search button is gated on site selection (disabled until a
|
||||
/// site is chosen, then enabled).</description></item>
|
||||
/// <item><description>Clicking Search resolves the query — the results table renders
|
||||
/// either a data row or the empty-state row.</description></item>
|
||||
/// </list>
|
||||
/// The row-expand affordance is exercised only when real data rows happen to exist.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Gated on <see cref="ClusterAvailability"/> via <c>Skip.IfNot</c>: when the cluster is
|
||||
/// unreachable the facts report as Skipped (not Failed), matching the suite idiom.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class EventLogsTests
|
||||
{
|
||||
private const string EventLogsUrl = "/monitoring/event-logs";
|
||||
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public EventLogsTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Search button must be disabled on page load (no site selected) and become
|
||||
/// enabled once a site is selected from the site dropdown. Deterministic regardless
|
||||
/// of whether the site has any logged events.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task EventLogs_SearchGatedOnSiteSelection()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{EventLogsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Site Event Logs')")).ToBeVisibleAsync();
|
||||
|
||||
// The site selector is populated from the cluster's known sites; the live cluster
|
||||
// exposes site-a. Each option's value is the site's SiteIdentifier.
|
||||
await Assertions.Expect(page.Locator("#filter-site option[value='site-a']")).ToHaveCountAsync(1);
|
||||
|
||||
// Search is gated: disabled until a site is selected.
|
||||
var search = page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')");
|
||||
await Assertions.Expect(search).ToBeDisabledAsync();
|
||||
|
||||
await page.Locator("#filter-site").SelectOptionAsync(new SelectOptionValue { Value = "site-a" });
|
||||
await Assertions.Expect(search).ToBeEnabledAsync(new() { Timeout = 5_000 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After selecting a site and clicking Search, the query must resolve: the results
|
||||
/// table renders either a data row or the empty-state row (both match
|
||||
/// <c>table tbody tr</c>). The row-expand affordance is exercised only when real data
|
||||
/// rows exist — a site may have zero logged events, and event logs are not seedable
|
||||
/// from central, so the empty-state render is itself the assertion in that case.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task EventLogs_Search_RendersTableOrEmptyState()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{EventLogsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Site Event Logs')")).ToBeVisibleAsync();
|
||||
|
||||
await page.Locator("#filter-site").SelectOptionAsync(new SelectOptionValue { Value = "site-a" });
|
||||
var searchBtn = page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')");
|
||||
await searchBtn.ClickAsync();
|
||||
|
||||
// The Search button re-enables only when _searching flips back to false, i.e. after the
|
||||
// site's Akka Ask round-trip completes. Gating on it here makes the table assertion below a
|
||||
// genuine post-query check (a hung Ask fails here instead of false-passing on the
|
||||
// intermediate empty render Blazor flushes while _entries is a momentary empty list).
|
||||
await Assertions.Expect(searchBtn).ToBeEnabledAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// The query has completed (Search re-enabled above), so the terminal table render is now
|
||||
// present: a data row OR the empty-state row. The empty-state markup is itself a
|
||||
// <tr><td colspan=7>No events found.</td></tr> living inside table tbody, so "table tbody
|
||||
// tr" matches it whether or not the site has events.
|
||||
var settled = page.Locator("table tbody tr");
|
||||
await Assertions.Expect(settled.First).ToBeVisibleAsync(new() { Timeout = 5_000 });
|
||||
|
||||
// Exercise the row-expand ONLY IF real data rows exist. A clean site may have zero
|
||||
// logged events, and event logs are not seedable from central — so when no expand
|
||||
// toggle is present, the empty-state render above is the assertion (tolerated).
|
||||
var expandBtns = page.Locator("button[aria-label='View full message']");
|
||||
if (await expandBtns.CountAsync() > 0)
|
||||
{
|
||||
await expandBtns.First.ClickAsync();
|
||||
await Assertions.Expect(page.Locator("button[aria-label='Hide full message']").First)
|
||||
.ToBeVisibleAsync(new() { Timeout = 5_000 });
|
||||
}
|
||||
// else: no events logged on this site — the empty-state render is the assertion.
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the Notification KPIs page (<c>/notifications/kpis</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// This page is pure-read (no mutations, no fixture seeding, no teardown). It requires
|
||||
/// the Deployment role; the test user <c>multi-role</c> has it. The KPI values themselves
|
||||
/// are non-deterministic; these tests assert structural render only — either the 5 KPI
|
||||
/// tiles render (happy path) or the cluster-unavailable alert renders (degraded path),
|
||||
/// and the Refresh button completes a round-trip without hanging.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class NotificationKpisTests
|
||||
{
|
||||
private const string KpisUrl = "/notifications/kpis";
|
||||
|
||||
private readonly PlaywrightFixture _pw;
|
||||
|
||||
public NotificationKpisTests(PlaywrightFixture pw)
|
||||
{
|
||||
_pw = pw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the Notification KPIs page and asserts that the page resolved to a
|
||||
/// real state — EITHER all 5 KPI tile labels rendered OR the 'KPIs unavailable'
|
||||
/// alert is shown.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task KpisPage_RendersTilesOrError()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{KpisUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Notification KPIs')")).ToBeVisibleAsync();
|
||||
|
||||
var tilesOk = await page.Locator("small.text-muted:has-text('Queue Depth')").IsVisibleAsync()
|
||||
&& await page.Locator("small.text-muted:has-text('Oldest Pending Age')").IsVisibleAsync();
|
||||
var errShown = await page.Locator(".alert.alert-warning:has-text('KPIs unavailable')").IsVisibleAsync();
|
||||
Assert.True(tilesOk || errShown,
|
||||
"Expected either the 5 KPI tiles or the 'KPIs unavailable' alert to render.");
|
||||
|
||||
if (tilesOk)
|
||||
{
|
||||
await Assertions.Expect(page.Locator("small.text-muted:has-text('Stuck')")).ToBeVisibleAsync(new() { Timeout = 5_000 });
|
||||
await Assertions.Expect(page.Locator("small.text-muted:has-text('Parked')")).ToBeVisibleAsync(new() { Timeout = 5_000 });
|
||||
await Assertions.Expect(page.Locator("small.text-muted:has-text('Delivered (last interval)')")).ToBeVisibleAsync(new() { Timeout = 5_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the Notification KPIs page, clicks the Refresh button, and asserts
|
||||
/// that the button re-enables within 10 s — proving the refresh round-trip completed
|
||||
/// without hanging.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task KpisPage_RefreshReenables()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{KpisUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var refresh = page.Locator("button.btn.btn-outline-secondary.btn-sm:has-text('Refresh')");
|
||||
await Assertions.Expect(refresh).ToBeEnabledAsync(new() { Timeout = 10_000 });
|
||||
await refresh.ClickAsync();
|
||||
await Assertions.Expect(refresh).ToBeEnabledAsync(new() { Timeout = 10_000 });
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Full-UI round-trip coverage for Notification Lists (<c>/notifications/lists</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// This page requires the Design role; the test user <c>multi-role</c> has it. The
|
||||
/// happy-path fact drives the entire lifecycle through the UI only — create the list,
|
||||
/// open it for edit, add a recipient, remove that recipient, then delete the list and
|
||||
/// assert the confirm-dialog + success-toast + row-gone sequence. A <c>finally</c> block
|
||||
/// performs best-effort CLI teardown so no <c>zztest-notiflist-*</c> list leaks on failure.
|
||||
/// The validation fact mutates nothing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class NotificationListCrudTests
|
||||
{
|
||||
private const string ListsUrl = "/notifications/lists";
|
||||
|
||||
private readonly PlaywrightFixture _pw;
|
||||
|
||||
public NotificationListCrudTests(PlaywrightFixture pw)
|
||||
{
|
||||
_pw = pw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives the complete Notification List lifecycle through the UI: create the list,
|
||||
/// open it for edit, add a recipient, remove that recipient, then delete the list —
|
||||
/// asserting the delete confirm dialog, the success toast, and the row disappearing.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Create_AddRecipient_RemoveRecipient_Delete_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var name = CliRunner.UniqueName("notiflist");
|
||||
const string recipEmail = "zzrec@example.invalid";
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Add Notification List')")).ToBeVisibleAsync();
|
||||
|
||||
// The create form has exactly one input (the list name); recipients are edit-only.
|
||||
await page.Locator("input.form-control").First.FillAsync(name);
|
||||
await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
|
||||
|
||||
// On success the form redirects back to the list page (no toast).
|
||||
await PlaywrightFixture.WaitForPathAsync(page, ListsUrl, excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var listRow = page.Locator("tr").Filter(new() { HasText = name });
|
||||
await Assertions.Expect(listRow).ToBeVisibleAsync();
|
||||
// Make the row locator strict-mode-safe: assert exactly one match before acting.
|
||||
await Assertions.Expect(listRow).ToHaveCountAsync(1, new() { Timeout = 10_000 });
|
||||
|
||||
// ── ADD RECIPIENT ─────────────────────────────────────────────────────────
|
||||
await listRow.Locator("button.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
||||
|
||||
// The Edit click triggers Blazor enhanced navigation (a SignalR round-trip that
|
||||
// loads the edit form's data); wait for it to settle before asserting the heading.
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Edit Notification List')")).ToBeVisibleAsync();
|
||||
|
||||
// The edit page has TWO "Name" text inputs (list name + recipient name). Scope the
|
||||
// recipient form to the card that contains the email input to disambiguate.
|
||||
var recipientCard = page.Locator(".card").Filter(new() { Has = page.Locator("input[type=email]") });
|
||||
await recipientCard.Locator("input[type=text].form-control").FillAsync("zzrec");
|
||||
await recipientCard.Locator("input[type=email]").FillAsync(recipEmail);
|
||||
await recipientCard.Locator("button.btn-success:has-text('Add')").ClickAsync();
|
||||
|
||||
var recipientRow = page.Locator("tr").Filter(new() { HasText = recipEmail });
|
||||
await Assertions.Expect(recipientRow).ToBeVisibleAsync();
|
||||
|
||||
// ── REMOVE RECIPIENT ──────────────────────────────────────────────────────
|
||||
// Direct delete — no confirm dialog.
|
||||
await recipientRow.Locator("button.btn-outline-danger.btn-sm:has-text('Delete')").ClickAsync();
|
||||
await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = recipEmail }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
|
||||
// ── DELETE LIST ───────────────────────────────────────────────────────────
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var listRowAgain = page.Locator("tr").Filter(new() { HasText = name });
|
||||
// Make the row locator strict-mode-safe: assert exactly one match before acting.
|
||||
await Assertions.Expect(listRowAgain).ToHaveCountAsync(1, new() { Timeout = 10_000 });
|
||||
await listRowAgain.Locator("button.btn-outline-danger.btn-sm:has-text('Delete')").ClickAsync();
|
||||
|
||||
// Confirm the global danger dialog.
|
||||
await Assertions.Expect(page.Locator(".modal-footer .btn-danger")).ToBeVisibleAsync();
|
||||
await page.Locator(".modal-footer .btn-danger").ClickAsync();
|
||||
|
||||
// Assert the success toast in one web-first check — count + body text together — so the
|
||||
// second assertion can't race the toast's 5s auto-dismiss.
|
||||
await Assertions.Expect(page.Locator(".toast", new() { HasText = "Deleted." }))
|
||||
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = name }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var id in await CliRunner.ListNotificationListIdsByNamePrefixAsync(name))
|
||||
await CliRunner.DeleteNotificationListAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits the create form with a blank name and asserts the inline 'Name required.'
|
||||
/// validation error appears and the page does not navigate away. Mutates nothing.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CreateForm_EmptyName_ShowsInlineError()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator("div.text-danger.small:has-text('Name required.')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page).ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/create"));
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the SMTP Configuration page (<c>/notifications/smtp</c>,
|
||||
/// <c>RequireAdmin</c> — the test user <c>multi-role</c> has Admin).
|
||||
///
|
||||
/// <para>
|
||||
/// SMTP config is SHARED central state with NO delete verb (neither UI nor CLI). The
|
||||
/// deterministic, always-safe assertions here MUTATE NOTHING:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="SmtpForm_MissingRequired_ShowsInlineError"/> opens the form, clears
|
||||
/// the required Host field and clicks Save — the page's <c>Save</c> handler returns at the
|
||||
/// required-field guard BEFORE persisting, so nothing is written. Then it cancels.</item>
|
||||
/// <item><see cref="SmtpPage_RendersConfigOrEmptyState"/> is pure-read render verification.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="SmtpEdit_NoopSave_ShowsSavedToast"/> is the only mutating fact, and is
|
||||
/// gated on safety. Its sole safety mechanism is that the Save is a genuine no-op: the
|
||||
/// page's <c>StartEdit</c> handler reloads EVERY field into the form
|
||||
/// (<c>_host/_port/_authType/_tlsMode/_credentials/_fromAddress</c>, including
|
||||
/// <c>_credentials = smtp.Credentials;</c>), so saving an untouched Edit form rewrites the
|
||||
/// SAME stored values — zero net change. No CLI restore is used: the masked credential is
|
||||
/// never exposed (un-snapshottable), and the save changes nothing, so a restore would add
|
||||
/// risk without benefit. The fact only proceeds when a config already exists (probed via
|
||||
/// the CLI); when none exists there is nothing to edit, so it skips — we never CREATE a
|
||||
/// config we could not delete (SMTP config has no delete verb).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class SmtpConfigTests
|
||||
{
|
||||
private const string SmtpUrl = "/notifications/smtp";
|
||||
|
||||
private readonly PlaywrightFixture _pw;
|
||||
|
||||
public SmtpConfigTests(PlaywrightFixture pw)
|
||||
{
|
||||
_pw = pw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the SMTP form (Add when empty, else Edit), clears the required Host field and
|
||||
/// clicks Save, asserting the inline 'Host and From Address are required.' error appears.
|
||||
/// The page's <c>Save</c> handler returns at this guard BEFORE persisting, so this fact
|
||||
/// mutates nothing. Then it cancels and asserts the form closed.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task SmtpForm_MissingRequired_ShowsInlineError()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SmtpUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('SMTP Configuration')")).ToBeVisibleAsync();
|
||||
|
||||
// Open the form. Prefer the empty-state Add button (Host starts empty); otherwise
|
||||
// edit the first existing card (Host pre-filled — we clear it below).
|
||||
var addBtn = page.Locator("button.btn.btn-primary.btn-sm:has-text('Add SMTP configuration')");
|
||||
if (await addBtn.IsVisibleAsync())
|
||||
{
|
||||
await addBtn.ClickAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await page.Locator("button.btn-outline-primary.btn-sm:has-text('Edit')").First.ClickAsync();
|
||||
}
|
||||
|
||||
// Ensure Host is EMPTY. Clearing Host alone trips the required-field guard, which
|
||||
// returns before saving — so nothing is persisted regardless of the other fields.
|
||||
await page.Locator("input[type=text].form-control").First.FillAsync("");
|
||||
|
||||
await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
|
||||
|
||||
await Assertions
|
||||
.Expect(page.Locator("div.text-danger.small:has-text('Host and From Address are required.')"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
|
||||
// Cancel and confirm the form closed (Save button gone). Leaves config exactly as found.
|
||||
await page.Locator("button.btn-outline-secondary:has-text('Cancel')").ClickAsync();
|
||||
await Assertions.Expect(page.Locator("button.btn-success:has-text('Save')")).ToHaveCountAsync(0);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('SMTP Configuration')")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the SMTP page and asserts it resolved to a real state — EITHER a config
|
||||
/// card (Credentials value '(stored)' or '(not set)') is present, OR the empty-state
|
||||
/// 'No SMTP configuration set.' text is shown. Pure-read; mutates nothing.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task SmtpPage_RendersConfigOrEmptyState()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SmtpUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('SMTP Configuration')")).ToBeVisibleAsync();
|
||||
|
||||
// Web-first: assert the page resolved to EITHER a config card (Credentials '(stored)' /
|
||||
// '(not set)') OR the empty-state, with retry so a slow Blazor init does not spuriously
|
||||
// fail. A union locator + single expectation replaces the point-in-time boolean OR.
|
||||
await Assertions.Expect(
|
||||
page.GetByText(new System.Text.RegularExpressions.Regex(@"\(stored\)|\(not set\)|No SMTP configuration set\."))
|
||||
.First
|
||||
).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-saves an existing SMTP config's Edit form with NOTHING changed and asserts the
|
||||
/// 'SMTP configuration saved.' toast appears.
|
||||
///
|
||||
/// <para>
|
||||
/// The no-op save is the SOLE safety mechanism, and it is genuinely sufficient: the page's
|
||||
/// <c>StartEdit</c> handler reloads every field into the form
|
||||
/// (<c>_host/_port/_authType/_tlsMode/_credentials/_fromAddress</c>, including
|
||||
/// <c>_credentials = smtp.Credentials;</c>), so an untouched Save rewrites the SAME stored
|
||||
/// values — the credential included — for zero net change. No CLI restore is used: the
|
||||
/// masked credential is never exposed so it could not be snapshotted/restored anyway, and
|
||||
/// the save mutates nothing. When no config exists there is nothing to edit and the fact
|
||||
/// skips (SMTP config has no delete verb, so we never create one we could not delete).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task SmtpEdit_NoopSave_ShowsSavedToast()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// Probe whether a config exists; if not, there is nothing safe to edit. We never
|
||||
// CREATE a config here because SMTP config has no delete verb (we could not clean up).
|
||||
using var listDoc = await CliRunner.RunJsonAsync("notification", "smtp", "list");
|
||||
var configs = listDoc.RootElement;
|
||||
Skip.If(
|
||||
configs.ValueKind != JsonValueKind.Array || configs.GetArrayLength() == 0,
|
||||
"No SMTP configuration exists to no-op-edit; SMTP config has no delete verb, " +
|
||||
"so we never create one we cannot delete. Validation gate is covered by " +
|
||||
"SmtpForm_MissingRequired_ShowsInlineError.");
|
||||
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SmtpUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('SMTP Configuration')")).ToBeVisibleAsync();
|
||||
|
||||
// Open the first card's Edit form. StartEdit reloads EVERY field (including the stored
|
||||
// credential, _credentials = smtp.Credentials), so re-saving untouched rewrites the
|
||||
// identical values — a true no-op. This is the sole safety mechanism: no CLI restore is
|
||||
// used, because the masked credential is never exposed (un-snapshottable) and the save
|
||||
// changes nothing, so a restore would add risk without benefit.
|
||||
await page.Locator("button.btn-outline-primary.btn-sm:has-text('Edit')").First.ClickAsync();
|
||||
await Assertions.Expect(page.Locator("button.btn-success:has-text('Save')")).ToBeVisibleAsync();
|
||||
|
||||
// Change NOTHING; click Save. StartEdit reloaded all fields, so this persists identical
|
||||
// values — zero net change.
|
||||
await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
|
||||
|
||||
await Assertions
|
||||
.Expect(page.Locator(".toast", new() { HasText = "SMTP configuration saved." }))
|
||||
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user