Compare commits
20 Commits
667d141f1a
...
efb3efe6dc
| Author | SHA1 | Date | |
|---|---|---|---|
| efb3efe6dc | |||
| 0700777e2f | |||
| 09f14f18ea | |||
| b52f7281aa | |||
| 3f88de932c | |||
| 79586ca5ad | |||
| 57ca5d6321 | |||
| 73b213442f | |||
| 89231e3245 | |||
| 9fe3ac30c9 | |||
| 84edf5a134 | |||
| fecac45d05 | |||
| 3e4b0ca44c | |||
| 8bd7656110 | |||
| 32240919cc | |||
| e618137ce7 | |||
| a8a515ec8a | |||
| c23e2bf227 | |||
| 8e8bf44a29 | |||
| 58bf59a42d |
@@ -0,0 +1,213 @@
|
||||
# Playwright Coverage Fill — Design
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Approved (brainstorming complete) → ready for writing-plans
|
||||
**Component:** #9 Central UI — `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests`
|
||||
**Predecessor:** [2026-06-05 Playwright Coverage Expansion](2026-06-05-playwright-coverage-expansion-design.md) (the first wave; this is the close-out)
|
||||
|
||||
## Goal
|
||||
|
||||
Close the remaining functional and edge-case gaps found in the 2026-06-06 coverage
|
||||
re-audit — every untested Tier 1–3 page plus a cross-cutting edge-case sweep on the
|
||||
already-covered pages — delivered as **4 risk-tiered waves** inside the existing
|
||||
xunit + `PlaywrightFixture` harness, against the live 8-node docker cluster.
|
||||
|
||||
## Background — the re-audit
|
||||
|
||||
After the first expansion (suite at 83 passing), a re-audit mapped all 38 routable pages
|
||||
against the test catalog. Findings:
|
||||
|
||||
- ~14 of 38 pages have real functional coverage; the rest are nav-only (h4 heading) or
|
||||
untested.
|
||||
- **Biggest blind spot:** `InstanceConfigure` (`/deployment/instances/{id}/configure`) —
|
||||
the most complex mutating page (5 override subsystems, browse dialog, test-bindings) —
|
||||
has zero coverage.
|
||||
- Edge cases are systematically uncovered across *all* pages: no duplicate-name validation,
|
||||
no cancel flows, no empty states, no pagination, no filter-combination, no error/500
|
||||
paths, no wrong-passphrase import.
|
||||
- The suite is happy-path + outcome-tolerant by design (a deliberate, defensible choice
|
||||
that matches the relay reality) — but a page that renders-but-misbehaves on a non-default
|
||||
input can still stay green.
|
||||
|
||||
## Decisions (settled during brainstorming)
|
||||
|
||||
| # | Decision | Choice | Rationale |
|
||||
|---|----------|--------|-----------|
|
||||
| D1 | How much to take on | **Everything** — Tier 1–3 functional gaps + the edge sweep | User chose full close-out; phased so it stays tractable. |
|
||||
| D2 | Structure | **Risk-tiered phased waves** (4) | Front-loads highest-risk surfaces; each wave is a shippable, green, zero-residue increment; mirrors the structure that worked for wave 1. |
|
||||
| D3 | Test selectors on hook-poor pages | **Add `data-test` hooks as needed** (minimal, additive, non-functional) | Deep assertions on the complex forms (esp. `InstanceConfigure`) are otherwise brittle; matches the convention the audited Audit/SiteCalls/Transport pages already follow. |
|
||||
| D4 | Real-time / streaming tests | **Drive real behavior, outcome-tolerant, generous timeouts, `SkippableFact`** | Highest value; managed flake risk on a handful of tests, with a documented downgrade-to-render-guard fallback. |
|
||||
|
||||
## Key enabler — the CLI mirrors the whole surface
|
||||
|
||||
The `scadabridge` CLI exposes create/list/delete (and read-back) for **every** entity the
|
||||
new suites need, including the entire `InstanceConfigure` surface:
|
||||
|
||||
- `instance set-bindings | set-overrides | alarm-override set/delete/list |
|
||||
native-alarm-source set/clear | set-area | diff | get`
|
||||
- `security api-key create/list/delete/update/set-methods`
|
||||
- `data-connection`, `db-connection`, `external-system` (+ `method`), `api-method`,
|
||||
`shared-script`, `notification` (list/create/delete + `smtp`) create/list/delete
|
||||
|
||||
→ New functional suites need **no DB seeders**. Fixtures are CLI-provisioned and
|
||||
persistence is verified by CLI read-back (`instance get`, `security api-key list`). DB
|
||||
seeding remains only in the existing Audit/SiteCalls/Notification *report* edge tests that
|
||||
require a `Parked` row (site SQLite isn't reachable, so those stay direct-SQL).
|
||||
|
||||
## Wave structure
|
||||
|
||||
| Wave | Theme | New suites | New fixtures |
|
||||
|------|-------|-----------|--------------|
|
||||
| **1** | Tier 1 — deepest mutating surfaces | `InstanceConfigureTests`, `ApiKeyCrudTests`, `TransportExportTests` (+ wrong-passphrase import negative) | `InstanceConfigureFixture`, `ApiSurfaceFixture` |
|
||||
| **2** | Tier 2 — real-time / relay | `DeploymentsRealtimeTests`, `TopologyAreaTests`, `DebugViewTests`, `ParkedMessagesActionTests`, Discard click-throughs (SiteCalls + Notification) | reuse `DeploymentFixture` / `InstanceConfigureFixture` |
|
||||
| **3** | Tier 3 — config CRUD breadth | `NotificationListCrudTests`, `SmtpConfigTests`, `NotificationKpisTests`, `DataConnectionCrudTests`, `ExternalSystemCrudTests`, `SharedScriptCrudTests`, `ApiMethodFormTests`, `EventLogsTests`, `AuditConfigurationTests` | small per-suite CLI helpers |
|
||||
| **4** | Cross-cutting edge sweep | extend `SiteCrudTests`, `TemplateCrudTests`, `LdapMappingCrudTests`, `AuditLogPageTests`, `SiteCallsPageTests`, `NotificationActionTests` | none |
|
||||
|
||||
Each wave ends green with zero residue — a clean stop/ship point. Inside a wave, suites
|
||||
are independent (disjoint fixtures/files) → parallel-dispatchable. Rough size: W1 ≈ 3 files
|
||||
/~12 tests, W2 ≈ 5 files/~14, W3 ≈ 9 files/~20, W4 ≈ 6 extended files/~20. Total ≈
|
||||
**23 files, ~65 new cases.**
|
||||
|
||||
## Shared infrastructure
|
||||
|
||||
**CLI helper extensions** (`CliRunner.Helpers.cs`, same throw-vs-swallow split):
|
||||
- *Provision (throw):* `CreateDataConnectionAsync`, `CreateExternalSystemAsync`,
|
||||
`CreateApiMethodAsync`, `CreateNotificationListAsync`, `CreateSharedScriptAsync`.
|
||||
- *Verify (read-back, throw):* `GetInstanceAsync(id)`, `ListApiKeysAsync`.
|
||||
- *Teardown (best-effort):* `DeleteApiKeyAsync`, `DeleteDataConnectionAsync`,
|
||||
`DeleteExternalSystemAsync`, `DeleteNotificationListAsync`, `DeleteSharedScriptAsync` —
|
||||
all keyed on `zztest-*`.
|
||||
|
||||
**New fixtures** (`IAsyncLifetime` + `IClassFixture`, partial-init guard + `Available`
|
||||
flag, mirroring `DeploymentFixture`):
|
||||
- **`InstanceConfigureFixture`** — site-a: `zztest` template + attribute(s) + `zztest`
|
||||
data-connection + an instance, **deployed**. Disposes instance → connection → template.
|
||||
- **`ApiSurfaceFixture`** — `zztest` external-system + one api-method, so the API-key form
|
||||
renders method checkboxes. Disposes api-method + external-system; created keys deleted
|
||||
per-test via `security api-key delete`.
|
||||
|
||||
**`data-test` hooks** (minimal, additive, each its own tiny commit so the app diff is
|
||||
auditable/revertable): `InstanceConfigure.razor` subsystem anchors; row anchors on
|
||||
`EventLogs.razor` / `ConfigurationAuditLog.razor`; save/result anchors on a few Design
|
||||
forms. Only where no stable existing selector (id/aria-label/text) exists.
|
||||
|
||||
**Reused as-is:** `ClusterAvailability` skip gate + `SkipSummaryReporter`;
|
||||
`PlaywrightFixture`; toast = `.toast` web-first `ToHaveCountAsync(1)`; confirm =
|
||||
`.modal-footer .btn-danger` (Delete) / `.btn-primary` (Confirm); `zztest-<kind>-<8hex>`
|
||||
naming; `PlaywrightDbConnection` (only Wave-4 report-page Parked-row edge tests).
|
||||
|
||||
## Per-wave test shapes
|
||||
|
||||
### Wave 1 — Tier 1 mutating surfaces
|
||||
|
||||
**`InstanceConfigureTests`** (`InstanceConfigureFixture`):
|
||||
- *Bindings round-trip* — bulk "Assign all to" the zztest connection → Save Bindings →
|
||||
toast; verify persisted via `GetInstanceAsync` (not just a toast).
|
||||
- *Attribute-override round-trip* — type text override → Save Overrides → toast →
|
||||
`GetInstanceAsync` shows it.
|
||||
- *Area reassignment* — select area → Set Area → toast → `GetInstanceAsync` confirms.
|
||||
- *(⚠ feasibility)* Alarm-override Edit→Save→badge→Clear — needs a template attribute with
|
||||
an alarm; resolve via `instance alarm-override set` precondition or downgrade to a
|
||||
section-renders assertion. Other three tests don't depend on alarms.
|
||||
- *Edge* — `/deployment/instances/999999/configure` → `alert-danger` not-found.
|
||||
|
||||
**`ApiKeyCrudTests`** (`ApiSurfaceFixture`; teardown `security api-key delete`):
|
||||
- Create→token reveal (`data-test="created-token"`, Copy works); Enable/Disable toast +
|
||||
badge; Delete confirm `.btn-danger` → row gone; Edit (name disabled, toggle method).
|
||||
- Edges: empty name → validation; uncheck all methods → "select at least one".
|
||||
|
||||
**`TransportExportTests`** + negative import:
|
||||
- Export — CLI-create zztest template → `/design/transport/export` → select → passphrase →
|
||||
Export → assert the bundle download (filename/size).
|
||||
- Wrong-passphrase import — feed the exported bundle to the import wizard with a bad
|
||||
passphrase → Unlock error state (passphrase input stays, error shown), no `diff-summary`.
|
||||
|
||||
### Wave 2 — Tier 2 real-time / relay (drive behavior, tolerant, generous timeouts, `SkippableFact`)
|
||||
|
||||
- **`DeploymentsRealtimeTests`** — on `/deployment/deployments`, CLI `instance deploy` a
|
||||
fixture instance → status row appears within ~20s (SignalR push). Second: Pause → deploy
|
||||
→ row absent → Refresh → row present.
|
||||
- **`TopologyAreaTests`** — create area (toolbar + site context-menu); inline rename
|
||||
(Enter commits + toast; Escape reverts); move area; move instance; *(⚠)* Diff dialog
|
||||
opens for a deployed instance (tolerant).
|
||||
- **`DebugViewTests`** — select site-a + enabled instance → Connect → badge "Live" +
|
||||
snapshot/table region renders (tolerant: rows *or* "Waiting for snapshot"). Disconnect →
|
||||
tables gone, selects re-enabled.
|
||||
- **`ParkedMessagesActionTests`** — render+controls guard on the page's own Retry/Discard
|
||||
button presence/enablement (site SQLite not seedable → no click-through here).
|
||||
- **Discard click-throughs** — add the symmetric Discard-clicked test to
|
||||
`SiteCallsPageTests` and `NotificationActionTests` (Retry is already click-through-tested).
|
||||
|
||||
### Wave 3 — Tier 3 config CRUD (breadth; CLI teardown by zztest name)
|
||||
|
||||
- **`NotificationListCrudTests`** — create → add recipient → delete recipient (no confirm)
|
||||
→ delete list (confirm + "Deleted." toast).
|
||||
- **`SmtpConfigTests`** (RequireAdmin) — add (host+from required) → saved toast →
|
||||
credentials "(stored)"; edit cancel/save.
|
||||
- **`NotificationKpisTests`** — load → 5 tiles resolve (or "—") → Refresh spinner.
|
||||
- **`DataConnectionCrudTests` / `ExternalSystemCrudTests` / `SharedScriptCrudTests`** —
|
||||
create→(edit)→delete round-trips.
|
||||
- **`ApiMethodFormTests`** — create method (name+timeout, minimal Monaco script, default
|
||||
schema) → Save → appears under external systems (Monaco interaction minimal/tolerant).
|
||||
- **`EventLogsTests`** — Search disabled until site selected; select site → Search; filter
|
||||
by Severity; row expand/collapse; Load more appends.
|
||||
- **`AuditConfigurationTests`** — load `/audit/configuration`; search narrows; Prev disabled
|
||||
on page 1; large-state modal open/close; copy-entity-id toast; `?bundleImportId=` chip
|
||||
drill-in.
|
||||
|
||||
### Wave 4 — cross-cutting edge sweep (⚠ verify each page's real validation first)
|
||||
|
||||
- **Sites** — duplicate-identifier (assert whatever the app surfaces), cancel-from-edit,
|
||||
invalid Akka/gRPC URL *if validated*.
|
||||
- **Templates** — duplicate-name, cancel, edit existing attribute, delete-when-instances-
|
||||
exist (blocking).
|
||||
- **LDAP** — duplicate group name, missing-field validation.
|
||||
- **Audit Log** — filter combination (channel+site+time), empty-results-after-Apply, drawer
|
||||
close (X/Escape), non-API row has no cURL button, pagination.
|
||||
- **Site Calls** — filter by status, empty state, keyset pagination (`site-calls-prev/next`
|
||||
hooks exist, unused today).
|
||||
- **Notification Report** — filter combos, "Stuck only", detail modal open/close, pagination.
|
||||
|
||||
## Error handling & verification strategy
|
||||
|
||||
**Validation-behavior protocol (the ⚠ items).** Before asserting a specific failure mode,
|
||||
the implementer reads the page code-behind to learn what it actually does — inline
|
||||
`_formError` vs error toast vs DB-constraint bubble vs silent success — and asserts that
|
||||
reality. Where the app doesn't validate (relies on a DB constraint with no friendly
|
||||
message), the test asserts the real surfaced behavior and a code comment notes the gap.
|
||||
Same protocol resolves the alarm-override and Diff-dialog feasibility ⚠s.
|
||||
|
||||
**Flake management (real-time/streaming).** Web-first assertions only (`Expect(...)` with
|
||||
explicit timeouts), never `WaitForTimeout` + read. Generous ceilings (~20s). Outcome-
|
||||
tolerant where the cluster's response isn't deterministic. All `SkippableFact` +
|
||||
`ClusterAvailability` gated. Documented fallback: if a real-time test is irreducibly flaky,
|
||||
downgrade *that test only* to a render+controls guard.
|
||||
|
||||
**Teardown & residue.** Best-effort cleanup in `DisposeAsync`/`finally`, keyed on
|
||||
`zztest-*`. Each wave ends with the no-residue check (`site/template/instance/
|
||||
data-connection/api-key/notification list` via CLI + DB marker scan) → zero. Mutating tests
|
||||
that target real site-a leave it as found.
|
||||
|
||||
**Per-wave gate.** Wave is "done" only when: new tests pass against the live cluster, the
|
||||
full suite stays at **0 failed** (skips logged), zero residue, and `dotnet build` is clean
|
||||
(`TreatWarningsAsErrors=true`). `data-test` additions verified not to alter rendered
|
||||
behavior.
|
||||
|
||||
## Scope guard (YAGNI)
|
||||
|
||||
No new page-object framework, no CI wiring, no parallelization/runner changes, no
|
||||
visual-regression/screenshot testing, no perf testing. New `data-test` attributes are the
|
||||
only app-code change and are purely additive. Everything slots into the existing structure.
|
||||
|
||||
## Success criteria
|
||||
|
||||
All 4 waves merged; ~23 files / ~65 new cases; every Tier 1–3 page from the audit has
|
||||
functional coverage; the edge sweep adds duplicate/cancel/empty/filter-combo/pagination
|
||||
assertions to the previously happy-path-only pages; suite green with logged skips; zero
|
||||
residue.
|
||||
|
||||
## Native tasks
|
||||
|
||||
Brainstorming checklist tasks #73–#78 track this design through to writing-plans. The
|
||||
implementation plan (produced next by the writing-plans skill) carries its own task set,
|
||||
one wave at a time.
|
||||
@@ -0,0 +1,700 @@
|
||||
# Playwright Coverage Fill — Wave 1 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 deep functional coverage for the three highest-risk untested mutating surfaces — `InstanceConfigure`, API-key create/edit/list, and Transport Export (+ a wrong-passphrase import negative) — with edge cases folded in.
|
||||
|
||||
**Architecture:** Extends the existing xunit + `PlaywrightFixture` harness. New ephemeral fixtures are CLI-provisioned on the live cluster and verified by CLI read-back (`instance get`, `security api-key list`); no DB seeding. All cluster-dependent tests are `[SkippableFact]` gated on `ClusterAvailability`. Cleanup is best-effort, keyed on `zztest-*`. Toast asserts are web-first `ToHaveCountAsync(1)`. A small number of additive, non-functional `data-test` attributes are added to `InstanceConfigure.razor`.
|
||||
|
||||
**Tech Stack:** .NET 10, xunit + `Xunit.SkippableFact`, Microsoft.Playwright (remote Chromium at `ws://localhost:3000`), the `scadabridge` CLI (`dotnet scadabridge.dll … --format json`).
|
||||
|
||||
**Reference design:** `docs/plans/2026-06-06-playwright-coverage-fill-design.md` (this is Wave 1 of 4).
|
||||
|
||||
**Conventions (carry into every task):**
|
||||
- Test files use `[Collection("Playwright")]`; cluster tests use `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason)`.
|
||||
- App URL from the browser is `fixture.BaseUrl` (`http://scadabridge-traefik`); the CLI runs from the host against `localhost:9000` (handled inside `CliRunner`).
|
||||
- Authenticated page: `await fixture.NewAuthenticatedPageAsync("multi-role", "password")`.
|
||||
- Fixture names: `CliRunner.UniqueName("<kind>")` → `zztest-<kind>-<8hex>`.
|
||||
- Toast: `await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });`
|
||||
- Danger confirm: `page.Locator(".modal-footer .btn-danger")`; non-danger: `.modal-footer .btn-primary`.
|
||||
- Build is `TreatWarningsAsErrors=true`, `Nullable=enable` — no warnings, no unused usings.
|
||||
|
||||
**Validation-behavior protocol:** before asserting any *specific* failure/validation message, the implementer Reads the page code-behind and asserts what the app actually surfaces. Where reality differs from this plan's assumption, follow reality and note it in a code comment.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: CLI helper extensions (data-connection, api-method, api-key teardown, instance read-back)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (foundation for the rest)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs`
|
||||
|
||||
**Context:** `CliRunner` is a `static partial class`. Add new helpers mirroring the existing throw-vs-swallow split: provision/read helpers throw (`RequireId`/`RunJsonAsync`); `Delete*` helpers swallow. Verified CLI signatures:
|
||||
- `data-connection create --site-id <int> --name <string> --protocol <string> [--primary-config <string>]` → JSON object with `id`. `data-connection delete --id <int>`.
|
||||
- `api-method create --name <string> --script <string> [--timeout <int>]` → JSON object with `id`. `api-method delete --id <int>`. `api-method list` → array of `{id,name}`.
|
||||
- `security api-key list` → array of `{keyId,name,enabled}`. `security api-key delete --key-id <string>` (key id is a **string**, so it cannot use the int-based `BestEffortAsync`).
|
||||
- `instance get --id <int>` → object with `connectionBindings[] {attributeName,dataConnectionId}`, `attributeOverrides[] {attributeName,overrideValue}`, `areaId`.
|
||||
|
||||
**Step 1: Add the helpers** to `CliRunner.Helpers.cs` (inside the partial class):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Creates a data connection on a site via <c>data-connection create</c> and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
public static async Task<int> CreateDataConnectionAsync(int siteId, string name, string protocol = "OpcUa", string? primaryConfig = null)
|
||||
{
|
||||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var args = new List<string>
|
||||
{
|
||||
"data-connection", "create",
|
||||
"--site-id", siteId.ToString(inv),
|
||||
"--name", name,
|
||||
"--protocol", protocol,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(primaryConfig))
|
||||
{
|
||||
args.Add("--primary-config");
|
||||
args.Add(primaryConfig);
|
||||
}
|
||||
|
||||
using var doc = await RunJsonAsync([.. args]);
|
||||
return RequireId(doc, "data-connection create");
|
||||
}
|
||||
|
||||
/// <summary>Best-effort delete of a data connection via <c>data-connection delete</c> for teardown.</summary>
|
||||
public static Task DeleteDataConnectionAsync(int id) => BestEffortAsync("data-connection", "delete", id);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an inbound API method via <c>api-method create</c> (so it appears as a checkbox in the
|
||||
/// API-key form) and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
public static async Task<int> CreateApiMethodAsync(string name, string script = "return null;")
|
||||
{
|
||||
using var doc = await RunJsonAsync("api-method", "create", "--name", name, "--script", script);
|
||||
return RequireId(doc, "api-method create");
|
||||
}
|
||||
|
||||
/// <summary>Best-effort delete of an API method via <c>api-method delete</c> for teardown.</summary>
|
||||
public static Task DeleteApiMethodAsync(int id) => BestEffortAsync("api-method", "delete", id);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an API key's opaque string <c>keyId</c> from its display name via
|
||||
/// <c>security api-key list</c>; returns <see langword="null"/> if no key matches.
|
||||
/// </summary>
|
||||
public static async Task<string?> ResolveApiKeyIdByNameAsync(string name)
|
||||
{
|
||||
using var doc = await RunJsonAsync("security", "api-key", "list");
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var key in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (key.TryGetProperty("name", out var n)
|
||||
&& n.ValueKind == JsonValueKind.String
|
||||
&& string.Equals(n.GetString(), name, StringComparison.Ordinal)
|
||||
&& key.TryGetProperty("keyId", out var k)
|
||||
&& k.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return k.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort delete of an API key via <c>security api-key delete --key-id</c> for teardown.
|
||||
/// The key id is an opaque string, so this cannot use the int-based <see cref="BestEffortAsync"/>.
|
||||
/// </summary>
|
||||
public static async Task DeleteApiKeyAsync(string keyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync("security", "api-key", "delete", "--key-id", keyId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort teardown — never mask the test's own failure.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an instance's full configuration via <c>instance get</c>; the returned document exposes
|
||||
/// <c>connectionBindings</c>, <c>attributeOverrides</c>, and <c>areaId</c> for persistence read-back.
|
||||
/// Caller owns the returned <see cref="JsonDocument"/>.
|
||||
/// </summary>
|
||||
public static Task<JsonDocument> GetInstanceAsync(int id) =>
|
||||
RunJsonAsync("instance", "get", "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
```
|
||||
|
||||
**Step 2: Add round-trip helper tests** to `CliRunnerHelpersTests.cs` (follow the existing `[SkippableFact]` + `Skip.IfNot` pattern). Resolve `site-a` first.
|
||||
|
||||
```csharp
|
||||
[SkippableFact]
|
||||
public async Task CreateThenDeleteDataConnection_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var siteId = await CliRunner.ResolveSiteIdAsync("site-a");
|
||||
var id = await CliRunner.CreateDataConnectionAsync(siteId, CliRunner.UniqueName("conn"));
|
||||
try
|
||||
{
|
||||
Assert.True(id > 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteDataConnectionAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CreateThenDeleteApiMethod_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var id = await CliRunner.CreateApiMethodAsync(CliRunner.UniqueName("method"));
|
||||
try
|
||||
{
|
||||
Assert.True(id > 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteApiMethodAsync(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Build + run** — `dotnet test --filter "FullyQualifiedName~CliRunnerHelpersTests"`. Expected: new tests pass (cluster up) or skip (cluster down); 0 failed.
|
||||
|
||||
**Step 4: Commit** — `git add -A && git commit -m "test(e2e): add CliRunner helpers for data-connection, api-method, api-key teardown, instance read-back"`
|
||||
|
||||
**Acceptance:** helpers compile warning-free; round-trip tests green; no residual `zztest-*` connection/method left behind.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: InstanceConfigureFixture (ephemeral instance + data-connection on site-a)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 6
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs`
|
||||
|
||||
**Context:** Mirror `DeploymentFixture` exactly (partial-init guard, `Available` flag, best-effort dispose). Provisions on **site-a**: a `zztest` template + one `Double` attribute named `Value`, a `zztest` **data-connection** (so the bindings UI has a connection to bind to), a `zztest` area (for the area-reassignment test), and one instance created with **no area** (so the reassignment test makes a real change). Deploy is intentionally NOT performed — bindings/overrides/area are pre-deploy config operations, so a non-deployed instance is the correct, simpler fixture.
|
||||
|
||||
**Validation-behavior check (do first):** Read `InstanceConfigure.razor.cs` to confirm what populates `_bindingDataSourceAttrs` and `_overrideAttrs`. A plain `Double` attribute is expected to appear in both. If a plain attribute does NOT qualify as a binding data-source, adjust the fixture's attribute (e.g. add the attribute kind the page requires) and note it in a comment.
|
||||
|
||||
**Step 1: Write the fixture:**
|
||||
|
||||
```csharp
|
||||
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 for the InstanceConfigure E2E tests. Provisions, on the real
|
||||
/// running <c>site-a</c>: a zztest template with a single bindable <c>Double</c> attribute, a zztest
|
||||
/// data-connection (so the bindings UI has a connection to assign), a zztest area (for the
|
||||
/// area-reassignment test), and one instance created with no area. The instance is NOT deployed —
|
||||
/// bindings/overrides/area assignment are pre-deploy configuration operations.
|
||||
/// </summary>
|
||||
public sealed class InstanceConfigureFixture : IAsyncLifetime
|
||||
{
|
||||
private const string SiteAIdentifier = "site-a";
|
||||
|
||||
public int SiteAId { get; private set; }
|
||||
public int TemplateId { get; private set; }
|
||||
public int ConnectionId { get; private set; }
|
||||
public int AreaId { get; private set; }
|
||||
public int InstanceId { get; private set; }
|
||||
|
||||
/// <summary>The single bindable/overridable attribute name on the fixture template.</summary>
|
||||
public string AttributeName => "Value";
|
||||
|
||||
/// <summary>The fixture data-connection name (for locating it in the bindings UI dropdown).</summary>
|
||||
public string ConnectionName { get; private set; } = string.Empty;
|
||||
|
||||
public bool Available { get; private set; }
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
Available = await ClusterAvailability.IsAvailableAsync();
|
||||
if (!Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SiteAId = await CliRunner.ResolveSiteIdAsync(SiteAIdentifier);
|
||||
TemplateId = await CliRunner.CreateTemplateAsync(CliRunner.UniqueName("cfgtmpl"));
|
||||
await CliRunner.AddAttributeAsync(TemplateId, AttributeName, "Double");
|
||||
ConnectionName = CliRunner.UniqueName("conn");
|
||||
ConnectionId = await CliRunner.CreateDataConnectionAsync(SiteAId, ConnectionName);
|
||||
AreaId = await CliRunner.CreateAreaAsync(SiteAId, CliRunner.UniqueName("cfgarea"));
|
||||
InstanceId = await CliRunner.CreateInstanceAsync(CliRunner.UniqueName("cfginst"), TemplateId, SiteAId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await SafeCleanupAsync();
|
||||
Available = false;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (!Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SafeCleanupAsync();
|
||||
}
|
||||
|
||||
private async Task SafeCleanupAsync()
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(InstanceId);
|
||||
await CliRunner.DeleteDataConnectionAsync(ConnectionId);
|
||||
await CliRunner.DeleteAreaAsync(AreaId);
|
||||
await CliRunner.DeleteTemplateAsync(TemplateId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Build** — `dotnet build`. Expected: clean.
|
||||
|
||||
**Step 3: Commit** — `git add -A && git commit -m "test(e2e): add InstanceConfigureFixture (template+attr+connection+area+instance on site-a)"`
|
||||
|
||||
**Acceptance:** compiles; fields populated; dispose deletes everything (verified by Task 11 residue check).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add data-test hooks to InstanceConfigure.razor
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1, Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor`
|
||||
|
||||
**Context:** The page's `<select>`s and the error alert have only generic Bootstrap classes. Add three additive, non-functional `data-test` attributes. Buttons ("Save Bindings", "Save Overrides", "Set Area") are reliably reachable by role+text and need no hooks.
|
||||
|
||||
**Step 1: Add the attributes** (exact locations from the selector audit — re-Read the file to confirm line numbers before editing):
|
||||
- The bulk "Assign all to…" `<select>` in the bindings card header (~line 87): add `data-test="binding-bulk-select"`.
|
||||
- The area `<select>` in the Area Assignment card (~line 439): add `data-test="area-select"`.
|
||||
- The error/not-found `<div class="alert alert-danger">@_errorMessage</div>` (~line 48): add `data-test="instance-error-alert"`.
|
||||
|
||||
Example edit (bulk select):
|
||||
```razor
|
||||
<select class="form-select form-select-sm" data-test="binding-bulk-select" @bind="_bulkConnectionId">
|
||||
```
|
||||
|
||||
**Step 2: Build** — `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI`. Expected: clean (attributes are inert).
|
||||
|
||||
**Step 3: Commit** — `git add -A && git commit -m "feat(centralui): add data-test hooks to InstanceConfigure selects + error alert (test instrumentation)"`
|
||||
|
||||
**Acceptance:** the three `data-test` attributes render; no behavioral/markup change beyond the attributes.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: InstanceConfigureTests — bindings round-trip
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 7, Task 9 (different files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs`
|
||||
|
||||
**Depends on:** Task 0, Task 1, Task 2.
|
||||
|
||||
**Test:** Bulk-assign all attributes to the fixture connection → Save Bindings → assert one toast → verify persisted via `instance get`.
|
||||
|
||||
**Step 1: Write the test class + first test:**
|
||||
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
||||
|
||||
[Collection("Playwright")]
|
||||
public sealed class InstanceConfigureTests : IClassFixture<InstanceConfigureFixture>
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
private readonly InstanceConfigureFixture _cfg;
|
||||
|
||||
public InstanceConfigureTests(PlaywrightFixture fixture, InstanceConfigureFixture cfg)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task BindAllAttributes_SavesAndPersists()
|
||||
{
|
||||
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
await page.GotoAsync($"{_fixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
|
||||
|
||||
// Bulk-assign every bindable attribute to the fixture connection, then Apply + Save.
|
||||
await page.Locator("[data-test='binding-bulk-select']")
|
||||
.SelectOptionAsync(new SelectOptionValue { Label = _cfg.ConnectionName });
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Apply" }).ClickAsync();
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Save Bindings" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
|
||||
// Verify persistence via CLI read-back (not just the toast).
|
||||
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
|
||||
var bindings = doc.RootElement.GetProperty("connectionBindings");
|
||||
var bound = bindings.EnumerateArray().Any(b =>
|
||||
b.GetProperty("attributeName").GetString() == _cfg.AttributeName
|
||||
&& b.GetProperty("dataConnectionId").GetInt32() == _cfg.ConnectionId);
|
||||
Assert.True(bound, "Expected the Value attribute to be bound to the fixture connection after Save Bindings.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note (do first):** confirm the bulk `<select>` option label is the connection *name* (the audit indicates options are connection names). If the option text differs, select by the rendered text. Confirm `SelectOptionValue { Label = … }` matches; if the option value is the connection id, select by `Value = _cfg.ConnectionId.ToString()` instead.
|
||||
|
||||
**Step 2: Run** — `dotnet test --filter "FullyQualifiedName~InstanceConfigureTests.BindAllAttributes"`. Expected: pass (cluster up) / skip (down).
|
||||
|
||||
**Step 3: Commit** — `git add -A && git commit -m "test(e2e): InstanceConfigure bindings round-trip (bulk assign → save → verify via instance get)"`
|
||||
|
||||
**Acceptance:** test drives the real bindings save and verifies persistence by read-back; leaves no residue (fixture owns cleanup).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: InstanceConfigureTests — attribute-override + area reassignment + not-found edge
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (same file as Task 3 → serial after it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs`
|
||||
|
||||
**Depends on:** Task 3.
|
||||
|
||||
**Tests (add three methods):**
|
||||
|
||||
1. **Attribute-override round-trip** — type an override value for `Value` in the Attribute Overrides section → "Save Overrides" → one toast → `instance get` shows `attributeOverrides` containing `{attributeName:"Value", overrideValue:<typed>}`. The per-attribute override input is the text `<input class="form-control form-control-sm">` in the overrides card row; locate it by scoping to the overrides card and the row whose label cell text is `Value` (re-Read the section to confirm the row structure; if ambiguous, add `data-test="override-input-Value"` to the input as a 4th hook in Task 2's spirit and reference it).
|
||||
|
||||
2. **Area reassignment** — `data-test='area-select'` → select the fixture area by its name → "Set Area" → one toast → `instance get` shows `areaId == _cfg.AreaId`.
|
||||
|
||||
3. **Not-found edge** — `GotoAsync(.../deployment/instances/999999999/configure)` → assert `page.Locator("[data-test='instance-error-alert']")` visible and contains text `not found` (confirm exact wording `Instance #999999999 not found.` against `InstanceConfigure.razor.cs` line ~547 per the protocol).
|
||||
|
||||
**Step 1–3:** write each test (same shape as Task 3: skip-gate, authenticated page, act, toast assert, CLI read-back), run the filtered tests, commit:
|
||||
`git add -A && git commit -m "test(e2e): InstanceConfigure attribute-override + area reassignment + not-found edge"`
|
||||
|
||||
**Note — alarm overrides deferred:** the Alarm Overrides subsystem renders rows only when the template defines an unlocked alarm, and template alarms are not CLI-provisionable. Alarm-override UI coverage is therefore **deferred to a later wave** (requires a template-with-alarm fixture path). Add a `// TODO(wave-N): alarm-override UI coverage — needs template-with-alarm fixture (not CLI-provisionable today)` comment at the bottom of the file so the gap is tracked in-code.
|
||||
|
||||
**Acceptance:** three tests pass/skip; overrides + area verified by read-back; not-found asserts the real surfaced message.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: ApiSurfaceFixture (inbound api-method for the API-key form)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1, Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiSurfaceFixture.cs`
|
||||
|
||||
**Depends on:** Task 0.
|
||||
|
||||
**Context:** The API-key form renders one checkbox per inbound API method (`id="method-access-{ApiMethod.Id}"`). Provision one `zztest` api-method so a checkbox exists; expose its `Id` so tests can target `#method-access-{Id}` precisely.
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a single inbound API method so the API-key form renders at least one method checkbox
|
||||
/// (<c>id="method-access-{MethodId}"</c>). Created API keys are deleted per-test; this fixture owns
|
||||
/// only the method.
|
||||
/// </summary>
|
||||
public sealed class ApiSurfaceFixture : IAsyncLifetime
|
||||
{
|
||||
public int MethodId { get; private set; }
|
||||
public string MethodName { get; private set; } = string.Empty;
|
||||
public bool Available { get; private set; }
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
Available = await ClusterAvailability.IsAvailableAsync();
|
||||
if (!Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
MethodName = CliRunner.UniqueName("method");
|
||||
MethodId = await CliRunner.CreateApiMethodAsync(MethodName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await CliRunner.DeleteApiMethodAsync(MethodId);
|
||||
Available = false;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (Available)
|
||||
{
|
||||
await CliRunner.DeleteApiMethodAsync(MethodId);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Commit:** `git add -A && git commit -m "test(e2e): add ApiSurfaceFixture (inbound api-method for API-key form checkboxes)"`
|
||||
|
||||
**Acceptance:** compiles; `MethodId > 0`; disposed cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: ApiKeyCrudTests — create→token reveal + validation edges
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3, Task 9 (different files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs`
|
||||
|
||||
**Depends on:** Task 0, Task 5.
|
||||
|
||||
**Selectors (verified):** Name input = the single `input[type=text].form-control.form-control-sm`; method checkbox = `#method-access-{_api.MethodId}`; Save = button text "Save"; created-token panel = `[data-test='created-token']`; inline validation = `div.text-danger.small.mt-2` (messages: `Name is required.`, `Select at least one API method for this key.`).
|
||||
|
||||
**Tests:**
|
||||
|
||||
1. **Create→token reveal** (mutates; teardown via CLI):
|
||||
```csharp
|
||||
[SkippableFact]
|
||||
public async Task CreateApiKey_RevealsOneTimeToken()
|
||||
{
|
||||
Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
var keyName = CliRunner.UniqueName("apikey");
|
||||
try
|
||||
{
|
||||
await page.GotoAsync($"{_fixture.BaseUrl}/admin/api-keys/create");
|
||||
await page.Locator("input[type='text'].form-control-sm").First.FillAsync(keyName);
|
||||
await page.Locator($"#method-access-{_api.MethodId}").CheckAsync();
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator("[data-test='created-token']")).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = "Copy" })).ToBeVisibleAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
var keyId = await CliRunner.ResolveApiKeyIdByNameAsync(keyName);
|
||||
if (keyId is not null) await CliRunner.DeleteApiKeyAsync(keyId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Empty name → validation** — leave name blank, check the method, Save → assert `div.text-danger.small` visible containing `Name is required.`; no token panel. (No teardown needed — nothing created.)
|
||||
|
||||
3. **No methods → validation** — fill name, leave all methods unchecked, Save → assert validation contains `Select at least one API method for this key.`; no token panel.
|
||||
|
||||
**Step: run** `dotnet test --filter "FullyQualifiedName~ApiKeyCrudTests"`, then **commit**: `git add -A && git commit -m "test(e2e): API-key create→token reveal + name/method validation edges"`
|
||||
|
||||
**Acceptance:** create reveals the token; both validation paths assert the real messages; the created key is deleted by name in `finally` (verified by Task 11 residue check).
|
||||
|
||||
---
|
||||
|
||||
## Task 7: ApiKeyCrudTests — enable/disable + delete-with-confirm
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (same file as Task 6 → serial after it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/ApiKeyCrudTests.cs`
|
||||
|
||||
**Depends on:** Task 6.
|
||||
|
||||
**Context:** Pre-create the key via `CliRunner.CreateApiKeyAsync(name, methods)` (added in Task 0's review fix — it runs `security api-key create` via `RunAsync` because that command prints prose, not JSON, and resolves the new `keyId` by name) so the list has a row to act on, then drive the list-page actions. Pass `methods = _api.MethodName`. Teardown via `CliRunner.DeleteApiKeyAsync(keyId)` in `finally`.
|
||||
|
||||
**Tests:**
|
||||
|
||||
1. **Enable/Disable** — on `/admin/api-keys`, open the row kebab `button[aria-label="More actions for {name}"]` → click `Disable` → assert one toast and the `Disabled` badge appears on the row; re-open kebab → `Enable` → toast, badge gone.
|
||||
|
||||
2. **Delete-with-confirm** — kebab → `Delete` (`.dropdown-item.text-danger`) → confirm modal title `Delete API Key`, click `.modal-footer .btn-danger` (text `Delete`) → assert the row for that name is gone (`ToHaveCountAsync(0)`).
|
||||
|
||||
Locate a key's row by name via `page.Locator("tr:has(td:text-is(\"<name>\"))")`. Teardown: best-effort `DeleteApiKeyAsync` in `finally` (the delete test removes it; the enable/disable test must clean up its own key).
|
||||
|
||||
**Step: run + commit** — `git add -A && git commit -m "test(e2e): API-key enable/disable toast + delete-with-confirm removes row"`
|
||||
|
||||
**Acceptance:** enable/disable + delete drive real mutations with toast/row assertions; no residual keys.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: TransportExportTests — export wizard happy path
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3, Task 6, Task 9
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportExportTests.cs`
|
||||
|
||||
**Depends on:** Task 0 (uses `CreateTemplateAsync`/`AddAttributeAsync`/`DeleteTemplateAsync`, already present).
|
||||
|
||||
**Context:** Route `/design/transport/export` (RequireDesign — multi-role qualifies). Wizard: Step 1 Select (pick the template), Step 2 Review (Next), Step 3 Encrypt (`#passphrase` + `#passphrase-confirm`, Export enabled when matching & ≥8 chars), Step 4 Download (`[data-testid='download-summary']` "Bundle ready. Your browser is downloading the file."). The download itself is a JS-interop blob, **not** a DOM `<a download>` — so assert the `download-summary` DOM (proof the export succeeded server-side) rather than capturing the file, to avoid a hang on `WaitForDownload`.
|
||||
|
||||
**Test:**
|
||||
```csharp
|
||||
[SkippableFact]
|
||||
public async Task ExportTemplate_ReachesDownloadSummary()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var tmplName = CliRunner.UniqueName("exptmpl");
|
||||
var tmplId = await CliRunner.CreateTemplateAsync(tmplName);
|
||||
await CliRunner.AddAttributeAsync(tmplId, "Value", "Double");
|
||||
try
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
await page.GotoAsync($"{_fixture.BaseUrl}/design/transport/export");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Step 1 — narrow to the zztest template and select it.
|
||||
await page.Locator("#export-filter").FillAsync(tmplName);
|
||||
await page.Locator($"[data-testid='group-templates'] label:has-text('{tmplName}')").ClickAsync();
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Next" }).ClickAsync();
|
||||
|
||||
// Step 2 — Review.
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Next" }).ClickAsync();
|
||||
|
||||
// Step 3 — Encrypt.
|
||||
await page.Locator("#passphrase").FillAsync("zztest-passphrase-123");
|
||||
await page.Locator("#passphrase-confirm").FillAsync("zztest-passphrase-123");
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Export" }).ClickAsync();
|
||||
|
||||
// Step 4 — success.
|
||||
await Assertions.Expect(page.Locator("[data-testid='download-summary']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 20_000 });
|
||||
await Assertions.Expect(page.Locator("[data-testid='download-summary']"))
|
||||
.ToContainTextAsync("Bundle ready");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteTemplateAsync(tmplId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes (do first):** confirm the template checkbox interaction — clicking the `label` toggles the tree checkbox; if the label click doesn't check it, target the adjacent `input[type=checkbox]`. Confirm the "Export" button label text and that it enables after both passphrases match. If `download-summary` doesn't appear because the JS download needs a real browser download path, wrap the Export click in `page.RunAndWaitForDownloadAsync(...)` and assert the download's `SuggestedFilename` ends with `.scadabundle` instead.
|
||||
|
||||
**Step: run + commit** — `git add -A && git commit -m "test(e2e): Transport Export wizard reaches download summary for a zztest template"`
|
||||
|
||||
**Acceptance:** export drives the full wizard to the success state; template cleaned up.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Wrong-passphrase import negative test
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 8 (different file)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Transport/TransportImportTests.cs`
|
||||
|
||||
**Depends on:** Task 0 (existing helpers only).
|
||||
|
||||
**Context:** Reuse the existing import scaffolding. Export a real encrypted bundle via `CliRunner.BundleExportAsync(path, tmplId, correctPass, env)`, upload it, then submit a **wrong** passphrase at Step 2. Verified failure behavior (`TransportImport.razor.cs` `SubmitPassphraseAsync`): `_errorMessage = "Wrong passphrase. Please try again."`, the `#import-passphrase` input stays visible, and `[data-testid='diff-summary']` does not appear. Error element: `[data-testid='error-message']`. Secondary: `[data-testid='unlock-attempts']` → "Failed unlock attempts: 1 of …".
|
||||
|
||||
**Test:**
|
||||
```csharp
|
||||
[SkippableFact]
|
||||
public async Task ImportWithWrongPassphrase_ShowsErrorAndStaysOnPassphraseStep()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var tmplName = CliRunner.UniqueName("wrongpass");
|
||||
var tmplId = await CliRunner.CreateTemplateAsync(tmplName);
|
||||
await CliRunner.AddAttributeAsync(tmplId, "Value", "Double");
|
||||
var bundlePath = Path.Combine(Path.GetTempPath(), tmplName + ".scadabundle");
|
||||
try
|
||||
{
|
||||
await CliRunner.BundleExportAsync(bundlePath, tmplId, "correct-passphrase-1", "src-env");
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
await page.GotoAsync($"{_fixture.BaseUrl}/design/transport/import");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);
|
||||
await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']")).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
await page.Locator("#import-passphrase").FillAsync("WRONG-passphrase-xyz");
|
||||
await page.Locator("button.btn-primary:has-text('Unlock')").ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator("[data-testid='error-message']"))
|
||||
.ToContainTextAsync("Wrong passphrase. Please try again.", new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(page.Locator("#import-passphrase")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("[data-testid='diff-summary']")).ToBeHiddenAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(tmplName))
|
||||
await CliRunner.DeleteTemplateAsync(id);
|
||||
try { File.Delete(bundlePath); } catch { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: the source template is NOT deleted before import here (we never reach the diff/apply step), so teardown deletes by name prefix to catch it.
|
||||
|
||||
**Step: run + commit** — `git add -A && git commit -m "test(e2e): Transport import wrong-passphrase shows error and stays on passphrase step"`
|
||||
|
||||
**Acceptance:** asserts the real error message, that the wizard stays on Step 2, and that no diff appears; bundle + template cleaned up.
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Wave 1 verification — full suite green + zero residue + clean build
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (final gate)
|
||||
|
||||
**Depends on:** Tasks 0–9.
|
||||
|
||||
**Steps:**
|
||||
1. `dotnet build` the test project — expect 0 warnings/0 errors (`TreatWarningsAsErrors=true`).
|
||||
2. Run the **full** suite: `dotnet test`. Expect **0 failed**; new Wave-1 tests pass against the live cluster; any cluster-down skips are logged by `SkipSummaryReporter`. Record the pass/skip/fail tally.
|
||||
3. **Residue check** (cluster up) — confirm zero `zztest-*` leftovers:
|
||||
- `dotnet scadabridge.dll … --format json template list` → no `zztest-` names.
|
||||
- `… instance list --site-id <site-a>` → no `zztest-inst`/`zztest-cfginst` names.
|
||||
- `… data-connection list --site-id <site-a>` → no `zztest-conn` names.
|
||||
- `… security api-key list` → no `zztest-apikey` names.
|
||||
- `… api-method list` → no `zztest-method` names.
|
||||
- `… site area list`/topology → no `zztest-cfgarea` names.
|
||||
Any leftover → fix the owning test's teardown before closing the wave.
|
||||
4. Confirm the InstanceConfigure `data-test` additions did not change rendered behavior (heading/sections unchanged) — spot-check by loading the page.
|
||||
|
||||
**Commit (if any residue/teardown fixes were needed):** `git add -A && git commit -m "test(e2e): Wave 1 verification fixes (teardown/residue)"`
|
||||
|
||||
**Acceptance:** full suite 0 failed with skips logged; zero `zztest-*` residue across all entity types; build clean. Wave 1 is shippable.
|
||||
|
||||
---
|
||||
|
||||
## Execution notes
|
||||
|
||||
- **Parallel dispatch:** after Task 0, Tasks 1 / 2 / 5 are independent (disjoint files) and can run concurrently. Tasks 3, 6, 8, 9 are independent test files and can run concurrently once their deps land. Tasks 4 and 7 are serial after 3 and 6 respectively (same file). Task 10 is the final gate.
|
||||
- **Cluster required:** all functional tests are `SkippableFact` — run against the live 8-node docker cluster for real coverage. If the cluster is down they skip-and-log (suite still green), but Wave 1 isn't "verified" until a green run against the live cluster with the residue check passing.
|
||||
- **Waves 2–4** are planned separately after this wave ships, per the design doc.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-06-playwright-coverage-fill-wave1.md",
|
||||
"lastUpdated": "2026-06-06T00:00:00Z",
|
||||
"nativeTaskIdBase": 79,
|
||||
"status": "completed",
|
||||
"tasks": [
|
||||
{"id": 0, "nativeId": 79, "subject": "Task 0: CLI helper extensions", "status": "completed"},
|
||||
{"id": 1, "nativeId": 80, "subject": "Task 1: InstanceConfigureFixture", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 2, "nativeId": 81, "subject": "Task 2: data-test hooks on InstanceConfigure.razor", "status": "completed"},
|
||||
{"id": 3, "nativeId": 82, "subject": "Task 3: InstanceConfigureTests bindings round-trip", "status": "completed", "blockedBy": [0, 1, 2]},
|
||||
{"id": 4, "nativeId": 83, "subject": "Task 4: InstanceConfigureTests override + area + not-found", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 5, "nativeId": 84, "subject": "Task 5: ApiSurfaceFixture", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 6, "nativeId": 85, "subject": "Task 6: ApiKeyCrudTests create + validation", "status": "completed", "blockedBy": [0, 5]},
|
||||
{"id": 7, "nativeId": 86, "subject": "Task 7: ApiKeyCrudTests enable/disable + delete", "status": "completed", "blockedBy": [6]},
|
||||
{"id": 8, "nativeId": 87, "subject": "Task 8: TransportExportTests happy path", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 9, "nativeId": 88, "subject": "Task 9: Wrong-passphrase import negative", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 10, "nativeId": 89, "subject": "Task 10: Wave 1 verification + residue check", "status": "completed", "blockedBy": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]}
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -45,7 +45,7 @@
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
<div class="alert alert-danger" data-test="instance-error-alert">@_errorMessage</div>
|
||||
}
|
||||
else if (_instance != null)
|
||||
{
|
||||
@@ -84,7 +84,7 @@
|
||||
@if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<select class="form-select form-select-sm" style="width: auto;" @bind="_bulkConnectionId">
|
||||
<select class="form-select form-select-sm" style="width: auto;" data-test="binding-bulk-select" @bind="_bulkConnectionId">
|
||||
<option value="0">Assign all to...</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
@@ -436,7 +436,7 @@
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select class="form-select form-select-sm" style="width: auto;" @bind="_reassignAreaId">
|
||||
<select class="form-select form-select-sm" style="width: auto;" data-test="area-select" @bind="_reassignAreaId">
|
||||
<option value="0">No area</option>
|
||||
@foreach (var a in _siteAreas)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// E2E coverage for the inbound API-key create form (<c>/admin/api-keys/create</c>,
|
||||
/// rendered by <c>ApiKeyForm.razor</c>). One happy-path test asserts the one-time token
|
||||
/// reveal after a successful create (and tears the key down via the CLI in a <c>finally</c>),
|
||||
/// plus two validation-edge tests that assert the exact inline messages the razor renders
|
||||
/// for an empty name and for a key with no method selected.
|
||||
///
|
||||
/// <para>
|
||||
/// The <see cref="ApiSurfaceFixture"/> provisions a single inbound api-method so the form
|
||||
/// renders at least one method checkbox (<c>#method-access-{MethodId}</c>); this fixture
|
||||
/// owns that method (cleaned at fixture dispose). Created keys are owned by the tests and
|
||||
/// deleted per-test.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Verified against <c>ApiKeyForm.razor</c>: the name input is the single
|
||||
/// <c>input[type='text'].form-control.form-control-sm</c> (editable on create); the Save
|
||||
/// button is the success button with text "Save"; the created-token panel exposes
|
||||
/// <c>[data-test='created-token']</c> with an adjacent "Copy" button; inline validation
|
||||
/// renders in <c>div.text-danger.small</c> with the literal messages
|
||||
/// <c>"Name is required."</c> and <c>"Select at least one API method for this key."</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public sealed class ApiKeyCrudTests : IClassFixture<ApiSurfaceFixture>
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
private readonly ApiSurfaceFixture _api;
|
||||
|
||||
public ApiKeyCrudTests(PlaywrightFixture fixture, ApiSurfaceFixture api)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_api = api;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Happy path: filling the name, checking the fixture method, and saving creates the key
|
||||
/// and reveals the one-time token panel (<c>[data-test='created-token']</c>) with its
|
||||
/// adjacent "Copy" button. MUTATES — the created key is deleted via the CLI in a
|
||||
/// <c>finally</c> (resolve by name, best-effort delete) so nothing leaks.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Create_RevealsOneTimeToken()
|
||||
{
|
||||
Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
var keyName = CliRunner.UniqueName("apikey");
|
||||
|
||||
try
|
||||
{
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys/create");
|
||||
|
||||
// Blazor Server page renders a LoadingSpinner while OnInitializedAsync loads the
|
||||
// method list; web-first wait for the name input before driving it.
|
||||
var nameInput = page.Locator("input[type='text'].form-control.form-control-sm").First;
|
||||
await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
await nameInput.FillAsync(keyName);
|
||||
await page.Locator($"#method-access-{_api.MethodId}").CheckAsync();
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator("[data-test='created-token']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = "Copy" }))
|
||||
.ToBeVisibleAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
var keyId = await CliRunner.ResolveApiKeyIdByNameAsync(keyName);
|
||||
if (keyId is not null)
|
||||
{
|
||||
await CliRunner.DeleteApiKeyAsync(keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation edge: an empty name (with a method checked) blocks the save and renders the
|
||||
/// literal <c>"Name is required."</c> message in <c>div.text-danger</c>; no token panel
|
||||
/// appears, so nothing is created and there is no teardown.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Create_EmptyName_ShowsValidationError()
|
||||
{
|
||||
Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys/create");
|
||||
|
||||
// Web-first wait on the method checkbox guarantees the form (and its method list)
|
||||
// has rendered before we interact.
|
||||
var methodCheckbox = page.Locator($"#method-access-{_api.MethodId}");
|
||||
await Assertions.Expect(methodCheckbox).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// Leave the name blank; check the method so only the name rule fires.
|
||||
await methodCheckbox.CheckAsync();
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
|
||||
var error = page.Locator("div.text-danger.small");
|
||||
await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(error).ToContainTextAsync("Name is required.");
|
||||
|
||||
// Nothing should have been created — the token panel must never appear.
|
||||
await Assertions.Expect(page.Locator("[data-test='created-token']")).ToHaveCountAsync(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation edge: a named key with no method selected blocks the save and renders the
|
||||
/// literal <c>"Select at least one API method for this key."</c> message in
|
||||
/// <c>div.text-danger</c>; no token panel appears, so nothing is created and there is no
|
||||
/// teardown.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Create_NoMethods_ShowsValidationError()
|
||||
{
|
||||
Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys/create");
|
||||
|
||||
var nameInput = page.Locator("input[type='text'].form-control.form-control-sm").First;
|
||||
await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// Fill the name but leave ALL method checkboxes unchecked so only the method rule fires.
|
||||
// The name input uses Blazor @bind (onchange), which only commits on blur — blur the input
|
||||
// and let the change round-trip to the server (NetworkIdle) so _formName is populated before
|
||||
// Save, otherwise the name-required rule short-circuits first.
|
||||
await nameInput.FillAsync(CliRunner.UniqueName("apikey"));
|
||||
await nameInput.BlurAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
|
||||
var error = page.Locator("div.text-danger.small");
|
||||
await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(error).ToContainTextAsync("Select at least one API method for this key.");
|
||||
|
||||
// The name-only submit must not create a key — no token panel.
|
||||
await Assertions.Expect(page.Locator("[data-test='created-token']")).ToHaveCountAsync(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable/disable round-trip on the list page (<c>/admin/api-keys</c>, rendered by
|
||||
/// <c>ApiKeys.razor</c>). MUTATES — creates a key via the CLI, drives the per-row kebab
|
||||
/// (<c>button[aria-label="More actions for {name}"]</c>, a Bootstrap <c>data-bs-toggle="dropdown"</c>)
|
||||
/// to Disable then Enable. The authoritative state indicator is the
|
||||
/// <c>span.badge.bg-secondary</c> "Disabled" badge the razor renders on a disabled row's name
|
||||
/// cell: it must appear after Disable and be gone after Enable. The CLI-created key is deleted
|
||||
/// in a <c>finally</c> (best-effort) so nothing leaks.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task ToggleEnabled_TransitionsDisabledBadge()
|
||||
{
|
||||
Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
var keyName = CliRunner.UniqueName("apikey");
|
||||
var keyId = await CliRunner.CreateApiKeyAsync(keyName, _api.MethodName);
|
||||
|
||||
try
|
||||
{
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys");
|
||||
|
||||
// Web-first: the row for our key (by name) must render before we drive it.
|
||||
var row = page.Locator("tr").Filter(new() { HasText = keyName });
|
||||
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// Scope ALL dropdown interactions to THIS row's .dropdown container so the kebab and
|
||||
// its menu items can never multi-match against another row's (hidden) menu under
|
||||
// Playwright strict mode (e.g. when the list has test residue / multiple keys).
|
||||
var rowDropdown = row.Locator(".dropdown");
|
||||
var kebab = rowDropdown.Locator("button[aria-label^='More actions']");
|
||||
var disabledBadge = row.Locator("span.badge.bg-secondary");
|
||||
|
||||
// Open the kebab (Bootstrap dropdown) and click Disable. Gate the click on the item's
|
||||
// visibility so we don't race the Bootstrap open-transition before the menu is .show.
|
||||
await kebab.ClickAsync();
|
||||
var disableItem = rowDropdown.Locator(".dropdown-menu button.dropdown-item")
|
||||
.Filter(new() { HasText = "Disable" });
|
||||
await Assertions.Expect(disableItem).ToBeVisibleAsync();
|
||||
await disableItem.ClickAsync();
|
||||
|
||||
// Authoritative: the "Disabled" badge appears on the row's name cell.
|
||||
await Assertions.Expect(disabledBadge).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
|
||||
// Re-open the kebab and click Enable; the badge must disappear from this row.
|
||||
await kebab.ClickAsync();
|
||||
var enableItem = rowDropdown.Locator(".dropdown-menu button.dropdown-item")
|
||||
.Filter(new() { HasText = "Enable" });
|
||||
await Assertions.Expect(enableItem).ToBeVisibleAsync();
|
||||
await enableItem.ClickAsync();
|
||||
|
||||
await Assertions.Expect(disabledBadge).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteApiKeyAsync(keyId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete-with-confirm removes the row (<c>/admin/api-keys</c>, rendered by <c>ApiKeys.razor</c>).
|
||||
/// MUTATES — creates a key via the CLI, then deletes it through the UI: kebab →
|
||||
/// <c>.dropdown-item.text-danger</c> "Delete" → the confirm dialog ("Delete API Key", rendered by
|
||||
/// <c>DialogHost.razor</c>) → <c>.modal-footer .btn-danger</c>. The row for that name must then be
|
||||
/// gone. The CLI delete in the <c>finally</c> is an idempotent safety net (no-op if the UI already
|
||||
/// removed it).
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Delete_WithConfirm_RemovesRow()
|
||||
{
|
||||
Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
var keyName = CliRunner.UniqueName("apikey");
|
||||
var keyId = await CliRunner.CreateApiKeyAsync(keyName, _api.MethodName);
|
||||
|
||||
try
|
||||
{
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys");
|
||||
|
||||
var row = page.Locator("tr").Filter(new() { HasText = keyName });
|
||||
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// Scope ALL dropdown interactions to THIS row's .dropdown container so the kebab and
|
||||
// its menu items can never multi-match against another row's (hidden) menu under
|
||||
// Playwright strict mode (e.g. when the list has test residue / multiple keys).
|
||||
var rowDropdown = row.Locator(".dropdown");
|
||||
var kebab = rowDropdown.Locator("button[aria-label^='More actions']");
|
||||
|
||||
// Open the kebab and click the danger "Delete" item. Gate the click on the item's
|
||||
// visibility so we don't race the Bootstrap open-transition before the menu is .show.
|
||||
await kebab.ClickAsync();
|
||||
var deleteItem = rowDropdown.Locator(".dropdown-menu button.dropdown-item")
|
||||
.Filter(new() { HasText = "Delete" });
|
||||
await Assertions.Expect(deleteItem).ToBeVisibleAsync();
|
||||
await deleteItem.ClickAsync();
|
||||
|
||||
// The confirm dialog must appear before we confirm.
|
||||
await Assertions.Expect(page.Locator(".modal-title").Filter(new() { HasText = "Delete API Key" }))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await page.Locator(".modal-footer .btn-danger").ClickAsync();
|
||||
|
||||
// The row for that name must be gone after the delete round-trips.
|
||||
await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = keyName }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteApiKeyAsync(keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a single inbound API method so the API-key form renders at least one method checkbox
|
||||
/// (<c>id="method-access-{MethodId}"</c>). Created API keys are deleted per-test; this fixture owns
|
||||
/// only the method.
|
||||
/// </summary>
|
||||
public sealed class ApiSurfaceFixture : IAsyncLifetime
|
||||
{
|
||||
public int MethodId { get; private set; }
|
||||
public string MethodName { get; private set; } = string.Empty;
|
||||
public bool Available { get; private set; }
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
Available = await ClusterAvailability.IsAvailableAsync();
|
||||
if (!Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
MethodName = CliRunner.UniqueName("method");
|
||||
MethodId = await CliRunner.CreateApiMethodAsync(MethodName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Partial-init guard: MethodId is non-zero only if CreateApiMethodAsync succeeded,
|
||||
// so skip the delete when nothing was created (avoids a phantom delete --id 0).
|
||||
if (MethodId != 0)
|
||||
{
|
||||
await CliRunner.DeleteApiMethodAsync(MethodId);
|
||||
}
|
||||
Available = false;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (Available)
|
||||
{
|
||||
await CliRunner.DeleteApiMethodAsync(MethodId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
-4
@@ -70,14 +70,34 @@ public static partial class CliRunner
|
||||
/// (<c>Boolean</c>, <c>Int32</c>, <c>Double</c>, <c>String</c>).
|
||||
/// Defaults to <c>Double</c>.
|
||||
/// </param>
|
||||
/// <param name="dataSourceReference">
|
||||
/// Optional data source reference (tag path). When provided, maps to
|
||||
/// <c>--data-source</c> on the CLI and sets
|
||||
/// <c>TemplateAttribute.DataSourceReference</c>. The InstanceConfigure page
|
||||
/// populates <c>_bindingDataSourceAttrs</c> by filtering attributes to those
|
||||
/// where <c>DataSourceReference</c> is non-empty, so an attribute that needs
|
||||
/// to appear in the Connection Bindings panel MUST be created with this set.
|
||||
/// </param>
|
||||
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
|
||||
public static async Task AddAttributeAsync(int templateId, string name, string dataType = "Double")
|
||||
public static async Task AddAttributeAsync(
|
||||
int templateId, string name, string dataType = "Double",
|
||||
string? dataSourceReference = null)
|
||||
{
|
||||
await RunAsync(
|
||||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var args = new List<string>
|
||||
{
|
||||
"template", "attribute", "add",
|
||||
"--template-id", templateId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"--template-id", templateId.ToString(inv),
|
||||
"--name", name,
|
||||
"--data-type", dataType);
|
||||
"--data-type", dataType,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(dataSourceReference))
|
||||
{
|
||||
args.Add("--data-source");
|
||||
args.Add(dataSourceReference);
|
||||
}
|
||||
|
||||
await RunAsync([.. args]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -224,6 +244,124 @@ public static partial class CliRunner
|
||||
/// <param name="id">Site id.</param>
|
||||
public static Task DeleteSiteAsync(int id) => BestEffortAsync("site", "delete", id);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a data connection on a site via <c>data-connection create</c> and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
public static async Task<int> CreateDataConnectionAsync(int siteId, string name, string protocol = "OpcUa", string? primaryConfig = null)
|
||||
{
|
||||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var args = new List<string>
|
||||
{
|
||||
"data-connection", "create",
|
||||
"--site-id", siteId.ToString(inv),
|
||||
"--name", name,
|
||||
"--protocol", protocol,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(primaryConfig))
|
||||
{
|
||||
args.Add("--primary-config");
|
||||
args.Add(primaryConfig);
|
||||
}
|
||||
|
||||
using var doc = await RunJsonAsync([.. args]);
|
||||
return RequireId(doc, "data-connection create");
|
||||
}
|
||||
|
||||
/// <summary>Best-effort delete of a data connection via <c>data-connection delete</c> for teardown.</summary>
|
||||
public static Task DeleteDataConnectionAsync(int id) => BestEffortAsync("data-connection", "delete", id);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an inbound API method via <c>api-method create</c> (so it appears as a checkbox in the
|
||||
/// API-key form) and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
public static async Task<int> CreateApiMethodAsync(string name, string script = "return null;")
|
||||
{
|
||||
using var doc = await RunJsonAsync("api-method", "create", "--name", name, "--script", script);
|
||||
return RequireId(doc, "api-method create");
|
||||
}
|
||||
|
||||
/// <summary>Best-effort delete of an API method via <c>api-method delete</c> for teardown.</summary>
|
||||
public static Task DeleteApiMethodAsync(int id) => BestEffortAsync("api-method", "delete", id);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an API key via <c>security api-key create</c> and returns its opaque string
|
||||
/// <c>keyId</c> (resolved by name from <c>security api-key list</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>security api-key create</c> prints a human-readable block (not JSON) even under
|
||||
/// <c>--format json</c>, so this uses <see cref="CliRunner.RunAsync"/> (never
|
||||
/// <c>RunJsonAsync</c>, which would throw a <c>JsonException</c> on that output) and then
|
||||
/// resolves the new key's id by its unique name. Use this for tests that need a
|
||||
/// pre-existing key to act on (enable/disable/delete); pair with
|
||||
/// <see cref="DeleteApiKeyAsync"/> for teardown.
|
||||
/// </remarks>
|
||||
/// <param name="name">Unique key name (typically from <see cref="UniqueName"/>).</param>
|
||||
/// <param name="methods">Comma-separated API method names the key is scoped to.</param>
|
||||
/// <returns>The new key's opaque <c>keyId</c>.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The CLI failed, or the key could not be resolved by name after creation.
|
||||
/// </exception>
|
||||
public static async Task<string> CreateApiKeyAsync(string name, string methods)
|
||||
{
|
||||
await RunAsync("security", "api-key", "create", "--name", name, "--methods", methods);
|
||||
return await ResolveApiKeyIdByNameAsync(name)
|
||||
?? throw new InvalidOperationException(
|
||||
$"API key '{name}' was created but could not be resolved by name in 'security api-key list'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an API key's opaque string <c>keyId</c> from its display name via
|
||||
/// <c>security api-key list</c>; returns <see langword="null"/> if no key matches.
|
||||
/// </summary>
|
||||
public static async Task<string?> ResolveApiKeyIdByNameAsync(string name)
|
||||
{
|
||||
using var doc = await RunJsonAsync("security", "api-key", "list");
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var key in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (key.TryGetProperty("name", out var n)
|
||||
&& n.ValueKind == JsonValueKind.String
|
||||
&& string.Equals(n.GetString(), name, StringComparison.Ordinal)
|
||||
&& key.TryGetProperty("keyId", out var k)
|
||||
&& k.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return k.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort delete of an API key via <c>security api-key delete --key-id</c> for teardown.
|
||||
/// The key id is an opaque string, so this cannot use the int-based <see cref="BestEffortAsync"/>.
|
||||
/// </summary>
|
||||
public static async Task DeleteApiKeyAsync(string keyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync("security", "api-key", "delete", "--key-id", keyId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort teardown — never mask the test's own failure.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an instance's full configuration via <c>instance get</c>; the returned document exposes
|
||||
/// <c>connectionBindings</c>, <c>attributeOverrides</c>, and <c>areaId</c> for persistence read-back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the only helper that hands back a live <see cref="JsonDocument"/> (the rest return
|
||||
/// scalars). The caller OWNS it and MUST wrap the call in <c>using var doc = …</c>; the
|
||||
/// <c>Document</c> suffix is the signal that this returns a disposable resource, not plain data.
|
||||
/// </remarks>
|
||||
public static Task<JsonDocument> GetInstanceDocumentAsync(int id) =>
|
||||
RunJsonAsync("instance", "get", "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ids of all templates whose <c>name</c> starts with
|
||||
/// <paramref name="prefix"/>, via <c>template list</c>.
|
||||
|
||||
+41
@@ -43,4 +43,45 @@ public class CliRunnerHelpersTests
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
Assert.True(await CliRunner.ResolveSiteIdAsync("site-a") > 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A freshly created data connection returns a positive id and is cleanly
|
||||
/// deleted in teardown, exercising <see cref="CliRunner.CreateDataConnectionAsync"/>
|
||||
/// and <see cref="CliRunner.DeleteDataConnectionAsync"/> as a round-trip.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CreateThenDeleteDataConnection_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var siteId = await CliRunner.ResolveSiteIdAsync("site-a");
|
||||
var id = await CliRunner.CreateDataConnectionAsync(siteId, CliRunner.UniqueName("conn"));
|
||||
try
|
||||
{
|
||||
Assert.True(id > 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteDataConnectionAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A freshly created API method returns a positive id and is cleanly deleted in
|
||||
/// teardown, exercising <see cref="CliRunner.CreateApiMethodAsync"/> and
|
||||
/// <see cref="CliRunner.DeleteApiMethodAsync"/> as a round-trip.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CreateThenDeleteApiMethod_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var id = await CliRunner.CreateApiMethodAsync(CliRunner.UniqueName("method"));
|
||||
try
|
||||
{
|
||||
Assert.True(id > 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteApiMethodAsync(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
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 for the InstanceConfigure E2E tests. Provisions, on the real
|
||||
/// running <c>site-a</c>: a zztest template with a single bindable <c>Double</c> attribute, a zztest
|
||||
/// data-connection (so the bindings UI has a connection to assign), a zztest area (for the
|
||||
/// area-reassignment test), and one instance created with no area. The instance is NOT deployed —
|
||||
/// bindings/overrides/area assignment are pre-deploy configuration operations.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why the attribute is created with <c>--data-source "Value"</c>:</b>
|
||||
/// <c>InstanceConfigure.razor.cs</c> populates <c>_bindingDataSourceAttrs</c> by filtering
|
||||
/// <c>GetAttributesByTemplateIdAsync</c> to attributes where
|
||||
/// <c>!string.IsNullOrEmpty(a.DataSourceReference)</c> (line 581 of InstanceConfigure.razor).
|
||||
/// A plain <c>Double</c> attribute created <em>without</em> <c>--data-source</c> has
|
||||
/// <c>DataSourceReference = null</c>, so it would not appear in the Connection Bindings panel
|
||||
/// and the bindings-UI tests would silently see "No data-sourced attributes". The attribute
|
||||
/// DOES appear unconditionally in <c>_overrideAttrs</c> (filtered only on <c>!IsLocked</c>,
|
||||
/// line 592), so overrides work with or without a tag path. The fixture therefore sets
|
||||
/// <c>dataSourceReference: "Value"</c> when adding the attribute so both panels are exercised.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InstanceConfigureFixture : IAsyncLifetime
|
||||
{
|
||||
private const string SiteAIdentifier = "site-a";
|
||||
|
||||
public int SiteAId { get; private set; }
|
||||
public int TemplateId { get; private set; }
|
||||
public int ConnectionId { get; private set; }
|
||||
public int AreaId { get; private set; }
|
||||
public int InstanceId { get; private set; }
|
||||
|
||||
/// <summary>The single bindable/overridable attribute name on the fixture template.</summary>
|
||||
public string AttributeName => "Value";
|
||||
|
||||
/// <summary>The fixture data-connection name (for locating it in the bindings UI dropdown).</summary>
|
||||
public string ConnectionName { get; private set; } = string.Empty;
|
||||
|
||||
public bool Available { get; private set; }
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
Available = await ClusterAvailability.IsAvailableAsync();
|
||||
if (!Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SiteAId = await CliRunner.ResolveSiteIdAsync(SiteAIdentifier);
|
||||
TemplateId = await CliRunner.CreateTemplateAsync(CliRunner.UniqueName("cfgtmpl"));
|
||||
// The attribute must have DataSourceReference set (via --data-source) so it appears
|
||||
// in _bindingDataSourceAttrs on the InstanceConfigure page. Without it the Connection
|
||||
// Bindings panel shows "No data-sourced attributes" and binding tests cannot run.
|
||||
// See the class-level XML doc for the full analysis.
|
||||
await CliRunner.AddAttributeAsync(TemplateId, AttributeName, "Double", dataSourceReference: AttributeName);
|
||||
ConnectionName = CliRunner.UniqueName("conn");
|
||||
ConnectionId = await CliRunner.CreateDataConnectionAsync(SiteAId, ConnectionName);
|
||||
AreaId = await CliRunner.CreateAreaAsync(SiteAId, CliRunner.UniqueName("cfgarea"));
|
||||
InstanceId = await CliRunner.CreateInstanceAsync(CliRunner.UniqueName("cfginst"), TemplateId, SiteAId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await SafeCleanupAsync();
|
||||
Available = false;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (!Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SafeCleanupAsync();
|
||||
}
|
||||
|
||||
private async Task SafeCleanupAsync()
|
||||
{
|
||||
await CliRunner.DeleteInstanceAsync(InstanceId);
|
||||
await CliRunner.DeleteDataConnectionAsync(ConnectionId);
|
||||
await CliRunner.DeleteAreaAsync(AreaId);
|
||||
await CliRunner.DeleteTemplateAsync(TemplateId);
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// E2E round-trip for the InstanceConfigure page's Connection Bindings panel. The
|
||||
/// <see cref="InstanceConfigureFixture"/> provisions a zztest template whose single
|
||||
/// bindable <c>Double</c> attribute carries a <c>DataSourceReference</c> (so it appears
|
||||
/// in the bindings panel), a zztest data-connection on site-a, a zztest area, and a
|
||||
/// non-deployed instance. This fact drives the page's bulk-assign UI to bind every
|
||||
/// data-sourced attribute to the fixture connection, saves, and then verifies the bind
|
||||
/// actually persisted via a CLI <c>instance get</c> read-back — not just the toast.
|
||||
///
|
||||
/// <para>
|
||||
/// Selector note: the bulk select (<c>data-test='binding-bulk-select'</c>) is bound to
|
||||
/// <c>_bulkConnectionId</c> (an int), and its option VALUES are connection ids while the
|
||||
/// option TEXT is <c>"{name} ({protocol})"</c>. Selecting by VALUE = the connection id is
|
||||
/// the robust choice (it doesn't depend on the connection's protocol suffix in the label).
|
||||
/// The bulk row only renders when there is at least one data-sourced attribute AND at
|
||||
/// least one site connection — both guaranteed by the fixture — so it is always present
|
||||
/// here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public sealed class InstanceConfigureTests : IClassFixture<InstanceConfigureFixture>
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
private readonly InstanceConfigureFixture _cfg;
|
||||
|
||||
public InstanceConfigureTests(PlaywrightFixture fixture, InstanceConfigureFixture cfg)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task BindAllAttributes_SavesAndPersists()
|
||||
{
|
||||
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
|
||||
|
||||
// This is a Blazor Server page: it renders a LoadingSpinner while OnInitializedAsync
|
||||
// loads the template attributes + site connections, then re-renders the bindings
|
||||
// panel (the bulk select renders only once both lists are non-empty). Settle the
|
||||
// initial load (NetworkIdle) and web-first wait for the bulk select before driving it,
|
||||
// so the interaction never races the post-load re-render.
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
var bulkSelect = page.Locator("[data-test='binding-bulk-select']");
|
||||
await Assertions.Expect(bulkSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// Bulk-assign every bindable attribute to the fixture connection, then Apply + Save.
|
||||
// Select by VALUE (the connection id) — most robust, since the select binds _bulkConnectionId.
|
||||
await bulkSelect.SelectOptionAsync(new SelectOptionValue { Value = _cfg.ConnectionId.ToString() });
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Apply" }).ClickAsync();
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Save Bindings" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
|
||||
// Verify persistence via CLI read-back (not just the toast).
|
||||
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
|
||||
var bindings = doc.RootElement.GetProperty("connectionBindings");
|
||||
var bound = bindings.EnumerateArray().Any(b =>
|
||||
b.GetProperty("attributeName").GetString() == _cfg.AttributeName
|
||||
&& b.GetProperty("dataConnectionId").GetInt32() == _cfg.ConnectionId);
|
||||
Assert.True(bound, "Expected the Value attribute to be bound to the fixture connection after Save Bindings.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round-trips an attribute override through the <b>Attribute Overrides</b> card. The
|
||||
/// override input carries no <c>data-test</c> hook, so it is located structurally: the
|
||||
/// overrides card is the one whose Save button reads "Save Overrides"; inside it, the
|
||||
/// table row whose label cell holds the attribute name (<c>_cfg.AttributeName</c> = "Value")
|
||||
/// owns the type=text <c>input.form-control-sm</c>. Fills a sentinel value, saves, asserts
|
||||
/// exactly one toast, then verifies the override persisted via a CLI <c>instance get</c>
|
||||
/// read-back (not just the toast).
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task SaveOverride_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
|
||||
|
||||
// Blazor Server page renders a LoadingSpinner first; web-first wait for the overrides
|
||||
// section's Save button before driving the input so we never race the post-load re-render.
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
var saveOverrides = page.GetByRole(AriaRole.Button, new() { Name = "Save Overrides" });
|
||||
await Assertions.Expect(saveOverrides).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// Scope to the Attribute Overrides card (the one containing the "Save Overrides" button),
|
||||
// pick the row whose label cell text is the attribute name, then its text input.
|
||||
var overridesCard = page.Locator("div.card", new() { Has = saveOverrides });
|
||||
var overrideInput = overridesCard
|
||||
.GetByRole(AriaRole.Row, new() { Name = _cfg.AttributeName })
|
||||
.Locator("input.form-control-sm[type='text']");
|
||||
await overrideInput.FillAsync("zztest-override-42");
|
||||
|
||||
await saveOverrides.ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
|
||||
// Verify persistence via CLI read-back (not just the toast).
|
||||
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
|
||||
var overrides = doc.RootElement.GetProperty("attributeOverrides");
|
||||
var saved = overrides.EnumerateArray().Any(o =>
|
||||
o.GetProperty("attributeName").GetString() == _cfg.AttributeName
|
||||
&& o.GetProperty("overrideValue").GetString() == "zztest-override-42");
|
||||
Assert.True(saved, "Expected the Value attribute override to persist after Save Overrides.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reassigns the (initially area-less) fixture instance to the fixture area via the
|
||||
/// <b>Area Assignment</b> card. Drives the existing <c>data-test='area-select'</c> hook by
|
||||
/// VALUE (the area id, since the select binds the area id), clicks "Set Area", asserts one
|
||||
/// toast, and verifies the new <c>areaId</c> via a CLI <c>instance get</c> read-back. This
|
||||
/// mutates the shared fixture instance's area, but is independent of the other tests (each
|
||||
/// gets a fresh page and asserts only on its own effect).
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task SetArea_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/{_cfg.InstanceId}/configure");
|
||||
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
var areaSelect = page.Locator("[data-test='area-select']");
|
||||
await Assertions.Expect(areaSelect).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// Select by VALUE = the area id (the select binds _reassignAreaId).
|
||||
await areaSelect.SelectOptionAsync(new SelectOptionValue { Value = _cfg.AreaId.ToString() });
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Set Area" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
|
||||
// Verify persistence: areaId must equal the fixture area after Set Area (it may have been
|
||||
// null/absent before).
|
||||
using var doc = await CliRunner.GetInstanceDocumentAsync(_cfg.InstanceId);
|
||||
Assert.True(doc.RootElement.TryGetProperty("areaId", out var areaIdEl)
|
||||
&& areaIdEl.ValueKind == JsonValueKind.Number,
|
||||
"Expected areaId to be a number after Set Area.");
|
||||
Assert.Equal(_cfg.AreaId, areaIdEl.GetInt32());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Not-found edge: navigating to a configure URL for a non-existent instance id surfaces the
|
||||
/// page's error alert (<c>data-test='instance-error-alert'</c>) carrying the
|
||||
/// <c>$"Instance #{Id} not found."</c> message built in <c>InstanceConfigure.OnInitializedAsync</c>.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task NotFoundInstance_ShowsErrorAlert()
|
||||
{
|
||||
Skip.IfNot(_cfg.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/instances/999999999/configure");
|
||||
|
||||
var errorAlert = page.Locator("[data-test='instance-error-alert']");
|
||||
await Assertions.Expect(errorAlert).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(errorAlert).ToContainTextAsync("not found");
|
||||
}
|
||||
|
||||
// TODO(wave-N): alarm-override UI coverage — needs a template-with-alarm fixture (template alarms are not CLI-provisionable today).
|
||||
}
|
||||
@@ -27,6 +27,23 @@ public class PlaywrightFixture : IAsyncLifetime
|
||||
public IPlaywright Playwright { get; private set; } = null!;
|
||||
public IBrowser Browser { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Live browser contexts created by <see cref="NewPageAsync"/>, oldest first.
|
||||
/// Capped to <see cref="MaxRetainedContexts"/> so finished tests' contexts are
|
||||
/// closed eagerly rather than leaking for the whole run (see <see cref="NewPageAsync"/>).
|
||||
/// </summary>
|
||||
private readonly List<IBrowserContext> _contexts = new();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of browser contexts kept open at once. Each context holds a live
|
||||
/// Blazor Server SignalR circuit on the Central UI; the full suite runs serially within
|
||||
/// the <c>Playwright</c> collection, so contexts from already-finished tests can be closed
|
||||
/// safely. Leaving every context open accumulated ~one circuit per test and slowed late
|
||||
/// tests into navigation/visibility timeouts. A small cap (covering any single test that
|
||||
/// opens more than one page) keeps server-side circuit pressure flat across the run.
|
||||
/// </summary>
|
||||
private const int MaxRetainedContexts = 4;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
|
||||
@@ -40,12 +57,36 @@ public class PlaywrightFixture : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new browser context and page. Each test gets an isolated session.
|
||||
/// Create a new browser context and page. Each test gets an isolated session. Contexts
|
||||
/// from already-finished tests are closed eagerly once more than
|
||||
/// <see cref="MaxRetainedContexts"/> are open, to bound the number of concurrent Blazor
|
||||
/// Server circuits the run holds on the Central UI.
|
||||
/// </summary>
|
||||
public async Task<IPage> NewPageAsync()
|
||||
{
|
||||
var context = await Browser.NewContextAsync();
|
||||
return await context.NewPageAsync();
|
||||
var page = await context.NewPageAsync();
|
||||
|
||||
List<IBrowserContext> toClose = new();
|
||||
lock (_contexts)
|
||||
{
|
||||
_contexts.Add(context);
|
||||
int excess = _contexts.Count - MaxRetainedContexts;
|
||||
if (excess > 0)
|
||||
{
|
||||
toClose = _contexts.GetRange(0, excess);
|
||||
_contexts.RemoveRange(0, excess);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var old in toClose)
|
||||
{
|
||||
// Best-effort: a finished test's context may already be gone; never fail a test
|
||||
// (or the next page creation) on teardown of a stale context.
|
||||
try { await old.CloseAsync(); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Happy-path end-to-end for the Transport Export wizard (Component #24, Task T21).
|
||||
///
|
||||
/// <para>
|
||||
/// Drives the full four-step wizard at <c>/design/transport/export</c> against the
|
||||
/// running dev cluster, exporting a single throwaway template the test creates via
|
||||
/// the CLI:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>Step 1 — Select : filter to the zztest template and tick its checkbox.</item>
|
||||
/// <item>Step 2 — Review : confirm the resolved closure (Next).</item>
|
||||
/// <item>Step 3 — Encrypt: supply a matching passphrase (≥ 8 chars) and Export.</item>
|
||||
/// <item>Step 4 — Download: assert the success summary.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Step 1's template list is a <c>TemplateFolderTree</c> in checkbox mode. The inner
|
||||
/// <c>TreeView</c> renders an <c><input type="checkbox" class="tv-checkbox"></c>
|
||||
/// per node and the name in a sibling <c>span.tv-label</c>; there is no
|
||||
/// <c><label for></c> association, and in checkbox mode clicking the label text
|
||||
/// does NOT toggle the box (content-click only selects in Single mode). So the test
|
||||
/// clicks the checkbox <c>input</c> itself, scoped to the row carrying the template's
|
||||
/// label text.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Step 4's download is a JS-interop blob stream (NOT a DOM <c><a download></c>),
|
||||
/// so the test asserts the success DOM (<c>[data-testid='download-summary']</c>) rather
|
||||
/// than waiting for a Playwright download event — which would hang, since no
|
||||
/// browser-level download fires.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public sealed class TransportExportTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public TransportExportTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ExportTemplate_ReachesDownloadSummary()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var tmplName = CliRunner.UniqueName("exptmpl");
|
||||
var tmplId = await CliRunner.CreateTemplateAsync(tmplName);
|
||||
await CliRunner.AddAttributeAsync(tmplId, "Value", "Double");
|
||||
try
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/export");
|
||||
// Wait for Step-1 to render (OnInitializedAsync makes 8 repo calls over SignalR
|
||||
// before the template tree appears — NetworkIdle is an unreliable proxy for this).
|
||||
await Assertions.Expect(page.Locator("[data-testid='group-templates']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// ── STEP 1: Select ────────────────────────────────────────────────────────
|
||||
// Narrow the tree to just the zztest template, then tick its checkbox.
|
||||
await page.Locator("#export-filter").FillAsync(tmplName);
|
||||
|
||||
// The template node is a TreeView leaf row: a checkbox input plus a
|
||||
// span.tv-label carrying the name. Clicking the label text is a no-op in
|
||||
// checkbox mode, so we click the input directly, scoped to the row whose
|
||||
// label matches the unique zztest name.
|
||||
var templateCheckbox = page
|
||||
.Locator("[data-testid='group-templates'] li")
|
||||
.Filter(new() { Has = page.Locator($"span.tv-label:text-is('{tmplName}')") })
|
||||
.Locator("input.tv-checkbox")
|
||||
.First;
|
||||
await Assertions.Expect(templateCheckbox).ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await templateCheckbox.CheckAsync();
|
||||
|
||||
// Next enables only once a selection exists; clicking it resolves the closure.
|
||||
var step1Next = page.Locator("button.btn.btn-primary:has-text('Next')");
|
||||
await Assertions.Expect(step1Next).ToBeEnabledAsync(new() { Timeout = 10_000 });
|
||||
await step1Next.ClickAsync();
|
||||
|
||||
// ── STEP 2: Review ────────────────────────────────────────────────────────
|
||||
// The seed group lists the picked template; Next is always enabled here.
|
||||
// Scope the Next click to the Step-2 panel — identified by its unique
|
||||
// #include-deps toggle — so it cannot accidentally match a Step-1 button.
|
||||
await Assertions.Expect(page.Locator("[data-testid='seed-group']"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
await page.Locator("div:has(#include-deps) button.btn.btn-primary:has-text('Next')")
|
||||
.ClickAsync();
|
||||
|
||||
// ── STEP 3: Encrypt ───────────────────────────────────────────────────────
|
||||
const string passphrase = "zztest-passphrase-123";
|
||||
await Assertions.Expect(page.Locator("#passphrase"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await page.Locator("#passphrase").FillAsync(passphrase);
|
||||
await page.Locator("#passphrase-confirm").FillAsync(passphrase);
|
||||
|
||||
// Export enables only when the two passphrases match AND length ≥ 8.
|
||||
var exportBtn = page.Locator("button.btn.btn-primary:has-text('Export')");
|
||||
await Assertions.Expect(exportBtn).ToBeEnabledAsync(new() { Timeout = 10_000 });
|
||||
await exportBtn.ClickAsync();
|
||||
|
||||
// ── STEP 4: Download ──────────────────────────────────────────────────────
|
||||
// JS-interop blob download — assert the success DOM, never WaitForDownload.
|
||||
var summary = page.Locator("[data-testid='download-summary']");
|
||||
await Assertions.Expect(summary).ToBeVisibleAsync(new() { Timeout = 20_000 });
|
||||
await Assertions.Expect(summary).ToContainTextAsync("Bundle ready");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteTemplateAsync(tmplId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-1
@@ -35,7 +35,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Transport;
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class TransportImportTests
|
||||
public sealed class TransportImportTests
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
@@ -156,4 +156,88 @@ public class TransportImportTests
|
||||
try { File.Delete(bundlePath); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Negative path: feed a real encrypted bundle, then submit the WRONG
|
||||
/// passphrase at Step 2. Per
|
||||
/// <c>TransportImport.razor.cs::SubmitPassphraseAsync</c>, the importer throws
|
||||
/// <see cref="System.Security.Cryptography.CryptographicException"/>, which
|
||||
/// increments <c>_failedUnlockAttempts</c> to 1 (below the configured
|
||||
/// <c>MaxUnlockAttemptsPerSession</c> of 3, so no lockout / no re-upload),
|
||||
/// sets <c>_errorMessage = "Wrong passphrase. Please try again."</c>, clears
|
||||
/// the passphrase field, and leaves <c>_step</c> on Passphrase. The wizard
|
||||
/// therefore renders the <c>[data-testid='error-message']</c> alert, keeps
|
||||
/// <c>#import-passphrase</c> visible, and never reaches the Diff step — so
|
||||
/// <c>[data-testid='diff-summary']</c> stays absent.
|
||||
///
|
||||
/// <para>
|
||||
/// We never reach the diff/apply, so the source template is deliberately NOT
|
||||
/// deleted before import; teardown drops it by name prefix.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task ImportWithWrongPassphrase_ShowsErrorAndStaysOnPassphraseStep()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var tmplName = CliRunner.UniqueName("wrongpass");
|
||||
var bundlePath = Path.Combine(Path.GetTempPath(), tmplName + ".scadabundle");
|
||||
|
||||
try
|
||||
{
|
||||
// ── ARRANGE: build + export a synthetic single-template encrypted bundle ──
|
||||
int tmplId = await CliRunner.CreateTemplateAsync(tmplName);
|
||||
await CliRunner.AddAttributeAsync(tmplId, "Value", "Double");
|
||||
await CliRunner.BundleExportAsync(bundlePath, tmplId, "correct-passphrase-1", "src-env");
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
// ── STEP 1: Upload ────────────────────────────────────────────────────────
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);
|
||||
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 (wrong) ────────────────────────────────────────────
|
||||
await Assertions.Expect(page.Locator("#import-passphrase"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
await page.Locator("#import-passphrase").FillAsync("WRONG-passphrase-xyz");
|
||||
await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync();
|
||||
|
||||
// The wrong passphrase surfaces the typed error, keeps the passphrase
|
||||
// input visible (still on Step 2), and never reveals the diff summary.
|
||||
await Assertions.Expect(page.Locator("[data-testid='error-message']"))
|
||||
.ToContainTextAsync("Wrong passphrase. Please try again.", new() { Timeout = 10_000 });
|
||||
await Assertions.Expect(page.Locator("#import-passphrase")).ToBeVisibleAsync();
|
||||
// Assert the diff step is genuinely absent (the @switch never rendered it), not merely
|
||||
// hidden — ToBeHiddenAsync is vacuously true for an element that doesn't exist.
|
||||
await Assertions.Expect(page.Locator("[data-testid='diff-summary']")).ToHaveCountAsync(0);
|
||||
|
||||
// Secondary indicator: one failed attempt recorded (1 of MaxUnlockAttempts).
|
||||
await Assertions.Expect(page.Locator("[data-testid='unlock-attempts']"))
|
||||
.ToContainTextAsync("Failed unlock attempts: 1 of");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The source template was never deleted (we never reached apply), so
|
||||
// teardown drops it by name prefix and removes the staged bundle.
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user