github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/workflow/run/run_test.go (about)

     1  package run
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/base64"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"os"
    11  	"testing"
    12  
    13  	"github.com/ungtb10d/cli/v2/api"
    14  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    15  	"github.com/ungtb10d/cli/v2/pkg/cmd/workflow/shared"
    16  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    17  	"github.com/ungtb10d/cli/v2/pkg/httpmock"
    18  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    19  	"github.com/ungtb10d/cli/v2/pkg/prompt"
    20  	"github.com/google/shlex"
    21  	"github.com/stretchr/testify/assert"
    22  )
    23  
    24  func TestNewCmdRun(t *testing.T) {
    25  	tests := []struct {
    26  		name     string
    27  		cli      string
    28  		tty      bool
    29  		wants    RunOptions
    30  		wantsErr bool
    31  		errMsg   string
    32  		stdin    string
    33  	}{
    34  		{
    35  			name:     "blank nontty",
    36  			wantsErr: true,
    37  			errMsg:   "workflow ID, name, or filename required when not running interactively",
    38  		},
    39  		{
    40  			name: "blank tty",
    41  			tty:  true,
    42  			wants: RunOptions{
    43  				Prompt: true,
    44  			},
    45  		},
    46  		{
    47  			name: "ref flag",
    48  			tty:  true,
    49  			cli:  "--ref 12345abc",
    50  			wants: RunOptions{
    51  				Prompt: true,
    52  				Ref:    "12345abc",
    53  			},
    54  		},
    55  		{
    56  			name:     "both STDIN and input fields",
    57  			stdin:    "some json",
    58  			cli:      "workflow.yml -fhey=there --json",
    59  			errMsg:   "only one of STDIN or -f/-F can be passed",
    60  			wantsErr: true,
    61  		},
    62  		{
    63  			name: "-f args",
    64  			tty:  true,
    65  			cli:  `workflow.yml -fhey=there -fname="dana scully"`,
    66  			wants: RunOptions{
    67  				Selector:  "workflow.yml",
    68  				RawFields: []string{"hey=there", "name=dana scully"},
    69  			},
    70  		},
    71  		{
    72  			name: "-F args",
    73  			tty:  true,
    74  			cli:  `workflow.yml -Fhey=there -Fname="dana scully" -Ffile=@cool.txt`,
    75  			wants: RunOptions{
    76  				Selector:    "workflow.yml",
    77  				MagicFields: []string{"hey=there", "name=dana scully", "file=@cool.txt"},
    78  			},
    79  		},
    80  		{
    81  			name: "-F/-f arg mix",
    82  			tty:  true,
    83  			cli:  `workflow.yml -fhey=there -Fname="dana scully" -Ffile=@cool.txt`,
    84  			wants: RunOptions{
    85  				Selector:    "workflow.yml",
    86  				RawFields:   []string{"hey=there"},
    87  				MagicFields: []string{`name=dana scully`, "file=@cool.txt"},
    88  			},
    89  		},
    90  		{
    91  			name:  "json on STDIN",
    92  			cli:   "workflow.yml --json",
    93  			stdin: `{"cool":"yeah"}`,
    94  			wants: RunOptions{
    95  				JSON:      true,
    96  				JSONInput: `{"cool":"yeah"}`,
    97  				Selector:  "workflow.yml",
    98  			},
    99  		},
   100  	}
   101  
   102  	for _, tt := range tests {
   103  		t.Run(tt.name, func(t *testing.T) {
   104  			ios, stdin, _, _ := iostreams.Test()
   105  			if tt.stdin == "" {
   106  				ios.SetStdinTTY(tt.tty)
   107  			} else {
   108  				stdin.WriteString(tt.stdin)
   109  			}
   110  			ios.SetStdoutTTY(tt.tty)
   111  
   112  			f := &cmdutil.Factory{
   113  				IOStreams: ios,
   114  			}
   115  
   116  			argv, err := shlex.Split(tt.cli)
   117  			assert.NoError(t, err)
   118  
   119  			var gotOpts *RunOptions
   120  			cmd := NewCmdRun(f, func(opts *RunOptions) error {
   121  				gotOpts = opts
   122  				return nil
   123  			})
   124  			cmd.SetArgs(argv)
   125  			cmd.SetIn(&bytes.Buffer{})
   126  			cmd.SetOut(io.Discard)
   127  			cmd.SetErr(io.Discard)
   128  
   129  			_, err = cmd.ExecuteC()
   130  			if tt.wantsErr {
   131  				assert.Error(t, err)
   132  				if tt.errMsg != "" {
   133  					assert.Equal(t, tt.errMsg, err.Error())
   134  				}
   135  				return
   136  			}
   137  
   138  			assert.NoError(t, err)
   139  
   140  			assert.Equal(t, tt.wants.Selector, gotOpts.Selector)
   141  			assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt)
   142  			assert.Equal(t, tt.wants.JSONInput, gotOpts.JSONInput)
   143  			assert.Equal(t, tt.wants.JSON, gotOpts.JSON)
   144  			assert.Equal(t, tt.wants.Ref, gotOpts.Ref)
   145  			assert.ElementsMatch(t, tt.wants.RawFields, gotOpts.RawFields)
   146  			assert.ElementsMatch(t, tt.wants.MagicFields, gotOpts.MagicFields)
   147  		})
   148  	}
   149  }
   150  
   151  func Test_magicFieldValue(t *testing.T) {
   152  	f, err := os.CreateTemp(t.TempDir(), "gh-test")
   153  	if err != nil {
   154  		t.Fatal(err)
   155  	}
   156  	defer f.Close()
   157  
   158  	fmt.Fprint(f, "file contents")
   159  
   160  	ios, _, _, _ := iostreams.Test()
   161  
   162  	type args struct {
   163  		v    string
   164  		opts RunOptions
   165  	}
   166  	tests := []struct {
   167  		name    string
   168  		args    args
   169  		want    interface{}
   170  		wantErr bool
   171  	}{
   172  		{
   173  			name:    "string",
   174  			args:    args{v: "hello"},
   175  			want:    "hello",
   176  			wantErr: false,
   177  		},
   178  		{
   179  			name: "file",
   180  			args: args{
   181  				v:    "@" + f.Name(),
   182  				opts: RunOptions{IO: ios},
   183  			},
   184  			want:    "file contents",
   185  			wantErr: false,
   186  		},
   187  		{
   188  			name: "file error",
   189  			args: args{
   190  				v:    "@",
   191  				opts: RunOptions{IO: ios},
   192  			},
   193  			want:    nil,
   194  			wantErr: true,
   195  		},
   196  	}
   197  	for _, tt := range tests {
   198  		t.Run(tt.name, func(t *testing.T) {
   199  			got, err := magicFieldValue(tt.args.v, tt.args.opts)
   200  			if (err != nil) != tt.wantErr {
   201  				t.Errorf("magicFieldValue() error = %v, wantErr %v", err, tt.wantErr)
   202  				return
   203  			}
   204  			if tt.wantErr {
   205  				return
   206  			}
   207  			assert.Equal(t, tt.want, got)
   208  		})
   209  	}
   210  }
   211  
   212  func Test_findInputs(t *testing.T) {
   213  	tests := []struct {
   214  		name    string
   215  		YAML    []byte
   216  		wantErr bool
   217  		errMsg  string
   218  		wantOut map[string]WorkflowInput
   219  	}{
   220  		{
   221  			name:    "blank",
   222  			YAML:    []byte{},
   223  			wantErr: true,
   224  			errMsg:  "invalid YAML file",
   225  		},
   226  		{
   227  			name:    "no event specified",
   228  			YAML:    []byte("name: workflow"),
   229  			wantErr: true,
   230  			errMsg:  "invalid workflow: no 'on' key",
   231  		},
   232  		{
   233  			name:    "not workflow_dispatch",
   234  			YAML:    []byte("name: workflow\non: pull_request"),
   235  			wantErr: true,
   236  			errMsg:  "unable to manually run a workflow without a workflow_dispatch event",
   237  		},
   238  		{
   239  			name:    "bad inputs",
   240  			YAML:    []byte("name: workflow\non:\n workflow_dispatch:\n  inputs: lol  "),
   241  			wantErr: true,
   242  			errMsg:  "could not decode workflow inputs: yaml: unmarshal errors:\n  line 4: cannot unmarshal !!str `lol` into map[string]run.WorkflowInput",
   243  		},
   244  		{
   245  			name:    "short syntax",
   246  			YAML:    []byte("name: workflow\non: workflow_dispatch"),
   247  			wantOut: map[string]WorkflowInput{},
   248  		},
   249  		{
   250  			name:    "array of events",
   251  			YAML:    []byte("name: workflow\non: [pull_request, workflow_dispatch]\n"),
   252  			wantOut: map[string]WorkflowInput{},
   253  		},
   254  		{
   255  			name: "inputs",
   256  			YAML: []byte(`name: workflow
   257  on:
   258    workflow_dispatch:
   259      inputs:
   260        foo:
   261          required: true
   262          description: good foo
   263        bar:
   264          default: boo
   265        baz:
   266          description: it's baz
   267        quux:
   268          required: true
   269          default: "cool"
   270  jobs:
   271    yell:
   272      runs-on: ubuntu-latest
   273      steps:
   274        - name: echo
   275          run: |
   276            echo "echo"`),
   277  			wantOut: map[string]WorkflowInput{
   278  				"foo": {
   279  					Required:    true,
   280  					Description: "good foo",
   281  				},
   282  				"bar": {
   283  					Default: "boo",
   284  				},
   285  				"baz": {
   286  					Description: "it's baz",
   287  				},
   288  				"quux": {
   289  					Required: true,
   290  					Default:  "cool",
   291  				},
   292  			},
   293  		},
   294  	}
   295  
   296  	for _, tt := range tests {
   297  		t.Run(tt.name, func(t *testing.T) {
   298  			result, err := findInputs(tt.YAML)
   299  			if tt.wantErr {
   300  				assert.Error(t, err)
   301  				if err != nil {
   302  					assert.Equal(t, tt.errMsg, err.Error())
   303  				}
   304  				return
   305  			} else {
   306  				assert.NoError(t, err)
   307  			}
   308  
   309  			assert.Equal(t, tt.wantOut, result)
   310  		})
   311  	}
   312  
   313  }
   314  
   315  func TestRun(t *testing.T) {
   316  	noInputsYAMLContent := []byte(`
   317  name: minimal workflow
   318  on: workflow_dispatch
   319  jobs:
   320    yell:
   321      runs-on: ubuntu-latest
   322      steps:
   323        - name: do a yell
   324          run: |
   325            echo "AUUUGH!"
   326  `)
   327  	encodedNoInputsYAMLContent := base64.StdEncoding.EncodeToString(noInputsYAMLContent)
   328  	yamlContent := []byte(`
   329  name: a workflow
   330  on:
   331    workflow_dispatch:
   332      inputs:
   333        greeting:
   334          default: hi
   335          description: a greeting
   336        name:
   337          required: true
   338          description: a name
   339  jobs:
   340    greet:
   341      runs-on: ubuntu-latest
   342      steps:
   343        - name: perform the greet
   344          run: |
   345            echo "${{ github.event.inputs.greeting}}, ${{ github.events.inputs.name }}!"`)
   346  
   347  	encodedYAMLContent := base64.StdEncoding.EncodeToString(yamlContent)
   348  
   349  	stubs := func(reg *httpmock.Registry) {
   350  		reg.Register(
   351  			httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yml"),
   352  			httpmock.JSONResponse(shared.Workflow{
   353  				Path: ".github/workflows/workflow.yml",
   354  				ID:   12345,
   355  			}))
   356  		reg.Register(
   357  			httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"),
   358  			httpmock.StatusStringResponse(204, "cool"))
   359  	}
   360  
   361  	tests := []struct {
   362  		name      string
   363  		opts      *RunOptions
   364  		tty       bool
   365  		wantErr   bool
   366  		errOut    string
   367  		wantOut   string
   368  		wantBody  map[string]interface{}
   369  		httpStubs func(*httpmock.Registry)
   370  		askStubs  func(*prompt.AskStubber)
   371  	}{
   372  		{
   373  			name: "bad JSON",
   374  			opts: &RunOptions{
   375  				Selector:  "workflow.yml",
   376  				JSONInput: `{"bad":"corrupt"`,
   377  			},
   378  			httpStubs: func(reg *httpmock.Registry) {
   379  				reg.Register(
   380  					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yml"),
   381  					httpmock.JSONResponse(shared.Workflow{
   382  						Path: ".github/workflows/workflow.yml",
   383  					}))
   384  			},
   385  			wantErr: true,
   386  			errOut:  "could not parse provided JSON: unexpected end of JSON input",
   387  		},
   388  		{
   389  			name: "good JSON",
   390  			tty:  true,
   391  			opts: &RunOptions{
   392  				Selector:  "workflow.yml",
   393  				JSONInput: `{"name":"scully"}`,
   394  			},
   395  			wantBody: map[string]interface{}{
   396  				"inputs": map[string]interface{}{
   397  					"name": "scully",
   398  				},
   399  				"ref": "trunk",
   400  			},
   401  			httpStubs: stubs,
   402  			wantOut:   "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=workflow.yml\n",
   403  		},
   404  		{
   405  			name: "nontty good JSON",
   406  			opts: &RunOptions{
   407  				Selector:  "workflow.yml",
   408  				JSONInput: `{"name":"scully"}`,
   409  			},
   410  			wantBody: map[string]interface{}{
   411  				"inputs": map[string]interface{}{
   412  					"name": "scully",
   413  				},
   414  				"ref": "trunk",
   415  			},
   416  			httpStubs: stubs,
   417  		},
   418  		{
   419  			name: "nontty good input fields",
   420  			opts: &RunOptions{
   421  				Selector:    "workflow.yml",
   422  				RawFields:   []string{`name=scully`},
   423  				MagicFields: []string{`greeting=hey`},
   424  			},
   425  			wantBody: map[string]interface{}{
   426  				"inputs": map[string]interface{}{
   427  					"name":     "scully",
   428  					"greeting": "hey",
   429  				},
   430  				"ref": "trunk",
   431  			},
   432  			httpStubs: stubs,
   433  		},
   434  		{
   435  			name: "respects ref",
   436  			tty:  true,
   437  			opts: &RunOptions{
   438  				Selector:  "workflow.yml",
   439  				JSONInput: `{"name":"scully"}`,
   440  				Ref:       "good-branch",
   441  			},
   442  			wantBody: map[string]interface{}{
   443  				"inputs": map[string]interface{}{
   444  					"name": "scully",
   445  				},
   446  				"ref": "good-branch",
   447  			},
   448  			httpStubs: stubs,
   449  			wantOut:   "✓ Created workflow_dispatch event for workflow.yml at good-branch\n\nTo see runs for this workflow, try: gh run list --workflow=workflow.yml\n",
   450  		},
   451  		{
   452  			// TODO this test is somewhat silly; it's more of a placeholder in case I decide to handle the API error more elegantly
   453  			name: "good JSON, missing required input",
   454  			tty:  true,
   455  			opts: &RunOptions{
   456  				Selector:  "workflow.yml",
   457  				JSONInput: `{"greeting":"hello there"}`,
   458  			},
   459  			httpStubs: func(reg *httpmock.Registry) {
   460  				reg.Register(
   461  					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yml"),
   462  					httpmock.JSONResponse(shared.Workflow{
   463  						Path: ".github/workflows/workflow.yml",
   464  						ID:   12345,
   465  					}))
   466  				reg.Register(
   467  					httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"),
   468  					httpmock.StatusStringResponse(422, "missing something"))
   469  			},
   470  			wantErr: true,
   471  			errOut:  "could not create workflow dispatch event: HTTP 422 (https://api.github.com/repos/OWNER/REPO/actions/workflows/12345/dispatches)",
   472  		},
   473  		{
   474  			name: "yaml file extension",
   475  			tty:  false,
   476  			opts: &RunOptions{
   477  				Selector: "workflow.yaml",
   478  			},
   479  			httpStubs: func(reg *httpmock.Registry) {
   480  				reg.Register(
   481  					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yaml"),
   482  					httpmock.StatusStringResponse(200, `{"id": 12345}`))
   483  				reg.Register(
   484  					httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"),
   485  					httpmock.StatusStringResponse(204, ""))
   486  			},
   487  			wantBody: map[string]interface{}{
   488  				"inputs": map[string]interface{}{},
   489  				"ref":    "trunk",
   490  			},
   491  			wantErr: false,
   492  		},
   493  		{
   494  			// TODO this test is somewhat silly; it's more of a placeholder in case I decide to handle the API error more elegantly
   495  			name: "input fields, missing required",
   496  			opts: &RunOptions{
   497  				Selector:  "workflow.yml",
   498  				RawFields: []string{`greeting="hello there"`},
   499  			},
   500  			httpStubs: func(reg *httpmock.Registry) {
   501  				reg.Register(
   502  					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yml"),
   503  					httpmock.JSONResponse(shared.Workflow{
   504  						Path: ".github/workflows/workflow.yml",
   505  						ID:   12345,
   506  					}))
   507  				reg.Register(
   508  					httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"),
   509  					httpmock.StatusStringResponse(422, "missing something"))
   510  			},
   511  			wantErr: true,
   512  			errOut:  "could not create workflow dispatch event: HTTP 422 (https://api.github.com/repos/OWNER/REPO/actions/workflows/12345/dispatches)",
   513  		},
   514  		{
   515  			name: "prompt, no workflows enabled",
   516  			tty:  true,
   517  			opts: &RunOptions{
   518  				Prompt: true,
   519  			},
   520  			httpStubs: func(reg *httpmock.Registry) {
   521  				reg.Register(
   522  					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
   523  					httpmock.JSONResponse(shared.WorkflowsPayload{
   524  						Workflows: []shared.Workflow{
   525  							{
   526  								Name:  "disabled",
   527  								State: shared.DisabledManually,
   528  								ID:    102,
   529  							},
   530  						},
   531  					}))
   532  			},
   533  			wantErr: true,
   534  			errOut:  "no workflows are enabled on this repository",
   535  		},
   536  		{
   537  			name: "prompt, no workflows",
   538  			tty:  true,
   539  			opts: &RunOptions{
   540  				Prompt: true,
   541  			},
   542  			httpStubs: func(reg *httpmock.Registry) {
   543  				reg.Register(
   544  					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
   545  					httpmock.JSONResponse(shared.WorkflowsPayload{
   546  						Workflows: []shared.Workflow{},
   547  					}))
   548  			},
   549  			wantErr: true,
   550  			errOut:  "could not fetch workflows for OWNER/REPO: no workflows are enabled",
   551  		},
   552  		{
   553  			name: "prompt, minimal yaml",
   554  			tty:  true,
   555  			opts: &RunOptions{
   556  				Prompt: true,
   557  			},
   558  			httpStubs: func(reg *httpmock.Registry) {
   559  				reg.Register(
   560  					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
   561  					httpmock.JSONResponse(shared.WorkflowsPayload{
   562  						Workflows: []shared.Workflow{
   563  							{
   564  								Name:  "minimal workflow",
   565  								ID:    1,
   566  								State: shared.Active,
   567  								Path:  ".github/workflows/minimal.yml",
   568  							},
   569  						},
   570  					}))
   571  				reg.Register(
   572  					httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/minimal.yml"),
   573  					httpmock.JSONResponse(struct{ Content string }{
   574  						Content: encodedNoInputsYAMLContent,
   575  					}))
   576  				reg.Register(
   577  					httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/1/dispatches"),
   578  					httpmock.StatusStringResponse(204, "cool"))
   579  			},
   580  			askStubs: func(as *prompt.AskStubber) {
   581  				as.StubPrompt("Select a workflow").AnswerDefault()
   582  			},
   583  			wantBody: map[string]interface{}{
   584  				"inputs": map[string]interface{}{},
   585  				"ref":    "trunk",
   586  			},
   587  			wantOut: "✓ Created workflow_dispatch event for minimal.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=minimal.yml\n",
   588  		},
   589  		{
   590  			name: "prompt",
   591  			tty:  true,
   592  			opts: &RunOptions{
   593  				Prompt: true,
   594  			},
   595  			httpStubs: func(reg *httpmock.Registry) {
   596  				reg.Register(
   597  					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
   598  					httpmock.JSONResponse(shared.WorkflowsPayload{
   599  						Workflows: []shared.Workflow{
   600  							{
   601  								Name:  "a workflow",
   602  								ID:    12345,
   603  								State: shared.Active,
   604  								Path:  ".github/workflows/workflow.yml",
   605  							},
   606  						},
   607  					}))
   608  				reg.Register(
   609  					httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"),
   610  					httpmock.JSONResponse(struct{ Content string }{
   611  						Content: encodedYAMLContent,
   612  					}))
   613  				reg.Register(
   614  					httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"),
   615  					httpmock.StatusStringResponse(204, "cool"))
   616  			},
   617  			askStubs: func(as *prompt.AskStubber) {
   618  				as.StubPrompt("Select a workflow").AnswerDefault()
   619  				as.StubPrompt("greeting").AnswerWith("hi")
   620  				as.StubPrompt("name").AnswerWith("scully")
   621  			},
   622  			wantBody: map[string]interface{}{
   623  				"inputs": map[string]interface{}{
   624  					"name":     "scully",
   625  					"greeting": "hi",
   626  				},
   627  				"ref": "trunk",
   628  			},
   629  			wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=workflow.yml\n",
   630  		},
   631  	}
   632  
   633  	for _, tt := range tests {
   634  		reg := &httpmock.Registry{}
   635  		if tt.httpStubs != nil {
   636  			tt.httpStubs(reg)
   637  		}
   638  		tt.opts.HttpClient = func() (*http.Client, error) {
   639  			return &http.Client{Transport: reg}, nil
   640  		}
   641  
   642  		ios, _, stdout, _ := iostreams.Test()
   643  		ios.SetStdinTTY(tt.tty)
   644  		ios.SetStdoutTTY(tt.tty)
   645  		tt.opts.IO = ios
   646  		tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
   647  			return api.InitRepoHostname(&api.Repository{
   648  				Name:             "REPO",
   649  				Owner:            api.RepositoryOwner{Login: "OWNER"},
   650  				DefaultBranchRef: api.BranchRef{Name: "trunk"},
   651  			}, "github.com"), nil
   652  		}
   653  
   654  		t.Run(tt.name, func(t *testing.T) {
   655  			//nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock
   656  			as := prompt.NewAskStubber(t)
   657  			if tt.askStubs != nil {
   658  				tt.askStubs(as)
   659  			}
   660  
   661  			err := runRun(tt.opts)
   662  			if tt.wantErr {
   663  				assert.Error(t, err)
   664  				assert.Equal(t, tt.errOut, err.Error())
   665  				return
   666  			}
   667  			assert.NoError(t, err)
   668  			assert.Equal(t, tt.wantOut, stdout.String())
   669  			reg.Verify(t)
   670  
   671  			if len(reg.Requests) > 0 {
   672  				lastRequest := reg.Requests[len(reg.Requests)-1]
   673  				if lastRequest.Method == "POST" {
   674  					bodyBytes, _ := io.ReadAll(lastRequest.Body)
   675  					reqBody := make(map[string]interface{})
   676  					err := json.Unmarshal(bodyBytes, &reqBody)
   677  					if err != nil {
   678  						t.Fatalf("error decoding JSON: %v", err)
   679  					}
   680  					assert.Equal(t, tt.wantBody, reqBody)
   681  				}
   682  			}
   683  		})
   684  	}
   685  }