diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ApiMethodFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ApiMethodFormTests.cs
new file mode 100644
index 00000000..9c20142c
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ApiMethodFormTests.cs
@@ -0,0 +1,104 @@
+using Microsoft.Playwright;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+using Xunit;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
+
+[Collection("Playwright")]
+public class ApiMethodFormTests
+{
+ private readonly PlaywrightFixture _pw;
+
+ public ApiMethodFormTests(PlaywrightFixture pw)
+ {
+ _pw = pw;
+ }
+
+ ///
+ /// The Add-API-Method form's required-field gate fires when Name is present but the
+ /// script is empty. This deliberately exercises the validation WITHOUT touching the
+ /// Monaco editor (brittle to type into): fill only the Name, click Save, and expect the
+ /// inline "Name and script required." error. The form does not navigate, so nothing is
+ /// saved — this fixture name never reaches the database.
+ ///
+ [SkippableFact]
+ public async Task CreateForm_NameWithoutScript_ShowsInlineError()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var page = await _pw.NewAuthenticatedPageAsync();
+
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/api-methods/create");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ await Assertions.Expect(page.Locator("h4:has-text('Add API Method')")).ToBeVisibleAsync();
+
+ // The form has several text inputs (Name + SchemaBuilder/parameter rows), so anchor
+ // the Name input to its label wrapper to stay unambiguous.
+ var nameInput = page.Locator("div.mb-3:has(label:has-text('Name')) input[type=text]");
+ await Assertions.Expect(nameInput).ToBeVisibleAsync();
+ await nameInput.FillAsync("zzapimethod");
+
+ // Leave the Monaco script empty and submit — the gate should reject it.
+ await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
+
+ await Assertions.Expect(page.Locator("div.text-danger.small:has-text('Name and script required.')"))
+ .ToBeVisibleAsync();
+
+ // The form must not have navigated away.
+ await Assertions.Expect(page).ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/design/api-methods/create$"));
+ }
+
+ ///
+ /// CLI-create an inbound API method, confirm it renders on the External Systems page's
+ /// "Inbound API Methods" tab, then delete it via the card kebab and confirm dialog. The
+ /// create/edit form is Monaco-driven (brittle), so authoring happens via the CLI; the UI
+ /// delete round-trip is the behavior under test. A best-effort CLI delete in finally
+ /// guarantees no zztest-method-* row leaks if the UI path fails mid-way.
+ ///
+ [SkippableFact]
+ public async Task CliCreated_Method_VisibleAndDeletes()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var name = CliRunner.UniqueName("method");
+ int id = await CliRunner.CreateApiMethodAsync(name);
+
+ try
+ {
+ var page = await _pw.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/external-systems");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Switch to the Inbound API Methods tab; only its panel renders, so the card
+ // locator below stays unambiguous against External System / DB Connection cards.
+ await page.Locator("button.nav-link:has-text('Inbound API Methods')").ClickAsync();
+
+ var card = page.Locator("div.card").Filter(new() { HasText = name });
+ await Assertions.Expect(card).ToHaveCountAsync(1, new() { Timeout = 10_000 });
+ await Assertions.Expect(card.Locator($"code:has-text('POST /api/{name}')")).ToBeVisibleAsync();
+
+ // Scope the kebab + delete item to the card's .dropdown for strict-mode safety.
+ var dropdown = card.Locator(".dropdown");
+ await dropdown.Locator("button[aria-label^='More actions']").ClickAsync();
+ await dropdown.Locator(".dropdown-menu button.dropdown-item.text-danger").ClickAsync();
+
+ await Assertions.Expect(page.Locator(".modal-footer .btn-danger")).ToBeVisibleAsync();
+ await page.Locator(".modal-footer .btn-danger").ClickAsync();
+
+ // Single web-first assertion on the success toast — toasts auto-dismiss, so we
+ // do NOT chase the toast body in a second sequential check.
+ await Assertions.Expect(page.Locator(".toast", new() { HasText = "Deleted." }))
+ .ToHaveCountAsync(1, new() { Timeout = 15_000 });
+
+ // The card must be gone.
+ await Assertions.Expect(page.Locator("div.card").Filter(new() { HasText = name }))
+ .ToHaveCountAsync(0, new() { Timeout = 10_000 });
+ }
+ finally
+ {
+ // Best-effort teardown: the happy path deletes via the UI, so this finds
+ // nothing; it only fires if the UI path failed mid-way.
+ await CliRunner.DeleteApiMethodAsync(id);
+ }
+ }
+}