diff --git a/cmds/dutctl/dutctl.go b/cmds/dutctl/dutctl.go index d9dc891..8df6669 100644 --- a/cmds/dutctl/dutctl.go +++ b/cmds/dutctl/dutctl.go @@ -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] dutctl [options] [args...] dutctl [options] help @@ -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. @@ -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`. @@ -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] @@ -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) diff --git a/cmds/dutctl/dutctl_test.go b/cmds/dutctl/dutctl_test.go new file mode 100644 index 0000000..ac8de67 --- /dev/null +++ b/cmds/dutctl/dutctl_test.go @@ -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 +}