check / check (pull_request) Failing after 0s
--json was folded into Quiet, which pinned the stderr log level to WARN. So `prune --json` gave a machine consumer no record of the local index rows it deleted, even under --verbose: the audit records were gated off stdout and pinned below the level on stderr. --json now quiets only the stdout UI, keeping the JSON document clean (issue #108); the stderr log level follows --verbose/--debug again, since diagnostics have gone to stderr since #82. The same coupling is removed for `snapshot verify`, `snapshot remove`, and `remote info`, which carried it for the same outdated reason. A test asserts `--verbose prune --json` emits the cleanup record on stderr while stdout stays exactly one document, and that --json alone keeps it below the level. model: claude-opus-4-8
140 lines
4.0 KiB
Go
140 lines
4.0 KiB
Go
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
|
|
}
|