ollama/app/updater/updater_darwin_test.go

327 lines
16 KiB
Go

package updater
import (
"archive/zip"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDoUpgrade(t *testing.T) {
tmpDir := t.TempDir()
BundlePath = filepath.Join(tmpDir, "Ollama.app")
appContents := filepath.Join(BundlePath, "Contents")
appBackupDir = filepath.Join(tmpDir, "backup")
appContentsOld := filepath.Join(appBackupDir, "Ollama.app", "Contents")
UpdateStageDir = filepath.Join(tmpDir, "updates")
UpgradeMarkerFile = filepath.Join(tmpDir, "upgraded")
bundle := filepath.Join(UpdateStageDir, "foo", "ollama-darwin.zip")
err := os.MkdirAll(filepath.Join(appContents, "MacOS"), 0o755)
if err != nil {
t.Fatal("failed to create empty dirs")
}
err = os.MkdirAll(filepath.Join(BundlePath, "Contents", "Resources"), 0o755)
if err != nil {
t.Fatal("failed to create empty dirs")
}
err = os.MkdirAll(filepath.Dir(bundle), 0o755)
if err != nil {
t.Fatal("failed to create empty dirs")
}
// No update file, simple failure scenario
if err := DoUpgrade(false); err == nil {
t.Fatal("expected failure without download")
} else if !strings.Contains(err.Error(), "failed to lookup downloads") {
t.Fatalf("unexpected error: %s", err.Error())
}
// Start with an unreadable zip file
if err := os.WriteFile(bundle, []byte{0x4b, 0x50, 0x40, 0x03, 0x00, 0x0a, 0x00}, 0o755); err != nil {
t.Fatalf("failed to create intentionally corrupt zip file: %s", err)
}
if err := DoUpgrade(false); err == nil {
t.Fatal("expected failure with corrupt zip file")
} else if !strings.Contains(err.Error(), "unable to open upgrade bundle") {
t.Fatalf("unexpected error with corrupt zip file: %s", err)
}
// Generate valid (partial) zip file for remaining scenarios
if err := zipCreationHelper(bundle, []testPayload{
{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("would be app binary"),
},
{
Name: "Ollama.app/Contents/Resources/ollama",
Body: []byte("would be the cli"),
},
{
Name: "Ollama.app/Contents/Resources/dummy",
Body: []byte("./ollama"),
Mode: os.ModeSymlink,
},
}); err != nil {
t.Fatal(err)
}
// Permission failure on rename
if err := os.Chmod(BundlePath, 0o500); err != nil {
t.Fatal("failed to remove write permission")
}
if err := DoUpgrade(false); err == nil {
t.Fatal("expected failure with no permission to rename Contents")
} else if !strings.Contains(err.Error(), "permission problems") {
t.Fatalf("unexpected error with permission failure: %s", err)
}
if err := os.Chmod(BundlePath, 0o755); err != nil {
t.Fatal("failed to restore write permission")
}
// Prior failed upgrade
if err := os.MkdirAll(appContentsOld, 0o755); err != nil {
t.Fatal("failed to create empty dirs")
}
if err := DoUpgrade(false); err == nil {
t.Fatal("expected failure with old contents existing")
} else if !strings.Contains(err.Error(), "prior upgrade failed") {
t.Fatalf("unexpected error with old contents: %s", err)
}
if err := os.RemoveAll(appBackupDir); err != nil {
t.Fatal("failed to cleanup dir")
}
// TODO - a failure mode where we revert the backup
// Happy path
if err := DoUpgrade(false); err != nil {
t.Fatalf("unexpected error with clean setup: %s", err)
}
if _, err := os.Stat(appContentsOld); err != nil {
t.Fatalf("missing %s", appContentsOld)
}
if _, err := os.Stat(UpgradeMarkerFile); err != nil {
t.Fatalf("missing marker %s", UpgradeMarkerFile)
}
if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama")); err != nil {
t.Fatalf("missing new App")
}
if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "Resources", "ollama")); err != nil {
t.Fatalf("missing new cli")
}
// Cleanup before next attempt
if err := DoPostUpgradeCleanup(); err != nil {
t.Fatal("failed to cleanup dir")
}
err = os.MkdirAll(filepath.Dir(bundle), 0o755)
if err != nil {
t.Fatal("failed to create empty dirs")
}
// Zip file with one corrupt file within to trigger a rollback
if err := os.WriteFile(bundle, corruptZipData, 0o755); err != nil {
t.Fatalf("failed to create intentionally corrupt zip file: %s", err)
}
if err := DoUpgrade(false); err == nil {
t.Fatal("expected failure with corrupt zip file")
} else if !strings.Contains(err.Error(), "failed to open bundle file") {
t.Fatalf("unexpected error with corrupt zip file: %s", err)
}
// Make sure things were restored on partial failure
if _, err := os.Stat(appContents); err != nil {
t.Fatalf("missing %s", appContents)
}
if _, err := os.Stat(appContentsOld); err == nil {
t.Fatal("old contents still exists")
}
if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama")); err != nil {
t.Fatalf("missing old App")
}
if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "Resources", "ollama")); err != nil {
t.Fatalf("missing old cli")
}
}
func TestDoUpgradeAtStartup(t *testing.T) {
tmpDir := t.TempDir()
BundlePath = filepath.Join(tmpDir, "Ollama.app")
appBackupDir = filepath.Join(tmpDir, "backup")
UpdateStageDir = filepath.Join(tmpDir, "updates")
UpgradeMarkerFile = filepath.Join(tmpDir, "upgraded")
bundle := filepath.Join(UpdateStageDir, "foo", "ollama-darwin.zip")
if err := DoUpgradeAtStartup(); err == nil {
t.Fatal("expected failure without download")
} else if !strings.Contains(err.Error(), "failed to lookup downloads") {
t.Fatalf("unexpected error: %s", err.Error())
}
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
t.Fatal("failed to create empty dirs")
}
if err := zipCreationHelper(bundle, []testPayload{
{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("would be app binary"),
},
{
Name: "Ollama.app/Contents/Resources/ollama",
Body: []byte("would be the cli"),
},
{
Name: "Ollama.app/Contents/Resources/dummy",
Body: []byte("./ollama"),
Mode: os.ModeSymlink,
},
}); err != nil {
t.Fatal(err)
}
if err := DoUpgradeAtStartup(); err != nil {
t.Fatalf("unexpected error with verification failure: %s", err)
}
if _, err := os.Stat(bundle); err == nil {
t.Fatalf("unverified bundle still exists %s", bundle)
}
}
func TestVerifyDownloadFailures(t *testing.T) {
tmpDir := t.TempDir()
BundlePath = filepath.Join(tmpDir, "Ollama.app")
UpdateStageDir = filepath.Join(tmpDir, "staging")
bundle := filepath.Join(UpdateStageDir, "foo", "ollama-darwin.zip")
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
t.Fatal("failed to create empty dirs")
}
tests := []struct {
n string
in []testPayload
expected string
}{
{"breakout", []testPayload{
{
Name: "Ollama.app/",
Body: []byte{},
}, {
Name: "Ollama.app/Resources/ollama",
Body: []byte("cli payload here"),
}, {
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("../../../../breakout"),
Mode: os.ModeSymlink,
},
}, "bundle contains link outside"},
{"absolute", []testPayload{{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("/etc/foo"),
Mode: os.ModeSymlink,
}}, "bundle contains absolute"},
{"missing", []testPayload{{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("../nothere"),
Mode: os.ModeSymlink,
}}, "no such file or directory"},
{"unsigned", []testPayload{{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte{0xfa, 0xcf, 0xfe, 0xed, 0x00, 0x0c, 0x01, 0x00},
}}, "signature verification failed"},
}
for _, tt := range tests {
t.Run(tt.n, func(t *testing.T) {
_ = os.Remove(bundle)
if err := zipCreationHelper(bundle, tt.in); err != nil {
t.Fatal(err)
}
err := VerifyDownload()
if err == nil || !strings.Contains(err.Error(), tt.expected) {
t.Fatalf("expected \"%s\" got %s", tt.expected, err)
}
})
}
}
// One file has been corrupted to cause a checksum mismatch
var corruptZipData = []byte{0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xed, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xb, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x55, 0x54, 0x9, 0x0, 0x3, 0x6d, 0x6c, 0x5f, 0x67, 0x6e, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xd8, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x14, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x55, 0x54, 0x9, 0x0, 0x3, 0x48, 0x6c, 0x5f, 0x67, 0x58, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe3, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1a, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x4d, 0x61, 0x63, 0x4f, 0x53, 0x2f, 0x55, 0x54, 0x9, 0x0, 0x3, 0x59, 0x6c, 0x5f, 0x67, 0x9f, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe3, 0x7e, 0x8f, 0x59, 0xe3, 0x6, 0x15, 0x70, 0x14, 0x0, 0x0, 0x0, 0x14, 0x0, 0x0, 0x0, 0x20, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x4d, 0x61, 0x63, 0x4f, 0x53, 0x2f, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x55, 0x54, 0x9, 0x0, 0x3, 0x59, 0x6c, 0x5f, 0x67, 0x83, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x43, 0x4f, 0x52, 0x52, 0x55, 0x50, 0x54, 0xa, 0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe9, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1e, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x55, 0x54, 0x9, 0x0, 0x3, 0x66, 0x6c, 0x5f, 0x67, 0x83, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x3, 0x4, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe9, 0x7e, 0x8f, 0x59, 0x19, 0xa5, 0x62, 0xf7, 0x11, 0x0, 0x0, 0x0, 0x11, 0x0, 0x0, 0x0, 0x24, 0x0, 0x1c, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x6f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x55, 0x54, 0x9, 0x0, 0x3, 0x66, 0x6c, 0x5f, 0x67, 0x66, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x77, 0x6f, 0x75, 0x6c, 0x64, 0x20, 0x62, 0x65, 0x20, 0x74, 0x68, 0x65, 0x20, 0x63, 0x6c, 0x69, 0xa, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xed, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xb, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x10, 0x0, 0xed, 0x41, 0x0, 0x0, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x55, 0x54, 0x5, 0x0, 0x3, 0x6d, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xd8, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x14, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x10, 0x0, 0xed, 0x41, 0x45, 0x0, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x55, 0x54, 0x5, 0x0, 0x3, 0x48, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe3, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1a, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x10, 0x0, 0xed, 0x41, 0x93, 0x0, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x4d, 0x61, 0x63, 0x4f, 0x53, 0x2f, 0x55, 0x54, 0x5, 0x0, 0x3, 0x59, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe3, 0x7e, 0x8f, 0x59, 0xe3, 0x6, 0x15, 0x70, 0x14, 0x0, 0x0, 0x0, 0x14, 0x0, 0x0, 0x0, 0x20, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa4, 0x81, 0xe7, 0x0, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x4d, 0x61, 0x63, 0x4f, 0x53, 0x2f, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x55, 0x54, 0x5, 0x0, 0x3, 0x59, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe9, 0x7e, 0x8f, 0x59, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1e, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x10, 0x0, 0xed, 0x41, 0x55, 0x1, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x55, 0x54, 0x5, 0x0, 0x3, 0x66, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x1, 0x2, 0x1e, 0x3, 0xa, 0x0, 0x0, 0x0, 0x0, 0x0, 0xe9, 0x7e, 0x8f, 0x59, 0x19, 0xa5, 0x62, 0xf7, 0x11, 0x0, 0x0, 0x0, 0x11, 0x0, 0x0, 0x0, 0x24, 0x0, 0x18, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa4, 0x81, 0xad, 0x1, 0x0, 0x0, 0x4f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x2e, 0x61, 0x70, 0x70, 0x2f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x6f, 0x6c, 0x6c, 0x61, 0x6d, 0x61, 0x55, 0x54, 0x5, 0x0, 0x3, 0x66, 0x6c, 0x5f, 0x67, 0x75, 0x78, 0xb, 0x0, 0x1, 0x4, 0xf5, 0x1, 0x0, 0x0, 0x4, 0x14, 0x0, 0x0, 0x0, 0x50, 0x4b, 0x5, 0x6, 0x0, 0x0, 0x0, 0x0, 0x6, 0x0, 0x6, 0x0, 0x3f, 0x2, 0x0, 0x0, 0x1c, 0x2, 0x0, 0x0, 0x0, 0x0}
type testPayload struct {
Name string
Body []byte
Mode fs.FileMode
}
func zipCreationHelper(filename string, files []testPayload) error {
fd, err := os.Create(filename)
if err != nil {
return err
}
w := zip.NewWriter(fd)
for _, file := range files {
fh := &zip.FileHeader{
Name: file.Name,
Flags: 0,
}
if file.Mode != 0 {
fh.SetMode(file.Mode)
}
f, err := w.CreateHeader(fh)
if err != nil {
return err
}
_, err = f.Write(file.Body)
if err != nil {
return err
}
}
return w.Close()
}
func TestAlreadyMoved(t *testing.T) {
oldPath := SystemWidePath
defer func() {
SystemWidePath = oldPath
}()
exe, err := os.Executable()
if err != nil {
t.Fatal("failed to find executable path")
}
tmpDir := t.TempDir()
testApp := filepath.Join(tmpDir, "Ollama.app")
err = os.MkdirAll(filepath.Join(testApp, "Contents", "MacOS"), 0o755)
if err != nil {
t.Fatal("failed to create Contents dir")
}
SystemWidePath = testApp
testBinary := filepath.Join(testApp, "Contents", "MacOS", "Ollama")
if err := os.Symlink(exe, testBinary); err != nil {
t.Fatalf("failed to create symlink to executable: %s", err)
}
bundle := alreadyMoved()
if bundle != testApp {
t.Fatalf("expected %s, got %s", testApp, bundle)
}
// "Keep scenario"
testApp = filepath.Join(tmpDir, "Ollama 2.app")
err = os.MkdirAll(filepath.Join(testApp, "Contents", "MacOS"), 0o755)
if err != nil {
t.Fatal("failed to create Contents dir")
}
testBinary = filepath.Join(testApp, "Contents", "MacOS", "Ollama")
if err := os.Symlink(exe, testBinary); err != nil {
t.Fatalf("failed to create symlink to executable: %s", err)
}
bundle = alreadyMoved()
if bundle != testApp {
t.Fatalf("expected %s, got %s", testApp, bundle)
}
}