mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
iOS simulator driver: Go-native companion-backed backend (#62)
* 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
This commit is contained in:
97 files changed
+22104
-83
No files matched your search
@@ -0,0 +1,556 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user