1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 18:30:08 +00:00

fix: harden CI test stability and status collector resilience

This commit is contained in:
tw93
2026-03-04 16:09:13 +08:00
parent c88691c2c8
commit ff69504f89
10 changed files with 158 additions and 19 deletions

1
.gitignore vendored
View File

@@ -43,6 +43,7 @@ tests/tmp-*
# AI Assistant Instructions # AI Assistant Instructions
.claude/ .claude/
.agents/
.gemini/ .gemini/
.kiro/ .kiro/
CLAUDE.md CLAUDE.md

View File

@@ -92,10 +92,7 @@ func TestScanPathConcurrentBasic(t *testing.T) {
} }
func TestDeletePathWithProgress(t *testing.T) { func TestDeletePathWithProgress(t *testing.T) {
// Skip in CI environments where Finder may not be available. skipIfFinderUnavailable(t)
if os.Getenv("CI") != "" {
t.Skip("Skipping Finder-dependent test in CI")
}
parent := t.TempDir() parent := t.TempDir()
target := filepath.Join(parent, "target") target := filepath.Join(parent, "target")

View File

@@ -7,10 +7,7 @@ import (
) )
func TestTrashPathWithProgress(t *testing.T) { func TestTrashPathWithProgress(t *testing.T) {
// Skip in CI environments where Finder may not be available. skipIfFinderUnavailable(t)
if os.Getenv("CI") != "" {
t.Skip("Skipping Finder-dependent test in CI")
}
parent := t.TempDir() parent := t.TempDir()
target := filepath.Join(parent, "target") target := filepath.Join(parent, "target")
@@ -42,10 +39,7 @@ func TestTrashPathWithProgress(t *testing.T) {
} }
func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) { func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) {
// Skip in CI environments where Finder may not be available. skipIfFinderUnavailable(t)
if os.Getenv("CI") != "" {
t.Skip("Skipping Finder-dependent test in CI")
}
base := t.TempDir() base := t.TempDir()
parent := filepath.Join(base, "parent") parent := filepath.Join(base, "parent")

View File

@@ -0,0 +1,44 @@
package main
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
)
func skipIfFinderUnavailable(t *testing.T) {
t.Helper()
if os.Getenv("CI") != "" {
t.Skip("Skipping Finder-dependent test in CI")
}
if os.Getenv("MOLE_SKIP_FINDER_TESTS") == "1" {
t.Skip("Skipping Finder-dependent test via MOLE_SKIP_FINDER_TESTS")
}
if _, err := exec.LookPath("osascript"); err != nil {
t.Skipf("Skipping Finder-dependent test, osascript unavailable: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "osascript", "-e", `tell application "Finder" to get name`)
output, err := cmd.CombinedOutput()
text := strings.ToLower(string(output))
if ctx.Err() == context.DeadlineExceeded {
t.Skip("Skipping Finder-dependent test, Finder probe timed out")
}
if strings.Contains(text, "connection invalid") || strings.Contains(text, "cant get application \"finder\"") || strings.Contains(text, "can't get application \"finder\"") {
t.Skipf("Skipping Finder-dependent test, Finder probe indicates unavailable session: %s", strings.TrimSpace(string(output)))
}
if err != nil {
reason := strings.TrimSpace(string(output))
if reason == "" {
reason = err.Error()
}
t.Skipf("Skipping Finder-dependent test, Finder unavailable: %s", reason)
}
}

View File

@@ -230,6 +230,9 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
// Host info is cached by gopsutil; fetch once. // Host info is cached by gopsutil; fetch once.
hostInfo, _ := host.Info() hostInfo, _ := host.Info()
if hostInfo == nil {
hostInfo = &host.InfoStat{}
}
var ( var (
wg sync.WaitGroup wg sync.WaitGroup
@@ -255,6 +258,18 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
defer func() {
if r := recover(); r != nil {
errMu.Lock()
panicErr := fmt.Errorf("collector panic: %v", r)
if mergeErr == nil {
mergeErr = panicErr
} else {
mergeErr = fmt.Errorf("%v; %w", mergeErr, panicErr)
}
errMu.Unlock()
}
}()
if err := fn(); err != nil { if err := fn(); err != nil {
errMu.Lock() errMu.Lock()
if mergeErr == nil { if mergeErr == nil {

View File

@@ -17,6 +17,9 @@ func collectMemory() (MemoryStatus, error) {
} }
swap, _ := mem.SwapMemory() swap, _ := mem.SwapMemory()
if swap == nil {
swap = &mem.SwapMemoryStat{}
}
pressure := getMemoryPressure() pressure := getMemoryPressure()
// On macOS, vm.Cached is 0, so we calculate from file-backed pages. // On macOS, vm.Cached is 0, so we calculate from file-backed pages.

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"fmt"
"net/url" "net/url"
"os" "os"
"runtime" "runtime"
@@ -13,10 +14,25 @@ import (
"github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/net"
) )
var ioCountersFunc = net.IOCounters
func collectIOCountersSafely(pernic bool) (stats []net.IOCountersStat, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic collecting network counters: %v", r)
}
}()
return ioCountersFunc(pernic)
}
func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) {
stats, err := net.IOCounters(true) stats, err := collectIOCountersSafely(true)
if err != nil { if err != nil {
return nil, err // Some restricted environments can break netstat-backed collectors.
// Degrade gracefully to keep status output available.
c.rxHistoryBuf.Add(0)
c.txHistoryBuf.Add(0)
return nil, nil
} }
// Map interface IPs. // Map interface IPs.

View File

@@ -1,6 +1,11 @@
package main package main
import "testing" import (
"strings"
"testing"
gopsutilnet "github.com/shirou/gopsutil/v4/net"
)
func TestCollectProxyFromEnvSupportsAllProxy(t *testing.T) { func TestCollectProxyFromEnvSupportsAllProxy(t *testing.T) {
env := map[string]string{ env := map[string]string{
@@ -58,3 +63,41 @@ func TestCollectProxyFromScutilOutputHTTPHostPort(t *testing.T) {
t.Fatalf("unexpected host: %s", got.Host) t.Fatalf("unexpected host: %s", got.Host)
} }
} }
func TestCollectIOCountersSafelyRecoversPanic(t *testing.T) {
original := ioCountersFunc
ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) {
panic("boom")
}
t.Cleanup(func() { ioCountersFunc = original })
stats, err := collectIOCountersSafely(true)
if err == nil {
t.Fatalf("expected error from panic recovery")
}
if !strings.Contains(err.Error(), "panic collecting network counters") {
t.Fatalf("unexpected error: %v", err)
}
if len(stats) != 0 {
t.Fatalf("expected empty stats when panic recovered")
}
}
func TestCollectIOCountersSafelyReturnsData(t *testing.T) {
original := ioCountersFunc
want := []gopsutilnet.IOCountersStat{
{Name: "en0", BytesRecv: 1, BytesSent: 2},
}
ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) {
return want, nil
}
t.Cleanup(func() { ioCountersFunc = original })
got, err := collectIOCountersSafely(true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 || got[0].Name != "en0" {
t.Fatalf("unexpected stats: %+v", got)
}
}

View File

@@ -13,6 +13,11 @@ clean_homebrew() {
fi fi
return 0 return 0
fi fi
# Keep behavior consistent with dry-run preview.
if is_path_whitelisted "$HOME/Library/Caches/Homebrew"; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · skipped whitelist"
return 0
fi
# Skip if cleaned recently to avoid repeated heavy operations. # Skip if cleaned recently to avoid repeated heavy operations.
local brew_cache_file="${HOME}/.cache/mole/brew_last_cleanup" local brew_cache_file="${HOME}/.cache/mole/brew_last_cleanup"
local cache_valid_days=7 local cache_valid_days=7

View File

@@ -150,7 +150,11 @@ echo ""
echo "3. Running Go tests..." echo "3. Running Go tests..."
if command -v go > /dev/null 2>&1; then if command -v go > /dev/null 2>&1; then
if go build ./... > /dev/null 2>&1 && go vet ./cmd/... > /dev/null 2>&1 && go test ./cmd/... > /dev/null 2>&1; then GO_TEST_CACHE="${MOLE_GO_TEST_CACHE:-/tmp/mole-go-build-cache}"
mkdir -p "$GO_TEST_CACHE"
if GOCACHE="$GO_TEST_CACHE" go build ./... > /dev/null 2>&1 &&
GOCACHE="$GO_TEST_CACHE" go vet ./cmd/... > /dev/null 2>&1 &&
GOCACHE="$GO_TEST_CACHE" go test ./cmd/... > /dev/null 2>&1; then
printf "${GREEN}${ICON_SUCCESS} Go tests passed${NC}\n" printf "${GREEN}${ICON_SUCCESS} Go tests passed${NC}\n"
else else
printf "${RED}${ICON_ERROR} Go tests failed${NC}\n" printf "${RED}${ICON_ERROR} Go tests failed${NC}\n"
@@ -182,9 +186,23 @@ echo ""
echo "6. Testing installation..." echo "6. Testing installation..."
# Skip if Homebrew mole is installed (install.sh will refuse to overwrite) # Skip if Homebrew mole is installed (install.sh will refuse to overwrite)
if brew list mole &> /dev/null; then install_test_home=""
if command -v brew > /dev/null 2>&1 && brew list mole &> /dev/null; then
printf "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n" printf "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n"
elif ./install.sh --prefix /tmp/mole-test > /dev/null 2>&1; then else
install_test_home="$(mktemp -d /tmp/mole-test-home.XXXXXX 2> /dev/null || true)"
if [[ -z "$install_test_home" ]]; then
install_test_home="/tmp/mole-test-home"
mkdir -p "$install_test_home"
fi
fi
if [[ -z "$install_test_home" ]]; then
:
elif HOME="$install_test_home" \
XDG_CONFIG_HOME="$install_test_home/.config" \
XDG_CACHE_HOME="$install_test_home/.cache" \
MO_NO_OPLOG=1 \
./install.sh --prefix /tmp/mole-test > /dev/null 2>&1; then
if [ -f /tmp/mole-test/mole ]; then if [ -f /tmp/mole-test/mole ]; then
printf "${GREEN}${ICON_SUCCESS} Installation test passed${NC}\n" printf "${GREEN}${ICON_SUCCESS} Installation test passed${NC}\n"
else else
@@ -195,7 +213,10 @@ else
printf "${RED}${ICON_ERROR} Installation test failed${NC}\n" printf "${RED}${ICON_ERROR} Installation test failed${NC}\n"
((FAILED++)) ((FAILED++))
fi fi
safe_remove "/tmp/mole-test" true || true MO_NO_OPLOG=1 safe_remove "/tmp/mole-test" true || true
if [[ -n "$install_test_home" ]]; then
MO_NO_OPLOG=1 safe_remove "$install_test_home" true || true
fi
echo "" echo ""
echo "===============================" echo "==============================="