Skip to content

Commit efb7d34

Browse files
authored
feat: introduce device command (#14)
* feat: introduce device command * test: add tests for the devices command
1 parent 714df34 commit efb7d34

6 files changed

Lines changed: 289 additions & 3 deletions

File tree

internal/reporter/reporter.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package reporter
2+
3+
import (
4+
"fmt"
5+
"io"
6+
)
7+
8+
func PrintBanner(w io.Writer) {
9+
fmt.Println(w, "Debugger")
10+
}
11+
12+
func PrintDeviceList(w io.Writer, platform string, devices []string) {
13+
if len(devices) == 0 {
14+
fmt.Fprintf(w, " No %s devices found (booted / connected)\n\n", platform)
15+
return
16+
}
17+
18+
fmt.Fprintf(w, "%s\n", platform)
19+
20+
for _, d := range devices {
21+
fmt.Fprintf(w, "%s %s\n", "*", d)
22+
}
23+
fmt.Fprintln(w)
24+
}

internal/simulator/simulator.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package simulator
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os/exec"
7+
"strings"
8+
)
9+
10+
type simctlDevice struct {
11+
UDID string `json:"udid"`
12+
Name string `json:"name"`
13+
State string `json:"state"`
14+
IsAvailable bool `json:"isAvailable"`
15+
}
16+
17+
type simctlOutput struct {
18+
Devices map[string][]simctlDevice `json:"devices"`
19+
}
20+
21+
func CheckToolsAvailable() map[string]bool {
22+
available := make(map[string]bool)
23+
for _, tool := range []string{"xcrun"} {
24+
_, err := exec.LookPath(tool)
25+
available[tool] = err == nil
26+
}
27+
return available
28+
}
29+
30+
func ListIOSDevices() ([]string, error) {
31+
out, err := exec.Command("xcrun", "simctl", "list", "devices", "booted", "--json").Output()
32+
if err != nil {
33+
return nil, fmt.Errorf("xcrun simctl failed: %w", err)
34+
}
35+
36+
var payload simctlOutput
37+
if err := json.Unmarshal(out, &payload); err != nil {
38+
return nil, fmt.Errorf("failed to parse simctl JSON: %w", err)
39+
}
40+
41+
var names []string
42+
for _, devices := range payload.Devices {
43+
for _, d := range devices {
44+
if strings.EqualFold(d.State, "Booted") && d.IsAvailable {
45+
names = append(names, fmt.Sprintf("%s (%s)", d.Name, d.UDID))
46+
}
47+
}
48+
}
49+
return names, nil
50+
}
51+
52+
func GetBootedIOSDevices() ([]simctlDevice, error) {
53+
out, err := exec.Command("xcrun", "simctl", "list", "devices", "booted", "--json").Output()
54+
if err != nil {
55+
return nil, fmt.Errorf("xcrun simctl failed: %w", err)
56+
}
57+
58+
var payload simctlOutput
59+
if err := json.Unmarshal(out, &payload); err != nil {
60+
return nil, fmt.Errorf("failed to parse simctl JSON: %w", err)
61+
}
62+
63+
var devices []simctlDevice
64+
for _, devices := range payload.Devices {
65+
for _, d := range devices {
66+
if strings.EqualFold(d.State, "Booted") && d.IsAvailable {
67+
devices = append(devices, d)
68+
}
69+
}
70+
}
71+
return devices, nil
72+
}

pkg/cmd/devices/devices.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package devices
2+
3+
import (
4+
"encoding/json"
5+
6+
"github.com/space-code/linkctl/internal/reporter"
7+
"github.com/space-code/linkctl/internal/simulator"
8+
"github.com/space-code/linkctl/pkg/cmdutil"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
type options struct {
13+
asJSON bool
14+
}
15+
16+
func NewCmdDevices(f *cmdutil.Factory) *cobra.Command {
17+
opts := options{}
18+
19+
cmd := &cobra.Command{
20+
Use: "devices",
21+
Short: "List connected iOS simulators",
22+
Long: "List all currently booted iOS simulators.",
23+
Example: `linkctl devices`,
24+
RunE: func(cmd *cobra.Command, args []string) error {
25+
return run(f, &opts)
26+
},
27+
}
28+
29+
cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output results as JSON")
30+
31+
return cmd
32+
}
33+
34+
type devicesJSON struct {
35+
IOS []string `json:"ios"`
36+
Tools map[string]bool `json:"tools"`
37+
}
38+
39+
func run(f *cmdutil.Factory, opts *options) error {
40+
tools := simulator.CheckToolsAvailable()
41+
var iosDevices []string
42+
43+
if tools["xcrun"] {
44+
iosDevices, _ = simulator.ListIOSDevices()
45+
}
46+
47+
if opts.asJSON {
48+
enc := json.NewEncoder(f.IOStreams.Out)
49+
enc.SetIndent("", " ")
50+
return enc.Encode(devicesJSON{
51+
IOS: iosDevices,
52+
Tools: tools,
53+
})
54+
}
55+
56+
w := f.IOStreams.Out
57+
reporter.PrintDeviceList(w, "iOS", iosDevices)
58+
return nil
59+
}

pkg/cmd/devices/devices_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package devices_test
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"strings"
7+
"testing"
8+
9+
"github.com/space-code/linkctl/pkg/cmd/devices"
10+
"github.com/space-code/linkctl/pkg/cmdutil"
11+
"github.com/space-code/linkctl/pkg/iostreams"
12+
)
13+
14+
func newFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
15+
t.Helper()
16+
17+
ios, _, stdout, _ := iostreams.Test()
18+
19+
f := &cmdutil.Factory{
20+
AppVersion: "1.0.0",
21+
ExecutableName: "linkctl",
22+
IOStreams: ios,
23+
}
24+
25+
return f, stdout
26+
}
27+
28+
func TestDevicesCmd_NoError(t *testing.T) {
29+
f, _ := newFactory(t)
30+
cmd := devices.NewCmdDevices(f)
31+
cmd.SetArgs([]string{})
32+
if err := cmd.Execute(); err != nil {
33+
t.Fatalf("unexpected error: %v", err)
34+
}
35+
}
36+
37+
func TestDevicesCmd_JSONOutput_HasAllKeys(t *testing.T) {
38+
f, stdout := newFactory(t)
39+
cmd := devices.NewCmdDevices(f)
40+
cmd.SetArgs([]string{"--json"})
41+
if err := cmd.Execute(); err != nil {
42+
t.Fatalf("unexpected error: %v", err)
43+
}
44+
45+
raw := stdout.String()
46+
for _, key := range []string{"ios", "tools"} {
47+
if !strings.Contains(raw, key) {
48+
t.Errorf("JSON output missing key %s\ngot: %s", key, raw)
49+
}
50+
}
51+
}
52+
53+
func TestDevicesCmd_UnknownFlag(t *testing.T) {
54+
f, _ := newFactory(t)
55+
cmd := devices.NewCmdDevices(f)
56+
cmd.SetArgs([]string{"--unknown"})
57+
if err := cmd.Execute(); err == nil {
58+
t.Fatalf("unexpected error for unknown flag")
59+
}
60+
}
61+
62+
func TestDevicesCmd_JSONOutput_Shape(t *testing.T) {
63+
f, stdout := newFactory(t)
64+
cmd := devices.NewCmdDevices(f)
65+
cmd.SetArgs([]string{"--json"})
66+
if err := cmd.Execute(); err != nil {
67+
t.Fatalf("unexpected error: %v", err)
68+
}
69+
70+
var payload struct {
71+
IOS []string `json:"ios"`
72+
Tools map[string]bool `json:"tools"`
73+
}
74+
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
75+
t.Fatalf("output is not valid JSON: %v\ngot: %s", err, stdout.String())
76+
}
77+
78+
for _, key := range []string{"xcrun"} {
79+
if _, ok := payload.Tools[key]; !ok {
80+
t.Errorf("uexpected tools map to contain key %q", key)
81+
}
82+
}
83+
}

pkg/cmd/root/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package root
22

33
import (
4+
devicesCmd "github.com/space-code/linkctl/pkg/cmd/devices"
45
versionCmd "github.com/space-code/linkctl/pkg/cmd/version"
56
"github.com/space-code/linkctl/pkg/cmdutil"
67
"github.com/spf13/cobra"
@@ -17,6 +18,7 @@ func NewCmdRoot(f *cmdutil.Factory, appVersion string) (*cobra.Command, error) {
1718
}
1819

1920
cmd.AddCommand(versionCmd.NewCmdVersion(f))
21+
cmd.AddCommand(devicesCmd.NewCmdDevices(f))
2022

2123
return cmd, nil
2224
}

pkg/iostreams/iostreams.go

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
package iostreams
22

33
import (
4+
"bytes"
45
"io"
56
"os"
67
)
78

9+
type fileWriter interface {
10+
io.Writer
11+
Fd() uintptr
12+
}
13+
14+
type fileReader interface {
15+
io.ReadCloser
16+
Fd() uintptr
17+
}
18+
819
type IOStreams struct {
9-
In io.Reader
10-
Out io.Writer
11-
ErrOut io.Writer
20+
In fileReader
21+
Out fileWriter
22+
ErrOut fileWriter
1223
}
1324

1425
func System() *IOStreams {
@@ -20,3 +31,38 @@ func System() *IOStreams {
2031

2132
return ios
2233
}
34+
35+
func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
36+
in := &bytes.Buffer{}
37+
out := &bytes.Buffer{}
38+
errOut := &bytes.Buffer{}
39+
40+
io := &IOStreams{
41+
In: &fdReader{
42+
fd: 0,
43+
ReadCloser: io.NopCloser(in),
44+
},
45+
Out: &fdWriter{fd: 1, Writer: out},
46+
ErrOut: &fdWriter{fd: 2, Writer: errOut},
47+
}
48+
49+
return io, in, out, errOut
50+
}
51+
52+
type fdWriter struct {
53+
io.Writer
54+
fd uintptr
55+
}
56+
57+
func (w *fdWriter) Fd() uintptr {
58+
return w.fd
59+
}
60+
61+
type fdReader struct {
62+
io.ReadCloser
63+
fd uintptr
64+
}
65+
66+
func (r *fdReader) Fd() uintptr {
67+
return r.fd
68+
}

0 commit comments

Comments
 (0)