From 8e8bf44a29e15204bf806ef27a4d98d9202685da Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 6 Jun 2026 11:32:18 -0400 Subject: [PATCH] docs: add Playwright coverage-fill Wave 1 plan (InstanceConfigure, API keys, Transport export) + tasks --- ...26-06-06-playwright-coverage-fill-wave1.md | 700 ++++++++++++++++++ ...aywright-coverage-fill-wave1.md.tasks.json | 18 + 2 files changed, 718 insertions(+) create mode 100644 docs/plans/2026-06-06-playwright-coverage-fill-wave1.md create mode 100644 docs/plans/2026-06-06-playwright-coverage-fill-wave1.md.tasks.json diff --git a/docs/plans/2026-06-06-playwright-coverage-fill-wave1.md b/docs/plans/2026-06-06-playwright-coverage-fill-wave1.md new file mode 100644 index 00000000..57144add --- /dev/null +++ b/docs/plans/2026-06-06-playwright-coverage-fill-wave1.md @@ -0,0 +1,700 @@ +# Playwright Coverage Fill — Wave 1 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task. + +**Goal:** Add deep functional coverage for the three highest-risk untested mutating surfaces — `InstanceConfigure`, API-key create/edit/list, and Transport Export (+ a wrong-passphrase import negative) — with edge cases folded in. + +**Architecture:** Extends the existing xunit + `PlaywrightFixture` harness. New ephemeral fixtures are CLI-provisioned on the live cluster and verified by CLI read-back (`instance get`, `security api-key list`); no DB seeding. All cluster-dependent tests are `[SkippableFact]` gated on `ClusterAvailability`. Cleanup is best-effort, keyed on `zztest-*`. Toast asserts are web-first `ToHaveCountAsync(1)`. A small number of additive, non-functional `data-test` attributes are added to `InstanceConfigure.razor`. + +**Tech Stack:** .NET 10, xunit + `Xunit.SkippableFact`, Microsoft.Playwright (remote Chromium at `ws://localhost:3000`), the `scadabridge` CLI (`dotnet scadabridge.dll … --format json`). + +**Reference design:** `docs/plans/2026-06-06-playwright-coverage-fill-design.md` (this is Wave 1 of 4). + +**Conventions (carry into every task):** +- Test files use `[Collection("Playwright")]`; cluster tests use `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason)`. +- App URL from the browser is `fixture.BaseUrl` (`http://scadabridge-traefik`); the CLI runs from the host against `localhost:9000` (handled inside `CliRunner`). +- Authenticated page: `await fixture.NewAuthenticatedPageAsync("multi-role", "password")`. +- Fixture names: `CliRunner.UniqueName("")` → `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.GetInstanceAsync(_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 CLI (`security api-key create --name --methods `) so the list has a row to act on, then drive the list-page actions. Add a `CreateApiKeyAsync` provision helper in Task 0's file if needed (returns keyId+name); otherwise create inline with `RunAsync` and resolve the keyId via `ResolveApiKeyIdByNameAsync`. + +**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. diff --git a/docs/plans/2026-06-06-playwright-coverage-fill-wave1.md.tasks.json b/docs/plans/2026-06-06-playwright-coverage-fill-wave1.md.tasks.json new file mode 100644 index 00000000..dda30c98 --- /dev/null +++ b/docs/plans/2026-06-06-playwright-coverage-fill-wave1.md.tasks.json @@ -0,0 +1,18 @@ +{ + "planPath": "docs/plans/2026-06-06-playwright-coverage-fill-wave1.md", + "lastUpdated": "2026-06-06T00:00:00Z", + "nativeTaskIdBase": 79, + "tasks": [ + {"id": 0, "nativeId": 79, "subject": "Task 0: CLI helper extensions", "status": "pending"}, + {"id": 1, "nativeId": 80, "subject": "Task 1: InstanceConfigureFixture", "status": "pending", "blockedBy": [0]}, + {"id": 2, "nativeId": 81, "subject": "Task 2: data-test hooks on InstanceConfigure.razor", "status": "pending"}, + {"id": 3, "nativeId": 82, "subject": "Task 3: InstanceConfigureTests bindings round-trip", "status": "pending", "blockedBy": [0, 1, 2]}, + {"id": 4, "nativeId": 83, "subject": "Task 4: InstanceConfigureTests override + area + not-found", "status": "pending", "blockedBy": [3]}, + {"id": 5, "nativeId": 84, "subject": "Task 5: ApiSurfaceFixture", "status": "pending", "blockedBy": [0]}, + {"id": 6, "nativeId": 85, "subject": "Task 6: ApiKeyCrudTests create + validation", "status": "pending", "blockedBy": [0, 5]}, + {"id": 7, "nativeId": 86, "subject": "Task 7: ApiKeyCrudTests enable/disable + delete", "status": "pending", "blockedBy": [6]}, + {"id": 8, "nativeId": 87, "subject": "Task 8: TransportExportTests happy path", "status": "pending", "blockedBy": [0]}, + {"id": 9, "nativeId": 88, "subject": "Task 9: Wrong-passphrase import negative", "status": "pending", "blockedBy": [0]}, + {"id": 10, "nativeId": 89, "subject": "Task 10: Wave 1 verification + residue check", "status": "pending", "blockedBy": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]} + ] +}