Files
ctask/cmd/completion_test.go
T
typebasedio 8120c399df feat(v0.6): AgentSpec field on TaskMeta with backward-compat unmarshal
Replace TaskMeta.Agent (string) with TaskMeta.Agent (AgentSpec) carrying
type/command/args/env. Custom UnmarshalYAML preserves the legacy scalar
form: a built-in name (claude, opencode) maps to that type; any other
scalar maps to type=custom with the scalar as command. A missing agent
field leaves Type empty so the resolver fills in default_agent at launch.

ValidateAgentSpec enforces: known type (claude|opencode|custom),
type=custom requires command, command must be an executable name or
path with no whitespace or shell metacharacters.

Launch-path wiring (Task 3) and the --agent flag rework (Task 4) are
intentionally not part of this commit; cmd/* call sites are patched to
the minimum needed for the build to compile.
2026-05-15 10:58:06 -04:00

231 lines
6.4 KiB
Go

package cmd
import (
"bytes"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/warrenronsiek/ctask/internal/workspace"
)
// completionTestEnv mirrors listTestEnv but is duplicated here so the
// completion tests are self-contained and can vary the fixtures.
func completionTestEnv(t *testing.T) string {
t.Helper()
root := t.TempDir()
mk := func(category, dirName, status, taskType string) {
dir := filepath.Join(root, category, dirName)
os.MkdirAll(dir, 0755)
now := time.Now().UTC().Truncate(time.Second)
slug := dirName[11:]
meta := &workspace.TaskMeta{
ID: "t", Slug: slug, Title: slug,
CreatedAt: now, UpdatedAt: now,
Status: status, Category: category, Type: taskType,
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
}
if status == "archived" {
meta.ArchivedAt = &now
}
workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta)
}
mk("general", "2026-04-05_alpha-active", "active", "task")
mk("general", "2026-04-04_beta-archived", "archived", "task")
mk("projects", "2026-04-03_gamma-active", "active", "project")
mk("projects", "2026-04-02_delta-archived", "archived", "project")
return root
}
// callCompletion invokes a ValidArgsFunction directly under a CTASK_ROOT
// override, returning the candidate list (sorted for stable comparisons)
// and the shell directive.
func callCompletion(t *testing.T, root string, fn func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective)) ([]string, cobra.ShellCompDirective) {
t.Helper()
prev := os.Getenv("CTASK_ROOT")
os.Setenv("CTASK_ROOT", root)
defer func() {
if prev == "" {
os.Unsetenv("CTASK_ROOT")
} else {
os.Setenv("CTASK_ROOT", prev)
}
}()
candidates, dir := fn(nil, nil, "")
sort.Strings(candidates)
return candidates, dir
}
func TestCompleteWorkspacesActiveOnly(t *testing.T) {
root := completionTestEnv(t)
got, _ := callCompletion(t, root, completeWorkspaces(completionActive))
want := []string{
"2026-04-03_gamma-active",
"2026-04-05_alpha-active",
}
if !equalStringSlices(got, want) {
t.Errorf("active filter:\nwant %v\ngot %v", want, got)
}
}
func TestCompleteWorkspacesArchivedOnly(t *testing.T) {
root := completionTestEnv(t)
got, _ := callCompletion(t, root, completeWorkspaces(completionArchived))
want := []string{
"2026-04-02_delta-archived",
"2026-04-04_beta-archived",
}
if !equalStringSlices(got, want) {
t.Errorf("archived filter:\nwant %v\ngot %v", want, got)
}
}
func TestCompleteWorkspacesAny(t *testing.T) {
root := completionTestEnv(t)
got, _ := callCompletion(t, root, completeWorkspaces(completionAny))
want := []string{
"2026-04-02_delta-archived",
"2026-04-03_gamma-active",
"2026-04-04_beta-archived",
"2026-04-05_alpha-active",
}
if !equalStringSlices(got, want) {
t.Errorf("any filter:\nwant %v\ngot %v", want, got)
}
}
func TestCompleteWorkspacesSecondArgReturnsNoCompletion(t *testing.T) {
// Once the user has typed the first positional arg, completion of further
// args must not enumerate workspaces.
root := completionTestEnv(t)
prev := os.Getenv("CTASK_ROOT")
os.Setenv("CTASK_ROOT", root)
defer func() {
if prev == "" {
os.Unsetenv("CTASK_ROOT")
} else {
os.Setenv("CTASK_ROOT", prev)
}
}()
candidates, dir := completeWorkspaces(completionAny)(nil, []string{"already-typed"}, "")
if len(candidates) != 0 {
t.Errorf("expected no candidates after first positional arg, got: %v", candidates)
}
if dir != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("expected ShellCompDirectiveNoFileComp, got: %v", dir)
}
}
// genCompletion calls the shell-specific generator that Cobra's auto-injected
// `ctask completion <shell>` subcommand uses internally. Bypasses rootCmd
// argument-routing state so the tests are independent.
func genCompletion(shell string) (string, error) {
var buf bytes.Buffer
var err error
switch shell {
case "bash":
err = rootCmd.GenBashCompletionV2(&buf, true)
case "zsh":
err = rootCmd.GenZshCompletion(&buf)
case "fish":
err = rootCmd.GenFishCompletion(&buf, true)
case "powershell":
err = rootCmd.GenPowerShellCompletionWithDesc(&buf)
}
return buf.String(), err
}
func TestCompletionBashGenerates(t *testing.T) {
out, err := genCompletion("bash")
if err != nil {
t.Fatalf("GenBashCompletionV2: %v", err)
}
if len(out) == 0 {
t.Fatal("expected non-empty bash completion script")
}
if !strings.Contains(out, "ctask") {
t.Errorf("bash completion script should mention 'ctask':\n%s", truncate(out, 200))
}
}
func TestCompletionPowerShellGenerates(t *testing.T) {
out, err := genCompletion("powershell")
if err != nil {
t.Fatalf("GenPowerShellCompletionWithDesc: %v", err)
}
if len(out) == 0 {
t.Fatal("expected non-empty powershell completion script")
}
if !strings.Contains(out, "ctask") {
t.Errorf("powershell completion script should mention 'ctask':\n%s", truncate(out, 200))
}
}
func TestCompletionZshGenerates(t *testing.T) {
out, err := genCompletion("zsh")
if err != nil {
t.Fatalf("GenZshCompletion: %v", err)
}
if len(out) == 0 {
t.Fatal("expected non-empty zsh completion script")
}
}
func TestCompletionFishGenerates(t *testing.T) {
out, err := genCompletion("fish")
if err != nil {
t.Fatalf("GenFishCompletion: %v", err)
}
if len(out) == 0 {
t.Fatal("expected non-empty fish completion script")
}
}
// TestCompletionSubcommandViaExecute verifies that the user-facing path
// `ctask completion bash` actually runs end-to-end through Cobra. Cobra
// adds the `completion` subcommand lazily on first Execute(), so a Find()
// before any Execute() returns "unknown command" — exercising the real
// path is the right test.
func TestCompletionSubcommandViaExecute(t *testing.T) {
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
defer rootCmd.SetOut(os.Stdout)
defer rootCmd.SetErr(os.Stderr)
rootCmd.SetArgs([]string{"completion", "bash"})
defer rootCmd.SetArgs(nil)
if err := rootCmd.Execute(); err != nil {
t.Fatalf("rootCmd.Execute(\"completion\", \"bash\"): %v", err)
}
if buf.Len() == 0 {
t.Fatal("expected non-empty bash completion via Execute")
}
if !strings.Contains(buf.String(), "ctask") {
t.Errorf("end-to-end bash completion should mention 'ctask'")
}
}
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}