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  }