//go:build linux package installer import ( "context" "errors" "os" "path/filepath" "strings" "testing" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/platform" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // newMockLinuxInstaller returns a LinuxInstaller whose command execution goes // through the injected mock executor; no real system commands run. func newMockLinuxInstaller(family string) (*LinuxInstaller, *MockCommandExecutor) { me := NewMockCommandExecutor() inst := NewInstaller(ReleaseInfo{}, "", WithExecutor(me)) li := NewLinuxInstaller(inst, &platform.Platform{OSType: platform.Linux, DistroFamily: family}) return li, me } // newDispatchHandler returns an InstallationHandler backed by the mock executor. func newDispatchHandler() (*InstallationHandler, *MockCommandExecutor) { me := NewMockCommandExecutor() inst := NewInstaller(ReleaseInfo{}, "", WithExecutor(me)) p := &platform.Platform{OSType: platform.Linux, DistroFamily: constants.PMFamilyDebian} return NewInstallationHandler(p, inst), me } // commandLine renders a recorded command call as a single string. func commandLine(call CommandCall) string { return strings.Join(append([]string{call.Name}, call.Args...), " ") } // commandLines renders recorded command calls as strings. func commandLines(calls []CommandCall) []string { lines := make([]string, len(calls)) for i, c := range calls { lines[i] = commandLine(c) } return lines } // failOn configures the mock to fail commands whose full line contains substr. func failOn(me *MockCommandExecutor, substr string, err error) { me.RunCommandFunc = func(name string, args ...string) error { if strings.Contains(commandLine(CommandCall{Name: name, Args: args}), substr) { return err } return nil } } // replacePathWithTools installs fake tool scripts like installFakeTools but // replaces PATH entirely, so host tools with the same names stay invisible. func replacePathWithTools(t *testing.T, tools map[string]string) { t.Helper() dir := t.TempDir() for name, body := range tools { require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body), 0755)) } t.Setenv("PATH", dir) } func TestLinuxProbes(t *testing.T) { t.Run("CheckFlathubEnabled parses remote-list output", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return []byte("flathub\n"), nil } enabled, err := li.checkFlathubEnabled(context.Background()) require.NoError(t, err) assert.True(t, enabled) me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return []byte("fedora\n"), nil } enabled, err = li.checkFlathubEnabled(context.Background()) require.NoError(t, err) assert.False(t, enabled) me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return nil, errors.New("no flatpak") } enabled, err = li.checkFlathubEnabled(context.Background()) require.Error(t, err) assert.False(t, enabled) // All probes went through the injected executor. for _, call := range me.GetCommands() { assert.Equal(t, "flatpak remote-list --columns=name", commandLine(call)) } }) t.Run("GetInstalledVersion parses mpv --version output", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return []byte("mpv 0.39.0-dirty\ncopyright\n"), nil } assert.Equal(t, "0.39.0-dirty", li.GetInstalledVersion()) me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return nil, errors.New("not installed") } assert.Empty(t, li.GetInstalledVersion()) }) t.Run("LookPath probes follow PATH", func(t *testing.T) { li, _ := newMockLinuxInstaller(constants.PMFamilyDebian) replacePathWithTools(t, map[string]string{ "flatpak": "exit 0\n", "mpv": "exit 0\n", }) assert.True(t, li.CheckFlatpakInstalled()) assert.True(t, li.CheckInstalled()) assert.True(t, li.CanUpdate()) replacePathWithTools(t, nil) assert.False(t, li.CheckFlatpakInstalled()) assert.False(t, li.CheckInstalled()) assert.False(t, li.CanUpdate()) }) } func TestInstallFlatpakWithOutput_MockExecutor(t *testing.T) { t.Run("install mpv with flathub already enabled", func(t *testing.T) { installFakeTools(t, map[string]string{"flatpak": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return []byte("flathub\n"), nil } cr, out := newBufferedRunner() require.NoError(t, li.InstallFlatpakWithOutput(cr, constants.FlatpakMPV, constants.UITypeNone, false)) lines := commandLines(me.GetCommands()) assert.Contains(t, lines, "flatpak install flathub io.mpv.Mpv -y") assert.Contains(t, lines, "flatpak override --user --filesystem=xdg-config/mpv:ro io.mpv.Mpv") assert.NotContains(t, lines, "flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo") assert.Contains(t, drainOutput(out), "Installing io.mpv.Mpv via Flatpak...") }) t.Run("enables flathub when missing before installing", func(t *testing.T) { installFakeTools(t, map[string]string{"flatpak": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) // Default RunCommandOutput returns empty: flathub is not enabled. cr, out := newBufferedRunner() require.NoError(t, li.InstallFlatpakWithOutput(cr, constants.FlatpakMPV, constants.UITypeNone, false)) lines := commandLines(me.GetCommands()) remoteAdd := "flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo" install := "flatpak install flathub io.mpv.Mpv -y" assert.Contains(t, lines, remoteAdd) assert.Contains(t, lines, install) assert.Less(t, indexOf(lines, remoteAdd), indexOf(lines, install), "flathub must be enabled before installing") assert.Contains(t, drainOutput(out), "✓ Flathub enabled") }) t.Run("update flow runs flatpak update and keeps the config note", func(t *testing.T) { installFakeTools(t, map[string]string{"flatpak": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, out := newBufferedRunner() require.NoError(t, li.InstallFlatpakWithOutput(cr, constants.FlatpakMPV, constants.UITypeNone, true)) lines := commandLines(me.GetCommands()) require.Len(t, lines, 1) assert.Equal(t, "flatpak update io.mpv.Mpv -y", lines[0]) assert.Contains(t, drainOutput(out), constants.MsgConfigNotOverwritten) }) t.Run("auto-installs flatpak via the package manager when missing", func(t *testing.T) { replacePathWithTools(t, nil) // no flatpak in PATH li, me := newMockLinuxInstaller(constants.PMFamilyDebian) me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return []byte("flathub\n"), nil } cr, out := newBufferedRunner() require.NoError(t, li.InstallFlatpakWithOutput(cr, constants.FlatpakMPV, constants.UITypeNone, false)) lines := commandLines(me.GetCommands()) require.NotEmpty(t, lines) assert.Equal(t, "apt install -y flatpak", lines[0], "flatpak itself is installed first via the package manager") assert.Contains(t, drainOutput(out), "✓ Flatpak installed successfully") assert.Contains(t, GetAndClearPendingNotice(), "Flatpak was just installed") }) t.Run("unsupported family cannot auto-install flatpak", func(t *testing.T) { replacePathWithTools(t, nil) li, _ := newMockLinuxInstaller("unknown-family") cr, _ := newBufferedRunner() err := li.InstallFlatpakWithOutput(cr, constants.FlatpakMPV, constants.UITypeNone, false) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported distribution family for auto-installing flatpak: unknown-family") }) t.Run("flatpak auto-install failure propagates", func(t *testing.T) { replacePathWithTools(t, nil) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "apt install -y flatpak", errors.New("apt boom")) cr, out := newBufferedRunner() err := li.InstallFlatpakWithOutput(cr, constants.FlatpakMPV, constants.UITypeNone, false) require.Error(t, err) assert.Contains(t, err.Error(), "failed to install flatpak: apt boom") assertOutputContains(t, drainOutput(out), "Failed to install flatpak") }) t.Run("remote-add failure propagates", func(t *testing.T) { installFakeTools(t, map[string]string{"flatpak": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "remote-add", errors.New("remote boom")) cr, _ := newBufferedRunner() err := li.InstallFlatpakWithOutput(cr, constants.FlatpakMPV, constants.UITypeNone, false) require.Error(t, err) assert.Equal(t, "remote boom", err.Error()) }) t.Run("install failure propagates", func(t *testing.T) { installFakeTools(t, map[string]string{"flatpak": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return []byte("flathub\n"), nil } failOn(me, "flatpak install", errors.New("install boom")) cr, _ := newBufferedRunner() err := li.InstallFlatpakWithOutput(cr, constants.FlatpakMPV, constants.UITypeNone, false) require.Error(t, err) assert.Equal(t, "install boom", err.Error()) }) t.Run("install celluloid configures the flatpak and disables the OSC", func(t *testing.T) { isolateConfig(t) installFakeTools(t, map[string]string{"flatpak": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return []byte("flathub\n"), nil } configPath, err := GetMPVConfigPath() require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) require.NoError(t, os.WriteFile(configPath, []byte("osc=yes\n"), 0644)) cr, _ := newBufferedRunner() require.NoError(t, li.InstallFlatpakWithOutput(cr, constants.FlatpakCelluloid, constants.UITypeNone, false)) lines := commandLines(me.GetCommands()) assert.Contains(t, lines, "flatpak install flathub io.github.celluloid_player.Celluloid -y") assert.Contains(t, lines, "flatpak override --user --filesystem=xdg-config/mpv:ro io.github.celluloid_player.Celluloid") assert.Contains(t, lines, "flatpak run --command=gsettings io.github.celluloid_player.Celluloid set io.github.celluloid-player.Celluloid mpv-config-enable true") conf, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "osc=no\n", string(conf), "the OSC must be disabled for Celluloid") }) } // indexOf returns the position of want in lines, or -1. func indexOf(lines []string, want string) int { for i, l := range lines { if l == want { return i } } return -1 } func TestUninstallFlatpakWithOutput_MockExecutor(t *testing.T) { t.Run("success", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, _ := newBufferedRunner() require.NoError(t, li.UninstallFlatpakWithOutput(cr, constants.FlatpakMPV)) lines := commandLines(me.GetCommands()) require.Len(t, lines, 1) assert.Equal(t, "flatpak uninstall -y io.mpv.Mpv", lines[0]) }) t.Run("failure is wrapped", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "uninstall", errors.New("uninstall boom")) cr, _ := newBufferedRunner() err := li.UninstallFlatpakWithOutput(cr, constants.FlatpakMPV) require.Error(t, err) assert.Contains(t, err.Error(), "flatpak uninstall failed: uninstall boom") }) } func TestUninstallBrewWithOutput_MockExecutor(t *testing.T) { t.Run("success", func(t *testing.T) { replacePathWithTools(t, map[string]string{"brew": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, _ := newBufferedRunner() require.NoError(t, li.UninstallBrewWithOutput(cr, "mpv")) lines := commandLines(me.GetCommands()) require.Len(t, lines, 1) assert.Equal(t, "brew uninstall mpv", lines[0]) }) t.Run("missing brew fails fast", func(t *testing.T) { replacePathWithTools(t, nil) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, _ := newBufferedRunner() err := li.UninstallBrewWithOutput(cr, "mpv") require.Error(t, err) assert.Contains(t, err.Error(), "homebrew is not installed") assert.Empty(t, me.GetCommands()) }) t.Run("failure is wrapped", func(t *testing.T) { replacePathWithTools(t, map[string]string{"brew": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "uninstall", errors.New("brew boom")) cr, _ := newBufferedRunner() err := li.UninstallBrewWithOutput(cr, "mpv") require.Error(t, err) assert.Contains(t, err.Error(), "brew uninstall failed: brew boom") }) } func TestEnsureGSettingsInstalled_MockExecutor(t *testing.T) { t.Run("already installed runs no command", func(t *testing.T) { replacePathWithTools(t, map[string]string{"gsettings": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, out := newBufferedRunner() require.NoError(t, li.EnsureGSettingsInstalled(cr)) assert.Contains(t, drainOutput(out), "gsettings is already installed") assert.Empty(t, me.GetCommands()) }) t.Run("missing gsettings installs the schemas package", func(t *testing.T) { replacePathWithTools(t, nil) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, _ := newBufferedRunner() require.NoError(t, li.EnsureGSettingsInstalled(cr)) lines := commandLines(me.GetCommands()) require.Len(t, lines, 1) assert.Equal(t, "apt install -y gsettings-desktop-schemas", lines[0]) }) t.Run("install failure propagates", func(t *testing.T) { replacePathWithTools(t, nil) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "gsettings-desktop-schemas", errors.New("apt boom")) cr, _ := newBufferedRunner() err := li.EnsureGSettingsInstalled(cr) require.Error(t, err) assert.Equal(t, "apt boom", err.Error()) }) } func TestConfigureCelluloidPackageWithOutput_MockExecutor(t *testing.T) { t.Run("installs gsettings then sets the celluloid keys", func(t *testing.T) { isolateConfig(t) replacePathWithTools(t, nil) // no gsettings in PATH li, me := newMockLinuxInstaller(constants.PMFamilyDebian) configPath, err := GetMPVConfigPath() require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) require.NoError(t, os.WriteFile(configPath, []byte("osc=yes\n"), 0644)) cr, out := newBufferedRunner() require.NoError(t, li.ConfigureCelluloidPackageWithOutput(cr)) wantConfigFile := filepath.Join(GetMPVConfigDirFromHome(os.Getenv("HOME")), "mpv.conf") lines := commandLines(me.GetCommands()) assert.Contains(t, lines, "apt install -y gsettings-desktop-schemas") assert.Contains(t, lines, "gsettings set io.github.celluloid-player.Celluloid mpv-config-enable true") assert.Contains(t, lines, "gsettings set io.github.celluloid-player.Celluloid mpv-config-file "+wantConfigFile) assert.Contains(t, drainOutput(out), "Celluloid configuration set!") conf, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "osc=no\n", string(conf)) }) t.Run("gsettings install failure skips configuration without failing", func(t *testing.T) { isolateConfig(t) replacePathWithTools(t, nil) li, me := newMockLinuxInstaller("unknown-family") cr, out := newBufferedRunner() require.NoError(t, li.ConfigureCelluloidPackageWithOutput(cr)) assert.Contains(t, drainOutput(out), "Warning: Failed to install gsettings, skipping configuration") assert.Empty(t, me.GetCommands()) }) t.Run("gsettings command failures only warn", func(t *testing.T) { isolateConfig(t) replacePathWithTools(t, map[string]string{"gsettings": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "gsettings set", errors.New("gsettings boom")) configPath, err := GetMPVConfigPath() require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) require.NoError(t, os.WriteFile(configPath, []byte("fullscreen=no\n"), 0644)) cr, out := newBufferedRunner() require.NoError(t, li.ConfigureCelluloidPackageWithOutput(cr)) assertOutputContains(t, drainOutput(out), "Warning: Failed to set gsettings") }) } func TestConfigureMPVFlatpakWithOutput_MockExecutor(t *testing.T) { t.Run("grants filesystem access", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, out := newBufferedRunner() require.NoError(t, li.ConfigureMPVFlatpakWithOutput(cr)) lines := commandLines(me.GetCommands()) require.Len(t, lines, 1) assert.Equal(t, "flatpak override --user --filesystem=xdg-config/mpv:ro io.mpv.Mpv", lines[0]) assert.Contains(t, drainOutput(out), "MPV Flatpak configuration set!") }) t.Run("override failure only warns", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "override", errors.New("override boom")) cr, out := newBufferedRunner() require.NoError(t, li.ConfigureMPVFlatpakWithOutput(cr)) assert.Contains(t, drainOutput(out), "Warning: Failed to set flatpak override") }) } func TestInstallMPVViaPackageWithOutput_MockExecutor(t *testing.T) { t.Run("install runs the family install command and writes the config", func(t *testing.T) { tests := []struct { family string want string }{ {constants.PMFamilyDebian, "apt install -y mpv"}, {constants.PMFamilyRHEL, "dnf install -y mpv"}, {constants.PMFamilyArch, "pacman -Syu --noconfirm mpv"}, {constants.PMFamilySUSE, "zypper --non-interactive install mpv"}, } for _, tt := range tests { t.Run(tt.family, func(t *testing.T) { isolateConfig(t) li, me := newMockLinuxInstaller(tt.family) cr, _ := newBufferedRunner() require.NoError(t, li.InstallMPVViaPackageWithOutput(cr, constants.UITypeNone, false)) lines := commandLines(me.GetCommands()) require.Len(t, lines, 1) assert.Equal(t, tt.want, lines[0]) configPath, err := GetMPVConfigPath() require.NoError(t, err) assert.FileExists(t, configPath, "the platform-default mpv.conf is installed") }) } }) t.Run("install notes the pacman full upgrade on arch", func(t *testing.T) { isolateConfig(t) li, _ := newMockLinuxInstaller(constants.PMFamilyArch) cr, out := newBufferedRunner() require.NoError(t, li.InstallMPVViaPackageWithOutput(cr, constants.UITypeNone, false)) assertOutputContains(t, drainOutput(out), "Note: pacman will refresh databases and perform a full system upgrade") }) t.Run("update refreshes metadata then upgrades the package", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, out := newBufferedRunner() require.NoError(t, li.InstallMPVViaPackageWithOutput(cr, constants.UITypeNone, true)) lines := commandLines(me.GetCommands()) require.Equal(t, []string{"apt update", "apt upgrade -y mpv"}, lines) assert.Contains(t, drainOutput(out), constants.MsgConfigNotOverwritten) }) t.Run("update tolerates a metadata refresh failure", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "apt update", errors.New("mirror down")) cr, _ := newBufferedRunner() require.NoError(t, li.InstallMPVViaPackageWithOutput(cr, constants.UITypeNone, true)) assert.Contains(t, commandLines(me.GetCommands()), "apt upgrade -y mpv") }) t.Run("update propagates an upgrade failure", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "apt upgrade", errors.New("upgrade boom")) cr, _ := newBufferedRunner() err := li.InstallMPVViaPackageWithOutput(cr, constants.UITypeNone, true) require.Error(t, err) assert.Equal(t, "upgrade boom", err.Error()) }) t.Run("install propagates a package manager failure", func(t *testing.T) { isolateConfig(t) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "apt install", errors.New("install boom")) cr, _ := newBufferedRunner() err := li.InstallMPVViaPackageWithOutput(cr, constants.UITypeNone, false) require.Error(t, err) assert.Equal(t, "install boom", err.Error()) }) } func TestInstallCelluloidViaPackageWithOutput_MockExecutor(t *testing.T) { t.Run("install refreshes, installs, then configures celluloid", func(t *testing.T) { isolateConfig(t) replacePathWithTools(t, nil) // no gsettings in PATH li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, out := newBufferedRunner() require.NoError(t, li.InstallCelluloidViaPackageWithOutput(cr, false)) lines := commandLines(me.GetCommands()) require.GreaterOrEqual(t, len(lines), 3) assert.Equal(t, "apt update", lines[0]) assert.Equal(t, "apt install -y celluloid", lines[1]) assert.Contains(t, lines, "apt install -y gsettings-desktop-schemas") assert.Contains(t, lines, "gsettings set io.github.celluloid-player.Celluloid mpv-config-enable true") assert.Contains(t, drainOutput(out), "Installing Celluloid via package manager (family: debian)...") }) t.Run("update refreshes metadata then upgrades", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, _ := newBufferedRunner() require.NoError(t, li.InstallCelluloidViaPackageWithOutput(cr, true)) require.Equal(t, []string{"apt update", "apt upgrade -y celluloid"}, commandLines(me.GetCommands())) }) t.Run("update propagates an upgrade failure", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "apt upgrade", errors.New("upgrade boom")) cr, _ := newBufferedRunner() err := li.InstallCelluloidViaPackageWithOutput(cr, true) require.Error(t, err) assert.Equal(t, "upgrade boom", err.Error()) }) t.Run("install propagates a package manager failure", func(t *testing.T) { isolateConfig(t) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "apt install -y celluloid", errors.New("install boom")) cr, _ := newBufferedRunner() err := li.InstallCelluloidViaPackageWithOutput(cr, false) require.Error(t, err) assert.Equal(t, "install boom", err.Error()) }) } func TestUninstallViaPackageWithOutput_MockExecutor(t *testing.T) { t.Run("runs the family remove command", func(t *testing.T) { tests := []struct { family string want string }{ {constants.PMFamilyDebian, "apt remove -y mpv"}, {constants.PMFamilyRHEL, "dnf remove -y mpv"}, {constants.PMFamilyArch, "pacman -Rns --noconfirm mpv"}, {constants.PMFamilySUSE, "zypper --non-interactive remove mpv"}, } for _, tt := range tests { t.Run(tt.family, func(t *testing.T) { li, me := newMockLinuxInstaller(tt.family) cr, _ := newBufferedRunner() require.NoError(t, li.UninstallViaPackageWithOutput(cr, constants.PackageMPV)) lines := commandLines(me.GetCommands()) require.Len(t, lines, 1) assert.Equal(t, tt.want, lines[0]) }) } }) t.Run("failure is wrapped", func(t *testing.T) { li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "remove", errors.New("remove boom")) cr, _ := newBufferedRunner() err := li.UninstallViaPackageWithOutput(cr, constants.PackageMPV) require.Error(t, err) assert.Contains(t, err.Error(), "package remove failed: remove boom") }) } func TestExecuteInstallDispatch_Linux(t *testing.T) { isolateConfig(t) t.Run("cancelled context aborts before dispatch", func(t *testing.T) { handler, _ := newDispatchHandler() ctx, cancel := context.WithCancel(context.Background()) cancel() cr, _ := newBufferedRunner() err := handler.ExecuteInstall(ctx, cr, constants.MethodMPVFlatpak, constants.UITypeNone, false) require.Error(t, err) assert.Contains(t, err.Error(), "installation cancelled") }) t.Run("mpv flatpak install dispatches to the flatpak flow", func(t *testing.T) { installFakeTools(t, map[string]string{"flatpak": "exit 0\n"}) handler, me := newDispatchHandler() me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return []byte("flathub\n"), nil } cr, _ := newBufferedRunner() require.NoError(t, handler.ExecuteInstall(context.Background(), cr, constants.MethodMPVFlatpak, constants.UITypeNone, false)) assert.Contains(t, commandLines(me.GetCommands()), "flatpak install flathub io.mpv.Mpv -y") }) t.Run("celluloid flatpak forces no custom UI", func(t *testing.T) { installFakeTools(t, map[string]string{"flatpak": "exit 0\n"}) handler, me := newDispatchHandler() me.RunCommandOutputFunc = func(name string, args ...string) ([]byte, error) { return []byte("flathub\n"), nil } cr, _ := newBufferedRunner() // Even with a UI requested, Celluloid installs none (it has its own UI). require.NoError(t, handler.ExecuteInstall(context.Background(), cr, constants.MethodCelluloidFlatpak, constants.UITypeModernZ, false)) assert.Contains(t, commandLines(me.GetCommands()), "flatpak install flathub io.github.celluloid_player.Celluloid -y") }) t.Run("mpv package install dispatches to the package flow", func(t *testing.T) { handler, me := newDispatchHandler() cr, _ := newBufferedRunner() require.NoError(t, handler.ExecuteInstall(context.Background(), cr, constants.MethodMPVPackage, constants.UITypeNone, false)) assert.Contains(t, commandLines(me.GetCommands()), "apt install -y mpv") }) t.Run("celluloid package install dispatches to the package flow", func(t *testing.T) { replacePathWithTools(t, nil) // no gsettings in PATH handler, me := newDispatchHandler() cr, _ := newBufferedRunner() require.NoError(t, handler.ExecuteInstall(context.Background(), cr, constants.MethodCelluloidPackage, constants.UITypeNone, false)) assert.Contains(t, commandLines(me.GetCommands()), "apt install -y celluloid") }) t.Run("ExecuteUpdate wraps ExecuteInstall with isUpdate", func(t *testing.T) { installFakeTools(t, map[string]string{"flatpak": "exit 0\n"}) handler, me := newDispatchHandler() cr, _ := newBufferedRunner() require.NoError(t, handler.ExecuteUpdate(context.Background(), cr, constants.MethodMPVFlatpak)) assert.Contains(t, commandLines(me.GetCommands()), "flatpak update io.mpv.Mpv -y") }) t.Run("unsupported-on-linux methods surface the linux stub errors", func(t *testing.T) { handler, _ := newDispatchHandler() cr, _ := newBufferedRunner() ctx := context.Background() err := handler.ExecuteInstall(ctx, cr, constants.MethodMPVBinary, constants.UITypeNone, false) require.Error(t, err) assert.Contains(t, err.Error(), "MPV binary installation is not supported on Linux") err = handler.ExecuteInstall(ctx, cr, constants.MethodMPVBinaryV3, constants.UITypeNone, true) require.Error(t, err) assert.Contains(t, err.Error(), "MPV binary update is not supported on Linux") err = handler.ExecuteInstall(ctx, cr, constants.MethodMPCQT, constants.UITypeNone, false) require.Error(t, err) assert.Contains(t, err.Error(), "MPC-QT is not supported on Linux") err = handler.ExecuteInstall(ctx, cr, constants.MethodMPVApp, constants.UITypeNone, false) require.Error(t, err) assert.Contains(t, err.Error(), "MPV App is not supported on Linux") err = handler.ExecuteInstall(ctx, cr, constants.MethodMPVApp, constants.UITypeNone, true) require.Error(t, err) assert.Contains(t, err.Error(), "MPV App update is not supported on Linux") err = handler.ExecuteInstall(ctx, cr, constants.MethodMPVBrew, constants.UITypeNone, false) require.Error(t, err) assert.Contains(t, err.Error(), "Homebrew is not supported on Linux") err = handler.ExecuteInstall(ctx, cr, constants.MethodIINA, constants.UITypeNone, false) require.Error(t, err) assert.Contains(t, err.Error(), "IINA is not supported on Linux") }) t.Run("ffmpeg component updates are windows only", func(t *testing.T) { handler, _ := newDispatchHandler() cr, _ := newBufferedRunner() err := handler.ExecuteInstall(context.Background(), cr, constants.MethodFFmpeg, constants.UITypeNone, false) require.Error(t, err) assert.Contains(t, err.Error(), "FFmpeg component updates are only supported on Windows") }) t.Run("unknown method fails", func(t *testing.T) { handler, _ := newDispatchHandler() cr, _ := newBufferedRunner() err := handler.ExecuteInstall(context.Background(), cr, "bogus-method", constants.UITypeNone, false) require.Error(t, err) assert.Contains(t, err.Error(), "unknown installation method: bogus-method") }) t.Run("component methods dispatch to the ui installers", func(t *testing.T) { handler, _ := newDispatchHandler() cr, _ := newBufferedRunner() ctx := context.Background() // The empty release manifest makes the downloads fail fast locally; // the assertion only proves dispatch reached the component installers. err := handler.ExecuteInstall(ctx, cr, constants.MethodUOSC, constants.UITypeNone, false) require.Error(t, err) err = handler.ExecuteInstall(ctx, cr, constants.MethodUOSC, constants.UITypeNone, true) require.Error(t, err) err = handler.ExecuteInstall(ctx, cr, constants.MethodModernZ, constants.UITypeNone, false) require.Error(t, err) err = handler.ExecuteInstall(ctx, cr, constants.MethodModernZ, constants.UITypeNone, true) require.Error(t, err) }) t.Run("nil platform installer reports the owning platform", func(t *testing.T) { inst := NewInstaller(ReleaseInfo{}, "", WithExecutor(NewMockCommandExecutor())) handler := &InstallationHandler{ Platform: &platform.Platform{OSType: platform.Linux, DistroFamily: constants.PMFamilyDebian}, Installer: inst, } cr, _ := newBufferedRunner() ctx := context.Background() for method, want := range map[string]string{ constants.MethodMPVBinary: "Windows installer not available on linux", constants.MethodMPCQT: "MPC-QT installer not available on linux", constants.MethodMPVApp: "macOS installer not available on linux", constants.MethodMPVBrew: "macOS installer not available on linux", constants.MethodIINA: "macOS installer not available on linux", constants.MethodMPVFlatpak: "Linux installer not available on linux", constants.MethodCelluloidFlatpak: "Linux installer not available on linux", constants.MethodMPVPackage: "Linux installer not available on linux", constants.MethodCelluloidPackage: "Linux installer not available on linux", } { err := handler.ExecuteInstall(ctx, cr, method, constants.UITypeNone, false) require.Error(t, err, method) assert.Contains(t, err.Error(), want, method) } }) } func TestExecuteUninstallDispatch_Linux(t *testing.T) { isolateConfig(t) t.Run("cancelled context aborts before dispatch", func(t *testing.T) { handler, _ := newDispatchHandler() ctx, cancel := context.WithCancel(context.Background()) cancel() cr, _ := newBufferedRunner() err := handler.ExecuteUninstall(ctx, cr, &config.InstalledApp{InstallMethod: constants.MethodMPVFlatpak}) require.Error(t, err) assert.Contains(t, err.Error(), "uninstallation cancelled") }) t.Run("flatpak and package methods dispatch to their flows", func(t *testing.T) { handler, me := newDispatchHandler() cr, _ := newBufferedRunner() ctx := context.Background() require.NoError(t, handler.ExecuteUninstall(ctx, cr, &config.InstalledApp{InstallMethod: constants.MethodMPVFlatpak})) require.NoError(t, handler.ExecuteUninstall(ctx, cr, &config.InstalledApp{InstallMethod: constants.MethodCelluloidFlatpak})) require.NoError(t, handler.ExecuteUninstall(ctx, cr, &config.InstalledApp{InstallMethod: constants.MethodMPVPackage})) require.NoError(t, handler.ExecuteUninstall(ctx, cr, &config.InstalledApp{InstallMethod: constants.MethodCelluloidPackage})) lines := commandLines(me.GetCommands()) assert.Contains(t, lines, "flatpak uninstall -y io.mpv.Mpv") assert.Contains(t, lines, "flatpak uninstall -y io.github.celluloid_player.Celluloid") assert.Contains(t, lines, "apt remove -y mpv") assert.Contains(t, lines, "apt remove -y celluloid") }) t.Run("brew uninstall dispatches with the mpv package name", func(t *testing.T) { replacePathWithTools(t, map[string]string{"brew": "exit 0\n"}) handler, me := newDispatchHandler() cr, _ := newBufferedRunner() require.NoError(t, handler.ExecuteUninstall(context.Background(), cr, &config.InstalledApp{InstallMethod: constants.MethodMPVBrew})) assert.Contains(t, commandLines(me.GetCommands()), "brew uninstall mpv") }) t.Run("windows and macos methods surface the linux stub errors", func(t *testing.T) { handler, _ := newDispatchHandler() cr, _ := newBufferedRunner() ctx := context.Background() for method, want := range map[string]string{ constants.MethodMPVBinary: "MPV binary uninstall is not supported on Linux", constants.MethodMPVBinaryV3: "MPV binary uninstall is not supported on Linux", constants.MethodMPCQT: "MPC-QT uninstall is not supported on Linux", constants.MethodMPVApp: "App uninstallation is not supported on Linux", constants.MethodIINA: "App uninstallation is not supported on Linux", } { err := handler.ExecuteUninstall(ctx, cr, &config.InstalledApp{InstallMethod: method}) require.Error(t, err, method) assert.Contains(t, err.Error(), want, method) } }) t.Run("unknown method fails", func(t *testing.T) { handler, _ := newDispatchHandler() cr, _ := newBufferedRunner() err := handler.ExecuteUninstall(context.Background(), cr, &config.InstalledApp{InstallMethod: "bogus-method"}) require.Error(t, err) assert.Contains(t, err.Error(), "unknown installation method: bogus-method") }) t.Run("nil platform installer reports the owning platform", func(t *testing.T) { inst := NewInstaller(ReleaseInfo{}, "", WithExecutor(NewMockCommandExecutor())) handler := &InstallationHandler{ Platform: &platform.Platform{OSType: platform.Linux, DistroFamily: constants.PMFamilyDebian}, Installer: inst, } cr, _ := newBufferedRunner() ctx := context.Background() for method, want := range map[string]string{ constants.MethodMPVBinary: "Windows installer not available on linux", constants.MethodMPCQT: "Windows installer not available on linux", constants.MethodMPVApp: "macOS installer not available on linux", constants.MethodIINA: "macOS installer not available on linux", constants.MethodMPVBrew: "macOS installer not available on linux", constants.MethodMPVFlatpak: "Linux installer not available on linux", constants.MethodCelluloidFlatpak: "Linux installer not available on linux", constants.MethodMPVPackage: "Linux installer not available on linux", constants.MethodCelluloidPackage: "Linux installer not available on linux", } { err := handler.ExecuteUninstall(ctx, cr, &config.InstalledApp{InstallMethod: method}) require.Error(t, err, method) assert.Contains(t, err.Error(), want, method) } }) } func TestInstallationHandlerPlatformGates_Linux(t *testing.T) { handler, _ := newDispatchHandler() cr, _ := newBufferedRunner() err := handler.SetupFileAssociationsWithOutput(cr) require.Error(t, err) assert.Contains(t, err.Error(), "file associations are only supported on Windows") err = handler.RemoveFileAssociationsWithOutput(cr) require.Error(t, err) assert.Contains(t, err.Error(), "file associations are only supported on Windows") err = handler.CreateMPVShortcutWithOutput(cr) require.Error(t, err) assert.Contains(t, err.Error(), "MPV shortcuts are only supported on Windows") // The installer shortcut is a no-op on Linux and succeeds. cr, out := newBufferedRunner() require.NoError(t, handler.CreateInstallerShortcutWithOutput(cr)) assert.Contains(t, drainOutput(out), "Installer shortcut creation not needed on Linux") // A nil platform installer on Linux still reports the platform. bare := &InstallationHandler{Platform: &platform.Platform{OSType: platform.Linux}} err = bare.CreateInstallerShortcutWithOutput(cr) require.Error(t, err) assert.Contains(t, err.Error(), "unavailable on linux") err = bare.CreateWebUIShortcutWithOutput(cr) require.Error(t, err) assert.Contains(t, err.Error(), "unavailable on linux") } func TestCreateWebUIShortcutWithOutput_Linux(t *testing.T) { isolateConfig(t) home := os.Getenv("HOME") // The internal icon install runs without an injected executor, so the fake // icon-cache tool is executed for real through the replaced PATH. replacePathWithTools(t, map[string]string{"gtk-update-icon-cache": "exit 0\n"}) require.NoError(t, os.MkdirAll(filepath.Join(home, "Desktop"), 0755)) handler, _ := newDispatchHandler() cr, out := newBufferedRunner() require.NoError(t, handler.CreateWebUIShortcutWithOutput(cr)) targetBin := filepath.Join(home, ".config", "mpv", constants.AppName) assert.NoFileExists(t, targetBin) targetBin, err := os.Executable() require.NoError(t, err) assert.FileExists(t, filepath.Join(home, ".config", "mpv", "uninstall-linux.sh")) assert.FileExists(t, filepath.Join(home, ".local", "share", "applications", "mpv-manager.desktop")) desktop, err := os.ReadFile(filepath.Join(home, ".local", "share", "applications", "mpv-manager.desktop")) require.NoError(t, err) assert.Contains(t, string(desktop), "Exec="+desktopExecQuote(targetBin)+" -m web") iconPath := filepath.Join(home, constants.LinuxIconsDir, constants.LinuxIconThemeDir, constants.LinuxIconFileName) assert.FileExists(t, iconPath) outputs := drainOutput(out) assertOutputContains(t, outputs, "✓ Application entry created") } func TestLinuxWebShortcutPreservesInstalledManager(t *testing.T) { isolateConfig(t) home := os.Getenv("HOME") replacePathWithTools(t, map[string]string{"gtk-update-icon-cache": "exit 0\n"}) require.NoError(t, os.MkdirAll(filepath.Join(home, "Desktop"), 0755)) target := filepath.Join(home, ".config", "mpv", constants.AppName) require.NoError(t, os.MkdirAll(filepath.Dir(target), 0755)) require.NoError(t, os.WriteFile(target, []byte("newer installed manager"), 0700)) cr, _ := newBufferedRunner() require.NoError(t, CreateLinuxWebUIShortcutWithOutput(cr)) data, err := os.ReadFile(target) require.NoError(t, err) require.Equal(t, "newer installed manager", string(data)) info, err := os.Stat(target) require.NoError(t, err) require.Equal(t, os.FileMode(0700), info.Mode().Perm()) desktop, err := os.ReadFile(filepath.Join(home, ".local", "share", "applications", "mpv-manager.desktop")) require.NoError(t, err) require.Contains(t, string(desktop), "Exec="+desktopExecQuote(target)+" -m web") } func TestInstallIcon_MockExecutor(t *testing.T) { t.Run("writes the icon and refreshes the cache via the injected executor", func(t *testing.T) { home := isolateHome(t) replacePathWithTools(t, map[string]string{"gtk-update-icon-cache": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, out := newBufferedRunner() require.NoError(t, li.InstallIcon(cr)) iconPath := filepath.Join(home, constants.LinuxIconsDir, constants.LinuxIconThemeDir, constants.LinuxIconFileName) assert.FileExists(t, iconPath) iconsBaseDir := filepath.Join(home, constants.LinuxIconsDir) lines := commandLines(me.GetCommands()) require.Len(t, lines, 1) assert.Equal(t, "gtk-update-icon-cache --quiet --force "+iconsBaseDir, lines[0]) assert.Contains(t, drainOutput(out), "✓ Icon cache updated") }) t.Run("existing icon skips the install", func(t *testing.T) { home := isolateHome(t) iconThemeDir := filepath.Join(home, constants.LinuxIconsDir, constants.LinuxIconThemeDir) require.NoError(t, os.MkdirAll(iconThemeDir, 0755)) iconPath := filepath.Join(iconThemeDir, constants.LinuxIconFileName) require.NoError(t, os.WriteFile(iconPath, []byte("png"), 0644)) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, out := newBufferedRunner() require.NoError(t, li.InstallIcon(cr)) assert.Contains(t, drainOutput(out), "Icon already installed: "+iconPath) assert.Empty(t, me.GetCommands()) }) t.Run("update-icon-caches is the fallback cache tool", func(t *testing.T) { home := isolateHome(t) replacePathWithTools(t, map[string]string{"update-icon-caches": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, _ := newBufferedRunner() require.NoError(t, li.InstallIcon(cr)) iconsBaseDir := filepath.Join(home, constants.LinuxIconsDir) lines := commandLines(me.GetCommands()) require.Len(t, lines, 1) assert.Equal(t, "update-icon-caches "+iconsBaseDir, lines[0]) }) t.Run("no cache tool in PATH is not an error", func(t *testing.T) { isolateHome(t) replacePathWithTools(t, nil) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) cr, _ := newBufferedRunner() require.NoError(t, li.InstallIcon(cr)) assert.Empty(t, me.GetCommands()) }) t.Run("cache refresh failure only warns", func(t *testing.T) { isolateHome(t) replacePathWithTools(t, map[string]string{"gtk-update-icon-cache": "exit 0\n"}) li, me := newMockLinuxInstaller(constants.PMFamilyDebian) failOn(me, "gtk-update-icon-cache", errors.New("cache boom")) cr, out := newBufferedRunner() require.NoError(t, li.InstallIcon(cr)) assert.Contains(t, drainOutput(out), "Note: Failed to update icon cache (non-critical)") }) } func TestLinuxInstallerStubs(t *testing.T) { li := &LinuxInstaller{} cr, out := newBufferedRunner() for name, fn := range map[string]func() error{ "InstallIINAWithOutput": func() error { return li.InstallIINAWithOutput(cr) }, "InstallMPCQTWithOutput": func() error { return li.InstallMPCQTWithOutput(cr, "method") }, "InstallMPVAppWithOutput": func() error { return li.InstallMPVAppWithOutput(cr, "ui") }, "InstallMPVViaBrewWithOutput": func() error { return li.InstallMPVViaBrewWithOutput(cr, "ui", false) }, "UninstallAppWithOutput": func() error { return li.UninstallAppWithOutput(cr, "MPV") }, "UpdateMPVAppWithOutput": func() error { return li.UpdateMPVAppWithOutput(cr) }, "SetupFileAssociationsWithOutput": func() error { return li.SetupFileAssociationsWithOutput(cr) }, "RemoveFileAssociationsWithOutput": func() error { return li.RemoveFileAssociationsWithOutput(cr) }, "CreateMPVShortcut": func() error { return li.CreateMPVShortcut(cr) }, "UninstallWithOutput": func() error { return li.UninstallWithOutput(cr) }, "UninstallMPCQTWithOutput": func() error { return li.UninstallMPCQTWithOutput(cr) }, "InstallMPVWithOutput": func() error { return li.InstallMPVWithOutput(cr, "method", "ui") }, "UpdateMPVWithOutput": func() error { return li.UpdateMPVWithOutput(cr, "method") }, } { err := fn() require.Error(t, err, name) assert.Contains(t, err.Error(), "not supported on Linux", name) } // Installer shortcuts are a no-op success on Linux. require.NoError(t, li.CreateInstallerShortcutWithOutput(cr)) require.NoError(t, li.CreateInstallerShortcutToBinaryWithOutput(cr, "/usr/bin/mpv-manager")) assert.Contains(t, drainOutput(out), "Installer shortcut creation not needed on Linux") } func TestLinuxInstallerHelpers(t *testing.T) { t.Run("GetDefaultInstallPath is under the home downloads dir", func(t *testing.T) { home := isolateHome(t) li := &LinuxInstaller{} assert.Equal(t, filepath.Join(home, "Downloads", "mpv"), li.GetDefaultInstallPath()) }) t.Run("NormalizePath cleans the path", func(t *testing.T) { li := &LinuxInstaller{} assert.Equal(t, filepath.Clean("/a/./b/../c"), li.NormalizePath("/a/./b/../c")) }) t.Run("isMPVInstall identifies mpv install ids", func(t *testing.T) { assert.True(t, isMPVInstall(constants.FlatpakMPV)) assert.True(t, isMPVInstall(constants.PackageMPV)) assert.False(t, isMPVInstall(constants.FlatpakCelluloid)) }) t.Run("notePacmanFullUpgrade only notes arch", func(t *testing.T) { cr, out := newBufferedRunner() notePacmanFullUpgrade(cr, constants.PMFamilyArch) notePacmanFullUpgrade(cr, constants.PMFamilyDebian) notePacmanFullUpgrade(nil, constants.PMFamilyArch) // nil runner must not panic outputs := drainOutput(out) require.Len(t, outputs, 1) assert.Contains(t, outputs[0], "pacman will refresh databases") }) t.Run("detectLinuxFamily delegates to the platform package", func(t *testing.T) { assert.Equal(t, platform.GetLinuxDistroFamily(), detectLinuxFamily()) }) } func TestPackageReinstallPreservesCompleteConfiguration(t *testing.T) { for _, method := range []string{"mpv", "celluloid"} { t.Run(method, func(t *testing.T) { isolateConfig(t) li, _ := newMockLinuxInstaller(constants.PMFamilyDebian) cr, _ := newBufferedRunner() path, err := GetMPVConfigPath() require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) before := []byte("# keep my comments\nosc=no\n[custom-profile]\nvolume=37\n") require.NoError(t, os.WriteFile(path, before, 0600)) if method == "mpv" { err = li.InstallMPVViaPackageWithOutput(cr, constants.UITypeNone, false) } else { err = li.InstallCelluloidViaPackageWithOutput(cr, false) } require.NoError(t, err) after, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, before, after) info, err := os.Stat(path) require.NoError(t, err) require.Equal(t, os.FileMode(0600), info.Mode().Perm()) }) } }