Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 9 additions & 107 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment on lines +203 to +225
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

e2e_test is not a dependency of publish — is this intentional?

The publish job (line 234) depends on [build_on_windows, build_on_linux, build_on_macos, server_test] but does not include e2e_test. This means a release can be published even when E2E tests fail. If this is intentional (e.g., because self-hosted runner availability is unreliable), consider adding a comment to document the decision. Otherwise, add e2e_test to the needs list.

🤖 Prompt for AI Agents
In @.github/workflows/build.yml around lines 203 - 225, The publish job
currently does not depend on the e2e_test job, allowing releases to proceed even
if E2E fails; update the publish job's needs list (reference the publish job and
the e2e_test job names) to include e2e_test so publish depends on it, or if
omission is intentional add an inline comment next to the publish job explaining
why e2e_test is excluded (e.g., flaky self-hosted runner) to document the
decision.


publish:
if: github.ref_type == 'tag'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ screenshot.png
**/coverage*.out
**/coverage*.html
test/coverage
coverage/
34 changes: 27 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -32,3 +51,4 @@ fmt:

clean:
rm -f mobilecli coverage.out coverage.html
rm -rf coverage test/coverage
53 changes: 50 additions & 3 deletions cli/server.go
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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))
}
108 changes: 108 additions & 0 deletions daemon/daemon.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading