Skip to content

Commit 67d0bc7

Browse files
committed
feat: add HTML report timestamps and titles
1 parent 87f656d commit 67d0bc7

8 files changed

Lines changed: 103 additions & 30 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,23 @@ unispeedtest
4646
Options:
4747

4848
- `-html <path>`: write a self-contained HTML report
49+
- `-html-title <title>`: append a title to the HTML report (requires `-html`)
4950
- `-json`: output compact JSON
5051
- `-pretty`: output indented JSON (implies `-json`)
5152
- `-v`, `--version`: print the CLI version and exit
5253

5354
Examples:
5455

5556
```sh
56-
unispeedtest -html report.html
57+
unispeedtest -html report.html -html-title "Home Wi-Fi"
5758
unispeedtest -json
5859
unispeedtest -pretty
5960
unispeedtest --version
6061
```
6162

6263
## HTML report
6364

64-
`-html <path>` writes a responsive, self-contained report with no external assets or JavaScript. Normal terminal or JSON output is preserved, so the flag can be combined with `-json` or `-pretty`. An existing file at the path is overwritten.
65+
`-html <path>` writes a responsive, self-contained report with no external assets. The measurement time is stored as Unix epoch milliseconds and formatted in the viewer's locale and time zone by inline JavaScript. `-html-title "Home Wi-Fi"` changes the document title and heading to `Internet Speed Report - Home Wi-Fi`. Normal terminal or JSON output is preserved, so the flag can be combined with `-json` or `-pretty`. An existing file at the path is overwritten.
6566

6667
## JSON output shape
6768

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Build a fast, reliable, script-friendly speed test CLI that supports multiple pr
3535
## Phase 3: CLI and Output Enhancements
3636

3737
- [x] Add self-contained HTML report export with `-html <path>`.
38+
- [x] Add measurement time and an optional title to HTML reports.
3839
- [ ] Add machine-readable metadata (`timestamp`, `provider`, `version`).
3940
- [ ] Add NDJSON / compact streaming output mode for automation pipelines.
4041
- [ ] Add optional result export (`--out` JSON file).

cmd/unispeedtest/main.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"os/signal"
1010
"runtime/debug"
11+
"time"
1112

1213
"github.com/hsblabs/universal-speedtest-cli/internal/cloudflare"
1314
"github.com/hsblabs/universal-speedtest-cli/internal/color"
@@ -18,7 +19,7 @@ import (
1819
var (
1920
version = ""
2021
buildInfoReader = debug.ReadBuildInfo
21-
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath string, stdout, stderr io.Writer) int {
22+
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath, htmlTitle string, stdout, stderr io.Writer) int {
2223
if prettyOut {
2324
jsonOut = true
2425
}
@@ -49,7 +50,7 @@ var (
4950

5051
progress("%sInitializing Cloudflare Speed Test...%s\n\n", color.Bold, color.Reset)
5152

52-
var result reporter.Result
53+
result := reporter.Result{MeasuredAtUnixMs: time.Now().UnixMilli()}
5354
var warnings []string
5455

5556
meta, err := cloudflare.FetchMeta()
@@ -136,7 +137,7 @@ var (
136137
result.Warnings = warnings
137138

138139
if htmlPath != "" {
139-
if err := writeHTMLReport(htmlPath, result); err != nil {
140+
if err := writeHTMLReport(htmlPath, htmlTitle, result); err != nil {
140141
fmt.Fprintf(stderr, "error writing HTML report: %v\n", err)
141142
return 1
142143
}
@@ -184,9 +185,9 @@ func main() {
184185
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
185186
}
186187

187-
func writeHTMLReport(path string, result reporter.Result) error {
188+
func writeHTMLReport(path, title string, result reporter.Result) error {
188189
var report bytes.Buffer
189-
if err := reporter.PrintHTML(&report, result); err != nil {
190+
if err := reporter.PrintHTML(&report, result, title); err != nil {
190191
return err
191192
}
192193
return os.WriteFile(path, report.Bytes(), 0o644)
@@ -204,15 +205,20 @@ func run(args []string, stdout, stderr io.Writer) int {
204205
jsonOut := fs.Bool("json", false, "Output results in JSON format")
205206
prettyOut := fs.Bool("pretty", false, "Output pretty-printed JSON (implies -json)")
206207
htmlPath := fs.String("html", "", "Write a self-contained HTML report to path")
208+
htmlTitle := fs.String("html-title", "", "Set the HTML report title suffix (requires -html)")
207209

208210
if err := fs.Parse(args); err != nil {
209211
if err == flag.ErrHelp {
210212
return 0
211213
}
212214
return 2
213215
}
216+
if *htmlTitle != "" && *htmlPath == "" {
217+
fmt.Fprintln(stderr, "error: -html-title requires -html")
218+
return 2
219+
}
214220

215-
return benchmarkMain(*jsonOut, *prettyOut, *htmlPath, stdout, stderr)
221+
return benchmarkMain(*jsonOut, *prettyOut, *htmlPath, *htmlTitle, stdout, stderr)
216222
}
217223

218224
func float64Ptr(value float64) *float64 {

cmd/unispeedtest/main_test.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func TestRunVersionFlagPrintsAndExits(t *testing.T) {
9393
})
9494

9595
version = "v1.2.3"
96-
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath string, stdout, stderr io.Writer) int {
96+
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath, htmlTitle string, stdout, stderr io.Writer) int {
9797
t.Fatal("benchmark path should not run for version flags")
9898
return 1
9999
}
@@ -122,7 +122,7 @@ func TestRunShortVersionFlagPrintsAndExits(t *testing.T) {
122122
})
123123

124124
version = "v9.9.9"
125-
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath string, stdout, stderr io.Writer) int {
125+
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath, htmlTitle string, stdout, stderr io.Writer) int {
126126
t.Fatal("benchmark path should not run for version flags")
127127
return 1
128128
}
@@ -149,7 +149,7 @@ func TestRunHelpFlagPrintsUsageAndExitsZero(t *testing.T) {
149149
benchmarkMain = oldBenchmark
150150
})
151151

152-
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath string, stdout, stderr io.Writer) int {
152+
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath, htmlTitle string, stdout, stderr io.Writer) int {
153153
t.Fatal("benchmark path should not run for help flags")
154154
return 1
155155
}
@@ -170,21 +170,44 @@ func TestRunHelpFlagPrintsUsageAndExitsZero(t *testing.T) {
170170
}
171171
}
172172

173-
func TestRunHTMLFlagPassesOutputPath(t *testing.T) {
173+
func TestRunHTMLFlagsPassOutputOptions(t *testing.T) {
174174
oldBenchmark := benchmarkMain
175175
t.Cleanup(func() {
176176
benchmarkMain = oldBenchmark
177177
})
178178

179-
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath string, stdout, stderr io.Writer) int {
179+
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath, htmlTitle string, stdout, stderr io.Writer) int {
180180
if htmlPath != "report.html" {
181181
t.Fatalf("htmlPath = %q, want %q", htmlPath, "report.html")
182182
}
183+
if htmlTitle != "Home" {
184+
t.Fatalf("htmlTitle = %q, want %q", htmlTitle, "Home")
185+
}
183186
return 0
184187
}
185188

186-
if exitCode := run([]string{"-html", "report.html"}, io.Discard, io.Discard); exitCode != 0 {
187-
t.Fatalf("run(-html report.html) exit code = %d, want 0", exitCode)
189+
if exitCode := run([]string{"-html", "report.html", "-html-title", "Home"}, io.Discard, io.Discard); exitCode != 0 {
190+
t.Fatalf("run() exit code = %d, want 0", exitCode)
191+
}
192+
}
193+
194+
func TestRunRejectsHTMLTitleWithoutHTMLPath(t *testing.T) {
195+
oldBenchmark := benchmarkMain
196+
t.Cleanup(func() {
197+
benchmarkMain = oldBenchmark
198+
})
199+
200+
benchmarkMain = func(jsonOut, prettyOut bool, htmlPath, htmlTitle string, stdout, stderr io.Writer) int {
201+
t.Fatal("benchmark path should not run for invalid flags")
202+
return 1
203+
}
204+
205+
var stderr bytes.Buffer
206+
if exitCode := run([]string{"-html-title", "Home"}, io.Discard, &stderr); exitCode != 2 {
207+
t.Fatalf("run() exit code = %d, want 2", exitCode)
208+
}
209+
if got := stderr.String(); !strings.Contains(got, "-html-title requires -html") {
210+
t.Fatalf("stderr = %q, want dependency error", got)
188211
}
189212
}
190213

@@ -195,7 +218,7 @@ func TestWriteHTMLReport(t *testing.T) {
195218
t.Fatalf("os.WriteFile() error = %v", err)
196219
}
197220

198-
if err := writeHTMLReport(path, reporter.Result{DownloadMbps: &download}); err != nil {
221+
if err := writeHTMLReport(path, "Home", reporter.Result{DownloadMbps: &download}); err != nil {
199222
t.Fatalf("writeHTMLReport() error = %v", err)
200223
}
201224

@@ -206,6 +229,9 @@ func TestWriteHTMLReport(t *testing.T) {
206229
if output := string(data); !strings.Contains(output, "123.40") {
207230
t.Fatalf("HTML report missing download speed:\n%s", output)
208231
}
232+
if output := string(data); !strings.Contains(output, "Internet Speed Report - Home") {
233+
t.Fatalf("HTML report missing custom title:\n%s", output)
234+
}
209235
if strings.Contains(string(data), "old report") {
210236
t.Fatalf("HTML report did not replace existing file:\n%s", data)
211237
}

docs/README/ja.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,25 @@ unispeedtest
4646
オプション:
4747

4848
- `-html <path>`: 自己完結 HTML レポートを保存
49+
- `-html-title <title>`: HTML レポートへタイトルを追加(`-html` が必要)
4950
- `-json`: JSON(1行)で出力
5051
- `-pretty`: 整形済み JSON で出力(`-json` を含む)
5152
- `-v`, `--version`: CLI バージョンを表示して終了
5253

5354
例:
5455

5556
```sh
56-
unispeedtest -html report.html
57+
unispeedtest -html report.html -html-title "自宅 Wi-Fi"
5758
unispeedtest -json
5859
unispeedtest -pretty
5960
unispeedtest --version
6061
```
6162

6263
## HTML レポート
6364

64-
`-html <path>` は、外部アセットと JavaScript を使わないレスポンシブな単一 HTML レポートを保存します。
65+
`-html <path>` は、外部アセットを使わないレスポンシブな単一 HTML レポートを保存します。
66+
計測日時は Unix エポックミリ秒で記録し、インライン JavaScript が閲覧環境のロケールとタイムゾーンに合わせて表示します。
67+
`-html-title "自宅 Wi-Fi"` を指定すると、文書タイトルと見出しは `Internet Speed Report - 自宅 Wi-Fi` になります。
6568
通常のターミナル出力や JSON 出力は維持されるため、`-json` または `-pretty` と併用できます。
6669
指定パスに既存ファイルがある場合は上書きします。
6770

internal/reporter/html.go

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ var htmlReportTemplate = template.Must(template.New("report").Funcs(template.Fun
1414
<head>
1515
<meta charset="utf-8">
1616
<meta name="viewport" content="width=device-width, initial-scale=1">
17-
<title>Internet Speed Report</title>
17+
<title>{{.Title}}</title>
1818
<style>
1919
:root {
2020
color-scheme: light dark;
@@ -27,6 +27,7 @@ var htmlReportTemplate = template.Must(template.New("report").Funcs(template.Fun
2727
main { width: min(960px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0; }
2828
header { margin-bottom: 24px; }
2929
h1 { margin: 0; font-size: clamp(1.75rem, 4vw, 2.5rem); letter-spacing: -0.04em; }
30+
.measured-at { margin: 8px 0 0; color: #65718a; font-size: 0.9rem; }
3031
h2 { margin: 0; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #65718a; }
3132
.speed-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
3233
.card, .panel { background: #fff; border: 1px solid #dde3ed; border-radius: 16px; box-shadow: 0 8px 24px rgba(26, 39, 64, 0.06); }
@@ -59,7 +60,7 @@ var htmlReportTemplate = template.Must(template.New("report").Funcs(template.Fun
5960
@media (prefers-color-scheme: dark) {
6061
:root { color: #edf2fa; background: #101522; }
6162
body { background: #101522; }
62-
h2, .speed-value span, .metric dt, .metric dd span, .network dt { color: #9da9bf; }
63+
h2, .measured-at, .speed-value span, .metric dt, .metric dd span, .network dt { color: #9da9bf; }
6364
.card, .panel { background: #171e2e; border-color: #2a3448; box-shadow: none; }
6465
.metric { background: #101522; }
6566
.warnings { background: #2a2213; border-color: #765b27; }
@@ -68,7 +69,10 @@ var htmlReportTemplate = template.Must(template.New("report").Funcs(template.Fun
6869
</head>
6970
<body>
7071
<main>
71-
<header><h1>Internet Speed Report</h1></header>
72+
<header>
73+
<h1>{{.Title}}</h1>
74+
<p class="measured-at">Measured <time data-unix-ms="{{.MeasuredAtUnixMs}}">{{.MeasuredAtUnixMs}}</time></p>
75+
</header>
7276
7377
<section class="speed-grid" aria-label="Transfer speed">
7478
<article class="card speed-card download">
@@ -109,13 +113,29 @@ var htmlReportTemplate = template.Must(template.New("report").Funcs(template.Fun
109113
</section>
110114
{{end}}
111115
</main>
116+
<script>
117+
const measuredAt = document.querySelector("[data-unix-ms]");
118+
const measuredAtDate = new Date(Number(measuredAt.dataset.unixMs));
119+
measuredAt.dateTime = measuredAtDate.toISOString();
120+
measuredAt.textContent = measuredAtDate.toLocaleString();
121+
</script>
112122
</body>
113123
</html>
114124
`))
115125

126+
type htmlReportData struct {
127+
Result
128+
Title string
129+
}
130+
116131
// PrintHTML writes a self-contained HTML report to w.
117-
func PrintHTML(w io.Writer, r Result) error {
118-
return htmlReportTemplate.Execute(w, r)
132+
func PrintHTML(w io.Writer, r Result, title string) error {
133+
if title == "" {
134+
title = "Internet Speed Report"
135+
} else {
136+
title = "Internet Speed Report - " + title
137+
}
138+
return htmlReportTemplate.Execute(w, htmlReportData{Result: r, Title: title})
119139
}
120140

121141
func formatHTMLNumber(value *float64, precision int) string {

internal/reporter/printer.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
// Result holds all measurements from a speed test run.
1212
type Result struct {
13+
MeasuredAtUnixMs int64
1314
DownloadMbps *float64
1415
UploadMbps *float64
1516
UnloadedLatency *float64

internal/reporter/printer_test.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,21 +72,26 @@ func TestPrintHTMLRendersResultAndEscapesContent(t *testing.T) {
7272
network := "Example <Network>"
7373

7474
result := reporter.Result{
75-
DownloadMbps: &download,
76-
UploadMbps: &upload,
77-
PacketLoss: &packetLoss,
78-
NetworkASOrg: &network,
79-
Warnings: []string{"measurement <script>alert(1)</script>"},
75+
MeasuredAtUnixMs: 1723456789012,
76+
DownloadMbps: &download,
77+
UploadMbps: &upload,
78+
PacketLoss: &packetLoss,
79+
NetworkASOrg: &network,
80+
Warnings: []string{"measurement <script>alert(1)</script>"},
8081
}
8182

8283
var buf bytes.Buffer
83-
if err := reporter.PrintHTML(&buf, result); err != nil {
84+
if err := reporter.PrintHTML(&buf, result, "Home <Lab>"); err != nil {
8485
t.Fatalf("PrintHTML() error = %v", err)
8586
}
8687

8788
output := buf.String()
8889
for _, want := range []string{
8990
"<!doctype html>",
91+
"<title>Internet Speed Report - Home &lt;Lab&gt;</title>",
92+
"<h1>Internet Speed Report - Home &lt;Lab&gt;</h1>",
93+
`data-unix-ms="1723456789012"`,
94+
"toLocaleString()",
9095
"225.14",
9196
"102.87",
9297
"Example &lt;Network&gt;",
@@ -99,9 +104,19 @@ func TestPrintHTMLRendersResultAndEscapesContent(t *testing.T) {
99104
if strings.Contains(output, "<script>alert(1)</script>") {
100105
t.Fatalf("PrintHTML() output contains unescaped warning\n%s", output)
101106
}
102-
for _, forbidden := range []string{"<script", " src=", " href="} {
107+
for _, forbidden := range []string{" src=", " href="} {
103108
if strings.Contains(output, forbidden) {
104109
t.Fatalf("PrintHTML() output contains external or executable content %q\n%s", forbidden, output)
105110
}
106111
}
107112
}
113+
114+
func TestPrintHTMLUsesDefaultTitle(t *testing.T) {
115+
var buf bytes.Buffer
116+
if err := reporter.PrintHTML(&buf, reporter.Result{}, ""); err != nil {
117+
t.Fatalf("PrintHTML() error = %v", err)
118+
}
119+
if output := buf.String(); !strings.Contains(output, "<title>Internet Speed Report</title>") {
120+
t.Fatalf("PrintHTML() output missing default title\n%s", output)
121+
}
122+
}

0 commit comments

Comments
 (0)