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
61 changes: 61 additions & 0 deletions .claude/skills/verify-wsstat/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
name: verify-wsstat
description: Build and drive the wsstat CLI end-to-end against the dev-stack mock server to verify changes at the CLI surface.
---

# Verifying wsstat changes

wsstat is a CLI; its surface is the terminal. Verify by running the built
binary against the repo's own mock server and reading stdout/stderr + exit
codes. Do NOT write a throwaway WebSocket server — `dev/` already provides one
with per-feature endpoints (see the endpoint table in `dev/README.md`).

## Build + server

```bash
make build # -> ./bin/wsstat
./dev/run.sh up # Dockerized mock on ws://localhost:17080 and wss://localhost:17443
```

No Docker? Run the mock on the host instead:

```bash
cd dev/mock-server && PORT=17080 TLS_PORT=17443 go run . &
```

## Drive

Pick the endpoint that isolates the changed feature (`/echo`, `/jsonrpc`,
`/stream?rate=N`, `/subscriptions` for stateful multi-frame conversations,
`/slow`, `/headers`, `/close-abrupt`, `/push`, …):

```bash
./bin/wsstat measure -t ping ws://localhost:17080/echo
./bin/wsstat stream -c 2 -o json -t '{"method":"subscribe","subscription":{}}' ws://localhost:17080/subscriptions
```

## Make it stick

A one-off invocation proves the change today; the suites keep proving it:

- `dev/smoke-test.sh` (`make smoke`) — one assertion per feature. A new feature
gets one `check` line.
- `dev/soak-test.sh` (`make soak`) — the combination matrix. A new flag gets a
POSITIVE row per alias, a REJECT row per validation rule (a rule that exits 0
is a silent accept), and an EFFECT check if its only failure mode is being
ignored.
- New server behavior needed? Add an endpoint to `dev/mock-server/main.go` and
document it in the `dev/README.md` table.

Both suites also run standalone against a host-run mock:
`WSSTAT=./bin/wsstat ./dev/smoke-test.sh`.

## Gotchas

- Flags must come before the URL; the subcommand must come first.
- `-q`/`-v`/`--body`/`--clip` are rejected under `-o json|raw` (axis purity).
- Usage errors exit 2, runtime errors exit 1; under `-o json` runtime errors
emit a `{"type":"error"}` envelope on stdout.
- Use `ws://` (17080) to skip TLS; `wss://` (17443) is self-signed — verify via
`SSL_CERT_FILE=<(curl http://localhost:17080/ca.pem)` or `-k`.
- Bare hosts default to `wss://`.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Repeatable `-t` in `stream` mode.** `wsstat stream` now accepts `-t/--text` multiple times and sends each message in argv order on the same connection, enabling multi-frame conversations (e.g. subscribe then unsubscribe) against a single server-side session. Sends are spaced by the new `--send-delay <duration>` flag (default `1s`) so responses interleave predictably under `-o json`. The first message remains the subscribe payload; receiving (`-c`, `--once`, `--timeout`) is unchanged and a single `-t` behaves exactly as before; if the receive limit is reached before all messages are sent, the remaining sends are skipped. `measure` mode rejects repeated `-t`.

## [3.1.1] - 2026-07-13

### Fixed
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,20 @@ the first event:
wsstat stream --once -t '{"method":"subscribe_ticker"}' wss://example.org/ws
```

In `stream`, `-t` may be repeated to hold a multi-frame conversation on a single
connection: each message is sent in argv order, spaced by `--send-delay`
(default `1s`) so the server can answer each frame before the next arrives. The
first message is the subscribe payload; receiving is unchanged, so `-c`,
`--once`, and `--timeout` still bound the read side. If the receive limit is
reached before all messages are sent, the remaining sends are skipped:

```sh
wsstat stream -c 4 -o json \
-t '{"method":"subscribe","subscription":{"type":"trades","coin":"BTC"}}' \
-t '{"method":"unsubscribe","subscription":{"type":"trades","coin":"BTC"}}' \
wss://example.org/ws
```

### Output

Output is split across three orthogonal axes:
Expand Down
31 changes: 20 additions & 11 deletions cmd/wsstat/cliux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,37 +16,46 @@ func TestResolveTextPayload(t *testing.T) {

t.Run("plain value passes through", func(t *testing.T) {
t.Parallel()
c := &commonFlags{text: "ping"}
c := &commonFlags{text: textList{"ping"}}
require.NoError(t, resolveTextPayload(c))
assert.Equal(t, "ping", c.text)
assert.Equal(t, textList{"ping"}, c.text)
})

t.Run("empty value passes through", func(t *testing.T) {
t.Run("empty value is dropped", func(t *testing.T) {
t.Parallel()
c := &commonFlags{text: ""}
c := &commonFlags{text: textList{""}}
require.NoError(t, resolveTextPayload(c))
assert.Empty(t, c.text)
})

t.Run("escaped leading at", func(t *testing.T) {
t.Parallel()
c := &commonFlags{text: "@@literal"}
c := &commonFlags{text: textList{"@@literal"}}
require.NoError(t, resolveTextPayload(c))
assert.Equal(t, "@literal", c.text)
assert.Equal(t, textList{"@literal"}, c.text)
})

t.Run("reads from file verbatim", func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "payload.json")
require.NoError(t, os.WriteFile(path, []byte("{\"a\":1}\n"), 0o600))
c := &commonFlags{text: "@" + path}
c := &commonFlags{text: textList{"@" + path}}
require.NoError(t, resolveTextPayload(c))
assert.Equal(t, "{\"a\":1}\n", c.text, "trailing newline is preserved")
assert.Equal(t, textList{"{\"a\":1}\n"}, c.text, "trailing newline is preserved")
})

t.Run("expands each entry independently", func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "payload.json")
require.NoError(t, os.WriteFile(path, []byte("from file"), 0o600))
c := &commonFlags{text: textList{"plain", "@" + path, ""}}
require.NoError(t, resolveTextPayload(c))
assert.Equal(t, textList{"plain", "from file"}, c.text, "order kept, empties dropped")
})

t.Run("missing file errors", func(t *testing.T) {
t.Parallel()
c := &commonFlags{text: "@" + filepath.Join(t.TempDir(), "nope")}
c := &commonFlags{text: textList{"@" + filepath.Join(t.TempDir(), "nope")}}
err := resolveTextPayload(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "reading --text payload")
Expand All @@ -64,9 +73,9 @@ func TestResolveTextPayload(t *testing.T) {
os.Stdin = f
defer func() { os.Stdin = prev }()

c := &commonFlags{text: "@-"}
c := &commonFlags{text: textList{"@-"}}
require.NoError(t, resolveTextPayload(c))
assert.Equal(t, "from stdin", c.text)
assert.Equal(t, textList{"from stdin"}, c.text)
})
}

Expand Down
72 changes: 51 additions & 21 deletions cmd/wsstat/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ type commonFlags struct {
resolves resolveList
rpcMethod string
rpcVersion string
text string
text textList
output string
file string
body string
Expand Down Expand Up @@ -66,8 +66,8 @@ func registerCommon(fs *flag.FlagSet, c *commonFlags) {

fs.StringVar(&c.rpcMethod, "rpc-method", "", "JSON-RPC method name to send (id=1, jsonrpc=2.0)")
fs.StringVar(&c.rpcVersion, "rpc-version", "2.0", "JSON-RPC version for --rpc-method: 2.0 or 1.0")
fs.StringVar(&c.text, "t", "", "text message to send (@file or @- reads payload from a file or stdin)")
fs.StringVar(&c.text, "text", "", "text message to send (@file or @- reads payload from a file or stdin)")
fs.Var(&c.text, "t", "text message to send (repeatable in stream mode; @file or @- reads payload from a file or stdin)")
fs.Var(&c.text, "text", "text message to send (repeatable in stream mode; @file or @- reads payload from a file or stdin)")

fs.StringVar(&c.output, "o", "text", "output contract: text, json, or raw")
fs.StringVar(&c.output, "output", "text", "output contract: text, json, or raw")
Expand Down Expand Up @@ -154,6 +154,10 @@ func resolveCommon(fs *flag.FlagSet, c *commonFlags, mode app.Mode) ([]app.Optio
return nil, nil, err
}

if err := validateMeasureMessaging(c, mode, output); err != nil {
return nil, nil, err
}

if c.timeout < 0 {
return nil, nil, errors.New("--timeout must be zero or greater")
}
Expand All @@ -176,11 +180,6 @@ func resolveCommon(fs *flag.FlagSet, c *commonFlags, mode app.Mode) ([]app.Optio
return nil, nil, err
}

// Raw measure has no payload to emit without a message.
if output == app.OutputRaw && mode == app.ModeMeasure && c.text == "" && c.rpcMethod == "" {
return nil, nil, errors.New("-o raw in measure mode requires --text or --rpc-method")
}

target, err := positionalURL(fs)
if err != nil {
return nil, nil, err
Expand All @@ -200,7 +199,7 @@ func buildCommonOptions(
app.WithResolves(c.resolves.Values()),
app.WithRPCMethod(c.rpcMethod),
app.WithRPCVersion(rpcVersion),
app.WithTextMessage(c.text),
app.WithTextMessages(c.text.Values()),
app.WithOutput(output),
app.WithResponseFile(c.file),
app.WithBodyRender(body),
Expand All @@ -220,18 +219,34 @@ func buildCommonOptions(
}
}

// resolveTextPayload expands an @-prefixed --text value: "@-" reads stdin, "@path" reads a
// resolveTextPayload expands @-prefixed --text values: "@-" reads stdin, "@path" reads a
// file. The bytes are sent verbatim (no trailing-newline stripping), matching the raw-output
// contract. A literal leading @ is escaped as "@@". Any other value passes through unchanged.
// Entries that resolve to an empty string are dropped, so -t "" keeps meaning "no message".
func resolveTextPayload(c *commonFlags) error {
if !strings.HasPrefix(c.text, "@") {
return nil
resolved := make(textList, 0, len(c.text))
for _, entry := range c.text {
expanded, err := expandTextEntry(entry)
if err != nil {
return err
}
if expanded != "" {
resolved = append(resolved, expanded)
}
}
if strings.HasPrefix(c.text, "@@") {
c.text = c.text[1:]
return nil
c.text = resolved
return nil
}

// expandTextEntry expands a single --text value per the resolveTextPayload contract.
func expandTextEntry(entry string) (string, error) {
if !strings.HasPrefix(entry, "@") {
return entry, nil
}
if strings.HasPrefix(entry, "@@") {
return entry[1:], nil
}
src := c.text[1:]
src := entry[1:]
var (
data []byte
err error
Expand All @@ -242,16 +257,15 @@ func resolveTextPayload(c *commonFlags) error {
data, err = os.ReadFile(src)
}
if err != nil {
return fmt.Errorf("reading --text payload: %w", err)
return "", fmt.Errorf("reading --text payload: %w", err)
}
c.text = string(data)
return nil
return string(data), nil
}

// validateMessaging checks the messaging flags (--text / --rpc-method / --rpc-version) and
// returns the resolved JSON-RPC version.
func validateMessaging(c *commonFlags, set map[string]bool) (string, error) {
if c.text != "" && c.rpcMethod != "" {
if len(c.text) > 0 && c.rpcMethod != "" {
return "", errors.New("mutually exclusive messaging flags: use --text or --rpc-method")
}
rpcVersion := strings.TrimSpace(c.rpcVersion)
Expand All @@ -260,12 +274,28 @@ func validateMessaging(c *commonFlags, set map[string]bool) (string, error) {
default:
return "", errors.New("--rpc-version must be 1.0 or 2.0")
}
if set["rpc-version"] && c.rpcMethod == "" && c.text == "" {
if set["rpc-version"] && c.rpcMethod == "" && len(c.text) == 0 {
return "", errors.New("--rpc-version requires --rpc-method or --text")
}
return rpcVersion, nil
}

// validateMeasureMessaging rejects measure-mode invocations whose messaging flags need stream
// mode or fall short of the output contract. Repeated -t drives a multi-frame conversation and
// only stream mode sends on an open connection; raw measure has no payload without a message.
func validateMeasureMessaging(c *commonFlags, mode app.Mode, output app.Output) error {
if mode != app.ModeMeasure {
return nil
}
if len(c.text) > 1 {
return errors.New("repeated -t requires the stream subcommand")
}
if output == app.OutputRaw && len(c.text) == 0 && c.rpcMethod == "" {
return errors.New("-o raw in measure mode requires --text or --rpc-method")
}
return nil
}

// splitCSV splits a comma-separated value into trimmed, non-empty parts. Returns nil for empty.
func splitCSV(s string) []string {
var out []string
Expand Down
46 changes: 46 additions & 0 deletions cmd/wsstat/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"testing"
"time"

"github.com/jkbrsn/wsstat/v3/internal/app"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -72,6 +73,12 @@ func TestMeasureFlags(t *testing.T) {
assert.Contains(t, err.Error(), "mutually exclusive")
})

t.Run("repeated text rejected", func(t *testing.T) {
_, _, err := buildMeasure([]string{"-t", "a", "-t", "b", "example.com"})
require.Error(t, err)
assert.Contains(t, err.Error(), "repeated -t requires the stream subcommand")
})

t.Run("rpc-version accepts 1.0 with rpc-method", func(t *testing.T) {
_, _, err := buildMeasure([]string{"--rpc-method", "m", "--rpc-version", "1.0", "example.com"})
require.NoError(t, err)
Expand Down Expand Up @@ -231,6 +238,45 @@ func TestStreamFlags(t *testing.T) {
_, _, err := buildStream([]string{"-b", "10", "example.com"})
require.NoError(t, err)
})

t.Run("repeated text accepted in order", func(t *testing.T) {
client, _, err := buildStream([]string{"-t", "sub", "--text", "unsub", "example.com"})
require.NoError(t, err)
assert.Equal(t, []string{"sub", "unsub"}, client.TextMessages())
})

t.Run("send-delay defaults to one second", func(t *testing.T) {
client, _, err := buildStream([]string{"-t", "a", "-t", "b", "example.com"})
require.NoError(t, err)
assert.Equal(t, time.Second, client.SendDelay())
})

t.Run("send-delay parsed", func(t *testing.T) {
client, _, err := buildStream([]string{
"--send-delay", "250ms", "-t", "a", "-t", "b", "example.com",
})
require.NoError(t, err)
assert.Equal(t, 250*time.Millisecond, client.SendDelay())
})

t.Run("negative send-delay rejected", func(t *testing.T) {
_, _, err := buildStream([]string{
"--send-delay", "-1s", "-t", "a", "-t", "b", "example.com",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "--send-delay must be zero or greater")
})

t.Run("send-delay without repeated text rejected", func(t *testing.T) {
for _, args := range [][]string{
{"--send-delay", "1s", "example.com"},
{"--send-delay", "1s", "-t", "a", "example.com"},
} {
_, _, err := buildStream(args)
require.Errorf(t, err, "args %v should be rejected", args)
assert.Contains(t, err.Error(), "--send-delay has no effect without repeated -t")
}
})
}

func TestParseReadLimit(t *testing.T) {
Expand Down
20 changes: 20 additions & 0 deletions cmd/wsstat/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ func (h *headerList) Values() []string {
return append([]string(nil), *h...)
}

// textList is a flag.Value implementation that accumulates repeated -t / --text entries.
// Values are kept verbatim; @-expansion and empty filtering happen in resolveTextPayload.
type textList []string

// Set appends the text message to the list.
func (l *textList) Set(value string) error {
*l = append(*l, value)
return nil
}

// String returns the string representation of the flag value.
func (l *textList) String() string {
return strings.Join(*l, ", ")
}

// Values returns the list of text messages.
func (l *textList) Values() []string {
return append([]string(nil), *l...)
}

// resolveList is a flag.Value implementation that accumulates DNS resolution overrides.
// Each entry has the format "host:port:address" (e.g., "example.com:443:192.168.1.1").
// The flag can be repeated to override multiple host:port combinations.
Expand Down
Loading