diff --git a/Makefile b/Makefile index 7e5445ec..33f11490 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ timecraft.src.go = \ $(wildcard */*/*.go) \ $(wildcard */*/*/*.go) -timecraft: go.mod $(timecraft.src.go) +timecraft: go.mod flatbuffers $(timecraft.src.go) $(GO) build -o timecraft clean: @@ -32,7 +32,7 @@ generate: flatbuffers flatbuffers: go.mod $(format.src.go) $(GO) build ./format/... -test: flatbuffers testdata +test: timecraft testdata $(GO) test ./... testdata: $(testdata.go.wasm) diff --git a/internal/cmd/config.go b/config.go similarity index 98% rename from internal/cmd/config.go rename to config.go index 7f93cd92..f534b99d 100644 --- a/internal/cmd/config.go +++ b/config.go @@ -1,4 +1,4 @@ -package cmd +package main import ( "bytes" @@ -24,7 +24,7 @@ Options: -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) --edit Open $EDITOR to edit the configuration -h, --help Show usage information - -o, --ouptut format Output format, one of: text, json, yaml + -o, --output format Output format, one of: text, json, yaml ` var ( diff --git a/internal/cmd/describe.go b/describe.go similarity index 99% rename from internal/cmd/describe.go rename to describe.go index e89ef16d..ea90b9c6 100644 --- a/internal/cmd/describe.go +++ b/describe.go @@ -1,4 +1,4 @@ -package cmd +package main import ( "context" @@ -49,7 +49,7 @@ Examples: Options: -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) -h, --help Show this usage information - -o, --ouptut format Output format, one of: text, json, yaml + -o, --output format Output format, one of: text, json, yaml ` func describe(ctx context.Context, args []string) error { diff --git a/internal/cmd/export.go b/export.go similarity index 94% rename from internal/cmd/export.go rename to export.go index 3dfa5785..dd9c00ef 100644 --- a/internal/cmd/export.go +++ b/export.go @@ -1,4 +1,4 @@ -package cmd +package main import ( "context" @@ -11,7 +11,7 @@ import ( ) const exportUsage = ` -Usage: timecraft export +Usage: timecraft export [options] The export command reads resources from the time machine registry and writes them to local files. This command is useful to extract data generated by @@ -31,12 +31,11 @@ func export(ctx context.Context, args []string) error { args = parseFlags(flagSet, args) if len(args) != 3 { - return errors.New(`expected resource type, id, and output file as argument` + useCmd("export")) + return usageError(`Expected resource type, id, and output file as argument` + useCmd("export")) } - resource, err := findResource("describe", args[0]) if err != nil { - return err + return usageError(err.Error()) } config, err := loadConfig() if err != nil { diff --git a/export_test.go b/export_test.go new file mode 100644 index 00000000..32e71899 --- /dev/null +++ b/export_test.go @@ -0,0 +1,66 @@ +package main_test + +import ( + "os" + "strings" + "testing" + + "github.com/stealthrocket/timecraft/internal/assert" +) + +var export = tests{ + "show the export command help with the short option": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "export", "-h") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft export ") + assert.Equal(t, stderr, "") + }, + + "show the export command help with the long option": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "export", "--help") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft export ") + assert.Equal(t, stderr, "") + }, + + "export without a resource type": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "export") + assert.ExitError(t, err, 2) + assert.Equal(t, stdout, "") + assert.HasPrefix(t, stderr, "Expected resource type, id, and output file as argument") + }, + + "export without a resource id": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "export", "profile") + assert.ExitError(t, err, 2) + assert.Equal(t, stdout, "") + assert.HasPrefix(t, stderr, "Expected resource type, id, and output file as argument") + }, + + "export without an output file": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "export", "profile", "74080192e42e") + assert.ExitError(t, err, 2) + assert.Equal(t, stdout, "") + assert.HasPrefix(t, stderr, "Expected resource type, id, and output file as argument") + }, + + "export a module to stdout": func(t *testing.T) { + stdout, processID, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") + assert.OK(t, err) + assert.Equal(t, stdout, "sleeping for 1ns\n") + assert.NotEqual(t, processID, "") + + moduleID, stderr, err := timecraft(t, "get", "mod", "-q") + assert.OK(t, err) + assert.Equal(t, stderr, "") + moduleID = strings.TrimSuffix(moduleID, "\n") + + moduleData, stderr, err := timecraft(t, "export", "mod", moduleID, "-") + assert.OK(t, err) + assert.Equal(t, stderr, "") + + sleepWasm, err := os.ReadFile("./testdata/go/sleep.wasm") + assert.OK(t, err) + assert.True(t, moduleData == string(sleepWasm)) + }, +} diff --git a/internal/cmd/get.go b/get.go similarity index 87% rename from internal/cmd/get.go rename to get.go index 0585f70c..2c7d25b7 100644 --- a/internal/cmd/get.go +++ b/get.go @@ -1,9 +1,8 @@ -package cmd +package main import ( "bytes" "context" - "errors" "fmt" "io" "os" @@ -47,14 +46,15 @@ Examples: Options: -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) -h, --help Show this usage information - -o, --ouptut format Output format, one of: text, json, yaml + -o, --output format Output format, one of: text, json, yaml + -q, --quiet Only display the resource ids ` type resource struct { typ string alt []string mediaType format.MediaType - get func(context.Context, io.Writer, *timemachine.Registry) stream.WriteCloser[*format.Descriptor] + get func(context.Context, io.Writer, *timemachine.Registry, bool) stream.WriteCloser[*format.Descriptor] describe func(context.Context, *timemachine.Registry, string) (any, error) lookup func(context.Context, *timemachine.Registry, string) (any, error) } @@ -118,18 +118,20 @@ func get(ctx context.Context, args []string) error { var ( timeRange = timemachine.Since(time.Unix(0, 0)) output = outputFormat("text") + quiet = false ) flagSet := newFlagSet("timecraft get", getUsage) customVar(flagSet, &output, "o", "output") + boolVar(flagSet, &quiet, "q", "quiet") args = parseFlags(flagSet, args) if len(args) != 1 { - return errors.New(`expected exactly one resource type as argument` + useCmd("get")) + return usageError(`Expected exactly one resource type as argument` + useCmd("get")) } resource, err := findResource("get", args[0]) if err != nil { - return err + return usageError(err.Error()) } config, err := loadConfig() if err != nil { @@ -153,7 +155,7 @@ func get(ctx context.Context, args []string) error { case "yaml": writer = yamlprint.NewWriter[*format.Manifest](os.Stdout) default: - writer = getLogs(ctx, os.Stdout, registry) + writer = getLogs(ctx, os.Stdout, registry, quiet) } defer writer.Close() @@ -171,7 +173,7 @@ func get(ctx context.Context, args []string) error { case "yaml": writer = yamlprint.NewWriter[*format.Descriptor](os.Stdout) default: - writer = resource.get(ctx, os.Stdout, registry) + writer = resource.get(ctx, os.Stdout, registry, quiet) } defer writer.Close() @@ -179,14 +181,14 @@ func get(ctx context.Context, args []string) error { return err } -func getConfigs(ctx context.Context, w io.Writer, reg *timemachine.Registry) stream.WriteCloser[*format.Descriptor] { +func getConfigs(ctx context.Context, w io.Writer, reg *timemachine.Registry, quiet bool) stream.WriteCloser[*format.Descriptor] { type config struct { ID string `text:"CONFIG ID"` Runtime string `text:"RUNTIME"` Modules int `text:"MODULES"` Size human.Bytes `text:"SIZE"` } - return newTableWriter(w, + return newTableWriter(w, quiet, func(c1, c2 config) bool { return c1.ID < c2.ID }, @@ -208,13 +210,13 @@ func getConfigs(ctx context.Context, w io.Writer, reg *timemachine.Registry) str }) } -func getModules(ctx context.Context, w io.Writer, reg *timemachine.Registry) stream.WriteCloser[*format.Descriptor] { +func getModules(ctx context.Context, w io.Writer, reg *timemachine.Registry, quiet bool) stream.WriteCloser[*format.Descriptor] { type module struct { ID string `text:"MODULE ID"` Name string `text:"MODULE NAME"` Size human.Bytes `text:"SIZE"` } - return newTableWriter(w, + return newTableWriter(w, quiet, func(m1, m2 module) bool { return m1.ID < m2.ID }, @@ -231,12 +233,12 @@ func getModules(ctx context.Context, w io.Writer, reg *timemachine.Registry) str }) } -func getProcesses(ctx context.Context, w io.Writer, reg *timemachine.Registry) stream.WriteCloser[*format.Descriptor] { +func getProcesses(ctx context.Context, w io.Writer, reg *timemachine.Registry, quiet bool) stream.WriteCloser[*format.Descriptor] { type process struct { ID format.UUID `text:"PROCESS ID"` StartTime human.Time `text:"START"` } - return newTableWriter(w, + return newTableWriter(w, quiet, func(p1, p2 process) bool { return time.Time(p1.StartTime).Before(time.Time(p2.StartTime)) }, @@ -252,7 +254,7 @@ func getProcesses(ctx context.Context, w io.Writer, reg *timemachine.Registry) s }) } -func getProfiles(ctx context.Context, w io.Writer, reg *timemachine.Registry) stream.WriteCloser[*format.Descriptor] { +func getProfiles(ctx context.Context, w io.Writer, reg *timemachine.Registry, quiet bool) stream.WriteCloser[*format.Descriptor] { type profile struct { ID string `text:"PROFILE ID"` ProcessID format.UUID `text:"PROCESS ID"` @@ -261,7 +263,7 @@ func getProfiles(ctx context.Context, w io.Writer, reg *timemachine.Registry) st Duration human.Duration `text:"DURATION"` Size human.Bytes `text:"SIZE"` } - return newTableWriter(w, + return newTableWriter(w, quiet, func(p1, p2 profile) bool { if p1.ProcessID != p2.ProcessID { return bytes.Compare(p1.ProcessID[:], p2.ProcessID[:]) < 0 @@ -289,13 +291,13 @@ func getProfiles(ctx context.Context, w io.Writer, reg *timemachine.Registry) st }) } -func getRuntimes(ctx context.Context, w io.Writer, reg *timemachine.Registry) stream.WriteCloser[*format.Descriptor] { +func getRuntimes(ctx context.Context, w io.Writer, reg *timemachine.Registry, quiet bool) stream.WriteCloser[*format.Descriptor] { type runtime struct { ID string `text:"RUNTIME ID"` Runtime string `text:"RUNTIME NAME"` Version string `text:"VERSION"` } - return newTableWriter(w, + return newTableWriter(w, quiet, func(r1, r2 runtime) bool { return r1.ID < r2.ID }, @@ -312,14 +314,14 @@ func getRuntimes(ctx context.Context, w io.Writer, reg *timemachine.Registry) st }) } -func getLogs(ctx context.Context, w io.Writer, reg *timemachine.Registry) stream.WriteCloser[*format.Manifest] { +func getLogs(ctx context.Context, w io.Writer, reg *timemachine.Registry, quiet bool) stream.WriteCloser[*format.Manifest] { type manifest struct { ProcessID format.UUID `text:"PROCESS ID"` Segments human.Count `text:"SEGMENTS"` StartTime human.Time `text:"START"` Size human.Bytes `text:"SIZE"` } - return newTableWriter(w, + return newTableWriter(w, quiet, func(m1, m2 manifest) bool { return time.Time(m1.StartTime).Before(time.Time(m2.StartTime)) }, @@ -336,8 +338,17 @@ func getLogs(ctx context.Context, w io.Writer, reg *timemachine.Registry) stream }) } -func newTableWriter[T1, T2 any](w io.Writer, orderBy func(T1, T1) bool, conv func(T2) (T1, error)) stream.WriteCloser[T2] { - tw := textprint.NewTableWriter[T1](w, textprint.OrderBy(orderBy)) +func newTableWriter[T1, T2 any](w io.Writer, quiet bool, orderBy func(T1, T1) bool, conv func(T2) (T1, error)) stream.WriteCloser[T2] { + opts := []textprint.TableOption[T1]{ + textprint.OrderBy(orderBy), + } + if quiet { + opts = append(opts, + textprint.Header[T1](false), + textprint.List[T1](true), + ) + } + tw := textprint.NewTableWriter[T1](w, opts...) cw := stream.ConvertWriter[T1](tw, conv) return stream.NewWriteCloser(cw, tw) } diff --git a/get_test.go b/get_test.go new file mode 100644 index 00000000..906c6509 --- /dev/null +++ b/get_test.go @@ -0,0 +1,111 @@ +package main_test + +import ( + "testing" + + "github.com/stealthrocket/timecraft/internal/assert" +) + +var get = tests{ + "show the get command help with the short option": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "get", "-h") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft get ") + assert.Equal(t, stderr, "") + }, + + "show the get command help with the long option": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "get", "--help") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft get ") + assert.Equal(t, stderr, "") + }, + + "get without a resource type": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "get") + assert.ExitError(t, err, 2) + assert.Equal(t, stdout, "") + assert.HasPrefix(t, stderr, "Expected exactly one resource type as argument") + }, + + "get configs on an empty time machine": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "get", "configs") + assert.OK(t, err) + assert.Equal(t, stdout, "CONFIG ID RUNTIME MODULES SIZE\n") + assert.Equal(t, stderr, "") + }, + + "get logs on an empty time machine": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "get", "logs") + assert.OK(t, err) + assert.Equal(t, stdout, "PROCESS ID SEGMENTS START SIZE\n") + assert.Equal(t, stderr, "") + }, + + "get modules on an empty time machine": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "get", "modules") + assert.OK(t, err) + assert.Equal(t, stdout, "MODULE ID MODULE NAME SIZE\n") + assert.Equal(t, stderr, "") + }, + + "get process on an empty time machine": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "get", "processes") + assert.OK(t, err) + assert.Equal(t, stdout, "PROCESS ID START\n") + assert.Equal(t, stderr, "") + }, + + "get runtimes on an empty time machine": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "get", "runtimes") + assert.OK(t, err) + assert.Equal(t, stdout, "RUNTIME ID RUNTIME NAME VERSION\n") + assert.Equal(t, stderr, "") + }, + + "get config after run": func(t *testing.T) { + stdout, processID, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") + assert.OK(t, err) + assert.Equal(t, stdout, "sleeping for 1ns\n") + assert.NotEqual(t, processID, "") + + configID, stderr, err := timecraft(t, "get", "conf", "-q") + assert.OK(t, err) + assert.NotEqual(t, configID, "") + assert.Equal(t, stderr, "") + }, + + "get log after run": func(t *testing.T) { + stdout, processID, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") + assert.OK(t, err) + assert.Equal(t, stdout, "sleeping for 1ns\n") + + logID, stderr, err := timecraft(t, "get", "log", "-q") + assert.OK(t, err) + assert.Equal(t, logID, processID) + assert.Equal(t, stderr, "") + }, + + "get process after run": func(t *testing.T) { + stdout, processID, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") + assert.OK(t, err) + assert.Equal(t, stdout, "sleeping for 1ns\n") + + procID, stderr, err := timecraft(t, "get", "proc", "-q") + assert.OK(t, err) + assert.Equal(t, procID, processID) + assert.Equal(t, stderr, "") + }, + + "get runtime after run": func(t *testing.T) { + stdout, processID, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") + assert.OK(t, err) + assert.Equal(t, stdout, "sleeping for 1ns\n") + assert.NotEqual(t, processID, "") + + runtimeID, stderr, err := timecraft(t, "get", "rt", "-q") + assert.OK(t, err) + assert.NotEqual(t, runtimeID, "") + assert.Equal(t, stderr, "") + }, +} diff --git a/go.mod b/go.mod index 78657b8a..425e0925 100644 --- a/go.mod +++ b/go.mod @@ -13,9 +13,7 @@ require ( github.com/stealthrocket/wzprof v0.1.5-0.20230526193557-ec6e2ad60848 github.com/tetratelabs/wazero v1.1.1-0.20230522055633-256b7a4bf970 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 + gopkg.in/yaml.v3 v3.0.1 ) -require ( - golang.org/x/sys v0.8.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) +require golang.org/x/sys v0.8.0 // indirect diff --git a/go.sum b/go.sum index d07c66dc..2fb5c94a 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,7 @@ golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERs golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/help.go b/help.go similarity index 92% rename from internal/cmd/help.go rename to help.go index f76c248e..8d998675 100644 --- a/internal/cmd/help.go +++ b/help.go @@ -1,8 +1,9 @@ -package cmd +package main import ( "context" "fmt" + "strings" ) const helpUsage = ` @@ -65,11 +66,10 @@ func help(ctx context.Context, args []string) error { case "version": msg = versionUsage default: - fmt.Printf("timecraft help %s: unknown command\n", cmd) - return ExitCode(1) + return usageError("timecraft help %s: unknown command", cmd) } - fmt.Println(msg) + fmt.Println(strings.TrimSpace(msg)) } return nil } diff --git a/help_test.go b/help_test.go new file mode 100644 index 00000000..a75de9c7 --- /dev/null +++ b/help_test.go @@ -0,0 +1,105 @@ +package main_test + +import ( + "testing" + + "github.com/stealthrocket/timecraft/internal/assert" +) + +var help = tests{ + "calling help with an unknown command causes an error": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "whatever") + assert.ExitError(t, err, 2) + assert.Equal(t, stdout, "") + assert.Equal(t, stderr, "timecraft help whatever: unknown command\n") + }, + + "passing an unsupported flag to the command causes an error": func(t *testing.T) { + _, _, err := timecraft(t, "help", "-_") + assert.ExitError(t, err, 2) + }, + + "show the help command help with the short option": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "-h") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") + assert.Equal(t, stderr, "") + }, + + "show the help command help with the long option": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "--help") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") + assert.Equal(t, stderr, "") + }, + + "show the help command help after a command name": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "get", "--help") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") + assert.Equal(t, stderr, "") + }, + + "timecraft help config": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "config") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft config ") + assert.Equal(t, stderr, "") + }, + + "timecraft help describe": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "describe") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft describe ") + assert.Equal(t, stderr, "") + }, + + "timecraft help export": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "export") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft export ") + assert.Equal(t, stderr, "") + }, + + "timecraft help get": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "get") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft get ") + assert.Equal(t, stderr, "") + }, + + "timecraft help help": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "help") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") + assert.Equal(t, stderr, "") + }, + + "timecraft help profile": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "profile") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft profile ") + assert.Equal(t, stderr, "") + }, + + "timecraft help run": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "run") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft run ") + assert.Equal(t, stderr, "") + }, + + "timecraft help replay": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "replay") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft replay ") + assert.Equal(t, stderr, "") + }, + + "timecraft help version": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "help", "version") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft version\n") + assert.Equal(t, stderr, "") + }, +} diff --git a/internal/assert/assert.go b/internal/assert/assert.go index 0bf055ad..051b9b05 100644 --- a/internal/assert/assert.go +++ b/internal/assert/assert.go @@ -2,7 +2,9 @@ package assert import ( "errors" + "os/exec" "reflect" + "strings" "testing" "golang.org/x/exp/constraints" @@ -15,6 +17,16 @@ func OK(t testing.TB, err error) { } } +func True(t testing.TB, value bool) { + t.Helper() + Equal(t, value, true) +} + +func False(t testing.TB, value bool) { + t.Helper() + Equal(t, value, false) +} + func Error(t testing.TB, got, want error) { if !errors.Is(got, want) { t.Helper() @@ -29,6 +41,13 @@ func Equal[T comparable](t testing.TB, got, want T) { } } +func NotEqual[T comparable](t testing.TB, got, want T) { + if got == want { + t.Helper() + t.Fatalf("value mismatch\nwant != %#v", want) + } +} + func EqualAll[T comparable](t testing.TB, got, want []T) { if len(got) != len(want) { t.Helper() @@ -56,3 +75,23 @@ func DeepEqual(t testing.TB, got, want any) { t.Fatalf("value mismatch\nwant = %#v\ngot = %#v", want, got) } } + +func ExitError(t testing.TB, got error, wantExitCode int) { + switch e := got.(type) { + case *exec.ExitError: + if gotExitCode := e.ExitCode(); gotExitCode != wantExitCode { + t.Helper() + t.Fatalf("exit code mismatch\nwant = %d\ngot = %d", wantExitCode, gotExitCode) + } + default: + t.Helper() + t.Fatalf("error mismatch\nwant = exec.ExitError{%d}\ngot = %s", wantExitCode, got) + } +} + +func HasPrefix(t testing.TB, got, want string) { + if !strings.HasPrefix(got, want) { + t.Helper() + t.Fatalf("prefix mismatch\nwant = %q\ngot = %q", want, got) + } +} diff --git a/internal/cmd/profile_test.go b/internal/cmd/profile_test.go deleted file mode 100644 index 3ae0261e..00000000 --- a/internal/cmd/profile_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package cmd_test - -import ( - "context" - - "github.com/stealthrocket/timecraft/internal/cmd" -) - -func ExampleRoot_profileMissingID() { - ctx := context.Background() - - FAIL(cmd.Root(ctx, "profile")) - // Output: - // ERR: timecraft profile: expected exactly one process id as argument -} - -func ExampleRoot_profileTooManyArgs() { - ctx := context.Background() - - FAIL(cmd.Root(ctx, "profile", "1", "2", "3")) - // Output: - // ERR: timecraft profile: expected exactly one process id as argument -} - -func ExampleRoot_profileInvalidID() { - ctx := context.Background() - - FAIL(cmd.Root(ctx, "profile", "1234567890")) - // Output: - // ERR: timecraft profile: malformed process id passed as argument (not a UUID) -} - -func ExampleRoot_profileUnknownID() { - ctx := context.Background() - - FAIL(cmd.Root(ctx, "profile", "b0f4dac5-9855-4cde-89fd-ebd3713c2249")) - // Output: - // ERR: timecraft profile: process has no records: b0f4dac5-9855-4cde-89fd-ebd3713c2249 -} diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go deleted file mode 100644 index 69240d3a..00000000 --- a/internal/cmd/root_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package cmd_test - -import ( - "fmt" - "os" -) - -func PASS(rc int) { - if rc != 0 { - fmt.Fprintf(os.Stderr, "exit: %d\n", rc) - } -} - -func FAIL(rc int) { - if rc != 1 { - fmt.Fprintf(os.Stderr, "exit: %d\n", rc) - } -} diff --git a/internal/cmd/run_test.go b/internal/cmd/run_test.go deleted file mode 100644 index 6fa44031..00000000 --- a/internal/cmd/run_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package cmd_test - -import ( - "context" - - "github.com/stealthrocket/timecraft/internal/cmd" -) - -func ExampleRoot_runExitZero() { - ctx := context.Background() - - PASS(cmd.Root(ctx, "run", "../../testdata/go/sleep.wasm", "10ms")) - // Output: sleeping for 10ms -} - -func ExampleRoot_runContextCanceled() { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - PASS(cmd.Root(ctx, "run", "../../testdata/go/sleep.wasm", "10s")) - // Output: -} diff --git a/internal/cmd/version_test.go b/internal/cmd/version_test.go deleted file mode 100644 index 28e49c70..00000000 --- a/internal/cmd/version_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package cmd_test - -import ( - "context" - - "github.com/stealthrocket/timecraft/internal/cmd" -) - -func ExampleRoot_version() { - ctx := context.Background() - - PASS(cmd.Root(ctx, "version")) - // Output: - // timecraft devel -} diff --git a/internal/print/textprint/table.go b/internal/print/textprint/table.go index 984e69b3..1ec00f38 100644 --- a/internal/print/textprint/table.go +++ b/internal/print/textprint/table.go @@ -12,15 +12,22 @@ import ( type TableOption[T any] func(*tableWriter[T]) +func Header[T any](enable bool) TableOption[T] { + return func(t *tableWriter[T]) { t.header = enable } +} + +func List[T any](enable bool) TableOption[T] { + return func(t *tableWriter[T]) { t.list = enable } +} + func OrderBy[T any](f func(T, T) bool) TableOption[T] { - return func(t *tableWriter[T]) { - t.orderBy = f - } + return func(t *tableWriter[T]) { t.orderBy = f } } func NewTableWriter[T any](w io.Writer, opts ...TableOption[T]) stream.WriteCloser[T] { t := &tableWriter[T]{ output: w, + header: true, } for _, opt := range opts { opt(t) @@ -31,6 +38,8 @@ func NewTableWriter[T any](w io.Writer, opts ...TableOption[T]) stream.WriteClos type tableWriter[T any] struct { output io.Writer values []T + header bool + list bool orderBy func(T, T) bool } @@ -59,6 +68,7 @@ func (t *tableWriter[T]) Close() error { } } + var columns []string var encoders []encodeFunc for _, f := range reflect.VisibleFields(valueType) { name := f.Name @@ -71,24 +81,32 @@ func (t *tableWriter[T]) Close() error { } } } - if name == "-" { continue } + columns = append(columns, name) + encoders = append(encoders, encodeFuncOfStructField(f.Type, f.Index)) + } - if len(encoders) > 0 { - if _, err := io.WriteString(tw, "\t"); err != nil { + if t.list { + columns = columns[:1] + encoders = encoders[:1] + } + + if t.header { + for i, name := range columns { + if i != 0 { + if _, err := io.WriteString(tw, "\t"); err != nil { + return err + } + } + if _, err := io.WriteString(tw, name); err != nil { return err } } - if _, err := io.WriteString(tw, name); err != nil { + if _, err := io.WriteString(tw, "\n"); err != nil { return err } - encoders = append(encoders, encodeFuncOfStructField(f.Type, f.Index)) - } - - if _, err := io.WriteString(tw, "\n"); err != nil { - return err } for n := range t.values { diff --git a/main.go b/main.go index 66720f1a..9792558a 100644 --- a/main.go +++ b/main.go @@ -2,11 +2,16 @@ package main import ( "context" + "io" + "log" "os" - - "github.com/stealthrocket/timecraft/internal/cmd" ) +func init() { + // TODO: do something better with logs + log.SetOutput(io.Discard) +} + func main() { - os.Exit(cmd.Root(context.Background(), os.Args[1:]...)) + os.Exit(root(context.Background(), os.Args[1:]...)) } diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000..7b8a9b55 --- /dev/null +++ b/main_test.go @@ -0,0 +1,83 @@ +package main_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" + "gopkg.in/yaml.v3" +) + +func TestTimecraft(t *testing.T) { + t.Run("export", export.run) + t.Run("get", get.run) + t.Run("help", help.run) + t.Run("root", root.run) + t.Run("unknown", unknown.run) + t.Run("version", version.run) +} + +type configuration struct { + Registry registry `yaml:"registry"` +} + +type registry struct { + Location string `yaml:"location"` +} + +type tests map[string]func(*testing.T) + +func (suite tests) run(t *testing.T) { + names := maps.Keys(suite) + slices.Sort(names) + + for _, name := range names { + test := suite[name] + t.Run(name, func(t *testing.T) { + tmp := t.TempDir() + + b, err := yaml.Marshal(configuration{ + Registry: registry{ + Location: tmp, + }, + }) + if err != nil { + t.Fatal("marshaling timecraft configuration:", err) + } + + configPath := filepath.Join(tmp, "config.yaml") + if err := os.WriteFile(configPath, b, 0666); err != nil { + t.Fatal("writing timecraft configuration:", err) + } + + t.Setenv("TIMECRAFTCONFIG", configPath) + + test(t) + }) + } +} + +func timecraft(t *testing.T, args ...string) (stdout, stderr string, err error) { + ctx := context.Background() + deadline, ok := t.Deadline() + if ok { + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, deadline) + defer cancel() + } + + outbuf := new(strings.Builder) + errbuf := new(strings.Builder) + + cmd := exec.CommandContext(ctx, "./timecraft", args...) + cmd.Stdout = outbuf + cmd.Stderr = errbuf + + err = cmd.Run() + return outbuf.String(), errbuf.String(), err +} diff --git a/internal/cmd/profile.go b/profile.go similarity index 97% rename from internal/cmd/profile.go rename to profile.go index 8050c93c..91f4d66f 100644 --- a/internal/cmd/profile.go +++ b/profile.go @@ -1,4 +1,4 @@ -package cmd +package main import ( "context" @@ -51,9 +51,9 @@ Options: -d, --duration duration Amount of time that the profiler will be running for (default to the process up time) --export type:path Exports the generated profiles, type is one of cpu or memory (may be repeated) -h, --help Show this usage information - -o, --ouptut format Output format, one of: text, json, yaml + -o, --output format Output format, one of: text, json, yaml + -q, --quiet Only display the profile ids -t, --start-time time Time at which the profiler gets started (default to 1 minute) - -r, --registry path Path to the timecraft registry (default to ~/.timecraft) ` func profile(ctx context.Context, args []string) error { @@ -62,6 +62,7 @@ func profile(ctx context.Context, args []string) error { output = outputFormat("text") startTime = human.Time{} duration = human.Duration(1 * time.Minute) + quiet = false ) flagSet := newFlagSet("timecraft profile", profileUsage) @@ -69,6 +70,7 @@ func profile(ctx context.Context, args []string) error { customVar(flagSet, &output, "o", "output") customVar(flagSet, &duration, "d", "duration") customVar(flagSet, &startTime, "t", "start-time") + boolVar(flagSet, &quiet, "q", "quiet") args = parseFlags(flagSet, args) if len(args) != 1 { @@ -204,7 +206,7 @@ func profile(ctx context.Context, args []string) error { case "yaml": writer = yamlprint.NewWriter[*format.Descriptor](os.Stdout) default: - writer = getProfiles(ctx, os.Stdout, registry) + writer = getProfiles(ctx, os.Stdout, registry, quiet) } defer writer.Close() diff --git a/internal/cmd/replay.go b/replay.go similarity index 99% rename from internal/cmd/replay.go rename to replay.go index 55e08dbc..5b2f9b6c 100644 --- a/internal/cmd/replay.go +++ b/replay.go @@ -1,4 +1,4 @@ -package cmd +package main import ( "context" diff --git a/internal/cmd/root.go b/root.go similarity index 84% rename from internal/cmd/root.go rename to root.go index 38e4b75a..7201293d 100644 --- a/internal/cmd/root.go +++ b/root.go @@ -1,4 +1,4 @@ -package cmd +package main // Notes on program structure // -------------------------- @@ -20,23 +20,14 @@ import ( "context" "flag" "fmt" - "io" - "log" _ "net/http/pprof" + "os" "strings" "golang.org/x/exp/maps" "golang.org/x/exp/slices" ) -// ExitCode is an error type returned from Root to indicate the exit code that -// should be returned by the program. -type ExitCode int - -func (e ExitCode) Error() string { - return fmt.Sprintf("exit: %d", e) -} - const rootUsage = `timecraft - WebAssembly Time Machine timecraft is a WebAssembly runtime that provides advanced capabilities to the @@ -45,8 +36,8 @@ const rootUsage = `timecraft - WebAssembly Time Machine Example: - $ timecraft run --record -- app.wasm - timecraft run: f6e9acbc-0543-47df-9413-b99f569cfa3b + $ timecraft run -- app.wasm + f6e9acbc-0543-47df-9413-b99f569cfa3b ... $ timecraft replay f6e9acbc-0543-47df-9413-b99f569cfa3b @@ -54,19 +45,14 @@ Example: For a list of commands available, run 'timecraft help'.` -func init() { - // TODO: do something better with logs - log.SetOutput(io.Discard) -} - -// Root is the timecraft entrypoint. -func Root(ctx context.Context, args ...string) int { +// root is the timecraft entrypoint. +func root(ctx context.Context, args ...string) int { flagSet := newFlagSet("timecraft", helpUsage) _ = flagSet.Parse(args) if args = flagSet.Args(); len(args) == 0 { fmt.Println(rootUsage) - return 1 + return 0 } var err error @@ -97,14 +83,39 @@ func Root(ctx context.Context, args ...string) int { switch e := err.(type) { case nil: return 0 - case ExitCode: + case exitCode: return int(e) + case usage: + fmt.Fprintf(os.Stderr, "%s\n", e) + return 2 default: - fmt.Printf("ERR: timecraft %s: %s\n", cmd, err) + fmt.Fprintf(os.Stderr, "ERR: timecraft %s: %s\n", cmd, err) return 1 } } +// exitCode is an error type returned from command functions to indicate the +// exit code that should be returned by the program. +type exitCode int + +func (e exitCode) Error() string { + return fmt.Sprintf("exit: %d", e) +} + +// usage is an error type returned from command functions to indicate a usage +// error. +// +// Usage erors cause the program to exist with status code 2. +type usage string + +func usageError(msg string, args ...any) error { + return usage(fmt.Sprintf(msg, args...)) +} + +func (e usage) Error() string { + return string(e) +} + func setEnum[T ~string](enum *T, typ string, value string, options ...string) error { for _, option := range options { if option == value { @@ -182,6 +193,7 @@ func (m stringMap) Set(value string) error { } func newFlagSet(cmd, usage string) *flag.FlagSet { + usage = strings.TrimSpace(usage) flagSet := flag.NewFlagSet(cmd, flag.ExitOnError) flagSet.Usage = func() { fmt.Println(usage) } customVar(flagSet, &configPath, "c", "config") @@ -205,6 +217,8 @@ func parseFlags(f *flag.FlagSet, args []string) []string { }) if i < 0 { i = len(args) + } else if args[i] == "-" { + i++ } if i == 0 { panic("parsing command line arguments did not error on " + args[0]) diff --git a/root_test.go b/root_test.go new file mode 100644 index 00000000..e3fb7485 --- /dev/null +++ b/root_test.go @@ -0,0 +1,30 @@ +package main_test + +import ( + "testing" + + "github.com/stealthrocket/timecraft/internal/assert" +) + +var root = tests{ + "invoking timecraft without a command prints the introduction message": func(t *testing.T) { + stdout, stderr, err := timecraft(t) + assert.OK(t, err) + assert.HasPrefix(t, stdout, "timecraft - WebAssembly Time Machine\n") + assert.Equal(t, stderr, "") + }, + + "show the timecraft help with the short option": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "-h") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") + assert.Equal(t, stderr, "") + }, + + "show the timecraft help with the long option": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "--help") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") + assert.Equal(t, stderr, "") + }, +} diff --git a/internal/cmd/run.go b/run.go similarity index 95% rename from internal/cmd/run.go rename to run.go index c5ba81aa..4b308f4d 100644 --- a/internal/cmd/run.go +++ b/run.go @@ -1,4 +1,4 @@ -package cmd +package main import ( "context" @@ -29,10 +29,10 @@ Options: -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) -D, --dial addr Expose a socket connected to the specified address -e, --env name=value Pass an environment variable to the guest module + --fly-blind Disable recording of the guest module execution -h, --help Show this usage information -L, --listen addr Expose a socket listening on the specified address -S, --sockets extension Enable a sockets extension, one of none, auto, path_open, wasmedgev1, wasmedgev2 (default to auto) - -R, --record Enable recording of the guest module execution --record-batch-size size Number of records written per batch (default to 4096) --record-compression type Compression to use when writing records, either snappy or zstd (default to zstd) -T, --trace Enable strace-like logging of host function calls @@ -46,7 +46,7 @@ func run(ctx context.Context, args []string) error { batchSize = human.Count(4096) compression = compression("zstd") sockets = sockets("auto") - record = false + flyBlind = false trace = false ) @@ -56,7 +56,7 @@ func run(ctx context.Context, args []string) error { customVar(flagSet, &dials, "D", "dial") customVar(flagSet, &sockets, "S", "sockets") boolVar(flagSet, &trace, "T", "trace") - boolVar(flagSet, &record, "R", "record") + boolVar(flagSet, &flyBlind, "fly-blind") customVar(flagSet, &batchSize, "record-batch-size") customVar(flagSet, &compression, "record-compression") _ = flagSet.Parse(args) @@ -110,7 +110,7 @@ func run(ctx context.Context, args []string) error { WithSocketsExtension(string(sockets), wasmModule). WithTracer(trace, os.Stderr) - if record { + if !flyBlind { var c timemachine.Compression switch compression { case "snappy": @@ -188,7 +188,7 @@ func run(ctx context.Context, args []string) error { }) }) - fmt.Println("timecraft run:", processID) + fmt.Fprintf(os.Stderr, "%s\n", processID) } ctx, cancel := signal.NotifyContext(ctx, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) @@ -229,8 +229,8 @@ func instantiate(ctx context.Context, runtime wazero.Runtime, compiledModule waz switch e := err.(type) { case *sys.ExitError: - if exitCode := e.ExitCode(); exitCode != 0 { - return ExitCode(e.ExitCode()) + if rc := e.ExitCode(); rc != 0 { + return exitCode(rc) } err = nil } diff --git a/internal/cmd/unknown.go b/unknown.go similarity index 72% rename from internal/cmd/unknown.go rename to unknown.go index d370420d..8926521f 100644 --- a/internal/cmd/unknown.go +++ b/unknown.go @@ -1,8 +1,7 @@ -package cmd +package main import ( "context" - "fmt" ) const unknownCommand = `timecraft %s: unknown command @@ -10,6 +9,5 @@ For a list of commands available, run 'timecraft help.' ` func unknown(ctx context.Context, cmd string) error { - fmt.Printf(unknownCommand, cmd) - return ExitCode(1) + return usageError(unknownCommand, cmd) } diff --git a/unknown_test.go b/unknown_test.go new file mode 100644 index 00000000..8f247430 --- /dev/null +++ b/unknown_test.go @@ -0,0 +1,16 @@ +package main_test + +import ( + "testing" + + "github.com/stealthrocket/timecraft/internal/assert" +) + +var unknown = tests{ + "an error is reported when invoking an unknown command": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "whatever") + assert.ExitError(t, err, 2) + assert.Equal(t, stdout, "") + assert.HasPrefix(t, stderr, "timecraft whatever: unknown command\n") + }, +} diff --git a/internal/cmd/version.go b/version.go similarity index 97% rename from internal/cmd/version.go rename to version.go index 8960d92d..2ee44cf8 100644 --- a/internal/cmd/version.go +++ b/version.go @@ -1,4 +1,4 @@ -package cmd +package main import ( "context" diff --git a/version_test.go b/version_test.go new file mode 100644 index 00000000..b46329df --- /dev/null +++ b/version_test.go @@ -0,0 +1,45 @@ +package main_test + +import ( + "strings" + "testing" + + "github.com/stealthrocket/timecraft/internal/assert" +) + +var version = tests{ + "show the version command help with the short option": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "version", "-h") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft version\n") + assert.Equal(t, stderr, "") + }, + + "show the version command help with the long option": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "version", "--help") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "Usage:\ttimecraft version\n") + assert.Equal(t, stderr, "") + }, + + "the version starts with the prefix timecraft": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "version") + assert.OK(t, err) + assert.HasPrefix(t, stdout, "timecraft ") + assert.Equal(t, stderr, "") + }, + + "the version number is not empty": func(t *testing.T) { + stdout, stderr, err := timecraft(t, "version") + assert.OK(t, err) + assert.Equal(t, stderr, "") + + _, version, _ := strings.Cut(string(stdout), " ") + assert.NotEqual(t, version, "") + }, + + "passing an unsupported flag to the command causes an error": func(t *testing.T) { + _, _, err := timecraft(t, "version", "-_") + assert.ExitError(t, err, 2) + }, +}