From d59a7c68947440562bf6614725544e15009aaa6b Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 14 Apr 2026 14:07:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20[EPIC-GW-02A]=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=BD=91=E5=85=B3=E5=85=A5=E5=8F=A3=E8=87=B3=20neocode=20?= =?UTF-8?q?=E5=AD=90=E5=91=BD=E4=BB=A4=E4=BD=93=E7=B3=BB=20(Single=20Binar?= =?UTF-8?q?y=20=E6=94=B6=E6=95=9B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++- cmd/neocode-gateway/main.go | 87 -------------------- cmd/neocode-gateway/main_test.go | 89 --------------------- internal/cli/gateway_commands.go | 133 +++++++++++++++++++++++++++++++ internal/cli/root.go | 4 + internal/cli/root_test.go | 98 +++++++++++++++++++++++ 6 files changed, 245 insertions(+), 178 deletions(-) delete mode 100644 cmd/neocode-gateway/main.go delete mode 100644 cmd/neocode-gateway/main_test.go create mode 100644 internal/cli/gateway_commands.go diff --git a/README.md b/README.md index 43c5a21b..a33962f9 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,20 @@ cd neo-code go run ./cmd/neocode ``` -Gateway 独立进程(Step 1 骨架): +Gateway 子命令(Step 1 骨架): ```bash -go run ./cmd/neocode-gateway +go run ./cmd/neocode gateway ``` +URL Scheme 派发骨架命令(EPIC-GW-02A): + +```bash +go run ./cmd/neocode url-dispatch --url "neocode://review?path=README.md" +``` + +> `url-dispatch` 当前仅提供参数与命令骨架,实际派发能力在 EPIC-GW-02 实现。 + 设置 API Key 示例(按你使用的 provider 选择): ```bash diff --git a/cmd/neocode-gateway/main.go b/cmd/neocode-gateway/main.go deleted file mode 100644 index cb231449..00000000 --- a/cmd/neocode-gateway/main.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "log" - "os" - "os/signal" - "strings" - "syscall" - - "neo-code/internal/gateway" -) - -const ( - defaultLogLevel = "info" -) - -var errHelpRequested = errors.New("help requested") - -// main 负责启动 Gateway 独立进程,并在收到系统信号时优雅退出。 -func main() { - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "neocode-gateway: %v\n", err) - os.Exit(1) - } -} - -// run 解析启动参数并驱动网关服务生命周期。 -func run() error { - listenAddress, logLevel, err := parseFlags() - if err != nil { - if errors.Is(err, errHelpRequested) { - return nil - } - return err - } - - logger := log.New(os.Stderr, "neocode-gateway: ", log.LstdFlags) - logger.Printf("starting gateway (log-level=%s)", logLevel) - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - server, err := gateway.NewServer(gateway.ServerOptions{ - ListenAddress: listenAddress, - Logger: logger, - }) - if err != nil { - return err - } - defer func() { - _ = server.Close(context.Background()) - }() - - logger.Printf("gateway listen address: %s", server.ListenAddress()) - return server.Serve(ctx, nil) -} - -// parseFlags 解析命令行参数并执行基础校验。 -func parseFlags() (listenAddress string, logLevel string, err error) { - fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) - fs.SetOutput(os.Stdout) - - var listen string - var level string - fs.StringVar(&listen, "listen", "", "gateway listen address (optional override)") - fs.StringVar(&level, "log-level", defaultLogLevel, "gateway log level: debug|info|warn|error") - - if parseErr := fs.Parse(os.Args[1:]); parseErr != nil { - if errors.Is(parseErr, flag.ErrHelp) { - return "", "", errHelpRequested - } - return "", "", parseErr - } - - normalizedLevel := strings.ToLower(strings.TrimSpace(level)) - switch normalizedLevel { - case "debug", "info", "warn", "error": - default: - return "", "", fmt.Errorf("invalid --log-level %q: must be debug|info|warn|error", level) - } - - return strings.TrimSpace(listen), normalizedLevel, nil -} diff --git a/cmd/neocode-gateway/main_test.go b/cmd/neocode-gateway/main_test.go deleted file mode 100644 index 6f706e3d..00000000 --- a/cmd/neocode-gateway/main_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "errors" - "flag" - "os" - "strings" - "testing" -) - -func TestParseFlagsValid(t *testing.T) { - withArgs(t, []string{"neocode-gateway", "--listen", " /tmp/gateway.sock ", "--log-level", " WARN "}, func() { - listen, level, err := parseFlags() - if err != nil { - t.Fatalf("parse flags: %v", err) - } - if listen != "/tmp/gateway.sock" { - t.Fatalf("listen = %q, want %q", listen, "/tmp/gateway.sock") - } - if level != "warn" { - t.Fatalf("log level = %q, want %q", level, "warn") - } - }) -} - -func TestParseFlagsHelp(t *testing.T) { - withArgs(t, []string{"neocode-gateway", "--help"}, func() { - _, _, err := parseFlags() - if !errors.Is(err, errHelpRequested) { - t.Fatalf("parse flags error = %v, want %v", err, errHelpRequested) - } - }) -} - -func TestParseFlagsInvalidLogLevel(t *testing.T) { - withArgs(t, []string{"neocode-gateway", "--log-level", "trace"}, func() { - _, _, err := parseFlags() - if err == nil { - t.Fatal("expected invalid log level error") - } - if !strings.Contains(err.Error(), "invalid --log-level") { - t.Fatalf("error = %v, want contains %q", err, "invalid --log-level") - } - }) -} - -func TestParseFlagsUnknownFlag(t *testing.T) { - withArgs(t, []string{"neocode-gateway", "--unknown"}, func() { - _, _, err := parseFlags() - if err == nil { - t.Fatal("expected parse error") - } - if errors.Is(err, flag.ErrHelp) { - t.Fatalf("error = %v, should not be help error", err) - } - }) -} - -func TestRunHelp(t *testing.T) { - withArgs(t, []string{"neocode-gateway", "--help"}, func() { - if err := run(); err != nil { - t.Fatalf("run help: %v", err) - } - }) -} - -func TestRunInvalidLogLevel(t *testing.T) { - withArgs(t, []string{"neocode-gateway", "--log-level", "trace"}, func() { - err := run() - if err == nil { - t.Fatal("expected run error") - } - if !strings.Contains(err.Error(), "invalid --log-level") { - t.Fatalf("error = %v, want contains %q", err, "invalid --log-level") - } - }) -} - -func withArgs(t *testing.T, args []string, fn func()) { - t.Helper() - - originalArgs := os.Args - os.Args = args - defer func() { - os.Args = originalArgs - }() - - fn() -} diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go new file mode 100644 index 00000000..ab092123 --- /dev/null +++ b/internal/cli/gateway_commands.go @@ -0,0 +1,133 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/spf13/cobra" + + "neo-code/internal/gateway" +) + +const ( + defaultGatewayLogLevel = "info" +) + +var ( + runGatewayCommand = defaultGatewayCommandRunner + runURLDispatchCommand = defaultURLDispatchCommandRunner +) + +type gatewayCommandOptions struct { + ListenAddress string + LogLevel string +} + +type urlDispatchCommandOptions struct { + URL string + ListenAddress string +} + +// newGatewayCommand 创建并返回网关子命令,负责启动本地 Gateway 进程。 +func newGatewayCommand() *cobra.Command { + options := &gatewayCommandOptions{} + + cmd := &cobra.Command{ + Use: "gateway", + Short: "Start local gateway server", + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + normalizedLogLevel, err := normalizeGatewayLogLevel(options.LogLevel) + if err != nil { + return err + } + + return runGatewayCommand(cmd.Context(), gatewayCommandOptions{ + ListenAddress: strings.TrimSpace(options.ListenAddress), + LogLevel: normalizedLogLevel, + }) + }, + } + + cmd.Flags().StringVar(&options.ListenAddress, "listen", "", "gateway listen address (optional override)") + cmd.Flags().StringVar(&options.LogLevel, "log-level", defaultGatewayLogLevel, "gateway log level: debug|info|warn|error") + + return cmd +} + +// normalizeGatewayLogLevel 对网关日志级别做归一化并校验合法值。 +func normalizeGatewayLogLevel(logLevel string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(logLevel)) + switch normalized { + case "debug", "info", "warn", "error": + return normalized, nil + default: + return "", fmt.Errorf("invalid --log-level %q: must be debug|info|warn|error", logLevel) + } +} + +// defaultGatewayCommandRunner 使用网关服务骨架启动本地 IPC 监听并处理信号退出。 +func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOptions) error { + logger := log.New(os.Stderr, "neocode-gateway: ", log.LstdFlags) + logger.Printf("starting gateway (log-level=%s)", options.LogLevel) + + signalContext, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + + server, err := gateway.NewServer(gateway.ServerOptions{ + ListenAddress: options.ListenAddress, + Logger: logger, + }) + if err != nil { + return err + } + defer func() { + _ = server.Close(context.Background()) + }() + + logger.Printf("gateway listen address: %s", server.ListenAddress()) + return server.Serve(signalContext, nil) +} + +// newURLDispatchCommand 创建 URL Scheme 派发子命令骨架,仅做参数收敛与调用转发。 +func newURLDispatchCommand() *cobra.Command { + options := &urlDispatchCommandOptions{} + + cmd := &cobra.Command{ + Use: "url-dispatch [url]", + Short: "Dispatch a neocode:// URL to gateway (skeleton)", + SilenceUsage: true, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + urlValue := strings.TrimSpace(options.URL) + if urlValue == "" && len(args) == 1 { + urlValue = strings.TrimSpace(args[0]) + } + if urlValue == "" { + return errors.New("missing required --url or positional ") + } + + return runURLDispatchCommand(cmd.Context(), urlDispatchCommandOptions{ + URL: urlValue, + ListenAddress: strings.TrimSpace(options.ListenAddress), + }) + }, + } + + cmd.Flags().StringVar(&options.URL, "url", "", "neocode:// URL to dispatch") + cmd.Flags().StringVar(&options.ListenAddress, "listen", "", "gateway listen address override (reserved for EPIC-GW-02)") + + return cmd +} + +// defaultURLDispatchCommandRunner 提供 url-dispatch 的默认骨架行为,明确告知后续步骤接管实现。 +func defaultURLDispatchCommandRunner(_ context.Context, _ urlDispatchCommandOptions) error { + return errors.New("url-dispatch is not implemented yet (planned in EPIC-GW-02)") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index b5b3c5dd..ba45e217 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -45,6 +45,10 @@ func NewRootCommand() *cobra.Command { cmd.PersistentFlags().String("workdir", "", "工作目录(覆盖本次运行工作区)") _ = settings.BindPFlag("workdir", cmd.PersistentFlags().Lookup("workdir")) + cmd.AddCommand( + newGatewayCommand(), + newURLDispatchCommand(), + ) return cmd } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 68760522..fe5ffd04 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "os" + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -166,6 +167,103 @@ func TestDefaultRootProgramLauncherJoinsRunAndCleanupErrors(t *testing.T) { } } +func TestGatewaySubcommandPassesFlagsToRunner(t *testing.T) { + originalRunner := runGatewayCommand + t.Cleanup(func() { runGatewayCommand = originalRunner }) + + var captured gatewayCommandOptions + runGatewayCommand = func(ctx context.Context, options gatewayCommandOptions) error { + captured = options + return nil + } + + command := NewRootCommand() + command.SetArgs([]string{"gateway", "--listen", " /tmp/gateway.sock ", "--log-level", " WARN "}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + + if captured.ListenAddress != "/tmp/gateway.sock" { + t.Fatalf("listen address = %q, want %q", captured.ListenAddress, "/tmp/gateway.sock") + } + if captured.LogLevel != "warn" { + t.Fatalf("log level = %q, want %q", captured.LogLevel, "warn") + } +} + +func TestGatewaySubcommandRejectsInvalidLogLevel(t *testing.T) { + command := NewRootCommand() + command.SetArgs([]string{"gateway", "--log-level", "trace"}) + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected invalid log level error") + } + if !strings.Contains(err.Error(), "invalid --log-level") { + t.Fatalf("error = %v, want contains %q", err, "invalid --log-level") + } +} + +func TestURLDispatchSubcommandUsesURLFlag(t *testing.T) { + originalRunner := runURLDispatchCommand + t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + + var captured urlDispatchCommandOptions + runURLDispatchCommand = func(ctx context.Context, options urlDispatchCommandOptions) error { + captured = options + return nil + } + + command := NewRootCommand() + command.SetArgs([]string{ + "url-dispatch", + "--url", " neocode://review?path=README.md ", + "--listen", " /tmp/gateway.sock ", + }) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + + if captured.URL != "neocode://review?path=README.md" { + t.Fatalf("url = %q, want %q", captured.URL, "neocode://review?path=README.md") + } + if captured.ListenAddress != "/tmp/gateway.sock" { + t.Fatalf("listen address = %q, want %q", captured.ListenAddress, "/tmp/gateway.sock") + } +} + +func TestURLDispatchSubcommandUsesPositionalURL(t *testing.T) { + originalRunner := runURLDispatchCommand + t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + + var captured urlDispatchCommandOptions + runURLDispatchCommand = func(ctx context.Context, options urlDispatchCommandOptions) error { + captured = options + return nil + } + + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch", "neocode://review?path=README.md"}) + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + + if captured.URL != "neocode://review?path=README.md" { + t.Fatalf("url = %q, want %q", captured.URL, "neocode://review?path=README.md") + } +} + +func TestURLDispatchSubcommandRejectsMissingURL(t *testing.T) { + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch"}) + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected missing url error") + } + if !strings.Contains(err.Error(), "missing required --url or positional ") { + t.Fatalf("error = %v, want missing url message", err) + } +} + type quitModel struct{} func (quitModel) Init() tea.Cmd { From 1d0db9ef8d3a6a1668a05a70572e2a49df883faf Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 14 Apr 2026 15:10:10 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E5=9C=A8=20CLI=20=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E6=96=B0=E5=A2=9E=20URL=20=E6=A0=A1=E9=AA=8C=E4=B8=8E?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BA=86=E9=BB=98=E8=AE=A4=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/gateway_commands.go | 37 ++++++- internal/cli/root_test.go | 184 +++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 2 deletions(-) diff --git a/internal/cli/gateway_commands.go b/internal/cli/gateway_commands.go index ab092123..8166c5e3 100644 --- a/internal/cli/gateway_commands.go +++ b/internal/cli/gateway_commands.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "net/url" "os" "os/signal" "strings" @@ -22,6 +23,7 @@ const ( var ( runGatewayCommand = defaultGatewayCommandRunner runURLDispatchCommand = defaultURLDispatchCommandRunner + newGatewayServer = defaultNewGatewayServer ) type gatewayCommandOptions struct { @@ -34,6 +36,12 @@ type urlDispatchCommandOptions struct { ListenAddress string } +type gatewayServer interface { + ListenAddress() string + Serve(ctx context.Context, runtimePort gateway.RuntimePort) error + Close(ctx context.Context) error +} + // newGatewayCommand 创建并返回网关子命令,负责启动本地 Gateway 进程。 func newGatewayCommand() *cobra.Command { options := &gatewayCommandOptions{} @@ -81,7 +89,7 @@ func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOpti signalContext, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() - server, err := gateway.NewServer(gateway.ServerOptions{ + server, err := newGatewayServer(gateway.ServerOptions{ ListenAddress: options.ListenAddress, Logger: logger, }) @@ -96,6 +104,11 @@ func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOpti return server.Serve(signalContext, nil) } +// defaultNewGatewayServer 创建默认网关服务实例,供命令层启动流程调用。 +func defaultNewGatewayServer(options gateway.ServerOptions) (gatewayServer, error) { + return gateway.NewServer(options) +} + // newURLDispatchCommand 创建 URL Scheme 派发子命令骨架,仅做参数收敛与调用转发。 func newURLDispatchCommand() *cobra.Command { options := &urlDispatchCommandOptions{} @@ -113,9 +126,13 @@ func newURLDispatchCommand() *cobra.Command { if urlValue == "" { return errors.New("missing required --url or positional ") } + normalizedURL, err := normalizeDispatchURL(urlValue) + if err != nil { + return err + } return runURLDispatchCommand(cmd.Context(), urlDispatchCommandOptions{ - URL: urlValue, + URL: normalizedURL, ListenAddress: strings.TrimSpace(options.ListenAddress), }) }, @@ -131,3 +148,19 @@ func newURLDispatchCommand() *cobra.Command { func defaultURLDispatchCommandRunner(_ context.Context, _ urlDispatchCommandOptions) error { return errors.New("url-dispatch is not implemented yet (planned in EPIC-GW-02)") } + +// normalizeDispatchURL 校验并标准化 url-dispatch 输入,确保只接受 neocode scheme。 +func normalizeDispatchURL(rawURL string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "", fmt.Errorf("invalid --url %q: %w", rawURL, err) + } + if !strings.EqualFold(strings.TrimSpace(parsed.Scheme), "neocode") { + return "", fmt.Errorf("invalid --url scheme %q: must be neocode", parsed.Scheme) + } + if strings.TrimSpace(parsed.Host) == "" { + return "", errors.New("invalid --url: missing action host") + } + + return parsed.String(), nil +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index fe5ffd04..8b0e802f 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "neo-code/internal/app" + "neo-code/internal/gateway" ) func TestNewRootCommandPassesWorkdirFlagToLauncher(t *testing.T) { @@ -203,6 +204,85 @@ func TestGatewaySubcommandRejectsInvalidLogLevel(t *testing.T) { } } +func TestDefaultGatewayCommandRunnerSuccess(t *testing.T) { + originalNewGatewayServer := newGatewayServer + t.Cleanup(func() { newGatewayServer = originalNewGatewayServer }) + + server := &stubGatewayServer{listenAddress: "stub://gateway"} + newGatewayServer = func(options gateway.ServerOptions) (gatewayServer, error) { + return server, nil + } + + err := defaultGatewayCommandRunner(context.Background(), gatewayCommandOptions{ + ListenAddress: "stub://gateway", + LogLevel: "info", + }) + if err != nil { + t.Fatalf("defaultGatewayCommandRunner() error = %v", err) + } + if !server.serveCalled { + t.Fatal("expected server Serve to be called") + } + if !server.closeCalled { + t.Fatal("expected server Close to be called") + } +} + +func TestDefaultGatewayCommandRunnerReturnsConstructorError(t *testing.T) { + originalNewGatewayServer := newGatewayServer + t.Cleanup(func() { newGatewayServer = originalNewGatewayServer }) + + expected := errors.New("new gateway server failed") + newGatewayServer = func(options gateway.ServerOptions) (gatewayServer, error) { + return nil, expected + } + + err := defaultGatewayCommandRunner(context.Background(), gatewayCommandOptions{ + ListenAddress: "stub://gateway", + LogLevel: "info", + }) + if !errors.Is(err, expected) { + t.Fatalf("expected constructor error %v, got %v", expected, err) + } +} + +func TestDefaultGatewayCommandRunnerReturnsServeError(t *testing.T) { + originalNewGatewayServer := newGatewayServer + t.Cleanup(func() { newGatewayServer = originalNewGatewayServer }) + + expected := errors.New("serve failed") + server := &stubGatewayServer{ + listenAddress: "stub://gateway", + serveErr: expected, + } + newGatewayServer = func(options gateway.ServerOptions) (gatewayServer, error) { + return server, nil + } + + err := defaultGatewayCommandRunner(context.Background(), gatewayCommandOptions{ + ListenAddress: "stub://gateway", + LogLevel: "info", + }) + if !errors.Is(err, expected) { + t.Fatalf("expected serve error %v, got %v", expected, err) + } + if !server.closeCalled { + t.Fatal("expected server Close to be called") + } +} + +func TestDefaultNewGatewayServer(t *testing.T) { + server, err := defaultNewGatewayServer(gateway.ServerOptions{ + ListenAddress: "stub://gateway", + }) + if err != nil { + t.Fatalf("defaultNewGatewayServer() error = %v", err) + } + if server == nil { + t.Fatal("defaultNewGatewayServer() returned nil server") + } +} + func TestURLDispatchSubcommandUsesURLFlag(t *testing.T) { originalRunner := runURLDispatchCommand t.Cleanup(func() { runURLDispatchCommand = originalRunner }) @@ -252,6 +332,30 @@ func TestURLDispatchSubcommandUsesPositionalURL(t *testing.T) { } } +func TestURLDispatchSubcommandRejectsInvalidScheme(t *testing.T) { + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch", "--url", "http://example.com"}) + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected invalid scheme error") + } + if !strings.Contains(err.Error(), `invalid --url scheme "http"`) { + t.Fatalf("error = %v, want invalid scheme message", err) + } +} + +func TestURLDispatchSubcommandRejectsMissingActionHost(t *testing.T) { + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch", "--url", "neocode://"}) + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected missing action host error") + } + if !strings.Contains(err.Error(), "missing action host") { + t.Fatalf("error = %v, want missing action host message", err) + } +} + func TestURLDispatchSubcommandRejectsMissingURL(t *testing.T) { command := NewRootCommand() command.SetArgs([]string{"url-dispatch"}) @@ -264,8 +368,88 @@ func TestURLDispatchSubcommandRejectsMissingURL(t *testing.T) { } } +func TestURLDispatchSubcommandDefaultRunnerError(t *testing.T) { + originalRunner := runURLDispatchCommand + t.Cleanup(func() { runURLDispatchCommand = originalRunner }) + runURLDispatchCommand = defaultURLDispatchCommandRunner + + command := NewRootCommand() + command.SetArgs([]string{"url-dispatch", "--url", "neocode://review?path=README.md"}) + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected default runner error") + } + if !strings.Contains(err.Error(), "planned in EPIC-GW-02") { + t.Fatalf("error = %v, want planned message", err) + } +} + +func TestNormalizeDispatchURL(t *testing.T) { + t.Run("success", func(t *testing.T) { + normalized, err := normalizeDispatchURL(" neocode://review?path=README.md ") + if err != nil { + t.Fatalf("normalizeDispatchURL() error = %v", err) + } + if normalized != "neocode://review?path=README.md" { + t.Fatalf("normalized = %q, want %q", normalized, "neocode://review?path=README.md") + } + }) + + t.Run("invalid format", func(t *testing.T) { + _, err := normalizeDispatchURL("://bad-url") + if err == nil { + t.Fatal("expected parse error") + } + if !strings.Contains(err.Error(), "invalid --url") { + t.Fatalf("error = %v, want invalid url message", err) + } + }) + + t.Run("invalid scheme", func(t *testing.T) { + _, err := normalizeDispatchURL("https://example.com") + if err == nil { + t.Fatal("expected scheme error") + } + if !strings.Contains(err.Error(), "must be neocode") { + t.Fatalf("error = %v, want scheme message", err) + } + }) + + t.Run("missing host", func(t *testing.T) { + _, err := normalizeDispatchURL("neocode://") + if err == nil { + t.Fatal("expected missing host error") + } + if !strings.Contains(err.Error(), "missing action host") { + t.Fatalf("error = %v, want missing host message", err) + } + }) +} + type quitModel struct{} +type stubGatewayServer struct { + listenAddress string + serveErr error + closeErr error + serveCalled bool + closeCalled bool +} + +func (s *stubGatewayServer) ListenAddress() string { + return s.listenAddress +} + +func (s *stubGatewayServer) Serve(_ context.Context, _ gateway.RuntimePort) error { + s.serveCalled = true + return s.serveErr +} + +func (s *stubGatewayServer) Close(_ context.Context) error { + s.closeCalled = true + return s.closeErr +} + func (quitModel) Init() tea.Cmd { return tea.Quit }