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 }