diff --git a/internal/exporter/html.go b/internal/exporter/html.go
index 752aee9..292ed2e 100644
--- a/internal/exporter/html.go
+++ b/internal/exporter/html.go
@@ -1,7 +1,6 @@
package exporter
import (
- "encoding/json"
"fmt"
"html"
"html/template"
@@ -18,7 +17,6 @@ type htmlData struct {
Format string
Iterations []htmlIteration
Summary htmlSummary
- DataJSON template.JS
}
type htmlIteration struct {
@@ -117,9 +115,6 @@ func buildHTMLData(t *trace.Trace) htmlData {
}
}
- jsonBytes, _ := json.Marshal(d.Iterations)
- d.DataJSON = template.JS(string(jsonBytes))
-
return d
}
diff --git a/internal/exporter/html_test.go b/internal/exporter/html_test.go
new file mode 100644
index 0000000..7fc392a
--- /dev/null
+++ b/internal/exporter/html_test.go
@@ -0,0 +1,123 @@
+package exporter
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/loop-eng/looprelay/internal/trace"
+)
+
+func sampleTrace() *trace.Trace {
+ tr := trace.New([]trace.Iteration{
+ {
+ Number: 1,
+ Phase: trace.PhaseAct,
+ Action: trace.Action{
+ Type: "Edit",
+ Target: "src/main.go",
+ Diff: "+func main() {}\n-func Main() {}",
+ },
+ Tokens: trace.TokenUsage{InputTokens: 100, OutputTokens: 50},
+ CostUSD: 0.0123,
+ Context: trace.ContextState{FillPct: 42, Compactions: 1},
+ DurationMs: 1200,
+ },
+ {
+ Number: 2,
+ Phase: trace.PhaseVerify,
+ Verification: &trace.Verification{
+ Status: "success",
+ Output: "ok ",
+ },
+ Tokens: trace.TokenUsage{InputTokens: 80, OutputTokens: 20},
+ CostUSD: 0.0045,
+ Context: trace.ContextState{FillPct: 55, Compactions: 1},
+ },
+ })
+ tr.SourceFile = "demo.ltf"
+ tr.SessionID = "sess-42"
+ tr.Agent = "claude"
+ tr.SourceFormat = "ltf"
+ return tr
+}
+
+func TestBuildHTMLData_NoDataJSONField(t *testing.T) {
+ d := buildHTMLData(sampleTrace())
+ if d.Title == "" {
+ t.Fatal("expected title")
+ }
+ if len(d.Iterations) != 2 {
+ t.Fatalf("expected 2 iterations, got %d", len(d.Iterations))
+ }
+ if d.Summary.TotalIterations != 2 {
+ t.Fatalf("expected summary total 2, got %d", d.Summary.TotalIterations)
+ }
+ // Regression for #14: DataJSON was computed via json.Marshal but never
+ // referenced in the template — the field must stay removed.
+ // (Compile-time: htmlData no longer has DataJSON.)
+}
+
+func TestExportHTML_RendersReplayWithoutUnusedJSON(t *testing.T) {
+ dir := t.TempDir()
+ out := filepath.Join(dir, "replay.html")
+ if err := ExportHTML(sampleTrace(), out); err != nil {
+ t.Fatalf("ExportHTML: %v", err)
+ }
+
+ raw, err := os.ReadFile(out)
+ if err != nil {
+ t.Fatalf("read output: %v", err)
+ }
+ html := string(raw)
+
+ for _, want := range []string{
+ "LoopRelay",
+ "sess-42",
+ "claude",
+ "Iteration 1",
+ "Iteration 2",
+ "src/main.go",
+ "diff-add",
+ "status-pass",
+ "ok <script>alert(1)</script>",
+ } {
+ if !strings.Contains(html, want) {
+ t.Errorf("exported HTML missing %q", want)
+ }
+ }
+
+ // Ensure we did not reintroduce a dumped iterations JSON blob for JS.
+ if strings.Contains(html, `"ActionType"`) || strings.Contains(html, `"PhaseClass"`) {
+ t.Error("exported HTML unexpectedly embeds marshaled iteration JSON (DataJSON dead code)")
+ }
+ if strings.Contains(html, "") {
+ t.Error("verification output was not HTML-escaped")
+ }
+}
+
+func TestPhaseAndStatusClass(t *testing.T) {
+ if got := phaseClass("act"); got != "phase-act" {
+ t.Errorf("phaseClass(act)=%q", got)
+ }
+ if got := phaseClass("unknown"); got != "phase-other" {
+ t.Errorf("phaseClass(unknown)=%q", got)
+ }
+ if got := statusClass("success"); got != "status-pass" {
+ t.Errorf("statusClass(success)=%q", got)
+ }
+ if got := statusClass("fail"); got != "status-fail" {
+ t.Errorf("statusClass(fail)=%q", got)
+ }
+}
+
+func TestRenderDiffHTML(t *testing.T) {
+ got := renderDiffHTML("+added\n-removed\n context")
+ if !strings.Contains(got, `class="diff-add"`) || !strings.Contains(got, "+added") {
+ t.Fatalf("missing add span: %s", got)
+ }
+ if !strings.Contains(got, `class="diff-del"`) || !strings.Contains(got, "-removed") {
+ t.Fatalf("missing del span: %s", got)
+ }
+}