From eed99e58aac621e55e26f76899cb081abdee22ca Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 22 Apr 2026 20:35:34 +0700 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 8 +- .gitignore | 8 +- Makefile | 6 +- cmd/sanderling/test_run.go | 289 +--------------- docs/development/decisions.md | 57 ++++ .../android/android.go | 200 ++++++----- .../android/android_test.go | 2 +- internal/inspect/runs.go | 319 +----------------- internal/inspect/runs_cache.go | 166 +++++++++ internal/inspect/runs_decode.go | 157 +++++++++ internal/testrun/driver.go | 72 ++++ .../testrun/progress.go | 7 +- internal/testrun/testrun.go | 223 ++++++++++++ .../testrun/testrun_test.go | 4 +- internal/verifier/bindings.go | 27 -- internal/verifier/spec_integration_test.go | 4 +- internal/verifier/types.go | 46 +++ internal/verifier/worker.go | 16 - pkg/{spec-api => spec}/README.md | 0 pkg/{spec-api => spec}/package-lock.json | 0 pkg/{spec-api => spec}/package.json | 2 +- pkg/{spec-api => spec}/src/actions.ts | 0 .../src/defaults/properties.ts | 0 pkg/{spec-api => spec}/src/extract.ts | 0 pkg/{spec-api => spec}/src/index.ts | 0 pkg/{spec-api => spec}/src/ltl.ts | 0 pkg/{spec-api => spec}/src/types.ts | 0 pkg/{spec-api => spec}/test/api.test.ts | 0 pkg/{spec-api => spec}/test/defaults.test.ts | 0 pkg/{spec-api => spec}/tsconfig.build.json | 0 pkg/{spec-api => spec}/tsconfig.json | 0 32 files changed, 870 insertions(+), 745 deletions(-) create mode 100644 docs/development/decisions.md rename cmd/sanderling/android_env.go => internal/android/android.go (85%) rename cmd/sanderling/android_env_test.go => internal/android/android_test.go (99%) create mode 100644 internal/inspect/runs_cache.go create mode 100644 internal/inspect/runs_decode.go create mode 100644 internal/testrun/driver.go rename cmd/sanderling/progress_logger.go => internal/testrun/progress.go (78%) create mode 100644 internal/testrun/testrun.go rename cmd/sanderling/test_run_test.go => internal/testrun/testrun_test.go (93%) create mode 100644 internal/verifier/types.go rename pkg/{spec-api => spec}/README.md (100%) rename pkg/{spec-api => spec}/package-lock.json (100%) rename pkg/{spec-api => spec}/package.json (97%) rename pkg/{spec-api => spec}/src/actions.ts (100%) rename pkg/{spec-api => spec}/src/defaults/properties.ts (100%) rename pkg/{spec-api => spec}/src/extract.ts (100%) rename pkg/{spec-api => spec}/src/index.ts (100%) rename pkg/{spec-api => spec}/src/ltl.ts (100%) rename pkg/{spec-api => spec}/src/types.ts (100%) rename pkg/{spec-api => spec}/test/api.test.ts (100%) rename pkg/{spec-api => spec}/test/defaults.test.ts (100%) rename pkg/{spec-api => spec}/tsconfig.build.json (100%) rename pkg/{spec-api => spec}/tsconfig.json (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae3edbf..af14d23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb04614..10ff80f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | diff --git a/.gitignore b/.gitignore index 495ccf9..5826a8a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index 2b2e6fb..6d19a7d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/sanderling/test_run.go b/cmd/sanderling/test_run.go index 253c447..2d826d9 100644 --- a/cmd/sanderling/test_run.go +++ b/cmd/sanderling/test_run.go @@ -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, - 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 -} - -// 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() + return testrun.Execute(ctx, testrun.Options{ + Spec: options.spec, + BundleID: options.bundleID, + Platform: options.platform, + AVD: options.avd, + Duration: options.duration, + Seed: options.seed, + Output: options.output, + }, stdout) } diff --git a/docs/development/decisions.md b/docs/development/decisions.md new file mode 100644 index 0000000..2a83171 --- /dev/null +++ b/docs/development/decisions.md @@ -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`. diff --git a/cmd/sanderling/android_env.go b/internal/android/android.go similarity index 85% rename from cmd/sanderling/android_env.go rename to internal/android/android.go index 0455a04..cc47fae 100644 --- a/cmd/sanderling/android_env.go +++ b/internal/android/android.go @@ -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 + } + command := exec.Command(adb, "reverse", "localabstract:"+socket, fmt.Sprintf("tcp:%d", port)) + return command.Run() +} + +// 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 } - return requested, nil + result = append(result, entry) } - 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, ", ")) + 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// and $ANDROID_SDK_ROOT//, +// 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// and $ANDROID_SDK_ROOT//, -// 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) +} diff --git a/cmd/sanderling/android_env_test.go b/internal/android/android_test.go similarity index 99% rename from cmd/sanderling/android_env_test.go rename to internal/android/android_test.go index 189ed12..c551667 100644 --- a/cmd/sanderling/android_env_test.go +++ b/internal/android/android_test.go @@ -1,4 +1,4 @@ -package main +package android import ( "reflect" diff --git a/internal/inspect/runs.go b/internal/inspect/runs.go index 29e4316..91450a0 100644 --- a/internal/inspect/runs.go +++ b/internal/inspect/runs.go @@ -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 -} diff --git a/internal/inspect/runs_cache.go b/internal/inspect/runs_cache.go new file mode 100644 index 0000000..691dd9b --- /dev/null +++ b/internal/inspect/runs_cache.go @@ -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 +} diff --git a/internal/inspect/runs_decode.go b/internal/inspect/runs_decode.go new file mode 100644 index 0000000..946a327 --- /dev/null +++ b/internal/inspect/runs_decode.go @@ -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 +} diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go new file mode 100644 index 0000000..ec2e1d7 --- /dev/null +++ b/internal/testrun/driver.go @@ -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 +} diff --git a/cmd/sanderling/progress_logger.go b/internal/testrun/progress.go similarity index 78% rename from cmd/sanderling/progress_logger.go rename to internal/testrun/progress.go index b854525..4f0b5cd 100644 --- a/cmd/sanderling/progress_logger.go +++ b/internal/testrun/progress.go @@ -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}) } diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go new file mode 100644 index 0000000..27c6d01 --- /dev/null +++ b/internal/testrun/testrun.go @@ -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 +} diff --git a/cmd/sanderling/test_run_test.go b/internal/testrun/testrun_test.go similarity index 93% rename from cmd/sanderling/test_run_test.go rename to internal/testrun/testrun_test.go index 28205ca..9611150 100644 --- a/cmd/sanderling/test_run_test.go +++ b/internal/testrun/testrun_test.go @@ -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) } diff --git a/internal/verifier/bindings.go b/internal/verifier/bindings.go index 4703136..f8facbe 100644 --- a/internal/verifier/bindings.go +++ b/internal/verifier/bindings.go @@ -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 diff --git a/internal/verifier/spec_integration_test.go b/internal/verifier/spec_integration_test.go index 575db3a..75f6530 100644 --- a/internal/verifier/spec_integration_test.go +++ b/internal/verifier/spec_integration_test.go @@ -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) } diff --git a/internal/verifier/types.go b/internal/verifier/types.go new file mode 100644 index 0000000..a55bd68 --- /dev/null +++ b/internal/verifier/types.go @@ -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 +} diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 00e698d..24fd683 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -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 diff --git a/pkg/spec-api/README.md b/pkg/spec/README.md similarity index 100% rename from pkg/spec-api/README.md rename to pkg/spec/README.md diff --git a/pkg/spec-api/package-lock.json b/pkg/spec/package-lock.json similarity index 100% rename from pkg/spec-api/package-lock.json rename to pkg/spec/package-lock.json diff --git a/pkg/spec-api/package.json b/pkg/spec/package.json similarity index 97% rename from pkg/spec-api/package.json rename to pkg/spec/package.json index 9bbda01..4af4aca 100644 --- a/pkg/spec-api/package.json +++ b/pkg/spec/package.json @@ -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": { diff --git a/pkg/spec-api/src/actions.ts b/pkg/spec/src/actions.ts similarity index 100% rename from pkg/spec-api/src/actions.ts rename to pkg/spec/src/actions.ts diff --git a/pkg/spec-api/src/defaults/properties.ts b/pkg/spec/src/defaults/properties.ts similarity index 100% rename from pkg/spec-api/src/defaults/properties.ts rename to pkg/spec/src/defaults/properties.ts diff --git a/pkg/spec-api/src/extract.ts b/pkg/spec/src/extract.ts similarity index 100% rename from pkg/spec-api/src/extract.ts rename to pkg/spec/src/extract.ts diff --git a/pkg/spec-api/src/index.ts b/pkg/spec/src/index.ts similarity index 100% rename from pkg/spec-api/src/index.ts rename to pkg/spec/src/index.ts diff --git a/pkg/spec-api/src/ltl.ts b/pkg/spec/src/ltl.ts similarity index 100% rename from pkg/spec-api/src/ltl.ts rename to pkg/spec/src/ltl.ts diff --git a/pkg/spec-api/src/types.ts b/pkg/spec/src/types.ts similarity index 100% rename from pkg/spec-api/src/types.ts rename to pkg/spec/src/types.ts diff --git a/pkg/spec-api/test/api.test.ts b/pkg/spec/test/api.test.ts similarity index 100% rename from pkg/spec-api/test/api.test.ts rename to pkg/spec/test/api.test.ts diff --git a/pkg/spec-api/test/defaults.test.ts b/pkg/spec/test/defaults.test.ts similarity index 100% rename from pkg/spec-api/test/defaults.test.ts rename to pkg/spec/test/defaults.test.ts diff --git a/pkg/spec-api/tsconfig.build.json b/pkg/spec/tsconfig.build.json similarity index 100% rename from pkg/spec-api/tsconfig.build.json rename to pkg/spec/tsconfig.build.json diff --git a/pkg/spec-api/tsconfig.json b/pkg/spec/tsconfig.json similarity index 100% rename from pkg/spec-api/tsconfig.json rename to pkg/spec/tsconfig.json