mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Remove in-app SDK (#43)
* chore: delete internal/agent package
* chore(build): remove sdk-android from gradle settings
* chore(makefile): remove sdk-android targets
* chore(ci): remove release-android job from release workflow
* chore(folio): remove sdk-android dependency
* chore(folio): remove SDK initialization from FolioApplication
* chore(folio): delete snapshot extractor files
* feat(folio): add balance to account card content description
* feat(folio): add hierarchy content descriptions to LedgerScreen
* refactor(folio): rewrite spec.ts to use ax extractors
* docs: remove in-app SDK from README
* feat(folio): add focused_input indicator to App
* docs: remove in-app SDK from index
* refactor(runner): remove agent SDK connection and snapshot step
* test(runner): update tests for SDK removal
* docs: remove Android SDK section from getting-started
* refactor(testrun): remove agent SDK connection setup
* docs: remove snapshots from writing-specs
* docs: remove in-app SDK from architecture doc
* docs(folio): update README for SDK removal
* docs: update per-step cycle diagram in architecture doc
* fix(folio): detect screens from unique element presence, not id: selectors
testTag() in Compose is not exposed as resource-id without testTagsAsResourceId.
Use desc: selectors for elements unique to each screen instead of id: path queries.
* feat(folio): add screen root contentDescription for scoped ax selection
Each screen root gets semantics { contentDescription = "ScreenName" } so
sanderling specs can scope element lookups through the screen: desc:LoginScreen > desc:login_submit.
* fix(folio): scope all ax selectors through screen root nodes
Use desc:ScreenName > desc:element path queries so every selector is
rooted at the screen level. focusedInput stays unscoped since it lives
in the app root, outside any screen.
* fix(folio): guard newAccountBalanceIsZero against navigation false positives
Scoped selectors return [] when not on HomeScreen so accounts vanish and
reappear as apparently-new on each visit. Skip the check when prev was empty.
* chore(folio): link @sanderling/spec to local pkg/spec for IDE type checking
* feat(spec): add desc, class, clickable, enabled, checked, focused, selected to AccessibilityElement
Runtime fields set by the verifier were missing from the TypeScript type,
causing linting errors on el.desc and related accesses in specs.
* chore(folio): switch to bun, add tsconfig.json for IDE type checking
- Remove package-lock.json, add bun.lock
- Add tsconfig.json so VSCode resolves @sanderling/spec types
- Fix parseAccount/parseLedgerRow to accept string | undefined
This commit is contained in:
60 files changed
+298
-3354
No files matched your search
@@ -1,127 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
MessageTypeHello MessageType = "HELLO"
|
||||
MessageTypePause MessageType = "PAUSE"
|
||||
MessageTypeResume MessageType = "RESUME"
|
||||
MessageTypeState MessageType = "STATE"
|
||||
MessageTypeExtractResult MessageType = "EXTRACT_RESULT"
|
||||
MessageTypeGoodbye MessageType = "GOODBYE"
|
||||
)
|
||||
|
||||
const MaxFrameSize = 16 * 1024 * 1024
|
||||
|
||||
// ProtocolVersion is the wire-format version. Bump on any breaking change
|
||||
// to the message schema or framing. Independent of the SDK release version.
|
||||
const ProtocolVersion = 1
|
||||
|
||||
type Message struct {
|
||||
Type MessageType `json:"type"`
|
||||
ID uint64 `json:"id,omitempty"`
|
||||
|
||||
ProtocolVersion int `json:"protocol_version,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
AppPackage string `json:"app_package,omitempty"`
|
||||
|
||||
Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
|
||||
Exceptions []Exception `json:"exceptions,omitempty"`
|
||||
|
||||
Extractor string `json:"extractor,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Exception mirrors an uncaught throwable captured by the SDK.
|
||||
type Exception struct {
|
||||
Class string `json:"class"`
|
||||
Message string `json:"message,omitempty"`
|
||||
StackTrace string `json:"stack_trace,omitempty"`
|
||||
UnixMillis int64 `json:"unix_millis,omitempty"`
|
||||
}
|
||||
|
||||
func Hello(version, platform, appPackage string) Message {
|
||||
return Message{
|
||||
Type: MessageTypeHello,
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
Version: version,
|
||||
Platform: platform,
|
||||
AppPackage: appPackage,
|
||||
}
|
||||
}
|
||||
|
||||
func Pause(id uint64) Message { return Message{Type: MessageTypePause, ID: id} }
|
||||
|
||||
func Resume(id uint64) Message { return Message{Type: MessageTypeResume, ID: id} }
|
||||
|
||||
func State(id uint64, snapshots map[string]json.RawMessage) Message {
|
||||
return Message{Type: MessageTypeState, ID: id, Snapshots: snapshots}
|
||||
}
|
||||
|
||||
func ExtractResult(id uint64, extractor string, result json.RawMessage, extractorError string) Message {
|
||||
return Message{
|
||||
Type: MessageTypeExtractResult,
|
||||
ID: id,
|
||||
Extractor: extractor,
|
||||
Result: result,
|
||||
Error: extractorError,
|
||||
}
|
||||
}
|
||||
|
||||
func Goodbye(reason string) Message {
|
||||
return Message{Type: MessageTypeGoodbye, Reason: reason}
|
||||
}
|
||||
|
||||
func WriteMessage(writer io.Writer, message Message) error {
|
||||
payload, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
if len(payload) > MaxFrameSize {
|
||||
return fmt.Errorf("frame of %d bytes exceeds maximum %d", len(payload), MaxFrameSize)
|
||||
}
|
||||
var header [4]byte
|
||||
binary.BigEndian.PutUint32(header[:], uint32(len(payload)))
|
||||
if _, err := writer.Write(header[:]); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
return fmt.Errorf("write payload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadMessage(reader io.Reader) (Message, error) {
|
||||
var header [4]byte
|
||||
if _, err := io.ReadFull(reader, header[:]); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
length := binary.BigEndian.Uint32(header[:])
|
||||
if length > MaxFrameSize {
|
||||
return Message{}, fmt.Errorf("frame of %d bytes exceeds maximum %d", length, MaxFrameSize)
|
||||
}
|
||||
payload := make([]byte, length)
|
||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
||||
return Message{}, fmt.Errorf("read payload: %w", err)
|
||||
}
|
||||
var message Message
|
||||
if err := json.Unmarshal(payload, &message); err != nil {
|
||||
return Message{}, fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
if message.Type == "" {
|
||||
return Message{}, errors.New("missing type")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func roundTrip(t *testing.T, message Message) Message {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
if err := WriteMessage(&buffer, message); err != nil {
|
||||
t.Fatalf("WriteMessage: %v", err)
|
||||
}
|
||||
got, err := ReadMessage(&buffer)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMessage: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestRoundTrip_Hello(t *testing.T) {
|
||||
got := roundTrip(t, Hello("0.0.1", "android", "in.okcredit.merchant"))
|
||||
if got.Type != MessageTypeHello || got.Version != "0.0.1" || got.Platform != "android" || got.AppPackage != "in.okcredit.merchant" {
|
||||
t.Fatalf("hello round-trip failed: %+v", got)
|
||||
}
|
||||
if got.ProtocolVersion != ProtocolVersion {
|
||||
t.Errorf("protocol_version: got %d, want %d", got.ProtocolVersion, ProtocolVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_PauseResume(t *testing.T) {
|
||||
for _, builder := range []func(uint64) Message{Pause, Resume} {
|
||||
got := roundTrip(t, builder(42))
|
||||
if got.ID != 42 {
|
||||
t.Errorf("id round-trip failed: %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_State(t *testing.T) {
|
||||
snapshots := map[string]json.RawMessage{
|
||||
"screen": json.RawMessage(`"customer_ledger"`),
|
||||
"ledger.balance": json.RawMessage(`1500`),
|
||||
"is_signed_in": json.RawMessage(`true`),
|
||||
}
|
||||
got := roundTrip(t, State(7, snapshots))
|
||||
if got.Type != MessageTypeState || got.ID != 7 {
|
||||
t.Fatalf("state envelope wrong: %+v", got)
|
||||
}
|
||||
if string(got.Snapshots["screen"]) != `"customer_ledger"` {
|
||||
t.Errorf("screen snapshot wrong: %s", got.Snapshots["screen"])
|
||||
}
|
||||
if string(got.Snapshots["ledger.balance"]) != `1500` {
|
||||
t.Errorf("balance snapshot wrong: %s", got.Snapshots["ledger.balance"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_ExtractResult(t *testing.T) {
|
||||
got := roundTrip(t, ExtractResult(1, "ledger.balance", json.RawMessage(`2500`), ""))
|
||||
if got.Extractor != "ledger.balance" || string(got.Result) != `2500` {
|
||||
t.Fatalf("extract result round-trip failed: %+v", got)
|
||||
}
|
||||
|
||||
failed := roundTrip(t, ExtractResult(2, "ledger.balance", nil, "no active customer"))
|
||||
if failed.Error != "no active customer" {
|
||||
t.Errorf("extract error round-trip failed: %+v", failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_Goodbye(t *testing.T) {
|
||||
got := roundTrip(t, Goodbye("app terminated"))
|
||||
if got.Type != MessageTypeGoodbye || got.Reason != "app terminated" {
|
||||
t.Fatalf("goodbye round-trip failed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMessage_FrameFormat(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
if err := WriteMessage(&buffer, Pause(99)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := buffer.Bytes()
|
||||
if len(raw) < 4 {
|
||||
t.Fatalf("frame too short: %d bytes", len(raw))
|
||||
}
|
||||
length := binary.BigEndian.Uint32(raw[:4])
|
||||
if int(length) != len(raw)-4 {
|
||||
t.Errorf("header length %d mismatches payload length %d", length, len(raw)-4)
|
||||
}
|
||||
if !strings.Contains(string(raw[4:]), `"type":"PAUSE"`) {
|
||||
t.Errorf("payload does not contain PAUSE type: %s", raw[4:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMessage_ShortReaderReturnsEOF(t *testing.T) {
|
||||
_, err := ReadMessage(bytes.NewReader(nil))
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Errorf("expected EOF on empty reader, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMessage_OversizedFrameRejected(t *testing.T) {
|
||||
var header [4]byte
|
||||
binary.BigEndian.PutUint32(header[:], uint32(MaxFrameSize+1))
|
||||
_, err := ReadMessage(bytes.NewReader(header[:]))
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds maximum") {
|
||||
t.Errorf("expected oversized-frame error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMessage_MissingTypeRejected(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
payload := []byte(`{"id":1}`)
|
||||
var header [4]byte
|
||||
binary.BigEndian.PutUint32(header[:], uint32(len(payload)))
|
||||
buffer.Write(header[:])
|
||||
buffer.Write(payload)
|
||||
|
||||
_, err := ReadMessage(&buffer)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing type") {
|
||||
t.Errorf("expected missing-type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMessage_StreamsMultipleFrames(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
messages := []Message{
|
||||
Hello("v", "android", "com.x"),
|
||||
Pause(1),
|
||||
State(1, map[string]json.RawMessage{"x": json.RawMessage(`42`)}),
|
||||
Resume(1),
|
||||
Goodbye("done"),
|
||||
}
|
||||
for _, message := range messages {
|
||||
if err := WriteMessage(&buffer, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for index, want := range messages {
|
||||
got, err := ReadMessage(&buffer)
|
||||
if err != nil {
|
||||
t.Fatalf("frame %d: %v", index, err)
|
||||
}
|
||||
if got.Type != want.Type {
|
||||
t.Errorf("frame %d: got type %q, want %q", index, got.Type, want.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
func NewServer(listener net.Listener) *Server {
|
||||
return &Server{listener: listener}
|
||||
}
|
||||
|
||||
func (s *Server) Addr() net.Addr { return s.listener.Addr() }
|
||||
|
||||
// Accept waits for the next SDK client and performs the HELLO handshake.
|
||||
// Only one Conn may be active at a time; subsequent Accepts block until the
|
||||
// current connection closes.
|
||||
func (s *Server) Accept(ctx context.Context) (*Conn, error) {
|
||||
cancelCloser := closeListenerOnCancel(ctx, s.listener)
|
||||
defer cancelCloser()
|
||||
|
||||
rawConn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("accept: %w", err)
|
||||
}
|
||||
hello, err := readWithDeadline(ctx, rawConn)
|
||||
if err != nil {
|
||||
rawConn.Close()
|
||||
return nil, fmt.Errorf("read hello: %w", err)
|
||||
}
|
||||
if hello.Type != MessageTypeHello {
|
||||
rawConn.Close()
|
||||
return nil, fmt.Errorf("expected HELLO, got %q", hello.Type)
|
||||
}
|
||||
if hello.ProtocolVersion != ProtocolVersion {
|
||||
rawConn.Close()
|
||||
return nil, fmt.Errorf("protocol version mismatch: host=%d sdk=%d", ProtocolVersion, hello.ProtocolVersion)
|
||||
}
|
||||
return &Conn{rawConn: rawConn, hello: hello}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Close() error { return s.listener.Close() }
|
||||
|
||||
type Conn struct {
|
||||
rawConn net.Conn
|
||||
hello Message
|
||||
nextID uint64
|
||||
}
|
||||
|
||||
func (c *Conn) Hello() Message { return c.hello }
|
||||
|
||||
func (c *Conn) RemoteAddr() net.Addr { return c.rawConn.RemoteAddr() }
|
||||
|
||||
// Snapshot sends PAUSE with a fresh id and blocks until the SDK returns the
|
||||
// matching STATE. The SDK's main thread stays paused until Release is called.
|
||||
func (c *Conn) Snapshot(ctx context.Context) (Message, error) {
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
|
||||
if err := writeWithDeadline(ctx, c.rawConn, Pause(id)); err != nil {
|
||||
return Message{}, fmt.Errorf("send pause: %w", err)
|
||||
}
|
||||
message, err := readWithDeadline(ctx, c.rawConn)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("read state: %w", err)
|
||||
}
|
||||
if message.Type != MessageTypeState {
|
||||
return Message{}, fmt.Errorf("expected STATE, got %q", message.Type)
|
||||
}
|
||||
if message.ID != id {
|
||||
return Message{}, fmt.Errorf("state id mismatch: sent %d, got %d", id, message.ID)
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// Release sends RESUME, freeing the SDK's paused main thread.
|
||||
func (c *Conn) Release(ctx context.Context) error {
|
||||
return writeWithDeadline(ctx, c.rawConn, Resume(c.nextID))
|
||||
}
|
||||
|
||||
// Close sends GOODBYE (best effort) and closes the underlying connection.
|
||||
func (c *Conn) Close() error {
|
||||
_ = writeWithDeadline(context.Background(), c.rawConn, Goodbye("shutdown"))
|
||||
return c.rawConn.Close()
|
||||
}
|
||||
|
||||
func readWithDeadline(ctx context.Context, conn net.Conn) (Message, error) {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetReadDeadline(deadline)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
defer close(exited)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = conn.SetReadDeadline(time.Unix(1, 0))
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
message, err := ReadMessage(conn)
|
||||
close(done)
|
||||
<-exited
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
if err != nil && ctx.Err() != nil {
|
||||
return Message{}, ctx.Err()
|
||||
}
|
||||
return message, err
|
||||
}
|
||||
|
||||
func writeWithDeadline(ctx context.Context, conn net.Conn, message Message) error {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetWriteDeadline(deadline)
|
||||
defer conn.SetWriteDeadline(time.Time{})
|
||||
}
|
||||
err := WriteMessage(conn, message)
|
||||
if err != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func closeListenerOnCancel(ctx context.Context, listener net.Listener) (cancel func()) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = listener.Close()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
|
||||
// ErrClosed is returned when a Conn method is called after Close.
|
||||
var ErrClosed = errors.New("agent: connection closed")
|
||||
@@ -1,347 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeSDK drives the client side of an agent connection the way the real SDK
|
||||
// would: HELLO on connect, then respond to PAUSE with STATE, honor RESUME,
|
||||
// and close on GOODBYE.
|
||||
type fakeSDK struct {
|
||||
conn net.Conn
|
||||
snapshotFunc func(id uint64) map[string]json.RawMessage
|
||||
}
|
||||
|
||||
func (f *fakeSDK) sendHello(version, platform, appPackage string) error {
|
||||
return WriteMessage(f.conn, Hello(version, platform, appPackage))
|
||||
}
|
||||
|
||||
func (f *fakeSDK) serveOne() error {
|
||||
message, err := ReadMessage(f.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch message.Type {
|
||||
case MessageTypePause:
|
||||
snapshots := f.snapshotFunc(message.ID)
|
||||
return WriteMessage(f.conn, State(message.ID, snapshots))
|
||||
case MessageTypeResume:
|
||||
return nil
|
||||
case MessageTypeGoodbye:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newLoopbackServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
return NewServer(listener)
|
||||
}
|
||||
|
||||
func TestServer_AcceptHandshake(t *testing.T) {
|
||||
server := newLoopbackServer(t)
|
||||
|
||||
connectErr := make(chan error, 1)
|
||||
go func() {
|
||||
client, err := net.Dial("tcp", server.Addr().String())
|
||||
if err != nil {
|
||||
connectErr <- err
|
||||
return
|
||||
}
|
||||
sdk := &fakeSDK{conn: client}
|
||||
connectErr <- sdk.sendHello("0.0.1", "android", "com.example")
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
conn, err := server.Accept(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if got := conn.Hello(); got.Type != MessageTypeHello || got.Version != "0.0.1" || got.AppPackage != "com.example" {
|
||||
t.Errorf("unexpected hello: %+v", got)
|
||||
}
|
||||
if err := <-connectErr; err != nil {
|
||||
t.Fatalf("client side: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_SnapshotAndRelease(t *testing.T) {
|
||||
server := newLoopbackServer(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
client, err := net.Dial("tcp", server.Addr().String())
|
||||
if err != nil {
|
||||
t.Errorf("dial: %v", err)
|
||||
return
|
||||
}
|
||||
sdk := &fakeSDK{
|
||||
conn: client,
|
||||
snapshotFunc: func(id uint64) map[string]json.RawMessage {
|
||||
return map[string]json.RawMessage{
|
||||
"screen": json.RawMessage(`"home"`),
|
||||
"ledger.balance": json.RawMessage(`1500`),
|
||||
}
|
||||
},
|
||||
}
|
||||
if err := sdk.sendHello("0.0.1", "android", "com.x"); err != nil {
|
||||
t.Errorf("hello: %v", err)
|
||||
return
|
||||
}
|
||||
for range 2 {
|
||||
if err := sdk.serveOne(); err != nil {
|
||||
t.Errorf("pause: %v", err)
|
||||
return
|
||||
}
|
||||
if err := sdk.serveOne(); err != nil {
|
||||
t.Errorf("resume: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conn, err := server.Accept(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
for expected := uint64(1); expected <= 2; expected++ {
|
||||
state, err := conn.Snapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot #%d: %v", expected, err)
|
||||
}
|
||||
if state.ID != expected {
|
||||
t.Errorf("snapshot #%d: id=%d", expected, state.ID)
|
||||
}
|
||||
if string(state.Snapshots["screen"]) != `"home"` {
|
||||
t.Errorf("snapshot #%d: screen=%s", expected, state.Snapshots["screen"])
|
||||
}
|
||||
if err := conn.Release(ctx); err != nil {
|
||||
t.Fatalf("Release #%d: %v", expected, err)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestServer_AcceptRejectsProtocolVersionMismatch(t *testing.T) {
|
||||
server := newLoopbackServer(t)
|
||||
|
||||
go func() {
|
||||
client, err := net.Dial("tcp", server.Addr().String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
mismatched := Hello("0.0.1", "android", "com.x")
|
||||
mismatched.ProtocolVersion = ProtocolVersion + 99
|
||||
_ = WriteMessage(client, mismatched)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_, err := server.Accept(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "protocol version mismatch") {
|
||||
t.Fatalf("expected protocol-version-mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_AcceptRequiresHello(t *testing.T) {
|
||||
server := newLoopbackServer(t)
|
||||
|
||||
go func() {
|
||||
client, err := net.Dial("tcp", server.Addr().String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
// Send a PAUSE instead of HELLO — server should reject.
|
||||
_ = WriteMessage(client, Pause(1))
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_, err := server.Accept(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "expected HELLO") {
|
||||
t.Fatalf("expected HELLO-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_AcceptCancelsOnContext(t *testing.T) {
|
||||
server := newLoopbackServer(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
acceptErr := make(chan error, 1)
|
||||
go func() { _, err := server.Accept(ctx); acceptErr <- err }()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-acceptErr:
|
||||
if err == nil {
|
||||
t.Errorf("expected error after cancel, got nil")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Errorf("accept did not return after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SnapshotRejectsIDMismatch(t *testing.T) {
|
||||
server := newLoopbackServer(t)
|
||||
|
||||
go func() {
|
||||
client, _ := net.Dial("tcp", server.Addr().String())
|
||||
defer client.Close()
|
||||
_ = WriteMessage(client, Hello("0.0.1", "android", "com.x"))
|
||||
// Read the PAUSE but respond with a wrong id.
|
||||
msg, _ := ReadMessage(client)
|
||||
_ = WriteMessage(client, State(msg.ID+99, map[string]json.RawMessage{}))
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
conn, err := server.Accept(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, err = conn.Snapshot(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "id mismatch") {
|
||||
t.Errorf("expected id-mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_CloseSendsGoodbye(t *testing.T) {
|
||||
server := newLoopbackServer(t)
|
||||
|
||||
received := make(chan Message, 1)
|
||||
go func() {
|
||||
client, _ := net.Dial("tcp", server.Addr().String())
|
||||
defer client.Close()
|
||||
_ = WriteMessage(client, Hello("0.0.1", "android", "com.x"))
|
||||
// Drain until GOODBYE.
|
||||
for {
|
||||
msg, err := ReadMessage(client)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if msg.Type == MessageTypeGoodbye {
|
||||
received <- msg
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
conn, err := server.Accept(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-received:
|
||||
if msg.Reason != "shutdown" {
|
||||
t.Errorf("expected reason=shutdown, got %q", msg.Reason)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("client did not receive GOODBYE")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConn_SnapshotAfterAcceptContextCancel guards against a race in
|
||||
// readWithDeadline where the watcher goroutine from Accept could clobber the
|
||||
// conn's read deadline with a past time after Accept returned, causing the
|
||||
// next read on the same conn (Snapshot) to time out instantly.
|
||||
func TestConn_SnapshotAfterAcceptContextCancel(t *testing.T) {
|
||||
for iteration := range 50 {
|
||||
server := newLoopbackServer(t)
|
||||
|
||||
clientDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(clientDone)
|
||||
client, err := net.Dial("tcp", server.Addr().String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
if err := WriteMessage(client, Hello("0.0.1", "android", "com.x")); err != nil {
|
||||
return
|
||||
}
|
||||
msg, err := ReadMessage(client)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = WriteMessage(client, State(msg.ID, map[string]json.RawMessage{"ok": json.RawMessage(`true`)}))
|
||||
}()
|
||||
|
||||
acceptCtx, acceptCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
conn, err := server.Accept(acceptCtx)
|
||||
acceptCancel()
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: Accept: %v", iteration, err)
|
||||
}
|
||||
|
||||
snapCtx, snapCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
state, err := conn.Snapshot(snapCtx)
|
||||
snapCancel()
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: Snapshot: %v", iteration, err)
|
||||
}
|
||||
if string(state.Snapshots["ok"]) != `true` {
|
||||
t.Errorf("iteration %d: unexpected snapshots: %v", iteration, state.Snapshots)
|
||||
}
|
||||
conn.Close()
|
||||
<-clientDone
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SnapshotTimesOutIfSDKSilent(t *testing.T) {
|
||||
server := newLoopbackServer(t)
|
||||
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() { close(done) })
|
||||
go func() {
|
||||
client, _ := net.Dial("tcp", server.Addr().String())
|
||||
defer client.Close()
|
||||
_ = WriteMessage(client, Hello("0.0.1", "android", "com.x"))
|
||||
// Never respond to PAUSE; stay alive until the test ends.
|
||||
<-done
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
conn, err := server.Accept(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
fastCtx, fastCancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
||||
defer fastCancel()
|
||||
_, err = conn.Snapshot(fastCtx)
|
||||
if err == nil {
|
||||
t.Errorf("expected timeout error, got nil")
|
||||
}
|
||||
}
|
||||
+18
-105
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/agent"
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||
"github.com/priyanshujain/sanderling/internal/ltl"
|
||||
@@ -20,12 +19,10 @@ import (
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Duration time.Duration
|
||||
SnapshotTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
Duration time.Duration
|
||||
IdleTimeout time.Duration
|
||||
|
||||
BundleID string
|
||||
Connection *agent.Conn
|
||||
Driver driver.DeviceDriver
|
||||
Verifier *verifier.Verifier
|
||||
TraceWriter *trace.Writer
|
||||
@@ -44,10 +41,9 @@ type ViolationRecord struct {
|
||||
Properties []string
|
||||
}
|
||||
|
||||
// Run drives the snapshot/evaluate/release/act loop until the duration
|
||||
// elapses or the context is canceled. The caller is responsible for
|
||||
// launching the app and connecting the SDK before Run is called, and for
|
||||
// terminating the app afterwards.
|
||||
// Run drives the evaluate/act loop until the duration elapses or the context
|
||||
// is canceled. The caller is responsible for launching the app before Run is
|
||||
// called and for terminating it afterwards.
|
||||
func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
if err := validate(options); err != nil {
|
||||
return Summary{}, err
|
||||
@@ -71,10 +67,8 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
stepIndex++
|
||||
stepStart := time.Now()
|
||||
|
||||
// Hierarchy, metrics, and logs are independent device reads. Run
|
||||
// them concurrently so metrics+logs hide behind the hierarchy
|
||||
// fetch (~2s). All three must finish before snapshotStep pauses
|
||||
// the SDK.
|
||||
// Hierarchy, metrics, and logs are independent device reads — run
|
||||
// them concurrently so metrics+logs hide behind the hierarchy fetch.
|
||||
var tree *hierarchy.Tree
|
||||
var hierarchyErr error
|
||||
var metrics *trace.Metrics
|
||||
@@ -115,31 +109,20 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
if tree != nil {
|
||||
treeSize = len(tree.Elements)
|
||||
}
|
||||
|
||||
snapshot, err := snapshotStep(ctx, options)
|
||||
if err != nil {
|
||||
return summary, fmt.Errorf("step %d snapshot: %w", stepIndex, err)
|
||||
}
|
||||
lastLogTime = stepStart
|
||||
|
||||
exceptions := decodeExceptions(snapshot)
|
||||
|
||||
if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{
|
||||
Snapshots: verifier.Snapshots(snapshot.Snapshots),
|
||||
Tree: tree,
|
||||
LastAction: lastAction,
|
||||
StepTime: stepStart,
|
||||
RunStart: summary.StartTime,
|
||||
Logs: logs,
|
||||
Exceptions: exceptions,
|
||||
}); err != nil {
|
||||
return summary, fmt.Errorf("step %d push: %w", stepIndex, err)
|
||||
}
|
||||
screen, screenErr := screenFromSnapshot(snapshot.Snapshots)
|
||||
if screenErr != nil {
|
||||
logger.Warn("screen snapshot decode failed", "step", stepIndex, "err", screenErr)
|
||||
}
|
||||
if screen == "" && tree != nil && len(tree.Elements) > 0 {
|
||||
|
||||
screen := ""
|
||||
if tree != nil && len(tree.Elements) > 0 {
|
||||
screen = tree.Elements[0].Screen
|
||||
}
|
||||
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
|
||||
@@ -165,16 +148,14 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
}
|
||||
|
||||
step := trace.Step{
|
||||
Index: stepIndex,
|
||||
Timestamp: stepStart,
|
||||
Screen: screen,
|
||||
Snapshots: snapshot.Snapshots,
|
||||
Action: traceAction,
|
||||
Exceptions: traceExceptions(exceptions),
|
||||
Index: stepIndex,
|
||||
Timestamp: stepStart,
|
||||
Screen: screen,
|
||||
Action: traceAction,
|
||||
Violations: violations,
|
||||
Hierarchy: tree,
|
||||
Residuals: residuals,
|
||||
Metrics: metrics,
|
||||
Hierarchy: tree,
|
||||
Residuals: residuals,
|
||||
Metrics: metrics,
|
||||
}
|
||||
if err := options.TraceWriter.WriteStep(step); err != nil {
|
||||
return summary, fmt.Errorf("step %d trace: %w", stepIndex, err)
|
||||
@@ -188,12 +169,6 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
})
|
||||
}
|
||||
|
||||
if options.Connection != nil {
|
||||
if err := options.Connection.Release(ctx); err != nil {
|
||||
return summary, fmt.Errorf("step %d release: %w", stepIndex, err)
|
||||
}
|
||||
}
|
||||
|
||||
if nextErr == nil {
|
||||
if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
|
||||
if isWDADrop(err) {
|
||||
@@ -240,28 +215,12 @@ func validate(options Options) error {
|
||||
if options.Duration <= 0 {
|
||||
return errors.New("runner: Duration must be positive")
|
||||
}
|
||||
if options.SnapshotTimeout <= 0 {
|
||||
options.SnapshotTimeout = 5 * time.Second
|
||||
}
|
||||
if options.IdleTimeout <= 0 {
|
||||
options.IdleTimeout = 2 * time.Second
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotStep(ctx context.Context, options Options) (agent.Message, error) {
|
||||
if options.Connection == nil {
|
||||
return agent.Message{}, nil
|
||||
}
|
||||
snapshotTimeout := options.SnapshotTimeout
|
||||
if snapshotTimeout <= 0 {
|
||||
snapshotTimeout = 5 * time.Second
|
||||
}
|
||||
snapshotCtx, snapshotCancel := context.WithTimeout(ctx, snapshotTimeout)
|
||||
defer snapshotCancel()
|
||||
return options.Connection.Snapshot(snapshotCtx)
|
||||
}
|
||||
|
||||
func violationNames(verdicts map[string]ltl.Verdict) []string {
|
||||
var names []string
|
||||
for name, verdict := range verdicts {
|
||||
@@ -272,18 +231,6 @@ func violationNames(verdicts map[string]ltl.Verdict) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func screenFromSnapshot(snapshots map[string]json.RawMessage) (string, error) {
|
||||
raw, ok := snapshots["screen"]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
var screen string
|
||||
if err := json.Unmarshal(raw, &screen); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return screen, nil
|
||||
}
|
||||
|
||||
func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error {
|
||||
switch action.Kind {
|
||||
case verifier.ActionKindTap:
|
||||
@@ -355,22 +302,6 @@ func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time)
|
||||
return result
|
||||
}
|
||||
|
||||
func decodeExceptions(snapshot agent.Message) []verifier.Exception {
|
||||
if len(snapshot.Exceptions) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]verifier.Exception, 0, len(snapshot.Exceptions))
|
||||
for _, e := range snapshot.Exceptions {
|
||||
result = append(result, verifier.Exception{
|
||||
Class: e.Class,
|
||||
Message: e.Message,
|
||||
StackTrace: e.StackTrace,
|
||||
UnixMillis: e.UnixMillis,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
|
||||
if action.X > 0 && action.Y > 0 {
|
||||
return action.X, action.Y, true
|
||||
@@ -421,9 +352,7 @@ func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action
|
||||
}
|
||||
|
||||
// stampSelectorTarget mirrors applyAction's coordinate-resolution rule so the
|
||||
// trace records the same point the runner taps. When the spec passed an ax
|
||||
// element directly, action.X/Y are already populated and we use them; when the
|
||||
// spec passed a string selector, we resolve it against the captured hierarchy.
|
||||
// trace records the same point the runner taps.
|
||||
func stampSelectorTarget(traceAction *trace.Action, action verifier.Action, tree *hierarchy.Tree) {
|
||||
if action.X > 0 && action.Y > 0 {
|
||||
traceAction.TapPoint = &trace.PointRecord{X: action.X, Y: action.Y}
|
||||
@@ -512,19 +441,3 @@ func isWDADrop(err error) bool {
|
||||
return strings.Contains(msg, "ConnectException") ||
|
||||
(strings.Contains(msg, "code = Internal") && strings.Contains(msg, "SocketException"))
|
||||
}
|
||||
|
||||
func traceExceptions(exceptions []verifier.Exception) []trace.Exception {
|
||||
if len(exceptions) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]trace.Exception, 0, len(exceptions))
|
||||
for _, e := range exceptions {
|
||||
result = append(result, trace.Exception{
|
||||
Class: e.Class,
|
||||
Message: e.Message,
|
||||
StackTrace: e.StackTrace,
|
||||
UnixMillis: e.UnixMillis,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
+56
-233
@@ -3,20 +3,16 @@ package runner
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/agent"
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
@@ -24,7 +20,6 @@ import (
|
||||
)
|
||||
|
||||
const fixtureSpec = `
|
||||
const screen = __sanderling__.extract(state => state.snapshots.screen ?? "");
|
||||
const balance = __sanderling__.extract(state => state.snapshots.balance ?? 0);
|
||||
globalThis.properties = {
|
||||
balanceNonNegative: __sanderling__.always(() => balance.current >= 0),
|
||||
@@ -32,28 +27,25 @@ globalThis.properties = {
|
||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:next" })]);
|
||||
`
|
||||
|
||||
const violationSpec = `
|
||||
globalThis.properties = {
|
||||
balanceNonNegative: __sanderling__.always(() => false),
|
||||
};
|
||||
globalThis.actions = __sanderling__.actions(() => []);
|
||||
`
|
||||
|
||||
type harness struct {
|
||||
server *agent.Server
|
||||
listener net.Listener
|
||||
clientWG sync.WaitGroup
|
||||
conn *agent.Conn
|
||||
mock *mockdriver.Driver
|
||||
verifier *verifier.Verifier
|
||||
writer *trace.Writer
|
||||
snapshot []map[string]json.RawMessage
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, snapshots []map[string]json.RawMessage) *harness {
|
||||
return newHarnessWithSpec(t, snapshots, fixtureSpec)
|
||||
func newHarness(t *testing.T) *harness {
|
||||
return newHarnessWithSpec(t, fixtureSpec)
|
||||
}
|
||||
|
||||
func newHarnessWithSpec(t *testing.T, snapshots []map[string]json.RawMessage, spec string) *harness {
|
||||
func newHarnessWithSpec(t *testing.T, spec string) *harness {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := agent.NewServer(listener)
|
||||
directory := t.TempDir()
|
||||
writer, err := trace.NewWriter(directory)
|
||||
if err != nil {
|
||||
@@ -67,84 +59,25 @@ func newHarnessWithSpec(t *testing.T, snapshots []map[string]json.RawMessage, sp
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := &harness{
|
||||
server: server,
|
||||
listener: listener,
|
||||
mock: mockdriver.New(),
|
||||
verifier: verifierInstance,
|
||||
writer: writer,
|
||||
snapshot: snapshots,
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
_ = writer.Close()
|
||||
})
|
||||
t.Cleanup(func() { _ = writer.Close() })
|
||||
return state
|
||||
}
|
||||
|
||||
func (h *harness) startSDK(t *testing.T) {
|
||||
t.Helper()
|
||||
h.clientWG.Go(func() {
|
||||
conn, err := net.Dial("tcp", h.listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Errorf("dial: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := agent.WriteMessage(conn, agent.Hello("0.0.1", "android", "com.fixture")); err != nil {
|
||||
t.Errorf("hello: %v", err)
|
||||
return
|
||||
}
|
||||
index := 0
|
||||
for {
|
||||
message, err := agent.ReadMessage(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if message.Type == agent.MessageTypePause {
|
||||
snapshots := map[string]json.RawMessage{}
|
||||
if index < len(h.snapshot) {
|
||||
snapshots = h.snapshot[index]
|
||||
}
|
||||
if err := agent.WriteMessage(conn, agent.State(message.ID, snapshots)); err != nil {
|
||||
return
|
||||
}
|
||||
index++
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *harness) acceptConnection(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
connection, err := h.server.Accept(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
h.conn = connection
|
||||
}
|
||||
|
||||
func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
|
||||
snapshots := []map[string]json.RawMessage{
|
||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
|
||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`200`)},
|
||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`300`)},
|
||||
}
|
||||
state := newHarness(t, snapshots)
|
||||
state.startSDK(t)
|
||||
state.acceptConnection(t)
|
||||
state := newHarness(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: 100 * time.Millisecond,
|
||||
SnapshotTimeout: 2 * time.Second,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Connection: state.conn,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
Duration: 100 * time.Millisecond,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -163,25 +96,16 @@ func TestRunner_HappyPathStepsAndTraces(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
|
||||
snapshots := []map[string]json.RawMessage{
|
||||
{"balance": json.RawMessage(`100`)},
|
||||
{"balance": json.RawMessage(`-1`)},
|
||||
{"balance": json.RawMessage(`50`)},
|
||||
}
|
||||
state := newHarness(t, snapshots)
|
||||
state.startSDK(t)
|
||||
state.acceptConnection(t)
|
||||
state := newHarnessWithSpec(t, violationSpec)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: 100 * time.Millisecond,
|
||||
SnapshotTimeout: 2 * time.Second,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Connection: state.conn,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
Duration: 100 * time.Millisecond,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -201,9 +125,7 @@ globalThis.properties = {
|
||||
};
|
||||
globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:next" })]);
|
||||
`
|
||||
state := newHarnessWithSpec(t, []map[string]json.RawMessage{{}, {}}, throwingSpec)
|
||||
state.startSDK(t)
|
||||
state.acceptConnection(t)
|
||||
state := newHarnessWithSpec(t, throwingSpec)
|
||||
|
||||
var buffer bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
@@ -211,14 +133,12 @@ globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: 100 * time.Millisecond,
|
||||
SnapshotTimeout: 2 * time.Second,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Connection: state.conn,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
Logger: logger,
|
||||
Duration: 100 * time.Millisecond,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -240,7 +160,6 @@ func TestRunner_RejectsMissingFields(t *testing.T) {
|
||||
|
||||
func TestRunner_RejectsZeroDuration(t *testing.T) {
|
||||
_, err := Run(context.Background(), Options{
|
||||
Connection: &agent.Conn{},
|
||||
Driver: mockdriver.New(),
|
||||
Verifier: mustNewVerifier(t),
|
||||
TraceWriter: mustNewTraceWriter(t),
|
||||
@@ -250,89 +169,18 @@ func TestRunner_RejectsZeroDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_RecordsScreenFieldFromSnapshot(t *testing.T) {
|
||||
snapshots := []map[string]json.RawMessage{
|
||||
{"screen": json.RawMessage(`"customer_ledger"`), "balance": json.RawMessage(`1`)},
|
||||
}
|
||||
state := newHarness(t, snapshots)
|
||||
state.startSDK(t)
|
||||
state.acceptConnection(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := Run(ctx, Options{
|
||||
Duration: 100 * time.Millisecond,
|
||||
SnapshotTimeout: 2 * time.Second,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Connection: state.conn,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"screen":"customer_ledger"`) {
|
||||
t.Errorf("screen field not in trace: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenFromSnapshot(t *testing.T) {
|
||||
t.Run("string value returns screen", func(t *testing.T) {
|
||||
snapshots := map[string]json.RawMessage{"screen": json.RawMessage(`"home"`)}
|
||||
screen, err := screenFromSnapshot(snapshots)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if screen != "home" {
|
||||
t.Errorf("screen = %q, want %q", screen, "home")
|
||||
}
|
||||
})
|
||||
t.Run("missing key returns empty with no error", func(t *testing.T) {
|
||||
screen, err := screenFromSnapshot(map[string]json.RawMessage{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if screen != "" {
|
||||
t.Errorf("screen = %q, want empty", screen)
|
||||
}
|
||||
})
|
||||
t.Run("non-string value returns error", func(t *testing.T) {
|
||||
snapshots := map[string]json.RawMessage{"screen": json.RawMessage(`{"nested":1}`)}
|
||||
screen, err := screenFromSnapshot(snapshots)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for non-string screen, got nil")
|
||||
}
|
||||
if screen != "" {
|
||||
t.Errorf("screen = %q, want empty on error", screen)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
|
||||
snapshots := []map[string]json.RawMessage{
|
||||
{"balance": json.RawMessage(`100`)},
|
||||
{"balance": json.RawMessage(`200`)},
|
||||
}
|
||||
state := newHarness(t, snapshots)
|
||||
state.startSDK(t)
|
||||
state.acceptConnection(t)
|
||||
|
||||
state := newHarness(t)
|
||||
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"com.fixture:id/next","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}`
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := Run(ctx, Options{
|
||||
Duration: 100 * time.Millisecond,
|
||||
SnapshotTimeout: 2 * time.Second,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Connection: state.conn,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
Duration: 100 * time.Millisecond,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -359,12 +207,7 @@ func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
|
||||
snapshots := []map[string]json.RawMessage{
|
||||
{"balance": json.RawMessage(`100`)},
|
||||
}
|
||||
state := newHarness(t, snapshots)
|
||||
state.startSDK(t)
|
||||
state.acceptConnection(t)
|
||||
state := newHarness(t)
|
||||
state.mock.Failures[mockdriver.ActionWaitForIdle] = errors.New("sidecar lost gRPC stream")
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
@@ -373,14 +216,12 @@ func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := Run(ctx, Options{
|
||||
Duration: 100 * time.Millisecond,
|
||||
SnapshotTimeout: 2 * time.Second,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Connection: state.conn,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
Logger: logger,
|
||||
Duration: 100 * time.Millisecond,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
Logger: logger,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -423,28 +264,21 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
|
||||
snapshots := []map[string]json.RawMessage{
|
||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
|
||||
}
|
||||
state := newHarness(t, snapshots)
|
||||
state := newHarness(t)
|
||||
state.mock.MetricsData = driver.Metrics{CPUPercent: 5.0, HeapBytes: 1024, TotalMemoryBytes: 4096}
|
||||
state.mock.LogEntries = []driver.LogEntry{
|
||||
{UnixMillis: 1000, Level: "E", Tag: "test", Message: "boom"},
|
||||
}
|
||||
state.startSDK(t)
|
||||
state.acceptConnection(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := Run(ctx, Options{
|
||||
Duration: 100 * time.Millisecond,
|
||||
SnapshotTimeout: 2 * time.Second,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
BundleID: "com.fixture",
|
||||
Connection: state.conn,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
Duration: 100 * time.Millisecond,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
BundleID: "com.fixture",
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -474,26 +308,17 @@ func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) {
|
||||
snapshots := []map[string]json.RawMessage{
|
||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
|
||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`200`)},
|
||||
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`300`)},
|
||||
}
|
||||
state := newHarness(t, snapshots)
|
||||
state := newHarness(t)
|
||||
state.mock.ImageData = driver.Image{PNG: []byte("fakepng"), Width: 100, Height: 200}
|
||||
state.startSDK(t)
|
||||
state.acceptConnection(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: 200 * time.Millisecond,
|
||||
SnapshotTimeout: 2 * time.Second,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Connection: state.conn,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
Duration: 200 * time.Millisecond,
|
||||
IdleTimeout: 50 * time.Millisecond,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -509,13 +334,11 @@ func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) {
|
||||
t.Errorf("expected pre-screenshot for step 1: %s", preFile)
|
||||
}
|
||||
|
||||
// Step 1's post-screenshot is pipelined into step 2's errgroup
|
||||
postFile := filepath.Join(screenshotDir, "step-00001-after.png")
|
||||
if _, err := os.Stat(postFile); os.IsNotExist(err) {
|
||||
t.Errorf("expected pipelined post-screenshot for step 1: %s", postFile)
|
||||
}
|
||||
|
||||
// Last step's post-screenshot is flushed after the loop
|
||||
lastAfter := filepath.Join(screenshotDir, fmt.Sprintf("step-%05d-after.png", summary.Steps))
|
||||
if _, err := os.Stat(lastAfter); os.IsNotExist(err) {
|
||||
t.Errorf("expected flushed post-screenshot for last step %d: %s", summary.Steps, lastAfter)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
@@ -80,3 +81,12 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
||||
}
|
||||
return driverClient, cleanup, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+11
-121
@@ -5,13 +5,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/agent"
|
||||
"github.com/priyanshujain/sanderling/internal/android"
|
||||
"github.com/priyanshujain/sanderling/internal/bundler"
|
||||
"github.com/priyanshujain/sanderling/internal/ios"
|
||||
@@ -20,11 +17,7 @@ import (
|
||||
"github.com/priyanshujain/sanderling/internal/verifier"
|
||||
)
|
||||
|
||||
const (
|
||||
socketName = "sanderling-agent"
|
||||
sidecarStartupTimeout = 30 * time.Second
|
||||
sdkAcceptTimeout = 60 * time.Second
|
||||
)
|
||||
const sidecarStartupTimeout = 30 * time.Second
|
||||
|
||||
// Options are the parameters for a single test pipeline run.
|
||||
type Options struct {
|
||||
@@ -38,7 +31,7 @@ type Options struct {
|
||||
Output string
|
||||
}
|
||||
|
||||
// Execute runs the full test pipeline: bundle, connect SDK, verify properties.
|
||||
// Execute runs the full test pipeline: bundle, launch app, verify properties.
|
||||
func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
||||
switch options.Platform {
|
||||
case "android":
|
||||
@@ -75,100 +68,8 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var connection *agent.Conn
|
||||
|
||||
switch options.Platform {
|
||||
case "android":
|
||||
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 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 {
|
||||
conn *agent.Conn
|
||||
err error
|
||||
}
|
||||
acceptChannel := make(chan acceptResult, 1)
|
||||
go func() {
|
||||
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
|
||||
defer cancel()
|
||||
conn, acceptErr := agentServer.Accept(acceptCtx)
|
||||
acceptChannel <- acceptResult{conn: conn, err: acceptErr}
|
||||
}()
|
||||
|
||||
if err := activeDriver.Launch(ctx, options.BundleID, false, nil); 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.conn
|
||||
defer connection.Close()
|
||||
hello := connection.Hello()
|
||||
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
|
||||
|
||||
case "ios":
|
||||
// iOS simulator shares the Mac's loopback — no port forwarding needed.
|
||||
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
|
||||
|
||||
agentServer := agent.NewServer(listener)
|
||||
|
||||
type acceptResult struct {
|
||||
conn *agent.Conn
|
||||
err error
|
||||
}
|
||||
acceptChannel := make(chan acceptResult, 1)
|
||||
go func() {
|
||||
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
|
||||
defer cancel()
|
||||
conn, acceptErr := agentServer.Accept(acceptCtx)
|
||||
acceptChannel <- acceptResult{conn: conn, err: acceptErr}
|
||||
}()
|
||||
|
||||
// Launch app via XCTest with SANDERLING_PORT so the SDK can connect.
|
||||
if err := activeDriver.Launch(ctx, options.BundleID, false, map[string]string{
|
||||
"SANDERLING_PORT": strconv.Itoa(agentPort),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("launch app: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "iOS app launched with SANDERLING_PORT=%d; waiting for SDK (%.0fs timeout)\n", agentPort, sdkAcceptTimeout.Seconds())
|
||||
|
||||
result := <-acceptChannel
|
||||
if result.err != nil {
|
||||
return fmt.Errorf("accept SDK: %w", result.err)
|
||||
}
|
||||
connection = result.conn
|
||||
defer connection.Close()
|
||||
hello := connection.Hello()
|
||||
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
|
||||
|
||||
case "web":
|
||||
fmt.Fprintln(stdout, "web mode: skipping SDK")
|
||||
if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil {
|
||||
return fmt.Errorf("launch app: %w", err)
|
||||
}
|
||||
if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil {
|
||||
return fmt.Errorf("launch app: %w", err)
|
||||
}
|
||||
|
||||
seed := options.Seed
|
||||
@@ -211,15 +112,13 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
||||
|
||||
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),
|
||||
Duration: options.Duration,
|
||||
IdleTimeout: 1 * time.Second,
|
||||
BundleID: options.BundleID,
|
||||
Driver: activeDriver,
|
||||
Verifier: verifierInstance,
|
||||
TraceWriter: traceWriter,
|
||||
Logger: newProgressLogger(stdout),
|
||||
})
|
||||
|
||||
terminateCtx, terminateCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@@ -269,12 +168,3 @@ func resolveSpecAPIPath(specPath string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user