35 KiB
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 useSkip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason). - App URL from the browser is
fixture.BaseUrl(http://scadabridge-traefik); the CLI runs from the host againstlocalhost:9000(handled insideCliRunner). - 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 withid.data-connection delete --id <int>.api-method create --name <string> --script <string> [--timeout <int>]→ JSON object withid.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-basedBestEffortAsync).instance get --id <int>→ object withconnectionBindings[] {attributeName,dataConnectionId},attributeOverrides[] {attributeName,overrideValue},areaId.
Step 1: Add the helpers to CliRunner.Helpers.cs (inside the partial class):
/// <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.
[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:
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): adddata-test="binding-bulk-select". - The area
<select>in the Area Assignment card (~line 439): adddata-test="area-select". - The error/not-found
<div class="alert alert-danger">@_errorMessage</div>(~line 48): adddata-test="instance-error-alert".
Example edit (bulk select):
<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:
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):
-
Attribute-override round-trip — type an override value for
Valuein the Attribute Overrides section → "Save Overrides" → one toast →instance getshowsattributeOverridescontaining{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 isValue(re-Read the section to confirm the row structure; if ambiguous, adddata-test="override-input-Value"to the input as a 4th hook in Task 2's spirit and reference it). -
Area reassignment —
data-test='area-select'→ select the fixture area by its name → "Set Area" → one toast →instance getshowsareaId == _cfg.AreaId. -
Not-found edge —
GotoAsync(.../deployment/instances/999999999/configure)→ assertpage.Locator("[data-test='instance-error-alert']")visible and contains textnot found(confirm exact wordingInstance #999999999 not found.againstInstanceConfigure.razor.csline ~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.
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:
- Create→token reveal (mutates; teardown via CLI):
[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);
}
}
-
Empty name → validation — leave name blank, check the method, Save → assert
div.text-danger.smallvisible containingName is required.; no token panel. (No teardown needed — nothing created.) -
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:
-
Enable/Disable — on
/admin/api-keys, open the row kebabbutton[aria-label="More actions for {name}"]→ clickDisable→ assert one toast and theDisabledbadge appears on the row; re-open kebab →Enable→ toast, badge gone. -
Delete-with-confirm — kebab →
Delete(.dropdown-item.text-danger) → confirm modal titleDelete API Key, click.modal-footer .btn-danger(textDelete) → 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:
[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:
[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:
dotnet buildthe test project — expect 0 warnings/0 errors (TreatWarningsAsErrors=true).- Run the full suite:
dotnet test. Expect 0 failed; new Wave-1 tests pass against the live cluster; any cluster-down skips are logged bySkipSummaryReporter. Record the pass/skip/fail tally. - Residue check (cluster up) — confirm zero
zztest-*leftovers:dotnet scadabridge.dll … --format json template list→ nozztest-names.… instance list --site-id <site-a>→ nozztest-inst/zztest-cfginstnames.… data-connection list --site-id <site-a>→ nozztest-connnames.… security api-key list→ nozztest-apikeynames.… api-method list→ nozztest-methodnames.… site area list/topology → nozztest-cfgareanames. Any leftover → fix the owning test's teardown before closing the wave.
- Confirm the InstanceConfigure
data-testadditions 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.