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")
+ }
+}