Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
647 lines
27 KiB
Python
647 lines
27 KiB
Python
import fnmatch
|
|
import importlib.util
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from string import Template
|
|
|
|
import pytest
|
|
|
|
SCRIPT = Path(__file__).parents[1] / "patch_research_edition_profile.py"
|
|
SPEC = importlib.util.spec_from_file_location("patch_research_edition_profile", SCRIPT)
|
|
assert SPEC and SPEC.loader
|
|
patcher = importlib.util.module_from_spec(SPEC)
|
|
# dataclasses.dataclass() looks the defining module up in sys.modules (to
|
|
# resolve string type hints), so it must be registered before exec_module
|
|
# runs the class body -- otherwise the `Patch` dataclass fails to import.
|
|
sys.modules[SPEC.name] = patcher
|
|
SPEC.loader.exec_module(patcher)
|
|
|
|
|
|
def make_fixture_root(tmp_path: Path, target: str) -> Path:
|
|
"""Build a synthetic tree containing exactly one occurrence of every ``old``.
|
|
|
|
Patches for the same path are concatenated in table order, each preceded
|
|
by a filler line, so every target string appears exactly once per file --
|
|
the precondition ``apply_patches`` requires to succeed.
|
|
"""
|
|
by_path: "dict[str, list[patcher.Patch]]" = {}
|
|
for patch in patcher.TARGETS[target]:
|
|
by_path.setdefault(patch.path, []).append(patch)
|
|
|
|
for rel_path, patches in by_path.items():
|
|
file_path = tmp_path / rel_path
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
content = "".join(f"# filler\n{p.old}" for p in patches)
|
|
file_path.write_text(content, encoding="utf-8")
|
|
|
|
return tmp_path
|
|
|
|
|
|
@pytest.mark.parametrize("target", ["qt", "tauri"])
|
|
def test_applies_every_site(tmp_path: Path, target: str):
|
|
root = make_fixture_root(tmp_path, target)
|
|
patches = patcher.TARGETS[target]
|
|
|
|
applied = patcher.apply_patches(root, patches, check=False)
|
|
|
|
assert len(applied) == len(patches)
|
|
|
|
by_path: "dict[str, list[patcher.Patch]]" = {}
|
|
for patch in patches:
|
|
by_path.setdefault(patch.path, []).append(patch)
|
|
|
|
for rel_path, file_patches in by_path.items():
|
|
text = (root / rel_path).read_text(encoding="utf-8")
|
|
for patch in file_patches:
|
|
assert patch.new in text
|
|
if patch.old not in patch.new:
|
|
assert patch.old not in text
|
|
|
|
|
|
@pytest.mark.parametrize("target", ["qt", "tauri"])
|
|
def test_check_mode_writes_nothing(tmp_path: Path, target: str):
|
|
root = make_fixture_root(tmp_path, target)
|
|
patches = patcher.TARGETS[target]
|
|
|
|
before = {
|
|
rel_path: (root / rel_path).read_bytes()
|
|
for rel_path in {p.path for p in patches}
|
|
}
|
|
|
|
applied = patcher.apply_patches(root, patches, check=True)
|
|
|
|
assert len(applied) == len(patches)
|
|
for rel_path, contents in before.items():
|
|
assert (root / rel_path).read_bytes() == contents
|
|
|
|
|
|
def test_missing_file_fails_closed(tmp_path: Path):
|
|
root = make_fixture_root(tmp_path, "qt")
|
|
patches = patcher.TARGETS["qt"]
|
|
victim = patches[0].path
|
|
(root / victim).unlink()
|
|
|
|
with pytest.raises(patcher.PatchError, match="submodule") as exc_info:
|
|
patcher.apply_patches(root, patches, check=False)
|
|
assert victim in str(exc_info.value)
|
|
|
|
|
|
def test_missing_token_fails_closed(tmp_path: Path):
|
|
root = make_fixture_root(tmp_path, "qt")
|
|
patches = patcher.TARGETS["qt"]
|
|
victim = patches[0]
|
|
file_path = root / victim.path
|
|
text = file_path.read_text(encoding="utf-8")
|
|
file_path.write_text(text.replace(victim.old, "", 1), encoding="utf-8")
|
|
|
|
with pytest.raises(patcher.PatchError) as exc_info:
|
|
patcher.apply_patches(root, patches, check=False)
|
|
|
|
message = str(exc_info.value)
|
|
assert victim.path in message
|
|
assert victim.why in message
|
|
assert "found 0" in message
|
|
|
|
|
|
def test_ambiguous_token_fails_closed(tmp_path: Path):
|
|
root = make_fixture_root(tmp_path, "qt")
|
|
patches = patcher.TARGETS["qt"]
|
|
victim = patches[0]
|
|
file_path = root / victim.path
|
|
text = file_path.read_text(encoding="utf-8")
|
|
file_path.write_text(text + victim.old, encoding="utf-8")
|
|
|
|
with pytest.raises(patcher.PatchError, match="found 2"):
|
|
patcher.apply_patches(root, patches, check=False)
|
|
|
|
|
|
def test_nothing_written_when_one_site_fails(tmp_path: Path):
|
|
root = make_fixture_root(tmp_path, "qt")
|
|
patches = patcher.TARGETS["qt"]
|
|
|
|
# Pick a path with multiple patches so breaking one leaves the others
|
|
# unable to complete the file's all-or-nothing write.
|
|
by_path: "dict[str, list[patcher.Patch]]" = {}
|
|
for patch in patches:
|
|
by_path.setdefault(patch.path, []).append(patch)
|
|
multi_patch_path = next(p for p, ps in by_path.items() if len(ps) > 1)
|
|
|
|
file_path = root / multi_patch_path
|
|
broken_patch = by_path[multi_patch_path][-1]
|
|
text = file_path.read_text(encoding="utf-8")
|
|
file_path.write_text(text.replace(broken_patch.old, "", 1), encoding="utf-8")
|
|
before = file_path.read_bytes()
|
|
|
|
with pytest.raises(patcher.PatchError):
|
|
patcher.apply_patches(root, patches, check=False)
|
|
|
|
assert file_path.read_bytes() == before
|
|
|
|
|
|
def test_reapply_is_refused(tmp_path: Path):
|
|
root = make_fixture_root(tmp_path, "qt")
|
|
patches = patcher.TARGETS["qt"]
|
|
|
|
patcher.apply_patches(root, patches, check=False)
|
|
|
|
with pytest.raises(patcher.PatchError, match="already applied"):
|
|
patcher.apply_patches(root, patches, check=False)
|
|
|
|
|
|
def test_main_exit_codes(tmp_path: Path, capsys):
|
|
good_root = make_fixture_root(tmp_path / "good", "qt")
|
|
assert patcher.main(["qt", "--root", str(good_root), "--check"]) == 0
|
|
|
|
bad_root = tmp_path / "bad"
|
|
bad_root.mkdir()
|
|
code = patcher.main(["qt", "--root", str(bad_root), "--check"])
|
|
assert code == 1
|
|
captured = capsys.readouterr()
|
|
assert "ERROR" in captured.err
|
|
|
|
|
|
def _repo_root() -> Path:
|
|
return Path(__file__).resolve().parents[2]
|
|
|
|
|
|
@pytest.mark.parametrize("target", ["qt", "tauri"])
|
|
def test_real_tree_check_passes_if_submodules_present(target: str):
|
|
root = _repo_root()
|
|
marker = root / "aw-qt" / "aw_qt" / "profile.py"
|
|
if not marker.is_file():
|
|
pytest.skip("submodules not checked out")
|
|
|
|
applied = patcher.apply_patches(root, patcher.TARGETS[target], check=True)
|
|
|
|
assert len(applied) == len(patcher.TARGETS[target])
|
|
|
|
|
|
def test_patched_python_profile_module_behaviour(tmp_path: Path, monkeypatch):
|
|
repo_root = _repo_root()
|
|
real_profile = repo_root / "aw-qt" / "aw_qt" / "profile.py"
|
|
if not real_profile.is_file():
|
|
pytest.skip("submodules not checked out")
|
|
|
|
dest = tmp_path / "aw-qt" / "aw_qt" / "profile.py"
|
|
dest.parent.mkdir(parents=True)
|
|
dest.write_text(real_profile.read_text(encoding="utf-8"), encoding="utf-8")
|
|
|
|
qt_python_patches = [
|
|
p for p in patcher.TARGETS["qt"] if p.path == "aw-qt/aw_qt/profile.py"
|
|
]
|
|
patcher.apply_patches(tmp_path, qt_python_patches, check=False)
|
|
|
|
spec = importlib.util.spec_from_file_location("patched_profile", dest)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
monkeypatch.delenv("AW_PROFILE", raising=False)
|
|
|
|
assert module.resolve_profile(None, False) == "research"
|
|
assert module.resolve_profile(None, True) == "testing"
|
|
assert module.DEFAULT_PROFILE == "default"
|
|
assert module.profile_suffix("research") == "-research"
|
|
|
|
module.export_profile(module.resolve_profile(None, False))
|
|
assert os.environ["AW_PROFILE"] == "research"
|
|
|
|
|
|
@pytest.mark.parametrize("target", ["qt", "tauri"])
|
|
def test_macos_bundle_dir_is_distinct_from_standard(tmp_path: Path, target: str):
|
|
"""Research builds must not emit ActivityWatch.app next to a standard install."""
|
|
root = make_fixture_root(tmp_path, target)
|
|
patcher.apply_patches(root, patcher.TARGETS[target], check=False)
|
|
|
|
stem = patcher.BUNDLE_DIR_STEM
|
|
expected_app = f"{stem}.app"
|
|
colliding = "ActivityWatch.app"
|
|
|
|
if target == "qt":
|
|
spec = (root / "aw.spec").read_text(encoding="utf-8")
|
|
assert f'name="{expected_app}"' in spec
|
|
assert f'name="{colliding}"' not in spec
|
|
else:
|
|
tauri = (root / "scripts/package/build_app_tauri.sh").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
assert f'APP_NAME="{stem}"' in tauri
|
|
assert 'APP_NAME="ActivityWatch"\n' not in tauri
|
|
|
|
makefile = (root / "Makefile").read_text(encoding="utf-8")
|
|
assert f"APP_BUNDLE ?= {stem}\n" in makefile
|
|
assert "APP_BUNDLE ?= ActivityWatch\n" not in makefile
|
|
|
|
notarize = (root / "scripts/notarize.sh").read_text(encoding="utf-8")
|
|
assert f"app=dist/{expected_app}" in notarize
|
|
assert "app=dist/ActivityWatch.app" not in notarize
|
|
|
|
|
|
# --- Windows install identity -------------------------------------------------
|
|
# These use the *real* .iss / tauri.conf.json rather than the synthetic fixture,
|
|
# because the thing under test is largely what the patch does NOT touch: the
|
|
# shortcut, uninstall and install-dir lines that derive from `#define MyAppName`.
|
|
# A synthetic file built from the `old` strings alone cannot catch upstream
|
|
# hardcoding a name that would then collide with a standard install.
|
|
|
|
WINDOWS_REAL_FILES = {
|
|
"qt": "scripts/package/activitywatch-setup.iss",
|
|
"tauri": "scripts/package/aw-tauri.iss",
|
|
}
|
|
STANDARD_APPID_QT = "F226B8F4-3244-46E6-901D-0CE8035423E4"
|
|
STANDARD_APPID_TAURI = "983D0855-08C8-46BD-AEFB-3924581C6703"
|
|
|
|
|
|
def _patch_real_file(tmp_path: Path, rel_path: str, patches) -> str:
|
|
"""Copy one real repo file into ``tmp_path``, patch it, return the result."""
|
|
src = _repo_root() / rel_path
|
|
if not src.is_file():
|
|
pytest.skip(f"{rel_path} not present")
|
|
dst = tmp_path / rel_path
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
|
|
|
relevant = [p for p in patches if p.path == rel_path]
|
|
patcher.apply_patches(tmp_path, relevant, check=False)
|
|
return dst.read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.mark.parametrize("target", ["qt", "tauri"])
|
|
def test_windows_installer_is_a_separate_product(tmp_path: Path, target: str):
|
|
"""Research setup must register its own product, not upgrade over standard."""
|
|
rel = WINDOWS_REAL_FILES[target]
|
|
patches = (
|
|
patcher.WINDOWS_PATCHES_QT
|
|
if target == "qt"
|
|
else patcher.WINDOWS_PATCHES_TAURI
|
|
)
|
|
text = _patch_real_file(tmp_path, rel, patches)
|
|
|
|
research_appid = (
|
|
patcher.WINDOWS_APPID_QT if target == "qt" else patcher.WINDOWS_APPID_TAURI
|
|
)
|
|
standard_appid = STANDARD_APPID_QT if target == "qt" else STANDARD_APPID_TAURI
|
|
|
|
# AppId is what Inno keys "same product" on — it must have changed.
|
|
assert f"AppId={{{{{research_appid}}}" in text
|
|
assert standard_appid not in text
|
|
|
|
# Display name drives shortcuts, uninstall entry and (Qt) the install dir.
|
|
assert patcher.BUNDLE_NAME in text
|
|
assert '#define MyAppName "ActivityWatch"\n' not in text
|
|
assert '#define MyAppName "ActivityWatch (Tauri)"\n' not in text
|
|
|
|
# Setup .exe no longer collides with the standard artifact.
|
|
assert "OutputBaseFilename=activitywatch-setup\n" not in text
|
|
assert "OutputBaseFilename=activitywatch-research" in text
|
|
|
|
# Shortcut / uninstall identity must still *derive* from MyAppName. If
|
|
# upstream ever hardcodes the name here, the research build would install a
|
|
# shortcut and uninstall entry indistinguishable from a standard install.
|
|
for line in ("{autoprograms}", "{autodesktop}", "{userstartup}"):
|
|
assert f'Name: "{line}\\{{#MyAppName}}"' in text
|
|
assert "UninstallDisplayName={#MyAppName}\n" in text
|
|
|
|
|
|
def test_windows_research_installers_do_not_collide_with_each_other(tmp_path: Path):
|
|
"""Qt-research and Tauri-research are also distinct products from each other."""
|
|
qt = _patch_real_file(
|
|
tmp_path / "qt", WINDOWS_REAL_FILES["qt"], patcher.WINDOWS_PATCHES_QT
|
|
)
|
|
tauri = _patch_real_file(
|
|
tmp_path / "tauri", WINDOWS_REAL_FILES["tauri"], patcher.WINDOWS_PATCHES_TAURI
|
|
)
|
|
|
|
appids = {
|
|
patcher.WINDOWS_APPID_QT,
|
|
patcher.WINDOWS_APPID_TAURI,
|
|
STANDARD_APPID_QT,
|
|
STANDARD_APPID_TAURI,
|
|
}
|
|
assert len(appids) == 4, "research AppIds must be unique GUIDs"
|
|
|
|
def _output_name(text: str) -> str:
|
|
for line in text.splitlines():
|
|
if line.startswith("OutputBaseFilename="):
|
|
return line.split("=", 1)[1]
|
|
raise AssertionError("no OutputBaseFilename")
|
|
|
|
# Both .iss files ship `activitywatch-setup` on master, so the research
|
|
# names must be distinct from each other as well as from standard.
|
|
assert _output_name(qt) != _output_name(tauri)
|
|
|
|
# Install directories must differ (Qt derives its dir from MyAppName).
|
|
assert "DefaultDirName={autopf}\\{#MyAppName}\n" in qt
|
|
assert f"DefaultDirName={{autopf}}\\{patcher.BUNDLE_DIR_STEM}-Tauri\n" in tauri
|
|
assert "DefaultDirName={autopf}\\ActivityWatch-Tauri\n" not in tauri
|
|
|
|
|
|
@pytest.fixture(
|
|
params=[(target, research) for target in ("qt", "tauri") for research in (False, True)]
|
|
)
|
|
def windows_installer_names(tmp_path: Path, request):
|
|
target, research = request.param
|
|
rel = WINDOWS_REAL_FILES[target]
|
|
# Packaging inputs are tracked root files, so a missing producer must fail.
|
|
assert (_repo_root() / rel).is_file()
|
|
patches = (
|
|
patcher.WINDOWS_PATCHES_QT if target == "qt" else patcher.WINDOWS_PATCHES_TAURI
|
|
)
|
|
text = _patch_real_file(tmp_path, rel, patches if research else [])
|
|
outputs = re.findall(r"^OutputBaseFilename=(\S+)$", text, re.MULTILINE)
|
|
assert len(outputs) == 1, "expected one executable OutputBaseFilename assignment"
|
|
|
|
package = (_repo_root() / "scripts/package/package-all.sh").read_text()
|
|
calls = re.findall(
|
|
r'^\s*"\$SCRIPT_DIR/collect-setup\.sh" "\$filename"\s*$', package, re.MULTILINE
|
|
)
|
|
assert len(calls) == 1, "build_setup must invoke the tested installer collector"
|
|
templates = re.findall(
|
|
r'^\s*filename="([^"\n]+-setup\.exe)"$', package, re.MULTILINE
|
|
)
|
|
assert len(templates) == 1, "expected one versioned installer filename assignment"
|
|
suffix = "-tauri" if target == "tauri" else ""
|
|
if research:
|
|
suffix += "-research"
|
|
final = Template(templates[0]).substitute(
|
|
build_suffix=suffix, version="v0.14.0b5", platform="windows", arch="x86_64"
|
|
)
|
|
return target, outputs[0] + ".exe", final
|
|
|
|
|
|
def test_windows_installer_producer_matches_packaging_and_release(windows_installer_names):
|
|
target, produced, final = windows_installer_names
|
|
collector = (_repo_root() / "scripts/package/collect-setup.sh").read_text()
|
|
inputs = re.findall(r"^\s*setup_src=\(([^)\n]+)\)$", collector, re.MULTILINE)
|
|
assert len(inputs) == 1, "expected one executable setup_src assignment"
|
|
assert fnmatch.fnmatchcase(f"dist/{produced}", inputs[0].strip())
|
|
|
|
workflow = (_repo_root() / ".github/workflows/release.yml").read_text()
|
|
jobs = dict(re.findall(
|
|
r"^ ([\w-]+):\n(.*?)(?=^ [\w-]+:|\Z)", workflow, re.MULTILINE | re.DOTALL
|
|
))
|
|
upload = re.search(
|
|
r"^ - name: Upload packages\n(.*?)(?=^ - |\Z)",
|
|
jobs[f"build-{target}"], re.MULTILINE | re.DOTALL,
|
|
)
|
|
assert upload, f"missing {target} package upload step"
|
|
assert "uses: actions/upload-artifact@" in upload[1]
|
|
path = re.search(r"^ path: (.+)$", upload[1], re.MULTILINE)
|
|
assert path, f"missing {target} package upload path"
|
|
globs = (
|
|
re.findall(r"^ (\S+)$", upload[1], re.MULTILINE)
|
|
if path[1] == "|" else [path[1]]
|
|
)
|
|
assert globs and any(fnmatch.fnmatchcase(f"dist/{final}", glob) for glob in globs)
|
|
release = re.search(
|
|
r"^ - name: Release\n(.*?)(?=^ - |\Z)",
|
|
jobs["release"], re.MULTILINE | re.DOTALL,
|
|
)
|
|
assert release and "uses: softprops/action-gh-release@" in release[1]
|
|
files = re.search(
|
|
r"^ files: \|\n((?:^ \S.*\n)+)", release[1], re.MULTILINE
|
|
)
|
|
assert files, "missing release action's asset files block"
|
|
release_globs = [line.strip() for line in files[1].splitlines()]
|
|
assert release_globs and any(
|
|
fnmatch.fnmatchcase(f"dist/builds-windows/{final}", glob) for glob in release_globs
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform != "linux", reason="Bash fixture runs in the Ubuntu packaging smoke job"
|
|
)
|
|
def test_windows_setup_collection(tmp_path: Path, windows_installer_names):
|
|
_, produced, final = windows_installer_names
|
|
dist = tmp_path / "dist"
|
|
dist.mkdir()
|
|
(dist / produced).write_bytes(b"fixture installer")
|
|
(dist / "activitywatch-portable.zip").write_bytes(b"portable archive")
|
|
|
|
result = subprocess.run(
|
|
["bash", str(_repo_root() / "scripts/package/collect-setup.sh"), final],
|
|
cwd=tmp_path, capture_output=True, text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert {path.name for path in dist.iterdir()} == {final, "activitywatch-portable.zip"}
|
|
assert (dist / final).read_bytes() == b"fixture installer"
|
|
assert (dist / "activitywatch-portable.zip").read_bytes() == b"portable archive"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform != "linux", reason="Bash fixture runs in the Ubuntu packaging smoke job"
|
|
)
|
|
@pytest.mark.parametrize("case", ["missing", "multiple", "directory"])
|
|
def test_windows_setup_collection_refuses_invalid_inputs(tmp_path: Path, case: str):
|
|
dist = tmp_path / "dist"
|
|
dist.mkdir()
|
|
(dist / "activitywatch-portable.zip").write_bytes(b"portable archive")
|
|
if case == "multiple":
|
|
for name in ("activitywatch-setup.exe", "activitywatch-research-setup.exe"):
|
|
(dist / name).write_bytes(name.encode())
|
|
elif case == "directory":
|
|
(dist / "activitywatch-research-setup.exe").mkdir()
|
|
|
|
def snapshot():
|
|
return {
|
|
path.name: path.read_bytes() if path.is_file() else None
|
|
for path in dist.iterdir()
|
|
}
|
|
|
|
before = snapshot()
|
|
result = subprocess.run(
|
|
[
|
|
"bash", str(_repo_root() / "scripts/package/collect-setup.sh"),
|
|
"activitywatch-research-v0.14.0b5-windows-x86_64-setup.exe",
|
|
],
|
|
cwd=tmp_path, capture_output=True, text=True,
|
|
)
|
|
|
|
assert result.returncode != 0
|
|
assert "expected exactly one" in result.stdout + result.stderr
|
|
assert snapshot() == before
|
|
|
|
|
|
def test_tauri_wix_upgrade_code_is_pinned_and_config_stays_valid_json(tmp_path: Path):
|
|
"""The MSI upgrade code defines the product family; pin it, don't derive it."""
|
|
import json
|
|
import uuid
|
|
|
|
rel = "aw-tauri/src-tauri/tauri.conf.json"
|
|
text = _patch_real_file(tmp_path, rel, patcher.WINDOWS_PATCHES_TAURI)
|
|
|
|
config = json.loads(text) # deny_unknown_fields upstream: must stay valid
|
|
wix = config["bundle"]["windows"]["wix"]
|
|
assert wix["upgradeCode"] == patcher.WINDOWS_WIX_UPGRADE_CODE_TAURI
|
|
uuid.UUID(wix["upgradeCode"]) # tauri parses this as a uuid::Uuid
|
|
|
|
|
|
# --- Linux package identity ---------------------------------------------------
|
|
# Like the Windows tests above, these patch the *real* packaging scripts: the
|
|
# thing under test is mostly what the patch does NOT touch. A synthetic fixture
|
|
# built from the `old` strings alone would still pass if upstream added a fourth
|
|
# reference to /opt/activitywatch or copied the desktop entry somewhere else.
|
|
|
|
|
|
def test_linux_deb_is_a_separate_package(tmp_path: Path):
|
|
"""A research deb must be co-installable, not an upgrade of the standard one."""
|
|
control = _patch_real_file(
|
|
tmp_path, "scripts/package/deb/control", patcher.LINUX_PATCHES_QT
|
|
)
|
|
assert f"Package: {patcher.LINUX_PACKAGE}\n" in control
|
|
# dpkg keys off the package name: same name == upgrade == standard removed.
|
|
assert "Package: activitywatch\n" not in control
|
|
|
|
|
|
def test_linux_deb_installs_beside_a_standard_install(tmp_path: Path):
|
|
"""No /opt/activitywatch or shared desktop-entry filename may survive."""
|
|
script = _patch_real_file(
|
|
tmp_path, "scripts/package/package-deb.sh", patcher.LINUX_PATCHES_QT
|
|
)
|
|
|
|
# The staged install tree, the Exec= line and the .deb filename must all
|
|
# move together; a leftover bare "/opt/activitywatch" would overwrite the
|
|
# standard install's files even with a distinct package name.
|
|
assert "/opt/activitywatch/" not in script
|
|
assert f"{patcher.LINUX_OPT_DIR}/aw-qt" in script
|
|
assert "activitywatch_${VERSION_NUM}.deb" not in script
|
|
|
|
# Both copies out of the install tree must land under the research
|
|
# filename: /etc/xdg/autostart and /usr/share/applications are shared
|
|
# namespaces, so a same-named entry silently replaces the standard one.
|
|
for dest in ("etc/xdg/autostart", "usr/share/applications"):
|
|
assert f"$PKGDIR/{dest}/{patcher.LINUX_DESKTOP_FILENAME}\n" in script
|
|
assert f"$PKGDIR/{dest}/\n" not in script
|
|
|
|
|
|
def test_linux_desktop_entry_is_rebranded(tmp_path: Path):
|
|
"""Menu/dock name and icon id must not read as a standard install."""
|
|
entry = _patch_real_file(
|
|
tmp_path, "aw-qt/resources/aw-qt.desktop", patcher.LINUX_PATCHES_QT
|
|
)
|
|
assert f"Name={patcher.BUNDLE_NAME}\n" in entry
|
|
assert "Name=ActivityWatch\n" not in entry
|
|
assert f"Icon={patcher.LINUX_ICON_ID}\n" in entry
|
|
|
|
|
|
def test_appimage_desktop_and_icon_ids_agree(tmp_path: Path):
|
|
"""linuxdeploy's --icon-filename must match the entry's Icon= key.
|
|
|
|
Desktop integration resolves the icon by that id; a mismatch ships an
|
|
AppImage with no icon, and a shared id would overwrite the standard
|
|
install's icon in the user's hicolor theme.
|
|
"""
|
|
script = _patch_real_file(
|
|
tmp_path, "scripts/package/package-appimage.sh", patcher.LINUX_PATCHES_QT
|
|
)
|
|
entry = _patch_real_file(
|
|
tmp_path, "aw-qt/resources/aw-qt.desktop", patcher.LINUX_PATCHES_QT
|
|
)
|
|
|
|
assert f"--icon-filename {patcher.LINUX_ICON_ID}\n" in script
|
|
assert f"Icon={patcher.LINUX_ICON_ID}\n" in entry
|
|
# The entry handed to linuxdeploy is the research-named copy, and the copy
|
|
# is staged before it is used.
|
|
assert f"--desktop-file ./activitywatch/{patcher.LINUX_DESKTOP_FILENAME} " in script
|
|
stage = script.index(f"cp ./activitywatch/aw-qt.desktop ./activitywatch/{patcher.LINUX_DESKTOP_FILENAME}")
|
|
assert stage < script.index("linuxdeploy-x86_64.AppImage --appdir")
|
|
|
|
|
|
def test_first_run_autostart_identity_is_distinct_on_every_platform(tmp_path: Path):
|
|
"""aw-qt writes its own autostart entry; those names are the installer's.
|
|
|
|
Bundle id, package name and profile are all already split at this point --
|
|
but if the login item / Startup shortcut / autostart .desktop keep their
|
|
standard names, the two editions still overwrite each other's autostart.
|
|
"""
|
|
rel = "aw-qt/aw_qt/autostart.py"
|
|
src = _repo_root() / rel
|
|
if not src.is_file():
|
|
pytest.skip(f"{rel} not present")
|
|
before = src.read_text(encoding="utf-8")
|
|
after = _patch_real_file(tmp_path, rel, patcher.AUTOSTART_PATCHES_QT)
|
|
|
|
# Linux: the written filename changes; the *shipped resource* name does not
|
|
# (the patched build still reads resources/aw-qt.desktop out of the bundle).
|
|
# Must not be aw-qt-research.desktop — that is the standard named-profile entry.
|
|
assert f'_linux_autostart_dir() / "{patcher.LINUX_DESKTOP_FILENAME}"' in after
|
|
assert "aw-qt-research.desktop" not in after
|
|
assert 'DESKTOP_FILENAME = "aw-qt.desktop"' in after
|
|
# macOS: plist path follows _macos_launch_agent_label(), which would otherwise
|
|
# become net.activitywatch.aw-qt-research and collide with --profile research.
|
|
assert f'return "{patcher.BUNDLE_ID}"' in after
|
|
assert 'return f"{LAUNCH_AGENT_LABEL}{_profile_suffix()}"' not in after
|
|
assert f'return "{patcher.LAUNCH_AGENT_LABEL}"' not in after
|
|
# Windows: both the Run value name and the Startup .lnk derive from APP_NAME.
|
|
# Named-profile suffixing then yields "ActivityWatch Research (research)".
|
|
assert f'APP_NAME = "{patcher.BUNDLE_NAME}"' in after
|
|
assert 'APP_NAME = "ActivityWatch"\n' not in after
|
|
|
|
# Guard the runtime identity sources: if upstream stops suffixing these, the
|
|
# research overrides above would be patching dead code.
|
|
for derived in (
|
|
'return _linux_autostart_dir() / f"{stem}{_profile_suffix()}{extension}"',
|
|
'return f"{LAUNCH_AGENT_LABEL}{_profile_suffix()}"',
|
|
'return APP_NAME if not suffix else f"{APP_NAME} ({_profile()})"',
|
|
):
|
|
assert derived in before, f"upstream no longer derives: {derived}"
|
|
|
|
|
|
def test_tauri_autostart_uses_distinct_app_name_for_research_build(tmp_path: Path):
|
|
"""tauri_plugin_autostart derives its entry name from productName by default.
|
|
|
|
Both standard and research Tauri builds share productName "aw-tauri", so
|
|
enabling autostart in one edition would overwrite the other's entry on
|
|
Windows (registry key) and Linux (~/.config/autostart/*.desktop).
|
|
|
|
The fix is entirely patcher-side: PROFILE_PATCHES_TAURI inserts BUILD_PROFILE
|
|
into profile.rs; AUTOSTART_PATCHES_TAURI replaces the tauri_plugin_autostart::init()
|
|
call in lib.rs with a Builder that sets an explicit app_name derived from
|
|
BUILD_PROFILE when it differs from DEFAULT_PROFILE.
|
|
|
|
This test asserts both patch sets produce the expected output on the real sources.
|
|
"""
|
|
# --- half 1: PROFILE_PATCHES_TAURI inserts BUILD_PROFILE = "research" ---
|
|
profile_rs_rel = "aw-tauri/src-tauri/src/profile.rs"
|
|
profile_rs_src = _repo_root() / profile_rs_rel
|
|
if not profile_rs_src.is_file():
|
|
pytest.skip(f"{profile_rs_rel} not present")
|
|
before_profile = profile_rs_src.read_text(encoding="utf-8")
|
|
# Standard source has DEFAULT_PROFILE but NOT BUILD_PROFILE
|
|
assert 'pub const DEFAULT_PROFILE: &str = "default";' in before_profile
|
|
assert "BUILD_PROFILE" not in before_profile, (
|
|
"BUILD_PROFILE must NOT exist in the unpatched source; the patcher inserts it"
|
|
)
|
|
after_profile = _patch_real_file(
|
|
tmp_path,
|
|
profile_rs_rel,
|
|
patcher.PROFILE_PATCHES_TAURI,
|
|
)
|
|
assert f'pub const BUILD_PROFILE: &str = "{patcher.RESEARCH_PROFILE}";' in after_profile
|
|
|
|
# --- half 2: AUTOSTART_PATCHES_TAURI overrides named-profile app_name ---
|
|
lib_rs_rel = "aw-tauri/src-tauri/src/lib.rs"
|
|
lib_rs_src = _repo_root() / lib_rs_rel
|
|
if not lib_rs_src.is_file():
|
|
pytest.skip(f"{lib_rs_rel} not present")
|
|
before_lib = lib_rs_src.read_text(encoding="utf-8")
|
|
# Standard source already uses Builder + named-profile identities.
|
|
assert "tauri_plugin_autostart::Builder::new()" in before_lib
|
|
assert "profile::autostart_app_name" in before_lib
|
|
assert "BUILD_PROFILE" not in before_lib
|
|
after_lib = _patch_real_file(
|
|
tmp_path,
|
|
lib_rs_rel,
|
|
patcher.AUTOSTART_PATCHES_TAURI,
|
|
)
|
|
# Research build keeps named-profile wiring, then overrides the OS identity
|
|
# so it does not collide with a standard `--profile research` login item.
|
|
assert "profile::autostart_app_name" in after_lib
|
|
assert "profile::BUILD_PROFILE != profile::DEFAULT_PROFILE" in after_lib
|
|
assert '"aw-tauri-{}-edition"' in after_lib
|
|
assert 'format!("aw-tauri-{}", profile::BUILD_PROFILE)' not in after_lib
|