Skip to content

Commit e8845f3

Browse files
committed
test(ui): create a report through the wizard, and cover more report shapes
Closes the gap the E2E suite shipped with: a report is now created entirely through the Builder wizard in the browser — source type, name and columns, formats, destination, Save — and then verified against the engine's API and run to a downloadable file. Saving through the UI and having the engine agree is the part component tests cannot reach. Adds six report shapes, each asserting the bytes that came out rather than only that the job completed — a run can report success and still deliver a truncated or empty file: - every ColumnType the source and writers branch on round-trips into the CSV; - 250 rows at 10 per page (25 real batches) arrive complete and without duplicates, which is the pagination loop actually working; - a zero-row report still yields a well-formed header-only file; - an .xlsx opens as a valid package AND carries the expected row count — both the workbook and sheet parts are written before any data arrives, so asserting they merely exist would pass on a run that wrote nothing; - a report with no destination is registered with none and still produces a downloadable artifact; - a csv+xlsx report downloads as a zip whose two files agree on the row count, which is where a multi-output write path losing data on one branch would show. The API calls behind these move into a shared ReportApi client, replacing three copies of the same poll loop. It polls the job id the run endpoint returns rather than searching by report name, and treats Cancelled as terminal so a cancelled job reports itself instead of timing out. One real race fixed while writing this: the format step fetches the engine's capabilities inside OnInitializedAsync, so the heading renders before the cards. Probing with CountAsync() before that second render silently skipped every toggle and left the wizard's defaults, failing later as a format-selection assertion that had nothing to do with selection.
1 parent f176a92 commit e8845f3

6 files changed

Lines changed: 428 additions & 34 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,6 @@ neoreports-sources/
9090

9191
# Runtime schedule overrides persisted locally by AddScheduling() (default directory)
9292
neoreports-schedules/
93+
94+
# Generated by the Web SDK for the E2E test host; it is never launched, only hosted in-process.
95+
tests/NeoReports.WebUi.E2ETests/Properties/launchSettings.json

tests/NeoReports.WebUi.E2ETests/BuilderWizardTests.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,62 @@ public class BuilderWizardTests
1616

1717
public BuilderWizardTests(WebUiFixture fixture) => _fixture = fixture;
1818

19+
/// <summary>
20+
/// Walks the wizard end to end and saves, exactly as a user would: source type, name + columns,
21+
/// format, destination, then Save on the review step.
22+
/// </summary>
23+
private static async Task CreateReportThroughWizardAsync(UiPage ui, string name, params string[] formats)
24+
{
25+
// Step 1 — source.
26+
await ui.Page.GetByText("inmemory").First.ClickAsync();
27+
await ui.Page.GetByRole(AriaRole.Button, new() { Name = "Continue" }).ClickAsync();
28+
29+
// Step 2 — name and the output columns the engine will project.
30+
await ui.Page.GetByRole(AriaRole.Heading, new() { Name = "Configure the source" }).First
31+
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
32+
await ui.Page.GetByPlaceholder("monthly-sales").FillAsync(name);
33+
await ui.Page.GetByPlaceholder("Id, Customer, Amount").FillAsync("Id, Customer");
34+
await ui.Page.GetByRole(AriaRole.Button, new() { Name = "Continue" }).ClickAsync();
35+
36+
// Step 3 — formats. The cards come from the engine's registered IWriterFactory instances.
37+
await ui.Page.GetByRole(AriaRole.Heading, new() { Name = "Choose formats" }).First
38+
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
39+
// Wait for the cards themselves: this step fetches the engine's capabilities inside
40+
// OnInitializedAsync, so the first render — the one the heading above proves — shows the
41+
// "no formats registered" empty state. Probing with CountAsync() before that second render
42+
// lands would silently skip every toggle and leave the wizard's defaults in place.
43+
await ui.Page.Locator(".sel-card").First
44+
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
45+
46+
// Converge on exactly the requested set rather than assuming what starts selected — the wizard
47+
// pre-selects every format it knows, so a plain "click the ones I want" would deselect them.
48+
// The card's label is a display name ("CSV", "Excel"); the extension is the one text that maps
49+
// 1:1 to the engine's format id.
50+
foreach (string format in new[] { "csv", "xlsx" })
51+
{
52+
ILocator card = ui.Page.Locator(".sel-card")
53+
.Filter(new LocatorFilterOptions { HasText = $".{format}" });
54+
if (await card.CountAsync() == 0)
55+
continue;
56+
57+
bool isSelected = (await card.First.GetAttributeAsync("class"))!.Contains("selected", StringComparison.Ordinal);
58+
if (isSelected != formats.Contains(format))
59+
await card.First.ClickAsync();
60+
}
61+
await ui.Page.GetByRole(AriaRole.Button, new() { Name = "Continue" }).ClickAsync();
62+
63+
// Step 4 — destination. The wizard defaults to None, which is what this test wants: a report
64+
// that produces a downloadable artifact without uploading anywhere.
65+
await ui.Page.GetByRole(AriaRole.Heading, new() { Name = "Choose a destination" }).First
66+
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
67+
await ui.Page.GetByRole(AriaRole.Button, new() { Name = "Continue" }).ClickAsync();
68+
69+
// Step 5 — review and save.
70+
await ui.Page.GetByRole(AriaRole.Heading, new() { Name = "Review and save" }).First
71+
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
72+
await ui.Page.GetByRole(AriaRole.Button, new() { Name = "Save report" }).ClickAsync();
73+
}
74+
1975
[SkippableFact]
2076
public async Task The_source_picker_is_populated_by_the_engine_and_advances_to_configure()
2177
{
@@ -64,6 +120,34 @@ public async Task The_wizard_keeps_its_state_across_steps_and_back_navigation()
64120
await ui.AssertNoCircuitErrorAsync();
65121
}
66122

123+
[SkippableFact]
124+
public async Task A_report_created_through_the_wizard_is_registered_and_can_be_run()
125+
{
126+
Skip.If(_fixture.Unavailable is not null, _fixture.Unavailable);
127+
string name = "e2e-wizard-" + Guid.NewGuid().ToString("N")[..6];
128+
129+
await using var ui = await UiPage.OpenAsync(_fixture, "/builder");
130+
await CreateReportThroughWizardAsync(ui, name, "csv");
131+
132+
// Saving navigates away from the wizard; the new report must be listed.
133+
await ui.Page.WaitForFunctionAsync(
134+
"() => !location.pathname.includes('/builder')",
135+
null,
136+
new PageWaitForFunctionOptions { Timeout = UiPage.Timeout });
137+
await ui.AssertNoCircuitErrorAsync();
138+
139+
// It is a real registration, not just UI state: the engine's own API returns it, with the
140+
// shape the wizard collected.
141+
using var api = new ReportApi(_fixture.App);
142+
ReportApi.Report created = (await api.ReportsAsync()).Where(r => r.Name == name).ShouldHaveSingleItem();
143+
created.Formats.ShouldBe(new[] { "csv" });
144+
created.Columns.ShouldBe(new[] { "Id", "Customer" });
145+
146+
// And it actually runs — a report you can save but not run would be a hollow pass.
147+
ReportApi.Job job = await api.RunToCompletionAsync(name);
148+
(await api.DownloadAsync(job.Id)).Length.ShouldBeGreaterThan(0);
149+
}
150+
67151
[SkippableFact]
68152
public async Task Cancelling_the_wizard_returns_to_the_reports_list()
69153
{
@@ -76,4 +160,5 @@ public async Task Cancelling_the_wizard_returns_to_the_reports_list()
76160
.WaitForAsync(new LocatorWaitForOptions { Timeout = UiPage.Timeout });
77161
await ui.AssertNoCircuitErrorAsync();
78162
}
163+
79164
}

tests/NeoReports.WebUi.E2ETests/GenerateReportTests.cs

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ public async Task Running_a_report_from_the_reports_page_produces_a_completed_jo
7777
// The run is asynchronous. Let it finish before opening the Jobs screen: that page loads its
7878
// list once on init, so navigating straight after the click would render an empty list and the
7979
// assertion below would be timing, not behaviour.
80-
using var client = new HttpClient();
81-
JobDto job = await WaitForCompletedJobAsync(client, name);
80+
using var api = new ReportApi(_fixture.App);
81+
ReportApi.Job job = await api.WaitForReportCompletionAsync(name);
8282

8383
await using var jobs = await UiPage.OpenAsync(_fixture, "/jobs");
8484
await jobs.WaitForTextAsync(name);
@@ -91,9 +91,7 @@ public async Task Running_a_report_from_the_reports_page_produces_a_completed_jo
9191

9292
await jobs.AssertNoCircuitErrorAsync();
9393

94-
byte[] artifact = await client.GetByteArrayAsync(
95-
$"{_fixture.App.BaseUrl}/api/jobs/{job.Id}/download");
96-
artifact.Length.ShouldBeGreaterThan(0);
94+
(await api.DownloadAsync(job.Id)).Length.ShouldBeGreaterThan(0);
9795
}
9896

9997
[SkippableFact]
@@ -128,31 +126,11 @@ public async Task A_multi_format_report_delivers_every_format_it_declares()
128126
await using var ui = await UiPage.OpenAsync(_fixture, "/reports");
129127
await RunFromReportsPageAsync(ui, name);
130128

131-
using var client = new HttpClient();
132-
JobDto job = await WaitForCompletedJobAsync(client, name);
129+
using var api = new ReportApi(_fixture.App);
130+
ReportApi.Job job = await api.WaitForReportCompletionAsync(name);
133131

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);
132+
(await api.ArtifactsAsync(job.Id)).Select(a => Path.GetExtension(a.FileName))
133+
.ShouldBe(new[] { ".csv", ".xlsx" }, ignoreOrder: true);
137134
}
138135

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);
158136
}

tests/NeoReports.WebUi.E2ETests/README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,23 @@ suite while the build stayed green. Same stance, same reasoning, as `tests/Share
4646
screen shows it in that report's own row, and the artifact endpoint serves non-empty bytes.
4747
- **Multi-format delivery** — a report declaring `csv` + `xlsx` produces both artifacts.
4848
- **Report detail** — clicking through shows the declared columns.
49-
- **Builder wizard** — the source picker is populated from the live engine, steps advance, and state
49+
- **Builder wizard, end to end** — a report is **created through the wizard itself** (source → name and
50+
columns → formats → destination → Save), then verified against the engine's API and *run*, producing
51+
a downloadable file. Also: the source picker is populated from the live engine, and wizard state
5052
survives Back navigation (the D69 regression, exercised over a real circuit).
53+
- **Report shapes** — each asserting the bytes that came out, not just that the job completed:
54+
every column type round-trips into the CSV; a 250-row report at 10 rows per page delivers all 250
55+
with no duplicates (25 real batches through the pagination loop); a zero-row report still yields a
56+
well-formed header-only file; an `.xlsx` opens as a valid package with a worksheet part; a report
57+
with no destination still produces a downloadable artifact; and a `csv`+`xlsx` report downloads as a
58+
zip whose two files agree on the row count.
5159

5260
Every test asserts Blazor's error overlay is **not** showing: a faulted circuit still returns HTTP 200
5361
for the shell, so without that check a broken screen looks green.
5462

55-
## Known gap
63+
## Scope note
5664

57-
The wizard is driven up to the Configure step; saving a brand-new report *through the wizard* (rather
58-
than through the API) is not covered yet — each remaining step needs source-specific input. Report
59-
creation itself is covered by the API-seeded flows above.
65+
Scenarios that only need a particular *report shape* register it through the engine's API and then
66+
assert the delivered bytes — the UI is not the thing under test there, and driving the wizard five
67+
steps deep for each shape would trade real coverage for a slower, more brittle suite. The wizard
68+
itself has its own end-to-end test that creates and saves a report entirely through the browser.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
using System.Net.Http.Json;
2+
using System.Text;
3+
using Shouldly;
4+
5+
namespace NeoReports.WebUi.E2ETests;
6+
7+
/// <summary>
8+
/// Thin client for the engine's own HTTP API, used by the E2E tests to set a scenario up and to verify
9+
/// what a UI action really produced. The UI drives the behaviour under test; this checks the result
10+
/// against the engine rather than against the screen that just rendered it.
11+
/// </summary>
12+
public sealed class ReportApi : IDisposable
13+
{
14+
private readonly HttpClient _client = new();
15+
private readonly string _baseUrl;
16+
17+
/// <summary>Creates a client for the running app.</summary>
18+
public ReportApi(WebUiApp app) => _baseUrl = app.BaseUrl;
19+
20+
/// <summary>One column of a report's schema.</summary>
21+
public readonly record struct Column(string Name, string Type);
22+
23+
/// <summary>Registers a dynamic report backed by the in-memory sample source.</summary>
24+
/// <param name="name">Report name.</param>
25+
/// <param name="columns">Schema columns; the source synthesises a value per type.</param>
26+
/// <param name="formats">Output formats (e.g. csv, xlsx).</param>
27+
/// <param name="rows">How many rows the source should yield.</param>
28+
/// <param name="pageSize">Batch size, so a scenario can force several pages.</param>
29+
/// <param name="withDestination">Whether to attach the local destination.</param>
30+
public async Task RegisterAsync(
31+
string name,
32+
IReadOnlyList<Column> columns,
33+
IReadOnlyList<string> formats,
34+
int rows = 25,
35+
int pageSize = 10,
36+
bool withDestination = true)
37+
{
38+
// Built with plain interpolation rather than raw strings: the JSON is brace-dense, and raw
39+
// interpolated literals need the `$` count to out-number every run of closing braces.
40+
string cols = string.Join(",", columns.Select(c => "{\"name\":\"" + c.Name + "\",\"type\":\"" + c.Type + "\"}"));
41+
string outs = string.Join(",", formats.Select(f => "{\"format\":\"" + f + "\"}"));
42+
string dests = withDestination ? ",\"destinations\":[{\"type\":\"local\"}]" : string.Empty;
43+
string config =
44+
"{\"name\":\"" + name + "\"," +
45+
"\"source\":{\"type\":\"inmemory\",\"properties\":{\"rows\":" + rows + "}}," +
46+
"\"columns\":[" + cols + "],\"outputs\":[" + outs + "]" + dests + "," +
47+
"\"pageSize\":" + pageSize + "}";
48+
49+
using var body = new StringContent(config, Encoding.UTF8, "application/json");
50+
using HttpResponseMessage response = await _client.PostAsync(_baseUrl + "/api/reports", body);
51+
response.IsSuccessStatusCode.ShouldBeTrue(
52+
$"registering '{name}' failed: {await response.Content.ReadAsStringAsync()}");
53+
}
54+
55+
/// <summary>Triggers a run and returns once its job reaches a terminal state.</summary>
56+
public async Task<Job> RunToCompletionAsync(string name)
57+
{
58+
using var body = new StringContent("{}", Encoding.UTF8, "application/json");
59+
using HttpResponseMessage response = await _client.PostAsync($"{_baseUrl}/api/reports/{name}/run", body);
60+
response.IsSuccessStatusCode.ShouldBeTrue($"running '{name}' failed: {response.StatusCode}");
61+
62+
// Poll the id the API just handed back rather than searching the job list by report name:
63+
// exact, and immune to another run of the same report existing.
64+
Accepted accepted = (await response.Content.ReadFromJsonAsync<Accepted>())!;
65+
return await WaitForCompletionAsync(accepted.JobId);
66+
}
67+
68+
/// <summary>Polls one job until it reaches a terminal state, failing loudly on anything but success.</summary>
69+
public async Task<Job> WaitForCompletionAsync(string jobId)
70+
{
71+
for (var attempt = 0; attempt < 60; attempt++)
72+
{
73+
Job job = (await _client.GetFromJsonAsync<Job>($"{_baseUrl}/api/jobs/{jobId}"))!;
74+
switch (job.Status)
75+
{
76+
case "Completed":
77+
return job;
78+
// Cancelled is terminal too; without this the poll would burn its whole budget and
79+
// then report a timeout instead of what actually happened.
80+
case "Failed":
81+
case "Cancelled":
82+
throw new Xunit.Sdk.XunitException($"Job {jobId} ended as {job.Status}: {job.Error}");
83+
default:
84+
await Task.Delay(500);
85+
break;
86+
}
87+
}
88+
89+
throw new Xunit.Sdk.XunitException($"Job {jobId} did not finish within 30s.");
90+
}
91+
92+
/// <summary>
93+
/// Polls until the named report has a completed job. Used when the run was started through the
94+
/// UI, so there is no job id to poll — prefer <see cref="WaitForCompletionAsync(string)"/> when
95+
/// the run was triggered here.
96+
/// </summary>
97+
public async Task<Job> WaitForReportCompletionAsync(string reportName)
98+
{
99+
for (var attempt = 0; attempt < 60; attempt++)
100+
{
101+
var jobs = await _client.GetFromJsonAsync<List<Job>>(_baseUrl + "/api/jobs?limit=100");
102+
Job? job = jobs!.FirstOrDefault(j => j.ReportName == reportName);
103+
if (job is { Status: "Completed" })
104+
return job;
105+
if (job is { Status: "Failed" or "Cancelled" })
106+
throw new Xunit.Sdk.XunitException($"Report '{reportName}' ended as {job.Status}: {job.Error}");
107+
await Task.Delay(500);
108+
}
109+
110+
throw new Xunit.Sdk.XunitException($"Report '{reportName}' did not complete within 30s.");
111+
}
112+
113+
/// <summary>The artifacts a completed job produced.</summary>
114+
public async Task<IReadOnlyList<Artifact>> ArtifactsAsync(string jobId) =>
115+
(await _client.GetFromJsonAsync<List<Artifact>>($"{_baseUrl}/api/jobs/{jobId}/artifacts"))!;
116+
117+
/// <summary>
118+
/// Downloads what a user gets from the job: the file itself for a single-output run, and a zip of
119+
/// every file when the run produced several (there is no per-artifact route).
120+
/// </summary>
121+
public Task<byte[]> DownloadAsync(string jobId) =>
122+
_client.GetByteArrayAsync($"{_baseUrl}/api/jobs/{jobId}/download");
123+
124+
/// <summary>Every registered report.</summary>
125+
public async Task<IReadOnlyList<Report>> ReportsAsync() =>
126+
(await _client.GetFromJsonAsync<List<Report>>(_baseUrl + "/api/reports"))!;
127+
128+
/// <summary>A registered report as the engine sees it.</summary>
129+
public sealed record Report(
130+
string Name, IReadOnlyList<string> Formats, IReadOnlyList<string> Columns, IReadOnlyList<string> Destinations);
131+
132+
/// <summary>The run endpoint's 202 body.</summary>
133+
private sealed record Accepted(string JobId);
134+
135+
/// <summary>A job as the engine sees it.</summary>
136+
public sealed record Job(string Id, string ReportName, string Status, string? Error);
137+
138+
/// <summary>One produced file.</summary>
139+
public sealed record Artifact(string FileName);
140+
141+
/// <inheritdoc />
142+
public void Dispose() => _client.Dispose();
143+
}

0 commit comments

Comments
 (0)