From 8ff0a708954b22101520dd356d3423f4aacc43bf Mon Sep 17 00:00:00 2001 From: mohit-bhandari45 Date: Tue, 8 Sep 2026 12:28:03 +0530 Subject: [PATCH] feat(cmd): add get subcommand for build and buildrun resources Signed-off-by: mohit-bhandari45 --- pkg/shp/cmd/build/build.go | 1 + pkg/shp/cmd/build/get.go | 119 +++++++++++++++++ pkg/shp/cmd/build/get_test.go | 220 +++++++++++++++++++++++++++++++ pkg/shp/cmd/buildrun/buildrun.go | 1 + pkg/shp/cmd/buildrun/get.go | 121 +++++++++++++++++ pkg/shp/cmd/buildrun/get_test.go | 178 +++++++++++++++++++++++++ 6 files changed, 640 insertions(+) create mode 100644 pkg/shp/cmd/build/get.go create mode 100644 pkg/shp/cmd/build/get_test.go create mode 100644 pkg/shp/cmd/buildrun/get.go create mode 100644 pkg/shp/cmd/buildrun/get_test.go diff --git a/pkg/shp/cmd/build/build.go b/pkg/shp/cmd/build/build.go index 80c1f6243..e9dfb4c71 100644 --- a/pkg/shp/cmd/build/build.go +++ b/pkg/shp/cmd/build/build.go @@ -23,6 +23,7 @@ func Command(p *params.Params, ioStreams *genericclioptions.IOStreams) *cobra.Co // TODO: add support for `update` and `get` commands command.AddCommand( + runner.NewRunner(p, ioStreams, getCmd()).Cmd(), runner.NewRunner(p, ioStreams, createCmd()).Cmd(), runner.NewRunner(p, ioStreams, listCmd()).Cmd(), runner.NewRunner(p, ioStreams, deleteCmd()).Cmd(), diff --git a/pkg/shp/cmd/build/get.go b/pkg/shp/cmd/build/get.go new file mode 100644 index 000000000..8053a76d1 --- /dev/null +++ b/pkg/shp/cmd/build/get.go @@ -0,0 +1,119 @@ +package build // nolint:revive + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + + "github.com/shipwright-io/cli/pkg/shp/cmd/runner" + "github.com/shipwright-io/cli/pkg/shp/params" +) + +type GetCommand struct { + cmd *cobra.Command + name string + output string +} + +func (c *GetCommand) Cmd() *cobra.Command { + return c.cmd +} + +func (c *GetCommand) Complete(_ *params.Params, _ *genericclioptions.IOStreams, args []string) error { + c.name = args[0] + return nil +} + +func (c *GetCommand) Validate() error { + if c.name == "" { + return fmt.Errorf("name must be provided") + } + return nil +} + +func (c *GetCommand) Run(params *params.Params, ioStreams *genericclioptions.IOStreams) error { + clientset, err := params.ShipwrightClientSet() + if err != nil { + return err + } + + ns := params.Namespace() + build, err := clientset.ShipwrightV1beta1().Builds(ns).Get(c.cmd.Context(), c.name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + fmt.Fprintf(ioStreams.Out, "Build '%s' not found in namespace '%s'.\n", c.name, ns) + return nil + } + return err + } + + switch c.output { + case "json": + data, err := json.MarshalIndent(build, "", " "); + if err != nil { + return err + } + fmt.Fprintln(ioStreams.Out, string(data)) + return nil + case "yaml": + data, err := yaml.Marshal(build); + if err != nil { + return err; + } + fmt.Fprintln(ioStreams.Out, string(data)) + return nil + case "": + w := tabwriter.NewWriter(ioStreams.Out, 0, 8, 2, '\t', 0); + fmt.Fprintf(w, "NAME:\t%s\n", build.Name) + fmt.Fprintf(w, "NAMESPACE:\t%s\n", build.Namespace) + if build.Spec.Source.Git != nil { + fmt.Fprintf(w, "SOURCE URL:\t%s\n", build.Spec.Source.Git.URL) + if build.Spec.Source.Git.Revision != nil { + fmt.Fprintf(w, "REVISION:\t%s\n", *build.Spec.Source.Git.Revision) + } + } + if build.Spec.Strategy.Name != "" { + kind := "" + if build.Spec.Strategy.Kind != nil { + kind = string(*build.Spec.Strategy.Kind) + } + if kind != "" { + fmt.Fprintf(w, "STRATEGY:\t%s (%s)\n", build.Spec.Strategy.Name, kind) + } else { + fmt.Fprintf(w, "STRATEGY:\t%s\n", build.Spec.Strategy.Name) + } + } + if build.Spec.Output.Image != "" { + fmt.Fprintf(w, "OUTPUT IMAGE:\t%s\n", build.Spec.Output.Image) + } + if build.Status.Registered != nil { + fmt.Fprintf(w, "REGISTERED:\t%s\n", *build.Status.Registered) + } + + return w.Flush() + default: + return fmt.Errorf("unsupported output format %q. Supported formats are: json, yaml", c.output) + } +} + +func getCmd() runner.SubCommand { + cmd := &cobra.Command{ + Use: "get [flags]", + Short: "Get details of a build", + Args: cobra.ExactArgs(1), + } + + c := &GetCommand{ + cmd: cmd, + } + + cmd.Flags().StringVarP(&c.output, "output", "o", "", "Output format. Allowed values: json, yaml") + return c +} \ No newline at end of file diff --git a/pkg/shp/cmd/build/get_test.go b/pkg/shp/cmd/build/get_test.go new file mode 100644 index 000000000..35f1fb372 --- /dev/null +++ b/pkg/shp/cmd/build/get_test.go @@ -0,0 +1,220 @@ +package build // nolint:revive + +import ( + "strings" + "testing" + "time" + + buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + shpfake "github.com/shipwright-io/build/pkg/client/clientset/versioned/fake" + "github.com/shipwright-io/cli/pkg/shp/params" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + kclientsetfake "k8s.io/client-go/kubernetes/fake" +) + +func TestBuildGet_DefaultTable(t *testing.T) { + revision := "main" + strategyKind := buildv1beta1.ClusterBuildStrategyKind + testBuild := &buildv1beta1.Build{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-build", + Namespace: metav1.NamespaceDefault, + }, + Spec: buildv1beta1.BuildSpec{ + Source: &buildv1beta1.Source{ + Git: &buildv1beta1.Git{ + URL: "https://github.com/shipwright-io/sample-go", + Revision: &revision, + }, + }, + Strategy: buildv1beta1.Strategy{ + Name: "buildpacks-v3", + Kind: &strategyKind, + }, + Output: buildv1beta1.Image{ + Image: "quay.io/myuser/my-app:latest", + }, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuild); + k8sClientset := kclientsetfake.NewSimpleClientset(); + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true); + timeout := 10 * time.Second; + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout); + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String(); + expectedStrings := []string{ + "NAME:", + "my-build", + "NAMESPACE:", + "default", + "SOURCE URL:", + "https://github.com/shipwright-io/sample-go", + "REVISION:", + "main", + "STRATEGY:", + "buildpacks-v3 (ClusterBuildStrategy)", + "OUTPUT IMAGE:", + "quay.io/myuser/my-app:latest", + } + + for _, expected := range expectedStrings { + if !strings.Contains(output, expected){ + t.Errorf("expected output to contain %q, but got:\n%s", expected, output) + } + } +} + +func TestBuildGet_JSON(t *testing.T) { + testBuild := &buildv1beta1.Build{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-build", + Namespace: metav1.NamespaceDefault, + }, + Spec: buildv1beta1.BuildSpec{ + Output: buildv1beta1.Image{ + Image: "quay.io/myuser/my-app:latest", + }, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuild) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + // Set output flag to "json" + if err := cmd.Cmd().Flags().Set("output", "json"); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + if !strings.Contains(output, `"name": "my-build"`) || !strings.Contains(output, `"image": "quay.io/myuser/my-app:latest"`) { + t.Errorf("expected JSON output containing build details, but got:\n%s", output) + } +} + +func TestBuildGet_YAML(t *testing.T) { + testBuild := &buildv1beta1.Build{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-build", + Namespace: metav1.NamespaceDefault, + }, + Spec: buildv1beta1.BuildSpec{ + Output: buildv1beta1.Image{ + Image: "quay.io/myuser/my-app:latest", + }, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuild) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + // Set output flag to "yaml" + if err := cmd.Cmd().Flags().Set("output", "yaml"); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + if !strings.Contains(output, "name: my-build") || !strings.Contains(output, "image: quay.io/myuser/my-app:latest") { + t.Errorf("expected YAML output containing build details, but got:\n%s", output) + } +} + +func TestBuildGet_NotFound(t *testing.T) { + shpClientset := shpfake.NewSimpleClientset(); + k8sClientSet := kclientsetfake.NewSimpleClientset(); + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second; + p := params.NewParamsForTest(k8sClientSet, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout); + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"nonexistent-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + output := out.String() + expectedMsg := "Build 'nonexistent-build' not found in namespace 'default'." + if !strings.Contains(output, expectedMsg) { + t.Errorf("expected output to contain %q, but got:\n%s", expectedMsg, output) + } +} + +func TestBuildGet_InvalidOutput(t *testing.T) { + testBuild := &buildv1beta1.Build{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-build", + Namespace: metav1.NamespaceDefault, + }, + } + shpClientset := shpfake.NewSimpleClientset(testBuild) + k8sClientset := kclientsetfake.NewSimpleClientset() + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + if err := cmd.Complete(p, &ioStreams, []string{"my-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + // Set invalid output flag + if err := cmd.Cmd().Flags().Set("output", "invalid"); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + err := cmd.Run(p, &ioStreams) + if err == nil { + t.Fatalf("expected error for unsupported output format, but got nil") + } + expectedErr := `unsupported output format "invalid". Supported formats are: json, yaml` + if err.Error() != expectedErr { + t.Errorf("expected error %q, but got %q", expectedErr, err.Error()) + } +} \ No newline at end of file diff --git a/pkg/shp/cmd/buildrun/buildrun.go b/pkg/shp/cmd/buildrun/buildrun.go index 01983214f..282361f08 100644 --- a/pkg/shp/cmd/buildrun/buildrun.go +++ b/pkg/shp/cmd/buildrun/buildrun.go @@ -22,6 +22,7 @@ func Command(p *params.Params, ioStreams *genericclioptions.IOStreams) *cobra.Co // TODO: add support for `update` and `get` commands command.AddCommand( + runner.NewRunner(p, ioStreams, getCmd()).Cmd(), runner.NewRunner(p, ioStreams, listCmd()).Cmd(), runner.NewRunner(p, ioStreams, logsCmd()).Cmd(), runner.NewRunner(p, ioStreams, createCmd()).Cmd(), diff --git a/pkg/shp/cmd/buildrun/get.go b/pkg/shp/cmd/buildrun/get.go new file mode 100644 index 000000000..19fb68181 --- /dev/null +++ b/pkg/shp/cmd/buildrun/get.go @@ -0,0 +1,121 @@ +package buildrun + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + + buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/cli/pkg/shp/cmd/runner" + "github.com/shipwright-io/cli/pkg/shp/params" +) + +// GetCommand contains user input for the `get` subcommand of BuildRun +type GetCommand struct { + cmd *cobra.Command + name string + output string +} + +func (c *GetCommand) Cmd() *cobra.Command { + return c.cmd +} + +func (c *GetCommand) Complete(_ *params.Params, _ *genericclioptions.IOStreams, args []string) error { + c.name = args[0] + return nil +} + +func (c *GetCommand) Validate() error { + if c.name == "" { + return fmt.Errorf("name must be provided") + } + return nil +} + +func (c *GetCommand) Run(params *params.Params, ioStreams *genericclioptions.IOStreams) error { + clientset, err := params.ShipwrightClientSet() + if err != nil { + return err + } + + ns := params.Namespace() + buildRun, err := clientset.ShipwrightV1beta1().BuildRuns(ns).Get(c.cmd.Context(), c.name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + fmt.Fprintf(ioStreams.Out, "BuildRun '%s' not found in namespace '%s'.\n", c.name, ns) + return nil + } + return err + } + + switch c.output { + case "json": + data, err := json.MarshalIndent(buildRun, "", " ") + if err != nil { + return err + } + fmt.Fprintln(ioStreams.Out, string(data)) + return nil + + case "yaml": + data, err := yaml.Marshal(buildRun) + if err != nil { + return err + } + fmt.Fprintln(ioStreams.Out, string(data)) + return nil + + case "": + w := tabwriter.NewWriter(ioStreams.Out, 0, 8, 2, '\t', 0) + fmt.Fprintf(w, "NAME:\t%s\n", buildRun.Name) + fmt.Fprintf(w, "NAMESPACE:\t%s\n", buildRun.Namespace) + if buildName := buildRun.Spec.BuildName(); buildName != "" { + fmt.Fprintf(w, "BUILD NAME:\t%s\n", buildName) + } + + status := "Unknown" + for _, condition := range buildRun.Status.Conditions { + if condition.Type == buildv1beta1.Succeeded { + status = condition.Reason + break + } + } + fmt.Fprintf(w, "STATUS:\t%s\n", status) + + if buildRun.Status.StartTime != nil { + fmt.Fprintf(w, "START TIME:\t%s\n", buildRun.Status.StartTime.Time.Format(time.RFC3339)) + } + if buildRun.Status.CompletionTime != nil { + fmt.Fprintf(w, "COMPLETION TIME:\t%s\n", buildRun.Status.CompletionTime.Time.Format(time.RFC3339)) + } + + return w.Flush() + + default: + return fmt.Errorf("unsupported output format %q. Supported formats are: json, yaml", c.output) + } +} + +func getCmd() runner.SubCommand { + cmd := &cobra.Command{ + Use: "get [flags]", + Short: "Get details of a BuildRun", + Args: cobra.ExactArgs(1), + } + + c := &GetCommand{ + cmd: cmd, + } + + cmd.Flags().StringVarP(&c.output, "output", "o", "", "Output format. Allowed values: json, yaml") + return c +} \ No newline at end of file diff --git a/pkg/shp/cmd/buildrun/get_test.go b/pkg/shp/cmd/buildrun/get_test.go new file mode 100644 index 000000000..53e65cd81 --- /dev/null +++ b/pkg/shp/cmd/buildrun/get_test.go @@ -0,0 +1,178 @@ +package buildrun + +import ( + "strings" + "testing" + "time" + + buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + shpfake "github.com/shipwright-io/build/pkg/client/clientset/versioned/fake" + "github.com/shipwright-io/cli/pkg/shp/params" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + kclientsetfake "k8s.io/client-go/kubernetes/fake" +) + +func TestBuildRunGet_DefaultTable(t *testing.T) { + now := metav1.Now() + testBuildRun := &buildv1beta1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-buildrun", + Namespace: metav1.NamespaceDefault, + }, + Spec: buildv1beta1.BuildRunSpec{ + Build: buildv1beta1.ReferencedBuild{ + Name: ptr("my-build"), + }, + }, + Status: buildv1beta1.BuildRunStatus{ + StartTime: &now, + CompletionTime: &now, + Conditions: []buildv1beta1.Condition{ + { + Type: buildv1beta1.Succeeded, + Reason: "Succeeded", + }, + }, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuildRun) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-buildrun"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + expectedStrings := []string{ + "NAME:", + "my-buildrun", + "NAMESPACE:", + "default", + "BUILD NAME:", + "my-build", + "STATUS:", + "Succeeded", + } + + for _, expected := range expectedStrings { + if !strings.Contains(output, expected) { + t.Errorf("expected output to contain %q, but got:\n%s", expected, output) + } + } +} + +func TestBuildRunGet_JSON(t *testing.T) { + testBuildRun := &buildv1beta1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-buildrun", + Namespace: metav1.NamespaceDefault, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuildRun) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-buildrun"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Cmd().Flags().Set("output", "json"); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + if !strings.Contains(output, `"name": "my-buildrun"`) { + t.Errorf("expected JSON output, but got:\n%s", output) + } +} + +func TestBuildRunGet_YAML(t *testing.T) { + testBuildRun := &buildv1beta1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-buildrun", + Namespace: metav1.NamespaceDefault, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuildRun) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-buildrun"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Cmd().Flags().Set("output", "yaml"); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + if !strings.Contains(output, "name: my-buildrun") { + t.Errorf("expected YAML output, but got:\n%s", output) + } +} + +func TestBuildRunGet_NotFound(t *testing.T) { + shpClientset := shpfake.NewSimpleClientset() + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"nonexistent-buildrun"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + expectedMsg := "BuildRun 'nonexistent-buildrun' not found in namespace 'default'." + if !strings.Contains(output, expectedMsg) { + t.Errorf("expected output to contain %q, but got:\n%s", expectedMsg, output) + } +} + +func ptr(s string) *string { + return &s +}