Skip to content

Commit 89f4807

Browse files
authored
Get all XCUITest logs (danielpaulus#398)
Starting with iOS 17 XCUITest logs are sent over two channels. There are debug logs that come over the DTX connection (like they did in the past), but the non-debug messages are sent over a different connection to the com.apple.coredevice.openstdiosocket service. We now open a connection to the openstdiosocket-service before we launch the test runner, and we tell the appservice to which socket it should connect stdio. And after that we pipe the data we get from the stdio-socket to the logoutput.
1 parent d10e692 commit 89f4807

File tree

5 files changed

+174
-37
lines changed

5 files changed

+174
-37
lines changed

ios/appservice/appservice.go

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/danielpaulus/go-ios/ios"
1414
"github.com/danielpaulus/go-ios/ios/coredevice"
15+
"github.com/danielpaulus/go-ios/ios/openstdio"
1516
"github.com/danielpaulus/go-ios/ios/xpc"
1617
"github.com/google/uuid"
1718
"howett.net/plist"
@@ -22,6 +23,7 @@ import (
2223
type Connection struct {
2324
conn *xpc.Connection
2425
deviceId string
26+
device ios.DeviceEntry
2527
}
2628

2729
const (
@@ -39,13 +41,28 @@ func New(deviceEntry ios.DeviceEntry) (*Connection, error) {
3941
return nil, fmt.Errorf("new: %w", err)
4042
}
4143

42-
return &Connection{conn: xpcConn, deviceId: uuid.New().String()}, nil
44+
return &Connection{conn: xpcConn, deviceId: uuid.New().String(), device: deviceEntry}, nil
4345
}
4446

45-
// AppLaunch represents the result of launching an app on the device for iOS17+.
46-
// It contains the PID of the launched app.
47-
type AppLaunch struct {
48-
Pid int
47+
// LaunchedAppWithStdIo is the launched app with a connection to the stdio-socket
48+
type LaunchedAppWithStdIo struct {
49+
stdIoConnection openstdio.Connection
50+
Pid int
51+
}
52+
53+
// Read reads from the stdio socket of the launched app
54+
func (a LaunchedAppWithStdIo) Read(p []byte) (n int, err error) {
55+
return a.stdIoConnection.Read(p)
56+
}
57+
58+
// Write reads from the stdio socket of the launched app
59+
func (a LaunchedAppWithStdIo) Write(p []byte) (n int, err error) {
60+
return a.stdIoConnection.Write(p)
61+
}
62+
63+
// Close closes the connection to stdio-socket of the launched app
64+
func (a LaunchedAppWithStdIo) Close() error {
65+
return a.stdIoConnection.Close()
4966
}
5067

5168
// Process represents a process running on the device for iOS17+.
@@ -56,21 +73,55 @@ type Process struct {
5673
}
5774

5875
// LaunchApp launches an app on the device with the given bundleId and arguments for iOS17+.
59-
func (c *Connection) LaunchApp(bundleId string, args []interface{}, env map[string]interface{}, options map[string]interface{}, terminateExisting bool) (AppLaunch, error) {
60-
msg := buildAppLaunchPayload(c.deviceId, bundleId, args, env, options, terminateExisting)
76+
// On a successful launch it returns the PID of the launched process.
77+
func (c *Connection) LaunchApp(bundleId string, args []interface{}, env map[string]interface{}, options map[string]interface{}, terminateExisting bool) (int, error) {
78+
pid, err := c.launchApp(bundleId, args, env, options, terminateExisting, map[string]any{})
79+
if err != nil {
80+
return 0, fmt.Errorf("LaunchApp: failed to launch app: %w", err)
81+
}
82+
return pid, nil
83+
}
84+
85+
// LaunchAppWithStdIo launches an app and connects to the stdio-socket
86+
// the returned value implements the io.ReadWriteCloser interface and needs to be closed when finished using the stdio-socket
87+
func (c *Connection) LaunchAppWithStdIo(bundleId string, args []interface{}, env map[string]interface{}, options map[string]interface{}, terminateExisting bool) (LaunchedAppWithStdIo, error) {
88+
stdio, err := openstdio.NewOpenStdIoSocket(c.device)
89+
if err != nil {
90+
return LaunchedAppWithStdIo{}, fmt.Errorf("LaunchAppWithStdIo: failed to open stdio socket: %w", err)
91+
}
92+
93+
// this is also how Xcode handles it. It uses the same socket for stdOut/stdErr/stdIn
94+
stdIoConfig := map[string]any{
95+
"standardInput": stdio.ID,
96+
"standardOutput": stdio.ID,
97+
"standardError": stdio.ID,
98+
}
99+
100+
pid, err := c.launchApp(bundleId, args, env, options, terminateExisting, stdIoConfig)
101+
if err != nil {
102+
return LaunchedAppWithStdIo{}, fmt.Errorf("LaunchAppWithStdIo: failed to launch app: %w", err)
103+
}
104+
return LaunchedAppWithStdIo{
105+
stdIoConnection: stdio,
106+
Pid: pid,
107+
}, nil
108+
}
109+
110+
func (c *Connection) launchApp(bundleId string, args []interface{}, env map[string]interface{}, options map[string]interface{}, terminateExisting bool, stdio map[string]any) (int, error) {
111+
msg := buildAppLaunchPayload(c.deviceId, bundleId, args, env, options, terminateExisting, map[string]any{})
61112
err := c.conn.Send(msg, xpc.HeartbeatRequestFlag)
62113
if err != nil {
63-
return AppLaunch{}, fmt.Errorf("LaunchApp: failed to send launch-app request: %w", err)
114+
return 0, fmt.Errorf("launchApp: failed to send launch-app request: %w", err)
64115
}
65116
m, err := c.conn.ReceiveOnServerClientStream()
66117
if err != nil {
67-
return AppLaunch{}, fmt.Errorf("launchApp2: %w", err)
118+
return 0, fmt.Errorf("launchApp: failed to read response: %w", err)
68119
}
69120
pid, err := pidFromResponse(m)
70121
if err != nil {
71-
return AppLaunch{}, fmt.Errorf("launchApp3: %w", err)
122+
return 0, fmt.Errorf("launchApp: failed to get PID: %w", err)
72123
}
73-
return AppLaunch{Pid: int(pid)}, nil
124+
return int(pid), nil
74125
}
75126

76127
// Close closes the connection to the appservice
@@ -181,7 +232,7 @@ func (p Process) ExecutableName() string {
181232
return file
182233
}
183234

184-
func buildAppLaunchPayload(deviceId string, bundleId string, args []interface{}, env map[string]interface{}, options map[string]interface{}, terminateExisting bool) map[string]interface{} {
235+
func buildAppLaunchPayload(deviceId string, bundleId string, args []interface{}, env map[string]interface{}, options map[string]interface{}, terminateExisting bool, stdIo map[string]any) map[string]interface{} {
185236
platformSpecificOptions := bytes.NewBuffer(nil)
186237
plistEncoder := plist.NewBinaryEncoder(platformSpecificOptions)
187238
err := plistEncoder.Encode(options)
@@ -207,7 +258,7 @@ func buildAppLaunchPayload(deviceId string, bundleId string, args []interface{},
207258
},
208259
"workingDirectory": nil,
209260
},
210-
"standardIOIdentifiers": map[string]interface{}{},
261+
"standardIOIdentifiers": stdIo,
211262
})
212263
}
213264

ios/appservice/appservice_integration_test.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
package appservice_test
44

55
import (
6+
"os"
7+
"slices"
8+
"testing"
9+
610
"github.com/danielpaulus/go-ios/ios"
711
"github.com/danielpaulus/go-ios/ios/appservice"
812
"github.com/stretchr/testify/assert"
913
"github.com/stretchr/testify/require"
10-
"os"
11-
"slices"
12-
"testing"
1314
)
1415

1516
func TestLaunchAndKillApps(t *testing.T) {
@@ -71,12 +72,12 @@ func testKillInvalidPidReturnsError(t *testing.T, device ios.DeviceEntry) {
7172
require.NoError(t, err)
7273
defer as.Close()
7374

74-
launched, err := as.LaunchApp("com.apple.mobilesafari", nil, nil, nil, true)
75+
pid, err := as.LaunchApp("com.apple.mobilesafari", nil, nil, nil, true)
7576
require.NoError(t, err)
7677

77-
err = as.KillProcess(launched.Pid)
78+
err = as.KillProcess(pid)
7879
require.NoError(t, err)
7980

80-
err = as.KillProcess(launched.Pid)
81+
err = as.KillProcess(pid)
8182
assert.Error(t, err)
8283
}

ios/connect.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func ConnectToShimService(device DeviceEntry, service string) (DeviceConnectionI
113113
}
114114

115115
port := device.Rsd.GetPort(service)
116-
conn, err := connectToTunnel(device, port)
116+
conn, err := ConnectToTunnel(device, port)
117117
if err != nil {
118118
return nil, err
119119
}
@@ -145,7 +145,7 @@ func ConnectToServiceTunnelIface(device DeviceEntry, serviceName string) (Device
145145
}
146146
port := device.Rsd.GetPort(serviceName)
147147

148-
conn, err := connectToTunnel(device, port)
148+
conn, err := ConnectToTunnel(device, port)
149149
if err != nil {
150150
return nil, fmt.Errorf("ConnectToServiceTunnelIface: failed to connect to tunnel: %w", err)
151151
}
@@ -175,24 +175,25 @@ func ConnectToHttp2(device DeviceEntry, port int) (*http.HttpConnection, error)
175175
return http.NewHttpConnection(conn)
176176
}
177177

178-
func connectToTunnel(device DeviceEntry, port int) (*net.TCPConn, error) {
178+
// ConnectToTunnel opens a new connection to the tunnel interface of the specified device and on the specified port
179+
func ConnectToTunnel(device DeviceEntry, port int) (*net.TCPConn, error) {
179180
addr, err := net.ResolveTCPAddr("tcp6", fmt.Sprintf("[%s]:%d", device.Address, port))
180181
if err != nil {
181-
return nil, fmt.Errorf("connectToTunnel: failed to resolve address: %w", err)
182+
return nil, fmt.Errorf("ConnectToTunnel: failed to resolve address: %w", err)
182183
}
183184

184185
conn, err := net.DialTCP("tcp", nil, addr)
185186
if err != nil {
186-
return nil, fmt.Errorf("connectToTunnel: failed to dial: %w", err)
187+
return nil, fmt.Errorf("ConnectToTunnel: failed to dial: %w", err)
187188
}
188189

189190
err = conn.SetKeepAlive(true)
190191
if err != nil {
191-
return nil, fmt.Errorf("connectToTunnel: failed to set keepalive: %w", err)
192+
return nil, fmt.Errorf("ConnectToTunnel: failed to set keepalive: %w", err)
192193
}
193194
err = conn.SetKeepAlivePeriod(1 * time.Second)
194195
if err != nil {
195-
return nil, fmt.Errorf("connectToTunnel: failed to set keepalive period: %w", err)
196+
return nil, fmt.Errorf("ConnectToTunnel: failed to set keepalive period: %w", err)
196197
}
197198

198199
return conn, nil

ios/openstdio/service.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Package openstdio is used to open a new socket connection that can be used to connect an app launched with appservice
2+
package openstdio
3+
4+
import (
5+
"errors"
6+
"fmt"
7+
"io"
8+
"net"
9+
10+
"github.com/danielpaulus/go-ios/ios"
11+
"github.com/google/uuid"
12+
)
13+
14+
// Connection is a single connection to the stdio socket
15+
type Connection struct {
16+
// ID is required whenever we need to tell iOS which socket should be used
17+
// (for example when we launch an app we need to pass this id so that the system knows which socket should be used)
18+
ID uuid.UUID
19+
connection io.ReadWriteCloser
20+
}
21+
22+
// NewOpenStdIoSocket creates a new stdio-socket on the device and the returned connection can be used
23+
// to read and write from this socket
24+
func NewOpenStdIoSocket(device ios.DeviceEntry) (Connection, error) {
25+
if device.Rsd == nil {
26+
return Connection{}, errors.New("NewOpenStdIoSocket: no rsd device found")
27+
}
28+
port := device.Rsd.GetPort("com.apple.coredevice.openstdiosocket")
29+
conn, err := ios.ConnectToTunnel(device, port)
30+
if err != nil {
31+
return Connection{}, fmt.Errorf("NewOpenStdIoSocket: failed to open connection: %w", err)
32+
}
33+
34+
uuidBytes := make([]byte, 16)
35+
_, err = conn.Read(uuidBytes)
36+
if err != nil {
37+
return Connection{}, fmt.Errorf("NewOpenStdIoSocket: failed to read UUID: %w", err)
38+
}
39+
40+
u, err := uuid.FromBytes(uuidBytes)
41+
if err != nil {
42+
return Connection{}, fmt.Errorf("NewOpenStdIoSocket: failed to parse UUID: %w", err)
43+
}
44+
45+
return Connection{
46+
ID: u,
47+
connection: conn,
48+
}, nil
49+
}
50+
51+
// Read reads from the connected stdio-socket
52+
func (s Connection) Read(p []byte) (n int, err error) {
53+
if s.connection == nil {
54+
return 0, fmt.Errorf("Read: not connected to service")
55+
}
56+
n, err = s.connection.Read(p)
57+
if err != nil && errors.Is(err, net.ErrClosed) {
58+
return n, io.EOF
59+
}
60+
return n, err
61+
}
62+
63+
// Write writes to the connected stdio-socket
64+
func (s Connection) Write(p []byte) (n int, err error) {
65+
if s.connection == nil {
66+
return 0, fmt.Errorf("Write: not connected to service")
67+
}
68+
return s.connection.Write(p)
69+
}
70+
71+
// Close closes the connected stdio-socket
72+
func (s Connection) Close() error {
73+
if s.connection == nil {
74+
return fmt.Errorf("Close: not connected to service")
75+
}
76+
return s.connection.Close()
77+
}

ios/testmanagerd/xcuitestrunner.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package testmanagerd
33
import (
44
"context"
55
"fmt"
6+
"io"
67
"path"
78
"strings"
89

@@ -358,18 +359,26 @@ func runXUITestWithBundleIdsXcode15Ctx(
358359
}
359360
defer appserviceConn.Close()
360361

361-
pid, err := startTestRunner17(device, appserviceConn, "", testRunnerBundleID, strings.ToUpper(testSessionID.String()), info.testApp.path+"/PlugIns/"+xctestConfigFileName, args, env)
362+
testRunnerLaunch, err := startTestRunner17(device, appserviceConn, "", testRunnerBundleID, strings.ToUpper(testSessionID.String()), info.testApp.path+"/PlugIns/"+xctestConfigFileName, args, env)
362363
if err != nil {
363364
return make([]TestSuite, 0), fmt.Errorf("runXUITestWithBundleIdsXcode15Ctx: cannot start test runner: %w", err)
364365
}
365366

367+
defer testRunnerLaunch.Close()
368+
go func() {
369+
_, err := io.Copy(testListener.logWriter, testRunnerLaunch)
370+
if err != nil {
371+
log.Warn("copying stdout failed", log.WithError(err))
372+
}
373+
}()
374+
366375
ideDaemonProxy2 := newDtxProxyWithConfig(conn2, testconfig, testListener)
367376
caps, err := ideDaemonProxy2.daemonConnection.initiateControlSessionWithCapabilities(nskeyedarchiver.XCTCapabilities{CapabilitiesDictionary: map[string]interface{}{}})
368377
if err != nil {
369378
return make([]TestSuite, 0), fmt.Errorf("runXUITestWithBundleIdsXcode15Ctx: cannot initiate a control session with capabilities: %w", err)
370379
}
371380
log.WithField("caps", caps).Info("got capabilities")
372-
authorized, err := ideDaemonProxy2.daemonConnection.authorizeTestSessionWithProcessID(uint64(pid))
381+
authorized, err := ideDaemonProxy2.daemonConnection.authorizeTestSessionWithProcessID(uint64(testRunnerLaunch.Pid))
373382
if err != nil {
374383
return make([]TestSuite, 0), fmt.Errorf("runXUITestWithBundleIdsXcode15Ctx: cannot authorize test session: %w", err)
375384
}
@@ -387,10 +396,10 @@ func runXUITestWithBundleIdsXcode15Ctx(
387396
case <-testListener.Done():
388397
break
389398
case <-ctx.Done():
390-
log.Infof("Killing test runner with pid %d ...", pid)
391-
err = killTestRunner(appserviceConn, pid)
399+
log.Infof("Killing test runner with pid %d ...", testRunnerLaunch.Pid)
400+
err = killTestRunner(appserviceConn, testRunnerLaunch.Pid)
392401
if err != nil {
393-
log.Infof("Nothing to kill, process with pid %d is already dead", pid)
402+
log.Infof("Nothing to kill, process with pid %d is already dead", testRunnerLaunch.Pid)
394403
} else {
395404
log.Info("Test runner killed with success")
396405
}
@@ -416,9 +425,7 @@ func killTestRunner(killer processKiller, pid int) error {
416425
return nil
417426
}
418427

419-
func startTestRunner17(device ios.DeviceEntry, appserviceConn *appservice.Connection, xctestConfigPath string, bundleID string,
420-
sessionIdentifier string, testBundlePath string, testArgs []string, testEnv []string,
421-
) (int, error) {
428+
func startTestRunner17(device ios.DeviceEntry, appserviceConn *appservice.Connection, xctestConfigPath string, bundleID string, sessionIdentifier string, testBundlePath string, testArgs []string, testEnv []string) (appservice.LaunchedAppWithStdIo, error) {
422429
args := []interface{}{}
423430
for _, arg := range testArgs {
424431
args = append(args, arg)
@@ -454,7 +461,7 @@ func startTestRunner17(device ios.DeviceEntry, appserviceConn *appservice.Connec
454461
"StartSuspendedKey": uint64(0),
455462
}
456463

457-
appLaunch, err := appserviceConn.LaunchApp(
464+
appLaunch, err := appserviceConn.LaunchAppWithStdIo(
458465
bundleID,
459466
args,
460467
env,
@@ -463,10 +470,10 @@ func startTestRunner17(device ios.DeviceEntry, appserviceConn *appservice.Connec
463470
)
464471

465472
if err != nil {
466-
return 0, err
473+
return appservice.LaunchedAppWithStdIo{}, err
467474
}
468475

469-
return appLaunch.Pid, nil
476+
return appLaunch, nil
470477
}
471478

472479
func setupXcuiTest(device ios.DeviceEntry, bundleID string, testRunnerBundleID string, xctestConfigFileName string, testsToRun []string, testsToSkip []string) (uuid.UUID, string, nskeyedarchiver.XCTestConfiguration, testInfo, error) {

0 commit comments

Comments
 (0)