diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de411c8..8d544fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,3 +71,16 @@ jobs: run: > dotnet test tests/Healthie.Tests.E2E --configuration Release --no-build --verbosity normal + + # A browser test that fails only on a runner is otherwise a stack trace and a guess. The suite + # records a Playwright trace per test and keeps the ones that failed; this is what makes them + # readable afterwards -- open a downloaded zip at https://trace.playwright.dev and you get the + # DOM, the network and a screenshot at the moment it gave up. + - name: Upload failure traces + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: playwright-traces + path: tests/Healthie.Tests.E2E/bin/Release/net8.0/playwright-traces + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 16d852a..8c3dd3c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -115,13 +115,40 @@ jobs: # The whole suite, unit and end-to-end, gates the release: nothing is packed, tagged, or # published unless the dashboard itself is proven to work in a browser first. + # + # Run as two steps rather than one solution-level `dotnet test`, which is not a style + # preference. That form hands both suites to one runner at once, and the browser suite is then + # driving a Blazor Server circuit over a WebSocket while eleven hundred unit tests across two + # frameworks saturate the machine. A starved circuit closes with 1006, and every assertion on + # that page fails from then on however long it waits -- which is what made releases fail + # browser tests that were green on every pull request, where ci.yml gives them a runner each. - name: Test run: > - dotnet test + dotnet test tests/Healthie.Tests.Unit --configuration ${{ env.CONFIGURATION }} --no-build --verbosity normal + - name: Test (browser) + run: > + dotnet test tests/Healthie.Tests.E2E + --configuration ${{ env.CONFIGURATION }} + --no-build + --verbosity normal + + # This is where a browser failure costs the most: the release stops, and all anybody has is a + # stack trace from a runner nobody can reach. The suite keeps a Playwright trace for each test + # that failed -- open the downloaded zip at https://trace.playwright.dev for the DOM, the + # network and a screenshot at the moment it gave up. + - name: Upload failure traces + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: playwright-traces + path: tests/Healthie.Tests.E2E/bin/${{ env.CONFIGURATION }}/net8.0/playwright-traces + if-no-files-found: ignore + retention-days: 7 + - name: Pack run: > dotnet pack diff --git a/tests/Healthie.Tests.E2E/BrowserFixture.cs b/tests/Healthie.Tests.E2E/BrowserFixture.cs index 617f676..84ac672 100644 --- a/tests/Healthie.Tests.E2E/BrowserFixture.cs +++ b/tests/Healthie.Tests.E2E/BrowserFixture.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Microsoft.Playwright; +using Xunit.Sdk; namespace Healthie.Tests.E2E; @@ -10,6 +11,8 @@ namespace Healthie.Tests.E2E; public sealed class BrowserFixture : IAsyncLifetime { private readonly ConcurrentDictionary> _errors = new(); + private readonly ConcurrentDictionary _contexts = new(); + private readonly ConcurrentDictionary _owners = new(); private IPlaywright? _playwright; private IBrowser? _browser; @@ -41,9 +44,82 @@ public async ValueTask InitializeAsync() public async Task NewPageAsync() { var browser = _browser ?? throw new InvalidOperationException("The browser is not running."); - return await browser.NewPageAsync(new() { ViewportSize = new() { Width = 1440, Height = 900 } }); + + var context = await browser.NewContextAsync(new() { ViewportSize = new() { Width = 1440, Height = 900 } }); + + // Recorded for every test and kept only for the ones that fail. A browser test that fails on + // a runner and passes everywhere else is otherwise a stack trace and a guess: this leaves the + // DOM, the network and a screenshot at the moment it gave up, which is the difference between + // diagnosing it once and patching it three times. + await context.Tracing.StartAsync(new() { Screenshots = true, Snapshots = true, Sources = false }); + + var page = await context.NewPageAsync(); + _contexts[page] = context; + + // Keyed by the running test so a class that opens several pages closes only its own. + if (TestContext.Current.Test is { } test) + { + _owners[page] = test; + } + + return page; } + /// + /// Closes every page the current test opened, keeping a trace only if it failed. + /// + /// + /// Kept only on failure because a trace is a few megabytes and fifty-three passing ones are worth + /// nothing. is what CI uploads. + /// + public async Task FinishCurrentTestAsync() + { + var test = TestContext.Current.Test; + var failed = TestContext.Current.TestState?.Result == TestResult.Failed; + var name = test?.TestDisplayName ?? "unknown-test"; + + foreach (var (page, context) in _contexts.ToArray()) + { + if (!ReferenceEquals(_owners.GetValueOrDefault(page), test)) + { + continue; + } + + _contexts.TryRemove(page, out _); + _owners.TryRemove(page, out _); + _errors.TryRemove(page, out var errors); + + if (failed) + { + // Straight into the CI log, next to the failure, because the usual cause of a + // browser assertion running out of patience is not a slow page: it is a Blazor + // circuit that closed, after which nothing on that page can ever change. That reads + // as "the element never got the attribute" and costs an afternoon. + if (errors is { Count: > 0 }) + { + TestContext.Current.TestOutputHelper?.WriteLine( + "The browser reported errors before this failed:" + Environment.NewLine + + string.Join(Environment.NewLine, errors)); + } + + Directory.CreateDirectory(TraceDirectory); + + var safe = string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-')); + await context.Tracing.StopAsync(new() { Path = Path.Combine(TraceDirectory, $"{safe}.zip") }); + } + else + { + await context.Tracing.StopAsync(); + } + + await context.CloseAsync(); + } + } + + /// Where failure traces are written, and what CI collects. + public static string TraceDirectory { get; } = + Path.Combine(AppContext.BaseDirectory, "playwright-traces"); + /// Records the console and page errors seen on a page, for . public void TrackErrors(IPage page, List errors) => _errors[page] = errors; diff --git a/tests/Healthie.Tests.E2E/DashboardGroupingTests.cs b/tests/Healthie.Tests.E2E/DashboardGroupingTests.cs index ec9a625..12ffb8c 100644 --- a/tests/Healthie.Tests.E2E/DashboardGroupingTests.cs +++ b/tests/Healthie.Tests.E2E/DashboardGroupingTests.cs @@ -14,7 +14,7 @@ namespace Healthie.Tests.E2E; /// is the test that would have caught it. /// [Collection(nameof(BrowserCollection))] -public class DashboardGroupingTests(BrowserFixture browser) +public class DashboardGroupingTests(BrowserFixture browser) : IAsyncDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; @@ -60,10 +60,13 @@ private async Task OpenDashboardAsync(SampleApp app) browser.TrackErrors(page, errors); await page.GotoAsync(app.DashboardUrl, new() { WaitUntil = WaitUntilState.NetworkIdle }); - await page.Locator(".hpm-row").First.WaitForAsync(new() { Timeout = 30_000 }); + await DashboardTests.WaitForTheBoardAsync(page); return page; } + /// Closes the pages this test opened, keeping a trace behind if it failed. + public async ValueTask DisposeAsync() => await browser.FinishCurrentTestAsync(); + /// /// The defaults declared in code have to survive the whole way to the screen: seeded into /// state on the first run, read back, and rendered. @@ -94,18 +97,16 @@ public async Task Dashboard_WhenGrouped_ShowsEveryCheckerExactlyOnce(ProviderSet var page = await OpenDashboardAsync(app); // The board opens grouped, so this is the state under test without touching anything. - await page.Locator(".hpm-group").First.WaitForAsync(); - - var groupedCount = await page.Locator(".hpm-row").CountAsync(); - Assert.Equal(1, await RowFor(page, TargetChecker).CountAsync()); + await Assertions.Expect(page.Locator(".hpm-group").First).ToBeVisibleAsync(); + await Assertions.Expect(RowFor(page, TargetChecker)).ToHaveCountAsync(1); // The same checkers laid out flat: the button goes the other way now, which makes this the // comparison the test always wanted rather than a count taken before grouping was applied. await page.GetByRole(AriaRole.Button, new() { Name = "GROUP", Exact = true }).ClickAsync(); await Assertions.Expect(page.Locator(".hpm-group")).ToHaveCountAsync(0); - Assert.Equal(groupedCount, await page.Locator(".hpm-row").CountAsync()); - Assert.Equal(1, await RowFor(page, TargetChecker).CountAsync()); + await Assertions.Expect(page.Locator(".hpm-row")).ToHaveCountAsync(DashboardTests.CheckerCount); + await Assertions.Expect(RowFor(page, TargetChecker)).ToHaveCountAsync(1); browser.AssertNoErrors(page); } @@ -177,10 +178,7 @@ public async Task Dashboard_CronEditor_AppliesAValidExpressionAndRefusesABadOne( await using var app = await SampleApp.StartAsync(setup, Ct); var page = await OpenDashboardAsync(app); - // Waiting on the panel's title, not on the box: the box is already visible for whichever - // checker was selected on load, so it tells you nothing about whether the click has landed. - await RowFor(page, CronChecker).ClickAsync(); - await Assertions.Expect(page.Locator(".hpm-sel-name")).ToHaveTextAsync(CronChecker); + await SelectCheckerAsync(page, CronChecker); var box = page.Locator("#hpm-cron"); var before = await box.InputValueAsync(); @@ -212,12 +210,10 @@ public async Task Dashboard_ForACronChecker_DisablesTheIntervalPicker(ProviderSe await using var app = await SampleApp.StartAsync(setup, Ct); var page = await OpenDashboardAsync(app); - await RowFor(page, CronChecker).ClickAsync(); - await Assertions.Expect(page.Locator(".hpm-sel-name")).ToHaveTextAsync(CronChecker); + await SelectCheckerAsync(page, CronChecker); await Assertions.Expect(page.Locator("#hpm-interval")).ToBeDisabledAsync(); - await RowFor(page, TargetChecker).ClickAsync(); - await Assertions.Expect(page.Locator(".hpm-sel-name")).ToHaveTextAsync(TargetChecker); + await SelectCheckerAsync(page, TargetChecker); await Assertions.Expect(page.Locator("#hpm-interval")).ToBeEnabledAsync(); browser.AssertNoErrors(page); @@ -254,7 +250,6 @@ public async Task Dashboard_CollapsingAGroup_HidesItsRowsAndLeavesTheOthers(Prov await group.WaitForAsync(); var inside = await group.Locator(".hpm-row").CountAsync(); - var total = await page.Locator(".hpm-row").CountAsync(); Assert.True(inside > 0); await group.Locator(".hpm-group-head").ClickAsync(); @@ -262,7 +257,7 @@ public async Task Dashboard_CollapsingAGroup_HidesItsRowsAndLeavesTheOthers(Prov // Awaited rather than counted on the spot: the click is a round-trip to the server and back // before anything re-renders. await Assertions.Expect(group.Locator(".hpm-row")).ToHaveCountAsync(0); - await Assertions.Expect(page.Locator(".hpm-row")).ToHaveCountAsync(total - inside); + await Assertions.Expect(page.Locator(".hpm-row")).ToHaveCountAsync(DashboardTests.CheckerCount - inside); browser.AssertNoErrors(page); } @@ -277,16 +272,19 @@ public async Task Dashboard_FilteringByATag_ShowsOnlyTheCheckersCarryingIt(Provi await using var app = await SampleApp.StartAsync(setup, Ct); var page = await OpenDashboardAsync(app); - var total = await page.Locator(".hpm-row").CountAsync(); - await page.Locator(".hpm-tag-filter").SelectOptionAsync("tier-1"); - await Assertions.Expect(page.Locator(".hpm-row")).Not.ToHaveCountAsync(total); - - var shown = await page.Locator(".hpm-row").CountAsync(); - Assert.True(shown > 0, "filtering by a tag the sample uses should leave something on screen"); - - // Every row left has to carry the tag, which is the only claim the filter makes. - Assert.Equal(shown, await page.Locator(".hpm-row").Filter(new() { Has = page.Locator(".hpm-chip", new() { HasTextString = "tier-1" }) }).CountAsync()); + await Assertions.Expect(page.Locator(".hpm-row")).Not.ToHaveCountAsync(DashboardTests.CheckerCount); + + Assert.True( + await page.Locator(".hpm-row").CountAsync() > 0, + "filtering by a tag the sample uses should leave something on screen"); + + // Every row left has to carry the tag, which is the only claim the filter makes. Stated as + // "nothing without it", so it is one assertion over the live list rather than two counts + // read a moment apart and compared. + await Assertions.Expect(page.Locator(".hpm-row") + .Filter(new() { HasNot = page.Locator(".hpm-chip", new() { HasTextString = "tier-1" }) })) + .ToHaveCountAsync(0); browser.AssertNoErrors(page); } @@ -306,7 +304,8 @@ public async Task Dashboard_PinningAChecker_SortsItFirstAndSurvivesAReload(Provi Assert.Equal(TargetChecker, await FirstRowNameAsync(page)); await page.ReloadAsync(new() { WaitUntil = WaitUntilState.NetworkIdle }); - await page.Locator(".hpm-row").First.WaitForAsync(); + await DashboardTests.WaitForTheBoardAsync(page); + await Assertions.Expect(page.Locator(".hpm-row").First.Locator(".hpm-pin-mark")).ToBeVisibleAsync(); Assert.Equal(TargetChecker, await FirstRowNameAsync(page)); browser.AssertNoErrors(page); @@ -320,7 +319,7 @@ public async Task Dashboard_AddingATag_ShowsItOnTheRowAndSurvivesAReload(Provide await using var app = await SampleApp.StartAsync(setup, Ct); var page = await OpenDashboardAsync(app); - await RowFor(page, TargetChecker).ClickAsync(); + await SelectCheckerAsync(page, TargetChecker); await page.GetByLabel("Add a tag").FillAsync("e2e-added"); await page.GetByRole(AriaRole.Button, new() { Name = "ADD" }).ClickAsync(); @@ -329,7 +328,7 @@ public async Task Dashboard_AddingATag_ShowsItOnTheRowAndSurvivesAReload(Provide await Assertions.Expect(tagged).ToBeVisibleAsync(); await page.ReloadAsync(new() { WaitUntil = WaitUntilState.NetworkIdle }); - await page.Locator(".hpm-row").First.WaitForAsync(); + await DashboardTests.WaitForTheBoardAsync(page); await Assertions.Expect(page.Locator(".hpm-row").Filter(new() { HasTextString = TargetChecker }) .Locator(".hpm-chip", new() { HasTextString = "e2e-added" })).ToBeVisibleAsync(); @@ -343,12 +342,10 @@ public async Task Dashboard_SwitchingToCards_KeepsEveryCheckerOnScreen(ProviderS await using var app = await SampleApp.StartAsync(setup, Ct); var page = await OpenDashboardAsync(app); - var total = await page.Locator(".hpm-row").CountAsync(); - await page.GetByRole(AriaRole.Button, new() { Name = "Card view" }).ClickAsync(); await Assertions.Expect(page.Locator(".hpm-list--cards")).ToBeVisibleAsync(); - Assert.Equal(total, await page.Locator(".hpm-row").CountAsync()); + await Assertions.Expect(page.Locator(".hpm-row")).ToHaveCountAsync(DashboardTests.CheckerCount); browser.AssertNoErrors(page); } @@ -360,7 +357,7 @@ public async Task Dashboard_ExpandingTheEventLog_OpensItAndClosesAgain(ProviderS var page = await OpenDashboardAsync(app); // The log starts empty and fills as checks report in, so give it something to show. - await RowFor(page, TargetChecker).ClickAsync(); + await SelectCheckerAsync(page, TargetChecker); await page.GetByRole(AriaRole.Button, new() { Name = "RUN NOW" }).ClickAsync(); await page.Locator(".hpm-log-body .hpm-event").First.WaitForAsync(); diff --git a/tests/Healthie.Tests.E2E/DashboardReadOnlyTests.cs b/tests/Healthie.Tests.E2E/DashboardReadOnlyTests.cs index 6264885..4bba0aa 100644 --- a/tests/Healthie.Tests.E2E/DashboardReadOnlyTests.cs +++ b/tests/Healthie.Tests.E2E/DashboardReadOnlyTests.cs @@ -17,7 +17,7 @@ namespace Healthie.Tests.E2E; /// /// [Collection(nameof(BrowserCollection))] -public class DashboardReadOnlyTests(BrowserFixture browser) +public class DashboardReadOnlyTests(BrowserFixture browser) : IAsyncDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; @@ -46,10 +46,13 @@ private async Task OpenDashboardAsync(SampleApp app) browser.TrackErrors(page, errors); await page.GotoAsync(app.DashboardUrl, new() { WaitUntil = WaitUntilState.NetworkIdle }); - await page.Locator(".hpm-row").First.WaitForAsync(new() { Timeout = 30_000 }); + await DashboardTests.WaitForTheBoardAsync(page); return page; } + /// Closes the pages this test opened, keeping a trace behind if it failed. + public async ValueTask DisposeAsync() => await browser.FinishCurrentTestAsync(); + [Fact] public async Task Dashboard_WithMutationsOff_HidesEveryControlThatChangesAChecker() { @@ -99,15 +102,15 @@ public async Task Dashboard_WithMutationsOff_StillReportsEverything() // A check has to land before there is any history to draw. await page.Locator(".hpm-blips").First.WaitForAsync(new() { Timeout = 30_000 }); - Assert.True(await page.Locator(".hpm-row").CountAsync() > 0, "no checkers are listed"); - Assert.True(await page.Locator(".hpm-stats").CountAsync() > 0, "the selected checker's stats are missing"); - Assert.Equal(1, await page.Locator(".hpm-log-body").CountAsync()); + await Assertions.Expect(page.Locator(".hpm-row").First).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator(".hpm-stats").First).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator(".hpm-log-body")).ToHaveCountAsync(1); // The controls that only change what the viewer sees are not mutations, so they stay. - Assert.Equal(1, await page.Locator("[aria-label='Search checkers']").CountAsync()); - Assert.Equal(1, await page.Locator("button:text-is('GROUP')").CountAsync()); - Assert.Equal(1, await page.Locator("[aria-label='Legend and about']").CountAsync()); - Assert.Equal(1, await page.Locator("[aria-label='Expand the event log']").CountAsync()); + await Assertions.Expect(page.Locator("[aria-label='Search checkers']")).ToHaveCountAsync(1); + await Assertions.Expect(page.Locator("button:text-is('GROUP')")).ToHaveCountAsync(1); + await Assertions.Expect(page.Locator("[aria-label='Legend and about']")).ToHaveCountAsync(1); + await Assertions.Expect(page.Locator("[aria-label='Expand the event log']")).ToHaveCountAsync(1); browser.AssertNoErrors(page); } diff --git a/tests/Healthie.Tests.E2E/DashboardTests.cs b/tests/Healthie.Tests.E2E/DashboardTests.cs index d9ba99a..92d6595 100644 --- a/tests/Healthie.Tests.E2E/DashboardTests.cs +++ b/tests/Healthie.Tests.E2E/DashboardTests.cs @@ -8,7 +8,7 @@ namespace Healthie.Tests.E2E; /// the Blazor circuit, the CSS, or the render loop fails the test -- none of which a unit test sees. /// [Collection(nameof(BrowserCollection))] -public class DashboardTests(BrowserFixture browser) +public class DashboardTests(BrowserFixture browser) : IAsyncDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; @@ -43,6 +43,13 @@ public static TheoryData Setups /// private const string TargetChecker = "Redis Cache"; + /// How many pulse checkers the sample declares. + /// + /// Asserted rather than ignored: the board losing a row is exactly the kind of regression a + /// "some rows are present" check waves through. + /// + internal const int CheckerCount = 14; + private static ILocator RowFor(IPage page, string displayName) => page.Locator(".hpm-row").Filter(new() { HasTextString = displayName }); @@ -55,11 +62,26 @@ private async Task OpenDashboardAsync(SampleApp app) browser.TrackErrors(page, errors); await page.GotoAsync(app.DashboardUrl, new() { WaitUntil = WaitUntilState.NetworkIdle }); - // Rows only appear once the checkers have been read, so this is the real readiness signal. - await page.Locator(".hpm-row").First.WaitForAsync(new() { Timeout = 30_000 }); + await WaitForTheBoardAsync(page); return page; } + /// + /// Waits until the board is showing every checker, which is what "loaded" means here. + /// + /// + /// Rows arrive as the checkers are read, so waiting for the first one only proves the list has + /// started. A count taken at that moment can catch it half-built -- and a count is the thing + /// most of these tests then compare against, which is how a suite that passes on a developer's + /// machine fails on a loaded runner. Waiting for the full set here is what makes reading a + /// count downstream safe at all. + /// + internal static Task WaitForTheBoardAsync(IPage page) => + Assertions.Expect(page.Locator(".hpm-row")).ToHaveCountAsync(CheckerCount, new() { Timeout = 30_000 }); + + /// Closes the pages this test opened, keeping a trace behind if it failed. + public async ValueTask DisposeAsync() => await browser.FinishCurrentTestAsync(); + [Theory] [MemberData(nameof(Setups))] public async Task Dashboard_ListsEveryChecker_AndReportsNoBrowserErrors(ProviderSetup setup) @@ -67,8 +89,8 @@ public async Task Dashboard_ListsEveryChecker_AndReportsNoBrowserErrors(Provider await using var app = await SampleApp.StartAsync(setup, Ct); var page = await OpenDashboardAsync(app); - Assert.Equal(14, await page.Locator(".hpm-row").CountAsync()); - Assert.Equal("HEALTHIE·PULSE", (await page.Locator(".hpm-wordmark").TextContentAsync())?.Trim()); + await Assertions.Expect(page.Locator(".hpm-row")).ToHaveCountAsync(CheckerCount); + await Assertions.Expect(page.Locator(".hpm-wordmark")).ToHaveTextAsync("HEALTHIE·PULSE"); browser.AssertNoErrors(page); } @@ -98,9 +120,7 @@ public async Task SelectingAChecker_ShowsItsDetail(ProviderSetup setup) await RowFor(page, TargetChecker).ClickAsync(); - await page.Locator(".hpm-sel-name", new() { HasTextString = TargetChecker }) - .WaitForAsync(new() { Timeout = 10_000 }); - Assert.Equal(TargetChecker, (await page.Locator(".hpm-sel-name").TextContentAsync())?.Trim()); + await Assertions.Expect(page.Locator(".hpm-sel-name")).ToHaveTextAsync(TargetChecker); // A count would be a timing assertion, not a detail one: the sample installs the uptime // package, whose 24H cell appears once a segment has been recorded and whose WORST cell @@ -125,12 +145,11 @@ public async Task Search_FiltersTheList_AndRestoresIt(ProviderSetup setup) var page = await OpenDashboardAsync(app); await page.FillAsync(".hpm-search input", "no-such-checker"); - await page.Locator(".hpm-empty").WaitForAsync(new() { Timeout = 10_000 }); - Assert.Equal(0, await page.Locator(".hpm-row").CountAsync()); + await Assertions.Expect(page.Locator(".hpm-row")).ToHaveCountAsync(0); + await Assertions.Expect(page.Locator(".hpm-empty")).ToBeVisibleAsync(); await page.FillAsync(".hpm-search input", ""); - await page.Locator(".hpm-row").First.WaitForAsync(new() { Timeout = 10_000 }); - Assert.Equal(14, await page.Locator(".hpm-row").CountAsync()); + await Assertions.Expect(page.Locator(".hpm-row")).ToHaveCountAsync(CheckerCount); browser.AssertNoErrors(page); } @@ -145,8 +164,7 @@ public async Task RunAll_ProducesEventLogEntries(ProviderSetup setup) await page.Locator(".hpm-btn", new() { HasTextString = "RUN ALL" }).ClickAsync(); - await page.Locator(".hpm-event").First.WaitForAsync(new() { Timeout = 20_000 }); - Assert.True(await page.Locator(".hpm-event").CountAsync() > 0); + await Assertions.Expect(page.Locator(".hpm-event").First).ToBeVisibleAsync(); browser.AssertNoErrors(page); } @@ -160,19 +178,23 @@ public async Task ChangingTheInterval_PersistsThroughTheStateProvider(ProviderSe var page = await OpenDashboardAsync(app); await RowFor(page, TargetChecker).ClickAsync(); - await page.Locator("#hpm-interval").WaitForAsync(new() { Timeout = 10_000 }); + await Assertions.Expect(page.Locator(".hpm-sel-name")).ToHaveTextAsync(TargetChecker); await page.SelectOptionAsync("#hpm-interval", "Every5Minutes"); - await page.WaitForTimeoutAsync(1500); + + // The event log entry is written once the write to the provider has returned, so waiting for + // it waits for the round trip. A fixed sleep only guessed at how long that takes, and guessed + // from a developer's machine. + await Assertions.Expect(page.Locator(".hpm-event").Filter(new() { HasTextString = "interval set to" }).First) + .ToBeVisibleAsync(); // Reloading drops every scrap of component state, so what comes back can only have come // from the state provider. await page.ReloadAsync(new() { WaitUntil = WaitUntilState.NetworkIdle }); - await RowFor(page, TargetChecker).WaitForAsync(new() { Timeout = 30_000 }); + await WaitForTheBoardAsync(page); await RowFor(page, TargetChecker).ClickAsync(); - await page.Locator(".hpm-sel-name", new() { HasTextString = TargetChecker }) - .WaitForAsync(new() { Timeout = 10_000 }); + await Assertions.Expect(page.Locator(".hpm-sel-name")).ToHaveTextAsync(TargetChecker); - Assert.Equal("Every5Minutes", await page.Locator("#hpm-interval").InputValueAsync()); + await Assertions.Expect(page.Locator("#hpm-interval")).ToHaveValueAsync("Every5Minutes"); browser.AssertNoErrors(page); } @@ -183,15 +205,15 @@ public async Task ThemeToggle_SwitchesBothWays(ProviderSetup setup) await using var app = await SampleApp.StartAsync(setup, Ct); var page = await OpenDashboardAsync(app); - Assert.Equal("dark", await page.Locator(".healthie-dashboard").GetAttributeAsync("data-hpm")); + var board = page.Locator(".healthie-dashboard"); + + await Assertions.Expect(board).ToHaveAttributeAsync("data-hpm", "dark"); await page.Locator(".hpm-btn--theme").ClickAsync(); - await page.WaitForTimeoutAsync(600); - Assert.Equal("light", await page.Locator(".healthie-dashboard").GetAttributeAsync("data-hpm")); + await Assertions.Expect(board).ToHaveAttributeAsync("data-hpm", "light"); await page.Locator(".hpm-btn--theme").ClickAsync(); - await page.WaitForTimeoutAsync(600); - Assert.Equal("dark", await page.Locator(".healthie-dashboard").GetAttributeAsync("data-hpm")); + await Assertions.Expect(board).ToHaveAttributeAsync("data-hpm", "dark"); browser.AssertNoErrors(page); } diff --git a/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs b/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs index 20ede61..6b4edde 100644 --- a/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs +++ b/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs @@ -166,13 +166,20 @@ public async Task OnceDisposed_ItStopsCounting() using var checker = Checker(provider, PulseCheckerHealth.Healthy, "disposal-target"); await checker.TriggerAsync(Ct); - var before = metrics.Snapshot(Ct).Checks; - Assert.True(before > 0); + Assert.True(metrics.Snapshot(Ct).Checks > 0); metrics.Dispose(); + // Read after disposal, not before it. A listener subscribes to the meter by name, so it + // counts every checker in the process -- including the ones other tests are running at the + // same time. A count taken before the Dispose call can be overtaken by one of those in the + // gap, which is a difference the assertion below then reads as a listener still attached. + // Taken here the number is frozen by the very thing under test, and a Dispose that failed + // to detach still moves it. + var frozen = metrics.Snapshot(Ct).Checks; + await checker.TriggerAsync(Ct); - Assert.Equal(before, metrics.Snapshot(Ct).Checks); + Assert.Equal(frozen, metrics.Snapshot(Ct).Checks); } }