Skip to content

Commit f176a92

Browse files
authored
test(ui): end-to-end tests that boot the app and drive the UI in a browser (#259)
* test(ui): end-to-end tests that boot the app and drive the UI in a browser Adds NeoReports.WebUi.E2ETests: it boots the product the way samples/09-web-ui-live does — Blazor UI and engine in one host — on real Kestrel, and drives it with Chromium through Playwright. A real browser is the point. The UI is Blazor Server, so every click, wizard step and job refresh travels over a SignalR circuit; TestServer has no port and cannot carry one, which is why this can't be a WebApplicationFactory suite. It complements rather than replaces the existing bUnit suite (components, no host) and the AspNetCore integration suite (endpoints, no clicks). Covered: the host boots and serves the shell, the UI package's own _content/ assets and the engine API; every screen loads in the browser with its heading and left-nav routing works; a report is run from the Reports page and reaches Completed with downloadable bytes; a csv+xlsx report delivers both artifacts; report detail renders its declared columns; and the Builder wizard advances with its state surviving Back navigation (the D69 regression, over a real circuit). Every test also asserts Blazor's error overlay is not showing — a faulted circuit still returns 200 for the shell, so without that a broken screen looks green. Two host-side compensations were needed and are load-bearing: ApplicationName is set to this assembly (under `dotnet test` the entry assembly is testhost, and MVC resolves a Razor Class Library's pages through the named application's dependency context, so the UI's _Host page is otherwise invisible), and UseStaticWebAssets() is called explicitly (the default builder wires RCL assets only in Development). CI and the release workflow install Chromium and set NEOREPORTS_REQUIRE_BROWSER=1 so an unusable browser fails the job instead of silently skipping the suite — without it the release gate would have run 3 of 17 tests while looking green. Verified: 17/17 pass, three consecutive runs, ~49s each. * test(ui): report an ignored browser-teardown failure instead of swallowing it silently CodeQL flagged the empty catch in the fixture's teardown. Ignoring the failure is right — a browser that already died must not stop Kestrel and the temp directory from being cleaned up, and must not replace the failure the test was reporting — but discarding it without a trace would hide a browser that crashes on every run. Write it out instead.
1 parent 2fec2d2 commit f176a92

13 files changed

Lines changed: 783 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ jobs:
4747
- name: Build
4848
run: dotnet build NeoReports.sln --configuration Release --no-restore
4949

50+
- name: Install Playwright browsers
51+
# The E2E suite drives a real Chromium against the app. Runs after Build so the
52+
# generated playwright.ps1 exists; --with-deps pulls the shared libraries a headless
53+
# browser needs on a bare runner. Skipped browsers would make the E2E tests self-skip,
54+
# so the install failing must fail the job rather than quietly reduce coverage.
55+
run: pwsh tests/NeoReports.WebUi.E2ETests/bin/Release/net8.0/playwright.ps1 install --with-deps chromium
56+
5057
- name: Test
5158
env:
5259
# Fail (don't silently skip) the Testcontainers integration suites if Docker can't start.
@@ -55,6 +62,9 @@ jobs:
5562
# Local `dotnet test` leaves the var unset and keeps skipping, so contributors without
5663
# Docker can still run the suite.
5764
NEOREPORTS_REQUIRE_DOCKER: "1"
65+
# Same reasoning for the browser: installing it is not proof it launches, and without this a
66+
# driver mismatch or an OOM-killed Chromium would skip most of the E2E suite silently.
67+
NEOREPORTS_REQUIRE_BROWSER: "1"
5868
run: dotnet test NeoReports.sln --configuration Release --no-build --verbosity normal
5969

6070
- name: Format (verify)

.github/workflows/release.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,17 @@ jobs:
3939
- name: Build
4040
run: dotnet build NeoReports.sln --configuration Release --no-restore -p:Version=${{ steps.ver.outputs.version }}
4141

42+
- name: Install Playwright browsers
43+
# Without this the E2E suite self-skips and the release gate would verify only that the host
44+
# boots — 3 of its 16 tests — while looking green. Same stance as the Docker gate below.
45+
run: pwsh tests/NeoReports.WebUi.E2ETests/bin/Release/net8.0/playwright.ps1 install --with-deps chromium
46+
4247
- name: Test
4348
env:
4449
# Never publish a release whose integration suites silently skipped for a broken/missing
4550
# Docker on the runner — hard-fail instead (see tests/Shared/DockerGate.cs).
4651
NEOREPORTS_REQUIRE_DOCKER: "1"
52+
NEOREPORTS_REQUIRE_BROWSER: "1"
4753
run: dotnet test NeoReports.sln --configuration Release --no-build --verbosity normal
4854

4955
- name: Pack

NeoReports.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Samples.AspirePr
217217
EndProject
218218
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Sources.Common.UnitTests", "tests\NeoReports.Sources.Common.UnitTests\NeoReports.Sources.Common.UnitTests.csproj", "{03BE5D53-EBB5-4627-8495-25094F2FD2C0}"
219219
EndProject
220+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.WebUi.E2ETests", "tests\NeoReports.WebUi.E2ETests\NeoReports.WebUi.E2ETests.csproj", "{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}"
221+
EndProject
220222
Global
221223
GlobalSection(SolutionConfigurationPlatforms) = preSolution
222224
Debug|Any CPU = Debug|Any CPU
@@ -1343,6 +1345,18 @@ Global
13431345
{03BE5D53-EBB5-4627-8495-25094F2FD2C0}.Release|x64.Build.0 = Release|Any CPU
13441346
{03BE5D53-EBB5-4627-8495-25094F2FD2C0}.Release|x86.ActiveCfg = Release|Any CPU
13451347
{03BE5D53-EBB5-4627-8495-25094F2FD2C0}.Release|x86.Build.0 = Release|Any CPU
1348+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1349+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
1350+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Debug|x64.ActiveCfg = Debug|Any CPU
1351+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Debug|x64.Build.0 = Debug|Any CPU
1352+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Debug|x86.ActiveCfg = Debug|Any CPU
1353+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Debug|x86.Build.0 = Debug|Any CPU
1354+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
1355+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Release|Any CPU.Build.0 = Release|Any CPU
1356+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Release|x64.ActiveCfg = Release|Any CPU
1357+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Release|x64.Build.0 = Release|Any CPU
1358+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Release|x86.ActiveCfg = Release|Any CPU
1359+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0}.Release|x86.Build.0 = Release|Any CPU
13461360
EndGlobalSection
13471361
GlobalSection(SolutionProperties) = preSolution
13481362
HideSolutionNode = FALSE
@@ -1450,5 +1464,6 @@ Global
14501464
{2A62EE63-E45A-486B-6E1B-49623D61E558} = {352D64FC-8B89-594D-28EF-2A361B65B687}
14511465
{25A31B4B-6319-433E-8831-9BAA6AB9237F} = {2A62EE63-E45A-486B-6E1B-49623D61E558}
14521466
{03BE5D53-EBB5-4627-8495-25094F2FD2C0} = {22222222-2222-2222-2222-222222222222}
1467+
{7761A0CD-569F-41A3-8C1F-890CD68BEDE0} = {22222222-2222-2222-2222-222222222222}
14531468
EndGlobalSection
14541469
EndGlobal

build/Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
4343
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
4444
<PackageVersion Include="Shouldly" Version="4.2.1" />
45+
<PackageVersion Include="Microsoft.Playwright" Version="1.56.0" />
4546
<PackageVersion Include="NSubstitute" Version="6.0.0" />
4647
<PackageVersion Include="Testcontainers.MsSql" Version="4.13.0" />
4748
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using System.Net;
2+
using Shouldly;
3+
using Xunit;
4+
5+
namespace NeoReports.WebUi.E2ETests;
6+
7+
/// <summary>
8+
/// The app boots and serves, checked over plain HTTP before any browser is involved — so a hosting or
9+
/// DI failure is reported as itself rather than as a mysterious browser timeout.
10+
/// </summary>
11+
[Collection(nameof(WebUiCollection))]
12+
public class AppBootTests
13+
{
14+
private readonly WebUiFixture _fixture;
15+
16+
public AppBootTests(WebUiFixture fixture) => _fixture = fixture;
17+
18+
[Fact]
19+
public async Task The_host_listens_and_the_root_redirects_into_the_ui()
20+
{
21+
using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });
22+
23+
using HttpResponseMessage response = await client.GetAsync(_fixture.App.BaseUrl + "/");
24+
25+
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
26+
response.Headers.Location!.ToString().ShouldBe(_fixture.App.UiPath);
27+
}
28+
29+
[Fact]
30+
public async Task The_ui_shell_is_served()
31+
{
32+
using var client = new HttpClient();
33+
34+
string html = await client.GetStringAsync(_fixture.App.Ui());
35+
36+
// The Blazor Server shell: without this script no circuit is ever established and every
37+
// interactive test below would fail for a reason that has nothing to do with the UI.
38+
html.ShouldContain("blazor.server.js");
39+
}
40+
41+
[Fact]
42+
public async Task The_ui_packages_own_static_assets_are_served()
43+
{
44+
using var client = new HttpClient();
45+
46+
// The UI ships as a Razor Class Library, so its CSS lives under _content/. A regression that
47+
// stops those being served leaves every page rendering — unstyled — so nothing else here
48+
// would notice.
49+
// Served under the UI's mounted base path — _Host.cshtml links it relatively, so it resolves
50+
// beneath wherever UseNeoReportsUI put the app, not at the site root.
51+
using HttpResponseMessage response = await client.GetAsync(
52+
_fixture.App.Ui("/_content/NeoReports.UI/css/neoreports.css"));
53+
54+
response.StatusCode.ShouldBe(HttpStatusCode.OK);
55+
}
56+
57+
[Fact]
58+
public async Task The_engine_api_is_mounted_in_the_same_host()
59+
{
60+
using var client = new HttpClient();
61+
62+
using HttpResponseMessage response = await client.GetAsync(_fixture.App.BaseUrl + "/api/reports");
63+
64+
response.StatusCode.ShouldBe(HttpStatusCode.OK);
65+
}
66+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using Microsoft.Playwright;
2+
using Shouldly;
3+
using Xunit;
4+
5+
namespace NeoReports.WebUi.E2ETests;
6+
7+
/// <summary>
8+
/// The Builder wizard driven in a real browser: each step is a separate route whose state lives in a
9+
/// scoped service on the circuit, so this is precisely the flow component-level tests cannot cover —
10+
/// a broken step transition or a lost selection only shows up over a live circuit.
11+
/// </summary>
12+
[Collection(nameof(WebUiCollection))]
13+
public class BuilderWizardTests
14+
{
15+
private readonly WebUiFixture _fixture;
16+
17+
public BuilderWizardTests(WebUiFixture fixture) => _fixture = fixture;
18+
19+
[SkippableFact]
20+
public async Task The_source_picker_is_populated_by_the_engine_and_advances_to_configure()
21+
{
22+
Skip.If(_fixture.Unavailable is not null, _fixture.Unavailable);
23+
await using var ui = await UiPage.OpenAsync(_fixture, "/builder");
24+
25+
ILocator continueButton = ui.Page.GetByRole(AriaRole.Button, new() { Name = "Continue" });
26+
await continueButton.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
27+
28+
// The host registered one IConfigSourceProvider ("inmemory"), so the engine offers it here —
29+
// this asserts the picker is populated from the live engine, not from demo data. (The wizard
30+
// auto-selects a lone provider, so asserting Continue's enabled state here would pass whether
31+
// or not the click did anything — the meaningful assertion is that the step advances.)
32+
await ui.Page.GetByText("inmemory").First.ClickAsync();
33+
await continueButton.ClickAsync();
34+
35+
await ui.Page.GetByRole(AriaRole.Heading, new() { Name = "Configure the source" }).First
36+
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
37+
await ui.AssertNoCircuitErrorAsync();
38+
}
39+
40+
[SkippableFact]
41+
public async Task The_wizard_keeps_its_state_across_steps_and_back_navigation()
42+
{
43+
Skip.If(_fixture.Unavailable is not null, _fixture.Unavailable);
44+
await using var ui = await UiPage.OpenAsync(_fixture, "/builder");
45+
46+
await ui.Page.GetByText("inmemory").First.ClickAsync();
47+
await ui.Page.GetByRole(AriaRole.Button, new() { Name = "Continue" }).ClickAsync();
48+
await ui.Page.GetByRole(AriaRole.Heading, new() { Name = "Configure the source" }).First
49+
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
50+
51+
// Name the report, then go back a step and forward again. D69 fixed a bug where any Back
52+
// navigation reset the whole wizard; this is that regression, exercised over a real circuit.
53+
await ui.Page.GetByPlaceholder("monthly-sales").FillAsync("wizard-state-check");
54+
await ui.Page.GetByRole(AriaRole.Button, new() { Name = "Back" }).ClickAsync();
55+
56+
await ui.Page.GetByRole(AriaRole.Heading, new() { Name = "Choose the data source" }).First
57+
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
58+
59+
await ui.Page.GetByRole(AriaRole.Button, new() { Name = "Continue" }).ClickAsync();
60+
await ui.Page.GetByRole(AriaRole.Heading, new() { Name = "Configure the source" }).First
61+
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
62+
63+
(await ui.Page.GetByPlaceholder("monthly-sales").InputValueAsync()).ShouldBe("wizard-state-check");
64+
await ui.AssertNoCircuitErrorAsync();
65+
}
66+
67+
[SkippableFact]
68+
public async Task Cancelling_the_wizard_returns_to_the_reports_list()
69+
{
70+
Skip.If(_fixture.Unavailable is not null, _fixture.Unavailable);
71+
await using var ui = await UiPage.OpenAsync(_fixture, "/builder");
72+
73+
await ui.Page.GetByRole(AriaRole.Button, new() { Name = "Cancel" }).ClickAsync();
74+
75+
await ui.Page.GetByRole(AriaRole.Heading, new() { Name = "Reports" }).First
76+
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
77+
await ui.AssertNoCircuitErrorAsync();
78+
}
79+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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

Comments
 (0)