Skip to content

Commit aa653cf

Browse files
authored
Add built-in HTML reporter (#36)
1 parent e59f808 commit aa653cf

8 files changed

Lines changed: 368 additions & 1 deletion

File tree

docs/reporters.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ skillgym run <suite.ts> --reporter standard
1111
skillgym run <suite.ts> --reporter json
1212
skillgym run <suite.ts> --reporter json-summary
1313
skillgym run <suite.ts> --reporter github-actions
14+
skillgym run <suite.ts> --reporter html
1415
skillgym run <suite.ts> --reporter ./examples/custom-reporter.ts
1516
skillgym run <suite.ts> --schedule isolated-by-runner --max-parallel 4
1617
```
1718

1819
- Omitting `--reporter` uses the built-in `standard` reporter.
19-
- Built-in reporters are `standard`, `json`, `json-summary`, and `github-actions`.
20+
- Built-in reporters are `standard`, `json`, `json-summary`, `github-actions`, and `html`.
2021
- Relative paths resolve from `process.cwd()`.
2122

2223
## Config
@@ -137,6 +138,17 @@ The built-in `json-summary` reporter writes a trimmed JSON summary to stdout —
137138
- Per-runner results include `failureClass` when present so downstream tooling can keep grouped-failure semantics.
138139
- It is useful for post-run analysis steps or feeding results to an LLM.
139140

141+
## HTML reporter
142+
143+
The built-in `html` reporter writes a self-contained `report.html` file to the suite run artifact directory.
144+
145+
- It renders a collapsible tree: suite summary → case → runner execution → individual session events.
146+
- Each runner block shows pass/fail status, duration, and token usage (input/output).
147+
- Assistant messages, tool calls, file reads, and commands are rendered as expandable `<details>` elements.
148+
- The final output of each execution is shown in a collapsible block.
149+
- Errors are shown inline when present.
150+
- The output path is printed to stdout after the file is written.
151+
140152
## GitHub Actions reporter
141153

142154
The built-in `github-actions` reporter is designed for GitHub CI.

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export { assert, CommandMatcherBuilder, commandMatcher } from "./assertions/inde
4545
export {
4646
BUILT_IN_REPORTER_NAMES,
4747
createGitHubActionsReporter,
48+
createHtmlReporter,
4849
createJsonReporter,
4950
createStandardReporter,
5051
loadReporter,

src/reporters/builtins.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const BUILT_IN_REPORTER_NAMES = [
33
"json",
44
"json-summary",
55
"github-actions",
6+
"html",
67
] as const;
78

89
export type BuiltInReporterName = (typeof BUILT_IN_REPORTER_NAMES)[number];

src/reporters/html.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { writeFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import type { CaseResult, RunnerResult, SuiteRunResult } from "../domain/result.js";
4+
import type { Case } from "../domain/case.js";
5+
import type { SessionEvent } from "../domain/session-report.js";
6+
import type { BenchmarkReporter, CaseFinishEvent } from "./contract.js";
7+
8+
function esc(str: string): string {
9+
return str
10+
.replace(/&/g, "&amp;")
11+
.replace(/</g, "&lt;")
12+
.replace(/>/g, "&gt;")
13+
.replace(/"/g, "&quot;");
14+
}
15+
16+
function ms(n: number | undefined): string {
17+
if (n == null) return "—";
18+
return n < 1000 ? `${n}ms` : `${(n / 1000).toFixed(1)}s`;
19+
}
20+
21+
function tokens(n: number | undefined): string {
22+
if (n == null) return "—";
23+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
24+
}
25+
26+
function renderEvent(e: SessionEvent): string {
27+
if (e.type === "message" && e.role === "assistant") {
28+
return `<details><summary>assistant message</summary><pre>${esc(e.text)}</pre></details>`;
29+
}
30+
if (e.type === "toolCall") {
31+
const args = e.args ? JSON.stringify(e.args, null, 2) : "";
32+
return `<details><summary>tool: ${esc(e.tool)}</summary><pre>${esc(args)}</pre></details>`;
33+
}
34+
if (e.type === "fileRead") {
35+
return `<p>read: <code>${esc(e.path)}</code></p>`;
36+
}
37+
if (e.type === "command") {
38+
return `<p>cmd: <code>${esc(e.command)}</code></p>`;
39+
}
40+
return "";
41+
}
42+
43+
function renderRunnerBlock(rr: RunnerResult): string {
44+
const report = rr.report;
45+
const statusLabel = rr.passed ? "PASS" : "FAIL";
46+
const runnerId = rr.runner?.id ?? "unknown";
47+
const eventsHtml = (report?.events ?? []).map(renderEvent).join("\n");
48+
const errorHtml = rr.error
49+
? `<pre>${esc(rr.error.message)}\n${esc(rr.error.stack ?? "")}</pre>`
50+
: "";
51+
const finalOutput = report?.finalOutput
52+
? `<details><summary>final output</summary><pre>${esc(report.finalOutput)}</pre></details>`
53+
: "";
54+
55+
return `
56+
<section>
57+
<h3>${esc(runnerId)}${statusLabel}${ms(rr.durationMs)}
58+
&nbsp; in: ${tokens(report?.usage?.inputTokens)} / out: ${tokens(report?.usage?.outputTokens)}
59+
</h3>
60+
${errorHtml}
61+
${finalOutput}
62+
${eventsHtml}
63+
</section>`;
64+
}
65+
66+
function renderCaseSection(testCase: Case, caseResult: CaseResult): string {
67+
const status = caseResult.passed ? "✓" : "✗";
68+
const runnerBlocks = (caseResult.runnerResults ?? [])
69+
.filter(Boolean)
70+
.map(renderRunnerBlock)
71+
.join("\n");
72+
73+
return `
74+
<details>
75+
<summary><strong>${status} ${esc(testCase.id)}</strong></summary>
76+
<blockquote><pre>${esc(testCase.prompt)}</pre></blockquote>
77+
${runnerBlocks}
78+
</details>`;
79+
}
80+
81+
function renderPage(result: SuiteRunResult, caseSections: string): string {
82+
const passed = result.cases.filter((c) => c.passed).length;
83+
const total = result.cases.length;
84+
85+
return `
86+
<!DOCTYPE html>
87+
<html lang="en">
88+
<head>
89+
<meta charset="UTF-8">
90+
<title>skillgym — ${esc(result.suitePath)}</title>
91+
<style>
92+
body { font-family: monospace; margin: 2em; }
93+
pre { white-space: pre-wrap; word-break: break-word; background: #f4f4f4; padding: 0.5em; }
94+
summary { cursor: pointer; }
95+
blockquote { border-left: 3px solid #ccc; margin: 0; padding-left: 1em; }
96+
h3 { margin: 0.5em 0; font-size: 1em; }
97+
section { margin-left: 1em; border-left: 2px solid #eee; padding-left: 1em; }
98+
</style>
99+
</head>
100+
<body>
101+
<h1>skillgym report</h1>
102+
<p>${esc(result.suitePath)}</p>
103+
<p>${passed}/${total} passed &nbsp; ${ms(result.durationMs)}</p>
104+
<hr>
105+
${caseSections}
106+
</body>
107+
</html>`;
108+
}
109+
110+
export function createHtmlReporter(): BenchmarkReporter {
111+
const caseResults: CaseFinishEvent[] = [];
112+
113+
return {
114+
onCaseFinish(event) {
115+
caseResults.push(event);
116+
},
117+
118+
onSuiteFinish(event) {
119+
try {
120+
const { result } = event;
121+
const caseSections = caseResults
122+
.map((ev) => renderCaseSection(ev.case, ev.result))
123+
.join("\n");
124+
const html = renderPage(result, caseSections);
125+
const outPath = join(result.suiteRunArtifactDir, "report.html");
126+
writeFileSync(outPath, html, "utf-8");
127+
console.log(`\nHTML report: ${outPath}\n`);
128+
} catch (err) {
129+
console.error("html-reporter error:", err);
130+
}
131+
},
132+
};
133+
}

src/reporters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type {
1111
} from "./contract.js";
1212
export { BUILT_IN_REPORTER_NAMES } from "./builtins.js";
1313
export { createGitHubActionsReporter } from "./github-actions.js";
14+
export { createHtmlReporter } from "./html.js";
1415
export { createJsonReporter } from "./json.js";
1516
export { createJsonSummaryReporter } from "./json-summary.js";
1617
export { loadReporter } from "./load-reporter.js";

src/reporters/load-reporter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from "node:path";
22
import type { BenchmarkReporter } from "./contract.js";
33
import { createGitHubActionsReporter } from "./github-actions.js";
4+
import { createHtmlReporter } from "./html.js";
45
import { createJsonReporter } from "./json.js";
56
import { createJsonSummaryReporter } from "./json-summary.js";
67
import { createStandardReporter } from "./standard.js";
@@ -40,6 +41,8 @@ export async function loadReporter(
4041
return createJsonSummaryReporter();
4142
case "github-actions":
4243
return createGitHubActionsReporter();
44+
case "html":
45+
return createHtmlReporter();
4346
}
4447
}
4548

0 commit comments

Comments
 (0)