Compare commits
26 Commits
d33617d65d
...
667d141f1a
| Author | SHA1 | Date | |
|---|---|---|---|
| 667d141f1a | |||
| 5546c32593 | |||
| ad0bc33231 | |||
| fac0bcbb01 | |||
| 1cbf260969 | |||
| 4d55c0ac95 | |||
| 9cc5b7355e | |||
| e358c231ce | |||
| 043914fd71 | |||
| 917e5f30bf | |||
| 8e11f1f900 | |||
| 19c4412fd1 | |||
| 3998a6126f | |||
| 271f70b1d2 | |||
| 234ddb5201 | |||
| 3d9ef0a477 | |||
| 754f049a98 | |||
| 12bf08f64a | |||
| 4f4b34ea89 | |||
| 2a25f2aaf8 | |||
| 4a7c46f1db | |||
| bf78e3e7bf | |||
| 9e914299c8 | |||
| 51e48fca91 | |||
| b540015fbd | |||
| cb3b3bf373 |
@@ -0,0 +1,156 @@
|
||||
# Playwright Coverage Expansion — Design
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Status:** Approved (brainstorming complete) → ready for writing-plans
|
||||
**Component:** #9 Central UI — `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests`
|
||||
|
||||
## Goal
|
||||
|
||||
Close the functional-coverage gaps found in the 2026-06-05 Playwright coverage audit by
|
||||
implementing all 7 audit recommendations: add ~15–18 functional E2E tests, upgrade the
|
||||
shallow navigation tests, and standardize the skip policy — all against the live 8-node
|
||||
docker cluster, inside the existing xunit + `PlaywrightFixture` structure.
|
||||
|
||||
## Background — the audit
|
||||
|
||||
The suite (~68 tests) is **bimodal**: a deep, well-built audit/site-calls core wrapped in
|
||||
a thin shell of navigation + nav-visibility tests, with a large blind spot over the app's
|
||||
**mutating actions** (deploy, import, retry/discard, all CRUD writes).
|
||||
|
||||
Key gaps the 7 recommendations target:
|
||||
|
||||
1. **Topology instance lifecycle** (Deploy/Enable/Disable/Delete) — Akka-singleton relays,
|
||||
the exact surface the recent report-page hang lived in — untested beyond URL change.
|
||||
2. **Parked-message / notification Retry/Discard** relays — untested (Site-Calls relay *is*
|
||||
tested; it is the pattern to copy).
|
||||
3. **Transport Import → Apply** — bulk writes across all central config, Admin-only — zero
|
||||
coverage.
|
||||
4. **Navigation tests assert URL only** — never that the destination rendered; a route
|
||||
could 500 after navigation and stay green.
|
||||
5. **No Health-dashboard load test** — the page that fans out to three singleton `Ask`s
|
||||
every 10s has no assertion its KPI tiles resolve vs. hang/degrade.
|
||||
6. **No successful persisted write through the UI** anywhere — the entire create/edit/delete
|
||||
surface is functionally unverified end-to-end (`SiteCrudTests` only covers validation
|
||||
failure; audit/site-calls "writes" are direct SQL seeds).
|
||||
7. **Silent coverage cliffs** — DB-dependent tests are inconsistent (`AuditLogPageTests`
|
||||
*throw* when MSSQL is down; Site-Calls/grid tests *skip*), and skips aren't surfaced.
|
||||
|
||||
## Decisions (settled during brainstorming)
|
||||
|
||||
| # | Decision | Choice | Rationale |
|
||||
|---|---|---|---|
|
||||
| D1 | Mutation fidelity for state-changing tests | **Ephemeral fixtures + outcome-tolerant** | High fidelity, isolated; matches the existing `SiteCallsPageTests` real-relay test that asserts the round-trip happened (toast: `Applied`/`NotParked`/`SiteUnreachable`), not a deep cluster side-effect. |
|
||||
| D2 | How fixtures are created/torn down | **Shell out to the `scadabridge` CLI** | CLAUDE.md prefers the CLI for state setup; the CLI exposes every needed verb with `--format json` + clean `0`/`1` exit codes. |
|
||||
| D3 | Skip vs fail when DB/cluster unavailable | **Standardize on Skip + log** | Consistent `SkippableFact` everywhere; a logged skipped-summary prevents a downed dependency from masquerading as full green. Local dev without the cluster still gets green on the rest. |
|
||||
|
||||
### CLI surface confirmed (host → `http://localhost:9000`, `multi-role`/`password`)
|
||||
|
||||
- `site create --name --identifier [--description]`, `site delete --id`, `site list/get`,
|
||||
`site area create --site-id --name`, `site area delete --id`, `site deploy-artifacts`.
|
||||
- `template create --name [--description] [--parent-id]`, `template delete --id`,
|
||||
`template attribute add --template-id --name --data-type`, `template validate --id`.
|
||||
- `instance create --name --template-id --site-id [--area-id]`, `instance deploy --id`,
|
||||
`instance enable --id`, `instance disable --id`, `instance delete --id`.
|
||||
- `bundle export --output --passphrase [--all|--include-dependencies]`, `bundle preview`,
|
||||
`bundle import --input --passphrase --on-conflict`.
|
||||
|
||||
## Design
|
||||
|
||||
### Section 1 — Shared infrastructure (underpins recs 1, 2, 3, 6, 7)
|
||||
|
||||
**`CliRunner`** (new helper):
|
||||
- Resolves the CLI via the built DLL of `src/ZB.MOM.WW.ScadaBridge.CLI` (invoked as
|
||||
`dotnet <cli>.dll …`), falling back to a `scadabridge` on PATH. The test `.csproj` adds a
|
||||
build-order `ProjectReference` to the CLI project so the binary always exists.
|
||||
- Fixed args: `--url http://localhost:9000 --username multi-role --password password --format json`.
|
||||
- `Task<JsonDocument> RunAsync(params string[] args)` — runs the subprocess, captures
|
||||
stdout/stderr, throws on non-zero exit (stderr in the message), parses JSON stdout.
|
||||
- Typed helpers: `CreateSiteAsync`, `CreateAreaAsync`, `CreateTemplateAsync` +
|
||||
`AddAttributeAsync` (so the template validates), `CreateInstanceAsync`,
|
||||
`DeleteInstanceAsync` / `DeleteTemplateAsync` / `DeleteSiteAsync`,
|
||||
`BundleExportAsync(path, templateId, passphrase)`.
|
||||
|
||||
**Naming + teardown convention:** every provisioned entity is named `zztest-<8charguid>`
|
||||
(sorts last; unambiguous for `LIKE 'zztest%'` safety-net deletes). Teardown is best-effort
|
||||
(swallow errors), mirroring `AuditDataSeeder`/`SiteCallDataSeeder`.
|
||||
|
||||
**`ClusterAvailability` gate + skip logging (rec 7):**
|
||||
- One shared probe (CLI `site list` succeeds *and* the existing DB `IsAvailableAsync`) →
|
||||
`Skip.IfNot(...)` used uniformly across all DB/cluster tests.
|
||||
- Convert `AuditLogPageTests`' 11 throw-on-unavailable tests to `SkippableFact`.
|
||||
- A collection fixture's `DisposeAsync` writes one summary line
|
||||
(`SKIPPED N tests — cluster/DB unavailable`) so skips are visible.
|
||||
|
||||
### Section 2 — Mutating action suites (recs 1, 2, 3)
|
||||
|
||||
**`DeploymentActionTests` (rec 1):** a `DeploymentFixture` (collection fixture) provisions
|
||||
**one** `zztest` site + valid template once; each test creates its **own** throwaway
|
||||
instance on it (cheap), acts via the Topology UI, deletes it:
|
||||
- `Deploy_Instance_ConfirmsAndShowsOutcome` — Topology → context-menu Deploy → confirm
|
||||
dialog → assert exactly one outcome toast (tolerating `Deployed`/`SiteUnreachable`) and
|
||||
the status badge transitions off "not deployed".
|
||||
- `Enable_Instance_ShowsOutcome`, `Disable_Instance_ShowsOutcome` — same shape.
|
||||
- `Delete_Instance_RemovesFromTree` — UI delete → confirm → node disappears.
|
||||
|
||||
**`RetryDiscardActionTests` (rec 2):** reuse the existing direct-SQL seeders to seed a
|
||||
`Parked` row, then drive the UI relay outcome-tolerantly (the `SiteCallsPageTests` pattern):
|
||||
- Parked Messages: `Retry_ParkedMessage_ShowsOutcomeToast`,
|
||||
`Discard_ParkedMessage_ShowsOutcomeToast` (seed a parked S&F message for a `zztest`
|
||||
target on `site-a`).
|
||||
- Notification Report: `Retry_ParkedNotification_ShowsOutcome`,
|
||||
`Discard_ParkedNotification_ShowsOutcome` (seed a parked `Notifications` row).
|
||||
- Each: confirm dialog → exactly one outcome toast (`Applied`/`NotParked`/`SiteUnreachable`);
|
||||
best-effort row teardown.
|
||||
|
||||
**`TransportImportTests` (rec 3):** round-trip via CLI export + UI import:
|
||||
1. CLI creates a `zztest` template → `bundle export --output /tmp/zztest-<guid>.bundle
|
||||
--passphrase <p>` (1-template synthetic bundle).
|
||||
2. UI Import wizard: upload the exported file → passphrase → diff/resolve (Add) →
|
||||
type-env-name confirm → **Apply**.
|
||||
3. Assert the result screen shows success, the imported template appears, and the audit
|
||||
drill-in `?bundleImportId=` link is present.
|
||||
4. CLI deletes the imported template(s).
|
||||
- **Impl risk to de-risk first:** remote file upload — Playwright `SetInputFiles` streams the
|
||||
host file to the container browser; fine for a tiny bundle, but the plan's first transport
|
||||
step verifies the upload end-to-end before building the rest.
|
||||
|
||||
### Section 3 — Happy-path CRUD round-trips (rec 6)
|
||||
|
||||
Three pure-UI create→edit→delete tests (the suite currently verifies *no* successful
|
||||
persisted write). Each ends by deleting what it made; a `zztest`-name safety-net teardown
|
||||
guards mid-test failure:
|
||||
- **Site** (extend `SiteCrudTests`): create via `/admin/sites/create` (name + identifier +
|
||||
node addresses) → appears in list → edit description → delete → gone.
|
||||
- **Template**: `/design/templates/create` → add an attribute on `/design/templates/{id}` →
|
||||
delete.
|
||||
- **LDAP mapping**: `/admin/ldap-mappings/create` (group + role) → edit role → delete.
|
||||
|
||||
### Section 4 — Shallow-coverage hardening (recs 4, 5)
|
||||
|
||||
**Nav render assertions (rec 4):** upgrade the 16 `NavigationTests` theory cases from "URL
|
||||
changed" to *also* assert the destination's heading/content renders (a per-route
|
||||
expected-heading map). No new tests — a strengthened helper.
|
||||
|
||||
**Health load test (rec 5):** `HealthDashboardTests.KpiTiles_ResolveToValues` — load
|
||||
`/monitoring/health`, assert the three KPI tile groups (Notification-Outbox, Site-Call,
|
||||
Audit) resolve to numeric values (not the em-dash degrade) within a timeout. A direct
|
||||
regression guard for the singleton-hang class of bug.
|
||||
|
||||
## Verification
|
||||
|
||||
- Each new suite runs green against the live cluster; the full run stays at **0 failed**,
|
||||
with any skips logged.
|
||||
- Mutating tests leave **no residual** `zztest-*` entities — verified by a post-run
|
||||
`site list` / `template list` check.
|
||||
- Build: `dotnet build` the test project (picks up the new CLI `ProjectReference`), then
|
||||
`dotnet test`.
|
||||
|
||||
## Scope guard (YAGNI)
|
||||
|
||||
No new page-object framework, no CI wiring, no parallelization changes. Everything slots
|
||||
into the existing xunit + `PlaywrightFixture` structure.
|
||||
|
||||
## Native tasks
|
||||
|
||||
Brainstorming checklist tasks #51–#56 track this design through to writing-plans. The
|
||||
implementation plan (produced next by the writing-plans skill) will carry its own task set.
|
||||
@@ -0,0 +1,433 @@
|
||||
# Playwright Coverage Expansion Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add ~15 functional Playwright E2E tests + a shared CLI fixture + a standardized skip policy to close the gaps from the 2026-06-05 coverage audit, against the live 8-node docker cluster.
|
||||
|
||||
**Architecture:** New tests live in the existing `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` project, share the existing serial `[Collection("Playwright")]` + `PlaywrightFixture` (remote Chromium at `ws://localhost:3000`, app at `http://scadabridge-traefik`). State-changing tests provision ephemeral `zztest-*` fixtures by shelling out to the `scadabridge` CLI (host → `http://localhost:9000`, `multi-role`/`password`) and assert **outcome-tolerantly** (a confirm-dialog → a single `.toast` appears; the relay outcome may be success or a fast error). Every DB/cluster-dependent test uses `SkippableFact` + a shared availability probe and a logged skip summary.
|
||||
|
||||
**Tech Stack:** C# net10.0, xunit + `Xunit.SkippableFact`, Microsoft.Playwright, Microsoft.Data.SqlClient, the `scadabridge` CLI (`src/ZB.MOM.WW.ScadaBridge.CLI`). `TreatWarningsAsErrors=true` — all new code must be warning-clean and nullable-correct.
|
||||
|
||||
---
|
||||
|
||||
## Shared reference (used by many tasks — read once)
|
||||
|
||||
**Project paths**
|
||||
- Test project dir: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/` (abbrev. `TESTS/`).
|
||||
- CLI project: `src/ZB.MOM.WW.ScadaBridge.CLI/ZB.MOM.WW.ScadaBridge.CLI.csproj`.
|
||||
- Existing seeders to mirror: `TESTS/Audit/AuditDataSeeder.cs`, `TESTS/SiteCalls/SiteCallDataSeeder.cs`.
|
||||
- Existing canonical mutating test to copy: `TESTS/SiteCalls/SiteCallsPageTests.cs` (`RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast`).
|
||||
- Fixture/collection: `TESTS/PlaywrightFixture.cs` (`[CollectionDefinition("Playwright")]`, `BaseUrl`, `NewAuthenticatedPageAsync()`, `ExpandAllNavSectionsAsync()`).
|
||||
|
||||
**Global UI selectors (from a full source sweep — use these literally)**
|
||||
- Confirm dialog (global `DialogHost`): modal `.modal.show.d-block`; footer `.modal-footer`; confirm button = `.modal-footer .btn-danger` (text `Delete`, danger) or `.modal-footer .btn-primary` (text `Confirm`, non-danger); cancel = `.modal-footer .btn-outline-secondary`.
|
||||
- Toast (`ToastNotification`): `.toast` (each `.toast.show[role=alert]`); body `.toast-body`. Assert `.toast` visible (Timeout 15_000) and `CountAsync()==1`.
|
||||
- Tree (Topology/Templates): nodes are `li[role=treeitem]` containing `div.tv-row`; label in `span.tv-label`; **right-click** the row to open the context menu `.dropdown-menu.show`; items are `button.dropdown-item` (delete is `button.dropdown-item.text-danger`).
|
||||
- Card/table kebab (Sites/LdapMappings): click `button[aria-label^="More actions"]` (text `⋮`) → `.dropdown-menu`.
|
||||
|
||||
**CLI quick facts**
|
||||
- Invoke: `dotnet <CLI.dll> --url http://localhost:9000 --username multi-role --password password --format json <verb> <args>`. Exit `0` ok, `1` error; JSON on stdout.
|
||||
- Verbs: `site list`/`create --name --identifier`/`delete --id`/`area create --site-id --name`/`area delete --id`; `template create --name [--description]`/`delete --id`/`attribute add --template-id --name --data-type`/`list`; `instance create --name --template-id --site-id [--area-id]`/`deploy --id`/`enable --id`/`disable --id`/`delete --id`; `bundle export --output --passphrase --source-environment`/`import`.
|
||||
|
||||
**Naming + teardown convention**
|
||||
- All provisioned entities named `zztest-<8hex>` (`Guid.NewGuid().ToString("N")[..8]`). Teardown is best-effort (swallow exceptions), in `finally`.
|
||||
|
||||
**Skip pattern (rec 7)**
|
||||
- `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);` at the top of every DB/cluster test.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Add CLI ProjectReference to the test project
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (foundation)
|
||||
|
||||
**Files:**
|
||||
- Modify: `TESTS/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.csproj`
|
||||
|
||||
**Step 1:** Add a `ProjectReference` to the CLI so its build output (the `scadabridge` dll) is always present for the test process to invoke. Use `ReferenceOutputAssembly="false"` so the CLI's types are NOT linked into the test assembly (we only need its build artifact), but `Private`/build-ordering pulls the build:
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.ScadaBridge.CLI\ZB.MOM.WW.ScadaBridge.CLI.csproj"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
```
|
||||
**Step 2:** Run `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` → expect success; confirm `src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/ZB.MOM.WW.ScadaBridge.CLI.dll` exists.
|
||||
**Step 3:** Commit: `test(e2e): reference CLI project so tests can shell out to it`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `CliRunner` core (subprocess + JSON + availability probe)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3, Task 13, Task 14
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Cluster/CliRunner.cs`
|
||||
- Create: `TESTS/Cluster/ClusterAvailability.cs`
|
||||
- Test: `TESTS/Cluster/CliRunnerSmokeTests.cs`
|
||||
|
||||
**Step 1 — Write the failing smoke test:**
|
||||
```csharp
|
||||
using Xunit;
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public class CliRunnerSmokeTests
|
||||
{
|
||||
[SkippableFact]
|
||||
public async Task SiteList_ReturnsJsonArray()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
using var doc = await CliRunner.RunJsonAsync("site", "list");
|
||||
Assert.True(doc.RootElement.ValueKind is System.Text.Json.JsonValueKind.Array
|
||||
or System.Text.Json.JsonValueKind.Object);
|
||||
}
|
||||
}
|
||||
```
|
||||
**Step 2:** Run `dotnet test --filter CliRunnerSmokeTests` → FAIL (CliRunner not defined).
|
||||
|
||||
**Step 3 — Implement `CliRunner`:**
|
||||
- Resolve the CLI dll: walk up from `AppContext.BaseDirectory` to the repo root, then `src/ZB.MOM.WW.ScadaBridge.CLI/bin/<config>/net10.0/ZB.MOM.WW.ScadaBridge.CLI.dll`. Allow `SCADABRIDGE_CLI_DLL` env override. Throw a clear message if not found.
|
||||
- `RunAsync(params string[] args)`: `Process.Start` `dotnet <dll> --url <url> --username multi-role --password password --format json <args...>`; capture stdout+stderr; `await WaitForExitAsync()` with a 60s timeout (kill + throw on timeout); on non-zero exit throw `InvalidOperationException($"CLI {string.Join(' ', args)} exited {code}: {stderr}")`. Return stdout.
|
||||
- `RunJsonAsync(...)`: `JsonDocument.Parse(await RunAsync(...))`.
|
||||
- URL constant `http://localhost:9000`; allow `SCADABRIDGE_MANAGEMENT_URL` override (matches CLI's own env var).
|
||||
|
||||
**Step 4 — Implement `ClusterAvailability`:**
|
||||
```csharp
|
||||
public static class ClusterAvailability
|
||||
{
|
||||
public const string SkipReason = "Cluster/MSSQL unavailable — start the docker cluster (bash docker/deploy.sh) to run E2E.";
|
||||
private static bool? _cached;
|
||||
public static async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
if (_cached is { } c) return c;
|
||||
try { using var _ = await CliRunner.RunJsonAsync("site", "list"); _cached = true; }
|
||||
catch { _cached = false; }
|
||||
return _cached.Value;
|
||||
}
|
||||
}
|
||||
```
|
||||
**Step 5:** Run the smoke test → PASS (cluster up). Verify it SKIPS (not fails) if you stop the cluster (optional manual check; do not leave the cluster down).
|
||||
**Step 6:** Commit: `test(e2e): add CliRunner + ClusterAvailability probe`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `CliRunner` typed fixture helpers
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3, Task 13, Task 14
|
||||
**Blocked by:** Task 1
|
||||
|
||||
**Files:**
|
||||
- Modify: `TESTS/Cluster/CliRunner.cs`
|
||||
- Test: `TESTS/Cluster/CliRunnerHelpersTests.cs`
|
||||
|
||||
**Step 1 — Failing test (round-trips a real template via the CLI):**
|
||||
```csharp
|
||||
[SkippableFact]
|
||||
public async Task CreateThenDeleteTemplate_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var name = CliRunner.UniqueName("tmpl");
|
||||
int id = await CliRunner.CreateTemplateAsync(name);
|
||||
try
|
||||
{
|
||||
var ids = await CliRunner.ListTemplateIdsByNamePrefixAsync(name);
|
||||
Assert.Contains(id, ids);
|
||||
}
|
||||
finally { await CliRunner.DeleteTemplateAsync(id); }
|
||||
}
|
||||
```
|
||||
**Step 2:** Run → FAIL.
|
||||
|
||||
**Step 3 — Implement helpers** (each parses the JSON the CLI returns to extract the new integer `id`; inspect a live `template create` JSON shape first via `dotnet <dll> ... template create --name probe` then delete it, to confirm the id field name — likely `id` or `templateId`):
|
||||
- `static string UniqueName(string kind) => $"zztest-{kind}-{Guid.NewGuid():N}"[..N]` (keep it short).
|
||||
- `Task<int> CreateTemplateAsync(string name, string? description=null)`
|
||||
- `Task AddAttributeAsync(int templateId, string name, string dataType="Double")`
|
||||
- `Task<int> CreateInstanceAsync(string name, int templateId, int siteId, int? areaId=null)`
|
||||
- `Task<int> CreateAreaAsync(int siteId, string name)`
|
||||
- `Task<int> ResolveSiteIdAsync(string identifier)` (run `site list`, find by `identifier`/`siteIdentifier` == "site-a", return its `id`)
|
||||
- `Task DeployInstanceAsync/EnableInstanceAsync/DisableInstanceAsync/DeleteInstanceAsync(int id)`
|
||||
- `Task DeleteTemplateAsync(int id)` / `DeleteAreaAsync(int id)` / `DeleteSiteAsync(int id)`
|
||||
- `Task<IReadOnlyList<int>> ListTemplateIdsByNamePrefixAsync(string prefix)` (run `template list`, filter `name` StartsWith prefix)
|
||||
- `Task BundleExportAsync(string outputPath, int templateId, string passphrase, string sourceEnvironment)` (use `bundle export --output <p> --passphrase <pp> --source-environment <env>` plus whatever selector flag scopes to one template — inspect `bundle export --help`; if it only supports `--all`/`--include-dependencies`, export `--all` into a throwaway-clean cluster slice is unsafe, so prefer a template selector flag; if none exists, note it and fall back to `--all` with the understanding the bundle may carry more — see Task 9 risk note).
|
||||
- All helpers swallow nothing on create (must throw on failure) but `Delete*` helpers swallow exceptions (best-effort teardown).
|
||||
|
||||
**Step 4:** Run → PASS. **Step 5:** Commit: `test(e2e): add CliRunner typed fixture helpers`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Standardize skip policy + skip-count logging (rec 7)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 1, Task 2, Task 13, Task 14
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Cluster/SkipLogCollectionFixture.cs` (or extend `PlaywrightFixture`)
|
||||
- Modify: `TESTS/Audit/AuditLogPageTests.cs` (convert the 11 throw-on-unavailable guards to `SkippableFact` + `Skip.IfNot`)
|
||||
|
||||
**Step 1:** In `AuditLogPageTests.cs`, replace each `[Fact]` whose body throws `InvalidOperationException` when `AuditDataSeeder.IsAvailableAsync()` is false with `[SkippableFact]` + `Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);` (mirror `AuditGridColumnTests`). Keep all assertions otherwise unchanged.
|
||||
**Step 2:** Add skip logging: implement `IDisposable`/`IAsyncLifetime` on a collection fixture (or in `PlaywrightFixture.DisposeAsync`) that, at assembly teardown, writes one line to the console:
|
||||
`Console.WriteLine($"[E2E] Skipped {SkipTracker.Count} cluster/DB-dependent tests — {ClusterAvailability.SkipReason}");` where `SkipTracker` is a static counter incremented by a tiny helper `SkipUnlessAvailable()` that the tests call. (Simplest: a `static int` on `ClusterAvailability` bumped inside `IsAvailableAsync` when it returns false; log it in fixture dispose.)
|
||||
**Step 3:** Run `dotnet test --filter AuditLogPageTests` with the cluster UP → all pass (0 skipped, log line shows 0). **Step 4:** Commit: `test(e2e): standardize DB-dependent tests on SkippableFact + skip logging`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `DeploymentFixture` (ephemeral template + area on real site-a)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8, Task 9 (different files)
|
||||
**Blocked by:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Deployment/DeploymentFixture.cs`
|
||||
|
||||
**Why site-a (not a throwaway site):** Deploy/Enable/Disable relay to the owning site over ClusterClient. An *unknown* site identifier has no registered ClusterClient, so the relay resolves only on a slow 10s Ask timeout and never produces a fast toast (see the explicit comment in `SiteCallsPageTests.RetryClickThrough...`). So the ephemeral instance must live on a **real, running** site — use `site-a`.
|
||||
|
||||
**Step 1:** Implement an `IAsyncLifetime` fixture (NOT a collection fixture — instantiate per test class so the shared template/area are created once for the deployment suite):
|
||||
- `InitializeAsync`: if `!await ClusterAvailability.IsAvailableAsync()` return (tests will skip). Else resolve `SiteAId = await CliRunner.ResolveSiteIdAsync("site-a")`; create `TemplateId = await CliRunner.CreateTemplateAsync(UniqueName("deploytmpl"))`; `await CliRunner.AddAttributeAsync(TemplateId, "Value", "Double")` (so it validates); `AreaId = await CliRunner.CreateAreaAsync(SiteAId, UniqueName("area"))`.
|
||||
- `Task<int> CreateInstanceAsync()` → `CliRunner.CreateInstanceAsync(UniqueName("inst"), TemplateId, SiteAId, AreaId)`.
|
||||
- `DisposeAsync`: best-effort delete the area + template (instances are deleted by the tests; also list+delete any leftover `zztest-inst-*` on site-a as a safety net).
|
||||
**Step 2:** No standalone test — it's exercised by Tasks 5–7. Build only (`dotnet build TESTS`). **Step 3:** Commit: `test(e2e): add DeploymentFixture (ephemeral instance on site-a)`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `DeploymentActionTests.Deploy`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (shares DeploymentFixture file is read-only; but tests in same class serialize)
|
||||
**Blocked by:** Task 4
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Deployment/DeploymentActionTests.cs`
|
||||
|
||||
**Step 1 — Test (TDD: write, watch it fail to compile, then it should pass against the live cluster):**
|
||||
```csharp
|
||||
[Collection("Playwright")]
|
||||
public class DeploymentActionTests : IClassFixture<DeploymentFixture>, IAsyncLifetime
|
||||
{
|
||||
private readonly PlaywrightFixture _pw; // via collection
|
||||
private readonly DeploymentFixture _cluster;
|
||||
public DeploymentActionTests(PlaywrightFixture pw, DeploymentFixture cluster){ _pw=pw; _cluster=cluster; }
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Deploy_Instance_ShowsOutcomeToast()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
int instanceId = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/topology");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
// Expand to the instance; locate its tree row by the unique name, right-click → Deploy.
|
||||
var row = page.Locator("li[role=treeitem] .tv-row", new(){ HasText = /* instance unique name */ });
|
||||
await ExpandToInstanceAsync(page, row); // helper: click ancestor toggles until visible
|
||||
await row.ClickAsync(new(){ Button = MouseButton.Right });
|
||||
await page.Locator(".dropdown-menu.show button.dropdown-item", new(){ HasText="Deploy" }).ClickAsync();
|
||||
// Deploy has NO confirm dialog → outcome toast directly.
|
||||
var toast = page.Locator(".toast");
|
||||
await Assertions.Expect(toast).ToBeVisibleAsync(new(){ Timeout = 15_000 });
|
||||
Assert.Equal(1, await toast.CountAsync());
|
||||
}
|
||||
finally { await CliRunner.DeleteInstanceAsync(instanceId); }
|
||||
}
|
||||
}
|
||||
```
|
||||
- Note: capture the instance unique name from `CreateInstanceAsync` (extend the fixture to return `(int id, string name)` so the test can locate the row). Adjust the helper accordingly.
|
||||
- `ExpandToInstanceAsync`: click the site-a node then the area node toggles (`.tv-row` chevrons) so the instance row renders; or use the page's `Expand` toolbar button (`button[aria-label="Expand all areas"]`) — simpler: click Expand, then locate the row.
|
||||
|
||||
**Step 2:** Run `dotnet test --filter Deploy_Instance` → PASS (toast appears; outcome may be Deployed or a fast error — tolerant). **Step 3:** Commit: `test(e2e): cover Topology Deploy action`.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `DeploymentActionTests.Enable` + `Disable`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (same file as Task 5 — sequential)
|
||||
**Blocked by:** Task 5
|
||||
|
||||
**Files:** Modify `TESTS/Deployment/DeploymentActionTests.cs`
|
||||
|
||||
- `Enable_Instance_ShowsOutcomeToast`: deploy first (Enable only shows when state==Disabled; flow depends on cluster state). Simpler and outcome-tolerant: right-click → if `Enable` item present click it, else click `Disable`; assert toast. To keep it deterministic, target **Disable**: after a Deploy the instance is `Enabled`, so `Disable` is offered. `Disable` opens a confirm dialog (danger → `.modal-footer .btn-danger` text `Delete`); click it, then assert toast.
|
||||
- `Disable_Instance_ShowsOutcomeToast`: deploy → right-click → `Disable` → confirm `.modal-footer .btn-danger` → assert single `.toast`.
|
||||
- Each test creates its own instance via `_cluster.CreateInstanceAsync()` and deletes it in `finally`.
|
||||
|
||||
**Step:** Run filter → PASS. Commit: `test(e2e): cover Topology Enable/Disable actions`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: `DeploymentActionTests.Delete`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (same file)
|
||||
**Blocked by:** Task 6
|
||||
|
||||
**Files:** Modify `TESTS/Deployment/DeploymentActionTests.cs`
|
||||
|
||||
- `Delete_Instance_RemovesFromTree`: create instance → Topology → locate row → right-click → `Delete` (`button.dropdown-item.text-danger`) → confirm `.modal-footer .btn-danger` (text `Delete`) → assert the row with that instance name is no longer visible (`await Assertions.Expect(row).ToHaveCountAsync(0)` within a timeout). No CLI delete needed in `finally` (the UI deleted it) but call `CliRunner.DeleteInstanceAsync` best-effort anyway in case the UI delete failed.
|
||||
|
||||
**Step:** Run → PASS. Commit: `test(e2e): cover Topology Delete action`.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: NotificationOutbox retry/discard + ParkedMessages query (rec 2)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 4, Task 9
|
||||
**Blocked by:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Notifications/NotificationDataSeeder.cs` (mirror `SiteCallDataSeeder`; INSERT a `Parked` row into the central `Notifications` table — read the Notifications EF entity at `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/.../Notification*.cs` + its migration to get exact columns; tag a unique field, e.g. a `zztest-*` recipient/list/target, for teardown).
|
||||
- Create: `TESTS/Notifications/NotificationActionTests.cs`
|
||||
- Create: `TESTS/Monitoring/ParkedMessagesTests.cs`
|
||||
|
||||
**Notification retry/discard (seedable — central singleton, fast):**
|
||||
- `Retry_ParkedNotification_ShowsOutcomeToast`: seed a `Parked` Notifications row with a unique marker → `/notifications/report` → filter to status Parked / search the marker → Query → find row → click row's `Retry` (`button.btn-outline-success`) → confirm dialog non-danger `.modal-footer .btn-primary` (text `Confirm`) → assert single `.toast`. Teardown: delete the seeded row.
|
||||
- `Discard_ParkedNotification_ShowsOutcomeToast`: same but `Discard` (`button.btn-outline-danger`) → confirm danger `.modal-footer .btn-danger` (text `Delete`) → toast.
|
||||
|
||||
**ParkedMessages query (NOT seedable — site SQLite over Akka Ask; deterministic render test):**
|
||||
- `ParkedMessages_QueryForSite_RendersWithoutHang`: `/monitoring/parked-messages` → select `#pm-filter-site` = `site-a` (option value = SiteIdentifier) → click `Query` (`button.btn-primary` text `Query`) → assert that within a timeout EITHER `table.parked-table` OR an empty-state element renders (i.e. the singleton-backed query resolved, not hung). This guards the Akka-Ask hang class without needing to seed site state. (Document in a comment why retry/discard isn't exercised here: parked S&F messages live in site SQLite and can't be seeded from central SQL.)
|
||||
|
||||
**Steps:** TDD each (write → fails to compile → passes live). Run `dotnet test --filter "NotificationActionTests|ParkedMessagesTests"` → PASS. Commit: `test(e2e): cover notification retry/discard + parked-messages query`.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Transport Import round-trip (rec 3)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 4, Task 8
|
||||
**Blocked by:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `TESTS/Transport/TransportImportTests.cs`
|
||||
|
||||
**De-risk file upload FIRST (Step 0):** Playwright `SetInputFilesAsync(hostPath)` over a remote browser connection streams the host file to the container — verify with a 3-line spike before building the rest (upload the exported bundle to `#bundle-input`, assert `[data-testid="manifest-summary"]` appears). If streaming fails, fall back to writing the bundle to a path the container can read (a shared mount) and note it.
|
||||
|
||||
**Test `ImportSyntheticBundle_AppliesAndShowsAuditDrillIn`:**
|
||||
1. `Skip.IfNot(...cluster...)`. `var env = CliRunner.UniqueName("env");` `var tmplName = CliRunner.UniqueName("imp");`
|
||||
2. CLI: `int tmplId = await CliRunner.CreateTemplateAsync(tmplName); await CliRunner.AddAttributeAsync(tmplId,"Value","Double");`
|
||||
3. CLI: `var bundlePath = Path.Combine(Path.GetTempPath(), $"{tmplName}.scadabundle"); await CliRunner.BundleExportAsync(bundlePath, tmplId, passphrase:"pw-"+env, sourceEnvironment:env);`
|
||||
4. **Delete the source template via CLI** so the import sees it as a NEW item (not Modified): `await CliRunner.DeleteTemplateAsync(tmplId);`
|
||||
5. UI: `/design/transport/import` → `SetInputFiles("#bundle-input", bundlePath)` → Next → `#import-passphrase` = `"pw-"+env` → `Unlock` → diff step (new template renders `Add`, no blockers) → Next → `#confirm-env` type `env` (must equal `Manifest.SourceEnvironment`) → `Apply Import` (`button.btn-danger`).
|
||||
6. Assert `[data-testid="result-summary"]` text contains `Import complete.` and the `Audit trail →` link href starts `/audit/configuration?bundleImportId=`.
|
||||
7. `finally`: `foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(tmplName)) await CliRunner.DeleteTemplateAsync(id);` and `File.Delete(bundlePath)` best-effort.
|
||||
|
||||
**Step:** Run `dotnet test --filter TransportImportTests` → PASS; verify no residual `zztest-imp-*` templates (`template list`). Commit: `test(e2e): cover Transport Import apply round-trip`.
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Site CRUD round-trip (rec 6)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 11, Task 12, Task 13, Task 14
|
||||
**Blocked by:** Task 2 (safety-net teardown only)
|
||||
|
||||
**Files:** Modify `TESTS/SiteCrudTests.cs`
|
||||
|
||||
- `CreateEditDelete_Site_RoundTrips`: `/admin/sites/create` → fill (by `<label>`-anchored inputs / `h6 Node A/B`-scoped placeholders): Name=`zztest-site-<hex>`, Identifier=`zztest-<hex>`, Description, and one Node A Akka + gRPC address (any well-formed value — `akka.tcp://scadabridge@zz:5000/user/site-communication`, `http://zz:8083`) → `Save` → assert the new card (`.card` with `.card-title` text == name) appears on `/admin/sites` → Edit → change Description → Save → kebab `⋮` → `Delete` → confirm `.modal-footer .btn-danger` → assert the card is gone. `finally`: best-effort `CliRunner` delete by resolving the site id via `site list` on the identifier.
|
||||
|
||||
**Step:** Run → PASS. Commit: `test(e2e): cover Site create/edit/delete round-trip`.
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Template CRUD round-trip (rec 6)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10, Task 12, Task 13, Task 14
|
||||
**Blocked by:** Task 2 (safety-net teardown)
|
||||
|
||||
**Files:** Create `TESTS/Design/TemplateCrudTests.cs`
|
||||
|
||||
- `CreateAddAttributeDelete_Template_RoundTrips`: `/design/templates/create` → Name=`zztest-tmpl-<hex>` → `Create` (`button.btn-success`) → lands on `/design/templates/{id}` → Attributes tab → `Add Attribute` → modal: Name=`Val`, Data Type select → `Add` (submit text is `Add` for new) → assert the attribute row appears → header `Delete` (`button.btn-outline-danger`) → confirm `.modal-footer .btn-danger` → assert redirect to `/design/templates` and the node is gone. `finally`: `ListTemplateIdsByNamePrefixAsync("zztest-tmpl-")`→delete.
|
||||
|
||||
**Step:** Run → PASS. Commit: `test(e2e): cover Template create/add-attribute/delete round-trip`.
|
||||
|
||||
---
|
||||
|
||||
## Task 12: LDAP mapping CRUD round-trip (rec 6)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 10, Task 11, Task 13, Task 14
|
||||
**Blocked by:** none (pure UI; safety-net via direct SQL optional)
|
||||
|
||||
**Files:** Create `TESTS/Admin/LdapMappingCrudTests.cs`
|
||||
|
||||
- `CreateEditDelete_LdapMapping_RoundTrips`: `/admin/ldap-mappings/create` → LDAP Group Name (label-anchored input)=`zztest-grp-<hex>` → Role select (`.form-select`) = `Designer` → `Save` → assert the row (`tr` whose group-name `td` == the group) appears on `/admin/ldap-mappings` → Edit → change Role to `Viewer` → Save → kebab `⋮` → `Delete`. **Important:** LdapMappings Delete has **NO confirm dialog** (the page doesn't inject `IDialogService`) — clicking `Delete` removes the row immediately. Assert the row disappears (do NOT wait for a modal). `finally`: best-effort delete (UI did it; optional SQL net keyed on the unique group name).
|
||||
|
||||
**Step:** Run → PASS. Commit: `test(e2e): cover LDAP mapping create/edit/delete round-trip`.
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Navigation render-assertion hardening (rec 4)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10, Task 11, Task 12, Task 14
|
||||
**Blocked by:** none
|
||||
|
||||
**Files:** Modify `TESTS/NavigationTests.cs`
|
||||
|
||||
- Add a per-route expected-heading map (route → expected `h1/h4/h5` text), e.g. `/admin/sites`→"Site Management", `/notifications/report`→"Notification Report", `/design/templates`→"Templates", `/deployment/topology`→(its heading), `/monitoring/health`→(its heading), etc. (Read each target page's heading to fill the map exactly.)
|
||||
- In the shared `ClickNavAndWait` helper (or each Theory), after asserting the URL, also `await Assertions.Expect(page.Locator("h1,h4,h5", new(){ HasText = expectedHeading })).ToBeVisibleAsync()`. This catches a route that 500s after navigation.
|
||||
- Keep all existing Theory cases; just strengthen the assertion. No new test methods.
|
||||
|
||||
**Step:** Run `dotnet test --filter NavigationTests` → PASS (all routes render). Commit: `test(e2e): assert destination renders, not just URL, in nav tests`.
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Health KPI load test (rec 5)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 10, Task 11, Task 12, Task 13
|
||||
**Blocked by:** none
|
||||
|
||||
**Files:** Create `TESTS/Monitoring/HealthDashboardTests.cs`
|
||||
|
||||
- `KpiTiles_ResolveToValues_NotDegradePlaceholder`: `/monitoring/health` → wait for load → assert the three KPI groups resolved to values (not the `—` em-dash degrade):
|
||||
- Notification-Outbox (inlined, no data-test): `.card` whose `.text-muted` label is `Queue Depth` → its sibling `h3` text matches `^\d+$` (and `!= "—"`). Repeat for `Stuck`, `Parked`.
|
||||
- Audit tiles: `[data-test='audit-kpi-volume']` (+ `error-rate`, `backlog`) text is non-empty/non-`—`.
|
||||
- Site-Call tiles: `[data-test='site-call-kpi-buffered']` (+ `stuck`, `parked`).
|
||||
- Use a generous per-tile wait (the page polls/loads async). This is the direct regression guard for the singleton-hang class.
|
||||
|
||||
**Step:** Run → PASS. Commit: `test(e2e): assert Health KPI tiles resolve (singleton-hang guard)`.
|
||||
|
||||
---
|
||||
|
||||
## Task 15: Full-suite verification + no-residue check
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (final)
|
||||
**Blocked by:** all
|
||||
|
||||
**Steps:**
|
||||
1. `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` → expect **0 failed**; note passed/skipped counts and confirm the skip-summary log line prints.
|
||||
2. `dotnet <CLI.dll> ... site list` / `template list` / `instance list --site-id <site-a>` → grep for `zztest-` → expect **none** (clean teardown). If any leak, fix the offending test's `finally`.
|
||||
3. Commit any cleanup. Final message summarizing new coverage (count of added tests, the recs covered).
|
||||
|
||||
---
|
||||
|
||||
## Notes / open risks (surface, don't silently absorb)
|
||||
- **CLI JSON id field names** (Task 2): confirm the actual field (`id` vs `templateId`/`instanceId`/`siteId`) from a live `create` response before finalizing the parsers.
|
||||
- **`bundle export` single-template selection** (Task 2/9): verify the CLI supports scoping the export to one template; if it only does `--all`, adjust Task 9 (export a minimal cluster or accept a larger bundle and assert only the zztest template landed).
|
||||
- **Remote file upload** (Task 9 Step 0): de-risk `SetInputFiles` over the WS-connected browser before building the full import test.
|
||||
- **Deploy side-effects on site-a** (Tasks 5–7): ephemeral instances are deployed to the real site-a and deleted in `finally`; the no-residue check (Task 15) is the backstop.
|
||||
- **Outcome tolerance:** deploy/enable/disable/retry/discard assert *a* toast, not a specific outcome — the live cluster decides Deployed/Applied vs SiteUnreachable/NotParked. This is intentional (matches the existing SiteCalls relay test).
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-05-playwright-coverage-expansion.md",
|
||||
"lastUpdated": "2026-06-05T00:00:00Z",
|
||||
"nativeTaskIdBase": 57,
|
||||
"tasks": [
|
||||
{"id": 0, "nativeId": 57, "subject": "Task 0: Add CLI ProjectReference to test project", "status": "pending"},
|
||||
{"id": 1, "nativeId": 58, "subject": "Task 1: CliRunner core + ClusterAvailability probe", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "nativeId": 59, "subject": "Task 2: CliRunner typed fixture helpers", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeId": 60, "subject": "Task 3: Standardize skip policy + skip-count logging", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "nativeId": 61, "subject": "Task 4: DeploymentFixture (ephemeral instance on site-a)", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 5, "nativeId": 62, "subject": "Task 5: DeploymentActionTests.Deploy", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "nativeId": 63, "subject": "Task 6: DeploymentActionTests.Enable + Disable", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "nativeId": 64, "subject": "Task 7: DeploymentActionTests.Delete", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "nativeId": 65, "subject": "Task 8: Notification retry/discard + ParkedMessages query", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 9, "nativeId": 66, "subject": "Task 9: Transport Import round-trip", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 10, "nativeId": 67, "subject": "Task 10: Site CRUD round-trip", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 11, "nativeId": 68, "subject": "Task 11: Template CRUD round-trip", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 12, "nativeId": 69, "subject": "Task 12: LDAP mapping CRUD round-trip", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 13, "nativeId": 70, "subject": "Task 13: Navigation render-assertion hardening", "status": "pending"},
|
||||
{"id": 14, "nativeId": 71, "subject": "Task 14: Health KPI load test", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 15, "nativeId": 72, "subject": "Task 15: Full-suite verification + no-residue check", "status": "pending", "blockedBy": [3, 7, 8, 9, 10, 11, 12, 13, 14]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Microsoft.Playwright;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end CRUD round-trip for the LDAP Group Mappings admin page.
|
||||
/// Covers create → edit → delete via the UI against the running dev cluster.
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class LdapMappingCrudTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public LdapMappingCrudTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CreateEditDelete_LdapMapping_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// Truncated to 18 chars to stay within the form field's maximum input length
|
||||
// while still being unique (the zztest-grp- prefix + 7 hex chars from the GUID).
|
||||
var group = $"zztest-grp-{Guid.NewGuid():N}"[..18];
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The LDAP Group Name label has no `for=` attribute so GetByLabel does not
|
||||
// work. Locate the input that immediately follows the label text instead.
|
||||
await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm").FillAsync(group);
|
||||
// Scope the role select to the div.mb-2 that owns the "Role" label so a
|
||||
// second select (Site Scope) on the edit page cannot cause a strict-mode
|
||||
// violation. The LdapMappingForm.razor uses Blazor @bind, not <form>,
|
||||
// so the "Role" label's parent div.mb-2 is the tightest stable scope.
|
||||
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Designer");
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
|
||||
// Wait for Blazor enhanced navigation back to the list page.
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The new row must be visible on the list.
|
||||
var newRow = page.Locator("tr", new() { HasText = group });
|
||||
await Assertions.Expect(newRow).ToBeVisibleAsync();
|
||||
|
||||
// ── EDIT ──────────────────────────────────────────────────────────────────
|
||||
// Click the Edit button within that row.
|
||||
await newRow.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings/", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Change the role to Viewer and save.
|
||||
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Viewer");
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/edit");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Row must still be present after the edit.
|
||||
var editedRow = page.Locator("tr", new() { HasText = group });
|
||||
await Assertions.Expect(editedRow).ToBeVisibleAsync();
|
||||
|
||||
// ── DELETE (no confirmation dialog) ───────────────────────────────────────
|
||||
// Scope all dropdown interactions to the row's .dropdown container so we
|
||||
// never accidentally match a Delete button from another row's menu.
|
||||
var rowDropdown = editedRow.Locator(".dropdown");
|
||||
var kebab = rowDropdown.Locator("button[aria-label^='More actions']");
|
||||
await kebab.ClickAsync();
|
||||
|
||||
// Click Delete in the now-open dropdown within this row — no confirm dialog.
|
||||
var deleteBtn = rowDropdown.Locator(".dropdown-menu button.dropdown-item.text-danger");
|
||||
await deleteBtn.ClickAsync();
|
||||
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The row must be gone.
|
||||
await Assertions.Expect(page.Locator("tr", new() { HasText = group }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort safety net: if the test failed mid-way and the mapping was
|
||||
// not deleted by the UI, clean it up via the CLI so it doesn't leak.
|
||||
// Uses `security role-mapping list` + `security role-mapping delete --id`
|
||||
// (CLI verbs from SecurityCommands.cs BuildRoleMapping). All exceptions
|
||||
// are swallowed — teardown must never mask the test's own failure.
|
||||
try
|
||||
{
|
||||
using var doc = await CliRunner.RunJsonAsync("security", "role-mapping", "list");
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var mapping in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (mapping.TryGetProperty("ldapGroupName", out var grpProp)
|
||||
&& grpProp.ValueKind == JsonValueKind.String
|
||||
&& string.Equals(grpProp.GetString(), group, StringComparison.Ordinal)
|
||||
&& mapping.TryGetProperty("id", out var idProp)
|
||||
&& idProp.TryGetInt32(out var mappingId))
|
||||
{
|
||||
await CliRunner.RunAsync(
|
||||
"security", "role-mapping", "delete",
|
||||
"--id", mappingId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — the mapping may already be deleted (happy path) or
|
||||
// the cluster may be unreachable; never fail teardown.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
|
||||
|
||||
@@ -33,24 +34,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
|
||||
/// </summary>
|
||||
internal static class AuditDataSeeder
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5";
|
||||
|
||||
private const string EnvVar = "SCADABRIDGE_PLAYWRIGHT_DB";
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the running cluster's configuration DB. Resolved
|
||||
/// from <c>SCADABRIDGE_PLAYWRIGHT_DB</c> when set, otherwise the local docker
|
||||
/// dev defaults.
|
||||
/// Connection string for the running cluster's configuration DB.
|
||||
/// Delegates to <see cref="PlaywrightDbConnection.ConnectionString"/>.
|
||||
/// </summary>
|
||||
public static string ConnectionString
|
||||
{
|
||||
get
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
|
||||
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
|
||||
}
|
||||
}
|
||||
public static string ConnectionString => PlaywrightDbConnection.ConnectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a single audit row into the canonical <c>AuditLog</c> table. After the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
|
||||
|
||||
@@ -54,18 +55,10 @@ public class AuditLogPageTests
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkippableFact]
|
||||
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
|
||||
{
|
||||
// Skip with a clear message when MSSQL is not reachable — the rest of
|
||||
// the Playwright suite is UI-only and does not need the DB, so this
|
||||
// surfaces a setup gap explicitly rather than as an opaque SqlException.
|
||||
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"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.");
|
||||
}
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/filter-narrow/{runId}/";
|
||||
@@ -119,13 +112,10 @@ public class AuditLogPageTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkippableFact]
|
||||
public async Task DrilldownDrawer_JsonPrettyPrintsRequestBody()
|
||||
{
|
||||
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/drilldown-json/{runId}/";
|
||||
@@ -184,13 +174,10 @@ public class AuditLogPageTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkippableFact]
|
||||
public async Task CopyAsCurlButton_IsVisibleAndClickableForApiInbound()
|
||||
{
|
||||
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/curl-button/{runId}/";
|
||||
@@ -239,13 +226,10 @@ public class AuditLogPageTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkippableFact]
|
||||
public async Task DrillInFromCorrelationId_LandsOnAuditLogWithFilterContext()
|
||||
{
|
||||
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/drill-in/{runId}/";
|
||||
@@ -299,18 +283,15 @@ public class AuditLogPageTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkippableFact]
|
||||
public async Task DrillInFromExecutionId_LandsOnAuditLogWithFilterContext()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// Mirrors the correlationId drill-in: the "View this execution" drawer
|
||||
// action navigates to /audit/log?executionId={ExecutionId}. We seed a row
|
||||
// carrying that ExecutionId, hit the deep link directly, and assert the
|
||||
// page deserializes the param and auto-loads the seeded row.
|
||||
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/exec-drill-in/{runId}/";
|
||||
var executionId = Guid.NewGuid();
|
||||
@@ -357,19 +338,16 @@ public class AuditLogPageTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkippableFact]
|
||||
public async Task DrillInFromParentExecution_FiltersGridToSpawnerExecution()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// The drawer's "View parent execution" action navigates a routed (child)
|
||||
// row to /audit/log?executionId={ParentExecutionId}. We seed a spawner row
|
||||
// (its ExecutionId == the parent id) and a child row (ParentExecutionId
|
||||
// pointing at the spawner), open the child's drawer, click the action, and
|
||||
// assert the grid auto-loads filtered to the spawner's own rows.
|
||||
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/parent-exec-drill-in/{runId}/";
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
@@ -437,19 +415,16 @@ public class AuditLogPageTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkippableFact]
|
||||
public async Task DrillInToExecutionChain_RendersTree_AndNodeClickFiltersGrid()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// Audit Log ParentExecutionId feature, Task 10: the drawer's "View
|
||||
// execution chain" action opens /audit/execution-tree?executionId={id}.
|
||||
// We seed a spawner row + a child row, open the child's drawer, click
|
||||
// "View execution chain", assert the tree renders BOTH executions, then
|
||||
// click the spawner node and assert the Audit Log grid filters to it.
|
||||
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/exec-chain-tree/{runId}/";
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
@@ -519,9 +494,11 @@ public class AuditLogPageTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SkippableFact]
|
||||
public async Task DoubleClickTreeNode_OpensExecutionRowModal()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// Execution-Tree Node Detail Modal feature, Task 5: double-clicking a
|
||||
// node on the /audit/execution-tree page opens ExecutionDetailModal —
|
||||
// a modal listing that execution's audit rows, with click-through to
|
||||
@@ -529,11 +506,6 @@ public class AuditLogPageTests
|
||||
// TWO audit rows (so the modal opens to the list view, not straight to
|
||||
// a single-row detail), open the tree, double-click the node, walk
|
||||
// list → row → detail, then close the modal.
|
||||
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/exec-node-modal/{runId}/";
|
||||
var executionId = Guid.NewGuid();
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// Typed fixture helpers over <see cref="CliRunner"/> for state-changing Central
|
||||
/// UI Playwright E2E tests. Each helper shells out through
|
||||
/// <see cref="CliRunner.RunJsonAsync"/> / <see cref="CliRunner.RunAsync"/> and
|
||||
/// extracts just the field the caller needs.
|
||||
///
|
||||
/// <para>
|
||||
/// Create / resolve helpers throw on failure (the underlying CLI surfaces a
|
||||
/// non-zero exit as an <see cref="InvalidOperationException"/>); the
|
||||
/// <c>Delete*</c> helpers are best-effort and swallow exceptions so teardown in a
|
||||
/// <c>finally</c> never masks the test's own failure. The lifecycle helpers
|
||||
/// (<see cref="DeployInstanceAsync"/>, <see cref="EnableInstanceAsync"/>,
|
||||
/// <see cref="DisableInstanceAsync"/>) deliberately surface errors because tests
|
||||
/// call them as assertions about a transition succeeding.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Response shapes are empirically verified against the dev cluster: <c>template
|
||||
/// create</c>, <c>template attribute add</c>, <c>site area create</c>, and
|
||||
/// <c>instance create</c> all return a JSON object whose new primary key is the
|
||||
/// <c>id</c> property (an instance additionally exposes <c>uniqueName</c> rather
|
||||
/// than <c>name</c>). <c>template list</c> / <c>site list</c> return JSON arrays.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static partial class CliRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a collision-resistant, length-bounded fixture name of the form
|
||||
/// <c>zztest-<kind>-<8 hex></c> (≤ ~22 chars). The <c>zztest-</c>
|
||||
/// prefix sorts entities to the end of listings and marks them as test-owned
|
||||
/// teardown targets.
|
||||
/// </summary>
|
||||
/// <param name="kind">Short entity discriminator, e.g. <c>"tmpl"</c> or <c>"inst"</c>.</param>
|
||||
public static string UniqueName(string kind) =>
|
||||
$"zztest-{kind}-{Guid.NewGuid().ToString("N")[..8]}";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a template via <c>template create</c> and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
/// <param name="name">Template name (typically from <see cref="UniqueName"/>).</param>
|
||||
/// <param name="description">Optional template description.</param>
|
||||
/// <returns>The id of the newly created template.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The CLI failed, or the response did not carry an integer <c>id</c>.
|
||||
/// </exception>
|
||||
public static async Task<int> CreateTemplateAsync(string name, string? description = null)
|
||||
{
|
||||
var args = new List<string> { "template", "create", "--name", name };
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
{
|
||||
args.Add("--description");
|
||||
args.Add(description);
|
||||
}
|
||||
|
||||
using var doc = await RunJsonAsync([.. args]);
|
||||
return RequireId(doc, "template create");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an attribute to a template via <c>template attribute add</c>.
|
||||
/// </summary>
|
||||
/// <param name="templateId">Owning template id.</param>
|
||||
/// <param name="name">Attribute name.</param>
|
||||
/// <param name="dataType">
|
||||
/// CLI data-type token; one of the <c>DataType</c> enum names
|
||||
/// (<c>Boolean</c>, <c>Int32</c>, <c>Double</c>, <c>String</c>).
|
||||
/// Defaults to <c>Double</c>.
|
||||
/// </param>
|
||||
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
|
||||
public static async Task AddAttributeAsync(int templateId, string name, string dataType = "Double")
|
||||
{
|
||||
await RunAsync(
|
||||
"template", "attribute", "add",
|
||||
"--template-id", templateId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"--name", name,
|
||||
"--data-type", dataType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an area under a site via <c>site area create</c> and returns its
|
||||
/// new <c>id</c>.
|
||||
/// </summary>
|
||||
/// <param name="siteId">Owning site id.</param>
|
||||
/// <param name="name">Area name.</param>
|
||||
/// <returns>The id of the newly created area.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The CLI failed, or the response did not carry an integer <c>id</c>.
|
||||
/// </exception>
|
||||
public static async Task<int> CreateAreaAsync(int siteId, string name)
|
||||
{
|
||||
using var doc = await RunJsonAsync(
|
||||
"site", "area", "create",
|
||||
"--site-id", siteId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"--name", name);
|
||||
return RequireId(doc, "site area create");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a site's numeric id from its <c>siteIdentifier</c> (e.g.
|
||||
/// <c>"site-a"</c>) via <c>site list</c>.
|
||||
/// </summary>
|
||||
/// <param name="identifier">The <c>siteIdentifier</c> to match.</param>
|
||||
/// <returns>The matching site's id.</returns>
|
||||
/// <exception cref="InvalidOperationException">No site matched the identifier.</exception>
|
||||
public static async Task<int> ResolveSiteIdAsync(string identifier)
|
||||
{
|
||||
using var doc = await RunJsonAsync("site", "list");
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var site in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (site.TryGetProperty("siteIdentifier", out var idtf)
|
||||
&& idtf.ValueKind == JsonValueKind.String
|
||||
&& string.Equals(idtf.GetString(), identifier, StringComparison.Ordinal)
|
||||
&& site.TryGetProperty("id", out var id)
|
||||
&& id.TryGetInt32(out var siteId))
|
||||
{
|
||||
return siteId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No site with siteIdentifier '{identifier}' found in 'site list'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instance via <c>instance create</c> and returns its new
|
||||
/// <c>id</c>.
|
||||
/// </summary>
|
||||
/// <param name="name">Instance unique name.</param>
|
||||
/// <param name="templateId">Template the instance is created from.</param>
|
||||
/// <param name="siteId">Target site id.</param>
|
||||
/// <param name="areaId">Optional area id to place the instance under.</param>
|
||||
/// <returns>The id of the newly created instance.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The CLI failed, or the response did not carry an integer <c>id</c>.
|
||||
/// </exception>
|
||||
public static async Task<int> CreateInstanceAsync(string name, int templateId, int siteId, int? areaId = null)
|
||||
{
|
||||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var args = new List<string>
|
||||
{
|
||||
"instance", "create",
|
||||
"--name", name,
|
||||
"--template-id", templateId.ToString(inv),
|
||||
"--site-id", siteId.ToString(inv),
|
||||
};
|
||||
if (areaId is { } area)
|
||||
{
|
||||
args.Add("--area-id");
|
||||
args.Add(area.ToString(inv));
|
||||
}
|
||||
|
||||
using var doc = await RunJsonAsync([.. args]);
|
||||
return RequireId(doc, "instance create");
|
||||
}
|
||||
|
||||
/// <summary>Deploys an instance via <c>instance deploy</c>. Surfaces CLI errors.</summary>
|
||||
/// <param name="id">Instance id.</param>
|
||||
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
|
||||
public static Task DeployInstanceAsync(int id) => RunInstanceVerbAsync("deploy", id);
|
||||
|
||||
/// <summary>Enables an instance via <c>instance enable</c>. Surfaces CLI errors.</summary>
|
||||
/// <param name="id">Instance id.</param>
|
||||
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
|
||||
public static Task EnableInstanceAsync(int id) => RunInstanceVerbAsync("enable", id);
|
||||
|
||||
/// <summary>Disables an instance via <c>instance disable</c>. Surfaces CLI errors.</summary>
|
||||
/// <param name="id">Instance id.</param>
|
||||
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
|
||||
public static Task DisableInstanceAsync(int id) => RunInstanceVerbAsync("disable", id);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort delete of an instance via <c>instance delete</c> for teardown;
|
||||
/// swallows any failure (the entity may already be gone).
|
||||
/// </summary>
|
||||
/// <param name="id">Instance id.</param>
|
||||
public static Task DeleteInstanceAsync(int id) => BestEffortAsync("instance", "delete", id);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort delete of a template via <c>template delete</c> for teardown;
|
||||
/// swallows any failure.
|
||||
/// </summary>
|
||||
/// <param name="id">Template id.</param>
|
||||
public static Task DeleteTemplateAsync(int id) => BestEffortAsync("template", "delete", id);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort delete of an area via <c>site area delete</c> for teardown;
|
||||
/// swallows any failure.
|
||||
/// </summary>
|
||||
/// <param name="id">Area id.</param>
|
||||
/// <remarks>
|
||||
/// This method intentionally does NOT delegate to <see cref="BestEffortAsync"/>
|
||||
/// even though the behaviour is identical. <see cref="BestEffortAsync"/> models
|
||||
/// two-word commands (<c><group> <verb></c>), whereas
|
||||
/// <c>site area delete</c> is a three-word command; extracting it would require
|
||||
/// changing <see cref="BestEffortAsync"/>'s signature or adding an overload.
|
||||
/// The inline try/catch is kept here deliberately — if you need to fix teardown
|
||||
/// logic, update both this method and any other three-word deletes together.
|
||||
/// </remarks>
|
||||
public static async Task DeleteAreaAsync(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync(
|
||||
"site", "area", "delete",
|
||||
"--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort teardown — never mask the test's own failure.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort delete of a site via <c>site delete</c> for teardown; swallows
|
||||
/// any failure.
|
||||
/// </summary>
|
||||
/// <param name="id">Site id.</param>
|
||||
public static Task DeleteSiteAsync(int id) => BestEffortAsync("site", "delete", id);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ids of all templates whose <c>name</c> starts with
|
||||
/// <paramref name="prefix"/>, via <c>template list</c>.
|
||||
/// </summary>
|
||||
/// <param name="prefix">Name prefix to filter by (ordinal comparison).</param>
|
||||
public static async Task<IReadOnlyList<int>> ListTemplateIdsByNamePrefixAsync(string prefix)
|
||||
{
|
||||
using var doc = await RunJsonAsync("template", "list");
|
||||
var ids = new List<int>();
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var tmpl in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (tmpl.TryGetProperty("name", out var name)
|
||||
&& name.ValueKind == JsonValueKind.String
|
||||
&& (name.GetString()?.StartsWith(prefix, StringComparison.Ordinal) ?? false)
|
||||
&& tmpl.TryGetProperty("id", out var id)
|
||||
&& id.TryGetInt32(out var templateId))
|
||||
{
|
||||
ids.Add(templateId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports a Transport bundle scoped to a single template via
|
||||
/// <c>bundle export</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The CLI's <c>bundle export</c> scopes templates <em>by name</em>
|
||||
/// (<c>--templates <comma-separated names></c>) — there is no id-based
|
||||
/// selector — so this resolves the template's name from
|
||||
/// <paramref name="templateId"/> via <c>template list</c> and passes that
|
||||
/// single name. Exactly one matching template must exist.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="outputPath">Destination <c>.scadabundle</c> path.</param>
|
||||
/// <param name="templateId">Id of the single template to export.</param>
|
||||
/// <param name="passphrase">Encryption passphrase for the bundle.</param>
|
||||
/// <param name="sourceEnvironment">
|
||||
/// <c>SourceEnvironment</c> value stamped into the bundle manifest.
|
||||
/// </param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The template id could not be resolved to a name, or the CLI failed.
|
||||
/// </exception>
|
||||
public static async Task BundleExportAsync(
|
||||
string outputPath, int templateId, string passphrase, string sourceEnvironment)
|
||||
{
|
||||
var templateName = await ResolveTemplateNameAsync(templateId);
|
||||
|
||||
// The CLI's --templates flag is comma-separated, so a name that itself
|
||||
// contains a comma would silently split into multiple selectors and scope
|
||||
// the export to the wrong set of templates.
|
||||
if (templateName.Contains(','))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Template name '{templateName}' contains a comma and cannot be used with '--templates'.");
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"bundle", "export",
|
||||
"--output", outputPath,
|
||||
"--passphrase", passphrase,
|
||||
"--templates", templateName,
|
||||
"--source-environment", sourceEnvironment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a template's name from its id via <c>template list</c>.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">No template matched the id.</exception>
|
||||
private static async Task<string> ResolveTemplateNameAsync(int templateId)
|
||||
{
|
||||
using var doc = await RunJsonAsync("template", "list");
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var tmpl in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (tmpl.TryGetProperty("id", out var id)
|
||||
&& id.TryGetInt32(out var foundId)
|
||||
&& foundId == templateId
|
||||
&& tmpl.TryGetProperty("name", out var name)
|
||||
&& name.ValueKind == JsonValueKind.String
|
||||
&& name.GetString() is { } templateName)
|
||||
{
|
||||
return templateName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No template with id {templateId} found in 'template list'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs an <c>instance <verb> --id <id></c> command, surfacing CLI
|
||||
/// failures to the caller.
|
||||
/// </summary>
|
||||
private static async Task RunInstanceVerbAsync(string verb, int id)
|
||||
{
|
||||
await RunAsync(
|
||||
"instance", verb,
|
||||
"--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a best-effort delete-style command, swallowing any failure so teardown
|
||||
/// in a <c>finally</c> never masks the test's own outcome.
|
||||
/// </summary>
|
||||
private static async Task BestEffortAsync(string group, string verb, int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync(
|
||||
group, verb,
|
||||
"--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort teardown — the entity may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a required integer <c>id</c> from a create-command response,
|
||||
/// throwing a descriptive error if it is missing or non-integral.
|
||||
/// </summary>
|
||||
private static int RequireId(JsonDocument doc, string command)
|
||||
{
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("id", out var id)
|
||||
&& id.TryGetInt32(out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"'{command}' response did not contain an integer 'id': {doc.RootElement.GetRawText()}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// Subprocess runner for the ScadaBridge CLI, used by state-changing Central UI
|
||||
/// Playwright E2E tests to provision fixtures (sites, templates, instances, …)
|
||||
/// against the running dev cluster.
|
||||
///
|
||||
/// <para>
|
||||
/// Shells out to <c>dotnet scadabridge.dll --url <Url> --username multi-role
|
||||
/// --password password --format json <args></c>. The CLI uses Basic Auth per
|
||||
/// request; <c>multi-role</c>/<c>password</c> carries Admin + Design + Deployment
|
||||
/// roles. Exit code 0 = success and JSON is printed to stdout; a non-zero exit
|
||||
/// surfaces as an <see cref="InvalidOperationException"/> carrying the captured
|
||||
/// stderr.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The built CLI assembly is named <c>scadabridge.dll</c> (the test project takes
|
||||
/// a <c>ReferenceOutputAssembly="false"</c> ProjectReference on the CLI so it is
|
||||
/// always built alongside the tests). <see cref="ResolveCliDll"/> honours the
|
||||
/// <c>SCADABRIDGE_CLI_DLL</c> env override, otherwise walks up from the test
|
||||
/// assembly to the repo root and probes the Debug/Release output paths.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static partial class CliRunner
|
||||
{
|
||||
/// <summary>Hard timeout for a single CLI invocation.</summary>
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
private const string ManagementUrlEnvVar = "SCADABRIDGE_MANAGEMENT_URL";
|
||||
private const string CliDllEnvVar = "SCADABRIDGE_CLI_DLL";
|
||||
private const string DefaultUrl = "http://localhost:9000";
|
||||
|
||||
private const string CliProjectRelativeDir = "src/ZB.MOM.WW.ScadaBridge.CLI";
|
||||
private const string CliDllName = "scadabridge.dll";
|
||||
|
||||
private const string CliUsername = "multi-role";
|
||||
private const string CliPassword = "password";
|
||||
|
||||
private static readonly object DllLock = new();
|
||||
private static volatile string? _cliDll;
|
||||
|
||||
/// <summary>
|
||||
/// Management URL the CLI connects to (via the Traefik load balancer).
|
||||
/// Resolved from <c>SCADABRIDGE_MANAGEMENT_URL</c> when set, otherwise the
|
||||
/// local docker dev default (<c>http://localhost:9000</c>).
|
||||
/// </summary>
|
||||
public static string Url
|
||||
{
|
||||
get
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable(ManagementUrlEnvVar);
|
||||
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultUrl : fromEnv;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the CLI with the given <paramref name="args"/> and returns its raw
|
||||
/// stdout. The connection flags (<c>--url</c>, <c>--username</c>,
|
||||
/// <c>--password</c>, <c>--format json</c>) are prepended automatically.
|
||||
/// </summary>
|
||||
/// <exception cref="TimeoutException">
|
||||
/// The invocation exceeded the 60-second timeout; the process tree is killed.
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The CLI exited non-zero; the message carries the captured stderr.
|
||||
/// </exception>
|
||||
public static async Task<string> RunAsync(params string[] args)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(ResolveCliDll());
|
||||
startInfo.ArgumentList.Add("--url");
|
||||
startInfo.ArgumentList.Add(Url);
|
||||
startInfo.ArgumentList.Add("--username");
|
||||
startInfo.ArgumentList.Add(CliUsername);
|
||||
startInfo.ArgumentList.Add("--password");
|
||||
startInfo.ArgumentList.Add(CliPassword);
|
||||
startInfo.ArgumentList.Add("--format");
|
||||
startInfo.ArgumentList.Add("json");
|
||||
foreach (var arg in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to start CLI process for [{string.Join(' ', args)}].");
|
||||
}
|
||||
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryKill(process);
|
||||
// Drain both pipes so the abandoned read tasks complete cleanly before disposal.
|
||||
try { await Task.WhenAll(stdoutTask, stderrTask).WaitAsync(TimeSpan.FromSeconds(5)); }
|
||||
catch { /* best-effort — we are already throwing TimeoutException */ }
|
||||
throw new TimeoutException(
|
||||
$"CLI [{string.Join(' ', args)}] did not exit within {Timeout.TotalSeconds:F0}s and was killed.");
|
||||
}
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"CLI [{string.Join(' ', args)}] exited {process.ExitCode}. stderr: {stderr}");
|
||||
}
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the CLI with the given <paramref name="args"/> and parses its stdout
|
||||
/// as a <see cref="JsonDocument"/>. The caller owns the returned document and
|
||||
/// must dispose it.
|
||||
/// </summary>
|
||||
public static async Task<JsonDocument> RunJsonAsync(params string[] args) =>
|
||||
JsonDocument.Parse(await RunAsync(args));
|
||||
|
||||
/// <summary>
|
||||
/// Locates the built <c>scadabridge.dll</c>. Honours the
|
||||
/// <c>SCADABRIDGE_CLI_DLL</c> env override; otherwise walks up from the test
|
||||
/// assembly's base directory to the repo root (the directory containing
|
||||
/// <c>src/ZB.MOM.WW.ScadaBridge.CLI</c>) and probes the <c>Debug</c>/<c>Release</c>
|
||||
/// build outputs — preferring the configuration the tests were built under.
|
||||
/// The resolved path is cached.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The dll could not be found; the message lists every probed path.
|
||||
/// </exception>
|
||||
private static string ResolveCliDll()
|
||||
{
|
||||
if (_cliDll is { } cached)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
lock (DllLock)
|
||||
{
|
||||
if (_cliDll is { } cachedInLock)
|
||||
{
|
||||
return cachedInLock;
|
||||
}
|
||||
|
||||
var fromEnv = Environment.GetEnvironmentVariable(CliDllEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||
{
|
||||
if (!File.Exists(fromEnv))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{CliDllEnvVar} is set to '{fromEnv}' but no file exists there.");
|
||||
}
|
||||
|
||||
_cliDll = fromEnv;
|
||||
return _cliDll;
|
||||
}
|
||||
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var repoRoot = FindRepoRoot(baseDir)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Could not locate the repo root (a directory containing '{CliProjectRelativeDir}') " +
|
||||
$"by walking up from '{baseDir}'. Set {CliDllEnvVar} to the absolute path of {CliDllName}.");
|
||||
|
||||
var cliProjectDir = Path.Combine(repoRoot, CliProjectRelativeDir.Replace('/', Path.DirectorySeparatorChar));
|
||||
var binDir = Path.Combine(cliProjectDir, "bin");
|
||||
|
||||
// Prefer the configuration the tests were built under (derived from
|
||||
// the test assembly path: …/bin/<config>/net10.0/), then fall back.
|
||||
var probeConfigs = OrderConfigs(baseDir);
|
||||
|
||||
var probed = new List<string>();
|
||||
foreach (var config in probeConfigs)
|
||||
{
|
||||
var candidate = Path.Combine(binDir, config, "net10.0", CliDllName);
|
||||
probed.Add(candidate);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
_cliDll = candidate;
|
||||
return _cliDll;
|
||||
}
|
||||
}
|
||||
|
||||
var probedList = string.Join(Environment.NewLine + " ", probed);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not find {CliDllName}. Build the CLI (it is referenced by this test project) " +
|
||||
$"or set {CliDllEnvVar}. Probed:" + Environment.NewLine + " " + probedList);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks up from <paramref name="startDir"/> looking for the repo root, i.e.
|
||||
/// the first ancestor directory that contains <c>src/ZB.MOM.WW.ScadaBridge.CLI</c>.
|
||||
/// Returns <see langword="null"/> if no such ancestor exists.
|
||||
/// </summary>
|
||||
private static string? FindRepoRoot(string startDir)
|
||||
{
|
||||
var marker = CliProjectRelativeDir.Replace('/', Path.DirectorySeparatorChar);
|
||||
for (var dir = new DirectoryInfo(startDir); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(dir.FullName, marker)))
|
||||
{
|
||||
return dir.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the build configurations to probe, ordered so the configuration the
|
||||
/// tests were built under (inferred from the test assembly path segment, e.g.
|
||||
/// <c>…/bin/Release/net10.0/</c>) is tried first.
|
||||
/// </summary>
|
||||
private static string[] OrderConfigs(string baseDir)
|
||||
{
|
||||
var normalized = baseDir.Replace('\\', '/');
|
||||
if (normalized.Contains("/bin/Release/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ["Release", "Debug"];
|
||||
}
|
||||
|
||||
// Default to Debug-first (covers /bin/Debug/ and any unrecognised layout).
|
||||
return ["Debug", "Release"];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort kill of the CLI process and its descendants. Swallows any error
|
||||
/// raised by a race with normal exit.
|
||||
/// </summary>
|
||||
private static void TryKill(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — the process may have exited between the check and the
|
||||
// kill, which is the outcome we wanted anyway.
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// TDD coverage for the typed <see cref="CliRunner"/> fixture helpers used by
|
||||
/// state-changing Central UI Playwright E2E tests to provision and tear down
|
||||
/// templates, attributes, areas, and instances against the running dev cluster.
|
||||
/// When the cluster / MSSQL is unreachable the facts report as Skipped (not
|
||||
/// Failed), matching the established suite idiom.
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class CliRunnerHelpersTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A freshly created template is discoverable by name prefix and can be
|
||||
/// deleted, exercising <see cref="CliRunner.CreateTemplateAsync"/>,
|
||||
/// <see cref="CliRunner.ListTemplateIdsByNamePrefixAsync"/>, and
|
||||
/// <see cref="CliRunner.DeleteTemplateAsync"/> as a round-trip.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CreateThenDeleteTemplate_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var name = CliRunner.UniqueName("tmpl");
|
||||
int id = await CliRunner.CreateTemplateAsync(name);
|
||||
try
|
||||
{
|
||||
var ids = await CliRunner.ListTemplateIdsByNamePrefixAsync(name);
|
||||
Assert.Contains(id, ids);
|
||||
}
|
||||
finally { await CliRunner.DeleteTemplateAsync(id); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CliRunner.ResolveSiteIdAsync"/> finds the well-known
|
||||
/// <c>site-a</c> seed site by its <c>siteIdentifier</c> and returns a
|
||||
/// positive id.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task ResolveSiteA_ReturnsId()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
Assert.True(await CliRunner.ResolveSiteIdAsync("site-a") > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// TDD smoke coverage for <see cref="CliRunner"/> and
|
||||
/// <see cref="ClusterAvailability"/>. Confirms the subprocess runner can locate
|
||||
/// the built <c>scadabridge.dll</c>, shell out through <c>dotnet</c>, and parse
|
||||
/// the JSON the CLI prints to stdout. When the dev cluster / MSSQL is unreachable
|
||||
/// the fact reports as Skipped (not Failed), matching the established
|
||||
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests</c> idiom.
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class CliRunnerSmokeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>scadabridge site list</c> round-trips through the CLI and returns a
|
||||
/// JSON document (the CLI emits a JSON array of sites in <c>--format json</c>).
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task SiteList_ReturnsJson()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
using var doc = await CliRunner.RunJsonAsync("site", "list");
|
||||
Assert.True(doc.RootElement.ValueKind is JsonValueKind.Array or JsonValueKind.Object);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot probe for whether the dev cluster (and its MSSQL config store) is
|
||||
/// reachable through the CLI. State-changing E2E tests gate their setup on this so
|
||||
/// a downed cluster surfaces as a Skipped fact (via <c>Skip.IfNot</c>) rather than
|
||||
/// an opaque failure. The result is cached for the process; <see cref="SkippedCount"/>
|
||||
/// is bumped each time a test would skip and is logged at suite teardown (Task 3).
|
||||
/// </summary>
|
||||
public static class ClusterAvailability
|
||||
{
|
||||
/// <summary>Reason surfaced on skipped facts when the cluster is unavailable.</summary>
|
||||
public const string SkipReason =
|
||||
"Cluster/MSSQL unavailable — start the docker cluster (bash docker/deploy.sh) to run E2E.";
|
||||
|
||||
private static bool? _cached;
|
||||
|
||||
/// <summary>
|
||||
/// Number of times a test would have skipped because the cluster is
|
||||
/// unavailable. Incremented on every <see cref="IsAvailableAsync"/> call that
|
||||
/// returns <see langword="false"/>; logged at suite teardown (Task 3).
|
||||
/// </summary>
|
||||
public static int SkippedCount;
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the cluster is reachable, probing once (a <c>site list</c>
|
||||
/// round-trip through the CLI) and caching the result for the process.
|
||||
/// </summary>
|
||||
public static async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
if (_cached is { } cached)
|
||||
{
|
||||
if (!cached)
|
||||
{
|
||||
System.Threading.Interlocked.Increment(ref SkippedCount);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var _ = await CliRunner.RunJsonAsync("site", "list");
|
||||
_cached = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_cached = false;
|
||||
System.Threading.Interlocked.Increment(ref SkippedCount);
|
||||
}
|
||||
|
||||
return _cached.Value;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for the dev-cluster database credential used by the
|
||||
/// Playwright E2E suite.
|
||||
///
|
||||
/// <para>
|
||||
/// The connection string mirrors the Docker cluster's <c>scadabridge_app</c>
|
||||
/// account from <c>docker/central-node-a/appsettings.Central.json</c>, with the
|
||||
/// host pointed at the host-exposed port (<c>localhost:1433</c>). The
|
||||
/// <c>SCADABRIDGE_PLAYWRIGHT_DB</c> environment variable lets CI override the
|
||||
/// connection without recompiling.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class PlaywrightDbConnection
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5";
|
||||
|
||||
private const string EnvVar = "SCADABRIDGE_PLAYWRIGHT_DB";
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the running cluster's configuration DB. Resolved
|
||||
/// from <c>SCADABRIDGE_PLAYWRIGHT_DB</c> when set (non-whitespace), otherwise
|
||||
/// the local docker dev defaults.
|
||||
/// </summary>
|
||||
public static string ConnectionString
|
||||
{
|
||||
get
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
|
||||
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// Makes the suite's skip policy visible: at process exit, writes one console
|
||||
/// line reporting how many cluster/DB-dependent test gates skipped because the
|
||||
/// cluster was unavailable (see <see cref="ClusterAvailability.SkippedCount"/>).
|
||||
/// Registered once via a <see cref="ModuleInitializerAttribute"/> so it runs
|
||||
/// regardless of how the test host is launched (Task 3).
|
||||
/// </summary>
|
||||
internal static class SkipSummaryReporter
|
||||
{
|
||||
[ModuleInitializer]
|
||||
internal static void Init() =>
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) =>
|
||||
{
|
||||
if (ClusterAvailability.SkippedCount > 0)
|
||||
{
|
||||
Console.WriteLine($"[E2E] Skipped {ClusterAvailability.SkippedCount} cluster/DB-dependent test gate(s) — {ClusterAvailability.SkipReason}");
|
||||
}
|
||||
};
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// E2E coverage for the per-instance deploy-family actions exposed from the
|
||||
/// Topology tree's right-click context menu — Deploy, Disable, Enable, and
|
||||
/// Delete. The <see cref="OpenInstanceContextMenuAsync"/> helper (navigate →
|
||||
/// expand → locate the instance row → right-click) is shared across every fact so
|
||||
/// none has to re-derive the tree navigation.
|
||||
///
|
||||
/// <para>
|
||||
/// Each fact mints a fresh ephemeral instance on the real <c>site-a</c> (via
|
||||
/// <see cref="DeploymentFixture.CreateInstanceAsync"/>), exercises the action
|
||||
/// against the live cluster, then deletes the instance in a <c>finally</c>.
|
||||
/// Outcomes are tolerant: the relay to <c>site-a</c> may return a confirmation or
|
||||
/// a fast error, but either way a single outcome toast appears, which every fact
|
||||
/// asserts with a single web-first <c>ToHaveCountAsync(1)</c> (it retries
|
||||
/// atomically, so it neither flakes on the relay round-trip nor races the toast's
|
||||
/// ~5s auto-dismiss).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The context menu offered per instance depends on the instance's config state
|
||||
/// (verified against <c>Topology.razor</c>): <c>Deploy</c>/<c>Redeploy</c> and
|
||||
/// <c>Delete</c> are always present; <c>Disable</c> is shown only when the state
|
||||
/// is <c>Enabled</c>, and <c>Enable</c> only when <c>Disabled</c>. So the
|
||||
/// Disable/Enable facts drive the precondition state deterministically over the
|
||||
/// CLI (<c>deploy</c> → Enabled; <c>deploy</c> then <c>disable</c> → Disabled)
|
||||
/// before opening the menu. <c>Deploy</c> and <c>Enable</c> fire with no confirm
|
||||
/// dialog; <c>Disable</c> and <c>Delete</c> are danger actions that first raise a
|
||||
/// confirm modal whose confirm button is <c>.modal-footer .btn-danger</c> (labelled
|
||||
/// <c>Delete</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class DeploymentActionTests : IClassFixture<DeploymentFixture>
|
||||
{
|
||||
private readonly PlaywrightFixture _pw;
|
||||
private readonly DeploymentFixture _cluster;
|
||||
|
||||
public DeploymentActionTests(PlaywrightFixture pw, DeploymentFixture cluster)
|
||||
{
|
||||
_pw = pw;
|
||||
_cluster = cluster;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Deploy_Instance_ShowsOutcomeToast()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await OpenInstanceContextMenuAsync(page, uniqueName);
|
||||
|
||||
// A fresh instance is NotDeployed, so the action reads "Deploy" (it would
|
||||
// read "Redeploy" only for a stale, already-deployed node). Deploy has no
|
||||
// confirm dialog — clicking relays to site-a immediately.
|
||||
await page.Locator(".dropdown-menu.show button.dropdown-item", new() { HasText = "Deploy" })
|
||||
.ClickAsync();
|
||||
|
||||
// The relay outcome surfaces on a toast — deployed confirmation or a fast
|
||||
// error. We assert exactly one toast (the single-toast contract), not which
|
||||
// outcome, since the live cluster may answer either way. The toast
|
||||
// auto-dismisses ~5s after it appears, so a separate ToBeVisible + CountAsync
|
||||
// would race that dismissal (a TOCTOU between the two reads); ToHaveCountAsync
|
||||
// retries atomically against the live DOM, with a generous wait for the relay
|
||||
// round-trip.
|
||||
var toast = page.Locator(".toast");
|
||||
await Assertions.Expect(toast).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Disable_Instance_ShowsOutcomeToast()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
// Deploy moves the instance config state to Enabled, so the context menu
|
||||
// offers "Disable" (it would offer "Enable" only for a Disabled instance).
|
||||
await CliRunner.DeployInstanceAsync(instanceId);
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await OpenInstanceContextMenuAsync(page, uniqueName);
|
||||
|
||||
await page.Locator(".dropdown-menu.show button.dropdown-item", new() { HasText = "Disable" })
|
||||
.ClickAsync();
|
||||
|
||||
// Disable is a danger action: it raises a confirm modal first. Confirm via
|
||||
// the danger button (labelled "Delete" for every danger confirm).
|
||||
await page.Locator(".modal-footer .btn-danger", new() { HasText = "Delete" })
|
||||
.ClickAsync();
|
||||
|
||||
// One outcome toast (confirmation or fast error). ToHaveCountAsync retries
|
||||
// atomically, so it survives the relay round-trip and the toast's ~5s dismiss.
|
||||
await Assertions.Expect(page.Locator(".toast"))
|
||||
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Enable_Instance_ShowsOutcomeToast()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
// Deploy then Disable leaves the instance config state at Disabled, so the
|
||||
// context menu offers "Enable".
|
||||
await CliRunner.DeployInstanceAsync(instanceId);
|
||||
await CliRunner.DisableInstanceAsync(instanceId);
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await OpenInstanceContextMenuAsync(page, uniqueName);
|
||||
|
||||
// Enable fires immediately — no confirm dialog.
|
||||
await page.Locator(".dropdown-menu.show button.dropdown-item", new() { HasText = "Enable" })
|
||||
.ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".toast"))
|
||||
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Delete_Instance_RemovesFromTree()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
var page = await _pw.NewAuthenticatedPageAsync();
|
||||
await OpenInstanceContextMenuAsync(page, uniqueName);
|
||||
|
||||
// Delete is always present (independent of state) and is the danger item.
|
||||
await page.Locator(".dropdown-menu.show button.dropdown-item.text-danger")
|
||||
.ClickAsync();
|
||||
|
||||
// Danger action: confirm via the modal's danger button.
|
||||
await page.Locator(".modal-footer .btn-danger", new() { HasText = "Delete" })
|
||||
.ClickAsync();
|
||||
|
||||
// The delete succeeds locally (NotDeployed instance) and the tree reloads,
|
||||
// so the row for this instance disappears. ToHaveCountAsync(0) retries until
|
||||
// the reload settles.
|
||||
await Assertions.Expect(page.Locator("div.tv-row", new() { HasText = uniqueName }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort net: if the UI delete failed, this removes the leftover
|
||||
// instance; if it already deleted, this is a no-op (delete is best-effort).
|
||||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the Topology page, expands the tree so the instance row under
|
||||
/// <c>site-a → zztest area</c> is rendered, locates the row by its
|
||||
/// <paramref name="uniqueName"/> label, and opens its right-click context menu.
|
||||
/// Returns the located row so callers can re-target it if needed.
|
||||
///
|
||||
/// <para>
|
||||
/// Live updates are switched off first: the page reloads the tree on a 15s timer,
|
||||
/// which would collapse a freshly-expanded node and tear down an open context
|
||||
/// menu mid-interaction. With it off the tree stays stable through the action.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static async Task<ILocator> OpenInstanceContextMenuAsync(IPage page, string uniqueName)
|
||||
{
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/topology");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Stop the 15s live-updates timer from rebuilding the tree under us.
|
||||
var liveToggle = page.Locator("#live-updates");
|
||||
if (await liveToggle.IsCheckedAsync())
|
||||
{
|
||||
await liveToggle.UncheckAsync();
|
||||
}
|
||||
|
||||
// ExpandAll() recurses every branch (site → area → …), so one click reveals
|
||||
// the nested instance row under site-a's zztest area.
|
||||
await page.Locator("button[aria-label='Expand all areas']").ClickAsync();
|
||||
|
||||
var row = page.Locator("div.tv-row", new() { HasText = uniqueName });
|
||||
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// The tree scrolls inside a fixed-height container; bring the row into view
|
||||
// so the right-click lands on it and the menu renders at a usable position.
|
||||
await row.ScrollIntoViewIfNeededAsync();
|
||||
await row.ClickAsync(new() { Button = MouseButton.Right });
|
||||
|
||||
await Assertions.Expect(page.Locator(".dropdown-menu.show"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 5_000 });
|
||||
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAsyncLifetime"/> fixture that provisions the shared, ephemeral
|
||||
/// scaffolding (a template + an area on <c>site-a</c>) the deploy-action E2E test
|
||||
/// class consumes via <c>IClassFixture<DeploymentFixture></c>, and mints
|
||||
/// per-test instances on demand via <see cref="CreateInstanceAsync"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why <c>site-a</c> and not a throwaway site:</b> the deploy / enable / disable
|
||||
/// actions relay to the owning site over the Akka <c>ClusterClient</c>. An unknown
|
||||
/// site identifier has no registered <c>ClusterClient</c>, so the relay only
|
||||
/// resolves on a slow 10-second timeout (and never surfaces a fast failure toast).
|
||||
/// To exercise the real action path, the ephemeral instances must therefore live
|
||||
/// on a <em>real, running</em> site — <c>site-a</c> — rather than a fixture-created
|
||||
/// throwaway site.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Provisioning is gated on <see cref="ClusterAvailability.IsAvailableAsync"/>:
|
||||
/// when the dev cluster is down, <see cref="InitializeAsync"/> sets
|
||||
/// <see cref="Available"/> to <see langword="false"/> and returns early without
|
||||
/// touching the cluster, so the consuming tests can skip (via <c>Skip.IfNot</c>)
|
||||
/// and teardown becomes a no-op.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Cleanup is best-effort and swallows every error: <see cref="DisposeAsync"/>
|
||||
/// lists the instances on <c>site-a</c>, deletes any whose name/unique name carries
|
||||
/// the <c>zztest-inst-</c> fixture prefix, then deletes the fixture's area and
|
||||
/// template. The <c>zztest-</c> prefix marks entities as test-owned so teardown
|
||||
/// never touches cluster-owned data.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DeploymentFixture : IAsyncLifetime
|
||||
{
|
||||
private const string SiteAIdentifier = "site-a";
|
||||
|
||||
/// <summary>
|
||||
/// Fixture-name prefix for the per-test instances minted by
|
||||
/// <see cref="CreateInstanceAsync"/>; teardown deletes <c>site-a</c> instances
|
||||
/// carrying this prefix.
|
||||
/// </summary>
|
||||
private const string InstanceNamePrefix = "zztest-inst-";
|
||||
|
||||
/// <summary>Numeric id of the real, running <c>site-a</c> the fixture provisions onto.</summary>
|
||||
public int SiteAId { get; private set; }
|
||||
|
||||
/// <summary>Id of the ephemeral template instances are created from.</summary>
|
||||
public int TemplateId { get; private set; }
|
||||
|
||||
/// <summary>Id of the ephemeral area on <c>site-a</c> instances are placed under.</summary>
|
||||
public int AreaId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the dev cluster was reachable at setup. When <see langword="false"/>,
|
||||
/// nothing was provisioned: consuming tests should skip and teardown is a no-op.
|
||||
/// </summary>
|
||||
public bool Available { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Probes cluster availability and, when reachable, provisions the shared
|
||||
/// scaffolding: resolves <c>site-a</c>'s id, creates an ephemeral template with a
|
||||
/// single <c>Double</c> <c>Value</c> attribute (so the template validates and is
|
||||
/// deployable), and creates an ephemeral area on <c>site-a</c>. When the cluster
|
||||
/// is unavailable, sets <see cref="Available"/> to <see langword="false"/> and
|
||||
/// returns without touching the cluster.
|
||||
/// </summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
Available = await ClusterAvailability.IsAvailableAsync();
|
||||
if (!Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SiteAId = await CliRunner.ResolveSiteIdAsync(SiteAIdentifier);
|
||||
TemplateId = await CliRunner.CreateTemplateAsync(CliRunner.UniqueName("deploytmpl"));
|
||||
await CliRunner.AddAttributeAsync(TemplateId, "Value", "Double");
|
||||
AreaId = await CliRunner.CreateAreaAsync(SiteAId, CliRunner.UniqueName("area"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Partial-init guard: best-effort cleanup of whatever was created before
|
||||
// the failure; DeleteAreaAsync/DeleteTemplateAsync are no-ops for id 0.
|
||||
await CliRunner.DeleteAreaAsync(AreaId);
|
||||
await CliRunner.DeleteTemplateAsync(TemplateId);
|
||||
Available = false;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fresh ephemeral instance from the fixture's template, on
|
||||
/// <c>site-a</c>, under the fixture's area, and returns both its numeric
|
||||
/// <c>id</c> and its server-assigned <c>uniqueName</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The <c>uniqueName</c> is read straight off the <c>instance create</c> response
|
||||
/// because it can differ from the <c>--name</c> passed in (the server may
|
||||
/// area-/path-qualify it). The Topology tree renders this <c>uniqueName</c> in
|
||||
/// <c>span.tv-label</c>, so deploy tests must locate the instance row by the
|
||||
/// returned <c>uniqueName</c>, not by the name they requested.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>The new instance's <c>id</c> and server-assigned <c>uniqueName</c>.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The CLI failed, or the response did not carry an integer <c>id</c> and a
|
||||
/// string <c>uniqueName</c>.
|
||||
/// </exception>
|
||||
public async Task<(int Id, string UniqueName)> CreateInstanceAsync()
|
||||
{
|
||||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||
// "inst" must match the InstanceNamePrefix ("zztest-inst-") used by the teardown sweep.
|
||||
var name = CliRunner.UniqueName("inst");
|
||||
|
||||
using var doc = await CliRunner.RunJsonAsync(
|
||||
"instance", "create",
|
||||
"--name", name,
|
||||
"--template-id", TemplateId.ToString(inv),
|
||||
"--site-id", SiteAId.ToString(inv),
|
||||
"--area-id", AreaId.ToString(inv));
|
||||
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("id", out var idElement)
|
||||
|| !idElement.TryGetInt32(out var id)
|
||||
|| !root.TryGetProperty("uniqueName", out var uniqueNameElement)
|
||||
|| uniqueNameElement.ValueKind != JsonValueKind.String
|
||||
|| uniqueNameElement.GetString() is not { } uniqueName)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"'instance create' response did not contain an integer 'id' and a string "
|
||||
+ $"'uniqueName': {root.GetRawText()}");
|
||||
}
|
||||
|
||||
return (id, uniqueName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort teardown: when the fixture provisioned anything, lists the
|
||||
/// instances on <c>site-a</c> and deletes any whose name/unique name starts with
|
||||
/// <see cref="InstanceNamePrefix"/>, then deletes the fixture's area and
|
||||
/// template. Swallows every error so a teardown hiccup never fails the suite.
|
||||
/// </summary>
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (!Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var instanceId in await ListFixtureInstanceIdsAsync())
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(instanceId);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort teardown — never fail the suite on a cleanup hiccup.
|
||||
}
|
||||
|
||||
try { await CliRunner.DeleteAreaAsync(AreaId); } catch { }
|
||||
try { await CliRunner.DeleteTemplateAsync(TemplateId); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists <c>site-a</c> via <c>instance list</c> and returns the ids of every
|
||||
/// instance whose <c>name</c> or <c>uniqueName</c> starts with
|
||||
/// <see cref="InstanceNamePrefix"/> — the fixture-owned instances to delete.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<int>> ListFixtureInstanceIdsAsync()
|
||||
{
|
||||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||
using var doc = await CliRunner.RunJsonAsync(
|
||||
"instance", "list",
|
||||
"--site-id", SiteAId.ToString(inv));
|
||||
|
||||
var ids = new List<int>();
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var instance in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (instance.ValueKind == JsonValueKind.Object
|
||||
&& HasFixturePrefix(instance)
|
||||
&& instance.TryGetProperty("id", out var id)
|
||||
&& id.TryGetInt32(out var instanceId))
|
||||
{
|
||||
ids.Add(instanceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether an instance JSON object's <c>name</c> or <c>uniqueName</c>
|
||||
/// carries the fixture's <see cref="InstanceNamePrefix"/>.
|
||||
/// </summary>
|
||||
private static bool HasFixturePrefix(JsonElement instance) =>
|
||||
StartsWithPrefix(instance, "name") || StartsWithPrefix(instance, "uniqueName");
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the named string property of <paramref name="instance"/>
|
||||
/// starts with <see cref="InstanceNamePrefix"/> (ordinal comparison).
|
||||
/// </summary>
|
||||
private static bool StartsWithPrefix(JsonElement instance, string property) =>
|
||||
instance.TryGetProperty(property, out var value)
|
||||
&& value.ValueKind == JsonValueKind.String
|
||||
&& (value.GetString()?.StartsWith(InstanceNamePrefix, StringComparison.Ordinal) ?? false);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end round-trip for the Template design pages:
|
||||
/// create → add attribute → delete, all via the Central UI against the
|
||||
/// running dev cluster.
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class TemplateCrudTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public TemplateCrudTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CreateAddAttributeDelete_Template_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var name = $"zztest-tmpl-{Guid.NewGuid().ToString("N")[..8]}";
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Name input is label-anchored: <label class="form-label">Name</label>
|
||||
// followed by <input class="form-control" @bind="_createName" />.
|
||||
// Use the label's sibling input scoped to the containing div to avoid any
|
||||
// strict-mode violation from the Description input (also form-control).
|
||||
await page.Locator("div.mb-3:has(label:has-text('Name')) input.form-control").FillAsync(name);
|
||||
|
||||
// Leave Parent Template at the default "(None - root template)".
|
||||
// Click the green Create button.
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Create')");
|
||||
|
||||
// After a successful create, Blazor navigates to /design/templates/{id}.
|
||||
// Poll window.location until the path matches /design/templates/ + digits
|
||||
// and does not still say /create.
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/design/templates/", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Sanity: we must be on a numeric template detail URL.
|
||||
Assert.Matches(@"/design/templates/\d+$", page.Url);
|
||||
|
||||
// ── ADD ATTRIBUTE ─────────────────────────────────────────────────────────
|
||||
// The Attributes tab is the default-active tab (_activeTab = "attributes"),
|
||||
// so we don't need to click it, but we do wait for the tab panel to render.
|
||||
await Assertions.Expect(
|
||||
page.Locator("button.nav-link:has-text('Attributes')"))
|
||||
.ToBeVisibleAsync();
|
||||
|
||||
// Click Add Attribute.
|
||||
await page.ClickAsync("button.btn.btn-primary.btn-sm:has-text('Add Attribute')");
|
||||
|
||||
// The modal is a page-local .modal.show.d-block — NOT the global DialogHost.
|
||||
// DialogHost adds a `fade` class; the page-local modal does not, so :not(.fade)
|
||||
// ensures we match only the page-local Add-Attribute modal.
|
||||
var modal = page.Locator(".modal.show.d-block:not(.fade)");
|
||||
await Assertions.Expect(modal).ToBeVisibleAsync();
|
||||
await Assertions.Expect(modal.Locator(".modal-title")).ToHaveTextAsync("Add Attribute");
|
||||
|
||||
// Fill Name field inside the modal.
|
||||
await modal.Locator("div.col-12:has(label:has-text('Name')) input.form-control").FillAsync("Val");
|
||||
|
||||
// Select Data Type = Double.
|
||||
// The select is label-anchored: <label>Data Type</label> + <select class="form-select">.
|
||||
await modal.Locator("div.col-12:has(label:has-text('Data Type')) select.form-select").SelectOptionAsync("Double");
|
||||
|
||||
// Click the footer Add button (btn-success btn-sm, text "Add").
|
||||
await modal.Locator(".modal-footer button.btn-success.btn-sm:has-text('Add')").ClickAsync();
|
||||
|
||||
// The modal should dismiss and the attribute table should show "Val".
|
||||
await Assertions.Expect(modal).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(page.Locator("table td:has-text('Val')"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
// Click the header Delete button (btn-outline-danger btn-sm, text "Delete").
|
||||
await page.ClickAsync("button.btn.btn-outline-danger.btn-sm:has-text('Delete')");
|
||||
|
||||
// The global DialogHost renders a confirm dialog.
|
||||
// The danger confirm button carries class "btn-danger" and text "Delete".
|
||||
var confirmBtn = page.Locator(".modal-footer .btn-danger:has-text('Delete')");
|
||||
await Assertions.Expect(confirmBtn).ToBeVisibleAsync(new() { Timeout = 5_000 });
|
||||
await confirmBtn.ClickAsync();
|
||||
|
||||
// After delete, Blazor navigates back to /design/templates (the list page).
|
||||
// excludePath: "/design/templates/" rejects any /design/templates/{id} detail URL.
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/design/templates", excludePath: "/design/templates/");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The template name must no longer appear in the tree view.
|
||||
await Assertions.Expect(
|
||||
page.Locator("span.tv-label", new() { HasText = name }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort safety net: clean up any leftover zztest-tmpl-* templates
|
||||
// that were not deleted by the UI (e.g. test failed mid-flow).
|
||||
try
|
||||
{
|
||||
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
||||
{
|
||||
await CliRunner.DeleteTemplateAsync(id);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — swallow to avoid masking the original test failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end guard for the Health dashboard KPI tiles (<c>/monitoring/health</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// The Health dashboard fans out to three Akka cluster-singleton <c>Ask</c>s every
|
||||
/// 10 s (Notification Outbox, Site Call, Audit) to populate nine KPI tiles. A
|
||||
/// previously-fixed bug caused those <c>Ask</c>s to hang, leaving every tile showing
|
||||
/// the em-dash degrade placeholder (<c>—</c>) instead of a resolved numeric value.
|
||||
/// This test guards that regression: it asserts that every tile resolves to a value
|
||||
/// and never shows <c>—</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The three tile groups and their selectors:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>Notification Outbox</b> — inlined in <c>Health.razor</c> with no
|
||||
/// <c>data-test</c> attribute. Each is a Bootstrap <c>div.card</c> whose
|
||||
/// <c>.card-body</c> contains a value <c>h3</c> and a <c>small.text-muted</c>
|
||||
/// label. Located by the card that contains the label text, then its <c>h3</c>.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Audit</b> — rendered by <c>AuditKpiTiles.razor</c>; each tile button
|
||||
/// carries a <c>data-test</c> attribute: <c>audit-kpi-volume</c>,
|
||||
/// <c>audit-kpi-error-rate</c>, <c>audit-kpi-backlog</c>.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Site Calls</b> — rendered by <c>SiteCallKpiTiles.razor</c>; each tile
|
||||
/// button carries a <c>data-test</c> attribute: <c>site-call-kpi-buffered</c>,
|
||||
/// <c>site-call-kpi-stuck</c>, <c>site-call-kpi-parked</c>.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class HealthDashboardTests
|
||||
{
|
||||
private const string HealthUrl = "/monitoring/health";
|
||||
|
||||
/// <summary>
|
||||
/// The degrade placeholder rendered when a KPI loader faults — an em-dash
|
||||
/// (U+2014). A healthy tile shows a non-negative integer instead.
|
||||
/// </summary>
|
||||
private const string DegradePlaceholder = "—"; // —
|
||||
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public HealthDashboardTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that all nine KPI tiles on the Health dashboard resolve to numeric
|
||||
/// values and do not show the em-dash degrade placeholder (<c>—</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// A generous 20 s per-tile timeout is intentional: the tiles are populated
|
||||
/// asynchronously after initial render as the three singleton <c>Ask</c>s
|
||||
/// complete. The Playwright web-first assertion retries within that window
|
||||
/// rather than using a fixed sleep, so a fast cluster will pass quickly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task KpiTiles_ResolveToValues_NotDegradePlaceholder()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{HealthUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// ── Notification Outbox tiles (no data-test; inlined in Health.razor as
|
||||
// plain div.card elements under the "Notification Outbox" h6 heading).
|
||||
// Scope all three cards to the div.row that immediately follows the flex
|
||||
// container holding the h6 — prevents false matches if a second section
|
||||
// later grows cards with the same label text ("Stuck", "Parked").
|
||||
//
|
||||
// Health.razor structure:
|
||||
// <div class="d-flex ...">
|
||||
// <h6 class="text-muted mb-0">Notification Outbox</h6> ← anchor
|
||||
// <a ...>View details →</a>
|
||||
// </div>
|
||||
// <div class="row g-3 mb-3"> ← outboxSection (+sibling)
|
||||
// <div class="col-..."><div class="card">Queue Depth</div></div>
|
||||
// <div class="col-..."><div class="card">Stuck</div></div>
|
||||
// <div class="col-..."><div class="card">Parked</div></div>
|
||||
// </div>
|
||||
var outboxSection = page.Locator("div.d-flex:has(h6:has-text('Notification Outbox')) + div.row");
|
||||
|
||||
var queueDepthH3 = outboxSection.Locator("div.card", new() { HasText = "Queue Depth" }).Locator("h3");
|
||||
var outboxStuckH3 = outboxSection.Locator("div.card", new() { HasText = "Stuck" }).Locator("h3");
|
||||
var outboxParkedH3 = outboxSection.Locator("div.card", new() { HasText = "Parked" }).Locator("h3");
|
||||
|
||||
await AssertTileResolvedAsync(queueDepthH3, "Outbox Queue Depth");
|
||||
await AssertTileResolvedAsync(outboxStuckH3, "Outbox Stuck");
|
||||
await AssertTileResolvedAsync(outboxParkedH3, "Outbox Parked");
|
||||
|
||||
// ── Audit KPI tiles (AuditKpiTiles.razor — data-test on the button) ──
|
||||
|
||||
var auditVolume = page.Locator("[data-test='audit-kpi-volume']").Locator("h3");
|
||||
var auditErrorRate = page.Locator("[data-test='audit-kpi-error-rate']").Locator("h3");
|
||||
var auditBacklog = page.Locator("[data-test='audit-kpi-backlog']").Locator("h3");
|
||||
|
||||
await AssertTileResolvedAsync(auditVolume, "Audit Volume");
|
||||
await AssertTileResolvedAsync(auditErrorRate, "Audit Error Rate");
|
||||
await AssertTileResolvedAsync(auditBacklog, "Audit Backlog");
|
||||
|
||||
// ── Site Call KPI tiles (SiteCallKpiTiles.razor — data-test on the button) ──
|
||||
|
||||
var siteCallBuffered = page.Locator("[data-test='site-call-kpi-buffered']").Locator("h3");
|
||||
var siteCallStuck = page.Locator("[data-test='site-call-kpi-stuck']").Locator("h3");
|
||||
var siteCallParked = page.Locator("[data-test='site-call-kpi-parked']").Locator("h3");
|
||||
|
||||
await AssertTileResolvedAsync(siteCallBuffered, "Site Call Buffered");
|
||||
await AssertTileResolvedAsync(siteCallStuck, "Site Call Stuck");
|
||||
await AssertTileResolvedAsync(siteCallParked, "Site Call Parked");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits up to 20 s for <paramref name="tileH3"/> to be non-empty and not
|
||||
/// equal to the em-dash degrade placeholder. Uses Playwright web-first
|
||||
/// assertions so the retry loop is inside the Playwright engine, not a
|
||||
/// C# busy-wait.
|
||||
/// </summary>
|
||||
private static async Task AssertTileResolvedAsync(ILocator tileH3, string tileName)
|
||||
{
|
||||
// The tile must be visible and contain text before we check its value.
|
||||
await Assertions.Expect(tileH3)
|
||||
.ToBeVisibleAsync(new() { Timeout = 20_000 });
|
||||
|
||||
// Primary guard: the value must NOT be the degrade placeholder.
|
||||
await Assertions.Expect(tileH3)
|
||||
.Not.ToHaveTextAsync(DegradePlaceholder, new() { Timeout = 20_000 });
|
||||
|
||||
// Secondary guard: the value must be non-empty — it should be a digit string.
|
||||
await Assertions.Expect(tileH3)
|
||||
.Not.ToBeEmptyAsync(new() { Timeout = 20_000 });
|
||||
|
||||
// Diagnostic: capture the resolved text for context in test output, but
|
||||
// don't fail on any particular number (the cluster state is environment-
|
||||
// dependent and any non-negative integer is valid).
|
||||
var resolvedText = await tileH3.TextContentAsync();
|
||||
Assert.True(
|
||||
resolvedText != null && resolvedText.Trim().Length > 0,
|
||||
$"KPI tile '{tileName}' resolved to null/empty content.");
|
||||
Assert.False(
|
||||
resolvedText!.Trim() == DegradePlaceholder,
|
||||
$"KPI tile '{tileName}' shows the degrade placeholder '—' — singleton Ask likely hung or faulted.");
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
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 / no-hang guard for the central Parked Messages page
|
||||
/// (<c>/monitoring/parked-messages</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why this is a render guard and NOT a mutation test (unlike
|
||||
/// <see cref="Notifications.NotificationActionTests"/>):</b> parked store-and-forward
|
||||
/// messages live in the SITE's local SQLite buffer, not in central MS SQL. The page
|
||||
/// resolves them by relaying a <c>ParkedMessageQueryRequest</c> to the owning site over
|
||||
/// the cluster (an Akka Ask answered by the site's S&F singleton). There is no central
|
||||
/// table to seed — a directly-INSERTed central row cannot produce a parked S&F message —
|
||||
/// so this test cannot deterministically seed a row to act on. Instead it asserts the
|
||||
/// singleton-backed query <em>resolves</em> (renders the results table or the empty-state
|
||||
/// card) within a generous window rather than hanging on the cross-cluster Ask — the
|
||||
/// regression class this guards against. Empty results are tolerated.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Gated on <see cref="ClusterAvailability"/> via <c>Skip.IfNot</c>: when the cluster is
|
||||
/// unreachable the fact reports as Skipped (not Failed), matching the established suite
|
||||
/// idiom. The query relays to a live site, so the cluster (not just MSSQL) must be up.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class ParkedMessagesTests
|
||||
{
|
||||
private const string ParkedMessagesUrl = "/monitoring/parked-messages";
|
||||
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public ParkedMessagesTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ParkedMessages_QueryForSite_RendersWithoutHang()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ParkedMessagesUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Parked Messages')")).ToBeVisibleAsync();
|
||||
|
||||
// Select site-a — the <option> value is the SiteIdentifier "site-a". The select is
|
||||
// an @onchange handler that, on a non-empty selection, kicks off the query itself;
|
||||
// SelectOptionAsync raises the change event so the query fires. Click Query as well
|
||||
// to be explicit (the button is enabled once a site is selected).
|
||||
await page.Locator("#pm-filter-site").SelectOptionAsync("site-a");
|
||||
await page.Locator("button.btn.btn-primary.btn-sm:has-text('Query')").ClickAsync();
|
||||
|
||||
// The singleton-backed query resolves to EITHER the results table or the empty-state
|
||||
// card. Web-first assertion with a generous timeout (20s) — the relay round-trips to
|
||||
// the site over the cluster, and the regression this guards is the query hanging
|
||||
// (leaving the page stuck on "Loading…"). Either terminal state proves it resolved.
|
||||
var resolved = page.Locator("table.parked-table, div.card-body:has-text('No parked messages')");
|
||||
await Assertions.Expect(resolved.First).ToBeVisibleAsync(new() { Timeout = 20_000 });
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,27 @@ public class NavigationTests
|
||||
await ClickNavAndWait(page, linkText, expectedPath);
|
||||
}
|
||||
|
||||
// Maps each navigable route to the exact heading text rendered by that page.
|
||||
private static readonly Dictionary<string, string> RouteHeadings = new()
|
||||
{
|
||||
["/admin/sites"] = "Site Management",
|
||||
["/admin/api-keys"] = "API Key Management",
|
||||
["/admin/ldap-mappings"] = "LDAP Group Mappings",
|
||||
["/notifications/smtp"] = "SMTP Configuration",
|
||||
["/notifications/lists"] = "Notification Lists",
|
||||
["/notifications/report"] = "Notification Report",
|
||||
["/notifications/kpis"] = "Notification KPIs",
|
||||
["/design/templates"] = "Templates",
|
||||
["/design/shared-scripts"] = "Shared Scripts",
|
||||
["/design/connections"] = "Connections",
|
||||
["/design/external-systems"] = "Integration Definitions",
|
||||
["/deployment/topology"] = "Topology",
|
||||
["/deployment/deployments"] = "Deployment Status",
|
||||
["/monitoring/health"] = "Health Dashboard",
|
||||
["/monitoring/event-logs"] = "Site Event Logs",
|
||||
["/monitoring/parked-messages"] = "Parked Messages",
|
||||
};
|
||||
|
||||
private static async Task ClickNavAndWait(IPage page, string linkText, string expectedPath)
|
||||
{
|
||||
// Sections are collapsed by default — open them so the link is in the DOM.
|
||||
@@ -82,6 +103,14 @@ public class NavigationTests
|
||||
await page.Locator($"nav a:has-text('{linkText}')").ClickAsync();
|
||||
await PlaywrightFixture.WaitForPathAsync(page, expectedPath);
|
||||
Assert.Contains(expectedPath, page.Url);
|
||||
|
||||
// Verify the destination page actually rendered its heading (catches 500s
|
||||
// and blank renders that a URL-only check would miss).
|
||||
// Every mapped route renders its heading as an <h4> — tightened from the
|
||||
// broader "h1, h4, h5" to prevent strict-mode violations if multiple
|
||||
// heading elements match.
|
||||
var expectedHeading = RouteHeadings[expectedPath];
|
||||
await Assertions.Expect(page.Locator("h4", new() { HasText = expectedHeading })).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the central Notification Report page's Retry / Discard
|
||||
/// actions on parked notifications (Notification Outbox #21).
|
||||
///
|
||||
/// <para>
|
||||
/// Each test seeds its own <c>Parked</c> row directly into the running cluster's
|
||||
/// configuration database via <see cref="NotificationDataSeeder"/>, exercises the UI
|
||||
/// through Playwright, then best-effort deletes the row by its unique <c>ListName</c>
|
||||
/// marker. The Notification Report page reads the <c>Notifications</c> table through the
|
||||
/// <c>NotificationOutboxActor</c> singleton — its query path is a pure read-from-table
|
||||
/// projection (no default time window), so a directly-INSERTed row surfaces exactly as a
|
||||
/// site-ingested row would. Crucially, the actor's manual Retry / Discard handlers act
|
||||
/// purely on the central row (load by id → flip <c>Status</c> → persist) with NO site
|
||||
/// relay, so a directly-seeded Parked row is genuinely retryable / discardable from
|
||||
/// central and the action's success toast (<c>ToastNotification.ShowSuccess</c>) appears.
|
||||
/// This is therefore a real mutating action test, not merely a render guard.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
|
||||
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed), matching
|
||||
/// the established <c>SiteCallsPageTests</c> idiom.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class NotificationActionTests
|
||||
{
|
||||
private const string NotificationReportUrl = "/notifications/report";
|
||||
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public NotificationActionTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
|
||||
private const string DbUnavailableSkipReason =
|
||||
"NotificationDataSeeder 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.";
|
||||
|
||||
/// <summary>
|
||||
/// Commits a <c>@bind</c> (commit-on-<c>change</c>) form control to the server as its
|
||||
/// own discrete circuit message before the caller clicks Query. Same rationale as
|
||||
/// <c>SiteCallsPageTests.SetSearchKeywordAsync</c>: <see cref="ILocator.SelectOptionAsync"/>
|
||||
/// already raises a <c>change</c>, and <see cref="ILocator.FillAsync"/> only fires
|
||||
/// <c>input</c> — so the subject search box needs an explicit <c>change</c> dispatch so
|
||||
/// its bound value is committed on the circuit before the Query click, not raced against
|
||||
/// the click's blur side effect.
|
||||
/// </summary>
|
||||
private static async Task SetSubjectKeywordAsync(IPage page, string keyword)
|
||||
{
|
||||
var search = page.Locator("#no-search");
|
||||
await search.FillAsync(keyword);
|
||||
await search.DispatchEventAsync("change");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a Parked notification, navigates to the report, narrows to it (status=Parked +
|
||||
/// subject keyword), and returns the row locator once it is visible.
|
||||
/// </summary>
|
||||
private async Task<(IPage Page, ILocator Row)> SeedAndLocateParkedRowAsync(
|
||||
Guid notificationId, string listNameMarker, string subject)
|
||||
{
|
||||
await NotificationDataSeeder.InsertParkedNotificationAsync(
|
||||
notificationId: notificationId,
|
||||
listNameMarker: listNameMarker,
|
||||
subject: subject,
|
||||
sourceSite: "site-a",
|
||||
retryCount: 3);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{NotificationReportUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();
|
||||
|
||||
// Narrow to the seeded row: Parked status (so only Retry/Discard-bearing rows
|
||||
// render) plus the unique subject keyword. The status select is a @bind
|
||||
// commit-on-change, so SelectOptionAsync's own change event commits it; the
|
||||
// subject search box needs the explicit change dispatch.
|
||||
await page.Locator("#no-status").SelectOptionAsync("Parked");
|
||||
await SetSubjectKeywordAsync(page, subject);
|
||||
await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var row = page.Locator("tbody tr", new() { HasText = subject });
|
||||
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
return (page, row);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Retry_ParkedNotification_ShowsOutcomeToast()
|
||||
{
|
||||
Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var marker = $"zztest-notif-retry-{runId}";
|
||||
var notificationId = Guid.NewGuid();
|
||||
var subject = $"zztest retry {runId}";
|
||||
|
||||
try
|
||||
{
|
||||
var (page, row) = await SeedAndLocateParkedRowAsync(notificationId, marker, subject);
|
||||
|
||||
// The Retry button is only rendered for Parked rows (btn-outline-success).
|
||||
await Assertions.Expect(row.Locator("button.btn.btn-outline-success.btn-sm")).ToBeVisibleAsync();
|
||||
await row.Locator("button.btn.btn-outline-success.btn-sm").ClickAsync();
|
||||
|
||||
// Confirm the action — non-danger footer button labelled "Confirm".
|
||||
var confirmButton = page.Locator(".modal-footer .btn-primary");
|
||||
await Assertions.Expect(confirmButton).ToBeVisibleAsync();
|
||||
await Assertions.Expect(confirmButton).ToHaveTextAsync("Confirm");
|
||||
await confirmButton.ClickAsync();
|
||||
|
||||
// The retry resolves purely against the central row (no site relay), so a
|
||||
// single success toast appears. We assert exactly one toast (the single-toast
|
||||
// contract), tolerant of the exact outcome text. The wait is generous (15s)
|
||||
// and the toast auto-dismisses 5s after it appears, so we use a single
|
||||
// web-first retrying assertion to avoid a TOCTOU race with the auto-dismiss.
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Discard_ParkedNotification_ShowsOutcomeToast()
|
||||
{
|
||||
Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var marker = $"zztest-notif-discard-{runId}";
|
||||
var notificationId = Guid.NewGuid();
|
||||
var subject = $"zztest discard {runId}";
|
||||
|
||||
try
|
||||
{
|
||||
var (page, row) = await SeedAndLocateParkedRowAsync(notificationId, marker, subject);
|
||||
|
||||
// The Discard button is only rendered for Parked rows (btn-outline-danger).
|
||||
await Assertions.Expect(row.Locator("button.btn.btn-outline-danger.btn-sm")).ToBeVisibleAsync();
|
||||
await row.Locator("button.btn.btn-outline-danger.btn-sm").ClickAsync();
|
||||
|
||||
// Confirm the action — danger footer button labelled "Delete" (the discard
|
||||
// dialog opens with danger: true).
|
||||
var deleteButton = page.Locator(".modal-footer .btn-danger");
|
||||
await Assertions.Expect(deleteButton).ToBeVisibleAsync();
|
||||
await Assertions.Expect(deleteButton).ToHaveTextAsync("Delete");
|
||||
await deleteButton.ClickAsync();
|
||||
|
||||
// The discard moves the central row to Discarded and surfaces a single success
|
||||
// toast. Same single-toast / outcome-tolerant assertion as Retry: a single
|
||||
// web-first retrying assertion to avoid a TOCTOU race with the auto-dismiss.
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Direct-SQL seeding helper for the Notification Report page Playwright E2E tests
|
||||
/// (Notification Outbox #21).
|
||||
///
|
||||
/// <para>
|
||||
/// The Notification Report page reads the central <c>Notifications</c> table through the
|
||||
/// <c>NotificationOutboxActor</c> singleton. Its query path
|
||||
/// (<c>NotificationOutboxQueryRequest</c> → <c>NotificationOutboxRepository.QueryAsync</c>)
|
||||
/// is a pure read-from-table projection with NO default time window — a row INSERTed
|
||||
/// directly into <c>Notifications</c> surfaces on the page exactly as a site-ingested row
|
||||
/// would. The actor's manual Retry / Discard handlers
|
||||
/// (<c>RetryNotificationRequest</c> / <c>DiscardNotificationRequest</c>) likewise act
|
||||
/// purely on the central row (load by id, flip <c>Status</c>, persist) — there is no
|
||||
/// site relay on this path — so a directly-seeded <c>Parked</c> row is genuinely
|
||||
/// retryable/discardable from central. This mirrors <see cref="SiteCalls.SiteCallDataSeeder"/>:
|
||||
/// each test inserts its own row at setup and best-effort deletes it at teardown, keeping
|
||||
/// the suite self-contained without touching <c>infra/mssql/seed-config.sql</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Rows are tagged with a unique <c>ListName</c> marker derived from the test name + a GUID
|
||||
/// so the teardown <c>DELETE</c> never touches rows the cluster itself produced.
|
||||
/// <c>CreatedAt</c>/<c>SiteEnqueuedAt</c> are pinned to "now" so the page's default
|
||||
/// (unconstrained) query window sees the row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class NotificationDataSeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Connection string for the running cluster's configuration DB.
|
||||
/// Delegates to <see cref="PlaywrightDbConnection.ConnectionString"/>.
|
||||
/// </summary>
|
||||
public static string ConnectionString => PlaywrightDbConnection.ConnectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a single <c>Parked</c> row into the central <c>Notifications</c> table.
|
||||
/// Populates every NOT NULL column (NotificationId, Type, ListName, Subject, Body,
|
||||
/// Status, RetryCount, SourceSiteId, SiteEnqueuedAt, CreatedAt) and stamps the row with
|
||||
/// the unique <paramref name="listNameMarker"/> so the test can filter to it and the
|
||||
/// teardown can delete by it. <c>Status</c> is fixed to <c>Parked</c> — the only status
|
||||
/// from which the page exposes Retry/Discard. Timestamps are pinned to "now" so the
|
||||
/// page's default unconstrained query window sees the row.
|
||||
/// </summary>
|
||||
/// <param name="notificationId">GUID primary key (stored as its 36-char string form).</param>
|
||||
/// <param name="listNameMarker">Unique per-run marker stored in <c>ListName</c>.</param>
|
||||
/// <param name="subject">Subject text (searchable via the page's subject keyword box).</param>
|
||||
/// <param name="sourceSite">Originating site identifier (e.g. <c>site-a</c>).</param>
|
||||
/// <param name="retryCount">Retry count to display on the row.</param>
|
||||
/// <param name="lastError">Optional last-error text shown beneath the subject.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public static async Task InsertParkedNotificationAsync(
|
||||
Guid notificationId,
|
||||
string listNameMarker,
|
||||
string subject,
|
||||
string sourceSite,
|
||||
int retryCount = 3,
|
||||
string? lastError = "SMTP 451 transient failure (seeded)",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// NotificationId is the varchar(64) primary key; Type/Status are stored as
|
||||
// varchar(32) (HasConversion<string>()). All NOT NULL columns are supplied;
|
||||
// the nullable provenance columns (SourceNode, OriginExecutionId, …) are left
|
||||
// to default to NULL, which the page renders as an em-dash.
|
||||
const string sql = @"
|
||||
INSERT INTO [Notifications]
|
||||
([NotificationId], [Type], [ListName], [Subject], [Body], [Status], [RetryCount],
|
||||
[LastError], [SourceSiteId], [SiteEnqueuedAt], [CreatedAt])
|
||||
VALUES
|
||||
(@id, @type, @listName, @subject, @body, @status, @retryCount,
|
||||
@lastError, @sourceSite, @siteEnqueuedAt, @createdAt);";
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.Parameters.AddWithValue("@id", notificationId.ToString());
|
||||
cmd.Parameters.AddWithValue("@type", "Email");
|
||||
cmd.Parameters.AddWithValue("@listName", listNameMarker);
|
||||
cmd.Parameters.AddWithValue("@subject", subject);
|
||||
cmd.Parameters.AddWithValue("@body", "Seeded notification body for Playwright E2E.");
|
||||
cmd.Parameters.AddWithValue("@status", "Parked");
|
||||
cmd.Parameters.AddWithValue("@retryCount", retryCount);
|
||||
cmd.Parameters.AddWithValue("@lastError", (object?)lastError ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@sourceSite", sourceSite);
|
||||
cmd.Parameters.AddWithValue("@siteEnqueuedAt", now);
|
||||
cmd.Parameters.AddWithValue("@createdAt", now);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort cleanup. Deletes every <c>Notifications</c> row whose <c>ListName</c>
|
||||
/// equals <paramref name="listNameMarker"/>. Swallows all errors — the marker carries a
|
||||
/// per-run GUID so the rows are unique to this test run. A Retry that flips the row back
|
||||
/// to <c>Pending</c> (and a subsequent dispatch sweep) does not change the <c>ListName</c>,
|
||||
/// so the marker still matches whatever terminal state the row ends in.
|
||||
/// </summary>
|
||||
public static async Task DeleteByMarkerAsync(string listNameMarker, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM [Notifications] WHERE [ListName] = @listName";
|
||||
cmd.Parameters.AddWithValue("@listName", listNameMarker);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe whether the configuration DB is reachable. Tests gate their per-test setup on
|
||||
/// this so a downed cluster surfaces a clear message rather than an opaque
|
||||
/// <see cref="SqlException"/>.
|
||||
/// </summary>
|
||||
public static async Task<bool> IsAvailableAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-16
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.SiteCalls;
|
||||
|
||||
@@ -25,24 +26,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.SiteCalls;
|
||||
/// </summary>
|
||||
internal static class SiteCallDataSeeder
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5";
|
||||
|
||||
private const string EnvVar = "SCADABRIDGE_PLAYWRIGHT_DB";
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the running cluster's configuration DB. Resolved
|
||||
/// from <c>SCADABRIDGE_PLAYWRIGHT_DB</c> when set, otherwise the local docker
|
||||
/// dev defaults.
|
||||
/// Connection string for the running cluster's configuration DB.
|
||||
/// Delegates to <see cref="PlaywrightDbConnection.ConnectionString"/>.
|
||||
/// </summary>
|
||||
public static string ConnectionString
|
||||
{
|
||||
get
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
|
||||
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
|
||||
}
|
||||
}
|
||||
public static string ConnectionString => PlaywrightDbConnection.ConnectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a single row into the central <c>SiteCalls</c> table. Optional
|
||||
|
||||
+2
-4
@@ -320,10 +320,8 @@ public class SiteCallsPageTests
|
||||
// path can sit on the 10s inner relay timeout before the response —
|
||||
// and the toast itself auto-dismisses 5s after it appears, so the
|
||||
// assertion must catch it inside that window.
|
||||
var toast = page.Locator(".toast");
|
||||
await Assertions.Expect(toast).ToBeVisibleAsync(
|
||||
new() { Timeout = 15_000 });
|
||||
Assert.Equal(1, await toast.CountAsync());
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(
|
||||
1, new() { Timeout = 15_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
||||
|
||||
@@ -91,6 +92,109 @@ public class SiteCrudTests
|
||||
await Expect(page.Locator(".text-danger")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full create → edit → delete round-trip for a site via the Central UI.
|
||||
/// Uses CliRunner for best-effort teardown so no zztest-* site leaks on failure.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CreateEditDelete_Site_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var hex = Guid.NewGuid().ToString("N")[..8];
|
||||
var ident = $"zztest-{hex}";
|
||||
var name = $"zztest-site-{hex}";
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Fill Identifier — label-anchored input (Node A/B share the same Akka/gRPC
|
||||
// placeholder text so we select the Akka inputs by placeholder index below).
|
||||
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync(ident);
|
||||
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(name);
|
||||
await page.Locator("label:has-text('Description') + input.form-control.form-control-sm").FillAsync("e2e");
|
||||
|
||||
// Node A and Node B share identical placeholder text; select by index.
|
||||
// Index 0 = Node A Akka, Index 1 = Node B Akka.
|
||||
var akkaInputs = page.Locator("input[placeholder='akka.tcp://scadabridge@host:port/user/site-communication']");
|
||||
await akkaInputs.Nth(0).FillAsync("akka.tcp://scadabridge@zz:5000/user/site-communication");
|
||||
await akkaInputs.Nth(1).FillAsync("akka.tcp://scadabridge@zz:5000/user/site-communication");
|
||||
|
||||
// Index 0 = Node A gRPC, Index 1 = Node B gRPC.
|
||||
var grpcInputs = page.Locator("input[placeholder='http://host:8083']");
|
||||
await grpcInputs.Nth(0).FillAsync("http://zz:8083");
|
||||
await grpcInputs.Nth(1).FillAsync("http://zz:8083");
|
||||
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
|
||||
// Wait for Blazor enhanced navigation back to the list page.
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The new site card must be visible.
|
||||
var card = page.Locator("div.card", new() { HasText = name });
|
||||
await Assertions.Expect(card).ToBeVisibleAsync();
|
||||
|
||||
// ── EDIT ──────────────────────────────────────────────────────────────────
|
||||
// Scope the Edit button to the card to avoid strict-mode violations.
|
||||
await card.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites/", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Update the description and save.
|
||||
await page.Locator("label:has-text('Description') + input.form-control.form-control-sm").FillAsync("e2e-edited");
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/edit");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Card must still be present after the edit.
|
||||
await Assertions.Expect(page.Locator("div.card", new() { HasText = name })).ToBeVisibleAsync();
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
// Re-locate the card after navigation; scope kebab+delete to the .dropdown
|
||||
// container inside the card to be defensive against strict-mode violations.
|
||||
var cardAfterEdit = page.Locator("div.card", new() { HasText = name });
|
||||
var cardDropdown = cardAfterEdit.Locator(".dropdown");
|
||||
var kebab = cardDropdown.Locator("button[aria-label^='More actions']");
|
||||
await kebab.ClickAsync();
|
||||
|
||||
// Click Delete in the now-open dropdown — scoped to the .dropdown container.
|
||||
var deleteBtn = cardDropdown.Locator(".dropdown-menu button.dropdown-item.text-danger");
|
||||
await deleteBtn.ClickAsync();
|
||||
|
||||
// Confirm the global danger dialog.
|
||||
await Assertions.Expect(page.Locator(".modal-footer .btn-danger")).ToBeVisibleAsync();
|
||||
await page.ClickAsync(".modal-footer .btn-danger");
|
||||
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The card must be gone.
|
||||
await Assertions.Expect(page.Locator("div.card", new() { HasText = name }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort teardown: delete the site via CLI in case the UI path failed
|
||||
// mid-way. The happy path already deleted it via the UI, so ResolveSiteIdAsync
|
||||
// will throw (no matching site) and the inner catch swallows it.
|
||||
try
|
||||
{
|
||||
await CliRunner.DeleteSiteAsync(await CliRunner.ResolveSiteIdAsync(ident));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Site already deleted (happy path) or cluster unreachable — ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||
Assertions.Expect(locator);
|
||||
}
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end round-trip for the Transport Import wizard (Component #24, Task T22).
|
||||
///
|
||||
/// <para>
|
||||
/// The test exercises the full five-step Apply path at
|
||||
/// <c>/design/transport/import</c> against the running dev cluster, using a
|
||||
/// synthetic single-template bundle the test itself exports via the CLI:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>Create a throwaway template (one Double attribute) and export it to an
|
||||
/// encrypted <c>.scadabundle</c> on the host temp dir.</item>
|
||||
/// <item>Delete the source template so the importer sees the bundle's template
|
||||
/// as <see cref="ConflictKind.New"/> — it renders as a static <c>Add</c>
|
||||
/// row with no blocker, so the diff step has nothing to resolve.</item>
|
||||
/// <item>Drive the wizard: upload → passphrase → diff (Next) → confirm
|
||||
/// (type the source-environment name) → Apply → assert the success
|
||||
/// summary and the audit drill-in link.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Remote file upload note: the Playwright browser runs in a Docker container
|
||||
/// (<c>ws://localhost:3000</c>) with no host-filesystem volume mount, so the
|
||||
/// staged bundle on the host is NOT visible to the remote browser as a path.
|
||||
/// Playwright's <see cref="ILocator.SetInputFilesAsync(string, LocatorSetInputFilesOptions?)"/>
|
||||
/// handles this transparently: the Playwright client (running on the host, in this
|
||||
/// test process) reads the file and streams its bytes over the WebSocket to the
|
||||
/// remote browser, which materialises it for the <c><input type=file></c>.
|
||||
/// This test is the de-risking evidence that the path-based overload works over a
|
||||
/// WS-connected remote browser.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class TransportImportTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public TransportImportTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ImportSyntheticBundle_AppliesAndShowsAuditDrillIn()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// The source-environment name doubles as the Step-4 confirm phrase, so it
|
||||
// must round-trip verbatim through the bundle manifest and back into the UI.
|
||||
var env = CliRunner.UniqueName("env");
|
||||
var tmplName = CliRunner.UniqueName("imp");
|
||||
var pass = "pw-" + env;
|
||||
|
||||
// Stage the exported bundle on the host temp dir. SetInputFilesAsync streams
|
||||
// the bytes to the remote browser, so the path need not be visible inside
|
||||
// the Playwright container.
|
||||
var bundlePath = Path.Combine(Path.GetTempPath(), tmplName + ".scadabundle");
|
||||
|
||||
try
|
||||
{
|
||||
// ── ARRANGE: build + export a synthetic single-template bundle ─────────────
|
||||
int tmplId = await CliRunner.CreateTemplateAsync(tmplName);
|
||||
await CliRunner.AddAttributeAsync(tmplId, "Value", "Double");
|
||||
await CliRunner.BundleExportAsync(bundlePath, tmplId, pass, env);
|
||||
|
||||
// Delete the source so the import diff classifies the bundle's template
|
||||
// as New (Add) rather than Identical/Modified — no blocker, no conflict
|
||||
// resolution required, Apply proceeds cleanly.
|
||||
await CliRunner.DeleteTemplateAsync(tmplId);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
// ── STEP 1: Upload ────────────────────────────────────────────────────────
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// De-risk the remote upload: stream the host file to the container browser.
|
||||
await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);
|
||||
|
||||
// The bundle is encrypted (we exported with a passphrase), so the page
|
||||
// peeks the manifest with no passphrase, fails to decrypt, and shows the
|
||||
// encrypted-bundle notice instead of the (unencrypted-only) manifest
|
||||
// summary. Either way, the Step-1 Next button only renders once the file
|
||||
// was read successfully — its appearance proves SetInputFiles worked.
|
||||
await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
await page.Locator("button.btn.btn-primary:has-text('Next')").ClickAsync();
|
||||
|
||||
// ── STEP 2: Passphrase ──────────────────────────────────────────────────────
|
||||
await Assertions.Expect(page.Locator("#import-passphrase"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await page.Locator("#import-passphrase").FillAsync(pass);
|
||||
await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync();
|
||||
|
||||
// ── STEP 3: Diff ────────────────────────────────────────────────────────────
|
||||
// Wait for the passphrase input to disappear before asserting the diff
|
||||
// summary — this gives a clearer failure when decrypt/preview is slow and
|
||||
// prevents a race between the decrypt completion and the render of the diff.
|
||||
await Assertions.Expect(page.Locator("#import-passphrase"))
|
||||
.ToBeHiddenAsync(new() { Timeout = 20_000 });
|
||||
|
||||
// A brand-new template renders as a static Add item with no blocker, so
|
||||
// the diff summary appears and the Next button is enabled.
|
||||
await Assertions.Expect(page.Locator("[data-testid='diff-summary']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
var diffNext = page.Locator("button.btn.btn-primary:has-text('Next')");
|
||||
await Assertions.Expect(diffNext).ToBeEnabledAsync(new() { Timeout = 15_000 });
|
||||
await diffNext.ClickAsync();
|
||||
|
||||
// ── STEP 4: Confirm ──────────────────────────────────────────────────────────
|
||||
// The Apply button is disabled until the typed text matches the bundle's
|
||||
// source-environment name exactly.
|
||||
await Assertions.Expect(page.Locator("#confirm-env"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await page.Locator("#confirm-env").FillAsync(env);
|
||||
|
||||
var applyBtn = page.Locator("button.btn.btn-danger:has-text('Apply Import')");
|
||||
await Assertions.Expect(applyBtn).ToBeEnabledAsync();
|
||||
await applyBtn.ClickAsync();
|
||||
|
||||
// ── STEP 5: Result ───────────────────────────────────────────────────────────
|
||||
var resultSummary = page.Locator("div.alert.alert-success[data-testid='result-summary']");
|
||||
await Assertions.Expect(resultSummary).ToBeVisibleAsync(new() { Timeout = 20_000 });
|
||||
await Assertions.Expect(resultSummary).ToContainTextAsync("Import complete.");
|
||||
|
||||
// The audit drill-in link must carry the correlated BundleImportId.
|
||||
var auditLink = page.Locator("a:has-text('Audit trail →')");
|
||||
await Assertions.Expect(auditLink).ToBeVisibleAsync();
|
||||
var href = await auditLink.GetAttributeAsync("href");
|
||||
Assert.NotNull(href);
|
||||
Assert.Matches(@"^/audit/configuration\?bundleImportId=[0-9a-fA-F\-]{36}$", href);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort teardown: drop any template the test left behind (the source
|
||||
// template, plus the one created by a successful import — both carry the
|
||||
// tmplName prefix) and delete the staged bundle file.
|
||||
try
|
||||
{
|
||||
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(tmplName))
|
||||
{
|
||||
await CliRunner.DeleteTemplateAsync(id);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — never mask the test's own failure.
|
||||
}
|
||||
|
||||
try { File.Delete(bundlePath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -32,4 +32,9 @@
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.ScadaBridge.CLI\ZB.MOM.WW.ScadaBridge.CLI.csproj"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user