Files
kernalix7--winpodx/tests/test_desktop.py
T
wehub-resource-sync 3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

921 lines
34 KiB
Python

# SPDX-License-Identifier: MIT
"""Tests for desktop entry, MIME, and icon handling."""
from __future__ import annotations
import configparser
from pathlib import Path
import pytest
from winpodx.core.app import AppInfo
from winpodx.desktop import entry as entry_mod
from winpodx.desktop.entry import DESKTOP_TEMPLATE, install_desktop_entry
from winpodx.desktop.icons import bundled_data_path
from winpodx.desktop.mime import register_mime_types, unregister_mime_types
def test_desktop_template():
app = AppInfo(
name="word",
full_name="Microsoft Word",
executable="C:\\Program Files\\Office\\WINWORD.EXE",
categories=["Office", "WordProcessor"],
mime_types=["application/msword"],
)
content = DESKTOP_TEMPLATE.format(
winpodx_exe="winpodx",
full_name=app.full_name,
name=app.name,
comment="Word processor",
icon_name=f"winpodx-{app.name}",
categories=";".join(app.categories) + ";",
mime_types=";".join(app.mime_types) + ";",
wm_class="winword",
folder_line="",
)
assert "Name=Microsoft Word" in content
assert "Comment=Word processor" in content
assert "Exec=winpodx app run word %u" in content
assert "Icon=winpodx-word" in content
assert "Categories=Office;WordProcessor;" in content
assert "MimeType=application/msword;" in content
assert "StartupWMClass=winword" in content
def test_register_mime_excludes_http_https_from_default_grab(tmp_path, monkeypatch):
"""Security: a discovered (semi-trusted) guest app must never seize the host
http/https default handler. register_mime_types runs `xdg-mime default` for
file mimes + mailto/vendor schemes, but NOT for http/https (#421/#694
trust-boundary hardening)."""
apps_dir = tmp_path / "applications"
apps_dir.mkdir()
monkeypatch.setattr("winpodx.desktop.mime.applications_dir", lambda: apps_dir)
(apps_dir / "winpodx-edge.desktop").write_text("[Desktop Entry]\n", encoding="utf-8")
registered: list[str] = []
class _R:
returncode = 0
stderr = ""
def _fake_run(argv, **kw):
# argv = ["xdg-mime", "default", "<desktop>", "<mime>"]
registered.append(argv[-1])
return _R()
monkeypatch.setattr("winpodx.desktop.mime.subprocess.run", _fake_run)
app = AppInfo(
name="edge",
full_name="Microsoft Edge",
executable="C:\\edge.exe",
mime_types=["text/html"],
url_schemes=["http", "https", "mailto", "webcal"],
)
register_mime_types(app)
assert "x-scheme-handler/http" not in registered
assert "x-scheme-handler/https" not in registered
# mailto + vendor schemes still auto-default (the #421 use case); file mimes too.
assert "x-scheme-handler/mailto" in registered
assert "x-scheme-handler/webcal" in registered
assert "text/html" in registered
# D1: unregister_mime_types must not destroy mimeapps.list structure
def _write_mimeapps(tmp_path: Path, content: str) -> Path:
"""Create a fake $XDG_CONFIG_HOME/mimeapps.list at tmp_path/mimeapps.list."""
mimeapps = tmp_path / "mimeapps.list"
mimeapps.write_text(content, encoding="utf-8")
return mimeapps
def test_unregister_mime_preserves_other_apps(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
mimeapps = _write_mimeapps(
tmp_path,
"[Default Applications]\n"
"text/plain=gedit.desktop;winpodx-notepad.desktop;\n"
"application/pdf=evince.desktop;\n"
"image/png=gimp.desktop;winpodx-paint.desktop;eog.desktop;\n"
"\n"
"[Added Associations]\n"
"text/plain=winpodx-notepad.desktop;kate.desktop;\n",
)
app = AppInfo(name="notepad", full_name="Notepad", executable="C:\\notepad.exe")
unregister_mime_types(app)
parser = configparser.RawConfigParser(strict=False, delimiters=("=",))
parser.optionxform = str # type: ignore[assignment,method-assign]
parser.read(mimeapps, encoding="utf-8")
assert parser.get("Default Applications", "text/plain") == "gedit.desktop;"
assert parser.get("Default Applications", "application/pdf") == "evince.desktop;"
assert (
parser.get("Default Applications", "image/png")
== "gimp.desktop;winpodx-paint.desktop;eog.desktop;"
)
assert parser.get("Added Associations", "text/plain") == "kate.desktop;"
def test_unregister_mime_drops_empty_keys(tmp_path, monkeypatch):
# When winpodx was the only value, the key is removed (not left empty).
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
mimeapps = _write_mimeapps(
tmp_path,
"[Default Applications]\n"
"application/x-foo=winpodx-foo.desktop;\n"
"text/plain=gedit.desktop;\n",
)
app = AppInfo(name="foo", full_name="Foo", executable="C:\\foo.exe")
unregister_mime_types(app)
parser = configparser.RawConfigParser(strict=False, delimiters=("=",))
parser.optionxform = str # type: ignore[assignment,method-assign]
parser.read(mimeapps, encoding="utf-8")
assert not parser.has_option("Default Applications", "application/x-foo")
assert parser.get("Default Applications", "text/plain") == "gedit.desktop;"
def test_unregister_mime_noop_when_file_missing(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
app = AppInfo(name="ghost", full_name="Ghost", executable="C:\\ghost.exe")
unregister_mime_types(app) # must not raise
assert not (tmp_path / "mimeapps.list").exists()
def test_unregister_mime_atomic_write(tmp_path, monkeypatch):
# Write goes through tempfile + os.replace, no partial file left behind.
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
_write_mimeapps(
tmp_path,
"[Default Applications]\ntext/plain=winpodx-notepad.desktop;gedit.desktop;\n",
)
app = AppInfo(name="notepad", full_name="Notepad", executable="C:\\notepad.exe")
unregister_mime_types(app)
leftovers = [p.name for p in tmp_path.iterdir() if p.name != "mimeapps.list"]
assert leftovers == [], f"stray tempfiles: {leftovers}"
# D2: install_desktop_entry must write UTF-8 explicitly
def test_install_desktop_entry_utf8_korean(tmp_path, monkeypatch):
# Non-ASCII full_name must round-trip under C/POSIX locale.
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
app = AppInfo(
name="hangul",
full_name="\ud55c\uae00 \uba54\ubaa8\uc7a5", # "한글 메모장"
executable="C:\\notepad.exe",
categories=["Utility"],
mime_types=["text/plain"],
)
monkeypatch.setattr(entry_mod, "_install_icon", lambda _app: "winpodx")
# Pin the Exec prefix so the assertion is independent of whether winpodx is
# resolvable on the test runner's PATH (install_desktop_entry now embeds the
# absolute path from shutil.which).
monkeypatch.setattr(entry_mod, "_winpodx_exe", lambda: "winpodx")
desktop_path = install_desktop_entry(app)
assert desktop_path.exists()
content = desktop_path.read_text(encoding="utf-8")
assert "Name=\ud55c\uae00 \uba54\ubaa8\uc7a5" in content
assert "Exec=winpodx app run hangul %u" in content
raw = desktop_path.read_bytes()
assert "\ud55c\uae00 \uba54\ubaa8\uc7a5".encode("utf-8") in raw
def test_desktop_entry_emits_scheme_handler(tmp_path, monkeypatch):
# #421/#694: url_schemes become x-scheme-handler/<scheme> MIME entries
# alongside the file MIME types.
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setattr(entry_mod, "_install_icon", lambda _app: "winpodx")
monkeypatch.setattr(entry_mod, "_winpodx_exe", lambda: "winpodx")
app = AppInfo(
name="outlook",
full_name="Outlook",
executable="C:\\o.exe",
mime_types=["application/pdf"],
url_schemes=["mailto", "slack"],
)
content = install_desktop_entry(app).read_text(encoding="utf-8")
mime_line = next(ln for ln in content.splitlines() if ln.startswith("MimeType="))
assert "application/pdf" in mime_line
assert "x-scheme-handler/mailto" in mime_line
assert "x-scheme-handler/slack" in mime_line
def test_scheme_only_app_triggers_db_update(tmp_path, monkeypatch):
# An app with URL schemes but NO file MIME types must still rebuild the
# desktop database (the old gate only checked app.mime_types).
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setattr(entry_mod, "_install_icon", lambda _app: "winpodx")
monkeypatch.setattr(entry_mod, "_winpodx_exe", lambda: "winpodx")
called: list = []
monkeypatch.setattr(entry_mod, "update_desktop_database", lambda: called.append(1))
app = AppInfo(name="mailer", full_name="Mailer", executable="C:\\m.exe", url_schemes=["mailto"])
install_desktop_entry(app)
assert called == [1]
def test_install_desktop_entry_utf8_japanese(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
app = AppInfo(
name="notepad-jp",
full_name="\u30e1\u30e2\u5e33", # "メモ帳"
executable="C:\\notepad.exe",
)
monkeypatch.setattr(entry_mod, "_install_icon", lambda _app: "winpodx")
desktop_path = install_desktop_entry(app)
content = desktop_path.read_text(encoding="utf-8")
assert "Name=\u30e1\u30e2\u5e33" in content
def test_install_desktop_entry_strips_newlines_in_full_name(tmp_path, monkeypatch):
# A hostile/compromised guest could embed a newline in the discovered app
# name to inject arbitrary .desktop keys (Exec=, Hidden=, ...) into the
# launcher spec via Name=. The newline must be collapsed so no extra
# key line is produced.
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
app = AppInfo(
name="evil",
full_name='Word\nExec=/bin/sh -c "touch /tmp/pwned"',
executable="C:\\word.exe",
)
monkeypatch.setattr(entry_mod, "_install_icon", lambda _app: "winpodx")
monkeypatch.setattr(entry_mod, "_winpodx_exe", lambda: "winpodx")
desktop_path = install_desktop_entry(app)
content = desktop_path.read_text(encoding="utf-8")
# The only Exec= line is winpodx's own; the injected one must be gone.
exec_lines = [ln for ln in content.splitlines() if ln.startswith("Exec=")]
assert exec_lines == ["Exec=winpodx app run evil %u"]
# Name= stays on a single line with the newline collapsed to a space.
name_lines = [ln for ln in content.splitlines() if ln.startswith("Name=")]
assert len(name_lines) == 1
assert "touch /tmp/pwned" in name_lines[0] # payload neutralised into the name text
# D4: bundled_data_path resolves icon from source/wheel/user data dirs
def test_bundled_data_path_source_layout():
path = bundled_data_path("winpodx-icon.svg")
assert path is not None, "icon must be discoverable in source layout"
assert path.exists()
assert path.name == "winpodx-icon.svg"
def test_bundled_data_path_missing_returns_none():
assert bundled_data_path("does-not-exist-" + "x" * 20 + ".svg") is None
# Audit Issue 12: update_icon_cache must enforce timeout
def test_update_icon_cache_gtk_timeout(monkeypatch, tmp_path, caplog):
# gtk-update-icon-cache hang must be bounded by timeout=30.
import logging
import subprocess
from winpodx.desktop import icons as icons_mod
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
seen_timeouts: list[int | None] = []
def fake_run(cmd, **kwargs):
seen_timeouts.append(kwargs.get("timeout"))
if cmd[0] == "gtk-update-icon-cache":
raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout", 0))
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(icons_mod.subprocess, "run", fake_run)
monkeypatch.setattr(icons_mod.shutil, "which", lambda _c: None)
with caplog.at_level(logging.WARNING, logger="winpodx.desktop.icons"):
icons_mod.update_icon_cache()
assert 30 in seen_timeouts, f"expected timeout=30 in calls, got {seen_timeouts}"
assert any("timed out" in rec.message for rec in caplog.records)
def test_update_icon_cache_xdg_timeout(monkeypatch, tmp_path):
# xdg-icon-resource forceupdate is also bounded.
import subprocess
from winpodx.desktop import icons as icons_mod
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
observed_cmds: list[list[str]] = []
def fake_run(cmd, **kwargs):
observed_cmds.append(list(cmd))
assert kwargs.get("timeout") is not None, f"missing timeout for {cmd[0]}"
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(icons_mod.subprocess, "run", fake_run)
monkeypatch.setattr(icons_mod.shutil, "which", lambda _c: None)
icons_mod.update_icon_cache()
called = [c[0] for c in observed_cmds]
assert "xdg-icon-resource" in called
# Audit Issue 13: notify-send must enforce timeout
def test_notify_send_has_timeout(monkeypatch):
# send_notification must pass timeout= to subprocess.run.
import subprocess
from winpodx.desktop import notify as notify_mod
captured: dict[str, object] = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
captured["timeout"] = kwargs.get("timeout")
return subprocess.CompletedProcess(cmd, 0)
monkeypatch.setattr(notify_mod.subprocess, "run", fake_run)
notify_mod.send_notification("Title", "Body")
# cmd[0] is now the resolved absolute path (shutil.which) so it survives a
# stripped-PATH transient unit (#675) — bare name when which() returns None.
assert captured["cmd"][0] == "notify-send" or captured["cmd"][0].endswith("/notify-send")
assert captured["timeout"] == 5
def test_notify_send_timeout_swallowed(monkeypatch, caplog):
# TimeoutExpired must not propagate out of send_notification.
import logging
import subprocess
from winpodx.desktop import notify as notify_mod
def fake_run(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout", 0))
monkeypatch.setattr(notify_mod.subprocess, "run", fake_run)
with caplog.at_level(logging.DEBUG, logger="winpodx.desktop.notify"):
notify_mod.send_notification("t", "b")
assert any("timed out" in rec.message for rec in caplog.records)
# Audit Issue 14: remove_desktop_entry must clean scalable/apps/
def test_remove_desktop_entry_cleans_scalable_apps(tmp_path, monkeypatch):
# Icons installed to scalable/apps/ must be removed.
from winpodx.desktop.entry import remove_desktop_entry
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
scalable = tmp_path / "icons" / "hicolor" / "scalable" / "apps"
scalable.mkdir(parents=True)
svg = scalable / "winpodx-notepad.svg"
svg.write_text("<svg/>", encoding="utf-8")
sized = tmp_path / "icons" / "hicolor" / "48x48" / "apps"
sized.mkdir(parents=True)
png = sized / "winpodx-notepad.png"
png.write_bytes(b"\x89PNG")
apps = tmp_path / "applications"
apps.mkdir()
(apps / "winpodx-notepad.desktop").write_text("[Desktop Entry]\n", encoding="utf-8")
remove_desktop_entry("notepad")
assert not svg.exists(), "scalable/apps/ SVG should be removed"
assert not png.exists(), "sized PNG should also be removed"
assert not (apps / "winpodx-notepad.desktop").exists()
# Audit Issue 16: non-SVG icons must not land in scalable/apps/
def test_install_icon_rejects_non_svg(tmp_path, monkeypatch, caplog):
# .ico in scalable/apps/ is ignored by icon cache; must fall back.
import logging
from winpodx.desktop import entry as entry_mod
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
ico = tmp_path / "foo.ico"
ico.write_bytes(b"\x00\x00\x01\x00")
app = AppInfo(
name="foo",
full_name="Foo",
executable="C:\\foo.exe",
icon_path=str(ico),
)
with caplog.at_level(logging.WARNING, logger="winpodx.desktop.entry"):
result = entry_mod._install_icon(app)
assert result == "winpodx", "non-SVG should fall back to default icon"
assert any("not SVG" in rec.message for rec in caplog.records)
scalable = tmp_path / "icons" / "hicolor" / "scalable" / "apps"
assert not scalable.exists() or not any(scalable.iterdir())
def test_install_icon_accepts_svg(tmp_path, monkeypatch):
from winpodx.desktop import entry as entry_mod
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
svg = tmp_path / "foo.svg"
svg.write_text("<svg/>", encoding="utf-8")
app = AppInfo(
name="foo",
full_name="Foo",
executable="C:\\foo.exe",
icon_path=str(svg),
)
result = entry_mod._install_icon(app)
assert result == "winpodx-foo"
dest = tmp_path / "icons" / "hicolor" / "scalable" / "apps" / "winpodx-foo.svg"
assert dest.exists()
# Audit Issue 18: bundled_data_path must refuse symlink escapes
def test_bundled_data_path_rejects_symlink_escape(tmp_path, monkeypatch):
# A symlink pointing outside the data dir must not be returned.
from winpodx.desktop import icons as icons_mod
secret = tmp_path / "secret.txt"
secret.write_text("TOP SECRET", encoding="utf-8")
data = tmp_path / "bundle" / "data"
data.mkdir(parents=True)
(data / "winpodx-icon.svg").symlink_to(secret)
monkeypatch.setattr(icons_mod, "bundle_dir", lambda: tmp_path / "bundle")
result = icons_mod.bundled_data_path("winpodx-icon.svg")
assert result is None, "symlink escape must be rejected, got %r" % (result,)
def test_install_winpodx_icon_refuses_symlink_source(tmp_path, monkeypatch):
# install_winpodx_icon must refuse symlink sources.
from winpodx.desktop import icons as icons_mod
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
secret = tmp_path / "secret.key"
secret.write_text("SECRET", encoding="utf-8")
symlink = tmp_path / "symlink.svg"
symlink.symlink_to(secret)
monkeypatch.setattr(icons_mod, "bundled_data_path", lambda *_p: symlink)
ok = icons_mod.install_winpodx_icon()
assert ok is False
dest = tmp_path / "share" / "icons" / "hicolor" / "scalable" / "apps" / "winpodx.svg"
assert not dest.exists(), "must not copy symlink target contents"
class TestInstallGuiLauncherDesktop:
"""G7 (#255): `winpodx setup` registers the GUI launcher .desktop file."""
def test_copies_bundled_desktop_to_user_applications(self, tmp_path, monkeypatch):
from winpodx.desktop import icons as icons_mod
# Sandbox HOME and bundle a fake .desktop file.
monkeypatch.setattr(Path, "home", lambda: tmp_path)
bundled = tmp_path / "bundle" / "winpodx.desktop"
bundled.parent.mkdir(parents=True)
bundled.write_text("[Desktop Entry]\nExec=winpodx gui\n", encoding="utf-8")
monkeypatch.setattr(icons_mod, "bundled_data_path", lambda *_p: bundled)
# No system copy on this sandbox.
monkeypatch.setattr(
"pathlib.Path.is_file",
lambda self: (
str(self) != "/usr/share/applications/winpodx.desktop"
and Path.__dict__["is_file"].__wrapped__(self)
if False
else (str(self) != "/usr/share/applications/winpodx.desktop" and self.exists())
),
)
result = icons_mod.install_gui_launcher_desktop()
assert result is True
dest = tmp_path / ".local" / "share" / "applications" / "winpodx.desktop"
assert dest.is_file()
assert "Exec=winpodx gui" in dest.read_text()
def test_skips_when_system_copy_exists(self, tmp_path, monkeypatch):
from winpodx.desktop import icons as icons_mod
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Pretend /usr/share/applications/winpodx.desktop exists.
monkeypatch.setattr(
"pathlib.Path.is_file",
lambda self: str(self) == "/usr/share/applications/winpodx.desktop",
)
result = icons_mod.install_gui_launcher_desktop()
assert result is False
dest = tmp_path / ".local" / "share" / "applications" / "winpodx.desktop"
assert not dest.exists()
def test_refuses_symlink_source(self, tmp_path, monkeypatch):
from winpodx.desktop import icons as icons_mod
monkeypatch.setattr(Path, "home", lambda: tmp_path)
secret = tmp_path / "secret.key"
secret.write_text("SECRET", encoding="utf-8")
symlink = tmp_path / "winpodx.desktop"
symlink.symlink_to(secret)
monkeypatch.setattr(icons_mod, "bundled_data_path", lambda *_p: symlink)
# No system copy.
monkeypatch.setattr(
"pathlib.Path.is_file",
lambda self: (
False
if str(self) == "/usr/share/applications/winpodx.desktop"
else self.exists() and not self.is_symlink()
),
)
result = icons_mod.install_gui_launcher_desktop()
assert result is False
dest = tmp_path / ".local" / "share" / "applications" / "winpodx.desktop"
assert not dest.exists()
def test_bundled_data_path_accepts_regular_file(tmp_path, monkeypatch):
# Regression: ordinary files in the data dir still work.
from winpodx.desktop import icons as icons_mod
data = tmp_path / "bundle" / "data"
data.mkdir(parents=True)
(data / "winpodx-icon.svg").write_text("<svg/>", encoding="utf-8")
monkeypatch.setattr(icons_mod, "bundle_dir", lambda: tmp_path / "bundle")
result = icons_mod.bundled_data_path("winpodx-icon.svg")
assert result is not None
assert result.name == "winpodx-icon.svg"
# Audit Issue 20: save_app_profile must write UTF-8
def test_save_app_profile_utf8_korean(tmp_path, monkeypatch):
# TOML with Korean full_name must save under C locale.
pytest.importorskip("PySide6")
from winpodx.gui.app_dialog import save_app_profile
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
data = {
"name": "hangul",
"full_name": "\ud55c\uae00 \uba54\ubaa8\uc7a5",
"executable": "C:\\notepad.exe",
"categories": [],
"mime_types": [],
}
toml_path = save_app_profile(data)
assert toml_path.exists()
content = toml_path.read_text(encoding="utf-8")
assert "\ud55c\uae00 \uba54\ubaa8\uc7a5" in content
raw = toml_path.read_bytes()
assert "\ud55c\uae00 \uba54\ubaa8\uc7a5".encode("utf-8") in raw
def test_install_desktop_entry_uses_app_description(tmp_path, monkeypatch):
"""When AppInfo.description is set, the .desktop Comment= line should
use it instead of the generic 'Windows application via winpodx' stamp."""
monkeypatch.setattr("winpodx.desktop.entry.applications_dir", lambda: tmp_path)
monkeypatch.setattr("winpodx.desktop.entry.icons_dir", lambda: tmp_path / "icons")
app = AppInfo(
name="edge",
full_name="Microsoft Edge",
executable="C:\\Program Files\\Edge\\msedge.exe",
description="Browse the web with Microsoft Edge",
)
desktop_path = install_desktop_entry(app)
raw = desktop_path.read_text(encoding="utf-8")
assert "Comment=Browse the web with Microsoft Edge" in raw
assert "Comment=Windows application via WinPodX" not in raw
def test_install_desktop_entry_falls_back_when_description_blank(tmp_path, monkeypatch):
"""Apps without a discovered description still get the generic stamp."""
monkeypatch.setattr("winpodx.desktop.entry.applications_dir", lambda: tmp_path)
monkeypatch.setattr("winpodx.desktop.entry.icons_dir", lambda: tmp_path / "icons")
app = AppInfo(
name="legacy",
full_name="Legacy App",
executable="C:\\legacy.exe",
# description omitted intentionally
)
desktop_path = install_desktop_entry(app)
raw = desktop_path.read_text(encoding="utf-8")
assert "Comment=Windows application via WinPodX" in raw
def test_install_desktop_entry_strips_newlines_in_description(tmp_path, monkeypatch):
"""Multi-line / tab-laden descriptions from the guest must not
corrupt later .desktop keys (each key is line-terminated)."""
monkeypatch.setattr("winpodx.desktop.entry.applications_dir", lambda: tmp_path)
monkeypatch.setattr("winpodx.desktop.entry.icons_dir", lambda: tmp_path / "icons")
monkeypatch.setattr(entry_mod, "_winpodx_exe", lambda: "winpodx")
app = AppInfo(
name="messy",
full_name="Messy App",
executable="C:\\messy.exe",
description="Line one\nLine two\twith tab\rcarriage",
)
desktop_path = install_desktop_entry(app)
raw = desktop_path.read_text(encoding="utf-8")
comment_line = next(line for line in raw.splitlines() if line.startswith("Comment="))
assert "\n" not in comment_line
assert "\t" not in comment_line
assert "\r" not in comment_line
# Following keys (Exec, Icon, …) must still be intact.
assert "Exec=winpodx app run messy %u" in raw
def test_install_desktop_entry_uses_absolute_exec_path(tmp_path, monkeypatch):
# Desktop environments that launch apps as systemd transient units (e.g.
# Deepin's dde-application-manager) run with a stripped PATH that doesn't
# include ~/.local/bin, so a bare ``Exec=winpodx`` fails with
# "exec: winpodx: not found". install_desktop_entry must embed the absolute
# path resolved at install time.
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setattr(entry_mod, "_install_icon", lambda _app: "winpodx")
monkeypatch.setattr(entry_mod.shutil, "which", lambda name: f"/home/u/.local/bin/{name}")
app = AppInfo(
name="notepad",
full_name="Notepad",
executable="C:\\notepad.exe",
)
desktop_path = install_desktop_entry(app)
content = desktop_path.read_text(encoding="utf-8")
assert "Exec=/home/u/.local/bin/winpodx app run notepad %u" in content
def test_winpodx_exe_falls_back_to_bare_name(monkeypatch):
# shutil.which returns None when winpodx isn't on PATH (e.g. running from a
# checkout); the bare name keeps the .desktop entry valid for PATH-based
# launchers rather than emitting ``Exec=None``.
monkeypatch.setattr(entry_mod.shutil, "which", lambda _name: None)
assert entry_mod._winpodx_exe() == "winpodx"
if __name__ == "__main__": # pragma: no cover
pytest.main([__file__, "-v"])
# Menu consolidation: all winpodx apps land under one "winpodx" folder
# (Wine-style), via a custom X-winpodx category + a .directory/.menu fragment.
def _menu_paths(root):
"""(directory_file, menu_fragment) under an XDG root used as both DATA+CONFIG."""
directory = root / "desktop-directories" / "winpodx-windows.directory"
fragment = root / "menus" / "applications-merged" / "winpodx.menu"
return directory, fragment
def test_install_entry_consolidates_category(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
app = AppInfo(
name="word",
full_name="Microsoft Word",
executable="C:\\Office\\WINWORD.EXE",
categories=["Office", "WordProcessor"],
)
desktop_path = install_desktop_entry(app)
content = desktop_path.read_text(encoding="utf-8")
# Natural categories are replaced by the single grouping category, so the
# app shows ONLY in the winpodx folder, not scattered across Office/etc.
assert "Categories=X-winpodx;" in content
assert "Office" not in content.split("Categories=")[1].splitlines()[0]
# Search still finds it.
assert "winpodx;" in content # Keywords line
def test_install_entry_creates_menu_folder(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
app = AppInfo(name="notepad", full_name="Notepad", executable="C:\\notepad.exe")
install_desktop_entry(app)
directory, fragment = _menu_paths(tmp_path)
assert directory.exists()
assert "Type=Directory" in directory.read_text(encoding="utf-8")
assert fragment.exists()
frag = fragment.read_text(encoding="utf-8")
assert "<Category>X-winpodx</Category>" in frag
assert "winpodx-windows.directory" in frag
def test_remove_last_app_tears_down_menu_folder(tmp_path, monkeypatch):
from winpodx.desktop.entry import remove_desktop_entry
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
a = AppInfo(name="word", full_name="Word", executable="C:\\word.exe")
b = AppInfo(name="excel", full_name="Excel", executable="C:\\excel.exe")
install_desktop_entry(a)
install_desktop_entry(b)
directory, fragment = _menu_paths(tmp_path)
assert directory.exists() and fragment.exists()
# Removing one of two apps keeps the folder.
remove_desktop_entry("word")
assert directory.exists() and fragment.exists()
# Removing the last app tears it down.
remove_desktop_entry("excel")
assert not directory.exists()
assert not fragment.exists()
# #581 Goal 2: Start Menu folder hierarchy mirrored into nested winpodx submenus
def test_foldered_app_carries_leaf_category_and_folder_key(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
app = AppInfo(
name="word",
full_name="Microsoft Word",
executable="C:\\Office\\WINWORD.EXE",
start_menu_folder="Microsoft Office/Tools",
)
content = install_desktop_entry(app).read_text(encoding="utf-8")
# Leaf category only -- not the bare root -- so it lands in the nested node.
cat_line = next(ln for ln in content.splitlines() if ln.startswith("Categories="))
assert cat_line == "Categories=X-winpodx-microsoft-office-tools;"
assert "X-Winpodx-Folder=Microsoft Office/Tools" in content
def test_top_level_app_keeps_root_category_no_folder_key(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
app = AppInfo(name="notepad", full_name="Notepad", executable="C:\\notepad.exe")
content = install_desktop_entry(app).read_text(encoding="utf-8")
assert "Categories=X-winpodx;" in content
assert "X-Winpodx-Folder=" not in content
def test_nested_menu_tree_and_directory_files(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
install_desktop_entry(
AppInfo(
name="word",
full_name="Word",
executable="C:\\w.exe",
start_menu_folder="Microsoft Office",
)
)
install_desktop_entry(
AppInfo(
name="solver",
full_name="Solver",
executable="C:\\s.exe",
start_menu_folder="Microsoft Office/Tools",
)
)
_, fragment = _menu_paths(tmp_path)
frag = fragment.read_text(encoding="utf-8")
# Nested: Tools submenu lives under Microsoft Office.
assert "<Category>X-winpodx-microsoft-office</Category>" in frag
assert "<Category>X-winpodx-microsoft-office-tools</Category>" in frag
office_idx = frag.index("microsoft-office.directory")
tools_idx = frag.index("microsoft-office-tools.directory")
assert office_idx < tools_idx # parent declared before child
dirs = tmp_path / "desktop-directories"
assert (dirs / "winpodx-folder-microsoft-office.directory").exists()
tools_dir = (dirs / "winpodx-folder-microsoft-office-tools.directory").read_text(
encoding="utf-8"
)
assert "Name=Tools" in tools_dir # display name = leaf component, not slug
def test_ampersand_folder_does_not_break_menu_xml(tmp_path, monkeypatch):
# A folder like "Games & Stuff" must not emit a raw & into the .menu XML;
# the node name/category use the slug, the display name lives in .directory.
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
install_desktop_entry(
AppInfo(
name="game",
full_name="Game",
executable="C:\\g.exe",
start_menu_folder="Games & Stuff",
)
)
_, fragment = _menu_paths(tmp_path)
frag = fragment.read_text(encoding="utf-8")
assert "Games & Stuff" not in frag # raw & never enters the XML
assert "<Category>X-winpodx-games-stuff</Category>" in frag
dirs = tmp_path / "desktop-directories"
disp = (dirs / "winpodx-folder-games-stuff.directory").read_text(encoding="utf-8")
assert "Name=Games & Stuff" in disp # literal in the Desktop Entry .directory
def test_emptied_subfolder_directory_is_pruned(tmp_path, monkeypatch):
from winpodx.desktop.entry import remove_desktop_entry
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
install_desktop_entry(AppInfo(name="top", full_name="Top", executable="C:\\t.exe"))
install_desktop_entry(
AppInfo(
name="word",
full_name="Word",
executable="C:\\w.exe",
start_menu_folder="Microsoft Office",
)
)
dirs = tmp_path / "desktop-directories"
office_dir = dirs / "winpodx-folder-microsoft-office.directory"
assert office_dir.exists()
# Removing the only app in "Microsoft Office" prunes its .directory while the
# top-level app keeps the root folder alive.
remove_desktop_entry("word")
assert not office_dir.exists()
assert (dirs / "winpodx-windows.directory").exists()
def test_top_level_windows_folder_does_not_clobber_root(tmp_path, monkeypatch):
# A Start Menu folder named exactly "Windows" slugs to "windows"; its
# per-folder .directory must be namespaced (winpodx-folder-windows...) so it
# can't overwrite the root winpodx-windows.directory label/icon.
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
install_desktop_entry(
AppInfo(
name="wt",
full_name="Windows Terminal",
executable="C:\\wt.exe",
start_menu_folder="Windows",
)
)
dirs = tmp_path / "desktop-directories"
root = (dirs / "winpodx-windows.directory").read_text(encoding="utf-8")
assert "Name=WinPodX (Windows Apps)" in root
assert "Icon=winpodx" in root
# The "Windows" subfolder lives under the namespaced name with its own label.
sub = (dirs / "winpodx-folder-windows.directory").read_text(encoding="utf-8")
assert "Name=Windows" in sub