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

701 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 13:** 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 09.
**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 24** are planned separately after this wave ships, per the design doc.