Files
sanderling/internal/trace/writer_test.go
T
pj 4ee3d97cff refactor(trace): rename Step.Action to Step.NextAction
The trace step's action field is the action chosen FOR THE NEXT iteration
based on observing this step's hierarchy, not the action that produced
this step. Rename Step.Action to Step.NextAction and the JSON tag to
next_action to make causality explicit at the data level.
2026-05-31 15:39:32 +05:30

303 lines
7.9 KiB
Go

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: "spec.ts",
BundleSHA256: "deadbeef",
Platform: "android",
BundleID: "in.okcredit.merchant",
StartedAt: time.Date(2026, 4, 17, 22, 30, 0, 0, time.UTC),
SanderlingVersion: "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 TestWriteMeta_EndedAtRoundTrip(t *testing.T) {
directory := t.TempDir()
writer, err := NewWriter(directory)
if err != nil {
t.Fatal(err)
}
defer writer.Close()
endedAt := time.Date(2026, 4, 17, 22, 31, 0, 0, time.UTC)
meta := Meta{
Seed: 7,
SpecPath: "spec.ts",
Platform: "android",
BundleID: "in.test",
StartedAt: time.Date(2026, 4, 17, 22, 30, 0, 0, time.UTC),
EndedAt: &endedAt,
SanderlingVersion: "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)
}
if !strings.Contains(string(body), `"ended_at": "2026-04-17T22:31:00Z"`) {
t.Errorf("ended_at not in meta.json: %s", body)
}
var got Meta
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if got.EndedAt == nil || !got.EndedAt.Equal(endedAt) {
t.Errorf("EndedAt round-trip wrong: %v", got.EndedAt)
}
}
func TestWriteMeta_OmitsEndedAtWhenNil(t *testing.T) {
directory := t.TempDir()
writer, _ := NewWriter(directory)
defer writer.Close()
if err := writer.WriteMeta(Meta{StartedAt: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
body, _ := os.ReadFile(filepath.Join(directory, "meta.json"))
if strings.Contains(string(body), "ended_at") {
t.Errorf("ended_at should be omitted when nil: %s", body)
}
}
func TestWriteStep_HierarchyAndResidualsRoundTrip(t *testing.T) {
directory := t.TempDir()
writer, _ := NewWriter(directory)
defer writer.Close()
step := Step{
Index: 1,
Timestamp: time.Now().UTC(),
NextAction: &Action{
Kind: "tap",
Selector: "id:next",
ResolvedBounds: &BoundsRecord{X: 10, Y: 20, Width: 100, Height: 50},
TapPoint: &PointRecord{X: 60, Y: 45},
},
Residuals: map[string]json.RawMessage{
"prop1": json.RawMessage(`{"op":"true"}`),
},
}
if err := writer.WriteStep(step); err != nil {
t.Fatal(err)
}
body, _ := os.ReadFile(filepath.Join(directory, "trace.jsonl"))
var got Step
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("bad jsonl: %v\n%s", err, body)
}
if got.NextAction.Selector != "id:next" {
t.Errorf("selector = %q", got.NextAction.Selector)
}
if got.NextAction.ResolvedBounds == nil || got.NextAction.ResolvedBounds.Width != 100 {
t.Errorf("resolved_bounds round-trip wrong: %+v", got.NextAction.ResolvedBounds)
}
if got.NextAction.TapPoint == nil || got.NextAction.TapPoint.X != 60 {
t.Errorf("tap_point round-trip wrong: %+v", got.NextAction.TapPoint)
}
if string(got.Residuals["prop1"]) != `{"op":"true"}` {
t.Errorf("residuals round-trip wrong: %s", got.Residuals["prop1"])
}
}
func TestWriteStep_OmitsEmptyHierarchyAndResiduals(t *testing.T) {
directory := t.TempDir()
writer, _ := NewWriter(directory)
defer writer.Close()
if err := writer.WriteStep(Step{Index: 1}); err != nil {
t.Fatal(err)
}
body, _ := os.ReadFile(filepath.Join(directory, "trace.jsonl"))
if strings.Contains(string(body), "hierarchy") || strings.Contains(string(body), "residuals") {
t.Errorf("empty hierarchy/residuals must omit: %s", body)
}
}
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`),
},
NextAction: &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.NextAction.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
}