diff --git a/README.md b/README.md index 5ec670e..d6712d0 100644 --- a/README.md +++ b/README.md @@ -98,12 +98,25 @@ sources: 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 +``` + +`dial` also takes `--message`, `--listen`, `--origin` (to test the Origin +check), `--url` and `--timeout`; see `ws-test dial --help`. + + ## Project layout | Path | Contents | |---|---| | `cmd/jobsearch` | The `job-search` command | | `cmd/mock-fts` | The mock FTS server | +| `cmd/ws-test` | The `ws-test` WebSocket test tool | | `internal/cli` | Cobra commands and shared CLI setup | | `internal/config` | Configuration loading, validation and editing | | `internal/fetcher` | Runs a worker per enabled source | diff --git a/cmd/jobsearch/main.go b/cmd/jobsearch/main.go index 36fd8e5..20056ec 100644 --- a/cmd/jobsearch/main.go +++ b/cmd/jobsearch/main.go @@ -31,7 +31,6 @@ import ( "git.avsdev.uk/Go/job-search/internal/cli/migrate" "git.avsdev.uk/Go/job-search/internal/cli/serve" "git.avsdev.uk/Go/job-search/internal/cli/version" - "git.avsdev.uk/Go/job-search/internal/cli/ws" ) // newRootCmd builds the command tree. It returns a fresh tree on each call so @@ -77,7 +76,6 @@ opportunities. config.NewCommand(opts), fetch.NewCommand(opts), migrate.NewCommand(opts), - ws.NewCommand(opts), serve.NewCommand(opts), ) diff --git a/cmd/jobsearch/main_test.go b/cmd/jobsearch/main_test.go index f763dd7..6493ae4 100644 --- a/cmd/jobsearch/main_test.go +++ b/cmd/jobsearch/main_test.go @@ -18,7 +18,7 @@ func TestCommandTree(t *testing.T) { names = append(names, c.Name()) } - commands := []string{"config", "fetch", "migrate", "version", "ws"} + commands := []string{"config", "fetch", "migrate", "serve", "version"} for _, want := range commands { if !slices.Contains(names, want) { t.Errorf("job-search has no %q command; has %v", want, names) diff --git a/cmd/ws-test/main.go b/cmd/ws-test/main.go new file mode 100644 index 0000000..f04b06d --- /dev/null +++ b/cmd/ws-test/main.go @@ -0,0 +1,82 @@ +/* +Copyright © 2026 Craig Williams + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with this program. If not, see . +*/ +// Command ws-test, for testing JobSearch's WebSocket servers. Run +// `ws-test --help` for usage. +package main + +import ( + "fmt" + "log/slog" + "os" + "strings" + + "github.com/spf13/cobra" + + "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/cli/ws" +) + +// newRootCmd builds the command tree. It returns a fresh tree on each call so +// tests can run commands in isolation. +func newRootCmd() *cobra.Command { + opts := &cli.Options{} + + root := &cobra.Command{ + Use: "ws-test", + Short: "WebSocket test tools for JobSearch", + Long: `ws-test runs a plain TCP server for websocket hub clients (serve), and +tests the JobSearch HTTP server's /ws endpoint (dial). Defaults come from the +JobSearch configuration. + `, + SilenceUsage: true, // don't print usage on runtime errors + SilenceErrors: true, // main prints the error once + CompletionOptions: cobra.CompletionOptions{ + HiddenDefaultCmd: true, + }, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + cli.SetupInitialLogging(cmd) + + slog.Debug("command persistent pre-run", "command", "root") + slog.Debug( + "command", + "path", strings.Join(strings.Split(cmd.CommandPath(), " ")[1:], " "), + ) + + return nil + }, + PersistentPostRunE: func(cmd *cobra.Command, args []string) error { + slog.Debug("command persistent post-run", "command", "root") + + return opts.Finish() + }, + } + + cobra.EnableTraverseRunHooks = true + + opts.Bind(root) + + root.AddCommand(ws.NewCommands(opts)...) + + return root +} + +func main() { + if err := newRootCmd().Execute(); err != nil { + fmt.Fprintln(os.Stderr, "ws-test:", err) + os.Exit(1) + } +} diff --git a/cmd/ws-test/main_test.go b/cmd/ws-test/main_test.go new file mode 100644 index 0000000..d0c6202 --- /dev/null +++ b/cmd/ws-test/main_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "slices" + "testing" +) + +// The commands are tested in internal/cli/ws. This test checks newRootCmd +// puts them together. + +func TestCommandTree(t *testing.T) { + root := newRootCmd() + + var names []string + for _, c := range root.Commands() { + names = append(names, c.Name()) + } + + for _, want := range []string{"dial", "serve"} { + if !slices.Contains(names, want) { + t.Errorf("ws-test has no %q command; has %v", want, names) + } + } + + for _, flag := range []string{"config", "debug", "quiet"} { + if root.PersistentFlags().Lookup(flag) == nil { + t.Errorf("ws-test has no --%s flag", flag) + } + } +} diff --git a/internal/cli/ws/command.go b/internal/cli/ws/command.go index 386d438..5945a37 100644 --- a/internal/cli/ws/command.go +++ b/internal/cli/ws/command.go @@ -1,30 +1,17 @@ -// Package ws holds the "ws" command: "ws serve" runs the plain TCP server for -// websocket hub clients, and "ws dial" tests the HTTP server's /ws endpoint. +// Package ws holds the ws-test commands: "serve" runs the plain TCP server +// for websocket hub clients, and "dial" tests the HTTP server's /ws endpoint. package ws import ( - "log/slog" - "github.com/spf13/cobra" "git.avsdev.uk/Go/job-search/internal/cli" ) -// NewCommand returns the "ws" command, for the WebSocket servers. -func NewCommand(opts *cli.Options) *cobra.Command { - cmd := &cobra.Command{ - Use: "ws", - Short: "WebSocket servers and tools", - PersistentPreRun: func(cmd *cobra.Command, args []string) { - slog.Debug("command persistent pre-run", "command", "ws") - }, - PersistentPostRun: func(cmd *cobra.Command, args []string) { - slog.Debug("command persistent post-run", "command", "ws") - }, +// NewCommands returns the ws-test commands, "serve" and "dial". +func NewCommands(opts *cli.Options) []*cobra.Command { + return []*cobra.Command{ + commandServe(opts), + commandDial(opts), } - - cmd.AddCommand(commandServe(opts)) - cmd.AddCommand(commandDial(opts)) - - return cmd } diff --git a/internal/cli/ws/dial.go b/internal/cli/ws/dial.go index 1556868..cab513f 100644 --- a/internal/cli/ws/dial.go +++ b/internal/cli/ws/dial.go @@ -40,7 +40,7 @@ type dialSettings struct { timeout time.Duration } -// commandDial returns the "ws dial" command, which opens a WebSocket on the +// commandDial returns the "dial" command, which opens a WebSocket on the // HTTP server's /ws path, sends messages and prints the replies. func commandDial(opts *cli.Options) *cobra.Command { cmd := &cobra.Command{ @@ -67,14 +67,14 @@ 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.`, PersistentPreRun: func(cmd *cobra.Command, args []string) { - slog.Debug("command persistent pre-run", "command", "ws.dial") + slog.Debug("command persistent pre-run", "command", "dial") }, PersistentPostRun: func(cmd *cobra.Command, args []string) { - slog.Debug("command persistent post-run", "command", "ws.dial") + slog.Debug("command persistent post-run", "command", "dial") }, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - slog.Debug("command run", "command", "ws.dial") + slog.Debug("command run", "command", "dial") settings, err := dialFlags(cmd) if err != nil { diff --git a/internal/cli/ws/dial_test.go b/internal/cli/ws/dial_test.go index 8a26804..2067f3b 100644 --- a/internal/cli/ws/dial_test.go +++ b/internal/cli/ws/dial_test.go @@ -24,7 +24,7 @@ import ( func newRoot() *cobra.Command { opts := &cli.Options{} - return cmdtest.NewRoot(opts, NewCommand(opts)) + return cmdtest.NewRoot(opts, NewCommands(opts)...) } // wsServer serves a hub that trusts https://admin.example.com on a test HTTP @@ -59,7 +59,7 @@ func TestDialPing(t *testing.T) { _, url := wsServer(t) out := cmdtest.MustRun( - t, newRoot, "ws", "dial", "--url", url, "--message", ping, + t, newRoot, "dial", "--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, "ws", "dial", "--url", url, "--ping") + out := cmdtest.MustRun(t, newRoot, "dial", "--url", url, "--ping") cmdtest.Contains( t, out, @@ -111,7 +111,7 @@ func TestDialPingTimeout(t *testing.T) { t, newRoot, "", - "ws", "dial", "--url", url, "--ping", "--timeout", "100ms", + "dial", "--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, - "ws", "dial", "--url", url, + "dial", "--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, "", - "ws", "dial", "--url", url, "--message", ping, + "dial", "--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, - "ws", "dial", "--url", url, "--message", ping, + "dial", "--url", url, "--message", ping, "--origin", "https://admin.example.com", ) cmdtest.Contains(t, out, "connected (101 Switching Protocols)") diff --git a/internal/cli/ws/serve.go b/internal/cli/ws/serve.go index af2d32d..16b343c 100644 --- a/internal/cli/ws/serve.go +++ b/internal/cli/ws/serve.go @@ -18,20 +18,20 @@ import ( "git.avsdev.uk/Go/job-search/internal/websocket/tcpserver" ) -// commandServe returns the "ws serve" command, which runs a plain TCP server +// commandServe returns the "serve" command, which runs a plain TCP server // for websocket hub clients (see tcpserver). func commandServe(opts *cli.Options) *cobra.Command { cmd := &cobra.Command{ Use: "serve", Short: "Run JobSearch TCP Websocket server", PersistentPreRun: func(cmd *cobra.Command, args []string) { - slog.Debug("command persistent pre-run", "command", "ws.serve") + slog.Debug("command persistent pre-run", "command", "serve") }, PersistentPostRun: func(cmd *cobra.Command, args []string) { - slog.Debug("command persistent post-run", "command", "ws.serve") + slog.Debug("command persistent post-run", "command", "serve") }, RunE: func(cmd *cobra.Command, args []string) error { - slog.Debug("command run", "command", "ws.serve") + slog.Debug("command run", "command", "serve") addrOverride, _ := cmd.Flags().GetString("listen") hostOverride, _ := cmd.Flags().GetString("host")