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
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:
84
scripts/tests/integration_tests.py
Normal file
84
scripts/tests/integration_tests.py
Normal 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))
|
||||
175
scripts/tests/test_check_tauri_server.py
Normal file
175
scripts/tests/test_check_tauri_server.py
Normal 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")
|
||||
290
scripts/tests/test_configure_tauri_release.py
Normal file
290
scripts/tests/test_configure_tauri_release.py
Normal 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 "")
|
||||
162
scripts/tests/test_emit_research_category_preset.py
Normal file
162
scripts/tests/test_emit_research_category_preset.py
Normal 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
|
||||
191
scripts/tests/test_generate_latest_json.py
Normal file
191
scripts/tests/test_generate_latest_json.py
Normal 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"),
|
||||
]
|
||||
)
|
||||
223
scripts/tests/test_patch_research_edition_config.py
Normal file
223
scripts/tests/test_patch_research_edition_config.py
Normal 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
|
||||
139
scripts/tests/test_patch_research_edition_export.py
Normal file
139
scripts/tests/test_patch_research_edition_export.py
Normal 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
|
||||
646
scripts/tests/test_patch_research_edition_profile.py
Normal file
646
scripts/tests/test_patch_research_edition_profile.py
Normal 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
|
||||
401
scripts/tests/test_publish_research_feed.py
Normal file
401
scripts/tests/test_publish_research_feed.py
Normal 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()
|
||||
Reference in New Issue
Block a user