mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
* perf(ios): use prebuilt XCTest runner to cut startup * chore(ioscompanion): add companion asset prepare script * feat(ioscompanion): embed and extract simulator companion bundle * test(ioscompanion): cover companion stub and embedded extraction * docs: add third party notices for vendored companion * chore: ignore vendored companion bundle artifact * build(proto): pin simulator companion proto v1.1.8 * build(proto): add dedicated buf module and gen template for pinned proto * build(proto): exclude pinned companion proto from root buf workspace * feat(ioscompanion): commit generated companion gRPC stubs * feat(ioscompanion): map flat companion describe dump to TreeNode JSON * test(ioscompanion): add hierarchy-map golden and unit tests * feat(ioscompanion): port screen-settle stability polling to Go * test(ioscompanion): cover settle transitional, hash, streak, and cap rules * feat(ioscompanion): add USB HID keymap module * test(ioscompanion): cover keymap branches and paste-chord constants * build: embed companion assets via withcompanion tag * feat(ioscompanion): add transport companion interface * feat(ioscompanion): add HID event wrapper and builders * feat(ioscompanion): wire gRPC companion client and Dial * test(ioscompanion): cover HID builders and unit conversions * test(ioscompanion): cover Dial, process-state mapping, and install archive * test(ioscompanion): add gated simulator integration smoke test * feat(ioscompanion): text input and gesture HID composition with pasteboard fallback * test(ioscompanion): cover input composers, paste dialog loop, and pure helpers * feat(ioscompanion): add Describe to companion transport * feat(ioscompanion): implement DeviceDriver with companion supervision * test(ioscompanion): unit tests with fake companion transport * test(ioscompanion): gated companion smoke test * feat(ios): add ResolveTarget for simulator vs physical-device routing * feat(testrun): route iOS simulators through the native companion driver * refactor(testrun): defer the java preflight check to the physical-device path * feat(cli): add --ios-app-path flag * feat(doctor): split iOS checks into simulator and physical-device paths * test(folio): add gate-analyzer fixtures for G1-G5 * feat(folio): add iOS conformance gate script * chore(folio): wire gates recipe, app path, and ignore gate output * style: gofmt struct alignment drift * fix(doctor): probe simctl via xcrun instead of PATH lookup * fix(ioscompanion): spawn companion under driver-lifetime context * test(ioscompanion): prove companion child outlives startup context * fix(ioscompanion): chunk install payload under companion message cap * test(ioscompanion): cover install payload chunking * fix(ioscompanion): reinstall via simctl and sanitize companion env * fix(ioscompanion): wait out unresolved accessibility values after launch * perf(ioscompanion): paste long text for atomic landing * test(ioscompanion): cover paste threshold, retry flow, and sentinel detection * fix(ioscompanion): treat unresolved bridge values as transitional, never as content * fix(ioscompanion): accept masked secure-field values as paste landing * test(ioscompanion): cover sentinel mapping and masked-field landing * fix(ioscompanion): atomic erase and single-send paste to prevent doubling * test(ioscompanion): cover atomic erase, single chord, unverifiable field * fix(ioscompanion): verify paste on a time budget that outlasts the bridge blackout * test(ioscompanion): cover bridge-blackout paste verification * fix(ioscompanion): drop unresolved-value settle gate that never let empty-field screens settle * refactor(ioscompanion): name the empty-editable-field sentinel for what it is * perf(ioscompanion): tighten settle streak for the fast companion transport * feat(ioscompanion): pre-grant pasteboard access so unicode input skips the OS prompt * refactor(ioscompanion): drop paste warm-up now that the grant suppresses the prompt * test(ioscompanion): cover pasteboard grant on launch, drop warm-up tests * fix(ioscompanion): retry describe past transient collapsed accessibility dumps * test(ioscompanion): cover collapsed-dump detection * perf(ioscompanion): split raw and retrying describe so settle does not double-wait collapses * perf(ioscompanion): tighten settle now that collapses are handled separately * fix(ioscompanion): replace field content on input so blackout-skipped erase cannot accumulate text * test(ioscompanion): cover replace-on-input and TextReplacer capability * refactor(ioscompanion): neutralize HID events behind the transport seam * feat(companion): add simulator runner project skeleton * feat(companion): serve accessibility snapshots over the wire protocol * feat(companion): synthesize timestamped touch gestures * feat(companion): type text with replace semantics * feat(companion): serve the wire protocol from a parked runner * feat(ioscompanion): add TextEditor capability and unavailable sentinel to the transport seam * feat(ioscompanion): route text input through a text-editing companion when available * fix(companion): bind listener by port and source screen size from snapshot * feat(ioscompanion): add runner companion JSON transport * test(ioscompanion): cover runner transport protocol mapping * fix(companion): synthesize gestures synchronously to avoid the async completion crash * fix(companion): type on the main thread and recover from focus assertions * fix(companion): keep serving after an automation failure * refactor(companion): tidy snapshot serialization * fix(companion): honor sequential tap gaps and survive synthesis exceptions * feat(ioscompanion): expose native typing with an explicit replace flag * chore(companion): add runner asset prepare script * feat(ioscompanion): embed and extract the runner test bundle * test(ioscompanion): cover runner asset extraction * build(ioscompanion): commit runner asset archive * feat(ioscompanion): pair the legacy companion with the in-simulator runner * test(ioscompanion): cover hybrid routing, paste-grant skip, and port binding * fix(ioscompanion): reconnect after interrupted runner calls instead of restarting * fix(ioscompanion): route hybrid lifecycle through the runner and harden restarts * feat(companion): launch and terminate apps through the automation session * build(ioscompanion): refresh runner asset with session lifecycle * fix(ioscompanion): classify connection deadline expiry as caller budget * fix(companion): capture snapshots on the main thread inside the catch bridge * build(ioscompanion): refresh runner asset with main-thread snapshots * perf(ioscompanion): count read spans toward settle and capture snapshots concurrently * feat(ioscompanion): make the hybrid simulator companion the default * test(folio): cover runner-session orphans in the gate harness * test(ioscompanion): pin the child-lifetime test to the legacy path * fix(ioscompanion): keep mappable text on one HID stream and verify unicode clears * fix(ioscompanion): pause the clear chord so selection applies before the delete * fix(companion): prune the keyboard subtree from snapshots * build(ioscompanion): refresh runner asset without keyboard elements * fix(ioscompanion): capture the screenshot transport before a recovery can reassign it * fix(companion): pin the runner listener to loopback * fix(companion): size the replace delete prefix to cover any focused field * build(ioscompanion): refresh runner asset with loopback bind and replace fix * fix(cli): cancel the run context on SIGINT so spawned children are reaped * fix(testrun): point the device java preflight hint at the ios-device doctor * fix(folio): word-bound the G2 ERROR scan and drop the dead objc allowlist glob * test(ioscompanion): cover stopProcess, restart, and failed bring-up supervision * chore: add test-companion target for the withcompanion-tagged suite * chore(ioscompanion): stop tracking the runner archive build artifact * build: produce the runner archive from source like the companion bundle * refactor(conformance): move the gate harness out of examples/folio * chore(folio): drop the gate harness wiring from the example app
557 lines
15 KiB
Go
557 lines
15 KiB
Go
package transport
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"reflect"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
_ Companion = (*runnerCompanion)(nil)
|
|
_ TextEditor = (*runnerCompanion)(nil)
|
|
)
|
|
|
|
// scriptedReply maps a method name to the raw JSON object the fake server writes
|
|
// back as the "result" field. A method absent from the script gets an empty
|
|
// object result.
|
|
type scriptedReply map[string]string
|
|
|
|
// fakeServer is an in-process runner stand-in. It accepts a single connection,
|
|
// records every decoded request, and answers from a scripted table.
|
|
type fakeServer struct {
|
|
listener net.Listener
|
|
address string
|
|
|
|
mutex sync.Mutex
|
|
requests []runnerRequest
|
|
}
|
|
|
|
func startFakeServer(t *testing.T, script scriptedReply) *fakeServer {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
server := &fakeServer{listener: listener, address: listener.Addr().String()}
|
|
go server.serve(script)
|
|
t.Cleanup(func() { listener.Close() })
|
|
return server
|
|
}
|
|
|
|
func (s *fakeServer) serve(script scriptedReply) {
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
line, err := reader.ReadBytes('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
var request runnerRequest
|
|
if err := json.Unmarshal(line, &request); err != nil {
|
|
return
|
|
}
|
|
s.mutex.Lock()
|
|
s.requests = append(s.requests, request)
|
|
s.mutex.Unlock()
|
|
|
|
result := script[request.Method]
|
|
if result == "" {
|
|
result = "{}"
|
|
}
|
|
response := `{"id":` + strconv.Itoa(request.ID) + `,"result":` + result + "}\n"
|
|
if _, err := conn.Write([]byte(response)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *fakeServer) recorded() []runnerRequest {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
out := make([]runnerRequest, len(s.requests))
|
|
copy(out, s.requests)
|
|
return out
|
|
}
|
|
|
|
func dialFake(t *testing.T, server *fakeServer, bundleID string) Companion {
|
|
t.Helper()
|
|
companion, err := DialRunner(server.address, "UDID-1234", bundleID)
|
|
if err != nil {
|
|
t.Fatalf("DialRunner: %v", err)
|
|
}
|
|
t.Cleanup(func() { companion.Close() })
|
|
return companion
|
|
}
|
|
|
|
func TestSnapshotReMarshalsElements(t *testing.T) {
|
|
server := startFakeServer(t, scriptedReply{
|
|
"snapshot": `{"elements":[{"role":"button"},{"role":"text"}]}`,
|
|
})
|
|
companion := dialFake(t, server, "com.example.app")
|
|
|
|
got, err := companion.AccessibilityInfo(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("AccessibilityInfo: %v", err)
|
|
}
|
|
want := `[{"role":"button"},{"role":"text"}]`
|
|
if got != want {
|
|
t.Fatalf("elements = %s, want %s", got, want)
|
|
}
|
|
|
|
requests := server.recorded()
|
|
if len(requests) != 1 {
|
|
t.Fatalf("recorded %d requests, want 1", len(requests))
|
|
}
|
|
if requests[0].Method != "snapshot" {
|
|
t.Fatalf("method = %s, want snapshot", requests[0].Method)
|
|
}
|
|
if requests[0].Params["bundleId"] != "com.example.app" {
|
|
t.Fatalf("bundleId = %v, want com.example.app", requests[0].Params["bundleId"])
|
|
}
|
|
}
|
|
|
|
func TestGestureEncodesDoubleTapStream(t *testing.T) {
|
|
server := startFakeServer(t, nil)
|
|
companion := dialFake(t, server, "com.example.app")
|
|
|
|
err := companion.SendHID(context.Background(),
|
|
TouchDown(10, 20),
|
|
TouchUp(10, 20),
|
|
Delay(60),
|
|
TouchDown(10, 20),
|
|
TouchUp(10, 20),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendHID: %v", err)
|
|
}
|
|
|
|
requests := server.recorded()
|
|
if len(requests) != 1 || requests[0].Method != "gesture" {
|
|
t.Fatalf("requests = %+v, want one gesture", requests)
|
|
}
|
|
events, ok := requests[0].Params["events"].([]any)
|
|
if !ok {
|
|
t.Fatalf("events not an array: %T", requests[0].Params["events"])
|
|
}
|
|
want := []map[string]any{
|
|
{"kind": "touchDown", "x": 10.0, "y": 20.0},
|
|
{"kind": "touchUp", "x": 10.0, "y": 20.0},
|
|
{"kind": "delay", "milliseconds": 60.0},
|
|
{"kind": "touchDown", "x": 10.0, "y": 20.0},
|
|
{"kind": "touchUp", "x": 10.0, "y": 20.0},
|
|
}
|
|
if len(events) != len(want) {
|
|
t.Fatalf("got %d events, want %d", len(events), len(want))
|
|
}
|
|
for index, event := range events {
|
|
if !reflect.DeepEqual(event, map[string]any(want[index])) {
|
|
t.Fatalf("event %d = %v, want %v", index, event, want[index])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSendHIDRejectsKeyEvents(t *testing.T) {
|
|
server := startFakeServer(t, nil)
|
|
companion := dialFake(t, server, "com.example.app")
|
|
|
|
err := companion.SendHID(context.Background(), KeyDown(4), KeyUp(4))
|
|
if err == nil {
|
|
t.Fatal("expected error for key HID events")
|
|
}
|
|
if errors.Is(err, ErrCompanionUnavailable) {
|
|
t.Fatalf("key rejection should not be a transport error: %v", err)
|
|
}
|
|
if requests := server.recorded(); len(requests) != 0 {
|
|
t.Fatalf("expected nothing sent, got %+v", requests)
|
|
}
|
|
}
|
|
|
|
func TestScreenshotBase64RoundTrip(t *testing.T) {
|
|
original := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a}
|
|
encoded := base64.StdEncoding.EncodeToString(original)
|
|
server := startFakeServer(t, scriptedReply{
|
|
"screenshot": `{"pngBase64":"` + encoded + `"}`,
|
|
})
|
|
companion := dialFake(t, server, "com.example.app")
|
|
|
|
data, format, err := companion.Screenshot(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Screenshot: %v", err)
|
|
}
|
|
if format != "png" {
|
|
t.Fatalf("format = %s, want png", format)
|
|
}
|
|
if !reflect.DeepEqual(data, original) {
|
|
t.Fatalf("data = %v, want %v", data, original)
|
|
}
|
|
}
|
|
|
|
func TestDescribeMapsScreenDescription(t *testing.T) {
|
|
server := startFakeServer(t, scriptedReply{
|
|
"describe": `{"widthPoints":390,"heightPoints":844,"scale":3.0}`,
|
|
})
|
|
companion := dialFake(t, server, "com.example.app")
|
|
|
|
description, err := companion.Describe(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Describe: %v", err)
|
|
}
|
|
want := ScreenDescription{WidthPoints: 390, HeightPoints: 844, Scale: 3.0}
|
|
if description != want {
|
|
t.Fatalf("description = %+v, want %+v", description, want)
|
|
}
|
|
}
|
|
|
|
func TestTextEditorMethods(t *testing.T) {
|
|
server := startFakeServer(t, nil)
|
|
companion := dialFake(t, server, "com.example.app")
|
|
editor := companion.(TextEditor)
|
|
|
|
if err := editor.InputText(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("InputText: %v", err)
|
|
}
|
|
if err := editor.EraseText(context.Background(), 3); err != nil {
|
|
t.Fatalf("EraseText: %v", err)
|
|
}
|
|
if err := editor.PressKey(context.Background(), "Enter"); err != nil {
|
|
t.Fatalf("PressKey: %v", err)
|
|
}
|
|
|
|
requests := server.recorded()
|
|
if len(requests) != 3 {
|
|
t.Fatalf("recorded %d requests, want 3", len(requests))
|
|
}
|
|
if requests[0].Method != "typeText" || requests[0].Params["text"] != "hello" || requests[0].Params["replace"] != true {
|
|
t.Fatalf("typeText request = %+v", requests[0])
|
|
}
|
|
if requests[1].Method != "eraseText" || requests[1].Params["count"] != 3.0 {
|
|
t.Fatalf("eraseText request = %+v", requests[1])
|
|
}
|
|
if requests[2].Method != "pressKey" || requests[2].Params["key"] != "return" {
|
|
t.Fatalf("pressKey request = %+v", requests[2])
|
|
}
|
|
}
|
|
|
|
func TestPressKeyRejectsUnknownKey(t *testing.T) {
|
|
server := startFakeServer(t, nil)
|
|
companion := dialFake(t, server, "com.example.app")
|
|
editor := companion.(TextEditor)
|
|
|
|
err := editor.PressKey(context.Background(), "home")
|
|
if err == nil {
|
|
t.Fatal("expected error for unsupported key")
|
|
}
|
|
if errors.Is(err, ErrCompanionUnavailable) {
|
|
t.Fatalf("unsupported key should not be a transport error: %v", err)
|
|
}
|
|
if requests := server.recorded(); len(requests) != 0 {
|
|
t.Fatalf("expected nothing sent, got %+v", requests)
|
|
}
|
|
}
|
|
|
|
func TestLaunchTerminateMapping(t *testing.T) {
|
|
server := startFakeServer(t, nil)
|
|
companion := dialFake(t, server, "com.example.app")
|
|
|
|
if err := companion.Launch(context.Background(), "com.example.target", true); err != nil {
|
|
t.Fatalf("Launch: %v", err)
|
|
}
|
|
if err := companion.Terminate(context.Background(), "com.example.target"); err != nil {
|
|
t.Fatalf("Terminate: %v", err)
|
|
}
|
|
|
|
requests := server.recorded()
|
|
if len(requests) != 2 {
|
|
t.Fatalf("recorded %d requests, want 2", len(requests))
|
|
}
|
|
if requests[0].Method != "launch" ||
|
|
requests[0].Params["bundleId"] != "com.example.target" ||
|
|
requests[0].Params["foregroundIfRunning"] != true {
|
|
t.Fatalf("launch request = %+v", requests[0])
|
|
}
|
|
if requests[1].Method != "terminate" || requests[1].Params["bundleId"] != "com.example.target" {
|
|
t.Fatalf("terminate request = %+v", requests[1])
|
|
}
|
|
}
|
|
|
|
func TestListAppsStateMapping(t *testing.T) {
|
|
cases := []struct {
|
|
state string
|
|
want ProcessState
|
|
}{
|
|
{"foreground", ProcessStateRunning},
|
|
{"background", ProcessStateRunning},
|
|
{"notRunning", ProcessStateNotRunning},
|
|
{"unknown", ProcessStateUnknown},
|
|
}
|
|
for _, testCase := range cases {
|
|
server := startFakeServer(t, scriptedReply{
|
|
"appState": `{"state":"` + testCase.state + `"}`,
|
|
})
|
|
companion := dialFake(t, server, "com.example.app")
|
|
|
|
apps, err := companion.ListApps(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListApps(%s): %v", testCase.state, err)
|
|
}
|
|
if len(apps) != 1 {
|
|
t.Fatalf("ListApps(%s) returned %d apps, want 1", testCase.state, len(apps))
|
|
}
|
|
app := apps[0]
|
|
if app.BundleID != "com.example.app" || app.InstallType != "user" {
|
|
t.Fatalf("app fields = %+v", app)
|
|
}
|
|
if app.ProcessState != testCase.want {
|
|
t.Fatalf("state %s -> %v, want %v", testCase.state, app.ProcessState, testCase.want)
|
|
}
|
|
|
|
requests := server.recorded()
|
|
if len(requests) != 1 || requests[0].Method != "appState" || requests[0].Params["bundleId"] != "com.example.app" {
|
|
t.Fatalf("appState request = %+v", requests)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServerErrorIsNotSentinel(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Cleanup(func() { listener.Close() })
|
|
go func() {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
reader := bufio.NewReader(conn)
|
|
line, err := reader.ReadBytes('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
var request runnerRequest
|
|
json.Unmarshal(line, &request)
|
|
conn.Write([]byte(`{"id":` + strconv.Itoa(request.ID) + `,"error":"field not focused"}` + "\n"))
|
|
}()
|
|
|
|
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
|
|
if err != nil {
|
|
t.Fatalf("DialRunner: %v", err)
|
|
}
|
|
t.Cleanup(func() { companion.Close() })
|
|
|
|
_, err = companion.Describe(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected server error")
|
|
}
|
|
if errors.Is(err, ErrCompanionUnavailable) {
|
|
t.Fatalf("server error must not wrap the sentinel: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServerClosesMidCallIsSentinel(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Cleanup(func() { listener.Close() })
|
|
go func() {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Read the request, then drop the connection without replying.
|
|
bufio.NewReader(conn).ReadBytes('\n')
|
|
conn.Close()
|
|
}()
|
|
|
|
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
|
|
if err != nil {
|
|
t.Fatalf("DialRunner: %v", err)
|
|
}
|
|
t.Cleanup(func() { companion.Close() })
|
|
|
|
_, err = companion.Describe(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error when server closes mid-call")
|
|
}
|
|
if !errors.Is(err, ErrCompanionUnavailable) {
|
|
t.Fatalf("dropped connection must wrap the sentinel: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestContextCancellationUnblocksCall(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Cleanup(func() { listener.Close() })
|
|
go func() {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Accept and hold the connection open, never replying.
|
|
bufio.NewReader(conn).ReadBytes('\n')
|
|
<-make(chan struct{})
|
|
}()
|
|
|
|
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
|
|
if err != nil {
|
|
t.Fatalf("DialRunner: %v", err)
|
|
}
|
|
t.Cleanup(func() { companion.Close() })
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
time.AfterFunc(50*time.Millisecond, cancel)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, callErr := companion.Describe(ctx)
|
|
done <- callErr
|
|
}()
|
|
|
|
select {
|
|
case callErr := <-done:
|
|
if callErr == nil {
|
|
t.Fatal("expected cancellation error")
|
|
}
|
|
// A caller-imposed cancel is the caller's budget, not a connection
|
|
// loss: it must NOT wrap the sentinel, or a slow call would trigger a
|
|
// pointless child restart.
|
|
if !errors.Is(callErr, context.Canceled) {
|
|
t.Fatalf("cancellation error must carry the context error: %v", callErr)
|
|
}
|
|
if errors.Is(callErr, ErrCompanionUnavailable) {
|
|
t.Fatalf("cancellation error must not wrap the sentinel: %v", callErr)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("cancelled call did not return within 2s")
|
|
}
|
|
}
|
|
|
|
func TestInterruptedCallReconnectsOnNextCall(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Cleanup(func() { listener.Close() })
|
|
|
|
// The server holds "describe" hostage and answers everything else, on
|
|
// every connection it accepts. A late reply to the interrupted request
|
|
// must never be misread by the following call.
|
|
accepted := make(chan net.Conn, 4)
|
|
go func() {
|
|
for {
|
|
conn, acceptErr := listener.Accept()
|
|
if acceptErr != nil {
|
|
return
|
|
}
|
|
accepted <- conn
|
|
go func(c net.Conn) {
|
|
reader := bufio.NewReader(c)
|
|
for {
|
|
line, readErr := reader.ReadBytes('\n')
|
|
if readErr != nil {
|
|
return
|
|
}
|
|
var request runnerRequest
|
|
if json.Unmarshal(line, &request) != nil {
|
|
return
|
|
}
|
|
if request.Method == "describe" {
|
|
continue
|
|
}
|
|
response := `{"id":` + strconv.Itoa(request.ID) + `,"result":{"ok":true}}` + "\n"
|
|
if _, writeErr := c.Write([]byte(response)); writeErr != nil {
|
|
return
|
|
}
|
|
}
|
|
}(conn)
|
|
}
|
|
}()
|
|
|
|
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
|
|
if err != nil {
|
|
t.Fatalf("DialRunner: %v", err)
|
|
}
|
|
t.Cleanup(func() { companion.Close() })
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
if _, err := companion.Describe(ctx); err == nil {
|
|
t.Fatal("expected the held call to time out")
|
|
}
|
|
|
|
// The next call must transparently reconnect and succeed.
|
|
if err := companion.Terminate(context.Background(), "com.example.app"); err != nil {
|
|
t.Fatalf("call after interrupt: %v", err)
|
|
}
|
|
if len(accepted) != 2 {
|
|
t.Fatalf("accepted %d connections, want 2 (reconnect)", len(accepted))
|
|
}
|
|
}
|
|
|
|
func TestResponseIDMismatchIsSentinel(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Cleanup(func() { listener.Close() })
|
|
go func() {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
bufio.NewReader(conn).ReadBytes('\n')
|
|
// Reply with an id that cannot match any request.
|
|
conn.Write([]byte(`{"id":9999,"result":{}}` + "\n"))
|
|
}()
|
|
|
|
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
|
|
if err != nil {
|
|
t.Fatalf("DialRunner: %v", err)
|
|
}
|
|
t.Cleanup(func() { companion.Close() })
|
|
|
|
_, err = companion.Describe(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected id-mismatch error")
|
|
}
|
|
if !errors.Is(err, ErrCompanionUnavailable) {
|
|
t.Fatalf("id mismatch must wrap the sentinel: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTypeTextAppendsWithoutReplace(t *testing.T) {
|
|
server := startFakeServer(t, nil)
|
|
companion := dialFake(t, server, "com.example.app")
|
|
typer := companion.(TextTyper)
|
|
|
|
if err := typer.TypeText(context.Background(), "héllo 🌟", false); err != nil {
|
|
t.Fatalf("TypeText: %v", err)
|
|
}
|
|
|
|
requests := server.recorded()
|
|
if len(requests) != 1 {
|
|
t.Fatalf("recorded %d requests, want 1", len(requests))
|
|
}
|
|
request := requests[0]
|
|
if request.Method != "typeText" || request.Params["text"] != "héllo 🌟" || request.Params["replace"] != false {
|
|
t.Fatalf("typeText request = %+v", request)
|
|
}
|
|
}
|