mirror of
https://github.com/AstronautCheetah/frame-tweaks.git
synced 2026-10-17 15:00:18 +02:00
A tweak's update wrote its menu entry whole (install_desktop; the installer's own entry; Claude Desktop's at each of its updates), so a name or icon given in KDE's menu editor went back. menu-built/ now keeps each entry as written, and lib/menuedits.py merge carries what the entry in the menu says differently into the new version. A command both you and the new version changed is the new version's: yours may name a program that moved. The wallet's rebuilds follow the same rule, telling the app's own command from what they wrap it with. An entry renamed before it's protected: the entry as written becomes the original and your edits go on top, so they survive the tweak's next update and the protection being removed. remove_files drops the copy with an entry it deletes (a pack's uninstall may remove its entry that way), and the example pack uses remove_desktop, the documented undo.
240 lines
13 KiB
Python
240 lines
13 KiB
Python
"""Shell helpers in lib/common.sh. Run: python3 -m unittest -v"""
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from helpers import kde_saves, sandbox_env, stub # noqa: E402
|
|
|
|
|
|
def sh(script, stdin="", **env):
|
|
with tempfile.TemporaryDirectory() as home: # common.sh creates folders in $HOME
|
|
return subprocess.run(["bash", "-c", f"source {ROOT}/lib/common.sh 2>/dev/null; {script}"],
|
|
input=stdin, capture_output=True, text=True,
|
|
env={**os.environ, "HOME": home, **env}).stdout.strip()
|
|
|
|
|
|
class NewestTag(unittest.TestCase):
|
|
TAGS = "v0.9.0\nv0.10.0\nv0.10.1-rc1\nv0.10.1-rc2\nfoo\n"
|
|
|
|
def test_stable(self):
|
|
self.assertEqual(sh("newest_tag", self.TAGS, FRAME_TWEAKS_CHANNEL="stable"), "v0.10.0")
|
|
|
|
def test_beta_takes_latest_prerelease(self):
|
|
self.assertEqual(sh("newest_tag", self.TAGS, FRAME_TWEAKS_CHANNEL="beta"), "v0.10.1-rc2")
|
|
|
|
def test_beta_final_beats_its_prerelease(self):
|
|
self.assertEqual(sh("newest_tag", "v1.0.0-rc1\nv1.0.0\n", FRAME_TWEAKS_CHANNEL="beta"), "v1.0.0")
|
|
|
|
|
|
class Pins(unittest.TestCase):
|
|
def test_every_pin_is_complete(self):
|
|
pins = [*ROOT.glob("packs/*/*/pin"), *ROOT.glob("packs/*/*/*.pin")]
|
|
self.assertTrue(pins) # (tweaks moved into packs/: found where they are)
|
|
for pin in pins:
|
|
fields = dict(l.split("=", 1) for l in pin.read_text().splitlines() if "=" in l and not l.startswith("#"))
|
|
self.assertTrue(fields.get("url", "").startswith("https://"), pin)
|
|
if "rev" in fields: # a git commit
|
|
self.assertRegex(fields["rev"], r"^[0-9a-f]{40}$", pin)
|
|
else: # a file, at a versioned URL
|
|
self.assertRegex(fields.get("sha256", ""), r"^[0-9a-f]{64}$", pin)
|
|
self.assertIn(fields.get("version", "?"), fields["url"], pin)
|
|
|
|
def test_python_constraints_are_exact(self):
|
|
for line in (ROOT / "lib" / "python-constraints.txt").read_text().splitlines():
|
|
if line.strip() and not line.startswith("#"):
|
|
self.assertRegex(line, r"^[A-Za-z0-9_.-]+==[0-9][0-9A-Za-z.]*$")
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("kwriteconfig6") and shutil.which("kreadconfig6"), "needs KDE's config tools")
|
|
class KConfig(unittest.TestCase):
|
|
"""save_kconfig / restore_kconfig: the keys go back as they were, a value (even one that looks
|
|
like an option, -0.4) or unset. In a throwaway config folder; nothing is told over D-Bus."""
|
|
|
|
def test_values_and_unset_keys_come_back(self):
|
|
script = """f=frame-tweaks-testrc
|
|
kwriteconfig6 --file $f --group Mouse --key Accel -- -0.4
|
|
save_kconfig t $f Mouse Accel Speed
|
|
kwriteconfig6 --file $f --group Mouse --key Accel 1; kwriteconfig6 --file $f --group Mouse --key Speed 3
|
|
restore_kconfig t >/dev/null
|
|
echo "$(kreadconfig6 --file $f --group Mouse --key Accel) $(kreadconfig6 --file $f --group Mouse --key Speed --default unset)"
|
|
"""
|
|
with tempfile.TemporaryDirectory() as home:
|
|
out = sh(script, XDG_CONFIG_HOME=f"{home}/.config", DBUS_SESSION_BUS_ADDRESS="disabled:")
|
|
self.assertEqual(out, "-0.4 unset")
|
|
|
|
|
|
class QtVenv(unittest.TestCase):
|
|
"""remove_qt_venv: the PyQt6 venv shared by Arrow Pad, Screen snip, the NordVPN tray and the
|
|
full installer window goes with the last of them, and stays on a reinstall."""
|
|
SETUP = 'mkdir -p "$QT_VENV/bin" "$BIN" && touch "$QT_VENV/bin/python"; '
|
|
|
|
def left(self, setup="", **env):
|
|
return sh(self.SETUP + setup + ' remove_qt_venv >/dev/null; [ -d "$QT_VENV" ] && echo kept || echo removed', **env)
|
|
|
|
def test_the_last_one_removes_it(self):
|
|
self.assertEqual(self.left(), "removed")
|
|
|
|
def test_kept_for_the_installer_window(self): # (it runs on the venv as bin/sft-python)
|
|
self.assertEqual(self.left('touch "$QT_VENV/bin/sft-python";'), "kept")
|
|
|
|
def test_kept_while_another_tweak_uses_it(self):
|
|
self.assertEqual(self.left('printf "#!/bin/sh\\n" > "$BIN/frame-snip"; chmod +x "$BIN/frame-snip";'), "kept")
|
|
|
|
def test_kept_on_a_reinstall(self):
|
|
self.assertEqual(self.left(FRAME_TWEAKS_REINSTALL="1"), "kept")
|
|
|
|
|
|
class Bashrc(unittest.TestCase):
|
|
"""ensure_path (and need_brew, after installing Homebrew) add their line to ~/.bashrc once, on
|
|
a line of its own: to a file whose last line had no newline it was glued onto that line, and
|
|
then found there, so it stayed broken."""
|
|
LINE = 'export PATH="$HOME/.local/bin:$PATH"'
|
|
|
|
def bashrc(self, before):
|
|
return sh(f"{before}; ensure_path; ensure_path; cat ~/.bashrc; echo END")
|
|
|
|
def test_after_a_last_line_without_a_newline(self):
|
|
self.assertEqual(self.bashrc("printf 'alias ll=\"ls -l\"' > ~/.bashrc"), f'alias ll="ls -l"\n{self.LINE}\nEND')
|
|
|
|
def test_after_a_last_line_with_one(self):
|
|
self.assertEqual(self.bashrc("printf 'alias ll=\"ls -l\"\\n' > ~/.bashrc"), f'alias ll="ls -l"\n{self.LINE}\nEND')
|
|
|
|
def test_empty_or_none(self):
|
|
self.assertEqual(self.bashrc(": > ~/.bashrc"), f"{self.LINE}\nEND")
|
|
self.assertEqual(self.bashrc("rm -f ~/.bashrc"), f"{self.LINE}\nEND")
|
|
|
|
|
|
|
|
class DesktopEntry(unittest.TestCase):
|
|
"""install_desktop writes a menu entry whole, in English when translating it fails: piped
|
|
straight from lib/i18n.py, a failure there left the entry empty (and, with pipefail, stopped
|
|
the tweak's install). (Stand-ins: python3 fails only for that; nothing real is started.)"""
|
|
def test_whole_in_english_when_translating_fails(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
d = Path(d)
|
|
stubs, home = d / "stubs", d / "home"
|
|
stubs.mkdir()
|
|
home.mkdir()
|
|
for name, body in (("systemctl", "exit 0"), ("update-desktop-database", "exit 0"),
|
|
("python3", 'case "$*" in *"i18n.py desktop"*) exit 1 ;; esac; exec /usr/bin/python3 "$@"')):
|
|
(stubs / name).write_text(f"#!/bin/bash\n{body}\n")
|
|
(stubs / name).chmod(0o755)
|
|
desktop = d / "thing.desktop"
|
|
desktop.write_text("[Desktop Entry]\nType=Application\nName=Thing\nExec=@BIN@/thing\n")
|
|
r = subprocess.run(["bash", "-c", f'source {ROOT}/lib/common.sh; install_desktop "{desktop}"'],
|
|
capture_output=True, text=True, timeout=60,
|
|
env={**os.environ, "HOME": str(home), "PATH": f"{stubs}:{os.environ['PATH']}"})
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
text = (home / ".local/share/applications/thing.desktop").read_text()
|
|
self.assertIn("Name=Thing", text)
|
|
self.assertIn(f"Exec={home}/.local/bin/thing", text)
|
|
|
|
def test_your_edits_stay_when_it_is_written_again(self):
|
|
"""A rename or new icon from KDE's menu editor stays when the tweak's next update writes
|
|
its entry again; the update's own changes come through. Its uninstall takes the copy kept."""
|
|
with tempfile.TemporaryDirectory() as d:
|
|
d = Path(d)
|
|
stubs, home = d / "stubs", d / "home"
|
|
for name in ("systemctl", "update-desktop-database"):
|
|
stub(stubs, name, "exit 0")
|
|
desktop = d / "thing.desktop"
|
|
desktop.write_text("[Desktop Entry]\nType=Application\nName=Thing\nComment=Does a thing\n"
|
|
"Exec=@BIN@/thing %U\nIcon=thing\n")
|
|
|
|
def run(command):
|
|
r = subprocess.run(["bash", "-c", f'source {ROOT}/lib/common.sh; {command}'], capture_output=True,
|
|
text=True, timeout=60, env=sandbox_env(home, stubs))
|
|
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
run(f'install_desktop "{desktop}"')
|
|
menu = home / ".local/share/applications/thing.desktop"
|
|
menu.write_text(kde_saves(menu.read_text(), Name="My thing", Icon="star"))
|
|
desktop.write_text(desktop.read_text().replace("Does a thing", "Does it better").replace(" %U", " --new %U"))
|
|
run(f'install_desktop "{desktop}"')
|
|
lines = menu.read_text().splitlines()
|
|
for line in ("Name=My thing", "Icon=star", "Comment=Does it better", f"Exec={home}/.local/bin/thing --new %U"):
|
|
self.assertIn(line, lines)
|
|
self.assertEqual(lines[0], "[Desktop Entry]")
|
|
run("remove_desktop thing.desktop")
|
|
self.assertFalse(menu.exists())
|
|
self.assertFalse((home / ".local/share/frame-tweaks/menu-built/thing.desktop").exists())
|
|
run(f'install_desktop "{desktop}"; remove_files "$APPS/thing.desktop"') # (a pack's own way)
|
|
self.assertFalse((home / ".local/share/frame-tweaks/menu-built/thing.desktop").exists())
|
|
|
|
|
|
class SteamVRRestart(unittest.TestCase):
|
|
"""status_restart: a status line the installer window turns into ⟳ Restart SteamVR. vr_started_before:
|
|
has the running SteamVR started since a file changed (with pgrep and ps stand-ins)?"""
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.dir = Path(self.tmp.name)
|
|
self.file = self.dir / "installed"
|
|
self.file.write_text("1.0\n")
|
|
|
|
def started_before(self, running=True, age=100, file_age=0):
|
|
for name, body in (("pgrep", 'echo 4242' if running else 'exit 1'), ("ps", f'echo " {age}"')):
|
|
(self.dir / name).write_text(f"#!/bin/sh\n{body}\n")
|
|
(self.dir / name).chmod(0o755)
|
|
if self.file.exists():
|
|
os.utime(self.file, (time.time() - file_age,) * 2)
|
|
return sh(f'vr_started_before "{self.file}" && echo yes || echo no', PATH=f"{self.dir}:/usr/bin:/bin")
|
|
|
|
def test_started_before(self):
|
|
self.assertEqual(self.started_before(), "yes") # installed after SteamVR started
|
|
self.assertEqual(self.started_before(file_age=200), "no") # SteamVR started since
|
|
self.assertEqual(self.started_before(running=False), "no")
|
|
self.file.unlink()
|
|
self.assertEqual(self.started_before(), "no")
|
|
|
|
def test_status_line(self):
|
|
r = subprocess.run(["bash", "-c", f"source {ROOT}/lib/common.sh 2>/dev/null; status_restart 'Load it'; echo after"],
|
|
capture_output=True, text=True, env={**os.environ, "HOME": str(self.dir)})
|
|
self.assertEqual((r.returncode, r.stdout), (2, "restart Load it\n")) # (2: needs attention)
|
|
|
|
|
|
|
|
class DashboardPatchInstalled(unittest.TestCase):
|
|
"""install_dashboard_patch while SteamVR's patches service runs: "Active now." only once the
|
|
service has injected the patch (it said so after its 10 checks either way); otherwise what the
|
|
service says. In a throwaway home, with stand-ins for the service and systemctl."""
|
|
|
|
def install(self, status):
|
|
with tempfile.TemporaryDirectory() as home:
|
|
d = Path(home) / ".local/share/frame-tweaks/vr-dashboard"
|
|
d.mkdir(parents=True)
|
|
(d / "sft-dashboard").write_text(f"#!/bin/sh\n[ -n '{status}' ] && echo '{status}'\n"
|
|
f"[ '{status}' = 'curvature: patched' ]\n")
|
|
(d / "sft-dashboard").chmod(0o755)
|
|
(Path(home) / "stubs").mkdir()
|
|
(Path(home) / "stubs/systemctl").write_text("#!/bin/sh\nexit 3\n") # (never the real one)
|
|
(Path(home) / "stubs/systemctl").chmod(0o755)
|
|
(Path(home) / "patch.js").write_text("")
|
|
script = (f"source {ROOT}/lib/common.sh 2>/dev/null; "
|
|
'sysctl_user() { [ "$3" = sft-dashboard.service ]; }; sleep() { :; }; ' # (it runs; no waiting)
|
|
f"install_dashboard_patch curvature {home}/patch.js")
|
|
env = {"HOME": home, "PATH": f"{home}/stubs:/usr/bin:/bin", "LANG": "C.UTF-8",
|
|
"FRAME_TWEAKS_LANGUAGE": "en", "FRAME_TWEAKS_LANG_RESOLVED": "en"}
|
|
r = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
return r.stdout
|
|
|
|
def test_says_active_only_once_it_is(self):
|
|
self.assertIn("Active now.", self.install("curvature: patched"))
|
|
out = self.install("curvature: port open, not patched yet")
|
|
self.assertNotIn("Active now.", out)
|
|
self.assertIn("Not active yet: curvature: port open, not patched yet", out)
|
|
out = self.install("curvature: SteamVR changed (missing: a thing); off")
|
|
self.assertIn("Not active yet: curvature: SteamVR changed (missing: a thing); off", out)
|
|
self.assertIn("Not active yet: the dashboard patch service did not answer", self.install(""))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|