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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ go.work

dist/
.idea/
.taskman/
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,37 @@ By default, wsget will print the data received from the WebSocket server only to
wsget wss://ws.postman-echo.com/raw -o output.txt
```

## CI / Headless Usage

wsget automatically detects when it is running without a terminal (e.g. GitHub Actions, Docker, scripts) and disables interactive keyboard input. No extra flags are needed — the following patterns work out of the box in headless environments:

**Send a request and wait for one response, then exit:**
```
wsget wss://example.com -r '{"hello":"world"}' -w 5
```

**Run a YAML script** (make sure the script ends with `exit`):
```yaml
# script.yaml
- send {"my":"payload"}
- wait 10
- exit
```
```
wsget wss://example.com -i script.yaml
```

**Force headless mode explicitly** (overrides the auto-detection):
```
wsget wss://example.com --no-input -i script.yaml
```

**Example GitHub Actions step:**
```yaml
- name: Test WebSocket server
run: wsget wss://example.com -r '{"ping":1}' -w 5
```

Example:

```
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/coder/websocket v1.8.15
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
github.com/fatih/color v1.19.0
github.com/mattn/go-isatty v0.0.20
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.22.0
Expand All @@ -18,7 +19,6 @@ require (
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/stretchr/objx v0.5.2 // indirect
Expand Down
33 changes: 21 additions & 12 deletions pkg/cmd/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (
"path/filepath"
"time"

"github.com/mattn/go-isatty"
"golang.org/x/sync/errgroup"

"github.com/ksysoev/wsget/pkg/core"
command2 "github.com/ksysoev/wsget/pkg/core/command"
"github.com/ksysoev/wsget/pkg/core/edit"
Expand All @@ -18,7 +21,6 @@ import (
"github.com/ksysoev/wsget/pkg/repo/macro"
"github.com/ksysoev/wsget/pkg/ws"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
)

const (
Expand Down Expand Up @@ -126,23 +128,28 @@ func runConnectCmd(ctx context.Context, args *flags, unnamedArgs []string) error

client := core.NewCLI(cmdFactory, wsConn, os.Stdout, editor, formater.NewFormat())

keyboard := input.NewKeyboard(client)
defer keyboard.Close()
inputEnabled := !args.noInput && isatty.IsTerminal(os.Stdin.Fd())
uiInteractive := inputEnabled && isatty.IsTerminal(os.Stdout.Fd())

opts, err := initRunOptions(args)
opts, err := initRunOptions(args, uiInteractive)
if err != nil {
return fmt.Errorf("failed to initialize run options: %w", err)
}

eg, ctx := errgroup.WithContext(ctx)

eg.Go(func() error {
if err := keyboard.Run(ctx); err != nil {
return fmt.Errorf("keyboard input failed: %w", err)
}
if inputEnabled {
keyboard := input.NewKeyboard(client)
defer keyboard.Close()

return nil
})
eg.Go(func() error {
if err := keyboard.Run(ctx); err != nil {
return fmt.Errorf("keyboard input failed: %w", err)
}

return nil
})
}

eg.Go(func() error {
if err := wsConn.Connect(ctx); err != nil {
Expand Down Expand Up @@ -198,8 +205,10 @@ func validateArgs(wsURL string, args *flags) error {
// It takes a single parameter args of type *flags which contains the command-line arguments.
// It returns a pointer to cli.RunOptions and an error.
// It returns an error if it fails to open the specified output file.
func initRunOptions(args *flags) (opts *core.RunOptions, err error) {
opts = &core.RunOptions{}
func initRunOptions(args *flags, interactive bool) (opts *core.RunOptions, err error) {
opts = &core.RunOptions{
Interactive: interactive,
}

if args.outputFile != "" {
if opts.OutputFile, err = os.Create(args.outputFile); err != nil {
Expand Down
43 changes: 35 additions & 8 deletions pkg/cmd/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func TestInitRunOptions(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts, err := initRunOptions(tt.args)
opts, err := initRunOptions(tt.args, false)
if tt.expectError {
assert.Error(t, err)
} else {
Expand Down Expand Up @@ -288,24 +288,51 @@ func TestRunConnectCmd_NoURL(t *testing.T) {
assert.Error(t, err)
}

func TestRunConnectCmd_SuccessConnect(t *testing.T) {
func TestInitRunOptions_Interactive(t *testing.T) {
opts, err := initRunOptions(&flags{}, true)
assert.NoError(t, err)
assert.True(t, opts.Interactive)

opts, err = initRunOptions(&flags{}, false)
assert.NoError(t, err)
assert.False(t, opts.Interactive)
}

func TestRunConnectCmd_NoInputFlag(t *testing.T) {
server := httptest.NewServer(createEchoWSHandler())
defer server.Close()

tmpConfigDir := t.TempDir()

url := "ws://" + server.Listener.Addr().String()

ctx := context.Background()
args := &flags{
request: "test request",
waitResponse: 1,
noInput: true,
configDir: tmpConfigDir,
}
Comment thread
ksysoev marked this conversation as resolved.

// tty is not available in the test environment
// so the test will fail in some cases and be successful in others
err := runConnectCmd(ctx, args, []string{url})
if err != nil {
assert.ErrorContains(t, err, "open /dev/tty: ")
} else {
assert.NoError(t, err)
assert.NoError(t, err)
}

func TestRunConnectCmd_SuccessConnect(t *testing.T) {
server := httptest.NewServer(createEchoWSHandler())
defer server.Close()

tmpConfigDir := t.TempDir()

url := "ws://" + server.Listener.Addr().String()

ctx := context.Background()
args := &flags{
request: "test request",
waitResponse: 1,
configDir: tmpConfigDir,
}

err := runConnectCmd(ctx, args, []string{url})
assert.NoError(t, err)
}
2 changes: 2 additions & 0 deletions pkg/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type flags struct {
timeout uint32
insecure bool
verbose bool
noInput bool
}

// InitCommands initializes and returns a new cobra.Command for the wsget tool.
Expand Down Expand Up @@ -73,6 +74,7 @@ func InitCommands(version string) *cobra.Command {
cmd.Flags().BoolVarP(&args.verbose, "verbose", "v", false, "Verbose output")
cmd.Flags().Int64VarP(&args.maxMsgSize, "max-size", "s", ws.DefaultMaxMessageSize, "Maximum message size in bytes, non-positive value will be ignored and default value will be used")
cmd.Flags().Uint32VarP(&args.timeout, "timeout", "t", 30, "WebSocket handshake timeout in seconds, 0 means no timeout")
cmd.Flags().BoolVar(&args.noInput, "no-input", false, "Disable interactive keyboard input (for CI/headless environments)")

args.configDir = cmp.Or(args.configDir, os.Getenv("WSGET_CONFIG_DIR"))

Expand Down
18 changes: 12 additions & 6 deletions pkg/core/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ type CLI struct {
}

type RunOptions struct {
OutputFile io.Writer
Commands []Executer
OutputFile io.Writer
Commands []Executer
Interactive bool
}
Comment thread
ksysoev marked this conversation as resolved.

type Formater interface {
Expand Down Expand Up @@ -138,13 +139,18 @@ func (c *CLI) onMessage(ctx context.Context, msg Message) {
func (c *CLI) Run(ctx context.Context, opts RunOptions) error {
defer func() {
close(c.done)
c.showCursor()

if opts.Interactive {
c.showCursor()
}

close(c.commands)
}()

c.hideCursor()

_, _ = fmt.Fprintln(c.output, WelcomMessage)
if opts.Interactive {
c.hideCursor()
_, _ = fmt.Fprintln(c.output, WelcomMessage)
}

for _, cmd := range opts.Commands {
c.commands <- cmd
Expand Down
45 changes: 45 additions & 0 deletions pkg/core/cli_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package core

import (
"bytes"
"context"
"encoding/base64"
"errors"
Expand All @@ -13,6 +14,22 @@ import (
"github.com/stretchr/testify/mock"
)

func newCLIWithBufferOutput(t *testing.T) (*CLI, *bytes.Buffer) {
t.Helper()

wsConn := NewMockConnectionHandler(t)
wsConn.EXPECT().SetOnMessage(mock.Anything)

factory := NewMockCommandFactory(t)

editor := NewMockEditor(t)
editor.EXPECT().SetInput(mock.Anything)

output := &bytes.Buffer{}

return NewCLI(factory, wsConn, output, editor, NewMockFormater(t)), output
}

func TestNewCLI(t *testing.T) {
wsConn := NewMockConnectionHandler(t)
wsConn.EXPECT().Send(context.Background(), mock.Anything).Return(nil)
Expand Down Expand Up @@ -567,3 +584,31 @@ func TestCLI_OnMessage_Binary(t *testing.T) {
t.Error("Timeout waiting for binary message")
}
}

func TestCLI_Run_InteractiveOutputEnabled(t *testing.T) {
cli, output := newCLIWithBufferOutput(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()

err := cli.Run(ctx, RunOptions{Interactive: true})
assert.NoError(t, err)

printed := output.String()
assert.Contains(t, printed, HideCursor)
assert.Contains(t, printed, ShowCursor)
assert.Contains(t, printed, WelcomMessage)
}

func TestCLI_Run_InteractiveOutputDisabled(t *testing.T) {
cli, output := newCLIWithBufferOutput(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()

err := cli.Run(ctx, RunOptions{Interactive: false})
assert.NoError(t, err)

printed := output.String()
assert.NotContains(t, printed, HideCursor)
assert.NotContains(t, printed, ShowCursor)
assert.NotContains(t, printed, WelcomMessage)
}