Skip to content

Commit a59f80d

Browse files
committed
test: cover install and verify scripts
1 parent 8a918aa commit a59f80d

2 files changed

Lines changed: 377 additions & 0 deletions

File tree

tests/test_install_script.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import shutil
5+
import subprocess
6+
from pathlib import Path
7+
8+
# NOTE: tests/conftest.py's autouse `_block_real_notifications` fixture patches
9+
# `subprocess.run` process-wide (it patches the shared `subprocess` module via
10+
# `app.notify.subprocess.run`). Resolving real tool paths must therefore use
11+
# `shutil.which`, not `subprocess.run(["which", ...])`.
12+
BASH = shutil.which("bash")
13+
14+
15+
def _write_stub(path: Path, body: str) -> None:
16+
path.write_text(f"#!{BASH}\n{body}\n", encoding="utf-8")
17+
path.chmod(0o755)
18+
19+
20+
def _prepare_install_script(tmp_path: Path, repo_root: Path) -> tuple[Path, Path]:
21+
"""Builds a fake repo + PATH so install.sh can run to completion without
22+
touching real system packages, sudo, or systemd state.
23+
24+
Strategy: shadow only the specific external commands whose real-world
25+
invocation would be destructive or non-deterministic (dpkg-query,
26+
systemctl, pgrep, ydotool, sudo, pip) by prepending a stub bin directory
27+
to the inherited PATH. Everything else (grep, sed, id, python3, ...)
28+
keeps using the real inherited PATH, since those calls are read-only and
29+
their real behavior is safe and deterministic across Ubuntu/Debian hosts.
30+
"""
31+
repo_dir = tmp_path / "repo"
32+
scripts_dir = repo_dir / "scripts"
33+
scripts_dir.mkdir(parents=True, exist_ok=True)
34+
script_path = scripts_dir / "install.sh"
35+
script_path.write_text((repo_root / "scripts" / "install.sh").read_text(encoding="utf-8"), encoding="utf-8")
36+
script_path.chmod(0o755)
37+
38+
systemd_dir = repo_dir / "systemd"
39+
systemd_dir.mkdir(parents=True, exist_ok=True)
40+
(systemd_dir / "blitztext-linux.service").write_text(
41+
(repo_root / "systemd" / "blitztext-linux.service").read_text(encoding="utf-8"), encoding="utf-8"
42+
)
43+
44+
venv_bin = repo_dir / ".venv" / "bin"
45+
venv_bin.mkdir(parents=True, exist_ok=True)
46+
_write_stub(venv_bin / "pip", "exit 0")
47+
48+
stub_bin = tmp_path / "bin"
49+
stub_bin.mkdir(parents=True, exist_ok=True)
50+
51+
_write_stub(stub_bin / "dpkg-query", 'printf "install ok installed\\n"\nexit 0')
52+
_write_stub(stub_bin / "ydotool", "exit 0")
53+
_write_stub(stub_bin / "pgrep", "exit 1")
54+
_write_stub(stub_bin / "sudo", 'exec "$@"')
55+
_write_stub(
56+
stub_bin / "systemctl",
57+
r"""
58+
args="$*"
59+
case "$args" in
60+
*"ydotool.service"*)
61+
exit 1
62+
;;
63+
*"is-active --quiet blitztext-linux"*)
64+
[[ "${BT_TEST_APP_ACTIVE:-0}" == "1" ]] && exit 0 || exit 1
65+
;;
66+
*"is-enabled --quiet blitztext-linux"*)
67+
[[ "${BT_TEST_APP_ENABLED:-0}" == "1" ]] && exit 0 || exit 1
68+
;;
69+
*"daemon-reload"*)
70+
exit 0
71+
;;
72+
*"user enable blitztext-linux"*)
73+
exit 0
74+
;;
75+
*)
76+
exit 1
77+
;;
78+
esac
79+
""".strip("\n"),
80+
)
81+
82+
return script_path, stub_bin
83+
84+
85+
def _run_script(
86+
script_path: Path,
87+
home_dir: Path,
88+
runtime_dir: Path,
89+
stub_bin: Path,
90+
*,
91+
extra_env: dict[str, str] | None = None,
92+
) -> subprocess.CompletedProcess[str]:
93+
env = os.environ.copy()
94+
env.update(
95+
{
96+
"HOME": str(home_dir),
97+
"XDG_RUNTIME_DIR": str(runtime_dir),
98+
"PATH": f"{stub_bin}{os.pathsep}{env['PATH']}",
99+
}
100+
)
101+
if extra_env:
102+
env.update(extra_env)
103+
proc = subprocess.Popen(
104+
[BASH, str(script_path)],
105+
cwd=str(script_path.parent),
106+
env=env,
107+
text=True,
108+
stdout=subprocess.PIPE,
109+
stderr=subprocess.PIPE,
110+
)
111+
stdout, stderr = proc.communicate()
112+
return subprocess.CompletedProcess(proc.args, proc.returncode, stdout, stderr)
113+
114+
115+
def test_install_dies_on_invalid_blitztext_no_hotkey_value(tmp_path: Path):
116+
repo_root = Path(__file__).resolve().parents[1]
117+
script_path, stub_bin = _prepare_install_script(tmp_path, repo_root)
118+
home_dir = tmp_path / "home"
119+
runtime_dir = tmp_path / "runtime"
120+
runtime_dir.mkdir(parents=True, exist_ok=True)
121+
122+
result = _run_script(
123+
script_path, home_dir, runtime_dir, stub_bin, extra_env={"BLITZTEXT_NO_HOTKEY": "2"}
124+
)
125+
126+
assert result.returncode == 1
127+
assert "BLITZTEXT_NO_HOTKEY muss 0 oder 1 sein" in result.stderr
128+
129+
130+
def test_install_warns_before_daemon_reload_when_app_is_active(tmp_path: Path):
131+
repo_root = Path(__file__).resolve().parents[1]
132+
script_path, stub_bin = _prepare_install_script(tmp_path, repo_root)
133+
home_dir = tmp_path / "home"
134+
runtime_dir = tmp_path / "runtime"
135+
runtime_dir.mkdir(parents=True, exist_ok=True)
136+
137+
result = _run_script(
138+
script_path,
139+
home_dir,
140+
runtime_dir,
141+
stub_bin,
142+
extra_env={"BLITZTEXT_NO_HOTKEY": "1", "BT_TEST_APP_ACTIVE": "1"},
143+
)
144+
145+
assert result.returncode == 0, result.stderr
146+
assert "blitztext-linux läuft gerade" in result.stdout
147+
assert "daemon-reload kann die App unerwartet beenden" in result.stdout
148+
assert "systemctl --user stop blitztext-linux" in result.stdout
149+
150+
151+
def test_install_stays_quiet_before_daemon_reload_when_app_is_inactive(tmp_path: Path):
152+
repo_root = Path(__file__).resolve().parents[1]
153+
script_path, stub_bin = _prepare_install_script(tmp_path, repo_root)
154+
home_dir = tmp_path / "home"
155+
runtime_dir = tmp_path / "runtime"
156+
runtime_dir.mkdir(parents=True, exist_ok=True)
157+
158+
result = _run_script(
159+
script_path,
160+
home_dir,
161+
runtime_dir,
162+
stub_bin,
163+
extra_env={"BLITZTEXT_NO_HOTKEY": "1", "BT_TEST_APP_ACTIVE": "0"},
164+
)
165+
166+
assert result.returncode == 0, result.stderr
167+
assert "blitztext-linux läuft gerade" not in result.stdout
168+
assert "daemon-reload kann die App unerwartet beenden" not in result.stdout
169+
170+
171+
def test_install_completes_and_enables_autostart_when_not_yet_enabled(tmp_path: Path):
172+
repo_root = Path(__file__).resolve().parents[1]
173+
script_path, stub_bin = _prepare_install_script(tmp_path, repo_root)
174+
home_dir = tmp_path / "home"
175+
runtime_dir = tmp_path / "runtime"
176+
runtime_dir.mkdir(parents=True, exist_ok=True)
177+
178+
result = _run_script(
179+
script_path,
180+
home_dir,
181+
runtime_dir,
182+
stub_bin,
183+
extra_env={"BLITZTEXT_NO_HOTKEY": "1", "BT_TEST_APP_ENABLED": "0"},
184+
)
185+
186+
assert result.returncode == 0, result.stderr
187+
assert "Installation abgeschlossen" in result.stdout
188+
assert "blitztext-linux.service für Autostart aktiviert" in result.stdout
189+
service_dst = home_dir / ".config" / "systemd" / "user" / "blitztext-linux.service"
190+
assert service_dst.exists()
191+
assert "%BLITZTEXT_DIR%" not in service_dst.read_text(encoding="utf-8")

tests/test_verify_script.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import shutil
5+
import subprocess
6+
from pathlib import Path
7+
8+
# NOTE: tests/conftest.py's autouse `_block_real_notifications` fixture patches
9+
# `subprocess.run` process-wide (it patches the shared `subprocess` module via
10+
# `app.notify.subprocess.run`). Resolving real tool paths must therefore use
11+
# `shutil.which`, not `subprocess.run(["which", ...])`.
12+
INFRA_TOOLS = ("grep", "whoami", "stat", "sort", "dirname", "sed")
13+
BASH = shutil.which("bash")
14+
15+
16+
def _symlink_real_tool(stub_bin: Path, name: str) -> None:
17+
resolved = shutil.which(name)
18+
if not resolved:
19+
raise RuntimeError(f"required real tool not found on PATH: {name}")
20+
(stub_bin / name).symlink_to(resolved)
21+
22+
23+
def _write_stub(stub_bin: Path, name: str, body: str) -> None:
24+
path = stub_bin / name
25+
path.write_text(f"#!{BASH}\n{body}\n", encoding="utf-8")
26+
path.chmod(0o755)
27+
28+
29+
def _prepare_verify_script(
30+
tmp_path: Path,
31+
repo_root: Path,
32+
*,
33+
present_checked_tools: tuple[str, ...] = (),
34+
fake_groups: str = "",
35+
) -> tuple[Path, Path]:
36+
"""Copies verify.sh into an isolated fake repo and builds a deterministic PATH.
37+
38+
Returns (script_path, stub_bin_dir). The isolated PATH shadows every
39+
checked binary (parec/wl-copy/xclip/ydotool/ffmpeg/socat) so results do
40+
not depend on what happens to be installed on the machine running the
41+
tests.
42+
"""
43+
scripts_dir = tmp_path / "repo" / "scripts"
44+
scripts_dir.mkdir(parents=True, exist_ok=True)
45+
script_path = scripts_dir / "verify.sh"
46+
script_path.write_text((repo_root / "scripts" / "verify.sh").read_text(encoding="utf-8"), encoding="utf-8")
47+
script_path.chmod(0o755)
48+
49+
stub_bin = tmp_path / "bin"
50+
stub_bin.mkdir(parents=True, exist_ok=True)
51+
for tool in INFRA_TOOLS:
52+
_symlink_real_tool(stub_bin, tool)
53+
54+
for tool in present_checked_tools:
55+
_write_stub(stub_bin, tool, "exit 0")
56+
57+
_write_stub(
58+
stub_bin,
59+
"groups",
60+
f'printf "%s\\n" "{fake_groups}"',
61+
)
62+
_write_stub(stub_bin, "id", '[[ "$1" == "-Gn" ]] && printf "%s\\n" "" || echo 0')
63+
_write_stub(stub_bin, "systemctl", "exit 1")
64+
_write_stub(stub_bin, "pgrep", "exit 1")
65+
66+
return script_path, stub_bin
67+
68+
69+
def _run_script(script_path: Path, home_dir: Path, runtime_dir: Path, stub_bin: Path) -> subprocess.CompletedProcess[str]:
70+
env = os.environ.copy()
71+
env.update(
72+
{
73+
"HOME": str(home_dir),
74+
"XDG_RUNTIME_DIR": str(runtime_dir),
75+
"PATH": str(stub_bin),
76+
"WAYLAND_DISPLAY": "",
77+
"DISPLAY": "",
78+
}
79+
)
80+
proc = subprocess.Popen(
81+
[BASH, str(script_path)],
82+
cwd=str(script_path.parent),
83+
env=env,
84+
text=True,
85+
stdout=subprocess.PIPE,
86+
stderr=subprocess.PIPE,
87+
)
88+
stdout, stderr = proc.communicate()
89+
return subprocess.CompletedProcess(proc.args, proc.returncode, stdout, stderr)
90+
91+
92+
def test_verify_fails_and_exits_1_when_required_tools_and_venv_are_missing(tmp_path: Path):
93+
repo_root = Path(__file__).resolve().parents[1]
94+
script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root, present_checked_tools=())
95+
home_dir = tmp_path / "home"
96+
runtime_dir = tmp_path / "runtime"
97+
runtime_dir.mkdir(parents=True, exist_ok=True)
98+
99+
result = _run_script(script_path, home_dir, runtime_dir, stub_bin)
100+
101+
assert result.returncode == 1
102+
assert "[FAIL]" in result.stdout
103+
assert ".venv/bin/python nicht gefunden" in result.stdout
104+
105+
106+
def test_verify_config_permissions_pass_when_file_is_0600(tmp_path: Path):
107+
repo_root = Path(__file__).resolve().parents[1]
108+
script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root)
109+
home_dir = tmp_path / "home"
110+
runtime_dir = tmp_path / "runtime"
111+
runtime_dir.mkdir(parents=True, exist_ok=True)
112+
config_file = home_dir / ".config" / "blitztext-linux" / "config.json"
113+
config_file.parent.mkdir(parents=True, exist_ok=True)
114+
config_file.write_text("{}", encoding="utf-8")
115+
config_file.chmod(0o600)
116+
117+
result = _run_script(script_path, home_dir, runtime_dir, stub_bin)
118+
119+
assert "config.json vorhanden und korrekt geschützt (0600)" in result.stdout
120+
121+
122+
def test_verify_config_permissions_warn_when_file_is_more_permissive(tmp_path: Path):
123+
repo_root = Path(__file__).resolve().parents[1]
124+
script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root)
125+
home_dir = tmp_path / "home"
126+
runtime_dir = tmp_path / "runtime"
127+
runtime_dir.mkdir(parents=True, exist_ok=True)
128+
config_file = home_dir / ".config" / "blitztext-linux" / "config.json"
129+
config_file.parent.mkdir(parents=True, exist_ok=True)
130+
config_file.write_text("{}", encoding="utf-8")
131+
config_file.chmod(0o644)
132+
133+
result = _run_script(script_path, home_dir, runtime_dir, stub_bin)
134+
135+
assert "Berechtigungen sind 644" in result.stdout
136+
assert "chmod 600" in result.stdout
137+
138+
139+
def test_verify_config_missing_reports_info_not_fail(tmp_path: Path):
140+
repo_root = Path(__file__).resolve().parents[1]
141+
script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root)
142+
home_dir = tmp_path / "home"
143+
runtime_dir = tmp_path / "runtime"
144+
runtime_dir.mkdir(parents=True, exist_ok=True)
145+
146+
result = _run_script(script_path, home_dir, runtime_dir, stub_bin)
147+
148+
assert "config.json nicht gefunden" in result.stdout
149+
assert "wird beim ersten Start automatisch erstellt" in result.stdout
150+
151+
152+
def test_verify_missing_xdg_runtime_dir_is_reported_as_fail(tmp_path: Path):
153+
repo_root = Path(__file__).resolve().parents[1]
154+
script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root)
155+
home_dir = tmp_path / "home"
156+
157+
env = os.environ.copy()
158+
env.update({"HOME": str(home_dir), "PATH": str(stub_bin), "WAYLAND_DISPLAY": "", "DISPLAY": ""})
159+
env.pop("XDG_RUNTIME_DIR", None)
160+
proc = subprocess.Popen(
161+
[BASH, str(script_path)],
162+
cwd=str(script_path.parent),
163+
env=env,
164+
text=True,
165+
stdout=subprocess.PIPE,
166+
stderr=subprocess.PIPE,
167+
)
168+
stdout, _ = proc.communicate()
169+
170+
assert "XDG_RUNTIME_DIR nicht gesetzt" in stdout
171+
assert proc.returncode == 1
172+
173+
174+
def test_verify_all_checked_binaries_present_removes_their_fail_lines(tmp_path: Path):
175+
repo_root = Path(__file__).resolve().parents[1]
176+
checked_tools = ("parec", "wl-copy", "xclip", "ydotool", "ffmpeg", "socat")
177+
script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root, present_checked_tools=checked_tools)
178+
home_dir = tmp_path / "home"
179+
runtime_dir = tmp_path / "runtime"
180+
runtime_dir.mkdir(parents=True, exist_ok=True)
181+
182+
result = _run_script(script_path, home_dir, runtime_dir, stub_bin)
183+
184+
for tool in checked_tools:
185+
assert f"{tool} nicht gefunden" not in result.stdout
186+
assert f"{tool} gefunden" in result.stdout

0 commit comments

Comments
 (0)