mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
* refactor: rename Go module path uatu -> sanderling
Module path github.com/priyanshujain/uatu -> github.com/priyanshujain/sanderling,
including all imports and the proto go_package option. Generated .pb.go files
rewritten in-place; safe to regenerate with protoc later.
* chore(proto): regenerate driverpb after module path rename
The previous sed-based module rename corrupted the embedded descriptor
byte lengths. buf generate rewrites them cleanly.
* refactor: rename CLI binary uatu -> sanderling
Updates Makefile target + UATU_BIN var, .goreleaser project/build IDs,
.gitignore comment, and all user-facing strings in the CLI help text,
error messages, and tests. Binary is now bin/sanderling.
* refactor(sdk): rename Kotlin package dev.uatu.sdk -> dev.sanderling.sdk
Moves sdk/android/src/{main,test}/kotlin/dev/uatu -> dev/sanderling and
rewrites package declarations, imports, and the Gradle namespace. Class
names (Uatu, UatuRuntime) are renamed in a follow-up commit.
* refactor(sidecar): rename Kotlin package dev.uatu.sidecar -> dev.sanderling.sidecar
Moves sidecar/src/{main,test}/kotlin/dev/uatu -> dev/sanderling and
rewrites package declarations, imports, and the application mainClass.
* refactor: rename Uatu API surface -> Sanderling
- Kotlin: Uatu -> Sanderling, UatuRuntime -> SanderlingRuntime (+ files).
- JS host binding: globalThis.__uatu__ -> __sanderling__ (Go verifier,
spec-api, tests).
- TS interface: UatuRuntime -> SanderlingRuntime; internal tags
__uatuFormula / __uatuActionGenerator -> __sanderling* variants.
- Go trace: UatuVersion field + uatu_version JSON tag renamed.
- Socket naming: uatu-agent / uatu-agent-reader -> sanderling-agent*.
- Sample app, docs, inline-JS test strings updated to match.
* refactor(examples): rename examples/folio/uatu -> examples/folio/sanderling
Renames the example spec directory; updates justfile paths + gitignore
entries accordingly. Package.json name/description and @uatu/spec
dependency are renamed in the npm + docs commits.
* chore(build): rename gradle property + rootProject.name uatu -> sanderling
- Renames the uatu.version gradle property and all its -P references in
Makefile, build.gradle.kts files, and .github/workflows/release.yml.
- settings.gradle.kts rootProject.name = "sanderling".
- Renames .env.local.example header + release-cli workflow job name.
* refactor(proto): rename proto package uatu.driver.v1 -> sanderling.driver.v1
Updates the proto package and java_package, regenerates driver.pb.go +
driver_grpc.pb.go, rewrites Kotlin imports and the gRPC ServiceName
assertion in driver_test.go.
* refactor: rename npm package @uatu/spec -> @sanderling/spec
Renames package name in pkg/spec-api/package.json + lockfile, all
consumer imports (examples/folio spec, testdata, verifier tests), the
esbuild alias in cmd/sanderling/test_run.go, and related doc references.
* docs: rename uatu -> sanderling in README, docs, and URLs
- README + docs/{manual,development}/*: narrative + GitHub + Pages URLs.
- POM + npm package.json repo/homepage/bugs URLs.
- .gitignore + embed_stub + Makefile-comment references updated to
'make sanderling'.
- Minor narrative comments in cmd/sanderling/test_run.go and
internal/inspect/server.go.
* refactor: rename remaining internal uatu strings -> sanderling
- SANDERLING_TEST_PHONE/OTP env vars (cmd + bundler tests).
- sanderling-sidecar runtime tmp dir + extracted JAR filename.
- Inspect web UI: @sanderling/inspect-web package, title, theme
localStorage key, RunList empty-state copy, uatu_version TS field.
- Sample app storage key sanderling.ledger.v1.
- Test data: sanderling_test AVD name + com.example.sanderling_test.
- Release docs tarball name template.
430 lines
12 KiB
Go
430 lines
12 KiB
Go
package inspect
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/trace"
|
|
)
|
|
|
|
// Maximum size of a single trace.jsonl line. Hierarchies and snapshots
|
|
// can be large; 16 MiB is enough headroom for realistic traces.
|
|
const maxScanTokenSize = 16 * 1024 * 1024
|
|
|
|
// RunSummary is the lightweight per-run record returned by Scan and the
|
|
// /api/runs handler. Keep this in lockstep with the JSON shape consumed
|
|
// by the SPA's run list view.
|
|
type RunSummary struct {
|
|
ID string `json:"id"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
EndedAt *time.Time `json:"ended_at,omitempty"`
|
|
SpecPath string `json:"spec_path"`
|
|
Seed int64 `json:"seed"`
|
|
Platform string `json:"platform"`
|
|
BundleID string `json:"bundle_id"`
|
|
DurationMillis int64 `json:"duration_millis"`
|
|
StepCount int `json:"step_count"`
|
|
ViolationCount int `json:"violation_count"`
|
|
InProgress bool `json:"in_progress"`
|
|
}
|
|
|
|
// StepSummary is the slim per-step record used to render the step list
|
|
// and timeline. Heavy fields (hierarchy, snapshots, residuals) are sent
|
|
// only via the per-step endpoint.
|
|
type StepSummary struct {
|
|
Index int `json:"index"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Screen string `json:"screen,omitempty"`
|
|
ActionKind string `json:"action_kind,omitempty"`
|
|
ActionLabel string `json:"action_label,omitempty"`
|
|
HasViolations bool `json:"has_violations"`
|
|
HasExceptions bool `json:"has_exceptions"`
|
|
}
|
|
|
|
// RunDetail is the full /api/runs/{id} payload: meta + slim step list.
|
|
type RunDetail struct {
|
|
RunSummary
|
|
Meta trace.Meta `json:"meta"`
|
|
Steps []StepSummary `json:"steps"`
|
|
}
|
|
|
|
// Run is a cached parse of one run directory. Step lookups re-read the
|
|
// JSONL file from disk; only the line offsets are kept in memory.
|
|
type Run struct {
|
|
ID string
|
|
Directory string
|
|
Meta trace.Meta
|
|
Summary RunSummary
|
|
Steps []StepSummary
|
|
tracePath string
|
|
traceMtime time.Time
|
|
offsets []int64
|
|
}
|
|
|
|
// IsRunDirectory reports whether dir looks like a single run (has meta.json).
|
|
func IsRunDirectory(directory string) bool {
|
|
_, err := os.Stat(filepath.Join(directory, "meta.json"))
|
|
return err == nil
|
|
}
|
|
|
|
// Scan walks runsDirectory and returns one RunSummary per child directory
|
|
// that contains meta.json. Results are sorted by StartedAt descending.
|
|
func Scan(runsDirectory string) ([]RunSummary, error) {
|
|
entries, err := os.ReadDir(runsDirectory)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return []RunSummary{}, nil
|
|
}
|
|
return nil, fmt.Errorf("read runs dir: %w", err)
|
|
}
|
|
summaries := make([]RunSummary, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
runDirectory := filepath.Join(runsDirectory, entry.Name())
|
|
summary, err := summarize(runDirectory, entry.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
summaries = append(summaries, summary)
|
|
}
|
|
sort.Slice(summaries, func(i, j int) bool {
|
|
return summaries[i].StartedAt.After(summaries[j].StartedAt)
|
|
})
|
|
return summaries, nil
|
|
}
|
|
|
|
func summarize(runDirectory, id string) (RunSummary, error) {
|
|
meta, err := readMeta(runDirectory)
|
|
if err != nil {
|
|
return RunSummary{}, err
|
|
}
|
|
stepCount, violationCount, err := tallyTrace(filepath.Join(runDirectory, "trace.jsonl"))
|
|
if err != nil {
|
|
return RunSummary{}, err
|
|
}
|
|
return buildSummary(id, meta, stepCount, violationCount), nil
|
|
}
|
|
|
|
func buildSummary(id string, meta trace.Meta, stepCount, violationCount int) RunSummary {
|
|
summary := RunSummary{
|
|
ID: id,
|
|
StartedAt: meta.StartedAt,
|
|
EndedAt: meta.EndedAt,
|
|
SpecPath: meta.SpecPath,
|
|
Seed: meta.Seed,
|
|
Platform: meta.Platform,
|
|
BundleID: meta.BundleID,
|
|
StepCount: stepCount,
|
|
ViolationCount: violationCount,
|
|
InProgress: meta.EndedAt == nil,
|
|
}
|
|
if meta.EndedAt != nil {
|
|
summary.DurationMillis = meta.EndedAt.Sub(meta.StartedAt).Milliseconds()
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func readMeta(runDirectory string) (trace.Meta, error) {
|
|
body, err := os.ReadFile(filepath.Join(runDirectory, "meta.json"))
|
|
if err != nil {
|
|
return trace.Meta{}, fmt.Errorf("read meta: %w", err)
|
|
}
|
|
var meta trace.Meta
|
|
if err := json.Unmarshal(body, &meta); err != nil {
|
|
return trace.Meta{}, fmt.Errorf("decode meta: %w", err)
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
func tallyTrace(tracePath string) (steps, violations int, err error) {
|
|
file, err := os.Open(tracePath)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return 0, 0, nil
|
|
}
|
|
return 0, 0, fmt.Errorf("open trace: %w", err)
|
|
}
|
|
defer file.Close()
|
|
scanner := bufio.NewScanner(file)
|
|
scanner.Buffer(make([]byte, 64*1024), maxScanTokenSize)
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var partial struct {
|
|
Violations []string `json:"violations,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(line, &partial); err != nil {
|
|
return 0, 0, fmt.Errorf("decode step: %w", err)
|
|
}
|
|
steps++
|
|
violations += len(partial.Violations)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return 0, 0, fmt.Errorf("scan trace: %w", err)
|
|
}
|
|
return steps, violations, nil
|
|
}
|
|
|
|
// Cache holds parsed Run records keyed by id. Open returns a fresh parse
|
|
// when the underlying trace.jsonl mtime changes.
|
|
type Cache struct {
|
|
root string
|
|
mutex sync.Mutex
|
|
runs map[string]*Run
|
|
}
|
|
|
|
func NewCache(runsDirectory string) *Cache {
|
|
return &Cache{root: runsDirectory, runs: map[string]*Run{}}
|
|
}
|
|
|
|
func (c *Cache) Root() string { return c.root }
|
|
|
|
// Open parses (or returns a cached parse of) the run named id.
|
|
func (c *Cache) Open(id string) (*Run, error) {
|
|
if !validRunID(id) {
|
|
return nil, fs.ErrNotExist
|
|
}
|
|
runDirectory := filepath.Join(c.root, id)
|
|
tracePath := filepath.Join(runDirectory, "trace.jsonl")
|
|
traceInfo, traceErr := os.Stat(tracePath)
|
|
|
|
c.mutex.Lock()
|
|
defer c.mutex.Unlock()
|
|
if cached, ok := c.runs[id]; ok {
|
|
if traceErr == nil && cached.traceMtime.Equal(traceInfo.ModTime()) {
|
|
return cached, nil
|
|
}
|
|
}
|
|
run, err := parseRun(runDirectory, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.runs[id] = run
|
|
return run, nil
|
|
}
|
|
|
|
func parseRun(runDirectory, id string) (*Run, error) {
|
|
meta, err := readMeta(runDirectory)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tracePath := filepath.Join(runDirectory, "trace.jsonl")
|
|
steps, offsets, violationCount, traceMtime, err := scanSteps(tracePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
summary := buildSummary(id, meta, len(steps), violationCount)
|
|
return &Run{
|
|
ID: id,
|
|
Directory: runDirectory,
|
|
Meta: meta,
|
|
Summary: summary,
|
|
Steps: steps,
|
|
tracePath: tracePath,
|
|
traceMtime: traceMtime,
|
|
offsets: offsets,
|
|
}, nil
|
|
}
|
|
|
|
func scanSteps(tracePath string) ([]StepSummary, []int64, int, time.Time, error) {
|
|
file, err := os.Open(tracePath)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return []StepSummary{}, nil, 0, time.Time{}, nil
|
|
}
|
|
return nil, nil, 0, time.Time{}, fmt.Errorf("open trace: %w", err)
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return nil, nil, 0, time.Time{}, fmt.Errorf("stat trace: %w", err)
|
|
}
|
|
reader := bufio.NewReaderSize(file, 64*1024)
|
|
steps := []StepSummary{}
|
|
offsets := []int64{}
|
|
violationCount := 0
|
|
var offset int64
|
|
for {
|
|
lineStart := offset
|
|
line, err := reader.ReadBytes('\n')
|
|
offset += int64(len(line))
|
|
trimmed := line
|
|
if len(trimmed) > 0 && trimmed[len(trimmed)-1] == '\n' {
|
|
trimmed = trimmed[:len(trimmed)-1]
|
|
}
|
|
if len(trimmed) > 0 {
|
|
summary, partial, decodeErr := decodeStepSummary(trimmed)
|
|
if decodeErr != nil {
|
|
return nil, nil, 0, time.Time{}, decodeErr
|
|
}
|
|
steps = append(steps, summary)
|
|
offsets = append(offsets, lineStart)
|
|
violationCount += partial
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
return steps, offsets, violationCount, info.ModTime(), nil
|
|
}
|
|
|
|
func decodeStepSummary(line []byte) (StepSummary, int, error) {
|
|
var partial struct {
|
|
Index int `json:"step"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Screen string `json:"screen,omitempty"`
|
|
Action *struct {
|
|
Kind string `json:"kind"`
|
|
X int `json:"x,omitempty"`
|
|
Y int `json:"y,omitempty"`
|
|
FromX int `json:"from_x,omitempty"`
|
|
FromY int `json:"from_y,omitempty"`
|
|
ToX int `json:"to_x,omitempty"`
|
|
ToY int `json:"to_y,omitempty"`
|
|
Key string `json:"key,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Selector string `json:"selector,omitempty"`
|
|
DurationMillis int `json:"duration_millis,omitempty"`
|
|
} `json:"action,omitempty"`
|
|
Exceptions []json.RawMessage `json:"exceptions,omitempty"`
|
|
Violations []string `json:"violations,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(line, &partial); err != nil {
|
|
return StepSummary{}, 0, fmt.Errorf("decode step: %w", err)
|
|
}
|
|
summary := StepSummary{
|
|
Index: partial.Index,
|
|
Timestamp: partial.Timestamp,
|
|
Screen: partial.Screen,
|
|
HasViolations: len(partial.Violations) > 0,
|
|
HasExceptions: len(partial.Exceptions) > 0,
|
|
}
|
|
if partial.Action != nil {
|
|
summary.ActionKind = partial.Action.Kind
|
|
switch partial.Action.Kind {
|
|
case "Tap":
|
|
if partial.Action.Selector != "" {
|
|
summary.ActionLabel = partial.Action.Selector
|
|
} else if partial.Action.Text != "" {
|
|
summary.ActionLabel = partial.Action.Text
|
|
} else if partial.Action.X != 0 || partial.Action.Y != 0 {
|
|
summary.ActionLabel = fmt.Sprintf("(%d,%d)", partial.Action.X, partial.Action.Y)
|
|
}
|
|
case "InputText":
|
|
summary.ActionLabel = fmt.Sprintf("%q", partial.Action.Text)
|
|
case "Swipe":
|
|
summary.ActionLabel = swipeDirectionLabel(
|
|
partial.Action.FromX, partial.Action.FromY,
|
|
partial.Action.ToX, partial.Action.ToY,
|
|
)
|
|
case "PressKey":
|
|
summary.ActionLabel = partial.Action.Key
|
|
case "Wait":
|
|
if partial.Action.DurationMillis > 0 {
|
|
summary.ActionLabel = fmt.Sprintf("%dms", partial.Action.DurationMillis)
|
|
}
|
|
}
|
|
}
|
|
return summary, len(partial.Violations), nil
|
|
}
|
|
|
|
func swipeDirectionLabel(fromX, fromY, toX, toY int) string {
|
|
dx := toX - fromX
|
|
dy := toY - fromY
|
|
absX := dx
|
|
if absX < 0 {
|
|
absX = -absX
|
|
}
|
|
absY := dy
|
|
if absY < 0 {
|
|
absY = -absY
|
|
}
|
|
if absY >= absX {
|
|
if dy < 0 {
|
|
return "up"
|
|
}
|
|
return "down"
|
|
}
|
|
if dx < 0 {
|
|
return "left"
|
|
}
|
|
return "right"
|
|
}
|
|
|
|
// Step decodes the full Step record at index n (1-based, matching trace.Step.Index).
|
|
func (c *Cache) Step(run *Run, index int) (trace.Step, error) {
|
|
position := -1
|
|
for i, summary := range run.Steps {
|
|
if summary.Index == index {
|
|
position = i
|
|
break
|
|
}
|
|
}
|
|
if position == -1 {
|
|
return trace.Step{}, fs.ErrNotExist
|
|
}
|
|
file, err := os.Open(run.tracePath)
|
|
if err != nil {
|
|
return trace.Step{}, fmt.Errorf("open trace: %w", err)
|
|
}
|
|
defer file.Close()
|
|
if _, err := file.Seek(run.offsets[position], 0); err != nil {
|
|
return trace.Step{}, fmt.Errorf("seek trace: %w", err)
|
|
}
|
|
reader := bufio.NewReaderSize(file, 64*1024)
|
|
line, err := reader.ReadBytes('\n')
|
|
if err != nil && len(line) == 0 {
|
|
return trace.Step{}, fmt.Errorf("read step line: %w", err)
|
|
}
|
|
if len(line) > 0 && line[len(line)-1] == '\n' {
|
|
line = line[:len(line)-1]
|
|
}
|
|
var step trace.Step
|
|
if err := json.Unmarshal(line, &step); err != nil {
|
|
return trace.Step{}, fmt.Errorf("decode step: %w", err)
|
|
}
|
|
return step, nil
|
|
}
|
|
|
|
// Detail returns the /api/runs/{id} payload.
|
|
func (c *Cache) Detail(id string) (RunDetail, error) {
|
|
run, err := c.Open(id)
|
|
if err != nil {
|
|
return RunDetail{}, err
|
|
}
|
|
return RunDetail{
|
|
RunSummary: run.Summary,
|
|
Meta: run.Meta,
|
|
Steps: run.Steps,
|
|
}, nil
|
|
}
|
|
|
|
func validRunID(id string) bool {
|
|
if id == "" || id == "." || id == ".." {
|
|
return false
|
|
}
|
|
for _, r := range id {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
case r >= 'A' && r <= 'Z':
|
|
case r >= '0' && r <= '9':
|
|
case r == '-' || r == '_' || r == '.':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|