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