using Microsoft.Playwright; using Microsoft.EntityFrameworkCore; using Prefab.Catalog.Data; using Prefab.Tests.Infrastructure; using Shouldly; namespace Prefab.Tests.EndToEnd; [Collection("Prefab.Ephemeral")] public sealed class ProductListingPlaywrightShould(PrefabCompositeFixture_Ephemeral fixture) { [Fact] public async Task NavigateViaMegaMenuToLeafCategoryListing() { using var scope = fixture.CreateWebScope(); var seeder = scope.ServiceProvider.GetRequiredService(); await seeder.Execute(scope.ServiceProvider, TestContext.Current.CancellationToken); var catalogDb = scope.ServiceProvider.GetRequiredService(); var expectedSlugs = new HashSet(StringComparer.OrdinalIgnoreCase) { "ceiling-supports", "boxes-and-covers", "prefab-assemblies", "rod-strut-hardware" }; var catalogReady = false; var maxAttempts = 120; // ~60 seconds total with 500ms delay for (var attempt = 1; attempt <= maxAttempts && !catalogReady; attempt++) { var slugs = await catalogDb.Categories .Where(category => category.Slug != null && expectedSlugs.Contains(category.Slug)) .Select(category => category.Slug!) .ToListAsync(TestContext.Current.CancellationToken); if (slugs.Count == expectedSlugs.Count) { catalogReady = true; break; } if (attempt % 20 == 0) { Console.WriteLine($"[Playwright] catalog readiness attempt {attempt}: found [{string.Join(", ", slugs)}]"); } await Task.Delay(500, TestContext.Current.CancellationToken); } catalogReady.ShouldBeTrue("Catalog seed should populate root categories before UI navigation."); using var playwright = await Playwright.CreateAsync(); await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); var context = await browser.NewContextAsync(new BrowserNewContextOptions { BaseURL = fixture.PrefabBaseAddress.ToString().TrimEnd('/') }); var page = await context.NewPageAsync(); await page.GotoAsync("/"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await page.WaitForFunctionAsync("window.Blazor && window.Blazor._internal && window.Blazor._internal.navigationManager"); var menuItem = page.Locator("li.main-nav__item--with--menu").First; await menuItem.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 60_000 }); var interceptDebug = await menuItem.EvaluateAsync( @"element => { if (!element) { return null; } const rect = element.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const stack = document.elementsFromPoint(x, y); return stack.map(node => `${node.tagName.toLowerCase()}#${node.id}.${node.className}`).join(' > '); }"); Console.WriteLine($"[Playwright] elementsFromPoint at nav center: {interceptDebug}"); await menuItem.HoverAsync(); var menuPanel = page.Locator("#productsMenuPanel"); await menuPanel.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Attached, Timeout = 60_000 }); var boundingBox = await menuItem.BoundingBoxAsync(); if (boundingBox is null) { throw new InvalidOperationException("Navigation menu item bounding box could not be determined."); } var menuReady = false; for (var attempt = 1; attempt <= 10 && !menuReady; attempt++) { var responseTask = page.WaitForResponseAsync( resp => resp.Url.Contains("/bff/nav-menu/"), new PageWaitForResponseOptions { Timeout = 5000 }); await page.Mouse.MoveAsync( boundingBox.X + boundingBox.Width / 2, boundingBox.Y + boundingBox.Height / 2); await menuItem.DispatchEventAsync("mouseenter"); await page.WaitForTimeoutAsync(350); IResponse? navResponse = null; try { navResponse = await responseTask; } catch (TimeoutException) { // No navigation menu response observed within the window; proceed to inspect DOM. } menuReady = await page.EvaluateAsync( "selector => document.querySelectorAll(selector).length > 0", "#productsMenuPanel .megamenu__links--root li"); if (!menuReady) { var panelText = await menuPanel.InnerTextAsync(); Console.WriteLine($"[Playwright] nav menu attempt {attempt} panel text: {panelText}"); if (navResponse is not null) { var payload = await navResponse.TextAsync(); Console.WriteLine($"[Playwright] nav menu attempt {attempt} response: {payload}"); } else { Console.WriteLine($"[Playwright] nav menu attempt {attempt} response: timed out waiting for /bff/nav-menu."); } await menuItem.DispatchEventAsync("mouseleave"); await page.WaitForTimeoutAsync(900); } } menuReady.ShouldBeTrue("Mega menu should load category links within retry window."); var ceilingLink = menuPanel .GetByRole(AriaRole.Link, new() { Name = "Ceiling Supports" }) .First; await Microsoft.Playwright.Assertions.Expect(ceilingLink) .ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 60000 }); await Microsoft.Playwright.Assertions.Expect(menuPanel) .ToHaveAttributeAsync("aria-hidden", "false"); await ceilingLink.ClickAsync(); await page.WaitForURLAsync("**/catalog/products?category-slug=ceiling-supports"); await page.WaitForSelectorAsync(".products-view .product-card"); var productNamesLocator = page.Locator(".product-card__name"); await Microsoft.Playwright.Assertions.Expect(productNamesLocator).ToHaveCountAsync(2); var productNames = (await productNamesLocator.AllInnerTextsAsync()) .Select(name => name.Trim()) .ToList(); productNames.ShouldContain("Ceiling T-Bar Box Assembly"); productNames.ShouldContain("Fan Hanger Assembly"); await context.CloseAsync(); } }