mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 11:31:46 +00:00
Merge branch 'main' into dev
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -69,5 +69,10 @@ tests/tmp-*/
|
||||
tests/*.tmp
|
||||
tests/*.log
|
||||
|
||||
# Go test coverage files
|
||||
*.out
|
||||
coverage.out
|
||||
coverage.html
|
||||
|
||||
session.json
|
||||
run_tests.ps1
|
||||
|
||||
@@ -274,13 +274,9 @@ Real feedback from users who shared Mole on X.
|
||||
|
||||
- If Mole helped you, star the repo or [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends.
|
||||
- Got ideas or found bugs? Check the [Contributing Guide](CONTRIBUTING.md) and open an issue or PR.
|
||||
- Like Mole? <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">Buy Tw93 a Coke</a> to support the project! 🥤
|
||||
- Like Mole? <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">Buy Tw93 a Coke</a> to support the project! 🥤 Supporters below.
|
||||
|
||||
<details>
|
||||
<summary><strong>Friends who bought me Coke</strong></summary>
|
||||
<br/>
|
||||
<a href="https://miaoyan.app/cats.html?name=Mole"><img src="https://miaoyan.app/assets/sponsors.svg" width="1000" /></a>
|
||||
</details>
|
||||
<a href="https://miaoyan.app/cats.html?name=Mole"><img src="https://miaoyan.app/assets/sponsors.svg" width="1000" loading="lazy" /></a>
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -142,10 +142,6 @@ cleanup() {
|
||||
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
printf "\r\033[K" >&2 || true
|
||||
fi
|
||||
|
||||
cleanup_temp_files
|
||||
|
||||
stop_sudo_session
|
||||
@@ -601,7 +597,7 @@ safe_clean() {
|
||||
fi
|
||||
|
||||
if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" ]]; then
|
||||
stop_section_spinner
|
||||
stop_inline_spinner
|
||||
fi
|
||||
|
||||
local permission_end=${MOLE_PERMISSION_DENIED_COUNT:-0}
|
||||
|
||||
@@ -119,14 +119,44 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
size := getActualFileSize(fullPath, info)
|
||||
atomic.AddInt64(&total, size)
|
||||
|
||||
entryChan <- dirEntry{
|
||||
// Reuse timer to reduce GC pressure
|
||||
timer := time.NewTimer(0)
|
||||
// Ensure timer is drained immediately since we start with 0
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
Name: child.Name() + " →",
|
||||
Path: fullPath,
|
||||
Size: size,
|
||||
IsDir: isDir,
|
||||
LastAccess: getLastAccessTimeFromInfo(info),
|
||||
}:
|
||||
default:
|
||||
// If channel is full, use timer to wait with timeout
|
||||
timer.Reset(100 * time.Millisecond)
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
Name: child.Name() + " →",
|
||||
Path: fullPath,
|
||||
Size: size,
|
||||
IsDir: isDir,
|
||||
LastAccess: getLastAccessTimeFromInfo(info),
|
||||
}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
// Skip if channel is blocked
|
||||
}
|
||||
}
|
||||
continue
|
||||
|
||||
}
|
||||
|
||||
if child.IsDir() {
|
||||
@@ -158,12 +188,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
atomic.AddInt64(&total, size)
|
||||
atomic.AddInt64(dirsScanned, 1)
|
||||
|
||||
entryChan <- dirEntry{
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: size,
|
||||
IsDir: true,
|
||||
LastAccess: time.Time{},
|
||||
}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}(child.Name(), fullPath)
|
||||
continue
|
||||
@@ -188,12 +225,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
atomic.AddInt64(&total, size)
|
||||
atomic.AddInt64(dirsScanned, 1)
|
||||
|
||||
entryChan <- dirEntry{
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: size,
|
||||
IsDir: true,
|
||||
LastAccess: time.Time{},
|
||||
}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}(child.Name(), fullPath)
|
||||
continue
|
||||
@@ -209,12 +253,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
atomic.AddInt64(&total, size)
|
||||
atomic.AddInt64(dirsScanned, 1)
|
||||
|
||||
entryChan <- dirEntry{
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: size,
|
||||
IsDir: true,
|
||||
LastAccess: time.Time{},
|
||||
}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}(child.Name(), fullPath)
|
||||
continue
|
||||
@@ -230,18 +281,35 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
atomic.AddInt64(filesScanned, 1)
|
||||
atomic.AddInt64(bytesScanned, size)
|
||||
|
||||
entryChan <- dirEntry{
|
||||
// Single-use timer for main loop (less pressure than tight loop above)
|
||||
// But let's be consistent and optimized
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
select {
|
||||
case entryChan <- dirEntry{
|
||||
Name: child.Name(),
|
||||
Path: fullPath,
|
||||
Size: size,
|
||||
IsDir: false,
|
||||
LastAccess: getLastAccessTimeFromInfo(info),
|
||||
}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
// Track large files only.
|
||||
if !shouldSkipFileForLargeTracking(fullPath) {
|
||||
minSize := atomic.LoadInt64(&largeFileMinSize)
|
||||
if size >= minSize {
|
||||
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
|
||||
timer.Reset(100 * time.Millisecond)
|
||||
select {
|
||||
case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,6 +519,15 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar
|
||||
maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers)
|
||||
sem := make(chan struct{}, maxConcurrent)
|
||||
|
||||
// Reuse timer for large file sends
|
||||
timer := time.NewTimer(0)
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
fullPath := filepath.Join(root, child.Name())
|
||||
|
||||
@@ -516,7 +593,14 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar
|
||||
if !shouldSkipFileForLargeTracking(fullPath) && largeFileMinSize != nil {
|
||||
minSize := atomic.LoadInt64(largeFileMinSize)
|
||||
if size >= minSize {
|
||||
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
|
||||
timer.Reset(100 * time.Millisecond)
|
||||
select {
|
||||
case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +668,7 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64,
|
||||
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "du", "-sk", target)
|
||||
cmd := exec.CommandContext(ctx, "du", "-skP", target)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
@@ -86,20 +86,14 @@ func TestColorizeTempThresholds(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestColorizeTempStyleRanges(t *testing.T) {
|
||||
// Test that different temperature ranges use different styles
|
||||
// We can't easily test the exact style applied, but we can verify
|
||||
// the function returns consistent results for each range
|
||||
|
||||
normalTemp := colorizeTemp(40.0)
|
||||
warningTemp := colorizeTemp(65.0)
|
||||
dangerTemp := colorizeTemp(85.0)
|
||||
|
||||
// All should be non-empty and contain the formatted value
|
||||
if normalTemp == "" || warningTemp == "" || dangerTemp == "" {
|
||||
t.Fatal("colorizeTemp should not return empty strings")
|
||||
}
|
||||
|
||||
// Verify formatting precision (one decimal place)
|
||||
if !strings.Contains(normalTemp, "40.0") {
|
||||
t.Errorf("normal temp should contain '40.0', got: %s", normalTemp)
|
||||
}
|
||||
@@ -110,3 +104,93 @@ func TestColorizeTempStyleRanges(t *testing.T) {
|
||||
t.Errorf("danger temp should contain '85.0', got: %s", dangerTemp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateHealthScoreEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cpu CPUStatus
|
||||
mem MemoryStatus
|
||||
disks []DiskStatus
|
||||
diskIO DiskIOStatus
|
||||
thermal ThermalStatus
|
||||
wantMin int
|
||||
wantMax int
|
||||
}{
|
||||
{
|
||||
name: "all metrics at normal threshold",
|
||||
cpu: CPUStatus{Usage: 30.0},
|
||||
mem: MemoryStatus{UsedPercent: 50.0},
|
||||
disks: []DiskStatus{{UsedPercent: 70.0}},
|
||||
diskIO: DiskIOStatus{ReadRate: 25.0, WriteRate: 25.0},
|
||||
thermal: ThermalStatus{CPUTemp: 60.0},
|
||||
wantMin: 95,
|
||||
wantMax: 100,
|
||||
},
|
||||
{
|
||||
name: "memory pressure warning only",
|
||||
cpu: CPUStatus{Usage: 10.0},
|
||||
mem: MemoryStatus{UsedPercent: 40.0, Pressure: "warn"},
|
||||
disks: []DiskStatus{{UsedPercent: 40.0}},
|
||||
diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0},
|
||||
thermal: ThermalStatus{CPUTemp: 40.0},
|
||||
wantMin: 90,
|
||||
wantMax: 100,
|
||||
},
|
||||
{
|
||||
name: "empty disks array",
|
||||
cpu: CPUStatus{Usage: 10.0},
|
||||
mem: MemoryStatus{UsedPercent: 30.0},
|
||||
disks: []DiskStatus{},
|
||||
diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0},
|
||||
thermal: ThermalStatus{CPUTemp: 40.0},
|
||||
wantMin: 95,
|
||||
wantMax: 100,
|
||||
},
|
||||
{
|
||||
name: "zero thermal data",
|
||||
cpu: CPUStatus{Usage: 10.0},
|
||||
mem: MemoryStatus{UsedPercent: 30.0},
|
||||
disks: []DiskStatus{{UsedPercent: 40.0}},
|
||||
diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0},
|
||||
thermal: ThermalStatus{CPUTemp: 0},
|
||||
wantMin: 95,
|
||||
wantMax: 100,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal)
|
||||
if score < tt.wantMin || score > tt.wantMax {
|
||||
t.Errorf("calculateHealthScore() = %d, want range [%d, %d]", score, tt.wantMin, tt.wantMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatUptimeEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secs uint64
|
||||
want string
|
||||
}{
|
||||
{"zero seconds", 0, "0m"},
|
||||
{"59 seconds", 59, "0m"},
|
||||
{"one minute exact", 60, "1m"},
|
||||
{"59 minutes 59 seconds", 3599, "59m"},
|
||||
{"one hour exact", 3600, "1h 0m"},
|
||||
{"one day exact", 86400, "1d 0h"},
|
||||
{"one day one hour", 90000, "1d 1h"},
|
||||
{"multiple days no hours", 172800, "2d 0h"},
|
||||
{"large uptime", 31536000, "365d 0h"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatUptime(tt.secs)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatUptime(%d) = %q, want %q", tt.secs, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatRate(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -39,6 +42,95 @@ func TestFormatRate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorizePercent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
percent float64
|
||||
input string
|
||||
expectDanger bool
|
||||
expectWarn bool
|
||||
expectOk bool
|
||||
}{
|
||||
{"low usage", 30.0, "30%", false, false, true},
|
||||
{"just below warn", 59.9, "59.9%", false, false, true},
|
||||
{"at warn threshold", 60.0, "60%", false, true, false},
|
||||
{"mid range", 70.0, "70%", false, true, false},
|
||||
{"just below danger", 84.9, "84.9%", false, true, false},
|
||||
{"at danger threshold", 85.0, "85%", true, false, false},
|
||||
{"high usage", 95.0, "95%", true, false, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := colorizePercent(tt.percent, tt.input)
|
||||
|
||||
if got == "" {
|
||||
t.Errorf("colorizePercent(%v, %q) returned empty string", tt.percent, tt.input)
|
||||
return
|
||||
}
|
||||
|
||||
expected := ""
|
||||
if tt.expectDanger {
|
||||
expected = dangerStyle.Render(tt.input)
|
||||
} else if tt.expectWarn {
|
||||
expected = warnStyle.Render(tt.input)
|
||||
} else if tt.expectOk {
|
||||
expected = okStyle.Render(tt.input)
|
||||
}
|
||||
|
||||
if got != expected {
|
||||
t.Errorf("colorizePercent(%v, %q) = %q, want %q (danger=%v warn=%v ok=%v)",
|
||||
tt.percent, tt.input, got, expected, tt.expectDanger, tt.expectWarn, tt.expectOk)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorizeBattery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
percent float64
|
||||
input string
|
||||
expectDanger bool
|
||||
expectWarn bool
|
||||
expectOk bool
|
||||
}{
|
||||
{"critical low", 10.0, "10%", true, false, false},
|
||||
{"just below low", 19.9, "19.9%", true, false, false},
|
||||
{"at low threshold", 20.0, "20%", false, true, false},
|
||||
{"mid range", 35.0, "35%", false, true, false},
|
||||
{"just below ok", 49.9, "49.9%", false, true, false},
|
||||
{"at ok threshold", 50.0, "50%", false, false, true},
|
||||
{"healthy", 80.0, "80%", false, false, true},
|
||||
{"full", 100.0, "100%", false, false, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := colorizeBattery(tt.percent, tt.input)
|
||||
|
||||
if got == "" {
|
||||
t.Errorf("colorizeBattery(%v, %q) returned empty string", tt.percent, tt.input)
|
||||
return
|
||||
}
|
||||
|
||||
expected := ""
|
||||
if tt.expectDanger {
|
||||
expected = dangerStyle.Render(tt.input)
|
||||
} else if tt.expectWarn {
|
||||
expected = warnStyle.Render(tt.input)
|
||||
} else if tt.expectOk {
|
||||
expected = okStyle.Render(tt.input)
|
||||
}
|
||||
|
||||
if got != expected {
|
||||
t.Errorf("colorizeBattery(%v, %q) = %q, want %q (danger=%v warn=%v ok=%v)",
|
||||
tt.percent, tt.input, got, expected, tt.expectDanger, tt.expectWarn, tt.expectOk)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShorten(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -113,6 +205,173 @@ func TestHumanBytesShort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input uint64
|
||||
want string
|
||||
}{
|
||||
// Zero and small values.
|
||||
{"zero", 0, "0 B"},
|
||||
{"one byte", 1, "1 B"},
|
||||
{"1023 bytes", 1023, "1023 B"},
|
||||
|
||||
// Kilobyte boundaries (uses > not >=).
|
||||
{"exactly 1KB", 1 << 10, "1024 B"},
|
||||
{"just over 1KB", (1 << 10) + 1, "1.0 KB"},
|
||||
{"1.5KB", 1536, "1.5 KB"},
|
||||
|
||||
// Megabyte boundaries (uses > not >=).
|
||||
{"exactly 1MB", 1 << 20, "1024.0 KB"},
|
||||
{"just over 1MB", (1 << 20) + 1, "1.0 MB"},
|
||||
{"500MB", 500 << 20, "500.0 MB"},
|
||||
|
||||
// Gigabyte boundaries (uses > not >=).
|
||||
{"exactly 1GB", 1 << 30, "1024.0 MB"},
|
||||
{"just over 1GB", (1 << 30) + 1, "1.0 GB"},
|
||||
{"100GB", 100 << 30, "100.0 GB"},
|
||||
|
||||
// Terabyte boundaries (uses > not >=).
|
||||
{"exactly 1TB", 1 << 40, "1024.0 GB"},
|
||||
{"just over 1TB", (1 << 40) + 1, "1.0 TB"},
|
||||
{"2TB", 2 << 40, "2.0 TB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := humanBytes(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("humanBytes(%d) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytesCompact(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input uint64
|
||||
want string
|
||||
}{
|
||||
// Zero and small values.
|
||||
{"zero", 0, "0"},
|
||||
{"one byte", 1, "1"},
|
||||
{"1023 bytes", 1023, "1023"},
|
||||
|
||||
// Kilobyte boundaries (uses >= not >).
|
||||
{"exactly 1KB", 1 << 10, "1.0K"},
|
||||
{"1.5KB", 1536, "1.5K"},
|
||||
|
||||
// Megabyte boundaries.
|
||||
{"exactly 1MB", 1 << 20, "1.0M"},
|
||||
{"500MB", 500 << 20, "500.0M"},
|
||||
|
||||
// Gigabyte boundaries.
|
||||
{"exactly 1GB", 1 << 30, "1.0G"},
|
||||
{"100GB", 100 << 30, "100.0G"},
|
||||
|
||||
// Terabyte boundaries.
|
||||
{"exactly 1TB", 1 << 40, "1.0T"},
|
||||
{"2TB", 2 << 40, "2.0T"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := humanBytesCompact(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("humanBytesCompact(%d) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitDisks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
disks []DiskStatus
|
||||
wantInternal int
|
||||
wantExternal int
|
||||
}{
|
||||
{
|
||||
name: "empty slice",
|
||||
disks: []DiskStatus{},
|
||||
wantInternal: 0,
|
||||
wantExternal: 0,
|
||||
},
|
||||
{
|
||||
name: "all internal",
|
||||
disks: []DiskStatus{
|
||||
{Mount: "/", External: false},
|
||||
{Mount: "/System", External: false},
|
||||
},
|
||||
wantInternal: 2,
|
||||
wantExternal: 0,
|
||||
},
|
||||
{
|
||||
name: "all external",
|
||||
disks: []DiskStatus{
|
||||
{Mount: "/Volumes/USB", External: true},
|
||||
{Mount: "/Volumes/Backup", External: true},
|
||||
},
|
||||
wantInternal: 0,
|
||||
wantExternal: 2,
|
||||
},
|
||||
{
|
||||
name: "mixed",
|
||||
disks: []DiskStatus{
|
||||
{Mount: "/", External: false},
|
||||
{Mount: "/Volumes/USB", External: true},
|
||||
{Mount: "/System", External: false},
|
||||
},
|
||||
wantInternal: 2,
|
||||
wantExternal: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
internal, external := splitDisks(tt.disks)
|
||||
if len(internal) != tt.wantInternal {
|
||||
t.Errorf("splitDisks() internal count = %d, want %d", len(internal), tt.wantInternal)
|
||||
}
|
||||
if len(external) != tt.wantExternal {
|
||||
t.Errorf("splitDisks() external count = %d, want %d", len(external), tt.wantExternal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
index int
|
||||
total int
|
||||
want string
|
||||
}{
|
||||
// Single disk — no numbering.
|
||||
{"single disk", "INTR", 0, 1, "INTR"},
|
||||
{"single external", "EXTR", 0, 1, "EXTR"},
|
||||
|
||||
// Multiple disks — numbered (1-indexed).
|
||||
{"first of two", "INTR", 0, 2, "INTR1"},
|
||||
{"second of two", "INTR", 1, 2, "INTR2"},
|
||||
{"third of three", "EXTR", 2, 3, "EXTR3"},
|
||||
|
||||
// Edge case: total 0 treated as single.
|
||||
{"total zero", "DISK", 0, 0, "DISK"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := diskLabel(tt.prefix, tt.index, tt.total)
|
||||
if got != tt.want {
|
||||
t.Errorf("diskLabel(%q, %d, %d) = %q, want %q", tt.prefix, tt.index, tt.total, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -333,3 +592,389 @@ func TestParsePMSet(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
percent float64
|
||||
wantRune int
|
||||
}{
|
||||
{"zero percent", 0, 16},
|
||||
{"negative clamped", -10, 16},
|
||||
{"low percent", 25, 16},
|
||||
{"half", 50, 16},
|
||||
{"high percent", 75, 16},
|
||||
{"full", 100, 16},
|
||||
{"over 100 clamped", 150, 16},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := progressBar(tt.percent)
|
||||
if len(got) == 0 {
|
||||
t.Errorf("progressBar(%v) returned empty string", tt.percent)
|
||||
return
|
||||
}
|
||||
gotClean := stripANSI(got)
|
||||
gotRuneCount := len([]rune(gotClean))
|
||||
if gotRuneCount != tt.wantRune {
|
||||
t.Errorf("progressBar(%v) rune count = %d, want %d", tt.percent, gotRuneCount, tt.wantRune)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatteryProgressBar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
percent float64
|
||||
wantRune int
|
||||
}{
|
||||
{"zero percent", 0, 16},
|
||||
{"negative clamped", -10, 16},
|
||||
{"critical low", 15, 16},
|
||||
{"low", 25, 16},
|
||||
{"medium", 50, 16},
|
||||
{"high", 75, 16},
|
||||
{"full", 100, 16},
|
||||
{"over 100 clamped", 120, 16},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := batteryProgressBar(tt.percent)
|
||||
if len(got) == 0 {
|
||||
t.Errorf("batteryProgressBar(%v) returned empty string", tt.percent)
|
||||
return
|
||||
}
|
||||
gotClean := stripANSI(got)
|
||||
gotRuneCount := len([]rune(gotClean))
|
||||
if gotRuneCount != tt.wantRune {
|
||||
t.Errorf("batteryProgressBar(%v) rune count = %d, want %d", tt.percent, gotRuneCount, tt.wantRune)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorizeTemp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
temp float64
|
||||
}{
|
||||
{"very low", 20.0},
|
||||
{"low", 40.0},
|
||||
{"normal threshold", 55.9},
|
||||
{"at warn threshold", 56.0},
|
||||
{"warn range", 65.0},
|
||||
{"just below danger", 75.9},
|
||||
{"at danger threshold", 76.0},
|
||||
{"high", 85.0},
|
||||
{"very high", 95.0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := colorizeTemp(tt.temp)
|
||||
if got == "" {
|
||||
t.Errorf("colorizeTemp(%v) returned empty string", tt.temp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIoBar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rate float64
|
||||
}{
|
||||
{"zero", 0},
|
||||
{"very low", 5},
|
||||
{"low normal", 20},
|
||||
{"at warn threshold", 30},
|
||||
{"warn range", 50},
|
||||
{"just below danger", 79},
|
||||
{"at danger threshold", 80},
|
||||
{"high", 100},
|
||||
{"very high", 200},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ioBar(tt.rate)
|
||||
if got == "" {
|
||||
t.Errorf("ioBar(%v) returned empty string", tt.rate)
|
||||
return
|
||||
}
|
||||
gotClean := stripANSI(got)
|
||||
gotRuneCount := len([]rune(gotClean))
|
||||
if gotRuneCount != 5 {
|
||||
t.Errorf("ioBar(%v) rune count = %d, want 5", tt.rate, gotRuneCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniBar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
percent float64
|
||||
}{
|
||||
{"zero", 0},
|
||||
{"negative", -5},
|
||||
{"low", 15},
|
||||
{"at first step", 20},
|
||||
{"mid", 50},
|
||||
{"high", 75},
|
||||
{"full", 100},
|
||||
{"over 100", 120},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := miniBar(tt.percent)
|
||||
if got == "" {
|
||||
t.Errorf("miniBar(%v) returned empty string", tt.percent)
|
||||
return
|
||||
}
|
||||
gotClean := stripANSI(got)
|
||||
gotRuneCount := len([]rune(gotClean))
|
||||
if gotRuneCount != 5 {
|
||||
t.Errorf("miniBar(%v) rune count = %d, want 5", tt.percent, gotRuneCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDiskLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
label string
|
||||
disk DiskStatus
|
||||
}{
|
||||
{
|
||||
name: "empty label defaults to DISK",
|
||||
label: "",
|
||||
disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30},
|
||||
},
|
||||
{
|
||||
name: "internal disk",
|
||||
label: "INTR",
|
||||
disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30},
|
||||
},
|
||||
{
|
||||
name: "external disk",
|
||||
label: "EXTR1",
|
||||
disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30},
|
||||
},
|
||||
{
|
||||
name: "low usage",
|
||||
label: "INTR",
|
||||
disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatDiskLine(tt.label, tt.disk)
|
||||
if got == "" {
|
||||
t.Errorf("formatDiskLine(%q, ...) returned empty string", tt.label)
|
||||
return
|
||||
}
|
||||
expectedLabel := tt.label
|
||||
if expectedLabel == "" {
|
||||
expectedLabel = "DISK"
|
||||
}
|
||||
if !contains(got, expectedLabel) {
|
||||
t.Errorf("formatDiskLine(%q, ...) = %q, should contain label %q", tt.label, got, expectedLabel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetScoreStyle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
score int
|
||||
}{
|
||||
{"critical low", 10},
|
||||
{"poor low", 25},
|
||||
{"just below fair", 39},
|
||||
{"at fair threshold", 40},
|
||||
{"fair range", 50},
|
||||
{"just below good", 59},
|
||||
{"at good threshold", 60},
|
||||
{"good range", 70},
|
||||
{"just below excellent", 74},
|
||||
{"at excellent threshold", 75},
|
||||
{"excellent range", 85},
|
||||
{"just below perfect", 89},
|
||||
{"perfect", 90},
|
||||
{"max", 100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
style := getScoreStyle(tt.score)
|
||||
if style.GetForeground() == nil {
|
||||
t.Errorf("getScoreStyle(%d) returned style with no foreground color", tt.score)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a int
|
||||
b int
|
||||
want int
|
||||
}{
|
||||
{"a greater", 10, 5, 10},
|
||||
{"b greater", 3, 8, 8},
|
||||
{"equal", 7, 7, 7},
|
||||
{"negative a greater", -5, -10, -5},
|
||||
{"negative b greater", -10, -5, -5},
|
||||
{"zero vs positive", 0, 5, 5},
|
||||
{"zero vs negative", 0, -5, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := maxInt(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("maxInt(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparkline(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
history []float64
|
||||
current float64
|
||||
width int
|
||||
wantLen int
|
||||
}{
|
||||
{
|
||||
name: "empty history",
|
||||
history: []float64{},
|
||||
current: 1.5,
|
||||
width: 10,
|
||||
wantLen: 10,
|
||||
},
|
||||
{
|
||||
name: "short history padded",
|
||||
history: []float64{1.0, 2.0, 3.0},
|
||||
current: 3.0,
|
||||
width: 10,
|
||||
wantLen: 10,
|
||||
},
|
||||
{
|
||||
name: "exact width",
|
||||
history: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
|
||||
current: 5.0,
|
||||
width: 5,
|
||||
wantLen: 5,
|
||||
},
|
||||
{
|
||||
name: "history longer than width",
|
||||
history: []float64{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0},
|
||||
current: 10.0,
|
||||
width: 5,
|
||||
wantLen: 5,
|
||||
},
|
||||
{
|
||||
name: "low current value ok style",
|
||||
history: []float64{1.0, 1.5, 2.0},
|
||||
current: 2.0,
|
||||
width: 5,
|
||||
wantLen: 5,
|
||||
},
|
||||
{
|
||||
name: "medium current value warn style",
|
||||
history: []float64{3.0, 4.0, 5.0},
|
||||
current: 5.0,
|
||||
width: 5,
|
||||
wantLen: 5,
|
||||
},
|
||||
{
|
||||
name: "high current value danger style",
|
||||
history: []float64{8.0, 9.0, 10.0},
|
||||
current: 10.0,
|
||||
width: 5,
|
||||
wantLen: 5,
|
||||
},
|
||||
{
|
||||
name: "all identical values flatline",
|
||||
history: []float64{5.0, 5.0, 5.0, 5.0, 5.0},
|
||||
current: 5.0,
|
||||
width: 5,
|
||||
wantLen: 5,
|
||||
},
|
||||
{
|
||||
name: "zero width edge case",
|
||||
history: []float64{1.0, 2.0, 3.0},
|
||||
current: 2.0,
|
||||
width: 0,
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "width of 1",
|
||||
history: []float64{1.0, 2.0, 3.0},
|
||||
current: 2.0,
|
||||
width: 1,
|
||||
wantLen: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sparkline(tt.history, tt.current, tt.width)
|
||||
if tt.width == 0 {
|
||||
return
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("sparkline() returned empty string")
|
||||
return
|
||||
}
|
||||
gotClean := stripANSI(got)
|
||||
if len([]rune(gotClean)) != tt.wantLen {
|
||||
t.Errorf("sparkline() rune length = %d, want %d", len([]rune(gotClean)), tt.wantLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stripANSI(s string) string {
|
||||
var result strings.Builder
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if i < len(s)-1 && s[i] == '\x1b' && s[i+1] == '[' {
|
||||
i += 2
|
||||
for i < len(s) && (s[i] < 'A' || s[i] > 'Z') && (s[i] < 'a' || s[i] > 'z') {
|
||||
i++
|
||||
}
|
||||
if i < len(s) {
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
result.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr)))
|
||||
}
|
||||
|
||||
func containsMiddle(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -506,6 +506,7 @@ download_binary() {
|
||||
if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; then
|
||||
if [[ -t 1 ]]; then stop_line_spinner; fi
|
||||
chmod +x "$target_path"
|
||||
xattr -cr "$target_path" 2> /dev/null || true
|
||||
log_success "Downloaded ${binary_name} binary"
|
||||
else
|
||||
if [[ -t 1 ]]; then stop_line_spinner; fi
|
||||
|
||||
@@ -422,8 +422,11 @@ get_macos_update_labels() {
|
||||
# ============================================================================
|
||||
|
||||
check_disk_space() {
|
||||
local free_gb=$(command df -H / | awk 'NR==2 {print $4}' | sed 's/G//')
|
||||
local free_num=$(echo "$free_gb" | tr -d 'G' | cut -d'.' -f1)
|
||||
# Use df -k to get KB values (always numeric), then calculate GB via math
|
||||
# This avoids unit suffix parsing issues (df -H can return MB or GB)
|
||||
local free_kb=$(command df -k / | awk 'NR==2 {print $4}')
|
||||
local free_gb=$(awk "BEGIN {printf \"%.1f\", $free_kb / 1048576}")
|
||||
local free_num=$(awk "BEGIN {printf \"%d\", $free_kb / 1048576}")
|
||||
|
||||
export DISK_FREE_GB=$free_num
|
||||
|
||||
|
||||
@@ -114,6 +114,11 @@ clean_media_players() {
|
||||
fi
|
||||
safe_clean ~/Library/Caches/com.apple.Music "Apple Music cache"
|
||||
safe_clean ~/Library/Caches/com.apple.podcasts "Apple Podcasts cache"
|
||||
# Apple Podcasts sandbox container: zombie sparse files and stale artwork cache (#387)
|
||||
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/StreamedMedia "Podcasts streamed media"
|
||||
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*.heic "Podcasts artwork cache"
|
||||
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*.img "Podcasts image cache"
|
||||
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*CFNetworkDownload*.tmp "Podcasts download temp"
|
||||
safe_clean ~/Library/Caches/com.apple.TV/* "Apple TV cache"
|
||||
safe_clean ~/Library/Caches/tv.plex.player.desktop "Plex cache"
|
||||
safe_clean ~/Library/Caches/com.netease.163music "NetEase Music cache"
|
||||
|
||||
@@ -413,7 +413,7 @@ clean_orphaned_system_services() {
|
||||
fi
|
||||
orphaned_files+=("$plist")
|
||||
local size_kb
|
||||
size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
((total_orphaned_kb += size_kb))
|
||||
((orphaned_count++))
|
||||
break
|
||||
@@ -444,7 +444,7 @@ clean_orphaned_system_services() {
|
||||
fi
|
||||
orphaned_files+=("$plist")
|
||||
local size_kb
|
||||
size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
((total_orphaned_kb += size_kb))
|
||||
((orphaned_count++))
|
||||
break
|
||||
@@ -474,7 +474,7 @@ clean_orphaned_system_services() {
|
||||
fi
|
||||
orphaned_files+=("$helper")
|
||||
local size_kb
|
||||
size_kb=$(sudo du -sk "$helper" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
((total_orphaned_kb += size_kb))
|
||||
((orphaned_count++))
|
||||
break
|
||||
|
||||
@@ -29,7 +29,7 @@ clean_homebrew() {
|
||||
local skip_cleanup=false
|
||||
local brew_cache_size=0
|
||||
if [[ -d ~/Library/Caches/Homebrew ]]; then
|
||||
brew_cache_size=$(run_with_timeout 3 du -sk ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}')
|
||||
brew_cache_size=$(run_with_timeout 3 du -skP ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}')
|
||||
local du_exit=$?
|
||||
if [[ $du_exit -eq 0 && -n "$brew_cache_size" && "$brew_cache_size" -lt 51200 ]]; then
|
||||
skip_cleanup=true
|
||||
|
||||
@@ -97,7 +97,7 @@ check_multiple_versions() {
|
||||
if [[ -n "$list_cmd" ]]; then
|
||||
hint=" · ${GRAY}${list_cmd}${NC}"
|
||||
fi
|
||||
echo -e " ${GRAY}${ICON_WARNING}${NC} ${tool_name}: ${count} found${hint}"
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${tool_name}: ${count} found${hint}"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -489,7 +489,7 @@ is_recently_modified() {
|
||||
get_dir_size_kb() {
|
||||
local path="$1"
|
||||
if [[ -d "$path" ]]; then
|
||||
du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0"
|
||||
du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,7 @@ clean_user_essentials() {
|
||||
[[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
[[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" || echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty"
|
||||
[[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" || echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty"
|
||||
elif [[ $trash_count -gt 0 ]]; then
|
||||
if osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $trash_count items"
|
||||
@@ -25,7 +25,7 @@ clean_user_essentials() {
|
||||
done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
|
||||
fi
|
||||
else
|
||||
echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty"
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -628,7 +628,7 @@ check_ios_device_backups() {
|
||||
if [[ -d "$backup_dir" ]]; then
|
||||
local backup_kb=$(get_path_size_kb "$backup_dir")
|
||||
if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then
|
||||
local backup_human=$(command du -sh "$backup_dir" 2> /dev/null | awk '{print $1}')
|
||||
local backup_human=$(command du -shP "$backup_dir" 2> /dev/null | awk '{print $1}')
|
||||
if [[ -n "$backup_human" ]]; then
|
||||
note_activity
|
||||
echo -e " ${YELLOW}${ICON_WARNING}${NC} iOS backups: ${GREEN}${backup_human}${NC}${GRAY}, Path: $backup_dir${NC}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ readonly MOLE_BASE_LOADED=1
|
||||
# ============================================================================
|
||||
readonly ESC=$'\033'
|
||||
readonly GREEN="${ESC}[0;32m"
|
||||
readonly BLUE="${ESC}[0;34m"
|
||||
readonly BLUE="${ESC}[1;34m"
|
||||
readonly CYAN="${ESC}[0;36m"
|
||||
readonly YELLOW="${ESC}[0;33m"
|
||||
readonly PURPLE="${ESC}[0;35m"
|
||||
@@ -626,9 +626,12 @@ start_section_spinner() {
|
||||
# Stop spinner and clear the line
|
||||
# Usage: stop_section_spinner
|
||||
stop_section_spinner() {
|
||||
# Only clear line if spinner was actually running
|
||||
if [[ -n "${INLINE_SPINNER_PID:-}" ]]; then
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
if [[ -t 1 ]]; then
|
||||
echo -ne "\r\033[K" >&2 || true
|
||||
echo -ne "\r\033[2K" >&2 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -646,7 +649,7 @@ safe_clear_lines() {
|
||||
# Clear lines one by one (more reliable than multi-line sequences)
|
||||
local i
|
||||
for ((i = 0; i < lines; i++)); do
|
||||
printf "\033[1A\r\033[K" > "$tty_device" 2> /dev/null || return 1
|
||||
printf "\033[1A\r\033[2K" > "$tty_device" 2> /dev/null || return 1
|
||||
done
|
||||
|
||||
return 0
|
||||
@@ -660,7 +663,7 @@ safe_clear_line() {
|
||||
# Use centralized ANSI support check
|
||||
is_ansi_supported 2> /dev/null || return 1
|
||||
|
||||
printf "\r\033[K" > "$tty_device" 2> /dev/null || return 1
|
||||
printf "\r\033[2K" > "$tty_device" 2> /dev/null || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ safe_sudo_remove() {
|
||||
|
||||
if sudo test -e "$path" 2> /dev/null; then
|
||||
local size_kb
|
||||
size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
if [[ "$size_kb" -gt 0 ]]; then
|
||||
file_size=$(bytes_to_human "$((size_kb * 1024))")
|
||||
fi
|
||||
@@ -297,7 +297,7 @@ safe_sudo_remove() {
|
||||
local size_human=""
|
||||
if oplog_enabled; then
|
||||
if sudo test -e "$path" 2> /dev/null; then
|
||||
size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then
|
||||
size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB")
|
||||
fi
|
||||
@@ -418,7 +418,7 @@ get_path_size_kb() {
|
||||
# Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e
|
||||
# Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it.
|
||||
local size
|
||||
size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
|
||||
size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
|
||||
|
||||
# Ensure size is a valid number (fix for non-numeric du output)
|
||||
if [[ "$size" =~ ^[0-9]+$ ]]; then
|
||||
|
||||
@@ -44,29 +44,43 @@ rotate_log_once() {
|
||||
export MOLE_LOG_ROTATED=1
|
||||
|
||||
local max_size="$LOG_MAX_SIZE_DEFAULT"
|
||||
if [[ -f "$LOG_FILE" ]] && [[ $(get_file_size "$LOG_FILE") -gt "$max_size" ]]; then
|
||||
if [[ -f "$LOG_FILE" ]]; then
|
||||
local size
|
||||
size=$(get_file_size "$LOG_FILE")
|
||||
if [[ "$size" -gt "$max_size" ]]; then
|
||||
mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true
|
||||
ensure_user_file "$LOG_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Rotate operations log (5MB limit)
|
||||
if [[ "${MO_NO_OPLOG:-}" != "1" ]]; then
|
||||
local oplog_max_size="$OPLOG_MAX_SIZE_DEFAULT"
|
||||
if [[ -f "$OPERATIONS_LOG_FILE" ]] && [[ $(get_file_size "$OPERATIONS_LOG_FILE") -gt "$oplog_max_size" ]]; then
|
||||
if [[ -f "$OPERATIONS_LOG_FILE" ]]; then
|
||||
local size
|
||||
size=$(get_file_size "$OPERATIONS_LOG_FILE")
|
||||
if [[ "$size" -gt "$oplog_max_size" ]]; then
|
||||
mv "$OPERATIONS_LOG_FILE" "${OPERATIONS_LOG_FILE}.old" 2> /dev/null || true
|
||||
ensure_user_file "$OPERATIONS_LOG_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Logging Functions
|
||||
# ============================================================================
|
||||
|
||||
# Get current timestamp (centralized for consistency)
|
||||
get_timestamp() {
|
||||
date '+%Y-%m-%d %H:%M:%S'
|
||||
}
|
||||
|
||||
# Log informational message
|
||||
log_info() {
|
||||
echo -e "${BLUE}$1${NC}"
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
local timestamp
|
||||
timestamp=$(get_timestamp)
|
||||
echo "[$timestamp] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo "[$timestamp] INFO: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
@@ -76,38 +90,43 @@ log_info() {
|
||||
# Log success message
|
||||
log_success() {
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1"
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
local timestamp
|
||||
timestamp=$(get_timestamp)
|
||||
echo "[$timestamp] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo "[$timestamp] SUCCESS: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Log warning message
|
||||
# shellcheck disable=SC2329
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}$1${NC}"
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
local timestamp
|
||||
timestamp=$(get_timestamp)
|
||||
echo "[$timestamp] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo "[$timestamp] WARNING: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Log error message
|
||||
# shellcheck disable=SC2329
|
||||
log_error() {
|
||||
echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
local timestamp
|
||||
timestamp=$(get_timestamp)
|
||||
echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo "[$timestamp] ERROR: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Debug logging (active when MO_DEBUG=1)
|
||||
# shellcheck disable=SC2329
|
||||
debug_log() {
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo -e "${GRAY}[DEBUG]${NC} $*" >&2
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
local timestamp
|
||||
timestamp=$(get_timestamp)
|
||||
echo "[$timestamp] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -139,7 +158,7 @@ log_operation() {
|
||||
[[ -z "$path" ]] && return 0
|
||||
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
timestamp=$(get_timestamp)
|
||||
|
||||
local log_line="[$timestamp] [$command] $action $path"
|
||||
[[ -n "$detail" ]] && log_line+=" ($detail)"
|
||||
@@ -154,7 +173,7 @@ log_operation_session_start() {
|
||||
|
||||
local command="${1:-mole}"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
timestamp=$(get_timestamp)
|
||||
|
||||
{
|
||||
echo ""
|
||||
@@ -162,8 +181,7 @@ log_operation_session_start() {
|
||||
} >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true
|
||||
}
|
||||
|
||||
# Log session end with summary
|
||||
# Usage: log_operation_session_end <command> <items_count> <total_size>
|
||||
# shellcheck disable=SC2329
|
||||
log_operation_session_end() {
|
||||
oplog_enabled || return 0
|
||||
|
||||
@@ -171,7 +189,7 @@ log_operation_session_end() {
|
||||
local items="${2:-0}"
|
||||
local size="${3:-0}"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
timestamp=$(get_timestamp)
|
||||
|
||||
local size_human=""
|
||||
if [[ "$size" =~ ^[0-9]+$ ]] && [[ "$size" -gt 0 ]]; then
|
||||
|
||||
@@ -301,6 +301,9 @@ start_inline_spinner() {
|
||||
[[ -z "$chars" ]] && chars="|/-\\"
|
||||
local i=0
|
||||
|
||||
# Clear line on first output to prevent text remnants from previous messages
|
||||
printf "\r\033[2K" >&2 || true
|
||||
|
||||
# Cooperative exit: check for stop file instead of relying on signals
|
||||
while [[ ! -f "$stop_file" ]]; do
|
||||
local c="${chars:$((i % ${#chars})):1}"
|
||||
|
||||
@@ -160,6 +160,32 @@ EOF
|
||||
[ "$result" = "protected" ]
|
||||
}
|
||||
|
||||
@test "Apple apps from App Store can be uninstalled (Issue #386)" {
|
||||
# Xcode should NOT be protected from uninstall
|
||||
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.dt.Xcode' && echo 'protected' || echo 'not-protected'")
|
||||
[ "$result" = "not-protected" ]
|
||||
|
||||
# Final Cut Pro should NOT be protected from uninstall
|
||||
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.FinalCutPro' && echo 'protected' || echo 'not-protected'")
|
||||
[ "$result" = "not-protected" ]
|
||||
|
||||
# GarageBand should NOT be protected from uninstall
|
||||
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.GarageBand' && echo 'protected' || echo 'not-protected'")
|
||||
[ "$result" = "not-protected" ]
|
||||
|
||||
# iWork apps should NOT be protected from uninstall
|
||||
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.iWork.Pages' && echo 'protected' || echo 'not-protected'")
|
||||
[ "$result" = "not-protected" ]
|
||||
|
||||
# But Safari (system app) should still be protected
|
||||
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.Safari' && echo 'protected' || echo 'not-protected'")
|
||||
[ "$result" = "protected" ]
|
||||
|
||||
# And Finder should still be protected
|
||||
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.finder' && echo 'protected' || echo 'not-protected'")
|
||||
[ "$result" = "protected" ]
|
||||
}
|
||||
|
||||
@test "print_summary_block formats output correctly" {
|
||||
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; print_summary_block 'success' 'Test Summary' 'Detail 1' 'Detail 2'")
|
||||
[[ "$result" == *"Test Summary"* ]]
|
||||
|
||||
@@ -123,28 +123,28 @@ EOF
|
||||
}
|
||||
|
||||
@test "check_android_ndk reports multiple NDK versions" {
|
||||
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh"
|
||||
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh"
|
||||
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"Android NDK versions: 3 found"* ]]
|
||||
}
|
||||
|
||||
@test "check_android_ndk silent when only one NDK" {
|
||||
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh"
|
||||
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh"
|
||||
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *"NDK versions"* ]]
|
||||
}
|
||||
|
||||
@test "check_rust_toolchains reports multiple toolchains" {
|
||||
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh"
|
||||
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh"
|
||||
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"Rust toolchains: 3 found"* ]]
|
||||
}
|
||||
|
||||
@test "check_rust_toolchains silent when only one toolchain" {
|
||||
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh"
|
||||
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh"
|
||||
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *"Rust toolchains"* ]]
|
||||
|
||||
97
tests/uninstall_naming_variants.bats
Normal file
97
tests/uninstall_naming_variants.bats
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bats
|
||||
# Test naming variant detection for find_app_files (Issue #377)
|
||||
|
||||
setup_file() {
|
||||
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
|
||||
export PROJECT_ROOT
|
||||
|
||||
ORIGINAL_HOME="${HOME:-}"
|
||||
export ORIGINAL_HOME
|
||||
|
||||
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-naming.XXXXXX")"
|
||||
export HOME
|
||||
|
||||
source "$PROJECT_ROOT/lib/core/base.sh"
|
||||
source "$PROJECT_ROOT/lib/core/log.sh"
|
||||
source "$PROJECT_ROOT/lib/core/app_protection.sh"
|
||||
}
|
||||
|
||||
teardown_file() {
|
||||
if [[ -d "$HOME" && "$HOME" =~ tmp-naming ]]; then
|
||||
rm -rf "$HOME"
|
||||
fi
|
||||
export HOME="$ORIGINAL_HOME"
|
||||
}
|
||||
|
||||
setup() {
|
||||
find "$HOME" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
|
||||
source "$PROJECT_ROOT/lib/core/base.sh"
|
||||
source "$PROJECT_ROOT/lib/core/log.sh"
|
||||
source "$PROJECT_ROOT/lib/core/app_protection.sh"
|
||||
}
|
||||
|
||||
@test "find_app_files detects lowercase-hyphen variant (maestro-studio)" {
|
||||
mkdir -p "$HOME/.config/maestro-studio"
|
||||
echo "test" > "$HOME/.config/maestro-studio/config.json"
|
||||
|
||||
result=$(find_app_files "com.maestro.studio" "Maestro Studio")
|
||||
|
||||
[[ "$result" =~ .config/maestro-studio ]]
|
||||
}
|
||||
|
||||
@test "find_app_files detects no-space variant (MaestroStudio)" {
|
||||
mkdir -p "$HOME/Library/Application Support/MaestroStudio"
|
||||
echo "test" > "$HOME/Library/Application Support/MaestroStudio/data.db"
|
||||
|
||||
result=$(find_app_files "com.maestro.studio" "Maestro Studio")
|
||||
|
||||
[[ "$result" =~ "Library/Application Support/MaestroStudio" ]]
|
||||
}
|
||||
|
||||
@test "find_app_files extracts base name from version suffix (Zed Nightly -> zed)" {
|
||||
mkdir -p "$HOME/.config/zed"
|
||||
mkdir -p "$HOME/Library/Application Support/Zed"
|
||||
echo "test" > "$HOME/.config/zed/settings.json"
|
||||
echo "test" > "$HOME/Library/Application Support/Zed/cache.db"
|
||||
|
||||
result=$(find_app_files "dev.zed.Zed-Nightly" "Zed Nightly")
|
||||
|
||||
[[ "$result" =~ .config/zed ]]
|
||||
[[ "$result" =~ "Library/Application Support/Zed" ]]
|
||||
}
|
||||
|
||||
@test "find_app_files detects multiple naming variants simultaneously" {
|
||||
mkdir -p "$HOME/.config/maestro-studio"
|
||||
mkdir -p "$HOME/Library/Application Support/MaestroStudio"
|
||||
mkdir -p "$HOME/Library/Application Support/Maestro-Studio"
|
||||
mkdir -p "$HOME/.local/share/maestrostudio"
|
||||
|
||||
echo "test" > "$HOME/.config/maestro-studio/config.json"
|
||||
echo "test" > "$HOME/Library/Application Support/MaestroStudio/data.db"
|
||||
echo "test" > "$HOME/Library/Application Support/Maestro-Studio/prefs.json"
|
||||
echo "test" > "$HOME/.local/share/maestrostudio/cache.db"
|
||||
|
||||
result=$(find_app_files "com.maestro.studio" "Maestro Studio")
|
||||
|
||||
[[ "$result" =~ .config/maestro-studio ]]
|
||||
[[ "$result" =~ "Library/Application Support/MaestroStudio" ]]
|
||||
[[ "$result" =~ "Library/Application Support/Maestro-Studio" ]]
|
||||
[[ "$result" =~ .local/share/maestrostudio ]]
|
||||
}
|
||||
|
||||
@test "find_app_files handles multi-word version suffix (Firefox Developer Edition)" {
|
||||
mkdir -p "$HOME/.local/share/firefox"
|
||||
echo "test" > "$HOME/.local/share/firefox/profiles.ini"
|
||||
|
||||
result=$(find_app_files "org.mozilla.firefoxdeveloperedition" "Firefox Developer Edition")
|
||||
|
||||
[[ "$result" =~ .local/share/firefox ]]
|
||||
}
|
||||
|
||||
@test "find_app_files does not match empty app name" {
|
||||
mkdir -p "$HOME/Library/Application Support/test"
|
||||
|
||||
result=$(find_app_files "com.test" "" 2>/dev/null || true)
|
||||
|
||||
[[ ! "$result" =~ "Library/Application Support"$ ]]
|
||||
}
|
||||
Reference in New Issue
Block a user