From 7823f53453c127868978fc145141ca562ed613df Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 17:44:52 +0530 Subject: [PATCH] feat(tracecorpus): load recorded runs for offline measures reads a run directory's meta and every step, and refuses a step whose trace_version is not the current one: an older step stores no element depths, so its hierarchy decodes with a nil root and a structural hash over it is the empty string for every screen. Discover walks a tree for the directories holding both meta.json and trace.jsonl. --- internal/tracecorpus/corpus.go | 113 ++++++++++++++++++++++++++ internal/tracecorpus/corpus_test.go | 122 ++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 internal/tracecorpus/corpus.go create mode 100644 internal/tracecorpus/corpus_test.go diff --git a/internal/tracecorpus/corpus.go b/internal/tracecorpus/corpus.go new file mode 100644 index 0000000..623912a --- /dev/null +++ b/internal/tracecorpus/corpus.go @@ -0,0 +1,113 @@ +// Package tracecorpus loads recorded runs for measures that read stored +// traces with no device attached. +package tracecorpus + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/priyanshujain/sanderling/internal/trace" +) + +const maxStepBytes = 64 * 1024 * 1024 + +// Run is one run directory: its meta and every step in file order, including +// the synthetic end-of-run record a finalised liveness obligation lands on. +type Run struct { + Directory string + Meta trace.Meta + Steps []trace.Step +} + +// Load reads a run directory and refuses anything an offline measure cannot +// read. A step written before the format change stores no element depths, so +// its hierarchy decodes with a nil root: a structural hash over it is the +// empty string for every screen, and every run would look identical to every +// other. The refusal has to name the version rather than report that number. +func Load(directory string) (Run, error) { + metaBody, err := os.ReadFile(filepath.Join(directory, "meta.json")) + if err != nil { + return Run{}, fmt.Errorf("read meta: %w", err) + } + var meta trace.Meta + if err := json.Unmarshal(metaBody, &meta); err != nil { + return Run{}, fmt.Errorf("decode meta: %w", err) + } + + file, err := os.Open(filepath.Join(directory, "trace.jsonl")) + if err != nil { + return Run{}, fmt.Errorf("open trace: %w", err) + } + defer file.Close() + + run := Run{Directory: directory, Meta: meta} + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 1024*1024), maxStepBytes) + line := 0 + for scanner.Scan() { + line++ + if len(scanner.Bytes()) == 0 { + continue + } + var step trace.Step + if err := json.Unmarshal(scanner.Bytes(), &step); err != nil { + return Run{}, fmt.Errorf("decode step on line %d: %w", line, err) + } + if step.TraceVersion != trace.TraceVersion { + return Run{}, fmt.Errorf( + "step %d is trace_version %d and an offline measure reads version %d only: "+ + "an older step stores no element depths, no logs and no exceptions, so "+ + "its hierarchy decodes with a nil root and nothing retrofits it", + step.Index, step.TraceVersion, trace.TraceVersion) + } + if step.Hierarchy != nil && len(step.Hierarchy.Elements) > 0 && + step.Hierarchy.Root == nil { + return Run{}, fmt.Errorf( + "step %d stores %d elements and no tree shape, so its hierarchy "+ + "cannot be rebuilt", + step.Index, len(step.Hierarchy.Elements)) + } + run.Steps = append(run.Steps, step) + } + if err := scanner.Err(); err != nil { + return Run{}, fmt.Errorf("read trace: %w", err) + } + if len(run.Steps) == 0 { + return Run{}, fmt.Errorf("trace has no steps") + } + return run, nil +} + +// Discover finds every run directory at or below root, a run directory being +// one holding both meta.json and trace.jsonl. +func Discover(root string) ([]string, error) { + var directories []string + err := filepath.Walk( + root, + func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + return nil + } + if _, statErr := os.Stat(filepath.Join(path, "trace.jsonl")); statErr != nil { + return nil + } + if _, statErr := os.Stat(filepath.Join(path, "meta.json")); statErr != nil { + return nil + } + directories = append(directories, path) + return nil + }, + ) + if err != nil { + return nil, err + } + sort.Strings(directories) + return directories, nil +} diff --git a/internal/tracecorpus/corpus_test.go b/internal/tracecorpus/corpus_test.go new file mode 100644 index 0000000..19bf4fc --- /dev/null +++ b/internal/tracecorpus/corpus_test.go @@ -0,0 +1,122 @@ +package tracecorpus + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/priyanshujain/sanderling/internal/hierarchy" + "github.com/priyanshujain/sanderling/internal/trace" +) + +const oneScreen = `{"attributes": {"text": "Hi", "bounds": "[0,0,10,10]"}, "children": [ + {"attributes": {"text": "child"}, "children": []} + ]}` + +func TestLoadRebuildsTheTreeAStepStored(t *testing.T) { + directory := writeRun(t, oneScreen) + + run, err := Load(directory) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(run.Steps) != 1 { + t.Fatalf("steps = %d, want 1", len(run.Steps)) + } + root := run.Steps[0].Hierarchy.Root + if root == nil { + t.Fatal("stored hierarchy came back with no root, so no selector or hash reads it") + } + if len(root.Children) != 1 || root.Children[0].Text != "child" { + t.Fatalf("rebuilt tree lost its child: %+v", root) + } +} + +func TestLoadRefusesAVersionZeroStepByVersion(t *testing.T) { + directory := writeRun(t, oneScreen) + downgrade(t, filepath.Join(directory, "trace.jsonl")) + + _, err := Load(directory) + if err == nil { + t.Fatal("a version 0 step must be refused, not measured") + } + if !strings.Contains(err.Error(), "trace_version 0") { + t.Fatalf("refusal must name the version, got %q", err) + } +} + +// TestLoadRefusesElementsWithNoStoredShape covers the failure that would be +// silent: a tree that decodes with elements and no root hashes to the empty +// string, which reads as one state shared by every screen. +func TestLoadRefusesElementsWithNoStoredShape(t *testing.T) { + directory := writeRun(t, oneScreen) + path := filepath.Join(directory, "trace.jsonl") + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var step map[string]any + if err := json.Unmarshal(body, &step); err != nil { + t.Fatal(err) + } + hierarchyField := step["hierarchy"].(map[string]any) + delete(hierarchyField, "depths") + rewrite(t, path, step) + + if _, err := Load(directory); err == nil || + !strings.Contains(err.Error(), "cannot be rebuilt") { + t.Fatalf("a shapeless tree must be refused, got %v", err) + } +} + +func writeRun(t *testing.T, dumps ...string) string { + t.Helper() + directory := t.TempDir() + writer, err := trace.NewWriter(directory) + if err != nil { + t.Fatal(err) + } + if err := writer.WriteMeta(trace.Meta{Seed: 7, Platform: "web"}); err != nil { + t.Fatal(err) + } + for index, dump := range dumps { + tree, err := hierarchy.Parse(dump) + if err != nil { + t.Fatal(err) + } + if err := writer.WriteStep(trace.Step{Index: index + 1, Hierarchy: tree}); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return directory +} + +func downgrade(t *testing.T, path string) { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var step map[string]any + if err := json.Unmarshal(body, &step); err != nil { + t.Fatal(err) + } + delete(step, "trace_version") + rewrite(t, path, step) +} + +func rewrite(t *testing.T, path string, step map[string]any) { + t.Helper() + body, err := json.Marshal(step) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(body, '\n'), 0o644); err != nil { + t.Fatal(err) + } +}