mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
Treat platform temp directories as default writable roots so file tools, shell commands, and sandbox profiles share the same temp view. On Linux, bind host temp roots instead of mounting an isolated tmpfs at /tmp so artifacts created under /tmp remain visible across tool and shell calls. Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/cli ./internal/agent
1404 lines
45 KiB
Go
1404 lines
45 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/mcp"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/tui"
|
|
"github.com/Gitlawb/zero/internal/update"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
var errWriteFailed = errors.New("write failed")
|
|
|
|
type failingWriter struct{}
|
|
|
|
func (failingWriter) Write([]byte) (int, error) {
|
|
return 0, errWriteFailed
|
|
}
|
|
|
|
func TestRunPrintsVersion(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"--version"}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
if got := stdout.String(); got != "zero dev\n" {
|
|
t.Fatalf("expected version output, got %q", got)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunPrintsHelp(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"--help"},
|
|
{"-h"},
|
|
{"help"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
assertHelpOutput(t, args)
|
|
})
|
|
}
|
|
}
|
|
|
|
func setCLIUserConfigRoot(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
root := t.TempDir()
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
t.Setenv("APPDATA", root)
|
|
case "darwin":
|
|
t.Setenv("HOME", root)
|
|
default:
|
|
t.Setenv("XDG_CONFIG_HOME", root)
|
|
}
|
|
|
|
configRoot, err := os.UserConfigDir()
|
|
if err != nil {
|
|
t.Fatalf("UserConfigDir() error = %v", err)
|
|
}
|
|
return configRoot
|
|
}
|
|
|
|
func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
setCLIUserConfigRoot(t)
|
|
projectConfigPath := filepath.Join(cwd, ".zero", "config.json")
|
|
if err := os.MkdirAll(filepath.Dir(projectConfigPath), 0o700); err != nil {
|
|
t.Fatalf("create project config parent: %v", err)
|
|
}
|
|
if err := os.WriteFile(projectConfigPath, []byte("{}"), 0o600); err != nil {
|
|
t.Fatalf("write project config: %v", err)
|
|
}
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
if workspaceRoot != cwd {
|
|
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
|
}
|
|
return config.ResolvedConfig{MaxTurns: 12}, nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
t.Fatal("newProvider should not be called without a resolved provider")
|
|
return nil, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 7
|
|
},
|
|
})
|
|
|
|
if exitCode != 7 {
|
|
t.Fatalf("expected TUI exit code 7, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
if launchedOptions.Cwd != cwd {
|
|
t.Fatalf("Cwd = %q, want %q", launchedOptions.Cwd, cwd)
|
|
}
|
|
if launchedOptions.UserConfigPath != userConfigPath {
|
|
t.Fatalf("UserConfigPath = %q, want %q", launchedOptions.UserConfigPath, userConfigPath)
|
|
}
|
|
if launchedOptions.DoctorUserConfigPath != "" {
|
|
t.Fatalf("DoctorUserConfigPath = %q, want empty for missing user config", launchedOptions.DoctorUserConfigPath)
|
|
}
|
|
if launchedOptions.ProjectConfigPath != projectConfigPath {
|
|
t.Fatalf("ProjectConfigPath = %q, want %q", launchedOptions.ProjectConfigPath, projectConfigPath)
|
|
}
|
|
if launchedOptions.Provider != nil {
|
|
t.Fatalf("Provider = %#v, want nil", launchedOptions.Provider)
|
|
}
|
|
if launchedOptions.ProviderName != "" || launchedOptions.ModelName != "" {
|
|
t.Fatalf("provider metadata = %q/%q, want empty", launchedOptions.ProviderName, launchedOptions.ModelName)
|
|
}
|
|
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
|
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
|
}
|
|
if launchedOptions.Setup.ConfigPath != userConfigPath {
|
|
t.Fatalf("Setup.ConfigPath = %q, want %q", launchedOptions.Setup.ConfigPath, userConfigPath)
|
|
}
|
|
assertCoreRegistry(t, launchedOptions.Registry)
|
|
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
|
|
}
|
|
|
|
func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
mcpConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"docs": {Type: "stdio", Command: "docs-mcp"},
|
|
}}
|
|
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewPermissionStore() error = %v", err)
|
|
}
|
|
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewTokenStore() error = %v", err)
|
|
}
|
|
var launchedOptions tui.Options
|
|
var registeredConfig config.MCPConfig
|
|
var registeredStore *mcp.PermissionStore
|
|
runtimeClosed := false
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
|
if workspaceRoot != cwd {
|
|
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
|
}
|
|
return mcpConfig, nil
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return permissionStore, nil
|
|
},
|
|
newMCPTokenStore: func() (*mcp.TokenStore, error) {
|
|
return tokenStore, nil
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
registeredConfig = cfg
|
|
registeredStore = options.PermissionStore
|
|
return closeFunc(func() error {
|
|
runtimeClosed = true
|
|
return nil
|
|
}), nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
if _, ok := registeredConfig.Servers["docs"]; !ok {
|
|
t.Fatalf("registered MCP config = %#v, want docs server", registeredConfig.Servers)
|
|
}
|
|
if registeredStore != permissionStore {
|
|
t.Fatalf("registered PermissionStore = %#v, want launched store %#v", registeredStore, permissionStore)
|
|
}
|
|
if _, ok := launchedOptions.MCPConfig.Servers["docs"]; !ok {
|
|
t.Fatalf("launched MCP config = %#v, want docs server", launchedOptions.MCPConfig.Servers)
|
|
}
|
|
if launchedOptions.MCPPermissionStore != permissionStore {
|
|
t.Fatalf("launched MCPPermissionStore = %#v, want %#v", launchedOptions.MCPPermissionStore, permissionStore)
|
|
}
|
|
if launchedOptions.MCPTokenStore != tokenStore {
|
|
t.Fatalf("launched MCPTokenStore = %#v, want %#v", launchedOptions.MCPTokenStore, tokenStore)
|
|
}
|
|
if launchedOptions.MCPCommand == nil {
|
|
t.Fatal("launched MCPCommand runner is nil")
|
|
}
|
|
if !runtimeClosed {
|
|
t.Fatal("MCP runtime was not closed after TUI exits")
|
|
}
|
|
}
|
|
|
|
func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
startupConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"docs": {Type: "stdio", Command: "docs-mcp"},
|
|
}}
|
|
refreshedConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"github": {Type: "http", URL: "https://mcp.github.example"},
|
|
}}
|
|
resolveCalls := 0
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
|
resolveCalls++
|
|
switch resolveCalls {
|
|
case 1:
|
|
return startupConfig, nil
|
|
case 2, 3:
|
|
return refreshedConfig, nil
|
|
default:
|
|
return config.MCPConfig{}, errors.New("config temporarily unavailable")
|
|
}
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
|
},
|
|
newMCPTokenStore: func() (*mcp.TokenStore, error) {
|
|
return mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return closeFunc(func() error { return nil }), nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
first := options.MCPCommand(ctx, []string{"list"})
|
|
if _, ok := first.Config.Servers["github"]; !ok {
|
|
t.Fatalf("first MCP result config = %#v, want refreshed github server", first.Config.Servers)
|
|
}
|
|
second := options.MCPCommand(ctx, []string{"list"})
|
|
if _, ok := second.Config.Servers["github"]; !ok {
|
|
t.Fatalf("second MCP result config = %#v, want refreshed github server", second.Config.Servers)
|
|
}
|
|
third := options.MCPCommand(ctx, []string{"list"})
|
|
if _, ok := third.Config.Servers["github"]; !ok {
|
|
t.Fatalf("third MCP result config = %#v, want last known github server after refresh error", third.Config.Servers)
|
|
}
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewPermissionStore() error = %v", err)
|
|
}
|
|
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewTokenStore() error = %v", err)
|
|
}
|
|
runtimeClosed := false
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
|
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"docs": {Type: "stdio", Command: "docs-mcp"},
|
|
}}, nil
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return permissionStore, nil
|
|
},
|
|
newMCPTokenStore: func() (*mcp.TokenStore, error) {
|
|
return tokenStore, nil
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return closeFunc(func() error {
|
|
runtimeClosed = true
|
|
return nil
|
|
}), errors.New("register mcp tools failed")
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
t.Fatal("TUI should not launch after MCP registration fails")
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode == 0 {
|
|
t.Fatalf("exitCode = %d, want failure", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "register mcp tools failed") {
|
|
t.Fatalf("stderr missing registration error: %s", stderr.String())
|
|
}
|
|
if !runtimeClosed {
|
|
t.Fatal("partial MCP runtime was not closed after registration error")
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsSoftFailsMCPTokenStoreInit(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewPermissionStore() error = %v", err)
|
|
}
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
|
return config.MCPConfig{}, nil
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return permissionStore, nil
|
|
},
|
|
newMCPTokenStore: func() (*mcp.TokenStore, error) {
|
|
return nil, errors.New("token store unreadable")
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
if launchedOptions.MCPTokenStore != nil {
|
|
t.Fatalf("MCPTokenStore = %#v, want nil after soft failure", launchedOptions.MCPTokenStore)
|
|
}
|
|
if !strings.Contains(stderr.String(), "warning: failed to initialize MCP OAuth tokens") {
|
|
t.Fatalf("stderr missing soft-failure warning: %s", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsLaunchesTUIWithResolvedProviderMetadata(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
fake := &cliFakeProvider{}
|
|
var launchedOptions tui.Options
|
|
var providerProfile config.ProviderProfile
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
if workspaceRoot != cwd {
|
|
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
|
}
|
|
return config.ResolvedConfig{
|
|
ActiveProvider: "work",
|
|
Provider: config.ProviderProfile{
|
|
Name: "work",
|
|
ProviderKind: config.ProviderKindOpenAI,
|
|
BaseURL: config.OpenAIBaseURL,
|
|
APIKey: "sk-test",
|
|
Model: "gpt-test",
|
|
},
|
|
Preferences: config.PreferencesConfig{FavoriteModels: []string{"qwen3-coder:480b"}},
|
|
MaxTurns: 5,
|
|
}, nil
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
providerProfile = profile
|
|
return fake, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
if providerProfile.Name != "work" || providerProfile.Model != "gpt-test" {
|
|
t.Fatalf("providerProfile = %#v, want resolved provider", providerProfile)
|
|
}
|
|
if launchedOptions.Provider != fake {
|
|
t.Fatalf("Provider = %#v, want fake provider", launchedOptions.Provider)
|
|
}
|
|
if launchedOptions.UserConfigPath != userConfigPath {
|
|
t.Fatalf("UserConfigPath = %q, want %q", launchedOptions.UserConfigPath, userConfigPath)
|
|
}
|
|
if launchedOptions.ProviderName != "work" {
|
|
t.Fatalf("ProviderName = %q, want work", launchedOptions.ProviderName)
|
|
}
|
|
if launchedOptions.ModelName != "gpt-test" {
|
|
t.Fatalf("ModelName = %q, want gpt-test", launchedOptions.ModelName)
|
|
}
|
|
if len(launchedOptions.FavoriteModels) != 1 || launchedOptions.FavoriteModels[0] != "qwen3-coder:480b" {
|
|
t.Fatalf("FavoriteModels = %#v, want qwen3-coder:480b", launchedOptions.FavoriteModels)
|
|
}
|
|
if launchedOptions.Setup.Visible {
|
|
t.Fatalf("Setup.Visible = true, want false for credentialed provider")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
assertCoreRegistry(t, launchedOptions.Registry)
|
|
assertAgentOptions(t, launchedOptions, 5, agent.PermissionModeAsk)
|
|
}
|
|
|
|
func TestRunNoArgsLaunchesTUIInAskPermissionMode(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(_ context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
// Auto only advertises PermissionAllow tools, so write_file/edit_file/bash/
|
|
// apply_patch (PermissionPrompt) would never be offered to the model. Ask
|
|
// advertises them and gates each through the permission flow.
|
|
if launchedOptions.PermissionMode != agent.PermissionModeAsk {
|
|
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeAsk)
|
|
}
|
|
if launchedOptions.AgentOptions.PermissionMode != agent.PermissionModeAsk {
|
|
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", launchedOptions.AgentOptions.PermissionMode, agent.PermissionModeAsk)
|
|
}
|
|
}
|
|
|
|
func TestRunSkipPermissionsUnsafeLaunchesTUIInUnsafeMode(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
launched := false
|
|
var launchedOptions tui.Options
|
|
|
|
// "zero --skip-permissions-unsafe" must launch the interactive TUI in unsafe
|
|
// mode (not fall through to "unknown command"). This is the only way to reach
|
|
// unsafe mode in the shell, which the "!" escape is gated behind.
|
|
exitCode := runWithDeps([]string{"--skip-permissions-unsafe"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(_ context.Context, options tui.Options) int {
|
|
launched = true
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
|
|
}
|
|
if !launched {
|
|
t.Fatal("expected the interactive TUI to launch, but runTUI was never called")
|
|
}
|
|
if launchedOptions.PermissionMode != agent.PermissionModeUnsafe {
|
|
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeUnsafe)
|
|
}
|
|
if launchedOptions.AgentOptions.PermissionMode != agent.PermissionModeUnsafe {
|
|
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", launchedOptions.AgentOptions.PermissionMode, agent.PermissionModeUnsafe)
|
|
}
|
|
}
|
|
|
|
func TestRunSkipPermissionsUnsafeRejectsTrailingPrompt(t *testing.T) {
|
|
// AUDIT-L3: a trailing prompt must be rejected loudly, not silently dropped
|
|
// while the interactive TUI launches.
|
|
var stdout, stderr bytes.Buffer
|
|
launched := false
|
|
exitCode := runWithDeps([]string{"--skip-permissions-unsafe", "fix the bug"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(context.Context, tui.Options) int { launched = true; return 0 },
|
|
})
|
|
if exitCode == 0 {
|
|
t.Fatal("a trailing prompt after --skip-permissions-unsafe must be rejected, not silently dropped")
|
|
}
|
|
if launched {
|
|
t.Fatal("the TUI must not launch when trailing args are rejected")
|
|
}
|
|
if !strings.Contains(stderr.String(), "takes no prompt") {
|
|
t.Fatalf("error should explain the no-prompt contract, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAddDirWiresExtraWriteRootIntoTUISandboxScope(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
extra := t.TempDir()
|
|
launched := false
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{"--add-dir", extra}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(_ context.Context, options tui.Options) int {
|
|
launched = true
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
|
|
}
|
|
if !launched {
|
|
t.Fatal("expected the interactive TUI to launch, but runTUI was never called")
|
|
}
|
|
if launchedOptions.AgentOptions.Sandbox == nil {
|
|
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
|
}
|
|
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
|
|
// The scope stores symlink-resolved roots (e.g. macOS /var -> /private/var),
|
|
// so compare against the resolved extra dir.
|
|
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks(%q): %v", extra, err)
|
|
}
|
|
found := false
|
|
for _, root := range roots {
|
|
if root == resolvedExtra {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("scope roots = %v, want to contain %q", roots, resolvedExtra)
|
|
}
|
|
}
|
|
|
|
func TestRunSkipPermissionsUnsafeMergesAddDirGrants(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
extra := t.TempDir()
|
|
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks(%q): %v", extra, err)
|
|
}
|
|
|
|
// --add-dir is accepted on either side of --skip-permissions-unsafe; both
|
|
// orders must reach the unsafe TUI with the extra root in scope.
|
|
for _, args := range [][]string{
|
|
{"--skip-permissions-unsafe", "--add-dir", extra},
|
|
{"--add-dir", extra, "--skip-permissions-unsafe"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(_ context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
|
|
}
|
|
if launchedOptions.PermissionMode != agent.PermissionModeUnsafe {
|
|
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeUnsafe)
|
|
}
|
|
if launchedOptions.AgentOptions.Sandbox == nil {
|
|
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
|
}
|
|
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
|
|
found := false
|
|
for _, root := range roots {
|
|
if root == resolvedExtra {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("scope roots = %v, want extra root %q", roots, resolvedExtra)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunSkipPermissionsUnsafeRejectsAddDirHiddenBehindStrayArg(t *testing.T) {
|
|
// splitLeadingAddDirFlags stops at the first non-flag token, so a grant
|
|
// placed after a stray arg would otherwise be discarded with the ignored
|
|
// trailing args. An explicit grant must never be silently dropped.
|
|
for _, args := range [][]string{
|
|
{"--skip-permissions-unsafe", "stray", "--add-dir", "/tmp"},
|
|
{"--skip-permissions-unsafe", "stray", "--add-dir=/tmp"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
runTUI: func(context.Context, tui.Options) int {
|
|
t.Fatal("TUI launcher should not be called when a misplaced --add-dir is rejected")
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "--add-dir must come before any other arguments") {
|
|
t.Fatalf("expected misplaced --add-dir rejection on stderr, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunAddDirRejectedForNonAgentCommands(t *testing.T) {
|
|
// --add-dir grants a write root that only the TUI and exec consume; every
|
|
// other subcommand — including help/version, which run no agent — must
|
|
// fail loud rather than silently drop the grant.
|
|
for _, args := range [][]string{
|
|
{"--add-dir", "/tmp", "config"},
|
|
{"--add-dir=/tmp", "doctor"},
|
|
{"--add-dir", "/tmp", "models"},
|
|
{"--add-dir", "/tmp", "sandbox"},
|
|
{"--add-dir", "/tmp", "help"},
|
|
{"--add-dir", "/tmp", "--help"},
|
|
{"--add-dir", "/tmp", "-h"},
|
|
{"--add-dir", "/tmp", "version"},
|
|
{"--add-dir", "/tmp", "--version"},
|
|
{"--add-dir", "/tmp", "-v"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
runTUI: func(context.Context, tui.Options) int {
|
|
t.Fatal("TUI launcher should not be called when --add-dir is rejected")
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "--add-dir is only supported for the interactive TUI and exec") {
|
|
t.Fatalf("expected --add-dir rejection on stderr, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsReportsConfigErrorsWithoutLaunchingTUI(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
launchCalled := false
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return t.TempDir(), nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, errors.New("bad config")
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchCalled = true
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", exitCode)
|
|
}
|
|
if launchCalled {
|
|
t.Fatal("TUI launcher should not be called when config fails")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "bad config") {
|
|
t.Fatalf("expected config error on stderr, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunCommandsDoNotLaunchTUI(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"--help"},
|
|
{"-h"},
|
|
{"help"},
|
|
{"--version"},
|
|
{"version"},
|
|
{"wat"},
|
|
{"exec"},
|
|
{"setup", "--help"},
|
|
{"config"},
|
|
{"models"},
|
|
{"providers"},
|
|
{"doctor"},
|
|
{"search"},
|
|
{"find"},
|
|
{"sessions"},
|
|
{"session"},
|
|
{"plugins"},
|
|
{"plugin"},
|
|
{"skills"},
|
|
{"skill"},
|
|
{"hooks"},
|
|
{"mcp"},
|
|
{"sandbox"},
|
|
{"update"},
|
|
{"worktrees"},
|
|
{"worktree"},
|
|
{"verify"},
|
|
{"serve"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
launchCalled := false
|
|
|
|
_ = runWithDeps(args, &stdout, &stderr, appDeps{
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchCalled = true
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if launchCalled {
|
|
t.Fatalf("TUI launcher should not be called for args %#v", args)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunSetupNoArgsForcesSetupTUI(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{"setup"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return configPath, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{
|
|
ActiveProvider: "work",
|
|
Provider: config.ProviderProfile{
|
|
Name: "work",
|
|
ProviderKind: config.ProviderKindOpenAI,
|
|
BaseURL: config.OpenAIBaseURL,
|
|
APIKey: "sk-test",
|
|
Model: "gpt-test",
|
|
},
|
|
MaxTurns: 3,
|
|
}, nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return &cliFakeProvider{}, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !launchedOptions.Setup.Visible {
|
|
t.Fatalf("Setup.Visible = false, want forced setup")
|
|
}
|
|
if launchedOptions.Setup.Required {
|
|
t.Fatalf("Setup.Required = true, want false for credentialed provider")
|
|
}
|
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
|
t.Fatalf("expected no output, stdout=%q stderr=%q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunSetupProviderWritesActiveConfig(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
|
|
exitCode := runWithDeps([]string{"setup", "ollama"}, &stdout, &stderr, appDeps{
|
|
userConfigPath: func() (string, error) {
|
|
return configPath, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("read config: %v", err)
|
|
}
|
|
var cfg config.FileConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatalf("decode config: %v\n%s", err, string(data))
|
|
}
|
|
if cfg.ActiveProvider != "ollama" {
|
|
t.Fatalf("ActiveProvider = %q, want ollama", cfg.ActiveProvider)
|
|
}
|
|
if len(cfg.Providers) != 1 || cfg.Providers[0].CatalogID != "ollama" || cfg.Providers[0].Model == "" {
|
|
t.Fatalf("Providers = %#v, want ollama provider with model", cfg.Providers)
|
|
}
|
|
if !strings.Contains(stdout.String(), "Zero setup complete") || !strings.Contains(stdout.String(), "next: zero") {
|
|
t.Fatalf("unexpected setup output: %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateCheckTextAndJSON(t *testing.T) {
|
|
result := update.Result{
|
|
CurrentVersion: "dev",
|
|
LatestVersion: "0.2.0",
|
|
ReleaseURL: "https://github.com/Gitlawb/zero/releases/tag/v0.2.0",
|
|
TagName: "v0.2.0",
|
|
ReleaseAsset: update.AssetCheck{
|
|
Platform: "linux",
|
|
Arch: "x64",
|
|
ArchiveName: "zero-v0.2.0-linux-x64.tar.gz",
|
|
ArchiveURL: "https://example.test/zero-v0.2.0-linux-x64.tar.gz",
|
|
ChecksumName: "zero-v0.2.0-linux-x64.tar.gz.sha256",
|
|
ChecksumURL: "https://example.test/zero-v0.2.0-linux-x64.tar.gz.sha256",
|
|
ArchiveFound: true,
|
|
ChecksumFound: true,
|
|
Verified: true,
|
|
},
|
|
UpdateAvailable: true,
|
|
}
|
|
deps := appDeps{
|
|
checkUpdate: func(ctx context.Context, options update.Options) (update.Result, error) {
|
|
result.CurrentVersion = options.CurrentVersion
|
|
return result, nil
|
|
},
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"update", "--check"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Update available: dev -> 0.2.0") {
|
|
t.Fatalf("unexpected update text: %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Release asset: zero-v0.2.0-linux-x64.tar.gz") || !strings.Contains(stdout.String(), "Checksum asset: zero-v0.2.0-linux-x64.tar.gz.sha256") {
|
|
t.Fatalf("unexpected update asset text: %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"update", "--check", "--json"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
var payload struct {
|
|
CurrentVersion string `json:"currentVersion"`
|
|
LatestVersion string `json:"latestVersion"`
|
|
ReleaseURL string `json:"releaseUrl"`
|
|
TagName string `json:"tagName"`
|
|
ReleaseAsset struct {
|
|
Platform string `json:"platform"`
|
|
Arch string `json:"arch"`
|
|
ArchiveName string `json:"archiveName"`
|
|
ChecksumName string `json:"checksumName"`
|
|
ArchiveFound bool `json:"archiveFound"`
|
|
ChecksumFound bool `json:"checksumFound"`
|
|
Verified bool `json:"verified"`
|
|
} `json:"releaseAsset"`
|
|
UpdateAvailable bool `json:"updateAvailable"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("update JSON did not decode: %v\n%s", err, stdout.String())
|
|
}
|
|
if payload.CurrentVersion != "dev" ||
|
|
payload.LatestVersion != result.LatestVersion ||
|
|
payload.ReleaseURL != result.ReleaseURL ||
|
|
payload.TagName != result.TagName ||
|
|
payload.ReleaseAsset.ArchiveName != result.ReleaseAsset.ArchiveName ||
|
|
payload.ReleaseAsset.ChecksumName != result.ReleaseAsset.ChecksumName ||
|
|
!payload.ReleaseAsset.Verified ||
|
|
payload.UpdateAvailable != result.UpdateAvailable {
|
|
t.Fatalf("unexpected update JSON: %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestRunUpdatePassesCheckOptions(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
var got update.Options
|
|
exitCode := runWithDeps([]string{"update", "--check", "--repo=Gitlawb/fork", "--endpoint", "https://example.test/releases/latest", "--timeout", "750ms", "--target", "windows-x64", "--json"}, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(_ context.Context, options update.Options) (update.Result, error) {
|
|
got = options
|
|
return update.Result{
|
|
CurrentVersion: options.CurrentVersion,
|
|
LatestVersion: "0.2.0",
|
|
ReleaseURL: "https://example.test/releases/tag/v0.2.0",
|
|
TagName: "v0.2.0",
|
|
UpdateAvailable: true,
|
|
}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if got.CurrentVersion != "dev" || got.Repository != "Gitlawb/fork" || got.Endpoint != "https://example.test/releases/latest" || got.Timeout != 750*time.Millisecond || got.GOOS != "windows" || got.GOARCH != "amd64" {
|
|
t.Fatalf("unexpected update options: %#v", got)
|
|
}
|
|
if stdout.Len() == 0 {
|
|
t.Fatalf("expected JSON output")
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateRejectsInvalidTarget(t *testing.T) {
|
|
tests := []struct {
|
|
args []string
|
|
errorText string
|
|
}{
|
|
{args: []string{"update", "--check", "--target", "solaris-sparc"}, errorText: "unsupported update target"},
|
|
{args: []string{"update", "--check", "--target="}, errorText: "--target requires a non-empty value"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(strings.Join(tt.args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps(tt.args, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
t.Fatal("checkUpdate should not run for invalid target")
|
|
return update.Result{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit code, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, tt.errorText) {
|
|
t.Fatalf("expected target usage error %q, got %q", tt.errorText, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateRequiresCheckFlag(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"update"}, &stdout, &stderr, appDeps{})
|
|
|
|
if exitCode == exitSuccess {
|
|
t.Fatalf("expected non-success exit code, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "--check") {
|
|
t.Fatalf("expected --check usage error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateRejectsInvalidTimeout(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"update", "--check", "--timeout", "fast"},
|
|
{"update", "--check", "--timeout", "0s"},
|
|
{"update", "--check", "--timeout=-1s"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
t.Fatal("checkUpdate should not run for invalid timeout")
|
|
return update.Result{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit code, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "invalid update timeout") {
|
|
t.Fatalf("expected timeout usage error, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateReportsCheckError(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"update", "--check"}, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
return update.Result{}, errors.New("network failure")
|
|
},
|
|
})
|
|
|
|
if exitCode == exitSuccess {
|
|
t.Fatalf("expected non-success exit code, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "network failure") {
|
|
t.Fatalf("expected update error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateHelpDocumentsCheckFlag(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"update", "--help"}, &stdout, &stderr, appDeps{})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
for _, want := range []string{"--check", "--repo", "--endpoint", "--timeout", "--target"} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("expected update help to document %s, got %q", want, stdout.String())
|
|
}
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateReportsUpToDate(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"update", "--check"}, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
return update.Result{
|
|
CurrentVersion: "dev",
|
|
LatestVersion: "dev",
|
|
ReleaseURL: "https://github.com/Gitlawb/zero/releases/tag/dev",
|
|
TagName: "dev",
|
|
UpdateAvailable: false,
|
|
}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "up to date") {
|
|
t.Fatalf("expected up-to-date output, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func assertHelpOutput(t *testing.T, args []string) {
|
|
t.Helper()
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run(args, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
|
|
output := stdout.String()
|
|
for _, want := range []string{
|
|
"ZERO terminal coding agent",
|
|
"Usage:",
|
|
"zero [command]",
|
|
"exec",
|
|
"config",
|
|
"models",
|
|
"providers",
|
|
"doctor",
|
|
"context",
|
|
"search",
|
|
"plugins",
|
|
"hooks",
|
|
"mcp",
|
|
"sandbox",
|
|
"update",
|
|
"worktrees",
|
|
"verify",
|
|
"serve",
|
|
"usage",
|
|
"--version",
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("expected help output to contain %q, got %q", want, output)
|
|
}
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecRequiresPrompt(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"exec"},
|
|
{"exec", ""},
|
|
{"exec", " "},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run(args, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Fatalf("expected exit code 2, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "Prompt required") {
|
|
t.Fatalf("expected prompt error, got %q", stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunReturnsFailureWhenStdoutWriteFails(t *testing.T) {
|
|
exitCode := Run([]string{"--version"}, failingWriter{}, io.Discard)
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRunReturnsFailureWhenStderrWriteFails(t *testing.T) {
|
|
exitCode := Run([]string{"wat"}, io.Discard, failingWriter{})
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsUnknownCommand(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"wat"}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Fatalf("expected exit code 2, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, `unknown command "wat"`) {
|
|
t.Fatalf("expected unknown command error, got %q", got)
|
|
}
|
|
}
|
|
|
|
type cliFakeProvider struct{}
|
|
|
|
func (cliFakeProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
ch := make(chan zeroruntime.StreamEvent)
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func assertCoreRegistry(t *testing.T, registry *tools.Registry) {
|
|
t.Helper()
|
|
|
|
if registry == nil {
|
|
t.Fatal("Registry = nil, want core tool registry")
|
|
}
|
|
|
|
for _, name := range []string{
|
|
"Task",
|
|
"read_file",
|
|
"list_directory",
|
|
"glob",
|
|
"grep",
|
|
"write_file",
|
|
"edit_file",
|
|
"apply_patch",
|
|
"update_plan",
|
|
"bash",
|
|
"web_fetch",
|
|
} {
|
|
if _, ok := registry.Get(name); !ok {
|
|
t.Fatalf("expected registry to include core tool %q", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertAgentOptions(t *testing.T, options tui.Options, maxTurns int, permissionMode agent.PermissionMode) {
|
|
t.Helper()
|
|
|
|
if options.AgentOptions.MaxTurns != maxTurns {
|
|
t.Fatalf("AgentOptions.MaxTurns = %d, want %d", options.AgentOptions.MaxTurns, maxTurns)
|
|
}
|
|
if options.AgentOptions.PermissionMode != permissionMode {
|
|
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", options.AgentOptions.PermissionMode, permissionMode)
|
|
}
|
|
if options.AgentOptions.Autonomy != "low" {
|
|
t.Fatalf("AgentOptions.Autonomy = %q, want %q", options.AgentOptions.Autonomy, "low")
|
|
}
|
|
if options.AgentOptions.Sandbox == nil {
|
|
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
|
}
|
|
if options.SandboxStore == nil {
|
|
t.Fatal("SandboxStore = nil, want sandbox grant store")
|
|
}
|
|
if options.PermissionMode != permissionMode {
|
|
t.Fatalf("PermissionMode = %q, want %q", options.PermissionMode, permissionMode)
|
|
}
|
|
}
|
|
|
|
func TestRunThemeFlagPopulatesTUIOptions(t *testing.T) {
|
|
// The --theme flag must reach tui.Options.Theme (resolveThemeMode prefers it
|
|
// over ZERO_THEME). Previously Options.Theme was read but never set by the CLI.
|
|
for _, tc := range []struct {
|
|
args []string
|
|
want string
|
|
}{
|
|
{[]string{"--theme", "light"}, "light"},
|
|
{[]string{"--theme=dark"}, "dark"},
|
|
{[]string{"--theme", "auto"}, "auto"},
|
|
} {
|
|
var stdout, stderr bytes.Buffer
|
|
var got tui.Options
|
|
launched := false
|
|
exit := runWithDeps(tc.args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(_ context.Context, o tui.Options) int { launched = true; got = o; return 0 },
|
|
})
|
|
if exit != 0 || !launched {
|
|
t.Fatalf("%v: exit=%d launched=%v stderr=%s", tc.args, exit, launched, stderr.String())
|
|
}
|
|
if got.Theme != tc.want {
|
|
t.Fatalf("%v: Options.Theme = %q, want %q", tc.args, got.Theme, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunThemeFlagRejectsBadValue(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
launched := false
|
|
exit := runWithDeps([]string{"--theme", "solarized"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(context.Context, tui.Options) int { launched = true; return 0 },
|
|
})
|
|
if exit == 0 {
|
|
t.Fatal("an invalid --theme value must be rejected, not silently ignored")
|
|
}
|
|
if launched {
|
|
t.Fatal("the TUI must not launch on a bad --theme value")
|
|
}
|
|
if !strings.Contains(stderr.String(), "--theme must be auto, dark, or light") {
|
|
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
|
}
|
|
}
|