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
2 changes: 1 addition & 1 deletion src/pkg/cli/compose/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ func (l *Loader) loadProject(ctx context.Context, suppressWarn bool) (*Project,

if term.DoDebug() {
b, _ := yaml.Marshal(project)
term.Println(string(b))
term.Println(string(b)) // term.Println routes to stderr in JSON mode, so this never corrupts --json stdout
}

l.cached = project
Expand Down
38 changes: 38 additions & 0 deletions src/pkg/cli/compose/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,44 @@ func TestResolveProjectWorkingDirDoesNotLoadOrCacheProject(t *testing.T) {
assert.Equal(t, dir, workingDir)
}

// TestLoadProjectDebugDumpNeverHitsStdoutInJSONMode is a regression test:
// `defang services --json` with DEFANG_DEBUG=1 used to write the project's
// YAML dump to stdout ahead of the JSON payload, corrupting it for callers
// like `defang-github-action`'s deployment summary (jq: invalid numeric
// literal). term.Println now routes to stderr in JSON mode, so the dump
// must never appear on stdout, whether or not JSON mode is on.
Comment thread
lionello marked this conversation as resolved.
func TestLoadProjectDebugDumpNeverHitsStdoutInJSONMode(t *testing.T) {
dir := t.TempDir()
composePath := filepath.Join(dir, "compose.yaml")
require.NoError(t, os.WriteFile(composePath, []byte("services:\n web:\n image: alpine\n"), 0o644))

oldTerm := term.DefaultTerm
t.Cleanup(func() { term.DefaultTerm = oldTerm })

t.Run("debug alone dumps the project to stdout", func(t *testing.T) {
var stdout, stderr bytes.Buffer
term.DefaultTerm = term.NewTerm(os.Stdin, &stdout, &stderr)
term.DefaultTerm.SetDebug(true)

_, err := NewLoader(WithPath(composePath)).LoadProject(t.Context())
require.NoError(t, err)
assert.Contains(t, stdout.String(), "services:")
assert.Empty(t, stderr.String())
})

t.Run("debug plus json moves the dump to stderr", func(t *testing.T) {
var stdout, stderr bytes.Buffer
term.DefaultTerm = term.NewTerm(os.Stdin, &stdout, &stderr)
term.DefaultTerm.SetDebug(true)
term.DefaultTerm.SetJSON(true)

_, err := NewLoader(WithPath(composePath)).LoadProject(t.Context())
require.NoError(t, err)
assert.Empty(t, stdout.String())
assert.Contains(t, stderr.String(), "services:")
})
}

func TestResolveProjectWorkingDirDoesNotSuppressProjectWarnings(t *testing.T) {
dir := t.TempDir()
composePath := filepath.Join(dir, "compose.yaml")
Expand Down
12 changes: 8 additions & 4 deletions src/pkg/term/colorizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,20 +195,24 @@ func ensurePrefix(prefix prefixChars, s string) string {
return string(prefix) + s
}

// Printc, Print, Println, and Printf are for human-readable text; they write
// to stderr instead of stdout when JSON mode is on, so they never corrupt a
// command's --json payload. The only thing that belongs on stdout in JSON
// mode is the JSON payload itself (see jsonTable in table.go).
func (t *Term) Printc(c Color, v ...any) (int, error) {
return output(t.out, c, fmt.Sprint(v...))
return output(t.outOrErr(), c, fmt.Sprint(v...))
}

func (t *Term) Print(v ...any) (int, error) {
return fmt.Fprint(t.out, v...)
return fmt.Fprint(t.outOrErr(), v...)
}

func (t *Term) Println(v ...any) (int, error) {
return fmt.Fprintln(t.out, v...)
return fmt.Fprintln(t.outOrErr(), v...)
}

func (t *Term) Printf(format string, v ...any) (int, error) {
return fmt.Fprintf(t.out, format, v...)
return fmt.Fprintf(t.outOrErr(), format, v...)
}

func (t *Term) Debug(v ...any) (int, error) {
Expand Down
29 changes: 29 additions & 0 deletions src/pkg/term/colorizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,35 @@ func TestIsTerminal(t *testing.T) {
t.Error("Expected IsTerminal() to return false")
}
}

// TestPrintRoutingInJSONMode is a regression test: Print/Println/Printf/Printc
// used to always write to stdout, so any of them reachable from a command
// that also emits --json output (e.g. via a debug dump) would corrupt that
// JSON payload. They must move to stderr in JSON mode, like Info/Warn do.
func TestPrintRoutingInJSONMode(t *testing.T) {
var stdout, stderr bytes.Buffer
defaultTerm := NewTerm(os.Stdin, &stdout, &stderr)

defaultTerm.Print("a")
defaultTerm.Println("b")
defaultTerm.Printf("%s", "c")
defaultTerm.Printc(InfoColor, "d")
Comment thread
lionello marked this conversation as resolved.
if stdout.String() == "" || stderr.String() != "" {
t.Errorf("expected Print* to write to stdout when JSON mode is off; stdout=%q stderr=%q", stdout.String(), stderr.String())
}

stdout.Reset()
stderr.Reset()
defaultTerm.SetJSON(true)

defaultTerm.Print("a")
defaultTerm.Println("b")
defaultTerm.Printf("%s", "c")
defaultTerm.Printc(InfoColor, "d")
if stdout.String() != "" || stderr.String() == "" {
t.Errorf("expected Print* to write to stderr when JSON mode is on; stdout=%q stderr=%q", stdout.String(), stderr.String())
}
Comment thread
lionello marked this conversation as resolved.
}
func TestWarn(t *testing.T) {
tests := []struct {
msgs []string
Expand Down
Loading