From d2e72361bdd25be19d9cbb349835a815d3f7ed1c Mon Sep 17 00:00:00 2001 From: RW Date: Sun, 24 Aug 2025 00:40:34 +0200 Subject: [PATCH 1/3] test: stub fiber module with sanitized exec --- cmd/internal/migrations/dependencies.go | 111 +++++++++++++++++++ cmd/internal/migrations/dependencies_test.go | 104 +++++++++++++++++ cmd/internal/migrations/lists.go | 2 +- cmd/internal/migrations/lists_test.go | 31 +++++- cmd/migrate_test.go | 32 ++++++ 5 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 cmd/internal/migrations/dependencies.go create mode 100644 cmd/internal/migrations/dependencies_test.go diff --git a/cmd/internal/migrations/dependencies.go b/cmd/internal/migrations/dependencies.go new file mode 100644 index 0000000..539b7bb --- /dev/null +++ b/cmd/internal/migrations/dependencies.go @@ -0,0 +1,111 @@ +package migrations + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + semver "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + "golang.org/x/mod/modfile" +) + +// ExecCommand is used to run external commands. It can be replaced in tests. +var ExecCommand = exec.Command + +// MigrateDependencies ensures that dependencies shared with Fiber are at least +// the versions required by the target Fiber release. +// +// It updates go.mod files that already require a dependency also required by +// Fiber, bumping the version when it is lower than Fiber's requirement. No +// changes are made if the existing version is equal or higher. +func MigrateDependencies(cmd *cobra.Command, cwd string, _, target *semver.Version) error { + fiberModule := fmt.Sprintf("github.com/gofiber/fiber/v%d@v%s", target.Major(), target.String()) + + c := ExecCommand("go", "mod", "download", "-json", fiberModule) + var out bytes.Buffer + c.Stdout = &out + c.Stderr = &out + if err := c.Run(); err != nil { + return fmt.Errorf("download fiber module: %w", err) + } + var info struct { + GoMod string `json:"GoMod"` //nolint:tagliatelle // field name defined by go tool output + } + if err := json.Unmarshal(out.Bytes(), &info); err != nil { + return fmt.Errorf("parse download info: %w", err) + } + + b, err := os.ReadFile(info.GoMod) // #nosec G304 + if err != nil { + return fmt.Errorf("read fiber go.mod: %w", err) + } + mf, err := modfile.Parse(info.GoMod, b, nil) + if err != nil { + return fmt.Errorf("parse fiber go.mod: %w", err) + } + + deps := make(map[string]*semver.Version, len(mf.Require)) + for _, r := range mf.Require { + v, err := semver.NewVersion(strings.TrimPrefix(r.Mod.Version, "v")) + if err != nil { + return fmt.Errorf("parse fiber dependency %s version %s: %w", r.Mod.Path, r.Mod.Version, err) + } + deps[r.Mod.Path] = v + } + + dirs, err := fiberModuleDirs(cwd) + if err != nil { + return fmt.Errorf("find modules: %w", err) + } + + anyChanged := false + for _, dir := range dirs { + modFile := filepath.Join(dir, "go.mod") + b, err := os.ReadFile(modFile) // #nosec G304 + if err != nil { + return fmt.Errorf("read %s: %w", modFile, err) + } + mf, err := modfile.Parse(modFile, b, nil) + if err != nil { + return fmt.Errorf("parse %s: %w", modFile, err) + } + + changed := false + for _, r := range mf.Require { + targetVer, ok := deps[r.Mod.Path] + if !ok { + continue + } + currVer, err := semver.NewVersion(strings.TrimPrefix(r.Mod.Version, "v")) + if err != nil { + return fmt.Errorf("parse %s version in %s: %w", r.Mod.Path, modFile, err) + } + if currVer.LessThan(targetVer) { + r.Mod.Version = "v" + targetVer.String() + changed = true + } + } + + if changed { + mf.SetRequire(mf.Require) + formatted, err := mf.Format() + if err != nil { + return fmt.Errorf("format %s: %w", modFile, err) + } + if err := os.WriteFile(modFile, formatted, 0o600); err != nil { + return fmt.Errorf("write %s: %w", modFile, err) + } + anyChanged = true + } + } + + if anyChanged { + cmd.Println("Updating dependency versions") + } + return nil +} diff --git a/cmd/internal/migrations/dependencies_test.go b/cmd/internal/migrations/dependencies_test.go new file mode 100644 index 0000000..55b6a68 --- /dev/null +++ b/cmd/internal/migrations/dependencies_test.go @@ -0,0 +1,104 @@ +package migrations_test + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + semver "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gofiber/cli/cmd/internal/migrations" +) + +func Test_MigrateDependencies(t *testing.T) { + dir, err := os.MkdirTemp("", "mdeps") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + mod := `module example + +go 1.22 + +require ( + github.com/gofiber/fiber/v3 v3.0.0 + github.com/valyala/fasthttp v1.0.0 + github.com/andybalholm/brotli v1.2.0 +)` + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(mod), 0o600)) + + fiberMod := `module github.com/gofiber/fiber/v3 + +go 1.22 + +require ( + github.com/valyala/fasthttp v1.10.0 + github.com/andybalholm/brotli v1.0.0 +)` + fiberGoMod := filepath.Join(dir, "fiber.mod") + require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) + + origExec := migrations.ExecCommand + migrations.ExecCommand = func(string, ...string) *exec.Cmd { + return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, fiberGoMod)) // #nosec G204 -- testing stub + } + defer func() { migrations.ExecCommand = origExec }() + + var buf bytes.Buffer + cmd := newCmd(&buf) + target := semver.MustParse("3.0.0") + require.NoError(t, migrations.MigrateDependencies(cmd, dir, nil, target)) + + content := readFile(t, filepath.Join(dir, "go.mod")) + assert.Contains(t, content, "github.com/valyala/fasthttp v1.10.0") + assert.Contains(t, content, "github.com/andybalholm/brotli v1.2.0") + assert.Contains(t, buf.String(), "Updating dependency versions") +} + +func Test_MigrateDependencies_NoChange(t *testing.T) { + dir, err := os.MkdirTemp("", "mdeps_nc") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + mod := `module example + +go 1.22 + +require ( + github.com/gofiber/fiber/v3 v3.0.0 + github.com/valyala/fasthttp v1.10.0 + github.com/andybalholm/brotli v1.2.0 +)` + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(mod), 0o600)) + + fiberMod := `module github.com/gofiber/fiber/v3 + +go 1.22 + +require ( + github.com/valyala/fasthttp v1.10.0 + github.com/andybalholm/brotli v1.0.0 +)` + fiberGoMod := filepath.Join(dir, "fiber.mod") + require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) + + origExec := migrations.ExecCommand + migrations.ExecCommand = func(string, ...string) *exec.Cmd { + return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, fiberGoMod)) // #nosec G204 -- testing stub + } + defer func() { migrations.ExecCommand = origExec }() + + var buf bytes.Buffer + cmd := newCmd(&buf) + target := semver.MustParse("3.0.0") + require.NoError(t, migrations.MigrateDependencies(cmd, dir, nil, target)) + + content := readFile(t, filepath.Join(dir, "go.mod")) + assert.Contains(t, content, "github.com/valyala/fasthttp v1.10.0") + assert.Contains(t, content, "github.com/andybalholm/brotli v1.2.0") + assert.Empty(t, buf.String()) +} diff --git a/cmd/internal/migrations/lists.go b/cmd/internal/migrations/lists.go index 1f5957f..2e852ed 100644 --- a/cmd/internal/migrations/lists.go +++ b/cmd/internal/migrations/lists.go @@ -30,7 +30,7 @@ type Migration struct { // Example structure: // {"from": ">=2.0.0", "to": "<=3.*.*", "fn": [MigrateFN, MigrateFN]} var Migrations = []Migration{ - {From: ">=1.0.0", To: ">=0.0.0-0", Functions: []MigrationFn{MigrateGoPkgs}}, + {From: ">=1.0.0", To: ">=0.0.0-0", Functions: []MigrationFn{MigrateGoPkgs, MigrateDependencies}}, { From: ">=2.0.0", To: "<4.0.0-0", diff --git a/cmd/internal/migrations/lists_test.go b/cmd/internal/migrations/lists_test.go index 10e3108..f0e4325 100644 --- a/cmd/internal/migrations/lists_test.go +++ b/cmd/internal/migrations/lists_test.go @@ -2,7 +2,9 @@ package migrations_test import ( "bytes" + "fmt" "os" + "os/exec" "path/filepath" "testing" @@ -44,14 +46,25 @@ func Test_DoMigration_Verbose(t *testing.T) { } func Test_DoMigration_Verbose_Run(t *testing.T) { - t.Parallel() curr := semver.MustParse("1.0.0") target := semver.MustParse("2.0.0") t.Run("no changes", func(t *testing.T) { - t.Parallel() + fiberMod := `module github.com/gofiber/fiber/v2 + +go 1.22 +require github.com/valyala/fasthttp v1.0.0` dir := t.TempDir() + fiberGoMod := filepath.Join(dir, "fiber.mod") + require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) + + origExec := migrations.ExecCommand + migrations.ExecCommand = func(string, ...string) *exec.Cmd { + return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, fiberGoMod)) // #nosec G204 -- testing stub + } + t.Cleanup(func() { migrations.ExecCommand = origExec }) + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\nrequire github.com/gofiber/fiber/v2 v2.0.0\n"), 0o600)) var buf bytes.Buffer cmd := &cobra.Command{} @@ -63,9 +76,21 @@ func Test_DoMigration_Verbose_Run(t *testing.T) { }) t.Run("changes", func(t *testing.T) { - t.Parallel() + fiberMod := `module github.com/gofiber/fiber/v1 + +go 1.22 +require github.com/valyala/fasthttp v1.0.0` dir := t.TempDir() + fiberGoMod := filepath.Join(dir, "fiber.mod") + require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) + + origExec := migrations.ExecCommand + migrations.ExecCommand = func(string, ...string) *exec.Cmd { + return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, fiberGoMod)) // #nosec G204 -- testing stub + } + t.Cleanup(func() { migrations.ExecCommand = origExec }) + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\nrequire github.com/gofiber/fiber/v1 v1.0.0\n"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nimport \"github.com/gofiber/fiber/v1\"\n"), 0o600)) var buf bytes.Buffer diff --git a/cmd/migrate_test.go b/cmd/migrate_test.go index cf0fd93..ea4d281 100644 --- a/cmd/migrate_test.go +++ b/cmd/migrate_test.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "net/http" "os" "os/exec" @@ -12,6 +13,7 @@ import ( "github.com/stretchr/testify/require" cmdinternal "github.com/gofiber/cli/cmd/internal" + "github.com/gofiber/cli/cmd/internal/migrations" ) func readFileTB(tb testing.TB, path string) string { @@ -21,6 +23,36 @@ func readFileTB(tb testing.TB, path string) string { return string(b) } +func TestMain(m *testing.M) { + orig := migrations.ExecCommand + + tmpDir, err := os.MkdirTemp("", "fiber_mod") + if err != nil { + panic(err) + } + fiberMod := `module github.com/gofiber/fiber/v2 + +go 1.22 + +require github.com/valyala/fasthttp v1.0.0` + fiberGoMod := filepath.Join(tmpDir, "go.mod") + if err := os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600); err != nil { + panic(err) + } + + migrations.ExecCommand = func(string, ...string) *exec.Cmd { + return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, fiberGoMod)) // #nosec G204 -- testing stub + } + + code := m.Run() + + migrations.ExecCommand = orig + if err := os.RemoveAll(tmpDir); err != nil { + panic(err) + } + os.Exit(code) +} + const goModV2 = `module example.com/demo go 1.20 From 745f3ae8d230fe170e9806fbe6a75b925ffa812a Mon Sep 17 00:00:00 2001 From: RW Date: Sun, 24 Aug 2025 00:50:03 +0200 Subject: [PATCH 2/3] test: escape fiber go.mod paths --- cmd/internal/migrations/dependencies_test.go | 4 ++-- cmd/internal/migrations/lists_test.go | 4 ++-- cmd/migrate_test.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/internal/migrations/dependencies_test.go b/cmd/internal/migrations/dependencies_test.go index 55b6a68..7087499 100644 --- a/cmd/internal/migrations/dependencies_test.go +++ b/cmd/internal/migrations/dependencies_test.go @@ -44,7 +44,7 @@ require ( origExec := migrations.ExecCommand migrations.ExecCommand = func(string, ...string) *exec.Cmd { - return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, fiberGoMod)) // #nosec G204 -- testing stub + return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod))) // #nosec G204 -- testing stub } defer func() { migrations.ExecCommand = origExec }() @@ -88,7 +88,7 @@ require ( origExec := migrations.ExecCommand migrations.ExecCommand = func(string, ...string) *exec.Cmd { - return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, fiberGoMod)) // #nosec G204 -- testing stub + return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod))) // #nosec G204 -- testing stub } defer func() { migrations.ExecCommand = origExec }() diff --git a/cmd/internal/migrations/lists_test.go b/cmd/internal/migrations/lists_test.go index f0e4325..789dbb8 100644 --- a/cmd/internal/migrations/lists_test.go +++ b/cmd/internal/migrations/lists_test.go @@ -61,7 +61,7 @@ require github.com/valyala/fasthttp v1.0.0` origExec := migrations.ExecCommand migrations.ExecCommand = func(string, ...string) *exec.Cmd { - return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, fiberGoMod)) // #nosec G204 -- testing stub + return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod))) // #nosec G204 -- testing stub } t.Cleanup(func() { migrations.ExecCommand = origExec }) @@ -87,7 +87,7 @@ require github.com/valyala/fasthttp v1.0.0` origExec := migrations.ExecCommand migrations.ExecCommand = func(string, ...string) *exec.Cmd { - return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, fiberGoMod)) // #nosec G204 -- testing stub + return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod))) // #nosec G204 -- testing stub } t.Cleanup(func() { migrations.ExecCommand = origExec }) diff --git a/cmd/migrate_test.go b/cmd/migrate_test.go index ea4d281..114751d 100644 --- a/cmd/migrate_test.go +++ b/cmd/migrate_test.go @@ -41,7 +41,7 @@ require github.com/valyala/fasthttp v1.0.0` } migrations.ExecCommand = func(string, ...string) *exec.Cmd { - return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, fiberGoMod)) // #nosec G204 -- testing stub + return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod))) // #nosec G204 -- testing stub } code := m.Run() From 6413e8310c3538546604db472f88a39cb481d70d Mon Sep 17 00:00:00 2001 From: RW Date: Sun, 24 Aug 2025 01:25:38 +0200 Subject: [PATCH 3/3] test: cross-platform exec stub --- cmd/internal/migrations/dependencies_test.go | 16 ++------ cmd/internal/migrations/exec_stub_test.go | 40 ++++++++++++++++++++ cmd/internal/migrations/lists_test.go | 16 ++------ cmd/migrate_test.go | 7 +++- cmd/tester_test.go | 3 ++ 5 files changed, 57 insertions(+), 25 deletions(-) create mode 100644 cmd/internal/migrations/exec_stub_test.go diff --git a/cmd/internal/migrations/dependencies_test.go b/cmd/internal/migrations/dependencies_test.go index 7087499..30fa605 100644 --- a/cmd/internal/migrations/dependencies_test.go +++ b/cmd/internal/migrations/dependencies_test.go @@ -2,9 +2,7 @@ package migrations_test import ( "bytes" - "fmt" "os" - "os/exec" "path/filepath" "testing" @@ -42,11 +40,8 @@ require ( fiberGoMod := filepath.Join(dir, "fiber.mod") require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) - origExec := migrations.ExecCommand - migrations.ExecCommand = func(string, ...string) *exec.Cmd { - return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod))) // #nosec G204 -- testing stub - } - defer func() { migrations.ExecCommand = origExec }() + restore := stubFiberDownload(t, fiberGoMod) + defer restore() var buf bytes.Buffer cmd := newCmd(&buf) @@ -86,11 +81,8 @@ require ( fiberGoMod := filepath.Join(dir, "fiber.mod") require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) - origExec := migrations.ExecCommand - migrations.ExecCommand = func(string, ...string) *exec.Cmd { - return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod))) // #nosec G204 -- testing stub - } - defer func() { migrations.ExecCommand = origExec }() + restore := stubFiberDownload(t, fiberGoMod) + defer restore() var buf bytes.Buffer cmd := newCmd(&buf) diff --git a/cmd/internal/migrations/exec_stub_test.go b/cmd/internal/migrations/exec_stub_test.go new file mode 100644 index 0000000..58a9665 --- /dev/null +++ b/cmd/internal/migrations/exec_stub_test.go @@ -0,0 +1,40 @@ +package migrations_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/gofiber/cli/cmd/internal/migrations" +) + +// stubFiberDownload replaces migrations.ExecCommand with a helper that +// returns the provided Fiber go.mod path in JSON format. It returns a +// function to restore the original ExecCommand. +func stubFiberDownload(t *testing.T, fiberGoMod string) func() { + t.Helper() + orig := migrations.ExecCommand + out := fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod)) + migrations.ExecCommand = func(string, ...string) *exec.Cmd { + cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--") // #nosec G204 -- test helper + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_STDOUT=" + out, + } + return cmd + } + return func() { migrations.ExecCommand = orig } +} + +func TestHelperProcess(t *testing.T) { + t.Helper() + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + if out := os.Getenv("GO_HELPER_STDOUT"); out != "" { + _, _ = fmt.Fprint(os.Stdout, out) + } + os.Exit(0) //nolint:revive // helper process exits intentionally +} diff --git a/cmd/internal/migrations/lists_test.go b/cmd/internal/migrations/lists_test.go index 789dbb8..0bca80b 100644 --- a/cmd/internal/migrations/lists_test.go +++ b/cmd/internal/migrations/lists_test.go @@ -2,9 +2,7 @@ package migrations_test import ( "bytes" - "fmt" "os" - "os/exec" "path/filepath" "testing" @@ -59,11 +57,8 @@ require github.com/valyala/fasthttp v1.0.0` fiberGoMod := filepath.Join(dir, "fiber.mod") require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) - origExec := migrations.ExecCommand - migrations.ExecCommand = func(string, ...string) *exec.Cmd { - return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod))) // #nosec G204 -- testing stub - } - t.Cleanup(func() { migrations.ExecCommand = origExec }) + restore := stubFiberDownload(t, fiberGoMod) + t.Cleanup(restore) require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\nrequire github.com/gofiber/fiber/v2 v2.0.0\n"), 0o600)) var buf bytes.Buffer @@ -85,11 +80,8 @@ require github.com/valyala/fasthttp v1.0.0` fiberGoMod := filepath.Join(dir, "fiber.mod") require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) - origExec := migrations.ExecCommand - migrations.ExecCommand = func(string, ...string) *exec.Cmd { - return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod))) // #nosec G204 -- testing stub - } - t.Cleanup(func() { migrations.ExecCommand = origExec }) + restore := stubFiberDownload(t, fiberGoMod) + t.Cleanup(restore) require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\nrequire github.com/gofiber/fiber/v1 v1.0.0\n"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nimport \"github.com/gofiber/fiber/v1\"\n"), 0o600)) diff --git a/cmd/migrate_test.go b/cmd/migrate_test.go index 114751d..7b159c4 100644 --- a/cmd/migrate_test.go +++ b/cmd/migrate_test.go @@ -41,7 +41,12 @@ require github.com/valyala/fasthttp v1.0.0` } migrations.ExecCommand = func(string, ...string) *exec.Cmd { - return exec.Command("echo", fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod))) // #nosec G204 -- testing stub + cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--", "go", "mod", "download") // #nosec G204 -- test helper + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_STDOUT=" + fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod)), + } + return cmd } code := m.Run() diff --git a/cmd/tester_test.go b/cmd/tester_test.go index 140c1e0..c962588 100644 --- a/cmd/tester_test.go +++ b/cmd/tester_test.go @@ -57,6 +57,9 @@ func TestHelperProcess(t *testing.T) { testExit(1) return } + if out := os.Getenv("GO_HELPER_STDOUT"); out != "" { + _, _ = fmt.Fprint(os.Stdout, out) + } testExit(0) }