CI / Tests (Linux, Python 3.12) (push) Failing after 4m51s
CI / Tests (Linux, Python 3.11) (push) Failing after 5m19s
CI / Tests (Linux, Python 3.9) (push) Failing after 5m3s
CI / Tests (macOS) (push) Has been cancelled
CI / Tests (Windows) (push) Has been cancelled
Cross-platform CLI that mirrors Dropbox web tags to OS-native tags: - macOS: Finder xattrs (content-preserving, no re-upload) - Windows: XMP:Subject via ExifTool (re-upload expected once per file) Includes an interactive wizard that auto-detects the OS and Dropbox folder, walks the user through read-only token creation, previews tagged files, applies tags, and installs a background scheduler (launchd on macOS, Task Scheduler on Windows). Ships with: - 47-test suite (pytest) covering config, state DB, run_sync, writers - Gitea Actions CI for Linux / macOS / Windows with coverage reporting
141 lines
4.3 KiB
Python
141 lines
4.3 KiB
Python
"""Tests for tagsync.platform_macos.
|
|
|
|
The plist encoding test runs anywhere. The xattr round-trip runs only on
|
|
systems where the extended-attribute syscalls are available (macOS and
|
|
most Linux filesystems).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import plistlib
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tagsync.platform_macos import FINDER_TAGS_XATTR, MacWriter
|
|
|
|
|
|
def test_mac_writer_supports_all_file_types():
|
|
w = MacWriter()
|
|
for path in ("a.jpg", "b.zip", "c.txt", "noextension"):
|
|
assert w.supports(Path(path))
|
|
|
|
|
|
def test_mac_writer_is_online_only_is_false():
|
|
w = MacWriter()
|
|
assert w.is_online_only(Path("/any/file")) is False
|
|
|
|
|
|
def test_plist_encoding_matches_finder_format():
|
|
"""Finder expects a binary plist of a string array."""
|
|
from tagsync.platform_macos import plistlib # re-export
|
|
tags = ["alpha", "beta", "gamma"]
|
|
encoded = plistlib.dumps(tags, fmt=plistlib.FMT_BINARY)
|
|
# Binary plists start with 'bplist'.
|
|
assert encoded.startswith(b"bplist")
|
|
decoded = plistlib.loads(encoded)
|
|
assert decoded == tags
|
|
|
|
|
|
def _xattr_supported(path: Path) -> bool:
|
|
"""Can we setxattr / getxattr on files in this directory?"""
|
|
if not hasattr(os, "setxattr") or not hasattr(os, "getxattr"):
|
|
return False
|
|
try:
|
|
# Use a neutral namespace; on Linux, user.* always works on ext4.
|
|
os.setxattr(path, "user.tagsync_probe", b"ok")
|
|
val = os.getxattr(path, "user.tagsync_probe")
|
|
os.removexattr(path, "user.tagsync_probe")
|
|
return val == b"ok"
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
@pytest.fixture
|
|
def xattr_capable_file(tmp_path):
|
|
f = tmp_path / "target"
|
|
f.touch()
|
|
if not _xattr_supported(f):
|
|
pytest.skip("filesystem does not support extended attributes")
|
|
return f
|
|
|
|
|
|
def test_write_and_read_tags_via_xattr(xattr_capable_file):
|
|
"""Round-trip the actual xattr the script uses in production.
|
|
|
|
On Linux, the 'com.apple.metadata:*' namespace may not be allowed,
|
|
so this test uses the MacWriter only on Darwin.
|
|
"""
|
|
if sys.platform != "darwin":
|
|
pytest.skip("Finder xattr namespace only writable on macOS")
|
|
|
|
w = MacWriter()
|
|
w.write_tags(xattr_capable_file, ["alpha", "beta"])
|
|
|
|
raw = os.getxattr(xattr_capable_file, FINDER_TAGS_XATTR)
|
|
assert plistlib.loads(raw) == ["alpha", "beta"]
|
|
|
|
|
|
def test_clear_tags_via_xattr(xattr_capable_file):
|
|
if sys.platform != "darwin":
|
|
pytest.skip("Finder xattr namespace only writable on macOS")
|
|
|
|
w = MacWriter()
|
|
w.write_tags(xattr_capable_file, ["alpha"])
|
|
w.write_tags(xattr_capable_file, [])
|
|
|
|
with pytest.raises(OSError):
|
|
os.getxattr(xattr_capable_file, FINDER_TAGS_XATTR)
|
|
|
|
|
|
def test_clear_tags_when_none_set_is_noop(tmp_path):
|
|
"""Removing a non-existent xattr should not raise."""
|
|
if sys.platform != "darwin":
|
|
pytest.skip("requires macOS xattr semantics")
|
|
f = tmp_path / "fresh"
|
|
f.touch()
|
|
MacWriter().write_tags(f, []) # should not raise
|
|
|
|
|
|
# --- launchd plist generation -----------------------------------------------
|
|
|
|
|
|
def test_install_schedule_writes_plist(tmp_path, monkeypatch):
|
|
"""We don't want to actually call launchctl; intercept it and verify
|
|
the plist contents."""
|
|
import tagsync.platform_macos as pm
|
|
|
|
calls = []
|
|
|
|
class FakeResult:
|
|
returncode = 0
|
|
stderr = ""
|
|
stdout = ""
|
|
|
|
monkeypatch.setattr(pm.subprocess, "run", lambda args, **kw: calls.append(args) or FakeResult())
|
|
|
|
# Redirect plist + log paths into tmp to avoid touching real HOME.
|
|
fake_plist = tmp_path / "agent.plist"
|
|
fake_log = tmp_path / "log"
|
|
monkeypatch.setattr(pm, "LAUNCHD_PLIST", fake_plist)
|
|
monkeypatch.setattr(pm, "LOG_PATH", fake_log)
|
|
|
|
entry = tmp_path / "tagsync.py"
|
|
entry.write_text("# placeholder")
|
|
|
|
result_path = pm.install_schedule(entry, interval_minutes=30)
|
|
assert result_path == fake_plist
|
|
content = fake_plist.read_text()
|
|
assert "StartInterval" in content
|
|
assert "1800" in content # 30 min -> 1800 sec
|
|
assert str(entry) in content
|
|
assert "launchctl" in " ".join(" ".join(c) for c in calls)
|
|
|
|
|
|
def test_uninstall_schedule_when_not_installed(tmp_path, monkeypatch):
|
|
import tagsync.platform_macos as pm
|
|
|
|
monkeypatch.setattr(pm, "LAUNCHD_PLIST", tmp_path / "missing.plist")
|
|
assert pm.uninstall_schedule() is False
|