diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/gameTargets.test.ts | 50 | ||||
| -rw-r--r-- | tests/nowPlaying.test.ts | 114 | ||||
| -rw-r--r-- | tests/steamLaunchOptions.test.ts | 219 | ||||
| -rw-r--r-- | tests/test_configuration_profiles.py | 114 | ||||
| -rw-r--r-- | tests/test_flatpak_profile_service.py | 233 | ||||
| -rw-r--r-- | tests/test_flatpak_service.py | 320 | ||||
| -rw-r--r-- | tests/test_installation_cleanup.py | 63 | ||||
| -rw-r--r-- | tests/test_plugin_migration.py | 101 | ||||
| -rw-r--r-- | tests/test_steam_service.py | 76 | ||||
| -rw-r--r-- | tests/test_wrapper_service.py | 210 |
10 files changed, 1500 insertions, 0 deletions
diff --git a/tests/gameTargets.test.ts b/tests/gameTargets.test.ts new file mode 100644 index 0000000..2eb8b7e --- /dev/null +++ b/tests/gameTargets.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getTargetSource, mergeGameTargets, targetsForSource } from "../src/utils/gameTargets.ts"; + +const config = (appid: string, profile: string) => ({ appid, profile, config: {} }); + +test("workaround sidecar source wins over current discovery metadata", () => { + const installed = [{ appid: "123", name: "Shortcut", nonSteam: false }]; + const workarounds = [{ appid: "123", non_steam: true, command_token_added: true }]; + + assert.equal(getTargetSource("123", installed, workarounds), "nonSteam"); + assert.equal(mergeGameTargets([config("123", "Shortcut")], installed, workarounds)[0].source, "nonSteam"); +}); + +test("configured profiles without reliable source are unknown", () => { + const targets = mergeGameTargets([config("456", "Missing Game")], [], []); + + assert.deepEqual(targets[0], { + appid: "456", + name: "Missing Game", + nonSteam: false, + source: "unknown", + configured: true, + }); +}); + +test("unknown configured profiles are visible in both source tabs", () => { + const targets = mergeGameTargets([ + config("123", "Steam Game"), + config("456", "Missing Game"), + ], [ + { appid: "123", name: "Steam Game", nonSteam: false }, + { appid: "789", name: "Shortcut", nonSteam: true }, + ], []); + + const steamTargets = targetsForSource(targets, "steam"); + const nonSteamTargets = targetsForSource(targets, "nonSteam"); + + assert.deepEqual(steamTargets.map((target) => target.appid).sort(), ["123", "456"]); + assert.deepEqual(nonSteamTargets.map((target) => target.appid).sort(), ["456", "789"]); +}); + +test("direct Flatpak shortcuts remain non-Steam targets", () => { + const targets = mergeGameTargets([], [ + { appid: "123", name: "Flatpak shortcut", nonSteam: true, isFlatpakShortcut: true }, + ], []); + + assert.equal(targets[0].source, "nonSteam"); + assert.equal(targets[0].isFlatpakShortcut, true); +}); diff --git a/tests/nowPlaying.test.ts b/tests/nowPlaying.test.ts new file mode 100644 index 0000000..a6b116a --- /dev/null +++ b/tests/nowPlaying.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveNowPlayingTarget, selectMostRecentRunningFlatpak } from "../src/utils/nowPlaying.ts"; + +const flatpak = (app_id: string, app_name = app_id) => ({ + app_id, + app_name, + runtime_ready: true, + prepared: true, + owned: true, + enabled: true, + profile: `flatpak:${app_id}`, + workarounds: { + dxvkFrameRate: 0, + disableGamescopeWsi: true, + disableHdr: true, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, + }, +}); + +const game = (nonSteam = true, configured = true) => ({ + appid: "123456", + name: nonSteam ? "1080 Snowboarding" : "Native Game", + nonSteam, + source: nonSteam ? "nonSteam" : "steam", + configured, +}); + +test("selects the newest active managed Flatpak", () => { + const apps = [flatpak("org.example.old"), flatpak("org.example.new")]; + const running = [ + { app_id: "org.example.old", active: true, pid: "100", start_time: 500 }, + { app_id: "org.example.new", active: true, pid: "200", start_time: 600 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.new"); +}); + +test("prefers active Flatpak status before process age", () => { + const apps = [flatpak("org.example.running"), flatpak("org.example.active")]; + const running = [ + { app_id: "org.example.running", active: false, pid: "900", start_time: 900 }, + { app_id: "org.example.active", active: true, pid: "100", start_time: 100 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.active"); +}); + +test("deduplicates multiple process rows for one managed Flatpak", () => { + const apps = [flatpak("com.heroicgameslauncher.hgl")]; + const running = [ + { app_id: "com.heroicgameslauncher.hgl", active: false, pid: "228081", start_time: null }, + { app_id: "com.heroicgameslauncher.hgl", active: false, pid: "228116", start_time: null }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "com.heroicgameslauncher.hgl"); +}); + +test("Flatpak runtime wins while a Steam shortcut is running", () => { + const target = resolveNowPlayingTarget(game(true), flatpak("org.libretro.RetroArch", "RetroArch")); + + assert.equal(target?.kind, "flatpak"); + assert.equal(target?.kind === "flatpak" ? target.launcher?.name : null, "1080 Snowboarding"); +}); + +test("native Steam game wins over an unrelated Flatpak", () => { + assert.equal(resolveNowPlayingTarget(game(false), flatpak("org.example.Game"))?.kind, "steam"); +}); + +test("unconfigured native Steam game blocks unrelated Flatpak Now Playing", () => { + assert.equal(resolveNowPlayingTarget(game(false, false), flatpak("org.example.Game")), null); +}); + +test("direct Flatpak launch creates a Flatpak Now Playing target", () => { + const target = resolveNowPlayingTarget(null, flatpak("org.example.Game")); + + assert.equal(target?.kind, "flatpak"); + assert.equal(target?.kind === "flatpak" ? target.launcher : null, null); +}); + +test("multiple inactive Flatpaks do not create an arbitrary Now Playing target", () => { + const apps = [flatpak("org.example.one"), flatpak("org.example.two")]; + const running = [ + { app_id: "org.example.one", active: false, pid: "100", start_time: 500 }, + { app_id: "org.example.two", active: false, pid: "200", start_time: 600 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running), null); +}); + +test("one inactive Flatpak remains a usable fallback", () => { + const apps = [flatpak("org.example.one")]; + const running = [{ app_id: "org.example.one", active: false, pid: "100", start_time: 500 }]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.one"); +}); + +test("configured Steam target remains the fallback", () => { + const target = resolveNowPlayingTarget(game(false), null); + + assert.equal(target?.kind, "steam"); +}); + +test("configured non-Steam target remains the fallback", () => { + const target = resolveNowPlayingTarget(game(true), null); + + assert.equal(target?.kind, "nonSteam"); +}); + +test("unconfigured Steam target has no Now Playing controls", () => { + assert.equal(resolveNowPlayingTarget(game(false, false), null), null); +}); diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts new file mode 100644 index 0000000..1d2f762 --- /dev/null +++ b/tests/steamLaunchOptions.test.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + cleanupPluginAssignments, + cleanupPluginLaunchOptions, + cleanupLegacyWrapper, + hasWrapperLaunchIntegration, + installWrapperIntegration, + installWrapperLaunchOption, + isLegacyWrapperToken, + normalizeLaunchOptions, + readSteamLaunchOptions, + removeWrapperIntegration, + removeWrapperLaunchOption, +} from "../src/utils/steamLaunchOptions.ts"; + +const wrapper = "~/.lsfg"; + +test("inserts one wrapper immediately before an existing command macro", () => { + assert.deepEqual(installWrapperLaunchOption('gamemoderun %command% --profile "high quality"', wrapper), { + options: 'gamemoderun ~/.lsfg %command% --profile "high quality"', + commandTokenAdded: false, + }); + assert.equal(hasWrapperLaunchIntegration(`gamemoderun ${wrapper} %command%`, wrapper), true); + assert.deepEqual(installWrapperLaunchOption(`gamemoderun ${wrapper} %command%`, wrapper), { + options: `gamemoderun ${wrapper} %command%`, + commandTokenAdded: false, + }); +}); + +test("normalizes blank, malformed, and argument-only launch fields", () => { + assert.deepEqual(installWrapperLaunchOption("", wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: true, + }); + assert.deepEqual(installWrapperLaunchOption("FOO=bar --windowed", wrapper), { + options: `FOO=bar ${wrapper} %command% --windowed`, + commandTokenAdded: true, + }); + assert.deepEqual(installWrapperLaunchOption("gamemoderun --windowed", wrapper), { + options: `${wrapper} %command% gamemoderun --windowed`, + commandTokenAdded: true, + }); + assert.deepEqual(installWrapperLaunchOption('"%command%"', wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: false, + }); + assert.deepEqual(installWrapperLaunchOption(`${wrapper} %command`, wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: false, + }); + assert.deepEqual(installWrapperLaunchOption(`${wrapper} --windowed`, wrapper), { + options: `${wrapper} %command% --windowed`, + commandTokenAdded: true, + }); +}); + +test("preserves assignments quoting suffixes and released wrapper cleanup", () => { + const options = 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun %command% --flag "two words"'; + assert.equal( + installWrapperLaunchOption(options, wrapper).options, + 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun ~/.lsfg %command% --flag "two words"', + ); + assert.equal(removeWrapperLaunchOption(`${wrapper} %command% --arg "${wrapper}"`, wrapper, true), `--arg "${wrapper}"`); + assert.equal(normalizeLaunchOptions(" FOO=bar %COMMAND% --flag "), "FOO=bar %COMMAND% --flag"); + for (const token of ["~/lsfg", "/home/deck/lsfg", "mako-run", "mako-launch"]) { + assert.equal(cleanupLegacyWrapper(`FOO=bar ${token} %command% --arg "${token}"`), `FOO=bar %command% --arg "${token}"`); + } + assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), true); + assert.equal(isLegacyWrapperToken("/opt/tools/lsfg"), false); +}); + +test("removes only managed assignments and preserves unrelated values", () => { + assert.equal( + cleanupPluginAssignments( + 'FOO="keep this" ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" %command%', + ), + 'FOO="keep this" DXVK_CONFIG="dxgi.syncInterval = 0" %command%', + ); + assert.equal(cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper), "%command%"); + assert.equal( + cleanupPluginAssignments("PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%"), + "PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%", + ); +}); + +test("uses launch options for Steam and non-Steam shortcuts without a Target API", async () => { + const previousWindow = (globalThis as Record<string, unknown>).window; + const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; + let appOptions = "FOO=bar %command%"; + let shortcutOptions = "--windowed"; + const appWrites: string[] = []; + const shortcutWrites: string[] = []; + const unregisters: number[] = []; + const apps = { + RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) { + callback(appId === 42 + ? { strLaunchOptions: appOptions, strShortcutLaunchOptions: "wrong-field" } + : { strShortcutLaunchOptions: shortcutOptions, strLaunchOptions: "wrong-field" }); + return { unregister: () => unregisters.push(appId) }; + }, + SetAppLaunchOptions(appId: number, options: string) { + assert.equal(appId, 42); + appWrites.push(options); + appOptions = options.replaceAll(" ", " "); + }, + SetShortcutLaunchOptions(appId: number, options: string) { + assert.equal(appId, 43); + shortcutWrites.push(options); + shortcutOptions = options; + }, + }; + (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; + (globalThis as Record<string, unknown>).SteamClient = { Apps: apps }; + try { + const normal = await readSteamLaunchOptions(42, false); + assert.equal(normal.options, "FOO=bar %command%"); + const installed = await installWrapperIntegration(42, false, wrapper); + assert.equal(installed.snapshot.options, `FOO=bar ${wrapper} %command%`.replaceAll(" ", " ")); + assert.equal(installed.commandTokenAdded, false); + assert.equal(appWrites.length, 1); + + const shortcut = await installWrapperIntegration(43, true, wrapper); + assert.equal(shortcut.snapshot.options, `~/.lsfg %command% --windowed`); + assert.deepEqual(shortcutWrites, [`~/.lsfg %command% --windowed`]); + + const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.commandTokenAdded); + assert.equal(restored.options, "--windowed"); + + const cleaned = await removeWrapperIntegration(42, false, wrapper, installed.commandTokenAdded); + assert.equal(cleaned.options, "FOO=bar %command%".replaceAll(" ", " ")); + assert.ok(unregisters.includes(42)); + assert.ok(unregisters.includes(43)); + } finally { + if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; + else (globalThis as Record<string, unknown>).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient; + else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient; + } +}); + +test("AppImage EmuDeck and direct Flatpak shortcuts all stay launch-option based", async () => { + const previousWindow = (globalThis as Record<string, unknown>).window; + const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; + const cases = [ + { + options: 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"', + expected: 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"', + }, + { + options: "", + expected: "~/.lsfg %command%", + }, + { + options: "run org.example.Game", + expected: "~/.lsfg %command% run org.example.Game", + }, + ]; + (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; + try { + for (const [index, item] of cases.entries()) { + let shortcutOptions = item.options; + const shortcutWrites: string[] = []; + (globalThis as Record<string, unknown>).SteamClient = { + Apps: { + RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { + callback({ strShortcutLaunchOptions: shortcutOptions }); + return { unregister() {} }; + }, + SetShortcutLaunchOptions(_appId: number, options: string) { + shortcutWrites.push(options); + shortcutOptions = options; + }, + }, + }; + const installed = await installWrapperIntegration(100 + index, true, wrapper); + assert.equal(installed.snapshot.options, item.expected); + assert.deepEqual(shortcutWrites, [item.expected]); + const restored = await removeWrapperIntegration(100 + index, true, wrapper, installed.commandTokenAdded); + assert.equal(restored.options, item.options); + } + } finally { + if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; + else (globalThis as Record<string, unknown>).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient; + else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient; + } +}); + +test("launch option write failure rolls back the original value", async () => { + const previousWindow = (globalThis as Record<string, unknown>).window; + const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; + let appOptions = "FOO=bar %command%"; + const writes: string[] = []; + (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; + (globalThis as Record<string, unknown>).SteamClient = { + Apps: { + RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { + callback({ strLaunchOptions: appOptions }); + return { unregister() {} }; + }, + SetAppLaunchOptions(_appId: number, options: string) { + writes.push(options); + appOptions = options; + if (options.includes(wrapper)) throw new Error("simulated launch option failure"); + }, + }, + }; + try { + await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch option failure/); + assert.equal(appOptions, "FOO=bar %command%"); + assert.deepEqual(writes, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); + } finally { + if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; + else (globalThis as Record<string, unknown>).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient; + else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient; + } +}); diff --git a/tests/test_configuration_profiles.py b/tests/test_configuration_profiles.py new file mode 100644 index 0000000..898a9e7 --- /dev/null +++ b/tests/test_configuration_profiles.py @@ -0,0 +1,114 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.config_schema import ConfigurationManager +from lsfg_vk.configuration import ConfigurationService + + +class ConfigurationProfileTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.runtime = Mock() + self.service = ConfigurationService(runtime_service=self.runtime) + self.service.user_home = self.home + self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + + def tearDown(self): + self.tempdir.cleanup() + + def test_selector_only_profile_survives_round_trip(self): + content = """version = 2 + +[global] +allow_fp16 = true + +[[profile]] +name = "flatpak:org.example.Game" +pacing_mode = "vsync" +multiplier = 3 +flow_scale = 0.8 +performance_mode = false +override_present_mode = true +preserve_swapchain_image_count = false +""" + parsed = ConfigurationManager.parse_toml_content_multi_profile(content) + self.assertIn("flatpak:org.example.Game", parsed["profiles"]) + self.assertEqual(parsed["profiles"]["flatpak:org.example.Game"]["active_in"], []) + rendered = ConfigurationManager.generate_toml_content_multi_profile(parsed) + reparsed = ConfigurationManager.parse_toml_content_multi_profile(rendered) + self.assertEqual(reparsed["profiles"]["flatpak:org.example.Game"]["multiplier"], 3) + + def test_game_reset_all_preserves_flatpak_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 3}) + + result = self.service.reset_all_game_configs() + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertNotIn("Steam Game", data["profiles"]) + self.assertIn("flatpak:org.example.Game", data["profiles"]) + self.assertEqual(data["profiles"]["flatpak:org.example.Game"]["multiplier"], 3) + + def test_scoped_game_reset_is_one_write_and_preserves_other_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_game_config("456", "Non-Steam Game", {"multiplier": 3}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 4}) + + with patch.object(self.service, "_save_profile_data", wraps=self.service._save_profile_data) as save: + result = self.service.reset_game_configs(["123"]) + + data = self.service._get_profile_data() + self.assertTrue(result["success"]) + self.assertEqual(save.call_count, 1) + self.assertNotIn("Steam Game", data["profiles"]) + self.assertIn("Non-Steam Game", data["profiles"]) + self.assertIn("flatpak:org.example.Game", data["profiles"]) + + def test_flatpak_reset_all_preserves_steam_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 3}) + + result = self.service.reset_all_flatpak_configs() + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertIn("Steam Game", data["profiles"]) + self.assertNotIn("flatpak:org.example.Game", data["profiles"]) + + def test_global_config_update_does_not_change_profile_values(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + + result = self.service.update_global_config({"no_fp16": True}) + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertTrue(result["global_config"]["no_fp16"]) + self.assertTrue(data["global_config"]["no_fp16"]) + self.assertEqual(data["profiles"]["Steam Game"]["multiplier"], 2) + + def test_profile_update_cannot_overwrite_global_fp16_setting(self): + self.service.update_global_config({"no_fp16": True}) + + self.service.update_game_config("123", "Steam Game", {"multiplier": 3, "no_fp16": False}) + + data = self.service._get_profile_data() + self.assertTrue(data["global_config"]["no_fp16"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_flatpak_profile_service.py b/tests/test_flatpak_profile_service.py new file mode 100644 index 0000000..2b9933b --- /dev/null +++ b/tests/test_flatpak_profile_service.py @@ -0,0 +1,233 @@ +import hashlib +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.configuration import ConfigurationService +from lsfg_vk.flatpak_service import FlatpakService +from lsfg_vk.flatpak_profile_service import FlatpakProfileService + + +class FakeFlatpakService: + def __init__(self, home: Path): + self.user_home = home + self.config_dir = home / ".config/lsfg-vk" + self.config_file_path = self.config_dir / "conf.toml" + self.backup_dir = self.config_dir / "flatpak-overrides" + self.state = {"version": 2, "plugin_owned_branches": [], "prepared_apps": {}} + self.commands = [] + self.running = "" + self.start_times = {} + + def _read_state(self): + return self.state + + def _write_state(self, state): + self.state = state + + def _override_path(self, app_id): + return self.user_home / ".local/share/flatpak/overrides" / app_id + + def _backup_path(self, app_id): + return self.backup_dir / f"{app_id}.ini" + + @staticmethod + def _sha256(content): + return hashlib.sha256(content).hexdigest() + + def _snapshot_override(self, app_id): + path = self._override_path(app_id) + if not path.exists(): + return False, b"" + return True, path.read_bytes() + + def _process_start_time(self, pid): + return self.start_times.get(pid) + + @staticmethod + def _write_file(path, content, mode=0o644): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + path.chmod(mode) + + def prepare_app(self, app_id): + apps = self.state["prepared_apps"] + if app_id not in apps: + existed, original = self._snapshot_override(app_id) + if existed: + self._write_file(self._backup_path(app_id), original.decode("utf-8")) + apps[app_id] = {"override_existed": existed, "managed_sha256": ""} + entry = apps[app_id] + baseline = "" + if entry["override_existed"]: + baseline = self._backup_path(app_id).read_text(encoding="utf-8") + managed = baseline + "\n[Context]\nfilesystems=/config:ro;/dll:ro;\n[Environment]\nLSFGVK_CONFIG=/config/conf.toml\nLSFGVK_FLATPAK=1\n" + self._write_file(self._override_path(app_id), managed) + entry["managed_sha256"] = self._sha256(managed.encode()) + return {"success": True, "owned": True, "prepared": True, "runtime": "org.freedesktop.Platform/x86_64/24.08", "runtime_branch": "24.08"} + + def remove_app_override(self, app_id): + entry = self.state["prepared_apps"].get(app_id) + if entry is None: + return {"success": True, "prepared": False, "owned": False} + existed, current = self._snapshot_override(app_id) + current_hash = self._sha256(current) if existed else self._sha256(b"") + if current_hash != entry["managed_sha256"]: + return {"success": False, "error": "Flatpak override changed after preparation"} + path = self._override_path(app_id) + backup = self._backup_path(app_id) + if entry["override_existed"]: + self._write_file(path, backup.read_text(encoding="utf-8")) + else: + path.unlink(missing_ok=True) + backup.unlink(missing_ok=True) + self.state["prepared_apps"].pop(app_id) + return {"success": True, "prepared": False, "owned": False} + + def get_flatpak_apps(self): + app_id = "org.example.Game" + return { + "success": True, + "apps": [{ + "app_id": app_id, + "app_name": "Example Game", + "runtime": "org.freedesktop.Platform/x86_64/24.08", + "runtime_branch": "24.08", + "runtime_ready": True, + "prepared": app_id in self.state["prepared_apps"], + "owned": app_id in self.state["prepared_apps"], + "error": None, + }], + } + + def _run_flatpak_command(self, args, **_kwargs): + self.commands.append(args) + if args[:3] == ["override", "--user", "--show"]: + path = self._override_path(args[3]) + return types.SimpleNamespace(returncode=0, stdout=path.read_text(encoding="utf-8") if path.exists() else "", stderr="") + if args[0] == "override": + app_id = args[-1] + path = self._override_path(app_id) + content = path.read_text(encoding="utf-8") if path.exists() else "" + env = [item.removeprefix("--env=") for item in args if item.startswith("--env=")] + unset = [item.removeprefix("--unset-env=") for item in args if item.startswith("--unset-env=")] + if unset: + content += "\n[Context]\nunset-environment=" + ";".join(unset) + ";\n" + if env: + content += "\n[Environment]\n" + "\n".join(env) + "\n" + self._write_file(path, content) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + if args[0] == "ps": + return types.SimpleNamespace(returncode=0, stdout=self.running, stderr="") + raise AssertionError(args) + + +class FlatpakProfileServiceTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.flatpak = FakeFlatpakService(self.home) + self.runtime = Mock() + self.configuration = ConfigurationService(runtime_service=self.runtime) + self.configuration.user_home = self.home + self.configuration.config_dir = self.flatpak.config_dir + self.configuration.config_file_path = self.flatpak.config_file_path + self.service = FlatpakProfileService(self.flatpak, self.configuration) + self.app_id = "org.example.Game" + + def tearDown(self): + self.tempdir.cleanup() + + def test_enable_creates_selector_profile_and_default_workarounds(self): + result = self.service.enable_app(self.app_id) + config = self.configuration.get_flatpak_config(self.app_id) + content = self.flatpak._override_path(self.app_id).read_text(encoding="utf-8") + + self.assertTrue(result["success"]) + self.assertTrue(config["exists"]) + self.assertEqual(config["profile"], "flatpak:org.example.Game") + self.assertEqual(config["config"]["active_in"], []) + self.assertIn("LSFGVK_PROFILE=flatpak:org.example.Game", content) + self.assertIn("ENABLE_GAMESCOPE_WSI=0", content) + self.assertIn("DXVK_HDR=0", content) + + def test_workaround_update_rebuilds_from_original_override(self): + baseline = "[Environment]\nDXVK_CONFIG=dxgi.syncInterval = 0\nKEEP=yes\n" + self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline) + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + + state = self.service.default_state() + state.update({"dxvkFrameRate": 30, "disableHdr": False, "enableZink": True}) + result = self.service.set_workaround_state(self.app_id, state) + command = next( + args for args in reversed(self.flatpak.commands) + if args[0] == "override" and any(item.startswith("--env=LSFGVK_PROFILE=") for item in args) + ) + + self.assertTrue(result["success"]) + self.assertIn("--env=DXVK_CONFIG=dxgi.syncInterval = 0; dxvk.maxFrameRate = 30", command) + self.assertNotIn("--env=DXVK_HDR=0", command) + self.assertIn("--env=MESA_LOADER_DRIVER_OVERRIDE=zink", command) + + def test_config_update_returns_without_relisting_flatpaks(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + self.service.get_app = Mock(side_effect=AssertionError("config updates must not relist Flatpaks")) + + result = self.service.update_config(self.app_id, {"multiplier": 4}) + + self.assertTrue(result["success"]) + self.assertEqual(result["app_id"], self.app_id) + self.assertEqual(result["config"]["multiplier"], 4) + self.service.get_app.assert_not_called() + + def test_remove_restores_exact_original_override_and_profile(self): + baseline = "[Environment]\nKEEP=yes\n" + self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline) + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + + removed = self.service.remove_app(self.app_id) + + self.assertTrue(removed["success"]) + self.assertEqual(self.flatpak._override_path(self.app_id).read_text(encoding="utf-8"), baseline) + self.assertFalse(self.configuration.get_flatpak_config(self.app_id)["exists"]) + + def test_external_override_change_fails_closed(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + path = self.flatpak._override_path(self.app_id) + path.write_text(path.read_text(encoding="utf-8") + "EXTERNAL=yes\n", encoding="utf-8") + + result = self.service.set_workaround_state(self.app_id, self.service.default_state()) + + self.assertFalse(result["success"]) + self.assertIn("changed after preparation", result["error"]) + + def test_running_detection_uses_owned_selector_state(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + self.flatpak.running = "org.example.Game\ttrue\t1234\norg.other.App\ttrue\t9999\n" + self.flatpak.start_times["1234"] = 200 + + result = self.service.get_running_apps() + + self.assertTrue(result["success"]) + self.assertEqual(result["apps"], [{"app_id": self.app_id, "active": True, "pid": "1234", "start_time": 200}]) + + def test_process_start_time_parser_handles_parentheses_in_command_name(self): + fields = ["S"] + ["0"] * 18 + ["4242"] + stat = "1234 (retro)arch) " + " ".join(fields) + + self.assertEqual(FlatpakService._parse_process_start_time(stat), 4242) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py new file mode 100644 index 0000000..17d0016 --- /dev/null +++ b/tests/test_flatpak_service.py @@ -0,0 +1,320 @@ +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.flatpak_service import FlatpakService + + +class FlatpakServiceTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.service = FlatpakService() + self.service.user_home = self.home + self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) + self.service.check_flatpak_available = Mock(return_value=True) + self.service._run_flatpak_command = Mock(side_effect=self._run_flatpak_command) + self.runtime_ref = "org.freedesktop.Platform/x86_64/24.08" + self.runtime_metadata = "" + self.user_branches = set() + self.system_branches = set() + self.user_extension_origin = "flathub" + self.apps = {"com.example.Game": "Example Game"} + self.dll_dir = self.home / ".local/share/Steam/steamapps/common/Lossless Scaling" + self.service._dll_directory = Mock(return_value=self.dll_dir) + + def tearDown(self): + self.tempdir.cleanup() + + @staticmethod + def _result(stdout="", returncode=0, stderr=""): + return types.SimpleNamespace(stdout=stdout, returncode=returncode, stderr=stderr) + + @staticmethod + def _extension_line(branch): + return f"org.freedesktop.Platform.VulkanLayer.lsfgvk\tx86_64\t{branch}\n" + + @staticmethod + def _parse_override(content): + section = None + filesystems = [] + unset_environment = [] + environment = {} + other = [] + for raw in content.splitlines(): + line = raw.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + key, separator, value = line.partition("=") + if not separator: + continue + if section == "Context" and key == "filesystems": + filesystems.extend(item for item in value.split(";") if item) + elif section == "Context" and key == "unset-environment": + unset_environment.extend(item for item in value.split(";") if item) + elif section == "Environment": + environment[key] = value + else: + other.append((section, key, value)) + return filesystems, unset_environment, environment, other + + @staticmethod + def _serialize_override(filesystems, unset_environment, environment): + lines = ["[Context]"] + if filesystems: + lines.append("filesystems=" + ";".join(filesystems) + ";") + if unset_environment: + lines.append("unset-environment=" + ";".join(unset_environment) + ";") + if environment: + lines.append("") + lines.append("[Environment]") + lines.extend(f"{key}={value}" for key, value in environment.items()) + return "\n".join(lines) + "\n" + + def _apply_override(self, args): + app_id = args[-1] + path = self.service._override_path(app_id) + content = path.read_text(encoding="utf-8") if path.exists() else "" + filesystems, unset_environment, environment, _ = self._parse_override(content) + for arg in args[2:-1]: + if arg.startswith("--filesystem="): + value = arg.split("=", 1)[1] + if value not in filesystems: + filesystems.append(value) + elif arg.startswith("--env="): + key, value = arg.split("=", 1)[1].split("=", 1) + environment[key] = value + if key in unset_environment: + unset_environment.remove(key) + elif arg.startswith("--unset-env="): + key = arg.split("=", 1)[1] + environment.pop(key, None) + if key not in unset_environment: + unset_environment.append(key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + self._serialize_override(filesystems, unset_environment, environment), + encoding="utf-8", + ) + return self._result() + + def _run_flatpak_command(self, args, **_kwargs): + if args[:2] == ["info", "--show-runtime"]: + return self._result(self.runtime_ref + "\n") + if args[:2] == ["info", "--show-metadata"]: + return self._result(self.runtime_metadata) + if args[:3] == ["info", "--user", "--show-origin"]: + return self._result(self.user_extension_origin) + if args[:2] == ["list", "--app"]: + return self._result("".join(f"{name}\t{app_id}\n" for app_id, name in self.apps.items())) + if args[0] == "list": + branches = self.user_branches if "--user" in args else self.system_branches + return self._result("".join(self._extension_line(branch) for branch in sorted(branches))) + if args[0] == "install": + self.user_branches.add(self.runtime_ref.rsplit("/", 1)[-1]) + return self._result() + if args[0] == "uninstall": + self.user_branches.discard(args[-1].rsplit("/", 1)[-1]) + return self._result() + if args[:3] == ["override", "--user", "--show"]: + path = self.service._override_path(args[-1]) + return self._result(path.read_text(encoding="utf-8") if path.exists() else "") + if args[:2] == ["override", "--user"]: + return self._apply_override(args) + raise AssertionError(f"Unexpected Flatpak command: {args}") + + def test_resolves_freedesktop_and_derived_runtimes(self): + runtime, branch = self.service._resolve_runtime("com.example.Game") + self.assertEqual(runtime, self.runtime_ref) + self.assertEqual(branch, "24.08") + + self.runtime_ref = "org.kde.Platform/x86_64/6.10" + self.runtime_metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n" + runtime, branch = self.service._resolve_runtime("com.example.Game") + self.assertEqual(runtime, self.runtime_ref) + self.assertEqual(branch, "25.08") + + def test_clean_env_targets_deck_user_session_bus(self): + env = self.service._clean_env() + user_id = self.home.stat().st_uid + self.assertEqual(env["XDG_RUNTIME_DIR"], f"/run/user/{user_id}") + self.assertEqual(env["DBUS_SESSION_BUS_ADDRESS"], f"unix:path=/run/user/{user_id}/bus") + + def test_prepare_app_installs_runtime_and_persists_narrow_override(self): + response = self.service.prepare_app("com.example.Game") + + self.assertTrue(response["success"]) + self.assertTrue(response["prepared"]) + self.assertTrue(response["owned"]) + self.assertEqual(response["runtime_branch"], "24.08") + self.assertEqual(self.user_branches, {"24.08"}) + install_calls = [ + call.args[0] + for call in self.service._run_flatpak_command.call_args_list + if call.args[0][0] == "install" + ] + self.assertEqual( + install_calls, + [[ + "install", + "--user", + "--noninteractive", + "--or-update", + "flathub", + "org.freedesktop.Platform.VulkanLayer.lsfgvk//24.08", + ]], + ) + status = self.service._app_override_status("com.example.Game") + self.assertTrue(status["prepared"]) + content = self.service._override_path("com.example.Game").read_text(encoding="utf-8") + self.assertIn(str(self.service.config_dir) + ":ro", content) + self.assertIn(str(self.dll_dir) + ":ro", content) + self.assertIn("LSFGVK_CONFIG=" + str(self.service.config_file_path), content) + self.assertIn("LSFGVK_FLATPAK=1", content) + self.assertNotIn("ENABLE_GAMESCOPE_WSI", content) + state = json.loads(self.service.ownership_path.read_text(encoding="utf-8")) + self.assertEqual(state["plugin_owned_branches"], ["24.08"]) + self.assertIn("com.example.Game", state["prepared_apps"]) + + def test_prepare_is_idempotent(self): + first = self.service.prepare_app("com.example.Game") + first_content = self.service._override_path("com.example.Game").read_bytes() + second = self.service.prepare_app("com.example.Game") + + self.assertTrue(first["success"]) + self.assertTrue(second["success"]) + self.assertEqual(first_content, self.service._override_path("com.example.Game").read_bytes()) + install_calls = [call for call in self.service._run_flatpak_command.call_args_list if call.args[0][0] == "install"] + self.assertEqual(len(install_calls), 1) + + def test_replaces_extension_from_another_remote(self): + self.user_branches = {"24.08"} + self.user_extension_origin = "lsfgvk-origin" + + response = self.service.install_extension("24.08") + + self.assertTrue(response["success"]) + commands = [call.args[0] for call in self.service._run_flatpak_command.call_args_list] + self.assertIn( + [ + "uninstall", + "--user", + "--noninteractive", + "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/24.08", + ], + commands, + ) + self.assertEqual(self.user_branches, {"24.08"}) + + def test_preinstalled_runtime_is_not_owned(self): + self.system_branches = {"24.08"} + response = self.service.prepare_app("com.example.Game") + + self.assertTrue(response["success"]) + state = json.loads(self.service.ownership_path.read_text(encoding="utf-8")) + self.assertEqual(state["plugin_owned_branches"], []) + self.assertIn("com.example.Game", state["prepared_apps"]) + + def test_external_preparation_is_preserved(self): + path = self.service._override_path("com.example.Game") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + self._serialize_override( + [str(self.service.config_dir) + ":ro", str(self.dll_dir) + ":ro"], + ["DISABLE_LSFGVK", "DISABLE_LSFG"], + { + "LSFGVK_CONFIG": str(self.service.config_file_path), + "LSFGVK_FLATPAK": "1", + }, + ), + encoding="utf-8", + ) + self.system_branches = {"24.08"} + + response = self.service.prepare_app("com.example.Game") + + self.assertTrue(response["success"]) + self.assertTrue(response["prepared"]) + self.assertFalse(response["owned"]) + self.assertFalse(self.service.ownership_path.exists()) + + def test_remove_restores_exact_previous_override(self): + self.system_branches = {"24.08"} + original = "[Context]\nfilesystems=~/Documents;\n\n[Environment]\nFOO=bar\n" + path = self.service._override_path("com.example.Game") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(original, encoding="utf-8") + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + + response = self.service.remove_app_override("com.example.Game") + + self.assertTrue(response["success"]) + self.assertEqual(path.read_text(encoding="utf-8"), original) + self.assertFalse(self.service.ownership_path.exists()) + + def test_remove_deletes_override_created_by_plugin(self): + self.system_branches = {"24.08"} + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + path = self.service._override_path("com.example.Game") + self.assertTrue(path.exists()) + + response = self.service.remove_app_override("com.example.Game") + + self.assertTrue(response["success"]) + self.assertFalse(path.exists()) + self.assertFalse(self.service.ownership_path.exists()) + + def test_remove_fails_closed_after_external_change(self): + self.system_branches = {"24.08"} + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + path = self.service._override_path("com.example.Game") + with path.open("a", encoding="utf-8") as handle: + handle.write("EXTERNAL=1\n") + + response = self.service.remove_app_override("com.example.Game") + + self.assertFalse(response["success"]) + self.assertIn("changed after preparation", response["error"]) + self.assertTrue(path.exists()) + self.assertTrue(self.service.ownership_path.exists()) + + def test_full_cleanup_removes_only_owned_state(self): + self.system_branches = {"23.08"} + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + self.assertEqual(self.user_branches, {"24.08"}) + + response = self.service.remove_plugin_owned_environment() + + self.assertTrue(response["success"]) + self.assertEqual(response["removed_apps"], ["com.example.Game"]) + self.assertEqual(response["removed_branches"], ["24.08"]) + self.assertEqual(self.user_branches, set()) + self.assertEqual(self.system_branches, {"23.08"}) + self.assertFalse(self.service.ownership_path.exists()) + + def test_corrupt_ownership_metadata_fails_closed(self): + self.service.ownership_path.write_text("{not-json", encoding="utf-8") + + response = self.service.remove_plugin_owned_environment() + + self.assertFalse(response["success"]) + self.assertEqual(self.service._run_flatpak_command.call_count, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_installation_cleanup.py b/tests/test_installation_cleanup.py new file mode 100644 index 0000000..3336505 --- /dev/null +++ b/tests/test_installation_cleanup.py @@ -0,0 +1,63 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.base_service import BaseService +from lsfg_vk.installation import InstallationService + + +class InstallationCleanupTests(unittest.TestCase): + def test_uninstall_removes_legacy_files_and_prunes_only_empty_directories(self): + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) / "home" / "deck" + service = InstallationService.__new__(InstallationService) + BaseService.__init__(service) + service.log = Mock() + service.user_home = home + service.local_bin_dir = home / ".local/bin" + service.local_lib_dir = home / ".local/lib" + service.local_share_dir = home / ".local/share/vulkan/implicit_layer.d" + service.config_dir = home / ".config/lsfg-vk" + service.config_file_path = service.config_dir / "conf.toml" + service.legacy_script_path = home / "lsfg" + service.lib_file = service.local_lib_dir / "liblsfg-vk-layer.so" + service.lib_x86_file = service.local_lib_dir / "liblsfg-vk-layer.x86.so" + service.json_file = service.local_share_dir / "VkLayer_LSFGVK_frame_generation.json" + service.json_x86_file = service.local_share_dir / "VkLayer_LSFGVK_frame_generation.x86.json" + service.cli_file = service.local_bin_dir / "lsfg-vk-cli" + service.legacy_lib_file = service.local_lib_dir / "liblsfg-vk.so" + service.legacy_json_file = service.local_share_dir / "VkLayer_LS_frame_generation.json" + + for path in ( + service.lib_file, + service.config_file_path, + service.legacy_script_path, + ): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("owned", encoding="utf-8") + unrelated = home / ".local/bin/keep-me" + unrelated.parent.mkdir(parents=True, exist_ok=True) + unrelated.write_text("user file", encoding="utf-8") + + result = service.uninstall() + + self.assertTrue(result["success"]) + self.assertFalse(service.lib_file.exists()) + self.assertFalse(service.config_file_path.exists()) + self.assertFalse(service.legacy_script_path.exists()) + self.assertTrue(unrelated.exists()) + self.assertTrue(service.local_bin_dir.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py new file mode 100644 index 0000000..fc51470 --- /dev/null +++ b/tests/test_plugin_migration.py @@ -0,0 +1,101 @@ +import asyncio +import sys +import types +import unittest +from unittest.mock import Mock + + +class PluginMigrationTests(unittest.TestCase): + def _load_plugin(self): + decky = types.SimpleNamespace( + DECKY_HOME="/decky", + DECKY_USER_HOME="/home/deck", + migrate_logs=Mock(), + migrate_settings=Mock(), + migrate_runtime=Mock(), + logger=Mock(), + ) + previous_decky = sys.modules.get("decky") + previous_tomllib = sys.modules.get("tomllib") + previous_plugin = sys.modules.pop("lsfg_vk.plugin", None) + sys.modules["decky"] = decky + sys.modules["tomllib"] = types.SimpleNamespace(loads=Mock()) + sys.path.insert(0, "py_modules") + from lsfg_vk.plugin import Plugin + return Plugin, decky, previous_decky, previous_tomllib, previous_plugin + + def _restore(self, previous_decky, previous_tomllib, previous_plugin): + sys.path.remove("py_modules") + if previous_decky is None: + sys.modules.pop("decky", None) + else: + sys.modules["decky"] = previous_decky + if previous_tomllib is None: + sys.modules.pop("tomllib", None) + else: + sys.modules["tomllib"] = previous_tomllib + if previous_plugin is None: + sys.modules.pop("lsfg_vk.plugin", None) + else: + sys.modules["lsfg_vk.plugin"] = previous_plugin + + def test_migration_only_runs_decky_path_migrations(self): + Plugin, decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() + try: + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + + asyncio.run(plugin._migration()) + + decky.migrate_logs.assert_called_once() + decky.migrate_settings.assert_called_once() + decky.migrate_runtime.assert_called_once() + plugin.installation_service.install.assert_not_called() + plugin.flatpak_service.prepare_app.assert_not_called() + finally: + self._restore(previous_decky, previous_tomllib, previous_plugin) + + def test_uninstall_cleans_owned_flatpak_state_and_profiles(self): + Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() + try: + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + plugin.configuration_service = Mock() + plugin.wrapper_service = Mock() + plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": True} + plugin.configuration_service.reset_all_flatpak_configs.return_value = {"success": True} + plugin.wrapper_service.neutralize.return_value = {"success": True} + + asyncio.run(plugin._uninstall()) + + plugin.flatpak_service.remove_plugin_owned_environment.assert_called_once_with() + plugin.configuration_service.reset_all_flatpak_configs.assert_called_once_with() + plugin.wrapper_service.neutralize.assert_called_once_with() + plugin.wrapper_service.purge.assert_not_called() + plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() + finally: + self._restore(previous_decky, previous_tomllib, previous_plugin) + + def test_uninstall_preserves_profiles_when_flatpak_cleanup_fails(self): + Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() + try: + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + plugin.configuration_service = Mock() + plugin.wrapper_service = Mock() + plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": False, "error": "changed"} + + asyncio.run(plugin._uninstall()) + + plugin.configuration_service.reset_all_flatpak_configs.assert_not_called() + plugin.wrapper_service.purge.assert_not_called() + plugin.installation_service.cleanup_on_uninstall.assert_not_called() + finally: + self._restore(previous_decky, previous_tomllib, previous_plugin) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py new file mode 100644 index 0000000..75cdabf --- /dev/null +++ b/tests/test_steam_service.py @@ -0,0 +1,76 @@ +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.steam_service import SteamService + + +class SteamShortcutTests(unittest.TestCase): + def test_direct_flatpak_shortcut_is_marked(self): + game = SteamService._shortcut_game( + { + "appid": 123456, + "AppName": "PCSX2 shortcut", + "Exe": "/usr/bin/flatpak", + "LaunchOptions": "run net.pcsx2.PCSX2 --fullscreen", + "StartDir": "/home/deck/Games", + } + ) + + self.assertEqual(game, { + "appid": "123456", + "name": "PCSX2 shortcut", + "nonSteam": True, + "isFlatpakShortcut": True, + }) + + def test_bare_flatpak_shortcut_is_marked(self): + game = SteamService._shortcut_game( + { + "appid": 654321, + "AppName": "Faugus shortcut", + "Exe": '"flatpak"', + "LaunchOptions": "run io.github.Faugus.faugus-launcher --game elliot", + } + ) + + self.assertEqual(game, { + "appid": "654321", + "name": "Faugus shortcut", + "nonSteam": True, + "isFlatpakShortcut": True, + }) + + def test_emudeck_launcher_is_ordinary_non_steam_metadata(self): + game = SteamService._shortcut_game( + { + "appid": 987654, + "AppName": "1080 Snowboarding", + "Exe": '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64', + "LaunchOptions": "", + } + ) + + self.assertEqual(game, { + "appid": "987654", + "name": "1080 Snowboarding", + "nonSteam": True, + }) + + def test_shortcut_rejects_invalid_identity(self): + self.assertIsNone(SteamService._shortcut_game({"appid": 0, "AppName": "Bad"})) + self.assertIsNone(SteamService._shortcut_game({"appid": 1, "AppName": ""})) + self.assertIsNone(SteamService._shortcut_game("bad")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py new file mode 100644 index 0000000..46e029b --- /dev/null +++ b/tests/test_wrapper_service.py @@ -0,0 +1,210 @@ +import subprocess +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.wrapper_service import WrapperService + + +class WrapperServiceTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.service = WrapperService() + self.service.user_home = self.home + self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + self.service.sidecar_path = self.service.config_dir / "workarounds.json" + self.service.wrapper_path = self.home / ".lsfg" + + def tearDown(self): + self.tempdir.cleanup() + + def _state(self, **changes): + state = self.service.default_state() + state.update(changes) + return state + + def _run(self, appid, *args, env=None): + process_env = {"PATH": "/usr/bin:/bin", "SteamAppId": str(appid)} + if env: + process_env.update(env) + return subprocess.run( + [str(self.service.wrapper_path), *args], + env=process_env, + capture_output=True, + text=True, + check=True, + ) + + def test_writes_owned_dispatcher_and_validates_shell(self): + response = self.service.set("123", self._state(dxvkFrameRate=60, enableZink=True)) + self.assertTrue(response["success"]) + self.assertEqual(response["wrapper_path"], "~/.lsfg") + self.assertTrue(response["wrapper_owned"]) + self.assertEqual(response["state"]["dxvkFrameRate"], 60) + self.assertEqual(subprocess.run(["/bin/sh", "-n", str(self.service.wrapper_path)]).returncode, 0) + self.assertIn(self.service.MARKER, self.service.wrapper_path.read_text(encoding="utf-8")) + self.assertEqual(self.service.get("123")["state"], self._state(dxvkFrameRate=60, enableZink=True)) + + def test_dispatch_exports_appid_config_and_workarounds(self): + self.service.set( + "123", + self._state(dxvkFrameRate=30, disableSteamdeckMode=True, disableVkbasalt=True, enableZink=True), + ) + result = self._run( + 123, + "/usr/bin/env", + env={ + "DXVK_CONFIG": "dxgi.syncInterval = 0", + "DXVK_FRAME_RATE": "5", + "ENABLE_GAMESCOPE_WSI": "1", + "DISABLE_LSFGVK": "1", + "DISABLE_LSFG": "1", + "DISABLE_VKBASALT": "0", + "MESA_LOADER_DRIVER_OVERRIDE": "llvmpipe", + "MANGOHUD": "1", + }, + ) + values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + self.assertEqual(values["SteamAppId"], "123") + self.assertEqual(values["LSFGVK_CONFIG"], str(self.service.config_file_path)) + self.assertEqual(values["ENABLE_GAMESCOPE_WSI"], "0") + self.assertEqual(values["DXVK_HDR"], "0") + self.assertEqual(values["SteamDeck"], "0") + self.assertEqual(values["DISABLE_VKBASALT"], "1") + self.assertEqual(values["__GLX_VENDOR_LIBRARY_NAME"], "mesa") + self.assertEqual(values["MESA_LOADER_DRIVER_OVERRIDE"], "zink") + self.assertEqual(values["GALLIUM_DRIVER"], "zink") + self.assertEqual(values["DXVK_CONFIG"], "dxgi.syncInterval = 0; dxvk.maxFrameRate = 30") + self.assertEqual(values["MANGOHUD"], "1") + self.assertNotIn("DXVK_FRAME_RATE", values) + self.assertNotIn("ENABLE_VKBASALT", values) + self.assertNotIn("DISABLE_LSFGVK", values) + self.assertNotIn("DISABLE_LSFG", values) + + def test_wrapper_is_transport_agnostic(self): + self.service.set("123", self._state()) + fake = self.home / "target" + fake.write_text("#!/bin/sh\nprintf '%s\\n' \"$@\"\n", encoding="utf-8") + fake.chmod(0o755) + result = self._run(123, str(fake), "run", "org.example.Game") + self.assertEqual(result.stdout.splitlines(), ["run", "org.example.Game"]) + content = self.service.wrapper_path.read_text(encoding="utf-8") + self.assertNotIn("flatpakAppId", content) + self.assertNotIn("shortcut_exe", content) + self.assertNotIn("--filesystem", content) + + def test_appid_fallback_and_unmatched_passthrough(self): + self.service.set("123", self._state(disableGamescopeWsi=False, disableHdr=False)) + self.service.set("456", self._state(disableSteamdeckMode=True)) + fallback = subprocess.run( + [str(self.service.wrapper_path), "/usr/bin/env"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "bad", "SteamGameId": "456"}, + capture_output=True, + text=True, + check=True, + ) + fallback_values = dict(line.split("=", 1) for line in fallback.stdout.splitlines() if "=" in line) + self.assertEqual(fallback_values["SteamDeck"], "0") + self.assertEqual(fallback_values["SteamAppId"], "456") + + passthrough = subprocess.run( + [str(self.service.wrapper_path), "/usr/bin/env"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "999", "KEEP": "yes", "DXVK_HDR": "1"}, + capture_output=True, + text=True, + check=True, + ) + passthrough_values = dict(line.split("=", 1) for line in passthrough.stdout.splitlines() if "=" in line) + self.assertEqual(passthrough_values["KEEP"], "yes") + self.assertEqual(passthrough_values["DXVK_HDR"], "1") + + def test_invalid_state_and_foreign_wrapper_fail_closed(self): + invalid = self.service.set("0", self.service.default_state()) + self.assertFalse(invalid["success"]) + invalid = self.service.set("123", {**self.service.default_state(), "dxvkFrameRate": 61}) + self.assertFalse(invalid["success"]) + + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.set("123", self.service.default_state()) + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n") + + def test_remove_keeps_safe_passthrough_wrapper(self): + self.service.set("123", self.service.default_state()) + response = self.service.remove("123") + self.assertTrue(response["success"]) + self.assertIsNone(self.service.get("123")["state"]) + self.assertTrue(self.service.wrapper_path.exists()) + result = subprocess.run( + [str(self.service.wrapper_path), "/usr/bin/printf", "ok"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"}, + capture_output=True, + text=True, + check=True, + ) + self.assertEqual(result.stdout, "ok") + + def test_purge_removes_owned_wrapper_and_state(self): + self.service.set("123", self._state(), non_steam=True) + self.assertTrue(self.service.get("123")["non_steam"]) + self.assertEqual(self.service.list_apps()["apps"][0]["non_steam"], True) + response = self.service.purge() + self.assertTrue(response["success"]) + self.assertEqual(response["removed_files"], [str(self.service.wrapper_path), str(self.service.sidecar_path)]) + self.assertFalse(self.service.wrapper_path.exists()) + self.assertFalse(self.service.sidecar_path.exists()) + + def test_purge_refuses_foreign_wrapper(self): + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.purge() + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertTrue(self.service.wrapper_path.exists()) + + def test_purge_refuses_invalid_state(self): + self.service.config_dir.mkdir(parents=True, exist_ok=True) + self.service.sidecar_path.write_text("not json", encoding="utf-8") + self.service.wrapper_path.write_text( + f"#!/bin/sh\n{self.service.MARKER}\nexec \"$@\"\n", + encoding="utf-8", + ) + response = self.service.purge() + self.assertFalse(response["success"]) + self.assertTrue(self.service.wrapper_path.exists()) + self.assertTrue(self.service.sidecar_path.exists()) + + def test_neutralize_leaves_dependency_free_passthrough_wrapper(self): + self.service.set("123", self._state()) + response = self.service.neutralize() + self.assertTrue(response["success"]) + self.assertFalse(self.service.sidecar_path.exists()) + self.assertTrue(self.service.wrapper_path.exists()) + content = self.service.wrapper_path.read_text(encoding="utf-8") + self.assertIn(self.service.MARKER, content) + self.assertNotIn("LSFGVK_CONFIG", content) + self.assertEqual(self._run(123, "/usr/bin/printf", "ok").stdout, "ok") + + def test_neutralize_refuses_foreign_wrapper(self): + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.neutralize() + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n") + + +if __name__ == "__main__": + unittest.main() |
