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
35 changes: 19 additions & 16 deletions cmds/dutctl/dutctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ import (

const usageAbstract = `dutctl - The client application of the DUT Control system.
`

const usageSynopsis = `
SYNOPSIS:
dutctl [options] list
dutctl [options] [list]
dutctl [options] <device>
dutctl [options] <device> <command> [args...]
dutctl [options] <device> <command> help
Expand All @@ -35,6 +36,7 @@ SYNOPSIS:
dutctl version

`

const usageDescription = `
If a device and a command are provided, dutctl will execute the command on the device.
The optional args are passed to the command.
Expand All @@ -49,6 +51,7 @@ The lock command reserves a device for the current user; the optional duration
(e.g. 30m, 2h) defaults to 30m. The unlock command releases it; pass the -force
option to release a lock held by another user.

When dutctl is run without any positional arguments, it defaults to the list command.
`

// Usage strings for the command-line flags, shown in the OPTIONS section of `dutctl -h`.
Expand Down Expand Up @@ -178,30 +181,32 @@ const exitInterrupted = 130

// start is the entry point of the application.
func (app *application) start() {
if len(app.args) == 0 {
app.exit(errInvalidCmdline)
}

if app.args[0] == "version" {
if len(app.args) > 0 && app.args[0] == "version" {
app.printVersion()
app.exit(nil)
}

app.setupRPCClient()
app.exit(app.dispatch())
}

// dispatch decides which RPC to call based on app.args.
// It is split out from start so it can be unit tested without os.Exit.
func (app *application) dispatch() error {
if len(app.args) == 0 {
return app.listRPC()
}

if app.args[0] == "list" {
if len(app.args) > 1 {
app.exit(errInvalidCmdline)
return errInvalidCmdline
}

err := app.listRPC()
app.exit(err)
return app.listRPC()
}

if len(app.args) == 1 {
device := app.args[0]
err := app.commandsRPC(device)
app.exit(err)
return app.commandsRPC(app.args[0])
}

device := app.args[0]
Expand All @@ -216,12 +221,10 @@ func (app *application) start() {
}

if len(cmdArgs) > 0 && cmdArgs[0] == "help" {
err := app.detailsRPC(device, command, "help")
app.exit(err)
return app.detailsRPC(device, command, "help")
}

err := app.runRPC(device, command, cmdArgs)
app.exit(err)
return app.runRPC(device, command, cmdArgs)
}

// exit terminates the application. Buffered diagnostics (the warning summary)
Expand Down
206 changes: 206 additions & 0 deletions cmds/dutctl/dutctl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Copyright 2025 Blindspot Software
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main

import (
"context"
"errors"
"io"
"testing"

"connectrpc.com/connect"

"github.com/BlindspotSoftware/dutctl/internal/output"
pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1"
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
)

// fakeDeviceServiceClient is a hand-written test double for
// dutctlv1connect.DeviceServiceClient. Only the unary RPCs are
// implemented; Run returns nil because the streaming path is not
// exercised in these tests.
type fakeDeviceServiceClient struct {
listDevices []string
listErr error
listCalls int

commandsCalls []string

detailsCalls []detailsCall
}

type detailsCall struct {
device, cmd, keyword string
}

func (f *fakeDeviceServiceClient) List(
_ context.Context, _ *connect.Request[pb.ListRequest],
) (*connect.Response[pb.ListResponse], error) {
f.listCalls++

if f.listErr != nil {
return nil, f.listErr
}

devices := make([]*pb.DeviceInfo, 0, len(f.listDevices))
for _, name := range f.listDevices {
devices = append(devices, &pb.DeviceInfo{Name: name})
}

return connect.NewResponse(&pb.ListResponse{Devices: devices}), nil
}

func (f *fakeDeviceServiceClient) Commands(
_ context.Context, req *connect.Request[pb.CommandsRequest],
) (*connect.Response[pb.CommandsResponse], error) {
f.commandsCalls = append(f.commandsCalls, req.Msg.GetDevice())

return connect.NewResponse(&pb.CommandsResponse{}), nil
}

func (f *fakeDeviceServiceClient) Details(
_ context.Context, req *connect.Request[pb.DetailsRequest],
) (*connect.Response[pb.DetailsResponse], error) {
f.detailsCalls = append(f.detailsCalls, detailsCall{
device: req.Msg.GetDevice(),
cmd: req.Msg.GetCmd(),
keyword: req.Msg.GetKeyword(),
})

return connect.NewResponse(&pb.DetailsResponse{}), nil
}

func (f *fakeDeviceServiceClient) Run(
_ context.Context,
) *connect.BidiStreamForClient[pb.RunRequest, pb.RunResponse] {
return nil
}

func (f *fakeDeviceServiceClient) Lock(
_ context.Context, _ *connect.Request[pb.LockRequest],
) (*connect.Response[pb.LockResponse], error) {
return connect.NewResponse(&pb.LockResponse{}), nil
}

func (f *fakeDeviceServiceClient) Unlock(
_ context.Context, _ *connect.Request[pb.UnlockRequest],
) (*connect.Response[pb.UnlockResponse], error) {
return connect.NewResponse(&pb.UnlockResponse{}), nil
}

// Compile-time assertion that the fake satisfies the interface.
var _ dutctlv1connect.DeviceServiceClient = (*fakeDeviceServiceClient)(nil)

// newTestApp builds an application with a fake RPC client and a
// discarding formatter. Use it to drive dispatch in unit tests.
func newTestApp(t *testing.T, fake *fakeDeviceServiceClient, args ...string) *application {
t.Helper()

return &application{
stdin: io.NopCloser(nil),
stdout: io.Discard,
stderr: io.Discard,
exitFunc: func(int) {},
args: args,
rpcClient: fake,
formatter: output.New(output.Config{Stdout: io.Discard, Stderr: io.Discard}),
}
}

func TestDispatch(t *testing.T) {
tests := []struct {
name string
args []string
listDevices []string
wantErrIs error
wantListHit int
wantCmdHits []string
wantDetailHi []detailsCall
}{
{
name: "no args defaults to list",
args: nil,
wantListHit: 1,
},
{
name: "explicit list",
args: []string{"list"},
wantListHit: 1,
},
{
name: "explicit list with extra args is invalid",
args: []string{"list", "extra"},
wantErrIs: errInvalidCmdline,
},
{
name: "single arg lists commands for that device",
args: []string{"mydevice"},
wantCmdHits: []string{"mydevice"},
},
{
name: "device command help calls details",
args: []string{"mydevice", "power", "help"},
wantDetailHi: []detailsCall{
{device: "mydevice", cmd: "power", keyword: "help"},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &fakeDeviceServiceClient{listDevices: tt.listDevices}
app := newTestApp(t, fake, tt.args...)

err := app.dispatch()

if tt.wantErrIs != nil {
if !errors.Is(err, tt.wantErrIs) {
t.Fatalf("dispatch error: want %v, got %v", tt.wantErrIs, err)
}
} else if err != nil {
t.Fatalf("dispatch: unexpected error: %v", err)
}

if fake.listCalls != tt.wantListHit {
t.Errorf("List calls: want %d, got %d", tt.wantListHit, fake.listCalls)
}

if !equalStrings(fake.commandsCalls, tt.wantCmdHits) {
t.Errorf("Commands calls: want %v, got %v", tt.wantCmdHits, fake.commandsCalls)
}

if !equalDetails(fake.detailsCalls, tt.wantDetailHi) {
t.Errorf("Details calls: want %v, got %v", tt.wantDetailHi, fake.detailsCalls)
}
})
}
}

func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}

for i := range a {
if a[i] != b[i] {
return false
}
}

return true
}

func equalDetails(a, b []detailsCall) bool {
if len(a) != len(b) {
return false
}

for i := range a {
if a[i] != b[i] {
return false
}
}

return true
}