From 5e793337401bd7c848cfb1edd8aa4f0e30931672 Mon Sep 17 00:00:00 2001 From: Terastar-Paperclip Date: Mon, 18 May 2026 23:35:43 -0400 Subject: [PATCH 1/2] test(qua-37): add cronoclient + CLI binary E2E smoke against httptest fake cronoapi has full storyboard tests, but cronoclient and the cobra binary had zero coverage post Phase 3b. Track A of the QUA-37 release gate: - internal/cronotest: shared permissive httptest fake (login + GWT-RPC + /export) reusable across cronoclient unit tests and binary E2E. - internal/cronoclient/client_test.go: drives all five export methods (Servings/Exercises/Biometrics/Nutrition/Notes) through the wrapper and through a real login/logout round-trip; pins shape of the typed records and the CSV->[]map[string]string conversion. - cmd/clie2e_test.go: builds the binary in TestMain and execs it against the fake; covers nutrition --format json, nutrition --format markdown, servings --format json, --format xml (rejected), auth status (no creds), and prime (no network). - cronoclient.NewLoggedIn honors CRONOMETER_BASE_URL so the binary E2E can point at httptest. Production users never set it. Track B (manual real-account probe) still required before v1.1.0 -- the fake validates wire shape we *believe* we authored, not what cronometer.com actually serves today. Refs QUA-37. Co-Authored-By: Paperclip --- cmd/clie2e_test.go | 194 ++++++++++++++++++++ internal/cronoclient/client.go | 7 + internal/cronoclient/client_e2e_test.go | 233 ++++++++++++++++++++++++ internal/cronotest/fake.go | 148 +++++++++++++++ 4 files changed, 582 insertions(+) create mode 100644 cmd/clie2e_test.go create mode 100644 internal/cronoclient/client_e2e_test.go create mode 100644 internal/cronotest/fake.go diff --git a/cmd/clie2e_test.go b/cmd/clie2e_test.go new file mode 100644 index 0000000..a229daa --- /dev/null +++ b/cmd/clie2e_test.go @@ -0,0 +1,194 @@ +package cmd_test + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/quantcli/crono-export-cli/internal/cronotest" +) + +// binPath is the compiled crono-export binary; built once in TestMain +// so individual test cases can exec it as a subprocess. +var binPath string + +func TestMain(m *testing.M) { + tmpDir, err := os.MkdirTemp("", "crono-export-e2e") + if err != nil { + panic(err) + } + defer os.RemoveAll(tmpDir) + + exe := "crono-export" + if runtime.GOOS == "windows" { + exe += ".exe" + } + binPath = filepath.Join(tmpDir, exe) + + // Build from the repo root (parent of cmd/). + build := exec.Command("go", "build", "-o", binPath, "..") + build.Stdout = os.Stdout + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +func runCLI(t *testing.T, env []string, args ...string) (stdout, stderr string, exitCode int) { + t.Helper() + cmd := exec.Command(binPath, args...) + cmd.Env = append(os.Environ(), env...) + var sout, serr bytes.Buffer + cmd.Stdout = &sout + cmd.Stderr = &serr + err := cmd.Run() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return sout.String(), serr.String(), ee.ExitCode() + } + t.Fatalf("exec %s: %v", binPath, err) + } + return sout.String(), serr.String(), 0 +} + +// fakeEnv returns the env-var overrides that point the CLI at a +// cronotest.Fake โ€” credentials are placeholders since the fake accepts +// anything that round-trips the CSRF token. +func fakeEnv(f *cronotest.Fake) []string { + return []string{ + "CRONOMETER_USERNAME=alice@example.com", + "CRONOMETER_PASSWORD=p@ssw0rd", + "CRONOMETER_BASE_URL=" + f.URL(), + } +} + +func TestCLI_AuthStatus_NoCreds_Fails(t *testing.T) { + // auth status is a local check; no network. With empty env it must + // exit non-zero. + _, stderr, code := runCLI(t, + []string{"CRONOMETER_USERNAME=", "CRONOMETER_PASSWORD="}, + "auth", "status", + ) + if code == 0 { + t.Fatalf("auth status with no creds should exit non-zero (stderr=%q)", stderr) + } + if !strings.Contains(stderr, "CRONOMETER_USERNAME") { + t.Errorf("stderr should mention missing env var; got %q", stderr) + } +} + +func TestCLI_Nutrition_JSON_E2E(t *testing.T) { + f := cronotest.New() + defer f.Close() + f.DailySummaryCSV = "Date,Calories,Protein\n2026-05-04,1800,90\n2026-05-05,2000,110\n" + + stdout, stderr, code := runCLI(t, fakeEnv(f), + "nutrition", + "--since", "2026-05-04", + "--until", "2026-05-11", + "--format", "json", + ) + if code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr) + } + var rows []map[string]any + if err := json.Unmarshal([]byte(stdout), &rows); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n--- stdout ---\n%s", err, stdout) + } + if len(rows) != 2 { + t.Fatalf("got %d rows, want 2: %+v", len(rows), rows) + } + if rows[0]["Calories"] != 1800.0 || rows[1]["Protein"] != 110.0 { + t.Errorf("rows = %+v", rows) + } +} + +func TestCLI_Nutrition_Markdown_E2E(t *testing.T) { + f := cronotest.New() + defer f.Close() + f.DailySummaryCSV = "Date,Calories,Protein\n2026-05-04,1800,90\n" + + stdout, stderr, code := runCLI(t, fakeEnv(f), + "nutrition", + "--since", "2026-05-04", + "--until", "2026-05-04", + "--format", "markdown", + ) + if code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr) + } + if !strings.Contains(stdout, "## 2026-05-04") { + t.Errorf("expected date header in markdown; got %q", stdout) + } + if !strings.Contains(stdout, "Calories: 1800") || !strings.Contains(stdout, "Protein: 90") { + t.Errorf("expected nutrient bullets in markdown; got %q", stdout) + } +} + +func TestCLI_Servings_JSON_E2E(t *testing.T) { + f := cronotest.New() + defer f.Close() + f.ServingsCSV = strings.Join([]string{ + `Day,Group,Food Name,Amount,Energy (kcal),Protein (g)`, + `2026-05-04,Breakfast,Apple,150 g,78,0.4`, + }, "\n") + + stdout, stderr, code := runCLI(t, fakeEnv(f), + "servings", + "--since", "2026-05-04", + "--until", "2026-05-04", + "--format", "json", + ) + if code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr) + } + var recs []map[string]any + if err := json.Unmarshal([]byte(stdout), &recs); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n--- stdout ---\n%s", err, stdout) + } + if len(recs) != 1 { + t.Fatalf("got %d records, want 1", len(recs)) + } + if recs[0]["FoodName"] != "Apple" { + t.Errorf("recs[0].FoodName = %v, want Apple", recs[0]["FoodName"]) + } +} + +func TestCLI_BadFormat_Exits1(t *testing.T) { + f := cronotest.New() + defer f.Close() + + _, stderr, code := runCLI(t, fakeEnv(f), + "nutrition", + "--since", "2026-05-04", + "--until", "2026-05-04", + "--format", "xml", + ) + if code == 0 { + t.Fatalf("--format xml should fail; stderr=%q", stderr) + } + if !strings.Contains(stderr, "unknown --format") { + t.Errorf("stderr should mention --format; got %q", stderr) + } +} + +func TestCLI_Prime_NoNetwork(t *testing.T) { + // `prime` is a local orientation dump per CONTRACT ยง6; it must not + // require credentials or hit the network. + stdout, stderr, code := runCLI(t, + []string{"CRONOMETER_USERNAME=", "CRONOMETER_PASSWORD="}, + "prime", + ) + if code != 0 { + t.Fatalf("prime should succeed without creds; exit=%d stderr=%q", code, stderr) + } + if !strings.Contains(stdout, "crono-export") { + t.Errorf("prime output should mention the binary name; got %q", stdout) + } +} diff --git a/internal/cronoclient/client.go b/internal/cronoclient/client.go index 683aa8e..f6f6d44 100644 --- a/internal/cronoclient/client.go +++ b/internal/cronoclient/client.go @@ -35,6 +35,10 @@ type Client struct { // case โ€” even with a cache hit we hold the password so the wrapper // can transparently re-login if the cached session turns out to be // stale. +// +// If CRONOMETER_BASE_URL is set, it overrides the production Cronometer +// host. This is intended for the agent-runnable E2E tests in this repo; +// real users never need to set it. func NewLoggedIn(ctx context.Context) (*Client, error) { user := os.Getenv("CRONOMETER_USERNAME") pass := os.Getenv("CRONOMETER_PASSWORD") @@ -42,6 +46,9 @@ func NewLoggedIn(ctx context.Context) (*Client, error) { return nil, fmt.Errorf("CRONOMETER_USERNAME and CRONOMETER_PASSWORD must be set") } inner := cronoapi.NewClient(nil) + if base := os.Getenv("CRONOMETER_BASE_URL"); base != "" { + inner.SetBaseURL(base) + } c := &Client{inner: inner, user: user, pass: pass} if cacheEnabled() { diff --git a/internal/cronoclient/client_e2e_test.go b/internal/cronoclient/client_e2e_test.go new file mode 100644 index 0000000..b751ab0 --- /dev/null +++ b/internal/cronoclient/client_e2e_test.go @@ -0,0 +1,233 @@ +package cronoclient_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/quantcli/crono-export-cli/internal/cronoapi" + "github.com/quantcli/crono-export-cli/internal/cronoclient" + "github.com/quantcli/crono-export-cli/internal/cronotest" +) + +// withFake spins up a cronotest.Fake and wires the env/base-URL hook +// that cronoclient.NewLoggedIn honors. +func withFake(t *testing.T) *cronotest.Fake { + t.Helper() + f := cronotest.New() + t.Cleanup(f.Close) + t.Setenv("CRONOMETER_USERNAME", "alice@example.com") + t.Setenv("CRONOMETER_PASSWORD", "p@ssw0rd") + t.Setenv("CRONOMETER_BASE_URL", f.URL()) + t.Setenv("CRONOMETER_NO_CACHE", "1") + return f +} + +// fixedRange is a stable test window; cronoclient just forwards Start/End +// to cronoapi, so the dates don't need to match anything in the canned CSV. +func fixedRange() cronoclient.DateRange { + return cronoclient.DateRange{ + Start: time.Date(2026, 5, 4, 0, 0, 0, 0, time.Local), + End: time.Date(2026, 5, 11, 0, 0, 0, 0, time.Local), + } +} + +func TestNewLoggedIn_RequiresCredentials(t *testing.T) { + t.Setenv("CRONOMETER_USERNAME", "") + t.Setenv("CRONOMETER_PASSWORD", "") + if _, err := cronoclient.NewLoggedIn(context.Background()); err == nil { + t.Fatal("expected error when env credentials missing") + } +} + +func TestNewLoggedIn_HonorsBaseURLEnv(t *testing.T) { + f := withFake(t) + c, err := cronoclient.NewLoggedIn(context.Background()) + if err != nil { + t.Fatalf("NewLoggedIn: %v", err) + } + defer c.Logout() + if f.LoginPosts != 1 { + t.Errorf("LoginPosts = %d, want 1 (BASE_URL hook may not be wired)", f.LoginPosts) + } +} + +func TestClient_Servings_E2E(t *testing.T) { + f := withFake(t) + f.ServingsCSV = strings.Join([]string{ + `Day,Group,Food Name,Amount,Category,Energy (kcal),Protein (g)`, + `2026-05-04,Breakfast,"Oats, rolled",50.00 g,Cereal,194,6.5`, + `2026-05-04,Breakfast,"Milk, whole",250 ml,Dairy,150,8`, + }, "\n") + + c, err := cronoclient.NewLoggedIn(context.Background()) + if err != nil { + t.Fatalf("NewLoggedIn: %v", err) + } + defer c.Logout() + + got, err := c.Servings(context.Background(), fixedRange()) + if err != nil { + t.Fatalf("Servings: %v", err) + } + recs, ok := got.(cronoapi.ServingRecords) + if !ok { + t.Fatalf("Servings returned %T, want cronoapi.ServingRecords", got) + } + if len(recs) != 2 { + t.Fatalf("got %d records, want 2", len(recs)) + } + if recs[0].FoodName != "Oats, rolled" || recs[0].EnergyKcal != 194 || recs[0].ProteinG != 6.5 { + t.Errorf("recs[0] = %+v", recs[0]) + } + if recs[1].FoodName != "Milk, whole" || recs[1].QuantityValue != 250 { + t.Errorf("recs[1] = %+v", recs[1]) + } +} + +func TestClient_Exercises_E2E(t *testing.T) { + f := withFake(t) + f.ExercisesCSV = strings.Join([]string{ + `Day,Exercise,Minutes,Calories Burned,Category`, + `2026-05-05,Running,30,310,Cardio`, + `2026-05-06,Yoga,45,150,Flexibility`, + }, "\n") + + c, err := cronoclient.NewLoggedIn(context.Background()) + if err != nil { + t.Fatalf("NewLoggedIn: %v", err) + } + defer c.Logout() + + got, err := c.Exercises(context.Background(), fixedRange()) + if err != nil { + t.Fatalf("Exercises: %v", err) + } + recs, ok := got.(cronoapi.ExerciseRecords) + if !ok { + t.Fatalf("Exercises returned %T, want cronoapi.ExerciseRecords", got) + } + if len(recs) != 2 || recs[0].Exercise != "Running" || recs[0].Minutes != 30 { + t.Fatalf("unexpected exercises: %+v", recs) + } +} + +func TestClient_Biometrics_E2E(t *testing.T) { + f := withFake(t) + f.BiometricsCSV = strings.Join([]string{ + `Day,Metric,Amount,Unit`, + `2026-05-04,Weight,180.5,lbs`, + `2026-05-04,Body Fat,18.2,%`, + }, "\n") + + c, err := cronoclient.NewLoggedIn(context.Background()) + if err != nil { + t.Fatalf("NewLoggedIn: %v", err) + } + defer c.Logout() + + got, err := c.Biometrics(context.Background(), fixedRange()) + if err != nil { + t.Fatalf("Biometrics: %v", err) + } + recs, ok := got.(cronoapi.BiometricRecords) + if !ok { + t.Fatalf("Biometrics returned %T, want cronoapi.BiometricRecords", got) + } + if len(recs) != 2 || recs[0].Metric != "Weight" || recs[1].Unit != "%" { + t.Fatalf("unexpected biometrics: %+v", recs) + } +} + +func TestClient_Nutrition_CSVToObjects_E2E(t *testing.T) { + f := withFake(t) + f.DailySummaryCSV = "Date,Calories,Protein\n2026-05-04,1800,90\n2026-05-05,2000,110\n" + + c, err := cronoclient.NewLoggedIn(context.Background()) + if err != nil { + t.Fatalf("NewLoggedIn: %v", err) + } + defer c.Logout() + + got, err := c.Nutrition(context.Background(), fixedRange()) + if err != nil { + t.Fatalf("Nutrition: %v", err) + } + rows, ok := got.([]map[string]any) + if !ok { + t.Fatalf("Nutrition returned %T, want []map[string]any", got) + } + if len(rows) != 2 { + t.Fatalf("got %d rows, want 2", len(rows)) + } + if rows[0]["Calories"] != 1800.0 || rows[0]["Protein"] != 90.0 { + t.Errorf("rows[0] = %+v", rows[0]) + } + if rows[1]["Date"] != "2026-05-05" { + t.Errorf("rows[1] = %+v", rows[1]) + } +} + +func TestClient_Notes_CSVToObjects_E2E(t *testing.T) { + f := withFake(t) + f.NotesCSV = "Day,Note\n2026-05-04,Slept poorly\n" + + c, err := cronoclient.NewLoggedIn(context.Background()) + if err != nil { + t.Fatalf("NewLoggedIn: %v", err) + } + defer c.Logout() + + got, err := c.Notes(context.Background(), fixedRange()) + if err != nil { + t.Fatalf("Notes: %v", err) + } + rows, ok := got.([]map[string]any) + if !ok { + t.Fatalf("Notes returned %T, want []map[string]any", got) + } + if len(rows) != 1 || rows[0]["Note"] != "Slept poorly" { + t.Errorf("rows = %+v", rows) + } +} + +func TestClient_AllExportsInOneSession(t *testing.T) { + f := withFake(t) + f.ServingsCSV = "Day,Food Name\n2026-05-04,Apple\n" + f.ExercisesCSV = "Day,Exercise,Minutes,Calories Burned,Category\n" + f.BiometricsCSV = "Day,Metric,Amount,Unit\n" + f.DailySummaryCSV = "Date,Calories\n2026-05-04,1800\n" + f.NotesCSV = "Day,Note\n" + + c, err := cronoclient.NewLoggedIn(context.Background()) + if err != nil { + t.Fatalf("NewLoggedIn: %v", err) + } + defer c.Logout() + rng := fixedRange() + if _, err := c.Servings(context.Background(), rng); err != nil { + t.Fatal(err) + } + if _, err := c.Exercises(context.Background(), rng); err != nil { + t.Fatal(err) + } + if _, err := c.Biometrics(context.Background(), rng); err != nil { + t.Fatal(err) + } + if _, err := c.Nutrition(context.Background(), rng); err != nil { + t.Fatal(err) + } + if _, err := c.Notes(context.Background(), rng); err != nil { + t.Fatal(err) + } + wantSeen := []string{"servings", "exercises", "biometrics", "dailySummary", "notes"} + if len(f.ExportRequests) != len(wantSeen) { + t.Fatalf("ExportRequests = %v, want %v", f.ExportRequests, wantSeen) + } + for i, w := range wantSeen { + if f.ExportRequests[i] != w { + t.Errorf("ExportRequests[%d] = %q, want %q", i, f.ExportRequests[i], w) + } + } +} diff --git a/internal/cronotest/fake.go b/internal/cronotest/fake.go new file mode 100644 index 0000000..758e6bd --- /dev/null +++ b/internal/cronotest/fake.go @@ -0,0 +1,148 @@ +// Package cronotest provides a permissive httptest stand-in for +// cronometer.com used by cronoclient and CLI-binary E2E tests. +// +// It implements the four endpoints the clean-room client touches: +// +// - GET /login/ anti-CSRF bootstrap +// - POST /login credential submit +// - POST /cronometer/app GWT-RPC (authenticate, generateAuthorizationToken, logout) +// - GET /export CSV export, keyed by ?generate= +// +// The cronoapi package's own tests pin the strict wire shape +// (GWT headers, framing strings, CSV parsing). This fake is deliberately +// looser โ€” it accepts any permutation/auth token, returns canned CSV +// bodies, and exists so the cronoclient wrapper and the CLI binary can +// be exercised end-to-end without hitting the real Cronometer host. +package cronotest + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" +) + +// Fake is an httptest.Server playing the role of cronometer.com. +type Fake struct { + Server *httptest.Server + + // Canned CSV bodies served by /export per `generate=` query. + ServingsCSV string + ExercisesCSV string + BiometricsCSV string + DailySummaryCSV string + NotesCSV string + + // Test-observable state. + LoginPosts int + GWTPosts int + ExportRequests []string // captured /export ?generate= values + + csrfToken string + authToken string + userID int +} + +// New starts an httptest server with sensible defaults and returns the +// Fake. The caller is responsible for closing it (typically via t.Cleanup). +func New() *Fake { + f := &Fake{ + csrfToken: "abcdef0123456789abcdef0123456789", + authToken: "11112222333344445555666677778888", + userID: 7654321, + } + mux := http.NewServeMux() + mux.HandleFunc("/login/", f.handleLoginGet) + mux.HandleFunc("/login", f.handleLoginPost) + mux.HandleFunc("/cronometer/app", f.handleGWT) + mux.HandleFunc("/export", f.handleExport) + f.Server = httptest.NewServer(mux) + return f +} + +// URL returns the base URL of the underlying httptest server. +func (f *Fake) URL() string { return f.Server.URL } + +// Close shuts down the underlying httptest server. +func (f *Fake) Close() { f.Server.Close() } + +func (f *Fake) handleLoginGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + http.SetCookie(w, &http.Cookie{Name: "session", Value: "s1", Path: "/"}) + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + fmt.Fprintf(w, `
`, f.csrfToken) +} + +func (f *Fake) handleLoginPost(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + f.LoginPosts++ + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if r.PostFormValue("anticsrf") != f.csrfToken { + w.Header().Set("Content-Type", "application/json;charset=UTF-8") + _, _ = w.Write([]byte(`{"error":"AntiCSRF Token Invalid"}`)) + return + } + http.SetCookie(w, &http.Cookie{Name: "auth", Value: "ok", Path: "/"}) + w.Header().Set("Content-Type", "application/json;charset=UTF-8") + _, _ = w.Write([]byte(`{"ok":true,"user":"x"}`)) +} + +func (f *Fake) handleGWT(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + f.GWTPosts++ + body, _ := io.ReadAll(r.Body) + bodyStr := string(body) + switch { + case strings.Contains(bodyStr, "|authenticate|"): + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, + `//OK[%d,1,2,3,"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","%s","unused"]`, + f.userID, f.authToken, + ) + case strings.Contains(bodyStr, "|generateAuthorizationToken|"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`//OK[1,["cccccccccccccccccccccccccccccccc"],0,7]`)) + case strings.Contains(bodyStr, "|logout|"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`//OK[1,0,7]`)) + default: + http.Error(w, "unknown GWT method", http.StatusBadRequest) + } +} + +func (f *Fake) handleExport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + gen := r.URL.Query().Get("generate") + f.ExportRequests = append(f.ExportRequests, gen) + w.Header().Set("Content-Type", "text/csv") + switch gen { + case "servings": + _, _ = io.WriteString(w, f.ServingsCSV) + case "exercises": + _, _ = io.WriteString(w, f.ExercisesCSV) + case "biometrics": + _, _ = io.WriteString(w, f.BiometricsCSV) + case "dailySummary": + _, _ = io.WriteString(w, f.DailySummaryCSV) + case "notes": + _, _ = io.WriteString(w, f.NotesCSV) + default: + http.Error(w, "unknown generate type", http.StatusBadRequest) + } +} From 69afb698aa6cb34301b89e2add87171995ea04fd Mon Sep 17 00:00:00 2001 From: Terastar-Paperclip Date: Mon, 18 May 2026 23:54:05 -0400 Subject: [PATCH 2/2] test(qua-37): isolate session cache in CLI E2E subprocesses The cmd/clie2e_test.go runCLI helper inherited os.Environ() directly, so each subprocess wrote to the developer's real session cache (~/.cache/crono-export/session.json on Linux, ~/Library/Caches on macOS, %LocalAppData% on Windows). That polluted dev caches with fake "alice@example.com" tokens and let a pre-existing real session mask the fake-login path the tests intend to exercise. Mirror session_test.go's redirect: per-test t.TempDir() with HOME, XDG_CACHE_HOME, and LOCALAPPDATA overridden, applied before the caller's env so a test can still override if it ever needs to. Co-Authored-By: Paperclip --- cmd/clie2e_test.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cmd/clie2e_test.go b/cmd/clie2e_test.go index a229daa..1103c16 100644 --- a/cmd/clie2e_test.go +++ b/cmd/clie2e_test.go @@ -42,8 +42,20 @@ func TestMain(m *testing.M) { func runCLI(t *testing.T, env []string, args ...string) (stdout, stderr string, exitCode int) { t.Helper() + // Redirect every cache-dir env var the binary might consult to a + // per-test temp dir, mirroring session_test.go's in-process + // redirect. Without this the subprocess inherits the developer's + // real HOME/XDG_CACHE_HOME/LOCALAPPDATA and can write a fake + // session into ~/.cache/crono-export/, clobbering the real cache + // and letting a pre-existing real session mask the fake-login path. + cacheDir := t.TempDir() + isolation := []string{ + "HOME=" + cacheDir, + "XDG_CACHE_HOME=" + cacheDir, + "LOCALAPPDATA=" + cacheDir, + } cmd := exec.Command(binPath, args...) - cmd.Env = append(os.Environ(), env...) + cmd.Env = append(append(os.Environ(), isolation...), env...) var sout, serr bytes.Buffer cmd.Stdout = &sout cmd.Stderr = &serr