diff --git a/dive/filetree/comparer_test.go b/dive/filetree/comparer_test.go new file mode 100644 index 0000000..9e7209c --- /dev/null +++ b/dive/filetree/comparer_test.go @@ -0,0 +1,333 @@ +package filetree + +import ( + "fmt" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNewTreeIndexKey(t *testing.T) { + t.Run("all zeros", func(t *testing.T) { + key := NewTreeIndexKey(0, 0, 0, 0) + assert.Equal(t, 0, key.bottomTreeStart) + assert.Equal(t, 0, key.bottomTreeStop) + assert.Equal(t, 0, key.topTreeStart) + assert.Equal(t, 0, key.topTreeStop) + }) + + t.Run("with values", func(t *testing.T) { + key := NewTreeIndexKey(1, 2, 3, 4) + assert.Equal(t, 1, key.bottomTreeStart) + assert.Equal(t, 2, key.bottomTreeStop) + assert.Equal(t, 3, key.topTreeStart) + assert.Equal(t, 4, key.topTreeStop) + }) +} + +func TestTreeIndexKey_String(t *testing.T) { + t.Run("single layer on both sides", func(t *testing.T) { + key := NewTreeIndexKey(0, 0, 0, 0) + assert.Equal(t, "Index(0:0)", key.String()) + }) + + t.Run("single bottom, multiple top", func(t *testing.T) { + key := NewTreeIndexKey(0, 0, 0, 3) + assert.Equal(t, "Index(0:0-3)", key.String()) + }) + + t.Run("multiple bottom, single top", func(t *testing.T) { + key := NewTreeIndexKey(0, 3, 0, 0) + assert.Equal(t, "Index(0-3:0)", key.String()) + }) + + t.Run("multiple on both sides", func(t *testing.T) { + key := NewTreeIndexKey(0, 2, 3, 5) + assert.Equal(t, "Index(0-2:3-5)", key.String()) + }) + + t.Run("different ranges", func(t *testing.T) { + key := NewTreeIndexKey(1, 1, 2, 2) + assert.Equal(t, "Index(1:2)", key.String()) + }) +} + +func TestNewComparer(t *testing.T) { + t.Run("empty ref trees", func(t *testing.T) { + cmp := NewComparer([]*FileTree{}) + assert.NotNil(t, cmp) + assert.Empty(t, cmp.refTrees) + assert.Empty(t, cmp.trees) + assert.Empty(t, cmp.pathErrors) + }) + + t.Run("with ref trees", func(t *testing.T) { + trees := []*FileTree{ + {Id: uuid.New()}, + {Id: uuid.New()}, + } + cmp := NewComparer(trees) + assert.NotNil(t, cmp) + assert.Len(t, cmp.refTrees, 2) + assert.Empty(t, cmp.trees) + assert.Empty(t, cmp.pathErrors) + }) +} + +func TestComparer_NaturalIndexes(t *testing.T) { + t.Run("no trees", func(t *testing.T) { + cmp := NewComparer([]*FileTree{}) + indexes := make([]TreeIndexKey, 0) + + for idx := range cmp.NaturalIndexes() { + indexes = append(indexes, idx) + } + + assert.Empty(t, indexes) + }) + + t.Run("single tree", func(t *testing.T) { + trees := []*FileTree{{Id: uuid.New()}} + cmp := NewComparer(trees) + indexes := make([]TreeIndexKey, 0) + + for idx := range cmp.NaturalIndexes() { + indexes = append(indexes, idx) + } + + assert.Len(t, indexes, 1) + assert.Equal(t, NewTreeIndexKey(0, 0, 0, 0), indexes[0]) + }) + + t.Run("multiple trees", func(t *testing.T) { + trees := []*FileTree{ + {Id: uuid.New()}, + {Id: uuid.New()}, + {Id: uuid.New()}, + } + cmp := NewComparer(trees) + indexes := make([]TreeIndexKey, 0) + + for idx := range cmp.NaturalIndexes() { + indexes = append(indexes, idx) + } + + assert.Len(t, indexes, 3) + // Index 0: (0:0) + assert.Equal(t, NewTreeIndexKey(0, 0, 0, 0), indexes[0]) + // Index 1: (0:1) + assert.Equal(t, NewTreeIndexKey(0, 0, 1, 1), indexes[1]) + // Index 2: (0-1:2) + assert.Equal(t, NewTreeIndexKey(0, 1, 2, 2), indexes[2]) + }) +} + +func TestComparer_AggregatedIndexes(t *testing.T) { + t.Run("no trees", func(t *testing.T) { + cmp := NewComparer([]*FileTree{}) + indexes := make([]TreeIndexKey, 0) + + for idx := range cmp.AggregatedIndexes() { + indexes = append(indexes, idx) + } + + assert.Empty(t, indexes) + }) + + t.Run("single tree", func(t *testing.T) { + trees := []*FileTree{{Id: uuid.New()}} + cmp := NewComparer(trees) + indexes := make([]TreeIndexKey, 0) + + for idx := range cmp.AggregatedIndexes() { + indexes = append(indexes, idx) + } + + assert.Len(t, indexes, 1) + assert.Equal(t, NewTreeIndexKey(0, 0, 0, 0), indexes[0]) + }) + + t.Run("multiple trees", func(t *testing.T) { + trees := []*FileTree{ + {Id: uuid.New()}, + {Id: uuid.New()}, + {Id: uuid.New()}, + } + cmp := NewComparer(trees) + indexes := make([]TreeIndexKey, 0) + + for idx := range cmp.AggregatedIndexes() { + indexes = append(indexes, idx) + } + + assert.Len(t, indexes, 3) + // Index 0: (0:0) + assert.Equal(t, NewTreeIndexKey(0, 0, 0, 0), indexes[0]) + // Index 1: (0:1) - bottom stays at 0, top starts at 1 + assert.Equal(t, NewTreeIndexKey(0, 0, 1, 1), indexes[1]) + // Index 2: (0:1-2) + assert.Equal(t, NewTreeIndexKey(0, 0, 1, 2), indexes[2]) + }) +} + +func TestComparer_GetPathErrors(t *testing.T) { + t.Run("get path errors from comparer", func(t *testing.T) { + // Create simple ref trees + tree1 := NewFileTree() + tree1.Name = "tree1" + + tree2 := NewFileTree() + tree2.Name = "tree2" + + cmp := NewComparer([]*FileTree{tree1, tree2}) + + // Test getting path errors for a key + key := NewTreeIndexKey(0, 0, 0, 0) + + pathErrors, err := cmp.GetPathErrors(key) + + // Should not error (even if tree is empty) + assert.NoError(t, err) + assert.NotNil(t, pathErrors) + // Empty tree should have no path errors + assert.Empty(t, pathErrors) + }) +} + +func TestComparer_GetTree(t *testing.T) { + t.Run("get tree from comparer", func(t *testing.T) { + // Create simple ref trees + tree1 := NewFileTree() + tree1.Name = "tree1" + + tree2 := NewFileTree() + tree2.Name = "tree2" + + cmp := NewComparer([]*FileTree{tree1, tree2}) + + // Test getting tree for a key + key := NewTreeIndexKey(0, 0, 0, 0) + + resultTree, err := cmp.GetTree(key) + + // Should not error + assert.NoError(t, err) + assert.NotNil(t, resultTree) + }) + + t.Run("cached tree returns same instance", func(t *testing.T) { + tree1 := NewFileTree() + tree1.Name = "tree1" + + cmp := NewComparer([]*FileTree{tree1}) + key := NewTreeIndexKey(0, 0, 0, 0) + + // Call GetTree twice + tree1, err1 := cmp.GetTree(key) + tree2, err2 := cmp.GetTree(key) + + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.NotNil(t, tree1) + assert.NotNil(t, tree2) + // Should return the same cached instance + assert.Same(t, tree1, tree2) + }) +} + +func TestComparer_BuildCache(t *testing.T) { + t.Run("build cache with empty ref trees", func(t *testing.T) { + cmp := NewComparer([]*FileTree{}) + + errors := cmp.BuildCache() + + // Should not error + assert.Empty(t, errors) + }) + + t.Run("build cache with single tree", func(t *testing.T) { + tree := NewFileTree() + tree.Name = "tree1" + + cmp := NewComparer([]*FileTree{tree}) + + errors := cmp.BuildCache() + + // Should not error + assert.Empty(t, errors) + }) + + t.Run("build cache with multiple trees", func(t *testing.T) { + trees := []*FileTree{ + NewFileTree(), + NewFileTree(), + NewFileTree(), + } + + for i, tree := range trees { + tree.Name = fmt.Sprintf("tree%d", i) + } + + cmp := NewComparer(trees) + + errors := cmp.BuildCache() + + // Should not error + assert.Empty(t, errors) + }) +} + +func TestEfficiencySlice_Len(t *testing.T) { + t.Run("empty slice", func(t *testing.T) { + efs := make(EfficiencySlice, 0) + assert.Equal(t, 0, efs.Len()) + }) + + t.Run("non-empty slice", func(t *testing.T) { + efs := EfficiencySlice{ + &EfficiencyData{Path: "/path1"}, + &EfficiencyData{Path: "/path2"}, + } + assert.Equal(t, 2, efs.Len()) + }) +} + +func TestEfficiencySlice_Swap(t *testing.T) { + efs := EfficiencySlice{ + &EfficiencyData{Path: "/path1", CumulativeSize: 100}, + &EfficiencyData{Path: "/path2", CumulativeSize: 200}, + } + + efs.Swap(0, 1) + + assert.Equal(t, "/path2", efs[0].Path) + assert.Equal(t, int64(200), efs[0].CumulativeSize) + assert.Equal(t, "/path1", efs[1].Path) + assert.Equal(t, int64(100), efs[1].CumulativeSize) +} + +func TestEfficiencySlice_Less(t *testing.T) { + t.Run("first is smaller", func(t *testing.T) { + efs := EfficiencySlice{ + &EfficiencyData{Path: "/path1", CumulativeSize: 100}, + &EfficiencyData{Path: "/path2", CumulativeSize: 200}, + } + assert.True(t, efs.Less(0, 1)) + }) + + t.Run("second is smaller", func(t *testing.T) { + efs := EfficiencySlice{ + &EfficiencyData{Path: "/path1", CumulativeSize: 200}, + &EfficiencyData{Path: "/path2", CumulativeSize: 100}, + } + assert.False(t, efs.Less(0, 1)) + }) + + t.Run("equal sizes", func(t *testing.T) { + efs := EfficiencySlice{ + &EfficiencyData{Path: "/path1", CumulativeSize: 100}, + &EfficiencyData{Path: "/path2", CumulativeSize: 100}, + } + assert.False(t, efs.Less(0, 1)) + }) +} diff --git a/dive/filetree/diff_path_error_node_data_test.go b/dive/filetree/diff_path_error_node_data_test.go new file mode 100644 index 0000000..680a1a0 --- /dev/null +++ b/dive/filetree/diff_path_error_node_data_test.go @@ -0,0 +1,210 @@ +package filetree + +import ( + "errors" + "github.com/stretchr/testify/assert" + "testing" +) + +// Tests for diff.go + +func TestDiffType_String(t *testing.T) { + t.Run("Unmodified", func(t *testing.T) { + assert.Equal(t, "Unmodified", Unmodified.String()) + }) + + t.Run("Modified", func(t *testing.T) { + assert.Equal(t, "Modified", Modified.String()) + }) + + t.Run("Added", func(t *testing.T) { + assert.Equal(t, "Added", Added.String()) + }) + + t.Run("Removed", func(t *testing.T) { + assert.Equal(t, "Removed", Removed.String()) + }) + + t.Run("unknown value", func(t *testing.T) { + unknownDiff := DiffType(99) + assert.Equal(t, "99", unknownDiff.String()) + }) +} + +func TestDiffType_Merge(t *testing.T) { + t.Run("same values - Unmodified", func(t *testing.T) { + result := Unmodified.merge(Unmodified) + assert.Equal(t, Unmodified, result) + }) + + t.Run("same values - Modified", func(t *testing.T) { + result := Modified.merge(Modified) + assert.Equal(t, Modified, result) + }) + + t.Run("same values - Added", func(t *testing.T) { + result := Added.merge(Added) + assert.Equal(t, Added, result) + }) + + t.Run("same values - Removed", func(t *testing.T) { + result := Removed.merge(Removed) + assert.Equal(t, Removed, result) + }) + + t.Run("different values - Added and Removed", func(t *testing.T) { + result := Added.merge(Removed) + assert.Equal(t, Modified, result) + }) + + t.Run("different values - Unmodified and Added", func(t *testing.T) { + result := Unmodified.merge(Added) + assert.Equal(t, Modified, result) + }) + + t.Run("different values - Removed and Modified", func(t *testing.T) { + result := Removed.merge(Modified) + assert.Equal(t, Modified, result) + }) + + t.Run("different values - Added and Unmodified", func(t *testing.T) { + result := Added.merge(Unmodified) + assert.Equal(t, Modified, result) + }) +} + +// Tests for path_error.go + +func TestFileAction_String(t *testing.T) { + t.Run("ActionAdd", func(t *testing.T) { + assert.Equal(t, "add", ActionAdd.String()) + }) + + t.Run("ActionRemove", func(t *testing.T) { + assert.Equal(t, "remove", ActionRemove.String()) + }) + + t.Run("unknown value", func(t *testing.T) { + unknownAction := FileAction(99) + assert.Equal(t, "", unknownAction.String()) + }) +} + +func TestNewPathError(t *testing.T) { + t.Run("create path error with all fields", func(t *testing.T) { + err := errors.New("test error") + pathErr := NewPathError("/test/path", ActionAdd, err) + + assert.Equal(t, "/test/path", pathErr.Path) + assert.Equal(t, ActionAdd, pathErr.Action) + assert.Equal(t, err, pathErr.Err) + }) + + t.Run("create path error with remove action", func(t *testing.T) { + err := errors.New("remove error") + pathErr := NewPathError("/old/path", ActionRemove, err) + + assert.Equal(t, "/old/path", pathErr.Path) + assert.Equal(t, ActionRemove, pathErr.Action) + assert.Equal(t, err, pathErr.Err) + }) + + t.Run("create path error with nil error", func(t *testing.T) { + pathErr := NewPathError("/test/path", ActionAdd, nil) + + assert.Equal(t, "/test/path", pathErr.Path) + assert.Equal(t, ActionAdd, pathErr.Action) + assert.Nil(t, pathErr.Err) + }) +} + +func TestPathError_String(t *testing.T) { + t.Run("with add action", func(t *testing.T) { + err := errors.New("file not found") + pathErr := NewPathError("/test/file.txt", ActionAdd, err) + + expected := "unable to add '/test/file.txt': file not found" + assert.Equal(t, expected, pathErr.String()) + }) + + t.Run("with remove action", func(t *testing.T) { + err := errors.New("permission denied") + pathErr := NewPathError("/test/file.txt", ActionRemove, err) + + expected := "unable to remove '/test/file.txt': permission denied" + assert.Equal(t, expected, pathErr.String()) + }) + + t.Run("with nil error", func(t *testing.T) { + pathErr := NewPathError("/test/file.txt", ActionAdd, nil) + + expected := "unable to add '/test/file.txt': " + assert.Equal(t, expected, pathErr.String()) + }) + + t.Run("with complex path", func(t *testing.T) { + err := errors.New("disk full") + pathErr := NewPathError("/very/long/path/to/file.txt", ActionAdd, err) + + expected := "unable to add '/very/long/path/to/file.txt': disk full" + assert.Equal(t, expected, pathErr.String()) + }) +} + +// Tests for node_data.go + +func TestNewNodeData(t *testing.T) { + t.Run("creates new node data with defaults", func(t *testing.T) { + data := NewNodeData() + + assert.NotNil(t, data) + assert.Equal(t, Unmodified, data.DiffType) + // ViewInfo and FileInfo should have their zero values + assert.NotNil(t, data) + }) +} + +func TestNodeData_Copy(t *testing.T) { + t.Run("copy node data", func(t *testing.T) { + original := NewNodeData() + original.DiffType = Added + original.FileInfo.Size = 1024 + + copied := original.Copy() + + assert.NotNil(t, copied) + assert.Equal(t, original.DiffType, copied.DiffType) + assert.Equal(t, original.FileInfo.Size, copied.FileInfo.Size) + + // Verify it's a deep copy + copied.DiffType = Modified + assert.Equal(t, Added, original.DiffType) + }) + + t.Run("copy creates new instance", func(t *testing.T) { + original := NewNodeData() + original.DiffType = Removed + original.FileInfo.Path = "/test/path" + + copied := original.Copy() + + // Pointers should be different + assert.NotSame(t, original, copied) + + // But values should be the same + assert.Equal(t, original.DiffType, copied.DiffType) + assert.Equal(t, original.FileInfo.Path, copied.FileInfo.Path) + }) + + t.Run("copy with unmodified diff type", func(t *testing.T) { + original := NewNodeData() + original.DiffType = Unmodified + original.FileInfo.Size = 2048 + + copied := original.Copy() + + assert.Equal(t, Unmodified, copied.DiffType) + assert.NotSame(t, original, copied) + assert.Equal(t, int64(2048), copied.FileInfo.Size) + }) +} diff --git a/dive/filetree/file_info_test.go b/dive/filetree/file_info_test.go new file mode 100644 index 0000000..2ad6837 --- /dev/null +++ b/dive/filetree/file_info_test.go @@ -0,0 +1,401 @@ +package filetree + +import ( + "archive/tar" + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewFileInfoFromTarHeader(t *testing.T) { + t.Run("regular file", func(t *testing.T) { + // Create a tar header for a regular file + header := &tar.Header{ + Name: "test.txt", + Typeflag: tar.TypeReg, + Size: 1024, + Mode: 0644, + Uid: 1000, + Gid: 1000, + Linkname: "", + } + + // Create a reader with some content + content := []byte("hello world") + reader := tar.NewReader(bytes.NewReader(content)) + + // Advance reader to setup (normally done by tar.Next()) + // We need to manually set up the reader state + result := NewFileInfoFromTarHeader(reader, header, "test.txt") + + assert.Equal(t, "test.txt", result.Path) + assert.Equal(t, byte(tar.TypeReg), result.TypeFlag) + assert.Equal(t, int64(1024), result.Size) + assert.Equal(t, 1000, result.Uid) + assert.Equal(t, 1000, result.Gid) + assert.False(t, result.IsDir) + assert.NotEqual(t, uint64(0), result.hash) // hash should be computed + // Don't check Mode as it can be platform-dependent + }) + + t.Run("directory", func(t *testing.T) { + header := &tar.Header{ + Name: "testdir", + Typeflag: tar.TypeDir, + Size: 0, + Mode: 0755, + Uid: 1000, + Gid: 1000, + } + + reader := tar.NewReader(bytes.NewReader([]byte{})) + result := NewFileInfoFromTarHeader(reader, header, "testdir") + + assert.Equal(t, "testdir", result.Path) + assert.Equal(t, byte(tar.TypeDir), result.TypeFlag) + assert.Equal(t, int64(0), result.Size) + assert.True(t, result.IsDir) + assert.Equal(t, uint64(0), result.hash) // directories have no hash + }) + + t.Run("symlink", func(t *testing.T) { + header := &tar.Header{ + Name: "link.txt", + Typeflag: tar.TypeSymlink, + Size: 0, + Mode: 0777, + Linkname: "target.txt", + } + + reader := tar.NewReader(bytes.NewReader([]byte{})) + result := NewFileInfoFromTarHeader(reader, header, "link.txt") + + assert.Equal(t, "link.txt", result.Path) + assert.Equal(t, byte(tar.TypeSymlink), result.TypeFlag) + assert.Equal(t, "target.txt", result.Linkname) + assert.False(t, result.IsDir) + // Note: current implementation computes hash for symlinks (it should only skip dirs) + // The hash will be the xxhash of empty content since reader is empty + assert.NotEqual(t, uint64(0), result.hash) + }) + + t.Run("custom path", func(t *testing.T) { + header := &tar.Header{ + Name: "original/name.txt", + Typeflag: tar.TypeReg, + Size: 512, + Mode: 0600, + } + + content := []byte("test content") + reader := tar.NewReader(bytes.NewReader(content)) + + result := NewFileInfoFromTarHeader(reader, header, "custom/path.txt") + + assert.Equal(t, "custom/path.txt", result.Path) + assert.Equal(t, int64(512), result.Size) + }) +} + +func TestFileInfo_Copy(t *testing.T) { + t.Run("copy file info", func(t *testing.T) { + original := FileInfo{ + Path: "/test/file.txt", + TypeFlag: byte(tar.TypeReg), + Linkname: "", + hash: 12345, + Size: 1024, + Mode: 0644, + Uid: 1000, + Gid: 1000, + IsDir: false, + } + + copied := original.Copy() + + assert.NotNil(t, copied) + assert.Equal(t, original.Path, copied.Path) + assert.Equal(t, original.TypeFlag, copied.TypeFlag) + assert.Equal(t, original.Linkname, copied.Linkname) + assert.Equal(t, original.hash, copied.hash) + assert.Equal(t, original.Size, copied.Size) + assert.Equal(t, original.Mode, copied.Mode) + assert.Equal(t, original.Uid, copied.Uid) + assert.Equal(t, original.Gid, copied.Gid) + assert.Equal(t, original.IsDir, copied.IsDir) + + // Verify it's a different instance + assert.NotSame(t, &original, copied) + }) + + t.Run("copy nil file info", func(t *testing.T) { + var original *FileInfo + copied := original.Copy() + + assert.Nil(t, copied) + }) + + t.Run("copy directory info", func(t *testing.T) { + original := FileInfo{ + Path: "/test/dir", + TypeFlag: byte(tar.TypeDir), + IsDir: true, + Size: 0, + } + + copied := original.Copy() + + assert.NotNil(t, copied) + assert.Equal(t, original.Path, copied.Path) + assert.True(t, copied.IsDir) + }) + + t.Run("modifying copy doesn't affect original", func(t *testing.T) { + original := FileInfo{ + Path: "/test/file.txt", + Size: 1024, + hash: 12345, + } + + copied := original.Copy() + copied.Size = 2048 + copied.hash = 54321 + + assert.Equal(t, int64(1024), original.Size) + assert.Equal(t, uint64(12345), original.hash) + assert.Equal(t, int64(2048), copied.Size) + assert.Equal(t, uint64(54321), copied.hash) + }) +} + +func TestFileInfo_Compare(t *testing.T) { + t.Run("identical files", func(t *testing.T) { + info1 := FileInfo{ + TypeFlag: byte(tar.TypeReg), + hash: 12345, + Mode: 0644, + Uid: 1000, + Gid: 1000, + } + + info2 := FileInfo{ + TypeFlag: byte(tar.TypeReg), + hash: 12345, + Mode: 0644, + Uid: 1000, + Gid: 1000, + } + + result := info1.Compare(info2) + assert.Equal(t, Unmodified, result) + }) + + t.Run("different type flag", func(t *testing.T) { + info1 := FileInfo{ + TypeFlag: byte(tar.TypeReg), + hash: 12345, + Mode: 0644, + Uid: 1000, + Gid: 1000, + } + + info2 := FileInfo{ + TypeFlag: byte(tar.TypeDir), + hash: 12345, + Mode: 0644, + Uid: 1000, + Gid: 1000, + } + + result := info1.Compare(info2) + assert.Equal(t, Modified, result) + }) + + t.Run("different hash", func(t *testing.T) { + info1 := FileInfo{ + TypeFlag: byte(tar.TypeReg), + hash: 12345, + Mode: 0644, + Uid: 1000, + Gid: 1000, + } + + info2 := FileInfo{ + TypeFlag: byte(tar.TypeReg), + hash: 54321, + Mode: 0644, + Uid: 1000, + Gid: 1000, + } + + result := info1.Compare(info2) + assert.Equal(t, Modified, result) + }) + + t.Run("different mode", func(t *testing.T) { + info1 := FileInfo{ + TypeFlag: byte(tar.TypeReg), + hash: 12345, + Mode: 0644, + Uid: 1000, + Gid: 1000, + } + + info2 := FileInfo{ + TypeFlag: byte(tar.TypeReg), + hash: 12345, + Mode: 0755, + Uid: 1000, + Gid: 1000, + } + + result := info1.Compare(info2) + assert.Equal(t, Modified, result) + }) + + t.Run("different uid", func(t *testing.T) { + info1 := FileInfo{ + TypeFlag: byte(tar.TypeReg), + hash: 12345, + Mode: 0644, + Uid: 1000, + Gid: 1000, + } + + info2 := FileInfo{ + TypeFlag: byte(tar.TypeReg), + hash: 12345, + Mode: 0644, + Uid: 2000, + Gid: 1000, + } + + result := info1.Compare(info2) + assert.Equal(t, Modified, result) + }) +} + +func TestGetHashFromReader(t *testing.T) { + t.Run("hash of empty reader", func(t *testing.T) { + reader := bytes.NewReader([]byte{}) + hash := getHashFromReader(reader) + + assert.Equal(t, uint64(17241709254077376921), hash) // xxhash of empty string + }) + + t.Run("hash of simple string", func(t *testing.T) { + reader := bytes.NewReader([]byte("hello world")) + hash := getHashFromReader(reader) + + assert.NotEqual(t, uint64(0), hash) + // Verify consistency + hash2 := getHashFromReader(bytes.NewReader([]byte("hello world"))) + assert.Equal(t, hash, hash2) + }) + + t.Run("hash of binary data", func(t *testing.T) { + data := []byte{0x00, 0x01, 0x02, 0x03, 0x04} + reader := bytes.NewReader(data) + hash := getHashFromReader(reader) + + assert.NotEqual(t, uint64(0), hash) + }) + + t.Run("hash of large data", func(t *testing.T) { + // Create data larger than buffer size (1024 bytes) + largeData := make([]byte, 2048) + for i := range largeData { + largeData[i] = byte(i % 256) + } + reader := bytes.NewReader(largeData) + hash := getHashFromReader(reader) + + assert.NotEqual(t, uint64(0), hash) + }) + + t.Run("different content produces different hash", func(t *testing.T) { + hash1 := getHashFromReader(bytes.NewReader([]byte("content1"))) + hash2 := getHashFromReader(bytes.NewReader([]byte("content2"))) + + assert.NotEqual(t, hash1, hash2) + }) + + t.Run("same content produces same hash", func(t *testing.T) { + content := []byte("same content") + hash1 := getHashFromReader(bytes.NewReader(content)) + hash2 := getHashFromReader(bytes.NewReader(content)) + + assert.Equal(t, hash1, hash2) + }) +} + +func TestNewFileInfo(t *testing.T) { + t.Run("create regular file info", func(t *testing.T) { + // Create a temporary file + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "test.txt") + err := os.WriteFile(filePath, []byte("test content"), 0644) + require.NoError(t, err) + + info, err := os.Stat(filePath) + require.NoError(t, err) + + fileInfo := NewFileInfo(filePath, "test.txt", info) + + assert.Equal(t, "test.txt", fileInfo.Path) + assert.Equal(t, byte(tar.TypeReg), fileInfo.TypeFlag) + assert.Equal(t, int64(12), fileInfo.Size) // "test content" is 12 bytes + assert.False(t, fileInfo.IsDir) + assert.Equal(t, -1, fileInfo.Uid) // UID/GID not supported, set to -1 + assert.Equal(t, -1, fileInfo.Gid) + assert.NotEqual(t, uint64(0), fileInfo.hash) // hash should be computed + // Mode may have additional bits set on different systems, just check it's not zero + assert.NotEqual(t, os.FileMode(0), fileInfo.Mode) + }) + + t.Run("create directory info", func(t *testing.T) { + tmpDir := t.TempDir() + dirPath := filepath.Join(tmpDir, "testdir") + err := os.Mkdir(dirPath, 0755) + require.NoError(t, err) + + info, err := os.Stat(dirPath) + require.NoError(t, err) + + fileInfo := NewFileInfo(dirPath, "testdir", info) + + assert.Equal(t, "testdir", fileInfo.Path) + assert.Equal(t, byte(tar.TypeDir), fileInfo.TypeFlag) + assert.True(t, fileInfo.IsDir) + assert.Equal(t, uint64(0), fileInfo.hash) // directories have no hash + // Check that directory mode has dir bit set + assert.True(t, fileInfo.Mode&os.ModeDir != 0) + }) + + t.Run("create symlink info", func(t *testing.T) { + tmpDir := t.TempDir() + targetPath := filepath.Join(tmpDir, "target.txt") + err := os.WriteFile(targetPath, []byte("target"), 0644) + require.NoError(t, err) + + linkPath := filepath.Join(tmpDir, "link.txt") + err = os.Symlink("target.txt", linkPath) + require.NoError(t, err) + + info, err := os.Lstat(linkPath) + require.NoError(t, err) + + fileInfo := NewFileInfo(linkPath, "link.txt", info) + + assert.Equal(t, "link.txt", fileInfo.Path) + assert.Equal(t, byte(tar.TypeSymlink), fileInfo.TypeFlag) + assert.Equal(t, "target.txt", fileInfo.Linkname) + assert.False(t, fileInfo.IsDir) + // Note: current implementation computes hash for symlinks (from the target file content) + assert.NotEqual(t, uint64(0), fileInfo.hash) + }) +} diff --git a/dive/filetree/file_tree_test.go b/dive/filetree/file_tree_test.go index d4fc4a0..bf22e51 100644 --- a/dive/filetree/file_tree_test.go +++ b/dive/filetree/file_tree_test.go @@ -843,3 +843,297 @@ func TestRemoveOnIterate(t *testing.T) { } } + +func TestVisibleSize(t *testing.T) { + t.Run("empty tree", func(t *testing.T) { + tree := NewFileTree() + size := tree.VisibleSize() + // Empty tree has only root, size-- makes it -1 + assert.Equal(t, -1, size) + }) + + t.Run("all visible nodes", func(t *testing.T) { + tree := NewFileTree() + paths := []string{"/dir", "/dir/file1.txt", "/dir/file2.txt", "/other"} + + for _, path := range paths { + fakeData := FileInfo{ + Path: path, + TypeFlag: 1, + hash: 123, + Size: 100, + } + if path == "/dir" { + fakeData.IsDir = true + } + _, _, err := tree.AddPath(path, fakeData) + assert.NoError(t, err) + } + + size := tree.VisibleSize() + // Should count all nodes except root: /dir, /dir/file1.txt, /dir/file2.txt, /other - 1(root) = 3 + assert.Equal(t, 3, size) + }) + + t.Run("with hidden nodes", func(t *testing.T) { + tree := NewFileTree() + paths := []string{"/dir", "/dir/file1.txt", "/dir/file2.txt"} + + for _, path := range paths { + fakeData := FileInfo{ + Path: path, + TypeFlag: 1, + hash: 123, + } + if path == "/dir" { + fakeData.IsDir = true + } + node, _, err := tree.AddPath(path, fakeData) + assert.NoError(t, err) + + // Hide /dir/file2.txt + if path == "/dir/file2.txt" { + node.Data.ViewInfo.Hidden = true + } + } + + size := tree.VisibleSize() + // Should count only visible nodes: /dir, /dir/file1.txt - 1(root) = 1 (file2.txt is hidden) + assert.Equal(t, 1, size) + }) + + t.Run("with collapsed directory", func(t *testing.T) { + tree := NewFileTree() + paths := []string{"/dir", "/dir/file1.txt", "/dir/file2.txt", "/other"} + + for _, path := range paths { + fakeData := FileInfo{ + Path: path, + TypeFlag: 1, + hash: 123, + } + if path == "/dir" { + fakeData.IsDir = true + } + node, _, err := tree.AddPath(path, fakeData) + assert.NoError(t, err) + + // Collapse /dir + if path == "/dir" { + node.Data.ViewInfo.Collapsed = true + } + } + + size := tree.VisibleSize() + // Should count: /dir (collapsed, counted but children not), /other - 1(root) = 1 + assert.Equal(t, 1, size) + }) + + t.Run("with hidden directory", func(t *testing.T) { + tree := NewFileTree() + paths := []string{"/dir", "/dir/file1.txt", "/other"} + + for _, path := range paths { + fakeData := FileInfo{ + Path: path, + TypeFlag: 1, + hash: 123, + } + if path == "/dir" { + fakeData.IsDir = true + } + node, _, err := tree.AddPath(path, fakeData) + assert.NoError(t, err) + + // Hide /dir (should hide children too) + if path == "/dir" { + node.Data.ViewInfo.Hidden = true + } + } + + size := tree.VisibleSize() + // Should count only: /other - 1(root) = 0 (dir and its children are hidden) + assert.Equal(t, 0, size) + }) + + t.Run("complex tree with mixed visibility", func(t *testing.T) { + tree := NewFileTree() + paths := []string{ + "/dir1", + "/dir1/file1.txt", + "/dir1/file2.txt", + "/dir2", + "/dir2/file3.txt", + "/other", + } + + for _, path := range paths { + fakeData := FileInfo{ + Path: path, + TypeFlag: 1, + hash: 123, + } + if path == "/dir1" || path == "/dir2" { + fakeData.IsDir = true + } + node, _, err := tree.AddPath(path, fakeData) + assert.NoError(t, err) + + // Hide /dir1/file2.txt + if path == "/dir1/file2.txt" { + node.Data.ViewInfo.Hidden = true + } + // Collapse /dir2 + if path == "/dir2" { + node.Data.ViewInfo.Collapsed = true + } + } + + size := tree.VisibleSize() + // Should count: /dir1, /dir1/file1.txt, /dir2 (collapsed), /other - 1(root) = 3 + // /dir1/file2.txt is hidden, /dir2/file3.txt not counted because dir2 is collapsed + assert.Equal(t, 3, size) + }) +} + +func TestVisitDepthParentFirst(t *testing.T) { + t.Run("visits nodes parent first", func(t *testing.T) { + tree := NewFileTree() + paths := []string{"/dir", "/dir/file1.txt", "/dir/file2.txt"} + + for _, path := range paths { + fakeData := FileInfo{ + Path: path, + TypeFlag: 1, + hash: 123, + } + if path == "/dir" { + fakeData.IsDir = true + } + _, _, err := tree.AddPath(path, fakeData) + assert.NoError(t, err) + } + + var visited []string + visitor := func(node *FileNode) error { + visited = append(visited, node.Path()) + return nil + } + evaluator := func(node *FileNode) bool { + return true + } + + err := tree.VisitDepthParentFirst(visitor, evaluator) + assert.NoError(t, err) + + // Parent should be visited before children + // Order should be: root, /dir, /dir/file1.txt, /dir/file2.txt + assert.Greater(t, len(visited), 0) + // Find indices + var dirIdx, file1Idx, file2Idx int + for i, path := range visited { + if path == "/dir" { + dirIdx = i + } else if path == "/dir/file1.txt" { + file1Idx = i + } else if path == "/dir/file2.txt" { + file2Idx = i + } + } + + // Parent should be visited before children + assert.Less(t, dirIdx, file1Idx) + assert.Less(t, dirIdx, file2Idx) + }) + + t.Run("respects evaluator", func(t *testing.T) { + tree := NewFileTree() + paths := []string{"/dir", "/dir/file1.txt", "/dir/file2.txt"} + + for _, path := range paths { + fakeData := FileInfo{ + Path: path, + TypeFlag: 1, + hash: 123, + } + if path == "/dir" { + fakeData.IsDir = true + } + _, _, err := tree.AddPath(path, fakeData) + assert.NoError(t, err) + } + + var visited []string + visitor := func(node *FileNode) error { + visited = append(visited, node.Path()) + return nil + } + // Don't visit /dir/file2.txt + evaluator := func(node *FileNode) bool { + return node.Path() != "/dir/file2.txt" + } + + err := tree.VisitDepthParentFirst(visitor, evaluator) + assert.NoError(t, err) + + // /dir/file2.txt should not be visited + assert.NotContains(t, visited, "/dir/file2.txt") + // But /dir should be visited (parent) + assert.Contains(t, visited, "/dir") + }) + + t.Run("handles empty tree", func(t *testing.T) { + tree := NewFileTree() + + visited := false + visitor := func(node *FileNode) error { + visited = true + return nil + } + evaluator := func(node *FileNode) bool { + return true + } + + err := tree.VisitDepthParentFirst(visitor, evaluator) + assert.NoError(t, err) + // Empty tree has no nodes to visit (root exists but has no children) + // The visitor will not be called for an empty tree + assert.False(t, visited) + }) + + t.Run("visitor error stops iteration", func(t *testing.T) { + tree := NewFileTree() + paths := []string{"/dir", "/dir/file1.txt", "/dir/file2.txt"} + + for _, path := range paths { + fakeData := FileInfo{ + Path: path, + TypeFlag: 1, + hash: 123, + } + if path == "/dir" { + fakeData.IsDir = true + } + _, _, err := tree.AddPath(path, fakeData) + assert.NoError(t, err) + } + + visitCount := 0 + visitor := func(node *FileNode) error { + visitCount++ + if node.Path() == "/dir/file1.txt" { + return assert.AnError + } + return nil + } + evaluator := func(node *FileNode) bool { + return true + } + + err := tree.VisitDepthParentFirst(visitor, evaluator) + assert.Error(t, err) + // Should stop after error + assert.LessOrEqual(t, visitCount, 3) // root, /dir, /dir/file1.txt + }) +} + diff --git a/dive/filetree/order_strategy_test.go b/dive/filetree/order_strategy_test.go new file mode 100644 index 0000000..32ac6e4 --- /dev/null +++ b/dive/filetree/order_strategy_test.go @@ -0,0 +1,240 @@ +package filetree + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGetSortOrderStrategy(t *testing.T) { + t.Run("ByName strategy", func(t *testing.T) { + strategy := GetSortOrderStrategy(ByName) + assert.IsType(t, orderByNameStrategy{}, strategy) + }) + + t.Run("BySizeDesc strategy", func(t *testing.T) { + strategy := GetSortOrderStrategy(BySizeDesc) + assert.IsType(t, orderBySizeDescStrategy{}, strategy) + }) + + t.Run("invalid value defaults to ByName", func(t *testing.T) { + strategy := GetSortOrderStrategy(SortOrder(99)) + assert.IsType(t, orderByNameStrategy{}, strategy) + }) +} + +func TestOrderByNameStrategy_OrderKeys(t *testing.T) { + t.Run("empty map", func(t *testing.T) { + strategy := orderByNameStrategy{} + files := make(map[string]*FileNode) + + result := strategy.orderKeys(files) + + assert.Empty(t, result) + }) + + t.Run("single file", func(t *testing.T) { + strategy := orderByNameStrategy{} + files := map[string]*FileNode{ + "file.txt": {}, + } + + result := strategy.orderKeys(files) + + assert.Len(t, result, 1) + assert.Equal(t, "file.txt", result[0]) + }) + + t.Run("multiple files sorted alphabetically", func(t *testing.T) { + strategy := orderByNameStrategy{} + files := map[string]*FileNode{ + "zebra.txt": {}, + "apple.txt": {}, + "banana.txt": {}, + } + + result := strategy.orderKeys(files) + + assert.Equal(t, []string{"apple.txt", "banana.txt", "zebra.txt"}, result) + }) + + t.Run("files with similar names", func(t *testing.T) { + strategy := orderByNameStrategy{} + files := map[string]*FileNode{ + "file1.txt": {}, + "file2.txt": {}, + "file10.txt": {}, + "file20.txt": {}, + } + + result := strategy.orderKeys(files) + + // Lexicographic sort (not numeric) + assert.Equal(t, []string{"file1.txt", "file10.txt", "file2.txt", "file20.txt"}, result) + }) + + t.Run("files with paths", func(t *testing.T) { + strategy := orderByNameStrategy{} + files := map[string]*FileNode{ + "/usr/bin/file": {}, + "/etc/config": {}, + "/var/log/app": {}, + "/home/user/doc": {}, + } + + result := strategy.orderKeys(files) + + assert.Equal(t, []string{"/etc/config", "/home/user/doc", "/usr/bin/file", "/var/log/app"}, result) + }) + + t.Run("case sensitive sorting", func(t *testing.T) { + strategy := orderByNameStrategy{} + files := map[string]*FileNode{ + "FILE.TXT": {}, + "file.txt": {}, + "File.Txt": {}, + } + + result := strategy.orderKeys(files) + + // Uppercase comes before lowercase in ASCII + assert.Equal(t, []string{"FILE.TXT", "File.Txt", "file.txt"}, result) + }) +} + +func TestOrderBySizeDescStrategy_OrderKeys(t *testing.T) { + t.Run("empty map", func(t *testing.T) { + strategy := orderBySizeDescStrategy{} + files := make(map[string]*FileNode) + + result := strategy.orderKeys(files) + + assert.Empty(t, result) + }) + + t.Run("single file", func(t *testing.T) { + strategy := orderBySizeDescStrategy{} + node := &FileNode{} + node.Size = 1024 + files := map[string]*FileNode{ + "file.txt": node, + } + + result := strategy.orderKeys(files) + + assert.Len(t, result, 1) + assert.Equal(t, "file.txt", result[0]) + }) + + t.Run("multiple files sorted by size descending", func(t *testing.T) { + strategy := orderBySizeDescStrategy{} + + smallNode := &FileNode{} + smallNode.Size = 100 + + mediumNode := &FileNode{} + mediumNode.Size = 500 + + largeNode := &FileNode{} + largeNode.Size = 1000 + + files := map[string]*FileNode{ + "small.txt": smallNode, + "large.txt": largeNode, + "medium.txt": mediumNode, + } + + result := strategy.orderKeys(files) + + assert.Equal(t, []string{"large.txt", "medium.txt", "small.txt"}, result) + }) + + t.Run("files with same size sorted alphabetically", func(t *testing.T) { + strategy := orderBySizeDescStrategy{} + + node1 := &FileNode{} + node1.Size = 500 + + node2 := &FileNode{} + node2.Size = 500 + + node3 := &FileNode{} + node3.Size = 500 + + files := map[string]*FileNode{ + "zebra.txt": node1, + "apple.txt": node2, + "banana.txt": node3, + } + + result := strategy.orderKeys(files) + + assert.Equal(t, []string{"apple.txt", "banana.txt", "zebra.txt"}, result) + }) + + t.Run("files with zero size", func(t *testing.T) { + strategy := orderBySizeDescStrategy{} + + zeroNode1 := &FileNode{} + zeroNode1.Size = 0 + + zeroNode2 := &FileNode{} + zeroNode2.Size = 0 + + smallNode := &FileNode{} + smallNode.Size = 100 + + files := map[string]*FileNode{ + "file1.txt": zeroNode1, + "file2.txt": zeroNode2, + "file3.txt": smallNode, + } + + result := strategy.orderKeys(files) + + // file3.txt comes first (100 bytes), then file1.txt and file2.txt (both 0 bytes, alphabetically) + assert.Equal(t, []string{"file3.txt", "file1.txt", "file2.txt"}, result) + }) + + t.Run("large and small files mixed", func(t *testing.T) { + strategy := orderBySizeDescStrategy{} + + // Create explicit files with known sizes + nodeA := &FileNode{} + nodeA.Size = 10 + + nodeB := &FileNode{} + nodeB.Size = 1000 + + nodeC := &FileNode{} + nodeC.Size = 500 + + nodeD := &FileNode{} + nodeD.Size = 50 + + nodeE := &FileNode{} + nodeE.Size = 5000 + + nodeF := &FileNode{} + nodeF.Size = 100 + + nodes := map[string]*FileNode{ + "a.txt": nodeA, + "b.txt": nodeB, + "c.txt": nodeC, + "d.txt": nodeD, + "e.txt": nodeE, + "f.txt": nodeF, + } + + result := strategy.orderKeys(nodes) + + // Verify descending order by size + sizesResult := make([]int64, len(result)) + for i, key := range result { + sizesResult[i] = nodes[key].Size + } + + // Check that sizes are in descending order + assert.Equal(t, []int64{5000, 1000, 500, 100, 50, 10}, sizesResult) + }) +} diff --git a/dive/get_image_resolver_test.go b/dive/get_image_resolver_test.go new file mode 100644 index 0000000..6380b30 --- /dev/null +++ b/dive/get_image_resolver_test.go @@ -0,0 +1,172 @@ +package dive + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wagoodman/dive/dive/image" + "testing" +) + +func TestImageSource_String(t *testing.T) { + t.Run("SourceUnknown", func(t *testing.T) { + assert.Equal(t, "unknown", SourceUnknown.String()) + }) + + t.Run("SourceDockerEngine", func(t *testing.T) { + assert.Equal(t, "docker", SourceDockerEngine.String()) + }) + + t.Run("SourcePodmanEngine", func(t *testing.T) { + assert.Equal(t, "podman", SourcePodmanEngine.String()) + }) + + t.Run("SourceDockerArchive", func(t *testing.T) { + assert.Equal(t, "docker-archive", SourceDockerArchive.String()) + }) +} + +func TestParseImageSource(t *testing.T) { + t.Run("parse docker", func(t *testing.T) { + result := ParseImageSource("docker") + assert.Equal(t, SourceDockerEngine, result) + }) + + t.Run("parse podman", func(t *testing.T) { + result := ParseImageSource("podman") + assert.Equal(t, SourcePodmanEngine, result) + }) + + t.Run("parse docker-archive", func(t *testing.T) { + result := ParseImageSource("docker-archive") + assert.Equal(t, SourceDockerArchive, result) + }) + + t.Run("parse docker-tar alias", func(t *testing.T) { + result := ParseImageSource("docker-tar") + assert.Equal(t, SourceDockerArchive, result) + }) + + t.Run("parse unknown source", func(t *testing.T) { + result := ParseImageSource("unknown") + assert.Equal(t, SourceUnknown, result) + }) + + t.Run("parse invalid source", func(t *testing.T) { + result := ParseImageSource("invalid-source") + assert.Equal(t, SourceUnknown, result) + }) + + t.Run("parse empty string", func(t *testing.T) { + result := ParseImageSource("") + assert.Equal(t, SourceUnknown, result) + }) +} + +func TestDeriveImageSource(t *testing.T) { + t.Run("docker scheme", func(t *testing.T) { + source, img := DeriveImageSource("docker://my-image:tag") + assert.Equal(t, SourceDockerEngine, source) + assert.Equal(t, "my-image:tag", img) + }) + + t.Run("podman scheme", func(t *testing.T) { + source, img := DeriveImageSource("podman://my-image:tag") + assert.Equal(t, SourcePodmanEngine, source) + assert.Equal(t, "my-image:tag", img) + }) + + t.Run("docker-archive scheme", func(t *testing.T) { + // Note: DeriveImageSource may not support docker-archive scheme + // This test documents current behavior + source, img := DeriveImageSource("docker-archive:/path/to/image.tar") + // Current implementation returns SourceUnknown for docker-archive + assert.Equal(t, SourceUnknown, source) + assert.Equal(t, "", img) + }) + + t.Run("docker-tar scheme alias", func(t *testing.T) { + // Note: DeriveImageSource may not support docker-tar scheme + // This test documents current behavior + source, img := DeriveImageSource("docker-tar:/path/to/image.tar") + // Current implementation returns SourceUnknown for docker-tar + assert.Equal(t, SourceUnknown, source) + assert.Equal(t, "", img) + }) + + t.Run("no scheme", func(t *testing.T) { + source, img := DeriveImageSource("my-image:tag") + assert.Equal(t, SourceUnknown, source) + assert.Equal(t, "", img) + }) + + t.Run("unknown scheme", func(t *testing.T) { + source, img := DeriveImageSource("unknown://my-image:tag") + assert.Equal(t, SourceUnknown, source) + assert.Equal(t, "", img) + }) + + t.Run("scheme with multiple colons", func(t *testing.T) { + source, img := DeriveImageSource("docker://my-image:tag:latest") + assert.Equal(t, SourceDockerEngine, source) + assert.Equal(t, "my-image:tag:latest", img) + }) + + t.Run("empty string", func(t *testing.T) { + source, img := DeriveImageSource("") + assert.Equal(t, SourceUnknown, source) + assert.Equal(t, "", img) + }) +} + +func TestGetImageResolver(t *testing.T) { + t.Run("docker engine resolver", func(t *testing.T) { + resolver, err := GetImageResolver(SourceDockerEngine) + require.NoError(t, err) + assert.NotNil(t, resolver) + // Check that resolver implements the interface + assert.Implements(t, (*image.Resolver)(nil), resolver) + }) + + t.Run("podman engine resolver", func(t *testing.T) { + resolver, err := GetImageResolver(SourcePodmanEngine) + require.NoError(t, err) + assert.NotNil(t, resolver) + // Check that resolver implements the interface + assert.Implements(t, (*image.Resolver)(nil), resolver) + }) + + t.Run("docker archive resolver", func(t *testing.T) { + resolver, err := GetImageResolver(SourceDockerArchive) + require.NoError(t, err) + assert.NotNil(t, resolver) + // Check that resolver implements the interface + assert.Implements(t, (*image.Resolver)(nil), resolver) + }) + + t.Run("unknown source returns error", func(t *testing.T) { + resolver, err := GetImageResolver(SourceUnknown) + assert.Error(t, err) + assert.Nil(t, resolver) + assert.Contains(t, err.Error(), "unable to determine image resolver") + }) + + t.Run("invalid source returns error", func(t *testing.T) { + invalidSource := ImageSource(99) + resolver, err := GetImageResolver(invalidSource) + assert.Error(t, err) + assert.Nil(t, resolver) + assert.Contains(t, err.Error(), "unable to determine image resolver") + }) +} + +func TestImageSources(t *testing.T) { + t.Run("contains all valid sources", func(t *testing.T) { + assert.Contains(t, ImageSources, "docker") + assert.Contains(t, ImageSources, "podman") + assert.Contains(t, ImageSources, "docker-archive") + }) + + t.Run("does not contain unknown", func(t *testing.T) { + assert.NotContains(t, ImageSources, "unknown") + }) +} diff --git a/dive/image/docker/archive_resolver_test.go b/dive/image/docker/archive_resolver_test.go new file mode 100644 index 0000000..f1f729d --- /dev/null +++ b/dive/image/docker/archive_resolver_test.go @@ -0,0 +1,329 @@ +package docker + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewResolverFromArchive(t *testing.T) { + t.Run("create new resolver", func(t *testing.T) { + resolver := NewResolverFromArchive() + + assert.NotNil(t, resolver) + assert.IsType(t, &archiveResolver{}, resolver) + }) +} + +func TestArchiveResolver_Name(t *testing.T) { + t.Run("resolver name", func(t *testing.T) { + resolver := NewResolverFromArchive() + + name := resolver.Name() + + assert.Equal(t, "docker-archive", name) + }) +} + +func TestArchiveResolver_Build(t *testing.T) { + t.Run("build not supported", func(t *testing.T) { + resolver := NewResolverFromArchive() + ctx := context.Background() + args := []string{"build", "args"} + + img, err := resolver.Build(ctx, args) + + assert.Nil(t, img) + assert.Error(t, err) + assert.Contains(t, err.Error(), "build option not supported") + }) +} + +func TestArchiveResolver_Extract(t *testing.T) { + t.Run("extract not implemented", func(t *testing.T) { + resolver := NewResolverFromArchive() + ctx := context.Background() + + err := resolver.Extract(ctx, "id", "layer", "path") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not implemented") + }) +} + +func TestArchiveResolver_Fetch(t *testing.T) { + t.Run("file does not exist", func(t *testing.T) { + resolver := NewResolverFromArchive() + ctx := context.Background() + nonExistentPath := "/non/existent/path.tar" + + img, err := resolver.Fetch(ctx, nonExistentPath) + + assert.Nil(t, img) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no such file or directory") + }) + + t.Run("fetch from valid archive", func(t *testing.T) { + // Create a simple Docker archive tar file for testing + tmpFile, err := os.CreateTemp("", "docker-archive-*.tar") + assert.NoError(t, err) + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + tmpFile.Close() + + // Create a minimal tar file with manifest.json + // Note: This is a simplified test - in reality, a proper Docker archive + // would have manifest.json, layer files, etc. + // For this test, we're just checking that Fetch properly opens the file + // and calls NewImageArchive (which will fail with an invalid archive) + + resolver := NewResolverFromArchive() + ctx := context.Background() + + img, err := resolver.Fetch(ctx, tmpPath) + + // NewImageArchive will fail with an empty/invalid tar file + assert.Nil(t, img) + assert.Error(t, err) + // The error should come from NewImageArchive, not from os.Open + assert.NotContains(t, err.Error(), "no such file or directory") + }) + + t.Run("fetch with empty path", func(t *testing.T) { + resolver := NewResolverFromArchive() + ctx := context.Background() + + img, err := resolver.Fetch(ctx, "") + + assert.Nil(t, img) + assert.Error(t, err) + }) + + t.Run("fetch from directory calls os.Exit", func(t *testing.T) { + // Note: NewImageArchive calls os.Exit(1) on error, which will kill the test + // This test documents that behavior - we cannot test it directly + // because it would kill the test runner + resolver := NewResolverFromArchive() + ctx := context.Background() + + // We can't test this case because NewImageArchive calls os.Exit(1) + // when encountering a directory, which would terminate the test runner + tmpDir := t.TempDir() + + // This will cause os.Exit(1) to be called, so we skip this test + t.Skip("NewImageArchive calls os.Exit(1) on error, which would kill the test runner") + + _, _ = resolver.Fetch(ctx, tmpDir) + }) +} + +func TestArchiveResolver_Integration(t *testing.T) { + // This test verifies that the resolver implements the Resolver interface correctly + t.Run("resolver implements interface", func(t *testing.T) { + resolver := NewResolverFromArchive() + + // Verify the resolver has the correct type + assert.IsType(t, &archiveResolver{}, resolver) + + // Verify all methods exist and return expected types + ctx := context.Background() + + // Name() should return string + name := resolver.Name() + assert.IsType(t, "", name) + + // Build() should return error + img, err := resolver.Build(ctx, []string{}) + assert.Nil(t, img) + assert.Error(t, err) + + // Extract() should return error + err = resolver.Extract(ctx, "id", "layer", "path") + assert.Error(t, err) + + // Fetch() with invalid path should return error + img, err = resolver.Fetch(ctx, "/invalid/path") + assert.Nil(t, img) + assert.Error(t, err) + }) +} + +func TestArchiveResolver_ErrorMessages(t *testing.T) { + t.Run("build error message", func(t *testing.T) { + resolver := NewResolverFromArchive() + ctx := context.Background() + + _, err := resolver.Build(ctx, []string{}) + + assert.Error(t, err) + expectedMsg := "build option not supported for docker archive resolver" + assert.Equal(t, expectedMsg, err.Error()) + }) + + t.Run("extract error message", func(t *testing.T) { + resolver := NewResolverFromArchive() + ctx := context.Background() + + err := resolver.Extract(ctx, "id", "layer", "path") + + assert.Error(t, err) + expectedMsg := "not implemented" + assert.Equal(t, expectedMsg, err.Error()) + }) +} + +func TestArchiveResolver_FetchErrors(t *testing.T) { + t.Run("verify file is closed on error", func(t *testing.T) { + // Note: NewImageArchive calls os.Exit(1) on tar parsing errors + // This prevents us from testing file handle cleanup in the normal way + t.Skip("NewImageArchive calls os.Exit(1) on tar errors, which would kill the test runner") + + resolver := NewResolverFromArchive() + ctx := context.Background() + + // Create a temp file with invalid content (will fail in NewImageArchive) + tmpFile, err := os.CreateTemp("", "docker-archive-*.tar") + assert.NoError(t, err) + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + // Write some invalid content + _, err = tmpFile.WriteString("invalid tar content") + assert.NoError(t, err) + tmpFile.Close() + + // Fetch should fail, but the file should be closed + img, err := resolver.Fetch(ctx, tmpPath) + + assert.Nil(t, img) + assert.Error(t, err) + + // Verify we can open the file again (meaning it was properly closed) + _, err = os.Open(tmpPath) + assert.NoError(t, err) + }) + + t.Run("verify context cancellation is respected", func(t *testing.T) { + // Note: The current implementation doesn't check context cancellation + // This test documents the current behavior + resolver := NewResolverFromArchive() + + // Create a cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Try to fetch with cancelled context + // Current implementation will still try to open the file + _, err := resolver.Fetch(ctx, "/non/existent/path") + + assert.Error(t, err) + }) +} + +func TestArchiveResolver_MultipleInstances(t *testing.T) { + t.Run("multiple resolvers are independent", func(t *testing.T) { + resolver1 := NewResolverFromArchive() + resolver2 := NewResolverFromArchive() + + // They should be different instances + assert.NotSame(t, resolver1, resolver2) + + // But have the same name + assert.Equal(t, resolver1.Name(), resolver2.Name()) + }) +} + +func TestArchiveResolver_NilSafety(t *testing.T) { + t.Run("methods work on nil receiver", func(t *testing.T) { + // This test checks if methods can handle nil receiver + // Note: In Go, calling methods on nil pointers is valid as long as + // the method doesn't dereference the receiver + var resolver *archiveResolver + + // Name() doesn't dereference the receiver, so it won't panic + name := resolver.Name() + assert.Equal(t, "docker-archive", name) + + // Build and Extract also don't dereference the receiver + ctx := context.Background() + _, err := resolver.Build(ctx, []string{}) + assert.Error(t, err) + + err = resolver.Extract(ctx, "id", "layer", "path") + assert.Error(t, err) + }) +} + +func TestArchiveResolver_FetchWithInvalidArchive(t *testing.T) { + t.Run("fetch with corrupted archive", func(t *testing.T) { + // Note: NewImageArchive calls os.Exit(1) on tar parsing errors + // which prevents testing with corrupted archives + t.Skip("NewImageArchive calls os.Exit(1) on tar errors, which would kill the test runner") + + resolver := NewResolverFromArchive() + ctx := context.Background() + + // Create a file with corrupted content + tmpFile, err := os.CreateTemp("", "docker-archive-*.tar") + assert.NoError(t, err) + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + // Write random bytes (not a valid tar) + _, err = tmpFile.Write([]byte{0xFF, 0xFE, 0xFD, 0xFC}) + assert.NoError(t, err) + tmpFile.Close() + + img, err := resolver.Fetch(ctx, tmpPath) + + assert.Nil(t, img) + assert.Error(t, err) + // Should be an error from NewImageArchive or ToImage + assert.NotContains(t, err.Error(), "no such file or directory") + }) +} + +func TestArchiveResolver_ContextVariants(t *testing.T) { + t.Run("fetch with different context types", func(t *testing.T) { + resolver := NewResolverFromArchive() + + // Test with background context + img1, err1 := resolver.Fetch(context.Background(), "/non/existent") + assert.Nil(t, img1) + assert.Error(t, err1) + + // Test with TODO context + img2, err2 := resolver.Fetch(context.TODO(), "/non/existent") + assert.Nil(t, img2) + assert.Error(t, err2) + + // Both should return similar errors + assert.Contains(t, err1.Error(), "no such file") + assert.Contains(t, err2.Error(), "no such file") + }) + + t.Run("build and extract ignore context", func(t *testing.T) { + resolver := NewResolverFromArchive() + + // Build doesn't use the context + _, err1 := resolver.Build(context.Background(), []string{}) + assert.Error(t, err1) + + _, err2 := resolver.Build(context.TODO(), []string{}) + assert.Error(t, err2) + + assert.Equal(t, err1.Error(), err2.Error()) + + // Extract doesn't use the context + err3 := resolver.Extract(context.Background(), "id", "layer", "path") + assert.Error(t, err3) + + err4 := resolver.Extract(context.TODO(), "id", "layer", "path") + assert.Error(t, err4) + + assert.Equal(t, err3.Error(), err4.Error()) + }) +} diff --git a/dive/image/docker/cli_test.go b/dive/image/docker/cli_test.go new file mode 100644 index 0000000..0590a5c --- /dev/null +++ b/dive/image/docker/cli_test.go @@ -0,0 +1,410 @@ +package docker + +import ( + "os" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsDockerClientBinaryAvailable(t *testing.T) { + t.Run("docker is available", func(t *testing.T) { + // This test checks if docker binary is in PATH + // It will pass if Docker is installed, fail otherwise + available := isDockerClientBinaryAvailable() + + // If docker is in PATH, this should return true + // We can't guarantee Docker is installed on all systems + // so we just check the function doesn't panic + if available { + // Docker is available + _, err := exec.LookPath("docker") + assert.NoError(t, err, "docker should be findable via LookPath") + } else { + // Docker is not available + _, err := exec.LookPath("docker") + assert.Error(t, err, "docker should not be findable via LookPath") + } + }) + + t.Run("function returns boolean", func(t *testing.T) { + // Verify the function returns a boolean value + available := isDockerClientBinaryAvailable() + assert.IsType(t, false, available) + }) + + t.Run("multiple calls return same result", func(t *testing.T) { + // Multiple calls should return the same result + result1 := isDockerClientBinaryAvailable() + result2 := isDockerClientBinaryAvailable() + result3 := isDockerClientBinaryAvailable() + + assert.Equal(t, result1, result2) + assert.Equal(t, result2, result3) + }) +} + +func TestRunDockerCmd(t *testing.T) { + t.Run("error when docker not available", func(t *testing.T) { + // This test verifies behavior when docker is not available + // Since we can't easily mock exec.LookPath, we test the error message + + // Save original PATH + originalPath := os.Getenv("PATH") + + // Set PATH to empty to make docker unavailable + os.Unsetenv("PATH") + defer func() { + // Restore PATH + os.Setenv("PATH", originalPath) + }() + + // Even with empty PATH, some systems might still find docker + // So we'll just verify the function can be called + err := runDockerCmd("--version") + + // Either docker is not found (expected error) or it's found + if err != nil { + // Docker not found + assert.Contains(t, err.Error(), "cannot find docker client executable") + } + // If docker was found, we can't test this case properly + }) + + t.Run("constructs correct command", func(t *testing.T) { + // This test verifies that runDockerCmd constructs the correct command + // Since we can't easily mock exec.Command, we'll test with a non-destructive command + + available := isDockerClientBinaryAvailable() + + if !available { + t.Skip("Docker not available, skipping integration test") + } + + // Test with --version which should always succeed if docker is installed + err := runDockerCmd("--version") + + // --version should succeed + assert.NoError(t, err, "docker --version should succeed if docker is available") + }) + + t.Run("handles command with arguments", func(t *testing.T) { + available := isDockerClientBinaryAvailable() + + if !available { + t.Skip("Docker not available, skipping integration test") + } + + // Test with a command that has arguments + // Using 'info' which should work on any Docker installation + err := runDockerCmd("info", "--format", "{{.ServerVersion}}") + + // This should succeed if docker is available + // Note: info might fail if docker daemon is not running, but that's ok + // We're testing that the command is constructed correctly + _ = err // We don't assert on error since docker daemon might not be running + }) + + t.Run("handles empty arguments", func(t *testing.T) { + available := isDockerClientBinaryAvailable() + + if !available { + t.Skip("Docker not available, skipping integration test") + } + + // Test with empty arguments - Docker shows help and exits successfully + // runDockerCmd requires at least one argument (cmdStr) + err := runDockerCmd("") + + // Docker shows help when called with empty string, exits successfully + // So we don't expect an error + _ = err // Don't assert, just verify it doesn't panic + }) + + t.Run("cleans arguments", func(t *testing.T) { + // Test that arguments are properly cleaned (whitespace trimmed) + // This is hard to test without mocking, but we can verify it doesn't panic + available := isDockerClientBinaryAvailable() + + if !available { + t.Skip("Docker not available, skipping integration test") + } + + // These arguments will be cleaned by utils.CleanArgs + err := runDockerCmd(" --version ") + + // Should succeed (whitespace should be trimmed) + assert.NoError(t, err) + }) +} + +func TestRunDockerCmd_ErrorCases(t *testing.T) { + t.Run("invalid command should error", func(t *testing.T) { + available := isDockerClientBinaryAvailable() + + if !available { + t.Skip("Docker not available, skipping integration test") + } + + // Test with an invalid docker command + err := runDockerCmd("invalid-command-that-does-not-exist") + + // Should return an error + assert.Error(t, err) + }) + + t.Run("command with invalid flags should error", func(t *testing.T) { + available := isDockerClientBinaryAvailable() + + if !available { + t.Skip("Docker not available, skipping integration test") + } + + // Test a valid command with invalid flags + err := runDockerCmd("--version", "--invalid-flag") + + // Should return an error + assert.Error(t, err) + }) +} + +func TestRunDockerCmd_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration tests in short mode") + } + + available := isDockerClientBinaryAvailable() + if !available { + t.Skip("Docker not available, skipping integration tests") + } + + t.Run("docker --version", func(t *testing.T) { + err := runDockerCmd("--version") + assert.NoError(t, err) + }) + + t.Run("docker help", func(t *testing.T) { + err := runDockerCmd("help") + assert.NoError(t, err) + }) +} + +func TestDockerCommandConstruction(t *testing.T) { + // This test verifies that the docker command is constructed correctly + // We can't directly test the construction without modifying the code + // But we can test that the function handles various argument formats + + t.Run("handles single command", func(t *testing.T) { + available := isDockerClientBinaryAvailable() + if !available { + t.Skip("Docker not available") + } + + err := runDockerCmd("version") + _ = err // Don't assert, just verify it doesn't panic + }) + + t.Run("handles command with multiple arguments", func(t *testing.T) { + available := isDockerClientBinaryAvailable() + if !available { + t.Skip("Docker not available") + } + + err := runDockerCmd("image", "ls", "--format", "{{.Repository}}") + _ = err // Don't assert, just verify it doesn't panic + }) +} + +func TestIsDockerClientBinaryAvailable_EdgeCases(t *testing.T) { + t.Run("handles repeated calls", func(t *testing.T) { + // Test that repeated calls don't have side effects + results := make([]bool, 10) + for i := 0; i < 10; i++ { + results[i] = isDockerClientBinaryAvailable() + } + + // All results should be the same + firstResult := results[0] + for _, result := range results { + assert.Equal(t, firstResult, result) + } + }) + + t.Run("result is consistent with exec.LookPath", func(t *testing.T) { + // Verify that isDockerClientBinaryAvailable is consistent with exec.LookPath + available := isDockerClientBinaryAvailable() + _, err := exec.LookPath("docker") + + if available { + assert.NoError(t, err, "isDockerClientBinaryAvailable returned true but LookPath failed") + } else { + assert.Error(t, err, "isDockerClientBinaryAvailable returned false but LookPath succeeded") + } + }) +} + +func TestRunDockerCmd_Stdio(t *testing.T) { + t.Run("stdio connections", func(t *testing.T) { + // This test verifies that runDockerCmd properly connects stdio + // We can't directly test this without running a command + // But we can verify the function completes without hanging + + available := isDockerClientBinaryAvailable() + if !available { + t.Skip("Docker not available") + } + + // Run a command that uses stdin/stdout/stderr + // --version is a good choice as it's fast and always works + err := runDockerCmd("--version") + + if err == nil { + // Command succeeded, stdio was properly connected + assert.NoError(t, err) + } + // If err != nil, docker might not be properly configured, but that's ok + }) +} + +func TestDockerArgsCleaning(t *testing.T) { + t.Run("arguments with whitespace are cleaned", func(t *testing.T) { + available := isDockerClientBinaryAvailable() + if !available { + t.Skip("Docker not available") + } + + // The function should use utils.CleanArgs which trims whitespace + // Test with arguments that have leading/trailing whitespace + err := runDockerCmd(" --version ") + assert.NoError(t, err) + }) +} + +func TestRunDockerCmd_CommandString(t *testing.T) { + // This test verifies the command string construction + // The full command is: docker + cmdStr + args + + t.Run("command has docker prefix", func(t *testing.T) { + available := isDockerClientBinaryAvailable() + if !available { + t.Skip("Docker not available") + } + + // The function should construct: docker --version + err := runDockerCmd("--version") + assert.NoError(t, err) + }) + + t.Run("command with args has correct format", func(t *testing.T) { + available := isDockerClientBinaryAvailable() + if !available { + t.Skip("Docker not available") + } + + // The function should construct: docker image ls + err := runDockerCmd("image", "ls") + _ = err // Don't assert, daemon might not be running + }) +} + +func TestIsDockerClientBinaryAvailable_System(t *testing.T) { + t.Run("detects docker in PATH", func(t *testing.T) { + // Check if docker is in common PATH locations + path := os.Getenv("PATH") + pathDirs := strings.Split(path, string(os.PathListSeparator)) + + dockerFound := false + for _, dir := range pathDirs { + dockerPath := dir + string(os.PathSeparator) + "docker" + if _, err := os.Stat(dockerPath); err == nil { + dockerFound = true + break + } + } + + available := isDockerClientBinaryAvailable() + + if dockerFound { + assert.True(t, available, "Docker found in PATH but isDockerClientBinaryAvailable returned false") + } else { + assert.False(t, available, "Docker not found in PATH but isDockerClientBinaryAvailable returned true") + } + }) +} + +func TestRunDockerCmd_ErrorMessages(t *testing.T) { + t.Run("returns specific error when docker not found", func(t *testing.T) { + // Save original PATH + originalPath := os.Getenv("PATH") + defer os.Setenv("PATH", originalPath) + + // Set PATH to a directory that doesn't contain docker + os.Setenv("PATH", "/nonexistent/path") + + err := runDockerCmd("--version") + + // Should get the specific error message + if err != nil { + assert.Contains(t, err.Error(), "cannot find docker client executable") + } + }) +} + +func TestRunDockerCmd_NoPanic(t *testing.T) { + // These tests verify that runDockerCmd doesn't panic with various inputs + + testCases := []struct { + name string + args []string + }{ + {"empty string command", []string{""}}, + {"single argument", []string{"--version"}}, + {"multiple arguments", []string{"image", "ls"}}, + {"arguments with spaces", []string{" image ", " ls "}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Just verify it doesn't panic + require.NotPanics(t, func() { + // tc.args[0] is the cmdStr, tc.args[1:] are additional args + if len(tc.args) > 0 { + cmdStr := tc.args[0] + args := tc.args[1:] + err := runDockerCmd(cmdStr, args...) + _ = err // Ignore errors, we're just testing for panics + } + }) + }) + } +} + +func TestIsDockerClientBinaryAvailable_NoPanic(t *testing.T) { + t.Run("does not panic", func(t *testing.T) { + // Verify the function doesn't panic + assert.NotPanics(t, func() { + _ = isDockerClientBinaryAvailable() + }) + }) + + t.Run("concurrent calls", func(t *testing.T) { + // Verify multiple concurrent calls don't cause issues + results := make(chan bool, 10) + + for i := 0; i < 10; i++ { + go func() { + results <- isDockerClientBinaryAvailable() + }() + } + + // Collect results + for i := 0; i < 10; i++ { + <-results + } + + // If we got here without deadlock or panic, the test passes + }) +} diff --git a/dive/image/docker/config_manifest_test.go b/dive/image/docker/config_manifest_test.go new file mode 100644 index 0000000..61f1a38 --- /dev/null +++ b/dive/image/docker/config_manifest_test.go @@ -0,0 +1,287 @@ +package docker + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestIsConfig(t *testing.T) { + t.Run("valid config with layers type", func(t *testing.T) { + configJSON := `{ + "history": [], + "rootfs": { + "type": "layers", + "diff_ids": [] + } + }` + result := isConfig([]byte(configJSON)) + assert.True(t, result) + }) + + t.Run("config with different rootfs type", func(t *testing.T) { + configJSON := `{ + "history": [], + "rootfs": { + "type": "not-layers", + "diff_ids": [] + } + }` + result := isConfig([]byte(configJSON)) + assert.False(t, result) + }) + + t.Run("invalid JSON", func(t *testing.T) { + invalidJSON := `{invalid json}` + result := isConfig([]byte(invalidJSON)) + assert.False(t, result) + }) + + t.Run("empty JSON object", func(t *testing.T) { + emptyJSON := `{}` + result := isConfig([]byte(emptyJSON)) + assert.False(t, result) + }) + + t.Run("missing rootfs field", func(t *testing.T) { + configJSON := `{"history": []}` + result := isConfig([]byte(configJSON)) + assert.False(t, result) + }) + + t.Run("config with history entries", func(t *testing.T) { + configJSON := `{ + "history": [ + { + "created": "2023-01-01T00:00:00Z", + "author": "test", + "created_by": "test command", + "empty_layer": false + } + ], + "rootfs": { + "type": "layers", + "diff_ids": ["sha256:abc123"] + } + }` + result := isConfig([]byte(configJSON)) + assert.True(t, result) + }) +} + +func TestNewConfig(t *testing.T) { + t.Run("valid config with non-empty layers", func(t *testing.T) { + configJSON := `{ + "history": [ + { + "created": "2023-01-01T00:00:00Z", + "created_by": "CMD /bin/sh", + "empty_layer": false + }, + { + "created": "2023-01-01T00:00:01Z", + "created_by": "RUN apt-get update", + "empty_layer": false + } + ], + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:layer1", + "sha256:layer2" + ] + } + }` + config := newConfig([]byte(configJSON)) + + require.Len(t, config.History, 2) + assert.Equal(t, "sha256:layer1", config.History[0].ID) + assert.Equal(t, "sha256:layer2", config.History[1].ID) + assert.False(t, config.History[0].EmptyLayer) + assert.False(t, config.History[1].EmptyLayer) + }) + + t.Run("config with empty layers", func(t *testing.T) { + configJSON := `{ + "history": [ + { + "created": "2023-01-01T00:00:00Z", + "created_by": "FROM scratch", + "empty_layer": true + }, + { + "created": "2023-01-01T00:00:01Z", + "created_by": "RUN echo test", + "empty_layer": false + } + ], + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:layer1" + ] + } + }` + config := newConfig([]byte(configJSON)) + + require.Len(t, config.History, 2) + assert.Equal(t, "", config.History[0].ID) + assert.True(t, config.History[0].EmptyLayer) + assert.Equal(t, "sha256:layer1", config.History[1].ID) + assert.False(t, config.History[1].EmptyLayer) + }) + + t.Run("config with mixed empty and non-empty layers", func(t *testing.T) { + configJSON := `{ + "history": [ + {"empty_layer": true}, + {"empty_layer": false}, + {"empty_layer": true}, + {"empty_layer": false} + ], + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:layer1", + "sha256:layer2" + ] + } + }` + config := newConfig([]byte(configJSON)) + + require.Len(t, config.History, 4) + assert.Equal(t, "", config.History[0].ID) + assert.Equal(t, "sha256:layer1", config.History[1].ID) + assert.Equal(t, "", config.History[2].ID) + assert.Equal(t, "sha256:layer2", config.History[3].ID) + }) + + t.Run("empty history and rootfs", func(t *testing.T) { + configJSON := `{ + "history": [], + "rootfs": { + "type": "layers", + "diff_ids": [] + } + }` + config := newConfig([]byte(configJSON)) + + require.Len(t, config.History, 0) + require.Len(t, config.RootFs.DiffIds, 0) + }) + + t.Run("invalid JSON panics", func(t *testing.T) { + invalidJSON := `{invalid json}` + assert.Panics(t, func() { + newConfig([]byte(invalidJSON)) + }) + }) + + t.Run("config with all history fields", func(t *testing.T) { + configJSON := `{ + "history": [ + { + "id": "original-id", + "size": 1024, + "created": "2023-01-01T00:00:00Z", + "author": "test@example.com", + "created_by": "CMD /bin/bash", + "empty_layer": false + } + ], + "rootfs": { + "type": "layers", + "diff_ids": ["sha256:abc123"] + } + }` + config := newConfig([]byte(configJSON)) + + require.Len(t, config.History, 1) + // ID should be replaced by diff_id + assert.Equal(t, "sha256:abc123", config.History[0].ID) + assert.Equal(t, uint64(1024), config.History[0].Size) + assert.Equal(t, "2023-01-01T00:00:00Z", config.History[0].Created) + assert.Equal(t, "test@example.com", config.History[0].Author) + assert.Equal(t, "CMD /bin/bash", config.History[0].CreatedBy) + }) +} + +func TestNewManifest(t *testing.T) { + t.Run("valid manifest", func(t *testing.T) { + manifestJSON := `[{ + "Config": "sha256:config.json", + "RepoTags": ["test:latest"], + "Layers": ["layer1.tar", "layer2.tar"] + }]` + manifest := newManifest([]byte(manifestJSON)) + + assert.Equal(t, "sha256:config.json", manifest.ConfigPath) + assert.Equal(t, []string{"test:latest"}, manifest.RepoTags) + assert.Equal(t, []string{"layer1.tar", "layer2.tar"}, manifest.LayerTarPaths) + }) + + t.Run("manifest with multiple entries (returns first)", func(t *testing.T) { + manifestJSON := `[{ + "Config": "config1.json", + "RepoTags": ["test:1"], + "Layers": ["layer1.tar"] + }, { + "Config": "config2.json", + "RepoTags": ["test:2"], + "Layers": ["layer2.tar"] + }]` + manifest := newManifest([]byte(manifestJSON)) + + assert.Equal(t, "config1.json", manifest.ConfigPath) + assert.Equal(t, []string{"test:1"}, manifest.RepoTags) + }) + + t.Run("manifest with empty arrays", func(t *testing.T) { + manifestJSON := `[{ + "Config": "config.json", + "RepoTags": [], + "Layers": [] + }]` + manifest := newManifest([]byte(manifestJSON)) + + assert.Equal(t, "config.json", manifest.ConfigPath) + assert.Empty(t, manifest.RepoTags) + assert.Empty(t, manifest.LayerTarPaths) + }) + + t.Run("manifest with multiple repo tags", func(t *testing.T) { + manifestJSON := `[{ + "Config": "config.json", + "RepoTags": ["test:latest", "test:v1.0", "myrepo:test"], + "Layers": ["layer1.tar"] + }]` + manifest := newManifest([]byte(manifestJSON)) + + assert.Equal(t, []string{"test:latest", "test:v1.0", "myrepo:test"}, manifest.RepoTags) + }) + + t.Run("invalid JSON panics", func(t *testing.T) { + invalidJSON := `[invalid json]` + assert.Panics(t, func() { + newManifest([]byte(invalidJSON)) + }) + }) + + t.Run("empty manifest array panics", func(t *testing.T) { + emptyJSON := `[]` + assert.Panics(t, func() { + newManifest([]byte(emptyJSON)) + }) + }) + + t.Run("manifest with only required fields", func(t *testing.T) { + manifestJSON := `[{ + "Config": "config.json" + }]` + manifest := newManifest([]byte(manifestJSON)) + + assert.Equal(t, "config.json", manifest.ConfigPath) + assert.Nil(t, manifest.RepoTags) + assert.Nil(t, manifest.LayerTarPaths) + }) +} diff --git a/internal/utils/format_test.go b/internal/utils/format_test.go new file mode 100644 index 0000000..bff7e3f --- /dev/null +++ b/internal/utils/format_test.go @@ -0,0 +1,67 @@ +package utils + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestCleanArgs(t *testing.T) { + t.Run("basic trimming", func(t *testing.T) { + input := []string{" hello ", " world", "test "} + expected := []string{"hello", "world", "test"} + result := CleanArgs(input) + assert.Equal(t, expected, result) + }) + + t.Run("empty strings are removed", func(t *testing.T) { + input := []string{"hello", "", "world", ""} + expected := []string{"hello", "world"} + result := CleanArgs(input) + assert.Equal(t, expected, result) + }) + + t.Run("whitespace only strings are kept but trimmed", func(t *testing.T) { + // Note: CleanArgs only removes empty strings (""), not whitespace-only strings + // It only trims spaces, not tabs + input := []string{" ", " ", "hello", " "} + expected := []string{"", "", "hello", ""} + result := CleanArgs(input) + assert.Equal(t, expected, result) + }) + + t.Run("empty slice", func(t *testing.T) { + input := []string{} + var expected []string // nil + result := CleanArgs(input) + assert.Equal(t, expected, result) + }) + + t.Run("nil slice", func(t *testing.T) { + var input []string + var expected []string // nil + result := CleanArgs(input) + assert.Equal(t, expected, result) + }) + + t.Run("all empty strings", func(t *testing.T) { + input := []string{"", "", ""} + var expected []string // nil - all empty strings are removed + result := CleanArgs(input) + assert.Equal(t, expected, result) + }) + + t.Run("no trimming needed", func(t *testing.T) { + input := []string{"hello", "world"} + expected := []string{"hello", "world"} + result := CleanArgs(input) + assert.Equal(t, expected, result) + }) + + t.Run("only spaces are trimmed, not tabs", func(t *testing.T) { + // Note: CleanArgs only trims spaces (" "), not tabs or other whitespace + input := []string{" hello ", " world ", "\thello\t"} + expected := []string{"hello", "world", "\thello\t"} + result := CleanArgs(input) + assert.Equal(t, expected, result) + }) +} diff --git a/internal/utils/view_test.go b/internal/utils/view_test.go new file mode 100644 index 0000000..f9f8109 --- /dev/null +++ b/internal/utils/view_test.go @@ -0,0 +1,49 @@ +package utils + +import ( + "errors" + "github.com/awesome-gocui/gocui" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestIsNewView(t *testing.T) { + t.Run("all ErrUnknownView returns true", func(t *testing.T) { + errs := []error{gocui.ErrUnknownView, gocui.ErrUnknownView} + result := IsNewView(errs...) + assert.True(t, result) + }) + + t.Run("single ErrUnknownView returns true", func(t *testing.T) { + result := IsNewView(gocui.ErrUnknownView) + assert.True(t, result) + }) + + t.Run("nil error returns false", func(t *testing.T) { + result := IsNewView(nil) + assert.False(t, result) + }) + + t.Run("mix of nil and ErrUnknownView returns false", func(t *testing.T) { + errs := []error{nil, gocui.ErrUnknownView} + result := IsNewView(errs...) + assert.False(t, result) + }) + + t.Run("other error returns true", func(t *testing.T) { + customErr := errors.New("custom error") + result := IsNewView(customErr) + assert.True(t, result) + }) + + t.Run("mix of errors returns true", func(t *testing.T) { + errs := []error{gocui.ErrUnknownView, errors.New("custom error")} + result := IsNewView(errs...) + assert.True(t, result) + }) + + t.Run("no errors returns true", func(t *testing.T) { + result := IsNewView() + assert.True(t, result) + }) +}