From c5ae7974e1b4ed6138afd1a0fd6c97cce159511c Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 19 Feb 2026 14:54:44 +0100 Subject: [PATCH] feat: add mage test:e2e for isolated end-to-end testing --- magefile.go | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/magefile.go b/magefile.go index 2be191f1b..3eef5c87a 100644 --- a/magefile.go +++ b/magefile.go @@ -28,6 +28,8 @@ import ( "io" "io/fs" "math" + "net" + "net/http" "os" "os/exec" "path/filepath" @@ -69,6 +71,7 @@ var ( "dev:make-notification": Dev.MakeNotification, "dev:prepare-worktree": Dev.PrepareWorktree, "dev:tag-release": Dev.TagRelease, + "test:e2e": Test.E2E, "plugins:build": Plugins.Build, "lint": Check.Golangci, "lint:fix": Check.GolangciFix, @@ -316,6 +319,41 @@ func printSuccess(text string, args ...interface{}) { fmt.Printf(InfoColor+"\n", text) } +// getE2EPort returns the port from the given env var, or a random available port. +func getE2EPort(envVar string) (int, error) { + if v := os.Getenv(envVar); v != "" { + return strconv.Atoi(v) + } + return getRandomPort() +} + +// getRandomPort finds a random available TCP port. +func getRandomPort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil +} + +// waitForHTTP polls a URL until it returns a 200 status or the timeout expires. +func waitForHTTP(url string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: 2 * time.Second} + for time.Now().Before(deadline) { + resp, err := client.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("timed out waiting for %s after %s", url, timeout) +} + // Fmt formats the code using go fmt func Fmt() error { mg.Deps(initVars) @@ -371,6 +409,161 @@ func (Test) All() { mg.Deps(Test.Feature, Test.Web) } +// E2E builds the API, starts it with an in-memory database and the frontend dev server, +// runs the Playwright e2e tests against them, then tears everything down. +// This does not touch your local database. +// +// Any arguments are passed through to Playwright. Examples: +// +// mage test:e2e "" # run all tests +// mage test:e2e "tests/e2e/misc/menu.spec.ts" # run a specific test file +// mage test:e2e "--grep menu" # filter by test name +// mage test:e2e "--headed" # run in headed browser mode +// mage test:e2e "--headed tests/e2e/misc/menu.spec.ts" # combine flags +// +// Environment variable overrides: +// - VIKUNJA_E2E_API_PORT: API port (default: random) +// - VIKUNJA_E2E_FRONTEND_PORT: Frontend port (default: random) +// - VIKUNJA_E2E_TESTING_TOKEN: Testing token for seed endpoints (default: random) +// - VIKUNJA_E2E_SKIP_BUILD: Set to "true" to skip rebuilding the API binary (default: false) +func (Test) E2E(args string) error { + mg.Deps(initVars) + + // Determine ports + apiPort, err := getE2EPort("VIKUNJA_E2E_API_PORT") + if err != nil { + return fmt.Errorf("could not get API port: %w", err) + } + frontendPort, err := getE2EPort("VIKUNJA_E2E_FRONTEND_PORT") + if err != nil { + return fmt.Errorf("could not get frontend port: %w", err) + } + + // Generate a random testing token + testingToken := os.Getenv("VIKUNJA_E2E_TESTING_TOKEN") + if testingToken == "" { + testingToken = fmt.Sprintf("e2e-test-token-%d", time.Now().UnixNano()) + } + + fmt.Printf("E2E test configuration:\n") + fmt.Printf(" API port: %d\n", apiPort) + fmt.Printf(" Frontend port: %d\n", frontendPort) + fmt.Printf(" Testing token: %s\n", testingToken) + + // Build the API binary (unless skipped) + if os.Getenv("VIKUNJA_E2E_SKIP_BUILD") != "true" { + fmt.Println("\n--- Building API binary ---") + if err := (Build{}).Build(); err != nil { + return fmt.Errorf("failed to build API: %w", err) + } + } + + // Create temp directory for the SQLite DB and file uploads + tmpDir, err := os.MkdirTemp("", "vikunja-e2e-*") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err := os.MkdirAll(filepath.Join(tmpDir, "files"), 0o755); err != nil { + return fmt.Errorf("failed to create files dir: %w", err) + } + + // Start the API server — all config via env vars, no config file + fmt.Println("\n--- Starting API server ---") + apiCmd := exec.Command("./vikunja", "web") + apiCmd.Env = append(os.Environ(), + fmt.Sprintf("VIKUNJA_SERVICE_INTERFACE=:%d", apiPort), + fmt.Sprintf("VIKUNJA_SERVICE_PUBLICURL=http://127.0.0.1:%d/", apiPort), + fmt.Sprintf("VIKUNJA_SERVICE_TESTINGTOKEN=%s", testingToken), + fmt.Sprintf("VIKUNJA_SERVICE_ROOTPATH=%s", tmpDir), + "VIKUNJA_SERVICE_JWTSECRET=e2e-test-jwt-secret-do-not-use-in-production", + "VIKUNJA_DATABASE_TYPE=sqlite", + fmt.Sprintf("VIKUNJA_DATABASE_PATH=%s", filepath.Join(tmpDir, "vikunja-e2e.db")), + fmt.Sprintf("VIKUNJA_FILES_BASEPATH=%s", filepath.Join(tmpDir, "files")), + "VIKUNJA_LOG_LEVEL=WARNING", + "VIKUNJA_MAILER_ENABLED=false", + "VIKUNJA_REDIS_ENABLED=false", + "VIKUNJA_CORS_ENABLE=true", + "VIKUNJA_CORS_ORIGINS=http://127.0.0.1:*,http://localhost:*", + ) + apiCmd.Stdout = os.Stdout + apiCmd.Stderr = os.Stderr + if err := apiCmd.Start(); err != nil { + return fmt.Errorf("failed to start API: %w", err) + } + defer func() { + fmt.Println("\n--- Stopping API server ---") + if apiCmd.Process != nil { + apiCmd.Process.Signal(os.Interrupt) + apiCmd.Wait() + } + }() + + // Wait for API to be ready + apiBase := fmt.Sprintf("http://127.0.0.1:%d/api/v1", apiPort) + fmt.Printf("Waiting for API at %s ...\n", apiBase) + if err := waitForHTTP(apiBase+"/info", 30*time.Second); err != nil { + return fmt.Errorf("API failed to start: %w", err) + } + printSuccess("API is ready!") + + // Start the frontend dev server + fmt.Println("\n--- Starting frontend dev server ---") + frontendCmd := exec.Command("pnpm", "dev", "--port", strconv.Itoa(frontendPort)) + frontendCmd.Dir = "frontend" + frontendCmd.Env = append(os.Environ(), + fmt.Sprintf("VIKUNJA_FRONTEND_PORT=%d", frontendPort), + fmt.Sprintf("DEV_PROXY=http://127.0.0.1:%d", apiPort), + ) + frontendCmd.Stdout = os.Stdout + frontendCmd.Stderr = os.Stderr + if err := frontendCmd.Start(); err != nil { + return fmt.Errorf("failed to start frontend: %w", err) + } + defer func() { + fmt.Println("\n--- Stopping frontend dev server ---") + if frontendCmd.Process != nil { + frontendCmd.Process.Signal(os.Interrupt) + frontendCmd.Wait() + } + }() + + // Wait for frontend to be ready + frontendBase := fmt.Sprintf("http://127.0.0.1:%d", frontendPort) + fmt.Printf("Waiting for frontend at %s ...\n", frontendBase) + if err := waitForHTTP(frontendBase, 60*time.Second); err != nil { + return fmt.Errorf("frontend failed to start: %w", err) + } + printSuccess("Frontend is ready!") + + // Run Playwright tests + fmt.Println("\n--- Running Playwright e2e tests ---") + playwrightArgs := []string{"test:e2e"} + if strings.TrimSpace(args) != "" { + playwrightArgs = append(playwrightArgs, strings.Fields(args)...) + } + playwrightCmd := exec.Command("pnpm", playwrightArgs...) + playwrightCmd.Dir = "frontend" + playwrightCmd.Env = append(os.Environ(), + fmt.Sprintf("API_URL=%s", apiBase), + fmt.Sprintf("BASE_URL=%s", frontendBase), + fmt.Sprintf("VIKUNJA_SERVICE_TESTINGTOKEN=%s", testingToken), + fmt.Sprintf("TEST_SECRET=%s", testingToken), + ) + playwrightCmd.Stdout = os.Stdout + playwrightCmd.Stderr = os.Stderr + + testErr := playwrightCmd.Run() + + if testErr != nil { + return fmt.Errorf("e2e tests failed: %w", testErr) + } + + printSuccess("All e2e tests passed!") + return nil +} + type Check mg.Namespace // GotSwag checks if the swagger docs need to be re-generated from the code annotations