mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
refactor: code organization cleanup (#35)
* chore: fix gitignore + decisions doc after web->inspect-ui rename Update web/ references to inspect-ui/ in .gitignore and Makefile. Add decisions.md tracking architectural decisions from code-org discussion. * refactor: rename pkg/spec-api to pkg/spec Aligns the directory name with the npm package name @sanderling/spec. Updates Makefile, package.json directory field, and resolveSpecAPIPath. * refactor(verifier): split bindings.go into types.go + bindings.go Move shared public types (Action, ActionKind, LogEntry, Exception) to types.go. bindings.go retains internal JS runtime wiring only. * refactor(inspect): split runs.go into runs.go, runs_cache.go, runs_decode.go runs.go: types (RunSummary, StepSummary, RunDetail, Run) and Scan. runs_cache.go: Cache type, Open/Step/Detail methods, parseRun, scanSteps. runs_decode.go: readMeta, tallyTrace, decodeStepSummary, validRunID. * refactor: move android_env.go to internal/android/ Extracts Android device/AVD/adb logic into internal/android package. Exports EnsureDevice, AdbReverse, AdbReverseRemove, EnvWithAndroidPlatformTools, AdbBinary. Moves tests to internal/android/android_test.go. cmd/sanderling becomes a thin caller. * refactor: extract test pipeline to internal/testrun/ runTestPipeline logic moves to testrun.Execute. buildDriver, resolveSpecAPIPath, pickFreePort, and the progress logger move to internal/testrun/. cmd/sanderling/test_run.go becomes a thin adapter. Tests follow their code. * ci: update workflow paths after pkg/spec-api -> pkg/spec rename
This commit is contained in:
32 files changed
+857
-732
No files matched your search
@@ -38,7 +38,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: pkg/spec-api/package-lock.json
|
cache-dependency-path: pkg/spec/package-lock.json
|
||||||
|
|
||||||
- name: Set up bun
|
- name: Set up bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|||||||
@@ -79,18 +79,18 @@ jobs:
|
|||||||
node-version: "22"
|
node-version: "22"
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: pkg/spec-api/package-lock.json
|
cache-dependency-path: pkg/spec/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: pkg/spec-api
|
working-directory: pkg/spec
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Stamp version
|
- 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
|
run: npm version ${{ steps.ver.outputs.version }} --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Publish
|
- 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
|
# npm tag pre-releases (e.g. 0.1.0-rc1) as "next" so npm install @sanderling/spec
|
||||||
# keeps resolving the latest stable.
|
# keeps resolving the latest stable.
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+4
-4
@@ -32,7 +32,7 @@ node_modules/
|
|||||||
internal/sidecar/assets/sidecar-all.jar
|
internal/sidecar/assets/sidecar-all.jar
|
||||||
|
|
||||||
# spec-api compiled output
|
# spec-api compiled output
|
||||||
pkg/spec-api/dist/
|
pkg/spec/dist/
|
||||||
|
|
||||||
# goreleaser local output
|
# goreleaser local output
|
||||||
/dist/
|
/dist/
|
||||||
@@ -44,9 +44,9 @@ pkg/spec-api/dist/
|
|||||||
!/internal/inspect/dist/index.html
|
!/internal/inspect/dist/index.html
|
||||||
|
|
||||||
# inspect web frontend
|
# inspect web frontend
|
||||||
web/node_modules/
|
inspect-ui/node_modules/
|
||||||
web/dist/
|
inspect-ui/dist/
|
||||||
web/.vite/
|
inspect-ui/.vite/
|
||||||
|
|
||||||
# d2 diagrams render into build/site/_assets/diagrams; keep sources only
|
# d2 diagrams render into build/site/_assets/diagrams; keep sources only
|
||||||
/docs/_diagrams/*.svg
|
/docs/_diagrams/*.svg
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ WEB_DIST := inspect-ui/dist
|
|||||||
bootstrap:
|
bootstrap:
|
||||||
$(GO) mod download
|
$(GO) mod download
|
||||||
$(BUF) generate
|
$(BUF) generate
|
||||||
cd pkg/spec-api && npm install --silent
|
cd pkg/spec && npm install --silent
|
||||||
|
|
||||||
proto:
|
proto:
|
||||||
$(BUF) lint
|
$(BUF) lint
|
||||||
@@ -82,7 +82,7 @@ test-kotlin:
|
|||||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test :sdk-android:testDebugUnitTest
|
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test :sdk-android:testDebugUnitTest
|
||||||
|
|
||||||
test-spec-api:
|
test-spec-api:
|
||||||
cd pkg/spec-api && npm test --silent
|
cd pkg/spec && npm test --silent
|
||||||
|
|
||||||
docs: $(DOCS_OUT) build/site/_assets $(DIAGRAM_OUT)
|
docs: $(DOCS_OUT) build/site/_assets $(DIAGRAM_OUT)
|
||||||
@echo "built $(words $(DOCS_OUT)) pages, $(words $(DIAGRAM_OUT)) diagrams to build/site"
|
@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
|
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:publishToMavenLocal -Psanderling.version=0.0.0-local
|
||||||
|
|
||||||
release-npm-dry:
|
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
@@ -2,288 +2,19 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"math/rand/v2"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/agent"
|
"github.com/priyanshujain/sanderling/internal/testrun"
|
||||||
"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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
|
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
|
||||||
if options.platform == "android" || options.platform == "ios" {
|
return testrun.Execute(ctx, testrun.Options{
|
||||||
if err := ensureDevice(ctx, options.avd, stdout); err != nil {
|
Spec: options.spec,
|
||||||
return err
|
BundleID: options.bundleID,
|
||||||
}
|
|
||||||
}
|
|
||||||
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,
|
Platform: options.platform,
|
||||||
BundleID: options.bundleID,
|
AVD: options.avd,
|
||||||
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,
|
Duration: options.duration,
|
||||||
SnapshotTimeout: 5 * time.Second,
|
Seed: options.seed,
|
||||||
IdleTimeout: 1 * time.Second,
|
Output: options.output,
|
||||||
BundleID: options.bundleID,
|
}, stdout)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -13,13 +13,13 @@ import (
|
|||||||
"time"
|
"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:
|
// Resolution order:
|
||||||
// - if an adb device is already online, use it;
|
// - if an adb device is already online, use it;
|
||||||
// - else if avdName is set, validate and boot it;
|
// - else if avdName is set, validate and boot it;
|
||||||
// - else if exactly one AVD exists locally, boot it;
|
// - else if exactly one AVD exists locally, boot it;
|
||||||
// - else fail with a helpful message listing the available AVDs.
|
// - 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)
|
devices, err := listAdbDevices(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list adb devices: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pickAVD(requested string, available []string) (string, error) {
|
// AdbReverse sets up adb reverse forwarding for a local abstract socket.
|
||||||
if requested != "" {
|
func AdbReverse(socket string, port int) error {
|
||||||
if !slices.Contains(available, requested) {
|
adb, err := AdbBinary()
|
||||||
return "", fmt.Errorf("AVD %q does not exist (available: %s)", requested, strings.Join(available, ", "))
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return requested, nil
|
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
|
||||||
}
|
}
|
||||||
switch len(available) {
|
return exec.Command(adb, "reverse", "--remove", "localabstract:"+socket).Run()
|
||||||
case 0:
|
}
|
||||||
return "", fmt.Errorf("no android device connected and no AVD found; create one in Android Studio or `avdmanager create avd`")
|
|
||||||
case 1:
|
// EnvWithAndroidPlatformTools returns env with the directory containing adb
|
||||||
return available[0], nil
|
// prepended to PATH, so child processes (the sidecar) can invoke adb even
|
||||||
default:
|
// when the user hasn't set up their shell PATH.
|
||||||
return "", fmt.Errorf("no android device connected and multiple AVDs available (%s); pick one with --avd", strings.Join(available, ", "))
|
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) {
|
func listAdbDevices(ctx context.Context) ([]string, error) {
|
||||||
adb, err := adbBinary()
|
adb, err := AdbBinary()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -104,50 +176,6 @@ func listAVDs(ctx context.Context) ([]string, error) {
|
|||||||
return parseAVDList(string(output)), nil
|
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 {
|
func parseAVDList(output string) []string {
|
||||||
var avds []string
|
var avds []string
|
||||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||||
@@ -161,6 +189,23 @@ func parseAVDList(output string) []string {
|
|||||||
return avds
|
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 {
|
func bootAVD(_ context.Context, name string) error {
|
||||||
emulator, err := emulatorBinary()
|
emulator, err := emulatorBinary()
|
||||||
if err != nil {
|
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) {
|
func bootCompleted(ctx context.Context) (bool, error) {
|
||||||
adb, err := adbBinary()
|
adb, err := AdbBinary()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -233,3 +248,6 @@ func bootCompleted(ctx context.Context) (bool, error) {
|
|||||||
return strings.TrimSpace(string(output)) == "1", nil
|
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 (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
package inspect
|
package inspect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/trace"
|
"github.com/priyanshujain/sanderling/internal/trace"
|
||||||
@@ -133,297 +130,3 @@ func buildSummary(id string, meta trace.Meta, stepCount, violationCount int) Run
|
|||||||
}
|
}
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMeta(runDirectory string) (trace.Meta, error) {
|
|
||||||
body, err := os.ReadFile(filepath.Join(runDirectory, "meta.json"))
|
|
||||||
if err != nil {
|
|
||||||
return trace.Meta{}, fmt.Errorf("read meta: %w", err)
|
|
||||||
}
|
|
||||||
var meta trace.Meta
|
|
||||||
if err := json.Unmarshal(body, &meta); err != nil {
|
|
||||||
return trace.Meta{}, fmt.Errorf("decode meta: %w", err)
|
|
||||||
}
|
|
||||||
return meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func tallyTrace(tracePath string) (steps, violations int, err error) {
|
|
||||||
file, err := os.Open(tracePath)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return 0, 0, nil
|
|
||||||
}
|
|
||||||
return 0, 0, fmt.Errorf("open trace: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
scanner.Buffer(make([]byte, 64*1024), maxScanTokenSize)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
if len(line) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var partial struct {
|
|
||||||
Violations []string `json:"violations,omitempty"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(line, &partial); err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("decode step: %w", err)
|
|
||||||
}
|
|
||||||
steps++
|
|
||||||
violations += len(partial.Violations)
|
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("scan trace: %w", err)
|
|
||||||
}
|
|
||||||
return steps, violations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache holds parsed Run records keyed by id. Open returns a fresh parse
|
|
||||||
// when the underlying trace.jsonl mtime changes.
|
|
||||||
type Cache struct {
|
|
||||||
root string
|
|
||||||
mutex sync.Mutex
|
|
||||||
runs map[string]*Run
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCache(runsDirectory string) *Cache {
|
|
||||||
return &Cache{root: runsDirectory, runs: map[string]*Run{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Cache) Root() string { return c.root }
|
|
||||||
|
|
||||||
// Open parses (or returns a cached parse of) the run named id.
|
|
||||||
func (c *Cache) Open(id string) (*Run, error) {
|
|
||||||
if !validRunID(id) {
|
|
||||||
return nil, fs.ErrNotExist
|
|
||||||
}
|
|
||||||
runDirectory := filepath.Join(c.root, id)
|
|
||||||
tracePath := filepath.Join(runDirectory, "trace.jsonl")
|
|
||||||
traceInfo, traceErr := os.Stat(tracePath)
|
|
||||||
|
|
||||||
c.mutex.Lock()
|
|
||||||
defer c.mutex.Unlock()
|
|
||||||
if cached, ok := c.runs[id]; ok {
|
|
||||||
if traceErr == nil && cached.traceMtime.Equal(traceInfo.ModTime()) {
|
|
||||||
return cached, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run, err := parseRun(runDirectory, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.runs[id] = run
|
|
||||||
return run, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseRun(runDirectory, id string) (*Run, error) {
|
|
||||||
meta, err := readMeta(runDirectory)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tracePath := filepath.Join(runDirectory, "trace.jsonl")
|
|
||||||
steps, offsets, violationCount, traceMtime, err := scanSteps(tracePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
summary := buildSummary(id, meta, len(steps), violationCount)
|
|
||||||
return &Run{
|
|
||||||
ID: id,
|
|
||||||
Directory: runDirectory,
|
|
||||||
Meta: meta,
|
|
||||||
Summary: summary,
|
|
||||||
Steps: steps,
|
|
||||||
tracePath: tracePath,
|
|
||||||
traceMtime: traceMtime,
|
|
||||||
offsets: offsets,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanSteps(tracePath string) ([]StepSummary, []int64, int, time.Time, error) {
|
|
||||||
file, err := os.Open(tracePath)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return []StepSummary{}, nil, 0, time.Time{}, nil
|
|
||||||
}
|
|
||||||
return nil, nil, 0, time.Time{}, fmt.Errorf("open trace: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, 0, time.Time{}, fmt.Errorf("stat trace: %w", err)
|
|
||||||
}
|
|
||||||
reader := bufio.NewReaderSize(file, 64*1024)
|
|
||||||
steps := []StepSummary{}
|
|
||||||
offsets := []int64{}
|
|
||||||
violationCount := 0
|
|
||||||
var offset int64
|
|
||||||
for {
|
|
||||||
lineStart := offset
|
|
||||||
line, err := reader.ReadBytes('\n')
|
|
||||||
offset += int64(len(line))
|
|
||||||
trimmed := line
|
|
||||||
if len(trimmed) > 0 && trimmed[len(trimmed)-1] == '\n' {
|
|
||||||
trimmed = trimmed[:len(trimmed)-1]
|
|
||||||
}
|
|
||||||
if len(trimmed) > 0 {
|
|
||||||
summary, partial, decodeErr := decodeStepSummary(trimmed)
|
|
||||||
if decodeErr != nil {
|
|
||||||
return nil, nil, 0, time.Time{}, decodeErr
|
|
||||||
}
|
|
||||||
steps = append(steps, summary)
|
|
||||||
offsets = append(offsets, lineStart)
|
|
||||||
violationCount += partial
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return steps, offsets, violationCount, info.ModTime(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeStepSummary(line []byte) (StepSummary, int, error) {
|
|
||||||
var partial struct {
|
|
||||||
Index int `json:"step"`
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
Screen string `json:"screen,omitempty"`
|
|
||||||
Action *struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
X int `json:"x,omitempty"`
|
|
||||||
Y int `json:"y,omitempty"`
|
|
||||||
FromX int `json:"from_x,omitempty"`
|
|
||||||
FromY int `json:"from_y,omitempty"`
|
|
||||||
ToX int `json:"to_x,omitempty"`
|
|
||||||
ToY int `json:"to_y,omitempty"`
|
|
||||||
Key string `json:"key,omitempty"`
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
Selector string `json:"selector,omitempty"`
|
|
||||||
DurationMillis int `json:"duration_millis,omitempty"`
|
|
||||||
} `json:"action,omitempty"`
|
|
||||||
Exceptions []json.RawMessage `json:"exceptions,omitempty"`
|
|
||||||
Violations []string `json:"violations,omitempty"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(line, &partial); err != nil {
|
|
||||||
return StepSummary{}, 0, fmt.Errorf("decode step: %w", err)
|
|
||||||
}
|
|
||||||
summary := StepSummary{
|
|
||||||
Index: partial.Index,
|
|
||||||
Timestamp: partial.Timestamp,
|
|
||||||
Screen: partial.Screen,
|
|
||||||
HasViolations: len(partial.Violations) > 0,
|
|
||||||
HasExceptions: len(partial.Exceptions) > 0,
|
|
||||||
}
|
|
||||||
if partial.Action != nil {
|
|
||||||
summary.ActionKind = partial.Action.Kind
|
|
||||||
switch partial.Action.Kind {
|
|
||||||
case "Tap":
|
|
||||||
if partial.Action.Selector != "" {
|
|
||||||
summary.ActionLabel = partial.Action.Selector
|
|
||||||
} else if partial.Action.Text != "" {
|
|
||||||
summary.ActionLabel = partial.Action.Text
|
|
||||||
} else if partial.Action.X != 0 || partial.Action.Y != 0 {
|
|
||||||
summary.ActionLabel = fmt.Sprintf("(%d,%d)", partial.Action.X, partial.Action.Y)
|
|
||||||
}
|
|
||||||
case "InputText":
|
|
||||||
summary.ActionLabel = fmt.Sprintf("%q", partial.Action.Text)
|
|
||||||
case "Swipe":
|
|
||||||
summary.ActionLabel = swipeDirectionLabel(
|
|
||||||
partial.Action.FromX, partial.Action.FromY,
|
|
||||||
partial.Action.ToX, partial.Action.ToY,
|
|
||||||
)
|
|
||||||
case "PressKey":
|
|
||||||
summary.ActionLabel = partial.Action.Key
|
|
||||||
case "Wait":
|
|
||||||
if partial.Action.DurationMillis > 0 {
|
|
||||||
summary.ActionLabel = fmt.Sprintf("%dms", partial.Action.DurationMillis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return summary, len(partial.Violations), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func swipeDirectionLabel(fromX, fromY, toX, toY int) string {
|
|
||||||
dx := toX - fromX
|
|
||||||
dy := toY - fromY
|
|
||||||
absX := dx
|
|
||||||
if absX < 0 {
|
|
||||||
absX = -absX
|
|
||||||
}
|
|
||||||
absY := dy
|
|
||||||
if absY < 0 {
|
|
||||||
absY = -absY
|
|
||||||
}
|
|
||||||
if absY >= absX {
|
|
||||||
if dy < 0 {
|
|
||||||
return "up"
|
|
||||||
}
|
|
||||||
return "down"
|
|
||||||
}
|
|
||||||
if dx < 0 {
|
|
||||||
return "left"
|
|
||||||
}
|
|
||||||
return "right"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step decodes the full Step record at index n (1-based, matching trace.Step.Index).
|
|
||||||
func (c *Cache) Step(run *Run, index int) (trace.Step, error) {
|
|
||||||
position := -1
|
|
||||||
for i, summary := range run.Steps {
|
|
||||||
if summary.Index == index {
|
|
||||||
position = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if position == -1 {
|
|
||||||
return trace.Step{}, fs.ErrNotExist
|
|
||||||
}
|
|
||||||
file, err := os.Open(run.tracePath)
|
|
||||||
if err != nil {
|
|
||||||
return trace.Step{}, fmt.Errorf("open trace: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
if _, err := file.Seek(run.offsets[position], 0); err != nil {
|
|
||||||
return trace.Step{}, fmt.Errorf("seek trace: %w", err)
|
|
||||||
}
|
|
||||||
reader := bufio.NewReaderSize(file, 64*1024)
|
|
||||||
line, err := reader.ReadBytes('\n')
|
|
||||||
if err != nil && len(line) == 0 {
|
|
||||||
return trace.Step{}, fmt.Errorf("read step line: %w", err)
|
|
||||||
}
|
|
||||||
if len(line) > 0 && line[len(line)-1] == '\n' {
|
|
||||||
line = line[:len(line)-1]
|
|
||||||
}
|
|
||||||
var step trace.Step
|
|
||||||
if err := json.Unmarshal(line, &step); err != nil {
|
|
||||||
return trace.Step{}, fmt.Errorf("decode step: %w", err)
|
|
||||||
}
|
|
||||||
return step, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detail returns the /api/runs/{id} payload.
|
|
||||||
func (c *Cache) Detail(id string) (RunDetail, error) {
|
|
||||||
run, err := c.Open(id)
|
|
||||||
if err != nil {
|
|
||||||
return RunDetail{}, err
|
|
||||||
}
|
|
||||||
return RunDetail{
|
|
||||||
RunSummary: run.Summary,
|
|
||||||
Meta: run.Meta,
|
|
||||||
Steps: run.Steps,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validRunID(id string) bool {
|
|
||||||
if id == "" || id == "." || id == ".." {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range id {
|
|
||||||
switch {
|
|
||||||
case r >= 'a' && r <= 'z':
|
|
||||||
case r >= 'A' && r <= 'Z':
|
|
||||||
case r >= '0' && r <= '9':
|
|
||||||
case r == '-' || r == '_' || r == '.':
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package inspect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cache holds parsed Run records keyed by id. Open returns a fresh parse
|
||||||
|
// when the underlying trace.jsonl mtime changes.
|
||||||
|
type Cache struct {
|
||||||
|
root string
|
||||||
|
mutex sync.Mutex
|
||||||
|
runs map[string]*Run
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCache(runsDirectory string) *Cache {
|
||||||
|
return &Cache{root: runsDirectory, runs: map[string]*Run{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Root() string { return c.root }
|
||||||
|
|
||||||
|
// Open parses (or returns a cached parse of) the run named id.
|
||||||
|
func (c *Cache) Open(id string) (*Run, error) {
|
||||||
|
if !validRunID(id) {
|
||||||
|
return nil, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
runDirectory := filepath.Join(c.root, id)
|
||||||
|
tracePath := filepath.Join(runDirectory, "trace.jsonl")
|
||||||
|
traceInfo, traceErr := os.Stat(tracePath)
|
||||||
|
|
||||||
|
c.mutex.Lock()
|
||||||
|
defer c.mutex.Unlock()
|
||||||
|
if cached, ok := c.runs[id]; ok {
|
||||||
|
if traceErr == nil && cached.traceMtime.Equal(traceInfo.ModTime()) {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run, err := parseRun(runDirectory, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.runs[id] = run
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRun(runDirectory, id string) (*Run, error) {
|
||||||
|
meta, err := readMeta(runDirectory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tracePath := filepath.Join(runDirectory, "trace.jsonl")
|
||||||
|
steps, offsets, violationCount, traceMtime, err := scanSteps(tracePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
summary := buildSummary(id, meta, len(steps), violationCount)
|
||||||
|
return &Run{
|
||||||
|
ID: id,
|
||||||
|
Directory: runDirectory,
|
||||||
|
Meta: meta,
|
||||||
|
Summary: summary,
|
||||||
|
Steps: steps,
|
||||||
|
tracePath: tracePath,
|
||||||
|
traceMtime: traceMtime,
|
||||||
|
offsets: offsets,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanSteps(tracePath string) ([]StepSummary, []int64, int, time.Time, error) {
|
||||||
|
file, err := os.Open(tracePath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return []StepSummary{}, nil, 0, time.Time{}, nil
|
||||||
|
}
|
||||||
|
return nil, nil, 0, time.Time{}, fmt.Errorf("open trace: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, 0, time.Time{}, fmt.Errorf("stat trace: %w", err)
|
||||||
|
}
|
||||||
|
reader := bufio.NewReaderSize(file, 64*1024)
|
||||||
|
steps := []StepSummary{}
|
||||||
|
offsets := []int64{}
|
||||||
|
violationCount := 0
|
||||||
|
var offset int64
|
||||||
|
for {
|
||||||
|
lineStart := offset
|
||||||
|
line, err := reader.ReadBytes('\n')
|
||||||
|
offset += int64(len(line))
|
||||||
|
trimmed := line
|
||||||
|
if len(trimmed) > 0 && trimmed[len(trimmed)-1] == '\n' {
|
||||||
|
trimmed = trimmed[:len(trimmed)-1]
|
||||||
|
}
|
||||||
|
if len(trimmed) > 0 {
|
||||||
|
summary, partial, decodeErr := decodeStepSummary(trimmed)
|
||||||
|
if decodeErr != nil {
|
||||||
|
return nil, nil, 0, time.Time{}, decodeErr
|
||||||
|
}
|
||||||
|
steps = append(steps, summary)
|
||||||
|
offsets = append(offsets, lineStart)
|
||||||
|
violationCount += partial
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return steps, offsets, violationCount, info.ModTime(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step decodes the full Step record at index n (1-based, matching trace.Step.Index).
|
||||||
|
func (c *Cache) Step(run *Run, index int) (trace.Step, error) {
|
||||||
|
position := -1
|
||||||
|
for i, summary := range run.Steps {
|
||||||
|
if summary.Index == index {
|
||||||
|
position = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if position == -1 {
|
||||||
|
return trace.Step{}, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
file, err := os.Open(run.tracePath)
|
||||||
|
if err != nil {
|
||||||
|
return trace.Step{}, fmt.Errorf("open trace: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if _, err := file.Seek(run.offsets[position], 0); err != nil {
|
||||||
|
return trace.Step{}, fmt.Errorf("seek trace: %w", err)
|
||||||
|
}
|
||||||
|
reader := bufio.NewReaderSize(file, 64*1024)
|
||||||
|
line, err := reader.ReadBytes('\n')
|
||||||
|
if err != nil && len(line) == 0 {
|
||||||
|
return trace.Step{}, fmt.Errorf("read step line: %w", err)
|
||||||
|
}
|
||||||
|
if len(line) > 0 && line[len(line)-1] == '\n' {
|
||||||
|
line = line[:len(line)-1]
|
||||||
|
}
|
||||||
|
var step trace.Step
|
||||||
|
if err := json.Unmarshal(line, &step); err != nil {
|
||||||
|
return trace.Step{}, fmt.Errorf("decode step: %w", err)
|
||||||
|
}
|
||||||
|
return step, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail returns the /api/runs/{id} payload.
|
||||||
|
func (c *Cache) Detail(id string) (RunDetail, error) {
|
||||||
|
run, err := c.Open(id)
|
||||||
|
if err != nil {
|
||||||
|
return RunDetail{}, err
|
||||||
|
}
|
||||||
|
return RunDetail{
|
||||||
|
RunSummary: run.Summary,
|
||||||
|
Meta: run.Meta,
|
||||||
|
Steps: run.Steps,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package inspect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/priyanshujain/sanderling/internal/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readMeta(runDirectory string) (trace.Meta, error) {
|
||||||
|
body, err := os.ReadFile(filepath.Join(runDirectory, "meta.json"))
|
||||||
|
if err != nil {
|
||||||
|
return trace.Meta{}, fmt.Errorf("read meta: %w", err)
|
||||||
|
}
|
||||||
|
var meta trace.Meta
|
||||||
|
if err := json.Unmarshal(body, &meta); err != nil {
|
||||||
|
return trace.Meta{}, fmt.Errorf("decode meta: %w", err)
|
||||||
|
}
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tallyTrace(tracePath string) (steps, violations int, err error) {
|
||||||
|
file, err := os.Open(tracePath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
return 0, 0, fmt.Errorf("open trace: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), maxScanTokenSize)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var partial struct {
|
||||||
|
Violations []string `json:"violations,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(line, &partial); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("decode step: %w", err)
|
||||||
|
}
|
||||||
|
steps++
|
||||||
|
violations += len(partial.Violations)
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("scan trace: %w", err)
|
||||||
|
}
|
||||||
|
return steps, violations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStepSummary(line []byte) (StepSummary, int, error) {
|
||||||
|
var partial struct {
|
||||||
|
Index int `json:"step"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Screen string `json:"screen,omitempty"`
|
||||||
|
Action *struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
X int `json:"x,omitempty"`
|
||||||
|
Y int `json:"y,omitempty"`
|
||||||
|
FromX int `json:"from_x,omitempty"`
|
||||||
|
FromY int `json:"from_y,omitempty"`
|
||||||
|
ToX int `json:"to_x,omitempty"`
|
||||||
|
ToY int `json:"to_y,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Selector string `json:"selector,omitempty"`
|
||||||
|
DurationMillis int `json:"duration_millis,omitempty"`
|
||||||
|
} `json:"action,omitempty"`
|
||||||
|
Exceptions []json.RawMessage `json:"exceptions,omitempty"`
|
||||||
|
Violations []string `json:"violations,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(line, &partial); err != nil {
|
||||||
|
return StepSummary{}, 0, fmt.Errorf("decode step: %w", err)
|
||||||
|
}
|
||||||
|
summary := StepSummary{
|
||||||
|
Index: partial.Index,
|
||||||
|
Timestamp: partial.Timestamp,
|
||||||
|
Screen: partial.Screen,
|
||||||
|
HasViolations: len(partial.Violations) > 0,
|
||||||
|
HasExceptions: len(partial.Exceptions) > 0,
|
||||||
|
}
|
||||||
|
if partial.Action != nil {
|
||||||
|
summary.ActionKind = partial.Action.Kind
|
||||||
|
switch partial.Action.Kind {
|
||||||
|
case "Tap":
|
||||||
|
if partial.Action.Selector != "" {
|
||||||
|
summary.ActionLabel = partial.Action.Selector
|
||||||
|
} else if partial.Action.Text != "" {
|
||||||
|
summary.ActionLabel = partial.Action.Text
|
||||||
|
} else if partial.Action.X != 0 || partial.Action.Y != 0 {
|
||||||
|
summary.ActionLabel = fmt.Sprintf("(%d,%d)", partial.Action.X, partial.Action.Y)
|
||||||
|
}
|
||||||
|
case "InputText":
|
||||||
|
summary.ActionLabel = fmt.Sprintf("%q", partial.Action.Text)
|
||||||
|
case "Swipe":
|
||||||
|
summary.ActionLabel = swipeDirectionLabel(
|
||||||
|
partial.Action.FromX, partial.Action.FromY,
|
||||||
|
partial.Action.ToX, partial.Action.ToY,
|
||||||
|
)
|
||||||
|
case "PressKey":
|
||||||
|
summary.ActionLabel = partial.Action.Key
|
||||||
|
case "Wait":
|
||||||
|
if partial.Action.DurationMillis > 0 {
|
||||||
|
summary.ActionLabel = fmt.Sprintf("%dms", partial.Action.DurationMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary, len(partial.Violations), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func swipeDirectionLabel(fromX, fromY, toX, toY int) string {
|
||||||
|
dx := toX - fromX
|
||||||
|
dy := toY - fromY
|
||||||
|
absX := dx
|
||||||
|
if absX < 0 {
|
||||||
|
absX = -absX
|
||||||
|
}
|
||||||
|
absY := dy
|
||||||
|
if absY < 0 {
|
||||||
|
absY = -absY
|
||||||
|
}
|
||||||
|
if absY >= absX {
|
||||||
|
if dy < 0 {
|
||||||
|
return "up"
|
||||||
|
}
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
if dx < 0 {
|
||||||
|
return "left"
|
||||||
|
}
|
||||||
|
return "right"
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRunID(id string) bool {
|
||||||
|
if id == "" || id == "." || id == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range id {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
case r == '-' || r == '_' || r == '.':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -8,11 +8,6 @@ import (
|
|||||||
"strings"
|
"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 {
|
func newProgressLogger(writer io.Writer) *slog.Logger {
|
||||||
return slog.New(&progressHandler{writer: writer, level: slog.LevelInfo})
|
return slog.New(&progressHandler{writer: writer, level: slog.LevelInfo})
|
||||||
}
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
func TestResolveSpecAPIPath_FindsUpwardSibling(t *testing.T) {
|
func TestResolveSpecAPIPath_FindsUpwardSibling(t *testing.T) {
|
||||||
root := t.TempDir()
|
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 {
|
if err := os.MkdirAll(filepath.Dir(apiPath), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -7,33 +7,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"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 {
|
type extractorState struct {
|
||||||
getter goja.Callable
|
getter goja.Callable
|
||||||
handle *goja.Object
|
handle *goja.Object
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ func bundleIntegrationSpec(t *testing.T) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -241,22 +241,6 @@ type SnapshotInput struct {
|
|||||||
Exceptions []Exception
|
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
|
// EvaluateProperties returns each registered property's running verdict
|
||||||
// after the most recent PushSnapshot. The step time passed in PushSnapshot is
|
// after the most recent PushSnapshot. The step time passed in PushSnapshot is
|
||||||
// forwarded to each evaluator so deadline-bound operators see the snapshot's
|
// 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": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/priyanshujain/sanderling.git",
|
"url": "git+https://github.com/priyanshujain/sanderling.git",
|
||||||
"directory": "pkg/spec-api"
|
"directory": "pkg/spec"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/priyanshujain/sanderling",
|
"homepage": "https://github.com/priyanshujain/sanderling",
|
||||||
"bugs": {
|
"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.
Reference in new issue
Block a user