vikunja/veans/e2e/helpers.go

258 lines
7.6 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// 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"
"code.vikunja.io/veans/internal/client"
)
// Harness bundles a built veans binary and an authenticated admin client
// for verifying side effects on the server.
type Harness struct {
Binary string
APIURL string
AdminToken string
AdminClient *client.Client
}
// 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),
}
}
// 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"},
// Disable any inherited commit signing; the test commit doesn't
// need provenance and signing brokers can fail in dev containers.
{"git", "config", "commit.gpgsign", "false"},
{"git", "config", "tag.gpgsign", "false"},
} {
cmd := exec.CommandContext(t.Context(), 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.CommandContext(t.Context(), 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.CommandContext(t.Context(), 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
}
// 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.CommandContext(context.Background(), "go", "build", "-o", bin, "./cmd/veans")
cmd.Dir = repoRoot()
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("build veans: %w (output: %s)", err, out)
}
return bin, nil
}
func repoRoot() string {
// e2e/helpers.go lives at <repo>/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
}