Files
CLIProxyAPI/internal/pluginhost/command_line_test.go
Luis Pater d625caddd9 feat(pluginhost): add capabilities for command-line flag handling and plugin execution
- Implemented command-line flag registration and execution for plugins with priority-based conflict resolution.
- Enabled plugin-owned command-line flag execution and persistence of plugin-auth data.
- Added new `Host` methods to support command-line capabilities, including flag normalization, validation, and execution state management.
- Introduced unit tests to ensure coverage for command-line plugin functionality, including auth data persistence.
- Updated configs to normalize plugins during initialization.
2026-06-06 18:35:17 +08:00

213 lines
7.2 KiB
Go

package pluginhost
import (
"bytes"
"context"
"flag"
"path/filepath"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestRegisterCommandLineFlagsSkipsNativeAndUsesPriority(t *testing.T) {
flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
flagSet.SetOutput(&bytes.Buffer{})
flagSet.Bool("native", false, "native flag")
high := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{
{Name: "native", Type: "bool", Usage: "conflicting native flag"},
{Name: "help", Type: "bool", Usage: "reserved help flag"},
{Name: "h", Type: "bool", Usage: "reserved short help flag"},
{Name: "shared", Type: "string", Usage: "shared flag"},
},
}
low := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{
{Name: "shared", Type: "string", Usage: "lower priority shared flag"},
{Name: "low-only", Type: "int", Usage: "low priority flag"},
},
}
host := newHostWithRecords(
capabilityRecord{id: "low", priority: 1, plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: low}}},
capabilityRecord{id: "high", priority: 10, plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: high}}},
)
host.RegisterCommandLineFlags(context.Background(), flagSet)
if flagSet.Lookup("native") == nil {
t.Fatal("native flag missing")
}
if flagSet.Lookup("shared") == nil {
t.Fatal("shared plugin flag missing")
}
if flagSet.Lookup("low-only") == nil {
t.Fatal("low-only plugin flag missing")
}
if got := host.commandLineFlags["shared"].pluginID; got != "high" {
t.Fatalf("shared owner = %q, want high", got)
}
if _, exists := host.commandLineFlags["native"]; exists {
t.Fatal("native flag was claimed by plugin")
}
if _, exists := host.commandLineFlags["help"]; exists {
t.Fatal("reserved help flag was claimed by plugin")
}
if _, exists := host.commandLineFlags["h"]; exists {
t.Fatal("reserved h flag was claimed by plugin")
}
}
func TestExecuteCommandLinePassesAllArgsAndTriggeredFlags(t *testing.T) {
flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
flagSet.SetOutput(&bytes.Buffer{})
plugin := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{{
Name: "plugin-command",
Type: "bool",
}},
}
host := newHostWithRecords(capabilityRecord{
id: "alpha",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: plugin}},
})
host.runtimeConfig = &config.Config{AuthDir: "/tmp/plugin-auth"}
host.RegisterCommandLineFlags(context.Background(), flagSet)
if errParse := flagSet.Parse([]string{"-plugin-command", "tail"}); errParse != nil {
t.Fatalf("Parse() error = %v", errParse)
}
if !host.HasTriggeredCommandLineFlags() {
t.Fatal("HasTriggeredCommandLineFlags() = false, want true")
}
exitCode, handled := host.ExecuteCommandLine(context.Background(), "cliproxy", []string{"-plugin-command", "tail"}, "/tmp/config.yaml", flagSet)
if !handled {
t.Fatal("ExecuteCommandLine() handled = false, want true")
}
if exitCode != 0 {
t.Fatalf("ExecuteCommandLine() exitCode = %d, want 0", exitCode)
}
if len(plugin.execRequests) != 1 {
t.Fatalf("execute calls = %d, want 1", len(plugin.execRequests))
}
req := plugin.execRequests[0]
if req.Program != "cliproxy" || req.ConfigPath != "/tmp/config.yaml" {
t.Fatalf("execution request = %#v, want program and config path", req)
}
if req.Host.AuthDir != "/tmp/plugin-auth" {
t.Fatalf("execution request host = %#v, want auth dir", req.Host)
}
if len(req.Args) != 2 || req.Args[0] != "-plugin-command" || req.Args[1] != "tail" {
t.Fatalf("Args = %#v, want full args", req.Args)
}
if got := req.TriggeredFlags["plugin-command"]; !got.Set || got.Value != "true" {
t.Fatalf("TriggeredFlags[plugin-command] = %#v, want set true", got)
}
}
func TestExecuteCommandLinePersistsReturnedAuths(t *testing.T) {
authDir := t.TempDir()
store := &commandLineAuthStore{}
origStore := sdkAuth.GetTokenStore()
sdkAuth.RegisterTokenStore(store)
defer sdkAuth.RegisterTokenStore(origStore)
flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
flagSet.SetOutput(&bytes.Buffer{})
plugin := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{{
Name: "plugin-login",
Type: "bool",
}},
response: pluginapi.CommandLineExecutionResponse{
Stdout: []byte("login ok\n"),
Auths: []pluginapi.AuthData{{
Provider: "Qoder",
ID: "qoder.json",
FileName: "qoder.json",
Label: "Luis",
StorageJSON: []byte(`{"token":"secret"}`),
}},
},
}
host := newHostWithRecords(capabilityRecord{
id: "qoder",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: plugin}},
})
host.runtimeConfig = &config.Config{AuthDir: authDir}
host.RegisterCommandLineFlags(context.Background(), flagSet)
if errParse := flagSet.Parse([]string{"-plugin-login"}); errParse != nil {
t.Fatalf("Parse() error = %v", errParse)
}
exitCode, handled := host.ExecuteCommandLine(context.Background(), "cliproxy", []string{"-plugin-login"}, "/tmp/config.yaml", flagSet)
if !handled {
t.Fatal("ExecuteCommandLine() handled = false, want true")
}
if exitCode != 0 {
t.Fatalf("ExecuteCommandLine() exitCode = %d, want 0", exitCode)
}
if store.baseDir != authDir {
t.Fatalf("store baseDir = %q, want %q", store.baseDir, authDir)
}
if len(store.saved) != 1 {
t.Fatalf("saved auths = %d, want 1", len(store.saved))
}
saved := store.saved[0]
if saved.Provider != "qoder" || saved.ID != "qoder.json" || saved.FileName != "qoder.json" {
t.Fatalf("saved auth = %#v, want normalized qoder auth", saved)
}
if saved.Storage == nil {
t.Fatal("saved auth storage = nil, want plugin token storage")
}
if store.paths[0] != filepath.Join(authDir, "qoder.json") {
t.Fatalf("saved path = %q, want auth dir path", store.paths[0])
}
}
type commandLinePluginDouble struct {
flags []pluginapi.CommandLineFlag
execRequests []pluginapi.CommandLineExecutionRequest
response pluginapi.CommandLineExecutionResponse
}
func (p *commandLinePluginDouble) RegisterCommandLine(context.Context, pluginapi.CommandLineRegistrationRequest) (pluginapi.CommandLineRegistrationResponse, error) {
return pluginapi.CommandLineRegistrationResponse{Flags: p.flags}, nil
}
func (p *commandLinePluginDouble) ExecuteCommandLine(ctx context.Context, req pluginapi.CommandLineExecutionRequest) (pluginapi.CommandLineExecutionResponse, error) {
p.execRequests = append(p.execRequests, req)
return p.response, nil
}
type commandLineAuthStore struct {
baseDir string
saved []*coreauth.Auth
paths []string
}
func (s *commandLineAuthStore) List(context.Context) ([]*coreauth.Auth, error) {
return nil, nil
}
func (s *commandLineAuthStore) Save(_ context.Context, auth *coreauth.Auth) (string, error) {
s.saved = append(s.saved, auth.Clone())
path := filepath.Join(s.baseDir, auth.FileName)
s.paths = append(s.paths, path)
return path, nil
}
func (s *commandLineAuthStore) Delete(context.Context, string) error {
return nil
}
func (s *commandLineAuthStore) SetBaseDir(dir string) {
s.baseDir = dir
}