diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ef3bb62..043a099 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -176,53 +176,6 @@ jobs: path: | mobilecli-windows-amd64.exe - ios_simulator_test: - if: false - runs-on: ${{ matrix.runner }} - timeout-minutes: 10 - needs: [build_on_macos] - strategy: - matrix: - include: - - ios_version: '17' - runner: macos-14 - - ios_version: '18' - runner: macos-latest - - ios_version: '26' - runner: macos-latest - fail-fast: false - - steps: - - uses: actions/checkout@v4 - - - name: Download macos build - uses: actions/download-artifact@v4 - with: - name: macos-build - path: . - - - name: Make mobilecli executable - run: | - mv mobilecli-darwin-arm64 mobilecli - chmod +x mobilecli - - - name: Switch to Xcode ${{ matrix.xcode_version }} - if: matrix.xcode_version - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ matrix.xcode_version }} - - - name: Launch simulator first - run: | - xcrun simctl list runtimes - xcodebuild -runFirstLaunch - - - name: Run tests for iOS ${{ matrix.ios_version }} - run: | - cd test - npm install --ignore-scripts - npm run test -- --grep "iOS ${{ matrix.ios_version }}" - server_test: runs-on: ubuntu-latest timeout-minutes: 10 @@ -247,80 +200,29 @@ jobs: npm install --ignore-scripts npm run test -- --grep "server" - android_emulator_test: - if: false - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [build_on_linux] - strategy: - matrix: - api_level: ['31', '36'] - fail-fast: false - + e2e_test: + runs-on: [self-hosted, macOS, ARM64] + timeout-minutes: 15 + needs: [build_on_macos] steps: - uses: actions/checkout@v4 - - name: Enable KVM - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Setup JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'adopt' - - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - with: - cmdline-tools-version: 11479570 - - - name: Install Android SDK components - run: | - yes | sdkmanager --licenses - sdkmanager "platform-tools" "platforms;android-${{ matrix.api_level }}" - sdkmanager "system-images;android-${{ matrix.api_level }};google_apis;x86_64" - sdkmanager "emulator" - - - name: List AVD directory - run: | - ls -la ~/.android/avd || echo "AVD directory does not exist" - - - name: Download linux build + - name: Download macos build uses: actions/download-artifact@v4 with: - name: linux-build + name: macos-build path: . - name: Make mobilecli executable run: | - mv mobilecli-linux-amd64 mobilecli + mv mobilecli-darwin-arm64 mobilecli chmod +x mobilecli - - name: npm install + - name: Run E2E tests run: | cd test npm install --ignore-scripts - - - name: run tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api_level }} - arch: x86_64 - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - target: google_apis - working-directory: test - script: npm run test -- --grep "Android API ${{ matrix.api_level }}" - -# - name: Run tests for Android API ${{ matrix.api_level }} -# run: | -# cd test -# npm install --ignore-scripts -# npm run test -- --grep "Android API ${{ matrix.api_level }}" + npm test publish: if: github.ref_type == 'tag' diff --git a/.gitignore b/.gitignore index 521b68b..d21e318 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ screenshot.png **/coverage*.out **/coverage*.html test/coverage +coverage/ diff --git a/Makefile b/Makefile index 1297207..69d2e2c 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,10 @@ .PHONY: all build test test-cover lint fmt clean +COVERAGE_DIR := $(CURDIR)/coverage +COVERAGE_UNIT := $(COVERAGE_DIR)/unit +COVERAGE_E2E := $(CURDIR)/test/coverage +COVERAGE_MERGED := $(COVERAGE_DIR)/merged + all: build build: @@ -13,15 +18,29 @@ build-cover: test: go test ./... -v -race -test-cover: build-cover - go test ./... -v -race -cover -coverprofile=coverage.out - go tool cover -html=coverage.out -o coverage.html +test-cover: + @rm -rf $(COVERAGE_UNIT) + @mkdir -p $(COVERAGE_UNIT) + go test -cover ./... -args -test.gocoverdir=$(COVERAGE_UNIT) test-e2e: build-cover - rm -rf test/coverage - (cd test && npm run test-simulator) - go tool covdata textfmt -i=test/coverage -o cover.out - go tool cover -func=cover.out + @rm -rf $(COVERAGE_E2E) + (cd test && npm test) + +coverage: test-cover test-e2e + @rm -rf $(COVERAGE_MERGED) + @mkdir -p $(COVERAGE_MERGED) + go tool covdata merge -i=$(COVERAGE_UNIT),$(COVERAGE_E2E) -o=$(COVERAGE_MERGED) + go tool covdata textfmt -i=$(COVERAGE_MERGED) -o=$(COVERAGE_DIR)/coverage.out + go tool cover -html=$(COVERAGE_DIR)/coverage.out -o=$(COVERAGE_DIR)/coverage.html + @echo "" + @echo "=== Coverage by package ===" + @go tool covdata percent -i=$(COVERAGE_MERGED) + @echo "" + @echo "=== Total ===" + @go tool cover -func=$(COVERAGE_DIR)/coverage.out | grep total + @echo "" + @echo "HTML report: $(COVERAGE_DIR)/coverage.html" lint: $(shell go env GOPATH)/bin/golangci-lint run @@ -32,3 +51,4 @@ fmt: clean: rm -f mobilecli coverage.out coverage.html + rm -rf coverage test/coverage diff --git a/cli/server.go b/cli/server.go index 778121d..62deb03 100644 --- a/cli/server.go +++ b/cli/server.go @@ -1,10 +1,15 @@ package cli import ( + "fmt" + + "github.com/mobile-next/mobilecli/daemon" "github.com/mobile-next/mobilecli/server" "github.com/spf13/cobra" ) +const defaultServerAddress = "localhost:12000" + var serverCmd = &cobra.Command{ Use: "server", Short: "Server management commands", @@ -15,25 +20,67 @@ var serverStartCmd = &cobra.Command{ Use: "start", Short: "Start the mobilecli server", Long: `Starts the mobilecli server.`, - Args: cobra.NoArgs, // No arguments allowed after "start" + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { listenAddr := cmd.Flag("listen").Value.String() if listenAddr == "" { - listenAddr = "localhost:12000" + listenAddr = defaultServerAddress } + // GetBool/GetString cannot fail for defined flags enableCORS, _ := cmd.Flags().GetBool("cors") + isDaemon, _ := cmd.Flags().GetBool("daemon") + + if isDaemon && !daemon.IsChild() { + child, err := daemon.Daemonize() + if err != nil { + return fmt.Errorf("failed to start daemon: %w", err) + } + + if child != nil { + fmt.Printf("Server daemon spawned, attempting to listen on %s\n", listenAddr) + return nil + } + } + return server.StartServer(listenAddr, enableCORS) }, } +var serverKillCmd = &cobra.Command{ + Use: "kill", + Short: "Stop the daemonized mobilecli server", + Long: `Connects to the server and sends a shutdown command via JSON-RPC.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // GetString cannot fail for defined flags + addr, _ := cmd.Flags().GetString("listen") + if addr == "" { + addr = defaultServerAddress + } + + err := daemon.KillServer(addr) + if err != nil { + return err + } + + fmt.Printf("Server shutdown command sent successfully\n") + return nil + }, +} + func init() { rootCmd.AddCommand(serverCmd) // add server subcommands serverCmd.AddCommand(serverStartCmd) + serverCmd.AddCommand(serverKillCmd) - // server command flags + // server start flags serverStartCmd.Flags().String("listen", "", "Address to listen on (e.g., 'localhost:12000' or '0.0.0.0:13000')") serverStartCmd.Flags().Bool("cors", false, "Enable CORS support") + serverStartCmd.Flags().BoolP("daemon", "d", false, "Run server in daemon mode (background)") + + // server kill flags + serverKillCmd.Flags().String("listen", "", fmt.Sprintf("Address of server to kill (default: %s)", defaultServerAddress)) } diff --git a/daemon/daemon.go b/daemon/daemon.go new file mode 100644 index 0000000..045d713 --- /dev/null +++ b/daemon/daemon.go @@ -0,0 +1,108 @@ +package daemon + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/sevlyar/go-daemon" +) + +const ( + // DaemonEnvVar is the environment variable that marks a daemon child process + DaemonEnvVar = "MOBILECLI_DAEMON_CHILD" + + // shutdownRequestID is the JSON-RPC request ID for shutdown commands + shutdownRequestID = 1 +) + +// Daemonize detaches the process and returns the child process handle +// If the returned process is nil, this is the child process +// If the returned process is non-nil, this is the parent process +func Daemonize() (*os.Process, error) { + // no PID file needed + // we don't want log file, server handles its own logging + ctx := &daemon.Context{ + PidFileName: "", + PidFilePerm: 0, + LogFileName: "", + LogFilePerm: 0, + WorkDir: "/", + Umask: 027, + Args: os.Args, + Env: append(os.Environ(), fmt.Sprintf("%s=1", DaemonEnvVar)), + } + + child, err := ctx.Reborn() + if err != nil { + return nil, fmt.Errorf("failed to daemonize: %w", err) + } + + return child, nil +} + +// IsChild returns true if this is the daemon child process +func IsChild() bool { + return os.Getenv(DaemonEnvVar) == "1" +} + +// KillServer connects to the server and sends a shutdown command via JSON-RPC +func KillServer(addr string) error { + // normalize address to match server's format + // if no colon, assume it's a bare port number + if !strings.Contains(addr, ":") { + // validate it's a number + if _, err := strconv.Atoi(addr); err == nil { + addr = ":" + addr + } + } + + // if address starts with colon, prepend localhost + if strings.HasPrefix(addr, ":") { + addr = "localhost" + addr + } + + // prepend http:// scheme + addr = "http://" + addr + + // create JSON-RPC request + reqBody := map[string]interface{}{ + "jsonrpc": "2.0", + "method": "server.shutdown", + "id": shutdownRequestID, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // send request + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodPost, addr+"/rpc", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + if strings.Contains(err.Error(), "connection refused") { + return fmt.Errorf("server is not running on %s", addr) + } + return fmt.Errorf("failed to connect to server: %w", err) + } + + // check response + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return fmt.Errorf("server returned error: %s", resp.Status) + } + + return resp.Body.Close() +} diff --git a/devices/android_test.go b/devices/android_test.go new file mode 100644 index 0000000..44a0fbe --- /dev/null +++ b/devices/android_test.go @@ -0,0 +1,364 @@ +package devices + +import ( + "encoding/xml" + "testing" + + "github.com/mobile-next/mobilecli/types" +) + +func TestIsAscii(t *testing.T) { + tests := []struct { + name string + text string + want bool + }{ + {"empty string", "", true}, + {"simple ascii", "hello world", true}, + {"numbers and punctuation", "abc123!@#", true}, + {"newlines and tabs", "hello\nworld\t!", true}, + {"unicode emoji", "hello 🌍", false}, + {"chinese characters", "你好", false}, + {"accented characters", "café", false}, + {"max ascii char", string(rune(127)), true}, + {"first non-ascii char", string(rune(128)), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isAscii(tt.text); got != tt.want { + t.Errorf("isAscii(%q) = %v, want %v", tt.text, got, tt.want) + } + }) + } +} + +func TestEscapeShellText(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + {"simple text", "hello", "hello"}, + {"text with spaces", "hello world", "hello\\ world"}, + {"single quote", "it's", "it\\'s"}, + {"double quote", `say "hi"`, `say\ \"hi\"`}, + {"semicolons", "a;b", "a\\;b"}, + {"pipes", "a|b", "a\\|b"}, + {"ampersands", "a&b", "a\\&b"}, + {"parentheses", "(test)", "\\(test\\)"}, + {"dollar sign", "$HOME", "\\$HOME"}, + {"asterisk", "*.txt", "\\*.txt"}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := escapeShellText(tt.text); got != tt.want { + t.Errorf("escapeShellText(%q) = %q, want %q", tt.text, got, tt.want) + } + }) + } +} + +func TestMatchesAVDName(t *testing.T) { + tests := []struct { + name string + avdName string + deviceName string + want bool + }{ + {"exact match", "Pixel_9_Pro", "Pixel_9_Pro", true}, + {"underscores to spaces", "Pixel_9_Pro", "Pixel 9 Pro", true}, + {"no match", "Pixel_9_Pro", "Pixel 8", false}, + {"empty strings", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := matchesAVDName(tt.avdName, tt.deviceName); got != tt.want { + t.Errorf("matchesAVDName(%q, %q) = %v, want %v", tt.avdName, tt.deviceName, got, tt.want) + } + }) + } +} + +func TestAndroidDevice_DeviceType(t *testing.T) { + tests := []struct { + name string + transportID string + state string + want string + }{ + {"emulator by transport id", "emulator-5554", "online", "emulator"}, + {"real device", "R5CR1234567", "online", "real"}, + {"offline device is emulator", "", "offline", "emulator"}, + {"empty transport online", "", "online", "real"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &AndroidDevice{transportID: tt.transportID, state: tt.state} + if got := d.DeviceType(); got != tt.want { + t.Errorf("DeviceType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAndroidDevice_GetAdbIdentifier(t *testing.T) { + tests := []struct { + name string + id string + transportID string + want string + }{ + {"uses transport id when set", "Pixel_9_Pro", "emulator-5554", "emulator-5554"}, + {"falls back to id", "Pixel_9_Pro", "", "Pixel_9_Pro"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &AndroidDevice{id: tt.id, transportID: tt.transportID} + if got := d.getAdbIdentifier(); got != tt.want { + t.Errorf("getAdbIdentifier() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAndroidDevice_AccessorMethods(t *testing.T) { + d := &AndroidDevice{ + id: "test-id", + name: "Test Device", + version: "14.0", + state: "online", + } + + if d.ID() != "test-id" { + t.Errorf("ID() = %q, want %q", d.ID(), "test-id") + } + if d.Name() != "Test Device" { + t.Errorf("Name() = %q, want %q", d.Name(), "Test Device") + } + if d.Version() != "14.0" { + t.Errorf("Version() = %q, want %q", d.Version(), "14.0") + } + if d.Platform() != "android" { + t.Errorf("Platform() = %q, want %q", d.Platform(), "android") + } + if d.State() != "online" { + t.Errorf("State() = %q, want %q", d.State(), "online") + } +} + +func TestAndroidDevice_GetScreenElementRect(t *testing.T) { + d := &AndroidDevice{} + + tests := []struct { + name string + bounds string + want types.ScreenElementRect + }{ + { + "valid bounds", + "[0,0][1080,2400]", + types.ScreenElementRect{X: 0, Y: 0, Width: 1080, Height: 2400}, + }, + { + "offset bounds", + "[100,200][500,600]", + types.ScreenElementRect{X: 100, Y: 200, Width: 400, Height: 400}, + }, + { + "invalid format", + "invalid", + types.ScreenElementRect{}, + }, + { + "empty string", + "", + types.ScreenElementRect{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := d.getScreenElementRect(tt.bounds) + if got != tt.want { + t.Errorf("getScreenElementRect(%q) = %+v, want %+v", tt.bounds, got, tt.want) + } + }) + } +} + +func TestAndroidDevice_CollectElements(t *testing.T) { + d := &AndroidDevice{} + + node := uiAutomatorXmlNode{ + Class: "android.widget.FrameLayout", + Bounds: "[0,0][1080,2400]", + Nodes: []uiAutomatorXmlNode{ + { + Class: "android.widget.TextView", + Text: "Hello World", + ContentDesc: "greeting", + Bounds: "[10,20][200,60]", + ResourceID: "com.example:id/text", + }, + { + Class: "android.widget.EditText", + Hint: "Enter name", + Focused: "true", + Bounds: "[10,70][200,110]", + }, + { + Class: "android.widget.View", + Bounds: "[0,0][0,0]", // zero-size, should be excluded + Text: "invisible", + }, + }, + } + + elements := d.collectElements(node) + + if len(elements) != 2 { + t.Fatalf("expected 2 elements, got %d", len(elements)) + } + + // first element: TextView with text and content-desc + if elements[0].Type != "android.widget.TextView" { + t.Errorf("element[0].Type = %q, want %q", elements[0].Type, "android.widget.TextView") + } + if *elements[0].Text != "Hello World" { + t.Errorf("element[0].Text = %q, want %q", *elements[0].Text, "Hello World") + } + if *elements[0].Label != "greeting" { + t.Errorf("element[0].Label = %q, want %q", *elements[0].Label, "greeting") + } + if *elements[0].Identifier != "com.example:id/text" { + t.Errorf("element[0].Identifier = %q, want %q", *elements[0].Identifier, "com.example:id/text") + } + + // second element: EditText with hint and focused + if *elements[1].Label != "Enter name" { + t.Errorf("element[1].Label = %q, want %q", *elements[1].Label, "Enter name") + } + if elements[1].Focused == nil || !*elements[1].Focused { + t.Error("element[1].Focused should be true") + } +} + +func TestAndroidDevice_DumpSource_ParsesUIAutomatorXML(t *testing.T) { + d := &AndroidDevice{} + + xmlContent := ` + + + + + + + +` + + var uiXml uiAutomatorXml + if err := xml.Unmarshal([]byte(xmlContent), &uiXml); err != nil { + t.Fatalf("failed to parse XML: %v", err) + } + + elements := d.collectElements(uiXml.RootNode) + + if len(elements) != 2 { + t.Fatalf("expected 2 elements, got %d", len(elements)) + } + + // verify Settings title element + if *elements[0].Text != "Settings" { + t.Errorf("element[0].Text = %q, want %q", *elements[0].Text, "Settings") + } + if elements[0].Rect.X != 50 || elements[0].Rect.Y != 100 { + t.Errorf("element[0].Rect = %+v, want x=50 y=100", elements[0].Rect) + } + + // verify Switch element + if *elements[1].Text != "ON" { + t.Errorf("element[1].Text = %q, want %q", *elements[1].Text, "ON") + } +} + +func TestAndroidDevice_PressButton_KeyMap(t *testing.T) { + validKeys := []string{ + "HOME", "BACK", "VOLUME_UP", "VOLUME_DOWN", "ENTER", + "DPAD_CENTER", "DPAD_UP", "DPAD_DOWN", "DPAD_LEFT", "DPAD_RIGHT", + "BACKSPACE", "APP_SWITCH", "POWER", + } + + // just verify the key map has entries for all expected keys + keyMap := map[string]string{ + "HOME": "KEYCODE_HOME", + "BACK": "KEYCODE_BACK", + "VOLUME_UP": "KEYCODE_VOLUME_UP", + "VOLUME_DOWN": "KEYCODE_VOLUME_DOWN", + "ENTER": "KEYCODE_ENTER", + "DPAD_CENTER": "KEYCODE_DPAD_CENTER", + "DPAD_UP": "KEYCODE_DPAD_UP", + "DPAD_DOWN": "KEYCODE_DPAD_DOWN", + "DPAD_LEFT": "KEYCODE_DPAD_LEFT", + "DPAD_RIGHT": "KEYCODE_DPAD_RIGHT", + "BACKSPACE": "KEYCODE_DEL", + "APP_SWITCH": "KEYCODE_APP_SWITCH", + "POWER": "KEYCODE_POWER", + } + + for _, key := range validKeys { + if _, ok := keyMap[key]; !ok { + t.Errorf("key %q missing from keyMap", key) + } + } +} + +func TestAndroidDevice_StartAgent_OfflineError(t *testing.T) { + d := &AndroidDevice{id: "test-emu", state: "offline"} + + err := d.StartAgent(StartAgentConfig{}) + if err == nil { + t.Error("expected error for offline device") + } +} + +func TestAndroidDevice_StartAgent_OnlineNoOp(t *testing.T) { + d := &AndroidDevice{id: "test-device", state: "online"} + + err := d.StartAgent(StartAgentConfig{}) + if err != nil { + t.Errorf("expected no error for online device, got %v", err) + } +} + +func TestAndroidDevice_Shutdown_OnlyEmulators(t *testing.T) { + d := &AndroidDevice{id: "R5CR1234567", transportID: "R5CR1234567", state: "online"} + + err := d.Shutdown() + if err == nil { + t.Error("expected error when shutting down real device") + } +} + +func TestAndroidDevice_Shutdown_AlreadyOffline(t *testing.T) { + d := &AndroidDevice{id: "test-emu", transportID: "emulator-5554", state: "offline"} + + err := d.Shutdown() + if err == nil { + t.Error("expected error when emulator already offline") + } +} + +func TestAndroidDevice_SetOrientation_InvalidValue(t *testing.T) { + d := &AndroidDevice{id: "test", state: "online"} + + err := d.SetOrientation("diagonal") + if err == nil { + t.Error("expected error for invalid orientation") + } +} diff --git a/devices/avd_test.go b/devices/avd_test.go new file mode 100644 index 0000000..27603b7 --- /dev/null +++ b/devices/avd_test.go @@ -0,0 +1,210 @@ +package devices + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConvertAPILevelToVersion(t *testing.T) { + tests := []struct { + apiLevel string + want string + }{ + {"36", "16.0"}, + {"35", "15.0"}, + {"34", "14.0"}, + {"33", "13.0"}, + {"32", "12.1"}, + {"31", "12.0"}, + {"30", "11.0"}, + {"29", "10.0"}, + {"28", "9.0"}, + {"21", "5.0"}, + // unknown API level returns as-is + {"99", "99"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run("api_"+tt.apiLevel, func(t *testing.T) { + if got := convertAPILevelToVersion(tt.apiLevel); got != tt.want { + t.Errorf("convertAPILevelToVersion(%q) = %q, want %q", tt.apiLevel, got, tt.want) + } + }) + } +} + +func TestGetAVDDetails_WithFixtures(t *testing.T) { + // create a temporary .android/avd directory structure + tmpHome := t.TempDir() + + avdDir := filepath.Join(tmpHome, ".android", "avd") + if err := os.MkdirAll(avdDir, 0750); err != nil { + t.Fatal(err) + } + + // create a .avd directory with config.ini + avdDataDir := filepath.Join(avdDir, "Pixel_9_Pro.avd") + if err := os.MkdirAll(avdDataDir, 0750); err != nil { + t.Fatal(err) + } + + // write the top-level .ini file pointing to the .avd directory + iniContent := "path=" + avdDataDir + "\n" + if err := os.WriteFile(filepath.Join(avdDir, "Pixel_9_Pro.ini"), []byte(iniContent), 0644); err != nil { + t.Fatal(err) + } + + // write config.ini inside the .avd directory + configContent := `avd.ini.displayname=Pixel 9 Pro +target=android-36 +AvdId=Pixel_9_Pro +` + if err := os.WriteFile(filepath.Join(avdDataDir, "config.ini"), []byte(configContent), 0644); err != nil { + t.Fatal(err) + } + + // override HOME for this test + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpHome) + defer os.Setenv("HOME", origHome) + + details, err := getAVDDetails() + if err != nil { + t.Fatalf("getAVDDetails() error: %v", err) + } + + if len(details) != 1 { + t.Fatalf("expected 1 AVD, got %d", len(details)) + } + + avd, ok := details["Pixel_9_Pro"] + if !ok { + t.Fatal("expected AVD 'Pixel_9_Pro' in results") + } + + if avd.Name != "Pixel 9 Pro" { + t.Errorf("Name = %q, want %q", avd.Name, "Pixel 9 Pro") + } + if avd.APILevel != "36" { + t.Errorf("APILevel = %q, want %q", avd.APILevel, "36") + } + if avd.AvdId != "Pixel_9_Pro" { + t.Errorf("AvdId = %q, want %q", avd.AvdId, "Pixel_9_Pro") + } +} + +func TestGetAVDDetails_EmptyDirectory(t *testing.T) { + tmpHome := t.TempDir() + + avdDir := filepath.Join(tmpHome, ".android", "avd") + if err := os.MkdirAll(avdDir, 0750); err != nil { + t.Fatal(err) + } + + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpHome) + defer os.Setenv("HOME", origHome) + + details, err := getAVDDetails() + if err != nil { + t.Fatalf("getAVDDetails() error: %v", err) + } + + if len(details) != 0 { + t.Errorf("expected 0 AVDs, got %d", len(details)) + } +} + +func TestGetAVDDetails_SkipsMissingDisplayName(t *testing.T) { + tmpHome := t.TempDir() + + avdDir := filepath.Join(tmpHome, ".android", "avd") + avdDataDir := filepath.Join(avdDir, "broken.avd") + if err := os.MkdirAll(avdDataDir, 0750); err != nil { + t.Fatal(err) + } + + iniContent := "path=" + avdDataDir + "\n" + if err := os.WriteFile(filepath.Join(avdDir, "broken.ini"), []byte(iniContent), 0644); err != nil { + t.Fatal(err) + } + + // config.ini without avd.ini.displayname + configContent := "target=android-31\n" + if err := os.WriteFile(filepath.Join(avdDataDir, "config.ini"), []byte(configContent), 0644); err != nil { + t.Fatal(err) + } + + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpHome) + defer os.Setenv("HOME", origHome) + + details, err := getAVDDetails() + if err != nil { + t.Fatalf("getAVDDetails() error: %v", err) + } + + if len(details) != 0 { + t.Errorf("expected 0 AVDs (missing display name), got %d", len(details)) + } +} + +func TestGetOfflineAndroidEmulators(t *testing.T) { + tmpHome := t.TempDir() + + avdDir := filepath.Join(tmpHome, ".android", "avd") + avdDataDir := filepath.Join(avdDir, "TestEmu.avd") + if err := os.MkdirAll(avdDataDir, 0750); err != nil { + t.Fatal(err) + } + + iniContent := "path=" + avdDataDir + "\n" + if err := os.WriteFile(filepath.Join(avdDir, "TestEmu.ini"), []byte(iniContent), 0644); err != nil { + t.Fatal(err) + } + + configContent := `avd.ini.displayname=Test Emulator +target=android-36 +AvdId=TestEmu +` + if err := os.WriteFile(filepath.Join(avdDataDir, "config.ini"), []byte(configContent), 0644); err != nil { + t.Fatal(err) + } + + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpHome) + defer os.Setenv("HOME", origHome) + + // no online devices - emulator should show as offline + offlineDevices, err := getOfflineAndroidEmulators(map[string]bool{}) + if err != nil { + t.Fatalf("getOfflineAndroidEmulators() error: %v", err) + } + + if len(offlineDevices) != 1 { + t.Fatalf("expected 1 offline device, got %d", len(offlineDevices)) + } + + device := offlineDevices[0] + if device.ID() != "TestEmu" { + t.Errorf("ID() = %q, want %q", device.ID(), "TestEmu") + } + if device.State() != "offline" { + t.Errorf("State() = %q, want %q", device.State(), "offline") + } + if device.Version() != "16.0" { + t.Errorf("Version() = %q, want %q", device.Version(), "16.0") + } + + // mark as online - should not appear in offline list + offlineDevices, err = getOfflineAndroidEmulators(map[string]bool{"TestEmu": true}) + if err != nil { + t.Fatalf("getOfflineAndroidEmulators() error: %v", err) + } + + if len(offlineDevices) != 0 { + t.Errorf("expected 0 offline devices when online, got %d", len(offlineDevices)) + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md index 1ced970..19d7c65 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -10,20 +10,16 @@ Run Go unit tests: make test ``` -## Integration Tests +## E2E Tests -The integration tests use iOS simulators to test real device functionality. +E2E tests exercise the mobilecli binary against real simulators, emulators, and physical devices. Tests use **persistent, named test devices** that you create once and reuse across runs. ### Prerequisites -1. **Install iOS Simulator Runtimes** - - Open Xcode - - **Settings** → **Components** - - Click the **"+"** button in the bottom-left corner - - Install the following iOS platforms: - - **iOS 16.4** (Build 20E247) - - **iOS 17.5** (Build 21F79) - - **iOS 18.6** (Build 22G86) +1. **Build the mobilecli binary** + ```bash + make build + ``` 2. **Install Node.js dependencies** ```bash @@ -31,25 +27,74 @@ The integration tests use iOS simulators to test real device functionality. npm install ``` -### Running Integration Tests +### Device Setup + +Tests look for specific named devices. Create them once; they persist across test runs. + +#### iOS Simulator: `mobilecli-test-sim` -Run all integration tests: ```bash -cd test -npm run test +xcrun simctl create "mobilecli-test-sim" "iPhone 16" com.apple.CoreSimulator.SimRuntime.iOS-26-0 ``` -Run tests for specific iOS version: +Boot it before running tests: +```bash +xcrun simctl boot "mobilecli-test-sim" +``` + +#### Android Emulator: `mobilecli-test-emu` + +```bash +avdmanager create avd -n "mobilecli-test-emu" -k "system-images;android-36;google_apis_playstore;arm64-v8a" -d "pixel_9" +``` + +Launch it before running tests: +```bash +emulator -avd mobilecli-test-emu & +``` + +#### iOS Real Device + +Connect a real iPhone via USB. No naming required — the tests auto-detect any connected iOS device. + +### Running Tests + +Run all tests (unavailable devices are skipped automatically): + ```bash cd test -npm run test -- --grep "iOS 16" -npm run test -- --grep "iOS 17" -npm run test -- --grep "iOS 18" +npm test +``` + +Run specific test suites: + +```bash +# Server protocol tests only (no devices needed) +npm test -- --grep "server" + +# iOS Simulator tests only +npm test -- --grep "iOS Simulator" + +# Android Emulator tests only +npm test -- --grep "Android Emulator" + +# iOS Real Device tests only +npm test -- --grep "iOS Real Device" ``` -### Test Behavior +### Skip Behavior + +Each test suite checks for its required device at startup: + +| Suite | Looks for | If missing | +|---|---|---| +| iOS Simulator | Simulator named `mobilecli-test-sim` | Skips all simulator tests | +| Android Emulator | Running emulator named `mobilecli-test-emu` | Skips all emulator tests | +| iOS Real Device | Any connected iOS device | Skips all real device tests | +| Server | Nothing (starts its own server) | Always runs | + +This means `npm test` always succeeds — it runs whatever is available and skips the rest. -- Tests automatically skip if the required iOS runtime is not installed -- Each test creates a fresh simulator and cleans up after completion -- Tests include screenshot capture and URL opening functionality +### CI +E2E tests run on a self-hosted macOS ARM64 runner where the test devices are pre-configured. Server tests run on ubuntu-latest (no devices needed). diff --git a/go.mod b/go.mod index 213164e..8970e2c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/sevlyar/go-daemon v0.1.6 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 @@ -25,6 +26,7 @@ require ( github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect github.com/grandcat/zeroconf v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/miekg/dns v1.1.57 // indirect github.com/onsi/ginkgo/v2 v2.9.5 // indirect diff --git a/go.sum b/go.sum index e9292b9..a9bb83f 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1: github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -61,6 +63,8 @@ github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULM github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sevlyar/go-daemon v0.1.6 h1:EUh1MDjEM4BI109Jign0EaknA2izkOyi0LV3ro3QQGs= +github.com/sevlyar/go-daemon v0.1.6/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= diff --git a/main.go b/main.go index 0baaa9f..689a6d8 100644 --- a/main.go +++ b/main.go @@ -8,10 +8,20 @@ import ( "github.com/mobile-next/mobilecli/cli" "github.com/mobile-next/mobilecli/commands" + "github.com/mobile-next/mobilecli/daemon" "github.com/mobile-next/mobilecli/devices" ) func main() { + // daemon child sets up its own signal handling in server.StartServer + if daemon.IsChild() { + if err := cli.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + // create shutdown hook for cleanup tracking hook := devices.NewShutdownHook() commands.SetShutdownHook(hook) diff --git a/server/dispatch.go b/server/dispatch.go index b2477ef..0ecbe11 100644 --- a/server/dispatch.go +++ b/server/dispatch.go @@ -33,6 +33,7 @@ func GetMethodRegistry() map[string]HandlerFunc { "device.apps.terminate": handleAppsTerminate, "device.apps.list": handleAppsList, "device.apps.foreground": handleAppsForeground, + "server.shutdown": handleServerShutdown, } } diff --git a/server/server.go b/server/server.go index d331fe8..29bc509 100644 --- a/server/server.go +++ b/server/server.go @@ -70,6 +70,9 @@ type SessionManager struct { // global session manager instance var sessionManager *SessionManager +// global shutdown channel for JSON-RPC shutdown command +var shutdownChan chan os.Signal + type JSONRPCRequest struct { // these fields are all omitempty, so we can report back to client if they are missing JSONRPC string `json:"jsonrpc,omitempty"` @@ -193,6 +196,9 @@ func StartServer(addr string, enableCORS bool) error { sessions: make(map[string]*StreamSession), } + // initialize shutdown channel for JSON-RPC shutdown command + shutdownChan = make(chan os.Signal, 1) + mux := http.NewServeMux() mux.HandleFunc("/", sendBanner) @@ -239,21 +245,14 @@ func StartServer(addr string, enableCORS bool) error { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - // wait for shutdown signal or server error - select { - case err := <-serverErr: - return fmt.Errorf("server error: %w", err) - case sig := <-sigChan: - utils.Info("Received signal %v, shutting down gracefully...", sig) - - // cleanup all resources first - hook.Shutdown() + performShutdown := func() error { + if err := hook.Shutdown(); err != nil { + utils.Info("hook shutdown error: %v", err) + } - // create context with timeout for shutdown ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - // shutdown server gracefully if err := server.Shutdown(ctx); err != nil { return fmt.Errorf("server shutdown error: %w", err) } @@ -261,6 +260,18 @@ func StartServer(addr string, enableCORS bool) error { utils.Info("Server stopped") return nil } + + // wait for shutdown signal or server error + select { + case err := <-serverErr: + return fmt.Errorf("server error: %w", err) + case sig := <-sigChan: + utils.Info("Received signal %v, shutting down gracefully...", sig) + return performShutdown() + case <-shutdownChan: + utils.Info("Received shutdown command via JSON-RPC, shutting down gracefully...") + return performShutdown() + } } func handleJSONRPC(w http.ResponseWriter, r *http.Request) { @@ -914,6 +925,20 @@ func handleAppsForeground(params json.RawMessage) (interface{}, error) { return response.Data, nil } +// handleServerShutdown initiates graceful server shutdown +func handleServerShutdown(params json.RawMessage) (interface{}, error) { + // trigger shutdown in background (after response is sent) + go func() { + time.Sleep(100 * time.Millisecond) // allow response to be sent + select { + case shutdownChan <- syscall.SIGTERM: + default: + } + }() + + return map[string]string{"status": "shutting down"}, nil +} + func sendJSONRPCError(w http.ResponseWriter, id interface{}, code int, message string, data interface{}) { response := JSONRPCResponse{ JSONRPC: "2.0", diff --git a/test/avdctl.ts b/test/avdctl.ts index 9467afb..ca433eb 100644 --- a/test/avdctl.ts +++ b/test/avdctl.ts @@ -209,3 +209,43 @@ export function getAvailableEmulators(): string[] { throw new Error(`Failed to list available emulators: ${error}`); } } + +export function findRunningEmulatorByName(name: string): string | null { + try { + const avds = getAvailableEmulators(); + if (!avds.includes(name)) { + return null; + } + + const devices = execSync(`${ADB_PATH} devices`, {encoding: 'utf8'}); + const deviceLines = devices.split('\n') + .filter(line => line.includes('device') && !line.includes('List')); + + for (const line of deviceLines) { + const parts = line.split('\t'); + if (parts.length >= 2 && parts[1].trim() === 'device') { + const deviceId = parts[0].trim(); + if (!deviceId.startsWith('emulator-')) { + continue; + } + + try { + const avdName = execSync(`${ADB_PATH} -s ${deviceId} emu avd name`, { + encoding: 'utf8', + timeout: 5000 + }).trim().split('\n')[0]; + + if (avdName === name) { + return deviceId; + } + } catch { + continue; + } + } + } + + return null; + } catch { + return null; + } +} diff --git a/test/emulator.ts b/test/emulator.ts index 6cb1679..3bb3d6f 100644 --- a/test/emulator.ts +++ b/test/emulator.ts @@ -1,114 +1,78 @@ -import { expect } from 'chai'; -import { execSync } from 'child_process'; +import {expect} from 'chai'; +import {execFileSync} from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; -import { - createAndLaunchEmulator, - shutdownEmulator, - deleteEmulator, - cleanupEmulators, - findAndroidSystemImage, - getAvailableEmulators -} from './avdctl'; +import {findRunningEmulatorByName} from './avdctl'; +import {mkdirSync} from "fs"; -const TEST_SERVER_URL = 'http://localhost:12001'; - -const SUPPORTED_VERSIONS = ['31', '36']; +const EMULATOR_NAME = 'mobilecli-test-emu'; describe('Android Emulator Tests', () => { - after(() => { - cleanupEmulators(); + let deviceId: string; + + before(function () { + this.timeout(30000); + + const id = findRunningEmulatorByName(EMULATOR_NAME); + if (!id) { + console.log(`No running "${EMULATOR_NAME}" emulator found, skipping Android Emulator tests`); + console.log('Create and launch one with:'); + console.log(' avdmanager create avd -n "mobilecli-test-emu" -k "system-images;android-36;google_apis_playstore;arm64-v8a" -d "pixel_9"'); + console.log(' emulator -avd mobilecli-test-emu &'); + this.skip(); + return; + } + + deviceId = id; }); - SUPPORTED_VERSIONS.forEach((apiLevel) => { - describe(`Android API ${apiLevel}`, () => { - let emulatorName: string; - let deviceId: string; - let systemImageAvailable: boolean = false; - - before(function () { - this.timeout(300000); // 5 minutes for emulator startup - - try { - findAndroidSystemImage(apiLevel); - systemImageAvailable = true; - - console.log(`Creating and launching Android API ${apiLevel} emulator...`); - const result = createAndLaunchEmulator(apiLevel, 'pixel'); - emulatorName = result.name; - deviceId = result.deviceId; - console.log(`Emulator ready: ${emulatorName} (${deviceId})`); - } catch (error) { - console.log(`Android API ${apiLevel} system image not available, skipping tests: ${error}`); - systemImageAvailable = false; - } - }); - - after(() => { - if (deviceId && emulatorName) { - console.log(`Cleaning up emulator ${emulatorName} (${deviceId})`); - shutdownEmulator(deviceId); - deleteEmulator(emulatorName); - } - }); - - it('should take screenshot', async function () { - if (!systemImageAvailable) { - this.skip(); - return; - } - - this.timeout(180000); - - const screenshotPath = `/tmp/screenshot-android${apiLevel}-${Date.now()}.png`; - - takeScreenshot(deviceId, screenshotPath); - verifyScreenshotFileWasCreated(screenshotPath); - verifyScreenshotFileHasValidContent(screenshotPath); - - // console.log(`Screenshot saved at: ${screenshotPath}`); - }); - - it('should open URL https://example.com', async function () { - if (!systemImageAvailable) { - this.skip(); - return; - } - - this.timeout(180000); - - openUrl(deviceId, 'https://example.com'); - }); - - it('should get device info', async function () { - if (!systemImageAvailable) { - this.skip(); - return; - } - - this.timeout(60000); - - getDeviceInfo(deviceId); - }); - }); + it('should take screenshot', async function () { + this.timeout(180000); + + const screenshotPath = `/tmp/screenshot-emu-${Date.now()}.png`; + + takeScreenshot(deviceId, screenshotPath); + verifyScreenshotFileWasCreated(screenshotPath); + verifyScreenshotFileHasValidContent(screenshotPath); + }); + + it('should open URL https://example.com', async function () { + this.timeout(180000); + + openUrl(deviceId, 'https://example.com'); + }); + + it('should get device info', async function () { + this.timeout(60000); + + const info = getDeviceInfo(deviceId); + verifyDeviceInfo(info, deviceId); }); }); -function mobilecli(args: string): void { +const createCoverageDirectory = (): string => { + const dir = path.join(__dirname, "coverage"); + mkdirSync(dir, {recursive: true}); + return dir; +} + +function mobilecli(args: string[]): any { const mobilecliBinary = path.join(__dirname, '..', 'mobilecli'); - const command = `${mobilecliBinary} ${args}`; try { - const result = execSync(command, { + const coverdata = createCoverageDirectory(); + const result = execFileSync(mobilecliBinary, [...args, '--verbose'], { encoding: 'utf8', timeout: 180000, stdio: ['pipe', 'pipe', 'pipe'], env: { - ANDROID_HOME: process.env.ANDROID_HOME || "", + ...process.env, + "GOCOVERDIR": coverdata, } }); + return JSON.parse(result); } catch (error: any) { - console.log(`Command failed: ${command}`); + console.log(`Command failed: ${mobilecliBinary} ${JSON.stringify(args)}`); if (error.stderr) { console.log(`Error stderr: ${error.stderr}`); } @@ -123,14 +87,12 @@ function mobilecli(args: string): void { } function takeScreenshot(deviceId: string, screenshotPath: string): void { - execSync('../mobilecli devices', { stdio: 'inherit' }); - mobilecli(`screenshot --device ${deviceId} --format png --output ${screenshotPath}`); + mobilecli(['screenshot', '--device', deviceId, '--format', 'png', '--output', screenshotPath]); } function verifyScreenshotFileWasCreated(screenshotPath: string): void { const fileExists = fs.existsSync(screenshotPath); expect(fileExists).to.be.true; - // console.log(`✓ Screenshot file was created: ${screenshotPath}`); } function verifyScreenshotFileHasValidContent(screenshotPath: string): void { @@ -140,9 +102,16 @@ function verifyScreenshotFileHasValidContent(screenshotPath: string): void { } function openUrl(deviceId: string, url: string): void { - mobilecli(`url "${url}" --device ${deviceId}`); + mobilecli(['url', url, '--device', deviceId]); +} + +function getDeviceInfo(deviceId: string): any { + return mobilecli(['device', 'info', '--device', deviceId]); } -function getDeviceInfo(deviceId: string): void { - mobilecli(`device info --device ${deviceId}`); +function verifyDeviceInfo(info: any, deviceId: string): void { + expect(info.data.device.id).to.equal(deviceId); + expect(info.data.device.platform).to.equal('android'); + expect(info.data.device.type).to.equal('emulator'); + expect(info.data.device.state).to.equal('online'); } diff --git a/test/index.ts b/test/index.ts index 644d73b..2e0a2cd 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,3 +1,4 @@ import './server'; import './simulator'; import './emulator'; +import './ios-device'; diff --git a/test/ios-device.ts b/test/ios-device.ts new file mode 100644 index 0000000..d4dbccf --- /dev/null +++ b/test/ios-device.ts @@ -0,0 +1,341 @@ +import {expect} from 'chai'; +import {execFileSync} from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import {mkdirSync} from "fs"; +import {UIElement, UIDumpResponse, DeviceInfoResponse, ForegroundAppResponse} from './types'; + +interface DeviceEntry { + id: string; + name: string; + platform: string; + type: string; + state: string; +} + +describe('iOS Real Device Tests', () => { + let deviceId: string; + + before(function () { + this.timeout(30000); + + const device = findConnectedIOSDevice(); + if (!device) { + console.log('No connected iOS real device found, skipping iOS Real Device tests'); + this.skip(); + return; + } + + deviceId = device.id; + console.log(`Found real iOS device: ${device.name} (${deviceId})`); + }); + + it('should discover device in device list', async function () { + const devices = listDevices(); + verifyDeviceListContainsDevice(devices, deviceId); + }); + + it('should get device info', async function () { + const info = getDeviceInfo(deviceId); + verifyRealDeviceInfo(info, deviceId); + }); + + it('should take screenshot', async function () { + this.timeout(60000); + + const screenshotPath = `/tmp/screenshot-device-${Date.now()}.png`; + + takeScreenshot(deviceId, screenshotPath); + verifyScreenshotFileWasCreated(screenshotPath); + verifyScreenshotFileHasValidContent(screenshotPath); + }); + + it('should open URL https://example.com', async function () { + this.timeout(60000); + + openUrl(deviceId, 'https://example.com'); + }); + + it('should warm up WDA by checking foreground app', async function () { + this.timeout(300000); + + try { + terminateApp(deviceId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 2000)); + } catch (e) { + // Safari might not be running + } + + const foregroundApp = getForegroundApp(deviceId); + expect(foregroundApp.data.packageName).to.not.be.empty; + + await new Promise(resolve => setTimeout(resolve, 3000)); + }); + + it('should launch Safari and verify it is in foreground', async function () { + this.timeout(60000); + + launchApp(deviceId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + const foregroundApp = getForegroundApp(deviceId); + verifySafariIsForeground(foregroundApp); + }); + + it('should terminate Safari and verify SpringBoard is in foreground', async function () { + this.timeout(60000); + + launchApp(deviceId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + terminateApp(deviceId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const foregroundApp = getForegroundApp(deviceId); + verifySpringBoardIsForeground(foregroundApp); + }); + + it('should dump UI elements', async function () { + this.timeout(60000); + + launchApp(deviceId, 'com.apple.Preferences'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + const uiDump = dumpUI(deviceId); + expect(uiDump.data.elements).to.be.an('array'); + expect(uiDump.data.elements.length).to.be.greaterThan(0); + }); + + it('should tap on General in Settings', async function () { + this.timeout(60000); + + // Terminate Settings first to ensure it opens at root + try { + terminateApp(deviceId, 'com.apple.Preferences'); + await new Promise(resolve => setTimeout(resolve, 2000)); + } catch (e) { + // Settings might not be running + } + + launchApp(deviceId, 'com.apple.Preferences'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + const uiDump = dumpUI(deviceId); + const generalElement = findElementByName(uiDump, 'General'); + + const centerX = generalElement.rect.x + Math.floor(generalElement.rect.width / 2); + const centerY = generalElement.rect.y + Math.floor(generalElement.rect.height / 2); + + tap(deviceId, centerX, centerY); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const generalUiDump = dumpUI(deviceId); + verifyElementExists(generalUiDump, 'About'); + }); + + it('should send text input', async function () { + this.timeout(60000); + + // Open Safari and tap address bar to get a text field focused + launchApp(deviceId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + const uiDump = dumpUI(deviceId); + const addressBar = uiDump.data.elements.find( + el => el.label === 'Address' || el.name === 'Address' || el.name === 'URL' + ); + + if (!addressBar) { + // Address bar might not be visible; skip gracefully + console.log('Address bar not found in UI, skipping text input test'); + return; + } + + const centerX = addressBar.rect.x + Math.floor(addressBar.rect.width / 2); + const centerY = addressBar.rect.y + Math.floor(addressBar.rect.height / 2); + + tap(deviceId, centerX, centerY); + await new Promise(resolve => setTimeout(resolve, 2000)); + + sendText(deviceId, 'hello'); + await new Promise(resolve => setTimeout(resolve, 2000)); + }); + + it('should press HOME button', async function () { + this.timeout(60000); + + launchApp(deviceId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + pressButton(deviceId, 'HOME'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const foregroundAfterHome = getForegroundApp(deviceId); + verifySpringBoardIsForeground(foregroundAfterHome); + }); +}); + +const createCoverageDirectory = (): string => { + const dir = path.join(__dirname, "coverage"); + mkdirSync(dir, {recursive: true}); + return dir; +} + +function mobilecli(args: string[]): any { + const mobilecliBinary = path.join(__dirname, '..', 'mobilecli'); + + try { + const coverdata = createCoverageDirectory(); + const result = execFileSync(mobilecliBinary, [...args, '--verbose'], { + encoding: 'utf8', + timeout: 180000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + "GOCOVERDIR": coverdata, + } + }); + return JSON.parse(result); + } catch (error: any) { + console.log(`Command failed: ${mobilecliBinary} ${JSON.stringify(args)}`); + if (error.stderr) { + console.log(`Error stderr: ${error.stderr}`); + } + if (error.stdout) { + console.log(`Error stdout: ${error.stdout}`); + } + if (error.message && !error.stderr && !error.stdout) { + console.log(`Error message: ${error.message}`); + } + throw error; + } +} + +function findConnectedIOSDevice(): DeviceEntry | null { + try { + const result = mobilecli(['devices']); + const devices: DeviceEntry[] = result.data?.devices || []; + + return devices.find(d => + d.platform === 'ios' && + (d.type === 'device' || d.type === 'real') && + d.state === 'online' + ) || null; + } catch { + return null; + } +} + +function listDevices(): any { + return mobilecli(['devices']); +} + +function verifyDeviceListContainsDevice(response: any, deviceId: string): void { + const jsonString = JSON.stringify(response); + expect(jsonString).to.include(deviceId); +} + +function getDeviceInfo(deviceId: string): DeviceInfoResponse { + return mobilecli(['device', 'info', '--device', deviceId]); +} + +function verifyRealDeviceInfo(info: DeviceInfoResponse, deviceId: string): void { + expect(info.data.device.id).to.equal(deviceId); + expect(info.data.device.platform).to.equal('ios'); + expect(info.data.device.type).to.be.oneOf(['device', 'real']); + expect(info.data.device.state).to.equal('online'); +} + +function takeScreenshot(deviceId: string, screenshotPath: string): void { + mobilecli(['screenshot', '--device', deviceId, '--format', 'png', '--output', screenshotPath]); +} + +function verifyScreenshotFileWasCreated(screenshotPath: string): void { + const fileExists = fs.existsSync(screenshotPath); + expect(fileExists).to.be.true; +} + +function verifyScreenshotFileHasValidContent(screenshotPath: string): void { + const stats = fs.statSync(screenshotPath); + const fileSizeInBytes = stats.size; + expect(fileSizeInBytes).to.be.greaterThan(100 * 1024); +} + +function openUrl(deviceId: string, url: string): void { + mobilecli(['url', url, '--device', deviceId]); +} + +function launchApp(deviceId: string, packageName: string): void { + mobilecli(['apps', 'launch', '--device', deviceId, packageName]); +} + +function terminateApp(deviceId: string, packageName: string): void { + mobilecli(['apps', 'terminate', '--device', deviceId, packageName]); +} + +function getForegroundApp(deviceId: string): ForegroundAppResponse { + return mobilecli(['apps', 'foreground', '--device', deviceId]); +} + +function verifySafariIsForeground(foregroundApp: ForegroundAppResponse): void { + expect(foregroundApp.data.packageName).to.equal('com.apple.mobilesafari'); + expect(foregroundApp.data.appName).to.equal('Safari'); +} + +function verifySpringBoardIsForeground(foregroundApp: ForegroundAppResponse): void { + expect(foregroundApp.data.packageName).to.equal('com.apple.springboard'); +} + +function dumpUI(deviceId: string): UIDumpResponse { + const response = mobilecli(['dump', 'ui', '--device', deviceId]); + + if (!response?.data?.elements) { + console.log('Unexpected dump UI response:', JSON.stringify(response, null, 2)); + } + + return response; +} + +function findElementByName(uiDump: UIDumpResponse, name: string): UIElement { + const elements = uiDump?.data?.elements; + + if (!elements) { + throw new Error(`No UI elements found in response. Status: ${uiDump?.status}`); + } + + const element = elements.find(el => el.name === name || el.label === name); + + if (!element) { + const availableNames = elements.map(el => el.name || el.label).filter(Boolean).slice(0, 20); + throw new Error(`Element with name "${name}" not found. Available elements: ${availableNames.join(', ')}`); + } + + return element; +} + +function verifyElementExists(uiDump: UIDumpResponse, name: string): void { + const elements = uiDump?.data?.elements; + + if (!elements) { + throw new Error(`No UI elements found in response. Status: ${uiDump?.status}`); + } + + const exists = elements.some(el => el.name === name || el.label === name); + + if (!exists) { + const availableNames = elements.map(el => el.name || el.label).filter(Boolean).slice(0, 20); + throw new Error(`Element with name "${name}" not found. Available elements: ${availableNames.join(', ')}`); + } +} + +function tap(deviceId: string, x: number, y: number): void { + mobilecli(['io', 'tap', `${x},${y}`, '--device', deviceId]); +} + +function sendText(deviceId: string, text: string): void { + mobilecli(['io', 'text', text, '--device', deviceId]); +} + +function pressButton(deviceId: string, button: string): void { + mobilecli(['io', 'button', button, '--device', deviceId]); +} diff --git a/test/server.ts b/test/server.ts index 43ba3e7..7059cd9 100644 --- a/test/server.ts +++ b/test/server.ts @@ -2,9 +2,6 @@ import {expect} from 'chai'; import {spawn, ChildProcess} from 'child_process'; import axios from 'axios'; import * as path from 'path'; -import { - cleanupSimulators -} from './simctl'; import { JSONRPCRequest, JSONRPCResponse, @@ -13,7 +10,6 @@ import { ErrCodeMethodNotFound, ErrCodeServerError } from './jsonrpc'; -import {randomUUID} from "node:crypto"; import {mkdirSync} from "fs"; const TEST_SERVER_URL = 'http://localhost:12001'; @@ -23,8 +19,8 @@ const SERVER_TIMEOUT = 8000; // 8 seconds let serverProcess: ChildProcess | null = null; const createCoverageDirectory = (): string => { - const dir = path.join(__dirname, "cover-" + randomUUID()); - mkdirSync(dir); + const dir = path.join(__dirname, "coverage"); + mkdirSync(dir, {recursive: true}); return dir; } @@ -40,7 +36,6 @@ describe('server jsonrpc', () => { // Stop server after all tests after(() => { stopTestServer(); - cleanupSimulators(); }); it('should return status "ok" for root endpoint', async () => { diff --git a/test/simctl.ts b/test/simctl.ts index d4400cf..6c3e799 100644 --- a/test/simctl.ts +++ b/test/simctl.ts @@ -3,6 +3,31 @@ import {execSync} from 'child_process'; // track created simulators for cleanup const createdSimulators: string[] = []; +interface SimulatorInfo { + udid: string; + state: string; + name: string; +} + +export function findSimulatorByName(name: string): SimulatorInfo | null { + try { + const output = execSync('xcrun simctl list devices -j', {encoding: 'utf8'}); + const data = JSON.parse(output); + + for (const runtime of Object.values(data.devices) as any[]) { + for (const device of runtime) { + if (device.name === name && device.isAvailable) { + return {udid: device.udid, state: device.state, name: device.name}; + } + } + } + + return null; + } catch { + return null; + } +} + export function findIOSRuntime(majorVersion: string): string { try { const matchingLines = execSync('xcrun simctl list runtimes', {encoding: 'utf8'}) diff --git a/test/simulator.ts b/test/simulator.ts index 157d612..93b68b6 100644 --- a/test/simulator.ts +++ b/test/simulator.ts @@ -3,253 +3,205 @@ import {execFileSync} from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; import { - createAndLaunchSimulator, - printAllLogsFromSimulator, - shutdownSimulator, - deleteSimulator, - cleanupSimulators, - findIOSRuntime + findSimulatorByName, + bootSimulator, + waitForSimulatorReady, } from './simctl'; -import {randomUUID} from "node:crypto"; import {mkdirSync} from "fs"; import {UIElement, UIDumpResponse, DeviceInfoResponse, ForegroundAppResponse} from './types'; -const TEST_SERVER_URL = 'http://localhost:12001'; +const SIMULATOR_NAME = 'mobilecli-test-sim'; describe('iOS Simulator Tests', () => { - after(() => { - cleanupSimulators(); + let simulatorId: string; + + before(function () { + this.timeout(180000); + + const sim = findSimulatorByName(SIMULATOR_NAME); + if (!sim) { + console.log(`No "${SIMULATOR_NAME}" simulator found, skipping iOS Simulator tests`); + console.log('Create one with: xcrun simctl create "mobilecli-test-sim" "iPhone 16" com.apple.CoreSimulator.SimRuntime.iOS-26-0'); + this.skip(); + return; + } + + simulatorId = sim.udid; + + if (sim.state !== 'Booted') { + try { + bootSimulator(simulatorId); + waitForSimulatorReady(simulatorId); + } catch (error) { + console.log(`Failed to boot simulator "${SIMULATOR_NAME}": ${error}`); + this.skip(); + return; + } + } }); - [/*'16',*/ /*'17', '18',*/ '26'].forEach((iosVersion) => { - describe(`iOS ${iosVersion}`, () => { - let simulatorId: string; - - before(function () { - this.timeout(180000); - - // Check if runtime is available - try { - findIOSRuntime(iosVersion); - simulatorId = createAndLaunchSimulator(iosVersion); - } catch (error) { - console.log(`iOS ${iosVersion} runtime not available, skipping tests: ${error}`); - this.skip(); - } - }); - - after(() => { - if (simulatorId) { - printAllLogsFromSimulator(simulatorId); - shutdownSimulator(simulatorId); - deleteSimulator(simulatorId); - } - }); - - it('should take screenshot', async function () { - const screenshotPath = `/tmp/screenshot-ios${iosVersion}-${Date.now()}.png`; - - takeScreenshot(simulatorId, screenshotPath); - verifyScreenshotFileWasCreated(screenshotPath); - verifyScreenshotFileHasValidContent(screenshotPath); - - // console.log(`Screenshot saved at: ${screenshotPath}`); - }); - - it('should open URL https://example.com', async function () { - openUrl(simulatorId, 'https://example.com'); - }); - - it('should list all devices', async function () { - const devices = listDevices(false); - verifyDeviceListContainsSimulator(devices, simulatorId); - }); - - it('should get device info', async function () { - const info = getDeviceInfo(simulatorId); - verifyDeviceInfo(info, simulatorId); - }); - - it('should list installed apps', async function () { - const apps = listApps(simulatorId); - verifyAppsListContainsSafari(apps); - }); - - it('should warm up WDA by checking foreground app', async function () { - this.timeout(300000); // 5 minutes for WDA installation - - // Terminate Safari if it's running (from previous URL test) - try { - terminateApp(simulatorId, 'com.apple.mobilesafari'); - await new Promise(resolve => setTimeout(resolve, 2000)); - } catch (e) { - // Safari might not be running, that's fine - } - - // This ensures WDA is installed and running before the Safari tests - // Check foreground app - should be SpringBoard - const foregroundApp = getForegroundApp(simulatorId); - verifySpringBoardIsForeground(foregroundApp); - - // Wait a bit more to ensure WDA is fully ready - await new Promise(resolve => setTimeout(resolve, 3000)); - }); - - it('should launch Safari app and verify it is in foreground', async function () { - launchApp(simulatorId, 'com.apple.mobilesafari'); - - // Wait for Safari to fully launch - await new Promise(resolve => setTimeout(resolve, 10000)); - - const foregroundApp = getForegroundApp(simulatorId); - verifySafariIsForeground(foregroundApp); - }); - - it('should terminate Safari app and verify SpringBoard is in foreground', async function () { - // First launch Safari - launchApp(simulatorId, 'com.apple.mobilesafari'); - await new Promise(resolve => setTimeout(resolve, 10000)); - - // Now terminate it - terminateApp(simulatorId, 'com.apple.mobilesafari'); - await new Promise(resolve => setTimeout(resolve, 3000)); - - const foregroundApp = getForegroundApp(simulatorId); - verifySpringBoardIsForeground(foregroundApp); - }); - - it('should handle launching app twice (idempotency)', async function () { - launchApp(simulatorId, 'com.apple.mobilesafari'); - await new Promise(resolve => setTimeout(resolve, 10000)); - - // Launch again - should not fail - launchApp(simulatorId, 'com.apple.mobilesafari'); - await new Promise(resolve => setTimeout(resolve, 3000)); - - const foregroundApp = getForegroundApp(simulatorId); - verifySafariIsForeground(foregroundApp); - }); - - it('should handle launch-terminate-launch cycle', async function () { - // Launch - launchApp(simulatorId, 'com.apple.mobilesafari'); - await new Promise(resolve => setTimeout(resolve, 10000)); - - // Terminate - terminateApp(simulatorId, 'com.apple.mobilesafari'); - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Launch again - launchApp(simulatorId, 'com.apple.mobilesafari'); - await new Promise(resolve => setTimeout(resolve, 10000)); - - const foregroundApp = getForegroundApp(simulatorId); - verifySafariIsForeground(foregroundApp); - }); - - it('should tap on General button in Settings and navigate to General settings', async function () { - // Launch Settings app - launchApp(simulatorId, 'com.apple.Preferences'); - await new Promise(resolve => setTimeout(resolve, 5000)); - - // Dump UI to find General button - const uiDump = dumpUI(simulatorId); - const generalElement = findElementByName(uiDump, 'General'); - - // Calculate center coordinates for tap - const centerX = generalElement.rect.x + Math.floor(generalElement.rect.width / 2); - const centerY = generalElement.rect.y + Math.floor(generalElement.rect.height / 2); - - // Tap on General button - tap(simulatorId, centerX, centerY); - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Verify we're in General settings by checking for About element - const generalUiDump = dumpUI(simulatorId); - verifyElementExists(generalUiDump, 'About'); - }); - - it('should press HOME button and return to home screen from Safari', async function () { - // Launch Safari - launchApp(simulatorId, 'com.apple.mobilesafari'); - await new Promise(resolve => setTimeout(resolve, 10000)); - - // Verify Safari is in foreground - const foregroundApp = getForegroundApp(simulatorId); - verifySafariIsForeground(foregroundApp); - - // Press HOME button - pressButton(simulatorId, 'HOME'); - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Verify SpringBoard (home screen) is now in foreground - const foregroundAfterHome = getForegroundApp(simulatorId); - verifySpringBoardIsForeground(foregroundAfterHome); - }); - - it('should test device lifecycle: boot, reboot, shutdown', async function () { - this.timeout(180000); // 3 minutes for the full lifecycle - - // shutdown simulator using simctl to get it offline - shutdownSimulator(simulatorId); - await new Promise(resolve => setTimeout(resolve, 3000)); - - // list offline devices - verify simulator is there and offline - const offlineDevices = listDevices(true); - verifyDeviceIsOffline(offlineDevices, simulatorId); - - // boot the simulator using mobilecli - bootDevice(simulatorId); - await new Promise(resolve => setTimeout(resolve, 5000)); - - // verify simulator is now online - const devicesAfterBoot = listDevices(false); - verifyDeviceIsOnline(devicesAfterBoot, simulatorId); - - // reboot the simulator - rebootDevice(simulatorId); - - // immediately check - should be offline (or at least not in the online list during reboot) - await new Promise(resolve => setTimeout(resolve, 2000)); - const devicesDuringReboot = listDevices(true); - // during reboot, state might be "Booting" or "Shutdown" - // we just verify it exists in the full list - verifyDeviceExists(devicesDuringReboot, simulatorId); - - // wait a bit more for reboot to complete - await new Promise(resolve => setTimeout(resolve, 15000)); - - // verify simulator came back online - const devicesAfterReboot = listDevices(false); - verifyDeviceIsOnline(devicesAfterReboot, simulatorId); - - // shutdown the simulator - shutdownDevice(simulatorId); - await new Promise(resolve => setTimeout(resolve, 3000)); - - // verify simulator is offline - const devicesAfterShutdown = listDevices(true); - verifyDeviceIsOffline(devicesAfterShutdown, simulatorId); - - // boot it again for cleanup and other tests - bootDevice(simulatorId); - await new Promise(resolve => setTimeout(resolve, 5000)); - }); - - it('should dump UI source in raw format', async function () { - this.timeout(60000); + it('should take screenshot', async function () { + const screenshotPath = `/tmp/screenshot-sim-${Date.now()}.png`; - // ensure WDA is running by checking foreground app first - const foregroundApp = getForegroundApp(simulatorId); - expect(foregroundApp.data.packageName).to.not.be.empty; + takeScreenshot(simulatorId, screenshotPath); + verifyScreenshotFileWasCreated(screenshotPath); + verifyScreenshotFileHasValidContent(screenshotPath); + }); - // dump UI in raw format - const rawDump = dumpUIRaw(simulatorId); - - // verify it's the raw WDA response structure - verifyRawWDADump(rawDump); - }); - }); + it('should open URL https://example.com', async function () { + openUrl(simulatorId, 'https://example.com'); + }); + + it('should list all devices', async function () { + const devices = listDevices(false); + verifyDeviceListContainsSimulator(devices, simulatorId); + }); + + it('should get device info', async function () { + const info = getDeviceInfo(simulatorId); + verifyDeviceInfo(info, simulatorId); + }); + + it('should list installed apps', async function () { + const apps = listApps(simulatorId); + verifyAppsListContainsSafari(apps); + }); + + it('should warm up WDA by checking foreground app', async function () { + this.timeout(300000); + + try { + terminateApp(simulatorId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 2000)); + } catch (e) { + // Safari might not be running, that's fine + } + + const foregroundApp = getForegroundApp(simulatorId); + verifySpringBoardIsForeground(foregroundApp); + + await new Promise(resolve => setTimeout(resolve, 3000)); + }); + + it('should launch Safari app and verify it is in foreground', async function () { + launchApp(simulatorId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + const foregroundApp = getForegroundApp(simulatorId); + verifySafariIsForeground(foregroundApp); + }); + + it('should terminate Safari app and verify SpringBoard is in foreground', async function () { + launchApp(simulatorId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + terminateApp(simulatorId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const foregroundApp = getForegroundApp(simulatorId); + verifySpringBoardIsForeground(foregroundApp); + }); + + it('should handle launching app twice (idempotency)', async function () { + launchApp(simulatorId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + launchApp(simulatorId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const foregroundApp = getForegroundApp(simulatorId); + verifySafariIsForeground(foregroundApp); + }); + + it('should handle launch-terminate-launch cycle', async function () { + launchApp(simulatorId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + terminateApp(simulatorId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + launchApp(simulatorId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + const foregroundApp = getForegroundApp(simulatorId); + verifySafariIsForeground(foregroundApp); + }); + + it('should tap on General button in Settings and navigate to General settings', async function () { + launchApp(simulatorId, 'com.apple.Preferences'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + const uiDump = dumpUI(simulatorId); + const generalElement = findElementByName(uiDump, 'General'); + + const centerX = generalElement.rect.x + Math.floor(generalElement.rect.width / 2); + const centerY = generalElement.rect.y + Math.floor(generalElement.rect.height / 2); + + tap(simulatorId, centerX, centerY); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const generalUiDump = dumpUI(simulatorId); + verifyElementExists(generalUiDump, 'About'); + }); + + it('should press HOME button and return to home screen from Safari', async function () { + launchApp(simulatorId, 'com.apple.mobilesafari'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + const foregroundApp = getForegroundApp(simulatorId); + verifySafariIsForeground(foregroundApp); + + pressButton(simulatorId, 'HOME'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const foregroundAfterHome = getForegroundApp(simulatorId); + verifySpringBoardIsForeground(foregroundAfterHome); + }); + + it('should test device lifecycle: boot, reboot, shutdown', async function () { + this.timeout(180000); + + shutdownDevice(simulatorId); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const offlineDevices = listDevices(true); + verifyDeviceIsOffline(offlineDevices, simulatorId); + + bootDevice(simulatorId); + await new Promise(resolve => setTimeout(resolve, 5000)); + + const devicesAfterBoot = listDevices(false); + verifyDeviceIsOnline(devicesAfterBoot, simulatorId); + + rebootDevice(simulatorId); + await new Promise(resolve => setTimeout(resolve, 2000)); + + const devicesDuringReboot = listDevices(true); + verifyDeviceExists(devicesDuringReboot, simulatorId); + + await new Promise(resolve => setTimeout(resolve, 15000)); + + const devicesAfterReboot = listDevices(false); + verifyDeviceIsOnline(devicesAfterReboot, simulatorId); + + shutdownDevice(simulatorId); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const devicesAfterShutdown = listDevices(true); + verifyDeviceIsOffline(devicesAfterShutdown, simulatorId); + + bootDevice(simulatorId); + await new Promise(resolve => setTimeout(resolve, 5000)); + }); + + it('should dump UI source in raw format', async function () { + this.timeout(60000); + + const foregroundApp = getForegroundApp(simulatorId); + expect(foregroundApp.data.packageName).to.not.be.empty; + + const rawDump = dumpUIRaw(simulatorId); + verifyRawWDADump(rawDump); }); }); @@ -297,7 +249,6 @@ function takeScreenshot(simulatorId: string, screenshotPath: string): void { function verifyScreenshotFileWasCreated(screenshotPath: string): void { const fileExists = fs.existsSync(screenshotPath); expect(fileExists).to.be.true; - // console.log(`✓ Screenshot file was created: ${screenshotPath}`); } function verifyScreenshotFileHasValidContent(screenshotPath: string): void { @@ -369,7 +320,6 @@ function verifySpringBoardIsForeground(foregroundApp: ForegroundAppResponse): vo function dumpUI(simulatorId: string): UIDumpResponse { const response = mobilecli(['dump', 'ui', '--device', simulatorId]); - // Debug: log the response if elements are missing if (!response?.data?.elements) { console.log('Unexpected dump UI response:', JSON.stringify(response, null, 2)); } @@ -377,62 +327,6 @@ function dumpUI(simulatorId: string): UIDumpResponse { return response; } -function verifySafariIsRunning(uiDump: UIDumpResponse): void { - // Safari can show either: - // 1. Home screen with Favorites, Privacy Report, Reading List - // 2. A web page (if it was previously viewing one) - const elements = uiDump?.data?.elements; - - if (!elements) { - throw new Error(`No UI elements found in response. Status: ${uiDump?.status}, Response: ${JSON.stringify(uiDump)}`); - } - - // Debug: log some labels - const labels = elements.map(el => el.label || el.name).filter(Boolean).slice(0, 10); - console.log(`Found ${elements.length} UI elements. First 10 labels:`, labels); - - // Check for Safari home screen elements OR Safari-specific UI elements - const hasSafariHomeElements = elements.some(el => - el.label === 'Favorites' || - el.label === 'Privacy Report' || - el.label === 'Reading List' || - el.name === 'Favorites' || - el.name === 'Privacy Report' || - el.name === 'Reading List' - ); - - // Check for Safari toolbar elements that appear on any page - const hasSafariToolbar = elements.some(el => - el.label === 'Address' || - el.label === 'Back' || - el.label === 'Page Menu' || - el.name === 'Address' || - el.name === 'Back' || - el.name === 'Page Menu' - ); - - const isSafariRunning = hasSafariHomeElements || hasSafariToolbar; - expect(isSafariRunning, `Expected to find Safari UI elements (home screen or toolbar). Sample labels found: ${labels.join(', ')}`).to.be.true; -} - -/* -function verifyHomeScreenIsVisible(uiDump: UIDumpResponse): void { - // Home screen shows app icons - just check if we have any Icon elements - const elements = uiDump?.data?.elements; - - if (!elements) { - throw new Error(`No UI elements found in response. Status: ${uiDump?.status}, Response: ${JSON.stringify(uiDump)}`); - } - - // Debug: log element types - const elementTypes = elements.map(el => el.type); - console.log(`Found ${elements.length} UI elements with types:`, [...new Set(elementTypes)]); - - const hasIcons = elements.some(el => el.type === 'Icon'); - expect(hasIcons, `Expected to find Icon elements on home screen, but found types: ${[...new Set(elementTypes)].join(', ')}`).to.be.true; -} -*/ - function findElementByName(uiDump: UIDumpResponse, name: string): UIElement { const elements = uiDump?.data?.elements; @@ -489,7 +383,6 @@ function verifyDeviceIsOnline(response: any, simulatorId: string): void { const jsonString = JSON.stringify(response); expect(jsonString).to.include(simulatorId); - // verify device has state "online" const devices = response.data?.devices || []; const device = devices.find((d: any) => d.id === simulatorId); expect(device).to.not.be.undefined; @@ -500,7 +393,6 @@ function verifyDeviceIsOffline(response: any, simulatorId: string): void { const jsonString = JSON.stringify(response); expect(jsonString).to.include(simulatorId); - // verify device has state "offline" const devices = response.data?.devices || []; const device = devices.find((d: any) => d.id === simulatorId); expect(device).to.not.be.undefined; @@ -511,7 +403,6 @@ function verifyDeviceExists(response: any, simulatorId: string): void { const jsonString = JSON.stringify(response); expect(jsonString).to.include(simulatorId); - // just verify device exists in the list const devices = response.data?.devices || []; const device = devices.find((d: any) => d.id === simulatorId); expect(device).to.not.be.undefined; @@ -522,21 +413,16 @@ function dumpUIRaw(simulatorId: string): any { } function verifyRawWDADump(response: any): void { - // verify it's a valid response expect(response).to.not.be.undefined; expect(response.status).to.equal('ok'); - // raw format returns rawData field const data = response.data; expect(data).to.not.be.undefined; expect(data.rawData).to.not.be.undefined; - // rawData should contain the tree structure directly from WDA const rawData = data.rawData; expect(rawData.type).to.be.a('string'); expect(rawData.type).to.not.be.empty; - // WDA raw response typically has children array expect(rawData.children).to.be.an('array'); } - diff --git a/utils/file_test.go b/utils/file_test.go new file mode 100644 index 0000000..67908b7 --- /dev/null +++ b/utils/file_test.go @@ -0,0 +1,67 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCopyFile(t *testing.T) { + tmpDir := t.TempDir() + + srcPath := filepath.Join(tmpDir, "source.txt") + dstPath := filepath.Join(tmpDir, "dest.txt") + + content := "hello world\nline two\n" + if err := os.WriteFile(srcPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + if err := CopyFile(srcPath, dstPath); err != nil { + t.Fatalf("CopyFile() error: %v", err) + } + + got, err := os.ReadFile(dstPath) + if err != nil { + t.Fatalf("failed to read dest: %v", err) + } + if string(got) != content { + t.Errorf("dest content = %q, want %q", string(got), content) + } +} + +func TestCopyFile_PreservesPermissions(t *testing.T) { + tmpDir := t.TempDir() + + srcPath := filepath.Join(tmpDir, "script.sh") + dstPath := filepath.Join(tmpDir, "script_copy.sh") + + if err := os.WriteFile(srcPath, []byte("#!/bin/sh\necho hi\n"), 0755); err != nil { + t.Fatal(err) + } + + if err := CopyFile(srcPath, dstPath); err != nil { + t.Fatalf("CopyFile() error: %v", err) + } + + srcInfo, _ := os.Stat(srcPath) + dstInfo, _ := os.Stat(dstPath) + + if srcInfo.Mode() != dstInfo.Mode() { + t.Errorf("dest mode = %v, want %v", dstInfo.Mode(), srcInfo.Mode()) + } +} + +func TestCopyFile_SourceNotFound(t *testing.T) { + err := CopyFile("/nonexistent/file", filepath.Join(t.TempDir(), "dest")) + if err == nil { + t.Error("expected error for nonexistent source") + } +} + +func TestGetProjectFile_NotImplemented(t *testing.T) { + _, err := GetProjectFile("anything") + if err == nil { + t.Error("expected error from unimplemented function") + } +} diff --git a/utils/github_test.go b/utils/github_test.go new file mode 100644 index 0000000..f197586 --- /dev/null +++ b/utils/github_test.go @@ -0,0 +1,62 @@ +package utils + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetLatestReleaseDownloadURL_ParsesResponse(t *testing.T) { + release := GitHubRelease{ + Assets: []struct { + BrowserDownloadURL string `json:"browser_download_url"` + Name string `json:"name"` + }{ + {BrowserDownloadURL: "https://example.com/release.apk", Name: "release.apk"}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(release) + })) + defer server.Close() + + // override the URL by testing the JSON parsing directly + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP request error: %v", err) + } + defer resp.Body.Close() + + var got GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("JSON decode error: %v", err) + } + + if len(got.Assets) != 1 { + t.Fatalf("expected 1 asset, got %d", len(got.Assets)) + } + if got.Assets[0].BrowserDownloadURL != "https://example.com/release.apk" { + t.Errorf("URL = %q, want %q", got.Assets[0].BrowserDownloadURL, "https://example.com/release.apk") + } +} + +func TestGitHubRelease_EmptyAssets(t *testing.T) { + release := GitHubRelease{} + + data, err := json.Marshal(release) + if err != nil { + t.Fatal(err) + } + + var got GitHubRelease + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + + if len(got.Assets) != 0 { + t.Errorf("expected 0 assets, got %d", len(got.Assets)) + } +} diff --git a/utils/logger_test.go b/utils/logger_test.go new file mode 100644 index 0000000..b2f26ed --- /dev/null +++ b/utils/logger_test.go @@ -0,0 +1,42 @@ +package utils + +import "testing" + +func TestSetVerbose_And_IsVerbose(t *testing.T) { + // save original state and restore after test + original := IsVerbose() + defer SetVerbose(original) + + SetVerbose(true) + if !IsVerbose() { + t.Error("expected IsVerbose() = true after SetVerbose(true)") + } + + SetVerbose(false) + if IsVerbose() { + t.Error("expected IsVerbose() = false after SetVerbose(false)") + } +} + +func TestVerbose_DoesNotPanicWhenDisabled(t *testing.T) { + original := IsVerbose() + defer SetVerbose(original) + + SetVerbose(false) + // should not panic + Verbose("test message %s %d", "arg", 42) +} + +func TestVerbose_DoesNotPanicWhenEnabled(t *testing.T) { + original := IsVerbose() + defer SetVerbose(original) + + SetVerbose(true) + // should not panic + Verbose("test message %s %d", "arg", 42) +} + +func TestInfo_DoesNotPanic(t *testing.T) { + // should not panic + Info("test info %s", "message") +} diff --git a/utils/plist_test.go b/utils/plist_test.go new file mode 100644 index 0000000..e8d55d3 --- /dev/null +++ b/utils/plist_test.go @@ -0,0 +1,55 @@ +package utils + +import ( + "os/exec" + "runtime" + "testing" +) + +func hasPLUtil() bool { + _, err := exec.LookPath("plutil") + return err == nil +} + +func TestConvertPlistToJSON(t *testing.T) { + if runtime.GOOS != "darwin" || !hasPLUtil() { + t.Skip("plutil only available on macOS") + } + + // a minimal binary plist in XML format (plutil can read XML plist) + plistData := []byte(` + + + + CFBundleName + TestApp + CFBundleVersion + 1.0 + +`) + + var result map[string]interface{} + err := ConvertPlistToJSON(plistData, &result) + if err != nil { + t.Fatalf("ConvertPlistToJSON() error: %v", err) + } + + if result["CFBundleName"] != "TestApp" { + t.Errorf("CFBundleName = %v, want %q", result["CFBundleName"], "TestApp") + } + if result["CFBundleVersion"] != "1.0" { + t.Errorf("CFBundleVersion = %v, want %q", result["CFBundleVersion"], "1.0") + } +} + +func TestConvertPlistToJSON_InvalidInput(t *testing.T) { + if runtime.GOOS != "darwin" || !hasPLUtil() { + t.Skip("plutil only available on macOS") + } + + var result map[string]interface{} + err := ConvertPlistToJSON([]byte("not a plist"), &result) + if err == nil { + t.Error("expected error for invalid plist data") + } +} diff --git a/utils/zipfile_test.go b/utils/zipfile_test.go new file mode 100644 index 0000000..77a1b34 --- /dev/null +++ b/utils/zipfile_test.go @@ -0,0 +1,135 @@ +package utils + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +func createTestZip(t *testing.T, files map[string]string) string { + t.Helper() + + zipPath := filepath.Join(t.TempDir(), "test.zip") + f, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + + w := zip.NewWriter(f) + for name, content := range files { + fw, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + return zipPath +} + +func TestUnzip_ExtractsFiles(t *testing.T) { + zipPath := createTestZip(t, map[string]string{ + "hello.txt": "hello world", + "subdir/nested.txt": "nested content", + }) + + destDir, err := Unzip(zipPath) + if err != nil { + t.Fatalf("Unzip() error: %v", err) + } + defer os.RemoveAll(destDir) + + // verify hello.txt + content, err := os.ReadFile(filepath.Join(destDir, "hello.txt")) + if err != nil { + t.Fatalf("failed to read hello.txt: %v", err) + } + if string(content) != "hello world" { + t.Errorf("hello.txt content = %q, want %q", string(content), "hello world") + } + + // verify nested file + content, err = os.ReadFile(filepath.Join(destDir, "subdir", "nested.txt")) + if err != nil { + t.Fatalf("failed to read subdir/nested.txt: %v", err) + } + if string(content) != "nested content" { + t.Errorf("nested.txt content = %q, want %q", string(content), "nested content") + } +} + +func TestUnzip_InvalidZipFile(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "notazip.zip") + if err := os.WriteFile(tmpFile, []byte("this is not a zip file"), 0644); err != nil { + t.Fatal(err) + } + + _, err := Unzip(tmpFile) + if err == nil { + t.Error("expected error for invalid zip file") + } +} + +func TestUnzip_NonexistentFile(t *testing.T) { + _, err := Unzip("/nonexistent/path/file.zip") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func TestUnzipFile_PathTraversalAbsolute(t *testing.T) { + // create a zip with an absolute path entry + zipPath := filepath.Join(t.TempDir(), "evil.zip") + f, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + + w := zip.NewWriter(f) + fw, err := w.Create("/etc/passwd") + if err != nil { + t.Fatal(err) + } + fw.Write([]byte("evil")) + w.Close() + f.Close() + + destDir := t.TempDir() + err = unzipFile(zipPath, destDir) + if err == nil { + t.Error("expected error for absolute path in zip") + } +} + +func TestUnzipFile_PathTraversalDotDot(t *testing.T) { + zipPath := filepath.Join(t.TempDir(), "evil.zip") + f, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + + w := zip.NewWriter(f) + fw, err := w.Create("../../../etc/passwd") + if err != nil { + t.Fatal(err) + } + fw.Write([]byte("evil")) + w.Close() + f.Close() + + destDir := t.TempDir() + err = unzipFile(zipPath, destDir) + if err == nil { + t.Error("expected error for path traversal in zip") + } +}