From 27a73940144018abd9ba0823bec176ec493e410b Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 31 Jul 2026 15:35:11 +0100 Subject: [PATCH] feat: waving flag while requests are in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes half of #52: a rippling Flagsmith flag on stderr for any request slow enough that a user would otherwise wonder whether the CLI had hung. It is gated on the CLI's own `loading_animation` flag, which means the CLI now evaluates flags about itself, through the same SDK `flagsmith evaluate` uses. Those flags live in Flagsmith's project, not the user's, so `internal/selfflags` is deliberately separate from everything the user configures: a baked-in client-side key (public by construction — every browser SDK ships one) always evaluated against Edge, never `--sdk-api-url` or `flagsmith.json`. It evaluates as an identity rather than taking the environment defaults, so a feature can be aimed at some installations and not others. The targeting key is a random id created on first use and kept in the config directory — not the cache, which is disposable, and re-rolling it would move an installation to the other side of every percentage rollout. Random rather than derived from the user or the machine: it has to be stable, and nothing more. Traits carry what a segment could usefully target — `cli.version`, `os`, `arch`, `is_saas`, and `organisation.id` when the context named one by id. Never by name: resolving that costs a request, and an organisation's name is its company's. The identity is stored rather than transient, so it can also be targeted from the dashboard — which does mean one identity per installation in the CLI's own project. The read path never touches the network, because a spinner cannot wait on the flag that decides whether to draw it. `Enabled` answers from a cache on disk and `Refresh` fills it in the background for the next invocation — abandoned if the process exits first, which only happens on commands too fast to have animated anything. A cold cache is off, matching how the flag was created. Nothing is evaluated when the answer cannot matter: no terminal on stderr (a pipe or a CI log), `FLAGSMITH_DEBUG` (whose trace line would fight a repainting one), or `FLAGSMITH_ANIMATION` set either way — an explicit local answer is also the opt-out for anyone who would rather the CLI did not ask about itself. The flag is drawn by wrapping the shared client's transport, so every request is covered without touching call sites, refcounted so concurrent requests raise one flag between them. It then *stands* between requests rather than being erased after each, or a command making several in a row would flicker; the frame counter is atomic and never reset, so the ripple resumes mid-wave. That needs an owner for the line: `Guard` wraps cobra's writers so the command's own output takes it back, prompts release it explicitly (huh in raw mode bypasses cobra), and Execute releases once more for a command that prints nothing. The cursor is hidden while a flag flies and restored by both of those paths and by a signal handler — hiding it outlives the process, so an interrupt must not leave a terminal with no cursor and no clue. Braille gives four rows to ripple through per line of text. The cloth is five cells of a gale — a wave six dots long against ten of flag — sampled at twenty phases with the stalls stripped, since rounding to whole dot rows makes five of them repeat their predecessor and a repeated frame reads as a hitch. No column moves more than one dot row between frames, which is the difference between rippling and flickering, and a test decodes the glyphs to hold the table to it. Each cell is lit by how high the cloth flies there, so the crests carry the light as the wave travels; colour is a function of the shape, not of the clock. lipgloss and termenv move from indirect to direct requirements — both were already in the module graph via huh, so go.sum is untouched. beep boop --- go.mod | 4 +- internal/cmd/animation.go | 111 +++++++ internal/cmd/client.go | 9 + internal/cmd/cmd_test.go | 231 ++++++++++++++ internal/cmd/context.go | 1 + internal/cmd/prompts.go | 4 + internal/cmd/root.go | 11 +- internal/selfflags/selfflags.go | 224 ++++++++++++++ internal/selfflags/selfflags_test.go | 432 ++++++++++++++++++++++++++ internal/spinner/spinner.go | 289 ++++++++++++++++++ internal/spinner/spinner_test.go | 438 +++++++++++++++++++++++++++ 11 files changed, 1751 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/animation.go create mode 100644 internal/selfflags/selfflags.go create mode 100644 internal/selfflags/selfflags_test.go create mode 100644 internal/spinner/spinner.go create mode 100644 internal/spinner/spinner_test.go diff --git a/go.mod b/go.mod index a7615ad..13bea8b 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,10 @@ go 1.26 require ( github.com/Flagsmith/flagsmith-go-client/v5 v5.1.0 github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/fatih/color v1.19.0 github.com/itchyny/gojq v0.12.19 + github.com/muesli/termenv v0.16.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 @@ -23,7 +25,6 @@ require ( github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -46,7 +47,6 @@ require ( github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/ohler55/ojg v1.28.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/internal/cmd/animation.go b/internal/cmd/animation.go new file mode 100644 index 0000000..2e1fef3 --- /dev/null +++ b/internal/cmd/animation.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "context" + "io" + "os" + "strconv" + "time" + + "golang.org/x/term" + + "github.com/Flagsmith/flagsmith-cli/v2/internal/selfflags" + "github.com/Flagsmith/flagsmith-cli/v2/internal/spinner" +) + +// envAnimation answers loading_animation locally, in either direction. +const envAnimation = "FLAGSMITH_ANIMATION" + +// selfFlagTimeout bounds the background evaluation. It is generous: nothing +// waits on it, and the only cost of it running long is a goroutine the process +// may exit out from under. +const selfFlagTimeout = 10 * time.Second + +// stderrIsTTY reports whether there is a terminal to animate on; tests stub it. +var stderrIsTTY = func() bool { + return term.IsTerminal(int(os.Stderr.Fd())) +} + +// animation decides whether requests raise a waving flag, and whether asking +// Flagsmith could change that answer next time. +// +// Both are false when the answer cannot matter: a pipe or a CI log has nothing +// to animate, and FLAGSMITH_DEBUG puts a trace line on stderr for every request, +// which a repainting flag would fight over. A local answer is also a final one — +// setting FLAGSMITH_ANIMATION is how someone who would rather the CLI did not +// ask about itself says so. +func animation() (draw, ask bool) { + if !stderrIsTTY() || envBool("FLAGSMITH_DEBUG") { + return false, false + } + if os.Getenv(envAnimation) != "" { + return envBool(envAnimation), false + } + return selfflags.Enabled(selfflags.LoadingAnimation), true +} + +// The flag for this invocation, and whether its value is worth refreshing. Both +// are decided once, in Execute: the decision reads a file and installs a signal +// handler, neither of which belongs in a code path a test may run in-process +// hundreds of times. +var ( + activeFlag *spinner.Spinner + refreshWant bool +) + +// animationOut is where the flag is drawn: stderr, which is where progress +// belongs. A var so tests can watch it. +var animationOut io.Writer = os.Stderr + +// startAnimation gives the flag the terminal, when there is one and it is +// wanted. cobra's writers are wrapped because the flag stands between requests +// rather than being erased after each: the command's own output is what takes the +// line back, whenever it has something to print. +func startAnimation() { + draw, ask := animation() + refreshWant = ask + if !draw { + return + } + activeFlag = spinner.New(animationOut) + // Both writers, or a standing flag outlives whichever one the command happens + // to print through. + rootCmd.SetOut(activeFlag.Guard(os.Stdout)) + rootCmd.SetErr(activeFlag.Guard(os.Stderr)) +} + +// releaseLine takes the line back from a standing flag. Called before anything +// that writes to the terminal without going through cobra — an interactive +// prompt — and once more before the process exits, for a command that printed +// nothing at all. +func releaseLine() { + if activeFlag != nil { + activeFlag.Release() + } +} + +// refreshSelfFlags evaluates the CLI's own flags in the background, for the next +// invocation to read. It builds its own client rather than sharing the command's, +// so the request cannot raise a flag about itself, and is abandoned if the +// process exits first — which only happens on commands too fast to have animated +// anything. +func refreshSelfFlags() { + aud := selfAudience + go func() { + ctx, cancel := context.WithTimeout(context.Background(), selfFlagTimeout) + defer cancel() + selfflags.Refresh(ctx, aud) //nolint:errcheck // best-effort, and nothing to report + }() +} + +// selfAudience is what the resolved context contributes to the CLI's own +// evaluation. +var selfAudience selfflags.Audience + +func noteAudience(pc *projectContext) { + aud := selfflags.Audience{IsSaas: pc.apiURL() == defaultAPIURL} + if id, ok := pc.Organisation.Value.(int); ok { + aud.Organisation = strconv.Itoa(id) + } + selfAudience = aud +} diff --git a/internal/cmd/client.go b/internal/cmd/client.go index b2701ac..e5620ba 100644 --- a/internal/cmd/client.go +++ b/internal/cmd/client.go @@ -25,6 +25,15 @@ var ( func sharedHTTPClient() *http.Client { httpClientOnce.Do(func() { httpClientMemo = httpx.New(userAgent()) + if activeFlag != nil { + httpClientMemo.Transport = activeFlag.Wrap(httpClientMemo.Transport) + } + // Reaching for the client is the CLI committing to network I/O, which is + // the only time the flag's value is worth evaluating: a command that never + // leaves the machine does not ask about itself either. + if refreshWant { + refreshSelfFlags() + } }) return httpClientMemo } diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index c0d3477..8bb5760 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -7625,3 +7626,233 @@ func TestRefreshPersistsToKeychain(t *testing.T) { t.Errorf("AccessToken = %q, want the refreshed token persisted", creds.AccessToken) } } + +// fakeStderrTTY makes the animation think it has a terminal to draw on. +func fakeStderrTTY(t *testing.T, on bool) { + t.Helper() + orig := stderrIsTTY + stderrIsTTY = func() bool { return on } + t.Cleanup(func() { stderrIsTTY = orig }) +} + +// cacheSelfFlag writes the CLI's own flag cache as selfflags reads it, standing +// in for a refresh that already happened. The shape is pinned in that package. +func cacheSelfFlag(t *testing.T, name string, enabled bool) { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CACHE_HOME", filepath.Join(tmp, ".cache")) + t.Setenv("LocalAppData", tmp) + dir, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "flagsmith"), 0o700); err != nil { + t.Fatal(err) + } + body := fmt.Sprintf(`{"flags":{%q:%t},"fetchedAt":%q}`, name, enabled, time.Now().Format(time.RFC3339)) + if err := os.WriteFile(filepath.Join(dir, "flagsmith", "selfflags.json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestAnimationDecision(t *testing.T) { + t.Run("without a terminal there is nothing to draw and nothing to ask", func(t *testing.T) { + // Given + cacheSelfFlag(t, "loading_animation", true) + fakeStderrTTY(t, false) + + // When + draw, ask := animation() + + // Then a piped or CI run neither animates nor evaluates + if draw || ask { + t.Errorf("animation() = (%t, %t), want (false, false)", draw, ask) + } + }) + + t.Run("FLAGSMITH_DEBUG keeps stderr to itself", func(t *testing.T) { + // Given + cacheSelfFlag(t, "loading_animation", true) + fakeStderrTTY(t, true) + t.Setenv("FLAGSMITH_DEBUG", "1") + + // When + draw, ask := animation() + + // Then the trace is not fighting a repainting line + if draw || ask { + t.Errorf("animation() = (%t, %t), want (false, false)", draw, ask) + } + }) + + t.Run("FLAGSMITH_ANIMATION=1 draws without asking", func(t *testing.T) { + // Given no cached evaluation at all + cacheSelfFlag(t, "something_else", true) + fakeStderrTTY(t, true) + t.Setenv("FLAGSMITH_ANIMATION", "1") + + // When + draw, ask := animation() + + // Then the local answer stands on its own + if !draw || ask { + t.Errorf("animation() = (%t, %t), want (true, false)", draw, ask) + } + }) + + t.Run("FLAGSMITH_ANIMATION=0 is the opt-out", func(t *testing.T) { + // Given the flag is on for this CLI + cacheSelfFlag(t, "loading_animation", true) + fakeStderrTTY(t, true) + t.Setenv("FLAGSMITH_ANIMATION", "0") + + // When + draw, ask := animation() + + // Then it neither draws nor phones home again to check + if draw || ask { + t.Errorf("animation() = (%t, %t), want (false, false)", draw, ask) + } + }) + + t.Run("otherwise the CLI's own flag decides", func(t *testing.T) { + // Given loading_animation is on in the cached evaluation + cacheSelfFlag(t, "loading_animation", true) + fakeStderrTTY(t, true) + + // When + draw, ask := animation() + + // Then it draws, and keeps the evaluation current + if !draw || !ask { + t.Errorf("animation() = (%t, %t), want (true, true)", draw, ask) + } + }) + + t.Run("a cold cache draws nothing but asks", func(t *testing.T) { + // Given a machine that has never evaluated the flag + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CACHE_HOME", filepath.Join(tmp, ".cache")) + t.Setenv("LocalAppData", tmp) + fakeStderrTTY(t, true) + + // When + draw, ask := animation() + + // Then the first run is plain, and the next one need not be + if draw { + t.Error("animation() drew on a cold cache") + } + if !ask { + t.Error("animation() did not ask on a cold cache") + } + }) +} + +// TestAnimationOwnsTheTerminalLine covers the wiring rather than the animation: +// the flag stands between requests, so something must take the line back before +// the command prints. A build where startAnimation ran but its writers were not +// installed left the flag stuck in front of the output. +func TestAnimationOwnsTheTerminalLine(t *testing.T) { + // Given an invocation that animates + cacheSelfFlag(t, "loading_animation", true) + fakeStderrTTY(t, true) + drawn := &bytes.Buffer{} + restoreAnimation(t, drawn) + + // When the animation is started + startAnimation() + + // Then a flag exists to be raised + if activeFlag == nil { + t.Fatal("startAnimation drew no flag with the flag enabled and a terminal") + } + // And neither of the command's writers is the bare stream any more: a flag + // standing on the line would never be cleared if either were + if got := rootCmd.OutOrStdout(); got == os.Stdout { + t.Error("command output writes straight to stdout, past the flag") + } + if got := rootCmd.ErrOrStderr(); got == os.Stderr { + t.Error("command errors write straight to stderr, past the flag") + } + // And output written through them still arrives + if _, err := fmt.Fprint(rootCmd.OutOrStdout(), ""); err != nil { + t.Errorf("writing through the guarded writer: %v", err) + } +} + +func TestAnimationLeavesWritersAloneWhenOff(t *testing.T) { + // Given a terminal but the flag turned off locally + cacheSelfFlag(t, "loading_animation", true) + fakeStderrTTY(t, true) + t.Setenv("FLAGSMITH_ANIMATION", "0") + drawn := &bytes.Buffer{} + restoreAnimation(t, drawn) + + // When the animation is started + startAnimation() + + // Then nothing was wrapped, and releasing the line is a no-op rather than a + // nil dereference + if activeFlag != nil { + t.Error("startAnimation drew a flag with the animation switched off") + } + releaseLine() + if got := drawn.String(); got != "" { + t.Errorf("wrote %q to the terminal with the animation off", got) + } +} + +// restoreAnimation points the flag at w and puts the package's animation state — +// and cobra's writers, which startAnimation replaces — back afterwards. +func restoreAnimation(t *testing.T, w io.Writer) { + t.Helper() + origOut, origWant, origFlag := animationOut, refreshWant, activeFlag + animationOut = w + t.Cleanup(func() { + animationOut, refreshWant, activeFlag = origOut, origWant, origFlag + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + }) +} + +func TestNoteAudience(t *testing.T) { + restore := selfAudience + t.Cleanup(func() { selfAudience = restore }) + + t.Run("a self-hosted instance and an organisation id", func(t *testing.T) { + // Given a context pointed at someone's own instance + pc := &projectContext{ + APIURL: resolved{Value: "https://flagsmith.corp.example"}, + Organisation: resolved{Value: 13}, + } + + // When it settles + noteAudience(pc) + + // Then both facts are available to target: the organisation, and that this + // is not Flagsmith's own instance + if got := selfAudience; got.Organisation != "13" || got.IsSaas { + t.Errorf("selfAudience = %+v, want organisation 13 and not SaaS", got) + } + }) + + t.Run("an organisation named rather than numbered is not sent", func(t *testing.T) { + // Given a context naming its organisation + pc := &projectContext{ + APIURL: resolved{Value: defaultAPIURL}, + Organisation: resolved{Value: "Acme Corp"}, + } + + // When it settles + noteAudience(pc) + + // Then the name is left behind — resolving it costs a request, and it is + // the company's name. The default instance is Flagsmith's own. + if got := selfAudience; got.Organisation != "" || !got.IsSaas { + t.Errorf("selfAudience = %+v, want no organisation and SaaS", got) + } + }) +} diff --git a/internal/cmd/context.go b/internal/cmd/context.go index 949b0ca..1c368d3 100644 --- a/internal/cmd/context.go +++ b/internal/cmd/context.go @@ -188,5 +188,6 @@ func applyContext(cmd *cobra.Command) (*projectContext, error) { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", w) } apiURL = pc.apiURL() + noteAudience(pc) return pc, nil } diff --git a/internal/cmd/prompts.go b/internal/cmd/prompts.go index 4a7631f..f60a840 100644 --- a/internal/cmd/prompts.go +++ b/internal/cmd/prompts.go @@ -44,6 +44,10 @@ func initPrompts(cmd *cobra.Command) { } func promptIO(cmd *cobra.Command) prompt.IO { + // A prompt takes over the terminal, and in raw mode writes to it directly + // rather than through cobra, so it cannot be trusted to clear a standing flag + // on its own. + releaseLine() return prompt.IO{In: promptIn, ErrOut: cmd.ErrOrStderr(), RawTTY: rawTerminal()} } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b5501b1..64a4fde 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -172,6 +172,10 @@ func nudgeInit(cmd *cobra.Command) bool { func Execute() { prepare() + startAnimation() + // Idempotent, and the last word on a hidden cursor: the explicit calls below + // handle the paths that end in os.Exit, this one handles a panic. + defer releaseLine() // ExecuteC returns the command that actually ran (or failed to parse), so a // usageError can print the nearest command's usage — not the root's. cmd, err := rootCmd.ExecuteC() @@ -180,9 +184,14 @@ func Execute() { cancelTimeout = nil } if err == nil { + // A command that printed nothing left the last flag standing, and the + // cursor with it. + releaseLine() return } - os.Exit(reportError(cmd, err)) + code := reportError(cmd, err) + releaseLine() + os.Exit(code) } // reportError renders an error's hint and, for incorrect-input (exit 2) errors, diff --git a/internal/selfflags/selfflags.go b/internal/selfflags/selfflags.go new file mode 100644 index 0000000..a23b18f --- /dev/null +++ b/internal/selfflags/selfflags.go @@ -0,0 +1,224 @@ +// Package selfflags evaluates the CLI's own feature flags. +package selfflags + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + flagsmith "github.com/Flagsmith/flagsmith-go-client/v5" + + "github.com/Flagsmith/flagsmith-cli/v2/internal/httpx" + "github.com/Flagsmith/flagsmith-cli/v2/internal/version" +) + +// LoadingAnimation gates the waving flag drawn while HTTP requests are in flight. +const LoadingAnimation = "loading_animation" + +const ( + // environmentKey identifies the CLI's own Flagsmith environment + environmentKey = "ESMtZFh4fZvWbfLeBgwiPm" + + // ttl is how long a cached evaluation is used before a refresh is attempted + ttl = 6 * time.Hour +) + +// baseURL is the SDK API these flags are evaluated against as the SDK wants it. +var baseURL = "https://edge.api.flagsmith.com/api/v1/" + +// Audience is what a segment might target about this installation that this +// package cannot know on its own. +type Audience struct { + // Organisation is the resolved organisation id. + Organisation string + + // IsSaas is false when the CLI is pointed at an instance other than + // Flagsmith's own. + IsSaas bool +} + +// traits describe this installation to Flagsmith. +func traits(aud Audience) []*flagsmith.Trait { + t := []*flagsmith.Trait{ + {TraitKey: "cli.version", TraitValue: version.Version}, + {TraitKey: "os", TraitValue: runtime.GOOS}, + {TraitKey: "arch", TraitValue: runtime.GOARCH}, + {TraitKey: "is_saas", TraitValue: aud.IsSaas}, + } + if aud.Organisation != "" { + t = append(t, &flagsmith.Trait{TraitKey: "organisation.id", TraitValue: aud.Organisation}) + } + return t +} + +const idPrefix = "cli-" + +// idPath is where the targeting key is kept. +func idPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "flagsmith", "install-id"), nil +} + +// installID is the identifier this installation is evaluated as, created on first +// use and kept thereafter. +func installID() (string, error) { + p, err := idPath() + if err != nil { + return "", err + } + if id := readID(p); id != "" { + return id, nil + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return "", err + } + // O_EXCL, then read back on failure: two invocations racing to be the first + // must agree which id won, or they would evaluate as two installations. + f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + if id := readID(p); id != "" { + return id, nil + } + return "", err + } + defer f.Close() + id := idPrefix + randomID() + if _, err := f.WriteString(id); err != nil { + return "", err + } + return id, nil +} + +func readID(path string) string { + raw, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(raw)) +} + +func randomID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) // crypto/rand failing is not recoverable + } + return base64.RawURLEncoding.EncodeToString(b) +} + +// cached is the on-disk evaluation. FetchedAt drives the ttl; a zero value (or +// an unparseable file) reads as "never fetched", so a refresh will retry. +type cached struct { + Flags map[string]bool `json:"flags"` + FetchedAt time.Time `json:"fetchedAt"` +} + +func path() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "flagsmith", "selfflags.json"), nil +} + +// load reads the cache, degrading to an empty evaluation on any error: an +// unreadable cosmetic cache must never fail a command. +func load() cached { + p, err := path() + if err != nil { + return cached{} + } + raw, err := os.ReadFile(p) + if err != nil { + return cached{} + } + var c cached + if err := json.Unmarshal(raw, &c); err != nil { + return cached{} + } + return c +} + +func store(c cached) error { + p, err := path() + if err != nil { + return err + } + // Owner-only, like the name cache beside it: nothing else on the machine + // needs to know what this CLI draws. + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + raw, err := json.Marshal(c) + if err != nil { + return err + } + return os.WriteFile(p, raw, 0o600) +} + +// Enabled reports whether the named flag was on the last time a refresh +// succeeded. A cold cache is false, matching how these flags are created: off +// until deliberately turned on. A stale value is kept until a refresh replaces +// it — flickering with every network hiccup would be worse than answering with +// yesterday's truth. +func Enabled(name string) bool { + return load().Flags[name] +} + +// Refresh evaluates the CLI's flags and caches the result, doing nothing if the +// cached one is younger than the ttl. The error is for tests and tracing: +// callers run this for its effect on the next invocation, and have nothing to do +// when it fails. +func Refresh(ctx context.Context, aud Audience) error { + if c := load(); !c.FetchedAt.IsZero() && time.Since(c.FetchedAt) < ttl { + return nil + } + flags, err := evaluate(ctx, aud) + if err != nil { + return err + } + return store(cached{Flags: flags, FetchedAt: time.Now()}) +} + +// evaluate resolves this installation's flags through the Flagsmith SDK +func evaluate(ctx context.Context, aud Audience) (map[string]bool, error) { + id, err := installID() + if err != nil { + return nil, err + } + hc := httpx.New(version.UserAgent()) + client := flagsmith.NewClient(environmentKey, + flagsmith.WithBaseURL(baseURL), + flagsmith.WithHTTPClient(hc), + flagsmith.WithSlogLogger(slog.New(slog.DiscardHandler)), + ) + hc.Timeout = 0 + resolved, err := client.GetIdentityFlagsFromAPI(ctx, id, traits(aud)) + if err != nil { + return nil, err + } + evaluated := resolved.AllFlags() + // No flags at all would turn everything off. The CLI's own project always has + // some, so read it as an answer from somewhere unexpected rather than + // overwriting a working cache with nothing. + if len(evaluated) == 0 { + return nil, errors.New("evaluating the CLI's own flags returned no flags") + } + flags := make(map[string]bool, len(evaluated)) + for _, e := range evaluated { + if e.FeatureName != "" { + flags[e.FeatureName] = e.Enabled + } + } + return flags, nil +} diff --git a/internal/selfflags/selfflags_test.go b/internal/selfflags/selfflags_test.go new file mode 100644 index 0000000..5fa1e6d --- /dev/null +++ b/internal/selfflags/selfflags_test.go @@ -0,0 +1,432 @@ +package selfflags + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Flagsmith/flagsmith-cli/v2/internal/version" +) + +func isolate(t *testing.T) { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CACHE_HOME", filepath.Join(tmp, ".cache")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + t.Setenv("LocalAppData", tmp) + t.Setenv("AppData", tmp) +} + +// evaluation is one identity evaluation as the stub received it. +type evaluation struct { + Key string + Identifier string + Traits map[string]any +} + +// stubSDK serves identity evaluations and records what was asked of it. +func stubSDK(t *testing.T, body string, status int) *[]evaluation { + t.Helper() + got := new([]evaluation) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/identities/" { + t.Errorf("%s %s, want POST /api/v1/identities/", r.Method, r.URL.Path) + } + var sent struct { + Identifier string `json:"identifier"` + Traits []struct { + Key string `json:"trait_key"` + Value any `json:"trait_value"` + } `json:"traits"` + } + if err := json.NewDecoder(r.Body).Decode(&sent); err != nil { + t.Errorf("decoding the evaluation request: %v", err) + } + e := evaluation{Key: r.Header.Get("X-Environment-Key"), Identifier: sent.Identifier, Traits: map[string]any{}} + for _, tr := range sent.Traits { + e.Traits[tr.Key] = tr.Value + } + *got = append(*got, e) + w.WriteHeader(status) + w.Write([]byte(body)) //nolint:errcheck + })) + t.Cleanup(srv.Close) + previous := baseURL + baseURL = srv.URL + "/api/v1/" + t.Cleanup(func() { baseURL = previous }) + return got +} + +const bothFlags = `{"flags": [ + {"enabled": true, "feature": {"name": "loading_animation"}}, + {"enabled": false, "feature": {"name": "something_else"}} +], "traits": []}` + +func TestEnabledColdCache(t *testing.T) { + // Given no cache has ever been written + isolate(t) + + // Then a flag nobody has evaluated is off, matching how it was created + if Enabled(LoadingAnimation) { + t.Error("Enabled on a cold cache = true, want false") + } +} + +func TestRefreshCachesEvaluation(t *testing.T) { + // Given the SDK API says the animation is on + isolate(t) + got := stubSDK(t, bothFlags, http.StatusOK) + + // When the cache is refreshed + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // Then the evaluation is readable without a network call, per flag + if !Enabled(LoadingAnimation) { + t.Error("Enabled(LoadingAnimation) = false, want true") + } + if Enabled("something_else") { + t.Error(`Enabled("something_else") = true, want false`) + } + if Enabled("never_heard_of_it") { + t.Error("an unevaluated flag reported enabled") + } + // And the request identified the CLI's own environment + if len(*got) != 1 || (*got)[0].Key != environmentKey { + t.Errorf("evaluations = %+v, want one carrying key %q", *got, environmentKey) + } +} + +func TestRefreshSkipsFreshCache(t *testing.T) { + // Given a cache written moments ago + isolate(t) + got := stubSDK(t, bothFlags, http.StatusOK) + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // When it is refreshed again + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // Then no second request was made: once per ttl is enough for a cosmetic flag + if len(*got) != 1 { + t.Errorf("requests = %d, want 1", len(*got)) + } +} + +func TestRefreshRefetchesStaleCache(t *testing.T) { + // Given a cache older than the ttl + isolate(t) + got := stubSDK(t, bothFlags, http.StatusOK) + if err := store(cached{ + Flags: map[string]bool{LoadingAnimation: false}, + FetchedAt: time.Now().Add(-2 * ttl), + }); err != nil { + t.Fatal(err) + } + + // When it is refreshed + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // Then the stale value was replaced + if len(*got) != 1 { + t.Errorf("requests = %d, want 1", len(*got)) + } + if !Enabled(LoadingAnimation) { + t.Error("stale value survived a successful refresh") + } +} + +func TestEnabledKeepsStaleValueWhenRefreshFails(t *testing.T) { + // Given a stale cache and an SDK API that is down + isolate(t) + if err := store(cached{ + Flags: map[string]bool{LoadingAnimation: true}, + FetchedAt: time.Now().Add(-2 * ttl), + }); err != nil { + t.Fatal(err) + } + stubSDK(t, "nope", http.StatusInternalServerError) + + // When the refresh fails + if err := Refresh(context.Background(), Audience{}); err == nil { + t.Fatal("Refresh on a 500 returned no error") + } + + // Then the last answer is still used: yesterday's truth beats flickering + if !Enabled(LoadingAnimation) { + t.Error("a failed refresh discarded the cached value") + } +} + +func TestRefreshRejectsMalformedResponse(t *testing.T) { + // Given an SDK API returning something that is not an evaluation + isolate(t) + stubSDK(t, `{"detail": "Invalid environment key"}`, http.StatusOK) + + // When the cache is refreshed + err := Refresh(context.Background(), Audience{}) + + // Then it fails rather than caching an empty evaluation + if err == nil { + t.Fatal("Refresh on a malformed body returned no error") + } + if _, statErr := os.Stat(mustPath(t)); !os.IsNotExist(statErr) { + t.Error("a malformed response was cached") + } +} + +func TestCacheIsOwnerOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no POSIX file modes") + } + // Given a refreshed cache + isolate(t) + stubSDK(t, bothFlags, http.StatusOK) + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // Then nothing else on the machine can read it + info, err := os.Stat(mustPath(t)) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("mode = %o, want 600", perm) + } +} + +func TestEnabledIgnoresUnreadableCache(t *testing.T) { + // Given a cache file that is not JSON + isolate(t) + path := mustPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{{{"), 0o600); err != nil { + t.Fatal(err) + } + + // Then reading it degrades to off rather than failing the command + if Enabled(LoadingAnimation) { + t.Error("Enabled on a corrupt cache = true, want false") + } +} + +func TestRefreshReplacesCorruptCache(t *testing.T) { + // Given a corrupt cache, which carries no usable fetch time + isolate(t) + path := mustPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{{{"), 0o600); err != nil { + t.Fatal(err) + } + got := stubSDK(t, bothFlags, http.StatusOK) + + // When it is refreshed + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // Then it was refetched, not treated as fresh + if len(*got) != 1 { + t.Errorf("requests = %d, want 1", len(*got)) + } + if !Enabled(LoadingAnimation) { + t.Error("Enabled after refreshing a corrupt cache = false, want true") + } +} + +func TestBaseURLIsFlagsmithEdge(t *testing.T) { + // The CLI's own flags live in Flagsmith's project, so nothing the user + // configures may redirect this request at their instance. + if baseURL != "https://edge.api.flagsmith.com/api/v1/" { + t.Errorf("baseURL = %q", baseURL) + } +} + +// mustPath is the cache path under the isolated HOME. +func mustPath(t *testing.T) string { + t.Helper() + p, err := path() + if err != nil { + t.Fatal(err) + } + return p +} + +// TestCachedRoundTrip pins the on-disk shape: an older CLI's cache must not +// confuse a newer one, and vice versa. +func TestCachedRoundTrip(t *testing.T) { + raw, err := json.Marshal(cached{Flags: map[string]bool{LoadingAnimation: true}, FetchedAt: time.Unix(0, 0).UTC()}) + if err != nil { + t.Fatal(err) + } + const want = `{"flags":{"loading_animation":true},"fetchedAt":"1970-01-01T00:00:00Z"}` + if string(raw) != want { + t.Errorf("cached JSON =\n%s\nwant\n%s", raw, want) + } +} + +func TestEvaluatesAsAStableInstall(t *testing.T) { + // Given an installation that has evaluated once + isolate(t) + got := stubSDK(t, bothFlags, http.StatusOK) + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // When it evaluates again, the cache having gone stale in between + if err := store(cached{Flags: map[string]bool{}, FetchedAt: time.Now().Add(-2 * ttl)}); err != nil { + t.Fatal(err) + } + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // Then both evaluations used the same identifier: a percentage rollout has to + // hold still across invocations, or the flag would flicker on and off + if len(*got) != 2 { + t.Fatalf("evaluations = %d, want 2", len(*got)) + } + first, second := (*got)[0].Identifier, (*got)[1].Identifier + if first != second { + t.Errorf("identifier changed between evaluations: %q then %q", first, second) + } + // And it is marked as the CLI's own, among a project's application identities + if !strings.HasPrefix(first, idPrefix) { + t.Errorf("identifier = %q, want it prefixed %q", first, idPrefix) + } + // And it is random, not the machine or the user: nothing recognisable + for _, leak := range []string{os.Getenv("USER"), hostname(t)} { + if leak != "" && strings.Contains(first, leak) { + t.Errorf("identifier %q carries %q", first, leak) + } + } +} + +func TestInstallIDOutlivesTheCache(t *testing.T) { + // Given an installation that has evaluated + isolate(t) + got := stubSDK(t, bothFlags, http.StatusOK) + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // When the cache is thrown away entirely, as a cache may be + if err := os.Remove(mustPath(t)); err != nil { + t.Fatal(err) + } + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // Then the identifier survived it: the key lives in the config directory for + // exactly this reason + if (*got)[0].Identifier != (*got)[1].Identifier { + t.Errorf("clearing the cache re-rolled the targeting key: %q then %q", + (*got)[0].Identifier, (*got)[1].Identifier) + } +} + +func TestInstallIDIsOwnerOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no POSIX file modes") + } + // Given a fresh installation + isolate(t) + + // When its targeting key is created + if _, err := installID(); err != nil { + t.Fatal(err) + } + + // Then nothing else on the machine can read it + p, err := idPath() + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("mode = %o, want 600", perm) + } +} + +func TestEvaluationCarriesTargetableTraits(t *testing.T) { + // Given a SaaS installation working in a known organisation + isolate(t) + got := stubSDK(t, bothFlags, http.StatusOK) + + // When it evaluates + if err := Refresh(context.Background(), Audience{Organisation: "13", IsSaas: true}); err != nil { + t.Fatal(err) + } + + // Then a segment has the version, the platform and the deployment to target + traits := (*got)[0].Traits + for key, want := range map[string]any{ + "cli.version": version.Version, + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "is_saas": true, + "organisation.id": "13", + } { + if traits[key] != want { + t.Errorf("trait %s = %v, want %v", key, traits[key], want) + } + } +} + +func TestUnknownOrganisationIsNotSent(t *testing.T) { + // Given an installation with no organisation resolved to an id + isolate(t) + got := stubSDK(t, bothFlags, http.StatusOK) + + // When it evaluates + if err := Refresh(context.Background(), Audience{}); err != nil { + t.Fatal(err) + } + + // Then the trait is absent rather than empty: a segment testing it should not + // match an installation that never said + if _, sent := (*got)[0].Traits["organisation.id"]; sent { + t.Errorf("traits = %v, want no organisation", (*got)[0].Traits) + } + // And a false is still stated rather than left out, so self-hosted is + // targetable and not merely the absence of SaaS + if got, sent := (*got)[0].Traits["is_saas"]; !sent || got != false { + t.Errorf("is_saas = %v (sent: %t), want false", got, sent) + } + // And what is always knowable is still there + if (*got)[0].Traits["cli.version"] != version.Version { + t.Errorf("cli.version = %v, want %q", (*got)[0].Traits["cli.version"], version.Version) + } +} + +func hostname(t *testing.T) string { + t.Helper() + name, err := os.Hostname() + if err != nil { + return "" + } + return name +} diff --git a/internal/spinner/spinner.go b/internal/spinner/spinner.go new file mode 100644 index 0000000..8cc0975 --- /dev/null +++ b/internal/spinner/spinner.go @@ -0,0 +1,289 @@ +// Package spinner draws a waving flag while HTTP requests are in flight. +package spinner + +import ( + "fmt" + "io" + "net/http" + "os" + "os/signal" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// The flag is drawn in braille, whose eight dots per cell give four rows to +// ripple through at the resolution of one line of text. +const ( + // pole is one full cell: two dot columns, four rows. + pole = "⣿" + + // erased returns to the start of the line and clears it, so whatever the + // command prints next starts on clean ground. + erased = "\r\x1b[K" + + // The cursor would otherwise sit blinking at the end of the flag. Hiding it + // is a change to the terminal that outlives the process, so everything that + // can end this one puts it back: Release, and a signal. + hideCursor = "\x1b[?25l" + showCursor = "\x1b[?25h" + + defaultInterval = 55 * time.Millisecond + + // defaultDelay is how long a request may take before it earns a flag. Most + // are quicker than this, and would only flash one. + defaultDelay = 150 * time.Millisecond +) + +// cloth is five cells of flag in a gale: a wave six dots long against ten of +// flag, so there is always more than one crest in the air and the cloth is +// working rather than undulating. Its amplitude grows with distance, as a real +// one does, so the tip flutters while the cloth stays put where it is tied on. +// +// The table is one wave sampled at twenty phases with the stalls stripped out — +// at this width, rounding to whole dot rows makes five of those phases repeat +// their predecessor, and a repeated frame reads as a hitch. Dropping them cannot +// break the bound that matters: no column moves more than a single dot row +// between frames, which is what makes this ripple rather than flicker. +// TestClothRipples holds the table to it. +var cloth = []string{ + "⠛⠛⠛⣤⠛", "⠛⠛⠛⢦⠞", "⠛⠞⠛⢣⡜", "⠛⠞⠛⠳⡴", "⠛⠶⠛⠳⡴", + "⠛⠶⠛⠛⣤", "⠛⠳⠞⠛⢦", "⠛⠳⠞⠛⢣", "⠛⠛⠶⠛⠳", "⠛⠛⠶⠛⠛", + "⠛⠛⢦⠞⠛", "⠛⠛⠳⠞⠛", "⠛⠛⠳⡜⠛", "⠛⠛⠳⡴⠛", "⠛⠛⠛⡴⠛", +} + +// shades light the cloth by how high it is flying, brightest first: a tint of +// $primary400, then $primary400 and $primary from the Flagsmith UI. Colour is a +// function of the shape rather than of the clock, so the crests carry the light +// with them as the wave travels and each cell is lit on its own — which is the +// whole of the shading. A time-based pulse on top of this only fought it. +var shades = []string{"#c9b3fd", "#906af6", "#6837fc"} + +// rowBits are the two braille dots that share each row, top to bottom. +var rowBits = [4][2]uint{{0, 3}, {1, 4}, {2, 5}, {6, 7}} + +// crest is the highest row a cell has ink in — how high the cloth flies there, +// and so which shade lights it. Ink-free cells report past the last shade and are +// clamped to the dimmest by the caller. +func crest(cell rune) int { + mask := uint(cell - 0x2800) + for row := range rowBits { + if mask&(1< 1 { + return + } + s.stop, s.done = make(chan struct{}), make(chan struct{}) + go s.animate(s.stop, s.done) +} + +// lower counts one request out, stopping the animation once the last one has +// landed. The flag it drew is left standing: only Release takes the line back. +// The wait is what makes that frame the last one written, so nothing arrives +// after whatever takes the line next. +func (s *Spinner) lower() { + s.mu.Lock() + defer s.mu.Unlock() + s.flying-- + if s.flying > 0 { + return + } + close(s.stop) + <-s.done +} + +// Release takes the line back: it erases the standing flag and restores the +// cursor, doing nothing if no flag stands. Everything that writes where a flag +// might be calls it first — see Guard — and it is safe to call as often as that +// implies. +func (s *Spinner) Release() { + s.lineMu.Lock() + defer s.lineMu.Unlock() + if !s.standing { + return + } + fmt.Fprint(s.out, erased+showCursor) //nolint:errcheck // nothing to do if the terminal has gone + s.standing = false +} + +// Guard returns w wrapped so that anything written to it takes the line back +// from the flag first. This is how a standing flag gets cleared: the flag holds +// the line between requests, and the next thing with something to say clears it. +func (s *Spinner) Guard(w io.Writer) io.Writer { + return &guard{base: w, spinner: s} +} + +type guard struct { + base io.Writer + spinner *Spinner +} + +func (g *guard) Write(p []byte) (int, error) { + g.spinner.Release() + return g.base.Write(p) +} + +// animate draws a frame every interval until stopped, leaving the last one +// standing. A request that finishes inside the delay never draws anything at all. +func (s *Spinner) animate(stop <-chan struct{}, done chan<- struct{}) { + defer close(done) + select { + case <-stop: + return + case <-time.After(s.delay): + } + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + for { + s.paint() + select { + case <-stop: + return + case <-ticker.C: + } + } +} + +// paint draws the current frame and advances the ripple, taking the line — and +// the cursor with it — the first time. +func (s *Spinner) paint() { + s.lineMu.Lock() + defer s.lineMu.Unlock() + if s.finished { + return // a signal has already put the terminal back; do not undo that + } + if !s.standing { + s.guarded.Do(s.guardCursor) + fmt.Fprint(s.out, hideCursor) //nolint:errcheck + s.standing = true + } + fmt.Fprint(s.out, "\r"+s.frame(int(s.tick.Add(1)-1))) //nolint:errcheck +} + +// guardCursor restores the cursor if this process is interrupted while a flag +// stands. Hiding the cursor outlives the CLI, so being killed mid-flight would +// otherwise leave the user's terminal with no cursor and no clue — and the only +// reason to handle a signal here is to prevent exactly that, so once the +// terminal is back it exits the way an unhandled signal would have. +func (s *Spinner) guardCursor() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + go func() { + received := <-signals + s.Release() + s.lineMu.Lock() + s.finished = true + s.lineMu.Unlock() + signal.Stop(signals) + os.Exit(signalExit(received)) + }() +} + +// signalExit is the code a shell expects from a process a signal ended: 128 plus +// the signal's number. +func signalExit(sig os.Signal) int { + if s, ok := sig.(syscall.Signal); ok { + return 128 + int(s) + } + return 1 +} + +// frame renders tick i: a pole, then the cloth cell by cell, each lit by how high +// it is flying. +func (s *Spinner) frame(i int) string { + var b strings.Builder + b.WriteString(s.poleStyle.Render(pole)) + for _, cell := range cloth[i%len(cloth)] { + b.WriteString(s.clothStyle[min(crest(cell), len(s.clothStyle)-1)].Render(string(cell))) + } + return b.String() +} diff --git a/internal/spinner/spinner_test.go b/internal/spinner/spinner_test.go new file mode 100644 index 0000000..e18a815 --- /dev/null +++ b/internal/spinner/spinner_test.go @@ -0,0 +1,438 @@ +package spinner + +import ( + "bytes" + "errors" + "net/http" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// recorder collects what the spinner draws and announces every paint, so tests +// can wait for the animation instead of sleeping for it. +type recorder struct { + mu sync.Mutex + buf bytes.Buffer + painted chan struct{} +} + +func newRecorder() *recorder { + return &recorder{painted: make(chan struct{}, 64)} +} + +func (r *recorder) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + if strings.Contains(string(p), pole) { + select { + case r.painted <- struct{}{}: + default: // a test that stopped watching must not block the animation + } + } + return r.buf.Write(p) +} + +func (r *recorder) String() string { + r.mu.Lock() + defer r.mu.Unlock() + return r.buf.String() +} + +// awaitPaint waits for the next frame to be drawn, failing rather than hanging. +func (r *recorder) awaitPaint(t *testing.T) { + t.Helper() + select { + case <-r.painted: + case <-time.After(5 * time.Second): + t.Fatal("the flag was never drawn") + } +} + +// fly makes one request that stays in flight until the flag has been drawn, so a +// test can observe a flag without waiting on wall-clock time. +func fly(t *testing.T, s *Spinner, out *recorder) { + t.Helper() + b := &blocker{release: make(chan struct{})} + done := make(chan struct{}) + go func() { + defer close(done) + get(t, s.Wrap(b)) //nolint:errcheck + }() + out.awaitPaint(t) + close(b.release) + <-done +} + +// blocker is a RoundTripper that hangs until released, standing in for a slow +// request. +type blocker struct { + release chan struct{} + err error +} + +func (b *blocker) RoundTrip(*http.Request) (*http.Response, error) { + <-b.release + if b.err != nil { + return nil, b.err + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func get(t *testing.T, rt http.RoundTripper) (*http.Response, error) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, "https://api.flagsmith.example/", nil) + if err != nil { + t.Fatal(err) + } + return rt.RoundTrip(req) +} + +// eager returns a spinner that animates immediately, so tests need not wait out +// the anti-flash delay. +func eager(out *recorder) *Spinner { + s := New(out) + s.delay = 0 + s.interval = time.Millisecond + return s +} + +func TestFlagFliesWhileRequestIsInFlight(t *testing.T) { + // Given a request that has not come back yet + out := newRecorder() + s := eager(out) + b := &blocker{release: make(chan struct{})} + rt := s.Wrap(b) + + // When it is in flight + done := make(chan struct{}) + go func() { + defer close(done) + get(t, rt) //nolint:errcheck + }() + out.awaitPaint(t) + + // Then the flag is up + if !strings.Contains(out.String(), pole+cloth[0]) { + t.Errorf("output = %q, want it to carry the flag", out.String()) + } + + // And the cursor is out of the way while it flies + if !strings.HasPrefix(out.String(), hideCursor) { + t.Errorf("output = %q, want it to start by hiding the cursor", out.String()) + } + + // And once the request returns the flag stands: the line is not given back + // until something asks for it + close(b.release) + <-done + standing := out.String() + if strings.Contains(standing, erased) { + t.Error("the line was erased when the request landed, rather than left standing") + } + if !strings.Contains(standing, pole) { + t.Errorf("output = %q, want a flag still standing", standing) + } + + // And releasing it puts back both the line and the cursor + s.Release() + if got := strings.TrimPrefix(out.String(), standing); got != erased+showCursor { + t.Errorf("Release wrote %q, want %q", got, erased+showCursor) + } +} + +func TestFlagStandsAcrossConsecutiveRequests(t *testing.T) { + // Given one request that has already raised a flag + out := newRecorder() + s := eager(out) + fly(t, s, out) + afterFirst := out.String() + + // When a second request follows it + fly(t, s, out) + + // Then the flag never came down in between, and the cursor was hidden once: + // a command making several requests in a row does not flicker + whole := out.String() + if strings.Contains(whole, erased) { + t.Error("the flag came down between two requests") + } + if got := strings.Count(whole, hideCursor); got != 1 { + t.Errorf("hid the cursor %d times, want 1", got) + } + // And the ripple carried on from where it stopped rather than restarting + if !strings.HasPrefix(whole, afterFirst) { + t.Error("the second request rewrote what the first had drawn") + } + if s.tick.Load() < 2 { + t.Errorf("tick = %d after two requests, want the ripple to have advanced", s.tick.Load()) + } +} + +func TestGuardTakesTheLineBack(t *testing.T) { + // Given a flag standing after a request + out := newRecorder() + s := eager(out) + fly(t, s, out) + standing := out.String() + + // When the command writes its own output through a guarded writer + if _, err := s.Guard(out).Write([]byte("NAME ID\n")); err != nil { + t.Fatal(err) + } + + // Then the flag was cleared first, so the output starts on a clean line + if got, want := strings.TrimPrefix(out.String(), standing), erased+showCursor+"NAME ID\n"; got != want { + t.Errorf("guarded write produced %q, want %q", got, want) + } +} + +func TestReleaseWithoutAFlagIsSilent(t *testing.T) { + // Given nothing has been drawn + out := newRecorder() + s := eager(out) + + // When the line is released anyway — as it is at the end of every invocation + s.Release() + s.Release() + + // Then the terminal is left entirely alone: no stray erase, no cursor it + // never hid + if got := out.String(); got != "" { + t.Errorf("Release wrote %q with no flag standing, want nothing", got) + } +} + +func TestFastRequestNeverFlashes(t *testing.T) { + // Given a spinner that only animates a request slower than an hour + out := newRecorder() + s := New(out) + s.delay = time.Hour + rt := s.Wrap(&blocker{release: closed()}) + + // When a request completes immediately + if _, err := get(t, rt); err != nil { + t.Fatal(err) + } + + // Then nothing was drawn — no flash, and no stray line to erase + if got := out.String(); got != "" { + t.Errorf("output = %q, want nothing", got) + } +} + +func TestConcurrentRequestsShareOneFlag(t *testing.T) { + // Given two overlapping requests + out := newRecorder() + s := eager(out) + first := &blocker{release: make(chan struct{})} + second := &blocker{release: make(chan struct{})} + firstDone, secondDone := make(chan struct{}), make(chan struct{}) + go func() { defer close(firstDone); get(t, s.Wrap(first)) }() //nolint:errcheck + go func() { defer close(secondDone); get(t, s.Wrap(second)) }() //nolint:errcheck + out.awaitPaint(t) + + // When the first one finishes while the second is still waiting + close(first.release) + <-firstDone + + // Then it did not take the flag down with it: one flag flies for all of them, + // raised once and never lowered on the way. + close(second.release) + <-secondDone + if got := strings.Count(out.String(), hideCursor); got != 1 { + t.Errorf("raised %d flags, want 1 between them", got) + } + if strings.Contains(out.String(), erased) { + t.Error("a request landing took the flag down while another was in flight") + } +} + +func TestWrapPassesResponsesAndErrorsThrough(t *testing.T) { + // Given a transport that fails + out := newRecorder() + sentinel := errors.New("dial tcp: nope") + rt := eager(out).Wrap(&blocker{release: closed(), err: sentinel}) + + // When a request is made through the spinner + _, err := get(t, rt) + + // Then the caller sees exactly what the wrapped transport returned + if !errors.Is(err, sentinel) { + t.Errorf("err = %v, want %v", err, sentinel) + } +} + +func TestFrameRipplesThroughEveryPhase(t *testing.T) { + // Given a spinner drawing to a terminal that can show every colour + out := &bytes.Buffer{} + r := lipgloss.NewRenderer(out) + r.SetColorProfile(termenv.TrueColor) + s := newSpinner(out, r) + + // When a full cycle of frames is drawn + seen := map[string]int{} + cycle := len(cloth) + for i := range cycle { + seen[s.frame(i)]++ + } + + // Then no frame in the cycle repeats: every phase of the wave looks different + // from every other, so nothing reads as a stall + if len(seen) != cycle { + t.Errorf("distinct frames = %d, want %d", len(seen), cycle) + } + if s.frame(0) != s.frame(cycle) { + t.Errorf("the cycle does not close: frame(0) != frame(%d)", cycle) + } + // And every frame is one flag on one pole + for i := range cycle { + if f := s.frame(i); !strings.Contains(f, pole) { + t.Errorf("frame(%d) = %q, want a pole in it", i, f) + } + } +} + +func TestFramePlainWithoutColour(t *testing.T) { + // Given output that is not a terminal, so lipgloss renders no colour + out := &bytes.Buffer{} + s := New(out) + + // Then the frame is the flag and nothing else: no escape codes to leak into + // a log or a pipe + if got, want := s.frame(0), pole+cloth[0]; got != want { + t.Errorf("frame(0) = %q, want %q", got, want) + } +} + +// TestClothRipples pins what makes the wave read as cloth rather than noise: +// every cell of every frame is a braille glyph, and no dot column jumps more +// than one row between consecutive frames. +func TestClothRipples(t *testing.T) { + width := len([]rune(cloth[0])) + for i, frame := range cloth { + if got := len([]rune(frame)); got != width { + t.Errorf("cloth[%d] = %q, %d cells, want %d", i, frame, got, width) + } + for _, r := range frame { + if r < 0x2800 || r > 0x28ff { + t.Errorf("cloth[%d] = %q contains %U, which is not braille", i, frame, r) + } + } + } + for i := range cloth { + next := (i + 1) % len(cloth) + for column, jump := range travel(cloth[i], cloth[next]) { + if jump > 1 { + t.Errorf("dot column %d jumps %d rows between cloth[%d] and cloth[%d]", + column, jump, i, next) + } + } + } +} + +// travel reports, per dot column, how far the cloth's top edge moves between two +// frames. +func travel(from, to string) []int { + fromTop, toTop := topEdge(from), topEdge(to) + jumps := make([]int, len(fromTop)) + for i := range fromTop { + if fromTop[i] < 0 || toTop[i] < 0 { + continue // an empty column has no edge to have moved + } + jumps[i] = abs(fromTop[i] - toTop[i]) + } + return jumps +} + +// topEdge is the highest filled dot row per dot column of a braille frame, or -1 +// where a column is empty. +func topEdge(frame string) []int { + // Braille dot bits, by (row, column within the cell). + bit := [4][2]uint{{0, 3}, {1, 4}, {2, 5}, {6, 7}} + var edges []int + for _, r := range frame { + mask := uint(r - 0x2800) + for col := range 2 { + top := -1 + for row := 3; row >= 0; row-- { + if mask&(1<