# 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("")` → `zztest--<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 --name --protocol [--primary-config ]` → JSON object with `id`. `data-connection delete --id `. - `api-method create --name --script [--timeout ]` → JSON object with `id`. `api-method delete --id `. `api-method list` → array of `{id,name}`. - `security api-key list` → array of `{keyId,name,enabled}`. `security api-key delete --key-id ` (key id is a **string**, so it cannot use the int-based `BestEffortAsync`). - `instance get --id ` → object with `connectionBindings[] {attributeName,dataConnectionId}`, `attributeOverrides[] {attributeName,overrideValue}`, `areaId`. **Step 1: Add the helpers** to `CliRunner.Helpers.cs` (inside the partial class): ```csharp /// /// Creates a data connection on a site via data-connection create and returns its new id. /// public static async Task CreateDataConnectionAsync(int siteId, string name, string protocol = "OpcUa", string? primaryConfig = null) { var inv = System.Globalization.CultureInfo.InvariantCulture; var args = new List { "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"); } /// Best-effort delete of a data connection via data-connection delete for teardown. public static Task DeleteDataConnectionAsync(int id) => BestEffortAsync("data-connection", "delete", id); /// /// Creates an inbound API method via api-method create (so it appears as a checkbox in the /// API-key form) and returns its new id. /// public static async Task 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"); } /// Best-effort delete of an API method via api-method delete for teardown. public static Task DeleteApiMethodAsync(int id) => BestEffortAsync("api-method", "delete", id); /// /// Resolves an API key's opaque string keyId from its display name via /// security api-key list; returns if no key matches. /// public static async Task 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; } /// /// Best-effort delete of an API key via security api-key delete --key-id for teardown. /// The key id is an opaque string, so this cannot use the int-based . /// 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. } } /// /// Reads an instance's full configuration via instance get; the returned document exposes /// connectionBindings, attributeOverrides, and areaId for persistence read-back. /// Caller owns the returned . /// public static Task 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; /// /// fixture for the InstanceConfigure E2E tests. Provisions, on the real /// running site-a: a zztest template with a single bindable Double 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. /// 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; } /// The single bindable/overridable attribute name on the fixture template. public string AttributeName => "Value"; /// The fixture data-connection name (for locating it in the bindings UI dropdown). 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 `` in the bindings card header (~line 87): add `data-test="binding-bulk-select"`. - The area ` ``` **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 { 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 `` 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; /// /// Provisions a single inbound API method so the API-key form renders at least one method checkbox /// (id="method-access-{MethodId}"). Created API keys are deleted per-test; this fixture owns /// only the method. /// 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(\"\"))")`. 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 `` — 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 ` → no `zztest-inst`/`zztest-cfginst` names. - `… data-connection list --site-id ` → 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.