diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesActionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesActionTests.cs
new file mode 100644
index 00000000..e47cda84
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/ParkedMessagesActionTests.cs
@@ -0,0 +1,124 @@
+using Microsoft.Playwright;
+using Xunit;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Monitoring;
+
+///
+/// End-to-end action and filter-control gating tests for the central Parked Messages page
+/// (/monitoring/parked-messages).
+///
+///
+/// Why this is distinct from : the existing render
+/// guard verifies that the singleton-backed cross-cluster query resolves (renders the results
+/// table or the empty-state card) rather than hanging. This class targets the DETERMINISTIC
+/// filter-control gating behaviour that is verifiable regardless of whether any parked rows
+/// exist in the live environment:
+///
+/// - The Query button is disabled until a site is selected.
+/// - The Clear button is disabled until at least one filter is active,
+/// and is re-disabled after clicking Clear.
+///
+/// A third, tolerant fact exercises the conditional bulk-action bar (Retry/Discard selected)
+/// that appears when at least one row checkbox is checked. Parked store-and-forward rows live
+/// in the SITE's local SQLite buffer — there is no central table to seed — so that fact
+/// performs an early-return no-op when no rows happen to be present, which is both expected
+/// and acceptable in this environment.
+///
+///
+///
+/// Gated on via Skip.IfNot: when the cluster is
+/// unreachable the facts report as Skipped (not Failed), matching the established suite idiom.
+///
+///
+[Collection("Playwright")]
+public class ParkedMessagesActionTests
+{
+ private readonly PlaywrightFixture _fixture;
+
+ public ParkedMessagesActionTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ ///
+ /// The Query button must be disabled on page load (no site selected) and become
+ /// enabled once a site is selected from the site dropdown.
+ ///
+ [SkippableFact]
+ public async Task QueryButton_DisabledUntilSiteSelected()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/monitoring/parked-messages");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ await Assertions.Expect(page.Locator("h4:has-text('Parked Messages')")).ToBeVisibleAsync();
+
+ var query = page.Locator("button.btn.btn-primary.btn-sm:has-text('Query')");
+ await Assertions.Expect(query).ToBeDisabledAsync();
+
+ await page.Locator("#pm-filter-site").SelectOptionAsync("site-a");
+ // Selecting a site enables Query (and kicks off its own search — tolerated).
+ await Assertions.Expect(query).ToBeEnabledAsync(new() { Timeout = 5_000 });
+ }
+
+ ///
+ /// The Clear button must be disabled on page load (no active filters) and become
+ /// enabled once any filter is changed. Clicking Clear must re-disable it.
+ ///
+ [SkippableFact]
+ public async Task ClearButton_DisabledUntilFilterSet_ThenResets()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/monitoring/parked-messages");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var clear = page.Locator("button.btn.btn-outline-secondary.btn-sm:has-text('Clear')");
+ await Assertions.Expect(clear).ToBeDisabledAsync();
+
+ // Setting any filter (Age) flips HasActiveFilters -> Clear enables.
+ await page.Locator("#pm-filter-age").SelectOptionAsync("LastHour");
+ await Assertions.Expect(clear).ToBeEnabledAsync(new() { Timeout = 5_000 });
+
+ await clear.ClickAsync();
+ await Assertions.Expect(clear).ToBeDisabledAsync(new() { Timeout = 5_000 });
+ }
+
+ ///
+ /// When at least one parked row is present, checking its row checkbox must reveal
+ /// the bulk action bar with Retry selected and Discard selected buttons. If no rows
+ /// are present (the common case in a clean test environment — parked rows are not
+ /// seedable from central), the test exits early as a tolerated no-op.
+ ///
+ [SkippableFact]
+ public async Task SelectingParkedRow_RevealsBulkRetryDiscardBar_WhenRowsPresent()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/monitoring/parked-messages");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await page.Locator("#pm-filter-site").SelectOptionAsync("site-a");
+ await page.Locator("button.btn.btn-primary.btn-sm:has-text('Query')").ClickAsync();
+
+ // Wait for the query to resolve (table OR empty-state card).
+ var resolved = page.Locator("table.parked-table, div.card-body:has-text('No parked messages')");
+ await Assertions.Expect(resolved.First).ToBeVisibleAsync(new() { Timeout = 20_000 });
+
+ // Parked S&F rows are not seedable, so rows may be absent in this environment. Only
+ // assert the action affordances when at least one row rendered.
+ var rows = page.Locator("tr.parked-row");
+ if (await rows.CountAsync() == 0)
+ {
+ return; // No parked messages at site-a — bulk-bar affordance can't be exercised.
+ }
+
+ await rows.First.Locator("input.form-check-input").CheckAsync();
+ await Assertions.Expect(page.Locator("button:has-text('Retry selected')")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("button:has-text('Discard selected')")).ToBeVisibleAsync();
+ }
+}