A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(command): port time from just-bash

Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 04aa2bce 8f5fe3a5

+679
+220
command/internal/time/time.go
··· 1 + package time 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "math" 9 + "os" 10 + "path" 11 + "strings" 12 + stdtime "time" 13 + 14 + "mvdan.cc/sh/v3/interp" 15 + "tangled.org/xeiaso.net/kefka/command" 16 + "tangled.org/xeiaso.net/kefka/command/registry" 17 + ) 18 + 19 + // Impl times the execution of another registered command. 20 + // 21 + // Registry must be non-nil for the command-execution path; without it, 22 + // `time CMD ...` cannot dispatch to the inner command. The no-command 23 + // path (silent success) still works without a registry. 24 + type Impl struct { 25 + Registry *registry.Impl 26 + } 27 + 28 + func (impl Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 29 + if ec == nil { 30 + return errors.New("time: nil ExecContext") 31 + } 32 + 33 + stderr := ec.Stderr 34 + if stderr == nil { 35 + stderr = io.Discard 36 + } 37 + 38 + usage := func() { 39 + io.WriteString(stderr, "Usage: time [OPTION]... COMMAND [ARGUMENT]...\n") 40 + io.WriteString(stderr, "Time the execution of COMMAND.\n\n") 41 + io.WriteString(stderr, " -f, --format=FORMAT use FORMAT for output (default \"%e %M\")\n") 42 + io.WriteString(stderr, " -o, --output=FILE write timing output to FILE\n") 43 + io.WriteString(stderr, " -a, --append append to output file (with -o)\n") 44 + io.WriteString(stderr, " -v, --verbose verbose output\n") 45 + io.WriteString(stderr, " -p, --portability POSIX portable output format\n") 46 + io.WriteString(stderr, " --help display this help and exit\n\n") 47 + io.WriteString(stderr, "Format specifiers:\n") 48 + io.WriteString(stderr, " %C Command being timed\n") 49 + io.WriteString(stderr, " %e Elapsed real time in seconds\n") 50 + io.WriteString(stderr, " %E Elapsed time in [hours:]minutes:seconds format\n") 51 + io.WriteString(stderr, " %M Maximum resident set size (KB) - always 0\n") 52 + io.WriteString(stderr, " %S System CPU time (seconds) - always 0.00\n") 53 + io.WriteString(stderr, " %U User CPU time (seconds) - always 0.00\n") 54 + io.WriteString(stderr, " %P CPU percentage - always 0%\n") 55 + } 56 + 57 + format := "%e %M" 58 + outputFile := "" 59 + appendMode := false 60 + posixFormat := false 61 + 62 + i := 0 63 + parseLoop: 64 + for i < len(args) { 65 + arg := args[i] 66 + switch { 67 + case arg == "-f" || arg == "--format": 68 + i++ 69 + if i >= len(args) { 70 + fmt.Fprint(stderr, "time: missing argument to '-f'\n") 71 + return interp.ExitStatus(1) 72 + } 73 + format = args[i] 74 + i++ 75 + case strings.HasPrefix(arg, "--format="): 76 + format = strings.TrimPrefix(arg, "--format=") 77 + i++ 78 + case arg == "-o" || arg == "--output": 79 + i++ 80 + if i >= len(args) { 81 + fmt.Fprint(stderr, "time: missing argument to '-o'\n") 82 + return interp.ExitStatus(1) 83 + } 84 + outputFile = args[i] 85 + i++ 86 + case strings.HasPrefix(arg, "--output="): 87 + outputFile = strings.TrimPrefix(arg, "--output=") 88 + i++ 89 + case arg == "-a" || arg == "--append": 90 + appendMode = true 91 + i++ 92 + case arg == "-v" || arg == "--verbose": 93 + format = "Command being timed: %C\nElapsed (wall clock) time: %e seconds\nMaximum resident set size (kbytes): %M" 94 + i++ 95 + case arg == "-p" || arg == "--portability": 96 + posixFormat = true 97 + i++ 98 + case arg == "--help": 99 + usage() 100 + return nil 101 + case arg == "--": 102 + i++ 103 + break parseLoop 104 + case strings.HasPrefix(arg, "-") && arg != "-": 105 + // Unknown option — skip it (be permissive like GNU time). 106 + i++ 107 + default: 108 + break parseLoop 109 + } 110 + } 111 + 112 + commandArgs := args[i:] 113 + 114 + // No command specified — return success silently (matches GNU time). 115 + if len(commandArgs) == 0 { 116 + return nil 117 + } 118 + 119 + displayCommand := strings.Join(commandArgs, " ") 120 + 121 + startTime := stdtime.Now() 122 + innerErr := runInner(ctx, impl.Registry, ec, commandArgs) 123 + elapsedSeconds := stdtime.Since(startTime).Seconds() 124 + 125 + var timingOutput string 126 + if posixFormat { 127 + timingOutput = fmt.Sprintf("real %.2f\nuser 0.00\nsys 0.00\n", elapsedSeconds) 128 + } else { 129 + timingOutput = applyFormat(format, elapsedSeconds, displayCommand) 130 + if !strings.HasSuffix(timingOutput, "\n") { 131 + timingOutput += "\n" 132 + } 133 + } 134 + 135 + if outputFile != "" { 136 + if err := writeTimingFile(ec, outputFile, timingOutput, appendMode); err != nil { 137 + fmt.Fprintf(stderr, "time: cannot write to '%s': %v\n", outputFile, err) 138 + } 139 + } else { 140 + fmt.Fprint(stderr, timingOutput) 141 + } 142 + 143 + return innerErr 144 + } 145 + 146 + func runInner(ctx context.Context, reg *registry.Impl, ec *command.ExecContext, commandArgs []string) error { 147 + if reg == nil { 148 + fmt.Fprint(ec.Stderr, "time: exec not available\n") 149 + return interp.ExitStatus(127) 150 + } 151 + cmd, ok := reg.Get(commandArgs[0]) 152 + if !ok { 153 + fmt.Fprintf(ec.Stderr, "time: %s: command not found\n", commandArgs[0]) 154 + return interp.ExitStatus(127) 155 + } 156 + return cmd.Exec(ctx, ec, commandArgs[1:]) 157 + } 158 + 159 + func writeTimingFile(ec *command.ExecContext, outputFile, timingOutput string, appendMode bool) error { 160 + if ec.FS == nil { 161 + return errors.New("no filesystem available") 162 + } 163 + full := resolvePath(ec, outputFile) 164 + 165 + flag := os.O_CREATE | os.O_WRONLY 166 + if appendMode { 167 + flag |= os.O_APPEND 168 + } else { 169 + flag |= os.O_TRUNC 170 + } 171 + 172 + f, err := ec.FS.OpenFile(full, flag, 0o644) 173 + if err != nil { 174 + return err 175 + } 176 + defer f.Close() 177 + _, err = io.WriteString(f, timingOutput) 178 + return err 179 + } 180 + 181 + func applyFormat(format string, elapsedSeconds float64, displayCommand string) string { 182 + out := format 183 + out = strings.ReplaceAll(out, "%e", fmt.Sprintf("%.2f", elapsedSeconds)) 184 + out = strings.ReplaceAll(out, "%E", formatElapsedTime(elapsedSeconds)) 185 + out = strings.ReplaceAll(out, "%M", "0") 186 + out = strings.ReplaceAll(out, "%S", "0.00") 187 + out = strings.ReplaceAll(out, "%U", "0.00") 188 + out = strings.ReplaceAll(out, "%P", "0%") 189 + out = strings.ReplaceAll(out, "%C", displayCommand) 190 + return out 191 + } 192 + 193 + func formatElapsedTime(seconds float64) string { 194 + hours := int(seconds / 3600) 195 + minutes := int(math.Mod(seconds, 3600) / 60) 196 + secs := math.Mod(seconds, 60) 197 + if hours > 0 { 198 + return fmt.Sprintf("%d:%02d:%05.2f", hours, minutes, secs) 199 + } 200 + return fmt.Sprintf("%d:%05.2f", minutes, secs) 201 + } 202 + 203 + func resolvePath(ec *command.ExecContext, p string) string { 204 + dir := ec.Dir 205 + if dir == "" { 206 + dir = "." 207 + } 208 + if path.IsAbs(p) { 209 + p = strings.TrimPrefix(p, "/") 210 + if p == "" { 211 + return "." 212 + } 213 + return path.Clean(p) 214 + } 215 + joined := path.Join(dir, p) 216 + if joined == "" { 217 + return "." 218 + } 219 + return joined 220 + }
+459
command/internal/time/time_test.go
··· 1 + package time 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "errors" 7 + "io" 8 + "os" 9 + "strings" 10 + "testing" 11 + 12 + "github.com/go-git/go-billy/v5" 13 + "github.com/go-git/go-billy/v5/memfs" 14 + "mvdan.cc/sh/v3/interp" 15 + "tangled.org/xeiaso.net/kefka/command" 16 + "tangled.org/xeiaso.net/kefka/command/registry" 17 + ) 18 + 19 + // echoImpl is a minimal Execer used to verify time dispatches to the inner 20 + // command. Writes its joined args to stdout, returns nil. 21 + type echoImpl struct{} 22 + 23 + func (echoImpl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 24 + if ec.Stdout != nil { 25 + io.WriteString(ec.Stdout, strings.Join(args, " ")) 26 + io.WriteString(ec.Stdout, "\n") 27 + } 28 + return nil 29 + } 30 + 31 + // failImpl returns a non-zero ExitStatus, used to verify time propagates 32 + // inner failure exit codes. 33 + type failImpl struct{} 34 + 35 + func (failImpl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 36 + return interp.ExitStatus(3) 37 + } 38 + 39 + // stdinEchoImpl reads from stdin and writes it to stdout, used to verify 40 + // time passes the outer stdin through to the inner command. 41 + type stdinEchoImpl struct{} 42 + 43 + func (stdinEchoImpl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 44 + if ec.Stdin == nil || ec.Stdout == nil { 45 + return nil 46 + } 47 + data, err := io.ReadAll(ec.Stdin) 48 + if err != nil { 49 + return err 50 + } 51 + ec.Stdout.Write(data) 52 + return nil 53 + } 54 + 55 + func newRegistry(t *testing.T) *registry.Impl { 56 + t.Helper() 57 + reg := registry.New() 58 + reg.Register("echo", echoImpl{}) 59 + reg.Register("fail", failImpl{}) 60 + reg.Register("stdin-echo", stdinEchoImpl{}) 61 + return reg 62 + } 63 + 64 + func newFS(t *testing.T) billy.Filesystem { 65 + t.Helper() 66 + return memfs.New() 67 + } 68 + 69 + type runResult struct { 70 + stdout string 71 + stderr string 72 + err error 73 + } 74 + 75 + func run(t *testing.T, args []string, opts ...func(*command.ExecContext)) runResult { 76 + t.Helper() 77 + var stdout, stderr bytes.Buffer 78 + ec := &command.ExecContext{ 79 + Stdout: &stdout, 80 + Stderr: &stderr, 81 + Dir: ".", 82 + FS: newFS(t), 83 + } 84 + for _, opt := range opts { 85 + opt(ec) 86 + } 87 + impl := Impl{Registry: newRegistry(t)} 88 + err := impl.Exec(context.Background(), ec, args) 89 + return runResult{ 90 + stdout: stdout.String(), 91 + stderr: stderr.String(), 92 + err: err, 93 + } 94 + } 95 + 96 + func TestTime_NoCommand(t *testing.T) { 97 + r := run(t, nil) 98 + if r.err != nil { 99 + t.Fatalf("unexpected error: %v", r.err) 100 + } 101 + if r.stdout != "" { 102 + t.Errorf("stdout = %q, want empty", r.stdout) 103 + } 104 + if r.stderr != "" { 105 + t.Errorf("stderr = %q, want empty", r.stderr) 106 + } 107 + } 108 + 109 + func TestTime_Help(t *testing.T) { 110 + r := run(t, []string{"--help"}) 111 + if r.err != nil { 112 + t.Fatalf("unexpected error: %v", r.err) 113 + } 114 + if r.stdout != "" { 115 + t.Errorf("stdout = %q, want empty", r.stdout) 116 + } 117 + wantLines := []string{ 118 + "Usage: time [OPTION]... COMMAND [ARGUMENT]...", 119 + "-f, --format=FORMAT", 120 + "--help", 121 + "Format specifiers:", 122 + } 123 + for _, line := range wantLines { 124 + if !strings.Contains(r.stderr, line) { 125 + t.Errorf("stderr missing %q; got: %q", line, r.stderr) 126 + } 127 + } 128 + } 129 + 130 + func TestTime_DefaultFormat(t *testing.T) { 131 + r := run(t, []string{"echo", "hello"}) 132 + if r.err != nil { 133 + t.Fatalf("unexpected error: %v", r.err) 134 + } 135 + if r.stdout != "hello\n" { 136 + t.Errorf("stdout = %q, want %q", r.stdout, "hello\n") 137 + } 138 + // Default format is "%e %M" → "<seconds> 0\n" 139 + if !strings.HasSuffix(r.stderr, " 0\n") { 140 + t.Errorf("stderr = %q, want suffix %q", r.stderr, " 0\n") 141 + } 142 + } 143 + 144 + func TestTime_PortableFormat(t *testing.T) { 145 + r := run(t, []string{"-p", "echo", "hi"}) 146 + if r.err != nil { 147 + t.Fatalf("unexpected error: %v", r.err) 148 + } 149 + if r.stdout != "hi\n" { 150 + t.Errorf("stdout = %q, want %q", r.stdout, "hi\n") 151 + } 152 + for _, want := range []string{"real ", "user 0.00\n", "sys 0.00\n"} { 153 + if !strings.Contains(r.stderr, want) { 154 + t.Errorf("stderr missing %q; got: %q", want, r.stderr) 155 + } 156 + } 157 + } 158 + 159 + func TestTime_VerboseFormat(t *testing.T) { 160 + r := run(t, []string{"-v", "echo", "hi"}) 161 + if r.err != nil { 162 + t.Fatalf("unexpected error: %v", r.err) 163 + } 164 + wantLines := []string{ 165 + "Command being timed: echo hi", 166 + "Elapsed (wall clock) time:", 167 + "Maximum resident set size (kbytes): 0", 168 + } 169 + for _, line := range wantLines { 170 + if !strings.Contains(r.stderr, line) { 171 + t.Errorf("stderr missing %q; got: %q", line, r.stderr) 172 + } 173 + } 174 + } 175 + 176 + func TestTime_FlagParsing(t *testing.T) { 177 + tests := []struct { 178 + name string 179 + args []string 180 + wantOut string 181 + wantErr []string // substrings stderr must contain 182 + errCode uint8 // 0 = no error 183 + }{ 184 + { 185 + name: "short format with separate value", 186 + args: []string{"-f", "elapsed=%e", "echo", "x"}, 187 + wantOut: "x\n", 188 + wantErr: []string{"elapsed="}, 189 + }, 190 + { 191 + name: "long format with equals", 192 + args: []string{"--format=cmd=%C", "echo", "x"}, 193 + wantOut: "x\n", 194 + wantErr: []string{"cmd=echo x"}, 195 + }, 196 + { 197 + name: "long format with separate value", 198 + args: []string{"--format", "cmd=%C", "echo", "x"}, 199 + wantOut: "x\n", 200 + wantErr: []string{"cmd=echo x"}, 201 + }, 202 + { 203 + name: "double-dash ends parsing", 204 + args: []string{"--", "echo", "-p"}, 205 + wantOut: "-p\n", 206 + }, 207 + { 208 + name: "unknown flag is permissively skipped", 209 + args: []string{"--unknown", "echo", "x"}, 210 + wantOut: "x\n", 211 + }, 212 + { 213 + name: "missing argument to -f", 214 + args: []string{"-f"}, 215 + wantErr: []string{"missing argument to '-f'"}, 216 + errCode: 1, 217 + }, 218 + { 219 + name: "missing argument to -o", 220 + args: []string{"-o"}, 221 + wantErr: []string{"missing argument to '-o'"}, 222 + errCode: 1, 223 + }, 224 + } 225 + 226 + for _, tt := range tests { 227 + t.Run(tt.name, func(t *testing.T) { 228 + r := run(t, tt.args) 229 + if tt.errCode != 0 { 230 + var status interp.ExitStatus 231 + if !errors.As(r.err, &status) || uint8(status) != tt.errCode { 232 + t.Errorf("err = %v, want ExitStatus(%d)", r.err, tt.errCode) 233 + } 234 + } else if r.err != nil { 235 + t.Fatalf("unexpected error: %v", r.err) 236 + } 237 + if tt.wantOut != "" && r.stdout != tt.wantOut { 238 + t.Errorf("stdout = %q, want %q", r.stdout, tt.wantOut) 239 + } 240 + for _, sub := range tt.wantErr { 241 + if !strings.Contains(r.stderr, sub) { 242 + t.Errorf("stderr missing %q; got: %q", sub, r.stderr) 243 + } 244 + } 245 + }) 246 + } 247 + } 248 + 249 + func TestTime_FormatSpecifiers(t *testing.T) { 250 + // Validate that the static-value specifiers (%M, %S, %U, %P) match GNU 251 + // time conventions even though we don't have real metrics. 252 + tests := []struct { 253 + name string 254 + format string 255 + want string 256 + }{ 257 + {"max RSS placeholder", "%M", "0\n"}, 258 + {"system CPU placeholder", "%S", "0.00\n"}, 259 + {"user CPU placeholder", "%U", "0.00\n"}, 260 + {"CPU percentage placeholder", "%P", "0%\n"}, 261 + {"command substitution", "%C", "echo hello\n"}, 262 + } 263 + 264 + for _, tt := range tests { 265 + t.Run(tt.name, func(t *testing.T) { 266 + r := run(t, []string{"-f", tt.format, "echo", "hello"}) 267 + if r.err != nil { 268 + t.Fatalf("unexpected error: %v", r.err) 269 + } 270 + if !strings.HasSuffix(r.stderr, tt.want) { 271 + t.Errorf("stderr = %q, want suffix %q", r.stderr, tt.want) 272 + } 273 + }) 274 + } 275 + } 276 + 277 + func TestTime_FormatElapsedTime(t *testing.T) { 278 + tests := []struct { 279 + name string 280 + seconds float64 281 + want string 282 + }{ 283 + {"sub-minute", 1.5, "0:01.50"}, 284 + {"minute boundary", 60, "1:00.00"}, 285 + {"hour boundary", 3600, "1:00:00.00"}, 286 + {"complex", 3661.25, "1:01:01.25"}, 287 + } 288 + for _, tt := range tests { 289 + t.Run(tt.name, func(t *testing.T) { 290 + got := formatElapsedTime(tt.seconds) 291 + if got != tt.want { 292 + t.Errorf("formatElapsedTime(%v) = %q, want %q", tt.seconds, got, tt.want) 293 + } 294 + }) 295 + } 296 + } 297 + 298 + func TestTime_OutputToFile(t *testing.T) { 299 + fs := memfs.New() 300 + var stdout, stderr bytes.Buffer 301 + ec := &command.ExecContext{ 302 + Stdout: &stdout, 303 + Stderr: &stderr, 304 + Dir: ".", 305 + FS: fs, 306 + } 307 + impl := Impl{Registry: newRegistry(t)} 308 + err := impl.Exec(context.Background(), ec, []string{"-o", "out.log", "-f", "fixed", "echo", "hi"}) 309 + if err != nil { 310 + t.Fatalf("unexpected error: %v", err) 311 + } 312 + // stderr should not contain timing — it went to the file instead. 313 + if stderr.String() != "" { 314 + t.Errorf("stderr = %q, want empty (timing should have gone to file)", stderr.String()) 315 + } 316 + got := readFile(t, fs, "out.log") 317 + if got != "fixed\n" { 318 + t.Errorf("file content = %q, want %q", got, "fixed\n") 319 + } 320 + } 321 + 322 + func TestTime_AppendToFile(t *testing.T) { 323 + fs := memfs.New() 324 + // Pre-seed the file so we can verify append vs overwrite. 325 + f, err := fs.OpenFile("out.log", os.O_CREATE|os.O_WRONLY, 0o644) 326 + if err != nil { 327 + t.Fatal(err) 328 + } 329 + io.WriteString(f, "previous\n") 330 + f.Close() 331 + 332 + var stdout, stderr bytes.Buffer 333 + ec := &command.ExecContext{ 334 + Stdout: &stdout, 335 + Stderr: &stderr, 336 + Dir: ".", 337 + FS: fs, 338 + } 339 + impl := Impl{Registry: newRegistry(t)} 340 + err = impl.Exec(context.Background(), ec, []string{"-o", "out.log", "-a", "-f", "appended", "echo", "hi"}) 341 + if err != nil { 342 + t.Fatalf("unexpected error: %v", err) 343 + } 344 + got := readFile(t, fs, "out.log") 345 + want := "previous\nappended\n" 346 + if got != want { 347 + t.Errorf("file content = %q, want %q", got, want) 348 + } 349 + } 350 + 351 + func TestTime_OverwriteFile(t *testing.T) { 352 + fs := memfs.New() 353 + f, err := fs.OpenFile("out.log", os.O_CREATE|os.O_WRONLY, 0o644) 354 + if err != nil { 355 + t.Fatal(err) 356 + } 357 + io.WriteString(f, "previous\n") 358 + f.Close() 359 + 360 + var stdout, stderr bytes.Buffer 361 + ec := &command.ExecContext{ 362 + Stdout: &stdout, 363 + Stderr: &stderr, 364 + Dir: ".", 365 + FS: fs, 366 + } 367 + impl := Impl{Registry: newRegistry(t)} 368 + err = impl.Exec(context.Background(), ec, []string{"-o", "out.log", "-f", "fresh", "echo", "hi"}) 369 + if err != nil { 370 + t.Fatalf("unexpected error: %v", err) 371 + } 372 + got := readFile(t, fs, "out.log") 373 + if got != "fresh\n" { 374 + t.Errorf("file content = %q, want %q (no append → overwrite)", got, "fresh\n") 375 + } 376 + } 377 + 378 + func TestTime_PropagatesInnerExitCode(t *testing.T) { 379 + r := run(t, []string{"fail"}) 380 + var status interp.ExitStatus 381 + if !errors.As(r.err, &status) || uint8(status) != 3 { 382 + t.Errorf("err = %v, want ExitStatus(3)", r.err) 383 + } 384 + // Timing should still be written even though the inner command failed. 385 + if r.stderr == "" { 386 + t.Errorf("expected timing on stderr even on inner failure") 387 + } 388 + } 389 + 390 + func TestTime_CommandNotFound(t *testing.T) { 391 + r := run(t, []string{"does-not-exist"}) 392 + var status interp.ExitStatus 393 + if !errors.As(r.err, &status) || uint8(status) != 127 { 394 + t.Errorf("err = %v, want ExitStatus(127)", r.err) 395 + } 396 + if !strings.Contains(r.stderr, "does-not-exist: command not found") { 397 + t.Errorf("stderr missing not-found message: %q", r.stderr) 398 + } 399 + } 400 + 401 + func TestTime_NilRegistry(t *testing.T) { 402 + var stdout, stderr bytes.Buffer 403 + ec := &command.ExecContext{ 404 + Stdout: &stdout, 405 + Stderr: &stderr, 406 + Dir: ".", 407 + FS: memfs.New(), 408 + } 409 + impl := Impl{Registry: nil} 410 + err := impl.Exec(context.Background(), ec, []string{"echo", "hi"}) 411 + var status interp.ExitStatus 412 + if !errors.As(err, &status) || uint8(status) != 127 { 413 + t.Errorf("err = %v, want ExitStatus(127)", err) 414 + } 415 + if !strings.Contains(stderr.String(), "exec not available") { 416 + t.Errorf("stderr missing 'exec not available': %q", stderr.String()) 417 + } 418 + } 419 + 420 + func TestTime_StdinPassthrough(t *testing.T) { 421 + var stdout, stderr bytes.Buffer 422 + ec := &command.ExecContext{ 423 + Stdin: strings.NewReader("piped through"), 424 + Stdout: &stdout, 425 + Stderr: &stderr, 426 + Dir: ".", 427 + FS: memfs.New(), 428 + } 429 + impl := Impl{Registry: newRegistry(t)} 430 + err := impl.Exec(context.Background(), ec, []string{"stdin-echo"}) 431 + if err != nil { 432 + t.Fatalf("unexpected error: %v", err) 433 + } 434 + if stdout.String() != "piped through" { 435 + t.Errorf("stdout = %q, want %q", stdout.String(), "piped through") 436 + } 437 + } 438 + 439 + func TestTime_NilExecContext(t *testing.T) { 440 + impl := Impl{Registry: newRegistry(t)} 441 + err := impl.Exec(context.Background(), nil, []string{"echo", "hi"}) 442 + if err == nil || !strings.Contains(err.Error(), "nil ExecContext") { 443 + t.Errorf("err = %v, want nil ExecContext error", err) 444 + } 445 + } 446 + 447 + func readFile(t *testing.T, fs billy.Filesystem, name string) string { 448 + t.Helper() 449 + f, err := fs.Open(name) 450 + if err != nil { 451 + t.Fatalf("open %s: %v", name, err) 452 + } 453 + defer f.Close() 454 + data, err := io.ReadAll(f) 455 + if err != nil { 456 + t.Fatalf("read %s: %v", name, err) 457 + } 458 + return string(data) 459 + }