mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
refactor: code organization cleanup (#35)
* chore: fix gitignore + decisions doc after web->inspect-ui rename Update web/ references to inspect-ui/ in .gitignore and Makefile. Add decisions.md tracking architectural decisions from code-org discussion. * refactor: rename pkg/spec-api to pkg/spec Aligns the directory name with the npm package name @sanderling/spec. Updates Makefile, package.json directory field, and resolveSpecAPIPath. * refactor(verifier): split bindings.go into types.go + bindings.go Move shared public types (Action, ActionKind, LogEntry, Exception) to types.go. bindings.go retains internal JS runtime wiring only. * refactor(inspect): split runs.go into runs.go, runs_cache.go, runs_decode.go runs.go: types (RunSummary, StepSummary, RunDetail, Run) and Scan. runs_cache.go: Cache type, Open/Step/Detail methods, parseRun, scanSteps. runs_decode.go: readMeta, tallyTrace, decodeStepSummary, validRunID. * refactor: move android_env.go to internal/android/ Extracts Android device/AVD/adb logic into internal/android package. Exports EnsureDevice, AdbReverse, AdbReverseRemove, EnvWithAndroidPlatformTools, AdbBinary. Moves tests to internal/android/android_test.go. cmd/sanderling becomes a thin caller. * refactor: extract test pipeline to internal/testrun/ runTestPipeline logic moves to testrun.Execute. buildDriver, resolveSpecAPIPath, pickFreePort, and the progress logger move to internal/testrun/. cmd/sanderling/test_run.go becomes a thin adapter. Tests follow their code. * ci: update workflow paths after pkg/spec-api -> pkg/spec rename
This commit is contained in:
32 files changed
+870
-745
No files matched your search
+11
-308
@@ -1,15 +1,12 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
@@ -23,17 +20,17 @@ const maxScanTokenSize = 16 * 1024 * 1024
|
||||
// /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"`
|
||||
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
|
||||
@@ -133,297 +130,3 @@ func buildSummary(id string, meta trace.Meta, stepCount, violationCount int) Run
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user