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 }