# 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.
