test(coverage): add targeted tests to raise per-package coverage gates
- internal/homesick/version: new version_test.go covers String constant and semver format validation - internal/homesick/cli: add list, generate, clone, status, diff, and git-repo helper tests; coverage raised from 62.5% to 71.2% - internal/homesick/core: new helpers_test.go covers runGit pretend, actionVerb, sayStatus, unlinkPath, linkPath, readSubdirs, matchesIgnoredDir, confirmDestroy, ExecAll edge cases, and Link/Unlink default castle wrappers; core_test.go and pull_test.go extended with New constructor and PullAll quiet-mode tests; exec_test.go extended with ExecAll no-repos-dir and error-wrap tests; coverage raised from 75.6% to 80.2%
This commit is contained in:
@@ -5,9 +5,13 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
|
||||
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
|
||||
git "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
@@ -32,6 +36,32 @@ func (s *CLISuite) SetupTest() {
|
||||
s.stderr = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
func (s *CLISuite) createCastleRepo(castle string) string {
|
||||
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", castle)
|
||||
repo, err := git.PlainInit(castleRoot, false)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
filePath := filepath.Join(castleRoot, "home", ".vimrc")
|
||||
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
|
||||
|
||||
wt, err := repo.Worktree()
|
||||
require.NoError(s.T(), err)
|
||||
_, err = wt.Add("home/.vimrc")
|
||||
require.NoError(s.T(), err)
|
||||
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||
Name: "Behavior Test",
|
||||
Email: "behavior@test.local",
|
||||
When: time.Now(),
|
||||
}})
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
_, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{"git://example.com/test.git"}})
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
return castleRoot
|
||||
}
|
||||
|
||||
func (s *CLISuite) TestRun_VersionAliases() {
|
||||
for _, args := range [][]string{{"-v"}, {"--version"}, {"version"}} {
|
||||
s.stdout.Reset()
|
||||
@@ -191,3 +221,52 @@ func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() {
|
||||
require.Contains(s.T(), s.stdout.String(), "git commit -m behavior-suite-commit")
|
||||
require.Empty(s.T(), s.stderr.String())
|
||||
}
|
||||
|
||||
func (s *CLISuite) TestRun_List_NoArguments() {
|
||||
s.createCastleRepo("dotfiles")
|
||||
|
||||
exitCode := cli.Run([]string{"list"}, s.stdout, s.stderr)
|
||||
|
||||
require.Equal(s.T(), 0, exitCode)
|
||||
require.Contains(s.T(), s.stdout.String(), "dotfiles")
|
||||
require.Empty(s.T(), s.stderr.String())
|
||||
}
|
||||
|
||||
func (s *CLISuite) TestRun_Generate_CreatesNewCastle() {
|
||||
castlePath := filepath.Join(s.T().TempDir(), "my-castle")
|
||||
|
||||
exitCode := cli.Run([]string{"generate", castlePath}, s.stdout, s.stderr)
|
||||
|
||||
require.Equal(s.T(), 0, exitCode)
|
||||
require.DirExists(s.T(), filepath.Join(castlePath, ".git"))
|
||||
require.DirExists(s.T(), filepath.Join(castlePath, "home"))
|
||||
}
|
||||
|
||||
func (s *CLISuite) TestRun_Clone_WithoutArgs() {
|
||||
exitCode := cli.Run([]string{"clone"}, s.stdout, s.stderr)
|
||||
|
||||
// Clone requires arguments, should fail
|
||||
require.NotEqual(s.T(), 0, exitCode)
|
||||
}
|
||||
|
||||
func (s *CLISuite) TestRun_Status_DefaultCastle() {
|
||||
castleRoot := s.createCastleRepo("dotfiles")
|
||||
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
|
||||
|
||||
exitCode := cli.Run([]string{"status"}, s.stdout, s.stderr)
|
||||
|
||||
require.Equal(s.T(), 0, exitCode)
|
||||
require.Contains(s.T(), s.stdout.String(), "modified:")
|
||||
require.Empty(s.T(), s.stderr.String())
|
||||
}
|
||||
|
||||
func (s *CLISuite) TestRun_Diff_DefaultCastle() {
|
||||
castleRoot := s.createCastleRepo("dotfiles")
|
||||
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
|
||||
|
||||
exitCode := cli.Run([]string{"diff"}, s.stdout, s.stderr)
|
||||
|
||||
require.Equal(s.T(), 0, exitCode)
|
||||
require.Contains(s.T(), s.stdout.String(), "diff --git")
|
||||
require.Empty(s.T(), s.stderr.String())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -47,3 +48,29 @@ func TestDeriveDestination(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInitializesApp(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
|
||||
app, err := New(stdout, stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if app == nil {
|
||||
t.Fatal("expected app instance")
|
||||
}
|
||||
|
||||
if app.Stdout != stdout {
|
||||
t.Fatal("expected stdout writer to be assigned")
|
||||
}
|
||||
if app.Stderr != stderr {
|
||||
t.Fatal("expected stderr writer to be assigned")
|
||||
}
|
||||
if app.HomeDir == "" {
|
||||
t.Fatal("expected home directory to be set")
|
||||
}
|
||||
if app.ReposDir != filepath.Join(app.HomeDir, ".homesick", "repos") {
|
||||
t.Fatalf("unexpected repos dir: %q", app.ReposDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,3 +88,22 @@ func (s *ExecSuite) TestExec_PretendDoesNotExecuteCommand() {
|
||||
require.NoFileExists(s.T(), target)
|
||||
require.Contains(s.T(), s.stdout.String(), "Would execute")
|
||||
}
|
||||
|
||||
func (s *ExecSuite) TestExecAll_RequiresCommand() {
|
||||
err := s.app.ExecAll(nil)
|
||||
require.Error(s.T(), err)
|
||||
require.Contains(s.T(), err.Error(), "exec_all requires COMMAND")
|
||||
}
|
||||
|
||||
func (s *ExecSuite) TestExecAll_NoReposDirIsNoop() {
|
||||
missingRepos := filepath.Join(s.T().TempDir(), "missing", "repos")
|
||||
app := &core.App{
|
||||
HomeDir: s.homeDir,
|
||||
ReposDir: missingRepos,
|
||||
Stdout: s.stdout,
|
||||
Stderr: s.stderr,
|
||||
}
|
||||
|
||||
err := app.ExecAll([]string{"echo hi"})
|
||||
require.NoError(s.T(), err)
|
||||
}
|
||||
|
||||
245
internal/homesick/core/helpers_test.go
Normal file
245
internal/homesick/core/helpers_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type errReader struct{}
|
||||
|
||||
func (errReader) Read(_ []byte) (int, error) {
|
||||
return 0, errors.New("boom")
|
||||
}
|
||||
|
||||
func TestRunGitPretendWritesStatus(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
app := &App{Stdout: stdout, Stderr: bytes.NewBuffer(nil), Pretend: true}
|
||||
|
||||
err := app.runGit("/tmp", "status")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, stdout.String(), "Would execute git status in /tmp")
|
||||
}
|
||||
|
||||
func TestActionVerb(t *testing.T) {
|
||||
app := &App{Pretend: true}
|
||||
require.Equal(t, "Would execute", app.actionVerb())
|
||||
|
||||
app.Pretend = false
|
||||
require.Equal(t, "Executing", app.actionVerb())
|
||||
}
|
||||
|
||||
func TestSayStatusHonorsQuiet(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
app := &App{Stdout: stdout, Quiet: true}
|
||||
app.sayStatus("git", "status")
|
||||
require.Empty(t, stdout.String())
|
||||
|
||||
app.Quiet = false
|
||||
app.sayStatus("git", "status")
|
||||
require.Contains(t, stdout.String(), "git: status")
|
||||
}
|
||||
|
||||
func TestUnlinkPath(t *testing.T) {
|
||||
t.Run("missing destination", func(t *testing.T) {
|
||||
err := unlinkPath(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("regular file is preserved", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "regular")
|
||||
require.NoError(t, os.WriteFile(target, []byte("x"), 0o644))
|
||||
|
||||
err := unlinkPath(target)
|
||||
require.NoError(t, err)
|
||||
require.FileExists(t, target)
|
||||
})
|
||||
|
||||
t.Run("symlink is removed", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source")
|
||||
destination := filepath.Join(dir, "dest")
|
||||
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
|
||||
require.NoError(t, os.Symlink(source, destination))
|
||||
|
||||
err := unlinkPath(destination)
|
||||
require.NoError(t, err)
|
||||
_, statErr := os.Lstat(destination)
|
||||
require.ErrorIs(t, statErr, os.ErrNotExist)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLinkPath(t *testing.T) {
|
||||
t.Run("existing symlink to same source is no-op", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source")
|
||||
destination := filepath.Join(dir, "dest")
|
||||
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
|
||||
|
||||
absSource, err := filepath.Abs(source)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Symlink(absSource, destination))
|
||||
|
||||
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||
err = app.linkPath(source, destination)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("conflict without force errors", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source")
|
||||
destination := filepath.Join(dir, "dest")
|
||||
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
|
||||
require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644))
|
||||
|
||||
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||
err := app.linkPath(source, destination)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "exists")
|
||||
})
|
||||
|
||||
t.Run("force replaces existing destination", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source")
|
||||
destination := filepath.Join(dir, "dest")
|
||||
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
|
||||
require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644))
|
||||
|
||||
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil), Force: true}
|
||||
err := app.linkPath(source, destination)
|
||||
require.NoError(t, err)
|
||||
|
||||
info, statErr := os.Lstat(destination)
|
||||
require.NoError(t, statErr)
|
||||
require.True(t, info.Mode()&os.ModeSymlink != 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadSubdirsAndMatchesIgnoredDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
meta := filepath.Join(dir, ".homesick_subdir")
|
||||
require.NoError(t, os.WriteFile(meta, []byte(" .config/myapp \n\n"), 0o644))
|
||||
|
||||
subdirs, err := readSubdirs(meta)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{filepath.Clean(".config/myapp")}, subdirs)
|
||||
|
||||
castleHome := filepath.Join(dir, "castle", "home")
|
||||
candidate := filepath.Join(castleHome, ".config")
|
||||
ignored, err := matchesIgnoredDir(castleHome, candidate, subdirs)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ignored)
|
||||
|
||||
notIgnored, err := matchesIgnoredDir(castleHome, filepath.Join(castleHome, ".vim"), subdirs)
|
||||
require.NoError(t, err)
|
||||
require.False(t, notIgnored)
|
||||
}
|
||||
|
||||
func TestPullAndPushDefaultCastlePretend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stdout := &bytes.Buffer{}
|
||||
app := &App{
|
||||
HomeDir: dir,
|
||||
ReposDir: filepath.Join(dir, ".homesick", "repos"),
|
||||
Stdout: stdout,
|
||||
Stderr: bytes.NewBuffer(nil),
|
||||
Pretend: true,
|
||||
}
|
||||
|
||||
require.NoError(t, app.Pull(""))
|
||||
require.NoError(t, app.Push(""))
|
||||
|
||||
out := stdout.String()
|
||||
require.Contains(t, out, "git pull")
|
||||
require.Contains(t, out, "git push")
|
||||
require.Contains(t, out, filepath.Join(app.ReposDir, "dotfiles"))
|
||||
}
|
||||
|
||||
func TestGenerateRequiresPath(t *testing.T) {
|
||||
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||
err := app.Generate(" ")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "generate requires PATH")
|
||||
}
|
||||
|
||||
func TestLinkAndUnlinkDefaultCastle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
homeDir := filepath.Join(dir, "home")
|
||||
reposDir := filepath.Join(homeDir, ".homesick", "repos")
|
||||
|
||||
castleHome := filepath.Join(reposDir, "dotfiles", "home")
|
||||
require.NoError(t, os.MkdirAll(castleHome, 0o755))
|
||||
source := filepath.Join(castleHome, ".vimrc")
|
||||
require.NoError(t, os.WriteFile(source, []byte("set number\n"), 0o644))
|
||||
|
||||
app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||
require.NoError(t, app.Link(""))
|
||||
|
||||
destination := filepath.Join(homeDir, ".vimrc")
|
||||
info, err := os.Lstat(destination)
|
||||
require.NoError(t, err)
|
||||
require.True(t, info.Mode()&os.ModeSymlink != 0)
|
||||
|
||||
require.NoError(t, app.Unlink(""))
|
||||
_, err = os.Lstat(destination)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestLinkAndUnlinkCastleMissingError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
app := &App{
|
||||
HomeDir: filepath.Join(dir, "home"),
|
||||
ReposDir: filepath.Join(dir, "home", ".homesick", "repos"),
|
||||
Stdout: bytes.NewBuffer(nil),
|
||||
Stderr: bytes.NewBuffer(nil),
|
||||
}
|
||||
|
||||
err := app.LinkCastle("missing")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "could not symlink")
|
||||
|
||||
err = app.UnlinkCastle("missing")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "could not symlink")
|
||||
}
|
||||
|
||||
func TestConfirmDestroyResponses(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
app := &App{Stdout: stdout, Stdin: strings.NewReader("yes\n")}
|
||||
|
||||
ok, err := app.confirmDestroy("dotfiles")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, stdout.String(), "Destroy castle \"dotfiles\"?")
|
||||
|
||||
stdout.Reset()
|
||||
app.Stdin = strings.NewReader("n\n")
|
||||
ok, err = app.confirmDestroy("dotfiles")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestConfirmDestroyReadError(t *testing.T) {
|
||||
app := &App{Stdout: bytes.NewBuffer(nil), Stdin: errReader{}}
|
||||
ok, err := app.confirmDestroy("dotfiles")
|
||||
require.Error(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestExecAllWrapsCastleError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
homeDir := filepath.Join(dir, "home")
|
||||
reposDir := filepath.Join(homeDir, ".homesick", "repos")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(reposDir, "broken", ".git"), 0o755))
|
||||
|
||||
app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||
err := app.ExecAll([]string{"exit 3"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "exec_all failed for \"broken\"")
|
||||
}
|
||||
@@ -140,3 +140,17 @@ func (s *PullSuite) TestPullAll_PrintsCastlePrefixes() {
|
||||
require.Contains(s.T(), stdout.String(), "alpha:")
|
||||
require.Contains(s.T(), stdout.String(), "zeta:")
|
||||
}
|
||||
|
||||
func (s *PullSuite) TestPullAll_QuietSuppressesCastlePrefixes() {
|
||||
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "alpha", ".git"), 0o755))
|
||||
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "zeta", ".git"), 0o755))
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
s.app.Stdout = stdout
|
||||
s.app.Quiet = true
|
||||
s.app.Pretend = true
|
||||
|
||||
require.NoError(s.T(), s.app.PullAll())
|
||||
require.NotContains(s.T(), stdout.String(), "alpha:")
|
||||
require.NotContains(s.T(), stdout.String(), "zeta:")
|
||||
}
|
||||
|
||||
21
internal/homesick/version/version_test.go
Normal file
21
internal/homesick/version/version_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStringConstant(t *testing.T) {
|
||||
// Test that the version constant is not empty
|
||||
assert.NotEmpty(t, String, "version.String should not be empty")
|
||||
}
|
||||
|
||||
func TestStringMatchesSemVer(t *testing.T) {
|
||||
// Test that the version string matches semantic versioning pattern (major.minor.patch)
|
||||
semverPattern := `^\d+\.\d+\.\d+$`
|
||||
matched, err := regexp.MatchString(semverPattern, String)
|
||||
assert.NoError(t, err, "regex should be valid")
|
||||
assert.True(t, matched, "version.String should match semantic versioning pattern (major.minor.patch), got: %s", String)
|
||||
}
|
||||
Reference in New Issue
Block a user