diff --git a/ios/testmanagerd/xcuitestrunner.go b/ios/testmanagerd/xcuitestrunner.go index 58923be0..0b2438e6 100644 --- a/ios/testmanagerd/xcuitestrunner.go +++ b/ios/testmanagerd/xcuitestrunner.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "maps" "path" "strings" @@ -219,7 +220,7 @@ const ( const testBundleSuffix = "UITests.xctrunner" -func RunXCUITest(bundleID string, testRunnerBundleID string, xctestConfigName string, device ios.DeviceEntry, env []string, testsToRun []string, testsToSkip []string, testListener *TestListener, isXCTest bool) ([]TestSuite, error) { +func RunXCUITest(bundleID string, testRunnerBundleID string, xctestConfigName string, device ios.DeviceEntry, env map[string]interface{}, testsToRun []string, testsToSkip []string, testListener *TestListener, isXCTest bool) ([]TestSuite, error) { // FIXME: this is redundant code, getting the app list twice and creating the appinfos twice // just to generate the xctestConfigFileName. Should be cleaned up at some point. installationProxy, err := installationproxy.New(device) @@ -256,7 +257,7 @@ func RunXCUIWithBundleIdsCtx( xctestConfigFileName string, device ios.DeviceEntry, args []string, - env []string, + env map[string]interface{}, testsToRun []string, testsToSkip []string, testListener *TestListener, @@ -288,7 +289,7 @@ func runXUITestWithBundleIdsXcode15Ctx( xctestConfigFileName string, device ios.DeviceEntry, args []string, - env []string, + env map[string]interface{}, testsToRun []string, testsToSkip []string, testListener *TestListener, @@ -443,7 +444,7 @@ func killTestRunner(killer processKiller, pid int) error { return nil } -func startTestRunner17(device ios.DeviceEntry, appserviceConn *appservice.Connection, xctestConfigPath string, bundleID string, sessionIdentifier string, testBundlePath string, testArgs []string, testEnv []string, isXCTest bool) (appservice.LaunchedAppWithStdIo, error) { +func startTestRunner17(device ios.DeviceEntry, appserviceConn *appservice.Connection, xctestConfigPath string, bundleID string, sessionIdentifier string, testBundlePath string, testArgs []string, testEnv map[string]interface{}, isXCTest bool) (appservice.LaunchedAppWithStdIo, error) { args := []interface{}{} for _, arg := range testArgs { args = append(args, arg) @@ -471,12 +472,12 @@ func startTestRunner17(device ios.DeviceEntry, appserviceConn *appservice.Connec "XCTestSessionIdentifier": strings.ToUpper(sessionIdentifier), } - for _, entrystring := range testEnv { - entry := strings.Split(entrystring, "=") - key := entry[0] - value := entry[1] - env[key] = value - log.Debugf("adding extra env %s=%s", key, value) + if len(testEnv) > 0 { + maps.Copy(env, testEnv) + + for key, value := range testEnv { + log.Debugf("adding extra env %s=%s", key, value) + } } opts := map[string]interface{}{ diff --git a/ios/testmanagerd/xcuitestrunner_11.go b/ios/testmanagerd/xcuitestrunner_11.go index 2da86197..24679da5 100644 --- a/ios/testmanagerd/xcuitestrunner_11.go +++ b/ios/testmanagerd/xcuitestrunner_11.go @@ -3,7 +3,7 @@ package testmanagerd import ( "context" "fmt" - "strings" + "maps" "github.com/danielpaulus/go-ios/ios" dtx "github.com/danielpaulus/go-ios/ios/dtx_codec" @@ -18,7 +18,7 @@ func runXCUIWithBundleIdsXcode11Ctx( xctestConfigFileName string, device ios.DeviceEntry, args []string, - env []string, + env map[string]interface{}, testsToRun []string, testsToSkip []string, testListener *TestListener, @@ -110,7 +110,7 @@ func runXCUIWithBundleIdsXcode11Ctx( } func startTestRunner11(pControl *instruments.ProcessControl, xctestConfigPath string, bundleID string, - sessionIdentifier string, testBundlePath string, wdaargs []string, wdaenv []string, + sessionIdentifier string, testBundlePath string, wdaargs []string, wdaenv map[string]interface{}, ) (uint64, error) { args := []interface{}{} for _, arg := range wdaargs { @@ -122,12 +122,12 @@ func startTestRunner11(pControl *instruments.ProcessControl, xctestConfigPath st "XCTestSessionIdentifier": sessionIdentifier, } - for _, entrystring := range wdaenv { - entry := strings.Split(entrystring, "=") - key := entry[0] - value := entry[1] - env[key] = value - log.Debugf("adding extra env %s=%s", key, value) + if len(wdaenv) > 0 { + maps.Copy(env, wdaenv) + + for key, value := range wdaenv { + log.Debugf("adding extra env %s=%s", key, value) + } } opts := map[string]interface{}{ diff --git a/ios/testmanagerd/xcuitestrunner_12.go b/ios/testmanagerd/xcuitestrunner_12.go index 930b3978..b82ff70e 100644 --- a/ios/testmanagerd/xcuitestrunner_12.go +++ b/ios/testmanagerd/xcuitestrunner_12.go @@ -3,7 +3,7 @@ package testmanagerd import ( "context" "fmt" - "strings" + "maps" "time" "github.com/danielpaulus/go-ios/ios" @@ -14,7 +14,7 @@ import ( ) func runXUITestWithBundleIdsXcode12Ctx(ctx context.Context, bundleID string, testRunnerBundleID string, xctestConfigFileName string, - device ios.DeviceEntry, args []string, env []string, testsToRun []string, testsToSkip []string, testListener *TestListener, isXCTest bool, + device ios.DeviceEntry, args []string, env map[string]interface{}, testsToRun []string, testsToSkip []string, testListener *TestListener, isXCTest bool, ) ([]TestSuite, error) { conn, err := dtx.NewUsbmuxdConnection(device, testmanagerdiOS14) if err != nil { @@ -108,7 +108,7 @@ func runXUITestWithBundleIdsXcode12Ctx(ctx context.Context, bundleID string, tes } func startTestRunner12(pControl *instruments.ProcessControl, xctestConfigPath string, bundleID string, - sessionIdentifier string, testBundlePath string, wdaargs []string, wdaenv []string, + sessionIdentifier string, testBundlePath string, wdaargs []string, wdaenv map[string]interface{}, ) (uint64, error) { args := []interface{}{ "-NSTreatUnknownArgumentsAsOpen", "NO", "-ApplePersistenceIgnoreState", "YES", @@ -130,12 +130,12 @@ func startTestRunner12(pControl *instruments.ProcessControl, xctestConfigPath st "XCTestSessionIdentifier": sessionIdentifier, } - for _, entrystring := range wdaenv { - entry := strings.Split(entrystring, "=") - key := entry[0] - value := entry[1] - env[key] = value - log.Debugf("adding extra env %s=%s", key, value) + if len(wdaenv) > 0 { + maps.Copy(env, wdaenv) + + for key, value := range wdaenv { + log.Debugf("adding extra env %s=%s", key, value) + } } opts := map[string]interface{}{ diff --git a/ios/testmanagerd/xcuitestrunner_test.go b/ios/testmanagerd/xcuitestrunner_test.go index 72f55cb9..7788d811 100644 --- a/ios/testmanagerd/xcuitestrunner_test.go +++ b/ios/testmanagerd/xcuitestrunner_test.go @@ -67,7 +67,7 @@ func TestXcuiTest(t *testing.T) { ctx, stopWda := context.WithCancel(context.Background()) bundleID, testbundleID, xctestconfig := "com.facebook.WebDriverAgentRunner.xctrunner", "com.facebook.WebDriverAgentRunner.xctrunner", "WebDriverAgentRunner.xctest" var wdaargs []string - var wdaenv []string + var wdaenv map[string]interface{} go func() { _, err := testmanagerd.RunXCUIWithBundleIdsCtx(ctx, bundleID, testbundleID, xctestconfig, device, wdaargs, wdaenv, nil, nil, testmanagerd.NewTestListener(os.Stdout, os.Stdout, os.TempDir()), false) if err != nil { diff --git a/main.go b/main.go index 3dfcc6b7..be8037c3 100644 --- a/main.go +++ b/main.go @@ -913,8 +913,7 @@ The commands work as following: } rawTestlog, rawTestlogErr := arguments.String("--log-output") - env := arguments["--env"].([]string) - + env := splitKeyValuePairs(arguments["--env"].([]string), "=") isXCTest, _ := arguments.Bool("--xctest") if rawTestlogErr == nil { @@ -1191,7 +1190,7 @@ func runWdaCommand(device ios.DeviceEntry, arguments docopt.Opts) bool { testbundleID, _ := arguments.String("--testrunnerbundleid") xctestconfig, _ := arguments.String("--xctestconfig") wdaargs := arguments["--arg"].([]string) - wdaenv := arguments["--env"].([]string) + wdaenv := splitKeyValuePairs(arguments["--env"].([]string), "=") if bundleID == "" && testbundleID == "" && xctestconfig == "" { log.Info("no bundle ids specified, falling back to defaults") @@ -2186,3 +2185,14 @@ func exitIfError(msg string, err error) { log.WithFields(log.Fields{"err": err}).Fatalf(msg) } } + +func splitKeyValuePairs(envArgs []string, sep string) map[string]interface{} { + env := make(map[string]interface{}) + for _, entrystring := range envArgs { + entry := strings.Split(entrystring, sep) + key := entry[0] + value := entry[1] + env[key] = value + } + return env +} diff --git a/restapi/api/middleware.go b/restapi/api/middleware.go index 12699f9c..e7141b0d 100644 --- a/restapi/api/middleware.go +++ b/restapi/api/middleware.go @@ -6,7 +6,9 @@ import ( "sync" "github.com/danielpaulus/go-ios/ios" + "github.com/danielpaulus/go-ios/ios/tunnel" "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" ) // DeviceMiddleware makes sure a udid was specified and that a device with that UDID @@ -30,11 +32,52 @@ func DeviceMiddleware() gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err}) return } + + info, err := tunnel.TunnelInfoForDevice(device.Properties.SerialNumber, ios.HttpApiPort()) + if err == nil { + log.WithField("udid", device.Properties.SerialNumber).Printf("Received tunnel info %v", info) + + device.UserspaceTUNPort = info.UserspaceTUNPort + device.UserspaceTUN = info.UserspaceTUN + + device, err = deviceWithRsdProvider(device, udid, info.Address, info.RsdPort) + if err != nil { + c.Error(err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) // Return an error response + c.Next() + } + } else { + log.WithField("udid", device.Properties.SerialNumber).Warn("failed to get tunnel info") + } + c.Set(IOS_KEY, device) c.Next() } } +func deviceWithRsdProvider(device ios.DeviceEntry, udid string, address string, rsdPort int) (ios.DeviceEntry, error) { + rsdService, err := ios.NewWithAddrPortDevice(address, rsdPort, device) + if err != nil { + return device, err + } + + defer rsdService.Close() + rsdProvider, err := rsdService.Handshake() + if err != nil { + return device, err + } + + device1, err := ios.GetDeviceWithAddress(udid, address, rsdProvider) + if err != nil { + return device, err + } + + device1.UserspaceTUN = device.UserspaceTUN + device1.UserspaceTUNPort = device.UserspaceTUNPort + + return device1, nil +} + const IOS_KEY = "go_ios_device" // LimitNumClientsUDID limits clients to one concurrent connection per device UDID at a time diff --git a/restapi/api/routes.go b/restapi/api/routes.go index b433650f..b61287c0 100644 --- a/restapi/api/routes.go +++ b/restapi/api/routes.go @@ -38,6 +38,9 @@ func simpleDeviceRoutes(device *gin.RouterGroup) { device.PUT("/setlocation", SetLocation) device.GET("/syslog", streamingMiddleWare, Syslog) + device.POST("/wda/session", CreateWdaSession) + device.GET("/wda/session/:sessionId", ReadWdaSession) + device.DELETE("/wda/session/:sessionId", DeleteWdaSession) } func appRoutes(group *gin.RouterGroup) { diff --git a/restapi/api/wda.go b/restapi/api/wda.go new file mode 100644 index 00000000..c68240d9 --- /dev/null +++ b/restapi/api/wda.go @@ -0,0 +1,183 @@ +package api + +import ( + "context" + "net/http" + "os" + "sync" + + "github.com/danielpaulus/go-ios/ios" + "github.com/danielpaulus/go-ios/ios/testmanagerd" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" +) + +type WdaConfig struct { + BundleID string `json:"bundleId" binding:"required"` + TestbundleID string `json:"testBundleId" binding:"required"` + XCTestConfig string `json:"xcTestConfig" binding:"required"` + Args []string `json:"args"` + Env map[string]interface{} `json:"env"` +} + +type WdaSessionKey struct { + udid string + sessionID string +} + +type WdaSession struct { + Config WdaConfig `json:"config" binding:"required"` + SessionId string `json:"sessionId" binding:"required"` + Udid string `json:"udid" binding:"required"` + stopWda context.CancelFunc +} + +func (session *WdaSession) Write(p []byte) (n int, err error) { + log. + WithField("udid", session.Udid). + WithField("sessionId", session.SessionId). + Debugf("WDA_LOG %s", p) + + return len(p), nil +} + +var globalSessions = sync.Map{} + +// @Summary Create a new WDA session +// @Description Create a new WebDriverAgent session for the specified device +// @Tags WebDriverAgent +// @Accept json +// @Produce json +// @Param config body WdaConfig true "WebDriverAgent Configuration" +// @Success 200 {object} WdaSession +// @Failure 400 {object} GenericResponse +// @Router /wda/session [post] +func CreateWdaSession(c *gin.Context) { + device := c.MustGet(IOS_KEY).(ios.DeviceEntry) + log. + WithField("udid", device.Properties.SerialNumber). + Debugf("Creating WDA session") + + var config WdaConfig + if err := c.ShouldBindJSON(&config); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + sessionKey := WdaSessionKey{ + udid: device.Properties.SerialNumber, + sessionID: uuid.New().String(), + } + + wdaCtx, stopWda := context.WithCancel(context.Background()) + + session := WdaSession{ + Udid: sessionKey.udid, + SessionId: sessionKey.sessionID, + Config: config, + stopWda: stopWda, + } + + go func() { + _, err := testmanagerd.RunXCUIWithBundleIdsCtx(wdaCtx, config.BundleID, config.TestbundleID, config.XCTestConfig, device, config.Args, config.Env, nil, nil, testmanagerd.NewTestListener(&session, &session, os.TempDir()), false) + if err != nil { + log. + WithField("udid", sessionKey.udid). + WithField("sessionId", sessionKey.sessionID). + WithError(err). + Error("Failed running WDA") + } + + stopWda() + globalSessions.Delete(sessionKey) + + log. + WithField("udid", sessionKey.udid). + WithField("sessionId", sessionKey.sessionID). + Debug("Deleted WDA session") + }() + + globalSessions.Store(sessionKey, session) + + log. + WithField("udid", sessionKey.udid). + WithField("sessionId", sessionKey.sessionID). + Debugf("Requested to start WDA session") + + c.JSON(http.StatusOK, session) +} + +// @Summary Get a WebDriverAgent session +// @Description Get a WebDriverAgent session by sessionId +// @Tags WebDriverAgent +// @Produce json +// @Param sessionId path string true "Session ID" +// @Success 200 {object} WdaSession +// @Failure 400 {object} GenericResponse +// @Router /wda/session/{sessionId} [get] +func ReadWdaSession(c *gin.Context) { + device := c.MustGet(IOS_KEY).(ios.DeviceEntry) + + sessionID := c.Param("sessionId") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"}) + return + } + + sessionKey := WdaSessionKey{ + udid: device.Properties.SerialNumber, + sessionID: sessionID, + } + + session, loaded := globalSessions.Load(sessionKey) + if !loaded { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + c.JSON(http.StatusOK, session) +} + +// @Summary Delete a WebDriverAgent session +// @Description Delete a WebDriverAgent session by sessionId +// @Tags WebDriverAgent +// @Produce json +// @Param sessionId path string true "Session ID" +// @Success 200 {object} WdaSession +// @Failure 400 {object} GenericResponse +// @Router /wda/session/{sessionId} [delete] +func DeleteWdaSession(c *gin.Context) { + device := c.MustGet(IOS_KEY).(ios.DeviceEntry) + + sessionID := c.Param("sessionId") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"}) + return + } + + sessionKey := WdaSessionKey{ + udid: device.Properties.SerialNumber, + sessionID: sessionID, + } + + session, loaded := globalSessions.Load(sessionKey) + if !loaded { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + wdaSession, ok := session.(WdaSession) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cast session"}) + return + } + wdaSession.stopWda() + + log. + WithField("udid", sessionKey.udid). + WithField("sessionId", sessionKey.sessionID). + Debug("Requested to stop WDA") + + c.JSON(http.StatusOK, session) +}