From b6a6aeff5f59bf62ac8b73cd130d70df08033c77 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 27 Apr 2026 18:40:05 +0700 Subject: [PATCH] feat(trace): add WriteHTML + Step.HTMLAvailable Per-step HTML lives in /html/step-NNNNN.html so trace.jsonl stays line-greppable on apps with hundreds-of-KB DOMs. --- internal/trace/writer.go | 45 ++++++++++++++++++++------ internal/trace/writer_test.go | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/internal/trace/writer.go b/internal/trace/writer.go index 4dfad4a..0aa4ff0 100644 --- a/internal/trace/writer.go +++ b/internal/trace/writer.go @@ -13,16 +13,17 @@ import ( ) type Step struct { - Index int `json:"step"` - Timestamp time.Time `json:"timestamp"` - Screen string `json:"screen,omitempty"` - Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"` - Action *Action `json:"action,omitempty"` - Exceptions []Exception `json:"exceptions,omitempty"` - Violations []string `json:"violations,omitempty"` - Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"` - Residuals map[string]json.RawMessage `json:"residuals,omitempty"` - Metrics *Metrics `json:"metrics,omitempty"` + Index int `json:"step"` + Timestamp time.Time `json:"timestamp"` + Screen string `json:"screen,omitempty"` + Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"` + Action *Action `json:"action,omitempty"` + Exceptions []Exception `json:"exceptions,omitempty"` + Violations []string `json:"violations,omitempty"` + Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"` + Residuals map[string]json.RawMessage `json:"residuals,omitempty"` + Metrics *Metrics `json:"metrics,omitempty"` + HTMLAvailable bool `json:"html_available,omitempty"` } type Metrics struct { @@ -143,6 +144,30 @@ func (w *Writer) writePNG(name string, png []byte) error { return os.WriteFile(filepath.Join(directory, name), png, 0o644) } +// WriteHTML stores the page HTML for `stepIndex` under `/html/`. Empty +// payloads are skipped so the directory only exists when there is real +// content to render in the inspect UI. +func (w *Writer) WriteHTML(stepIndex int, html []byte) error { + return w.writeHTML(fmt.Sprintf("step-%05d.html", stepIndex), html) +} + +// WriteHTMLAfter writes the post-action HTML, paired with the post-action +// screenshot. +func (w *Writer) WriteHTMLAfter(stepIndex int, html []byte) error { + return w.writeHTML(fmt.Sprintf("step-%05d-after.html", stepIndex), html) +} + +func (w *Writer) writeHTML(name string, html []byte) error { + if len(html) == 0 { + return nil + } + directory := filepath.Join(w.directory, "html") + if err := os.MkdirAll(directory, 0o755); err != nil { + return fmt.Errorf("mkdir html: %w", err) + } + return os.WriteFile(filepath.Join(directory, name), html, 0o644) +} + func (w *Writer) Close() error { w.mutex.Lock() defer w.mutex.Unlock() diff --git a/internal/trace/writer_test.go b/internal/trace/writer_test.go index 454656c..ad39e07 100644 --- a/internal/trace/writer_test.go +++ b/internal/trace/writer_test.go @@ -269,6 +269,65 @@ func TestWriteAfterClose_Errors(t *testing.T) { } } +func TestWriteHTML_WritesToHTMLDir(t *testing.T) { + directory := t.TempDir() + writer, _ := NewWriter(directory) + defer writer.Close() + + html := []byte("hi") + if err := writer.WriteHTML(3, html); err != nil { + t.Fatal(err) + } + if err := writer.WriteHTMLAfter(3, html); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(directory, "html", "step-00003.html")) + if err != nil { + t.Fatalf("step-00003 missing: %v", err) + } + if string(got) != string(html) { + t.Errorf("html bytes wrong: %q", got) + } + if _, err := os.Stat(filepath.Join(directory, "html", "step-00003-after.html")); err != nil { + t.Errorf("step-00003-after missing: %v", err) + } +} + +func TestWriteHTML_EmptyByteSliceIsNoop(t *testing.T) { + directory := t.TempDir() + writer, _ := NewWriter(directory) + defer writer.Close() + + if err := writer.WriteHTML(1, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(directory, "html")); !os.IsNotExist(err) { + t.Errorf("html dir should not exist after empty write") + } +} + +func TestStep_HTMLAvailableRoundTrip(t *testing.T) { + directory := t.TempDir() + writer, _ := NewWriter(directory) + defer writer.Close() + + if err := writer.WriteStep(Step{Index: 1, HTMLAvailable: true}); err != nil { + t.Fatal(err) + } + if err := writer.WriteStep(Step{Index: 2}); err != nil { + t.Fatal(err) + } + body, _ := os.ReadFile(filepath.Join(directory, "trace.jsonl")) + lines := strings.Split(strings.TrimSpace(string(body)), "\n") + if !strings.Contains(lines[0], `"html_available":true`) { + t.Errorf("html_available missing in step 1: %s", lines[0]) + } + if strings.Contains(lines[1], "html_available") { + t.Errorf("html_available should be omitted when false: %s", lines[1]) + } +} + func TestNewWriter_CreatesNestedDirectory(t *testing.T) { parent := t.TempDir() target := filepath.Join(parent, "runs", "2026-04-17T22-30-00")