Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,12 @@ func (a *App) IsMachineOutput() bool {
if a.Flags.Agent || a.Flags.Quiet || a.Flags.IDsOnly || a.Flags.Count || a.Flags.JSON || a.Flags.JQFilter != "" {
return true
}
// An explicit --styled/--md is what ApplyFlags honors over a configured
// machine format; this predicate must agree, or the same invocation is
// human to the renderer and machine to every gate.
if a.Flags.Styled || a.Flags.MD {
return false
}
// Config-driven machine output formats
if a.Config != nil {
switch a.Config.Format {
Expand Down
29 changes: 29 additions & 0 deletions internal/appctx/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,35 @@ func TestIsMachineOutputConfigFormat(t *testing.T) {
}
}

// Test that an explicit --styled/--md overrides a configured machine format,
// matching ApplyFlags: the flag rebuilds the writer as styled/markdown, so the
// predicate must stand down too, or the same invocation is human to the
// renderer and machine to every gate. Machine flags still win over style flags,
// mirroring ApplyFlags' JSON-first ordering.
func TestIsMachineOutputStyleFlagOverridesConfig(t *testing.T) {
tests := []struct {
name string
format string
setFlags func(*App)
expected bool
}{
{"styled over config json", "json", func(a *App) { a.Flags.Styled = true }, false},
{"md over config quiet", "quiet", func(a *App) { a.Flags.MD = true }, false},
{"config json with no style flag stays machine", "json", func(a *App) {}, true},
{"json flag beats styled flag", "json", func(a *App) { a.Flags.JSON = true; a.Flags.Styled = true }, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{Format: tt.format}
app := NewApp(cfg)
tt.setFlags(app)

assert.Equal(t, tt.expected, app.IsMachineOutput())
})
}
}

// Test that app.Err doesn't print stats in machine output modes
func TestAppErrMachineOutputNoStats(t *testing.T) {
tests := []struct {
Expand Down
71 changes: 71 additions & 0 deletions internal/commands/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"io"
"net/http"
"os"
"strings"
"testing"

Expand Down Expand Up @@ -209,6 +210,76 @@ func TestIsMachineOutput_JSONFlag(t *testing.T) {
assert.True(t, isMachineOutput(cmd))
}

// TestMachinePredicatesAgree is the regression net for app.IsMachineOutput and
// machineReadsThisOutput drifting apart. They answer the same question through
// different lenses — flags+config vs the writer ApplyFlags built — and the chat
// delete --force corner came from them disagreeing on `--styled` over a
// configured json. Every flag/config combination must agree, with one
// deliberate exception: FormatAuto on a pipe, where the renderer emits JSON
// (machineReadsThisOutput true) while gates keep treating `basecamp foo | grep`
// as human (IsMachineOutput false) so notices, progress, and wizard gating
// don't flip on redirection alone. That row is asserted as a divergence, not
// skipped, so a change to either side of it shows up here.
func TestMachinePredicatesAgree(t *testing.T) {
t.Setenv("BASECAMP_NO_KEYRING", "1")

// Neither predicate may consult a real terminal: pin all three stdio
// streams to pipes and assert the precondition, so the FormatAuto rows
// resolve deterministically wherever the test runs.
r, w, err := os.Pipe()
require.NoError(t, err)
t.Cleanup(func() { r.Close(); w.Close() })
origIn, origOut, origErr := os.Stdin, os.Stdout, os.Stderr
os.Stdin, os.Stdout, os.Stderr = r, w, w
t.Cleanup(func() { os.Stdin, os.Stdout, os.Stderr = origIn, origOut, origErr })
fi, err := os.Stdout.Stat()
require.NoError(t, err)
require.Zero(t, fi.Mode()&os.ModeCharDevice, "precondition: stdout must not be a terminal")

configFormats := []string{"", "json", "quiet", "markdown"}
flagSets := []struct {
name string
set func(*appctx.App)
}{
{"none", func(a *appctx.App) {}},
{"json", func(a *appctx.App) { a.Flags.JSON = true }},
{"agent", func(a *appctx.App) { a.Flags.Agent = true }},
{"quiet", func(a *appctx.App) { a.Flags.Quiet = true }},
{"ids-only", func(a *appctx.App) { a.Flags.IDsOnly = true }},
{"count", func(a *appctx.App) { a.Flags.Count = true }},
{"jq", func(a *appctx.App) { a.Flags.JQFilter = ".x" }},
{"styled", func(a *appctx.App) { a.Flags.Styled = true }},
{"md", func(a *appctx.App) { a.Flags.MD = true }},
{"styled+json", func(a *appctx.App) { a.Flags.Styled = true; a.Flags.JSON = true }},
}

for _, format := range configFormats {
for _, flags := range flagSets {
name := "config=" + format + "/flags=" + flags.name
t.Run(name, func(t *testing.T) {
app := appctx.NewApp(&config.Config{Format: format})
flags.set(app)
app.ApplyFlags()

cmd := &cobra.Command{Use: "test"}
cmd.SetContext(appctx.WithApp(context.Background(), app))

gate := app.IsMachineOutput()
renderer := machineReadsThisOutput(cmd)

if format == "" && flags.name == "none" {
// The FormatAuto-on-a-pipe exception described above.
assert.False(t, gate, "IsMachineOutput must not flip on redirection alone")
assert.True(t, renderer, "FormatAuto on a pipe renders JSON")
return
}
assert.Equal(t, gate, renderer,
"IsMachineOutput=%v but machineReadsThisOutput=%v for %s", gate, renderer, name)
})
}
}
}

func TestIsNonInteractiveCommand_NonInteractiveEnv(t *testing.T) {
t.Setenv("BASECAMP_NONINTERACTIVE", "1")

Expand Down
4 changes: 3 additions & 1 deletion internal/commands/wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,9 @@ func fetchProjectName(cmd *cobra.Command, app *appctx.App, projectID string) str
// no one of them sees everything: IsInteractive covers non-terminal
// stdin/stdout, the machine-output flags and the BASECAMP_NONINTERACTIVE escape
// hatch; IsMachineOutput adds the config-driven json/quiet formats it does not
// look at; InteractivePrompt adds stderr, which is where huh actually draws.
// look at (standing down when an explicit --styled/--md overrides them, since
// ApplyFlags renders those human); InteractivePrompt adds stderr, which is
// where huh actually draws.
//
// Two callers, deliberately different responses. `basecamp setup` was asked for
// by name, so it refuses out loud. Bare `basecamp` never asked for a wizard at
Expand Down
4 changes: 3 additions & 1 deletion internal/commands/wizard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,9 @@ func TestSetupRefusesUnderNonInteractiveEnv(t *testing.T) {
// Terminal stdio is not enough: a caller that asked for machine output has
// declared it is not there to answer questions, and the wizard is nothing but
// questions. Config-driven json/quiet counts too — app.IsInteractive() does not
// look at it, which is why the gate also asks IsMachineOutput().
// look at it, which is why the gate also asks IsMachineOutput(). An explicit
// --styled/--md overrides a configured machine format there, so that pairing
// prompts like any human invocation.
func TestSetupRefusesMachineOutputOnATerminal(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no /dev/ptmx on Windows")
Expand Down