summaryrefslogtreecommitdiff
path: root/internal/askcli/command_watch_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/askcli/command_watch_test.go')
-rw-r--r--internal/askcli/command_watch_test.go269
1 files changed, 269 insertions, 0 deletions
diff --git a/internal/askcli/command_watch_test.go b/internal/askcli/command_watch_test.go
new file mode 100644
index 0000000..77d0fda
--- /dev/null
+++ b/internal/askcli/command_watch_test.go
@@ -0,0 +1,269 @@
+package askcli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+)
+
+type fakeWatchTicker struct {
+ ch <-chan time.Time
+ stopped bool
+}
+
+func (t *fakeWatchTicker) C() <-chan time.Time {
+ return t.ch
+}
+
+func (t *fakeWatchTicker) Stop() {
+ t.stopped = true
+}
+
+func TestHandleWatch_ForwardsNonZeroCode(t *testing.T) {
+ ticks := make(chan time.Time)
+ oldTicker := newWatchTicker
+ newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
+ t.Cleanup(func() { newWatchTicker = oldTicker })
+
+ ctx := context.Background()
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ return 1, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch", "urgency"}, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 1 {
+ t.Fatalf("watch code = %d, want 1", code)
+ }
+ if stdout.Len() != 0 {
+ t.Fatalf("expected no output, got %q", stdout.String())
+ }
+}
+
+func TestHandleWatch_ForwardsInnerError(t *testing.T) {
+ ctx := context.Background()
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ return 1, errors.New("boom")
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch", "urgency"}, nil, &stdout, &stderr)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "boom") {
+ t.Fatalf("error = %v, want boom", err)
+ }
+ if code != 1 {
+ t.Fatalf("watch code = %d, want 1", code)
+ }
+}
+
+func TestHandleWatch_DrawsStderrOnNonZero(t *testing.T) {
+ ticks := make(chan time.Time)
+ oldTicker := newWatchTicker
+ newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
+ t.Cleanup(func() { newWatchTicker = oldTicker })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ callCount := 0
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ callCount++
+ if len(args) >= 2 && args[0] == "started" && args[1] == "export" {
+ if callCount == 1 {
+ cancel()
+ return 1, nil
+ }
+ }
+ _, _ = io.WriteString(stdout, "[]")
+ return 0, nil
+ }})
+
+ var out bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch", "info"}, nil, &out, &bytes.Buffer{})
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 1 {
+ t.Fatalf("watch code = %d, want 1", code)
+ }
+ if !strings.Contains(out.String(), ansiClearScreen) {
+ t.Fatalf("stdout missing clear screen: %q", out.String())
+ }
+ if !strings.Contains(out.String(), "no started task found") {
+ t.Fatalf("stdout missing expected error message: %q", out.String())
+ }
+}
+
+func TestHandleWatch_DefaultsToListAndRedrawsOnChange(t *testing.T) {
+ dir := t.TempDir()
+ oldRoot := taskAliasCacheRoot
+ oldNow := nowTaskAliasCache
+ taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
+ nowTaskAliasCache = func() time.Time { return time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) }
+ t.Cleanup(func() {
+ taskAliasCacheRoot = oldRoot
+ nowTaskAliasCache = oldNow
+ })
+
+ ticks := make(chan time.Time, 1)
+ ticks <- time.Now()
+ fakeTicker := &fakeWatchTicker{ch: ticks}
+ oldTicker := newWatchTicker
+ newWatchTicker = func(interval time.Duration) watchTicker {
+ if interval != watchInterval {
+ t.Fatalf("watch interval = %s, want %s", interval, watchInterval)
+ }
+ return fakeTicker
+ }
+ t.Cleanup(func() { newWatchTicker = oldTicker })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ var calls [][]string
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ calls = append(calls, append([]string(nil), args...))
+ if len(calls) == 1 {
+ _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Task 1","status":"pending","priority":"M","tags":["agent"],"urgency":3,"depends":[]}]`)
+ return 0, nil
+ }
+ _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Task 1 updated","status":"pending","priority":"M","tags":["agent"],"urgency":3,"depends":[]}]`)
+ cancel()
+ return 0, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch"}, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 0 {
+ t.Fatalf("watch code = %d, want 0: stderr=%s", code, stderr.String())
+ }
+ wantCalls := [][]string{{"status:pending", "export"}, {"status:pending", "export"}}
+ if !reflect.DeepEqual(calls, wantCalls) {
+ t.Fatalf("runner calls = %#v, want %#v", calls, wantCalls)
+ }
+ if got := strings.Count(stdout.String(), "\033[2J\033[H"); got != 2 {
+ t.Fatalf("clear count = %d, want 2; output=%q", got, stdout.String())
+ }
+ if !strings.Contains(stdout.String(), "Task 1 updated") {
+ t.Fatalf("stdout missing updated task: %q", stdout.String())
+ }
+ if !fakeTicker.stopped {
+ t.Fatal("watch ticker was not stopped")
+ }
+}
+
+func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) {
+ dir := t.TempDir()
+ oldRoot := taskAliasCacheRoot
+ oldNow := nowTaskAliasCache
+ taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
+ nowTaskAliasCache = func() time.Time { return time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) }
+ t.Cleanup(func() {
+ taskAliasCacheRoot = oldRoot
+ nowTaskAliasCache = oldNow
+ })
+
+ ticks := make(chan time.Time, 1)
+ ticks <- time.Now()
+ oldTicker := newWatchTicker
+ newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
+ t.Cleanup(func() { newWatchTicker = oldTicker })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ runCount := 0
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ runCount++
+ _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Task 1","status":"pending","priority":"M","tags":["agent"],"urgency":3,"depends":[]}]`)
+ if runCount == 2 {
+ cancel()
+ }
+ return 0, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch", "list"}, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 0 {
+ t.Fatalf("watch code = %d, want 0: stderr=%s", code, stderr.String())
+ }
+ if got := strings.Count(stdout.String(), "\033[2J\033[H"); got != 1 {
+ t.Fatalf("clear count = %d, want 1; output=%q", got, stdout.String())
+ }
+}
+
+func TestHandleWatch_ForwardsSubcommandArgs(t *testing.T) {
+ ticks := make(chan time.Time)
+ oldTicker := newWatchTicker
+ newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
+ t.Cleanup(func() { newWatchTicker = oldTicker })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ var gotArgs []string
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ gotArgs = append([]string(nil), args...)
+ cancel()
+ _, _ = io.WriteString(stdout, `[]`)
+ return 0, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(ctx, []string{"watch", "ready", "limit:2"}, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 0 {
+ t.Fatalf("watch code = %d, want 0: stderr=%s", code, stderr.String())
+ }
+ if want := []string{"+READY", "limit:2", "export"}; !reflect.DeepEqual(gotArgs, want) {
+ t.Fatalf("runner args = %v, want %v", gotArgs, want)
+ }
+}
+
+func TestHandleWatch_RejectsUnsafeSubcommands(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ }{
+ {name: "recursive watch", args: []string{"watch", "watch", "list"}},
+ {name: "implicit add typo", args: []string{"watch", "typo"}},
+ {name: "write command", args: []string{"watch", "start", "0"}},
+ {name: "write dependency command", args: []string{"watch", "dep", "add", "0", "1"}},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ t.Fatalf("runner should not be called for unsafe watch command")
+ return 0, nil
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, err := d.Dispatch(context.Background(), tc.args, nil, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("watch returned error: %v", err)
+ }
+ if code != 1 {
+ t.Fatalf("watch code = %d, want 1", code)
+ }
+ if !strings.Contains(stderr.String(), "read-only subcommands") {
+ t.Fatalf("stderr = %q, want read-only subcommand error", stderr.String())
+ }
+ if stdout.Len() != 0 {
+ t.Fatalf("stdout = %q, want empty", stdout.String())
+ }
+ })
+ }
+}