From c3e3494963329144daa4b2be97df35b544552b13 Mon Sep 17 00:00:00 2001 From: sofq Date: Sat, 18 Apr 2026 14:54:02 +0700 Subject: [PATCH 1/2] ci: restore full test coverage and remove duplicate e2e stub CI was only running ./internal/..., ./gen/..., and ./test/e2e/ - skipping ./cmd/... (all unit tests) and the canonical root e2e_test.go (100 tests). Expanded the test invocation to cover those packages, bringing ~1066 tests back under CI. Also removed test/e2e/e2e_test.go (8 tests, all duplicates of root e2e_test.go coverage) and the now-empty test/e2e/ directory. --- .github/workflows/ci.yml | 2 +- test/e2e/e2e_test.go | 194 --------------------------------------- 2 files changed, 1 insertion(+), 195 deletions(-) delete mode 100644 test/e2e/e2e_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 932e67f..e3694d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: run: go build ./... - name: Unit tests - run: go test ./internal/... ./gen/... ./test/e2e/ -v -coverprofile=coverage.out -covermode=atomic + run: go test ./cmd/... ./internal/... ./gen/... . -v -coverprofile=coverage.out -covermode=atomic - name: Upload coverage to Codecov uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go deleted file mode 100644 index e8a9d64..0000000 --- a/test/e2e/e2e_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package e2e_test - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "strings" - "testing" -) - -// buildBinary builds the jr binary for testing. -func buildBinary(t *testing.T) string { - t.Helper() - binary := t.TempDir() + "/jr" - cmd := exec.Command("go", "build", "-o", binary, "../../.") - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - t.Fatalf("failed to build jr: %v", err) - } - return binary -} - -// runJR executes jr with the given args against a test server. -func runJR(t *testing.T, binary, serverURL string, args ...string) (stdout, stderr string, exitCode int) { - t.Helper() - fullArgs := append([]string{"--base-url", serverURL, "--auth-type", "bearer", "--auth-token", "test"}, args...) - cmd := exec.Command(binary, fullArgs...) - var outBuf, errBuf strings.Builder - cmd.Stdout = &outBuf - cmd.Stderr = &errBuf - err := cmd.Run() - exitCode = 0 - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - t.Fatalf("failed to run jr: %v", err) - } - } - return outBuf.String(), errBuf.String(), exitCode -} - -func TestE2E_IssueGet(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/rest/api/3/issue/TEST-1" { - w.Header().Set("Content-Type", "application/json") - fmt.Fprintln(w, `{"key":"TEST-1","fields":{"summary":"Test issue"}}`) - return - } - w.WriteHeader(404) - })) - defer ts.Close() - - binary := buildBinary(t) - stdout, _, exitCode := runJR(t, binary, ts.URL, "issue", "get", "--issueIdOrKey", "TEST-1") - if exitCode != 0 { - t.Fatalf("expected exit 0, got %d", exitCode) - } - var result map[string]any - if err := json.Unmarshal([]byte(stdout), &result); err != nil { - t.Fatalf("stdout not JSON: %s", stdout) - } - if result["key"] != "TEST-1" { - t.Errorf("expected key=TEST-1, got %v", result["key"]) - } -} - -func TestE2E_IssueGetWithJQ(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprintln(w, `{"key":"TEST-1","fields":{"summary":"Test"}}`) - })) - defer ts.Close() - - binary := buildBinary(t) - stdout, _, exitCode := runJR(t, binary, ts.URL, "issue", "get", "--issueIdOrKey", "TEST-1", "--jq", ".key") - if exitCode != 0 { - t.Fatalf("expected exit 0, got %d", exitCode) - } - if strings.TrimSpace(stdout) != `"TEST-1"` { - t.Errorf("expected \"TEST-1\", got %s", stdout) - } -} - -func TestE2E_NotFound(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - fmt.Fprintln(w, `{"errorMessages":["Issue not found"]}`) - })) - defer ts.Close() - - binary := buildBinary(t) - _, stderr, exitCode := runJR(t, binary, ts.URL, "issue", "get", "--issueIdOrKey", "NOPE-1") - if exitCode != 3 { - t.Fatalf("expected exit 3, got %d", exitCode) - } - var errObj map[string]any - if err := json.Unmarshal([]byte(stderr), &errObj); err != nil { - t.Fatalf("stderr not JSON: %s", stderr) - } - if errObj["error_type"] != "not_found" { - t.Errorf("expected error_type=not_found, got %v", errObj["error_type"]) - } -} - -func TestE2E_DryRun(t *testing.T) { - binary := buildBinary(t) - stdout, _, exitCode := runJR(t, binary, "https://fake.atlassian.net", "--dry-run", "issue", "get", "--issueIdOrKey", "X-1") - if exitCode != 0 { - t.Fatalf("expected exit 0, got %d", exitCode) - } - var obj map[string]string - if err := json.Unmarshal([]byte(stdout), &obj); err != nil { - t.Fatalf("stdout not JSON: %s", stdout) - } - if obj["method"] != "GET" { - t.Errorf("expected method=GET, got %s", obj["method"]) - } -} - -func TestE2E_SchemaList(t *testing.T) { - binary := buildBinary(t) - // schema doesn't need auth - cmd := exec.Command(binary, "schema", "--list") - out, err := cmd.Output() - if err != nil { - t.Fatalf("schema --list failed: %v", err) - } - var resources []string - if err := json.Unmarshal(out, &resources); err != nil { - t.Fatalf("stdout not JSON array: %s", out) - } - if len(resources) < 10 { - t.Errorf("expected many resources, got %d", len(resources)) - } -} - -func TestE2E_SchemaCompact(t *testing.T) { - binary := buildBinary(t) - cmd := exec.Command(binary, "schema", "--compact") - out, err := cmd.Output() - if err != nil { - t.Fatalf("schema --compact failed: %v", err) - } - var compact map[string][]string - if err := json.Unmarshal(out, &compact); err != nil { - t.Fatalf("stdout not JSON object: %s", out) - } - if len(compact) < 10 { - t.Errorf("expected many resources, got %d", len(compact)) - } - // Check that issue resource has verbs - if verbs, ok := compact["issue"]; !ok || len(verbs) == 0 { - t.Error("expected issue resource with verbs") - } -} - -func TestE2E_Version(t *testing.T) { - binary := buildBinary(t) - cmd := exec.Command(binary, "version") - out, err := cmd.Output() - if err != nil { - t.Fatalf("version failed: %v", err) - } - var obj map[string]string - if err := json.Unmarshal(out, &obj); err != nil { - t.Fatalf("not JSON: %s", out) - } - if _, ok := obj["version"]; !ok { - t.Error("missing version field") - } -} - -func TestE2E_FieldsParam(t *testing.T) { - var gotFields string - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotFields = r.URL.Query().Get("fields") - w.Header().Set("Content-Type", "application/json") - fmt.Fprintln(w, `{"key":"TEST-1"}`) - })) - defer ts.Close() - - binary := buildBinary(t) - _, _, exitCode := runJR(t, binary, ts.URL, "--fields", "key,summary", "issue", "get", "--issueIdOrKey", "TEST-1") - if exitCode != 0 { - t.Fatalf("expected exit 0, got %d", exitCode) - } - if gotFields != "key,summary" { - t.Errorf("expected fields=key,summary, got %q", gotFields) - } -} From 59889f9fb64c84c0c60d95a9bf3c6064daab33eb Mon Sep 17 00:00:00 2001 From: sofq Date: Sat, 18 Apr 2026 15:04:50 +0700 Subject: [PATCH 2/2] test: fix platform-specific failures exposed by expanded CI coverage Five tests passed on macOS but failed on Linux CI once they started running. Root causes: - Three template-related cmd tests (TestRunTemplateShow/List/Apply _LookupError) and TestBatchTemplateApply_LookupError rely on os.UserConfigDir() resolving into t.TempDir(). On Linux it reads XDG_CONFIG_HOME first, which on CI points outside the sandbox, so the sentinel file meant to trigger a ReadDir error was never consulted. Pin XDG_CONFIG_HOME to a path inside tmpHome. - TestBatchTemplateApply_LookupError only wrote the macOS sentinel path. Also write the Linux path (.config/jr/templates). - internal/template TestList read the real user templates dir with no sandboxing, picking up leftover state on CI runners. Override userTemplatesDir to a clean t.TempDir() for the duration of the test. --- cmd/batch_test.go | 28 ++++++++++++++++++---------- cmd/template_test.go | 5 +++++ internal/template/template_test.go | 6 ++++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/cmd/batch_test.go b/cmd/batch_test.go index 920a4a8..91317e5 100644 --- a/cmd/batch_test.go +++ b/cmd/batch_test.go @@ -4027,16 +4027,24 @@ func TestRunBatch_MaxExitPropagated(t *testing.T) { func TestBatchTemplateApply_LookupError(t *testing.T) { tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) - - // On macOS, os.UserConfigDir() returns $HOME/Library/Application Support. - // Create a regular file where the templates directory should be, so - // os.ReadDir fails with "not a directory". - templatesPath := filepath.Join(tmpHome, "Library", "Application Support", "jr", "templates") - if err := os.MkdirAll(filepath.Dir(templatesPath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(templatesPath, []byte("not a dir"), 0o644); err != nil { - t.Fatal(err) + // Pin XDG_CONFIG_HOME so os.UserConfigDir() resolves into tmpHome on Linux + // regardless of the runner's ambient XDG_CONFIG_HOME value. + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpHome, ".config")) + + // os.UserConfigDir() is platform-specific: macOS → $HOME/Library/Application + // Support, Linux → $XDG_CONFIG_HOME (or $HOME/.config). Write the sentinel + // at both paths so os.ReadDir fails with "not a directory" regardless of OS. + for _, rel := range []string{ + "Library/Application Support/jr/templates", + ".config/jr/templates", + } { + path := filepath.Join(tmpHome, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("not a dir"), 0o644); err != nil { + t.Fatal(err) + } } var stdout, stderr bytes.Buffer diff --git a/cmd/template_test.go b/cmd/template_test.go index e22b7de..26a9a24 100644 --- a/cmd/template_test.go +++ b/cmd/template_test.go @@ -889,6 +889,9 @@ func TestRunTemplateShow_LookupConfigError(t *testing.T) { // expected, so that os.ReadDir returns a non-ErrNotExist error. tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) + // Pin XDG_CONFIG_HOME; on Linux os.UserConfigDir reads it first and would + // otherwise resolve outside tmpHome on CI runners. + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpHome, ".config")) // On macOS UserConfigDir returns $HOME/Library/Application Support. // On Linux it returns $HOME/.config. Create a file at both possible paths @@ -970,6 +973,7 @@ func TestRunTemplateCreate_NoClientWithFrom(t *testing.T) { func TestRunTemplateList_ListError(t *testing.T) { tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpHome, ".config")) for _, rel := range []string{ "Library/Application Support/jr/templates", ".config/jr/templates", @@ -1013,6 +1017,7 @@ func TestRunTemplateList_ListError(t *testing.T) { func TestRunTemplateApply_LookupError(t *testing.T) { tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpHome, ".config")) for _, rel := range []string{ "Library/Application Support/jr/templates", ".config/jr/templates", diff --git a/internal/template/template_test.go b/internal/template/template_test.go index 406f2e1..f48c730 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -160,6 +160,12 @@ func TestRenderFieldsEmptyOmitted(t *testing.T) { } func TestList(t *testing.T) { + // Sandbox user templates to a clean empty dir so List() returns only + // builtin templates, regardless of whatever the runner's real HOME contains. + origDir := userTemplatesDir + userTemplatesDir = func() string { return t.TempDir() } + defer func() { userTemplatesDir = origDir }() + data, err := List() if err != nil { t.Fatalf("unexpected error: %v", err)