From 72c4e4c4d324821f3a38242807fb9a80c50ca6f1 Mon Sep 17 00:00:00 2001 From: PJ Date: Fri, 17 Apr 2026 23:43:45 +0700 Subject: [PATCH] feat(trace): JSONL writer for steps + meta + screenshots Each WriteStep appends one JSON object per line so the trace can be filtered with jq directly. Screenshots land under screenshots/ with zero-padded indexes. Writer is concurrency-safe; Close is idempotent and a write after Close errors loudly rather than silently dropping. --- internal/trace/writer.go | 104 ++++++++++++++++++ internal/trace/writer_test.go | 199 ++++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 internal/trace/writer.go create mode 100644 internal/trace/writer_test.go diff --git a/internal/trace/writer.go b/internal/trace/writer.go new file mode 100644 index 0000000..c97847e --- /dev/null +++ b/internal/trace/writer.go @@ -0,0 +1,104 @@ +package trace + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" +) + +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"` + Violations []string `json:"violations,omitempty"` +} + +type Action struct { + Kind string `json:"kind"` + X int `json:"x,omitempty"` + Y int `json:"y,omitempty"` + Text string `json:"text,omitempty"` +} + +type Meta struct { + Seed int64 `json:"seed"` + SpecPath string `json:"spec_path"` + BundleSHA256 string `json:"bundle_sha256"` + Platform string `json:"platform"` + BundleID string `json:"bundle_id"` + StartedAt time.Time `json:"started_at"` + UatuVersion string `json:"uatu_version"` +} + +type Writer struct { + directory string + mutex sync.Mutex + file io.WriteCloser + encoder *json.Encoder +} + +// NewWriter ensures `directory` exists and opens trace.jsonl for append. +// meta.json is written separately via WriteMeta. Caller must Close. +func NewWriter(directory string) (*Writer, error) { + if err := os.MkdirAll(directory, 0o755); err != nil { + return nil, fmt.Errorf("mkdir: %w", err) + } + file, err := os.OpenFile( + filepath.Join(directory, "trace.jsonl"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, + 0o644, + ) + if err != nil { + return nil, fmt.Errorf("open trace.jsonl: %w", err) + } + encoder := json.NewEncoder(file) + return &Writer{directory: directory, file: file, encoder: encoder}, nil +} + +func (w *Writer) Directory() string { return w.directory } + +func (w *Writer) WriteMeta(meta Meta) error { + body, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("marshal meta: %w", err) + } + return os.WriteFile(filepath.Join(w.directory, "meta.json"), body, 0o644) +} + +func (w *Writer) WriteStep(step Step) error { + w.mutex.Lock() + defer w.mutex.Unlock() + if w.file == nil { + return fmt.Errorf("trace: writer is closed") + } + return w.encoder.Encode(step) +} + +func (w *Writer) WriteScreenshot(stepIndex int, png []byte) error { + if len(png) == 0 { + return nil + } + directory := filepath.Join(w.directory, "screenshots") + if err := os.MkdirAll(directory, 0o755); err != nil { + return fmt.Errorf("mkdir screenshots: %w", err) + } + path := filepath.Join(directory, fmt.Sprintf("step-%05d.png", stepIndex)) + return os.WriteFile(path, png, 0o644) +} + +func (w *Writer) Close() error { + w.mutex.Lock() + defer w.mutex.Unlock() + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + return err +} diff --git a/internal/trace/writer_test.go b/internal/trace/writer_test.go new file mode 100644 index 0000000..709dd4e --- /dev/null +++ b/internal/trace/writer_test.go @@ -0,0 +1,199 @@ +package trace + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWriteMeta_RoundTrip(t *testing.T) { + directory := t.TempDir() + writer, err := NewWriter(directory) + if err != nil { + t.Fatal(err) + } + defer writer.Close() + + meta := Meta{ + Seed: 42, + SpecPath: "examples/specs/merchant-ledger.ts", + BundleSHA256: "deadbeef", + Platform: "android", + BundleID: "in.okcredit.merchant", + StartedAt: time.Date(2026, 4, 17, 22, 30, 0, 0, time.UTC), + UatuVersion: "0.0.1", + } + if err := writer.WriteMeta(meta); err != nil { + t.Fatal(err) + } + + body, err := os.ReadFile(filepath.Join(directory, "meta.json")) + if err != nil { + t.Fatal(err) + } + var got Meta + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("meta.json is not valid JSON: %v\n%s", err, body) + } + if got != meta { + t.Errorf("meta round-trip mismatch:\n got: %+v\nwant: %+v", got, meta) + } +} + +func TestWriteStep_AppendsOneJsonLine(t *testing.T) { + directory := t.TempDir() + writer, err := NewWriter(directory) + if err != nil { + t.Fatal(err) + } + defer writer.Close() + + step := Step{ + Index: 1, + Timestamp: time.Now().UTC(), + Screen: "customer_ledger", + Snapshots: map[string]json.RawMessage{ + "ledger.balance": json.RawMessage(`1500`), + }, + Action: &Action{Kind: "tap", X: 100, Y: 200}, + Violations: []string{"ledgerBalanceMatchesTxns"}, + } + if err := writer.WriteStep(step); err != nil { + t.Fatal(err) + } + + lines := readLines(t, filepath.Join(directory, "trace.jsonl")) + if len(lines) != 1 { + t.Fatalf("expected 1 line, got %d", len(lines)) + } + var got Step + if err := json.Unmarshal([]byte(lines[0]), &got); err != nil { + t.Fatalf("invalid JSONL line: %v\n%s", err, lines[0]) + } + if got.Index != 1 || got.Screen != "customer_ledger" || got.Action.X != 100 || got.Violations[0] != "ledgerBalanceMatchesTxns" { + t.Errorf("step round-trip wrong: %+v", got) + } +} + +func TestWriteStep_MultipleStepsAppend(t *testing.T) { + directory := t.TempDir() + writer, err := NewWriter(directory) + if err != nil { + t.Fatal(err) + } + defer writer.Close() + + for index := 1; index <= 3; index++ { + if err := writer.WriteStep(Step{Index: index, Screen: "s"}); err != nil { + t.Fatal(err) + } + } + lines := readLines(t, filepath.Join(directory, "trace.jsonl")) + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d", len(lines)) + } +} + +func TestWriteStep_ViolationsAreGreppable(t *testing.T) { + directory := t.TempDir() + writer, _ := NewWriter(directory) + defer writer.Close() + + _ = writer.WriteStep(Step{Index: 1}) + _ = writer.WriteStep(Step{Index: 2, Violations: []string{"prop1"}}) + _ = writer.WriteStep(Step{Index: 3}) + + body, err := os.ReadFile(filepath.Join(directory, "trace.jsonl")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"violations":["prop1"]`) { + t.Errorf("violations not in expected JSON shape: %s", body) + } +} + +func TestWriteScreenshot_CreatesPaddedFilenames(t *testing.T) { + directory := t.TempDir() + writer, _ := NewWriter(directory) + defer writer.Close() + + pngBytes := []byte{0x89, 0x50, 0x4e, 0x47} + if err := writer.WriteScreenshot(7, pngBytes); err != nil { + t.Fatal(err) + } + if err := writer.WriteScreenshot(2024, pngBytes); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(directory, "screenshots", "step-00007.png")) + if err != nil { + t.Fatalf("step-00007 missing: %v", err) + } + if string(got) != string(pngBytes) { + t.Errorf("screenshot bytes wrong") + } + if _, err := os.Stat(filepath.Join(directory, "screenshots", "step-02024.png")); err != nil { + t.Errorf("step-02024 missing: %v", err) + } +} + +func TestWriteScreenshot_EmptyByteSliceIsNoop(t *testing.T) { + directory := t.TempDir() + writer, _ := NewWriter(directory) + defer writer.Close() + + if err := writer.WriteScreenshot(1, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(directory, "screenshots")); !os.IsNotExist(err) { + t.Errorf("screenshots dir should not exist after empty write") + } +} + +func TestWriteAfterClose_Errors(t *testing.T) { + directory := t.TempDir() + writer, _ := NewWriter(directory) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + err := writer.WriteStep(Step{Index: 1}) + if err == nil || !strings.Contains(err.Error(), "closed") { + t.Errorf("expected closed-writer error, got %v", err) + } +} + +func TestNewWriter_CreatesNestedDirectory(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "runs", "2026-04-17T22-30-00") + writer, err := NewWriter(target) + if err != nil { + t.Fatal(err) + } + defer writer.Close() + if _, err := os.Stat(target); err != nil { + t.Errorf("nested directory was not created: %v", err) + } +} + +func readLines(t *testing.T, path string) []string { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + return lines +}