Skip to content
Merged
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
8 changes: 4 additions & 4 deletions cli/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func parseNameFilter(value string) (commands.DeviceFilter, error) {
}

// buildAllocateFilters builds a filters slice from CLI flag values.
func buildAllocateFilters(platform, deviceType string, versions, names []string) ([]commands.DeviceFilter, error) {
func buildAllocateFilters(platform, deviceType string, versions []string, name string) ([]commands.DeviceFilter, error) {
var filters []commands.DeviceFilter

filters = append(filters, commands.DeviceFilter{
Expand All @@ -85,10 +85,10 @@ func buildAllocateFilters(platform, deviceType string, versions, names []string)
filters = append(filters, f)
}

for _, n := range names {
f, err := parseNameFilter(n)
if name != "" {
f, err := parseNameFilter(name)
if err != nil {
return nil, fmt.Errorf("invalid --name %q: %w", n, err)
return nil, fmt.Errorf("invalid --name %q: %w", name, err)
}
filters = append(filters, f)
}
Expand Down
6 changes: 3 additions & 3 deletions cli/filters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func TestParseNameFilter_JustWildcard(t *testing.T) {
}

func TestBuildAllocateFilters_PlatformOnly(t *testing.T) {
filters, err := buildAllocateFilters("ios", "", nil, nil)
filters, err := buildAllocateFilters("ios", "", nil, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -104,7 +104,7 @@ func TestBuildAllocateFilters_PlatformOnly(t *testing.T) {
}

func TestBuildAllocateFilters_WithType(t *testing.T) {
filters, err := buildAllocateFilters("ios", "real", nil, nil)
filters, err := buildAllocateFilters("ios", "real", nil, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -117,7 +117,7 @@ func TestBuildAllocateFilters_WithType(t *testing.T) {
}

func TestBuildAllocateFilters_Combined(t *testing.T) {
filters, err := buildAllocateFilters("ios", "real", []string{">=18", "<20"}, []string{"iPhone*"})
filters, err := buildAllocateFilters("ios", "real", []string{">=18", "<20"}, "iPhone*")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ var (
// for remote allocate command
fleetType string
fleetVersions []string
fleetNames []string
fleetName string
fleetWait bool
fleetTimeout int

Expand Down
6 changes: 3 additions & 3 deletions cli/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ var remoteAllocateCmd = &cobra.Command{
Short: "Allocate a remote device",
Long: `Allocates a device from the remote fleet matching the given filters.

Flags --version and --name can be specified multiple times (all are ANDed).
Flag --version can be specified multiple times (all are ANDed).

Version supports comparison operators:
--version ">=18" (greater than or equal)
Expand All @@ -54,7 +54,7 @@ Name supports wildcard prefix matching:
return err
}

filters, err := buildAllocateFilters(platform, fleetType, fleetVersions, fleetNames)
filters, err := buildAllocateFilters(platform, fleetType, fleetVersions, fleetName)
if err != nil {
return err
}
Expand Down Expand Up @@ -191,7 +191,7 @@ func init() {
_ = remoteAllocateCmd.MarkFlagRequired("platform")
remoteAllocateCmd.Flags().StringVar(&fleetType, "type", "", "device type (real)")
remoteAllocateCmd.Flags().StringArrayVar(&fleetVersions, "version", nil, "OS version filter (supports >=, >, <=, < prefixes)")
remoteAllocateCmd.Flags().StringArrayVar(&fleetNames, "name", nil, "device name filter (supports trailing * for prefix match)")
remoteAllocateCmd.Flags().StringVar(&fleetName, "name", "", "device name filter (supports trailing * for prefix match)")
remoteAllocateCmd.Flags().BoolVar(&fleetWait, "wait", false, "wait for device to finish allocating before returning")
remoteAllocateCmd.Flags().IntVar(&fleetTimeout, "timeout", 900, "seconds to wait for allocation (only used with --wait)")

Expand Down
83 changes: 75 additions & 8 deletions commands/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package commands
import (
"encoding/json"
"fmt"
"net/http"
"net/url"

"github.com/mobile-next/mobilecli/devices"
"github.com/mobile-next/mobilecli/rpc"
Expand All @@ -16,14 +18,14 @@ type DeviceFilter struct {
Value string `json:"value"`
}

// fleetAllocateParams is the params object of the fleet.allocate rpc
// fleetAllocateParams is the request body of POST /api/v1/sessions/{sessionId}/devices
type fleetAllocateParams struct {
Filters []DeviceFilter `json:"filters"`
}

// fleetReleaseParams is the params object of the fleet.release rpc
type fleetReleaseParams struct {
DeviceID string `json:"deviceId"`
// createSessionResponse is the response body of POST /api/v1/sessions
type createSessionResponse struct {
ID string `json:"id"`
}

// devicesListResult is the result object of the devices.list rpc
Expand Down Expand Up @@ -62,12 +64,20 @@ func (r FleetAllocateResponse) IsAllocating() bool {
return r.State == "allocating"
}

// FleetAllocateCommand creates a session and allocates a device into it over REST, so the
// allocation is linked to a sessions row from the start (a WebSocket fleet.allocate call has
// no sessionId, leaving the allocation invisible to GET /api/v1/sessions).
func FleetAllocateCommand(req FleetAllocateRequest) *CommandResponse {
var session createSessionResponse
if err := rpc.RESTCall(req.Token, http.MethodPost, "/api/v1/sessions", nil, &session); err != nil {
return NewErrorResponse(fmt.Errorf("create session: %w", err))
}

var result FleetAllocateResponse
params := fleetAllocateParams{Filters: req.Filters}
err := rpc.Call(req.Token, "fleet.allocate", params, &result)
if err != nil {
return NewErrorResponse(fmt.Errorf("fleet.allocate: %w", err))
path := fmt.Sprintf("/api/v1/sessions/%s/devices", session.ID)
if err := rpc.RESTCall(req.Token, http.MethodPost, path, params, &result); err != nil {
return NewErrorResponse(fmt.Errorf("allocate device: %w", err))
}
return NewSuccessResponse(result)
}
Expand Down Expand Up @@ -137,11 +147,68 @@ type FleetReleaseRequest struct {
Token string
}

// sessionDeviceEntry is a device as embedded in a GET /api/v1/sessions session's "devices" list.
type sessionDeviceEntry struct {
Status string `json:"status"`
Info struct {
Serial string `json:"serial"`
} `json:"info"`
}

// sessionListEntry is one session as returned by GET /api/v1/sessions.
type sessionListEntry struct {
ID string `json:"id"`
Devices []sessionDeviceEntry `json:"devices"`
}

// sessionsPageResponse is the paginated envelope of GET /api/v1/sessions.
type sessionsPageResponse struct {
Data []sessionListEntry `json:"data"`
NextCursor *string `json:"nextCursor"`
}

// findOwningSessionID pages through the account's sessions to find the one holding a live
// (not yet released) allocation of deviceID. The release endpoint is session-scoped, and
// fleet.release/devices.list never surface a device's owning sessionId, so this is the only
// way to recover it.
func findOwningSessionID(token, deviceID string) (string, error) {
before := ""
for {
path := "/api/v1/sessions?limit=500"
if before != "" {
path += "&before=" + url.QueryEscape(before)
}

var page sessionsPageResponse
if err := rpc.RESTCall(token, http.MethodGet, path, nil, &page); err != nil {
return "", fmt.Errorf("list sessions: %w", err)
}

for _, session := range page.Data {
for _, device := range session.Devices {
if device.Info.Serial == deviceID && device.Status != "released" {
return session.ID, nil
}
}
}

if page.NextCursor == nil {
return "", fmt.Errorf("device %s is not allocated in any active session", deviceID)
}
before = *page.NextCursor
}
}

func FleetReleaseCommand(req FleetReleaseRequest) *CommandResponse {
err := rpc.Call(req.Token, "fleet.release", fleetReleaseParams{DeviceID: req.DeviceID}, nil)
sessionID, err := findOwningSessionID(req.Token, req.DeviceID)
if err != nil {
return NewErrorResponse(fmt.Errorf("fleet.release: %w", err))
}

path := fmt.Sprintf("/api/v1/sessions/%s/devices/%s/release", url.PathEscape(sessionID), url.PathEscape(req.DeviceID))
if err := rpc.RESTCall(req.Token, http.MethodPost, path, nil, nil); err != nil {
return NewErrorResponse(fmt.Errorf("fleet.release: %w", err))
}
return NewSuccessResponse(nil)
}

Expand Down
162 changes: 162 additions & 0 deletions commands/remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package commands

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/mobile-next/mobilecli/devices"
Expand Down Expand Up @@ -29,3 +32,162 @@ func TestFindDeviceByAllocation(t *testing.T) {
t.Errorf("expected no device for unknown allocation, got %s", found.ID)
}
}

func TestFleetAllocateCommandCreatesASessionThenAllocatesIntoIt(t *testing.T) {
var gotPaths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPaths = append(gotPaths, r.URL.Path)
switch r.URL.Path {
case "/api/v1/sessions":
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": "session-abc"})
case "/api/v1/sessions/session-abc/devices":
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"allocationId": "alloc-123",
"device": map[string]string{"id": "R83X10DZ2TW", "platform": "ios"},
})
default:
t.Errorf("unexpected request path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http"))

response := FleetAllocateCommand(FleetAllocateRequest{
Filters: []DeviceFilter{{Attribute: "platform", Operator: "EQUALS", Value: "ios"}},
Token: "my-token",
})

if response.Status != "ok" {
t.Fatalf("expected ok, got %+v", response)
}
result, ok := response.Data.(FleetAllocateResponse)
if !ok {
t.Fatalf("expected FleetAllocateResponse, got %T", response.Data)
}
if result.AllocationID != "alloc-123" {
t.Errorf("expected allocationId alloc-123, got %s", result.AllocationID)
}
if result.Device == nil || result.Device.ID != "R83X10DZ2TW" {
t.Errorf("expected device R83X10DZ2TW, got %+v", result.Device)
}
if len(gotPaths) != 2 || gotPaths[0] != "/api/v1/sessions" || gotPaths[1] != "/api/v1/sessions/session-abc/devices" {
t.Errorf("expected create-session then allocate-into-session, got %v", gotPaths)
}
}

func TestFleetAllocateCommandFailsWhenSessionCreationFails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]string{"code": "internal_error", "message": "Failed to create session"},
})
}))
defer server.Close()

t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http"))

response := FleetAllocateCommand(FleetAllocateRequest{Token: "my-token"})
if response.Status != "error" {
t.Fatalf("expected error, got %+v", response)
}
if !strings.Contains(response.Error, "Failed to create session") {
t.Errorf("expected the server's error message, got %q", response.Error)
}
}

// sessionsPage renders one page of the GET /api/v1/sessions response, embedding the given
// devices (by serial + status) into a single session with the given ID.
func sessionsPage(sessionID string, nextCursor string, devices ...map[string]string) map[string]any {
deviceEntries := make([]map[string]any, len(devices))
for i, d := range devices {
deviceEntries[i] = map[string]any{
"status": d["status"],
"info": map[string]string{"serial": d["serial"]},
}
}
page := map[string]any{
"object": "list",
"data": []map[string]any{
{"id": sessionID, "devices": deviceEntries},
},
}
if nextCursor != "" {
page["nextCursor"] = nextCursor
}
return page
}

func TestFleetReleaseCommandFindsOwningSessionThenReleases(t *testing.T) {
var gotPaths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPaths = append(gotPaths, r.Method+" "+r.URL.Path)
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions":
_ = json.NewEncoder(w).Encode(sessionsPage("session-abc", "", map[string]string{"serial": "R83X10DZ2TW", "status": "in_use"}))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/session-abc/devices/R83X10DZ2TW/release":
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http"))

response := FleetReleaseCommand(FleetReleaseRequest{DeviceID: "R83X10DZ2TW", Token: "my-token"})
if response.Status != "ok" {
t.Fatalf("expected ok, got %+v", response)
}
if len(gotPaths) != 2 || gotPaths[1] != "POST /api/v1/sessions/session-abc/devices/R83X10DZ2TW/release" {
t.Errorf("expected a session lookup then a release call, got %v", gotPaths)
}
}

func TestFleetReleaseCommandPaginatesUntilTheDeviceIsFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Query().Get("before") == "":
_ = json.NewEncoder(w).Encode(sessionsPage("session-old", "2026-01-01T00:00:00Z", map[string]string{"serial": "OTHER_DEVICE", "status": "in_use"}))
case r.Method == http.MethodGet && r.URL.Query().Get("before") == "2026-01-01T00:00:00Z":
_ = json.NewEncoder(w).Encode(sessionsPage("session-target", "", map[string]string{"serial": "R83X10DZ2TW", "status": "in_use"}))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/session-target/devices/R83X10DZ2TW/release":
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
default:
t.Errorf("unexpected request %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http"))

response := FleetReleaseCommand(FleetReleaseRequest{DeviceID: "R83X10DZ2TW", Token: "my-token"})
if response.Status != "ok" {
t.Fatalf("expected ok, got %+v", response)
}
}

func TestFleetReleaseCommandFailsWhenDeviceIsNotInAnyActiveSession(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// same serial, but already released, so it must not match
_ = json.NewEncoder(w).Encode(sessionsPage("session-abc", "", map[string]string{"serial": "R83X10DZ2TW", "status": "released"}))
}))
defer server.Close()

t.Setenv("MOBILECLI_FLEET_URL", "ws"+strings.TrimPrefix(server.URL, "http"))

response := FleetReleaseCommand(FleetReleaseRequest{DeviceID: "R83X10DZ2TW", Token: "my-token"})
if response.Status != "error" {
t.Fatalf("expected error, got %+v", response)
}
if !strings.Contains(response.Error, "R83X10DZ2TW") {
t.Errorf("expected the error to name the device, got %q", response.Error)
}
}
Loading
Loading