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:
pj authored and GitHub committed 2026-04-22 20:35:34 +07:00
1 parent 46abb28ed6
commit eed99e58aa
32 files changed
+857 -732

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
with:
node-version: "22"
cache: npm
cache-dependency-path: pkg/spec-api/package-lock.json
cache-dependency-path: pkg/spec/package-lock.json
- name: Set up bun
uses: oven-sh/setup-bun@v2
+4 -4
View File
@@ -79,18 +79,18 @@ jobs:
node-version: "22"
registry-url: "https://registry.npmjs.org"
cache: npm
cache-dependency-path: pkg/spec-api/package-lock.json
cache-dependency-path: pkg/spec/package-lock.json
- name: Install dependencies
working-directory: pkg/spec-api
working-directory: pkg/spec
run: npm ci
- name: Stamp version
working-directory: pkg/spec-api
working-directory: pkg/spec
run: npm version ${{ steps.ver.outputs.version }} --no-git-tag-version --allow-same-version
- name: Publish
working-directory: pkg/spec-api
working-directory: pkg/spec
# npm tag pre-releases (e.g. 0.1.0-rc1) as "next" so npm install @sanderling/spec
# keeps resolving the latest stable.
run: |
+4 -4
View File
@@ -32,7 +32,7 @@ node_modules/
internal/sidecar/assets/sidecar-all.jar
# spec-api compiled output
pkg/spec-api/dist/
pkg/spec/dist/
# goreleaser local output
/dist/
@@ -44,9 +44,9 @@ pkg/spec-api/dist/
!/internal/inspect/dist/index.html
# inspect web frontend
web/node_modules/
web/dist/
web/.vite/
inspect-ui/node_modules/
inspect-ui/dist/
inspect-ui/.vite/
# d2 diagrams render into build/site/_assets/diagrams; keep sources only
/docs/_diagrams/*.svg
+3 -3
View File
@@ -25,7 +25,7 @@ WEB_DIST := inspect-ui/dist
bootstrap:
$(GO) mod download
$(BUF) generate
cd pkg/spec-api && npm install --silent
cd pkg/spec && npm install --silent
proto:
$(BUF) lint
@@ -82,7 +82,7 @@ test-kotlin:
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test :sdk-android:testDebugUnitTest
test-spec-api:
cd pkg/spec-api && npm test --silent
cd pkg/spec && npm test --silent
docs: $(DOCS_OUT) build/site/_assets $(DIAGRAM_OUT)
@echo "built $(words $(DOCS_OUT)) pages, $(words $(DIAGRAM_OUT)) diagrams to build/site"
@@ -118,4 +118,4 @@ release-android-local:
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:publishToMavenLocal -Psanderling.version=0.0.0-local
release-npm-dry:
cd pkg/spec-api && npm ci && npm run build && npm pack --dry-run
cd pkg/spec && npm ci && npm run build && npm pack --dry-run
+8 -277
View File
@@ -2,288 +2,19 @@ package main
import (
"context"
"fmt"
"io"
"math/rand/v2"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
"github.com/priyanshujain/sanderling/internal/agent"
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/driver/chrome"
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
"github.com/priyanshujain/sanderling/internal/runner"
"github.com/priyanshujain/sanderling/internal/sidecar"
"github.com/priyanshujain/sanderling/internal/trace"
"github.com/priyanshujain/sanderling/internal/verifier"
)
const (
socketName = "sanderling-agent"
sidecarStartupTimeout = 30 * time.Second
sdkAcceptTimeout = 60 * time.Second
"github.com/priyanshujain/sanderling/internal/testrun"
)
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
if options.platform == "android" || options.platform == "ios" {
if err := ensureDevice(ctx, options.avd, stdout); err != nil {
return err
}
}
aliases := map[string]string{}
if specApiPath := resolveSpecAPIPath(options.spec); specApiPath != "" {
aliases["@sanderling/spec"] = specApiPath
base := filepath.Dir(specApiPath)
aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts")
}
bundle, err := bundler.Bundle(bundler.Options{
EntryFile: options.spec,
Defines: map[string]string{
"SANDERLING_TEST_PHONE": os.Getenv("SANDERLING_TEST_PHONE"),
"SANDERLING_TEST_OTP": os.Getenv("SANDERLING_TEST_OTP"),
},
Aliases: aliases,
})
if err != nil {
return fmt.Errorf("bundle spec: %w", err)
}
fmt.Fprintf(stdout, "bundled spec: %d bytes (sha256=%s)\n", len(bundle.JavaScript), bundle.SHA256[:12])
activeDriver, cleanup, err := buildDriver(ctx, options, stdout)
if err != nil {
return err
}
defer cleanup()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("agent listener: %w", err)
}
defer listener.Close()
agentPort := listener.Addr().(*net.TCPAddr).Port
if options.platform != "web" {
if err := adbReverse(socketName, agentPort); err != nil {
return fmt.Errorf("adb reverse: %w", err)
}
defer func() {
if err := adbReverseRemove(socketName); err != nil {
fmt.Fprintf(stdout, "warning: adb reverse cleanup: %v\n", err)
}
}()
fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort)
}
agentServer := agent.NewServer(listener)
type acceptResult struct {
connection *agent.Conn
err error
}
acceptChannel := make(chan acceptResult, 1)
go func() {
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
defer cancel()
connection, acceptErr := agentServer.Accept(acceptCtx)
acceptChannel <- acceptResult{connection: connection, err: acceptErr}
}()
if err := activeDriver.Launch(ctx, options.bundleID, false); err != nil {
return fmt.Errorf("launch app: %w", err)
}
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.bundleID, sdkAcceptTimeout.Seconds())
result := <-acceptChannel
if result.err != nil {
return fmt.Errorf("accept SDK: %w", result.err)
}
connection := result.connection
defer connection.Close()
hello := connection.Hello()
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
seed := options.seed
if seed == 0 {
seed = time.Now().UnixNano()
}
verifierInstance, err := verifier.New(verifier.WithRand(rand.New(rand.NewPCG(uint64(seed), 0))))
if err != nil {
return fmt.Errorf("verifier: %w", err)
}
if err := verifierInstance.Load(string(bundle.JavaScript)); err != nil {
return fmt.Errorf("load spec: %w", err)
}
fmt.Fprintln(stdout, "spec loaded into verifier")
runDirectory := filepath.Join(options.output, time.Now().UTC().Format("20060102-150405"))
traceWriter, err := trace.NewWriter(runDirectory)
if err != nil {
return fmt.Errorf("trace writer: %w", err)
}
defer traceWriter.Close()
meta := trace.Meta{
Seed: seed,
SpecPath: options.spec,
BundleSHA256: bundle.SHA256,
return testrun.Execute(ctx, testrun.Options{
Spec: options.spec,
BundleID: options.bundleID,
Platform: options.platform,
BundleID: options.bundleID,
StartedAt: time.Now().UTC(),
SanderlingVersion: "0.0.1",
}
if err := traceWriter.WriteMeta(meta); err != nil {
return fmt.Errorf("trace meta: %w", err)
}
defer func() {
endedAt := time.Now().UTC()
meta.EndedAt = &endedAt
_ = traceWriter.WriteMeta(meta)
}()
fmt.Fprintf(stdout, "trace dir: %s\n", runDirectory)
fmt.Fprintf(stdout, "running for %s (seed=%d)\n", options.duration, seed)
summary, err := runner.Run(ctx, runner.Options{
AVD: options.avd,
Duration: options.duration,
SnapshotTimeout: 5 * time.Second,
IdleTimeout: 1 * time.Second,
BundleID: options.bundleID,
Connection: connection,
Driver: activeDriver,
Verifier: verifierInstance,
TraceWriter: traceWriter,
Logger: newProgressLogger(stdout),
})
terminateCtx, terminateCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = activeDriver.Terminate(terminateCtx)
terminateCancel()
if err != nil {
return fmt.Errorf("runner: %w", err)
}
fmt.Fprintf(stdout, "\nrun complete: %d steps in %s\n", summary.Steps, summary.EndTime.Sub(summary.StartTime).Round(time.Millisecond))
if len(summary.Violations) == 0 {
fmt.Fprintln(stdout, "no violations.")
} else {
fmt.Fprintf(stdout, "%d violation record(s):\n", len(summary.Violations))
for _, violation := range summary.Violations {
fmt.Fprintf(stdout, " step %d: %v\n", violation.StepIndex, violation.Properties)
}
}
return nil
}
// buildDriver creates the appropriate DeviceDriver for the platform and returns
// a cleanup function. For web, ChromeDriver is used directly; for android/ios
// the JVM sidecar is extracted, spawned, and dialed.
func buildDriver(ctx context.Context, options testOptions, stdout io.Writer) (driver.DeviceDriver, func(), error) {
if options.platform == "web" {
d := chrome.New()
return d, func() { _ = d.Terminate(context.Background()) }, nil
}
sidecarDirectory := filepath.Join(os.TempDir(), "sanderling-sidecar")
jarPath, err := sidecar.Extract(sidecarDirectory)
if err != nil {
return nil, nil, fmt.Errorf("extract sidecar: %w", err)
}
fmt.Fprintf(stdout, "sidecar JAR: %s (size=%d)\n", jarPath, sidecar.EmbeddedSize())
sidecarPort, err := pickFreePort()
if err != nil {
return nil, nil, err
}
sidecarCommand := exec.CommandContext(ctx, "java", "-jar", jarPath,
"--port", strconv.Itoa(sidecarPort),
"--platform", options.platform,
)
sidecarCommand.Stdout = stdout
sidecarCommand.Stderr = stdout
sidecarCommand.Env = envWithAndroidPlatformTools(os.Environ())
if err := sidecarCommand.Start(); err != nil {
return nil, nil, fmt.Errorf("spawn sidecar: %w", err)
}
fmt.Fprintf(stdout, "sidecar pid=%d listening on 127.0.0.1:%d\n", sidecarCommand.Process.Pid, sidecarPort)
driverClient, err := driverSidecar.Dial(fmt.Sprintf("127.0.0.1:%d", sidecarPort))
if err != nil {
_ = sidecarCommand.Process.Kill()
return nil, nil, fmt.Errorf("dial sidecar: %w", err)
}
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
if err := driverClient.WaitForHealth(healthCtx, 250*time.Millisecond); err != nil {
healthCancel()
_ = sidecarCommand.Process.Kill()
_ = driverClient.Close()
return nil, nil, fmt.Errorf("sidecar health check: %w", err)
}
healthCancel()
fmt.Fprintln(stdout, "sidecar is healthy")
cleanup := func() {
_ = driverClient.Close()
if sidecarCommand.Process != nil {
_ = sidecarCommand.Process.Kill()
}
}
return driverClient, cleanup, nil
}
// resolveSpecAPIPath returns the path to pkg/spec-api/src/index.ts inside
// a sanderling source checkout, searched upward from the spec file and the cwd.
// Returns "" when not found, in which case esbuild resolves @sanderling/spec via
// node_modules the way a downstream user's project would.
func resolveSpecAPIPath(specPath string) string {
candidates := []string{}
if absoluteSpec, err := filepath.Abs(specPath); err == nil {
directory := filepath.Dir(absoluteSpec)
for {
candidates = append(candidates, filepath.Join(directory, "pkg/spec-api/src/index.ts"))
parent := filepath.Dir(directory)
if parent == directory {
break
}
directory = parent
}
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "pkg/spec-api/src/index.ts"))
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}
func pickFreePort() (int, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port, nil
}
func adbReverse(socket string, port int) error {
adb, err := adbBinary()
if err != nil {
return err
}
command := exec.Command(adb, "reverse", "localabstract:"+socket, fmt.Sprintf("tcp:%d", port))
return command.Run()
}
func adbReverseRemove(socket string) error {
adb, err := adbBinary()
if err != nil {
return err
}
return exec.Command(adb, "reverse", "--remove", "localabstract:"+socket).Run()
Seed: options.seed,
Output: options.output,
}, stdout)
}
+57
View File
@@ -0,0 +1,57 @@
---
title: Decisions
---
# Decisions
Architectural and organizational decisions worth recording. Each entry states the decision and the reasoning.
---
## Directory and Package Organization
### `web/` renamed to `inspect-ui/`
The directory containing the React/TypeScript frontend is `inspect-ui/`, not `web/`. The name `web/` was ambiguous (the project also has a web/Chrome driver target). `inspect-ui/` makes the purpose explicit: this is the UI for the `sanderling inspect` command.
### Keep `internal/`
Go's `internal/` directory restriction prevents any code outside this module from importing these packages. Sanderling is a CLI tool today, but the restriction costs nothing to keep and prevents accidental coupling if the module is ever used as a Go dependency. All implementation packages live under `internal/`.
### `internal/driver/` is an interface + subdirectory implementations
The `driver.go` file defines the `DeviceDriver` interface. Concrete implementations live in subdirectories: `sidecar/` (Maestro gRPC), `chrome/` (CDP), `mock/` (tests). This pattern keeps the runner and verifier decoupled from any specific platform.
### `internal/verifier/marshal.go` moves to `internal/inspect/`
`marshal.go` serializes LTL formulas to JSON for the inspect UI. That is an inspect concern, not a verifier concern. Verifier should not know inspect exists.
### `internal/verifier/bindings.go` splits into `types.go` + `bindings.go`
`bindings.go` currently holds shared types (`Action`, `ActionKind`, `LogEntry`, `Exception`) alongside JavaScript runtime wiring. The types half moves to `types.go` so the two concerns are separately navigable.
### `internal/permissions/` stays as-is
Android-only package but there is no iOS equivalent yet. Revisit if iOS gets similar permission setup.
---
### `cmd/sanderling/android_env.go` moves to `internal/android/`
Android device enumeration, AVD selection, and emulator boot logic moves to `internal/android/`. This keeps `cmd/sanderling/` as a thin CLI wrapper and makes the Android logic independently testable.
### `cmd/sanderling/test_run.go` logic moves to `internal/testrun/`
Driver setup, agent connection, verifier init, trace setup, and runner orchestration extract to `internal/testrun/`. `cmd/sanderling/` wires CLI flags to `testrun` calls and nothing more.
### `internal/inspect/runs.go` splits into multiple files
429 LOC with mixed concerns (cache, file I/O, JSON decoding, summary types) splits into at least `runs_cache.go` and `runs_decode.go` within the same package.
### `cmd/internal-tools/` stays in `cmd/`
`bundle-check` and `hier-check` are dev/debug binaries. Leave them under `cmd/` for now.
### `pkg/spec-api/` renamed to `pkg/spec/`
Aligns the directory name with the npm package name `@sanderling/spec`.
@@ -1,4 +1,4 @@
package main
package android
import (
"bufio"
@@ -13,13 +13,13 @@ import (
"time"
)
// ensureDevice makes sure an Android device is ready for adb commands.
// EnsureDevice makes sure an Android device is ready for adb commands.
// Resolution order:
// - if an adb device is already online, use it;
// - else if avdName is set, validate and boot it;
// - else if exactly one AVD exists locally, boot it;
// - else fail with a helpful message listing the available AVDs.
func ensureDevice(ctx context.Context, avdName string, stdout io.Writer) error {
func EnsureDevice(ctx context.Context, avdName string, stdout io.Writer) error {
devices, err := listAdbDevices(ctx)
if err != nil {
return fmt.Errorf("list adb devices: %w", err)
@@ -47,25 +47,97 @@ func ensureDevice(ctx context.Context, avdName string, stdout io.Writer) error {
return nil
}
func pickAVD(requested string, available []string) (string, error) {
if requested != "" {
if !slices.Contains(available, requested) {
return "", fmt.Errorf("AVD %q does not exist (available: %s)", requested, strings.Join(available, ", "))
// AdbReverse sets up adb reverse forwarding for a local abstract socket.
func AdbReverse(socket string, port int) error {
adb, err := AdbBinary()
if err != nil {
return err
}
return requested, nil
command := exec.Command(adb, "reverse", "localabstract:"+socket, fmt.Sprintf("tcp:%d", port))
return command.Run()
}
switch len(available) {
case 0:
return "", fmt.Errorf("no android device connected and no AVD found; create one in Android Studio or `avdmanager create avd`")
case 1:
return available[0], nil
default:
return "", fmt.Errorf("no android device connected and multiple AVDs available (%s); pick one with --avd", strings.Join(available, ", "))
// AdbReverseRemove removes an adb reverse forwarding rule.
func AdbReverseRemove(socket string) error {
adb, err := AdbBinary()
if err != nil {
return err
}
return exec.Command(adb, "reverse", "--remove", "localabstract:"+socket).Run()
}
// EnvWithAndroidPlatformTools returns env with the directory containing adb
// prepended to PATH, so child processes (the sidecar) can invoke adb even
// when the user hasn't set up their shell PATH.
func EnvWithAndroidPlatformTools(env []string) []string {
adb, err := AdbBinary()
if err != nil {
return env
}
adbDir := filepath.Dir(adb)
result := make([]string, 0, len(env))
found := false
for _, entry := range env {
if current, ok := strings.CutPrefix(entry, "PATH="); ok {
if !pathContains(current, adbDir) {
entry = "PATH=" + adbDir + string(os.PathListSeparator) + current
}
found = true
}
result = append(result, entry)
}
if !found {
result = append(result, "PATH="+adbDir)
}
return result
}
// AdbBinary locates the adb binary via PATH or known Android SDK locations.
func AdbBinary() (string, error) { return findAndroidTool("adb", "platform-tools") }
func emulatorBinary() (string, error) { return findAndroidTool("emulator", "emulator") }
// findAndroidTool locates a binary from the Android SDK. It checks PATH,
// then $ANDROID_HOME/<subdir>/<name> and $ANDROID_SDK_ROOT/<subdir>/<name>,
// then the canonical install locations used by Android Studio and Homebrew.
func findAndroidTool(name, subdir string) (string, error) {
if path, err := exec.LookPath(name); err == nil {
return path, nil
}
var tried []string
for _, root := range androidSDKCandidates() {
candidate := filepath.Join(root, subdir, name)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
tried = append(tried, candidate)
}
return "", fmt.Errorf("could not locate %q: not on PATH and not under any known Android SDK root (set $ANDROID_HOME to point at your SDK; tried %v)", name, tried)
}
func androidSDKCandidates() []string {
var roots []string
seen := map[string]bool{}
addRoot := func(path string) {
if path == "" || seen[path] {
return
}
seen[path] = true
roots = append(roots, path)
}
addRoot(os.Getenv("ANDROID_HOME"))
addRoot(os.Getenv("ANDROID_SDK_ROOT"))
if home, err := os.UserHomeDir(); err == nil {
addRoot(filepath.Join(home, "Library", "Android", "sdk"))
addRoot(filepath.Join(home, "Android", "Sdk"))
}
addRoot("/opt/homebrew/share/android-commandlinetools")
addRoot("/usr/local/share/android-commandlinetools")
return roots
}
func listAdbDevices(ctx context.Context) ([]string, error) {
adb, err := adbBinary()
adb, err := AdbBinary()
if err != nil {
return nil, err
}
@@ -104,50 +176,6 @@ func listAVDs(ctx context.Context) ([]string, error) {
return parseAVDList(string(output)), nil
}
func adbBinary() (string, error) { return findAndroidTool("adb", "platform-tools") }
func emulatorBinary() (string, error) { return findAndroidTool("emulator", "emulator") }
// findAndroidTool locates a binary from the Android SDK. It checks PATH,
// then $ANDROID_HOME/<subdir>/<name> and $ANDROID_SDK_ROOT/<subdir>/<name>,
// then the canonical install locations used by Android Studio and Homebrew.
// Returns a descriptive error when nothing works, so the user knows to set
// $ANDROID_HOME instead of getting a raw "executable file not found".
func findAndroidTool(name, subdir string) (string, error) {
if path, err := exec.LookPath(name); err == nil {
return path, nil
}
var tried []string
for _, root := range androidSDKCandidates() {
candidate := filepath.Join(root, subdir, name)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
tried = append(tried, candidate)
}
return "", fmt.Errorf("could not locate %q: not on PATH and not under any known Android SDK root (set $ANDROID_HOME to point at your SDK; tried %v)", name, tried)
}
func androidSDKCandidates() []string {
var roots []string
seen := map[string]bool{}
addRoot := func(path string) {
if path == "" || seen[path] {
return
}
seen[path] = true
roots = append(roots, path)
}
addRoot(os.Getenv("ANDROID_HOME"))
addRoot(os.Getenv("ANDROID_SDK_ROOT"))
if home, err := os.UserHomeDir(); err == nil {
addRoot(filepath.Join(home, "Library", "Android", "sdk"))
addRoot(filepath.Join(home, "Android", "Sdk"))
}
addRoot("/opt/homebrew/share/android-commandlinetools")
addRoot("/usr/local/share/android-commandlinetools")
return roots
}
func parseAVDList(output string) []string {
var avds []string
scanner := bufio.NewScanner(strings.NewReader(output))
@@ -161,6 +189,23 @@ func parseAVDList(output string) []string {
return avds
}
func pickAVD(requested string, available []string) (string, error) {
if requested != "" {
if !slices.Contains(available, requested) {
return "", fmt.Errorf("AVD %q does not exist (available: %s)", requested, strings.Join(available, ", "))
}
return requested, nil
}
switch len(available) {
case 0:
return "", fmt.Errorf("no android device connected and no AVD found; create one in Android Studio or `avdmanager create avd`")
case 1:
return available[0], nil
default:
return "", fmt.Errorf("no android device connected and multiple AVDs available (%s); pick one with --avd", strings.Join(available, ", "))
}
}
func bootAVD(_ context.Context, name string) error {
emulator, err := emulatorBinary()
if err != nil {
@@ -191,38 +236,8 @@ func waitForBoot(ctx context.Context, timeout time.Duration) error {
}
}
// envWithAndroidPlatformTools returns env with the directory containing adb
// prepended to PATH, so child processes (the sidecar) can invoke adb even
// when the user hasn't set up their shell PATH.
func envWithAndroidPlatformTools(env []string) []string {
adb, err := adbBinary()
if err != nil {
return env
}
adbDir := filepath.Dir(adb)
result := make([]string, 0, len(env))
found := false
for _, entry := range env {
if current, ok := strings.CutPrefix(entry, "PATH="); ok {
if !pathContains(current, adbDir) {
entry = "PATH=" + adbDir + string(os.PathListSeparator) + current
}
found = true
}
result = append(result, entry)
}
if !found {
result = append(result, "PATH="+adbDir)
}
return result
}
func pathContains(path, directory string) bool {
return slices.Contains(strings.Split(path, string(os.PathListSeparator)), directory)
}
func bootCompleted(ctx context.Context) (bool, error) {
adb, err := adbBinary()
adb, err := AdbBinary()
if err != nil {
return false, err
}
@@ -233,3 +248,6 @@ func bootCompleted(ctx context.Context) (bool, error) {
return strings.TrimSpace(string(output)) == "1", nil
}
func pathContains(path, directory string) bool {
return slices.Contains(strings.Split(path, string(os.PathListSeparator)), directory)
}
@@ -1,4 +1,4 @@
package main
package android
import (
"reflect"
-297
View File
@@ -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"
@@ -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
}
+166
View File
@@ -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
}
+157
View File
@@ -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
}
+72
View File
@@ -0,0 +1,72 @@
package testrun
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"github.com/priyanshujain/sanderling/internal/android"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/driver/chrome"
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
"github.com/priyanshujain/sanderling/internal/sidecar"
)
// buildDriver creates the appropriate DeviceDriver for the platform and returns
// a cleanup function. For web, ChromeDriver is used directly; for android/ios
// the JVM sidecar is extracted, spawned, and dialed.
func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver.DeviceDriver, func(), error) {
if options.Platform == "web" {
d := chrome.New()
return d, func() { _ = d.Terminate(context.Background()) }, nil
}
sidecarDirectory := os.TempDir() + "/sanderling-sidecar"
jarPath, err := sidecar.Extract(sidecarDirectory)
if err != nil {
return nil, nil, fmt.Errorf("extract sidecar: %w", err)
}
fmt.Fprintf(stdout, "sidecar JAR: %s (size=%d)\n", jarPath, sidecar.EmbeddedSize())
sidecarPort, err := pickFreePort()
if err != nil {
return nil, nil, err
}
sidecarCommand := exec.CommandContext(ctx, "java", "-jar", jarPath,
"--port", strconv.Itoa(sidecarPort),
"--platform", options.Platform,
)
sidecarCommand.Stdout = stdout
sidecarCommand.Stderr = stdout
sidecarCommand.Env = android.EnvWithAndroidPlatformTools(os.Environ())
if err := sidecarCommand.Start(); err != nil {
return nil, nil, fmt.Errorf("spawn sidecar: %w", err)
}
fmt.Fprintf(stdout, "sidecar pid=%d listening on 127.0.0.1:%d\n", sidecarCommand.Process.Pid, sidecarPort)
driverClient, err := driverSidecar.Dial(fmt.Sprintf("127.0.0.1:%d", sidecarPort))
if err != nil {
_ = sidecarCommand.Process.Kill()
return nil, nil, fmt.Errorf("dial sidecar: %w", err)
}
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
if err := driverClient.WaitForHealth(healthCtx, 250e6); err != nil {
healthCancel()
_ = sidecarCommand.Process.Kill()
_ = driverClient.Close()
return nil, nil, fmt.Errorf("sidecar health check: %w", err)
}
healthCancel()
fmt.Fprintln(stdout, "sidecar is healthy")
cleanup := func() {
_ = driverClient.Close()
if sidecarCommand.Process != nil {
_ = sidecarCommand.Process.Kill()
}
}
return driverClient, cleanup, nil
}
@@ -1,4 +1,4 @@
package main
package testrun
import (
"context"
@@ -8,11 +8,6 @@ import (
"strings"
)
// newProgressLogger wires a slog.Logger that prints user-facing progress
// lines to the CLI's stdout stream. Info messages render as
// "msg key=value ..." to match the prose style of other CLI prints;
// warnings and errors get a "warn:" / "error:" prefix so they stand
// out in the same stream.
func newProgressLogger(writer io.Writer) *slog.Logger {
return slog.New(&progressHandler{writer: writer, level: slog.LevelInfo})
}
+223
View File
@@ -0,0 +1,223 @@
package testrun
import (
"context"
"fmt"
"io"
"math/rand/v2"
"net"
"os"
"path/filepath"
"time"
"github.com/priyanshujain/sanderling/internal/agent"
"github.com/priyanshujain/sanderling/internal/android"
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/runner"
"github.com/priyanshujain/sanderling/internal/trace"
"github.com/priyanshujain/sanderling/internal/verifier"
)
const (
socketName = "sanderling-agent"
sidecarStartupTimeout = 30 * time.Second
sdkAcceptTimeout = 60 * time.Second
)
// Options are the parameters for a single test pipeline run.
type Options struct {
Spec string
BundleID string
Platform string
AVD string
Duration time.Duration
Seed int64
Output string
}
// Execute runs the full test pipeline: bundle, connect SDK, verify properties.
func Execute(ctx context.Context, options Options, stdout io.Writer) error {
if options.Platform == "android" || options.Platform == "ios" {
if err := android.EnsureDevice(ctx, options.AVD, stdout); err != nil {
return err
}
}
aliases := map[string]string{}
if specAPIPath := resolveSpecAPIPath(options.Spec); specAPIPath != "" {
aliases["@sanderling/spec"] = specAPIPath
base := filepath.Dir(specAPIPath)
aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts")
}
bundle, err := bundler.Bundle(bundler.Options{
EntryFile: options.Spec,
Defines: map[string]string{
"SANDERLING_TEST_PHONE": os.Getenv("SANDERLING_TEST_PHONE"),
"SANDERLING_TEST_OTP": os.Getenv("SANDERLING_TEST_OTP"),
},
Aliases: aliases,
})
if err != nil {
return fmt.Errorf("bundle spec: %w", err)
}
fmt.Fprintf(stdout, "bundled spec: %d bytes (sha256=%s)\n", len(bundle.JavaScript), bundle.SHA256[:12])
activeDriver, cleanup, err := buildDriver(ctx, options, stdout)
if err != nil {
return err
}
defer cleanup()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("agent listener: %w", err)
}
defer listener.Close()
agentPort := listener.Addr().(*net.TCPAddr).Port
if options.Platform != "web" {
if err := android.AdbReverse(socketName, agentPort); err != nil {
return fmt.Errorf("adb reverse: %w", err)
}
defer func() {
if err := android.AdbReverseRemove(socketName); err != nil {
fmt.Fprintf(stdout, "warning: adb reverse cleanup: %v\n", err)
}
}()
fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort)
}
agentServer := agent.NewServer(listener)
type acceptResult struct {
connection *agent.Conn
err error
}
acceptChannel := make(chan acceptResult, 1)
go func() {
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
defer cancel()
connection, acceptErr := agentServer.Accept(acceptCtx)
acceptChannel <- acceptResult{connection: connection, err: acceptErr}
}()
if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil {
return fmt.Errorf("launch app: %w", err)
}
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds())
result := <-acceptChannel
if result.err != nil {
return fmt.Errorf("accept SDK: %w", result.err)
}
connection := result.connection
defer connection.Close()
hello := connection.Hello()
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
seed := options.Seed
if seed == 0 {
seed = time.Now().UnixNano()
}
verifierInstance, err := verifier.New(verifier.WithRand(rand.New(rand.NewPCG(uint64(seed), 0))))
if err != nil {
return fmt.Errorf("verifier: %w", err)
}
if err := verifierInstance.Load(string(bundle.JavaScript)); err != nil {
return fmt.Errorf("load spec: %w", err)
}
fmt.Fprintln(stdout, "spec loaded into verifier")
runDirectory := filepath.Join(options.Output, time.Now().UTC().Format("20060102-150405"))
traceWriter, err := trace.NewWriter(runDirectory)
if err != nil {
return fmt.Errorf("trace writer: %w", err)
}
defer traceWriter.Close()
meta := trace.Meta{
Seed: seed,
SpecPath: options.Spec,
BundleSHA256: bundle.SHA256,
Platform: options.Platform,
BundleID: options.BundleID,
StartedAt: time.Now().UTC(),
SanderlingVersion: "0.0.1",
}
if err := traceWriter.WriteMeta(meta); err != nil {
return fmt.Errorf("trace meta: %w", err)
}
defer func() {
endedAt := time.Now().UTC()
meta.EndedAt = &endedAt
_ = traceWriter.WriteMeta(meta)
}()
fmt.Fprintf(stdout, "trace dir: %s\n", runDirectory)
fmt.Fprintf(stdout, "running for %s (seed=%d)\n", options.Duration, seed)
summary, err := runner.Run(ctx, runner.Options{
Duration: options.Duration,
SnapshotTimeout: 5 * time.Second,
IdleTimeout: 1 * time.Second,
BundleID: options.BundleID,
Connection: connection,
Driver: activeDriver,
Verifier: verifierInstance,
TraceWriter: traceWriter,
Logger: newProgressLogger(stdout),
})
terminateCtx, terminateCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = activeDriver.Terminate(terminateCtx)
terminateCancel()
if err != nil {
return fmt.Errorf("runner: %w", err)
}
fmt.Fprintf(stdout, "\nrun complete: %d steps in %s\n", summary.Steps, summary.EndTime.Sub(summary.StartTime).Round(time.Millisecond))
if len(summary.Violations) == 0 {
fmt.Fprintln(stdout, "no violations.")
} else {
fmt.Fprintf(stdout, "%d violation record(s):\n", len(summary.Violations))
for _, violation := range summary.Violations {
fmt.Fprintf(stdout, " step %d: %v\n", violation.StepIndex, violation.Properties)
}
}
return nil
}
// resolveSpecAPIPath returns the path to pkg/spec/src/index.ts inside
// a sanderling source checkout, searched upward from the spec file and the cwd.
// Returns "" when not found, in which case esbuild resolves @sanderling/spec via
// node_modules the way a downstream user's project would.
func resolveSpecAPIPath(specPath string) string {
var candidates []string
if absoluteSpec, err := filepath.Abs(specPath); err == nil {
directory := filepath.Dir(absoluteSpec)
for {
candidates = append(candidates, filepath.Join(directory, "pkg/spec/src/index.ts"))
parent := filepath.Dir(directory)
if parent == directory {
break
}
directory = parent
}
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "pkg/spec/src/index.ts"))
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}
func pickFreePort() (int, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port, nil
}
@@ -1,4 +1,4 @@
package main
package testrun
import (
"os"
@@ -8,7 +8,7 @@ import (
func TestResolveSpecAPIPath_FindsUpwardSibling(t *testing.T) {
root := t.TempDir()
apiPath := filepath.Join(root, "pkg", "spec-api", "src", "index.ts")
apiPath := filepath.Join(root, "pkg", "spec", "src", "index.ts")
if err := os.MkdirAll(filepath.Dir(apiPath), 0o755); err != nil {
t.Fatal(err)
}
-27
View File
@@ -7,33 +7,6 @@ import (
"github.com/dop251/goja"
)
type ActionKind string
const (
ActionKindTap ActionKind = "Tap"
ActionKindInputText ActionKind = "InputText"
ActionKindSwipe ActionKind = "Swipe"
ActionKindPressKey ActionKind = "PressKey"
ActionKindWait ActionKind = "Wait"
)
type Action struct {
Kind ActionKind
On string
Text string
// X, Y hold the element center when the spec passed an ax element to
// Tap/InputText. Zero means the runner must resolve On against the
// current hierarchy.
X, Y int
// Swipe coordinates (raw px). Used only for ActionKindSwipe.
FromX, FromY int
ToX, ToY int
// DurationMillis is the Swipe gesture duration or the Wait duration.
DurationMillis int
// Key is the logical key name for ActionKindPressKey.
Key string
}
type extractorState struct {
getter goja.Callable
handle *goja.Object
+2 -2
View File
@@ -19,11 +19,11 @@ func bundleIntegrationSpec(t *testing.T) string {
if err != nil {
t.Fatal(err)
}
apiPath, err := filepath.Abs("../../pkg/spec-api/src/index.ts")
apiPath, err := filepath.Abs("../../pkg/spec/src/index.ts")
if err != nil {
t.Fatal(err)
}
defaultsPath, err := filepath.Abs("../../pkg/spec-api/src/defaults/properties.ts")
defaultsPath, err := filepath.Abs("../../pkg/spec/src/defaults/properties.ts")
if err != nil {
t.Fatal(err)
}
+46
View File
@@ -0,0 +1,46 @@
package verifier
// ActionKind identifies the category of a generated UI action.
type ActionKind string
const (
ActionKindTap ActionKind = "Tap"
ActionKindInputText ActionKind = "InputText"
ActionKindSwipe ActionKind = "Swipe"
ActionKindPressKey ActionKind = "PressKey"
ActionKindWait ActionKind = "Wait"
)
// Action is a single UI interaction produced by the spec's action generator.
type Action struct {
Kind ActionKind
On string
Text string
// X, Y hold the element center when the spec passed an ax element to
// Tap/InputText. Zero means the runner must resolve On against the
// current hierarchy.
X, Y int
// Swipe coordinates (raw px). Used only for ActionKindSwipe.
FromX, FromY int
ToX, ToY int
// DurationMillis is the Swipe gesture duration or the Wait duration.
DurationMillis int
// Key is the logical key name for ActionKindPressKey.
Key string
}
// LogEntry mirrors a logcat line captured between steps.
type LogEntry struct {
UnixMillis int64
Level string
Tag string
Message string
}
// Exception mirrors an SDK-captured uncaught throwable.
type Exception struct {
Class string
Message string
StackTrace string
UnixMillis int64
}
-16
View File
@@ -241,22 +241,6 @@ type SnapshotInput struct {
Exceptions []Exception
}
// LogEntry mirrors a logcat line captured between steps.
type LogEntry struct {
UnixMillis int64
Level string
Tag string
Message string
}
// Exception mirrors an SDK-captured uncaught throwable.
type Exception struct {
Class string
Message string
StackTrace string
UnixMillis int64
}
// EvaluateProperties returns each registered property's running verdict
// after the most recent PushSnapshot. The step time passed in PushSnapshot is
// forwarded to each evaluator so deadline-bound operators see the snapshot's
File renamed without changes.
File renamed without changes.
@@ -22,7 +22,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/priyanshujain/sanderling.git",
"directory": "pkg/spec-api"
"directory": "pkg/spec"
},
"homepage": "https://github.com/priyanshujain/sanderling",
"bugs": {
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.