github.com/jbrudvik/gmc@v0.0.12-0.20230324172602-e4a1f8624839/cli/cli_test.go (about) 1 package cli_test 2 3 import ( 4 "bytes" 5 "fmt" 6 "io/fs" 7 "os" 8 "os/exec" 9 "path/filepath" 10 "strings" 11 "testing" 12 13 "github.com/jbrudvik/gmc/cli" 14 ) 15 16 const editor string = "vim" 17 const gitBranchName string = "main" 18 19 var helpOutput string = fmt.Sprintf("NAME:\n"+ 20 " %s - (Go mod create) creates Go modules\n"+ 21 "\n"+ 22 "USAGE:\n"+ 23 " %s [global options] [module name]\n"+ 24 "\n"+ 25 "VERSION:\n"+ 26 " %s\n"+ 27 "\n"+ 28 "DESCRIPTION:\n"+ 29 " `%s [module name]` creates a directory containing:\n"+ 30 " - Go module metadata: go.mod\n"+ 31 " - A place to start writing code: main.go\n"+ 32 " - A .gitignore file\n"+ 33 " \n"+ 34 " This module can be immediately run:\n"+ 35 " \n"+ 36 " $ go run .\n"+ 37 " hello, world!\n"+ 38 " \n"+ 39 " Optionally, the directory can also include:\n"+ 40 " - Git repository setup with .gitignore, README.md\n"+ 41 " \n"+ 42 " More information: %s\n"+ 43 "\n"+ 44 "GLOBAL OPTIONS:\n"+ 45 " --git, -g create as Git repository (default: false)\n"+ 46 " --quiet, -q silence output (default: false)\n"+ 47 " --help, -h show help (default: false)\n"+ 48 " --version, -v print the version (default: false)\n", 49 cli.Name, 50 cli.Name, 51 cli.Version, 52 cli.Name, 53 cli.Url, 54 ) 55 56 var versionOutput string = fmt.Sprintf("%s version %s\n", cli.Name, cli.Version) 57 58 const mainGoContents string = "package main\n" + 59 "\n" + 60 "import (\n" + 61 " \"fmt\"\n" + 62 ")\n" + 63 "\n" + 64 "func main() {\n" + 65 " fmt.Println(\"hello, world!\")\n" + 66 "}\n" 67 68 const errorMessageUnknownFlag string = "Error: Unknown flag\n\n" 69 const errorMessageModuleNameRequired string = "Error: Module name is required\n\n" 70 const errorMessageTooManyModuleNames string = "Error: Only one module name is allowed\n\n" 71 72 type testRunTestCaseData struct { 73 args []string 74 expectedOutput string 75 expectedErrorOutput string 76 expectedExitCode int 77 expectedFiles *file 78 expectedGitRepo *gitRepo 79 } 80 81 type file struct { 82 name string 83 perm fs.FileMode 84 content []byte // Non-nil for file 85 files []file // Non-nil for directory 86 } 87 88 const dirPerms fs.FileMode = 0755 | fs.ModeDir 89 const filePerms fs.FileMode = 0644 90 91 type gitRepo struct { 92 dir string 93 branchName string 94 commitMessages []string 95 remote *string 96 } 97 98 func TestRun(t *testing.T) { 99 tests := []testRunTestCaseData{ 100 { 101 args: []string{"-h"}, 102 expectedOutput: helpOutput, 103 expectedErrorOutput: "", 104 expectedExitCode: 0, 105 expectedFiles: nil, 106 expectedGitRepo: nil, 107 }, 108 { 109 args: []string{"--help"}, 110 expectedOutput: helpOutput, 111 expectedErrorOutput: "", 112 expectedExitCode: 0, 113 expectedFiles: nil, 114 expectedGitRepo: nil, 115 }, 116 { 117 args: []string{"-v"}, 118 expectedOutput: versionOutput, 119 expectedErrorOutput: "", 120 expectedExitCode: 0, 121 expectedFiles: nil, 122 expectedGitRepo: nil, 123 }, 124 { 125 args: []string{"-q"}, 126 expectedOutput: "", 127 expectedErrorOutput: "", 128 expectedExitCode: 1, 129 expectedFiles: nil, 130 expectedGitRepo: nil, 131 }, 132 { 133 args: []string{"-h", "-q"}, 134 expectedOutput: helpOutput, 135 expectedErrorOutput: "", 136 expectedExitCode: 0, 137 expectedFiles: nil, 138 expectedGitRepo: nil, 139 }, 140 { 141 args: []string{"-v", "-q"}, 142 expectedOutput: versionOutput, 143 expectedErrorOutput: "", 144 expectedExitCode: 0, 145 expectedFiles: nil, 146 expectedGitRepo: nil, 147 }, 148 { 149 args: []string{"--version"}, 150 expectedOutput: versionOutput, 151 expectedErrorOutput: "", 152 expectedExitCode: 0, 153 expectedFiles: nil, 154 expectedGitRepo: nil, 155 }, 156 { 157 args: []string{}, 158 expectedOutput: helpOutput, 159 expectedErrorOutput: errorMessageModuleNameRequired, 160 expectedExitCode: 1, 161 expectedFiles: nil, 162 expectedGitRepo: nil, 163 }, 164 { 165 args: []string{"-e"}, 166 expectedOutput: helpOutput, 167 expectedErrorOutput: errorMessageUnknownFlag, 168 expectedExitCode: 1, 169 expectedFiles: nil, 170 expectedGitRepo: nil, 171 }, 172 { 173 args: []string{"-e", "a1"}, 174 expectedOutput: helpOutput, 175 expectedErrorOutput: errorMessageUnknownFlag, 176 expectedExitCode: 1, 177 expectedFiles: nil, 178 expectedGitRepo: nil, 179 }, 180 { 181 args: []string{"a1", "a2"}, 182 expectedOutput: helpOutput, 183 expectedErrorOutput: errorMessageTooManyModuleNames, 184 expectedExitCode: 1, 185 expectedFiles: nil, 186 expectedGitRepo: nil, 187 }, 188 { 189 args: []string{"-q", "a1", "a2"}, 190 expectedOutput: "", 191 expectedErrorOutput: "", 192 expectedExitCode: 1, 193 expectedFiles: nil, 194 expectedGitRepo: nil, 195 }, 196 { 197 args: []string{"a1"}, 198 expectedOutput: fmt.Sprintf("Creating Go module: a1\n"+ 199 "- Created directory: a1\n"+ 200 "- Initialized Go module\n"+ 201 "- Created file : a1/main.go\n"+ 202 "- Created file : a1/.gitignore\n"+ 203 "\n"+ 204 "Finished creating Go module: a1\n"+ 205 "\n"+ 206 "Next steps:\n"+ 207 "- Change into module's directory: $ cd a1\n"+ 208 "- Run module: $ go run .\n"+ 209 "- Start coding: $ %s .\n", 210 editor), 211 expectedErrorOutput: "", 212 expectedExitCode: 0, 213 expectedFiles: &file{"a1", dirPerms, nil, []file{ 214 {"go.mod", filePerms, []byte("module a1\n\ngo 1.18\n"), nil}, 215 {"main.go", filePerms, []byte(mainGoContents), nil}, 216 {".gitignore", filePerms, []byte("a1"), nil}, 217 }}, 218 expectedGitRepo: nil, 219 }, 220 { 221 args: []string{"github.com/foo"}, 222 expectedOutput: fmt.Sprintf("Creating Go module: github.com/foo\n"+ 223 "- Created directory: foo\n"+ 224 "- Initialized Go module\n"+ 225 "- Created file : foo/main.go\n"+ 226 "- Created file : foo/.gitignore\n"+ 227 "\n"+ 228 "Finished creating Go module: github.com/foo\n"+ 229 "\n"+ 230 "Next steps:\n"+ 231 "- Change into module's directory: $ cd foo\n"+ 232 "- Run module: $ go run .\n"+ 233 "- Start coding: $ %s .\n", 234 editor), 235 expectedErrorOutput: "", 236 expectedExitCode: 0, 237 expectedFiles: &file{"foo", dirPerms, nil, []file{ 238 {"go.mod", filePerms, []byte("module github.com/foo\n\ngo 1.18\n"), nil}, 239 {"main.go", filePerms, []byte(mainGoContents), nil}, 240 {".gitignore", filePerms, []byte("foo"), nil}, 241 }}, 242 expectedGitRepo: nil, 243 }, 244 { 245 args: []string{"--git", "github.com/foo/bar"}, 246 expectedOutput: fmt.Sprintf("Creating Go module: github.com/foo/bar\n"+ 247 "- Created directory: bar\n"+ 248 "- Initialized Go module\n"+ 249 "- Created file : bar/main.go\n"+ 250 "- Created file : bar/.gitignore\n"+ 251 "- Initialized Git repository\n"+ 252 "- Created file : bar/README.md\n"+ 253 "- Committed all files to Git repository\n"+ 254 "- Added remote for Git repository: git@github.com:foo/bar.git\n"+ 255 "\n"+ 256 "Finished creating Go module: github.com/foo/bar\n"+ 257 "\n"+ 258 "Next steps:\n"+ 259 "- Change into module's directory: $ cd bar\n"+ 260 "- Run module: $ go run .\n"+ 261 "- Create remote Git repository git@github.com:foo/bar.git: https://github.com/new\n"+ 262 "- Push to remote Git repository: $ git push -u origin %s\n"+ 263 "- Start coding: $ %s .\n", 264 gitBranchName, 265 editor), 266 expectedErrorOutput: "", 267 expectedExitCode: 0, 268 expectedFiles: &file{"bar", dirPerms, nil, []file{ 269 {"go.mod", filePerms, []byte("module github.com/foo/bar\n\ngo 1.18\n"), nil}, 270 {"main.go", filePerms, []byte(mainGoContents), nil}, 271 {".git", dirPerms, nil, nil}, 272 {".gitignore", filePerms, []byte("bar"), nil}, 273 {"README.md", filePerms, []byte("# bar\n\n"), nil}, 274 }}, 275 expectedGitRepo: &gitRepo{ 276 "bar", 277 gitBranchName, 278 []string{"Initial commit"}, 279 ptr("git@github.com:foo/bar.git"), 280 }, 281 }, 282 { 283 args: []string{"-g", "github.com/foo/bar"}, 284 expectedOutput: fmt.Sprintf("Creating Go module: github.com/foo/bar\n"+ 285 "- Created directory: bar\n"+ 286 "- Initialized Go module\n"+ 287 "- Created file : bar/main.go\n"+ 288 "- Created file : bar/.gitignore\n"+ 289 "- Initialized Git repository\n"+ 290 "- Created file : bar/README.md\n"+ 291 "- Committed all files to Git repository\n"+ 292 "- Added remote for Git repository: git@github.com:foo/bar.git\n"+ 293 "\n"+ 294 "Finished creating Go module: github.com/foo/bar\n"+ 295 "\n"+ 296 "Next steps:\n"+ 297 "- Change into module's directory: $ cd bar\n"+ 298 "- Run module: $ go run .\n"+ 299 "- Create remote Git repository git@github.com:foo/bar.git: https://github.com/new\n"+ 300 "- Push to remote Git repository: $ git push -u origin %s\n"+ 301 "- Start coding: $ %s .\n", 302 gitBranchName, 303 editor, 304 ), 305 expectedErrorOutput: "", 306 expectedExitCode: 0, 307 expectedFiles: &file{"bar", dirPerms, nil, []file{ 308 {"go.mod", filePerms, []byte("module github.com/foo/bar\n\ngo 1.18\n"), nil}, 309 {"main.go", filePerms, []byte(mainGoContents), nil}, 310 {".git", dirPerms, nil, nil}, 311 {".gitignore", filePerms, []byte("bar"), nil}, 312 {"README.md", filePerms, []byte("# bar\n\n"), nil}, 313 }}, 314 expectedGitRepo: &gitRepo{ 315 "bar", 316 gitBranchName, 317 []string{"Initial commit"}, 318 ptr("git@github.com:foo/bar.git"), 319 }, 320 }, 321 } 322 323 t.Setenv("EDITOR", editor) // Automatically reset 324 325 for _, tc := range tests { 326 testName := strings.Join(tc.args, " ") 327 t.Run(testName, func(t *testing.T) { 328 testRunTestCase(t, tc) 329 }) 330 } 331 } 332 333 func testRunTestCase(t *testing.T, tc testRunTestCaseData) { 334 tempTestDir := t.TempDir() // Automatically cleaned up 335 336 cwd, err := os.Getwd() 337 if err != nil { 338 t.Fatal(err) 339 } 340 err = os.Chdir(tempTestDir) 341 if err != nil { 342 t.Fatal(err) 343 } 344 t.Cleanup(func() { 345 err = os.Chdir(cwd) 346 if err != nil { 347 t.Fatal(err) 348 } 349 }) 350 351 var outputBuffer bytes.Buffer 352 var errorOutputBuffer bytes.Buffer 353 exitCodeHandler := func(exitCode int) { 354 // Test: Exit code 355 if tc.expectedExitCode != exitCode { 356 t.Errorf(testCaseUnexpectedMessage("exit code", tc.expectedExitCode, exitCode)) 357 } 358 } 359 360 app := cli.AppWithCustomEverything(&outputBuffer, &errorOutputBuffer, exitCodeHandler, ptr(gitBranchName)) 361 args := append([]string{cli.Name}, tc.args...) 362 _ = app.Run(args) 363 actualOutput := outputBuffer.String() 364 actualErrorOutput := errorOutputBuffer.String() 365 366 // Test: Output 367 if actualOutput != tc.expectedOutput { 368 t.Error(testCaseUnexpectedMessage("output", tc.expectedOutput, actualOutput)) 369 } 370 371 // Test: Error output 372 if actualErrorOutput != tc.expectedErrorOutput { 373 t.Error(testCaseUnexpectedMessage("error output", tc.expectedErrorOutput, actualErrorOutput)) 374 } 375 376 // Test: Files created 377 assertExpectedFilesExist(t, tc.expectedFiles) 378 379 // Test: Git 380 if tc.expectedGitRepo != nil { 381 assertExpectedGitRepoExists(t, *tc.expectedGitRepo) 382 } 383 } 384 385 func assertExpectedFilesExist(t *testing.T, expectedFiles *file) { 386 cwd, err := os.Getwd() 387 if err != nil { 388 t.Error("Could not get cwd", err) 389 } 390 if expectedFiles != nil { 391 walkDir(*expectedFiles, cwd, func(f file, root string) { 392 filePath := filepath.Join(root, f.name) 393 assertExpectedFileIsAtPath(t, f, filePath) 394 }) 395 } else { 396 actualEntries, err := os.ReadDir(cwd) 397 if err != nil { 398 t.Errorf("Unable to read current directory: %s", cwd) 399 } else { 400 if len(actualEntries) > 0 { 401 fileNames := []string{} 402 for _, actualEntry := range actualEntries { 403 fileNames = append(fileNames, actualEntry.Name()) 404 } 405 t.Errorf("Files were created when none were expected: %v", fileNames) 406 } 407 } 408 } 409 } 410 411 func walkDir(f file, root string, fn func(file, string)) { 412 fn(f, root) 413 414 if f.files != nil { 415 root = filepath.Join(root, f.name) 416 for _, childFile := range f.files { 417 walkDir(childFile, root, fn) 418 } 419 } 420 } 421 422 func assertExpectedFileIsAtPath(t *testing.T, f file, filePath string) { 423 fileInfo, err := os.Stat(filePath) 424 if err != nil { 425 t.Errorf("Unable to stat expected file: %s", filePath) 426 return 427 } 428 429 actualMode := fileInfo.Mode() 430 if f.perm != actualMode { 431 t.Error(testCaseUnexpectedMessage(fmt.Sprintf("file perms at path: %s", filePath), f.perm, actualMode)) 432 } 433 434 if f.content != nil { 435 // Compare files 436 bytes, err := os.ReadFile(filePath) 437 if err != nil { 438 t.Errorf("Unable to read expected file: %s", filePath) 439 } else { 440 expectedFileContent := string(f.content) 441 actualFileContent := string(bytes) 442 if expectedFileContent != actualFileContent { 443 t.Error(testCaseUnexpectedMessage(fmt.Sprintf("file content at path: %s", filePath), expectedFileContent, actualFileContent)) 444 } 445 } 446 } else { 447 // Compare dirs 448 actualEntries, err := os.ReadDir(filePath) 449 if err != nil { 450 t.Errorf("Unable to read expected directory: %s", filePath) 451 } else { 452 expectedEntriesExist := map[string]bool{} 453 454 if f.files != nil { // nil -> Ignore contents of directory 455 for _, expectedEntry := range f.files { 456 expectedEntriesExist[expectedEntry.name] = false 457 } 458 459 for _, actualEntry := range actualEntries { 460 actualFileName := actualEntry.Name() 461 _, ok := expectedEntriesExist[actualFileName] 462 if !ok { 463 t.Errorf(fmt.Sprintf("Unexpected file exists: %s", filepath.Join(filePath, actualFileName))) 464 } else { 465 expectedEntriesExist[actualFileName] = true 466 } 467 } 468 469 for fileName, wasFound := range expectedEntriesExist { 470 if !wasFound { 471 t.Errorf("Expected file not found: %s", filepath.Join(filePath, fileName)) 472 } 473 } 474 } 475 } 476 } 477 } 478 479 func assertExpectedGitRepoExists(t *testing.T, expectedGitRepo gitRepo) { 480 // Assert Git repository has expected branch name 481 cmd := exec.Command("git", "branch", "--show-current") 482 cmd.Dir = expectedGitRepo.dir 483 cmdOutput, err := cmd.Output() 484 if err != nil { 485 t.Errorf("Unable to view Git branch name in %s:", expectedGitRepo.dir) 486 return 487 } 488 actualBranchName := strings.TrimSpace(string(cmdOutput)) 489 if expectedGitRepo.branchName != actualBranchName { 490 t.Error(testCaseUnexpectedMessage("Git repository branch name", expectedGitRepo.branchName, actualBranchName)) 491 } 492 493 // Assert all files have been committed to Git repository 494 cmd = exec.Command("git", "status", "-s") 495 cmd.Dir = expectedGitRepo.dir 496 cmdOutput, err = cmd.Output() 497 if err != nil { 498 t.Errorf("Unable to view Git status in %s:", expectedGitRepo.dir) 499 return 500 } 501 cmdOutputString := strings.TrimSpace(string(cmdOutput)) 502 if cmdOutputString != "" { 503 t.Errorf("Not all files committed to Git repository: %s", cmdOutput) 504 } 505 506 // Assert Git repository has expected commit history 507 cmd = exec.Command("git", "log", "--pretty=%s") 508 cmd.Dir = expectedGitRepo.dir 509 cmdOutput, err = cmd.Output() 510 if err != nil { 511 t.Errorf("Unable to view Git commit history in %s:", expectedGitRepo.dir) 512 return 513 } 514 actualCommitMessagesString := strings.TrimSpace(string(cmdOutput)) 515 expectedCommitMessagesString := strings.Join(expectedGitRepo.commitMessages, "\n") 516 if expectedCommitMessagesString != actualCommitMessagesString { 517 t.Error(testCaseUnexpectedMessage("Git repository commit message history", expectedCommitMessagesString, actualCommitMessagesString)) 518 } 519 520 // Assert expected Git remote 521 cmd = exec.Command("git", "remote", "get-url", "origin") 522 cmd.Dir = expectedGitRepo.dir 523 var actualGitRemote *string = nil 524 cmdOutput, err = cmd.Output() 525 if err != nil { 526 // Expected when no remote set 527 } 528 cmdOutputString = strings.TrimSpace(string(cmdOutput)) 529 if cmdOutputString != "" { 530 actualGitRemote = &cmdOutputString 531 } 532 if expectedGitRepo.remote == nil { 533 if actualGitRemote != nil { 534 t.Error(testCaseUnexpectedMessage("Git remote", expectedGitRepo.remote, actualGitRemote)) 535 } 536 } else { 537 expectedGitRemote := *expectedGitRepo.remote 538 if expectedGitRemote != cmdOutputString { 539 t.Error(testCaseUnexpectedMessage("Git remote", *expectedGitRepo.remote, *actualGitRemote)) 540 } 541 } 542 } 543 544 func testCaseUnexpectedMessage[T any](thing string, expected T, actual T) string { 545 return fmt.Sprintf("Unexpected %s\nExpected: %v\nActual : %v\n", thing, expected, actual) 546 } 547 548 func ptr[T any](t T) *T { 549 return &t 550 }