The CLI can emit JSON results (--no-ui) and a live terminal UI, but there is no shareable,
human-friendly artifact of a run. This adds a HtmlReportGenerator service that renders a
TestRunResult + originating TestSuite into a self-contained single-page HTML report
(embedded CSS, native <details>/<summary> expand-collapse, no JavaScript), written to a path
supplied via a new --report option on run-suite. Issue #14 (apiResponse on TestCaseResult)
is merged, so request and response data are available per test.
The project renders Thymeleaf via a plain TemplateEngine using OGNL (util/FileLoader.java).
OGNL resolves ${obj.prop} to getProp()/public field — it does not read Java record
accessors (name()). All model types here are records. Therefore the report binds Maps, not
records: HtmlReportGenerator converts records → Map<String,Object> / List/String via
private helper methods before populating the Thymeleaf Context, exactly mirroring how
FileLoader.parseFile passes Maps today. This is OGNL-native and GraalVM-friendly (Maps need no
per-type reflection metadata).
Conscious deviations from the issue text (call out in PR description):
- No
TestCaseReportViewrecord. Helpers convertTestCaseResult→Mapdirectly (the Map is the view). Drops that checklist item by design, per user guidance. - No constructor-injected
ObjectMapper. There is no Jackson bean in this project (every service news its own — seeRunSuiteCommand,PureJavaTestEngine). The generator creates its ownObjectMapperfield. Document thread-safety. - No model reflection hints needed for the template (Maps/Lists/Strings only). Only a
resource-config.jsonentry for the template file is required for native image.
util/FileLoader.java—static final TemplateEnginebuilt in a static block withStringTemplateResolver+TemplateMode.TEXT;parseFilesets Map vars on aContext. Mirror this pattern for the HTML engine.commands/RunSuiteCommand.java— Spring Shell@Command(name="run-suite", alias={"rs"}); options use method-parameter injection with@Option(longName = "...")(singular attribute name — match this, not the issue'slongNames). Constructor injects services;jsonMapperisnew ObjectMapper(). Output viacontext.outputWriter().println(...)+.flush().- UI branch (~line 230):
testEngine.runConfigurationSuite(...)return value is discarded, thencontroller.await(). - Non-UI branch (~line 255): captures
TestRunResult result, printstoJson(...).
- UI branch (~line 230):
- Models (records, package
model):TestRunResult(passedCount,failedCount,skippedCount,errorCount,results,appliedOptions),TestCaseResult(name,result,passedAssertions,failures,skipReason,requestInfo,apiResponse),ApiResponse(statusCode,headers,Body body,responseTimeMs)+Body(text,json),ExecutedRequestInfo(method,url,headers,body),AssertionFailure(description,expected,actual,error),TestSuite(name,description,...), enumTestResult{PASSED,FAILED,SKIPPED,ERROR}. - Native-image metadata dir exists:
src/main/resources/META-INF/native-image/io.github.snytkine.apitester/api-tester-cli/withreflect-config.jsononly — noresource-config.jsonyet.
Full HTML document, lang="en", all CSS embedded in a <style> block in <head> (self-contained).
Thymeleaf HTML-mode attributes bind to the Map context (below). Structure:
- Header:
th:text="${suiteName}", optionalth:if="${suiteDescription}"description, andgenerated:+th:text="${generatedAt}"(pre-formatted String). - Stats block: flex row of 5 stat
<div>s (PASSED/FAILED/SKIPPED/ERROR/TOTAL) with colored top borders,th:textfrom${passedCount}etc. and${totalCount}. - Per-test:
<article th:each="t : ${tests}" th:classappend="${t.statusClass}">with a left border colored by status;:hoverbox-shadow.- Badge
<span class="badge" th:classappend="${t.statusClass}" th:text="${t.result}">. Assertions passed: <span th:text="${t.passedAssertions}">and, when${t.failedAssertions > 0},failed: <span th:text="${t.failedAssertions}">.- SKIPPED: show
Reason:+th:text="${t.skipReason}"; hide request/response (th:if="${t.hasRequest}"). <details><summary>request</summary>…</details>(when${t.hasRequest}): method+url, a header grid, and a<pre><code th:text="${t.requestBody}">when body present.<details><summary>response</summary>…</details>(when${t.hasResponse}): status,responseTimeMs, header grid,<pre><code th:text="${t.formattedResponseBody}">.<details th:if="${t.failedAssertions > 0}" open><summary>Failed Assertions</summary>→<ul><li th:each="f : ${t.failures}">showingf.description, andexpected/actual/errorin<code>spans.- Header grids render from
List<Map>of{name,value}:th:each="h : ${t.requestHeaders}"→${h.name}/${h.value}(lists of maps, not rawMap.Entry, to stay OGNL/native-safe).
- Badge
- CSS palette from the issue: page
#f5f7fa, card#ffffff, header accent#1e3a5f, PASSED#2e7d32, FAILED#c62828, SKIPPED#e65100, ERROR#6a1a6a, border#dde1e7; page max-width960px, centered,font-family: system-ui, sans-serif;<summary>styled as a pill/badge.
@Service, thread-safe singleton (document in class Javadoc). Members:
private static final TemplateEngine HTML_ENGINEbuilt in astatic {}block (mirrorFileLoader):ClassPathTemplateResolverwithsetPrefix("templates/"),setSuffix(".html"),setTemplateMode(TemplateMode.HTML),setCharacterEncoding("UTF-8"),setCacheable(true). Document thatTemplateEngineis thread-safe after initialization.private final ObjectMapper jsonMapper = new ObjectMapper();(own instance; no bean exists).
Public method:
public void generate(TestRunResult result, TestSuite suite, Path outputPath) throws IOException- Build a
Context;setVariablefor:suiteName,suiteDescription,generatedAt(LocalDateTime.now()formatted viaDateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")→ String),passedCount/failedCount/skippedCount/errorCount,totalCount(sum), andtests=result.results().stream().map(this::toTestMap).toList(). String html = HTML_ENGINE.process("suite-report", ctx);Files.createDirectories(outputPath.toAbsolutePath().getParent())thenFiles.writeString(outputPath, html).
Private helper methods (the record→Map converters the user asked for):
Map<String,Object> toTestMap(TestCaseResult tc)— keys:name,result(tc.result().name()),statusClass(lowercase, e.g.passed),passedAssertions,failedAssertions(tc.failures().size()),skipReason,hasRequest(requestInfo != null),requestMethod,requestUrl,requestBody,requestHeaders(viaheadersToList),hasResponse(apiResponse != null),responseStatus,responseTimeMs,responseHeaders,formattedResponseBody(viaformatBody),failures(viafailuresToList). Null-safe for every nullable field.List<Map<String,String>> headersToList(@Nullable Map<String,String> headers)→[{name,value}…](empty list when null).List<Map<String,Object>> failuresToList(List<AssertionFailure> failures)→ per failure{description,expected,actual,error}.@Nullable String formatBody(@Nullable ApiResponse resp)— ifresp==nullreturn null; ifbody.json()!=nullreturnjsonMapper.writerWithDefaultPrettyPrinter().writeValueAsString(json); else returnbody.text().
3. Native image — META-INF/native-image/io.github.snytkine.apitester/api-tester-cli/resource-config.json
New file including the template as a runtime resource:
{ "resources": { "includes": [ { "pattern": "templates/suite-report.html" } ] } }No reflect-config/@RegisterReflectionForBinding additions needed (template binds Maps, not
records). TEXT-mode Thymeleaf already runs in the native binary, so engine + OGNL + standard dialect
are already reachable; HTML mode adds the attoparser HTML path — verify via native build (below).
- Constructor: add
HtmlReportGenerator htmlReportGeneratorparam +private finalfield. - Add option parameter to
runSuite, matching this file's existing@Optionstyle:@Option(longName = "report", description = "Write an HTML execution report to this file path.") @Nullable String reportPath(add the file's supported short-name attribute for-rif present in its@OptionAPI; existing options use none, so long name is the baseline). - Capture the run result in both branches: declare
TestRunResult result = null;before theif (useUi); assignresult = testEngine.runConfigurationSuite(...)in the UI branch (currently discarded) and keep the assignment in the non-UI branch. - After the if/else (and after
controller.await()in UI mode), once, generate the report when requested and a run actually occurred:Early-return validation/empty-tag paths leaveif (reportPath != null && result != null) { Path out = Path.of(reportPath); htmlReportGenerator.generate(result, suiteToRun, out); context.outputWriter().println("Report written to " + out.toAbsolutePath()); context.outputWriter().flush(); }
result == null, so no report is written then.
- New
service/HtmlReportGeneratorTest.java— build aTestRunResultwith threeTestCaseResults: PASSED (withrequestInfo+apiResponse), FAILED (withfailures+apiResponseJSON body), SKIPPED (withskipReason, null request/response). Callgenerate(...)to a JUnit@TempDirpath, read the file, assert the HTML contains: suite name, the 4 counts and TOTAL, each test name, the status words (PASSED/FAILED/SKIPPED),<details>/<summary>, the skip reason, a failuredescription/expected/actual, and the pretty-printed JSON body. Assert it starts with<!DOCTYPE html>/contains<style>(self-contained). commands/RunSuiteCommandTest.java— every site constructingnew RunSuiteCommand(...)must pass a (Mockito)HtmlReportGenerator(constructor arity changed). Add a test: invokingrunSuitewith the report path set callshtmlReportGenerator.generate(...)and the output containsReport written to. Existing no-report tests verifygenerateis never called.
./mvnw spotless:apply
./mvnw test
./mvnw test -Dtest=HtmlReportGeneratorTest
./mvnw test -Dtest=RunSuiteCommandTestManual (JVM): rs --suite ./src/test/resources/<suite>.yml --report /tmp/report.html api_base_url=…
then open /tmp/report.html in a browser — confirm header/counts, per-test badges, and that
<details> sections expand/collapse with no JS, JSON bodies are pretty-printed, FAILED tests show
the failures section open.
Native (project requirement — the key risk to confirm): ./mvnw -Pnative native:compile then
./target/api-tester-cli rs --suite … --report /tmp/report.html — confirms the template resource is
bundled (resource-config) and HTML-mode Thymeleaf renders in the native binary. If the native run
reports missing resources/reflection, extend resource-config.json / add hints accordingly.
-
HtmlReportGenerator@Serviceadded; thread-safety documented; ownObjectMapper -
templates/suite-report.htmlcreated (self-contained, embedded CSS,<details>/<summary>) - Dedicated HTML-mode
TemplateEngine(ClassPathTemplateResolver), not the TEXT engine - Record→Map helper methods (
toTestMap/headersToList/failuresToList/formatBody) -
formattedResponseBodyviaObjectMapper.writerWithDefaultPrettyPrinter() -
resource-config.jsonincludestemplates/suite-report.html -
RunSuiteCommandgains--report; result captured in UI and non-UI branches - Header (name/description/generated time), stats block, per-test badges + assertion counts
- Failures
<details>open on FAILED; SKIPPED hides request/response and shows reason -
HtmlReportGeneratorTest+RunSuiteCommandTestupdated (constructor arity +--report) -
spotless:apply; all tests pass; native build renders a report