diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs
new file mode 100644
index 00000000..d8530286
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditConfigurationTests.cs
@@ -0,0 +1,141 @@
+using Microsoft.Playwright;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+using Xunit;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
+
+///
+/// End-to-end coverage for the Configuration Audit Log page
+/// (/audit/configuration, OperationalAudit policy — the
+/// multi-role test user carries Admin + Viewer so it can reach the page).
+/// The page reads the central AuditLogEntries table; each fact seeds its
+/// own rows via under a unique per-test
+/// marker (stamped as EntityType), filters the grid by that marker
+/// so the (N total) pagination footer is deterministic regardless of the
+/// other rows the live cluster produces, then best-effort deletes the marker's
+/// rows in finally.
+///
+///
+/// This part (Wave 3, Task 10) covers render / search-narrows / pagination. The
+/// class is intentionally structured so Task 11 can APPEND modal/copy/chip facts
+/// to it without restructuring: shared constants (the DB-unavailable skip reason,
+/// the page route) and the seeded-test gate idiom live here once.
+///
+///
+///
+/// The seeded tests are + Skip.IfNot:
+/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
+/// matching the established idiom.
+///
+///
+[Collection("Playwright")]
+public class AuditConfigurationTests
+{
+ ///
+ /// Route to the Configuration Audit Log page. The results grid only renders
+ /// after a Search (a bare visit, without a ?bundleImportId= query param,
+ /// does not auto-fetch), so each fact issues a marker-scoped Search.
+ ///
+ private const string ConfigAuditUrl = "/audit/configuration";
+
+ /// Skip reason shared by the DB-seeding tests when MSSQL is down.
+ private const string DbUnavailableSkipReason =
+ "AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
+ "or set SCADABRIDGE_PLAYWRIGHT_DB to a reachable connection string.";
+
+ private readonly PlaywrightFixture _fixture;
+
+ public AuditConfigurationTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ ///
+ /// Generates a fresh per-test marker: a zzCfgAudit- prefix plus a short
+ /// GUID slice. Stamped as EntityType on every seeded row so a UI filter
+ /// on Entity Type isolates exactly this run's rows (deterministic totals) and
+ /// the finally cleanup never touches cluster-produced rows.
+ ///
+ private static string NewMarker() => "zzCfgAudit-" + Guid.NewGuid().ToString("N")[..8];
+
+ [SkippableFact]
+ public async Task Grid_RendersAndSearchNarrows()
+ {
+ Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
+
+ var marker = NewMarker();
+ try
+ {
+ // 3 rows, all EntityType = marker, no bundle rows → exactly 3 match.
+ await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 3, bundleRows: 0);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // The page renders its heading and filter controls. The results table
+ // (the whole @if (_entries != null) block) does NOT render until a
+ // Search is issued — the page only auto-fetches when arriving with a
+ // ?bundleImportId= query param, which a bare visit lacks — so the
+ // Entity Type filter is present and ready to drive the first query.
+ await Assertions.Expect(page.Locator("h4:has-text('Configuration Audit Log')")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("#audit-filter-entity-type")).ToBeVisibleAsync();
+
+ // Filter by the unique marker so only this run's 3 rows remain, then
+ // Search to load the grid.
+ await page.Locator("#audit-filter-entity-type").FillAsync(marker);
+ await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Seeded rows are isolated: a known row is visible AND the footer total
+ // is exactly 3 (filtering by the unique marker makes this deterministic
+ // regardless of the other rows the live cluster holds).
+ await Assertions.Expect(page.Locator($"tr:has-text('{marker}-0')")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("span:has-text('(3 total)')")).ToBeVisibleAsync();
+ }
+ finally
+ {
+ await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
+ }
+ }
+
+ [SkippableFact]
+ public async Task Pagination_PrevDisabledOnPage1_NextPaginates()
+ {
+ Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
+
+ var marker = NewMarker();
+ try
+ {
+ // 55 rows → 2 pages at 50/page.
+ await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 55, bundleRows: 0);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Filter by the marker so the totals/page-count reflect only this run.
+ await page.Locator("#audit-filter-entity-type").FillAsync(marker);
+ await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Page 1 of 2: Previous disabled, Next enabled.
+ await Assertions.Expect(page.Locator("span:has-text('Page 1 of 2')")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("span:has-text('(55 total)')")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("button:has-text('Previous')")).ToBeDisabledAsync();
+ await Assertions.Expect(page.Locator("button:has-text('Next')")).ToBeEnabledAsync();
+
+ // Advance to the last page: Previous enabled, Next disabled.
+ await page.Locator("button:has-text('Next')").ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await Assertions.Expect(page.Locator("span:has-text('Page 2 of 2')")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("button:has-text('Previous')")).ToBeEnabledAsync();
+ await Assertions.Expect(page.Locator("button:has-text('Next')")).ToBeDisabledAsync();
+ }
+ finally
+ {
+ await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker);
+ }
+ }
+}