From f0c171c8a80fc3a720ca28f1381fcd721e6f7895 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Fri, 16 Oct 2026 09:47:38 +0100 Subject: [PATCH] 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. --- README.md | 19 ++- internal/cli/ws/dial.go | 191 ++++++++++++++++++++------- internal/cli/ws/dial_test.go | 12 +- internal/cli/ws/direct.go | 115 +++++++++++++++++ internal/cli/ws/direct_test.go | 229 +++++++++++++++++++++++++++++++++ 5 files changed, 506 insertions(+), 60 deletions(-) create mode 100644 internal/cli/ws/direct.go create mode 100644 internal/cli/ws/direct_test.go diff --git a/README.md b/README.md index 0c70f9c..7d316d3 100644 --- a/README.md +++ b/README.md @@ -110,13 +110,22 @@ See [internal/mock](internal/mock/README.md) for its options and behaviour. ## WebSocket test tool `ws-test` reads the same configuration as `job-search` for its defaults: ```bash -go run ./cmd/ws-test dial --ping # ping the HTTP server's /ws endpoint -go run ./cmd/ws-test dial # send each line of stdin, print replies -go run ./cmd/ws-test serve # plain TCP server for websocket hub clients +go run ./cmd/ws-test dial --http-upgrade --ping # ping job-search serve's /ws endpoint +go run ./cmd/ws-test dial --direct --ping # ping ws-test serve +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 -check), `--url` and `--timeout`; see `ws-test dial --help`. +`dial` needs one of `--http-upgrade`, to upgrade an HTTP connection to a +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 diff --git a/internal/cli/ws/dial.go b/internal/cli/ws/dial.go index cab513f..462ed28 100644 --- a/internal/cli/ws/dial.go +++ b/internal/cli/ws/dial.go @@ -30,9 +30,13 @@ import ( // carries back. 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 { + direct bool url string + addr string origin string messages []string ping bool @@ -40,14 +44,33 @@ type dialSettings struct { timeout time.Duration } -// commandDial returns the "dial" command, which opens a WebSocket on the -// HTTP server's /ws path, sends messages and prints the replies. +// connection is what dial talks to: a *websocket.Conn with --http-upgrade, +// 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 { cmd := &cobra.Command{ Use: "dial", - Short: "Open a WebSocket on /ws, send messages and print the replies", - Long: `Upgrade an HTTP connection to a WebSocket on the server's /ws path and print -every message the server sends, starting with its welcome. + Short: "Connect to a WebSocket hub, send messages and print the replies", + Long: `Connect to a server's WebSocket hub and print every message the server sends, +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 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 of stdin stops the sending, but the connection stays open. -The URL defaults to the configured server.host and server.port (wss:// if -server.tls.enabled is set). Set --origin to test the 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.`, +With --http-upgrade, the URL defaults to the configured server.host and +server.port (wss:// if server.tls.enabled is set). Set --origin to test the +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. + +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) { 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 } - 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) if err != nil { 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. @@ -100,15 +131,34 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`, } 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( "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( "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( "message", @@ -129,8 +179,13 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`, flags.Duration( "timeout", 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 } @@ -139,7 +194,9 @@ server.csrf.cross_origin_protection.trusted_origins is refused with 403.`, // commandDial, so getting them can't fail. func dialFlags(cmd *cobra.Command) (dialSettings, error) { flags := cmd.Flags() + direct, directErr := flags.GetBool("direct") url, urlErr := flags.GetString("url") + addr, addrErr := flags.GetString("addr") origin, originErr := flags.GetString("origin") messages, messagesErr := flags.GetStringArray("message") ping, pingErr := flags.GetBool("ping") @@ -147,7 +204,9 @@ func dialFlags(cmd *cobra.Command) (dialSettings, error) { timeout, timeoutErr := flags.GetDuration("timeout") err := errors.Join( + directErr, urlErr, + addrErr, originErr, messagesErr, pingErr, @@ -159,7 +218,9 @@ func dialFlags(cmd *cobra.Command) (dialSettings, error) { } return dialSettings{ + direct: direct, url: url, + addr: addr, origin: origin, messages: messages, 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:// -// with TLS, on its host and port. An empty host (every interface) is dialled -// as 127.0.0.1. +// with TLS, at defaultAddr. func defaultURL(conf config.JSCServer) string { scheme := "ws" if conf.TLS.Enabled { 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 if host == "" { host = "127.0.0.1" } - address := net.JoinHostPort(host, strconv.Itoa(conf.Port)) - - return scheme + "://" + address + "/ws" + return net.JoinHostPort(host, strconv.Itoa(conf.Port)) } -// dial opens a WebSocket at settings.url and prints the messages received -// to out, each after "<". It sends each of settings.messages, printed after -// ">", or without them each line of input (see interact), which isn't -// printed again. +// dial connects as settings choose (see connect) and prints the messages +// received to out, each after "<". It sends each of settings.messages, +// printed after ">", or without them each line of input (see interact), +// which isn't printed again. func dial( ctx context.Context, input io.Reader, out io.Writer, settings dialSettings, ) error { - header := http.Header{} - if settings.origin != "" { - header.Set("Origin", settings.origin) - } - - dialCtx, cancel := util.WithTimeout(ctx, settings.timeout) + connectCtx, cancel := util.WithTimeout(ctx, settings.timeout) defer cancel() - conn, res, err := websocket.Dial( - dialCtx, - settings.url, - &websocket.DialOptions{ - HTTPHeader: header, - }, - ) + conn, err := connect(connectCtx, out, settings) if err != nil { - if res != nil { - return fmt.Errorf("upgrade refused: %s", res.Status) - } - return fmt.Errorf("dialling %s: %w", settings.url, err) + return err } 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 { return interact(ctx, input, out, conn, settings.timeout) } @@ -260,12 +308,53 @@ func dial( 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, // printing each message received meanwhile to out. func pingPong( ctx context.Context, out io.Writer, - conn *websocket.Conn, + conn connection, timeout time.Duration, ) error { ping, err := json.Marshal(hub.Envelope{ @@ -312,7 +401,7 @@ func interact( ctx context.Context, input io.Reader, out io.Writer, - conn *websocket.Conn, + conn connection, timeout time.Duration, ) error { listening := make(chan error, 1) @@ -366,7 +455,7 @@ func interact( // send writes message to conn as a text frame, waiting at most timeout. func send( ctx context.Context, - conn *websocket.Conn, + conn connection, message string, timeout time.Duration, ) error { @@ -386,7 +475,7 @@ func send( func receive( ctx context.Context, out io.Writer, - conn *websocket.Conn, + conn connection, timeout time.Duration, ) error { 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 // (Ctrl+C) or the server closes the connection, either of which is a normal // 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 { _, data, err := conn.Read(ctx) status := websocket.CloseStatus(err) @@ -412,8 +501,12 @@ func listen(ctx context.Context, out io.Writer, conn *websocket.Conn) error { case err == nil: fmt.Fprintf(out, "< %s\n", data) //nolint:errcheck // printing only 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 + 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: fmt.Fprintf(out, "closed by the server (%d)\n", status) //nolint:errcheck // printing only return nil diff --git a/internal/cli/ws/dial_test.go b/internal/cli/ws/dial_test.go index 2067f3b..6c4c99e 100644 --- a/internal/cli/ws/dial_test.go +++ b/internal/cli/ws/dial_test.go @@ -59,7 +59,7 @@ func TestDialPing(t *testing.T) { _, url := wsServer(t) out := cmdtest.MustRun( - t, newRoot, "dial", "--url", url, "--message", ping, + t, newRoot, "dial", "--http-upgrade", "--url", url, "--message", ping, ) cmdtest.Contains( t, @@ -76,7 +76,7 @@ func TestDialPingFlag(t *testing.T) { cmdtest.Setup(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( t, out, @@ -111,7 +111,7 @@ func TestDialPingTimeout(t *testing.T) { t, newRoot, "", - "dial", "--url", url, "--ping", "--timeout", "100ms", + "dial", "--http-upgrade", "--url", url, "--ping", "--timeout", "100ms", ) cmdtest.ErrContains(t, err, "no pong within 100ms") } @@ -124,7 +124,7 @@ func TestDialMessages(t *testing.T) { out := cmdtest.MustRun( t, newRoot, - "dial", "--url", url, + "dial", "--http-upgrade", "--url", url, "--message", `{"type":"echo","id":"7","payload":{"a":1}}`, "--message", `{"type":"nonsense","id":"8"}`, ) @@ -145,7 +145,7 @@ func TestDialOrigin(t *testing.T) { t, newRoot, "", - "dial", "--url", url, "--message", ping, + "dial", "--http-upgrade", "--url", url, "--message", ping, "--origin", "https://evil.example", ) cmdtest.ErrContains(t, err, "upgrade refused: 403 Forbidden") @@ -153,7 +153,7 @@ func TestDialOrigin(t *testing.T) { out := cmdtest.MustRun( t, newRoot, - "dial", "--url", url, "--message", ping, + "dial", "--http-upgrade", "--url", url, "--message", ping, "--origin", "https://admin.example.com", ) cmdtest.Contains(t, out, "connected (101 Switching Protocols)") diff --git a/internal/cli/ws/direct.go b/internal/cli/ws/direct.go new file mode 100644 index 0000000..c4b3c94 --- /dev/null +++ b/internal/cli/ws/direct.go @@ -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 +} diff --git a/internal/cli/ws/direct_test.go b/internal/cli/ws/direct_test.go new file mode 100644 index 0000000..ce745fc --- /dev/null +++ b/internal/cli/ws/direct_test.go @@ -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) + } + } +}