package installer import ( "context" "encoding/base64" "errors" "io" "net/http" "os" "path/filepath" "strings" "testing" "gitgud.io/mike/mpv-manager/internal/assets" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // serveByURL configures the mock HTTP client to serve per-URL bodies. func serveByURL(mc *MockHTTPClient, bodies map[string][]byte) { mc.DoFunc = func(req *http.Request) (*http.Response, error) { body, ok := bodies[req.URL.String()] if !ok { return &http.Response{ StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), }, nil } return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(body))), ContentLength: int64(len(body)), }, nil } } func TestInstallUOSCWithOutput_MockFlow(t *testing.T) { isolateConfig(t) zipBytes := makeZipBytes(t, map[string][]byte{ "scripts/uosc/main.lua": []byte("-- module"), "fonts/uosc_icons.otf": []byte("icons"), "fonts/uosc_textures.ttf": []byte("textures"), "script-opts/uosc.conf": []byte("archive default"), }) confBytes := []byte("fake-uosc-conf") release := ReleaseInfo{} release.UOSC.URL = "https://example.invalid/uosc.zip" release.UOSC.BLAKE3 = blake3HashOf(zipBytes) release.UOSC.ConfURL = "https://example.invalid/uosc.conf" release.UOSC.ConfBLAKE3 = blake3HashOf(confBytes) release.UOSC.AppVersion = "0.0.0-test" t.Run("full install through injected HTTP client and native extractor", func(t *testing.T) { installDir := t.TempDir() mc := NewMockHTTPClient() serveByURL(mc, map[string][]byte{ release.UOSC.URL: zipBytes, release.UOSC.ConfURL: confBytes, }) me := NewMockCommandExecutor() inst := NewInstaller(release, installDir, WithHTTPClient(mc), WithExecutor(me)) cr, out := newBufferedRunner() require.NoError(t, inst.InstallUOSCWithOutput(cr, installDir)) // Both artifacts were downloaded via the injected client. require.Len(t, mc.GetRequests(), 2) assert.Empty(t, me.GetCommands(), "uOSC extraction must not invoke a platform command") // The config landed in script-opts and the version was recorded. conf, err := os.ReadFile(filepath.Join(installDir, constants.ScriptOptsDir, "uosc.conf")) require.NoError(t, err) assert.Equal(t, confBytes, conf) assert.Equal(t, "0.0.0-test", config.GetUOSCVersion()) assertTransactionFile(t, filepath.Join(installDir, "fonts", constants.UOSCIconsFontFile), "icons") assertTransactionFile(t, filepath.Join(installDir, "fonts", constants.UOSCTexturesFontFile), "textures") assert.FileExists(t, filepath.Join(installDir, "scripts", "uosc", "main.lua")) // The bundled thumbfast script was installed and the zip was cleaned up. assert.FileExists(t, filepath.Join(installDir, constants.ScriptsDir, "thumbfast.lua")) assert.NoFileExists(t, filepath.Join(installDir, "uosc.zip")) assert.Contains(t, drainOutput(out), "uOSC installed successfully!") }) t.Run("checksum mismatch aborts before extraction", func(t *testing.T) { installDir := t.TempDir() badRelease := release badRelease.UOSC.BLAKE3 = "blake3:" + strings.Repeat("0", 64) mc := NewMockHTTPClient() serveByURL(mc, map[string][]byte{ badRelease.UOSC.URL: zipBytes, badRelease.UOSC.ConfURL: confBytes, }) me := NewMockCommandExecutor() inst := NewInstaller(badRelease, installDir, WithHTTPClient(mc), WithExecutor(me)) cr, _ := newBufferedRunner() err := inst.InstallUOSCWithOutput(cr, installDir) require.Error(t, err) assert.Contains(t, err.Error(), "uOSC checksum verification failed") assert.Empty(t, me.GetCommands(), "extraction must not run after a checksum failure") assert.NoFileExists(t, filepath.Join(installDir, "uosc.zip")) assert.NoFileExists(t, filepath.Join(installDir, "uosc.conf")) }) t.Run("download failure propagates", func(t *testing.T) { installDir := t.TempDir() mc := NewMockHTTPClient() mc.DoFunc = func(req *http.Request) (*http.Response, error) { return nil, errors.New("network down") } inst := NewInstaller(release, installDir, WithHTTPClient(mc)) cr, _ := newBufferedRunner() err := inst.InstallUOSCWithOutput(cr, installDir) require.Error(t, err) assert.Contains(t, err.Error(), "network down") }) } func TestInstallModernZWithOutput_MockFlow(t *testing.T) { isolateConfig(t) scriptBytes := []byte("-- modernz lua") fontBytes := []byte("fake-font") confBytes := []byte("# modernz conf\n") release := ReleaseInfo{} release.ModernZ.ScriptURL = "https://example.invalid/modernz.lua" release.ModernZ.ScriptBLAKE3 = blake3HashOf(scriptBytes) release.ModernZ.FontURL = "https://example.invalid/modernz-icons.ttf" release.ModernZ.FontBLAKE3 = blake3HashOf(fontBytes) release.ModernZ.ConfURL = "https://example.invalid/modernz.conf" release.ModernZ.ConfBLAKE3 = blake3HashOf(confBytes) release.ModernZ.AppVersion = "0.0.0-test" // The happy path is covered by TestInstallModernZAppliesManagedScalingDefaults. t.Run("script checksum mismatch cleans up staging files", func(t *testing.T) { installDir := t.TempDir() badRelease := release badRelease.ModernZ.ScriptBLAKE3 = "blake3:" + strings.Repeat("0", 64) mc := NewMockHTTPClient() serveByURL(mc, map[string][]byte{ badRelease.ModernZ.ScriptURL: scriptBytes, badRelease.ModernZ.FontURL: fontBytes, badRelease.ModernZ.ConfURL: confBytes, }) inst := NewInstaller(badRelease, installDir, WithHTTPClient(mc)) cr, _ := newBufferedRunner() err := inst.InstallModernZWithOutput(cr, installDir) require.Error(t, err) assert.Contains(t, err.Error(), "ModernZ script checksum verification failed") assert.NoFileExists(t, filepath.Join(installDir, "modernz.lua.download")) assert.NoFileExists(t, filepath.Join(installDir, constants.ScriptsDir, "modernz.lua")) }) } func TestUpdateFFmpegWithOutput_ErrorPaths(t *testing.T) { isolateConfig(t) fixture, err := base64.StdEncoding.DecodeString(sevenZipFixtureBase64) require.NoError(t, err) newRelease := func() ReleaseInfo { var release ReleaseInfo release.FFmpeg.X8664.URL = "https://example.invalid/ffmpeg.7z" release.FFmpeg.X8664.BLAKE3 = blake3HashOf(fixture) release.FFmpeg.X8664v3.URL = release.FFmpeg.X8664.URL release.FFmpeg.X8664v3.BLAKE3 = release.FFmpeg.X8664.BLAKE3 release.FFmpeg.Aarch64 = release.FFmpeg.X8664 release.FFmpeg.AppVersion = "0.0.0-test" return release } t.Run("missing manifest URL fails closed", func(t *testing.T) { inst := NewInstaller(ReleaseInfo{}, t.TempDir()) cr, _ := newBufferedRunner() err := inst.UpdateFFmpegWithOutput(cr, t.TempDir()) require.Error(t, err) assert.Contains(t, err.Error(), "release manifest unavailable") }) t.Run("missing checksum fails closed before downloading", func(t *testing.T) { release := newRelease() release.FFmpeg.X8664.BLAKE3 = "" release.FFmpeg.X8664v3.BLAKE3 = "" release.FFmpeg.Aarch64.BLAKE3 = "" mc := NewMockHTTPClient() inst := NewInstaller(release, t.TempDir(), WithHTTPClient(mc)) cr, _ := newBufferedRunner() err := inst.UpdateFFmpegWithOutput(cr, t.TempDir()) require.Error(t, err) assert.Contains(t, err.Error(), "without a BLAKE3 checksum") assert.Empty(t, mc.GetRequests()) }) t.Run("download failure propagates", func(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusBadGateway, nil) inst := NewInstaller(newRelease(), t.TempDir(), WithHTTPClient(mc)) cr, _ := newBufferedRunner() err := inst.UpdateFFmpegWithOutput(cr, t.TempDir()) require.Error(t, err) assert.Contains(t, err.Error(), "failed to download FFmpeg") }) t.Run("checksum mismatch propagates", func(t *testing.T) { release := newRelease() release.FFmpeg.X8664.BLAKE3 = "blake3:" + strings.Repeat("0", 64) release.FFmpeg.X8664v3.BLAKE3 = release.FFmpeg.X8664.BLAKE3 release.FFmpeg.Aarch64.BLAKE3 = release.FFmpeg.X8664.BLAKE3 mc := NewMockHTTPClient() serveBytes(mc, http.StatusOK, fixture) inst := NewInstaller(release, t.TempDir(), WithHTTPClient(mc)) cr, _ := newBufferedRunner() err := inst.UpdateFFmpegWithOutput(cr, t.TempDir()) require.Error(t, err) assert.Contains(t, err.Error(), "FFmpeg checksum verification failed") }) t.Run("archive without ffmpeg.exe is rejected", func(t *testing.T) { // The fixture contains bin/mpv.exe but no ffmpeg.exe, so a successful // extraction still fails the post-extraction check. installDir := t.TempDir() mc := NewMockHTTPClient() serveBytes(mc, http.StatusOK, fixture) inst := NewInstaller(newRelease(), installDir, WithHTTPClient(mc)) cr, _ := newBufferedRunner() err := inst.UpdateFFmpegWithOutput(cr, installDir) require.Error(t, err) assert.Contains(t, err.Error(), "could not find ffmpeg.exe in extracted archive") }) } func TestBackupAndRestoreConfigWithOutput(t *testing.T) { isolateConfig(t) configPath, err := GetMPVConfigPath() require.NoError(t, err) configDir := filepath.Dir(configPath) require.NoError(t, os.MkdirAll(configDir, 0755)) inst := NewInstaller(ReleaseInfo{}, "") t.Run("backup without config fails", func(t *testing.T) { cr, _ := newBufferedRunner() err := inst.BackupConfigWithOutput(cr) require.Error(t, err) assert.Contains(t, err.Error(), "no mpv.conf found") }) t.Run("backup then restore round-trips the config", func(t *testing.T) { require.NoError(t, os.WriteFile(configPath, []byte("original config"), 0644)) cr, out := newBufferedRunner() require.NoError(t, inst.BackupConfigWithOutput(cr)) assert.Contains(t, drainOutput(out), "Backup created successfully!") backupDir, err := GetConfigBackupDir() require.NoError(t, err) entries, err := os.ReadDir(backupDir) require.NoError(t, err) require.Len(t, entries, 1) backupPath := filepath.Join(backupDir, entries[0].Name()) require.NoError(t, os.WriteFile(configPath, []byte("changed config"), 0644)) cr, out = newBufferedRunner() require.NoError(t, inst.RestoreConfigWithOutput(cr, backupPath)) assert.Contains(t, drainOutput(out), "Configuration restored successfully!") data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "original config", string(data)) }) t.Run("restore with an invalid path fails", func(t *testing.T) { cr, _ := newBufferedRunner() err := inst.RestoreConfigWithOutput(cr, filepath.Join(t.TempDir(), "mpv.conf")) require.Error(t, err) assert.Contains(t, err.Error(), "failed to restore backup") }) } func TestDisableOSCForCelluloid(t *testing.T) { isolateConfig(t) configPath, err := GetMPVConfigPath() require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) inst := NewInstaller(ReleaseInfo{}, "") t.Run("rewrites osc=yes to osc=no", func(t *testing.T) { require.NoError(t, os.WriteFile(configPath, []byte("osc=yes\nfullscreen=no\n"), 0644)) cr, _ := newBufferedRunner() require.NoError(t, inst.DisableOSCForCelluloid(cr)) data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "osc=no\nfullscreen=no\n", string(data)) }) t.Run("missing osc setting is explicitly disabled", func(t *testing.T) { require.NoError(t, os.WriteFile(configPath, []byte("fullscreen=no\n"), 0644)) cr, out := newBufferedRunner() require.NoError(t, inst.DisableOSCForCelluloid(cr)) assert.Contains(t, drainOutput(out), "OSC disabled successfully for Celluloid!") data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "fullscreen=no\nosc=no\n", string(data)) }) t.Run("missing config fails", func(t *testing.T) { require.NoError(t, os.Remove(configPath)) cr, _ := newBufferedRunner() err := inst.DisableOSCForCelluloid(cr) require.Error(t, err) assert.Contains(t, err.Error(), "failed to read mpv.conf") }) } func TestDownloadFileWithProgress_MockHTTP(t *testing.T) { body := []byte("artifact-bytes") t.Run("success with progress callback", func(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusOK, body) inst := NewInstaller(ReleaseInfo{}, "", WithHTTPClient(mc)) var gotWritten, gotTotal int64 dest := filepath.Join(t.TempDir(), "a.bin") require.NoError(t, inst.DownloadFileWithProgress("https://example.invalid/a.bin", dest, func(written, total int64) { gotWritten, gotTotal = written, total })) data, err := os.ReadFile(dest) require.NoError(t, err) assert.Equal(t, body, data) assert.Equal(t, int64(len(body)), gotWritten) assert.Equal(t, int64(len(body)), gotTotal) }) t.Run("success with unknown length", func(t *testing.T) { mc := NewMockHTTPClient() mc.DoFunc = func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(body))), // ContentLength zero: the plain copy branch is used. }, nil } inst := NewInstaller(ReleaseInfo{}, "", WithHTTPClient(mc)) dest := filepath.Join(t.TempDir(), "a.bin") require.NoError(t, inst.DownloadFileWithProgress("https://example.invalid/a.bin", dest, nil)) data, err := os.ReadFile(dest) require.NoError(t, err) assert.Equal(t, body, data) }) t.Run("request failure propagates", func(t *testing.T) { mc := NewMockHTTPClient() mc.DoFunc = func(req *http.Request) (*http.Response, error) { return nil, errors.New("connection refused") } inst := NewInstaller(ReleaseInfo{}, "", WithHTTPClient(mc)) err := inst.DownloadFileWithProgress("https://example.invalid/a.bin", filepath.Join(t.TempDir(), "a.bin"), nil) require.Error(t, err) assert.Contains(t, err.Error(), "connection refused") }) t.Run("http error status", func(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusNotFound, nil) inst := NewInstaller(ReleaseInfo{}, "", WithHTTPClient(mc)) err := inst.DownloadFileWithProgress("https://example.invalid/a.bin", filepath.Join(t.TempDir(), "a.bin"), nil) require.Error(t, err) assert.Contains(t, err.Error(), "download failed: HTTP 404") }) t.Run("uncreatable destination fails", func(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusOK, body) inst := NewInstaller(ReleaseInfo{}, "", WithHTTPClient(mc)) // A directory at the destination path makes os.Create fail. dest := t.TempDir() err := inst.DownloadFileWithProgress("https://example.invalid/a.bin", dest, nil) require.Error(t, err) }) t.Run("malformed URL fails request construction", func(t *testing.T) { inst := NewInstaller(ReleaseInfo{}, "") err := inst.DownloadFileWithProgress("://bad-url", filepath.Join(t.TempDir(), "a.bin"), nil) require.Error(t, err) }) } func TestDownloadFileWithContext_CancelledContext(t *testing.T) { mc := NewMockHTTPClient() serveBytes(mc, http.StatusOK, []byte("artifact-bytes")) inst := NewInstaller(ReleaseInfo{}, "", WithDownloader(NewHTTPDownloader(mc))) ctx, cancel := context.WithCancel(context.Background()) cancel() dest := filepath.Join(t.TempDir(), "a.bin") err := inst.DownloadFileWithContext(ctx, "https://example.invalid/a.bin", dest, nil) require.Error(t, err) assert.Contains(t, err.Error(), "download cancelled") assert.NoFileExists(t, dest) } func TestSetOSCConfig(t *testing.T) { inst := NewInstaller(ReleaseInfo{}, "") t.Run("creates a missing config", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "mpv.conf") cr, out := newBufferedRunner() require.NoError(t, inst.SetOSCConfig(cr, configPath, "no")) data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "osc=no\n", string(data)) assert.Contains(t, drainOutput(out), "mpv.conf not found, creating new file with osc setting...") }) t.Run("updates an existing osc line", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "mpv.conf") require.NoError(t, os.WriteFile(configPath, []byte("osc=yes\nfullscreen=no\n"), 0644)) cr, out := newBufferedRunner() require.NoError(t, inst.SetOSCConfig(cr, configPath, "no")) data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "osc=no\nfullscreen=no\n", string(data)) assert.Contains(t, drainOutput(out), "Updated: osc=yes -> osc=no") }) t.Run("normalizes duplicate osc entries using the effective value", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "mpv.conf") require.NoError(t, os.WriteFile(configPath, []byte("osc=yes # old\nfullscreen=no\nosc=no # effective\n"), 0644)) cr, out := newBufferedRunner() require.NoError(t, inst.SetOSCConfig(cr, configPath, "yes")) data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "# old\nfullscreen=no\nosc=yes # effective\n", string(data)) assert.Equal(t, 1, strings.Count(string(data), "osc=")) assert.Contains(t, drainOutput(out), "Normalized 1 duplicate osc setting(s); effective value no -> yes") }) t.Run("already-correct value is a no-op", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "mpv.conf") require.NoError(t, os.WriteFile(configPath, []byte("osc=no\n"), 0644)) cr, out := newBufferedRunner() require.NoError(t, inst.SetOSCConfig(cr, configPath, "no")) assert.Contains(t, drainOutput(out), "osc=no is already set correctly") }) t.Run("appends osc when missing", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "mpv.conf") require.NoError(t, os.WriteFile(configPath, []byte("fullscreen=no"), 0644)) cr, out := newBufferedRunner() require.NoError(t, inst.SetOSCConfig(cr, configPath, "yes")) data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "fullscreen=no\nosc=yes\n", string(data)) assert.Contains(t, drainOutput(out), "Added: osc=yes") }) t.Run("unreadable config fails", func(t *testing.T) { // A directory at the config path makes ReadFile fail (not IsNotExist). cr, _ := newBufferedRunner() err := inst.SetOSCConfig(cr, t.TempDir(), "no") require.Error(t, err) assert.Contains(t, err.Error(), "failed to read mpv.conf") }) } func TestInstallUISafely_ModernZSuccess(t *testing.T) { isolateConfig(t) scriptBytes := []byte("-- modernz lua") fontBytes := []byte("fake-font") confBytes := []byte("# modernz conf\n") release := ReleaseInfo{} release.ModernZ.ScriptURL = "https://example.invalid/modernz.lua" release.ModernZ.ScriptBLAKE3 = blake3HashOf(scriptBytes) release.ModernZ.FontURL = "https://example.invalid/modernz-icons.ttf" release.ModernZ.FontBLAKE3 = blake3HashOf(fontBytes) release.ModernZ.ConfURL = "https://example.invalid/modernz.conf" release.ModernZ.ConfBLAKE3 = blake3HashOf(confBytes) release.ModernZ.AppVersion = "0.0.0-test" configDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(configDir, constants.MPVConfigFileName), []byte("osc=yes\n"), 0644)) require.NoError(t, os.MkdirAll(filepath.Join(configDir, constants.ScriptsDir), 0755)) require.NoError(t, os.WriteFile(filepath.Join(configDir, constants.ScriptsDir, "uosc.lua"), []byte("-- old uosc"), 0644)) mc := NewMockHTTPClient() serveByURL(mc, map[string][]byte{ release.ModernZ.ScriptURL: scriptBytes, release.ModernZ.FontURL: fontBytes, release.ModernZ.ConfURL: confBytes, }) inst := NewInstaller(release, configDir, WithHTTPClient(mc)) cr, out := newBufferedRunner() require.NoError(t, InstallUISafely(inst, cr, configDir, constants.UITypeModernZ)) assert.FileExists(t, filepath.Join(configDir, constants.ScriptsDir, "modernz.lua")) assert.NoFileExists(t, filepath.Join(configDir, constants.ScriptsDir, "uosc.lua")) conf, err := os.ReadFile(filepath.Join(configDir, constants.MPVConfigFileName)) require.NoError(t, err) assert.Contains(t, string(conf), "osc=no") assert.NotContains(t, drainOutput(out), "Previous UI restored after installation failure") } func TestInstallUISafely_UOSCSuccess(t *testing.T) { isolateConfig(t) zipBytes := makeZipBytes(t, map[string][]byte{"scripts/uosc.lua": []byte("-- uosc")}) confBytes := []byte("fake-uosc-conf") release := ReleaseInfo{} release.UOSC.URL = "https://example.invalid/uosc.zip" release.UOSC.BLAKE3 = blake3HashOf(zipBytes) release.UOSC.ConfURL = "https://example.invalid/uosc.conf" release.UOSC.ConfBLAKE3 = blake3HashOf(confBytes) release.UOSC.AppVersion = "0.0.0-test" configDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(configDir, constants.MPVConfigFileName), []byte("osc=no\n"), 0644)) mc := NewMockHTTPClient() serveByURL(mc, map[string][]byte{ release.UOSC.URL: zipBytes, release.UOSC.ConfURL: confBytes, }) me := NewMockCommandExecutor() inst := NewInstaller(release, configDir, WithHTTPClient(mc), WithExecutor(me)) cr, _ := newBufferedRunner() require.NoError(t, InstallUISafely(inst, cr, configDir, constants.UITypeUOSC)) conf, err := os.ReadFile(filepath.Join(configDir, constants.ScriptOptsDir, "uosc.conf")) require.NoError(t, err) assert.Equal(t, confBytes, conf) mpvConf, err := os.ReadFile(filepath.Join(configDir, constants.MPVConfigFileName)) require.NoError(t, err) assert.Contains(t, string(mpvConf), "osc=yes") } func TestInstallMPVConfigSafely(t *testing.T) { isolateConfig(t) inst := NewInstaller(ReleaseInfo{}, "") cr, _ := newBufferedRunner() require.NoError(t, InstallMPVConfigSafely(inst, cr)) } func TestUpdateUIWithPreservedConfig_ErrorPaths(t *testing.T) { isolateConfig(t) t.Run("unsupported UI type", func(t *testing.T) { inst := NewInstaller(ReleaseInfo{}, "") cr, _ := newBufferedRunner() err := inst.updateUIWithPreservedConfig(cr, t.TempDir(), "bogus-ui", func(string) error { return nil }) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported UI type") }) t.Run("staging failure restores the previous version", func(t *testing.T) { installDir := t.TempDir() require.NoError(t, config.SetUOSCVersion("9.9.9")) inst := NewInstaller(ReleaseInfo{}, installDir) cr, _ := newBufferedRunner() err := inst.updateUIWithPreservedConfig(cr, installDir, constants.UITypeUOSC, func(string) error { return errors.New("stage boom") }) require.Error(t, err) assert.Contains(t, err.Error(), "stage uosc update: stage boom") assert.Equal(t, "9.9.9", config.GetUOSCVersion(), "version must be restored after a staging failure") }) t.Run("cancellation before commit aborts", func(t *testing.T) { installDir := t.TempDir() ctx, cancel := context.WithCancel(context.Background()) out := make(chan string, 64) cr := NewCommandRunnerWithContext(ctx, out, nil) inst := NewInstaller(ReleaseInfo{}, installDir) err := inst.updateUIWithPreservedConfig(cr, installDir, constants.UITypeUOSC, func(stageDir string) error { cancel() // the job is cancelled while staging return nil }) require.Error(t, err) assert.Contains(t, err.Error(), "update cancelled before commit") }) } func TestPendingNotice(t *testing.T) { SetPendingNotice("hello notice") assert.Equal(t, "hello notice", GetPendingNotice()) assert.Equal(t, "hello notice", GetAndClearPendingNotice()) assert.Empty(t, GetPendingNotice()) } func TestPostInstallMessageError(t *testing.T) { assert.Equal(t, "done", PostInstallMessage{Message: "done"}.Error()) } func TestDownloadFileWrapper(t *testing.T) { body := []byte("wrapper-bytes") mc := NewMockHTTPClient() serveBytes(mc, http.StatusOK, body) inst := NewInstaller(ReleaseInfo{}, "", WithHTTPClient(mc)) dest := filepath.Join(t.TempDir(), "f.bin") require.NoError(t, inst.DownloadFile("https://example.invalid/f.bin", dest)) data, err := os.ReadFile(dest) require.NoError(t, err) assert.Equal(t, body, data) } func TestCopyAsset_BadSource(t *testing.T) { inst := NewInstaller(ReleaseInfo{}, "") err := inst.CopyAsset(assets.MPVConfig, "does-not-exist.conf", filepath.Join(t.TempDir(), "out.conf")) require.Error(t, err) } func TestEnsureDirWithOutput_Failure(t *testing.T) { mfs := NewMockFileSystem() mfs.MkdirAllFunc = func(path string, perm os.FileMode) error { return errors.New("mkdir boom") } inst := NewInstaller(ReleaseInfo{}, "", WithFileSystem(mfs)) cr, out := newBufferedRunner() err := inst.EnsureDirWithOutput(cr, "/x") require.Error(t, err) assert.Equal(t, "mkdir boom", err.Error()) assert.Contains(t, drainOutput(out), "Error: Failed to create directory: /x") } func TestEnsureDirectory_ErrorPath(t *testing.T) { // A regular file as a parent component makes MkdirAll fail. blocker := filepath.Join(t.TempDir(), "blocker") require.NoError(t, os.WriteFile(blocker, []byte("x"), 0644)) cr, out := newBufferedRunner() err := EnsureDirectory(filepath.Join(blocker, "sub"), cr) require.Error(t, err) assertOutputContains(t, drainOutput(out), "Error: Failed to create directory") } func TestHomeAndConfigPaths(t *testing.T) { home := isolateHome(t) configPath, err := GetMPVConfigPath() require.NoError(t, err) assert.True(t, strings.HasPrefix(configPath, home)) backupDir, err := GetConfigBackupDir() require.NoError(t, err) assert.Contains(t, backupDir, constants.ConfigBackupsDir) } func TestUpdateUOSCWithOutput_MockFlow(t *testing.T) { isolateConfig(t) zipBytes := makeZipBytes(t, map[string][]byte{"scripts/uosc.lua": []byte("-- uosc")}) confBytes := []byte("upstream-uosc-conf") release := ReleaseInfo{} release.UOSC.URL = "https://example.invalid/uosc.zip" release.UOSC.BLAKE3 = blake3HashOf(zipBytes) release.UOSC.ConfURL = "https://example.invalid/uosc.conf" release.UOSC.ConfBLAKE3 = blake3HashOf(confBytes) release.UOSC.AppVersion = "0.0.1-test" installDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(installDir, constants.ScriptsDir), 0755)) require.NoError(t, os.MkdirAll(filepath.Join(installDir, constants.ScriptOptsDir), 0755)) userConf := []byte("# my tweaks\ntimeline_style=bar\n") require.NoError(t, os.WriteFile(filepath.Join(installDir, constants.ScriptsDir, "uosc.lua"), []byte("-- old"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(installDir, constants.ScriptOptsDir, "uosc.conf"), userConf, 0600)) mc := NewMockHTTPClient() serveByURL(mc, map[string][]byte{ release.UOSC.URL: zipBytes, release.UOSC.ConfURL: confBytes, }) me := NewMockCommandExecutor() inst := NewInstaller(release, installDir, WithHTTPClient(mc), WithExecutor(me)) cr, out := newBufferedRunner() require.NoError(t, inst.UpdateUOSCWithOutput(cr, installDir)) // The user's live config survives the update byte for byte. live, err := os.ReadFile(filepath.Join(installDir, constants.ScriptOptsDir, "uosc.conf")) require.NoError(t, err) assert.Equal(t, userConf, live) // Staged files (thumbfast) were committed. assert.FileExists(t, filepath.Join(installDir, constants.ScriptsDir, "thumbfast.lua")) assert.Equal(t, "0.0.1-test", config.GetUOSCVersion()) assertOutputContains(t, drainOutput(out), "uOSC updated successfully!") } func TestInstallModernZWithOutput_FontAndConfChecksumFailures(t *testing.T) { isolateConfig(t) scriptBytes := []byte("-- modernz lua") fontBytes := []byte("fake-font") confBytes := []byte("# modernz conf\n") newRelease := func() ReleaseInfo { var release ReleaseInfo release.ModernZ.ScriptURL = "https://example.invalid/modernz.lua" release.ModernZ.ScriptBLAKE3 = blake3HashOf(scriptBytes) release.ModernZ.FontURL = "https://example.invalid/modernz-icons.ttf" release.ModernZ.FontBLAKE3 = blake3HashOf(fontBytes) release.ModernZ.ConfURL = "https://example.invalid/modernz.conf" release.ModernZ.ConfBLAKE3 = blake3HashOf(confBytes) return release } bodies := map[string][]byte{ "https://example.invalid/modernz.lua": scriptBytes, "https://example.invalid/modernz-icons.ttf": fontBytes, "https://example.invalid/modernz.conf": confBytes, } t.Run("font checksum mismatch", func(t *testing.T) { installDir := t.TempDir() release := newRelease() release.ModernZ.FontBLAKE3 = "blake3:" + strings.Repeat("0", 64) mc := NewMockHTTPClient() serveByURL(mc, bodies) inst := NewInstaller(release, installDir, WithHTTPClient(mc)) cr, _ := newBufferedRunner() err := inst.InstallModernZWithOutput(cr, installDir) require.Error(t, err) assert.Contains(t, err.Error(), "ModernZ font checksum verification failed") assert.NoFileExists(t, filepath.Join(installDir, "modernz.lua.download")) assert.NoFileExists(t, filepath.Join(installDir, constants.ModernZFontFile+".download")) }) t.Run("config checksum mismatch", func(t *testing.T) { installDir := t.TempDir() release := newRelease() release.ModernZ.ConfBLAKE3 = "blake3:" + strings.Repeat("0", 64) mc := NewMockHTTPClient() serveByURL(mc, bodies) inst := NewInstaller(release, installDir, WithHTTPClient(mc)) cr, _ := newBufferedRunner() err := inst.InstallModernZWithOutput(cr, installDir) require.Error(t, err) assert.Contains(t, err.Error(), "ModernZ config checksum verification failed") assert.NoFileExists(t, filepath.Join(installDir, "modernz.conf.download")) }) } func TestSetOSCDefault(t *testing.T) { inst := NewInstaller(ReleaseInfo{}, "") t.Run("missing config is a no-op", func(t *testing.T) { cr, out := newBufferedRunner() require.NoError(t, inst.SetOSCDefault(cr, filepath.Join(t.TempDir(), "mpv.conf"))) assert.Contains(t, drainOutput(out), "mpv.conf not found, nothing to modify") }) t.Run("osc=yes already set returns early", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "mpv.conf") require.NoError(t, os.WriteFile(configPath, []byte("osc=yes\n"), 0644)) cr, out := newBufferedRunner() require.NoError(t, inst.SetOSCDefault(cr, configPath)) assert.Contains(t, drainOutput(out), "osc=yes already set, no changes needed") }) t.Run("osc=no is rewritten", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "mpv.conf") require.NoError(t, os.WriteFile(configPath, []byte("osc=no\nfullscreen=no\n"), 0644)) cr, _ := newBufferedRunner() require.NoError(t, inst.SetOSCDefault(cr, configPath)) data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "osc=yes\nfullscreen=no\n", string(data)) }) t.Run("correct effective value still removes duplicates", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "mpv.conf") require.NoError(t, os.WriteFile(configPath, []byte("osc=no\nosc=yes\n"), 0644)) cr, _ := newBufferedRunner() require.NoError(t, inst.SetOSCDefault(cr, configPath)) data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "osc=yes\n", string(data)) }) t.Run("no osc line adds one", func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "mpv.conf") require.NoError(t, os.WriteFile(configPath, []byte("fullscreen=no\n"), 0644)) cr, out := newBufferedRunner() require.NoError(t, inst.SetOSCDefault(cr, configPath)) data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Contains(t, string(data), "osc=yes") assert.Contains(t, drainOutput(out), "No osc= setting found, adding osc=yes") }) t.Run("unreadable config fails", func(t *testing.T) { cr, _ := newBufferedRunner() err := inst.SetOSCDefault(cr, t.TempDir()) require.Error(t, err) assert.Contains(t, err.Error(), "failed to read mpv.conf") }) } func TestInstallUISafely_DefaultsToModernZForUnknownType(t *testing.T) { isolateConfig(t) scriptBytes := []byte("-- modernz lua") fontBytes := []byte("fake-font") confBytes := []byte("# modernz conf\n") release := ReleaseInfo{} release.ModernZ.ScriptURL = "https://example.invalid/modernz.lua" release.ModernZ.ScriptBLAKE3 = blake3HashOf(scriptBytes) release.ModernZ.FontURL = "https://example.invalid/modernz-icons.ttf" release.ModernZ.FontBLAKE3 = blake3HashOf(fontBytes) release.ModernZ.ConfURL = "https://example.invalid/modernz.conf" release.ModernZ.ConfBLAKE3 = blake3HashOf(confBytes) configDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(configDir, constants.MPVConfigFileName), []byte("osc=yes\n"), 0644)) mc := NewMockHTTPClient() serveByURL(mc, map[string][]byte{ release.ModernZ.ScriptURL: scriptBytes, release.ModernZ.FontURL: fontBytes, release.ModernZ.ConfURL: confBytes, }) inst := NewInstaller(release, configDir, WithHTTPClient(mc)) cr, _ := newBufferedRunner() require.NoError(t, InstallUISafely(inst, cr, configDir, "some-unknown-ui")) assert.FileExists(t, filepath.Join(configDir, constants.ScriptsDir, "modernz.lua")) } func TestInstallMPVConfigSafely_Failure(t *testing.T) { isolateConfig(t) // A regular file where the config directory should be makes // EnsureDirectory fail inside InstallMPVConfigWithOutput. configPath, err := GetMPVConfigPath() require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Dir(configPath)), 0755)) require.NoError(t, os.WriteFile(filepath.Dir(configPath), []byte("blocker"), 0644)) inst := NewInstaller(ReleaseInfo{}, "") cr, out := newBufferedRunner() err = InstallMPVConfigSafely(inst, cr) require.Error(t, err) assert.Contains(t, err.Error(), "install MPV config") assertOutputContains(t, drainOutput(out), "Warning: Failed to install MPV config") } func TestBackupConfigWithOutput_Failure(t *testing.T) { isolateConfig(t) configPath, err := GetMPVConfigPath() require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0755)) require.NoError(t, os.WriteFile(configPath, []byte("osc=no\n"), 0644)) // Block the conf_backups directory with a regular file. require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(configPath), constants.ConfigBackupsDir), []byte("blocker"), 0644)) inst := NewInstaller(ReleaseInfo{}, "") cr, _ := newBufferedRunner() err = inst.BackupConfigWithOutput(cr) require.Error(t, err) assert.Contains(t, err.Error(), "failed to create backup") } func TestInstallerZeroValueFallbacks(t *testing.T) { var nilInstaller *Installer clone := nilInstaller.CloneForOperation(ReleaseInfo{Version: "v"}, "/dir") assert.Equal(t, "v", clone.ReleaseInfo.Version) assert.Equal(t, "/dir", clone.InstallDir) // Zero-value Installer falls back to production defaults. zero := &Installer{} assert.NotNil(t, zero.downloaderOrDefault()) assert.NotNil(t, zero.http()) assert.NotNil(t, zero.fileSystem()) fallback := NewCommandRunner(nil, nil) assert.Equal(t, fallback, zero.executorOr(fallback)) } func TestDownloadFileWithProgressToChannel_CancelledContext(t *testing.T) { mc := NewMockHTTPClient() mc.DoFunc = func(req *http.Request) (*http.Response, error) { return nil, errors.New("socket gone") } inst := NewInstaller(ReleaseInfo{}, "", WithHTTPClient(mc)) ctx, cancel := context.WithCancel(context.Background()) cancel() out := make(chan string, 16) cr := NewCommandRunnerWithContext(ctx, out, nil) err := inst.DownloadFileWithProgressToChannel(cr, "https://example.invalid/f.bin", filepath.Join(t.TempDir(), "f.bin")) require.Error(t, err) assert.Contains(t, err.Error(), "download cancelled") } // TestUOSCReleasedArchive exercises the actual reviewed release without adding // a network dependency to unit tests. Native QA supplies the verified archive. func TestUOSCReleasedArchive(t *testing.T) { archive := os.Getenv("MPV_MANAGER_QA_UOSC_ARCHIVE") if archive == "" { t.Skip("native QA supplies the reviewed uOSC 5.13.0 archive") } isolateConfig(t) zipBytes, err := os.ReadFile(archive) require.NoError(t, err) require.Equal(t, "blake3:c9563d8f7f26e51db99a40e53dcedeca48fbcfafd60d1a01cadab150fb03f66f", blake3HashOf(zipBytes)) conf := []byte("timeline_size=40\n") var release ReleaseInfo release.UOSC.URL = "https://fixture.invalid/uosc.zip" release.UOSC.BLAKE3 = blake3HashOf(zipBytes) release.UOSC.ConfURL = "https://fixture.invalid/uosc.conf" release.UOSC.ConfBLAKE3 = blake3HashOf(conf) release.UOSC.AppVersion = "5.13.0" client := NewMockHTTPClient() serveByURL(client, map[string][]byte{release.UOSC.URL: zipBytes, release.UOSC.ConfURL: conf}) live := t.TempDir() inst := NewInstaller(release, live, WithHTTPClient(client)) run := func(ui string) { cr, _ := newBufferedRunner(); require.NoError(t, InstallUISafely(inst, cr, live, ui)) } run(constants.UITypeUOSC) for _, font := range []string{constants.UOSCIconsFontFile, constants.UOSCTexturesFontFile} { info, err := os.Stat(filepath.Join(live, "fonts", font)) require.NoError(t, err) require.Positive(t, info.Size()) } require.FileExists(t, filepath.Join(live, "scripts", "uosc", "main.lua")) writeTransactionFile(t, filepath.Join(live, "script-opts", "uosc.conf"), "# custom\ntimeline_size=52\n") writeTransactionFile(t, filepath.Join(live, "fonts", "user.ttf"), "user font") run(constants.UITypeUOSC) assertTransactionFile(t, filepath.Join(live, "script-opts", "uosc.conf"), "# custom\ntimeline_size=52\n") run(constants.UITypeNone) require.NoDirExists(t, filepath.Join(live, "scripts", "uosc")) require.NoFileExists(t, filepath.Join(live, "fonts", constants.UOSCIconsFontFile)) require.NoFileExists(t, filepath.Join(live, "fonts", constants.UOSCTexturesFontFile)) assertTransactionFile(t, filepath.Join(live, "fonts", "user.ttf"), "user font") }