summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2026-09-12 21:29:08 -0400
committerGitHub <noreply@github.com>2026-09-12 21:29:08 -0400
commite580c6bd60c92af36f92d4c29b5d9a44f73d47d7 (patch)
tree7fc6799626519e5b01fb137f5440c99fda5faca7 /tests
parente997a3fb74fa70f5e60b60807fb0897120e98313 (diff)
parent81feb03288166755545401b9df2ff2c9bc3ae7d7 (diff)
downloaddecky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.tar.gz
decky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.zip
Merge pull request #264 from xXJSONDeruloXx/chore/migrate
the big one
Diffstat (limited to 'tests')
-rw-r--r--tests/gameTargets.test.ts50
-rw-r--r--tests/nowPlaying.test.ts114
-rw-r--r--tests/steamLaunchOptions.test.ts219
-rw-r--r--tests/test_configuration_profiles.py114
-rw-r--r--tests/test_flatpak_profile_service.py233
-rw-r--r--tests/test_flatpak_service.py320
-rw-r--r--tests/test_installation_cleanup.py63
-rw-r--r--tests/test_plugin_migration.py101
-rw-r--r--tests/test_steam_service.py76
-rw-r--r--tests/test_wrapper_service.py210
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()