package subprocess import ( "context" "os" "path/filepath" "strings" "testing" "time" "gopkg.in/yaml.v3" ) func TestRunSuccessCapturesStdoutStderr(t *testing.T) { exe, err := os.Executable() if err != nil { t.Fatalf("os.Executable() error = %v", err) } dir := t.TempDir() stdoutPath := filepath.Join(dir, "stdout.log") stderrPath := filepath.Join(dir, "stderr.log") req := RunRequest{ Executable: exe, Args: []string{"-test.run=TestSubprocessHelper", "--", "success"}, EnvOverrides: map[string]string{"GO_WANT_SUBPROCESS_HELPER": "1", "SUBPROCESS_HELPER_STDOUT": "hello-out", "SUBPROCESS_HELPER_STDERR": "hello-err"}, StdoutLogPath: stdoutPath, StderrLogPath: stderrPath, } res, err := Run(context.Background(), req) if err != nil { t.Fatalf("Run() error = %v", err) } if res.ExitCode != 0 { t.Fatalf("ExitCode = %d, want 0", res.ExitCode) } stdoutBytes, err := os.ReadFile(stdoutPath) if err != nil { t.Fatalf("read stdout log: %v", err) } if !strings.Contains(string(stdoutBytes), "hello-out") { t.Fatalf("stdout log = %q, want hello-out", string(stdoutBytes)) } stderrBytes, err := os.ReadFile(stderrPath) if err != nil { t.Fatalf("read stderr log: %v", err) } if !strings.Contains(string(stderrBytes), "hello-err") { t.Fatalf("stderr log = %q, want hello-err", string(stderrBytes)) } } func TestRunFailureReturnsUsefulError(t *testing.T) { exe, err := os.Executable() if err != nil { t.Fatalf("os.Executable() error = %v", err) } req := RunRequest{ Executable: exe, Args: []string{"-test.run=TestSubprocessHelper", "--", "fail"}, EnvOverrides: map[string]string{ "GO_WANT_SUBPROCESS_HELPER": "1", }, } res, err := Run(context.Background(), req) if err == nil { t.Fatal("Run() error = nil, want non-nil") } if res.ExitCode == 0 { t.Fatalf("ExitCode = %d, want non-zero", res.ExitCode) } if !strings.Contains(err.Error(), "exit code") { t.Fatalf("error = %q, want exit code context", err.Error()) } if !strings.Contains(err.Error(), exe) { t.Fatalf("error = %q, want executable context", err.Error()) } } func TestRunTimeout(t *testing.T) { exe, err := os.Executable() if err != nil { t.Fatalf("os.Executable() error = %v", err) } req := RunRequest{ Executable: exe, Args: []string{"-test.run=TestSubprocessHelper", "--", "sleep"}, EnvOverrides: map[string]string{ "GO_WANT_SUBPROCESS_HELPER": "1", }, Timeout: 50 * time.Millisecond, } res, err := Run(context.Background(), req) if err == nil { t.Fatal("Run() error = nil, want timeout error") } if !res.TimedOut { t.Fatalf("TimedOut = %v, want true", res.TimedOut) } if !strings.Contains(err.Error(), "timed out") { t.Fatalf("error = %q, want timeout context", err.Error()) } } func TestRunInheritsParentEnvironmentByDefault(t *testing.T) { exe, err := os.Executable() if err != nil { t.Fatalf("os.Executable() error = %v", err) } t.Setenv("GO_WANT_SUBPROCESS_HELPER", "1") t.Setenv("SUBPROCESS_HELPER_ENV_KEY", "SUBPROCESS_PARENT_VALUE") t.Setenv("SUBPROCESS_PARENT_VALUE", "inherited-value") dir := t.TempDir() stdoutPath := filepath.Join(dir, "stdout.log") req := RunRequest{ Executable: exe, Args: []string{"-test.run=TestSubprocessHelper", "--", "printenv"}, StdoutLogPath: stdoutPath, } if _, err := Run(context.Background(), req); err != nil { t.Fatalf("Run() error = %v", err) } data, err := os.ReadFile(stdoutPath) if err != nil { t.Fatalf("read stdout log: %v", err) } if strings.TrimSpace(string(data)) != "inherited-value" { t.Fatalf("stdout = %q, want inherited-value", strings.TrimSpace(string(data))) } } func TestRunEnvOverridesWinOverInheritedValues(t *testing.T) { exe, err := os.Executable() if err != nil { t.Fatalf("os.Executable() error = %v", err) } t.Setenv("GO_WANT_SUBPROCESS_HELPER", "1") t.Setenv("SUBPROCESS_HELPER_ENV_KEY", "SUBPROCESS_PARENT_VALUE") t.Setenv("SUBPROCESS_PARENT_VALUE", "parent-value") dir := t.TempDir() stdoutPath := filepath.Join(dir, "stdout.log") req := RunRequest{ Executable: exe, Args: []string{"-test.run=TestSubprocessHelper", "--", "printenv"}, EnvOverrides: map[string]string{"SUBPROCESS_PARENT_VALUE": "override-value"}, StdoutLogPath: stdoutPath, } if _, err := Run(context.Background(), req); err != nil { t.Fatalf("Run() error = %v", err) } data, err := os.ReadFile(stdoutPath) if err != nil { t.Fatalf("read stdout log: %v", err) } if strings.TrimSpace(string(data)) != "override-value" { t.Fatalf("stdout = %q, want override-value", strings.TrimSpace(string(data))) } } func TestWriteYAMLAtomic(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.generated.yml") if err := WriteYAMLAtomic(path, map[string]any{"name": "narratio", "stage": "merge"}, 0o644); err != nil { t.Fatalf("WriteYAMLAtomic() error = %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("read yaml: %v", err) } var got map[string]any if err := yaml.Unmarshal(data, &got); err != nil { t.Fatalf("yaml unmarshal: %v", err) } if got["name"] != "narratio" { t.Fatalf("name = %#v, want narratio", got["name"]) } } func TestWriteYAMLAtomicOverwriteNoTempResidue(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.generated.yml") if err := WriteYAMLAtomic(path, map[string]any{"value": "one"}, 0o644); err != nil { t.Fatalf("first write: %v", err) } if err := WriteYAMLAtomic(path, map[string]any{"value": "two"}, 0o644); err != nil { t.Fatalf("second write: %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("read yaml: %v", err) } if !strings.Contains(string(data), "two") { t.Fatalf("yaml = %q, want overwritten value", string(data)) } entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir() error = %v", err) } for _, entry := range entries { name := entry.Name() if strings.Contains(name, ".tmp-") { t.Fatalf("temp file residue found: %q", name) } } } func TestSubprocessHelper(t *testing.T) { if os.Getenv("GO_WANT_SUBPROCESS_HELPER") != "1" { return } args := os.Args mode := "" for i := range args { if args[i] == "--" && i+1 < len(args) { mode = args[i+1] break } } if mode == "" { os.Exit(2) } switch mode { case "success": _, _ = os.Stdout.WriteString(os.Getenv("SUBPROCESS_HELPER_STDOUT") + "\n") _, _ = os.Stderr.WriteString(os.Getenv("SUBPROCESS_HELPER_STDERR") + "\n") os.Exit(0) case "fail": _, _ = os.Stderr.WriteString("intentional failure\n") os.Exit(3) case "sleep": time.Sleep(500 * time.Millisecond) os.Exit(0) case "printenv": key := os.Getenv("SUBPROCESS_HELPER_ENV_KEY") _, _ = os.Stdout.WriteString(os.Getenv(key) + "\n") os.Exit(0) default: os.Exit(2) } }