|
| 1 | +using System.Net.Http.Json; |
| 2 | +using System.Text; |
| 3 | +using Microsoft.Playwright; |
| 4 | +using Shouldly; |
| 5 | +using Xunit; |
| 6 | + |
| 7 | +namespace NeoReports.WebUi.E2ETests; |
| 8 | + |
| 9 | +/// <summary> |
| 10 | +/// The headline flow: a user opens the UI, runs a report, watches the job finish and gets a file. |
| 11 | +/// Everything here happens through the browser against the live host — the click travels over the |
| 12 | +/// SignalR circuit, the engine runs for real, and the artifact is served by the real endpoint. |
| 13 | +/// </summary> |
| 14 | +[Collection(nameof(WebUiCollection))] |
| 15 | +public class GenerateReportTests |
| 16 | +{ |
| 17 | + private readonly WebUiFixture _fixture; |
| 18 | + |
| 19 | + public GenerateReportTests(WebUiFixture fixture) => _fixture = fixture; |
| 20 | + |
| 21 | + /// <summary> |
| 22 | + /// Registers a dynamic report over the API. Creating it through the Builder wizard is covered |
| 23 | + /// separately; seeding it here keeps this test about running and delivering, and keeps it |
| 24 | + /// independent of the wizard's own state. |
| 25 | + /// </summary> |
| 26 | + private async Task<string> SeedReportAsync(string name, params string[] formats) |
| 27 | + { |
| 28 | + string outputs = string.Join(",", formats.Select(f => $$"""{"format":"{{f}}"}""")); |
| 29 | + string config = $$""" |
| 30 | + { |
| 31 | + "name": "{{name}}", |
| 32 | + "source": { "type": "inmemory", "properties": { "rows": 25 } }, |
| 33 | + "columns": [ { "name": "Id", "type": "Integer" }, { "name": "Customer", "type": "String" } ], |
| 34 | + "outputs": [ {{outputs}} ], |
| 35 | + "destinations": [ { "type": "local" } ], |
| 36 | + "pageSize": 10 |
| 37 | + } |
| 38 | + """; |
| 39 | + |
| 40 | + using var client = new HttpClient(); |
| 41 | + using var body = new StringContent(config, Encoding.UTF8, "application/json"); |
| 42 | + using HttpResponseMessage response = await client.PostAsync(_fixture.App.BaseUrl + "/api/reports", body); |
| 43 | + response.IsSuccessStatusCode.ShouldBeTrue($"seeding '{name}' failed: {await response.Content.ReadAsStringAsync()}"); |
| 44 | + return name; |
| 45 | + } |
| 46 | + |
| 47 | + /// <summary> |
| 48 | + /// Filters the Reports list down to one report and clicks its Run action. Scoping matters: the |
| 49 | + /// host is shared by every test in the collection, so reports accumulate and a bare "first Run |
| 50 | + /// button" would start whichever report happens to sort first — a different one each run. |
| 51 | + /// </summary> |
| 52 | + private static async Task RunFromReportsPageAsync(UiPage ui, string name) |
| 53 | + { |
| 54 | + // Scope to the card that carries this report's name. Filtering the list first and taking the |
| 55 | + // first Run button would be a race: the filter re-renders over the circuit, so the click can |
| 56 | + // land while the unfiltered list is still shown — and the list is name-ordered, so it would |
| 57 | + // deterministically start whichever report sorts first, not this one. |
| 58 | + ILocator card = ui.Page.Locator(".report-card").Filter(new LocatorFilterOptions { HasText = name }); |
| 59 | + await card.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout }); |
| 60 | + await card.GetByRole(AriaRole.Button, new() { Name = "Run" }).ClickAsync(); |
| 61 | + } |
| 62 | + |
| 63 | + [SkippableFact] |
| 64 | + public async Task Running_a_report_from_the_reports_page_produces_a_completed_job_and_a_file() |
| 65 | + { |
| 66 | + Skip.If(_fixture.Unavailable is not null, _fixture.Unavailable); |
| 67 | + string name = await SeedReportAsync("e2e-run-" + Guid.NewGuid().ToString("N")[..6], "csv"); |
| 68 | + |
| 69 | + await using var ui = await UiPage.OpenAsync(_fixture, "/reports"); |
| 70 | + |
| 71 | + // The report the API registered must be visible to the UI without a manual refresh. |
| 72 | + await ui.WaitForTextAsync(name); |
| 73 | + |
| 74 | + // Run it the way a user does — search for it, then the card's primary action. |
| 75 | + await RunFromReportsPageAsync(ui, name); |
| 76 | + |
| 77 | + // The run is asynchronous. Let it finish before opening the Jobs screen: that page loads its |
| 78 | + // list once on init, so navigating straight after the click would render an empty list and the |
| 79 | + // assertion below would be timing, not behaviour. |
| 80 | + using var client = new HttpClient(); |
| 81 | + JobDto job = await WaitForCompletedJobAsync(client, name); |
| 82 | + |
| 83 | + await using var jobs = await UiPage.OpenAsync(_fixture, "/jobs"); |
| 84 | + await jobs.WaitForTextAsync(name); |
| 85 | + |
| 86 | + // Scope the status to THIS job's row. A page-wide text match also hits the status filter's |
| 87 | + // hidden <option value="Completed">, which is present before any job has ever run — so the |
| 88 | + // assertion would pass on an empty Jobs page. |
| 89 | + ILocator row = jobs.Page.Locator("tr", new PageLocatorOptions { HasText = name }); |
| 90 | + (await row.First.InnerTextAsync()).ShouldContain("Completed"); |
| 91 | + |
| 92 | + await jobs.AssertNoCircuitErrorAsync(); |
| 93 | + |
| 94 | + byte[] artifact = await client.GetByteArrayAsync( |
| 95 | + $"{_fixture.App.BaseUrl}/api/jobs/{job.Id}/download"); |
| 96 | + artifact.Length.ShouldBeGreaterThan(0); |
| 97 | + } |
| 98 | + |
| 99 | + [SkippableFact] |
| 100 | + public async Task A_report_detail_page_shows_the_declared_columns() |
| 101 | + { |
| 102 | + Skip.If(_fixture.Unavailable is not null, _fixture.Unavailable); |
| 103 | + string name = await SeedReportAsync("e2e-detail-" + Guid.NewGuid().ToString("N")[..6], "csv"); |
| 104 | + |
| 105 | + await using var ui = await UiPage.OpenAsync(_fixture, "/reports"); |
| 106 | + await ui.WaitForTextAsync(name); |
| 107 | + |
| 108 | + ILocator card = ui.Page.Locator(".report-card").Filter(new LocatorFilterOptions { HasText = name }); |
| 109 | + await card.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout }); |
| 110 | + await card.ClickAsync(); |
| 111 | + |
| 112 | + // Blazor routes client-side, so there is no navigation "Load" event to wait on — poll the |
| 113 | + // URL the router actually set instead. |
| 114 | + await ui.Page.WaitForFunctionAsync( |
| 115 | + "() => location.pathname.includes('/reports/')", |
| 116 | + null, |
| 117 | + new PageWaitForFunctionOptions { Timeout = UiPage.Timeout }); |
| 118 | + await ui.WaitForTextAsync("Customer"); |
| 119 | + await ui.AssertNoCircuitErrorAsync(); |
| 120 | + } |
| 121 | + |
| 122 | + [SkippableFact] |
| 123 | + public async Task A_multi_format_report_delivers_every_format_it_declares() |
| 124 | + { |
| 125 | + Skip.If(_fixture.Unavailable is not null, _fixture.Unavailable); |
| 126 | + string name = await SeedReportAsync("e2e-multi-" + Guid.NewGuid().ToString("N")[..6], "csv", "xlsx"); |
| 127 | + |
| 128 | + await using var ui = await UiPage.OpenAsync(_fixture, "/reports"); |
| 129 | + await RunFromReportsPageAsync(ui, name); |
| 130 | + |
| 131 | + using var client = new HttpClient(); |
| 132 | + JobDto job = await WaitForCompletedJobAsync(client, name); |
| 133 | + |
| 134 | + var artifacts = await client.GetFromJsonAsync<List<ArtifactDto>>( |
| 135 | + $"{_fixture.App.BaseUrl}/api/jobs/{job.Id}/artifacts"); |
| 136 | + artifacts!.Select(a => Path.GetExtension(a.FileName)).ShouldBe(new[] { ".csv", ".xlsx" }, ignoreOrder: true); |
| 137 | + } |
| 138 | + |
| 139 | + private async Task<JobDto> WaitForCompletedJobAsync(HttpClient client, string reportName) |
| 140 | + { |
| 141 | + for (var attempt = 0; attempt < 60; attempt++) |
| 142 | + { |
| 143 | + var jobs = await client.GetFromJsonAsync<List<JobDto>>(_fixture.App.BaseUrl + "/api/jobs?limit=50"); |
| 144 | + JobDto? job = jobs!.FirstOrDefault(j => j.ReportName == reportName); |
| 145 | + if (job is { Status: "Completed" }) |
| 146 | + return job; |
| 147 | + if (job is { Status: "Failed" }) |
| 148 | + throw new Xunit.Sdk.XunitException($"Report '{reportName}' failed: {job.Error}"); |
| 149 | + await Task.Delay(500); |
| 150 | + } |
| 151 | + |
| 152 | + throw new Xunit.Sdk.XunitException($"Report '{reportName}' did not complete within 30s."); |
| 153 | + } |
| 154 | + |
| 155 | + private sealed record JobDto(string Id, string ReportName, string Status, string? Error); |
| 156 | + |
| 157 | + private sealed record ArtifactDto(string FileName); |
| 158 | +} |
0 commit comments