package cli //nolint:testpackage // shares the prune fixtures and capture helpers import ( "bytes" "io" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // staleRecordLogMessage is the local-cleanup audit line CleanupLocalSnapshots // logs for each stale record. It is exactly the signal issue #112 says a // machine consumer lost under --json: gated off stdout, and pinned below // the log level on stderr because --json used to force Quiet. const staleRecordLogMessage = "Removing stale local snapshot record" // TestEntryPruneJSONStderrHonoursVerbosity is the end-to-end regression // guard for issue #112. Under --json the log level must still follow // --verbose/--debug rather than being pinned to WARN, so the // local-cleanup records reach stderr under --verbose while stdout stays // exactly one JSON document; without --verbose they stay below the // level, as they do without --json. // // Both halves are asserted together on the same run, because the fix has // to keep the document clean (issue #108) while freeing stderr. // // Not parallel: it replaces os.Args, os.Stdout, os.Stderr and the xdg // globals. // //nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals func TestEntryPruneJSONStderrHonoursVerbosity(t *testing.T) { for _, testCase := range []struct { name string verbose bool wantOnStderr bool }{ { name: "verbose json surfaces the cleanup record on stderr", verbose: true, wantOnStderr: true, }, { name: "json alone keeps the cleanup record below the level", verbose: false, wantOnStderr: false, }, } { t.Run(testCase.name, func(t *testing.T) { configPath := writeHermeticPruneConfig(t, true) previousArgs := os.Args t.Cleanup(func() { os.Args = previousArgs rootFlags = RootFlags{} }) args := []string{ programName, flagConfig, configPath, cmdPrune, flagJSON, } if testCase.verbose { args = append(args, "--verbose") } os.Args = args stdout, stderr := captureProcessStdoutAndStderr(t, Entry) // The document stays clean in both cases: freeing stderr must // not regress issue #108. requireExactlyOneJSONDocument(t, stdout) if testCase.wantOnStderr { assert.Contains(t, stderr, staleRecordLogMessage, "--verbose --json must emit the cleanup record on stderr") assert.Contains(t, stderr, stalePruneSnapshotID, "the record must name the snapshot it removed") } else { assert.NotContains(t, stderr, staleRecordLogMessage, "without --verbose the record stays below the log level") } }) } } // captureProcessStdoutAndStderr redirects both of the process's own // standard streams to pipes for the duration of fn and returns what was // written to each. The redirection is at the file-descriptor level // because the logger binds os.Stderr when it initializes inside fn, and // the JSON document reaches os.Stdout independently; the point is to see // where each actually lands. // // Not parallel-safe: os.Stdout and os.Stderr are process-global. func captureProcessStdoutAndStderr(t *testing.T, fn func()) (string, string) { t.Helper() outReader, outWriter, err := os.Pipe() require.NoError(t, err) errReader, errWriter, err := os.Pipe() require.NoError(t, err) previousOut, previousErr := os.Stdout, os.Stderr os.Stdout, os.Stderr = outWriter, errWriter capturedOut := drain(outReader) capturedErr := drain(errReader) fn() os.Stdout, os.Stderr = previousOut, previousErr require.NoError(t, outWriter.Close()) require.NoError(t, errWriter.Close()) out, errOut := <-capturedOut, <-capturedErr require.NoError(t, outReader.Close()) require.NoError(t, errReader.Close()) return out, errOut } // drain copies a reader to a string on a goroutine and delivers the // result once the writer end is closed. func drain(reader io.Reader) <-chan string { captured := make(chan string, 1) go func() { var buf bytes.Buffer _, _ = io.Copy(&buf, reader) captured <- buf.String() }() return captured }