snapshot of https://github.com/ActivityWatch/activitywatch.git (master, 2026-09-15; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-15 22:29:06 +05:00
commit c5c5af8cde
85 changed files with 12052 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
import os
import platform
import subprocess
import tempfile
from time import sleep
import pytest
def _windows_kill_process(pid):
import ctypes
PROCESS_TERMINATE = 1
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
ctypes.windll.kernel32.TerminateProcess(handle, -1)
ctypes.windll.kernel32.CloseHandle(handle)
# NOTE: to run tests with a specific server binary,
# set the PATH such that it is the "aw-server" binary.
@pytest.fixture(scope="session")
def server_process():
logfile_stdout = tempfile.NamedTemporaryFile(delete=False)
logfile_stderr = tempfile.NamedTemporaryFile(delete=False)
# find the path of the "aw-server" binary and log it
which_server = subprocess.check_output(["which", "aw-server"], text=True)
print(f"aw-server path: {which_server}")
# if aw-server-rust in PATH, assert that we're picking up the aw-server-rust binary
if "aw-server-rust" in os.environ["PATH"]:
assert "aw-server-rust" in which_server
server_proc = subprocess.Popen(
["aw-server", "--testing"], stdout=logfile_stdout, stderr=logfile_stderr
)
# Wait for server to start up properly
# TODO: Ping the server until it's alive to remove this sleep
sleep(5)
yield server_proc
if platform.system() == "Windows":
# On Windows, for whatever reason, server_proc.kill() doesn't do the job.
_windows_kill_process(server_proc.pid)
else:
server_proc.kill()
server_proc.wait(5)
server_proc.communicate()
error_indicators = ["ERROR"]
with open(logfile_stdout.name, "r+b") as f:
stdout = str(f.read(), "utf8")
if any(e in stdout for e in error_indicators):
pytest.fail(f"Found ERROR indicator in stdout from server: {stdout}")
with open(logfile_stderr.name, "r+b") as f:
stderr = str(f.read(), "utf8")
# For some reason, this fails aw-server-rust, but not aw-server-python
# if not stderr:
# pytest.fail("No output to stderr from server")
# Will show in case pytest fails
print(stderr)
for s in error_indicators:
if s in stderr:
pytest.fail(f"Found ERROR indicator in stderr from server: {s}")
# NOTE: returncode was -9 for whatever reason
# if server_proc.returncode != 0:
# pytest.fail("Exit code was non-zero ({})".format(server_proc.returncode))
# TODO: Use the fixture in the tests instead of this thing here
def test_integration(server_process):
# This is just here so that the server_process fixture is initialized
pass
# exit_code = pytest.main(["./aw-server/tests", "-v"])
# if exit_code != 0:
# pytest.fail("Tests exited with non-zero code: " + str(exit_code))

View File

@@ -0,0 +1,175 @@
import importlib.util
import subprocess
from pathlib import Path
import pytest
SCRIPT = Path(__file__).parents[1] / "check_tauri_server.py"
SPEC = importlib.util.spec_from_file_location("check_tauri_server", SCRIPT)
assert SPEC and SPEC.loader
checker = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(checker)
major_minor = checker.major_minor
read_locked_server = checker.read_locked_server
read_package_version = checker.read_package_version
validation_errors = checker.validation_errors
is_git_checkout = checker.is_git_checkout
initialize_submodule = checker.initialize_submodule
sync_submodule = checker.sync_submodule
REVISION = "a" * 40
def test_major_minor_accepts_beta_release():
assert major_minor("0.14.0b5") == "0.14"
def test_read_package_version(tmp_path: Path):
cargo_toml = tmp_path / "Cargo.toml"
cargo_toml.write_text(
'[workspace]\n\n[package]\nname = "aw-server"\nversion = "0.14.0"\n'
)
assert read_package_version(cargo_toml) == "0.14.0"
def test_read_locked_server(tmp_path: Path):
cargo_lock = tmp_path / "Cargo.lock"
cargo_lock.write_text(
'[[package]]\nname = "other"\nversion = "1.0.0"\n\n'
'[[package]]\nname = "aw-server"\nversion = "0.14.0"\n'
'source = "git+https://github.com/ActivityWatch/aw-server-rust.git?branch=master#'
f'{REVISION}"\n'
)
assert read_locked_server(cargo_lock) == ("0.14.0", REVISION)
def test_valid_when_release_line_and_revisions_match():
assert validation_errors("0.14.0b5", "0.14.0", REVISION, "0.14.0", REVISION) == []
def test_rejects_old_tauri_release_line():
errors = validation_errors("0.14.0b5", "0.14.0", REVISION, "0.13.1", REVISION)
assert errors == [
"Tauri locks aw-server 0.13.1, which does not match release line 0.14"
]
def test_rejects_different_revision_even_on_same_release_line():
errors = validation_errors("0.14.0b5", "0.14.0", REVISION, "0.14.0", "b" * 40)
assert errors == [
"Tauri locks aw-server-rust bbbbbbbbbbbb, but the release submodule is aaaaaaaaaaaa"
]
def test_rejects_invalid_version():
with pytest.raises(ValueError, match="invalid version"):
major_minor("dev")
def git(repo: Path, *args: str) -> str:
return subprocess.check_output(["git", "-C", str(repo), *args], text=True).strip()
def make_git_repo(path: Path) -> tuple[str, str]:
path.mkdir()
subprocess.run(["git", "-C", str(path), "init"], check=True, capture_output=True)
subprocess.run(
["git", "-C", str(path), "config", "user.email", "test@example.com"],
check=True,
)
subprocess.run(
["git", "-C", str(path), "config", "user.name", "Test"], check=True
)
tracked = path / "tracked.txt"
tracked.write_text("first\n")
subprocess.run(["git", "-C", str(path), "add", "tracked.txt"], check=True)
subprocess.run(
["git", "-C", str(path), "commit", "-m", "first"],
check=True,
capture_output=True,
)
first = git(path, "rev-parse", "HEAD")
tracked.write_text("second\n")
subprocess.run(
["git", "-C", str(path), "commit", "-am", "second"],
check=True,
capture_output=True,
)
return first, git(path, "rev-parse", "HEAD")
def test_sync_submodule_checks_out_locked_revision(tmp_path: Path):
repo = tmp_path / "aw-server-rust"
first, second = make_git_repo(repo)
assert git(repo, "rev-parse", "HEAD") == second
assert sync_submodule(repo, first)
assert git(repo, "rev-parse", "HEAD") == first
assert not sync_submodule(repo, first)
def test_sync_submodule_refuses_dirty_checkout(tmp_path: Path):
repo = tmp_path / "aw-server-rust"
_, current = make_git_repo(repo)
(repo / "tracked.txt").write_text("dirty\n")
with pytest.raises(ValueError, match="refusing to replace dirty"):
sync_submodule(repo, current)
def test_sync_submodule_rejects_uninitialized_nested_directory(tmp_path: Path):
parent = tmp_path / "activitywatch"
_, _ = make_git_repo(parent)
server = parent / "aw-server-rust"
server.mkdir()
with pytest.raises(ValueError, match="not initialized as a Git submodule"):
sync_submodule(server, REVISION)
def test_is_git_checkout_requires_the_exact_repository_root(tmp_path: Path):
repo = tmp_path / "activitywatch"
_, _ = make_git_repo(repo)
nested = repo / "aw-server-rust"
nested.mkdir()
assert is_git_checkout(repo)
assert not is_git_checkout(nested)
def test_initialize_submodule_rejects_unrelated_checkout(tmp_path: Path):
parent = tmp_path / "activitywatch"
_, _ = make_git_repo(parent)
unrelated = parent / "aw-server-rust"
_, _ = make_git_repo(unrelated)
with pytest.raises(ValueError, match="refusing unmanaged Git checkout"):
initialize_submodule(parent, "aw-server-rust")
def test_initialize_submodule_rejects_old_form_checkout_with_migration_hint(
tmp_path: Path,
):
parent = tmp_path / "activitywatch"
_, _ = make_git_repo(parent)
server = parent / "aw-server-rust"
_, _ = make_git_repo(server)
url = "https://github.com/ActivityWatch/aw-server-rust.git"
subprocess.run(
["git", "-C", str(server), "remote", "add", "origin", url], check=True
)
(parent / ".gitmodules").write_text(
f'[submodule "aw-server-rust"]\n'
"\tpath = aw-server-rust\n"
f"\turl = {url}\n"
)
with pytest.raises(ValueError, match="git submodule absorbgitdirs aw-server-rust"):
initialize_submodule(parent, "aw-server-rust")

View File

@@ -0,0 +1,290 @@
import base64
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import textwrap
import pytest
ROOT = Path(__file__).parents[2]
CONFIGURE = ROOT / "scripts/package/configure_tauri_release.py"
GENERATOR = ROOT / "scripts/package/generate_latest_json.py"
RESEARCH_ENDPOINT = (
"https://raw.githubusercontent.com/ActivityWatch/activitywatch/"
"research-updates/latest-research.json"
)
sys.path.insert(0, str(ROOT / "scripts/package"))
from configure_tauri_release import configure, msi_rejects # noqa: E402
def key(material=b"r" * 32, comment="fixture", key_id=b"i" * 8):
packet = base64.b64encode(b"Ed" + key_id + material).decode()
return base64.b64encode(
f"untrusted comment: {comment}\n{packet}\n".encode()
).decode()
@pytest.fixture
def config(tmp_path):
# Exercise the real pinned Tauri config, including its standard public key.
source = ROOT / "aw-tauri/src-tauri/tauri.conf.json"
assert source.exists(), "Initialize the aw-tauri submodule before these tests"
path = tmp_path / "tauri.conf.json"
path.write_bytes(source.read_bytes())
return path
def run_configure(config, *args, public="", private="", version="v0.14.0b5-research"):
env = dict(
os.environ,
TAURI_UPDATER_PUBLIC_KEY_RESEARCH=public,
TAURI_SIGNING_PRIVATE_KEY=private,
)
return subprocess.run(
[
sys.executable,
str(CONFIGURE),
"--config",
str(config),
"--version",
version,
*args,
],
env=env,
text=True,
capture_output=True,
)
@pytest.mark.parametrize(
"version,rejects",
[
("0.14.0", False),
("0.14.0-5", False),
("0.14.0-beta.5", True),
("0.14.0-dev.gabc1234", True),
("0.14.0-rc.1", True),
],
)
def test_msi_rejects(version, rejects):
assert msi_rejects(version) is rejects
def test_windows_drops_msi_for_non_numeric_prerelease(config):
# v0.14.0b5 -> "0.14.0-beta.5", which the msi (WiX) bundler rejects.
configure(
config, "v0.14.0b5", research=False, require_signing_key=False, platform="win32"
)
after = json.loads(config.read_text())
assert after["version"] == "0.14.0-beta.5"
assert after["bundle"]["targets"] == ["nsis"]
def test_non_windows_keeps_all_targets_for_non_numeric_prerelease(config):
configure(
config,
"v0.14.0b5",
research=False,
require_signing_key=False,
platform="darwin",
)
after = json.loads(config.read_text())
assert after["bundle"]["targets"] == "all"
def test_windows_keeps_all_targets_for_msi_safe_version(config):
configure(
config, "v0.14.0", research=False, require_signing_key=False, platform="win32"
)
after = json.loads(config.read_text())
assert after["version"] == "0.14.0"
assert after["bundle"]["targets"] == "all"
def test_standard_version_changes_without_changing_update_trust(config):
before = json.loads(config.read_text())
result = run_configure(config, version="v0.14.0rc2")
assert result.returncode == 0, result.stderr
after = json.loads(config.read_text())
assert after["version"] == "0.14.0-rc.2"
before["version"] = after["version"]
assert after == before
def test_research_release_replaces_endpoint_and_key(config):
result = run_configure(
config,
"--research",
"--require-signing-key",
public=key(),
private="fixture-secret-presence-only",
)
assert result.returncode == 0, result.stderr
after = json.loads(config.read_text())
assert after["version"] == "0.14.0-beta.5"
assert after["plugins"]["updater"]["endpoints"] == [RESEARCH_ENDPOINT]
assert after["plugins"]["updater"]["pubkey"] == key()
def test_secretless_research_smoke_build_has_no_update_trust(config):
result = run_configure(config, "--research")
assert result.returncode == 0, result.stderr
updater = json.loads(config.read_text())["plugins"]["updater"]
assert updater["endpoints"] == []
assert updater["pubkey"] == ""
@pytest.mark.parametrize("public,private", [("", ""), (key(), ""), ("", "secret")])
def test_release_missing_either_key_fails_without_modifying_config(
config, public, private
):
before = config.read_bytes()
result = run_configure(
config, "--research", "--require-signing-key", public=public, private=private
)
assert result.returncode != 0
assert "require their own public and signing keys" in result.stderr
assert config.read_bytes() == before
@pytest.mark.parametrize("change_envelope", [False, True])
def test_same_standard_key_rejected_even_with_changed_comment_and_id(
config, change_envelope
):
before = config.read_bytes()
standard = json.loads(before)["plugins"]["updater"]["pubkey"]
if change_envelope:
packet = base64.b64decode(base64.b64decode(standard).decode().splitlines()[1])
standard = key(packet[10:], comment="research", key_id=b"j" * 8)
result = run_configure(config, "--research", public=standard)
assert result.returncode != 0
assert "must differ" in result.stderr
assert config.read_bytes() == before
@pytest.mark.parametrize("bad_key", ["bad-base64", "dGVzdA==", key(b"short")])
def test_malformed_public_key_rejected(config, bad_key):
before = config.read_bytes()
result = run_configure(config, "--research", public=bad_key)
assert result.returncode != 0
assert config.read_bytes() == before
def test_endpoint_drift_fails_closed(config):
data = json.loads(config.read_text())
data["plugins"]["updater"]["endpoints"].append("https://example.com/latest.json")
config.write_text(json.dumps(data))
before = config.read_bytes()
result = run_configure(config, "--research", public=key())
assert result.returncode != 0
assert "Unexpected source updater endpoint" in result.stderr
assert config.read_bytes() == before
@pytest.mark.parametrize(
"event,ref,research,success",
[
("push", "refs/tags/v0.14.0b5-research", "true", False),
("workflow_dispatch", "refs/heads/master", "true", False),
("pull_request", "refs/pull/1/merge", "true", True),
("push", "refs/heads/master", "true", True),
("push", "refs/tags/v0.14.0", "false", True),
],
)
def test_workflow_requires_keys_only_for_publishable_research_builds(
config,
event,
ref,
research,
success,
):
workflow = (ROOT / ".github/workflows/release.yml").read_text()
step = workflow.split(
" - name: Configure Tauri release version and update channel\n"
)[1]
script = textwrap.dedent(
step.split(" run: |\n")[1].split(" env:\n")[0]
)
work = config.parent / "build"
shutil.copytree(ROOT / "scripts/package", work / "scripts/package")
target = work / "aw-tauri/src-tauri/tauri.conf.json"
target.parent.mkdir(parents=True)
shutil.copyfile(config, target)
env = dict(
os.environ,
AW_RESEARCH_EDITION=research,
GITHUB_REF=ref,
GITHUB_EVENT_NAME=event,
VERSION_NO_V="0.14.0b5",
TAURI_UPDATER_PUBLIC_KEY_RESEARCH="",
TAURI_SIGNING_PRIVATE_KEY="",
)
result = subprocess.run(
["bash", "-e", "-c", script], cwd=work, env=env, text=True, capture_output=True
)
assert (result.returncode == 0) == success, result.stderr
if success and research == "true":
assert json.loads(target.read_text())["plugins"]["updater"]["endpoints"] == []
if not success:
assert target.read_bytes() == config.read_bytes()
@pytest.mark.parametrize("edition", ["standard", "research"])
def test_manifest_and_compiled_config_share_version_and_partition_assets(
config, edition
):
tag = "v0.14.0b5" + ("-research" if edition == "research" else "")
result = run_configure(
config,
*(["--research"] if edition == "research" else []),
version=tag,
public=key(),
)
assert result.returncode == 0, result.stderr
for token in ("", "-research"):
bundle = (
config.parent / f"activitywatch-tauri{token}-0.14.0b5-linux-x86_64.AppImage"
)
bundle.write_bytes(b"fixture, not a cryptographic verification")
bundle.with_suffix(".AppImage.sig").write_text("sig" + token)
output = config.parent / (
"latest-research.json" if edition == "research" else "latest.json"
)
result = subprocess.run(
[
sys.executable,
str(GENERATOR),
"--version",
tag,
"--tag",
tag,
"--edition",
edition,
"--repo",
"ActivityWatch/activitywatch",
"--notes",
"fixture",
"--dist",
str(config.parent),
"--output",
str(output),
],
text=True,
capture_output=True,
)
assert result.returncode == 0, result.stderr
manifest = json.loads(output.read_text())
assert (
manifest["version"]
== json.loads(config.read_text())["version"]
== "0.14.0-beta.5"
)
assert list(manifest["platforms"]) == ["linux-x86_64"]
target = manifest["platforms"]["linux-x86_64"]
assert ("-research" in target["url"]) == (edition == "research")
assert f"/releases/download/{tag}/" in target["url"]
assert target["signature"] == "sig" + ("-research" if edition == "research" else "")

View File

@@ -0,0 +1,162 @@
import importlib.util
import json
import re
from pathlib import Path
def _load(name: str):
path = Path(__file__).parents[1] / f"{name}.py"
spec = importlib.util.spec_from_file_location(name, path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
emitter = _load("emit_research_category_preset")
patcher = _load("patch_research_edition_config")
def test_preset_covers_every_category_in_the_watcher_map():
"""Preset and watcher map must share one source, or the UI drifts from the data."""
expected = {c for _, c in patcher.CATEGORY_MAP} | set(
patcher.APP_CATEGORY_MAP.values()
)
names = {c["name"][0] for c in emitter.build_preset()["categories"]}
assert names == expected
def _classify_event(data: dict[str, str]) -> str | None:
"""Mirror aw-webui's parsed-rule contract and equal-depth matching."""
matches: list[str] = []
for category in emitter.build_preset()["categories"]:
rule = category["rule"]
flags = re.IGNORECASE if rule["ignore_case"] else 0
if any(
key in data and re.search(rule["regex"], data[key], flags)
for key in ("app", "title")
):
matches.append(category["name"][0])
assert len(matches) <= 1, (
f"aw-webui cannot prioritize equal-depth matches: {matches}"
)
return matches[0] if matches else None
def test_rules_match_browser_categories_and_raw_app_aliases():
"""Browser titles and retained app names resolve to the same taxonomy."""
assert _classify_event({"app": "Safari", "title": "Work & Productivity"}) == (
"Work & Productivity"
)
assert _classify_event({"app": "Microsoft Word"}) == "Work & Productivity"
assert _classify_event({"app": "SPOTIFY"}) == "Music & Audio"
assert _classify_event({"app": "Terminal"}) == "Excluded"
assert _classify_event({"app": "Some Unmapped Program"}) is None
def test_rules_are_exact_and_scoped_to_approved_fields():
categories = {
category["name"][0]: category
for category in emitter.build_preset()["categories"]
}
work_rule = categories["Work & Productivity"]["rule"]
assert re.search(work_rule["regex"], "Microsoft Word", re.IGNORECASE)
assert not re.search(work_rule["regex"], "Microsoft Word extra", re.IGNORECASE)
assert _classify_event({"hostname": "Microsoft Word"}) is None
def test_rules_use_only_fields_preserved_by_the_aw_webui_preset_parser():
"""The oldest pinned carrier strips unknown keys; behavior must survive that."""
for category in emitter.build_preset()["categories"]:
assert set(category["rule"]) <= {"type", "regex", "ignore_case"}
def test_excluded_rule_only_matches_explicitly_excluded_apps():
categories = {
category["name"][0]: category
for category in emitter.build_preset()["categories"]
}
excluded = categories["Excluded"]["rule"]["regex"]
assert re.search(excluded, "Terminal", re.IGNORECASE)
assert not re.search(excluded, "Outlook", re.IGNORECASE)
assert not re.search(excluded, "Some Unmapped Program", re.IGNORECASE)
def test_every_known_value_matches_exactly_one_rule_after_parser_projection():
"""Catch overlaps the production classifier cannot resolve at equal depth."""
values = {category["name"][0] for category in emitter.build_preset()["categories"]}
values |= set(patcher.APP_CATEGORY_MAP)
for value in values:
assert _classify_event({"app": value}) is not None
def test_escaping_is_portable_to_javascript_unicode_mode():
"""`re.escape` is unusable here: it emits `\\ ` and `\\&`.
Those are valid in Python but are *invalid identity escapes* in JavaScript
unicode-mode regex, so `new RegExp(r, "u")` throws on 14 of the 18 study
categories. aw-webui compiles without the `u` flag today, which is the only
reason `re.escape` would appear to work -- and the failure would present as
"categories don't show", indistinguishable from the bug this preset fixes.
"""
for category in emitter.build_preset()["categories"]:
pattern = category["rule"]["regex"]
escaped = {pattern[i + 1] for i, ch in enumerate(pattern[:-1]) if ch == "\\"}
assert escaped <= emitter._REGEX_METACHARACTERS, (
f"{pattern!r} escapes characters JavaScript rejects under the u flag"
)
def test_escape_portable_still_escapes_real_metacharacters():
"""A future category name containing a metacharacter must not become a wildcard."""
assert emitter.escape_portable("a.b") == r"a\.b"
assert emitter.escape_portable("x(y)") == r"x\(y\)"
assert emitter.escape_portable("Shopping - Goods") == "Shopping - Goods"
assert re.fullmatch(emitter.escape_portable("a.b"), "a.b")
assert not re.fullmatch(emitter.escape_portable("a.b"), "axb")
def test_exact_alternation_is_stable_and_whole_value():
pattern = emitter.exact_alternation({"zoom.us", "Zoom"})
assert pattern == r"^(?:Zoom|zoom\.us)$"
assert re.fullmatch(pattern, "Zoom")
assert re.fullmatch(pattern, "zoom.us")
assert not re.fullmatch(pattern, "zoom.us meeting")
def test_output_is_stable_across_runs():
"""CI reruns must not produce a different preset from the same map."""
assert emitter.build_preset() == emitter.build_preset()
def test_serialises_without_newlines_for_github_env():
"""The value is written straight into $GITHUB_ENV, which ends at a newline."""
payload = json.dumps([emitter.build_preset()], separators=(",", ":"))
assert "\n" not in payload
assert json.loads(payload)[0]["id"] == "research-study"
def test_every_category_has_a_distinct_hex_color():
"""Activity view has no name-hash fallback; missing data.color renders grey."""
categories = emitter.build_preset()["categories"]
colors = [category["data"]["color"] for category in categories]
assert len(colors) == len(categories)
assert len(set(colors)) == len(colors)
for color in colors:
assert re.fullmatch(r"#[0-9A-F]{6}", color)
def test_color_table_matches_the_study_taxonomy():
"""A new map entry without a color must fail the build, not ship unstyled."""
names = {category["name"][0] for category in emitter.build_preset()["categories"]}
assert set(emitter.CATEGORY_COLORS) == names

View File

@@ -0,0 +1,191 @@
import importlib.util
import json
from pathlib import Path
import pytest
GENERATOR = Path(__file__).parents[1] / "package" / "generate_latest_json.py"
def _load():
spec = importlib.util.spec_from_file_location("generate_latest_json", GENERATOR)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
gen = _load()
def _write_sig(root: Path, name: str, signature: str = "sig-bytes") -> None:
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(signature + "\n")
# Matching binary next to the .sig, as the packaging step copies both.
path.with_name(name[: -len(".sig")]).write_bytes(b"bundle")
def test_normalize_version_strips_v_and_research_suffix():
assert gen.normalize_version("v0.14.0b4") == "0.14.0b4"
assert gen.normalize_version("0.14.0b4") == "0.14.0b4"
assert gen.normalize_version("v0.14.0b4-research") == "0.14.0b4"
assert gen.normalize_version("0.14.0b4-research") == "0.14.0b4"
@pytest.mark.parametrize(
"version,expected",
[
("v0.14.0", "0.14.0"),
("v0.14.0b5-research", "0.14.0-beta.5"),
("0.14.0a1", "0.14.0-alpha.1"),
("0.14.0rc2", "0.14.0-rc.2"),
("v0.14.0.dev-0123456", "0.14.0-dev.g0123456"),
("v0.14.0b5.dev-abcdef0", "0.14.0-beta.5.dev.gabcdef0"),
],
)
def test_tauri_version(version, expected):
assert gen.tauri_version(version) == expected
@pytest.mark.parametrize("version", ["0.14", "0.14.0b01", "00.14.0", "0.14.0oops", ""])
def test_tauri_version_refuses_unrecognized_labels(version):
with pytest.raises(ValueError):
gen.tauri_version(version)
def test_infer_edition_from_tag_or_explicit_flag():
assert gen.infer_edition("v0.14.0") == "standard"
assert gen.infer_edition("v0.14.0b4") == "standard"
assert gen.infer_edition("v0.14.0b4-research") == "research"
assert gen.infer_edition("v0.14.0b4-research", "standard") == "standard"
assert gen.infer_edition("v0.14.0", "research") == "research"
def test_asset_prefix_and_manifest_filename_partition_editions():
assert gen.asset_prefix("standard") == "activitywatch-tauri"
assert gen.asset_prefix("research") == "activitywatch-tauri-research"
assert gen.manifest_filename("standard") == "latest.json"
assert gen.manifest_filename("research") == "latest-research.json"
def test_standard_collect_ignores_research_artifacts(tmp_path):
_write_sig(
tmp_path,
"activitywatch-tauri-0.14.0b4-darwin-aarch64.app.tar.gz.sig",
"std-sig",
)
_write_sig(
tmp_path,
"activitywatch-tauri-research-0.14.0b4-darwin-aarch64.app.tar.gz.sig",
"research-sig",
)
platforms = gen.collect_platforms(
str(tmp_path),
"0.14.0b4",
"ActivityWatch/activitywatch",
"v0.14.0b4",
"standard",
)
assert list(platforms) == ["darwin-aarch64"]
assert platforms["darwin-aarch64"]["signature"] == "std-sig"
assert platforms["darwin-aarch64"]["url"].endswith(
"/v0.14.0b4/activitywatch-tauri-0.14.0b4-darwin-aarch64.app.tar.gz"
)
def test_research_collect_ignores_standard_artifacts(tmp_path):
_write_sig(
tmp_path,
"activitywatch-tauri-0.14.0b4-linux-x86_64.AppImage.tar.gz.sig",
"std-sig",
)
_write_sig(
tmp_path,
"activitywatch-tauri-research-0.14.0b4-linux-x86_64.AppImage.tar.gz.sig",
"research-sig",
)
platforms = gen.collect_platforms(
str(tmp_path),
"0.14.0b4",
"ActivityWatch/activitywatch",
"v0.14.0b4-research",
"research",
)
assert list(platforms) == ["linux-x86_64"]
assert platforms["linux-x86_64"]["signature"] == "research-sig"
assert platforms["linux-x86_64"]["url"].endswith(
"/v0.14.0b4-research/"
"activitywatch-tauri-research-0.14.0b4-linux-x86_64.AppImage.tar.gz"
)
def test_windows_prefers_nsis_over_msi_regardless_of_walk_order(tmp_path):
_write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.msi.zip.sig", "msi-sig")
_write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip.sig", "nsis-sig")
platforms = gen.collect_platforms(
str(tmp_path),
"0.14.0",
"ActivityWatch/activitywatch",
"v0.14.0",
"standard",
)
assert list(platforms) == ["windows-x86_64"]
assert platforms["windows-x86_64"]["signature"] == "nsis-sig"
assert platforms["windows-x86_64"]["url"].endswith(
"/v0.14.0/activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip"
)
def test_main_writes_manifest_and_refuses_empty(tmp_path):
_write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip.sig")
out = tmp_path / "latest.json"
gen.main(
[
"--version",
"v0.14.0",
"--notes",
"ActivityWatch v0.14.0",
"--repo",
"ActivityWatch/activitywatch",
"--tag",
"v0.14.0",
"--edition",
"standard",
"--dist",
str(tmp_path),
"--output",
str(out),
]
)
manifest = json.loads(out.read_text())
assert manifest["version"] == "0.14.0"
assert "windows-x86_64" in manifest["platforms"]
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(SystemExit, match="No standard updater artifacts"):
gen.main(
[
"--version",
"0.14.0",
"--notes",
"none",
"--repo",
"ActivityWatch/activitywatch",
"--tag",
"v0.14.0",
"--dist",
str(empty),
"--output",
str(tmp_path / "latest.json"),
]
)

View File

@@ -0,0 +1,223 @@
import ast
import importlib.util
import tomllib
from pathlib import Path
import pytest
SCRIPT = Path(__file__).parents[1] / "patch_research_edition_config.py"
SPEC = importlib.util.spec_from_file_location("patch_research_edition_config", SCRIPT)
assert SPEC and SPEC.loader
patcher = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(patcher)
# Current config.py reads both maps. The Research Edition patch deliberately
# leaves the app map empty so approved application names survive capture.
RUNTIME_LOOKUPS = """
def parse_args():
parsed_args.research_category_map = dict(config.get("research_category_map", {}))
parsed_args.research_app_category_map = dict(config.get("research_app_category_map", {}))
"""
# Layout before aw-watcher-window#137: the research tables live in
# default_config, section-prefixed.
PRE_137 = (
'''default_config = """
[aw-watcher-window]
poll_time = 1.0
research_enabled = false
[aw-watcher-window.research_category_map]
[aw-watcher-window.research_app_category_map]
""".strip()
'''
+ RUNTIME_LOOKUPS
)
# Layout since aw-watcher-window#137: research knobs moved to their own template
# so they are not persisted into every fresh install's config, and a comment
# documents the release-time rewrite -- including the literal flag text.
POST_137 = (
'''default_config = """
[aw-watcher-window]
poll_time = 1.0
""".strip()
# The Research Edition release build rewrites the flag below with
# sed -i 's/^research_enabled = false$/research_enabled = true/'
# Keep that line at column 0 and byte-identical.
research_defaults = """
research_enabled = false
""".strip()
'''
+ RUNTIME_LOOKUPS
)
def _string_constant(source: str, name: str) -> str:
"""Return the value of a module-level string assignment, unwrapping .strip()."""
for node in ast.parse(source).body:
if (
isinstance(node, ast.Assign)
and getattr(node.targets[0], "id", None) == name
):
value = node.value
if isinstance(value, ast.Call): # `""" ... """.strip()`
value = value.func.value
return ast.literal_eval(value)
raise AssertionError(f"{name} not found")
def test_pre_137_layout_injects_browser_map_and_preserves_app_names():
result = patcher.patch_config(PRE_137)
ast.parse(result)
section = tomllib.loads(_string_constant(result, "default_config"))[
"aw-watcher-window"
]
assert section["research_enabled"] is True
assert section["research_category_map"]["svenskaspel.se"] == "Sensitive / Excluded"
assert section["research_app_category_map"] == {}
def test_post_137_layout_injects_only_browser_map_into_research_defaults():
"""The tables must land in research_defaults, with unprefixed headers.
research_defaults is parsed standalone and merged into the
[aw-watcher-window] section key by key, so a section-prefixed header there
would produce a nested `aw-watcher-window` key that nothing reads.
"""
result = patcher.patch_config(POST_137)
ast.parse(result)
defaults = tomllib.loads(_string_constant(result, "research_defaults"))
assert "aw-watcher-window" not in defaults
assert defaults["research_enabled"] is True
assert defaults["research_category_map"]["svenskaspel.se"] == "Sensitive / Excluded"
# An empty table is emitted so upgrades from an earlier Research Edition
# (which may have had this map populated) explicitly clear it.
assert defaults["research_app_category_map"] == {}
def test_post_137_emits_explicit_empty_app_map_to_clear_legacy_entries():
"""The patched research_defaults must include an explicit empty app-category table.
An upgrade from an earlier Research Edition may have a populated
research_app_category_map in the user's saved config. Because research_defaults
is merged into [aw-watcher-window] key-by-key, omitting the table would leave
legacy entries intact, so application names would continue to be replaced with
categories, defeating the primary behavior change. The explicit empty section
header ensures the watcher's merge logic resets the key.
"""
result = patcher.patch_config(POST_137)
defaults_str = _string_constant(result, "research_defaults")
# The literal section header must appear so older installs see an explicit reset.
assert "[research_app_category_map]" in defaults_str
assert tomllib.loads(defaults_str)["research_app_category_map"] == {}
def test_patched_config_matches_the_pinned_watcher_storage_contract():
"""Approved app names survive, while browser content and every URL do not."""
root = Path(__file__).resolve().parents[2]
config_path = root / "aw-watcher-window/aw_watcher_window/config.py"
filter_path = root / "aw-watcher-window/aw_watcher_window/research_filter.py"
if not config_path.is_file() or not filter_path.is_file():
pytest.skip("aw-watcher-window not checked out")
patched = patcher.patch_config(config_path.read_text(encoding="utf-8"))
defaults = tomllib.loads(_string_constant(patched, "research_defaults"))
spec = importlib.util.spec_from_file_location("research_filter", filter_path)
assert spec and spec.loader
research_filter = importlib.util.module_from_spec(spec)
spec.loader.exec_module(research_filter)
word = research_filter.transform(
{
"app": "Microsoft Word",
"title": "confidential-draft.docx",
"url": "file:///private/confidential-draft.docx",
},
defaults["research_category_map"],
defaults.get("research_app_category_map"),
)
safari = research_filter.transform(
{
"app": "Safari",
"title": "Confidential draft - Google Docs",
"url": "https://docs.google.com/document/private-id",
},
defaults["research_category_map"],
defaults.get("research_app_category_map"),
)
assert word == {"app": "Microsoft Word"}
assert safari == {"app": "Safari", "title": "Work & Productivity"}
def test_flag_rewrite_is_line_anchored_and_spares_the_sed_comment():
"""The #137 comment contains the literal flag text, and precedes the real flag.
An unanchored replace patches the comment and leaves research_enabled = false
-- a green build shipping a Research Edition with research silently disabled.
"""
result = patcher.patch_config(POST_137)
assert "s/^research_enabled = false$/research_enabled = true/" in result
assert (
tomllib.loads(_string_constant(result, "research_defaults"))["research_enabled"]
is True
)
def test_app_map_runtime_support_is_not_required_for_name_preserving_build():
without_lookup = POST_137.replace(
'config.get("research_app_category_map"', 'config.get("something_else"'
)
result = patcher.patch_config(without_lookup)
defaults = tomllib.loads(_string_constant(result, "research_defaults"))
# An empty app-map table is always emitted to clear legacy populated maps;
# the watcher ignores it if it does not read research_app_category_map.
assert defaults["research_app_category_map"] == {}
def test_fails_closed_when_flag_is_missing():
with pytest.raises(ValueError, match="research_enabled = false"):
patcher.patch_config(
f'default_config = """\n[aw-watcher-window]\n"""{RUNTIME_LOOKUPS}'
)
def test_fails_closed_on_ambiguous_flag():
"""Two line-anchored flags mean an unknown layout -- refuse rather than guess."""
ambiguous = POST_137.replace(
"research_enabled = false\n",
"research_enabled = false\nresearch_enabled = false\n",
1,
)
with pytest.raises(ValueError, match="found 2"):
patcher.patch_config(ambiguous)
def test_pre_137_layout_does_not_require_app_section():
missing_app_table = PRE_137.replace(
"\n[aw-watcher-window.research_app_category_map]\n", "\n"
)
result = patcher.patch_config(missing_app_table)
section = tomllib.loads(_string_constant(result, "default_config"))[
"aw-watcher-window"
]
assert section["research_enabled"] is True
assert "research_app_category_map" not in section

View File

@@ -0,0 +1,139 @@
import importlib.util
from pathlib import Path
import pytest
SCRIPT = Path(__file__).parents[1] / "patch_research_edition_export.py"
SPEC = importlib.util.spec_from_file_location("patch_research_edition_export", SCRIPT)
assert SPEC and SPEC.loader
patcher = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(patcher)
CONFIG_PATCHER_PATH = Path(__file__).parents[1] / "patch_research_edition_config.py"
CONFIG_SPEC = importlib.util.spec_from_file_location(
"patch_research_edition_config", CONFIG_PATCHER_PATH
)
assert CONFIG_SPEC and CONFIG_SPEC.loader
config_patcher = importlib.util.module_from_spec(CONFIG_SPEC)
CONFIG_SPEC.loader.exec_module(config_patcher)
def _write_tree(tmp_path: Path, export: str, bucket: str, mod: str) -> Path:
endpoints = tmp_path / "aw-server-rust" / "aw-server" / "src" / "endpoints"
endpoints.mkdir(parents=True)
(endpoints / "export.rs").write_text(export, encoding="utf-8")
(endpoints / "bucket.rs").write_text(bucket, encoding="utf-8")
(endpoints / "mod.rs").write_text(mod, encoding="utf-8")
return tmp_path
EXPORT_SRC = """use std::collections::HashMap;
pub fn buckets_export() {
for (bid, mut bucket) in buckets.drain() {
export.buckets.insert(bid, bucket);
}
Ok(export.into())
}
"""
BUCKET_SRC = """pub fn bucket_export() {
export.buckets.insert(bucket_id.into(), bucket);
Ok(export.into())
}
"""
MOD_SRC = """mod util;
mod export;
mod hostcheck;
"""
def test_patch_inserts_module_and_both_call_sites(tmp_path: Path):
root = _write_tree(tmp_path, EXPORT_SRC, BUCKET_SRC, MOD_SRC)
patcher.patch_tree(root)
export = (root / "aw-server-rust/aw-server/src/endpoints/export.rs").read_text(
encoding="utf-8"
)
bucket = (root / "aw-server-rust/aw-server/src/endpoints/bucket.rs").read_text(
encoding="utf-8"
)
mod = (root / "aw-server-rust/aw-server/src/endpoints/mod.rs").read_text(
encoding="utf-8"
)
copied = root / "aw-server-rust/aw-server/src/endpoints/export_sanitize.rs"
assert copied.is_file()
assert "mod export_sanitize;" in mod
assert patcher.MARKER in export
assert patcher.MARKER in bucket
assert "sanitize_buckets_export" in export
assert "sanitize_buckets_export" in bucket
assert "Status::Conflict" in export
assert "Status::Conflict" in bucket
def test_patch_is_idempotent(tmp_path: Path):
root = _write_tree(tmp_path, EXPORT_SRC, BUCKET_SRC, MOD_SRC)
patcher.patch_tree(root)
first = (root / "aw-server-rust/aw-server/src/endpoints/export.rs").read_text(
encoding="utf-8"
)
patcher.patch_tree(root)
second = (root / "aw-server-rust/aw-server/src/endpoints/export.rs").read_text(
encoding="utf-8"
)
assert first == second
mod = (root / "aw-server-rust/aw-server/src/endpoints/mod.rs").read_text(
encoding="utf-8"
)
assert mod.count("mod export_sanitize;") == 1
def test_patch_fails_closed_without_export_marker(tmp_path: Path):
root = _write_tree(tmp_path, "fn buckets_export() {}\n", BUCKET_SRC, MOD_SRC)
with pytest.raises(ValueError, match="insertion point"):
patcher.patch_tree(root)
def test_live_tree_is_patchable_or_already_patched():
root = Path(__file__).resolve().parents[2]
export = root / "aw-server-rust/aw-server/src/endpoints/export.rs"
bucket = root / "aw-server-rust/aw-server/src/endpoints/bucket.rs"
if not export.is_file() or not bucket.is_file():
pytest.skip("aw-server-rust not checked out")
export_text = export.read_text(encoding="utf-8")
bucket_text = bucket.read_text(encoding="utf-8")
assert (
patcher.MARKER in export_text
or export_text.count(patcher.EXPORT_INSERT_NEEDLE) == 1
)
assert (
patcher.MARKER in bucket_text
or bucket_text.count(patcher.BUCKET_INSERT_NEEDLE) == 1
)
def test_sanitizer_allowlist_covers_config_categories():
rust = (
Path(__file__).parents[1] / "research_edition" / "export_sanitize.rs"
).read_text(encoding="utf-8")
expected = {c for _, c in config_patcher.CATEGORY_MAP} | set(
config_patcher.APP_CATEGORY_MAP.values()
)
expected.update({"Excluded", "excluded"})
missing = [category for category in expected if f'"{category}"' not in rust]
assert missing == []
def test_every_research_build_patches_the_export_sanitizer():
workflow = (
Path(__file__).resolve().parents[2] / ".github" / "workflows" / "release.yml"
).read_text(encoding="utf-8")
assert workflow.count("python3 scripts/patch_research_edition_config.py") == 3
assert workflow.count("python3 scripts/patch_research_edition_export.py") == 3

View File

@@ -0,0 +1,646 @@
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

View File

@@ -0,0 +1,401 @@
"""Release fixtures and real local Git remotes; never contact the public feed."""
import importlib.util
import json
from pathlib import Path
import subprocess
import pytest
SCRIPT = Path(__file__).parents[1] / "package" / "publish_research_feed.py"
spec = importlib.util.spec_from_file_location("publish_research_feed", SCRIPT)
publisher = importlib.util.module_from_spec(spec)
spec.loader.exec_module(publisher)
def fixture(beta=5):
tag = f"v0.14.0b{beta}-research"
base = f"https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/"
manifest = {
"version": f"0.14.0-beta.{beta}",
"notes": tag,
"pub_date": "2026-09-09T00:00:00Z",
"platforms": {},
}
release = {
"tag_name": tag,
"draft": False,
"immutable": True,
"published_at": "2026-09-09T00:00:00Z",
"assets": [],
}
for target, extensions in publisher.TARGETS.items():
name = f"activitywatch-tauri-research-0.14.0b{beta}-{target}.{extensions[0]}"
manifest["platforms"][target] = {
"url": base + name,
"signature": "test-signature",
}
for asset in (name, name + ".sig"):
release["assets"].append(
{
"name": asset,
"state": "uploaded",
"size": 42,
"browser_download_url": base + asset,
}
)
return manifest, release, tag
def validate(manifest, release, tag):
publisher.validate_manifest(manifest, release, tag, lambda _: b"test-signature\n")
def test_valid_research_release():
validate(*fixture())
@pytest.mark.parametrize(
"mutation",
[
lambda m, r: m.update(pub_date="not a date"),
lambda m, r: m.update(pub_date="2026-09-09T00:00:00+00:99"),
lambda m, r: m.update(pub_date="2026-02-30T00:00:00Z"),
lambda m, r: m.update(pub_date="2026-09-09T00:00:00"),
lambda m, r: m.update(notes=123),
lambda m, r: m.update(name="0.14.0-beta.6"),
lambda m, r: m.update(url=123),
lambda m, r: m["platforms"]["darwin-aarch64"].update(extra="unexpected"),
lambda m, r: r.update(immutable=False),
lambda m, r: r.pop("immutable"),
lambda m, r: r.update(draft=True),
lambda m, r: r.update(draft="false"),
lambda m, r: r.update(published_at=None),
lambda m, r: r.update(tag_name="v0.14.0b4-research"),
lambda m, r: r["assets"].pop(0), # bundle absent
lambda m, r: r["assets"].pop(1), # signature absent
lambda m, r: r["assets"][0].update(size=0),
lambda m, r: r["assets"][0].update(state="new"),
lambda m, r: r["assets"][1].update(
browser_download_url="https://example.com/signature"
),
lambda m, r: r["assets"].append(r["assets"][0]),
lambda m, r: m.update(version="0.14.0b5"),
lambda m, r: m.update(version="0.14.0-beta.6"),
lambda m, r: m["platforms"].pop("darwin-x86_64"),
lambda m, r: m["platforms"].update({"unsupported-platform": {}}),
lambda m, r: m["platforms"]["darwin-aarch64"].update(signature=""),
lambda m, r: m["platforms"]["darwin-aarch64"].update(signature="wrong"),
lambda m, r: m.update(platforms=list(m["platforms"])),
],
)
def test_invalid_release_or_manifest(mutation):
manifest, release, tag = fixture()
mutation(manifest, release)
with pytest.raises(ValueError):
validate(manifest, release, tag)
@pytest.mark.parametrize(
"replacement",
[
("v0.14.0b5-research/", "v0.14.0b4-research/"),
("v0.14.0b5-research/", "v0.14.0b5/"),
("activitywatch-tauri-research-", "activitywatch-tauri-"),
("research-0.14.0b5-", "research-0.14.0b4-"),
(".app.tar.gz", ".dmg"),
("https://github.com/", "https://example.com/"),
],
)
def test_wrong_edition_release_version_or_extension(replacement):
manifest, release, tag = fixture()
entry = manifest["platforms"]["darwin-aarch64"]
entry["url"] = entry["url"].replace(*replacement)
# Even if metadata claims this asset exists, it is not an allowed URL.
release["assets"][0]["browser_download_url"] = entry["url"]
with pytest.raises(ValueError):
validate(manifest, release, tag)
@pytest.mark.parametrize(
"tag",
[
"v0.14.0b5",
"latest-research",
"v0.14.0b05-research",
"v0.14.0b5.dev-abcdef-research",
"v0.14.0b5-research/other",
],
)
def test_invalid_release_tag(tag):
manifest, release, _ = fixture()
with pytest.raises(ValueError):
validate(manifest, release, tag)
@pytest.fixture
def remote(tmp_path, monkeypatch):
config = tmp_path / "gitconfig"
config.write_text("[user]\nname = Publisher test\nemail = test@example.invalid\n")
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config))
monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
# Isolate test repositories from any enclosing harness Git invocation.
for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
monkeypatch.delenv(name, raising=False)
remote = tmp_path / "remote.git"
remote.mkdir()
publisher.git(remote, "init", "--bare", "--quiet")
return str(remote)
def candidate(tmp_path, remote, name, parent="absent", beta=5):
repo = tmp_path / name
manifest = fixture(beta)[0]
commit = publisher.prepare(repo, remote, parent, manifest)
return repo, commit
def test_initial_preparation_does_not_publish(tmp_path, remote):
repo, commit = candidate(tmp_path, remote, "candidate")
assert publisher.remote_parent(repo, remote) == "absent"
assert publisher.git(repo, "rev-list", "--parents", "-n", "1", commit) == commit
assert json.loads((repo / publisher.MANIFEST).read_text()) == fixture()[0]
publisher.publish(repo, remote, "absent", commit)
assert publisher.remote_parent(repo, remote) == commit
def test_advance_preserves_history_and_unrelated_files(tmp_path, remote):
repo, first = candidate(tmp_path, remote, "first")
(repo / "README.md").write_text("Feed history\n")
publisher.git(repo, "add", "README.md")
publisher.git(repo, "commit", "-m", "Document feed")
parent = publisher.git(repo, "rev-parse", "HEAD")
publisher.git(repo, "push", remote, f"{parent}:{publisher.REF}")
new_repo, second = candidate(tmp_path, remote, "second", parent, 6)
publisher.publish(new_repo, remote, parent, second)
assert publisher.git(new_repo, "rev-parse", f"{second}^") == parent
assert publisher.git(new_repo, "rev-parse", f"{second}^^") == first
assert (new_repo / "README.md").read_text() == "Feed history\n"
def test_replay_is_noop_and_conflicting_equal_version_rejected(tmp_path, remote):
repo, first = candidate(tmp_path, remote, "first")
publisher.publish(repo, remote, "absent", first)
_, repeated = candidate(tmp_path, remote, "replay", first)
assert repeated is None
different = fixture()[0]
different["notes"] = "changed"
with pytest.raises(ValueError, match="Conflicting"):
publisher.prepare(tmp_path / "conflict", remote, first, different)
assert publisher.remote_parent(repo, remote) == first
def test_late_b5_cannot_replace_b6(tmp_path, remote):
repo, first = candidate(tmp_path, remote, "b6", beta=6)
publisher.publish(repo, remote, "absent", first)
with pytest.raises(ValueError, match="regression"):
candidate(tmp_path, remote, "late-b5", first, 5)
def test_numeric_semver_order(tmp_path, remote):
repo, first = candidate(tmp_path, remote, "b9", beta=9)
publisher.publish(repo, remote, "absent", first)
new_repo, second = candidate(tmp_path, remote, "b10", first, 10)
publisher.publish(new_repo, remote, first, second)
assert publisher.remote_parent(repo, remote) == second
@pytest.mark.parametrize("provision", [False, True])
def test_same_parent_race_and_retry_revalidates_order(tmp_path, remote, provision):
parent = "absent"
if not provision:
repo, parent = candidate(tmp_path, remote, "seed", beta=4)
publisher.publish(repo, remote, "absent", parent)
slow_repo, slow = candidate(tmp_path, remote, "slow", parent, 5)
fast_repo, fast = candidate(tmp_path, remote, "fast", parent, 6)
publisher.publish(fast_repo, remote, parent, fast)
with pytest.raises(subprocess.CalledProcessError):
publisher.publish(slow_repo, remote, parent, slow)
assert publisher.remote_parent(fast_repo, remote) == fast
with pytest.raises(ValueError, match="Stale"):
candidate(tmp_path, remote, "stale", parent, 5)
with pytest.raises(ValueError, match="regression"):
candidate(tmp_path, remote, "retry", fast, 5)
def test_deleted_parent_cannot_be_silently_recreated(tmp_path, remote):
repo, parent = candidate(tmp_path, remote, "seed")
publisher.publish(repo, remote, "absent", parent)
new_repo, second = candidate(tmp_path, remote, "second", parent, 6)
# Simulate an administrator's intervening ref deletion, not publisher behavior.
publisher.git(Path(remote), "update-ref", "-d", publisher.REF)
with pytest.raises(subprocess.CalledProcessError, match="returned non-zero"):
publisher.publish(new_repo, remote, parent, second)
assert publisher.remote_parent(repo, remote) == "absent"
def test_rewound_parent_is_rejected_even_when_push_would_fast_forward(tmp_path, remote):
repo, first = candidate(tmp_path, remote, "first", beta=4)
publisher.publish(repo, remote, "absent", first)
second_repo, second = candidate(tmp_path, remote, "second", first, 5)
publisher.publish(second_repo, remote, first, second)
third_repo, third = candidate(tmp_path, remote, "third", second, 6)
publisher.git(Path(remote), "update-ref", publisher.REF, first)
with pytest.raises(subprocess.CalledProcessError):
publisher.publish(third_repo, remote, second, third)
assert publisher.remote_parent(repo, remote) == first
def test_existing_pre_push_hook_is_preserved(tmp_path, remote):
repo, commit = candidate(tmp_path, remote, "candidate")
hooks = tmp_path / "hooks"
hooks.mkdir()
sentinel = tmp_path / "hook-ran"
hook = hooks / "pre-push"
hook.write_text(f'#!/bin/sh\ncat > "{sentinel}"\nexit 1\n')
hook.chmod(0o755)
publisher.git(repo, "config", "core.hooksPath", str(hooks))
with pytest.raises(subprocess.CalledProcessError):
publisher.publish(repo, remote, "absent", commit)
assert commit in sentinel.read_text()
assert publisher.remote_parent(repo, remote) == "absent"
def test_prepare_rejects_existing_workdir(tmp_path, remote):
repo = tmp_path / "owned"
repo.mkdir()
(repo / "precious").write_text("keep")
with pytest.raises(FileExistsError):
publisher.prepare(repo, remote, "absent", fixture()[0])
assert (repo / "precious").read_text() == "keep"
def test_cli_validates_before_prepare(tmp_path, monkeypatch):
manifest, release, tag = fixture()
manifest["platforms"].pop("linux-aarch64")
path = tmp_path / "manifest.json"
path.write_text(json.dumps(manifest))
monkeypatch.setattr(
publisher, "public_bytes", lambda _: json.dumps(release).encode()
)
work = tmp_path / "candidate"
with pytest.raises(SystemExit) as error:
publisher.main(
[
"--manifest",
str(path),
"--tag",
tag,
"--expected-parent",
"absent",
"--work-dir",
str(work),
]
)
assert error.value.code == 1
assert not work.exists()
def test_receive_side_rejects_race_after_parent_was_advertised(tmp_path, remote):
repo, first = candidate(tmp_path, remote, "seed", beta=4)
publisher.publish(repo, remote, "absent", first)
slow_repo, slow = candidate(tmp_path, remote, "slow", first, 5)
fast_repo, fast = candidate(tmp_path, remote, "fast", first, 6)
# Put the competing commit object on the remote without moving the feed yet.
publisher.git(fast_repo, "push", remote, f"{fast}:refs/heads/competing")
hooks = tmp_path / "race-hooks"
hooks.mkdir()
hook = hooks / "pre-push"
hook.write_text(
f'#!/bin/sh\ngit -C "{remote}" update-ref {publisher.REF} {fast} {first}\n'
)
hook.chmod(0o755)
publisher.git(slow_repo, "config", "core.hooksPath", str(hooks))
with pytest.raises(subprocess.CalledProcessError):
publisher.publish(slow_repo, remote, first, slow)
assert publisher.remote_parent(repo, remote) == fast
@pytest.mark.parametrize("do_publish", [False, True])
def test_cli_public_checks_and_explicit_publish_flag(
tmp_path, remote, monkeypatch, capsys, do_publish
):
manifest, release, tag = fixture()
path = tmp_path / "manifest.json"
path.write_text(json.dumps(manifest))
urls = []
def public(url):
urls.append(url)
return (
json.dumps(release).encode()
if url.startswith("https://api.github.com/")
else b"test-signature\n"
)
real_git = publisher.git
def local_git(repo, *args, **kwargs):
return real_git(
repo,
*(
remote
if arg == "https://github.com/ActivityWatch/activitywatch.git"
else arg
for arg in args
),
**kwargs,
)
monkeypatch.setattr(publisher, "public_bytes", public)
monkeypatch.setattr(publisher, "git", local_git)
work = tmp_path / "candidate"
args = [
"--manifest",
str(path),
"--tag",
tag,
"--expected-parent",
"absent",
"--work-dir",
str(work),
]
if do_publish:
args.append("--publish")
publisher.main(args)
result = capsys.readouterr().out
assert result.startswith("Published" if do_publish else "Prepared")
assert len(urls) == 6 # public release metadata and all five public signatures
assert (publisher.remote_parent(work, remote) != "absent") == do_publish
def test_failed_public_signature_read_leaves_no_candidate(
tmp_path, remote, monkeypatch
):
manifest, release, tag = fixture()
path = tmp_path / "manifest.json"
path.write_text(json.dumps(manifest))
def public(url):
if url.startswith("https://api.github.com/"):
return json.dumps(release).encode()
raise OSError("signature HTTP 404")
monkeypatch.setattr(publisher, "public_bytes", public)
work = tmp_path / "candidate"
with pytest.raises(SystemExit) as error:
publisher.main(
[
"--manifest",
str(path),
"--tag",
tag,
"--expected-parent",
"absent",
"--work-dir",
str(work),
"--publish",
]
)
assert error.value.code == 1
assert not work.exists()