From 4c3d449a35dda22182a626f102ba6506740fa001 Mon Sep 17 00:00:00 2001 From: Tink bot Date: Tue, 26 May 2026 22:41:35 +0200 Subject: [PATCH] test(veans): add e2e suite covering init, tasks, claim, prime flows --- veans/e2e/claim_test.go | 60 ++++++++++ veans/e2e/helpers.go | 250 +++++++++++++++++++++++++++++++++++++++ veans/e2e/init_test.go | 157 ++++++++++++++++++++++++ veans/e2e/prime_test.go | 52 ++++++++ veans/e2e/shared_test.go | 81 +++++++++++++ veans/e2e/tasks_test.go | 135 +++++++++++++++++++++ 6 files changed, 735 insertions(+) create mode 100644 veans/e2e/claim_test.go create mode 100644 veans/e2e/helpers.go create mode 100644 veans/e2e/init_test.go create mode 100644 veans/e2e/prime_test.go create mode 100644 veans/e2e/shared_test.go create mode 100644 veans/e2e/tasks_test.go diff --git a/veans/e2e/claim_test.go b/veans/e2e/claim_test.go new file mode 100644 index 000000000..c814cd232 --- /dev/null +++ b/veans/e2e/claim_test.go @@ -0,0 +1,60 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +// TestClaim_AssignsBotMovesToInProgressTagsBranch exercises the full claim +// flow: assignment, bucket transition, and branch label application. +func TestClaim_AssignsBotMovesToInProgressTagsBranch(t *testing.T) { + ws, h := provisionWorkspace(t) + + out, _, code := h.Run(t, ws, "--json", "create", "claim me") + if code != 0 { + t.Fatalf("create exit %d\n%s", code, out) + } + var created client.Task + if err := json.Unmarshal([]byte(out), &created); err != nil { + t.Fatal(err) + } + id := fmt.Sprintf("%d", created.Index) + + // Switch the workspace's git branch so claim has something to label with. + gitInWorkspace(t, ws, "checkout", "-q", "-b", "feat-claim-test") + + _, errOut, code := h.Run(t, ws, "claim", id) + if code != 0 { + t.Fatalf("claim exit %d\n%s", code, errOut) + } + + server := h.GetTask(t, created.ID) + + // Verify bucket transition by reading the workspace's .veans.yml — the + // bot's expected In Progress bucket is stored there. + cfg := loadConfig(t, ws) + if server.BucketID != cfg.Buckets.InProgress { + t.Fatalf("task not in In Progress bucket: got %d, want %d", server.BucketID, cfg.Buckets.InProgress) + } + + // Bot assigned. + assigned := false + for _, a := range server.Assignees { + if a != nil && a.ID == cfg.Bot.UserID { + assigned = true + break + } + } + if !assigned { + t.Fatalf("bot %d not in assignees: %+v", cfg.Bot.UserID, server.Assignees) + } + + // Branch label attached. + branchLabel := "veans:branch:feat-claim-test" + if !taskHasLabelTitle(server, branchLabel) { + t.Fatalf("expected label %q on task; got %+v", branchLabel, server.Labels) + } +} diff --git a/veans/e2e/helpers.go b/veans/e2e/helpers.go new file mode 100644 index 000000000..64b7ec68a --- /dev/null +++ b/veans/e2e/helpers.go @@ -0,0 +1,250 @@ +// Package e2e is the integration suite for veans. It assumes a running +// Vikunja API at VEANS_E2E_API_URL and admin/seed credentials in +// VEANS_E2E_ADMIN_TOKEN (or VEANS_E2E_ADMIN_USER + VEANS_E2E_ADMIN_PASS). +// +// The suite never provisions Vikunja itself — locally, point it at a dev +// instance; in CI, the workflow spins one up the same way the frontend +// Playwright suite does. +package e2e + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "code.vikunja.io/veans/internal/client" +) + +// Harness bundles a built veans binary and an authenticated admin client +// for verifying side effects on the server. It also tracks the base env +// (HOME / XDG_CONFIG_HOME overrides) every runVeans invocation inherits. +type Harness struct { + binary string + apiURL string + adminToken string + adminClient *client.Client + suiteStartTS time.Time +} + +// SkipIfNotConfigured calls t.Skip if the suite hasn't been pointed at a +// Vikunja instance. Intended for the top of TestMain / TestXxx so plain +// `go test ./...` doesn't fail on contributors who haven't set up the env. +func SkipIfNotConfigured(t *testing.T) { + t.Helper() + if os.Getenv("VEANS_E2E_API_URL") == "" { + t.Skip("VEANS_E2E_API_URL not set — skipping e2e") + } +} + +// New builds (or reuses) the veans binary, mints/loads an admin token via +// the env, and returns a Harness ready to drive tests. +func New(t *testing.T) *Harness { + t.Helper() + SkipIfNotConfigured(t) + + apiURL := strings.TrimRight(os.Getenv("VEANS_E2E_API_URL"), "/") + binary, err := buildOrLocate() + if err != nil { + t.Fatalf("locate veans binary: %v", err) + } + + tok := os.Getenv("VEANS_E2E_ADMIN_TOKEN") + if tok == "" { + user, pass := os.Getenv("VEANS_E2E_ADMIN_USER"), os.Getenv("VEANS_E2E_ADMIN_PASS") + if user == "" || pass == "" { + t.Fatal("set VEANS_E2E_ADMIN_TOKEN or VEANS_E2E_ADMIN_USER + VEANS_E2E_ADMIN_PASS") + } + c := client.New(apiURL, "") + resp, err := c.Login(context.Background(), &client.LoginRequest{ + Username: user, Password: pass, LongToken: true, + }) + if err != nil { + t.Fatalf("admin login: %v", err) + } + tok = resp.Token + } + + return &Harness{ + binary: binary, + apiURL: apiURL, + adminToken: tok, + adminClient: client.New(apiURL, tok), + suiteStartTS: time.Now(), + } +} + +// Workspace creates a per-test git repo in a TempDir, with HOME and +// XDG_CONFIG_HOME pointed at TempDirs so the credential store falls back +// to its file backend rather than touching the developer's keychain. +type Workspace struct { + Dir string + Home string + XDGConfig string + ConfigPath string + BotUsername string + envOverrides map[string]string +} + +// NewWorkspace initializes a fresh repo with `git init` + a single commit so +// `git rev-parse --abbrev-ref HEAD` returns a real branch name. +func (h *Harness) NewWorkspace(t *testing.T) *Workspace { + t.Helper() + dir := t.TempDir() + home := t.TempDir() + xdg := t.TempDir() + + for _, c := range [][]string{ + {"git", "init", "-q", "-b", "main"}, + {"git", "config", "user.email", "veans-e2e@example.com"}, + {"git", "config", "user.name", "veans-e2e"}, + } { + cmd := exec.Command(c[0], c[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", strings.Join(c, " "), err, out) + } + } + if err := os.WriteFile(filepath.Join(dir, "README"), []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + for _, c := range [][]string{ + {"git", "add", "."}, + {"git", "commit", "-q", "-m", "initial"}, + } { + cmd := exec.Command(c[0], c[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", strings.Join(c, " "), err, out) + } + } + + return &Workspace{ + Dir: dir, + Home: home, + XDGConfig: xdg, + ConfigPath: filepath.Join(dir, ".veans.yml"), + envOverrides: map[string]string{ + "HOME": home, + "XDG_CONFIG_HOME": xdg, + }, + } +} + +// Run executes the veans binary against this workspace, returning stdout, +// stderr, and exit code. +func (h *Harness) Run(t *testing.T, ws *Workspace, args ...string) (stdout, stderr string, exitCode int) { + t.Helper() + cmd := exec.Command(h.binary, args...) + cmd.Dir = ws.Dir + cmd.Env = append(os.Environ(), envSlice(ws.envOverrides)...) + var so, se bytes.Buffer + cmd.Stdout = &so + cmd.Stderr = &se + err := cmd.Run() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + return so.String(), se.String(), ee.ExitCode() + } + t.Fatalf("run veans %v: %v", args, err) + } + return so.String(), se.String(), 0 +} + +// AdminClient returns the admin-authenticated client for verification. +func (h *Harness) AdminClient() *client.Client { return h.adminClient } + +// AdminToken returns the admin's bearer token (handy for --token flows). +func (h *Harness) AdminToken() string { return h.adminToken } + +// APIURL returns the configured Vikunja base URL. +func (h *Harness) APIURL() string { return h.apiURL } + +// CreateProject creates a fresh project owned by the admin user and returns +// it. Tests use a unique title to keep results isolated across parallel runs. +func (h *Harness) CreateProject(t *testing.T, title, identifier string) *client.Project { + t.Helper() + body := map[string]any{"title": title} + if identifier != "" { + body["identifier"] = identifier + } + var out client.Project + if err := h.adminClient.Do(context.Background(), "PUT", "/projects", nil, body, &out); err != nil { + t.Fatalf("create project %q: %v", title, err) + } + return &out +} + +// FindKanbanView returns the first Kanban view of the project (Vikunja +// auto-creates one). +func (h *Harness) FindKanbanView(t *testing.T, projectID int64) *client.ProjectView { + t.Helper() + views, err := h.adminClient.ListProjectViews(context.Background(), projectID) + if err != nil { + t.Fatalf("list views: %v", err) + } + for _, v := range views { + if v.ViewKind == client.ViewKindKanban { + return v + } + } + t.Fatalf("no Kanban view on project %d", projectID) + return nil +} + +// GetTask fetches a task by ID for verification. +func (h *Harness) GetTask(t *testing.T, id int64) *client.Task { + t.Helper() + task, err := h.adminClient.GetTask(context.Background(), id) + if err != nil { + t.Fatalf("get task %d: %v", id, err) + } + return task +} + +func buildOrLocate() (string, error) { + if env := os.Getenv("VEANS_BINARY"); env != "" { + if abs, err := filepath.Abs(env); err == nil { + if _, err := os.Stat(abs); err == nil { + return abs, nil + } + } + } + tmp, err := os.MkdirTemp("", "veans-bin-*") + if err != nil { + return "", err + } + bin := filepath.Join(tmp, "veans") + if runtime.GOOS == "windows" { + bin += ".exe" + } + cmd := exec.Command("go", "build", "-o", bin, "./cmd/veans") + cmd.Dir = repoRoot() + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("build veans: %v\n%s", err, out) + } + return bin, nil +} + +func repoRoot() string { + // e2e/helpers.go lives at /veans/e2e/helpers.go, so go up two. + _, file, _, _ := runtime.Caller(0) + return filepath.Clean(filepath.Join(filepath.Dir(file), "..")) +} + +func envSlice(overrides map[string]string) []string { + out := make([]string, 0, len(overrides)) + for k, v := range overrides { + out = append(out, k+"="+v) + } + return out +} diff --git a/veans/e2e/init_test.go b/veans/e2e/init_test.go new file mode 100644 index 000000000..f7bef0864 --- /dev/null +++ b/veans/e2e/init_test.go @@ -0,0 +1,157 @@ +package e2e + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "code.vikunja.io/veans/internal/config" + "code.vikunja.io/veans/internal/credentials" +) + +// TestInit_HappyPath exercises the full bootstrap: pick project + view, +// create canonical buckets, create the bot user, share the project, mint +// the bot's token, and write .veans.yml. Verifies side effects via the +// admin client. +func TestInit_HappyPath(t *testing.T) { + h := New(t) + + // Use a unique title and identifier per run so parallel jobs don't + // collide on the bot username. + suffix := uniqueSuffix() + project := h.CreateProject(t, "veans-e2e-"+suffix, "VE"+strings.ToUpper(suffix[:4])) + view := h.FindKanbanView(t, project.ID) + + ws := h.NewWorkspace(t) + // Rename the workspace dir so the auto-generated bot username is unique. + ws.BotUsername = "bot-veans-e2e-" + suffix + + stdout, stderr, code := h.Run(t, ws, + "init", + "--server", h.APIURL(), + "--token", h.AdminToken(), + "--project", fmt.Sprintf("%d", project.ID), + "--view", fmt.Sprintf("%d", view.ID), + "--bot-username", ws.BotUsername, + "--yes-buckets", + ) + if code != 0 { + t.Fatalf("init exit %d\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr) + } + + // Config written? + cfg, err := config.Load(ws.ConfigPath) + if err != nil { + t.Fatalf("load .veans.yml: %v", err) + } + if cfg.ProjectID != project.ID || cfg.ViewID != view.ID { + t.Fatalf("unexpected ids in config: %+v", cfg) + } + if cfg.Bot.Username != ws.BotUsername { + t.Fatalf("bot username = %q, want %q", cfg.Bot.Username, ws.BotUsername) + } + if cfg.Buckets.Todo == 0 || cfg.Buckets.InProgress == 0 || cfg.Buckets.InReview == 0 || cfg.Buckets.Done == 0 || cfg.Buckets.Scrapped == 0 { + t.Fatalf("buckets not fully populated: %+v", cfg.Buckets) + } + + // Bot token persisted in the file backend (since HOME points at a + // fresh tmpdir, the file backend takes over from the missing keyring). + store := credentials.NewFileBackend(ws.XDGConfig + "/veans/credentials.yml") + tok, err := store.Get(h.APIURL(), ws.BotUsername) + if err != nil { + t.Fatalf("token not persisted: %v", err) + } + if !strings.HasPrefix(tok, "tk_") { + t.Fatalf("bot token doesn't look like a Vikunja API token: %q", tok) + } + + // Bot exists on the server with the right username. + bots, err := h.AdminClient().ListBotUsers(context.Background()) + if err != nil { + t.Fatalf("list bots: %v", err) + } + found := false + for _, b := range bots { + if b.Username == ws.BotUsername { + found = true + if b.ID != cfg.Bot.UserID { + t.Fatalf("bot user_id mismatch: server=%d cfg=%d", b.ID, cfg.Bot.UserID) + } + break + } + } + if !found { + t.Fatalf("bot %q not found on server", ws.BotUsername) + } + + // Project shared with the bot at write permission. + var shares []map[string]any + _ = h.AdminClient().Do(context.Background(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares) + shareFound := false + for _, s := range shares { + if u, _ := s["username"].(string); u == ws.BotUsername { + if p, _ := s["permission"].(float64); int(p) >= 1 { + shareFound = true + break + } + } + } + if !shareFound { + t.Fatalf("project not shared with bot at write permission: %v", shares) + } +} + +func TestInit_NoIdentifierFallsBackToHashNN(t *testing.T) { + h := New(t) + + suffix := uniqueSuffix() + project := h.CreateProject(t, "veans-e2e-noid-"+suffix, "") + view := h.FindKanbanView(t, project.ID) + + ws := h.NewWorkspace(t) + ws.BotUsername = "bot-veans-noid-" + suffix + + _, stderr, code := h.Run(t, ws, + "init", + "--server", h.APIURL(), + "--token", h.AdminToken(), + "--project", fmt.Sprintf("%d", project.ID), + "--view", fmt.Sprintf("%d", view.ID), + "--bot-username", ws.BotUsername, + "--yes-buckets", + ) + if code != 0 { + t.Fatalf("init exit %d\n%s", code, stderr) + } + + cfg, err := config.Load(ws.ConfigPath) + if err != nil { + t.Fatal(err) + } + if cfg.ProjectIdentifier != "" { + t.Fatalf("expected empty identifier, got %q", cfg.ProjectIdentifier) + } + if got := cfg.FormatTaskID(7); got != "#7" { + t.Fatalf("expected #7, got %q", got) + } +} + +// uniqueSuffix returns a short random-ish slug for naming test artifacts. +// Time-based is fine here — tests don't need cryptographic uniqueness. +func uniqueSuffix() string { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "host" + } + return strings.ToLower(fmt.Sprintf("%s-%d", trunc(hostname, 4), time.Now().UnixNano()))[:18] +} + +func trunc(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/veans/e2e/prime_test.go b/veans/e2e/prime_test.go new file mode 100644 index 000000000..f62eba1f7 --- /dev/null +++ b/veans/e2e/prime_test.go @@ -0,0 +1,52 @@ +package e2e + +import ( + "strings" + "testing" +) + +// TestPrime_RendersWithProjectAndBot pins the literal anchors hooks depend +// on. Mirrors plan e2e test 12. +func TestPrime_RendersWithProjectAndBot(t *testing.T) { + ws, h := provisionWorkspace(t) + cfg := loadConfig(t, ws) + + out, _, code := h.Run(t, ws, "prime") + if code != 0 { + t.Fatalf("prime exit %d", code) + } + + mustContain := []string{ + "", + cfg.Bot.Username, + "Refs:", + "veans claim", + "Todo", + "In Progress", + "In Review", + "Done", + "Scrapped", + } + for _, s := range mustContain { + if !strings.Contains(out, s) { + t.Errorf("prime output missing %q", s) + } + } +} + +// TestPrime_SilentOutsideWorkspace verifies the safe-globally-installed +// hook contract: no .veans.yml ⇒ silent + exit 0. +func TestPrime_SilentOutsideWorkspace(t *testing.T) { + h := New(t) + + // A workspace with no .veans.yml — just a temp dir. + ws := h.NewWorkspace(t) + + stdout, stderr, code := h.Run(t, ws, "prime") + if code != 0 { + t.Fatalf("prime exit %d (expected 0)\n%s\n%s", code, stdout, stderr) + } + if stdout != "" { + t.Fatalf("expected silent stdout outside workspace, got: %s", stdout) + } +} diff --git a/veans/e2e/shared_test.go b/veans/e2e/shared_test.go new file mode 100644 index 000000000..870f14f83 --- /dev/null +++ b/veans/e2e/shared_test.go @@ -0,0 +1,81 @@ +package e2e + +import ( + "os/exec" + "strings" + "testing" + + "code.vikunja.io/veans/internal/config" +) + +// provisionWorkspace runs `veans init` against a fresh project and returns +// the workspace + harness primed for command-level e2e tests. Each test that +// needs a working .veans.yml calls this at the top. +func provisionWorkspace(t *testing.T) (*Workspace, *Harness) { + t.Helper() + h := New(t) + suffix := uniqueSuffix() + project := h.CreateProject(t, "veans-e2e-"+suffix, "VE"+strings.ToUpper(suffix[:4])) + view := h.FindKanbanView(t, project.ID) + + ws := h.NewWorkspace(t) + ws.BotUsername = "bot-veans-e2e-" + suffix + + _, stderr, code := h.Run(t, ws, + "init", + "--server", h.APIURL(), + "--token", h.AdminToken(), + "--project", iToS(project.ID), + "--view", iToS(view.ID), + "--bot-username", ws.BotUsername, + "--yes-buckets", + ) + if code != 0 { + t.Fatalf("provision init failed: %s", stderr) + } + return ws, h +} + +// loadConfig reads .veans.yml out of a workspace. +func loadConfig(t *testing.T, ws *Workspace) *config.Config { + t.Helper() + c, err := config.Load(ws.ConfigPath) + if err != nil { + t.Fatal(err) + } + return c +} + +// gitInWorkspace runs git inside the workspace and fails the test on error. +func gitInWorkspace(t *testing.T, ws *Workspace, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = ws.Dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func iToS(n int64) string { + const digits = "0123456789" + if n == 0 { + return "0" + } + negative := false + if n < 0 { + negative = true + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = digits[n%10] + n /= 10 + } + if negative { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/veans/e2e/tasks_test.go b/veans/e2e/tasks_test.go new file mode 100644 index 000000000..4562bfbad --- /dev/null +++ b/veans/e2e/tasks_test.go @@ -0,0 +1,135 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +// TestCreateShowList_RoundTrip verifies the read+write path against a real +// Vikunja: provision a workspace via init, create a task, show it, list it +// (with --filter), and confirm the JSON shapes are unwrapped raw object/array. +func TestCreateShowList_RoundTrip(t *testing.T) { + ws, h := provisionWorkspace(t) + + // Create a task with a description and a label. + out, errOut, code := h.Run(t, ws, + "--json", "create", "Test bug fix", + "-d", "## Repro\n- [ ] step 1\n- [ ] step 2", + "--label", "bug", + "--priority", "3", + ) + if code != 0 { + t.Fatalf("create exit %d\n%s\n%s", code, out, errOut) + } + var created client.Task + if err := json.Unmarshal([]byte(out), &created); err != nil { + t.Fatalf("decode create: %v\n%s", err, out) + } + if created.Title != "Test bug fix" { + t.Fatalf("created title = %q", created.Title) + } + if created.Priority != 3 { + t.Fatalf("priority = %d", created.Priority) + } + if !taskHasLabelTitle(&created, "veans:bug") { + t.Fatalf("expected veans:bug label on created task; got %+v", created.Labels) + } + + // Show with --json — should be a raw object, not enveloped. + id := fmt.Sprintf("%d", created.Index) + showOut, _, code := h.Run(t, ws, "--json", "show", id) + if code != 0 { + t.Fatalf("show exit %d", code) + } + var shown client.Task + if err := json.Unmarshal([]byte(showOut), &shown); err != nil { + t.Fatalf("decode show: %v\n%s", err, showOut) + } + if shown.ID != created.ID { + t.Fatalf("show returned wrong task: %d vs %d", shown.ID, created.ID) + } + + // List with --json — should be a raw array. + listOut, _, code := h.Run(t, ws, "--json", "list") + if code != 0 { + t.Fatalf("list exit %d", code) + } + var listed []*client.Task + if err := json.Unmarshal([]byte(listOut), &listed); err != nil { + t.Fatalf("decode list: %v\n%s", err, listOut) + } + if len(listed) == 0 { + t.Fatalf("list returned empty array; expected at least our created task") + } + + // --filter passthrough: only items with priority > 2. + filterOut, _, code := h.Run(t, ws, "--json", "list", "--filter", "priority > 2") + if code != 0 { + t.Fatalf("list --filter exit %d\n%s", code, filterOut) + } + var filtered []*client.Task + if err := json.Unmarshal([]byte(filterOut), &filtered); err != nil { + t.Fatalf("decode filter list: %v", err) + } + for _, ft := range filtered { + if ft.Priority <= 2 { + t.Fatalf("filter leaked priority=%d task into result", ft.Priority) + } + } +} + +// TestUpdate_DescriptionReplaceUniqueness pins the agent-friendly Edit-tool +// behavior: the "old" string must match exactly once, otherwise the update +// errors and nothing changes. +func TestUpdate_DescriptionReplaceUniqueness(t *testing.T) { + ws, h := provisionWorkspace(t) + + out, errOut, code := h.Run(t, ws, "--json", "create", "checkbox task", + "-d", "- [ ] step 1\n- [ ] step 1 (again)", + ) + if code != 0 { + t.Fatalf("create exit %d\n%s\n%s", code, out, errOut) + } + var created client.Task + if err := json.Unmarshal([]byte(out), &created); err != nil { + t.Fatal(err) + } + id := fmt.Sprintf("%d", created.Index) + + // Non-unique replace should fail with a validation error and a + // non-zero exit code. + _, stderr, code := h.Run(t, ws, + "update", id, + "--description-replace-old", "step 1", + "--description-replace-new", "step 1 [x]", + ) + if code == 0 { + t.Fatalf("expected non-zero exit on non-unique replace") + } + if !strings.Contains(stderr, "VALIDATION_ERROR") { + t.Fatalf("expected VALIDATION_ERROR in stderr, got: %s", stderr) + } + + // Disambiguate and try again — should succeed. + _, _, code = h.Run(t, ws, + "update", id, + "--description-replace-old", "- [ ] step 1\n", + "--description-replace-new", "- [x] step 1\n", + ) + if code != 0 { + t.Fatalf("disambiguated update should succeed; exit %d", code) + } +} + +func taskHasLabelTitle(t *client.Task, title string) bool { + for _, l := range t.Labels { + if l != nil && l.Title == title { + return true + } + } + return false +}