Added --http-upgrade and --direct to ws-test dial, so it can connect to job-search serve or ws-test serve

One of the two is required. --http-upgrade upgrades an HTTP connection to
a WebSocket on /ws, as before, for job-search serve; --url and --origin go
with it. --direct connects straight to ws-test serve over plain TCP, one
JSON message per line, at --addr (default from server.host and
server.port). --ping, --message, --listen and stdin work the same in both
modes. The README's ws-test section explains the two modes.
This commit is contained in:
bwilliams committed 2026-10-16 09:47:38 +01:00
1 parent 88f879d844
commit f0c171c8a8
5 files changed
+506 -60

No files matched your search

+14 -5
View File
@@ -110,13 +110,22 @@ See [internal/mock](internal/mock/README.md) for its options and behaviour.
## WebSocket test tool ## WebSocket test tool
`ws-test` reads the same configuration as `job-search` for its defaults: `ws-test` reads the same configuration as `job-search` for its defaults:
```bash ```bash
go run ./cmd/ws-test dial --ping # ping the HTTP server's /ws endpoint go run ./cmd/ws-test dial --http-upgrade --ping # ping job-search serve's /ws endpoint
go run ./cmd/ws-test dial # send each line of stdin, print replies go run ./cmd/ws-test dial --direct --ping # ping ws-test serve
go run ./cmd/ws-test serve # plain TCP server for websocket hub clients go run ./cmd/ws-test dial --http-upgrade # send each line of stdin, print replies
go run ./cmd/ws-test serve # plain TCP server for websocket hub clients
``` ```
`dial` also takes `--message`, `--listen`, `--origin` (to test the Origin `dial` needs one of `--http-upgrade`, to upgrade an HTTP connection to a
check), `--url` and `--timeout`; see `ws-test dial --help`. WebSocket on `/ws` as `job-search serve` expects, or `--direct`, to connect
straight to `ws-test serve` over plain TCP with one JSON message per line.
It also takes `--message`, `--listen` and `--timeout`; `--url` and `--origin`
(to test the Origin check) with `--http-upgrade`, and `--addr` with
`--direct`. See `ws-test dial --help`.
`ws-test serve` is a test server only: don't run it alongside
`job-search serve`. Both listen on `server.host` and `server.port`, so the
second one fails with "address already in use".
## Project layout ## Project layout
+142 -49
View File
@@ -30,9 +30,13 @@ import (
// carries back. // carries back.
const pingID = "ping" const pingID = "ping"
// dialSettings are the dial command's flag values. // dialSettings are the dial command's flag values. direct chooses a plain
// TCP connection to addr (--direct) over an HTTP upgrade at url
// (--http-upgrade).
type dialSettings struct { type dialSettings struct {
direct bool
url string url string
addr string
origin string origin string
messages []string messages []string
ping bool ping bool
@@ -40,14 +44,33 @@ type dialSettings struct {
timeout time.Duration timeout time.Duration
} }
// commandDial returns the "dial" command, which opens a WebSocket on the // connection is what dial talks to: a *websocket.Conn with --http-upgrade,
// HTTP server's /ws path, sends messages and prints the replies. // or a *directConn with --direct.
type connection interface {
Read(ctx context.Context) (websocket.MessageType, []byte, error)
Write(
ctx context.Context,
messageType websocket.MessageType,
payload []byte,
) error
Close(code websocket.StatusCode, reason string) error
CloseNow() error
}
// commandDial returns the "dial" command, which connects to the WebSocket
// hub of job-search serve (--http-upgrade) or ws-test serve (--direct),
// sends messages and prints the replies.
func commandDial(opts *cli.Options) *cobra.Command { func commandDial(opts *cli.Options) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "dial", Use: "dial",
Short: "Open a WebSocket on /ws, send messages and print the replies", Short: "Connect to a WebSocket hub, send messages and print the replies",
Long: `Upgrade an HTTP connection to a WebSocket on the server's /ws path and print Long: `Connect to a server's WebSocket hub and print every message the server sends,
every message the server sends, starting with its welcome. starting with its welcome. One of these is required:
--http-upgrade upgrade an HTTP connection to a WebSocket on the /ws path,
as job-search serve expects
--direct connect straight to ws-test serve over plain TCP, with one
JSON message per line in each direction
With --ping, it sends a ping and waits for the pong, failing if none arrives With --ping, it sends a ping and waits for the pong, failing if none arrives
within --timeout. within --timeout.
@@ -62,10 +85,13 @@ With neither, each line read from stdin is sent as a message (blank lines are
skipped), until Ctrl+C is pressed or the server closes the connection. The end skipped), until Ctrl+C is pressed or the server closes the connection. The end
of stdin stops the sending, but the connection stays open. of stdin stops the sending, but the connection stays open.
The URL defaults to the configured server.host and server.port (wss:// if With --http-upgrade, the URL defaults to the configured server.host and
server.tls.enabled is set). Set --origin to test the Origin check: an upgrade server.port (wss:// if server.tls.enabled is set). Set --origin to test the
from an origin that isn't the server's own or one of Origin check: an upgrade from an origin that isn't the server's own or one of
server.csrf.cross_origin_protection.trusted_origins is refused with 403.`, server.csrf.cross_origin_protection.trusted_origins is refused with 403.
With --direct, --addr defaults to the configured server.host and server.port.
There is no HTTP request, so --url and --origin don't apply.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) { PersistentPreRun: func(cmd *cobra.Command, args []string) {
slog.Debug("command persistent pre-run", "command", "dial") slog.Debug("command persistent pre-run", "command", "dial")
}, },
@@ -81,12 +107,17 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`,
return err return err
} }
if settings.url == "" { // Only one of url and addr can be set, for the chosen mode.
if settings.url == "" && settings.addr == "" {
conf, err := opts.RunSetup(cmd, true, true) conf, err := opts.RunSetup(cmd, true, true)
if err != nil { if err != nil {
return fmt.Errorf("load configuration: %w", err) return fmt.Errorf("load configuration: %w", err)
} }
settings.url = defaultURL(conf.Server) if settings.direct {
settings.addr = defaultAddr(conf.Server)
} else {
settings.url = defaultURL(conf.Server)
}
} }
// ctx is cancelled on the first SIGINT (Ctrl+C) or SIGTERM. // ctx is cancelled on the first SIGINT (Ctrl+C) or SIGTERM.
@@ -100,15 +131,34 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`,
} }
flags := cmd.Flags() flags := cmd.Flags()
flags.Bool(
"http-upgrade",
false,
"upgrade an HTTP connection to a WebSocket on /ws (job-search serve)",
)
flags.Bool(
"direct",
false,
"connect straight over plain TCP, one JSON message per line "+
"(ws-test serve)",
)
flags.String( flags.String(
"url", "url",
"", "",
"WebSocket URL (default from config, ws://127.0.0.1:9393/ws)", "with --http-upgrade, the WebSocket URL "+
"(default from config, ws://127.0.0.1:9393/ws)",
)
flags.String(
"addr",
"",
"with --direct, the address to connect to "+
"(default from config, 127.0.0.1:9393)",
) )
flags.String( flags.String(
"origin", "origin",
"", "",
"Origin header to send, as a browser would (default none)", "with --http-upgrade, the Origin header to send, as a browser would "+
"(default none)",
) )
flags.StringArray( flags.StringArray(
"message", "message",
@@ -129,8 +179,13 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`,
flags.Duration( flags.Duration(
"timeout", "timeout",
10*time.Second, 10*time.Second,
"how long to wait for the upgrade and for each reply; 0s for no limit", "how long to wait to connect and for each reply; 0s for no limit",
) )
cmd.MarkFlagsOneRequired("http-upgrade", "direct")
cmd.MarkFlagsMutuallyExclusive("http-upgrade", "direct")
cmd.MarkFlagsMutuallyExclusive("direct", "url")
cmd.MarkFlagsMutuallyExclusive("direct", "origin")
cmd.MarkFlagsMutuallyExclusive("http-upgrade", "addr")
return cmd return cmd
} }
@@ -139,7 +194,9 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`,
// commandDial, so getting them can't fail. // commandDial, so getting them can't fail.
func dialFlags(cmd *cobra.Command) (dialSettings, error) { func dialFlags(cmd *cobra.Command) (dialSettings, error) {
flags := cmd.Flags() flags := cmd.Flags()
direct, directErr := flags.GetBool("direct")
url, urlErr := flags.GetString("url") url, urlErr := flags.GetString("url")
addr, addrErr := flags.GetString("addr")
origin, originErr := flags.GetString("origin") origin, originErr := flags.GetString("origin")
messages, messagesErr := flags.GetStringArray("message") messages, messagesErr := flags.GetStringArray("message")
ping, pingErr := flags.GetBool("ping") ping, pingErr := flags.GetBool("ping")
@@ -147,7 +204,9 @@ func dialFlags(cmd *cobra.Command) (dialSettings, error) {
timeout, timeoutErr := flags.GetDuration("timeout") timeout, timeoutErr := flags.GetDuration("timeout")
err := errors.Join( err := errors.Join(
directErr,
urlErr, urlErr,
addrErr,
originErr, originErr,
messagesErr, messagesErr,
pingErr, pingErr,
@@ -159,7 +218,9 @@ func dialFlags(cmd *cobra.Command) (dialSettings, error) {
} }
return dialSettings{ return dialSettings{
direct: direct,
url: url, url: url,
addr: addr,
origin: origin, origin: origin,
messages: messages, messages: messages,
ping: ping, ping: ping,
@@ -169,59 +230,46 @@ func dialFlags(cmd *cobra.Command) (dialSettings, error) {
} }
// defaultURL is the /ws URL of the server conf configures: ws://, or wss:// // defaultURL is the /ws URL of the server conf configures: ws://, or wss://
// with TLS, on its host and port. An empty host (every interface) is dialled // with TLS, at defaultAddr.
// as 127.0.0.1.
func defaultURL(conf config.JSCServer) string { func defaultURL(conf config.JSCServer) string {
scheme := "ws" scheme := "ws"
if conf.TLS.Enabled { if conf.TLS.Enabled {
scheme = "wss" scheme = "wss"
} }
return scheme + "://" + defaultAddr(conf) + "/ws"
}
// defaultAddr is the host:port of the server conf configures. An empty host
// (every interface) is dialled as 127.0.0.1.
func defaultAddr(conf config.JSCServer) string {
host := conf.Host host := conf.Host
if host == "" { if host == "" {
host = "127.0.0.1" host = "127.0.0.1"
} }
address := net.JoinHostPort(host, strconv.Itoa(conf.Port)) return net.JoinHostPort(host, strconv.Itoa(conf.Port))
return scheme + "://" + address + "/ws"
} }
// dial opens a WebSocket at settings.url and prints the messages received // dial connects as settings choose (see connect) and prints the messages
// to out, each after "<". It sends each of settings.messages, printed after // received to out, each after "<". It sends each of settings.messages,
// ">", or without them each line of input (see interact), which isn't // printed after ">", or without them each line of input (see interact),
// printed again. // which isn't printed again.
func dial( func dial(
ctx context.Context, ctx context.Context,
input io.Reader, input io.Reader,
out io.Writer, out io.Writer,
settings dialSettings, settings dialSettings,
) error { ) error {
header := http.Header{} connectCtx, cancel := util.WithTimeout(ctx, settings.timeout)
if settings.origin != "" {
header.Set("Origin", settings.origin)
}
dialCtx, cancel := util.WithTimeout(ctx, settings.timeout)
defer cancel() defer cancel()
conn, res, err := websocket.Dial( conn, err := connect(connectCtx, out, settings)
dialCtx,
settings.url,
&websocket.DialOptions{
HTTPHeader: header,
},
)
if err != nil { if err != nil {
if res != nil { return err
return fmt.Errorf("upgrade refused: %s", res.Status)
}
return fmt.Errorf("dialling %s: %w", settings.url, err)
} }
defer conn.CloseNow() //nolint:errcheck // finished with; it may already be closed defer conn.CloseNow() //nolint:errcheck // finished with; it may already be closed
fmt.Fprintf(out, "connected (%s)\n", res.Status) //nolint:errcheck // printing only
if !settings.ping && len(settings.messages) == 0 { if !settings.ping && len(settings.messages) == 0 {
return interact(ctx, input, out, conn, settings.timeout) return interact(ctx, input, out, conn, settings.timeout)
} }
@@ -260,12 +308,53 @@ func dial(
return conn.Close(websocket.StatusNormalClosure, "") return conn.Close(websocket.StatusNormalClosure, "")
} }
// connect opens a WebSocket at settings.url, or with settings.direct a
// plain TCP connection to settings.addr, giving up when ctx is done. It
// prints how it connected to out.
func connect(
ctx context.Context,
out io.Writer,
settings dialSettings,
) (connection, error) {
if settings.direct {
conn, err := dialDirect(ctx, settings.addr)
if err != nil {
return nil, fmt.Errorf("connecting to %s: %w", settings.addr, err)
}
fmt.Fprintf(out, "connected directly to %s\n", settings.addr) //nolint:errcheck // printing only
return conn, nil
}
header := http.Header{}
if settings.origin != "" {
header.Set("Origin", settings.origin)
}
conn, res, err := websocket.Dial(
ctx,
settings.url,
&websocket.DialOptions{
HTTPHeader: header,
},
)
if err != nil {
if res != nil {
return nil, fmt.Errorf("upgrade refused: %s", res.Status)
}
return nil, fmt.Errorf("dialling %s: %w", settings.url, err)
}
fmt.Fprintf(out, "connected (%s)\n", res.Status) //nolint:errcheck // printing only
return conn, nil
}
// pingPong sends a ping on conn and waits at most timeout for its pong, // pingPong sends a ping on conn and waits at most timeout for its pong,
// printing each message received meanwhile to out. // printing each message received meanwhile to out.
func pingPong( func pingPong(
ctx context.Context, ctx context.Context,
out io.Writer, out io.Writer,
conn *websocket.Conn, conn connection,
timeout time.Duration, timeout time.Duration,
) error { ) error {
ping, err := json.Marshal(hub.Envelope{ ping, err := json.Marshal(hub.Envelope{
@@ -312,7 +401,7 @@ func interact(
ctx context.Context, ctx context.Context,
input io.Reader, input io.Reader,
out io.Writer, out io.Writer,
conn *websocket.Conn, conn connection,
timeout time.Duration, timeout time.Duration,
) error { ) error {
listening := make(chan error, 1) listening := make(chan error, 1)
@@ -366,7 +455,7 @@ func interact(
// send writes message to conn as a text frame, waiting at most timeout. // send writes message to conn as a text frame, waiting at most timeout.
func send( func send(
ctx context.Context, ctx context.Context,
conn *websocket.Conn, conn connection,
message string, message string,
timeout time.Duration, timeout time.Duration,
) error { ) error {
@@ -386,7 +475,7 @@ func send(
func receive( func receive(
ctx context.Context, ctx context.Context,
out io.Writer, out io.Writer,
conn *websocket.Conn, conn connection,
timeout time.Duration, timeout time.Duration,
) error { ) error {
readCtx, cancel := util.WithTimeout(ctx, timeout) readCtx, cancel := util.WithTimeout(ctx, timeout)
@@ -404,7 +493,7 @@ func receive(
// listen prints each message received on conn to out until ctx is cancelled // listen prints each message received on conn to out until ctx is cancelled
// (Ctrl+C) or the server closes the connection, either of which is a normal // (Ctrl+C) or the server closes the connection, either of which is a normal
// end. // end.
func listen(ctx context.Context, out io.Writer, conn *websocket.Conn) error { func listen(ctx context.Context, out io.Writer, conn connection) error {
for { for {
_, data, err := conn.Read(ctx) _, data, err := conn.Read(ctx)
status := websocket.CloseStatus(err) status := websocket.CloseStatus(err)
@@ -412,8 +501,12 @@ func listen(ctx context.Context, out io.Writer, conn *websocket.Conn) error {
case err == nil: case err == nil:
fmt.Fprintf(out, "< %s\n", data) //nolint:errcheck // printing only fmt.Fprintf(out, "< %s\n", data) //nolint:errcheck // printing only
case ctx.Err() != nil: case ctx.Err() != nil:
// Ctrl+C: Read has closed the connection already. // Ctrl+C: dial closes the connection.
return nil //nolint:nilerr // a normal end return nil //nolint:nilerr // a normal end
case errors.Is(err, io.EOF):
// A direct connection has no close status.
fmt.Fprintln(out, "closed by the server") //nolint:errcheck // printing only
return nil
case status != -1: case status != -1:
fmt.Fprintf(out, "closed by the server (%d)\n", status) //nolint:errcheck // printing only fmt.Fprintf(out, "closed by the server (%d)\n", status) //nolint:errcheck // printing only
return nil return nil
+6 -6
View File
@@ -59,7 +59,7 @@ func TestDialPing(t *testing.T) {
_, url := wsServer(t) _, url := wsServer(t)
out := cmdtest.MustRun( out := cmdtest.MustRun(
t, newRoot, "dial", "--url", url, "--message", ping, t, newRoot, "dial", "--http-upgrade", "--url", url, "--message", ping,
) )
cmdtest.Contains( cmdtest.Contains(
t, t,
@@ -76,7 +76,7 @@ func TestDialPingFlag(t *testing.T) {
cmdtest.Setup(t, "") cmdtest.Setup(t, "")
_, url := wsServer(t) _, url := wsServer(t)
out := cmdtest.MustRun(t, newRoot, "dial", "--url", url, "--ping") out := cmdtest.MustRun(t, newRoot, "dial", "--http-upgrade", "--url", url, "--ping")
cmdtest.Contains( cmdtest.Contains(
t, t,
out, out,
@@ -111,7 +111,7 @@ func TestDialPingTimeout(t *testing.T) {
t, t,
newRoot, newRoot,
"", "",
"dial", "--url", url, "--ping", "--timeout", "100ms", "dial", "--http-upgrade", "--url", url, "--ping", "--timeout", "100ms",
) )
cmdtest.ErrContains(t, err, "no pong within 100ms") cmdtest.ErrContains(t, err, "no pong within 100ms")
} }
@@ -124,7 +124,7 @@ func TestDialMessages(t *testing.T) {
out := cmdtest.MustRun( out := cmdtest.MustRun(
t, t,
newRoot, newRoot,
"dial", "--url", url, "dial", "--http-upgrade", "--url", url,
"--message", `{"type":"echo","id":"7","payload":{"a":1}}`, "--message", `{"type":"echo","id":"7","payload":{"a":1}}`,
"--message", `{"type":"nonsense","id":"8"}`, "--message", `{"type":"nonsense","id":"8"}`,
) )
@@ -145,7 +145,7 @@ func TestDialOrigin(t *testing.T) {
t, t,
newRoot, newRoot,
"", "",
"dial", "--url", url, "--message", ping, "dial", "--http-upgrade", "--url", url, "--message", ping,
"--origin", "https://evil.example", "--origin", "https://evil.example",
) )
cmdtest.ErrContains(t, err, "upgrade refused: 403 Forbidden") cmdtest.ErrContains(t, err, "upgrade refused: 403 Forbidden")
@@ -153,7 +153,7 @@ func TestDialOrigin(t *testing.T) {
out := cmdtest.MustRun( out := cmdtest.MustRun(
t, t,
newRoot, newRoot,
"dial", "--url", url, "--message", ping, "dial", "--http-upgrade", "--url", url, "--message", ping,
"--origin", "https://admin.example.com", "--origin", "https://admin.example.com",
) )
cmdtest.Contains(t, out, "connected (101 Switching Protocols)") cmdtest.Contains(t, out, "connected (101 Switching Protocols)")
+115
View File
@@ -0,0 +1,115 @@
package ws
import (
"bufio"
"context"
"errors"
"io"
"net"
"strings"
"time"
"github.com/coder/websocket"
)
// maxDirectLineBytes is the longest line a directConn reads.
const maxDirectLineBytes = 1 << 20
// errBinaryMessage is returned for a binary message, which a directConn
// can't send.
var errBinaryMessage = errors.New("only text messages can be sent directly")
// directConn is a plain TCP connection to ws-test serve, without an HTTP
// upgrade: each message is one line of JSON in both directions (see
// websocket.Hub.ServeTCP). It has the methods dial uses of a
// *websocket.Conn.
type directConn struct {
conn net.Conn
scanner *bufio.Scanner
}
// dialDirect connects to addr, giving up when ctx is done.
func dialDirect(ctx context.Context, addr string) (*directConn, error) {
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(conn)
scanner.Buffer(nil, maxDirectLineBytes)
return &directConn{
conn: conn,
scanner: scanner,
}, nil
}
// Read returns the next line as a text message, skipping the blank lines the
// server sends to check the connection. Cancelling ctx interrupts it. The
// server closing the connection is reported as io.EOF.
func (d *directConn) Read(ctx context.Context) (websocket.MessageType, []byte, error) {
stop := context.AfterFunc(ctx, func() {
d.conn.SetReadDeadline(time.Now()) //nolint:errcheck,gosec // only interrupts the read
})
defer stop()
for d.scanner.Scan() {
line := strings.TrimSuffix(d.scanner.Text(), "\r")
if strings.TrimSpace(line) == "" {
continue
}
return websocket.MessageText, []byte(line), nil
}
err := d.scanner.Err()
switch {
case ctx.Err() != nil:
return 0, nil, ctx.Err()
case err == nil:
return 0, nil, io.EOF
}
return 0, nil, err
}
// Write sends payload as one line. Cancelling ctx interrupts it.
func (d *directConn) Write(
ctx context.Context,
messageType websocket.MessageType,
payload []byte,
) error {
if messageType != websocket.MessageText {
return errBinaryMessage
}
stop := context.AfterFunc(ctx, func() {
d.conn.SetWriteDeadline(time.Now()) //nolint:errcheck,gosec // only interrupts the write
})
defer stop()
line := make([]byte, 0, len(payload)+1)
line = append(append(line, payload...), '\n')
_, err := d.conn.Write(line)
if err != nil && ctx.Err() != nil {
return ctx.Err()
}
return err
}
// Close closes the connection. Plain TCP has no close handshake, so code and
// reason aren't sent.
func (d *directConn) Close(code websocket.StatusCode, reason string) error {
return d.CloseNow()
}
// CloseNow closes the connection. Closing it again isn't an error.
func (d *directConn) CloseNow() error {
err := d.conn.Close()
if errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
+229
View File
@@ -0,0 +1,229 @@
package ws
import (
"context"
"errors"
"io"
"log/slog"
"net"
"testing"
"time"
coder "github.com/coder/websocket"
"git.avsdev.uk/Go/job-search/internal/cli/cmdtest"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/websocket"
)
// directServer serves a hub's plain TCP clients on a local port, as ws-test
// serve does, and returns the hub and its address.
func directServer(t *testing.T) (*websocket.Hub, string) {
t.Helper()
hub := websocket.NewHub(
config.JSCServerWebSocket{
WriteTimeout: time.Second,
},
websocket.WithLogger(slog.New(slog.DiscardHandler)),
)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go hub.ServeTCP(ctx, conn)
}
}()
t.Cleanup(func() {
listener.Close()
shutdownCtx, cancelShutdown := context.WithTimeout(
context.Background(), time.Second,
)
defer cancelShutdown()
hub.Shutdown(shutdownCtx)
cancel()
})
return hub, listener.Addr().String()
}
// With --direct, --ping and --message work over plain TCP as they do over an
// HTTP upgrade.
func TestDialDirect(t *testing.T) {
cmdtest.Setup(t, "")
_, addr := directServer(t)
out := cmdtest.MustRun(
t,
newRoot,
"dial", "--direct", "--addr", addr, "--ping",
"--message", `{"type":"echo","id":"7","payload":{"a":1}}`,
)
cmdtest.Contains(
t,
out,
"connected directly to "+addr,
`< {"type":"welcome"`,
`> {"type":"ping","id":"ping"}`,
`< {"type":"pong","id":"ping"`,
`< {"type":"echo","id":"7","payload":{"a":1}}`,
)
}
// With --direct and --listen, pushed messages are printed until the server
// closes the connection, which has no close status.
func TestDialDirectListen(t *testing.T) {
hub, addr := directServer(t)
out := &syncBuffer{}
done := make(chan error, 1)
go func() {
done <- dial(context.Background(), nil, out, dialSettings{
direct: true,
addr: addr,
messages: []string{ping},
listen: true,
timeout: time.Second,
})
}()
waitFor(t, out, `< {"type":"pong","id":"1"`)
hub.Push("event", map[string]any{"n": 1})
waitFor(t, out, `< {"type":"event","payload":{"n":1}}`)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
hub.Shutdown(ctx)
select {
case err := <-done:
if err != nil {
t.Fatalf("dial = %v; want nil when the server closes", err)
}
case <-time.After(time.Second):
t.Fatal("dial did not return when the server closed")
}
waitFor(t, out, `"error":"server shutting down"`, "closed by the server\n")
}
// With --direct and no messages, each line of stdin is sent.
func TestDialDirectStdin(t *testing.T) {
_, addr := directServer(t)
stdin, input := io.Pipe()
out := &syncBuffer{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- dial(ctx, stdin, out, dialSettings{
direct: true,
addr: addr,
timeout: time.Second,
})
}()
io.WriteString(input, ping+"\n")
waitFor(t, out, `< {"type":"welcome"`, `< {"type":"pong","id":"1"`)
cancel() // Ctrl+C
select {
case err := <-done:
if err != nil {
t.Fatalf("dial = %v; want nil after Ctrl+C", err)
}
case <-time.After(time.Second):
t.Fatal("dial did not return after Ctrl+C")
}
}
// --direct fails clearly when nothing is listening.
func TestDialDirectRefused(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := listener.Addr().String()
listener.Close()
err = dial(context.Background(), nil, io.Discard, dialSettings{
direct: true,
addr: addr,
timeout: time.Second,
})
if err == nil {
t.Fatal("dial succeeded with nothing listening")
}
cmdtest.ErrContains(t, err, "connecting to "+addr)
}
// One of --http-upgrade and --direct is required, and each refuses the other's
// flags.
func TestDialModeFlags(t *testing.T) {
cmdtest.Setup(t, "")
tests := []struct {
name string
args []string
want string
}{
{"neither", []string{"--ping"}, "at least one of the flags"},
{"both", []string{"--http-upgrade", "--direct"}, "none of the others"},
{"direct with url", []string{"--direct", "--url", "ws://h/ws"}, "none of the others"},
{"direct with origin", []string{"--direct", "--origin", "https://o"}, "none of the others"},
{"upgrade with addr", []string{"--http-upgrade", "--addr", "h:1"}, "none of the others"},
}
for _, tt := range tests {
_, err := cmdtest.Run(t, newRoot, "", append([]string{"dial"}, tt.args...)...)
if err == nil {
t.Errorf("%s: dial was accepted", tt.name)
continue
}
cmdtest.ErrContains(t, err, tt.want)
}
}
// A directConn sends only text messages, and a closed one closes again
// without an error.
func TestDirectConn(t *testing.T) {
_, addr := directServer(t)
conn, err := dialDirect(context.Background(), addr)
if err != nil {
t.Fatal(err)
}
err = conn.Write(context.Background(), coder.MessageBinary, []byte("x"))
if !errors.Is(err, errBinaryMessage) {
t.Errorf("binary Write = %v; want errBinaryMessage", err)
}
if err := conn.CloseNow(); err != nil {
t.Errorf("CloseNow = %v", err)
}
if err := conn.CloseNow(); err != nil {
t.Errorf("second CloseNow = %v; want nil", err)
}
}
// Without --addr, --direct uses the configured host and port.
func TestDefaultAddr(t *testing.T) {
tests := []struct {
conf config.JSCServer
want string
}{
{config.JSCServer{Host: "10.0.0.1", Port: 8080}, "10.0.0.1:8080"},
{config.JSCServer{Port: 9393}, "127.0.0.1:9393"},
{config.JSCServer{Host: "::1", Port: 9393}, "[::1]:9393"},
}
for _, tt := range tests {
if got := defaultAddr(tt.conf); got != tt.want {
t.Errorf("defaultAddr(%+v) = %q; want %q", tt.conf, got, tt.want)
}
}
}