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) + } +}