From 084999ce0d9bf51b30679619436b51d9ccdaac75 Mon Sep 17 00:00:00 2001 From: Mike Benner <36419818+mikebenner@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:20:22 -0700 Subject: [PATCH 1/4] Read any OneDrive account's status, and discover them all (#1) onedrive-status.py gains --confdir, to report status for one account by config directory, and --list-accounts, to enumerate the accounts on the machine. Each account's config directory is read out of its own systemd unit's ExecStart argv[], never guessed from the instance name, so an instance pointed at an unrelated directory still resolves correctly. Enumeration is systemctl list-units plus a sweep of the enablement symlinks, because list-units reports only loaded units and list-unit-files never expands template instances. With no systemd at all it falls back to the single default account. Each account gets its own state directory, so the lock is per-account as well as the cache, keyed on the service and the canonicalized config directory. The default account's JSON output, cache path and lock path are unchanged, so today's single-account behaviour is byte-identical. The QML layer is unchanged and does not use the new flags yet; it is redesigned separately. Reviewed through the full gauntlet at Tier 2: /code-review high plus a convergence pass, /security-review (no high or medium findings), /simplify, three persona agents, and cross-family passes from codex and grok. 34 confirmed findings fixed. See the reports on PR #1. Known follow-ups, both flagged on the PR: --resume-unit is required by the QML design and does not exist yet, and docs/ARCHITECTURE.md's read-surface list is now stale. Co-Authored-By: Claude Opus 5 --- onedrive-status.py | 386 +++++++++++++++++++++++++++++++++-- tests/Status.test.sh | 469 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 831 insertions(+), 24 deletions(-) diff --git a/onedrive-status.py b/onedrive-status.py index 1cf4b85..1639992 100755 --- a/onedrive-status.py +++ b/onedrive-status.py @@ -2,6 +2,7 @@ import argparse import fcntl +import hashlib import heapq import json import os @@ -50,6 +51,46 @@ def default_confdir(): return Path.home() / ".config" / "onedrive" +def canonical_confdir(value): + # Collapses "." and ".." segments and any trailing slash so one account cannot + # key two different caches. Deliberately not realpath(): resolving symlinks + # would silently move an account whose config directory is a link. + return Path(os.path.normpath(str(value))) + + +def valid_confdir(value): + text = str(value) + if not text.startswith("/"): + return False + # Nothing is ever shell-interpolated, so ".." is not a threat here and a real + # directory reached through one must not be rejected; only characters that + # cannot appear in a path at all are refused. + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in text): + return False + path = canonical_confdir(text) + return not path.exists() or path.is_dir() + + +def account_state_dir(service, confdir): + # The default account keeps the historical directory, so its cache and lock + # paths are unchanged. Every other account gets its own directory, which + # isolates the lock as well as the cache: a 30s cloud check on one account + # must not block another account's ordinary poll. Keyed on the service AND + # the config directory, because two services may share one confdir. + base = state_dir() + if confdir is not None and str(service) == DEFAULT_SERVICE \ + and canonical_confdir(confdir) == canonical_confdir(default_confdir()): + return base + # os.fsencode, not str.encode: Linux paths are bytes, and a non-UTF-8 path + # arrives surrogate-escaped and would raise on a strict encode. + # An unknown confdir still gets a stable per-service key of its own. + confdir_key = os.fsencode(str(canonical_confdir(confdir))) if confdir is not None else b"" + digest = hashlib.sha256( + os.fsencode(str(service)) + b"\0" + confdir_key + ).hexdigest()[:16] + return base / "accounts" / digest + + def state_dir(): state_home = os.environ.get("XDG_STATE_HOME") base = Path(state_home) if state_home else Path.home() / ".local" / "state" @@ -101,7 +142,7 @@ def read_config_values(confdir): if not match: continue values[match.group(1)] = match.group(2).split("#", 1)[0].strip().strip('"') - except OSError: + except (OSError, UnicodeDecodeError): pass return values @@ -109,7 +150,16 @@ def read_config_values(confdir): def client_config(confdir, onedrive_path): values = read_config_values(confdir) client_version = "" - if onedrive_path: + # The client CREATES the tree when handed a --confdir that does not exist, which + # would be a config write the widget has no business making — and, before the + # argv re-join landed, could have created a truncated path like "/home/u/My". + if onedrive_path and not Path(confdir).is_dir(): + # --version takes no confdir, so the client version survives even when the + # account's directory does not exist yet. + exit_code, output = command_output([onedrive_path, "--version"], timeout=6) + if exit_code == 0: + client_version = output.strip().splitlines()[0].strip() if output.strip() else "" + if onedrive_path and Path(confdir).is_dir(): exit_code, output = command_output( [onedrive_path, "--confdir", str(confdir), "--display-config"], timeout=6, @@ -124,8 +174,15 @@ def client_config(confdir, onedrive_path): client_version = version_match.group(1).strip() sync_dir_value = values.get("sync_dir", "") - sync_dir = Path(os.path.expandvars(os.path.expanduser(sync_dir_value))) \ - if sync_dir_value else Path.home() / "OneDrive" + if sync_dir_value: + sync_dir = Path(os.path.expandvars(os.path.expanduser(sync_dir_value))) + elif canonical_confdir(confdir) == canonical_confdir(default_confdir()): + sync_dir = Path.home() / "OneDrive" + else: + # An account whose config could not be read has no known sync directory. + # Falling back to ~/OneDrive would list the DEFAULT account's files under + # this account's identity, and cache them there. + sync_dir = None download_only = parse_bool(values.get("download_only")) upload_only = parse_bool(values.get("upload_only")) if download_only and upload_only: @@ -147,19 +204,34 @@ def systemctl_value(arguments): return command_output(["systemctl", "--user", *arguments], timeout=4) -def service_state(service): +def unit_properties(unit, names): + """Parsed `systemctl show` properties for one unit, or None when the call failed. + + Multi-valued properties (ExecStart) keep every line; single-valued ones keep + the first, which is all systemd emits. + """ exit_code, output = systemctl_value([ "show", - service, - "--property=LoadState,ActiveState,SubState,UnitFileState,Result,ExecMainCode,ExecMainStatus", + unit, + "--property=" + ",".join(names), "--no-pager", ]) + if exit_code != 0: + return None properties = {} - if exit_code == 0: - for line in output.splitlines(): - key, separator, value = line.partition("=") - if separator: - properties[key] = value.strip() + for line in output.splitlines(): + key, separator, value = line.partition("=") + if separator: + properties.setdefault(key, []).append(value.strip()) + return properties + + +def service_state(service): + properties = unit_properties( + service, + ["LoadState", "ActiveState", "SubState", "UnitFileState", "Result", "ExecMainCode", "ExecMainStatus"], + ) or {} + properties = {key: values[0] for key, values in properties.items()} load_state = properties.get("LoadState", "") active_state = properties.get("ActiveState", "") unit_file_state = properties.get("UnitFileState", "") @@ -205,6 +277,232 @@ def resume_timer_state(): return next_usec // 1_000_000 if next_usec > 0 else 0 +# Unit names the helper is willing to hand back to itself through --service. +# Discovery and the --service gate MUST accept the same set, or an account can be +# discoverable and permanently unusable. Backslash and colon are here because +# systemd-escape produces them (e.g. "onedrive@team\x20space.service"); "/" is +# not, so "../bad.service" is still refused. +SERVICE_NAME_PATTERN = re.compile(r"[A-Za-z0-9_.@:\\-]+\.service") + +# "onedrive.service" or "onedrive@.service"; the bare template +# "onedrive@.service" deliberately does not match — it is not an account. +ONEDRIVE_UNIT_PATTERN = re.compile(r"onedrive(?:@(?P[A-Za-z0-9_.:\\-]+))?\.service") + +# systemd renders each ExecStart entry as a brace-delimited record of +# " ; "-separated key=value fields: +# { path=/usr/bin/onedrive ; argv[]=/usr/bin/onedrive --monitor --confdir=/x ; ... } +# Splitting the record into fields (rather than searching the whole string for +# "argv[]=") is what keeps a path= value containing the literal text "argv[]=" +# from being read as the argv. +# The delimiters are "{ " and " }" (with the spaces), so a brace inside an argv +# value cannot be mistaken for a record boundary. +EXEC_RECORD_PATTERN = re.compile(r"\{ (?P.*?) \}") + + +def exec_start_fields(body): + fields = {} + for part in body.split(" ; "): + key, separator, value = part.partition("=") + if separator: + fields.setdefault(key.strip(), value.strip()) + return fields + + +def join_confdir_tokens(head, rest): + # systemd joins argv with literal spaces and does not quote, so a confdir + # containing a space arrives split across tokens and is indistinguishable from + # separate arguments. Re-join greedily and let the filesystem arbitrate: the + # longest prefix that is an existing directory wins. A path that does not + # exist stays as the first token, which is the honest reading. + if head[:1] in ("'", '"'): + quote = head[0] + candidate = head[1:] + if candidate.endswith(quote): + return candidate[:-1] + for token in rest: + candidate += " " + token + if token.endswith(quote): + return candidate[:-1] + return candidate + best = head if Path(head).is_dir() else "" + candidate = head + for token in rest: + if token.startswith("-"): + break + candidate += " " + token + if Path(candidate).is_dir(): + best = candidate + return best or head + + +def confdir_from_argv(argv): + """The --confdir in one argv, or None when the argv carries none at all. + + None and "" are different answers: None means this unit simply does not pass + --confdir (so the client default applies), while a returned string may still + be invalid. Collapsing the two is what let an unusable confdir silently + masquerade as the default account. + """ + tokens = str(argv).split(" ") + for index, token in enumerate(tokens): + if token.startswith("--confdir="): + return join_confdir_tokens(token[len("--confdir="):], tokens[index + 1:]) + if token == "--confdir" and index + 1 < len(tokens): + return join_confdir_tokens(tokens[index + 1], tokens[index + 2:]) + return None + + +def confdir_from_exec_start(value): + """The account's confdir; None when no --confdir applies; "" when unreadable. + + Three answers, because the caller must treat them differently: None means the + client default genuinely applies, while "" means the ExecStart could not be + parsed and the unit must be dropped rather than aliased onto the default + account. + """ + if not value.strip(): + return None + # A unit may carry several ExecStart lines, only one of which is the OneDrive + # client; a preparatory command's --confdir must not be mistaken for it. The + # client's own record is authoritative once found, INCLUDING when it carries no + # --confdir at all -- otherwise a preparatory command's flag wins by default. + fallback = None + parsed = False + for match in EXEC_RECORD_PATTERN.finditer(value): + argv = exec_start_fields(match.group("body")).get("argv[]", "") + tokens = [token for token in argv.split(" ") if token] + if not tokens: + continue + parsed = True + confdir = confdir_from_argv(argv) + if "onedrive" in os.path.basename(tokens[0]): + return confdir + if fallback is None and confdir is not None: + fallback = confdir + return fallback if parsed else "" + + +def unit_search_dirs(): + # Overridable so the test suite can enumerate a sandboxed tree instead of the + # host's real unit directories; also useful in a container. + override = os.environ.get("OMAONEDRIVE_UNIT_ROOTS") + if override: + return [Path(part) for part in override.split(":") if part] + config_home = os.environ.get("XDG_CONFIG_HOME") + base = Path(config_home) if config_home else Path.home() / ".config" + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") + directories = [base / "systemd" / "user"] + # Runtime enablement ("systemctl --user enable --runtime") writes here, and is + # invisible to both list-units and the persistent search path. + if runtime_dir: + directories.append(Path(runtime_dir) / "systemd" / "user") + directories.extend([ + Path("/run/systemd/user"), + Path("/etc/systemd/user"), + # /lib/systemd/user is a usrmerge symlink to this one; globbing both is a + # pure duplicate that the name dedupe would only have to undo. + Path("/usr/lib/systemd/user"), + ]) + return directories + + +def wants_unit_names(): + # "list-units" only reports units systemd currently has loaded, so an instance + # that is enabled but has never been started (or was garbage-collected while + # inactive) is invisible there. Its enablement symlink is not, and + # "list-unit-files" never expands template instances, so the symlinks are the + # only way to see it. + names = [] + for directory in unit_search_dirs(): + for pattern in ("*.wants/onedrive*.service", "*.requires/onedrive*.service"): + try: + for link in directory.glob(pattern): + names.append(link.name) + except OSError: + continue + return names + + +def onedrive_unit_names(): + names = [] + exit_code, output = systemctl_value([ + "list-units", + "onedrive*", + "--all", + "--no-legend", + "--plain", + "--no-pager", + ]) + if exit_code == 0: + for line in output.splitlines(): + fields = line.split() + if fields: + names.append(fields[0]) + # Names swept off the filesystem are raw symlink names, never validated by + # systemd, so the pattern filter below is what keeps an unusable name out of + # the payload — it is a gate, not a tidy-up. + names.extend(wants_unit_names()) + return list(dict.fromkeys( + name for name in names if ONEDRIVE_UNIT_PATTERN.fullmatch(name) + )) + + +def default_account(): + return { + "service": DEFAULT_SERVICE, + "instance": "", + "confdir": str(default_confdir()), + "description": "OneDrive", + } + + +def account_entry(unit): + match = ONEDRIVE_UNIT_PATTERN.fullmatch(unit) + if match is None: + # Reachable from build_status: --service accepts any unit name, not only + # ours. Not an account, so there is nothing to describe. + return None + properties = unit_properties(unit, ["ExecStart", "Description", "LoadState"]) + if properties is None: + return None + load_state = (properties.get("LoadState") or [""])[0] + # not-found is a stale enablement symlink; masked is a unit deliberately turned + # off, whose empty ExecStart would otherwise read as "uses the default confdir". + if load_state in ("not-found", "masked"): + return None + # All ExecStart lines are considered together: systemd emits one line per + # record, and the "prefer the record whose argv[0] is onedrive" rule only + # means anything when it can see every record at once. + confdir = confdir_from_exec_start("\n".join(properties.get("ExecStart", []))) + if confdir is None: + # The unit passes no --confdir at all, so the client default genuinely applies. + confdir = str(default_confdir()) + elif valid_confdir(confdir): + confdir = str(canonical_confdir(confdir)) + else: + # Present but unusable. Reporting the default here would alias this unit onto + # the default account's token, cache and sync directory. + return None + return { + "service": unit, + "instance": match.group("instance") or "", + "confdir": confdir, + "description": (properties.get("Description") or [""])[0] or "OneDrive", + } + + +def discover_accounts(): + accounts = [ + entry for entry in (account_entry(unit) for unit in onedrive_unit_names()) if entry + ] + if not accounts: + # No systemd, no systemctl, or nothing enabled: degrade to the single + # default account so callers still get a usable list. + return [default_account()] + accounts.sort(key=lambda row: row["instance"]) + return accounts + + TRANSFER_SKIP_PREFIXES = ("changes", "differences", "new items", "items", "advertised") # Multi-line CLI error blocks: "ERROR: ..." then indented detail lines. @@ -577,12 +875,36 @@ def status_text(installed, authenticated, service, journal, resume_at=0): def build_status(args): onedrive_path = shutil.which("onedrive") - confdir = default_confdir() - config = client_config(confdir, onedrive_path) + if args.confdir: + confdir = canonical_confdir(args.confdir) + elif args.service != DEFAULT_SERVICE: + # Asked about another account but told nothing about where it lives: read the + # confdir out of that unit, exactly as discovery does. Reporting the default + # account's token and files under this account's name would be a lie. + entry = account_entry(args.service) + # None, not the default: an unresolvable unit is an unknown account, and + # answering with the default account's data would be a lie about identity. + confdir = canonical_confdir(entry["confdir"]) if entry else None + else: + confdir = default_confdir() + config = client_config(confdir, onedrive_path) if confdir else { + "syncDir": None, + "syncMode": "Two-way", + "clientVersion": "", + } sync_dir = config["syncDir"] - authenticated = (confdir / "refresh_token").is_file() + authenticated = confdir is not None and (confdir / "refresh_token").is_file() service = service_state(args.service) - resume_at = resume_timer_state() + # RESUME_TIMER is one fixed unit that starts onedrive.service, so it says + # nothing about any other account; reading it for them reported "Paused · + # resumes in ..." for accounts that were never paused. Per-account resume needs + # a --resume-unit option, which is not in this change's scope. + is_default_account = ( + args.service == DEFAULT_SERVICE + and confdir is not None + and confdir == canonical_confdir(default_confdir()) + ) + resume_at = resume_timer_state() if is_default_account else 0 journal = journal_state(args.service) if service["serviceAvailable"] else { "syncing": False, "lastSyncTs": 0, @@ -595,9 +917,17 @@ def build_status(args): "syncStage": "", } - directory = state_dir() + directory = account_state_dir(args.service, confdir) directory.mkdir(parents=True, exist_ok=True, mode=0o700) + # Every level, not just the leaf: mkdir applies its mode to the leaf alone, so + # a run for a non-default account would otherwise leave the plugin state root + # world-traversable on a machine where the default account never polls. + os.chmod(state_dir(), 0o700) os.chmod(directory, 0o700) + if directory != state_dir(): + os.chmod(directory.parent, 0o700) + # Both the lock and the cache live in this directory, so a 30s cloud check on + # one account cannot block another account's ordinary poll. lock_path = directory / "status.lock" cache_path = directory / "status-cache.json" with lock_path.open("a+", encoding="utf-8") as lock: @@ -611,10 +941,11 @@ def build_status(args): cached_sync_dir = str(cache.get("scanSyncDir", "")) cached_limit = int(cache.get("scanLimit", 0) or 0) scan_at = int(cache.get("scanAt", 0) or 0) - if cached_sync_dir != str(sync_dir) or cached_limit != args.limit or now - scan_at >= SCAN_CACHE_SECONDS: - cache["files"] = scan_recent(sync_dir, args.limit) + sync_dir_text = str(sync_dir) if sync_dir else "" + if cached_sync_dir != sync_dir_text or cached_limit != args.limit or now - scan_at >= SCAN_CACHE_SECONDS: + cache["files"] = scan_recent(sync_dir, args.limit) if sync_dir else [] cache["scanAt"] = now - cache["scanSyncDir"] = str(sync_dir) + cache["scanSyncDir"] = sync_dir_text cache["scanLimit"] = args.limit check_quota = args.remote or args.quota @@ -681,7 +1012,7 @@ def build_status(args): "syncStage": journal["syncStage"] if service["running"] else "", "statusText": status_text(onedrive_path is not None, authenticated, service, journal, resume_at), "resumeAt": resume_at, - "syncDir": str(sync_dir), + "syncDir": str(sync_dir) if sync_dir else "", "syncMode": config["syncMode"], "clientVersion": config["clientVersion"], "lastSyncTs": journal["lastSyncTs"], @@ -709,10 +1040,21 @@ def main(): parser.add_argument("--remote", action="store_true", help="query both quota and full-drive sync status") parser.add_argument("--limit", type=int, default=20, help="number of recent local files") parser.add_argument("--service", default=DEFAULT_SERVICE, help="systemd user service name") + parser.add_argument("--confdir", default=None, help="OneDrive config directory for this account") + parser.add_argument( + "--list-accounts", + action="store_true", + help="print the accounts configured on this machine as JSON and exit", + ) args = parser.parse_args() + if args.list_accounts: + print(json.dumps(discover_accounts(), separators=(",", ":"))) + return args.limit = max(5, min(50, args.limit)) - if not re.fullmatch(r"[A-Za-z0-9_.@-]+\.service", args.service): + if not SERVICE_NAME_PATTERN.fullmatch(args.service): parser.error("invalid service name") + if args.confdir is not None and not valid_confdir(args.confdir): + parser.error("invalid confdir") print(json.dumps(build_status(args), separators=(",", ":"))) diff --git a/tests/Status.test.sh b/tests/Status.test.sh index bd5ccb0..04f81e8 100755 --- a/tests/Status.test.sh +++ b/tests/Status.test.sh @@ -21,10 +21,25 @@ cat >"$fake_bin/onedrive" <<'SH' #!/bin/bash set -euo pipefail printf '%s\n' "$*" >>"${FAKE_ONEDRIVE_LOG:?}" +if [[ -n ${FAKE_ONEDRIVE_ARGV_LOG:-} ]]; then + printf 'ARGC=%s\n' "$#" >>"$FAKE_ONEDRIVE_ARGV_LOG" + for argument in "$@"; do printf '[%s]\n' "$argument" >>"$FAKE_ONEDRIVE_ARGV_LOG"; done +fi +confdir="" +previous="" +for argument in "$@"; do + [[ $previous == --confdir ]] && confdir="$argument" + previous="$argument" +done +sync_dir="${FAKE_SYNC_DIR:?}" +if [[ -n $confdir && -f "$confdir/config" ]]; then + from_config=$(sed -n 's/^sync_dir *= *"\(.*\)"$/\1/p' "$confdir/config" | head -1) + [[ -n $from_config ]] && sync_dir="$from_config" +fi case " $* " in *" --display-config "*) printf "Application version = onedrive v2.5.11\n" - printf "Config option 'sync_dir' = %s\n" "${FAKE_SYNC_DIR:?}" + printf "Config option 'sync_dir' = %s\n" "$sync_dir" printf "Config option 'upload_only' = %s\n" "${FAKE_UPLOAD_ONLY:-false}" printf "Config option 'download_only' = %s\n" "${FAKE_DOWNLOAD_ONLY:-true}" ;; @@ -59,6 +74,12 @@ SH cat >"$fake_bin/systemctl" <<'SH' #!/bin/bash +unit="" +for argument in "$@"; do + case "$argument" in + *.service) unit="$argument" ;; + esac +done case " $* " in *" list-timers "*) if [[ -n ${FAKE_RESUME_AT:-} ]]; then @@ -67,6 +88,93 @@ case " $* " in echo '[]' fi ;; + *" list-units "*) + case " $* " in + *" --plain "*) ;; + *) echo "list-units called without --plain" >&2; exit 64 ;; + esac + case " $* " in + *" --no-legend "*) ;; + *) echo "list-units called without --no-legend" >&2; exit 64 ;; + esac + if [[ ${FAKE_NO_SYSTEMD:-0} == 1 ]]; then + exit 1 + fi + if [[ -n ${FAKE_UNITS:-} ]]; then + printf '%s\n' "$FAKE_UNITS" + fi + exit 0 + ;; + *"--property=ExecStart"*) + if [[ ${FAKE_NO_SYSTEMD:-0} == 1 ]]; then + exit 1 + fi + load=loaded + extra="" + case "$unit" in + onedrive.service) + description='OneDrive Client for Linux' + args='--monitor' + ;; + onedrive@personal.service) + # confdir deliberately unrelated to the instance name, written with the + # "--confdir=" spelling. + description='OneDrive sync (personal account)' + args='--monitor --confdir=/srv/onedrive/mailboxes/alpha' + ;; + onedrive@work.service) + # Same, with the "--confdir " spelling systemd also preserves. + description='OneDrive sync (work account)' + args='--monitor --confdir /srv/onedrive/mailboxes/beta' + ;; + onedrive@spaced.service) + # A confdir containing a space. systemd joins argv with literal spaces + # and does not quote, so this is indistinguishable from extra arguments. + description='OneDrive sync (spaced account)' + args="--monitor --confdir=${FAKE_SPACED_CONFDIR:-/nonexistent/My Config}" + ;; + onedrive@decoy.service) + # path= itself contains the literal text "argv[]=" plus a --confdir. A + # parser that searches the whole property for "argv[]=" reads the decoy. + description='OneDrive sync (decoy account)' + path='/opt/argv[]=/dummy --confdir=/srv/onedrive/DECOY' + args='--monitor --confdir=/srv/onedrive/mailboxes/real' + ;; + onedrive@prepared.service) + # Two ExecStart lines: a preparatory command with its own --confdir, + # then the real client. Only the client's confdir is this account's. + description='OneDrive sync (prepared account)' + extra='ExecStart={ path=/usr/bin/prepare ; argv[]=/usr/bin/prepare --confdir=/srv/onedrive/STAGING ; ignore_errors=no ; }' + args='--monitor --confdir=/srv/onedrive/mailboxes/prepared' + ;; + onedrive@bogus.service) + # Present but unusable: a relative confdir. Must be dropped, never + # rewritten to the default account's directory. + description='OneDrive sync (bogus account)' + args='--monitor --confdir=relative/not/absolute' + ;; + onedrive@masked.service) + load=masked + description='onedrive@masked.service' + args='' + ;; + onedrive@ghost.service) + load=not-found + description='onedrive@ghost.service' + args='' + ;; + *) exit 1 ;; + esac + echo "LoadState=$load" + echo "Description=$description" + [[ -n $extra ]] && echo "$extra" + if [[ -n $args ]]; then + echo "ExecStart={ path=${path:-/usr/bin/onedrive} ; argv[]=/usr/bin/onedrive $args ; ignore_errors=no ; start_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }" + else + echo 'ExecStart=' + fi + exit 0 + ;; *" show "*) active=${FAKE_ACTIVE:-active} sub_state=dead @@ -124,6 +232,7 @@ export FAKE_SYNC_DIR="$sync_dir" export HOME="$test_home" export XDG_CONFIG_HOME="$test_home/.config" export XDG_STATE_HOME="$test_root/state" +export OMAONEDRIVE_UNIT_ROOTS="$test_home/.config/systemd/user" export PATH="$fake_bin:$PATH" local_output="$test_root/local.json" @@ -340,6 +449,362 @@ if python3 "$root/onedrive-status.py" --service '../bad.service' >/dev/null 2>&1 exit 1 fi +# --- multi-account discovery ------------------------------------------------- + +# A loaded template ("onedrive@.service") is not an account; a not-found unit is +# a stale enablement symlink; a masked unit is deliberately off and its empty +# ExecStart must not read as "uses the default confdir"; and a unit whose +# ExecStart carries a present-but-unusable confdir must be dropped rather than +# silently aliased onto the default account. +units=$'onedrive.service loaded active running OneDrive Client for Linux +onedrive@.service loaded active running OneDrive sync template +onedrive@ghost.service not-found inactive dead onedrive@ghost.service +onedrive@masked.service masked inactive dead onedrive@masked.service +onedrive@bogus.service loaded inactive dead OneDrive sync (bogus account) +onedrive@work.service loaded inactive dead OneDrive sync (work account) +onedrive@personal.service loaded active running OneDrive sync (personal account)' + +log_lines_before=$(wc -l <"$FAKE_ONEDRIVE_LOG") +FAKE_UNITS="$units" python3 "$root/onedrive-status.py" --list-accounts >"$test_root/accounts.json" +jq -e --arg default_confdir "$test_home/.config/onedrive" ' + length == 3 + and .[0].service == "onedrive.service" + and .[0].instance == "" + and .[0].confdir == $default_confdir + and .[0].description == "OneDrive Client for Linux" + and .[1].service == "onedrive@personal.service" + and .[1].instance == "personal" + and .[1].confdir == "/srv/onedrive/mailboxes/alpha" + and .[1].description == "OneDrive sync (personal account)" + and .[2].service == "onedrive@work.service" + and .[2].instance == "work" + and .[2].confdir == "/srv/onedrive/mailboxes/beta" + and .[2].description == "OneDrive sync (work account)" +' "$test_root/accounts.json" >/dev/null +# Discovery is pure enumeration: it must not shell out to the OneDrive client. +[[ $(wc -l <"$FAKE_ONEDRIVE_LOG") == "$log_lines_before" ]] + +FAKE_NO_SYSTEMD=1 python3 "$root/onedrive-status.py" --list-accounts >"$test_root/accounts-no-systemd.json" +jq -e --arg default_confdir "$test_home/.config/onedrive" ' + length == 1 + and .[0].service == "onedrive.service" + and .[0].instance == "" + and .[0].confdir == $default_confdir + and .[0].description == "OneDrive" +' "$test_root/accounts-no-systemd.json" >/dev/null + +python3 "$root/onedrive-status.py" --list-accounts >"$test_root/accounts-empty.json" +jq -e --arg default_confdir "$test_home/.config/onedrive" ' + length == 1 and .[0].service == "onedrive.service" and .[0].confdir == $default_confdir +' "$test_root/accounts-empty.json" >/dev/null + +# An instance that is enabled but not currently loaded never appears in +# "list-units", and "list-unit-files" never expands template instances, so +# discovery also reads the enablement symlinks. +mkdir -p "$test_home/.config/systemd/user/default.target.wants" +ln -sf "$test_home/.config/systemd/user/onedrive@.service" \ + "$test_home/.config/systemd/user/default.target.wants/onedrive@work.service" +python3 "$root/onedrive-status.py" --list-accounts >"$test_root/accounts-unloaded.json" +jq -e ' + length == 1 + and .[0].service == "onedrive@work.service" + and .[0].instance == "work" + and .[0].confdir == "/srv/onedrive/mailboxes/beta" +' "$test_root/accounts-unloaded.json" >/dev/null +# Remove it again: a leftover enablement symlink is a real discovery source and +# would otherwise add a fourth account to every later assertion. +rm "$test_home/.config/systemd/user/default.target.wants/onedrive@work.service" + +python3 - "$root/onedrive-status.py" <<'ACCOUNTS_PY' +import importlib.util +import sys + +spec = importlib.util.spec_from_file_location("omaonedrive_status", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +import os +import tempfile + +assert module.confdir_from_argv("/usr/bin/onedrive --monitor --confdir=/a/b") == "/a/b" +assert module.confdir_from_argv("/usr/bin/onedrive --monitor --confdir /a/b") == "/a/b" +# None, not "": a unit that passes no --confdir genuinely uses the client default, +# which is a different answer from a --confdir that is present but unusable. +assert module.confdir_from_argv("/usr/bin/onedrive --monitor") is None + +# The confdir comes out of argv[], never out of path= — including when path= +# itself contains the literal text "argv[]=" and a decoy --confdir. +assert module.confdir_from_exec_start( + "{ path=/opt/onedrive--confdir=/decoy ; " + "argv[]=/usr/bin/onedrive --monitor --confdir=/real/mailbox ; ignore_errors=no ; }" +) == "/real/mailbox" +assert module.confdir_from_exec_start( + "{ path=/opt/argv[]=/dummy --confdir=/DECOY ; " + "argv[]=/usr/bin/onedrive --monitor --confdir=/real/mailbox ; ignore_errors=no ; }" +) == "/real/mailbox" +assert module.confdir_from_exec_start( + "{ path=/usr/bin/onedrive ; argv[]=/usr/bin/onedrive --monitor ; ignore_errors=no ; }" +) is None + +# Several ExecStart records: the OneDrive one owns the confdir, not a +# preparatory command that happens to take the same flag. +assert module.confdir_from_exec_start( + "{ path=/usr/bin/prepare ; argv[]=/usr/bin/prepare --confdir=/staging ; ignore_errors=no ; }\n" + "{ path=/usr/bin/onedrive ; argv[]=/usr/bin/onedrive --monitor --confdir=/accounts/work ; ignore_errors=no ; }" +) == "/accounts/work" +# ...and the client's record stays authoritative when it carries no --confdir at +# all, or a preparatory command's flag would win by default. +assert module.confdir_from_exec_start( + "{ path=/usr/bin/prepare ; argv[]=/usr/bin/prepare --confdir=/staging ; ignore_errors=no ; }\n" + "{ path=/usr/bin/onedrive ; argv[]=/usr/bin/onedrive --monitor ; ignore_errors=no ; }" +) is None + +# A brace inside the confdir is not a record boundary. Returning None here would +# put the unit on the default account, which is the aliasing this guards against. +assert module.confdir_from_exec_start( + "{ path=/usr/bin/onedrive ; argv[]=/usr/bin/onedrive --monitor --confdir=/srv/{acct}/od ; ignore_errors=no ; }" +) == "/srv/{acct}/od" +# An ExecStart that cannot be parsed at all is "unknown" (drop the unit), never +# "uses the default". +assert module.confdir_from_exec_start("garbage with no record") == "" +assert not module.valid_confdir("") + +# systemd joins argv with single spaces, so a tab inside a path is part of the +# path and must survive. +assert module.confdir_from_argv("/usr/bin/onedrive --monitor --confdir=/a\tb") == "/a\tb" + +# When a shorter prefix ALSO exists, the longest real directory wins -- returning +# the prefix would point the client at a directory that is not a config dir. +with tempfile.TemporaryDirectory() as temporary: + os.makedirs(os.path.join(temporary, "OneDrive")) + os.makedirs(os.path.join(temporary, "OneDrive Work")) + assert module.confdir_from_argv( + "/usr/bin/onedrive --monitor --confdir=" + os.path.join(temporary, "OneDrive Work") + ) == os.path.join(temporary, "OneDrive Work") + +# A unit that is not one of ours is not an account, and must not crash the +# helper on the instance-name match. +assert module.account_entry("dbus.service") is None + +# systemd joins argv with literal spaces and never quotes, so a confdir +# containing a space arrives split. The longest prefix that is a real directory +# is the answer; a path that resolves nowhere stays at the first token. +with tempfile.TemporaryDirectory() as temporary: + spaced = os.path.join(temporary, "My Config", "personal") + os.makedirs(spaced) + assert module.confdir_from_argv( + "/usr/bin/onedrive --monitor --confdir=" + spaced + ) == spaced + assert module.confdir_from_argv( + "/usr/bin/onedrive --monitor --confdir " + spaced + " --verbose" + ) == spaced + assert module.confdir_from_argv( + '/usr/bin/onedrive --monitor --confdir="' + spaced + '"' + ) == spaced + # Two accounts under one spaced parent must stay distinct, not merge. + other = os.path.join(temporary, "My Config", "work") + os.makedirs(other) + assert module.confdir_from_argv("/usr/bin/onedrive --confdir=" + other) == other + assert module.account_state_dir("a.service", spaced) != module.account_state_dir("a.service", other) + +assert module.valid_confdir("/home/user/.config/onedrive") +assert not module.valid_confdir("relative/onedrive") +assert not module.valid_confdir("/tmp/onedrive\n--resync") +# ".." is not a threat when nothing is shell-interpolated, and a real directory +# reached through one must not be refused. +assert module.valid_confdir("/etc/../etc") +assert module.canonical_confdir("/etc/../etc") == module.Path("/etc") +# One account, one cache: "." and a trailing slash must not key two. +assert module.account_state_dir("a.service", "/x/onedrive/.") == module.account_state_dir("a.service", "/x/onedrive") +assert module.account_state_dir("a.service", "/x/onedrive/") == module.account_state_dir("a.service", "/x/onedrive") +# Two services sharing one confdir are still two accounts. +assert module.account_state_dir("a.service", "/x") != module.account_state_dir("b.service", "/x") +# The default pair keeps the historical directory; nothing else may claim it. +assert module.account_state_dir("onedrive.service", module.default_confdir()) == module.state_dir() +assert module.account_state_dir("onedrive@x.service", module.default_confdir()) != module.state_dir() +# A non-UTF-8 path is surrogate-escaped by the OS and must not raise. +module.account_state_dir("a.service", os.fsdecode(b"/tmp/\xff/onedrive")) + +# Discovery and the --service gate must accept exactly the same names, or an +# account can be discoverable and permanently unusable. +for name in ("onedrive.service", "onedrive@work.service", "onedrive@work:west.service", + "onedrive@team\\x20space.service"): + assert module.ONEDRIVE_UNIT_PATTERN.fullmatch(name), name + assert module.SERVICE_NAME_PATTERN.fullmatch(name), name +for name in ("onedrive@.service", "../bad.service", "onedrive@a/b.service"): + assert not (module.ONEDRIVE_UNIT_PATTERN.fullmatch(name) and module.SERVICE_NAME_PATTERN.fullmatch(name)), name +ACCOUNTS_PY + +# --- --confdir selects the account ------------------------------------------- + +alt_confdir="$test_home/.config/onedrive-accounts/work" +alt_sync_dir="$test_home/Work OneDrive" +mkdir -p "$alt_confdir" "$alt_sync_dir" +printf 'sync_dir = "%s"\n' "$alt_sync_dir" >"$alt_confdir/config" +printf '%s' "$alt_sync_dir" >"$alt_confdir/fake_sync_dir" + +python3 "$root/onedrive-status.py" --confdir "$alt_confdir" --service onedrive@work.service --limit 5 \ + >"$test_root/alt-confdir.json" +jq -e --arg sync_dir "$alt_sync_dir" ' + .ok == true + and .syncDir == $sync_dir + and .authenticated == false + and .statusText == "Login required" +' "$test_root/alt-confdir.json" >/dev/null +grep -Fq -- "--confdir $alt_confdir --display-config" "$FAKE_ONEDRIVE_LOG" +# Accounts must not share the status cache, or one account's quota leaks into +# another's panel. +state_root="$XDG_STATE_HOME/omarchy/io.github.salemsayed.omaonedrive" +[[ -f "$state_root/status-cache.json" ]] +# The lock must be per-account too, not just the cache: a 30s cloud check on one +# account must not block another account's ordinary poll. +[[ $(find "$state_root/accounts" -mindepth 2 -maxdepth 2 -name 'status-cache.json' | wc -l) == 1 ]] +[[ $(find "$state_root/accounts" -mindepth 2 -maxdepth 2 -name 'status.lock' | wc -l) == 1 ]] +account_dir=$(find "$state_root/accounts" -mindepth 1 -maxdepth 1 -type d | head -1) +[[ $(stat -c '%a' "$account_dir") == 700 ]] +[[ $(stat -c '%a' "$account_dir/status-cache.json") == 600 ]] +[[ $(stat -c '%a' "$account_dir/status.lock") == 600 ]] +[[ $(stat -c '%a' "$state_root/accounts") == 700 ]] + +# The default account still reads the default directory. +python3 "$root/onedrive-status.py" --limit 5 >"$test_root/default-confdir.json" +jq -e --arg sync_dir "$sync_dir" '.syncDir == $sync_dir and .authenticated == true' \ + "$test_root/default-confdir.json" >/dev/null + +# The predicate itself is covered in-process above; this proves the argparse +# wiring actually rejects. ".." is deliberately NOT in this list — a real +# directory reached through one is valid, and refusing it was a false rejection. +for bad_confdir in 'relative/onedrive' "$test_home/.config/onedrive/config"; do + if python3 "$root/onedrive-status.py" --confdir "$bad_confdir" >/dev/null 2>&1; then + echo "invalid confdir unexpectedly passed: $bad_confdir" >&2 + exit 1 + fi +done +# A real directory reached through ".." is accepted. +python3 "$root/onedrive-status.py" --confdir "$test_home/.config/../.config/onedrive" --limit 5 \ + >"$test_root/dotdot.json" +jq -e --arg sync_dir "$sync_dir" '.syncDir == $sync_dir' "$test_root/dotdot.json" >/dev/null + +# Today's no-flag invocation keeps exactly the fields the QML layer reads. +python3 "$root/onedrive-status.py" --limit 5 >"$test_root/shape.json" +jq -e ' + ([keys_unsorted[]] | sort) == ([ + "ok","installed","serviceAvailable","running","enabled","activeState","subState", + "serviceResult","serviceExitStatus","serviceFailed","resyncRequired","authenticated", + "reauthRequired","syncing","syncStage","statusText","resumeAt","syncDir","syncMode", + "clientVersion","lastSyncTs","usedBytes","quotaBytes","quotaKnown","quotaCheckedTs", + "quotaError","remoteStatus","syncStatusCheckedTs","syncStatusError","remoteCheckedTs", + "remoteError","files","activity","lastError" + ] | sort) +' "$test_root/shape.json" >/dev/null + +# --- discovery regressions --------------------------------------------------- + +# path= containing the literal "argv[]=" and a decoy confdir; two ExecStart lines +# where a preparatory command takes the same flag; and a confdir with a space. +mkdir -p "$test_home/My Config/spaced" +regression_units=$'onedrive@decoy.service loaded active running OneDrive sync (decoy account) +onedrive@prepared.service loaded active running OneDrive sync (prepared account) +onedrive@spaced.service loaded active running OneDrive sync (spaced account)' +FAKE_UNITS="$regression_units" FAKE_SPACED_CONFDIR="$test_home/My Config/spaced" \ + python3 "$root/onedrive-status.py" --list-accounts >"$test_root/accounts-regressions.json" +jq -e --arg spaced "$test_home/My Config/spaced" ' + length == 3 + and any(.[]; .instance == "decoy" and .confdir == "/srv/onedrive/mailboxes/real") + and any(.[]; .instance == "prepared" and .confdir == "/srv/onedrive/mailboxes/prepared") + and any(.[]; .instance == "spaced" and .confdir == $spaced) +' "$test_root/accounts-regressions.json" >/dev/null + +# An account discovered by --list-accounts must be usable: every service name it +# emits has to survive the --service gate. +python3 - "$root/onedrive-status.py" "$test_root/accounts.json" <<'ROUNDTRIP_PY' +import importlib.util +import json +import sys + +spec = importlib.util.spec_from_file_location("omaonedrive_status", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +for account in json.load(open(sys.argv[2])): + assert module.SERVICE_NAME_PATTERN.fullmatch(account["service"]), account["service"] +ROUNDTRIP_PY + +# --- the confdir reaches the client as exactly one argument ------------------ + +export FAKE_ONEDRIVE_ARGV_LOG="$test_root/argv.log" +hostile="$test_home/pwn; touch $test_root/OWNED --resync --monitor" +mkdir -p "$hostile" +python3 "$root/onedrive-status.py" --confdir "$hostile" --limit 5 >/dev/null +unset FAKE_ONEDRIVE_ARGV_LOG +# Three arguments, not five: the hostile string must not have been split, and no +# extra flag may have been introduced. +[[ $(grep -c '^ARGC=3$' "$test_root/argv.log") -ge 1 ]] +grep -Fqx -- "[$hostile]" "$test_root/argv.log" +[[ ! -e "$test_root/OWNED" ]] +if grep -Fqx -- '[--resync]' "$test_root/argv.log"; then + echo "confdir was split into additional arguments" >&2 + exit 1 +fi + +# --- the helper never creates a config directory ----------------------------- + +absent_confdir="$test_home/.config/onedrive-accounts/never-created" +python3 "$root/onedrive-status.py" --confdir "$absent_confdir" --limit 5 >"$test_root/absent.json" +if [[ -e $absent_confdir ]]; then + echo "helper created a config directory it was only asked to read" >&2 + exit 1 +fi +# An account whose config could not be read must not inherit the DEFAULT +# account's sync directory, or its files show under this account's identity. +jq -e '.syncDir == "" and (.files | length) == 0' "$test_root/absent.json" >/dev/null + +# --- --service without --confdir resolves the account, not the default ------- + +# Reporting the default account's token, files and sync directory under another +# account's name would be a lie; the confdir is read from that unit instead. +FAKE_UNITS="$units" python3 "$root/onedrive-status.py" --service onedrive@personal.service --limit 5 \ + >"$test_root/service-only.json" +jq -e '.authenticated == false and .syncDir == ""' "$test_root/service-only.json" >/dev/null + +# An unresolvable account must not be answered with the DEFAULT account's token, +# files and sync directory under its name. +FAKE_UNITS="$units" python3 "$root/onedrive-status.py" --service onedrive@nosuch.service --limit 5 \ + >"$test_root/unresolvable.json" +jq -e '.authenticated == false and .syncDir == "" and (.files | length) == 0' \ + "$test_root/unresolvable.json" >/dev/null +# ...while the default account, in the same run, still reports its own. +python3 "$root/onedrive-status.py" --limit 5 >"$test_root/default-contrast.json" +jq -e --arg sync_dir "$sync_dir" '.syncDir == $sync_dir and .authenticated == true' \ + "$test_root/default-contrast.json" >/dev/null + +# A config file that is not valid UTF-8 must not turn a status call into a +# traceback -- --confdir makes arbitrary directories reachable. +binary_confdir="$test_home/.config/onedrive-accounts/binary" +mkdir -p "$binary_confdir" +printf 'sync_dir = "\xff\xfe"\n' >"$binary_confdir/config" +python3 "$root/onedrive-status.py" --confdir "$binary_confdir" --limit 5 >"$test_root/binary.json" +jq -e '.ok == true' "$test_root/binary.json" >/dev/null + +# --- the plugin state root stays 0700 even if only another account ever runs -- + +isolated_state="$test_root/isolated-state" +XDG_STATE_HOME="$isolated_state" FAKE_UNITS="$units" python3 "$root/onedrive-status.py" \ + --service onedrive@work.service --confdir "$alt_confdir" --limit 5 >/dev/null +[[ $(stat -c '%a' "$isolated_state/omarchy/io.github.salemsayed.omaonedrive") == 700 ]] +[[ $(stat -c '%a' "$isolated_state/omarchy/io.github.salemsayed.omaonedrive/accounts") == 700 ]] + +# --- the resume timer is not cross-account ----------------------------------- + +resume_at=$(($(date +%s) + 3600)) +FAKE_ACTIVE=inactive FAKE_RESUME_AT="$resume_at" python3 "$root/onedrive-status.py" \ + --service onedrive@work.service --confdir "$alt_confdir" --limit 5 >"$test_root/other-resume.json" +jq -e '.resumeAt == 0 and (.statusText | startswith("Paused · resumes in") | not)' \ + "$test_root/other-resume.json" >/dev/null +# ...but the default account still reports it. +FAKE_ACTIVE=inactive FAKE_RESUME_AT="$resume_at" python3 "$root/onedrive-status.py" --limit 5 \ + >"$test_root/default-resume.json" +jq -e --argjson resume_at "$resume_at" '.resumeAt == $resume_at' "$test_root/default-resume.json" >/dev/null + grep -Fq '["systemctl", "--user", "stop", "onedrive.service"]' "$root/Service.qml" grep -Fq '["systemctl", "--user", "start", "onedrive.service"]' "$root/Service.qml" grep -Fq '["omarchy-launch-terminal", "onedrive"]' "$root/Service.qml" @@ -362,4 +827,4 @@ if grep -v 'omarchy-launch-terminal' "$root/Service.qml" \ exit 1 fi -echo "Status tests passed (local state, timed pause, remote opt-in, cache, permissions, login and control boundaries)" +echo "Status tests passed (local state, timed pause, remote opt-in, cache, permissions, login, multi-account discovery, --confdir and control boundaries)" From b2c9537bbf65bda32db8156578294973bfbdb5d3 Mon Sep 17 00:00:00 2001 From: Mike Benner <36419818+mikebenner@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:21:03 -0700 Subject: [PATCH 2/4] Add --resume-unit, and make the docs' own claims true (#2) --resume-unit names the transient systemd resume unit to inspect, so resumeAt can be correct per account instead of every account reporting the one fixed timer. It defaults to the legacy omaonedrive-resume for the plain service, so an in-flight timer survives an upgrade; without the flag every other account reports no resume time rather than borrowing that one. The value is validated as a systemd unit name, capped at 255 bytes, and a trailing .timer is normalised rather than refused. Neither a service nor a resume unit name may begin with '-'. Both are passed to systemctl as positional arguments, so such a value was read as its --machine or --host option, the latter making systemctl attempt an outbound connection. Confirmed against the real binary; the service gate had the same hole beforehand. Both gates now share one character class and one length rule, and --list-accounts no longer returns before validation runs. A config directory whose own name contains ' - ' is now rejoined correctly rather than resolving to a shorter path that happened to exist, which was another account's directory. docs/ARCHITECTURE.md states the helper's read surface as a closed list and rests its privacy claim on that completeness. The list was missing the config file the helper parses directly, the onedrive --version call, the OMAONEDRIVE_UNIT_ROOTS override that replaces the whole unit-directory list, and the account's own status-cache.json. Seven further claims across that file and TESTING.md overstated what the code guarantees; all are corrected. Reviewed over three rounds: /code-review xhigh, /security-review twice (no high or medium findings), /simplify, a docs-accuracy pass, and two rounds of cross-family review from codex and grok, the second re-reviewing the first round's fixes. 27 findings fixed. Seven further confirmed findings against already-merged code are listed on the PR as follow-ups. The no-flag JSON output remains byte-identical to the pre-multi-account version. Co-Authored-By: Claude Opus 5 --- TESTING.md | 12 ++++ docs/ARCHITECTURE.md | 83 ++++++++++++++++++++---- onedrive-status.py | 87 ++++++++++++++++++------- tests/Status.test.sh | 149 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 284 insertions(+), 47 deletions(-) diff --git a/TESTING.md b/TESTING.md index 7ea9968..b11f143 100644 --- a/TESTING.md +++ b/TESTING.md @@ -16,6 +16,18 @@ timer, journal, authentication state, local files, quota, sync status, cache reuse and state permissions — including that a routine refresh never runs a cloud query and that quota and sync-status failures never clear each other. +It also covers multi-account behaviour against a fake `systemctl`: that +`--list-accounts` finds every account from its own unit's `ExecStart` (ignoring +the bare template, stale `not-found` symlinks and masked units, and never +guessing a confdir from an instance name), that `--confdir` selects an account +and gives it its own cache *and* lock, that the confdir reaches the client as +exactly one argument and cannot introduce a second flag, that the helper never +creates a config directory it was only asked to read, that `--resume-unit` reads +that account's own timer and no other's, that an account discovered by +`--list-accounts` always survives the `--service` gate, that a present-but- +unusable confdir is dropped rather than aliased onto the default account, and +that the no-flag JSON output keeps exactly its expected field set. + ## In the shell Use an Omarchy Quattro VM with no host block device attached: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eddd5e0..e862dfa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,25 +8,73 @@ and panel-switch contracts. `Service.qml` is the asynchronous boundary between QML and the operating system. Local polling, cloud checks, and systemd control each run in a `Quickshell.Io.Process`; no command is constructed through a shell. -Timed pauses stop `onedrive.service` and schedule a fixed-name transient -`omaonedrive-resume.timer` through `systemd-run --user`. Replacing a preset or +Timed pauses stop `onedrive.service` and schedule the transient +`omaonedrive-resume.timer` through `systemd-run --user`. That is today's +single-account behaviour: the helper accepts a different resume unit per account +through `--resume-unit`, which the current QML does not yet pass, just as it does +not yet pass `--service`, `--confdir` or `--list-accounts`. Replacing a preset or resuming immediately first cancels that timer. If scheduling fails after the service was stopped, the service is started again so a failed timer cannot leave sync paused unexpectedly. `onedrive-status.py` reads: -- the effective `sync_dir` from `onedrive --display-config`; +- the account's `config` file, every `name = value` line of it, and the + effective `sync_dir` from that file and from `onedrive --display-config`; when + neither yields one, the default account falls back to `~/OneDrive` and every + other account reports no sync directory at all rather than borrowing that; - presence (never contents) of the CLI's `refresh_token` file; -- effective two-way, download-only, or upload-only mode from the CLI's - read-only `--display-config` output; -- `onedrive.service` load, enabled, active, failure, result, and main-process - exit states; -- the next activation of the transient timed-resume user timer, when present; +- effective two-way, download-only, or upload-only mode from the same two + sources; +- the client version, from `--display-config`, or from `onedrive --version` + when the account's directory does not exist; +- the `--service` unit's (default `onedrive.service`) load, enabled, active, + failure, result, and main-process exit states; +- the next activation of that account's transient timed-resume user timer, named + by `--resume-unit`, when present; without that flag only `onedrive.service` + has a well-known one and reads its legacy `omaonedrive-resume.timer`, while + every other account reports no resume time rather than borrowing it; - bounded user-journal history for sync-in-progress, live reconciliation phase, last-complete, and error state; - recent regular files below the configured sync directory, without following - symlinks. + symlinks; +- the names, `Description`, `LoadState` and `ExecStart` command line of the + `onedrive` and `onedrive@` user units, from which each account's + `--confdir` is read out of `argv[]` — never guessed from the instance name; +- systemd enablement symlinks, `*.wants/onedrive*.service` and + `*.requires/onedrive*.service`, under the user (`$XDG_CONFIG_HOME/systemd/user` + or `~/.config/systemd/user`, and `$XDG_RUNTIME_DIR/systemd/user` when that + variable is set) and system (`/run/systemd/user`, `/etc/systemd/user`, + `/usr/lib/systemd/user`) unit directories — or, when `OMAONEDRIVE_UNIT_ROOTS` + is set to a non-empty value, the colon-separated directories it names *instead* + of all of those — so an enabled-but-unloaded instance is still discovered; +- whether each candidate config directory exists, including every space-joined + prefix of a `--confdir` read out of an `ExecStart` — systemd renders argv + unquoted, so the longest prefix that is a real directory is taken as the + intended one, and prefixes are tried even through tokens that look like flags + because a directory may legitimately be named `My - Work`; +- this account's own cached presentation data from a previous run — quota, + remote status and the recent-file rows — out of its `status-cache.json`. + +`--confdir` selects which account's config directory is read, defaulting to +`$XDG_CONFIG_HOME/onedrive` or `~/.config/onedrive`; a value must be an absolute +path containing no C0 control character or DEL, and must not be an existing +non-directory. It reaches the CLI as a single argument. `--service` and +`--resume-unit` name that account's unit and the bare name of its transient +resume unit; neither may begin with `-`, because both are passed to `systemctl` +as positional arguments where such a value would be read as an option, and both +are capped at systemd's 255-byte unit-name limit counting the suffix. A +`--resume-unit` given with a trailing `.timer` is normalised rather than +refused, so a name whose instance legitimately ends that way still works. Given a +non-default `--service` with no `--confdir`, the helper reads that unit's own +config directory rather than describing the default account under another +account's name; a unit that cannot be resolved is reported as an unknown account +with no config directory, never as the default one. `--list-accounts` is a separate mode with its own output shape: +it prints a JSON array of the discovered accounts (`service`, `instance`, +`confdir`, `description`) and exits without building a status object or invoking +the OneDrive client at all, falling back to the single default account when +systemd is unavailable. The helper never creates a config directory it was only +asked to read. Routine status calls do not contact Microsoft. `--quota` invokes the CLI's fast `--display-quota` mode, while `--sync-status` invokes the potentially slow @@ -35,10 +83,17 @@ runs both with independent bounded timeouts. Quota and sync-status timestamps, errors, and successful results remain independent. Presentation data and recent-file rows are atomically cached under `$XDG_STATE_HOME/omarchy/io.github.salemsayed.omaonedrive`, or the standard -`~/.local/state` fallback. The directory is mode `0700`; the cache and lock are -mode `0600`. Failed or timed-out cloud queries retain the last successful result -and are reported separately from each other and from local service failures. A -file lock serializes multiple monitor/widget instances. +`~/.local/state` fallback. Each account gets its own directory there, keyed on +its service and canonical config directory, so accounts never read each other's +quota, recent files or remote status; the default account keeps the historical +top-level `status-cache.json` and `status.lock`. Every such directory is mode +`0700` and every cache and lock inside it is mode `0600`. Failed or timed-out +cloud queries retain the last successful result and are reported separately from +each other and from local service failures. Each account's own file lock +serializes multiple monitor/widget instances for that account, so one account's +cloud check cannot block another account's refresh. No refresh-token content, Microsoft response URL, access token, browser state, -or file content crosses the helper boundary. +or synced-file content crosses the helper boundary; the only additional data +`--list-accounts` emits is each unit's name, the instance parsed out of that +name, its systemd description, and its config-directory path. diff --git a/onedrive-status.py b/onedrive-status.py index 1639992..d6d108b 100755 --- a/onedrive-status.py +++ b/onedrive-status.py @@ -17,7 +17,7 @@ PLUGIN_ID = "io.github.salemsayed.omaonedrive" DEFAULT_SERVICE = "onedrive.service" -RESUME_TIMER = "omaonedrive-resume.timer" +DEFAULT_RESUME_UNIT = "omaonedrive-resume" SCAN_CACHE_SECONDS = 120 MAX_SERVICE_EVENTS = 2 MAX_FILE_EVENTS = 5 @@ -254,10 +254,10 @@ def service_state(service): } -def resume_timer_state(): +def resume_timer_state(unit): exit_code, output = systemctl_value([ "list-timers", - RESUME_TIMER, + unit + ".timer", "--all", "--output=json", "--no-pager", @@ -277,12 +277,40 @@ def resume_timer_state(): return next_usec // 1_000_000 if next_usec > 0 else 0 -# Unit names the helper is willing to hand back to itself through --service. -# Discovery and the --service gate MUST accept the same set, or an account can be -# discoverable and permanently unusable. Backslash and colon are here because -# systemd-escape produces them (e.g. "onedrive@team\x20space.service"); "/" is -# not, so "../bad.service" is still refused. -SERVICE_NAME_PATTERN = re.compile(r"[A-Za-z0-9_.@:\\-]+\.service") +# Unit names the helper is willing to hand back to itself through --service or +# --resume-unit. Discovery and the --service gate MUST accept the same set, or an +# account can be discoverable and permanently unusable, so both patterns are +# derived from one class. Backslash and colon are here because systemd-escape +# produces them (e.g. "onedrive@team\x20space.service"); "/" is not, so +# "../bad.service" is still refused. The first character may not be "-": these +# names are passed to systemctl as positional arguments, and "-Mguest.timer" or +# "-Hsomewhere.timer" would be read as its --machine/--host OPTIONS instead. +UNIT_NAME = r"[A-Za-z0-9_.@:\\][A-Za-z0-9_.@:\\-]*" +UNIT_NAME_PATTERN = re.compile(UNIT_NAME) +SERVICE_NAME_PATTERN = re.compile(UNIT_NAME + r"\.service") + +# systemd's own ceiling for a unit name, which the ".timer" suffix counts against. +MAX_UNIT_NAME_LENGTH = 255 + + +def valid_unit_name(value, suffix): + # One rule for every unit name the helper hands to systemctl, so the --service + # and --resume-unit gates cannot drift apart. + if len(value) + len(suffix) > MAX_UNIT_NAME_LENGTH: + return False + return UNIT_NAME_PATTERN.fullmatch(value) is not None + + +def resume_unit_name(value): + # The caller passes a BARE name and resume_timer_state appends ".timer", so a + # value that already carries one would query ".timer.timer". Strip one + # rather than refusing: the same spelling is legitimate when an instance ends + # in ".timer", and refusing would abort the status call entirely. + return value[: -len(".timer")] if value.endswith(".timer") else value + + +def valid_resume_unit(value): + return valid_unit_name(resume_unit_name(value), ".timer") # "onedrive.service" or "onedrive@.service"; the bare template # "onedrive@.service" deliberately does not match — it is not an account. @@ -324,11 +352,13 @@ def join_confdir_tokens(head, rest): if token.endswith(quote): return candidate[:-1] return candidate + # Every space-joined prefix is tried, including through tokens that look like + # flags: a directory may legitimately be named "My - Work", and only the + # filesystem can settle it. The longest one that exists wins; a following real + # flag simply never forms an existing path. best = head if Path(head).is_dir() else "" candidate = head for token in rest: - if token.startswith("-"): - break candidate += " " + token if Path(candidate).is_dir(): best = candidate @@ -895,16 +925,15 @@ def build_status(args): sync_dir = config["syncDir"] authenticated = confdir is not None and (confdir / "refresh_token").is_file() service = service_state(args.service) - # RESUME_TIMER is one fixed unit that starts onedrive.service, so it says - # nothing about any other account; reading it for them reported "Paused · - # resumes in ..." for accounts that were never paused. Per-account resume needs - # a --resume-unit option, which is not in this change's scope. - is_default_account = ( - args.service == DEFAULT_SERVICE - and confdir is not None - and confdir == canonical_confdir(default_confdir()) + # Each account schedules its own transient resume unit, so the caller names it. + # Without the flag only the plain service has a well-known one -- + # DEFAULT_RESUME_UNIT starts onedrive.service, so its pending time belongs to + # that SERVICE whichever config directory was named -- and every other account + # reports no resume time rather than borrowing that one. + resume_unit = args.resume_unit or ( + DEFAULT_RESUME_UNIT if args.service == DEFAULT_SERVICE else "" ) - resume_at = resume_timer_state() if is_default_account else 0 + resume_at = resume_timer_state(resume_unit_name(resume_unit)) if resume_unit else 0 journal = journal_state(args.service) if service["serviceAvailable"] else { "syncing": False, "lastSyncTs": 0, @@ -1041,20 +1070,30 @@ def main(): parser.add_argument("--limit", type=int, default=20, help="number of recent local files") parser.add_argument("--service", default=DEFAULT_SERVICE, help="systemd user service name") parser.add_argument("--confdir", default=None, help="OneDrive config directory for this account") + parser.add_argument( + "--resume-unit", + default=None, + help="bare name of this account's transient resume unit, without a suffix; " + "when omitted only " + DEFAULT_SERVICE + " reads its legacy " + + DEFAULT_RESUME_UNIT + " timer and every other account reports none", + ) parser.add_argument( "--list-accounts", action="store_true", help="print the accounts configured on this machine as JSON and exit", ) args = parser.parse_args() - if args.list_accounts: - print(json.dumps(discover_accounts(), separators=(",", ":"))) - return args.limit = max(5, min(50, args.limit)) - if not SERVICE_NAME_PATTERN.fullmatch(args.service): + if not SERVICE_NAME_PATTERN.fullmatch(args.service) \ + or not valid_unit_name(args.service[: -len(".service")], ".service"): parser.error("invalid service name") if args.confdir is not None and not valid_confdir(args.confdir): parser.error("invalid confdir") + if args.resume_unit is not None and not valid_resume_unit(args.resume_unit): + parser.error("invalid resume unit") + if args.list_accounts: + print(json.dumps(discover_accounts(), separators=(",", ":"))) + return print(json.dumps(build_status(args), separators=(",", ":"))) diff --git a/tests/Status.test.sh b/tests/Status.test.sh index 04f81e8..1fb50d2 100755 --- a/tests/Status.test.sh +++ b/tests/Status.test.sh @@ -75,15 +75,19 @@ SH cat >"$fake_bin/systemctl" <<'SH' #!/bin/bash unit="" +timer="" for argument in "$@"; do case "$argument" in *.service) unit="$argument" ;; + *.timer) timer="$argument" ;; esac done case " $* " in *" list-timers "*) - if [[ -n ${FAKE_RESUME_AT:-} ]]; then - printf '[{"next":%s,"unit":"omaonedrive-resume.timer"}]\n' "$((FAKE_RESUME_AT * 1000000))" + # Only the unit actually asked for reports a pending resume, so an account + # cannot inherit another account's timer. + if [[ -n ${FAKE_RESUME_AT:-} && $timer == "${FAKE_RESUME_TIMER:-omaonedrive-resume.timer}" ]]; then + printf '[{"next":%s,"unit":"%s"}]\n' "$((FAKE_RESUME_AT * 1000000))" "$timer" else echo '[]' fi @@ -793,17 +797,144 @@ XDG_STATE_HOME="$isolated_state" FAKE_UNITS="$units" python3 "$root/onedrive-sta [[ $(stat -c '%a' "$isolated_state/omarchy/io.github.salemsayed.omaonedrive") == 700 ]] [[ $(stat -c '%a' "$isolated_state/omarchy/io.github.salemsayed.omaonedrive/accounts") == 700 ]] -# --- the resume timer is not cross-account ----------------------------------- +# --- --resume-unit reads that account's own timer ---------------------------- resume_at=$(($(date +%s) + 3600)) +# An authenticated second account, so the paused status text is actually +# reachable -- an unauthenticated one short-circuits to "Login required". +paused_confdir="$test_home/.config/onedrive-accounts/paused" +paused_sync_dir="$test_home/Paused OneDrive" +mkdir -p "$paused_confdir" "$paused_sync_dir" +printf 'sync_dir = "%s"\n' "$paused_sync_dir" >"$paused_confdir/config" +touch "$paused_confdir/refresh_token" + +# The tandera account's timer is pending... +FAKE_ACTIVE=inactive FAKE_RESUME_AT="$resume_at" FAKE_RESUME_TIMER="omaonedrive-resume@tandera.timer" \ + python3 "$root/onedrive-status.py" --service onedrive@tandera.service --confdir "$paused_confdir" \ + --resume-unit omaonedrive-resume@tandera --limit 5 >"$test_root/resume-tandera.json" +jq -e --argjson resume_at "$resume_at" ' + .authenticated == true + and .resumeAt == $resume_at + and (.statusText | startswith("Paused · resumes in")) +' "$test_root/resume-tandera.json" >/dev/null + +# ...and on the same machine state a different account must NOT inherit it. +FAKE_ACTIVE=inactive FAKE_RESUME_AT="$resume_at" FAKE_RESUME_TIMER="omaonedrive-resume@tandera.timer" \ + python3 "$root/onedrive-status.py" --service onedrive@personal.service --confdir "$paused_confdir" \ + --resume-unit omaonedrive-resume@personal --limit 5 >"$test_root/resume-personal.json" +jq -e ' + .authenticated == true + and .resumeAt == 0 + and (.statusText | startswith("Paused · resumes in") | not) +' "$test_root/resume-personal.json" >/dev/null + +# With no flag, a NON-default account reports no resume time rather than +# borrowing the legacy timer -- and this account is authenticated, so the status +# text clause bites instead of short-circuiting on "Login required". FAKE_ACTIVE=inactive FAKE_RESUME_AT="$resume_at" python3 "$root/onedrive-status.py" \ - --service onedrive@work.service --confdir "$alt_confdir" --limit 5 >"$test_root/other-resume.json" -jq -e '.resumeAt == 0 and (.statusText | startswith("Paused · resumes in") | not)' \ - "$test_root/other-resume.json" >/dev/null -# ...but the default account still reports it. + --service onedrive@work.service --confdir "$paused_confdir" --limit 5 \ + >"$test_root/other-resume.json" +jq -e ' + .authenticated == true + and .resumeAt == 0 + and (.statusText | startswith("Paused · resumes in") | not) +' "$test_root/other-resume.json" >/dev/null + +# The plain account keeps the legacy unit name with no flag at all, so an +# in-flight timer survives an upgrade... FAKE_ACTIVE=inactive FAKE_RESUME_AT="$resume_at" python3 "$root/onedrive-status.py" --limit 5 \ - >"$test_root/default-resume.json" -jq -e --argjson resume_at "$resume_at" '.resumeAt == $resume_at' "$test_root/default-resume.json" >/dev/null + >"$test_root/resume-legacy.json" +jq -e --argjson resume_at "$resume_at" '.resumeAt == $resume_at' "$test_root/resume-legacy.json" >/dev/null + +# ...and that legacy default follows the SERVICE, not the config directory: the +# timer starts onedrive.service, so its pending time is that service's whichever +# directory was named. +FAKE_ACTIVE=inactive FAKE_RESUME_AT="$resume_at" python3 "$root/onedrive-status.py" \ + --confdir "$paused_confdir" --limit 5 >"$test_root/resume-legacy-otherdir.json" +jq -e --argjson resume_at "$resume_at" '.resumeAt == $resume_at' \ + "$test_root/resume-legacy-otherdir.json" >/dev/null + +# The value becomes a systemd unit name passed to systemctl as a positional +# argument, so it is validated at least as strictly as a service name. +for bad_unit in 'bad/unit' 'unit with space' '../escape' \ + "$(python3 -c 'print("u" * 260)')"; do + if python3 "$root/onedrive-status.py" --resume-unit "$bad_unit" >/dev/null 2>&1; then + echo "invalid resume unit unexpectedly passed: $bad_unit" >&2 + exit 1 + fi +done + +# Option-shaped names MUST be spelled with "=" here. In the two-token form +# argparse rejects them itself ("expected one argument") before either validator +# runs, so the assertion would pass even if the first-character restriction were +# removed -- which is exactly the regression it exists to catch. +for option_shaped in '--resume-unit=-Mguest' '--resume-unit=-Hsomewhere.example.com' \ + '--service=-Mguest.service' '--service=-Hsomewhere.example.com.service'; do + if python3 "$root/onedrive-status.py" "$option_shaped" >/dev/null 2>&1; then + echo "option-shaped unit name unexpectedly passed: $option_shaped" >&2 + exit 1 + fi +done +# Prove the guard is the validator and not argparse: the same spelling with a +# leading character that is legal does reach the helper and succeeds. +python3 "$root/onedrive-status.py" --resume-unit=omaonedrive-resume --limit 5 >/dev/null + +# A bare name whose instance legitimately ends in ".timer" must NOT be refused -- +# systemd-escape produces such names -- and a caller-supplied ".timer" suffix is +# normalised rather than doubled. +python3 - "$root/onedrive-status.py" <<'SUFFIX_PY' +import importlib.util +import sys + +spec = importlib.util.spec_from_file_location("omaonedrive_status", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +assert module.valid_resume_unit("omaonedrive-resume@team.service") +assert module.valid_resume_unit("omaonedrive-resume@foo.timer") +assert module.valid_resume_unit("omaonedrive-resume.timer") +assert module.resume_unit_name("omaonedrive-resume.timer") == "omaonedrive-resume" +assert module.resume_unit_name("omaonedrive-resume") == "omaonedrive-resume" +assert not module.valid_resume_unit("-Mguest") +assert not module.valid_resume_unit("bad/unit") +assert not module.valid_resume_unit("u" * 260) +# The service gate carries the same length rule, not just the same characters. +assert not module.SERVICE_NAME_PATTERN.fullmatch("-Mguest.service") +assert not module.valid_unit_name("o" * 300, ".service") +SUFFIX_PY + +# A confdir whose own directory name contains " - " must not resolve to a +# shorter path that merely happens to exist -- that would be another account. +dashed_parent="$test_home/accounts" +mkdir -p "$dashed_parent/My" "$dashed_parent/My - Work" +python3 - "$root/onedrive-status.py" "$dashed_parent" <<'DASHED_PY' +import importlib.util +import sys + +spec = importlib.util.spec_from_file_location("omaonedrive_status", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +parent = sys.argv[2] +assert module.confdir_from_argv( + "/usr/bin/onedrive --monitor --confdir=" + parent + "/My - Work" +) == parent + "/My - Work" +# A real trailing flag still never forms an existing path. +assert module.confdir_from_argv( + "/usr/bin/onedrive --confdir=" + parent + "/My --monitor" +) == parent + "/My" +DASHED_PY + +# --list-accounts must not be a validation bypass: the same values it would +# accept there are rejected in status mode. +if python3 "$root/onedrive-status.py" --list-accounts --confdir relative/path >/dev/null 2>&1; then + echo "--list-accounts accepted an invalid confdir" >&2 + exit 1 +fi +if python3 "$root/onedrive-status.py" --list-accounts '--service=-Mguest.service' >/dev/null 2>&1; then + echo "--list-accounts accepted an option-shaped service name" >&2 + exit 1 +fi grep -Fq '["systemctl", "--user", "stop", "onedrive.service"]' "$root/Service.qml" grep -Fq '["systemctl", "--user", "start", "onedrive.service"]' "$root/Service.qml" From 11f8901265f8ee1108e0204ed11c2a6d96d60f01 Mon Sep 17 00:00:00 2001 From: Michael Benner <36419818+mikebenner@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:16:38 -0700 Subject: [PATCH 3/4] Show and control every OneDrive account from one bar widget (#3) The bar becomes multi-account: the helper discovers every onedrive systemd unit (plain and template instances), and the widget polls them round-robin, aggregates them worst-first into one badge, and drives every action -- pause, resume, timed pause, reauth, resync repair, folder, storage -- against the selected account's own service, config directory and resume timer. One account keeps exactly the single-account behaviour, commands and all. Coordination: one status poll at a time across the fleet, with a startup ramp that gives unpolled accounts priority without letting a broken one monopolise; one cloud check at a time, deduplicated against both the queue and the check in flight; desktop notifications coalesced per polling burst, grouped popups opening the worst account. IPC gains accounts() and selectAccount(); every gesture and notification click selects the account it is about before acting. Worst-first is the user's stated rule for the badge, pause included: every account must be working before the bar looks normal. Hardening that fell out of five adversarial review passes: every process settles exactly once per invocation -- on exit, on failure to start (real Quickshell never emits exited for those), or by watchdog -- so a missing python3, a wedged helper or a hung systemctl cannot freeze the fleet or kill Pause for the session. A pause holds its optimistic state until a poll STARTED after the control confirms it. Every helper reply is stamped with the config directory it actually read, so the startup seed poll can never attach the default account's data to another unit's name. The helper allowlists LoadState=loaded with a real ExecStart, and survives any malformed cache. Verification: 124 node tests, 263 harness checks driving the real coordinator headless, a QML-to-real-helper contract check, helper suites, and a qmllint gate for the two files nothing else parses. Every function in Service.qml and Account.qml fails the suite when emptied; every condition in Model.js fails when inverted. Full finding ledger on the branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ErMhXzfZ86BcJuhhgb242 --- Account.qml | 942 ++++++++ BarWidget.qml | 63 +- Commands.js | 135 ++ Model.js | 614 +++++ Panel.qml | 115 +- README.md | 22 +- Service.qml | 1015 ++++---- TESTING.md | 32 +- docs/ARCHITECTURE.md | 60 +- manifest.json | 19 +- onedrive-status.py | 77 +- tests/Aggregate.test.js | 633 +++++ tests/Commands.test.js | 305 +++ tests/Discovery.test.js | 135 ++ tests/IpcLifecycle.test.js | 102 + tests/Model.test.js | 75 + tests/Notifications.test.js | 169 ++ tests/PanelWiring.test.js | 151 ++ tests/Scheduler.test.js | 212 ++ tests/Status.test.sh | 198 +- tests/contract.sh | 101 + tests/qml/Contract.qml | 44 + tests/qml/Harness.qml | 2061 +++++++++++++++++ tests/qml/Smoke.qml | 41 + tests/qmlstubs/Quickshell/Io/Process.qml | 55 + .../qmlstubs/Quickshell/Io/StdioCollector.qml | 8 + tests/qmlstubs/Quickshell/Io/qmldir | 3 + tests/qmlstubs/Quickshell/Quickshell.qml | 59 + tests/qmlstubs/Quickshell/qmldir | 2 + tests/run | 91 +- 30 files changed, 6995 insertions(+), 544 deletions(-) create mode 100644 Account.qml create mode 100644 Commands.js create mode 100644 tests/Aggregate.test.js create mode 100644 tests/Commands.test.js create mode 100644 tests/Discovery.test.js create mode 100644 tests/Notifications.test.js create mode 100644 tests/PanelWiring.test.js create mode 100644 tests/Scheduler.test.js create mode 100755 tests/contract.sh create mode 100644 tests/qml/Contract.qml create mode 100644 tests/qml/Harness.qml create mode 100644 tests/qml/Smoke.qml create mode 100644 tests/qmlstubs/Quickshell/Io/Process.qml create mode 100644 tests/qmlstubs/Quickshell/Io/StdioCollector.qml create mode 100644 tests/qmlstubs/Quickshell/Io/qmldir create mode 100644 tests/qmlstubs/Quickshell/Quickshell.qml create mode 100644 tests/qmlstubs/Quickshell/qmldir diff --git a/Account.qml b/Account.qml new file mode 100644 index 0000000..08fef34 --- /dev/null +++ b/Account.qml @@ -0,0 +1,942 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "Model.js" as Model +import "Commands.js" as Commands + +// One OneDrive account: its own systemd unit, config directory, resume timer, +// status process and control processes. Nothing here reads or writes another +// account's state, and every command vector is built from this object's own +// identity by Commands.js. +Item { + id: root + + // Identity, supplied by discovery. confdir is read out of this unit's own + // ExecStart by the helper -- never guessed from the instance name. + property string service: Commands.DEFAULT_SERVICE + property string instance: "" + property string confdir: "" + property string description: "" + + readonly property string displayName: Model.accountName(instance, description) + // Waiting to be told what a control actually did. The coordinator gives these + // the next poll slot: without that the answer could be swallowed indefinitely + // by a neighbour's polling, and the bar would keep showing pre-control state. + readonly property bool settling: settleTimer.waiting + // The config directory the displayed sample actually came from, as the helper + // reported it -- not the one we believe this unit uses. + property string sampleConfdir: "" + + // Discovery can tell us this unit's real directory AFTER a poll has already + // reported under a different one. That sample is another account's; nothing in + // it may stay on screen. + onConfdirChanged: { + if (sampleConfdir !== "" && confdir !== "" && sampleConfdir !== confdir) forgetSample() + } + readonly property string resumeUnit: Commands.resumeUnit(instance) + // True once a status poll has produced a usable sample. Until then this + // account contributes no state to the aggregate, so default values cannot + // flash a wrong badge. + property bool initialized: false + // True once a poll has been ATTEMPTED, whatever its outcome. An account whose + // helper always fails is never `initialized`, so this is what "the first round + // is over" must be measured with -- otherwise one broken account either + // freezes the aggregate forever or defeats the startup notification hold. + property bool attempted: false + // Incremented whenever this account's identity changes. A status reply carries + // the generation it was started under, so a reply from the previous config + // directory is discarded instead of overwriting the new one's state. + property int generation: 0 + property int _pendingGeneration: 0 + + property var settings: ({}) + property var coordinator: null + + property bool installed: false + property bool serviceAvailable: false + property bool running: false + property bool enabled: false + property string activeState: "" + property bool serviceFailed: false + property bool resyncRequired: false + property bool authenticated: false + property bool reauthRequired: false + property bool syncing: false + property string syncStage: "" + property int _desired: -1 + // Real Quickshell sets `running` false WITHOUT ever emitting `exited` when the + // executable cannot be started -- a missing python3, a helper deleted under + // us, a systemd-run that is not on PATH. Every one of these processes cleans + // up only in onExited, and the coordinator's one-poll-at-a-time gate hangs off + // `refreshing`, so a single unstartable command froze routine polling for + // EVERY account for the life of the session. Each process therefore settles on + // whichever signal arrives first, and settles exactly once. + // Per-INVOCATION, not a shared boolean. A deferred settle callback outlives + // the process that scheduled it: start a quota check, queue a sync-status for + // the same account, let the quota finish normally, and the quota's deferred + // callback then found `_statusSettled` false again -- because the sync-status + // had started -- and abandoned a process that was running perfectly well. + // Each start takes a new token; a callback acts only on its own. + property int _statusRun: 0 + property int _statusSettledRun: 0 + property int _controlRun: 0 + property int _controlSettledRun: 0 + property int _cancelRun: 0 + property int _cancelSettledRun: 0 + property int _scheduleRun: 0 + property int _scheduleSettledRun: 0 + readonly property bool _statusSettled: _statusSettledRun === _statusRun + // Polls are numbered as they START, and the number of the poll whose sample was + // last APPLIED is kept. Counting completions was not enough: a poll started + // BEFORE the control could complete after it and be accepted as the + // confirmation, which undid the optimistic pause with pre-control data -- + // exactly the revert the settle loop exists to prevent. + property int _pollsStarted: 0 + property int _pendingPoll: 0 + property int _confirmedPoll: 0 + readonly property bool active: _desired === -1 + ? (running || activeState === "activating") : _desired === 1 + property bool refreshing: false + // Distinct from `refreshing`: only a ROUTINE poll occupies the coordinator's + // one-at-a-time slot. A 30s cloud check must not freeze every account's + // routine polling. + readonly property bool routinePolling: refreshing && _activeCloudMode === "" + // Any status process at all -- routine or cloud. An account in this state + // cannot accept a poll slot, so the scheduler must skip it rather than + // spending a tick on a refresh() that returns immediately. + readonly property bool statusBusy: statusProcess.running + property string statusText: "Checking…" + property string syncDir: "" + property string syncMode: "Two-way" + property string clientVersion: "" + property double resumeAt: 0 + property double lastSyncTs: 0 + property double usedBytes: 0 + property double quotaBytes: 0 + property bool quotaKnown: false + property double quotaCheckedTs: 0 + property string quotaError: "" + property string remoteStatus: "Not checked" + property double syncStatusCheckedTs: 0 + property string syncStatusError: "" + property double remoteCheckedTs: 0 + property string remoteError: "" + property var files: [] + property var activity: [] + property string actionStatus: "" + property string lastError: "" + + readonly property bool notificationsEnabled: { + var value = setting("notifications", true) + return value === true || String(value).toLowerCase() === "true" + } + readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 10, 3600) + readonly property int recentFileLimit: intSetting("recentFileLimit", 20, 5, 50) + readonly property string helperPath: Model.filePath(Qt.resolvedUrl("onedrive-status.py")) + readonly property bool busy: statusProcess.running || controlProcess.running + || cancelTimerProcess.running || scheduleTimerProcess.running + readonly property bool cloudChecking: _activeCloudMode !== "" && statusProcess.running + // A cloud check that is waiting for this account's routine poll to finish is + // already holding the shared slot, even though no process is running for it + // yet. Without this the coordinator saw "not busy" and started a second one. + readonly property bool cloudPending: _cloudRequested !== "" + // Which cloud mode this account occupies the shared slot with, running or + // merely deferred, so the coordinator can recognise a duplicate request. + readonly property string activeCloudMode: _activeCloudMode !== "" ? _activeCloudMode : _cloudRequested + readonly property bool quotaChecking: _activeCloudMode === "quota" && statusProcess.running + readonly property bool fullStatusChecking: _activeCloudMode === "sync-status" && statusProcess.running + + // Must match QUOTA_TIMEOUT_SECONDS / SYNC_STATUS_TIMEOUT_SECONDS in onedrive-status.py. + readonly property int cloudTimeoutSec: 30 + // How long a routine poll may run before it is abandoned, and how long the + // settle loop waits between asking for the result of a control. Both are + // settings rather than constants only so the test harness can drive them in + // milliseconds instead of minutes; nothing sets them in production. + readonly property int statusTimeoutMs: intSetting("statusTimeoutMs", + Math.max(60, cloudTimeoutSec * 2) * 1000, 200, 600000) + readonly property int settleIntervalMs: intSetting("settleIntervalMs", 1200, 20, 60000) + readonly property int cloudRetryAfterSec: 300 + + property string _cloudRequested: "" + property string _activeCloudMode: "" + property string _statusOutput: "" + property string _statusError: "" + property string _controlOutput: "" + property string _controlError: "" + property string _timerOutput: "" + property string _timerError: "" + property string _afterTimerCancel: "" + property int _pauseMinutes: 0 + property int _controlDesired: -1 + property bool _scheduleRecovery: false + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null ? fallback : value + } + + function intSetting(name, fallback, minimum, maximum) { + var value = parseInt(String(setting(name, fallback)), 10) + if (!isFinite(value)) value = fallback + return Math.max(minimum, Math.min(maximum, value)) + } + + // Drop everything derived from a previous config directory, keeping identity + // and in-flight processes. The next poll repopulates it. + // Discard whatever is in flight without touching what is on screen. + // + // The startup seed polls `onedrive.service` before discovery has told us its + // config directory, so that poll uses the CLIENT's default. If the unit's + // ExecStart names a different --confdir, the reply describes a different + // account: its sync directory, quota and token state would be applied under + // this one's name, and "Open folder" would open the wrong tree. Nothing is + // displayed yet at that point, so there is no sample worth forgetting -- only + // a reply worth refusing. + function discardInFlight() { + generation += 1 + } + + function forgetSample() { + // Any reply already in flight was started under the previous config + // directory; this makes onExited drop it. + generation += 1 + sampleConfdir = "" + initialized = false + // A new identity has not been polled yet, whatever the old one had done. + attempted = false + actionStatus = "" + // The displayed fields too: leaving these meant the panel showed the old + // account's status and "Open folder" opened the PREVIOUS account's sync + // directory -- the exact leak this function exists to prevent. + syncDir = "" + statusText = "Checking…" + syncStage = "" + syncMode = "Two-way" + clientVersion = "" + activeState = "" + installed = false + running = false + enabled = false + syncing = false + serviceAvailable = false + resumeAt = 0 + files = [] + activity = [] + quotaKnown = false + usedBytes = 0 + quotaBytes = 0 + quotaCheckedTs = 0 + quotaError = "" + remoteStatus = "Not checked" + syncStatusCheckedTs = 0 + syncStatusError = "" + remoteCheckedTs = 0 + remoteError = "" + lastError = "" + lastSyncTs = 0 + // Edge latches too: a condition that was true for the old directory must be + // able to notify again for the new one. + authenticated = false + serviceFailed = false + resyncRequired = false + reauthRequired = false + accountStateChanged() + } + + function refresh(remote) { + if (remote === true) { + checkQuota() + return + } + if (statusProcess.running || helperPath === "") return + startStatusProcess("") + } + + // Internal follow-up refreshes -- after a control command settles, or a + // delayed re-read -- go through the coordinator's shared slot like everything + // else. Calling refresh() directly from those timers started a second helper + // while another account was mid-poll. + function requestRefresh() { + if (coordinator && coordinator.routinePollRunning()) return + refresh(false) + } + + function checkQuota() { + requestCloud("quota") + } + + // Opening the panel is explicit user intent, so a failed storage result + // older than cloudRetryAfterSec is retried once on open. The decision is + // deferred until the next status poll returns, because at open() time the + // in-memory state may predate the poll the panel just started. + // quotaCheckedTs updates even on failure, which blocks another retry until + // the window passes. Verify sync is never retried automatically — it is + // the expensive full-drive check and stays strictly manual. + property bool _quotaRetryQueued: false + + function retryStaleQuotaOnOpen() { + _quotaRetryQueued = true + } + + function maybeRetryStaleQuota() { + if (quotaError === "" || quotaChecking) return + if (Date.now() / 1000 - quotaCheckedTs < cloudRetryAfterSec) return + checkQuota() + } + + function checkFullStatus() { + requestCloud("sync-status") + } + + // Cloud checks are slow and shared: the coordinator serialises them across + // every account so two 30s checks cannot run at once. Without a coordinator + // this account serves itself, which keeps Account usable on its own. + function requestCloud(mode) { + if (helperPath === "") return + if (coordinator) { + coordinator.requestCloud(root, mode) + return + } + startCloudCheck(mode) + } + + function startCloudCheck(mode) { + if (helperPath === "") return + if (statusProcess.running) { + _cloudRequested = mode + return + } + startStatusProcess(mode) + } + + function startStatusProcess(cloudMode) { + var command = Commands.status(helperPath, root, recentFileLimit, cloudMode) + if (command.length === 0) { + // This account cannot be described (a non-default service with no known + // config directory). Starting a Process on an empty command would never + // exit, so `refreshing` would stay true forever and the coordinator's + // one-poll-at-a-time gate would freeze EVERY account permanently. + attempted = true + lastError = "This account's configuration directory is unknown" + accountStateChanged() + pollFinished(root.service) + return + } + _activeCloudMode = cloudMode + _statusOutput = "" + _statusError = "" + _pendingGeneration = generation + _pollsStarted += 1 + _pendingPoll = _pollsStarted + _statusRun += 1 + statusWatchdog.restart() + refreshing = true + if (cloudMode !== "") { + actionStatusTimer.stop() + actionStatus = (cloudMode === "quota" ? "Refreshing storage" : "Verifying sync") + + "… may take up to " + String(cloudTimeoutSec) + "s" + } + statusProcess.command = command + statusProcess.running = true + } + + signal pollFinished(string service) + signal transition(var event) + // NOT named stateChanged: QQuickItem already has that as the NOTIFY signal for + // `state`, so declaring it is an invalid override -- Qt warns once per account + // per start and Item.state loses its change notification. qmllint does not + // catch it. The design doc named it stateChanged; this is a deliberate + // deviation. + signal accountStateChanged() + + // Transitions are reported, not delivered. The coordinator batches whatever + // arrives in one polling burst into at most one desktop notification, so three + // accounts going wrong together do not produce three popups. + function report(kind, summary, short, body, action, actionLabel) { + transition({ + service: root.service, + name: displayName, + kind: kind, + summary: summary, + short: short, + body: body, + action: action || "", + actionLabel: actionLabel || "" + }) + } + + function applyStatus(raw) { + var parsed = Model.parseStatus(raw) + if (!parsed.ok) { + lastError = parsed.lastError || "Failed to read OneDrive status" + return + } + // The helper reports which config directory it actually read. A reply from a + // different one describes a different account, and applying it would put + // that account's sync directory, quota, token state and file list under this + // account's name -- which is what the startup seed poll did whenever a unit + // overrode the client's default directory. + var reported = String(parsed.confdir || "") + if (reported !== "" && confdir !== "" && reported !== confdir) { + // Say so. A silent return leaves the account permanently on "Checking…" + // with nothing to explain it, and if the two directories ever disagree for + // a reason other than the startup race -- a normalisation difference, say + // -- that is a bug that must be visible rather than a mystery. + lastError = "OneDrive status came from " + reported + ", not " + confdir + return + } + var wasFailed = serviceFailed + var wasResync = resyncRequired + var wasReauth = reauthRequired + var hadAttention = serviceFailed || resyncRequired || reauthRequired + var wasStorageSevere = Model.usageSevere(usedBytes, quotaBytes, quotaKnown) + installed = parsed.installed === true + serviceAvailable = parsed.serviceAvailable === true + running = parsed.running === true + enabled = parsed.enabled === true + activeState = String(parsed.activeState || "") + serviceFailed = parsed.serviceFailed === true + resyncRequired = parsed.resyncRequired === true + authenticated = parsed.authenticated === true + reauthRequired = parsed.reauthRequired === true + syncing = parsed.syncing === true + syncStage = String(parsed.syncStage || "") + _confirmedPoll = _pendingPoll + sampleConfdir = reported + if (_desired !== -1 && running === (_desired === 1)) _desired = -1 + statusText = String(parsed.statusText || (installed ? "Sync paused" : "Not installed")) + syncDir = String(parsed.syncDir || "") + syncMode = String(parsed.syncMode || "Two-way") + clientVersion = String(parsed.clientVersion || "") + resumeAt = Number(parsed.resumeAt || 0) + lastSyncTs = Number(parsed.lastSyncTs || 0) + usedBytes = Number(parsed.usedBytes || 0) + quotaBytes = Number(parsed.quotaBytes || 0) + quotaKnown = parsed.quotaKnown === true + quotaCheckedTs = Number(parsed.quotaCheckedTs || 0) + quotaError = String(parsed.quotaError || "") + remoteStatus = String(parsed.remoteStatus || "Not checked") + syncStatusCheckedTs = Number(parsed.syncStatusCheckedTs || 0) + syncStatusError = String(parsed.syncStatusError || "") + remoteCheckedTs = Number(parsed.remoteCheckedTs || 0) + remoteError = String(parsed.remoteError || "") + files = parsed.files || [] + activity = parsed.activity || [] + lastError = String(parsed.lastError || "") + // Last, and only once the whole snapshot is applied: this is the gate that + // lets an account contribute to the aggregate, and opening it early would + // publish default values as if they were a reading. + initialized = true + accountStateChanged() + + if (resyncRequired && !wasResync) + report("resync", "OneDrive needs a resync", "Resync required", + "Syncing stopped until the resync repair runs.", + "repair", "Run resync repair") + else if (serviceFailed && !wasFailed) + report("failed", "OneDrive sync failed", "Sync failed", + lastError !== "" ? lastError : "The OneDrive service entered a failed state.", + "open", "Open OneDrive panel") + if (reauthRequired && !wasReauth) + report("reauth", "OneDrive needs reauthentication", "Reauthentication required", + "Sign in again to keep syncing.", + "open", "Open OneDrive panel") + // Recovery is only meaningful for an account that was seen unhealthy first. + if (hadAttention && !serviceFailed && !resyncRequired && !reauthRequired) + report("recovered", "OneDrive recovered", "Recovered", "Syncing is healthy again.") + if (!wasStorageSevere && Model.usageSevere(usedBytes, quotaBytes, quotaKnown)) + report("storage", "OneDrive storage almost full", "Almost full", + Model.freeText(usedBytes, quotaBytes, quotaKnown) + " of " + + Model.formatBytes(quotaBytes) + " remains.") + } + + // Reached when the status process stopped without an exit -- it could not be + // started, or the watchdog gave up on it. Everything onExited would have + // released has to be released here too, or the account keeps the coordinator's + // poll slot and no other account is ever polled again. + function abandonStatus(run, reason) { + // Not this invocation any more: the process was restarted before this + // deferred callback ran, and settling now would abandon a live poll. + if (run !== _statusRun || _statusSettledRun === run) return + _statusSettledRun = run + statusWatchdog.stop() + refreshing = false + if (_pendingGeneration === generation) { + attempted = true + lastError = reason + } + _activeCloudMode = "" + _cloudRequested = "" + _quotaRetryQueued = false + accountStateChanged() + pollFinished(root.service) + } + + // In Model.js so it is testable; kept as a method because four handlers and + // two bindings call it. + function elideStatus(text) { return Model.elideStatus(text) } + + function login() { + if (!installed) return + Quickshell.execDetached(Commands.login(confdir)) + actionStatus = "Opened OneDrive login" + actionStatusTimer.restart() + } + + function reauthenticate() { + if (!installed || running) return + Quickshell.execDetached(Commands.login(confdir, "reauth")) + actionStatus = "Opened OneDrive reauthentication" + actionStatusTimer.restart() + } + + function repairResync() { + if (!installed || running || busy) return + Quickshell.execDetached(Commands.login(confdir, "resync")) + actionStatus = "Opened OneDrive resync repair" + actionStatusTimer.restart() + } + + function openWeb() { + Quickshell.execDetached(["uwsm-app", "--", "xdg-open", "https://onedrive.live.com/"]) + } + + function pause() { + if (busy) return + _pauseMinutes = 0 + cancelResumeTimer("pause") + } + + function pauseFor(minutes) { + var requested = parseInt(String(minutes), 10) + if (!isFinite(requested) || requested <= 0) return + var duration = Math.max(5, Math.min(1440, requested)) + if (!installed || !serviceAvailable || !authenticated || busy + || serviceFailed || resyncRequired || reauthRequired) return + // No derivable resume unit means no timer can be scheduled for this account. + // An untimed pause is honest; a timer that collides with another account is + // not. Say so, or the user gets an indefinite pause from a button labelled + // "4 hours". + if (resumeUnit === "") { + actionStatus = "Paused — no resume timer is available for this account" + actionStatusTimer.restart() + pause() + return + } + _pauseMinutes = duration + cancelResumeTimer("pause") + } + + function resume() { + if (!authenticated) { + login() + return + } + if (busy) return + _pauseMinutes = 0 + cancelResumeTimer("resume") + } + + function toggleRunning() { + if (active) pause() + else resume() + } + + function runControl(command, desired) { + if (!installed || !serviceAvailable || controlProcess.running) return + _desired = desired + _controlDesired = desired + _controlOutput = "" + _controlError = "" + controlProcess.command = command + _controlRun += 1 + controlWatchdog.restart() + controlProcess.running = true + } + + function cancelResumeTimer(afterAction) { + if (resumeUnit === "") { + // Nothing to cancel, and "systemctl stop .timer .service" is not a command + // worth sending. Continue straight to the action the cancel precedes. + _afterTimerCancel = afterAction + Qt.callLater(function() { root.afterResumeTimerCancelled() }) + return + } + _afterTimerCancel = afterAction + _timerOutput = "" + _timerError = "" + cancelTimerProcess.command = Commands.cancelResume(resumeUnit) + _cancelRun += 1 + cancelWatchdog.restart() + cancelTimerProcess.running = true + } + + function settleResumeTimerCancel(run) { + if (run !== _cancelRun || _cancelSettledRun === run) return + _cancelSettledRun = run + cancelWatchdog.stop() + afterResumeTimerCancelled() + } + + // Shared by the cancel process and by the no-timer path, which has nothing to + // cancel but must still perform the action the cancel precedes. + function afterResumeTimerCancelled() { + var action = _afterTimerCancel + _afterTimerCancel = "" + resumeAt = 0 + if (action === "resume") { + runControl(Commands.control("start", root.service), 1) + } else if (action === "pause") { + if (running || active || activeState === "activating") { + runControl(Commands.control("stop", root.service), 0) + } else if (_pauseMinutes > 0) { + var minutes = _pauseMinutes + _pauseMinutes = 0 + scheduleResume(minutes) + } else { + requestRefresh() + } + } + } + + function scheduleResume(minutes) { + _timerOutput = "" + _timerError = "" + scheduleTimerProcess.command = Commands.scheduleResume(resumeUnit, service, minutes) + _scheduleRun += 1 + scheduleWatchdog.restart() + scheduleTimerProcess.running = true + } + + function openFolder() { + if (syncDir !== "") Quickshell.execDetached(["uwsm-app", "--", "xdg-open", syncDir]) + } + + function openFile(file) { + if (!file || !file.path) return + Quickshell.execDetached(["uwsm-app", "--", "nautilus", "--select", fileUri(String(file.path))]) + } + + function fileUri(path) { + var parts = String(path || "").split("/") + for (var index = 0; index < parts.length; index++) parts[index] = encodeURIComponent(parts[index]) + return "file://" + parts.join("/") + } + + // No repeating poll timer here: the coordinator owns cadence, so N accounts + // share one budget instead of each polling every refreshIntervalSec. The + // timers that remain are per-account control flow -- settling after a control + // command, and clearing transient action text. + + Timer { + id: delayedRefresh + interval: 750 + repeat: false + onTriggered: root.requestRefresh() + } + + // After a control succeeds, keep asking until a poll taken AFTER it lands. + // + // This used to give up after five ticks and clear the optimistic state + // unconditionally. `requestRefresh` is drop-not-queue, so with two or three + // accounts a neighbour holding the shared poll slot swallowed every one of + // those five asks -- and six seconds after the user paused Work, the bar + // reverted to "Monitoring" from a sample that predated the pause, while the + // unit was really stopped. Reverting to known-stale data is strictly worse + // than holding the user's intent, so the intent is now held until fresh truth + // arrives (`applyStatus` clears it the moment reality agrees) or the account + // gives up asking entirely. + Timer { + id: settleTimer + property int ticks: 0 + property int baseline: 0 + // Whether this account is waiting to be told what a control actually did. + // The coordinator gives it the next poll slot, so the answer arrives in one + // round rather than never. + readonly property bool waiting: running && root._confirmedPoll <= baseline + interval: root.settleIntervalMs + repeat: true + onTriggered: { + ticks += 1 + root.requestRefresh() + if (root._confirmedPoll > baseline) { + // A poll taken after the control has landed and applyStatus has had its + // say. Anything still optimistic now is a genuine divergence. + ticks = 0 + stop() + root._desired = -1 + } else if (ticks >= 30) { + // ~36s of a completely blocked slot. Stop asking, but leave the intent + // alone: the next successful poll clears it through applyStatus. + ticks = 0 + stop() + } + } + } + + // A helper that never exits -- a wedged python3, an os.walk over a stalled + // network mount -- held the single poll slot forever, and with it every other + // account's polling. Nothing else in the stack bounds this: the helper's own + // 30s limit covers only its outbound CLI calls, not its local directory scan. + // The control processes need the same bound the poll has. `pause()` refuses + // while `busy`, and `busy` is true for as long as one of these runs -- so a + // `systemctl --user stop` that never exits (the user bus not yet back after a + // suspend is the realistic way) left that account's Pause and Resume dead for + // the rest of the session, recoverable only by restarting the bar. + Timer { + id: controlWatchdog + interval: root.statusTimeoutMs + repeat: false + onTriggered: { + if (root._controlSettledRun === root._controlRun) return + var run = root._controlRun + controlProcess.running = false + root.settleControl(run, 124) + } + } + + Timer { + id: cancelWatchdog + interval: root.statusTimeoutMs + repeat: false + onTriggered: { + if (root._cancelSettledRun === root._cancelRun) return + var run = root._cancelRun + cancelTimerProcess.running = false + // The action the cancel precedes still goes ahead: an untimed pause is a + // worse outcome than a stranded timer, but silently doing nothing at all + // is worse than both. + root.settleResumeTimerCancel(run) + } + } + + Timer { + id: scheduleWatchdog + interval: root.statusTimeoutMs + repeat: false + onTriggered: { + if (root._scheduleSettledRun === root._scheduleRun) return + var run = root._scheduleRun + scheduleTimerProcess.running = false + root.settleResumeSchedule(run, 124) + } + } + + Timer { + id: statusWatchdog + interval: root.statusTimeoutMs + repeat: false + onTriggered: { + if (root._statusSettled) return + // Assigning false terminates it in real Quickshell; the exit that follows + // finds the process already settled and is ignored. + var run = root._statusRun + statusProcess.running = false + root.abandonStatus(run, "OneDrive status check timed out") + } + } + + Timer { + id: actionStatusTimer + interval: 2500 + repeat: false + onTriggered: root.actionStatus = "" + } + + Process { + id: statusProcess + running: false + command: [] + stdout: StdioCollector { + id: statusStdout + waitForEnd: true + onStreamFinished: root._statusOutput = text + } + stderr: StdioCollector { + id: statusStderr + waitForEnd: true + onStreamFinished: root._statusError = text + } + onRunningChanged: { + // Ordering between `exited` and `running` is not ours to rely on, so defer: + // by the time this runs, a real exit has already settled the process and + // this is a no-op. Only a failure to start reaches abandonStatus. + if (!running) { + var run = root._statusRun + Qt.callLater(function() { + root.abandonStatus(run, "Could not run the OneDrive status helper") + }) + } + } + onExited: function(exitCode) { + if (root._statusSettledRun === root._statusRun) return + root._statusSettledRun = root._statusRun + statusWatchdog.stop() + var cloudMode = root._activeCloudMode + root.refreshing = false + // Only a reply for the CURRENT identity counts as an attempt; a discarded + // one would tell the ramp this account had been sampled when it has not. + if (root._pendingGeneration === root.generation) root.attempted = true + if (root._pendingGeneration !== root.generation) { + // Started under a previous config directory. Applying it would restore + // that directory's syncDir, quota and edge latches over the new + // account's. Everything the normal path clears must still be cleared, or + // a pending cloud request keeps holding the global semaphore until the + // next routine poll. + root._activeCloudMode = "" + root._cloudRequested = "" + root._quotaRetryQueued = false + root.pollFinished(root.service) + return + } + var stdout = String(statusStdout.text || root._statusOutput || "") + var stderr = String(statusStderr.text || root._statusError || "") + if (exitCode === 0) root.applyStatus(stdout) + else root.lastError = root.elideStatus(stderr || stdout || "Could not read OneDrive status") + if (cloudMode !== "") { + if (exitCode !== 0) root.actionStatus = root.lastError + else if (cloudMode === "quota") + root.actionStatus = root.quotaError === "" ? "Storage refreshed" : root.quotaError + else root.actionStatus = root.syncStatusError === "" + ? "Sync verified" : root.syncStatusError + actionStatusTimer.restart() + } + root._activeCloudMode = "" + if (root._cloudRequested !== "") { + var requested = root._cloudRequested + root._cloudRequested = "" + // Back through the coordinator, not straight into startCloudCheck: + // going direct released the shared slot and then took it again without + // asking, which let a second account start its own check in between. + Qt.callLater(function() { root.requestCloud(requested) }) + } + if (root._quotaRetryQueued) { + root._quotaRetryQueued = false + Qt.callLater(function() { root.maybeRetryStaleQuota() }) + } + root.pollFinished(root.service) + } + } + + Process { + id: cancelTimerProcess + running: false + command: [] + stdout: StdioCollector { waitForEnd: true } + stderr: StdioCollector { waitForEnd: true } + // A cancel that cannot even start must still let the action it precedes + // through, or pause and resume simply do nothing from then on. + onRunningChanged: { + if (!running) { + var run = root._cancelRun + Qt.callLater(function() { root.settleResumeTimerCancel(run) }) + } + } + onExited: function(exitCode) { root.settleResumeTimerCancel(root._cancelRun) } + } + + function settleResumeSchedule(run, exitCode) { + if (run !== _scheduleRun || _scheduleSettledRun === run) return + _scheduleSettledRun = run + scheduleWatchdog.stop() + var stdout = String(timerStdout.text || _timerOutput || "") + var stderr = String(timerStderr.text || _timerError || "") + if (exitCode !== 0) { + lastError = elideStatus(stderr || stdout || "Could not schedule OneDrive resume") + actionStatus = "Timed pause failed; resuming syncing…" + _scheduleRecovery = true + // Recovery starts the SAME service the pause stopped. + runControl(Commands.control("start", root.service), 1) + } else { + lastError = "" + actionStatus = "Timed pause scheduled" + actionStatusTimer.restart() + requestRefresh() + } + } + + Process { + id: scheduleTimerProcess + running: false + command: [] + stdout: StdioCollector { + id: timerStdout + waitForEnd: true + onStreamFinished: root._timerOutput = text + } + stderr: StdioCollector { + id: timerStderr + waitForEnd: true + onStreamFinished: root._timerError = text + } + // systemd-run failing to START is the same outcome for the user as it + // failing: the account is already stopped and nothing will resume it. Take + // the recovery path rather than leaving it paused indefinitely. + onRunningChanged: { + if (!running) { + var run = root._scheduleRun + Qt.callLater(function() { root.settleResumeSchedule(run, 127) }) + } + } + onExited: function(exitCode) { root.settleResumeSchedule(root._scheduleRun, exitCode) } + } + + function settleControl(run, exitCode) { + if (run !== _controlRun || _controlSettledRun === run) return + _controlSettledRun = run + controlWatchdog.stop() + var desired = root._controlDesired + root._controlDesired = -1 + var stdout = String(controlStdout.text || root._controlOutput || "") + var stderr = String(controlStderr.text || root._controlError || "") + if (exitCode !== 0) { + root._desired = -1 + root._pauseMinutes = 0 + root._scheduleRecovery = false + root.lastError = root.elideStatus(stderr || stdout || "OneDrive service command failed") + } else { + root.lastError = "" + settleTimer.ticks = 0 + // Every poll started from here on outranks this number; one started + // before the control does not, however late it comes back. + settleTimer.baseline = root._pollsStarted + settleTimer.start() + if (desired === 0 && root._pauseMinutes > 0) { + var minutes = root._pauseMinutes + root._pauseMinutes = 0 + root.scheduleResume(minutes) + } else if (root._scheduleRecovery) { + root._scheduleRecovery = false + root.actionStatus = "Timed pause failed; syncing resumed" + actionStatusTimer.restart() + } + } + delayedRefresh.restart() + } + + Process { + id: controlProcess + running: false + command: [] + stdout: StdioCollector { + id: controlStdout + waitForEnd: true + onStreamFinished: root._controlOutput = text + } + stderr: StdioCollector { + id: controlStderr + waitForEnd: true + onStreamFinished: root._controlError = text + } + // A control that could not start leaves the unit exactly as it was, so the + // optimistic state has to be dropped -- otherwise the bar claims a pause + // that never happened. + onRunningChanged: { + if (!running) { + var run = root._controlRun + Qt.callLater(function() { root.settleControl(run, 127) }) + } + } + onExited: function(exitCode) { root.settleControl(root._controlRun, exitCode) } + } +} diff --git a/BarWidget.qml b/BarWidget.qml index 92cef5f..69c6165 100644 --- a/BarWidget.qml +++ b/BarWidget.qml @@ -13,33 +13,35 @@ BarWidget { property bool ipcRegistrationReady: false readonly property var service: panelLoader.item ? panelLoader.item.service : null - readonly property bool active: service ? service.active : false - readonly property bool syncing: service ? service.syncing : false - readonly property bool starting: service ? service.activeState === "activating" : false - readonly property bool installed: service ? service.installed : false - readonly property bool authenticated: service ? service.authenticated : false - readonly property bool attention: service - ? service.serviceFailed || service.resyncRequired || service.reauthRequired - : false + + // Worst of N. With one account this resolves to exactly the state the old + // flat ternary produced; the mapping and the precedence are table-tested in + // tests/Aggregate.test.js rather than spelled out here. + readonly property var aggregate: service + ? service.aggregate + : ({ kind: "checking", count: 0, anyActive: false, initialized: false }) + readonly property int accountCount: service ? service.accountCount : 0 + + // Lit/dim/spinning is decided in Model.js, not here: nothing in this file can + // be instantiated by a test, so an expression written inline is one no + // assertion can reach. tests/Aggregate.test.js pins the rules. + readonly property var barState: Model.barState(aggregate) + readonly property bool active: barState.active + readonly property bool syncing: barState.syncing + readonly property bool installed: barState.installed readonly property color iconColor: active ? (bar ? bar.barForeground : Color.foreground) : Qt.darker(bar ? bar.barForeground : Color.foreground, 1.55) - readonly property string badgeKind: !installed ? "missing" - : (!authenticated ? "login" - : (attention ? "attention" - : (syncing || starting ? "syncing" - : (!active ? "paused" : "")))) - readonly property string badgeGlyph: badgeKind === "missing" ? "󰅖" - : (badgeKind === "login" ? "󰌋" - : (badgeKind === "paused" ? "󰏤" - : (badgeKind === "syncing" ? "󰑓" - : (badgeKind === "attention" ? "󰀪" : "")))) + // The worst account's kind, pause included: every account has to be working + // before the bar looks normal. See the note in Model.aggregateAccounts. + readonly property string badgeKind: Model.badgeKind(aggregate.kind) + readonly property string badgeGlyph: Model.badgeGlyph(badgeKind) readonly property color badgeColor: badgeKind === "login" || badgeKind === "attention" ? (bar ? bar.urgent : Color.urgent) : (badgeKind === "syncing" ? Color.accent : iconColor) readonly property color badgeBackground: bar ? bar.background : Color.background readonly property string tooltipText: service - ? Model.tooltip(service, Date.now()) + ? Model.aggregateTooltip(service.accounts, Date.now()) : "Checking OneDrive…" readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false @@ -135,6 +137,23 @@ BarWidget { return "ok" } function status(): string { return root.service ? root.service.statusText : "Checking…" } + // Enumerate and select, so automation can reach a non-default account before + // invoking any of the controls above -- which all act on the selected one. + // Both bodies live in Model.js: this file cannot be instantiated headless, + // so anything inline here is untestable. + function accounts(): string { + if (!root.service) return "[]" + return JSON.stringify(Model.accountRows(root.service.accounts, root.service.selectedService)) + } + function selectAccount(target: string): string { + if (!root.service) return "no accounts" + var found = Model.resolveAccountTarget(root.service.accounts, target) + if (found === "") return "unknown account: " + target + // false: automation selecting an account merely to target a control must + // not trigger the panel's stale-quota retry, which contacts Microsoft. + root.service.selectAccount(found, false) + return "ok" + } } BarIconButton { @@ -184,7 +203,13 @@ BarWidget { } } } + // Every gesture points at the account the badge is about before acting. + // Only left-click did, so middle-click opened the folder of whichever + // account happened to be selected -- at cold boot, the first discovered one + // -- while the badge was about another, and right-click spent the single + // 30-second cloud slot on the wrong account. onPressed: function(buttonCode) { + if (root.service) root.service.selectBadgedAccount() if (buttonCode === Qt.RightButton && root.service) root.service.checkQuota() else if (buttonCode === Qt.MiddleButton && root.service) root.service.openFolder() else root.togglePanel() diff --git a/Commands.js b/Commands.js new file mode 100644 index 0000000..3078631 --- /dev/null +++ b/Commands.js @@ -0,0 +1,135 @@ +// Command vectors for one OneDrive account. +// +// Every function returns an argv array. Nothing here is ever passed through a +// shell, and no value is interpolated into a string that becomes a command -- +// that is the invariant `docs/ARCHITECTURE.md` states and that +// `tests/Commands.test.js` pins with exact-array assertions. +// +// Each account is identified by its systemd service, its config directory, and +// the instance parsed out of the service name. Nothing is derived from a naming +// convention: the confdir comes from the helper's discovery, which reads it out +// of each unit's own ExecStart. + +var DEFAULT_SERVICE = "onedrive.service" +var DEFAULT_RESUME_UNIT = "omaonedrive-resume" + +// The plain service deliberately keeps the legacy unit name, so a timer that is +// already in flight survives an upgrade and stays visible and cancellable. +// +// Returns "" when no usable resume unit can be derived. The caller degrades to +// an untimed pause rather than scheduling a timer that would collide with +// another account's or that systemd would refuse. +function resumeUnit(instance) { + var value = String(instance || "") + if (value === "") return DEFAULT_RESUME_UNIT + // systemd-run reads a --unit value ending in a unit suffix as THAT unit, so + // instance "foo.timer" would derive the same timer as instance "foo" -- and + // cancelling it would target "…foo.timer.timer", which does not exist, + // stranding the other account's pause. + if (value.endsWith(".timer") || value.endsWith(".service")) return "" + var unit = DEFAULT_RESUME_UNIT + "@" + value + // systemd-run creates a .timer AND a .service, so the longer suffix is what + // has to fit. Checking only .timer let a 230-character instance stop the + // account and then fail to schedule its resume. + if (unit.length + ".service".length > 255) return "" + return unit +} + +// The status call is account-complete: every invocation names the service, the +// config directory and the resume unit, so no part of the answer can be about a +// different account. `mode` is "" for the routine local poll, or "quota" / +// "sync-status" for an explicit cloud check. +function status(helperPath, account, recentFileLimit, mode) { + var command = ["python3", String(helperPath)] + // An empty value means "unspecified", not "empty string": the helper's own + // defaults are the single-account behaviour, so before discovery has supplied + // an identity this produces exactly the command the widget sends today. + var service = String(account.service || "") + var confdir = String(account.confdir || "") + var instance = String(account.instance || "") + if (service !== "" && service !== DEFAULT_SERVICE) { + command.push("--service", service) + } + if (confdir !== "") command.push("--confdir", confdir) + // A non-default account with no confdir must not be polled at all. The helper + // independently re-derives the confdir in that case, so this is belt-and- + // braces -- but relying on that means the widget silently reports the DEFAULT + // account's token, quota and files under this account's name if the helper + // guard is ever relaxed. Refuse instead, and let the caller show nothing. + else if (service !== "" && service !== DEFAULT_SERVICE) return [] + var unit = instance === "" ? "" : resumeUnit(instance) + if (unit !== "") command.push("--resume-unit", unit) + command.push("--limit", String(recentFileLimit)) + if (mode === "quota") command.push("--quota") + else if (mode === "sync-status") command.push("--sync-status") + return command +} + +function listAccounts(helperPath) { + return ["python3", String(helperPath), "--list-accounts"] +} + +function control(action, service) { + return ["systemctl", "--user", String(action), String(service)] +} + +// The interactive CLI flows are the only place --resync may appear, and only +// through omarchy-launch-terminal, where the client prompts for confirmation. +function login(confdir, mode) { + var command = ["omarchy-launch-terminal", "onedrive"] + // Same rule as status(): an unspecified confdir means the client's own + // default, not an empty string. Passing "--confdir ''" would hand the CLI a + // value it must reject, and this path is reachable before discovery has + // supplied an identity. + if (String(confdir || "") !== "") command.push("--confdir", String(confdir)) + if (mode === "reauth") command.push("--reauth") + else if (mode === "resync") { + command.push("--sync") + command.push("--resync") + } + return command +} + +// Cancel only this account's timer and service; another account's pause is +// untouched. +function cancelResume(unit) { + return ["systemctl", "--user", "stop", unit + ".timer", unit + ".service"] +} + +// On expiry the timer starts the SAME service the pause stopped. +function scheduleResume(unit, service, minutes) { + return [ + "systemd-run", "--user", + "--unit=" + unit, + "--description=Resume OneDrive after timed pause", + "--on-active=" + String(minutes) + "m", + "--timer-property=AccuracySec=1s", + "--collect", + "/usr/bin/systemctl", "--user", "start", String(service) + ] +} + +function notify(urgency, summary, body, action) { + var command = ["notify-send", "--app-name=OmaOneDrive", "--urgency=" + String(urgency)] + if (action) { + command.push("--action=" + String(action.id) + "=" + String(action.label)) + } + command.push(String(summary)) + command.push(String(body)) + return command +} + +if (typeof module !== "undefined") { + module.exports = { + DEFAULT_SERVICE: DEFAULT_SERVICE, + DEFAULT_RESUME_UNIT: DEFAULT_RESUME_UNIT, + resumeUnit: resumeUnit, + status: status, + listAccounts: listAccounts, + control: control, + login: login, + cancelResume: cancelResume, + scheduleResume: scheduleResume, + notify: notify + } +} diff --git a/Model.js b/Model.js index 26eead5..2872d30 100644 --- a/Model.js +++ b/Model.js @@ -243,6 +243,603 @@ function heroMeta(status) { return parts.join(" · ") } +// --- multi-account aggregation ------------------------------------------------ + +// "personal" -> "Personal", "work-mail" -> "Work Mail". The plain service has no +// instance and is simply "OneDrive", which is also the hero title, so a +// single-account install reads exactly as it does today. The description is +// accepted for callers that want it but is not used: systemd descriptions are +// sentences ("OneDrive sync (personal account)"), not labels for a tab. +function accountName(instance, description) { + var value = String(instance || "").trim() + if (value === "") return "OneDrive" + var words = value.replace(/[_-]+/g, " ").split(" ") + var named = [] + for (var index = 0; index < words.length; index++) { + var word = words[index] + if (word === "") continue + named.push(word.charAt(0).toUpperCase() + word.slice(1)) + } + return named.length ? named.join(" ") : value +} + +// One total order, worst first. Ranked rather than named-compared so the bar can +// pick a winner without knowing what any particular state means. +// +// resync is deliberately checked before failed: a required resync exits 126, +// which sets serviceFailed too, and "Resync required" is the actionable half. +var ACCOUNT_STATES = [ + { kind: "resync", rank: 1 }, + { kind: "reauth", rank: 2 }, + { kind: "failed", rank: 3 }, + { kind: "missing", rank: 4 }, + { kind: "login", rank: 5 }, + { kind: "unavailable", rank: 6 }, + { kind: "paused", rank: 7 }, + { kind: "starting", rank: 8 }, + { kind: "syncing", rank: 9 }, + { kind: "healthy", rank: 10 } +] + +function accountStateKind(account) { + if (!account || typeof account !== "object") return "missing" + if (account.resyncRequired === true) return "resync" + if (account.reauthRequired === true) return "reauth" + if (account.serviceFailed === true) return "failed" + if (account.installed !== true) return "missing" + if (account.authenticated !== true) return "login" + if (account.serviceAvailable !== true) return "unavailable" + if (String(account.activeState || "") === "activating") return "starting" + // `active` folds in the optimistic desired state, which is what made the old + // bar respond to Pause and Resume immediately instead of a poll later. Fall + // back to `running` for plain objects that have no `active`. + var isActive = account.active !== undefined ? account.active === true : account.running === true + if (!isActive) return "paused" + // ...and a syncing flag left over from before a pause must not outrank it. + if (account.syncing === true) return "syncing" + return "healthy" +} + +function accountState(account) { + var kind = accountStateKind(account) + for (var index = 0; index < ACCOUNT_STATES.length; index++) { + if (ACCOUNT_STATES[index].kind === kind) return ACCOUNT_STATES[index] + } + return ACCOUNT_STATES[ACCOUNT_STATES.length - 1] +} + +// Worst of N. Until every discovered account has produced a first sample the +// aggregate is "checking" with no badge, so default property values cannot flash +// a missing-client badge before the first poll lands. +function aggregateAccounts(accounts) { + var list = Array.isArray(accounts) ? accounts : [] + if (list.length === 0) { + return { kind: "checking", rank: 0, count: 0, worst: null, anyActive: false, initialized: false } + } + // Accounts that have not reported yet are EXCLUDED rather than gating the + // whole aggregate. Excluding them already prevents default property values + // from flashing a missing-client badge, which is the reason the design gives + // for the checking state -- while gating on all of them meant a single + // permanently-failing account (its confdir unreadable, say) froze the bar at + // "checking" forever, hiding a resync-required account behind it. + var anyInitialized = false + var worst = null + var worstRank = Number.MAX_VALUE + var anyActive = false + for (var index = 0; index < list.length; index++) { + var account = list[index] + if (!account || account.initialized !== true) continue + anyInitialized = true + var state = accountState(account) + // Strictly less-than, so equal ranks keep discovery order. + if (state.rank < worstRank) { + worstRank = state.rank + worst = account + } + // account.active already folds in the optimistic _desired state, so a just- + // pressed Pause dims the icon immediately instead of waiting for a poll. + // Fall back to the raw fields for plain objects that have no `active`. + var stated = account.active + var isActive = stated !== undefined + ? stated === true + : (account.running === true || String(account.activeState || "") === "activating") + // A transfer keeps the icon lit even between "running" samples -- but NOT + // for an account the user has just paused. The helper only ever sets + // `syncing` alongside `running`, so the leftover flag from the sample before + // the pause was the one thing this clause could still light, and the icon + // stayed bright until a confirming poll landed. Pausing during an upload + // looked like it had not taken. + if (isActive || (account.syncing === true && stated !== false)) anyActive = true + } + if (!anyInitialized || worst === null) { + return { + kind: "checking", rank: 0, count: list.length, + worst: null, anyActive: anyActive, initialized: false + } + } + var kind = accountStateKind(worst) + return { + // Worst-first, INCLUDING a pause. A reviewer argued that progress should + // outrank a deliberate pause on the badge, and for one round it did; the + // user overruled it: a paused account anywhere is a state you must be shown, + // and every account has to be working before the bar looks normal. The + // badge, the tooltip and the aggregate therefore all answer with the same + // worst account. + kind: kind, + rank: worstRank, + count: list.length, + worst: worst, + anyActive: anyActive, + initialized: true + } +} + +// N=1 keeps exactly today's one-line tooltip. N>1 is attributed and ordered +// worst first, then discovery order, capped so a large installation cannot grow +// an unbounded tooltip. +function aggregateTooltip(accounts, nowMs, maxLines) { + var list = Array.isArray(accounts) ? accounts : [] + if (list.length === 0) return "Checking OneDrive…" + if (list.length === 1) { + var only = list[0] + // An account that has not reported still carries its default values, and the + // default `installed: false` renders as "OneDrive CLI is not installed" -- + // which sends the user looking for a missing package when what actually + // happened is that the status helper could not be run, or timed out. Say + // that instead, and only guess at the client when a poll has told us. + if (!only || only.initialized !== true) { + var why = only && only.attempted === true ? String(only.lastError || "") : "" + return why === "" ? "Checking OneDrive…" : "OneDrive status unavailable\n" + why + } + return tooltip(only, nowMs) + } + // An account that has been polled and still cannot report is not "checking": + // its helper failed, or timed out, and it will keep failing. Saying so is the + // only way the user learns which account is broken and why -- and when the + // helper is missing outright, EVERY account is in this state at once, so this + // is the whole tooltip, not a footnote. + var broken = list.filter(function (account) { + return account && account.initialized !== true && account.attempted === true + && String(account.lastError || "") !== "" + }) + var summary = aggregateAccounts(list) + if (!summary.initialized) { + if (broken.length === 0) return "Checking " + list.length + " OneDrive accounts…" + var stalled = ["OneDrive status unavailable"] + for (var b = 0; b < broken.length; b++) { + stalled.push(accountName(broken[b].instance, broken[b].description) + + ": " + String(broken[b].lastError)) + } + if (broken.length < list.length) { + stalled.push("Checking " + (list.length - broken.length) + " more…") + } + return stalled.join("\n") + } + + // Only accounts that have reported. An un-polled account still carries default + // values, which classify as "missing" and would sort to the TOP of a + // worst-first list -- so the tooltip would announce a missing client while the + // badge, which already excludes them, showed nothing. + var reported = list.filter(function (account) { + return account && account.initialized === true + }) + var ordered = reported.map(function (account, index) { + return { account: account, index: index, rank: accountState(account).rank } + }) + ordered.sort(function (left, right) { + return left.rank === right.rank ? left.index - right.index : left.rank - right.rank + }) + + var cap = maxLines === undefined ? 5 : maxLines + var lines = ["OneDrive · " + list.length + " accounts"] + // A broken account listed among the healthy ones, so a permanently failing + // helper is visible next to the accounts that ARE working rather than being + // folded into a "checking" count that never goes down. + for (var f = 0; f < broken.length; f++) { + lines.push(accountName(broken[f].instance, broken[f].description) + + ": " + String(broken[f].lastError)) + } + var stillChecking = list.length - reported.length - broken.length + if (stillChecking > 0) lines.push("Checking " + stillChecking + " more…") + for (var index = 0; index < ordered.length && index < cap; index++) { + var account = ordered[index].account + lines.push(accountName(account.instance, account.description) + ": " + tooltip(account, nowMs)) + } + if (ordered.length > cap) lines.push("+" + (ordered.length - cap) + " more") + return lines.join("\n") +} + +// Map an aggregate state onto the bar's existing badge vocabulary. The visual +// language does not grow with the state list: several states share a badge and +// the tooltip distinguishes them, because at eight pixels a badge can only +// carry "something is wrong", "signed out", "paused" or "working". +// What the bar icon does with an aggregate: lit or dim, spinning or not. +// +// This lived as three expressions in BarWidget.qml, which derives from the bar's +// own BarWidget type and so cannot be instantiated by any harness. A reviewer +// demonstrated the cost: the old broken `installed` expression -- the one that +// dimmed a bar with a healthy account syncing because some OTHER account was +// missing -- could be restored there with the entire suite still green. +function barState(aggregate) { + var summary = aggregate && typeof aggregate === "object" ? aggregate : {} + var kind = String(summary.kind || "") + // Lit while ANY account is working, so one paused account does not dim a bar + // that is still syncing two others. + var anyActive = summary.anyActive === true + return { + active: anyActive, + syncing: kind === "syncing" || kind === "starting", + // Dimming asks whether ANYTHING is usable, which is not the question the + // badge answers. Deriving it from the badge kind left the icon undimmed + // before the first poll, and undimmed while showing the missing-client badge + // for an account whose unit is merely unavailable. + installed: summary.initialized === true + && (anyActive || (kind !== "missing" && kind !== "unavailable")) + } +} + +// One line of error text, fit for a tooltip or a status row. +// +// A systemd or onedrive error can be several hundred characters of multi-line +// output; pasting that straight into the panel pushed every other row off the +// screen. This lived in Account.qml, where no test could reach it. +function elideStatus(text) { + var value = String(text || "").replace(/\s+/g, " ").trim() + return value.length > 180 ? value.substring(0, 177) + "…" : value +} + +function badgeKind(kind) { + if (kind === "resync" || kind === "reauth" || kind === "failed") return "attention" + if (kind === "missing" || kind === "unavailable") return "missing" + if (kind === "login") return "login" + if (kind === "paused") return "paused" + if (kind === "starting" || kind === "syncing") return "syncing" + return "" // healthy, and checking before the first poll +} + +// Which account the panel should show when it is opened from the bar. +// +// The badge is worst-of-N, but every control in the panel -- the buttons, the +// keyboard shortcuts, right-click Storage, middle-click Folder, and the IPC +// controls -- acts on the SELECTED account, which stays wherever the user last +// left it (the first discovered account, until they pick a tab). So the bar +// could show "reauthentication required" for Work while the panel opened on a +// perfectly healthy Personal, and pressing P paused Personal. +// +// Returns the service to select, or "" to leave the selection alone. The user's +// own choice is only overridden when it is not itself asking for attention: +// having deliberately opened Work to deal with it, they must not be bounced to +// Personal the moment Personal goes wrong too. +function openSelection(summary, selectedKind) { + var aggregate = summary && typeof summary === "object" ? summary : {} + var kind = String(aggregate.kind || "") + if (needsAttention(kind)) { + // A selection the user made for a reason is not stolen: having opened Work + // to deal with its reauth, they must not be bounced to Personal the moment + // Personal fails too. + if (needsAttention(selectedKind)) return "" + return aggregate.worst ? String(aggregate.worst.service || "") : "" + } + // A spinning bar is about the transfer. Under worst-first the transferring + // account IS the worst one, so clicking the spin reaches it rather than + // whatever was selected last -- but never away from an account the user is + // already watching sync. + if ((kind === "syncing" || kind === "starting") && selectedKind !== "syncing" + && selectedKind !== "starting") { + return aggregate.worst ? String(aggregate.worst.service || "") : "" + } + return "" +} + +// The states that put a badge on the bar and have something for the user to do. +// "paused" is deliberate and "syncing" is progress, so neither steals a +// selection. +function needsAttention(kind) { + return kind === "resync" || kind === "reauth" || kind === "failed" + || kind === "missing" || kind === "login" || kind === "unavailable" +} + +// Compose one desktop notification from the events of a single polling burst. +// +// Three accounts going wrong at once must not produce three popups, and the old +// unattributed summaries ("OneDrive sync failed") are useless when several +// accounts exist. Returns null when there is nothing to send. +// +// An event is { service, name, kind, body, action } where kind is one of +// "resync" | "failed" | "reauth" | "storage" | "recovered". +// Must agree with ACCOUNT_STATES, or a grouped notification opens a different +// account than the bar badge blames. +var ATTENTION_KINDS = { resync: 1, reauth: 2, failed: 3 } + +function composeNotification(events, multiAccount) { + var list = Array.isArray(events) ? events.filter(function (event) { + return event && typeof event === "object" + }) : [] + if (list.length === 0) return null + + var attention = list.filter(function (event) { return ATTENTION_KINDS[event.kind] }) + var recovered = list.filter(function (event) { return event.kind === "recovered" }) + var storage = list.filter(function (event) { return event.kind === "storage" }) + + // Attention outranks everything: it is the only kind that is actionable. + if (attention.length === 1 && recovered.length === 0 && storage.length === 0) { + var only = attention[0] + return { + urgency: "critical", + summary: multiAccount ? only.summary + " — " + only.name : only.summary, + body: only.body, + action: only.action || "", + actionLabel: only.actionLabel || "", + service: only.service + } + } + if (attention.length > 0) { + var worst = attention.slice().sort(function (left, right) { + return ATTENTION_KINDS[left.kind] - ATTENTION_KINDS[right.kind] + })[0] + var others = attention.concat(recovered, storage) + // With one account every event is about that account, so "needs attention in + // 2 accounts" would be nonsense. Lead with the worst condition and keep its + // own action -- which is how a single account behaved before this work. + if (!multiAccount) { + return { + urgency: "critical", + summary: worst.summary, + body: others.length > 1 + ? others.map(function (event) { return event.short }).join("\n") + : worst.body, + action: worst.action || "open", + actionLabel: worst.actionLabel || "Open OneDrive panel", + service: worst.service + } + } + if (attention.length === 1) { + return { + urgency: "critical", + summary: worst.summary + " — " + worst.name, + body: others.map(function (event) { return event.name + ": " + event.short }).join("\n"), + // A grouped popup deliberately does not carry a direct repair action: it + // cannot know which account the reader meant. + action: "open", + actionLabel: "Open OneDrive panel", + service: worst.service + } + } + // Count ACCOUNTS, not events: one account with two conditions is not two + // accounts. And carry the other kinds in the body -- a storage threshold is + // edge-latched, so dropping it here loses it until it clears and re-arms. + var names = {} + for (var scan = 0; scan < attention.length; scan++) names[attention[scan].service] = true + var accountCount = Object.keys(names).length + return { + urgency: "critical", + summary: accountCount > 1 + ? "OneDrive needs attention in " + accountCount + " accounts" + : worst.summary + " — " + worst.name, + body: others.map(function (event) { return event.name + ": " + event.short }).join("\n"), + action: "open", + actionLabel: "Open OneDrive panel", + service: worst.service + } + } + if (storage.length > 0) { + // A recovery in the same burst is reported in the body rather than dropped; + // the previous code returned the storage popup alone. + if (storage.length === 1 && recovered.length === 0) { + return { + urgency: "normal", + summary: multiAccount ? storage[0].summary + " — " + storage[0].name : storage[0].summary, + body: storage[0].body, + action: "", + actionLabel: "", + service: storage[0].service + } + } + if (storage.length === 1) { + return { + urgency: "normal", + summary: multiAccount ? storage[0].summary + " — " + storage[0].name : storage[0].summary, + body: storage.concat(recovered).map(function (event) { + return multiAccount ? event.name + ": " + event.short : event.short + }).join("\n"), + action: "", + actionLabel: "", + service: storage[0].service + } + } + return { + urgency: "normal", + summary: storage.length + " OneDrive accounts are almost full", + // Recoveries are carried here too. The single-storage branch above already + // does this; omitting it here dropped an edge-latched recovery, which then + // never re-reports. + body: storage.concat(recovered).map(function (event) { + return event.name + ": " + event.short + }).join("\n"), + action: "", + actionLabel: "", + service: storage[0].service + } + } + if (recovered.length === 1) { + return { + urgency: "normal", + summary: multiAccount ? "OneDrive recovered — " + recovered[0].name : "OneDrive recovered", + body: recovered[0].body, + action: "", + actionLabel: "", + service: recovered[0].service + } + } + if (recovered.length > 1) { + return { + urgency: "normal", + summary: recovered.length + " OneDrive accounts recovered", + body: recovered.map(function (event) { return event.name + ": " + event.short }).join("\n"), + action: "", + actionLabel: "", + service: recovered[0].service + } + } + // No branch matched: an event kind this function does not know about. Say + // nothing rather than throwing -- flushTransitions has already cleared the + // pending list, so an exception here would lose the whole burst silently. + return null +} + +// The glyph for a badge kind. Shared so the bar badge and the account selector +// cannot drift apart -- they are the same vocabulary at two sizes. +function badgeGlyph(kind) { + if (kind === "missing") return "\u{f0156}" + if (kind === "login") return "\u{f030b}" + if (kind === "paused") return "\u{f03e4}" + if (kind === "syncing") return "\u{f0453}" + if (kind === "attention") return "\u{f002a}" + return "" +} + +// --- scheduling decisions ----------------------------------------------------- +// +// These are pure so they can be tested. The QML that calls them cannot be, and a +// reviewer's observation is the reason they exist here: "the suite is green; +// that is not evidence these paths work". Deleting the ramp or bypassing the +// semaphore used to pass every test. + +// Which account should take the next poll slot? Returns an index, or -1. +// `accounts` is [{ routinePolling, initialized }]. Accounts that have not +// reported yet take priority, but the cursor still advances, so several +// unreported accounts interleave rather than the first one taking every slot. +function nextPollIndex(accounts, cursor) { + var list = Array.isArray(accounts) ? accounts : [] + if (list.length === 0) return -1 + var start = ((cursor % list.length) + list.length) % list.length + // Three passes: an account waiting on the result of a control it just ran, + // then one that has never been attempted, then everyone else. + for (var pass = 0; pass < 3; pass++) { + for (var step = 0; step < list.length; step++) { + var index = (start + step) % list.length + var account = list[index] + // `busy` covers a cloud check too: such an account will refuse the slot, + // so handing it one wastes the tick entirely. + if (!account || account.routinePolling === true || account.busy === true) continue + // Priority is "has not been ATTEMPTED yet", not "has not reported". An + // account whose helper always fails never reports, and gating on that gave + // it every slot forever while the healthy accounts went unpolled. + // An account that has just paused or resumed is holding an optimistic + // state that only a fresh poll can confirm or drop. Until it gets one the + // bar shows the pre-control sample, so it goes first -- ahead even of the + // startup ramp, which is at worst a few seconds of "checking". + if (pass === 0 && account.settling !== true) continue + if (pass === 1 && account.attempted === true) continue + return index + } + } + return -1 +} + +// May this cloud request start now, and if not, should it be queued? +// Returns "start" | "queue" | "drop". +function cloudDecision(busy, queue, service, mode, active) { + if (!service || !mode) return "drop" + if (busy) { + // The request already RUNNING counts as a duplicate too. Seeing only a + // boolean, this used to queue a second copy of the check in flight, which + // then ran the same 30-second query again the moment the first finished. + if (active && active.service === service && active.mode === mode) return "drop" + var pending = Array.isArray(queue) ? queue : [] + for (var index = 0; index < pending.length; index++) { + if (pending[index].service === service && pending[index].mode === mode) return "drop" + } + return "queue" + } + return "start" +} + +// Decide how to bring the current descriptor list in line with what discovery +// returned, as a plan of operations rather than a rebuilt list. Delegates must +// be preserved for services that still exist: recreating them would drop +// in-flight processes and the notification edge history that decides whether a +// condition is newly true. +// +// `current` is the ordered list of service names already present. +// Returns { updates: [{index,row}], appends: [row], removes: [index desc] }. +// --- the IPC account surface ------------------------------------------------- +// +// These two live here rather than inline in BarWidget.qml because that file +// cannot be instantiated headless -- it derives from the bar's own BarWidget +// type -- so logic left inside it is unreachable by any test. Both functions +// were previously deletable outright without a single assertion failing. + +// One row per account, in discovery order, for `omarchy-cmd ... accounts`. +function accountRows(accounts, selectedService) { + var list = accounts || [] + var rows = [] + for (var index = 0; index < list.length; index++) { + var account = list[index] + rows.push({ + service: String(account.service || ""), + instance: String(account.instance || ""), + name: String(account.displayName || ""), + selected: String(account.service || "") === String(selectedService || ""), + status: String(account.statusText || "") + }) + } + return rows +} + +// Accept either the full unit name or the bare instance, because a script author +// reaches for "personal" before "onedrive@personal.service". +// +// The full unit name is the unambiguous form, so it is matched across EVERY +// account before any instance is considered. Scanning account-by-account and +// testing both keys per account made the answer depend on discovery order: an +// instance match on the first account would beat an exact unit-name match on the +// second, and the control would then act on the wrong account. +// +// Returns the matched account's service name, or "" when nothing matches. +function resolveAccountTarget(accounts, target) { + var list = accounts || [] + var wanted = String(target || "") + // The plain account's instance IS "", so an unset argument would otherwise + // match it and silently retarget every later control at the default account. + // (No account key can equal "" today, so this guard cannot currently be + // observed failing -- it is here so that stops being an accident.) + if (wanted === "") return "" + var index + for (index = 0; index < list.length; index++) { + if (String(list[index].service || "") === wanted) return wanted + } + for (index = 0; index < list.length; index++) { + if (String(list[index].instance || "") === wanted) return String(list[index].service || "") + } + return "" +} + +function reconcilePlan(current, discovered) { + var present = Array.isArray(current) ? current : [] + var rows = Array.isArray(discovered) ? discovered : [] + var plan = { updates: [], appends: [], removes: [] } + var seen = {} + + for (var index = 0; index < rows.length; index++) { + var row = rows[index] + if (!row || typeof row !== "object") continue + var service = String(row.service || "") + if (service === "") continue + if (seen[service]) continue // discovery should not repeat, but never trust it + seen[service] = true + var existing = present.indexOf(service) + if (existing === -1) plan.appends.push(row) + else plan.updates.push({ index: existing, row: row }) + } + + // Descending, so applying them cannot invalidate the indices that follow. + for (var scan = present.length - 1; scan >= 0; scan--) { + if (!seen[present[scan]]) plan.removes.push(scan) + } + return plan +} + function filePath(url) { return decodeURIComponent(String(url || "").replace(/^file:\/\//, "")) } @@ -270,6 +867,23 @@ if (typeof module !== "undefined") { folderName: folderName, tooltip: tooltip, heroMeta: heroMeta, + accountName: accountName, + accountStateKind: accountStateKind, + accountState: accountState, + aggregateAccounts: aggregateAccounts, + aggregateTooltip: aggregateTooltip, + composeNotification: composeNotification, + elideStatus: elideStatus, + barState: barState, + badgeKind: badgeKind, + openSelection: openSelection, + needsAttention: needsAttention, + badgeGlyph: badgeGlyph, + nextPollIndex: nextPollIndex, + cloudDecision: cloudDecision, + accountRows: accountRows, + resolveAccountTarget: resolveAccountTarget, + reconcilePlan: reconcilePlan, filePath: filePath } } diff --git a/Panel.qml b/Panel.qml index 714da9a..bb39021 100644 --- a/Panel.qml +++ b/Panel.qml @@ -107,7 +107,10 @@ Panel { function open() { root.controller.show() - oneDrive.refresh(false) + // Before anything reads the selection: the panel's own bindings, and every + // control in it, follow selectedAccount. + oneDrive.selectBadgedAccount() + oneDrive.refreshSelected() oneDrive.retryStaleQuotaOnOpen() Qt.callLater(function() { if (root.opened) { @@ -152,6 +155,16 @@ Panel { } onPanelStyleChanged: ensureCursor() + // Switching account changes which rows exist and whether they are enabled, so + // the keyboard cursor is revalidated and the view returns to the top rather + // than leaving the reader halfway down a different account's activity list. + function selectAccount(service) { + if (!service || service === oneDrive.selectedService) return + oneDrive.selectAccount(service) + if (panelScroll) panelScroll.contentY = 0 + ensureCursor() + } + Service { id: oneDrive settings: root.settings @@ -166,6 +179,8 @@ Panel { function onBusyChanged() { root.ensureCursor() } function onResumeAtChanged() { root.ensureCursor() } function onActiveChanged() { root.ensureCursor() } + function onSelectedServiceChanged() { root.ensureCursor() } + function onAccountCountChanged() { root.ensureCursor() } } BarIconButton { @@ -218,6 +233,46 @@ Panel { width: panelScroll.width spacing: Style.space(10) + // Account selector. Hidden entirely for a single account, where it + // contributes no height and no spacing, so both panel styles keep + // their current layout. The hero title stays "OneDrive" either way -- + // the selected segment already carries identity. + Flickable { + id: accountTabsScroll + visible: oneDrive.accountCount > 1 + width: parent.width + height: visible ? accountTabs.implicitHeight : 0 + contentWidth: accountTabs.implicitWidth + contentHeight: height + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.HorizontalFlick + // Only this row scrolls sideways when the labels do not fit; the + // panel itself never widens. + interactive: contentWidth > width + + Row { + id: accountTabs + spacing: Style.space(4) + readonly property real segmentWidth: { + var count = Math.max(1, oneDrive.accountCount) + var available = accountTabsScroll.width - spacing * (count - 1) + return Math.max(Style.space(88), available / count) + } + + Repeater { + model: oneDrive.accounts + delegate: AccountTab { + required property var modelData + account: modelData + width: accountTabs.segmentWidth + selected: modelData && modelData.service === oneDrive.selectedService + onActivated: root.selectAccount(modelData.service) + } + } + } + } + Item { id: headerItem visible: oneDrive.authenticated @@ -894,6 +949,64 @@ Panel { } } + component AccountTab: CursorSurface { + id: accountTab + property var account: null + property bool selected: false + readonly property bool keyboardEnabled: true + signal activated() + function keyboardActivate() { activated() } + + // No glyph until this account has reported: its default values would + // otherwise render as the missing-client mark, which is the same drift the + // bar's checking gate exists to prevent. + readonly property string stateKind: account && account.initialized + ? Model.badgeKind(Model.accountStateKind(account)) : "" + + foreground: root.foreground + // The selected segment carries the border; the rest read as quiet labels. + bordered: accountTab.selected + hasCursor: (accountTabMouse.containsMouse || root.cursorItem === accountTab) + implicitHeight: Style.space(30) + height: implicitHeight + + Row { + anchors.centerIn: parent + spacing: Style.space(5) + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: accountTab.stateKind !== "" + text: Model.badgeGlyph(accountTab.stateKind) + color: accountTab.stateKind === "attention" || accountTab.stateKind === "login" + ? Color.urgent + : (accountTab.stateKind === "syncing" ? Color.accent : root.dim) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: accountTab.account + ? Model.accountName(accountTab.account.instance, accountTab.account.description) + : "" + color: accountTab.selected ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + width: Math.min(implicitWidth, Math.max(0, accountTab.width - Style.space(22))) + } + } + + MouseArea { + id: accountTabMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: accountTab.activated() + } + } + component ActionChip: CursorSurface { id: actionChip property string text: "" diff --git a/README.md b/README.md index 9a77226..b64f184 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,15 @@ pause/resume — in a panel like Omarchy's own Dropbox widget. - **Live status** — monitoring, syncing, paused, or needs attention, with the file currently transferring and its progress. Interruptions show as "retrying", never as a frozen percentage. +- **Multiple accounts** — every configured account is discovered from its own + systemd unit, with a selector row in the panel, per-account pause, login and + repair, and one bar badge showing whichever account most needs attention. + A single-account setup keeps the same commands, cache and lock paths, tooltip, + panel layout and notification text. - **Notifications** when OneDrive fails, needs a resync or reauthentication, recovers, or storage passes 90% full. Clicking one opens the panel or - starts the repair. Optional. + starts the repair. Events from several accounts are grouped into one + notification that names them. Optional. - **Guided repair** — opens the CLI's own interactive `--resync` flow in a terminal, which asks before it touches anything. - **Pause and resume** with 15-minute, 1-hour and 4-hour timed pauses. @@ -40,7 +46,9 @@ pause/resume — in a panel like Omarchy's own Dropbox widget. ## Controls - Left click toggles the panel; middle click opens the OneDrive folder; - right click refreshes cloud storage. + right click refreshes cloud storage. With several accounts, the panel's + selector row chooses which account every control acts on; there is + deliberately no pause-everything button. - `↑` `↓` move, `Enter` activates. `R` refreshes storage, `F` verifies sync, `P` pauses or resumes, `O` opens the folder, `W` opens OneDrive on the web, `L` opens login, `Esc` closes. @@ -51,6 +59,16 @@ contacted only by **Refresh storage** (`onedrive --display-quota`) and never automatic) — plus one storage retry when you open the panel onto a failed check older than five minutes. +With several accounts the refresh interval is shared rather than multiplied: +in steady state each account is polled once per interval, staggered across it, +and only one account is read at a time. At startup a faster ramp runs until every +account has reported once. Cloud checks stay manual and run one at a time across +all accounts. + +Accounts are found by reading each `onedrive` systemd user unit's own +`ExecStart` for its `--confdir`, so an instance pointed at an unrelated +directory is still found correctly. Nothing is guessed from unit names. + ## Requirements - Omarchy 4 (Quattro) diff --git a/Service.qml b/Service.qml index 0be87bd..e81e5fd 100644 --- a/Service.qml +++ b/Service.qml @@ -1,79 +1,62 @@ import QtQuick +import QtQml.Models import Quickshell import Quickshell.Io import "Model.js" as Model - +import "Commands.js" as Commands + +// Coordinator over every discovered OneDrive account. +// +// It owns discovery, selection, aggregation and scheduling; each Account owns +// its own state and processes. The panel and bar widget bind to the selected +// account through the forwarding block near the bottom, so a single-account +// install behaves exactly as it did before discovery existed. Item { id: root property var settings: ({}) - property bool installed: false - property bool serviceAvailable: false - property bool running: false - property bool enabled: false - property string activeState: "" - property bool serviceFailed: false - property bool resyncRequired: false - property bool authenticated: false - property bool reauthRequired: false - property bool syncing: false - property string syncStage: "" - property int _desired: -1 - readonly property bool active: _desired === -1 - ? (running || activeState === "activating") : _desired === 1 - property bool refreshing: false - property string statusText: "Checking…" - property string syncDir: "" - property string syncMode: "Two-way" - property string clientVersion: "" - property double resumeAt: 0 - property double lastSyncTs: 0 - property double usedBytes: 0 - property double quotaBytes: 0 - property bool quotaKnown: false - property double quotaCheckedTs: 0 - property string quotaError: "" - property string remoteStatus: "Not checked" - property double syncStatusCheckedTs: 0 - property string syncStatusError: "" - property double remoteCheckedTs: 0 - property string remoteError: "" - property var files: [] - property var activity: [] - property string actionStatus: "" - property string lastError: "" + property var _accountObjects: [] + property int _aggregateRevision: 0 + + readonly property var accounts: _accountObjects + readonly property int accountCount: accounts.length + property string selectedService: "" + + readonly property var selectedAccount: { + var found = accountForService(selectedService) + if (found) return found + return accounts.length > 0 ? accounts[0] : null + } + + readonly property var aggregate: { + void(_aggregateRevision) + return Model.aggregateAccounts(accounts) + } + + // Every account has been polled at least once, whatever the outcome. Distinct + // from aggregate.initialized, which asks whether any account produced a usable + // sample: an account whose helper always fails is attempted but never + // initialized, and the startup hold has to end for it. + readonly property bool allAttempted: { + void(_aggregateRevision) + for (var index = 0; index < accounts.length; index++) { + if (!accounts[index].attempted) return false + } + return accounts.length > 0 + } readonly property bool notificationsEnabled: { var value = setting("notifications", true) return value === true || String(value).toLowerCase() === "true" } readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 10, 3600) - readonly property int recentFileLimit: intSetting("recentFileLimit", 20, 5, 50) readonly property string helperPath: Model.filePath(Qt.resolvedUrl("onedrive-status.py")) - readonly property bool busy: statusProcess.running || controlProcess.running - || cancelTimerProcess.running || scheduleTimerProcess.running - readonly property string resumeUnit: "omaonedrive-resume" - readonly property bool cloudChecking: _activeCloudMode !== "" && statusProcess.running - readonly property bool quotaChecking: _activeCloudMode === "quota" && statusProcess.running - readonly property bool fullStatusChecking: _activeCloudMode === "sync-status" && statusProcess.running - - // Must match QUOTA_TIMEOUT_SECONDS / SYNC_STATUS_TIMEOUT_SECONDS in onedrive-status.py. - readonly property int cloudTimeoutSec: 30 - readonly property int cloudRetryAfterSec: 300 - - property string _cloudRequested: "" - property string _activeCloudMode: "" - property string _statusOutput: "" - property string _statusError: "" - property string _controlOutput: "" - property string _controlError: "" - property string _timerOutput: "" - property string _timerError: "" - property string _afterTimerCancel: "" - property int _pauseMinutes: 0 - property int _controlDesired: -1 - property bool _scheduleRecovery: false + + property string discoveryError: "" + property bool _discoverySettled: true + + signal openPanelRequested() function setting(name, fallback) { var value = settings ? settings[name] : undefined @@ -86,480 +69,574 @@ Item { return Math.max(minimum, Math.min(maximum, value)) } - function refresh(remote) { - if (remote === true) { - checkQuota() - return + function accountForService(service) { + for (var index = 0; index < _accountObjects.length; index++) { + if (_accountObjects[index].service === service) return _accountObjects[index] + } + return null + } + + // `withQuotaRetry` is the panel's behaviour, not selection's: opening the + // panel onto a stale failed quota check retries it once. Automation selecting + // an account merely to target a control must not silently contact Microsoft, + // so IPC passes false. + function selectAccount(service, withQuotaRetry) { + var found = accountForService(service) + if (!found) return + selectedService = found.service + if (!routinePollRunning()) found.refresh(false) + if (withQuotaRetry !== false) found.retryStaleQuotaOnOpen() + } + + // --- discovery ------------------------------------------------------------ + + // Reconcile by the stable service key rather than clearing and rebuilding: + // recreating delegates would drop in-flight processes and the notification + // edge history that decides whether a condition is new. + function applyDiscovery(rows) { + var current = [] + for (var scan = 0; scan < descriptors.count; scan++) current.push(descriptors.get(scan).service) + + var plan = Model.reconcilePlan(current, rows) + for (var update = 0; update < plan.updates.length; update++) { + var descriptor = normalizeDescriptor(plan.updates[update].row) + var existing = descriptors.get(plan.updates[update].index) + // A unit repointed at a different config directory is a different account + // behind the same name. Keeping the delegate preserves its processes, but + // its quota, file list, auth flag and notification edge history now belong + // to the previous directory and must not be shown as this one's. + // Only a real repoint: "" means "not yet known", and the seeded descriptor + // always starts that way, so treating it as a change wiped the first + // sample and the edge latches on every startup. + if (existing.confdir !== descriptor.confdir) { + var account = accountForService(descriptor.service) + if (existing.confdir !== "" && descriptor.confdir !== "") { + if (account) account.forgetSample() + } else if (account) { + // "" -> a real directory is the startup seed learning its identity, + // not a repoint: there is no sample to wipe, and wiping one reset the + // edge latches on every startup. But a poll already in flight was + // started against the CLIENT's default directory, which is only the + // same directory if this unit does not override it. Refuse that reply + // rather than attach another account's sync directory and quota to + // this one. + account.discardInFlight() + } + } + descriptors.set(plan.updates[update].index, descriptor) + } + for (var append = 0; append < plan.appends.length; append++) { + descriptors.append(normalizeDescriptor(plan.appends[append])) + } + for (var remove = 0; remove < plan.removes.length; remove++) { + descriptors.remove(plan.removes[remove]) } - if (statusProcess.running || helperPath === "") return - startStatusProcess("") - } - function checkQuota() { - requestCloudCheck("quota") + if (descriptors.count === 0) descriptors.append(defaultDescriptor()) + // Reconciliation may have removed the account holding the cloud slot, or the + // one a queued entry belongs to. Re-check once the model has settled. + Qt.callLater(function() { root.cloudFinished() }) + // A removed selected account falls back to the first discovered one. + if (accountForService(selectedService) === null) { + selectedService = descriptors.count > 0 ? descriptors.get(0).service : "" + } } - // Opening the panel is explicit user intent, so a failed storage result - // older than cloudRetryAfterSec is retried once on open. The decision is - // deferred until the next status poll returns, because at open() time the - // in-memory state may predate the poll the panel just started. - // quotaCheckedTs updates even on failure, which blocks another retry until - // the window passes. Verify sync is never retried automatically — it is - // the expensive full-drive check and stays strictly manual. - property bool _quotaRetryQueued: false + function normalizeDescriptor(row) { + return { + service: String(row.service || ""), + instance: String(row.instance || ""), + confdir: String(row.confdir || ""), + description: String(row.description || "") + } + } - function retryStaleQuotaOnOpen() { - _quotaRetryQueued = true + // The compatibility guarantee: with no template instances this is what + // discovery returns, and it is also what we fall back to if discovery fails. + function defaultDescriptor() { + return { + service: Commands.DEFAULT_SERVICE, + instance: "", + confdir: "", + description: "" + } } - function maybeRetryStaleQuota() { - if (quotaError === "" || quotaChecking) return - if (Date.now() / 1000 - quotaCheckedTs < cloudRetryAfterSec) return - checkQuota() + function reloadAccounts() { + if (discoveryProcess.running || helperPath === "") return + discoveryProcess.command = Commands.listAccounts(helperPath) + _discoverySettled = false + discoveryProcess.running = true } - function checkFullStatus() { - requestCloudCheck("sync-status") + function trackAccount(object) { + var next = _accountObjects.slice() + next.push(object) + _accountObjects = next + _aggregateRevision++ } - function requestCloudCheck(mode) { - if (helperPath === "") return - if (statusProcess.running) { - _cloudRequested = mode - return + function untrackAccount(object) { + var next = [] + for (var index = 0; index < _accountObjects.length; index++) { + if (_accountObjects[index] !== object) next.push(_accountObjects[index]) } - startStatusProcess(mode) - } - - function startStatusProcess(cloudMode) { - _activeCloudMode = cloudMode - _statusOutput = "" - _statusError = "" - refreshing = true - var command = ["python3", helperPath, "--limit", String(recentFileLimit)] - if (cloudMode === "quota") command.push("--quota") - else if (cloudMode === "sync-status") command.push("--sync-status") - if (cloudMode !== "") { - actionStatusTimer.stop() - actionStatus = (cloudMode === "quota" ? "Refreshing storage" : "Verifying sync") - + "… may take up to " + String(cloudTimeoutSec) + "s" - } - statusProcess.command = command - statusProcess.running = true + _accountObjects = next + _aggregateRevision++ } - signal openPanelRequested() - - function notify(urgency, summary, body) { - if (!notificationsEnabled) return - Quickshell.execDetached([ - "notify-send", "--app-name=OmaOneDrive", "--urgency=" + urgency, summary, body - ]) - } + // --- scheduling ----------------------------------------------------------- - // Omarchy's notification popups invoke the libnotify action registered under - // the canonical "default" identifier when the popup is clicked, rather than - // rendering per-action buttons — so the action is always "default" and the - // intended behavior is tracked here. One tracked notify-send at a time so - // the click can be read back from stdout; overlaps fall back to notify(). - property string _notifyBehavior: "" + property int _pollCursor: 0 - function notifyWithAction(urgency, summary, body, behavior, actionLabel) { - if (!notificationsEnabled) return - if (notifyProcess.running) { - notify(urgency, summary, body) + // One routine status process at a time across every account, so N accounts + // cost one subprocess per slot rather than N at once. + function routinePollRunning() { + for (var index = 0; index < _accountObjects.length; index++) { + if (_accountObjects[index].routinePolling) return true + } + return false + } + + // Shape Model.nextPollIndex expects: it must skip an account whose status + // process is busy for ANY reason, not only a routine poll. + function pollCandidates() { + var rows = [] + for (var index = 0; index < _accountObjects.length; index++) { + rows.push({ + routinePolling: _accountObjects[index].routinePolling, + busy: _accountObjects[index].statusBusy, + attempted: _accountObjects[index].attempted, + settling: _accountObjects[index].settling + }) + } + return rows + } + + // An account that has not reported yet takes priority, but the cursor still + // advances, so several unreported accounts interleave instead of the first one + // taking every slot until it gives up. Priority is "has not reported" + // (!initialized), not "not running": a deliberately paused account is a known + // state and must not consume startup slots at all. + function nextAccountToPoll() { + var index = Model.nextPollIndex(pollCandidates(), _pollCursor) + if (index < 0) return null + _pollCursor = (index + 1) % _accountObjects.length + return _accountObjects[index] + } + + function pollNextAccount() { + if (routinePollRunning()) return + var account = nextAccountToPoll() + if (account) account.refresh(false) + } + + // --- cloud check semaphore ------------------------------------------------ + + // Explicit cloud checks are slow (up to 30s) and are never automatic. One at a + // time across all accounts, de-duplicated by (service, mode) so repeated + // clicks cannot queue a backlog. + property var _cloudQueue: [] + + function requestCloud(account, mode) { + if (!account) return + var busyAccount = cloudBusyAccount() + var active = busyAccount + ? { service: busyAccount.service, mode: busyAccount.activeCloudMode } + : null + var decision = Model.cloudDecision( + busyAccount !== null, _cloudQueue, account.service, mode, active) + if (decision === "drop") return + if (decision === "queue") { + var next = _cloudQueue.slice() + next.push({ service: account.service, mode: mode }) + _cloudQueue = next return } - _notifyBehavior = behavior - notifyProcess.command = [ - "notify-send", "--app-name=OmaOneDrive", "--urgency=" + urgency, - "--action=default=" + actionLabel, summary, body - ] - notifyProcess.running = true + account.startCloudCheck(mode) } - function applyStatus(raw) { - var parsed = Model.parseStatus(raw) - if (!parsed.ok) { - lastError = parsed.lastError || "Failed to read OneDrive status" + function hasDescriptor(service) { + for (var row = 0; row < descriptors.count; row++) { + if (descriptors.get(row).service === service) return true + } + return false + } + + function cloudBusyAccount() { + for (var index = 0; index < _accountObjects.length; index++) { + var account = _accountObjects[index] + // A delegate discovery has already dropped will never report again, so it + // must not hold the shared slot: nothing releases a slot held by an + // account that no longer exists, and every queued account then sat there + // showing "Not checked". + // + // The retry at the end of applyDiscovery is what the harness actually + // pins -- there the delegate is already destroyed by the time it runs. + // This guard covers the case where destruction is deferred past that + // point, which the harness cannot produce and which real Quickshell can. + if (!hasDescriptor(account.service)) continue + // Pending counts as busy: a check deferred behind that account's routine + // poll has already claimed the slot. + if (account.cloudChecking || account.cloudPending) return account + } + return null + } + + function cloudFinished() { + // pollFinished fires for routine polls too; only advance when the shared + // slot is actually free. + if (cloudBusyAccount() !== null) return + if (_cloudQueue.length === 0) return + var next = _cloudQueue.slice() + // Keep taking entries until one belongs to an account that still exists. + // Stopping after a single shift meant an account removed by discovery while + // queued took the whole rest of the queue with it: the entries behind it + // stayed put and no cloud check started, so the accounts waiting on a + // storage figure showed "Not checked" until some later poll happened to + // call back in -- and never, if polling was itself blocked. + while (next.length > 0) { + var entry = next.shift() + var account = accountForService(entry.service) + if (account) { + _cloudQueue = next + account.startCloudCheck(entry.mode) + return + } + } + _cloudQueue = next + } + + // --- notification broker -------------------------------------------------- + + // One popup per polling burst, not one per account. Accounts report + // transitions; this decides how many notifications that becomes. + property var _pendingEvents: [] + property bool _baselineSent: false + + function enqueueTransition(event) { + if (!notificationsEnabled || !event) return + var next = _pendingEvents.slice() + next.push(event) + _pendingEvents = next + // start(), not restart(): restarting on every event made the window mean + // "900ms of quiet", which a staggered scheduler never produces, so each + // account's events flushed separately. The window now runs from the FIRST + // event of a batch and is long enough to span one poll round. + if (!burstTimer.running) burstTimer.start() + } + + function flushTransitions() { + var events = _pendingEvents + _pendingEvents = [] + if (events.length === 0) return + // Checked at SEND time, not only at enqueue: the burst window is up to a + // full refresh interval and the startup hold longer still, so a user who + // turns notifications off inside that window would otherwise still get one. + if (!notificationsEnabled) return + var composed = Model.composeNotification(events, accountCount > 1) + if (!composed) return + _baselineSent = true + if (composed.action === "") { + Quickshell.execDetached(Commands.notify(composed.urgency, composed.summary, composed.body)) return } - var wasFailed = serviceFailed - var wasResync = resyncRequired - var wasReauth = reauthRequired - var hadAttention = serviceFailed || resyncRequired || reauthRequired - var wasStorageSevere = Model.usageSevere(usedBytes, quotaBytes, quotaKnown) - installed = parsed.installed === true - serviceAvailable = parsed.serviceAvailable === true - running = parsed.running === true - enabled = parsed.enabled === true - activeState = String(parsed.activeState || "") - serviceFailed = parsed.serviceFailed === true - resyncRequired = parsed.resyncRequired === true - authenticated = parsed.authenticated === true - reauthRequired = parsed.reauthRequired === true - syncing = parsed.syncing === true - syncStage = String(parsed.syncStage || "") - if (_desired !== -1 && running === (_desired === 1)) _desired = -1 - statusText = String(parsed.statusText || (installed ? "Sync paused" : "Not installed")) - syncDir = String(parsed.syncDir || "") - syncMode = String(parsed.syncMode || "Two-way") - clientVersion = String(parsed.clientVersion || "") - resumeAt = Number(parsed.resumeAt || 0) - lastSyncTs = Number(parsed.lastSyncTs || 0) - usedBytes = Number(parsed.usedBytes || 0) - quotaBytes = Number(parsed.quotaBytes || 0) - quotaKnown = parsed.quotaKnown === true - quotaCheckedTs = Number(parsed.quotaCheckedTs || 0) - quotaError = String(parsed.quotaError || "") - remoteStatus = String(parsed.remoteStatus || "Not checked") - syncStatusCheckedTs = Number(parsed.syncStatusCheckedTs || 0) - syncStatusError = String(parsed.syncStatusError || "") - remoteCheckedTs = Number(parsed.remoteCheckedTs || 0) - remoteError = String(parsed.remoteError || "") - files = parsed.files || [] - activity = parsed.activity || [] - lastError = String(parsed.lastError || "") - - if (resyncRequired && !wasResync) - notifyWithAction("critical", "OneDrive needs a resync", - "Syncing stopped until the resync repair runs.", - "repair", "Run resync repair") - else if (serviceFailed && !wasFailed) - notifyWithAction("critical", "OneDrive sync failed", - lastError !== "" ? lastError : "The OneDrive service entered a failed state.", - "open", "Open OneDrive panel") - if (reauthRequired && !wasReauth) - notifyWithAction("critical", "OneDrive needs reauthentication", - "Sign in again to keep syncing.", - "open", "Open OneDrive panel") - if (hadAttention && !serviceFailed && !resyncRequired && !reauthRequired) - notify("normal", "OneDrive recovered", "Syncing is healthy again.") - if (!wasStorageSevere && Model.usageSevere(usedBytes, quotaBytes, quotaKnown)) - notify("normal", "OneDrive storage almost full", - Model.freeText(usedBytes, quotaBytes, quotaKnown) + " of " - + Model.formatBytes(quotaBytes) + " remains.") - } - - function elideStatus(text) { - var value = String(text || "").replace(/\s+/g, " ").trim() - return value.length > 180 ? value.substring(0, 177) + "…" : value - } - - function login() { - if (!installed) return - Quickshell.execDetached(["omarchy-launch-terminal", "onedrive"]) - actionStatus = "Opened OneDrive login" - actionStatusTimer.restart() - } - - function reauthenticate() { - if (!installed || running) return - Quickshell.execDetached(["omarchy-launch-terminal", "onedrive", "--reauth"]) - actionStatus = "Opened OneDrive reauthentication" - actionStatusTimer.restart() - } - - function repairResync() { - if (!installed || running || busy) return - Quickshell.execDetached(["omarchy-launch-terminal", "onedrive", "--sync", "--resync"]) - actionStatus = "Opened OneDrive resync repair" - actionStatusTimer.restart() - } - - function openWeb() { - Quickshell.execDetached(["uwsm-app", "--", "xdg-open", "https://onedrive.live.com/"]) - } - - function pause() { - if (busy) return - _pauseMinutes = 0 - cancelResumeTimer("pause") - } - - function pauseFor(minutes) { - var requested = parseInt(String(minutes), 10) - if (!isFinite(requested) || requested <= 0) return - var duration = Math.max(5, Math.min(1440, requested)) - if (!installed || !serviceAvailable || !authenticated || busy - || serviceFailed || resyncRequired || reauthRequired) return - _pauseMinutes = duration - cancelResumeTimer("pause") - } - - function resume() { - if (!authenticated) { - login() + // Only one action-bearing notify-send can be tracked at a time, because the + // click is read back from its stdout. A second one waits rather than losing + // its action. + if (notifyProcess.running) { + // An action-bearing notify-send blocks until the popup is dismissed, and a + // critical popup does not expire on its own. Queueing behind it meant a + // single unread alert hid every later one, possibly for hours. Show this + // one without its click action instead -- which is what the widget did + // before the broker existed. + Quickshell.execDetached(Commands.notify(composed.urgency, composed.summary, composed.body)) return } - if (busy) return - _pauseMinutes = 0 - cancelResumeTimer("resume") - } - - function toggleRunning() { - if (active) pause() - else resume() - } - - function runControl(command, desired) { - if (!installed || !serviceAvailable || controlProcess.running) return - _desired = desired - _controlDesired = desired - _controlOutput = "" - _controlError = "" - controlProcess.command = command - controlProcess.running = true - } - - function cancelResumeTimer(afterAction) { - _afterTimerCancel = afterAction - _timerOutput = "" - _timerError = "" - cancelTimerProcess.command = [ - "systemctl", "--user", "stop", - resumeUnit + ".timer", resumeUnit + ".service" - ] - cancelTimerProcess.running = true + startActionNotification(composed) } - function scheduleResume(minutes) { - _timerOutput = "" - _timerError = "" - scheduleTimerProcess.command = [ - "systemd-run", "--user", - "--unit=" + resumeUnit, - "--description=Resume OneDrive after timed pause", - "--on-active=" + String(minutes) + "m", - "--timer-property=AccuracySec=1s", - "--collect", - "/usr/bin/systemctl", "--user", "start", "onedrive.service" - ] - scheduleTimerProcess.running = true - } - - function openFolder() { - if (syncDir !== "") Quickshell.execDetached(["uwsm-app", "--", "xdg-open", syncDir]) - } - - function openFile(file) { - if (!file || !file.path) return - Quickshell.execDetached(["uwsm-app", "--", "nautilus", "--select", fileUri(String(file.path))]) - } - - function fileUri(path) { - var parts = String(path || "").split("/") - for (var index = 0; index < parts.length; index++) parts[index] = encodeURIComponent(parts[index]) - return "file://" + parts.join("/") - } - - Timer { - interval: root.refreshIntervalSec * 1000 - repeat: true - running: true - triggeredOnStart: true - onTriggered: root.refresh(false) + property string _notifyBehavior: "" + property string _notifyService: "" + + function startActionNotification(composed) { + _notifyBehavior = composed.action + _notifyService = composed.service + notifyProcess.command = Commands.notify( + composed.urgency, composed.summary, composed.body, + { id: "default", label: composed.actionLabel }) + notifyProcess.running = true } - Timer { - id: startupRamp - property int ticks: 0 - interval: 2000 - repeat: true - running: true - onTriggered: { - ticks += 1 - if (root.running || ticks >= 15) startupRamp.running = false - else root.refresh(false) + // --- selected-account facade ---------------------------------------------- + // + // Panel.qml and BarWidget.qml still say `oneDrive.running`. Every forward is + // null-safe: between startup and the first descriptor there is no account. + + readonly property bool installed: selectedAccount ? selectedAccount.installed : false + readonly property bool serviceAvailable: selectedAccount ? selectedAccount.serviceAvailable : false + readonly property bool running: selectedAccount ? selectedAccount.running : false + readonly property bool enabled: selectedAccount ? selectedAccount.enabled : false + readonly property string activeState: selectedAccount ? selectedAccount.activeState : "" + readonly property bool serviceFailed: selectedAccount ? selectedAccount.serviceFailed : false + readonly property bool resyncRequired: selectedAccount ? selectedAccount.resyncRequired : false + readonly property bool authenticated: selectedAccount ? selectedAccount.authenticated : false + readonly property bool reauthRequired: selectedAccount ? selectedAccount.reauthRequired : false + readonly property bool syncing: selectedAccount ? selectedAccount.syncing : false + readonly property string syncStage: selectedAccount ? selectedAccount.syncStage : "" + readonly property bool active: selectedAccount ? selectedAccount.active : false + readonly property bool refreshing: selectedAccount ? selectedAccount.refreshing : false + readonly property string statusText: selectedAccount ? selectedAccount.statusText : "Checking…" + readonly property string syncDir: selectedAccount ? selectedAccount.syncDir : "" + readonly property string syncMode: selectedAccount ? selectedAccount.syncMode : "Two-way" + readonly property string clientVersion: selectedAccount ? selectedAccount.clientVersion : "" + readonly property double resumeAt: selectedAccount ? selectedAccount.resumeAt : 0 + readonly property double lastSyncTs: selectedAccount ? selectedAccount.lastSyncTs : 0 + readonly property double usedBytes: selectedAccount ? selectedAccount.usedBytes : 0 + readonly property double quotaBytes: selectedAccount ? selectedAccount.quotaBytes : 0 + readonly property bool quotaKnown: selectedAccount ? selectedAccount.quotaKnown : false + readonly property double quotaCheckedTs: selectedAccount ? selectedAccount.quotaCheckedTs : 0 + readonly property string quotaError: selectedAccount ? selectedAccount.quotaError : "" + readonly property string remoteStatus: selectedAccount ? selectedAccount.remoteStatus : "Not checked" + readonly property double syncStatusCheckedTs: selectedAccount ? selectedAccount.syncStatusCheckedTs : 0 + readonly property string syncStatusError: selectedAccount ? selectedAccount.syncStatusError : "" + readonly property double remoteCheckedTs: selectedAccount ? selectedAccount.remoteCheckedTs : 0 + readonly property string remoteError: selectedAccount ? selectedAccount.remoteError : "" + readonly property var files: selectedAccount ? selectedAccount.files : [] + readonly property var activity: selectedAccount ? selectedAccount.activity : [] + readonly property string actionStatus: selectedAccount ? selectedAccount.actionStatus : "" + // A discovery failure is non-destructive, but the user should still learn the + // account list may be stale; the account's own error takes precedence. + readonly property string lastError: { + if (selectedAccount && selectedAccount.lastError !== "") return selectedAccount.lastError + return discoveryError + } + readonly property bool busy: selectedAccount ? selectedAccount.busy : false + readonly property bool cloudChecking: selectedAccount ? selectedAccount.cloudChecking : false + readonly property bool quotaChecking: selectedAccount ? selectedAccount.quotaChecking : false + readonly property bool fullStatusChecking: selectedAccount ? selectedAccount.fullStatusChecking : false + // Forwarded from the selected account, which carries the comment tying it to + // the helper's own timeout constants. A second literal here would drift. + readonly property int cloudTimeoutSec: selectedAccount ? selectedAccount.cloudTimeoutSec : 30 + + // A cloud check is explicit user intent and is serialised by its own + // semaphore; a routine refresh goes through the shared slot, so IPC, the panel + // and the scheduler cannot each start a helper at the same time. + function refresh(remote) { + if (!selectedAccount) return + if (remote === true) { selectedAccount.refresh(true); return } + refreshSelected() + } + // Called when the panel is opened from the bar: point it at the account the + // badge is blaming, so the controls act on what the user just clicked about. + function selectBadgedAccount() { + var current = selectedAccount + var target = Model.openSelection(aggregate, + current ? Model.accountStateKind(current) : "") + if (target !== "" && target !== selectedService) selectAccount(target, false) + } + + function refreshSelected() { + if (selectedAccount && !routinePollRunning()) selectedAccount.refresh(false) + } + function checkQuota() { if (selectedAccount) selectedAccount.checkQuota() } + function checkFullStatus() { if (selectedAccount) selectedAccount.checkFullStatus() } + function retryStaleQuotaOnOpen() { if (selectedAccount) selectedAccount.retryStaleQuotaOnOpen() } + function login() { if (selectedAccount) selectedAccount.login() } + function reauthenticate() { if (selectedAccount) selectedAccount.reauthenticate() } + function repairResync() { if (selectedAccount) selectedAccount.repairResync() } + function openWeb() { if (selectedAccount) selectedAccount.openWeb() } + function openFolder() { if (selectedAccount) selectedAccount.openFolder() } + function openFile(file) { if (selectedAccount) selectedAccount.openFile(file) } + function pause() { if (selectedAccount) selectedAccount.pause() } + function pauseFor(minutes) { if (selectedAccount) selectedAccount.pauseFor(minutes) } + function resume() { if (selectedAccount) selectedAccount.resume() } + function toggleRunning() { if (selectedAccount) selectedAccount.toggleRunning() } + + // --- wiring --------------------------------------------------------------- + + ListModel { id: descriptors } + + Instantiator { + id: accountInstances + model: descriptors + delegate: Account { + // Bind the model roles onto Account's OWN properties. Redeclaring them as + // `required property string service` here SHADOWS the base's, producing a + // split brain: reads from outside the object see the model role, but every + // read inside Account.qml -- id-qualified, unqualified, or in a binding -- + // sees the base default. Status reads went through JS and looked correct + // while every control, timer and login vector silently targeted + // onedrive.service. Verified with a minimal qml6 reproduction. + required property var model + service: model.service + instance: model.instance + confdir: model.confdir + description: model.description + settings: root.settings + coordinator: root + onAccountStateChanged: root._aggregateRevision++ + onPollFinished: root.cloudFinished() + onTransition: function(event) { root.enqueueTransition(event) } } + onObjectAdded: function(index, object) { root.trackAccount(object) } + onObjectRemoved: function(index, object) { root.untrackAccount(object) } } + // A polling burst is roughly one scheduler slot. Until every account has + // reported once, the window is held open so a startup round of pre-existing + // problems becomes ONE baseline notification rather than N. Timer { - id: delayedRefresh - interval: 750 + id: burstTimer + property int heldRounds: 0 + // One poll round, because that is how long it takes every account to be + // sampled once and therefore how long a related set of transitions takes to + // arrive. A single account has nothing to wait for and keeps the short + // window, so its notifications are as prompt as they were before. + // A full poll round, uncapped: capping at 30s meant a 60-second refresh + // interval spread one round's events across two windows and produced two + // popups for the same round. + interval: root.accountCount > 1 ? Math.max(900, root.refreshIntervalSec * 1000) : 900 repeat: false - onTriggered: root.refresh(false) - } - - Timer { - id: settleTimer - property int ticks: 0 - interval: 1200 - repeat: true onTriggered: { - ticks += 1 - root.refresh(false) - if (ticks >= 5) { - ticks = 0 - stop() - root._desired = -1 + // Hold the window open until the first round is over, so a startup round of + // pre-existing problems becomes ONE baseline notification rather than N. + // Gated on allAttempted, not aggregate.initialized: the latter flips as + // soon as the FIRST account reports, which released the hold immediately + // and produced one popup per account -- exactly what this prevents. + if (!root.allAttempted && !root._baselineSent && heldRounds < 12) { + heldRounds += 1 + burstTimer.restart() + return } + heldRounds = 0 + root.flushTransitions() } } - Timer { - id: actionStatusTimer - interval: 2500 - repeat: false - onTriggered: root.actionStatus = "" - } - + // Omarchy's notification popups invoke the libnotify action registered under + // the canonical "default" identifier when the popup is clicked, rather than + // rendering per-action buttons, so the action is always "default" and the + // intended behaviour is tracked here. Process { id: notifyProcess running: false command: [] - stdout: StdioCollector { - id: notifyStdout - waitForEnd: true - } + stdout: StdioCollector { id: notifyStdout; waitForEnd: true } stderr: StdioCollector { waitForEnd: true } onExited: function(exitCode) { var behavior = root._notifyBehavior + var service = root._notifyService root._notifyBehavior = "" + root._notifyService = "" if (String(notifyStdout.text || "").trim() !== "default") return - if (behavior === "open") root.openPanelRequested() - else if (behavior === "repair") root.repairResync() + if (behavior === "open") { + // Select the account the notification was about before opening. + if (service !== "") root.selectAccount(service) + root.openPanelRequested() + } else if (behavior === "repair") { + var account = root.accountForService(service) + if (account) account.repairResync() + } } } Process { - id: statusProcess + id: discoveryProcess running: false command: [] - stdout: StdioCollector { - id: statusStdout - waitForEnd: true - onStreamFinished: root._statusOutput = text - } - stderr: StdioCollector { - id: statusStderr - waitForEnd: true - onStreamFinished: root._statusError = text + stdout: StdioCollector { id: discoveryStdout; waitForEnd: true } + stderr: StdioCollector { id: discoveryStderr; waitForEnd: true } + // Discovery is a Process like any other, so it too can stop without ever + // reporting an exit. Without this, a missing python3 left `discoveryError` + // empty for ever: the user saw one account and nothing at all to say the + // list might be wrong. + onRunningChanged: { + if (!running) Qt.callLater(function() { + if (root._discoverySettled) return + root._discoverySettled = true + root.discoveryError = "Could not run the OneDrive status helper" + }) } onExited: function(exitCode) { - var cloudMode = root._activeCloudMode - root.refreshing = false - var stdout = String(statusStdout.text || root._statusOutput || "") - var stderr = String(statusStderr.text || root._statusError || "") - if (exitCode === 0) root.applyStatus(stdout) - else root.lastError = root.elideStatus(stderr || stdout || "Could not read OneDrive status") - if (cloudMode !== "") { - if (exitCode !== 0) root.actionStatus = root.lastError - else if (cloudMode === "quota") - root.actionStatus = root.quotaError === "" ? "Storage refreshed" : root.quotaError - else root.actionStatus = root.syncStatusError === "" - ? "Sync verified" : root.syncStatusError - actionStatusTimer.restart() + root._discoverySettled = true + if (exitCode !== 0) { + // Non-destructive: keep whatever accounts we already have. The startup + // seed and applyDiscovery's own floor both guarantee there is at least + // one, so there is nothing to re-seed here -- an earlier version tried, + // in a branch that could never be reached. + root.discoveryError = String(discoveryStderr.text || "").trim() + || "Could not list OneDrive accounts" + return } - root._activeCloudMode = "" - if (root._cloudRequested !== "") { - var requested = root._cloudRequested - root._cloudRequested = "" - Qt.callLater(function() { root.requestCloudCheck(requested) }) + var rows = null + try { + var parsed = JSON.parse(String(discoveryStdout.text || "")) + if (Array.isArray(parsed)) rows = parsed + } catch (error) { + rows = null } - if (root._quotaRetryQueued) { - root._quotaRetryQueued = false - Qt.callLater(function() { root.maybeRetryStaleQuota() }) + if (rows === null) { + // Unparseable output is "could not look", not "found nothing". Treating + // it as an empty result would remove every account -- more destructive + // than a non-zero exit, which is handled above -- and it would repeat on + // every discovery tick. + root.discoveryError = "Could not read the account list" + return } + root.discoveryError = "" + if (rows.length === 0) rows = [root.defaultDescriptor()] + root.applyDiscovery(rows) } } - Process { - id: cancelTimerProcess - running: false - command: [] - stdout: StdioCollector { waitForEnd: true } - stderr: StdioCollector { waitForEnd: true } - onExited: function(exitCode) { - var action = root._afterTimerCancel - root._afterTimerCancel = "" - root.resumeAt = 0 - if (action === "resume") { - root.runControl(["systemctl", "--user", "start", "onedrive.service"], 1) - } else if (action === "pause") { - if (root.running || root.active || root.activeState === "activating") { - root.runControl(["systemctl", "--user", "stop", "onedrive.service"], 0) - } else if (root._pauseMinutes > 0) { - var minutes = root._pauseMinutes - root._pauseMinutes = 0 - root.scheduleResume(minutes) - } else { - root.refresh(false) - } - } - } + // Slots are spread across the interval, so three accounts at the default + // setting start about ten seconds apart and each is still polled about every + // thirty seconds. + Timer { + id: pollScheduler + interval: Math.max(1000, Math.round(root.refreshIntervalSec * 1000 / Math.max(1, root.accountCount))) + repeat: true + running: true + triggeredOnStart: true + onTriggered: root.pollNextAccount() } - Process { - id: scheduleTimerProcess - running: false - command: [] - stdout: StdioCollector { - id: timerStdout - waitForEnd: true - onStreamFinished: root._timerOutput = text - } - stderr: StdioCollector { - id: timerStderr - waitForEnd: true - onStreamFinished: root._timerError = text - } - onExited: function(exitCode) { - var stdout = String(timerStdout.text || root._timerOutput || "") - var stderr = String(timerStderr.text || root._timerError || "") - if (exitCode !== 0) { - root.lastError = root.elideStatus(stderr || stdout || "Could not schedule OneDrive resume") - root.actionStatus = "Timed pause failed; resuming syncing…" - root._scheduleRecovery = true - root.runControl(["systemctl", "--user", "start", "onedrive.service"], 1) - } else { - root.lastError = "" - root.actionStatus = "Timed pause scheduled" - actionStatusTimer.restart() - root.refresh(false) - } + // The old widget ran a dedicated two-second ramp alongside the poll timer, so + // a service coming up at login was noticed within ~2s. Reordering slots does + // not reproduce that -- with one account the slot IS the refresh interval -- + // so the fast ramp is a timer of its own again, running only until every + // account has reported. + Timer { + id: startupRamp + property int ticks: 0 + interval: 2000 + repeat: true + // Until every account has been polled once -- not until one succeeds, which + // would leave the rest without ramp coverage, and not forever, which is what + // an always-failing helper would otherwise get. The old widget capped its + // ramp at 15 ticks; so does this. + running: root.accountCount > 0 && !root.allAttempted && ticks < 15 * Math.max(1, root.accountCount) + onTriggered: { + ticks += 1 + root.pollNextAccount() } } - Process { - id: controlProcess - running: false - command: [] - stdout: StdioCollector { - id: controlStdout - waitForEnd: true - onStreamFinished: root._controlOutput = text - } - stderr: StdioCollector { - id: controlStderr - waitForEnd: true - onStreamFinished: root._controlError = text - } - onExited: function(exitCode) { - var desired = root._controlDesired - root._controlDesired = -1 - var stdout = String(controlStdout.text || root._controlOutput || "") - var stderr = String(controlStderr.text || root._controlError || "") - if (exitCode !== 0) { - root._desired = -1 - root._pauseMinutes = 0 - root._scheduleRecovery = false - root.lastError = root.elideStatus(stderr || stdout || "OneDrive service command failed") - } else { - root.lastError = "" - settleTimer.ticks = 0 - settleTimer.start() - if (desired === 0 && root._pauseMinutes > 0) { - var minutes = root._pauseMinutes - root._pauseMinutes = 0 - root.scheduleResume(minutes) - } else if (root._scheduleRecovery) { - root._scheduleRecovery = false - root.actionStatus = "Timed pause failed; syncing resumed" - actionStatusTimer.restart() - } - } - delayedRefresh.restart() - } + // Units can be enabled or removed while the widget runs. + Timer { + interval: 300000 + repeat: true + running: true + onTriggered: root.reloadAccounts() + } + + Component.onCompleted: { + // Seed the compatibility descriptor immediately so the panel has an account + // to bind to before the first discovery returns; discovery then reconciles + // it in place rather than replacing it. + descriptors.append(defaultDescriptor()) + selectedService = Commands.DEFAULT_SERVICE + reloadAccounts() } } diff --git a/TESTING.md b/TESTING.md index b11f143..b205d8a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -23,11 +23,41 @@ guessing a confdir from an instance name), that `--confdir` selects an account and gives it its own cache *and* lock, that the confdir reaches the client as exactly one argument and cannot introduce a second flag, that the helper never creates a config directory it was only asked to read, that `--resume-unit` reads -that account's own timer and no other's, that an account discovered by +that account's own timer and no other's, that the scheduler interleaves accounts +that have not reported rather than letting one monopolise the ramp, that a cloud +check waiting behind a routine poll still holds the shared slot, that an account +discovered by `--list-accounts` always survives the `--service` gate, that a present-but- unusable confdir is dropped rather than aliased onto the default account, and that the no-flag JSON output keeps exactly its expected field set. +The QML layer's pure logic is covered by node tests: `tests/Commands.test.js` +asserts every command vector as an exact array for both a plain service and a +template instance, `tests/Aggregate.test.js` covers all ten account states and +the worst-of-N rules, `tests/Discovery.test.js` covers reconciling discovery +results without churning delegates, and `tests/Notifications.test.js` covers +grouping a burst of events into one notification. + +## Multiple accounts, by hand + +With a plain `onedrive.service` only, the panel must be pixel-identical to the +single-account screenshots: no selector row, no extra spacing, hero title +`OneDrive`. + +With template instances (`onedrive@a`, `onedrive@b`, …): + +- each account appears once in the selector, named from its instance; +- switching accounts changes the hero, storage, activity and every control, + returns the scroll to the top, and leaves keyboard navigation working; +- pausing one account for 15 minutes leaves the others running, and its timer + resumes only that account; +- one account failing while another syncs shows the attention badge while the + cloud icon stays lit; +- removing the selected unit and waiting for rediscovery falls back to the + first remaining account rather than emptying the panel; +- restarting the shell with an active timed pause still shows the pending + resume for the right account. + ## In the shell Use an Omarchy Quattro VM with no host block device attached: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e862dfa..da762e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,17 +5,40 @@ `Panel.qml`, which follows Omarchy's native popup ownership, keyboard, theme, and panel-switch contracts. -`Service.qml` is the asynchronous boundary between QML and the operating -system. Local polling, cloud checks, and systemd control each run in a -`Quickshell.Io.Process`; no command is constructed through a shell. -Timed pauses stop `onedrive.service` and schedule the transient -`omaonedrive-resume.timer` through `systemd-run --user`. That is today's -single-account behaviour: the helper accepts a different resume unit per account -through `--resume-unit`, which the current QML does not yet pass, just as it does -not yet pass `--service`, `--confdir` or `--list-accounts`. Replacing a preset or -resuming immediately first cancels that timer. If scheduling fails after the -service was stopped, the service is started again so a failed timer cannot -leave sync paused unexpectedly. +`Service.qml` is the coordinator and `Account.qml` is the asynchronous boundary +between QML and the operating system. One `Account` exists per discovered +account, each owning its own status, control and timer processes; local polling, +cloud checks, and systemd control each run in a `Quickshell.Io.Process`. No +command is constructed through a shell: every argv vector is built by +`Commands.js` from that account's own service, config directory and resume unit, +and `tests/Commands.test.js` asserts the exact arrays. + +`Service.qml` discovers accounts with the helper's `--list-accounts`, reconciles +the result by the stable service key so existing delegates and their +notification history survive, dispatches one local poll per scheduler slot +(`refreshIntervalSec` divided by the account count, so the steady-state poll rate +does not grow with the number of accounts), serialises explicit cloud checks +behind a semaphore of one -- a check deferred behind an account's own routine +poll still holds that slot -- and batches transition events into at most one +desktop notification per polling burst, whose window spans a full poll round so +accounts that fail together are reported together. Until every account has been +polled once, a separate two-second ramp polls faster, as the single-account +widget always did; it is capped, so a helper that always fails cannot spin on it. With no template instances, discovery yields the +plain `onedrive.service` and everything below behaves as it did when the widget +was single-account. + +Timed pauses stop that account's service and schedule its own transient resume +unit through `systemd-run --user` — `omaonedrive-resume` for the plain service, +`omaonedrive-resume@` for a template instance, so an in-flight timer +survives an upgrade and one account's pause never cancels another's. A few +instance names cannot yield a safe unit at all: one ending in `.timer` or +`.service` would make `systemd-run` derive the same unit as a different account, +and one long enough to overflow systemd's 255-byte limit cannot be scheduled. For +those, a timed pause degrades to an untimed one and says so, rather than arming a +timer that would collide. Replacing a +preset or resuming immediately first cancels only that account's timer. If +scheduling fails after the service was stopped, that same service is started +again so a failed timer cannot leave sync paused unexpectedly. `onedrive-status.py` reads: @@ -97,3 +120,18 @@ No refresh-token content, Microsoft response URL, access token, browser state, or synced-file content crosses the helper boundary; the only additional data `--list-accounts` emits is each unit's name, the instance parsed out of that name, its systemd description, and its config-directory path. + +The bar shows one state for all accounts: each is classified into exactly one of +ten states and the worst wins, with the cloud icon lit while any account is +still working. An account that has not produced a first sample is excluded from the aggregate +rather than gating it: excluding it already stops default values flashing a +missing-client badge, while gating on all of them meant one permanently-failing +account froze the bar at `checking` forever, hiding a healthy account's real +state behind it. Only when nothing has reported is the aggregate `checking`, +with no badge drawn. The tooltip applies the same rule, so badge and tooltip +cannot disagree about which accounts are known. The tooltip is the account's own single line +when there is one account, and an attributed worst-first list when there are +several. `Model.js` holds the classification, aggregation, tooltip, badge and +notification-composition rules as pure functions, table-tested in +`tests/Aggregate.test.js`, `tests/Discovery.test.js` and +`tests/Notifications.test.js`. diff --git a/manifest.json b/manifest.json index bc32d4a..2b9048d 100644 --- a/manifest.json +++ b/manifest.json @@ -2,10 +2,10 @@ "schemaVersion": 1, "id": "io.github.salemsayed.omaonedrive", "name": "OmaOneDrive", - "version": "1.5.4", + "version": "1.6.0", "author": "Salem Sayed", "license": "MIT", - "description": "OneDrive status, timed pause controls, cloud storage, and recent sync activity in the Omarchy bar.", + "description": "OneDrive status, timed pause controls, cloud storage, and recent sync activity in the Omarchy bar, for one account or several.", "kinds": [ "bar-widget" ], @@ -14,7 +14,7 @@ }, "barWidget": { "displayName": "OmaOneDrive", - "description": "Control and temporarily pause the OneDrive CLI, check cloud status, and open recent local files.", + "description": "Control and temporarily pause the OneDrive CLI, check cloud status, and open recent local files. Discovers every configured account and shows the one that most needs attention.", "category": "Files", "allowMultiple": false, "defaultSection": "right", @@ -30,7 +30,7 @@ "type": "boolean", "label": "Desktop notifications", "defaultValue": true, - "description": "Notify when OneDrive needs attention, recovers, or cloud storage is almost full." + "description": "Notify when OneDrive needs attention, recovers, or cloud storage is almost full. Events from several accounts are grouped into one notification." }, { "key": "refreshIntervalSec", @@ -40,7 +40,7 @@ "max": 3600, "step": 10, "defaultValue": 30, - "description": "Reads the local service, journal, and cached cloud status. It does not contact Microsoft." + "description": "Reads the local service, journal, and cached cloud status. It does not contact Microsoft. With several accounts the interval is shared: each account is polled once per interval, staggered across it." }, { "key": "recentFileLimit", @@ -50,15 +50,18 @@ "max": 50, "step": 5, "defaultValue": 20, - "description": "Maximum number of recently modified local OneDrive files shown in the panel." + "description": "Maximum number of recently modified local OneDrive files shown in the panel, for the selected account." }, { "key": "panelStyle", "type": "enum", "label": "Panel layout", - "options": ["Full", "Compact"], + "options": [ + "Full", + "Compact" + ], "defaultValue": "Full", - "description": "Full shows storage and recent activity. Compact shows storage only, in a shorter panel." + "description": "Full shows storage and recent activity. Compact shows storage only, in a shorter panel. Both show the account selector when more than one account exists." } ] } diff --git a/onedrive-status.py b/onedrive-status.py index d6d108b..fc6404e 100755 --- a/onedrive-status.py +++ b/onedrive-status.py @@ -44,13 +44,6 @@ def command_output(command, timeout=4): return completed.returncode, (completed.stdout + completed.stderr).strip() -def default_confdir(): - config_home = os.environ.get("XDG_CONFIG_HOME") - if config_home: - return Path(config_home) / "onedrive" - return Path.home() / ".config" / "onedrive" - - def canonical_confdir(value): # Collapses "." and ".." segments and any trailing slash so one account cannot # key two different caches. Deliberately not realpath(): resolving symlinks @@ -58,6 +51,18 @@ def canonical_confdir(value): return Path(os.path.normpath(str(value))) +def default_confdir(): + # Canonical, like every other confdir we report. The widget compares the + # directory a reply says it read against the one discovery gave it, and refuses + # a mismatch -- so if discovery reported "/home/u/./config/onedrive" and a poll + # reported the normalised form, that account would be refused on every poll for + # ever, showing nothing and saying nothing. + config_home = os.environ.get("XDG_CONFIG_HOME") + if config_home: + return canonical_confdir(Path(config_home) / "onedrive") + return canonical_confdir(Path.home() / ".config" / "onedrive") + + def valid_confdir(value): text = str(value) if not text.startswith("/"): @@ -97,13 +102,39 @@ def state_dir(): return base / "omarchy" / PLUGIN_ID +# Every cache field that is used as a number. The cache is JSON we wrote, but it +# is also a file on disk that anything can edit, and a *valid* JSON document with +# a string where a number belongs used to raise ValueError before the code that +# would have repaired or replaced it -- so one bad byte made that account's +# helper fail on every poll, for ever, with no way out but deleting the file. +CACHE_INTS = ( + "scanLimit", "scanAt", "usedBytes", "quotaBytes", + "quotaCheckedTs", "syncStatusCheckedTs", "remoteCheckedTs", +) + + def load_cache(path): try: with path.open("r", encoding="utf-8") as handle: value = json.load(handle) - return value if isinstance(value, dict) else {} except (OSError, json.JSONDecodeError): return {} + if not isinstance(value, dict): + return {} + # Coerce here, once, rather than at every use site: a field that cannot be a + # number is treated as absent, and the next save writes it back correctly. + for key in CACHE_INTS: + if key not in value: + continue + try: + value[key] = int(value[key] or 0) + except (TypeError, ValueError, OverflowError): + # OverflowError is the one that is easy to miss: JSON's 1e999 parses to + # float infinity, which int() refuses. Like the others, it killed the + # helper for that account on every poll, for ever, before anything could + # repair the file. + value.pop(key, None) + return value def save_cache(path, value): @@ -496,16 +527,28 @@ def account_entry(unit): if properties is None: return None load_state = (properties.get("LoadState") or [""])[0] - # not-found is a stale enablement symlink; masked is a unit deliberately turned - # off, whose empty ExecStart would otherwise read as "uses the default confdir". - if load_state in ("not-found", "masked"): + # An allowlist, not a denylist. not-found is a stale enablement symlink and + # masked is a unit deliberately turned off, but "error", "stub" and + # "bad-setting" leave ExecStart empty in exactly the same way -- and an empty + # ExecStart then read as "this unit passes no --confdir, so the client default + # applies", aliasing a template instance onto the DEFAULT account's token, + # cache and sync directory. That is the identity leak the masked case was + # already there to prevent. + if load_state != "loaded": return None # All ExecStart lines are considered together: systemd emits one line per # record, and the "prefer the record whose argv[0] is onedrive" rule only # means anything when it can see every record at once. - confdir = confdir_from_exec_start("\n".join(properties.get("ExecStart", []))) + exec_start = [line for line in properties.get("ExecStart", []) if line.strip()] + if not exec_start: + # Loaded, but with nothing to run: `systemctl show` answered with no + # properties, or the unit has no ExecStart. Either way we have learned + # nothing about where this account lives, and answering with the default + # would be a claim about identity we cannot support. + return None + confdir = confdir_from_exec_start("\n".join(exec_start)) if confdir is None: - # The unit passes no --confdir at all, so the client default genuinely applies. + # The unit really does pass no --confdir, so the client default applies. confdir = str(default_confdir()) elif valid_confdir(confdir): confdir = str(canonical_confdir(confdir)) @@ -916,7 +959,7 @@ def build_status(args): # answering with the default account's data would be a lie about identity. confdir = canonical_confdir(entry["confdir"]) if entry else None else: - confdir = default_confdir() + confdir = canonical_confdir(default_confdir()) config = client_config(confdir, onedrive_path) if confdir else { "syncDir": None, "syncMode": "Two-way", @@ -1041,6 +1084,12 @@ def build_status(args): "syncStage": journal["syncStage"] if service["running"] else "", "statusText": status_text(onedrive_path is not None, authenticated, service, journal, resume_at), "resumeAt": resume_at, + # The identity stamp. The widget cannot otherwise tell whether a reply + # describes the account it now believes this unit to be: the startup poll + # runs before discovery has read the unit's ExecStart, so it uses the + # client's default directory, and if the unit overrides it that reply belongs + # to a different account entirely. + "confdir": str(confdir) if confdir else "", "syncDir": str(sync_dir) if sync_dir else "", "syncMode": config["syncMode"], "clientVersion": config["clientVersion"], diff --git a/tests/Aggregate.test.js b/tests/Aggregate.test.js new file mode 100644 index 0000000..cdfc953 --- /dev/null +++ b/tests/Aggregate.test.js @@ -0,0 +1,633 @@ +const assert = require("node:assert") +const test = require("node:test") + +const Model = require("../Model.js") + +// A healthy, initialized account. Each case below overrides only what it means +// to test, so a case cannot pass by accident of an unrelated default. +function account(overrides) { + return Object.assign({ + initialized: true, + service: "onedrive@x.service", + instance: "x", + description: "OneDrive sync (x account)", + installed: true, + authenticated: true, + serviceAvailable: true, + running: true, + activeState: "active", + serviceFailed: false, + resyncRequired: false, + reauthRequired: false, + syncing: false, + syncMode: "Two-way", + statusText: "Monitoring", + lastSyncTs: 0 + }, overrides || {}) +} + +test("every state in the total order is reachable and ranked worst-first", () => { + const cases = [ + ["resync", { resyncRequired: true, serviceFailed: true }, 1], + ["reauth", { reauthRequired: true }, 2], + ["failed", { serviceFailed: true }, 3], + ["missing", { installed: false }, 4], + ["login", { authenticated: false }, 5], + ["unavailable", { serviceAvailable: false }, 6], + ["paused", { running: false, activeState: "inactive" }, 7], + ["starting", { activeState: "activating", running: false }, 8], + ["syncing", { syncing: true }, 9], + ["healthy", {}, 10] + ] + for (const [kind, overrides, rank] of cases) { + const state = Model.accountState(account(overrides)) + assert.equal(state.kind, kind, `expected ${kind}, got ${state.kind}`) + assert.equal(state.rank, rank) + } + // Ranks are unique and dense, so "worst" is always well defined. + const ranks = cases.map(([, , rank]) => rank) + assert.deepEqual(ranks, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) +}) + +test("the order holds when several conditions are true at once", () => { + // The table above uses one condition per case, which cannot detect a swapped + // pair. These fixtures set BOTH conditions of each adjacent pair, so the + // winner is decided by precedence alone. + const pairs = [ + [{ resyncRequired: true, reauthRequired: true }, "resync"], + [{ reauthRequired: true, serviceFailed: true }, "reauth"], + [{ serviceFailed: true, installed: false }, "failed"], + [{ installed: false, authenticated: false }, "missing"], + [{ authenticated: false, serviceAvailable: false }, "login"], + [{ serviceAvailable: false, running: false, activeState: "inactive" }, "unavailable"], + [{ running: false, activeState: "inactive", syncing: true }, "paused"], + [{ activeState: "activating", running: false, syncing: true }, "starting"] + ] + for (const [overrides, expected] of pairs) { + assert.equal(Model.accountStateKind(account(overrides)), expected, JSON.stringify(overrides)) + } +}) + +test("a just-pressed pause beats a stale syncing flag", () => { + // `active` folds in the optimistic desired state. Without it the badge lagged + // a poll in both directions, and a syncing flag left over from before the + // pause outranked the pause itself. + assert.equal(Model.accountStateKind(account({ running: true, active: false })), "paused") + assert.equal(Model.accountStateKind(account({ running: true, active: false, syncing: true })), "paused") + // ...and resuming is equally immediate. + assert.equal(Model.accountStateKind(account({ running: false, active: true, activeState: "inactive" })), "healthy") +}) + +test("a required resync outranks the failure it also sets", () => { + // Exit 126 sets both; "Resync required" is the actionable half. + assert.equal(Model.accountStateKind(account({ resyncRequired: true, serviceFailed: true })), "resync") +}) + +test("the worst account wins, and the cloud stays lit while any account is active", () => { + // The design's concrete disagreement: Dragones reauth, Personal syncing, + // Tandera paused. + const accounts = [ + account({ instance: "dragones", reauthRequired: true, statusText: "Reauthentication required" }), + account({ instance: "personal", syncing: true, statusText: "Syncing" }), + account({ instance: "tandera", running: false, activeState: "inactive", statusText: "Sync paused" }) + ] + const summary = Model.aggregateAccounts(accounts) + assert.equal(summary.kind, "reauth") + assert.equal(summary.count, 3) + assert.equal(summary.worst.instance, "dragones") + assert.equal(summary.anyActive, true) +}) + +test("only a genuinely working account lights the icon", () => { + // Discriminating: nothing here is running, so the ONLY thing that can light + // the icon is the syncing account. Drop the syncing clause from + // aggregateAccounts and this fails. + // + // `active` is deliberately UNDEFINED here, not false: undefined means "no + // optimistic intent, work it out from the sample", which is the state an + // account is in between polls. Setting it to false would mean "the user just + // paused this", and the fixture would then be asserting that a pause leaves + // the icon lit -- see the next test. + const stopped = { running: false, active: undefined, activeState: "inactive" } + const syncingOnly = [ + account(Object.assign({ instance: "a", reauthRequired: true }, stopped)), + account(Object.assign({ instance: "b", syncing: true }, stopped)) + ] + assert.equal(Model.aggregateAccounts(syncingOnly).anyActive, true) + + const noneWorking = [ + account(Object.assign({ instance: "a", reauthRequired: true }, stopped)), + account(Object.assign({ instance: "b" }, stopped)) + ] + assert.equal(Model.aggregateAccounts(noneWorking).anyActive, false) + + // An account that is running but was just paused reports active:false, and + // the icon must dim immediately rather than waiting for the next poll. + const justPaused = [account({ instance: "a", running: true, active: false })] + assert.equal(Model.aggregateAccounts(justPaused).anyActive, false) +}) + +test("pausing during a transfer dims the icon at once", () => { + // The real post-click state: the last sample still says the account was + // uploading, and `active` says the user has just stopped it. The helper only + // ever sets `syncing` alongside `running`, so this leftover flag was the one + // thing that could still light the icon -- and it did, until a confirming + // poll landed a scheduler slot later. Pausing for a meeting looked like it + // had not taken. + const midTransfer = [account({ + instance: "work", running: true, active: false, syncing: true, + statusText: "Uploading report.docx" + })] + const summary = Model.aggregateAccounts(midTransfer) + assert.equal(summary.kind, "paused") + assert.equal(summary.anyActive, false, "the icon must dim") + assert.equal(Model.barState(summary).active, false) + assert.equal(Model.barState(summary).syncing, false) + + // ...while an account transferring that nobody has touched stays lit, even + // between samples that report it running. + const stillGoing = [account({ + instance: "work", running: false, active: undefined, + activeState: "inactive", syncing: true + })] + assert.equal(Model.aggregateAccounts(stillGoing).anyActive, true) +}) + +test("all accounts idle means the icon dims", () => { + const accounts = [ + account({ instance: "a", running: false, activeState: "inactive" }), + account({ instance: "b", running: false, activeState: "inactive" }) + ] + assert.equal(Model.aggregateAccounts(accounts).anyActive, false) +}) + +test("equal ranks resolve by discovery order, not by name", () => { + const accounts = [ + account({ instance: "zebra", reauthRequired: true }), + account({ instance: "alpha", reauthRequired: true }) + ] + assert.equal(Model.aggregateAccounts(accounts).worst.instance, "zebra") +}) + +test("an uninitialized account never contributes a state", () => { + // Default property values would classify as "missing"; flashing that badge + // before the first poll is the failure this guards. But an account that has + // not reported must be EXCLUDED, not allowed to gate the whole aggregate -- + // see the freeze test below. + const accounts = [ + account({ instance: "a", reauthRequired: true }), + { initialized: false, instance: "b", installed: false, authenticated: false } + ] + const summary = Model.aggregateAccounts(accounts) + assert.notEqual(summary.kind, "missing") + // The account that HAS reported still drives the badge. + assert.equal(summary.kind, "reauth") + assert.equal(summary.worst.instance, "a") +}) + +test("one account that can never initialize does not freeze the bar", () => { + // A unit whose confdir is unreadable makes the helper exit non-zero on every + // poll, so that account's `initialized` is never set. Gating the aggregate on + // ALL accounts meant the bar sat at "checking" forever -- no badge, undimmed + // icon -- while another account was resync-required and invisible. + const accounts = [ + { initialized: false, instance: "broken" }, + account({ instance: "ok", resyncRequired: true }) + ] + const summary = Model.aggregateAccounts(accounts) + assert.equal(summary.kind, "resync") + assert.equal(summary.initialized, true) + // ...and only when NOTHING has reported is it still checking. + assert.equal(Model.aggregateAccounts([{ initialized: false }]).kind, "checking") +}) + +test("an empty account list is checking, not missing", () => { + const summary = Model.aggregateAccounts([]) + assert.equal(summary.kind, "checking") + assert.equal(summary.count, 0) +}) + +test("one account keeps exactly today's single-line tooltip", () => { + const only = account({ instance: "", statusText: "Monitoring", lastSyncTs: 0 }) + assert.equal(Model.aggregateTooltip([only], Date.now()), Model.tooltip(only, Date.now())) + assert.ok(!Model.aggregateTooltip([only], Date.now()).includes("\n")) +}) + +test("many accounts get an attributed, worst-first tooltip", () => { + const now = Date.now() + const accounts = [ + account({ instance: "dragones", reauthRequired: true, statusText: "Reauthentication required" }), + account({ instance: "personal", syncing: true, statusText: "Syncing" }), + account({ instance: "tandera", running: false, activeState: "inactive", statusText: "Sync paused" }) + ] + const lines = Model.aggregateTooltip(accounts, now).split("\n") + assert.equal(lines[0], "OneDrive · 3 accounts") + // Worst first: reauth (2) then paused (7) then syncing (9). NOTE: the design + // doc's illustrative example lists Personal before Tandera, which is discovery + // order, not the worst-first rule the same section states. The rule wins -- + // an account needing attention must not sort below one that is merely idle. + assert.ok(lines[1].startsWith("Dragones: Reauthentication required")) + assert.ok(lines[2].startsWith("Tandera: Sync paused")) + assert.ok(lines[3].startsWith("Personal: Syncing")) + assert.equal(lines.length, 4) +}) + +test("a large installation cannot grow an unbounded tooltip", () => { + const accounts = [] + for (let index = 0; index < 9; index++) accounts.push(account({ instance: "acct" + index })) + const lines = Model.aggregateTooltip(accounts, Date.now()).split("\n") + assert.equal(lines.length, 1 + 5 + 1) + assert.equal(lines[lines.length - 1], "+4 more") +}) + +test("the cap keeps the accounts that need a human, not an arbitrary five", () => { + // Nine healthy accounts plus one that needs reauth, with the unhealthy one + // LAST in discovery order. A cap that simply took the first five in discovery + // order -- or the last five -- would hide it behind "+N more". + const accounts = [] + for (let index = 0; index < 9; index++) accounts.push(account({ instance: "ok" + index })) + accounts.push(account({ instance: "broken", reauthRequired: true, + statusText: "Reauthentication required" })) + const lines = Model.aggregateTooltip(accounts, Date.now()).split("\n") + assert.ok(lines[1].startsWith("Broken: Reauthentication required"), lines.join(" | ")) + assert.equal(lines.length, 1 + 5 + 1) +}) + +test("the tooltip is checking only while nothing has reported", () => { + const nothing = [{ initialized: false, instance: "a" }, { initialized: false, instance: "b" }] + assert.equal(Model.aggregateTooltip(nothing, Date.now()), "Checking 2 OneDrive accounts…") + + // Once any account has reported, the tooltip says what it knows rather than + // hiding it behind a permanent "checking". + const partial = [account({ instance: "a", statusText: "Monitoring" }), + { initialized: false, instance: "b" }] + const text = Model.aggregateTooltip(partial, Date.now()) + assert.ok(text.startsWith("OneDrive · 2 accounts"), text) + assert.ok(text.includes("A: Monitoring"), text) + // The un-polled account must NOT be listed. Its default values classify as + // "missing", which would sort it to the TOP of a worst-first list -- so the + // tooltip would lead with "OneDrive CLI is not installed" while the badge, + // which excludes it, showed nothing at all. + assert.ok(!text.includes("B:"), text) + assert.ok(!text.includes("not installed"), text) + assert.ok(text.includes("Checking 1 more"), text) +}) + +test("every state maps onto the existing badge vocabulary", () => { + // The badge set is deliberately smaller than the state set: at eight pixels a + // badge carries a category, and the tooltip carries the detail. + assert.equal(Model.badgeKind("resync"), "attention") + assert.equal(Model.badgeKind("reauth"), "attention") + assert.equal(Model.badgeKind("failed"), "attention") + assert.equal(Model.badgeKind("missing"), "missing") + assert.equal(Model.badgeKind("unavailable"), "missing") + assert.equal(Model.badgeKind("login"), "login") + assert.equal(Model.badgeKind("paused"), "paused") + assert.equal(Model.badgeKind("starting"), "syncing") + assert.equal(Model.badgeKind("syncing"), "syncing") + // Healthy shows no badge, and neither does the pre-first-poll state -- that + // is the whole point of having a checking state. + assert.equal(Model.badgeKind("healthy"), "") + assert.equal(Model.badgeKind("checking"), "") +}) + +test("no state is left without a badge decision", () => { + const states = ["resync", "reauth", "failed", "missing", "login", "unavailable", + "paused", "starting", "syncing", "healthy", "checking"] + for (const kind of states) { + assert.equal(typeof Model.badgeKind(kind), "string", kind) + } +}) + +// origin/main:BarWidget.qml's flat ternary, transcribed. The single-account +// badge is compared against THIS over the whole flag space rather than against a +// hand-picked list -- a list can only ever be a partial oracle, and the earlier +// one omitted every combination where the two actually diverge. +function legacyBadgeKind(a) { + const active = a.active !== undefined ? a.active : (a.running || a.activeState === "activating") + const attention = a.serviceFailed || a.resyncRequired || a.reauthRequired + if (!a.installed) return "missing" + if (!a.authenticated) return "login" + if (attention) return "attention" + if (a.syncing || a.activeState === "activating") return "syncing" + if (!active) return "paused" + return "" +} + +test("the single-account badge is compared against the old ternary exhaustively", () => { + const flags = ["installed", "authenticated", "serviceAvailable", "running", + "serviceFailed", "resyncRequired", "reauthRequired", "syncing"] + const divergences = [] + for (let mask = 0; mask < (1 << flags.length); mask++) { + for (const activeState of ["active", "activating", "inactive"]) { + const overrides = { activeState: activeState } + flags.forEach((flag, bit) => { overrides[flag] = Boolean(mask & (1 << bit)) }) + overrides.active = overrides.running || activeState === "activating" + const mine = Model.badgeKind(Model.aggregateAccounts([account(overrides)]).kind) + const old = legacyBadgeKind(overrides) + if (mine !== old) divergences.push({ overrides, mine, old }) + } + } + + // Every divergence must be one of the deliberate ones, named here with its + // reason. An unlisted divergence fails, which is the point. + const allowed = [ + // The old ternary could not express "installed and signed in but the unit is + // not loaded"; it called that paused. `unavailable` is a distinct state and + // maps to the missing glyph, with the tooltip distinguishing them. + d => d.overrides.installed && d.overrides.authenticated && !d.overrides.serviceAvailable, + // The old ternary checked !authenticated before the attention flags, so a + // failing service on a signed-out account showed a login key. Attention + // outranks it now: a failure is the more actionable of the two. + d => !d.overrides.authenticated && (d.overrides.serviceFailed || d.overrides.resyncRequired + || d.overrides.reauthRequired), + // Likewise for a missing client with a failing unit. + d => !d.overrides.installed && (d.overrides.serviceFailed || d.overrides.resyncRequired + || d.overrides.reauthRequired), + // The old ternary let a stale `syncing` flag outrank a stopped service, so a + // just-pressed Pause kept a pulsing sync badge until the next poll. Pause + // wins now, which is what makes the button feel immediate. + d => d.overrides.syncing && !d.overrides.active && d.mine === "paused" && d.old === "syncing" + ] + const unexplained = divergences.filter(d => !allowed.some(rule => rule(d))) + assert.deepEqual(unexplained, [], + "undeclared badge divergence from origin/main: " + JSON.stringify(unexplained.slice(0, 3), null, 1)) +}) + +// --- which account the panel opens on ---------------------------------------- +// +// The badge is worst-of-N; every control in the panel acts on the SELECTED +// account. Those were unrelated, so the bar could show "reauthentication +// required" for Work while the panel opened on a healthy Personal -- and the +// P key, right-click Storage and the IPC controls all acted on Personal. + +// `summary` is the aggregate as the coordinator has it. +function summaryOf(kind, worstService) { + return { kind: kind, worst: worstService ? { service: worstService } : null } +} + +test("opening the panel moves to the account the badge is blaming", () => { + for (const kind of ["reauth", "resync", "failed", "missing", "login", "unavailable"]) { + assert.equal( + Model.openSelection(summaryOf(kind, "onedrive@work.service"), "healthy"), + "onedrive@work.service", kind) + } + assert.equal( + Model.openSelection(summaryOf("resync", "onedrive@work.service"), "syncing"), + "onedrive@work.service") + assert.equal( + Model.openSelection(summaryOf("failed", "onedrive@work.service"), "paused"), + "onedrive@work.service") +}) + +test("a selection the user made for a reason is not stolen", () => { + // Having opened Work to deal with its reauth, the user must not be bounced to + // Personal the moment Personal fails too. + assert.equal( + Model.openSelection(summaryOf("resync", "onedrive@personal.service"), "reauth"), "") + assert.equal( + Model.openSelection(summaryOf("failed", "onedrive@personal.service"), "login"), "") +}) + +test("clicking a spinning bar reaches the account that is actually transferring", () => { + // Under worst-first a spinning badge means the WORST account is the one + // transferring (a paused or broken account would outrank it), so the click + // goes there rather than to whatever was selected last -- often the cold-boot + // default. But never away from an account the user is already watching sync. + const spinning = summaryOf("syncing", "onedrive@personal.service") + assert.equal(Model.openSelection(spinning, "paused"), "onedrive@personal.service") + assert.equal(Model.openSelection(spinning, "healthy"), "onedrive@personal.service") + assert.equal(Model.openSelection(spinning, "syncing"), "") + assert.equal(Model.openSelection(spinning, "starting"), "") + const starting = summaryOf("starting", "onedrive@personal.service") + assert.equal(Model.openSelection(starting, "healthy"), "onedrive@personal.service") + // A paused fleet does not spin, and does not move the selection. + assert.equal(Model.openSelection(summaryOf("paused", "onedrive@work.service"), "healthy"), "") +}) + +test("a healthy fleet never moves the selection", () => { + for (const kind of ["healthy", "paused", "checking", ""]) { + assert.equal(Model.openSelection(summaryOf(kind, "onedrive@a.service"), "healthy"), "", + kind + " must not steal the selection") + } + // A deliberate pause is not a problem to be shown; nor is progress. + assert.equal(Model.needsAttention("paused"), false) + assert.equal(Model.needsAttention("syncing"), false) + assert.equal(Model.needsAttention("healthy"), false) + assert.equal(Model.needsAttention("checking"), false) + for (const kind of ["resync", "reauth", "failed", "missing", "login", "unavailable"]) { + assert.equal(Model.needsAttention(kind), true, kind + " must be actionable") + } +}) + +test("nothing to blame means nothing to select", () => { + assert.equal(Model.openSelection(summaryOf("reauth", ""), "healthy"), "") + assert.equal(Model.openSelection(summaryOf("reauth", null), "healthy"), "") + assert.equal(Model.openSelection(summaryOf("syncing", ""), "healthy"), "") + assert.equal(Model.openSelection(null, "healthy"), "") + assert.equal(Model.openSelection(undefined, "healthy"), "") +}) + +// --- what the bar icon does with an aggregate -------------------------------- + +test("the icon is lit whenever any account is working", () => { + assert.equal(Model.barState({ kind: "paused", anyActive: true, initialized: true }).active, true) + assert.equal(Model.barState({ kind: "healthy", anyActive: false, initialized: true }).active, false) +}) + +test("the icon spins for progress, and only for progress", () => { + assert.equal(Model.barState({ kind: "syncing" }).syncing, true) + assert.equal(Model.barState({ kind: "starting" }).syncing, true) + for (const kind of ["healthy", "paused", "reauth", "resync", "failed", "checking"]) { + assert.equal(Model.barState({ kind: kind }).syncing, false, kind) + } +}) + +test("one missing account does not dim a bar that has a healthy one syncing", () => { + // The regression a reviewer restored with the whole suite green: deriving the + // dim from the WORST account's kind blanked a bar that was working fine. + assert.equal(Model.barState({ + kind: "missing", anyActive: true, initialized: true }).installed, true) + assert.equal(Model.barState({ + kind: "unavailable", anyActive: true, initialized: true }).installed, true) +}) + +test("a fleet with nothing usable is dimmed", () => { + assert.equal(Model.barState({ + kind: "missing", anyActive: false, initialized: true }).installed, false) + assert.equal(Model.barState({ + kind: "unavailable", anyActive: false, initialized: true }).installed, false) + // ...but a merely paused fleet is installed, just idle. + assert.equal(Model.barState({ + kind: "paused", anyActive: false, initialized: true }).installed, true) +}) + +test("nothing is claimed before the first poll", () => { + const checking = Model.barState({ kind: "checking", anyActive: false, initialized: false }) + assert.equal(checking.installed, false) + assert.equal(checking.active, false) + assert.equal(checking.syncing, false) + // A missing aggregate must not throw on the way to the bar. + assert.deepEqual(Model.barState(null), { active: false, syncing: false, installed: false }) + assert.deepEqual(Model.barState(undefined), { active: false, syncing: false, installed: false }) +}) + +test("a helper that failed is not reported as a missing client", () => { + // A single account that has not reported carries default values, and the + // default `installed: false` renders as "OneDrive CLI is not installed" -- + // sending the user to look for a missing package when the status helper is + // what died. That is the ONE-account path; the multi-account branch already + // said "Checking N OneDrive accounts…". + const before = Model.aggregateTooltip([{ initialized: false }], Date.now()) + assert.equal(before, "Checking OneDrive…") + assert.ok(!before.includes("not installed"), before) + + const failed = Model.aggregateTooltip( + [{ initialized: false, attempted: true, lastError: "Could not run the OneDrive status helper" }], + Date.now()) + assert.ok(failed.includes("Could not run the OneDrive status helper"), failed) + assert.ok(!failed.includes("not installed"), failed) + + // ...and once it HAS reported, a genuinely missing client is still named. + const reallyMissing = Model.aggregateTooltip( + [{ initialized: true, attempted: true, installed: false }], Date.now()) + assert.ok(reallyMissing.includes("not installed"), reallyMissing) +}) + +test("an account whose helper keeps failing says so, however many accounts there are", () => { + // The one-account path already said this. With two or three accounts a + // permanently broken one was folded into "Checking N more…" -- a count that + // never goes down and never explains itself. And when python3 or the helper is + // missing outright, EVERY account is in that state at once, so the bar sat on + // "checking 3 accounts" for ever with no badge and no reason. + const dead = { + initialized: false, attempted: true, instance: "work", + description: "OneDrive sync (work account)", + lastError: "Could not run the OneDrive status helper" + } + const whole = Model.aggregateTooltip([dead, Object.assign({}, dead, { instance: "home" })], + Date.now()) + assert.ok(whole.includes("Could not run the OneDrive status helper"), whole) + // Named by their display names, so the user can tell which one is broken. + assert.ok(whole.includes("Work:"), whole) + assert.ok(whole.includes("Home:"), whole) + assert.ok(!whole.includes("Checking 2"), whole) + + // Mixed with a healthy account, it is listed rather than counted. + const mixed = Model.aggregateTooltip([account({ instance: "personal" }), dead], Date.now()) + assert.ok(mixed.includes("Could not run the OneDrive status helper"), mixed) + assert.ok(mixed.includes("Work:"), mixed) + assert.ok(!mixed.includes("Checking 1 more"), mixed) + + // An account that simply has not been polled YET is still just checking. + const early = Model.aggregateTooltip( + [account({ instance: "personal" }), { initialized: false, attempted: false }], Date.now()) + assert.ok(early.includes("Checking 1 more…"), early) + assert.ok(!early.includes("unavailable"), early) +}) + +// --- what the bar shows when one account is paused ---------------------------- +// +// Worst-first, INCLUDING a pause. For one round the badge let progress outrank a +// deliberate pause; the user overruled it: a paused account anywhere is a state +// you must be shown, and every account has to be working before the bar looks +// normal. + +test("one paused account puts the pause on the badge, whoever else is syncing", () => { + const fleet = [ + account({ instance: "work", running: false, active: false, activeState: "inactive" }), + account({ instance: "personal", syncing: true }), + account({ instance: "archive" }) + ] + const summary = Model.aggregateAccounts(fleet) + assert.equal(summary.kind, "paused") + assert.equal(summary.worst.instance, "work") + assert.equal(Model.badgeKind(summary.kind), "paused") + // The icon stays LIT -- two accounts are still working -- but it does not + // spin: the bar is reporting the pause, not the progress. + assert.equal(summary.anyActive, true) + assert.equal(Model.barState(summary).active, true) + assert.equal(Model.barState(summary).syncing, false) +}) + +test("a problem still outranks a pause, and a pause still outranks progress", () => { + const overrides = { + resync: { resyncRequired: true }, reauth: { reauthRequired: true }, + failed: { serviceFailed: true }, missing: { installed: false }, + login: { authenticated: false }, unavailable: { serviceAvailable: false } + } + for (const kind of Object.keys(overrides)) { + const summary = Model.aggregateAccounts([ + account(Object.assign({ instance: "bad" }, overrides[kind])), + account({ instance: "idle", running: false, active: false, activeState: "inactive" }), + account({ instance: "busy", syncing: true }) + ]) + assert.equal(summary.kind, kind, kind + " must reach the badge past the pause") + } + // ...and with every account at least working, progress shows. + const allWorking = Model.aggregateAccounts([ + account({ instance: "busy", syncing: true }), + account({ instance: "calm" }) + ]) + assert.equal(allWorking.kind, "syncing") + assert.equal(Model.barState(allWorking).syncing, true) +}) + +test("the tooltip still leads with the paused account", () => { + // The reminder must not be lost: the badge stops shouting about it, the + // tooltip still lists it first. + const text = Model.aggregateTooltip([ + account({ instance: "personal", syncing: true, statusText: "Syncing" }), + account({ instance: "work", running: false, active: false, + activeState: "inactive", statusText: "Sync paused" }) + ], Date.now()) + const lines = text.split("\n") + assert.ok(lines[1].startsWith("Work:"), text) +}) + +test("every badge kind has its own glyph, and they are all distinct", () => { + // Inverting any one of these mappings left the whole suite green: the bar + // would have shown the pause glyph for a reauth, and nothing would have said + // so. The glyphs are the only thing the user sees at eight pixels. + const glyphs = { + missing: "\u{f0156}", + login: "\u{f030b}", + paused: "\u{f03e4}", + syncing: "\u{f0453}", + attention: "\u{f002a}" + } + for (const kind of Object.keys(glyphs)) { + assert.equal(Model.badgeGlyph(kind), glyphs[kind], kind) + } + const drawn = Object.keys(glyphs).map(kind => Model.badgeGlyph(kind)) + assert.equal(new Set(drawn).size, drawn.length, "two badge kinds share a glyph") + // A healthy fleet, and anything unrecognised, draws no badge at all. + assert.equal(Model.badgeGlyph(""), "") + assert.equal(Model.badgeGlyph("healthy"), "") + assert.equal(Model.badgeGlyph("checking"), "") + assert.equal(Model.badgeGlyph(undefined), "") + // ...and every kind the aggregate can produce maps to a badge that has a + // glyph, or to none. Nothing may fall through to a blank badge by accident. + for (const kind of ["resync", "reauth", "failed", "missing", "login", + "unavailable", "paused", "starting", "syncing"]) { + const badge = Model.badgeKind(kind) + assert.notEqual(badge, "", kind + " must reach a badge") + assert.notEqual(Model.badgeGlyph(badge), "", kind + " -> " + badge + " must have a glyph") + } +}) + +test("the tooltip counts only the accounts it has not otherwise explained", () => { + const dead = { + initialized: false, attempted: true, instance: "work", + description: "OneDrive sync (work account)", lastError: "helper failed" + } + // Every account is broken: there is no one left to be "checking", and saying + // "Checking 0 more…" would be nonsense. + const all = Model.aggregateTooltip([dead, Object.assign({}, dead, { instance: "home" })], + Date.now()) + assert.ok(!all.includes("more…"), all) + // One broken, one not yet polled: exactly one is still checking. + const some = Model.aggregateTooltip( + [dead, { initialized: false, attempted: false }], Date.now()) + assert.ok(some.includes("Checking 1 more…"), some) +}) diff --git a/tests/Commands.test.js b/tests/Commands.test.js new file mode 100644 index 0000000..4f7f5d9 --- /dev/null +++ b/tests/Commands.test.js @@ -0,0 +1,305 @@ +const assert = require("node:assert") +const test = require("node:test") +const fs = require("node:fs") +const path = require("node:path") + +const Commands = require("../Commands.js") +const Model = require("../Model.js") + +const root = path.join(__dirname, "..") + +const PLAIN = { + service: "onedrive.service", + instance: "", + confdir: "/home/u/.config/onedrive", + description: "OneDrive Client for Linux" +} +const DRAGONES = { + service: "onedrive@dragones.service", + instance: "dragones", + confdir: "/home/u/.config/onedrive/accounts/dragones", + description: "OneDrive sync (dragones account)" +} + +test("resume units are collision-free and keep the legacy name for the plain service", () => { + assert.equal(Commands.resumeUnit(""), "omaonedrive-resume") + assert.equal(Commands.resumeUnit("dragones"), "omaonedrive-resume@dragones") + assert.equal(Commands.resumeUnit("personal"), "omaonedrive-resume@personal") + // Distinct instances can never collide on one unit name -- and the set has to + // include the shapes that can actually collide, not just well-behaved ones. + const units = ["", "dragones", "personal", "tandera"].map(Commands.resumeUnit) + assert.equal(new Set(units).size, units.length) +}) + +test("an instance that cannot yield a safe timer yields none at all", () => { + // systemd-run reads a --unit value ending in a unit suffix as THAT unit, so + // instance "foo.timer" would derive the same timer as instance "foo" and one + // account could cancel the other's pause. Refusing to derive is the safe + // answer; the caller falls back to an untimed pause. + assert.equal(Commands.resumeUnit("foo.timer"), "") + assert.equal(Commands.resumeUnit("foo.service"), "") + // ...and the collision it prevents: + assert.notEqual(Commands.resumeUnit("foo"), Commands.resumeUnit("foo.timer")) + + // The derived name must fit systemd's 255-byte limit -- and systemd-run creates + // BOTH a .timer and a .service, so the LONGER suffix is the binding one. + // These two lengths are the band where the two rules disagree: checking only + // ".timer" accepted them, and the account was then stopped by a pause whose + // timer could not be scheduled. + assert.equal(Commands.resumeUnit("x".repeat(229)), "", "229 must be refused (.service overflows)") + assert.equal(Commands.resumeUnit("x".repeat(231)), "") + const longest = Commands.resumeUnit("x".repeat(228)) + assert.notEqual(longest, "", "228 must still be accepted") + assert.ok(longest.length + ".service".length <= 255) + + // Every derived timer name, across shapes, is unique or empty. + const derived = ["", "foo", "foo.timer", "foo.service", "bar", "x".repeat(231)] + .map(Commands.resumeUnit).filter(unit => unit !== "") + assert.equal(new Set(derived).size, derived.length) +}) + +test("a status command omits a resume unit it could not derive", () => { + // Passing nothing is right: the helper then reports no resume time, rather + // than one belonging to a different account. + const command = Commands.status("/p/h.py", + { service: "onedrive@foo.timer.service", instance: "foo.timer", confdir: "/c" }, 20) + assert.ok(!command.includes("--resume-unit"), command.join(" ")) + assert.ok(!command.includes(""), command.join(" ")) +}) + +test("control vectors name the account's own service", () => { + assert.deepEqual( + Commands.control("stop", "onedrive@dragones.service"), + ["systemctl", "--user", "stop", "onedrive@dragones.service"]) + assert.deepEqual( + Commands.control("start", "onedrive.service"), + ["systemctl", "--user", "start", "onedrive.service"]) +}) + +test("interactive flows carry the account's own confdir", () => { + assert.deepEqual(Commands.login(DRAGONES.confdir), + ["omarchy-launch-terminal", "onedrive", "--confdir", DRAGONES.confdir]) + assert.deepEqual(Commands.login(DRAGONES.confdir, "reauth"), + ["omarchy-launch-terminal", "onedrive", "--confdir", DRAGONES.confdir, "--reauth"]) + assert.deepEqual(Commands.login(DRAGONES.confdir, "resync"), + ["omarchy-launch-terminal", "onedrive", "--confdir", DRAGONES.confdir, "--sync", "--resync"]) +}) + +test("an unspecified confdir is omitted from the interactive flows too", () => { + // Reachable before discovery supplies an identity: the seeded fallback + // descriptor has no confdir. "--confdir ''" is not the same as no --confdir -- + // the client would reject it -- and the old code ran a plain `onedrive` here. + assert.deepEqual(Commands.login(""), ["omarchy-launch-terminal", "onedrive"]) + assert.deepEqual(Commands.login("", "reauth"), + ["omarchy-launch-terminal", "onedrive", "--reauth"]) + assert.deepEqual(Commands.login("", "resync"), + ["omarchy-launch-terminal", "onedrive", "--sync", "--resync"]) + assert.deepEqual(Commands.login(null), ["omarchy-launch-terminal", "onedrive"]) + // No builder may ever emit an empty argument. + for (const vector of [Commands.login(""), Commands.login("", "reauth"), + Commands.status("/p/h.py", {}, 20)]) { + for (const argument of vector) assert.notEqual(argument, "", vector.join(" ")) + } +}) + +test("the plain account sends exactly today's command", () => { + // Before discovery supplies an identity, and for the plain service afterwards, + // the helper's own defaults ARE the single-account behaviour. Sending the + // default service or an empty confdir explicitly would be noise at best and, + // for an empty confdir, rejected by the helper's absolute-path rule. + assert.deepEqual( + Commands.status("/p/h.py", { service: "onedrive.service", instance: "", confdir: "" }, 20), + ["python3", "/p/h.py", "--limit", "20"]) + assert.deepEqual( + Commands.status("/p/h.py", {}, 20), + ["python3", "/p/h.py", "--limit", "20"]) + // ...but a discovered plain account still passes its real confdir. + assert.deepEqual( + Commands.status("/p/h.py", PLAIN, 20), + ["python3", "/p/h.py", "--confdir", PLAIN.confdir, "--limit", "20"]) +}) + +test("a non-default account with no confdir is not polled at all", () => { + // The helper independently re-derives the confdir in this case, so this is + // belt-and-braces -- but depending on that means the widget would silently + // report the DEFAULT account's token, quota and files under this account's + // name if that guard were ever relaxed. An empty command means "show nothing". + assert.deepEqual( + Commands.status("/p/h.py", { service: "onedrive@x.service", instance: "x", confdir: "" }, 20), + []) + // The plain service is unaffected: its default IS the correct behaviour. + assert.deepEqual( + Commands.status("/p/h.py", { service: "onedrive.service", instance: "", confdir: "" }, 20), + ["python3", "/p/h.py", "--limit", "20"]) +}) + +test("an empty status command is a refusal the caller must handle", () => { + // This shape bricked the widget once: startStatusProcess assigned the empty + // vector to a Process and set running = true. An empty command never launches, + // so onExited never fired, `refreshing` stayed true forever, and the + // coordinator's one-poll-at-a-time gate then froze EVERY account permanently. + // Account.startStatusProcess must bail before touching any state. + const refused = Commands.status("/p/h.py", + { service: "onedrive@x.service", instance: "x", confdir: "" }, 20) + assert.deepEqual(refused, []) + assert.equal(refused.length, 0) + + const source = fs.readFileSync(path.join(root, "Account.qml"), "utf8") + const fn = source.slice(source.indexOf("function startStatusProcess")) + const body = fn.slice(0, fn.indexOf("\n }")) + // The guard must come before `refreshing = true`, or the state is already + // corrupted by the time we return. + const guard = body.indexOf("command.length === 0") + const setsRefreshing = body.indexOf("refreshing = true") + assert.ok(guard !== -1, "startStatusProcess has no empty-command guard") + assert.ok(guard < setsRefreshing, + "the empty-command guard must precede `refreshing = true`") +}) + +test("the status command is account-complete", () => { + const command = Commands.status("/p/onedrive-status.py", DRAGONES, 20) + assert.deepEqual(command, [ + "python3", "/p/onedrive-status.py", + "--service", "onedrive@dragones.service", + "--confdir", DRAGONES.confdir, + "--resume-unit", "omaonedrive-resume@dragones", + "--limit", "20" + ]) + // The three identity flags must agree with each other on every invocation. + assert.equal(command[command.indexOf("--service") + 1], DRAGONES.service) + assert.equal(command[command.indexOf("--confdir") + 1], DRAGONES.confdir) + assert.equal(command[command.indexOf("--resume-unit") + 1], + Commands.resumeUnit(DRAGONES.instance)) +}) + +test("cloud modes add exactly one flag, in the right place", () => { + // Exact vectors, not membership: a stray extra flag would make a quota + // refresh also run the slow full-drive check, and membership cannot see that. + const base = ["python3", "/p/h.py", "--confdir", PLAIN.confdir, "--limit", "5"] + assert.deepEqual(Commands.status("/p/h.py", PLAIN, 5), base) + assert.deepEqual(Commands.status("/p/h.py", PLAIN, 5, "quota"), base.concat(["--quota"])) + assert.deepEqual(Commands.status("/p/h.py", PLAIN, 5, "sync-status"), base.concat(["--sync-status"])) + // An unknown mode adds nothing rather than guessing. + assert.deepEqual(Commands.status("/p/h.py", PLAIN, 5, "nonsense"), base) +}) + +test("a timed pause cancels and schedules only its own account", () => { + const unit = Commands.resumeUnit(DRAGONES.instance) + assert.deepEqual(Commands.cancelResume(unit), + ["systemctl", "--user", "stop", + "omaonedrive-resume@dragones.timer", "omaonedrive-resume@dragones.service"]) + + const schedule = Commands.scheduleResume(unit, DRAGONES.service, 15) + assert.deepEqual(schedule, [ + "systemd-run", "--user", + "--unit=omaonedrive-resume@dragones", + "--description=Resume OneDrive after timed pause", + "--on-active=15m", + "--timer-property=AccuracySec=1s", + "--collect", + "/usr/bin/systemctl", "--user", "start", "onedrive@dragones.service" + ]) + // The timer must start the same service the pause stopped. + assert.equal(schedule[schedule.length - 1], DRAGONES.service) + // ...and must not mention any other account. + assert.ok(!schedule.join(" ").includes("personal")) +}) + +test("the plain service keeps today's exact vectors", () => { + const unit = Commands.resumeUnit(PLAIN.instance) + assert.deepEqual(Commands.cancelResume(unit), + ["systemctl", "--user", "stop", "omaonedrive-resume.timer", "omaonedrive-resume.service"]) + assert.deepEqual(Commands.scheduleResume(unit, PLAIN.service, 60), [ + "systemd-run", "--user", + "--unit=omaonedrive-resume", + "--description=Resume OneDrive after timed pause", + "--on-active=60m", + "--timer-property=AccuracySec=1s", + "--collect", + "/usr/bin/systemctl", "--user", "start", "onedrive.service" + ]) +}) + +test("--resync appears only in the interactive terminal vector", () => { + // Every non-interactive builder, exercised, must be free of the mutating flags. + const vectors = [ + Commands.status("/p/h.py", DRAGONES, 20), + Commands.status("/p/h.py", DRAGONES, 20, "quota"), + Commands.status("/p/h.py", DRAGONES, 20, "sync-status"), + Commands.listAccounts("/p/h.py"), + Commands.control("start", DRAGONES.service), + Commands.control("stop", DRAGONES.service), + Commands.cancelResume(Commands.resumeUnit(DRAGONES.instance)), + Commands.scheduleResume(Commands.resumeUnit(DRAGONES.instance), DRAGONES.service, 15), + Commands.notify("normal", "s", "b") + ] + for (const vector of vectors) { + const joined = vector.join(" ") + assert.ok(!joined.includes("--resync"), joined) + assert.ok(!joined.includes("--logout"), joined) + } + // ...and it must actually BE there. Without this, deleting the push in + // Commands.login leaves the test green while Repair silently stops repairing. + const repair = Commands.login(DRAGONES.confdir, "resync") + assert.equal(repair[0], "omarchy-launch-terminal") + assert.ok(repair.includes("--resync"), repair.join(" ")) + assert.ok(repair.includes("--sync"), repair.join(" ")) +}) + +test("only the interactive vector may carry a mutating token", () => { + // Token-level, not substring: the old shell grep banned a bare --sync as well + // as --resync and --logout, and "--sync-status" must not false-positive. A new + // builder that leaked one of these would otherwise pass every other check. + const FORBIDDEN = ["--sync", "--resync", "--logout", "--reauth"] + const nonInteractive = { + status: Commands.status("/p/h.py", DRAGONES, 20), + statusQuota: Commands.status("/p/h.py", DRAGONES, 20, "quota"), + statusSync: Commands.status("/p/h.py", DRAGONES, 20, "sync-status"), + listAccounts: Commands.listAccounts("/p/h.py"), + controlStart: Commands.control("start", DRAGONES.service), + controlStop: Commands.control("stop", DRAGONES.service), + cancelResume: Commands.cancelResume("omaonedrive-resume@dragones"), + scheduleResume: Commands.scheduleResume("omaonedrive-resume@dragones", DRAGONES.service, 15), + notify: Commands.notify("normal", "s", "b") + } + for (const [name, vector] of Object.entries(nonInteractive)) { + for (const token of vector) { + assert.ok(!FORBIDDEN.includes(token), name + " leaked " + token) + } + } + // --sync-status is a distinct token and must survive the check above. + assert.ok(nonInteractive.statusSync.includes("--sync-status")) +}) + +test("no builder ever produces a shell invocation", () => { + const vectors = [ + Commands.status("/p/h.py", DRAGONES, 20), + Commands.listAccounts("/p/h.py"), + Commands.control("start", DRAGONES.service), + Commands.login(DRAGONES.confdir, "reauth"), + Commands.cancelResume("omaonedrive-resume"), + Commands.scheduleResume("omaonedrive-resume", PLAIN.service, 5), + Commands.notify("critical", "s", "b", { id: "resync", label: "Run resync repair" }) + ] + for (const vector of vectors) { + assert.ok(Array.isArray(vector)) + for (const argument of vector) assert.equal(typeof argument, "string") + assert.ok(!["sh", "bash", "/bin/sh", "/bin/bash", "env"].includes(vector[0]), vector[0]) + assert.ok(!vector.includes("-c"), vector.join(" ")) + } +}) + +test("the source itself contains no shell construction", () => { + const source = fs.readFileSync(path.join(root, "Commands.js"), "utf8") + assert.ok(!/\bbash\b|\bsh -c\b|execDetached\(\s*"/.test(source)) +}) + +test("account names are labels, not systemd sentences", () => { + assert.equal(Model.accountName("dragones"), "Dragones") + assert.equal(Model.accountName("personal"), "Personal") + assert.equal(Model.accountName("work-mail"), "Work Mail") + assert.equal(Model.accountName("work_mail"), "Work Mail") + // The plain service has no instance and keeps today's identity. + assert.equal(Model.accountName("", "OneDrive Client for Linux"), "OneDrive") + assert.equal(Model.accountName(null), "OneDrive") +}) diff --git a/tests/Discovery.test.js b/tests/Discovery.test.js new file mode 100644 index 0000000..a04aa5e --- /dev/null +++ b/tests/Discovery.test.js @@ -0,0 +1,135 @@ +const assert = require("node:assert") +const test = require("node:test") + +const Model = require("../Model.js") + +function row(service, instance, confdir) { + return { + service: service, + instance: instance === undefined ? service.replace(/^onedrive@|\.service$/g, "") : instance, + confdir: confdir || ("/c/" + service), + description: "OneDrive sync" + } +} + +const PLAIN = row("onedrive.service", "", "/c/default") +const DRAGONES = row("onedrive@dragones.service", "dragones") +const PERSONAL = row("onedrive@personal.service", "personal") +const TANDERA = row("onedrive@tandera.service", "tandera") + +test("first discovery appends everything", () => { + const plan = Model.reconcilePlan([], [DRAGONES, PERSONAL, TANDERA]) + assert.equal(plan.appends.length, 3) + assert.equal(plan.updates.length, 0) + assert.equal(plan.removes.length, 0) +}) + +test("an unchanged set is all updates and no churn", () => { + // This is the property that matters: a repeat discovery must not recreate a + // single delegate, or in-flight processes and notification latches are lost. + const current = [DRAGONES.service, PERSONAL.service, TANDERA.service] + const plan = Model.reconcilePlan(current, [DRAGONES, PERSONAL, TANDERA]) + assert.equal(plan.appends.length, 0) + assert.equal(plan.removes.length, 0) + // deepEqual on the ROW, not just the index. Pinning only the index let every + // update carry the same row, which would give every account the first + // account's confdir -- they would all poll and display one drive. + assert.deepEqual(plan.updates, [ + { index: 0, row: DRAGONES }, + { index: 1, row: PERSONAL }, + { index: 2, row: TANDERA } + ]) +}) + +test("each update carries its OWN row, not a neighbour's", () => { + // Reordered discovery: the rows must still pair with the right descriptors. + const current = [DRAGONES.service, PERSONAL.service, TANDERA.service] + const plan = Model.reconcilePlan(current, [TANDERA, DRAGONES, PERSONAL]) + const byIndex = {} + for (const update of plan.updates) byIndex[update.index] = update.row.service + assert.deepEqual(byIndex, { + 0: DRAGONES.service, + 1: PERSONAL.service, + 2: TANDERA.service + }) +}) + +test("a changed confdir updates in place rather than replacing the account", () => { + const moved = Object.assign({}, DRAGONES, { confdir: "/srv/moved" }) + const plan = Model.reconcilePlan([DRAGONES.service], [moved]) + assert.equal(plan.appends.length, 0) + assert.equal(plan.removes.length, 0) + assert.deepEqual(plan.updates, [{ index: 0, row: moved }]) +}) + +test("a new account appends without disturbing existing indices", () => { + const plan = Model.reconcilePlan([DRAGONES.service], [DRAGONES, PERSONAL]) + assert.deepEqual(plan.updates.map(u => u.index), [0]) + assert.deepEqual(plan.appends, [PERSONAL]) + assert.equal(plan.removes.length, 0) +}) + +test("a removed account is dropped, and removals are descending", () => { + const current = [DRAGONES.service, PERSONAL.service, TANDERA.service] + const plan = Model.reconcilePlan(current, [PERSONAL]) + assert.deepEqual(plan.updates.map(u => u.index), [1]) + // Descending, so applying one cannot shift the next. + assert.deepEqual(plan.removes, [2, 0]) + for (let index = 1; index < plan.removes.length; index++) { + assert.ok(plan.removes[index] < plan.removes[index - 1]) + } +}) + +test("everything disappearing removes everything", () => { + const plan = Model.reconcilePlan([DRAGONES.service, PERSONAL.service], []) + assert.deepEqual(plan.removes, [1, 0]) + assert.equal(plan.appends.length, 0) +}) + +test("reordered discovery does not churn delegates", () => { + // Discovery sorts by instance; a rename elsewhere could reorder it. Existing + // services must still map to their existing rows, not be torn down. + const current = [DRAGONES.service, PERSONAL.service] + const plan = Model.reconcilePlan(current, [PERSONAL, DRAGONES]) + assert.equal(plan.appends.length, 0) + assert.equal(plan.removes.length, 0) + assert.deepEqual(plan.updates.map(u => u.index).sort(), [0, 1]) +}) + +test("malformed discovery rows are ignored, not turned into accounts", () => { + const plan = Model.reconcilePlan([], [null, {}, { service: "" }, DRAGONES, "nonsense"]) + assert.deepEqual(plan.appends, [DRAGONES]) +}) + +test("a repeated service in one payload is taken once", () => { + const plan = Model.reconcilePlan([], [DRAGONES, DRAGONES]) + assert.equal(plan.appends.length, 1) +}) + +test("the plain account is reconciled like any other", () => { + // The single-account fallback is a first-class descriptor, not a special path. + const plan = Model.reconcilePlan([PLAIN.service], [PLAIN]) + assert.deepEqual(plan.updates, [{ index: 0, row: PLAIN }]) + assert.equal(plan.appends.length, 0) + assert.equal(plan.removes.length, 0) +}) + +test("the seeded fallback is replaced in place when discovery names it", () => { + // Startup seeds { onedrive.service, confdir: "" }; discovery returns the same + // service with a real confdir. That must update the existing delegate rather + // than remove-and-append, which would restart its processes. + const seeded = ["onedrive.service"] + const discovered = [Object.assign({}, PLAIN, { confdir: "/home/u/.config/onedrive" })] + const plan = Model.reconcilePlan(seeded, discovered) + assert.equal(plan.updates.length, 1) + assert.equal(plan.appends.length, 0) + assert.equal(plan.removes.length, 0) +}) + +test("switching from the plain account to templates removes the plain one", () => { + // A machine that stops using onedrive.service and starts three instances. + const plan = Model.reconcilePlan(["onedrive.service"], [DRAGONES, PERSONAL, TANDERA]) + assert.deepEqual(plan.removes, [0]) + assert.equal(plan.appends.length, 3) + assert.equal(plan.updates.length, 0) +}) diff --git a/tests/IpcLifecycle.test.js b/tests/IpcLifecycle.test.js index a524c39..f804208 100644 --- a/tests/IpcLifecycle.test.js +++ b/tests/IpcLifecycle.test.js @@ -10,3 +10,105 @@ test("IPC registration waits for a relocated bar slot to retire", () => { assert.match(source, /id: ipcRegistrationTimer\s+interval: 100/) assert.match(source, /IpcHandler \{\s+enabled: root\.ipcRegistrationReady\s+target: root\.moduleName/) }) + +// --- the account IPC surface ------------------------------------------------- +// +// `accounts()` and `selectAccount()` are how automation reaches a non-default +// account: every other IPC control acts on the SELECTED one, so a wrong answer +// here silently redirects a pause, a resync or a reauth to another account. +// Both were previously deletable outright with the whole suite still green. + +const Model = require("../Model.js") + +function account(instance, overrides) { + return Object.assign({ + service: instance === "" ? "onedrive.service" : "onedrive@" + instance + ".service", + instance: instance, + displayName: instance === "" ? "OneDrive" : instance, + statusText: "Monitoring" + }, overrides || {}) +} + +const THREE = [account(""), account("personal"), account("dragones")] + +test("accounts() reports every account, in discovery order, with one selected", () => { + const rows = Model.accountRows(THREE, "onedrive@personal.service") + assert.equal(rows.length, 3) + assert.deepEqual(rows.map(row => row.instance), ["", "personal", "dragones"]) + assert.deepEqual(rows.map(row => row.selected), [false, true, false]) + assert.equal(rows.filter(row => row.selected).length, 1) + assert.deepEqual(rows[1], { + service: "onedrive@personal.service", + instance: "personal", + name: "personal", + selected: true, + status: "Monitoring" + }) + // Round-trips as JSON, which is what the IPC caller actually receives. + assert.deepEqual(JSON.parse(JSON.stringify(rows)), rows) +}) + +test("a selection that matches nothing marks nothing selected", () => { + // Reachable during discovery: selectedService is cleared before the new list + // settles. Reporting a stale `selected: true` would point a script at an + // account the widget is no longer acting on. + const rows = Model.accountRows(THREE, "onedrive@gone.service") + assert.deepEqual(rows.map(row => row.selected), [false, false, false]) + assert.deepEqual(Model.accountRows([], "onedrive.service"), []) + assert.deepEqual(Model.accountRows(null, ""), []) + // An empty selection must not select the plain account by accident of both + // being falsy. + assert.deepEqual(Model.accountRows(THREE, "").map(row => row.selected), + [false, false, false]) +}) + +test("selectAccount accepts the full unit name and the bare instance", () => { + assert.equal(Model.resolveAccountTarget(THREE, "onedrive@dragones.service"), + "onedrive@dragones.service") + assert.equal(Model.resolveAccountTarget(THREE, "dragones"), "onedrive@dragones.service") + assert.equal(Model.resolveAccountTarget(THREE, "onedrive.service"), "onedrive.service") +}) + +test("an unknown or empty target resolves to nothing rather than to an account", () => { + // "" is what an unset argument arrives as, and the plain account's instance is + // also "". No account key can equal "" today, so this pins a contract rather + // than catching a live mutation -- but the day an account carries an empty + // service or instance, an unset argument must still resolve to nothing. + assert.equal(Model.resolveAccountTarget(THREE, ""), "") + assert.equal(Model.resolveAccountTarget(THREE, null), "") + assert.equal(Model.resolveAccountTarget(THREE, "personal.service"), "") + assert.equal(Model.resolveAccountTarget(THREE, "Personal"), "") + assert.equal(Model.resolveAccountTarget([], "personal"), "") +}) + +test("an exact unit name beats another account's instance, whatever the order", () => { + // systemd instance names may contain dots, so an instance CAN equal another + // account's unit name. Testing both keys account-by-account made the winner + // depend on discovery order, and the control then acted on the wrong account. + const collide = [ + account("legacy", { instance: "onedrive.service" }), + account("") + ] + assert.equal(Model.resolveAccountTarget(collide, "onedrive.service"), "onedrive.service") + // ...and reversed, so this cannot pass by the order of the fixture. + assert.equal(Model.resolveAccountTarget(collide.slice().reverse(), "onedrive.service"), + "onedrive.service") + // The instance form still reaches the account that has no unambiguous rival. + assert.equal(Model.resolveAccountTarget(collide, "legacy"), "") +}) + +test("the IPC handler still exposes both account functions, and selects without a cloud call", () => { + // The pure functions above are worthless if nothing calls them. These pin the + // wiring, which is the half that lives in a file no harness can instantiate. + const handler = source.slice(source.indexOf("IpcHandler {")) + assert.match(handler, /function accounts\(\): string/) + assert.match(handler, /function selectAccount\(target: string\): string/) + assert.match(handler, /Model\.accountRows\(root\.service\.accounts, root\.service\.selectedService\)/) + assert.match(handler, /Model\.resolveAccountTarget\(root\.service\.accounts, target\)/) + // The second argument is the fix from an earlier round: automation selecting + // an account must not inherit the panel's stale-quota retry, which contacts + // Microsoft. Dropping the `false` restores that. + assert.match(handler, /root\.service\.selectAccount\(found, false\)/) + // An unresolved target must report, not silently act on whatever is selected. + assert.match(handler, /return "unknown account: " \+ target/) +}) diff --git a/tests/Model.test.js b/tests/Model.test.js index 90d7d8c..ac72317 100644 --- a/tests/Model.test.js +++ b/tests/Model.test.js @@ -113,3 +113,78 @@ test("folder, tooltip, and plugin paths handle spaces", () => { }), "Monitoring · Upload only") assert.equal(Model.filePath("file:///tmp/Oma%20OneDrive/status.py"), "/tmp/Oma OneDrive/status.py") }) + +test("an error line is squashed to one line and capped", () => { + // systemd and the onedrive client both emit multi-line errors hundreds of + // characters long. Pasted straight into the panel they pushed every other row + // off the screen. + assert.equal(Model.elideStatus(" one\n two \t three "), "one two three") + assert.equal(Model.elideStatus(""), "") + assert.equal(Model.elideStatus(null), "") + assert.equal(Model.elideStatus(undefined), "") + + const long = "x".repeat(400) + const cut = Model.elideStatus(long) + assert.equal(cut.length, 178, cut.length + " characters") + assert.ok(cut.endsWith("…")) + // The boundary: 180 is kept whole, 181 is cut. + assert.equal(Model.elideStatus("y".repeat(180)).length, 180) + assert.ok(!Model.elideStatus("y".repeat(180)).endsWith("…")) + assert.ok(Model.elideStatus("y".repeat(181)).endsWith("…")) + // A tall error is squashed FIRST and capped second, so what survives is 177 + // characters of message rather than 177 characters of indentation. + const tall = Model.elideStatus(" a\n".repeat(100)) + assert.ok(!tall.includes("\n"), tall) + assert.ok(!tall.includes(" "), tall) + assert.equal(tall.length, 178) + assert.ok(tall.startsWith("a a a a"), tall) + // Whitespace alone is nothing to report. + assert.equal(Model.elideStatus(" \n\t "), "") +}) + +test("relative times step through every unit, including the ones nobody reaches", () => { + // Inverting the month/year boundary left the suite green. A stale account is + // exactly where these matter: "13mo ago" and "1y ago" are the difference + // between a sync that is old and one that never happened. + const now = Date.UTC(2026, 0, 1) + const ago = seconds => Model.relativeTime(now / 1000 - seconds, now) + assert.equal(ago(30), "Just now") + assert.equal(Model.relativeTime(0, now), "Never") + assert.equal(ago(60 * 5), "5m ago") + assert.equal(ago(60 * 60 * 3), "3h ago") + assert.equal(ago(60 * 60 * 24 * 5), "5d ago") + assert.equal(ago(60 * 60 * 24 * 60), "2mo ago") + // The boundary the inversion crossed: 11 months is months, 12 is years. + assert.equal(ago(60 * 60 * 24 * 30 * 11), "11mo ago") + assert.equal(ago(60 * 60 * 24 * 400), "1y ago") + assert.equal(ago(60 * 60 * 24 * 800), "2y ago") +}) + +test("a file's glyph follows its kind, and each kind has its own", () => { + // Literal glyphs, not "whatever this kind currently returns": deriving the + // expectation from the code under test made the whole check self-consistent, + // and inverting the document branch stayed green. + const byKind = { image: "\u{f02e9}", video: "\u{f022b}", document: "\u{f0219}", other: "\u{f0214}" } + assert.equal(Model.fileGlyph("a.png"), byKind.image) + assert.equal(Model.fileGlyph("a.mp4"), byKind.video) + assert.equal(Model.fileGlyph("a.docx"), byKind.document) + assert.equal(Model.fileGlyph("a.bin"), byKind.other) + const drawn = Object.values(byKind) + assert.equal(new Set(drawn).size, drawn.length, "two file kinds share a glyph") + assert.equal(Model.fileGlyph("report.pdf"), byKind.document) + assert.equal(Model.fileGlyph("notes.txt"), byKind.document) + assert.equal(Model.fileGlyph("photo.JPG"), byKind.image, "extensions are case-insensitive") + assert.equal(Model.fileGlyph(""), byKind.other) +}) + +test("the hero line names the sync mode only when it means something", () => { + // Before sign-in the client reports a mode it is not using. Showing + // "Sign in required · Two-way" reads as though syncing were configured. + assert.equal(Model.heroMeta({ statusText: "Monitoring", authenticated: true, syncMode: "Two-way" }), + "Monitoring · Two-way") + assert.equal(Model.heroMeta({ statusText: "Sign in required", authenticated: false, syncMode: "Two-way" }), + "Sign in required") + assert.equal(Model.heroMeta({ statusText: "Monitoring", authenticated: true, syncMode: "" }), + "Monitoring") + assert.equal(Model.heroMeta(null), "Checking…") +}) diff --git a/tests/Notifications.test.js b/tests/Notifications.test.js new file mode 100644 index 0000000..74552ab --- /dev/null +++ b/tests/Notifications.test.js @@ -0,0 +1,169 @@ +const assert = require("node:assert") +const test = require("node:test") + +const Model = require("../Model.js") + +function event(kind, name, overrides) { + const summaries = { + resync: "OneDrive needs a resync", + failed: "OneDrive sync failed", + reauth: "OneDrive needs reauthentication", + storage: "OneDrive storage almost full", + recovered: "OneDrive recovered" + } + const shorts = { + resync: "Resync required", + failed: "Sync failed", + reauth: "Reauthentication required", + storage: "Almost full", + recovered: "Recovered" + } + return Object.assign({ + service: "onedrive@" + name.toLowerCase() + ".service", + name: name, + kind: kind, + summary: summaries[kind], + short: shorts[kind], + body: "detail for " + name, + action: kind === "resync" ? "repair" : (kind === "recovered" || kind === "storage" ? "" : "open"), + actionLabel: kind === "resync" ? "Run resync repair" : "Open OneDrive panel" + }, overrides || {}) +} + +test("nothing to say produces no notification", () => { + assert.equal(Model.composeNotification([], true), null) + assert.equal(Model.composeNotification(null, true), null) + assert.equal(Model.composeNotification([null, undefined], true), null) +}) + +test("a single account keeps today's unattributed summary", () => { + const one = Model.composeNotification([event("reauth", "OneDrive")], false) + assert.equal(one.summary, "OneDrive needs reauthentication") + assert.ok(!one.summary.includes("—")) +}) + +test("with several accounts a lone event names its account", () => { + const one = Model.composeNotification([event("reauth", "Dragones")], true) + assert.equal(one.summary, "OneDrive needs reauthentication — Dragones") + assert.equal(one.urgency, "critical") +}) + +test("a lone resync keeps its actionable repair button", () => { + // The one case where the click can act directly rather than just opening. + const one = Model.composeNotification([event("resync", "Dragones")], true) + assert.equal(one.action, "repair") + assert.equal(one.actionLabel, "Run resync repair") + assert.equal(one.service, "onedrive@dragones.service") +}) + +test("three accounts failing at once produce ONE grouped popup", () => { + const grouped = Model.composeNotification([ + event("reauth", "Dragones"), event("failed", "Personal"), event("resync", "Tandera") + ], true) + assert.equal(grouped.summary, "OneDrive needs attention in 3 accounts") + assert.equal(grouped.body.split("\n").length, 3) + assert.ok(grouped.body.includes("Dragones:")) + assert.ok(grouped.body.includes("Personal:")) + assert.ok(grouped.body.includes("Tandera:")) +}) + +test("a grouped popup opens the WORST account, wherever it sits", () => { + // The previous fixture had the worst account LAST, so "open the last one" + // passed it. The worst must be found by rank, from any position. + const worstLast = Model.composeNotification([ + event("reauth", "Dragones"), event("resync", "Tandera") + ], true) + assert.equal(worstLast.service, "onedrive@tandera.service") + + const worstFirst = Model.composeNotification([ + event("resync", "Tandera"), event("reauth", "Dragones") + ], true) + assert.equal(worstFirst.service, "onedrive@tandera.service") + + const worstMiddle = Model.composeNotification([ + event("failed", "A"), event("resync", "Middle"), event("reauth", "C") + ], true) + assert.equal(worstMiddle.service, "onedrive@middle.service") + + // The notification's ranking must agree with the bar's: reauth outranks + // failed, so a reauth account wins over a failed one. + const order = Model.composeNotification([ + event("failed", "Failed"), event("reauth", "Reauth") + ], true) + assert.equal(order.service, "onedrive@reauth.service") + + assert.equal(worstLast.action, "open") +}) + +test("several recoveries collapse into one", () => { + const recovered = Model.composeNotification([ + event("recovered", "Dragones"), event("recovered", "Personal") + ], true) + assert.equal(recovered.summary, "2 OneDrive accounts recovered") + assert.equal(recovered.urgency, "normal") + assert.equal(recovered.action, "") +}) + +test("attention outranks recovery in the same burst, and names its account", () => { + const mixed = Model.composeNotification([ + event("recovered", "Personal"), event("reauth", "Dragones") + ], true) + assert.equal(mixed.urgency, "critical") + assert.ok(mixed.summary.includes("reauthentication")) + // Attribution is asserted in THIS branch too. It was previously pinned only in + // the lone-attention branch, so dropping the name here stayed green -- and a + // three-account user would be told "OneDrive needs reauthentication" with no + // idea which account. + assert.ok(mixed.summary.includes("Dragones"), mixed.summary) + assert.ok(mixed.body.includes("Personal")) +}) + +test("storage alone stays a normal-urgency notification", () => { + const one = Model.composeNotification([event("storage", "Dragones")], true) + assert.equal(one.urgency, "normal") + assert.equal(one.summary, "OneDrive storage almost full — Dragones") + const many = Model.composeNotification([ + event("storage", "Dragones"), event("storage", "Personal") + ], true) + assert.equal(many.summary, "2 OneDrive accounts are almost full") +}) + +test("a burst reports every account it received, in one notification", () => { + // The old version of this test asserted only that a summary was a string. + // composeNotification returns one object by construction, so NO mutation could + // fail it. What actually matters is that nothing is DROPPED: a storage + // threshold is edge-latched, so an event omitted here is lost until it clears + // and re-arms. + const burst = [event("resync", "A"), event("failed", "B"), + event("storage", "C"), event("recovered", "D")] + const composed = Model.composeNotification(burst, true) + assert.equal(typeof composed.summary, "string") + for (const name of ["A", "B", "C", "D"]) { + assert.ok(composed.body.includes(name + ":"), name + " missing from: " + composed.body) + } +}) + +test("the grouped count is accounts, not events", () => { + // One account with two conditions is one account. Counting events produced + // "OneDrive needs attention in 2 accounts" for a single-account machine. + const sameAccount = [ + Object.assign(event("resync", "Solo"), { service: "onedrive.service" }), + Object.assign(event("reauth", "Solo"), { service: "onedrive.service" }) + ] + // Rejecting only the literal "2 accounts" let a mutation reporting "3 accounts" + // stay green. Assert the shape instead: a single account is never counted. + const solo = Model.composeNotification(sameAccount, true) + assert.ok(!/\d+ accounts/.test(solo.summary), solo.summary) + assert.ok(solo.summary.includes("Solo"), solo.summary) + const twoAccounts = [event("resync", "A"), event("reauth", "B")] + assert.ok(Model.composeNotification(twoAccounts, true).summary.includes("2 accounts")) +}) + +test("a startup round of pre-existing problems is one baseline popup", () => { + // Three accounts that were already unhealthy when the widget started must not + // fire three notifications. + const baseline = Model.composeNotification([ + event("reauth", "Dragones"), event("reauth", "Personal"), event("failed", "Tandera") + ], true) + assert.equal(baseline.summary, "OneDrive needs attention in 3 accounts") +}) diff --git a/tests/PanelWiring.test.js b/tests/PanelWiring.test.js new file mode 100644 index 0000000..e457b52 --- /dev/null +++ b/tests/PanelWiring.test.js @@ -0,0 +1,151 @@ +const assert = require("node:assert/strict") +const { readFileSync } = require("node:fs") +const path = require("node:path") +const test = require("node:test") + +// BarWidget.qml and Panel.qml derive from the bar's own types, so neither can be +// instantiated headless and neither is reachable by the QML harness. A reviewer +// showed what regex assertions cost there: with `assert.match` alone, the broken +// "one missing account dims a healthy bar" expression could be restored, the IPC +// handlers given early returns, and Panel.open()'s selection call wrapped in +// `if (false)` -- all with the entire suite green, because a regex only proves +// the text appears SOMEWHERE. +// +// So these compare the whole decision, exactly. Every rule is tested for real in +// Model.js; what is pinned here is that these two files do nothing but delegate +// to it. That makes them brittle by design: if you change the wiring, change the +// expectation below, and the diff will say what the bar's behaviour now is. + +function occurrences(text, needle) { + let count = 0 + let at = text.indexOf(needle) + while (at !== -1) { + count += 1 + at = text.indexOf(needle, at + needle.length) + } + return count +} + +const root = path.join(__dirname, "..") +const barWidget = readFileSync(path.join(root, "BarWidget.qml"), "utf8") +const panel = readFileSync(path.join(root, "Panel.qml"), "utf8") + +// Comments are stripped so wording changes do not break the test; everything +// else -- including an added `if (false)` or an early return -- does. +// +// Both markers must be UNIQUE. A reviewer defeated an earlier version of this +// by adding a string property containing the expected text: indexOf found the +// decoy, the comparison passed, and the live wiring below it was wrong. +function code(source, from, to) { + assert.equal(occurrences(source, from), 1, + "marker is not unique, so this test can be spoofed: " + from) + const start = source.indexOf(from) + const end = source.indexOf(to, start + from.length) + assert.notEqual(end, -1, "could not find: " + to) + assert.equal(occurrences(source.slice(start), to), 1, + "end marker is not unique after the start: " + to) + return source + .slice(start, end) + .split("\n") + .map(line => line.replace(/(^|\s)\/\/.*$/, "")) + .join(" ") + .replace(/\s+/g, " ") + .trim() +} + +test("the bar's state is Model's answer, unmodified", () => { + // Nothing may be recomputed, overridden or short-circuited on the way to the + // icon. Every one of these rules is table-tested in Aggregate.test.js. + assert.equal( + code(barWidget, "readonly property var barState:", "readonly property color iconColor:"), + 'readonly property var barState: Model.barState(aggregate) ' + + 'readonly property bool active: barState.active ' + + 'readonly property bool syncing: barState.syncing ' + + 'readonly property bool installed: barState.installed') +}) + +test("the badge follows the worst account's kind, pause included", () => { + // Worst-first, pause included -- the user's decision: a paused account + // anywhere is a state the bar must show, and every account has to be working + // before it looks normal. + assert.equal( + code(barWidget, "readonly property string badgeKind:", "readonly property color badgeColor:"), + 'readonly property string badgeKind: Model.badgeKind(aggregate.kind) ' + + 'readonly property string badgeGlyph: Model.badgeGlyph(badgeKind)') +}) + +test("the aggregate comes from the service, with a checking placeholder", () => { + assert.equal( + code(barWidget, "readonly property var aggregate:", "readonly property int accountCount:"), + 'readonly property var aggregate: service ? service.aggregate ' + + ': ({ kind: "checking", count: 0, anyActive: false, initialized: false })') +}) + +test("the IPC account handlers delegate and do nothing else", () => { + assert.equal( + code(barWidget, "function accounts(): string", "function selectAccount(target: string)"), + 'function accounts(): string { if (!root.service) return "[]" ' + + 'return JSON.stringify(Model.accountRows(root.service.accounts, root.service.selectedService)) }') + assert.equal( + code(barWidget, "function selectAccount(target: string)", "\n }\n\n BarIconButton"), + 'function selectAccount(target: string): string { if (!root.service) return "no accounts" ' + + 'var found = Model.resolveAccountTarget(root.service.accounts, target) ' + + 'if (found === "") return "unknown account: " + target ' + + // false: automation must not inherit the panel's stale-quota retry, which + // contacts Microsoft. + 'root.service.selectAccount(found, false) return "ok" }') +}) + +test("opening the panel selects the badged account first, then refreshes", () => { + // Order matters: the panel's bindings and its refresh both read the selection. + assert.equal( + code(panel, "function open() {", "Qt.callLater"), + 'function open() { root.controller.show() oneDrive.selectBadgedAccount() ' + + 'oneDrive.refreshSelected() oneDrive.retryStaleQuotaOnOpen()') +}) + +test("every IPC control routes to the coordinator, and none of them to another one", () => { + // A reviewer changed IPC `resync()` to call pause() and the whole suite stayed + // green: only five hand-picked regions were pinned, and this was not one. The + // fix is to pin the WHOLE handler -- fourteen one-line delegations, each of + // which is a command a script can run against the user's real account. + const handler = code(barWidget, "IpcHandler {", "\n }\n\n BarIconButton") + assert.equal(handler, + 'IpcHandler { enabled: root.ipcRegistrationReady target: root.moduleName ' + + 'function open(): void { root.open() } ' + + 'function close(): void { root.close() } ' + + 'function show(): void { root.open() } ' + + 'function hide(): void { root.close() } ' + + 'function toggle(): void { root.togglePanel() } ' + + 'function refresh(): string { if (root.service) root.service.refresh(false) return "ok" } ' + + 'function check(): string { if (root.service) root.service.checkQuota() return "ok" } ' + + 'function fullStatus(): string { if (root.service) root.service.checkFullStatus() return "ok" } ' + + 'function pause(): string { if (root.service) root.service.pause() return "ok" } ' + + 'function pauseFor(minutes: int): string { if (root.service) root.service.pauseFor(minutes) return "ok" } ' + + 'function resume(): string { if (root.service) root.service.resume() return "ok" } ' + + 'function toggleSync(): string { if (root.service) root.service.toggleRunning() return "ok" } ' + + 'function folder(): string { if (root.service) root.service.openFolder() return "ok" } ' + + 'function web(): string { if (root.service) root.service.openWeb() return "ok" } ' + + 'function resync(): string { if (root.service) root.service.repairResync() return "ok" } ' + + 'function status(): string { return root.service ? root.service.statusText : "Checking…" } ' + + 'function accounts(): string { if (!root.service) return "[]" ' + + 'return JSON.stringify(Model.accountRows(root.service.accounts, root.service.selectedService)) } ' + + 'function selectAccount(target: string): string { if (!root.service) return "no accounts" ' + + 'var found = Model.resolveAccountTarget(root.service.accounts, target) ' + + 'if (found === "") return "unknown account: " + target ' + + 'root.service.selectAccount(found, false) return "ok" }') +}) + +test("every bar gesture points at the badged account before acting", () => { + // Left-click was fixed; middle-click and right-click were not, so middle-click + // opened the folder of whichever account happened to be selected -- at cold + // boot, the first discovered one -- while the badge was about another, and + // right-click spent the single 30-second cloud slot on the wrong account. + assert.equal( + code(barWidget, "onPressed: function(buttonCode) {", "\n }\n }\n}"), + 'onPressed: function(buttonCode) { ' + + 'if (root.service) root.service.selectBadgedAccount() ' + + 'if (buttonCode === Qt.RightButton && root.service) root.service.checkQuota() ' + + 'else if (buttonCode === Qt.MiddleButton && root.service) root.service.openFolder() ' + + 'else root.togglePanel()') +}) diff --git a/tests/Scheduler.test.js b/tests/Scheduler.test.js new file mode 100644 index 0000000..3b249a5 --- /dev/null +++ b/tests/Scheduler.test.js @@ -0,0 +1,212 @@ +const assert = require("node:assert") +const test = require("node:test") + +const Model = require("../Model.js") + +// These exist because a reviewer pointed out that deleting the ramp or bypassing +// the cloud semaphore passed the entire suite: the logic lived in QML, which has +// no harness here. Pulling the DECISIONS out makes them assertable. + +// A healthy account that has already reported. `initialized` has to DEFAULT to +// true: without it every fixture looked equally unreported, so reverting the +// product's priority from `attempted` back to `initialized` -- the monopoly bug +// -- left the test named after it green. +function acct(overrides) { + return Object.assign( + { routinePolling: false, busy: false, attempted: true, initialized: true }, + overrides || {}) +} + +test("an account already polling is never handed another slot", () => { + const accounts = [acct({ routinePolling: true }), acct()] + assert.equal(Model.nextPollIndex(accounts, 0), 1) + // ...and if every account is busy, nobody is picked. + assert.equal(Model.nextPollIndex([acct({ routinePolling: true })], 0), -1) +}) + +test("accounts that have not reported take priority over ones that have", () => { + const accounts = [acct(), acct({ attempted: false }), acct()] + assert.equal(Model.nextPollIndex(accounts, 0), 1) +}) + +test("several unreported accounts interleave instead of one taking every slot", () => { + // This is the defect this function was extracted to prevent: the old code + // returned the FIRST unreported account every slot, so with three of them the + // second and third were never polled until the first exhausted a 15-slot + // counter -- 150 seconds at default settings, with no badge on the bar. + const accounts = [acct({ attempted: false }), acct({ attempted: false }), acct({ attempted: false })] + const picked = [] + let cursor = 0 + for (let slot = 0; slot < 6; slot++) { + const index = Model.nextPollIndex(accounts, cursor) + picked.push(index) + cursor = (index + 1) % accounts.length + } + assert.deepEqual(picked, [0, 1, 2, 0, 1, 2]) + // Every account is reached within one round, not after the first gives up. + assert.equal(new Set(picked.slice(0, 3)).size, 3) +}) + +test("a paused account does not consume startup priority", () => { + // Priority is "has not reported", not "not running". A deliberately paused + // account is a known state; treating it as ramping made it monopolise slots + // every time the user paused it. + const accounts = [acct({ attempted: true }), acct({ attempted: false })] + assert.equal(Model.nextPollIndex(accounts, 0), 1) +}) + +test("the round-robin advances and wraps", () => { + const accounts = [acct(), acct(), acct()] + assert.equal(Model.nextPollIndex(accounts, 0), 0) + assert.equal(Model.nextPollIndex(accounts, 1), 1) + assert.equal(Model.nextPollIndex(accounts, 2), 2) + assert.equal(Model.nextPollIndex(accounts, 3), 0) + // A cursor left over from a longer list cannot index out of range. + assert.equal(Model.nextPollIndex(accounts, 99), 0) + assert.equal(Model.nextPollIndex([], 0), -1) +}) + +test("a busy slot queues a cloud check rather than starting a second one", () => { + // Two concurrent 30-second cloud checks is the invariant this protects. + assert.equal(Model.cloudDecision(false, [], "a.service", "quota"), "start") + assert.equal(Model.cloudDecision(true, [], "a.service", "quota"), "queue") +}) + +test("a repeated request is dropped, not queued twice", () => { + const queue = [{ service: "a.service", mode: "quota" }] + assert.equal(Model.cloudDecision(true, queue, "a.service", "quota"), "drop") + // A different mode for the same account is a different request. + assert.equal(Model.cloudDecision(true, queue, "a.service", "sync-status"), "queue") + // ...as is the same mode for a different account. + assert.equal(Model.cloudDecision(true, queue, "b.service", "quota"), "queue") +}) + +test("a malformed cloud request is dropped", () => { + assert.equal(Model.cloudDecision(false, [], "", "quota"), "drop") + assert.equal(Model.cloudDecision(false, [], "a.service", ""), "drop") +}) + +test("the queue cannot grow past one entry per account per mode", () => { + // Bounded by construction: 2 modes x N accounts. A user leaning on the refresh + // key cannot build a backlog. + const queue = [] + for (const service of ["a", "b", "c"]) { + for (const mode of ["quota", "sync-status"]) { + for (let repeat = 0; repeat < 5; repeat++) { + if (Model.cloudDecision(true, queue, service, mode) === "queue") { + queue.push({ service: service, mode: mode }) + } + } + } + } + assert.equal(queue.length, 6) +}) + +test("an account busy with a cloud check is skipped, not handed a wasted slot", () => { + // routinePolling is false during a cloud check -- it is not a routine poll -- + // but the account will still refuse the slot, so handing it one wastes the + // tick entirely. Deleting the busy guard used to keep this file green. + const accounts = [acct({ busy: true }), acct()] + assert.equal(Model.nextPollIndex(accounts, 0), 1) + + // ...including when it is the unreported account that would otherwise take + // pass-0 priority. + const priority = [acct({ busy: true, attempted: false }), acct()] + assert.equal(Model.nextPollIndex(priority, 0), 1) + + // If every account is busy for any reason, nobody is picked. + assert.equal(Model.nextPollIndex([acct({ busy: true }), acct({ routinePolling: true })], 0), -1) +}) + +test("an account that can never report does not monopolise the scheduler", () => { + // A helper that always fails leaves `initialized` false forever. Priority is + // "has not been ATTEMPTED", so such an account rejoins the round-robin after + // its first try instead of taking every slot for the life of the session. + // + // An earlier version of this test asserted [0, 1, 0, 1] -- encoding the + // monopoly as the expected behaviour, with the healthy third account never + // polled at all. + const accounts = [ + acct({ attempted: true, initialized: false }), // broken, already tried + acct({ attempted: true }), + acct({ attempted: true }) + ] + const picks = [] + let cursor = 0 + for (let i = 0; i < 9; i++) { + const index = Model.nextPollIndex(accounts, cursor) + picks.push(index) + cursor = (index + 1) % accounts.length + } + assert.deepEqual(picks, [0, 1, 2, 0, 1, 2, 0, 1, 2]) + assert.equal(new Set(picks).size, 3, "every account must be reached") +}) + +test("priority is 'not yet attempted', not 'not yet reported'", () => { + // Before the first attempt, an account jumps the queue -- that is the ramp. + assert.equal(Model.nextPollIndex( + [acct({ attempted: true }), acct({ attempted: false })], 0), 1) + // After it, even if it never produced a usable sample, it waits its turn. + assert.equal(Model.nextPollIndex( + [acct({ attempted: true }), acct({ attempted: true, initialized: false })], 0), 0) +}) + +test("a request identical to the one already RUNNING is dropped", () => { + // Seeing only a busy boolean, the coordinator could not tell that the check in + // flight was already this exact (service, mode) -- so a repeat click queued a + // duplicate that ran the same 30-second query again the moment it finished. + const active = { service: "a.service", mode: "quota" } + assert.equal(Model.cloudDecision(true, [], "a.service", "quota", active), "drop") + // A different mode for the same account is still a real request. + assert.equal(Model.cloudDecision(true, [], "a.service", "sync-status", active), "queue") + // ...as is the same mode for another account. + assert.equal(Model.cloudDecision(true, [], "b.service", "quota", active), "queue") + // Without the active pair the old behaviour is unchanged. + assert.equal(Model.cloudDecision(true, [], "a.service", "quota"), "queue") +}) + +test("an account waiting on a control it just ran gets the next slot", () => { + // `requestRefresh` is drop-not-queue, so the settle loop's asks are swallowed + // whenever a neighbour holds the shared slot. With two or three accounts that + // meant the poll confirming a pause could be delayed indefinitely -- and the + // bar reverted to a sample taken before the pause. Settling therefore outranks + // even the startup ramp, which costs at most a few seconds of "Checking". + const accounts = [ + acct({ attempted: true }), + acct({ attempted: false }), + acct({ attempted: true, settling: true }) + ] + assert.equal(Model.nextPollIndex(accounts, 0), 2) + // ...from any cursor position, since the user's click decides urgency, not + // where the round-robin happened to be. + assert.equal(Model.nextPollIndex(accounts, 1), 2) + assert.equal(Model.nextPollIndex(accounts, 2), 2) +}) + +test("a settling account that is busy still does not get a slot it would refuse", () => { + assert.equal(Model.nextPollIndex( + [acct({ settling: true, routinePolling: true }), acct()], 0), 1) + assert.equal(Model.nextPollIndex( + [acct({ settling: true, busy: true }), acct()], 0), 1) +}) + +test("two settling accounts interleave rather than one taking every slot", () => { + const accounts = [acct({ settling: true }), acct({ settling: true }), acct()] + const picks = [] + let cursor = 0 + for (let i = 0; i < 4; i++) { + const index = Model.nextPollIndex(accounts, cursor) + picks.push(index) + cursor = (index + 1) % accounts.length + } + assert.deepEqual(picks, [0, 1, 0, 1]) +}) + +test("settling priority does not disturb the ordinary round-robin", () => { + // Nothing settling: the previous behaviour, unchanged. + const accounts = [acct(), acct(), acct()] + assert.deepEqual([0, 1, 2, 0].map((_, i) => Model.nextPollIndex(accounts, i)), + [0, 1, 2, 0]) + // Ramp still beats steady state when nothing is settling. + assert.equal(Model.nextPollIndex([acct(), acct({ attempted: false })], 0), 1) +}) diff --git a/tests/Status.test.sh b/tests/Status.test.sh index 1fb50d2..a133142 100755 --- a/tests/Status.test.sh +++ b/tests/Status.test.sh @@ -167,6 +167,34 @@ case " $* " in description='onedrive@ghost.service' args='' ;; + onedrive@broken.service) + # A unit systemd could not parse. Like masked and not-found, it has no + # ExecStart -- and an empty ExecStart used to read as "passes no + # --confdir", which aliased this instance onto the DEFAULT account. + load=error + description='onedrive@broken.service' + args='' + ;; + onedrive@stub.service) + load=stub + description='onedrive@stub.service' + args='' + ;; + onedrive@unloadable.service) + # LoadState=error WITH a perfectly good ExecStart. systemd could not load + # this unit, so it will never run: offering it as an account gives the + # user a tab whose pause and resume buttons do nothing. The empty- + # ExecStart rule does not catch this one -- only the allowlist does. + load=error + description='OneDrive sync (unloadable account)' + args='--monitor --confdir=/srv/onedrive/mailboxes/unloadable' + ;; + onedrive@hollow.service) + # Loaded, but `systemctl show` tells us nothing about what it runs. + load=loaded + description='OneDrive sync (hollow account)' + args='' + ;; *) exit 1 ;; esac echo "LoadState=$load" @@ -460,10 +488,21 @@ fi # ExecStart must not read as "uses the default confdir"; and a unit whose # ExecStart carries a present-but-unusable confdir must be dropped rather than # silently aliased onto the default account. +# +# error, stub and a LOADED unit whose `systemctl show` reports no ExecStart at +# all reach the same place by a different road: an empty ExecStart read as "this +# unit passes no --confdir, so the client default applies", which pointed a +# template instance at the DEFAULT account's token, cache and sync directory. +# The rule is an allowlist -- loaded, with something to run -- not a list of the +# bad states we happened to think of. units=$'onedrive.service loaded active running OneDrive Client for Linux onedrive@.service loaded active running OneDrive sync template onedrive@ghost.service not-found inactive dead onedrive@ghost.service onedrive@masked.service masked inactive dead onedrive@masked.service +onedrive@broken.service error inactive dead onedrive@broken.service +onedrive@stub.service stub inactive dead onedrive@stub.service +onedrive@unloadable.service error inactive dead OneDrive sync (unloadable account) +onedrive@hollow.service loaded inactive dead OneDrive sync (hollow account) onedrive@bogus.service loaded inactive dead OneDrive sync (bogus account) onedrive@work.service loaded inactive dead OneDrive sync (work account) onedrive@personal.service loaded active running OneDrive sync (personal account)' @@ -472,6 +511,11 @@ log_lines_before=$(wc -l <"$FAKE_ONEDRIVE_LOG") FAKE_UNITS="$units" python3 "$root/onedrive-status.py" --list-accounts >"$test_root/accounts.json" jq -e --arg default_confdir "$test_home/.config/onedrive" ' length == 3 + and ([.[].service] | index("onedrive@broken.service")) == null + and ([.[].service] | index("onedrive@stub.service")) == null + and ([.[].service] | index("onedrive@hollow.service")) == null + and ([.[].service] | index("onedrive@unloadable.service")) == null + and ([.[] | select(.confdir == $default_confdir)] | length) == 1 and .[0].service == "onedrive.service" and .[0].instance == "" and .[0].confdir == $default_confdir @@ -688,6 +732,122 @@ done python3 "$root/onedrive-status.py" --confdir "$test_home/.config/../.config/onedrive" --limit 5 \ >"$test_root/dotdot.json" jq -e --arg sync_dir "$sync_dir" '.syncDir == $sync_dir' "$test_root/dotdot.json" >/dev/null +# Every reply names the config directory it actually READ, canonicalised. The +# widget's startup poll runs before discovery has read the unit's ExecStart, so +# it uses the client's default; without this stamp the widget cannot tell that +# reply apart from one describing the account it later learns this unit is, and +# it showed the default account's sync directory, quota and token state under +# the other account's name. +jq -e --arg confdir "$test_home/.config/onedrive" '.confdir == $confdir' \ + "$test_root/dotdot.json" >/dev/null || { + echo "the reply did not report the canonical confdir it read" >&2 + jq -r '.confdir' "$test_root/dotdot.json" >&2 + exit 1 +} +python3 "$root/onedrive-status.py" --limit 5 >"$test_root/stamp.json" + +# THE invariant behind the stamp: the directory discovery reports for an account +# and the directory a poll of that account reports must be byte-identical. If +# they ever diverge -- an un-normalised default, a trailing slash -- the widget +# refuses every reply and that account shows nothing, for ever, with no error to +# explain it. Check it for the plain service, whose confdir comes from +# default_confdir() on one path and canonical_confdir() on the other. +FAKE_UNITS='onedrive.service loaded active running OneDrive Client for Linux' \ + python3 "$root/onedrive-status.py" --list-accounts >"$test_root/stamp-accounts.json" +discovered=$(jq -r '.[0].confdir' "$test_root/stamp-accounts.json") +polled=$(python3 "$root/onedrive-status.py" \ + --confdir "$discovered" --limit 5 | jq -r '.confdir') +if [[ "$discovered" != "$polled" ]]; then + echo "discovery and polling disagree about the same account's confdir" >&2 + echo " discovery: $discovered" >&2 + echo " poll: $polled" >&2 + exit 1 +fi +# ...and it holds when XDG_CONFIG_HOME is not already normalised, which is where +# the two paths used to diverge. +# pathlib silently drops "." segments but keeps "..", so ".." is the form that +# actually reaches normpath and diverges. +odd_home="$test_home/.config/../.config" +XDG_CONFIG_HOME="$odd_home" FAKE_UNITS='onedrive.service loaded active running OneDrive Client for Linux' \ + python3 "$root/onedrive-status.py" --list-accounts >"$test_root/odd-accounts.json" +# Polled the way the WIDGET polls it: with the confdir discovery just reported. +# That is the path that normalises, so this is where the two used to diverge. +XDG_CONFIG_HOME="$odd_home" python3 "$root/onedrive-status.py" \ + --confdir "$(jq -r '.[0].confdir' "$test_root/odd-accounts.json")" --limit 5 \ + >"$test_root/odd-stamp.json" +odd_discovered=$(jq -r '.[0].confdir' "$test_root/odd-accounts.json") +odd_polled=$(jq -r '.confdir' "$test_root/odd-stamp.json") +if [[ "$odd_discovered" != "$odd_polled" ]]; then + echo "an unnormalised XDG_CONFIG_HOME made discovery and polling disagree" >&2 + echo " discovery: $odd_discovered" >&2 + echo " poll: $odd_polled" >&2 + exit 1 +fi +if [[ "$odd_discovered" == *"/../"* ]]; then + echo "the reported confdir was not normalised: $odd_discovered" >&2 + exit 1 +fi + +# A cache file that is valid JSON but has a string where a number belongs used to +# raise ValueError before the code that would have repaired or replaced it. One +# bad byte -- an editor, a truncated write, a botched migration -- and that +# account's helper failed on EVERY poll from then on, for ever, with no way out +# but finding and deleting the file. The reply below must still be produced. +default_cache="$XDG_STATE_HOME/omarchy/io.github.salemsayed.omaonedrive/status-cache.json" +cat >"$default_cache" <<'JSON' +{"scanLimit":"not-a-number","scanAt":"also-bad","usedBytes":null,"quotaBytes":[], + "quotaCheckedTs":1e999,"remoteStatus":"Not checked","files":[]} +JSON +python3 "$root/onedrive-status.py" --limit 5 >"$test_root/badcache.json" || { + echo "a malformed cache value killed the helper" >&2 + exit 1 +} +jq -e '.ok == true and .usedBytes == 0 and .quotaBytes == 0 and .quotaCheckedTs == 0' \ + "$test_root/badcache.json" >/dev/null || { + echo "a malformed cache was not treated as absent" >&2 + exit 1 +} +# JSON's 1e999 parses to float infinity, which int() refuses with OverflowError +# rather than ValueError -- a different exception through the same fatal door. +jq -e '.quotaCheckedTs == 0' "$test_root/badcache.json" >/dev/null || { + echo "an infinite cache value was not treated as absent" >&2 + exit 1 +} +# A truncated write -- the ordinary way this file goes wrong, on a full disk or +# a hard poweroff -- must also be survivable. +printf '{"usedBytes": 12' >"$default_cache" +python3 "$root/onedrive-status.py" --limit 5 >"$test_root/truncated.json" || { + echo "a truncated cache killed the helper" >&2 + exit 1 +} +jq -e '.ok == true and .usedBytes == 0' "$test_root/truncated.json" >/dev/null || { + echo "a truncated cache was not discarded" >&2 + exit 1 +} +# ...and a cache that is valid JSON but not an object at all. +printf '["not", "an", "object"]' >"$default_cache" +python3 "$root/onedrive-status.py" --limit 5 >/dev/null || { + echo "a non-object cache killed the helper" >&2 + exit 1 +} + +cat >"$default_cache" <<'JSON' +{"scanLimit":"not-a-number","scanAt":"also-bad","files":[]} +JSON +python3 "$root/onedrive-status.py" --limit 5 >/dev/null +# ...and the next write repairs it, rather than leaving the bad value to be +# re-read for ever. +jq -e '(.scanLimit | type) == "number" and (.scanAt | type) == "number"' \ + "$default_cache" >/dev/null || { + echo "the cache was not repaired on save" >&2 + cat "$default_cache" >&2 + exit 1 +} +jq -e --arg confdir "$test_home/.config/onedrive" '.confdir == $confdir' \ + "$test_root/stamp.json" >/dev/null || { + echo "a default-confdir reply did not report which directory it read" >&2 + exit 1 +} # Today's no-flag invocation keeps exactly the fields the QML layer reads. python3 "$root/onedrive-status.py" --limit 5 >"$test_root/shape.json" @@ -695,7 +855,7 @@ jq -e ' ([keys_unsorted[]] | sort) == ([ "ok","installed","serviceAvailable","running","enabled","activeState","subState", "serviceResult","serviceExitStatus","serviceFailed","resyncRequired","authenticated", - "reauthRequired","syncing","syncStage","statusText","resumeAt","syncDir","syncMode", + "reauthRequired","syncing","syncStage","statusText","resumeAt","confdir","syncDir","syncMode", "clientVersion","lastSyncTs","usedBytes","quotaBytes","quotaKnown","quotaCheckedTs", "quotaError","remoteStatus","syncStatusCheckedTs","syncStatusError","remoteCheckedTs", "remoteError","files","activity","lastError" @@ -936,26 +1096,28 @@ if python3 "$root/onedrive-status.py" --list-accounts '--service=-Mguest.service exit 1 fi -grep -Fq '["systemctl", "--user", "stop", "onedrive.service"]' "$root/Service.qml" -grep -Fq '["systemctl", "--user", "start", "onedrive.service"]' "$root/Service.qml" -grep -Fq '["omarchy-launch-terminal", "onedrive"]' "$root/Service.qml" -grep -Fq '["omarchy-launch-terminal", "onedrive", "--reauth"]' "$root/Service.qml" -grep -Fq '["omarchy-launch-terminal", "onedrive", "--sync", "--resync"]' "$root/Service.qml" -grep -Fq '"notify-send"' "$root/Service.qml" +# The command vectors themselves are asserted as exact arrays, with injected +# account identity, in tests/Commands.test.js. Greping the QML for hard-coded +# unit names is what those tests replace: a per-account vector has no fixed +# string to grep for, and re-pinning one account's spelling here would only +# assert that the multi-account work had not happened. grep -Fq 'retryStaleQuotaOnOpen' "$root/Panel.qml" grep -Fq '(oneDrive.syncing ? "Syncing" : (oneDrive.active ? "Monitoring" : "Paused"))' "$root/Panel.qml" -grep -Fq 'command.push("--quota")' "$root/Service.qml" -grep -Fq 'command.push("--sync-status")' "$root/Service.qml" -grep -Fq '"--unit=" + resumeUnit' "$root/Service.qml" -grep -Fq '"--on-active=" + String(minutes) + "m"' "$root/Service.qml" -grep -Fq '"/usr/bin/systemctl", "--user", "start", "onedrive.service"' "$root/Service.qml" + # Resync may only ever run interactively through omarchy-launch-terminal (the # CLI prompts for confirmation there); every direct or scripted mutation stays -# forbidden. -if grep -v 'omarchy-launch-terminal' "$root/Service.qml" \ - | grep -Eq 'bash.*-c|--resync|--logout|--sync([^a-z-]|$)'; then - echo "service boundary includes an unsafe OneDrive mutation" >&2 - exit 1 -fi +# forbidden. The QML must now contain NO mutating flag at all, because every +# command vector is built in Commands.js. Commands.js itself is deliberately not +# line-scanned -- it builds arrays across several lines, which a line-based grep +# cannot reason about -- and is instead covered behaviourally by the +# "--resync appears only in the interactive terminal vector" test in +# tests/Commands.test.js, which exercises every builder and inspects its output. +for boundary_file in Service.qml Account.qml Panel.qml BarWidget.qml; do + if grep -v 'omarchy-launch-terminal' "$root/$boundary_file" \ + | grep -Eq 'bash.*-c|--resync|--logout|--sync([^a-z-]|$)'; then + echo "service boundary includes an unsafe OneDrive mutation: $boundary_file" >&2 + exit 1 + fi +done echo "Status tests passed (local state, timed pause, remote opt-in, cache, permissions, login, multi-account discovery, --confdir and control boundaries)" diff --git a/tests/contract.sh b/tests/contract.sh new file mode 100755 index 0000000..92b4d44 --- /dev/null +++ b/tests/contract.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Integration: every command the QML builds must be accepted by the real helper. +# Skips cleanly where qml6 or real accounts are unavailable. +set -uo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +qml_runner=$(command -v qml6 || echo /usr/lib/qt6/bin/qml) +[ -x "$qml_runner" ] || { echo "Contract check SKIPPED (no qml6)"; exit 0; } + +# A helper that CRASHED and a machine with no accounts are not the same thing. +# Swallowing the exit status turned "--list-accounts raises" into a clean skip, +# so breaking discovery outright passed the suite. +if ! accounts=$(python3 "$root/onedrive-status.py" --list-accounts 2>&1); then + echo "Contract check FAILED: --list-accounts exited non-zero" >&2 + printf ' %s\n' "$accounts" >&2 + exit 1 +fi +if ! printf '%s' "$accounts" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then + echo "Contract check FAILED: --list-accounts did not produce JSON" >&2 + printf ' %s\n' "$accounts" >&2 + exit 1 +fi +case "$accounts" in '[]') echo "Contract check SKIPPED (no accounts on this machine)"; exit 0 ;; esac + +# Generated beside Contract.qml so its relative import of the widget resolves. +harness="$root/tests/qml/.Contract.generated.qml" +trap 'rm -f -- "$harness"' EXIT +python3 - "$root/tests/qml/Contract.qml" "$harness" "$accounts" <<'PY' +import json, sys +src = open(sys.argv[1]).read() +src = src.replace('property string discoveryJson: "[]"', + 'property string discoveryJson: %s' % json.dumps(sys.argv[3])) +open(sys.argv[2], "w").write(src) +PY + +qml_out=$(mktemp) +# Unset for the same reason tests/run does: an inherited gtk3 platform theme +# tries to open the X display even under the offscreen platform, and the check +# then fails for a reason that has nothing to do with the widget. +( ulimit -c 0; unset QT_QPA_PLATFORMTHEME; \ + QT_QPA_PLATFORM=offscreen QT_FORCE_STDERR_LOGGING=1 \ + "$qml_runner" -I "$root/tests/qmlstubs" "$harness" >"$qml_out" 2>&1 ) +qml_status=$? +commands=$(sed -n 's/^qml: CMD //p' "$qml_out") +rm -f -- "$qml_out" +# The QML's own status matters: without this, a harness that printed one command +# and then exited 1 passed the check. +[ "$qml_status" -eq 0 ] || { echo "Contract check FAILED: the QML harness exited $qml_status" >&2; exit 1; } +[ -n "$commands" ] || { echo "Contract check FAILED: the QML produced no commands" >&2; exit 1; } +count=0 +routine=0 +quota=0 +syncstatus=0 +while IFS= read -r line; do + [ -n "$line" ] || continue + # In a process substitution this python3 could fail and the check would sail + # on: feeding it a line that is not JSON printed a traceback and still ended + # with "Contract check passed (0 commands)". + if ! argv_lines=$(python3 -c 'import json,sys +argv = json.loads(sys.argv[1]) +assert isinstance(argv, list) and argv, "not a non-empty argv array" +for a in argv: print(a)' "$line" 2>&1); then + echo "Contract check FAILED: the QML emitted a line that is not an argv array" >&2 + printf ' line: %s\n error: %s\n' "$line" "$argv_lines" >&2 + exit 1 + fi + mapfile -t argv <<<"$argv_lines" + [ "${#argv[@]}" -gt 0 ] || continue + case "${argv[*]}" in *--list-accounts*) continue ;; esac + if ! out=$("${argv[@]}" 2>&1); then + echo "Contract check FAILED: the helper rejected a command the widget builds" >&2 + printf ' argv: %s\n error: %s\n' "$line" "$out" >&2 + exit 1 + fi + if ! printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("ok") is True' 2>/dev/null; then + echo "Contract check FAILED: helper output was not a valid status object" >&2 + printf ' argv: %s\n' "$line" >&2 + exit 1 + fi + count=$((count + 1)) + case "${argv[*]}" in + *--quota*) quota=$((quota + 1)) ;; + *--sync-status*) syncstatus=$((syncstatus + 1)) ;; + *) routine=$((routine + 1)) ;; + esac +done <<<"$commands" + +# Counted from what was actually EXECUTED, not grepped out of the QML's output. +# The old gate looked only for --quota and --sync-status, so making every +# routine status vector return [] -- the poll the widget runs every few seconds, +# and the only one that reports sync state -- still passed. And nothing required +# the count to be positive at all. +[ "$count" -gt 0 ] || { echo "Contract check FAILED: no command was executed" >&2; exit 1; } +for mode in "routine:$routine" "quota:$quota" "sync-status:$syncstatus"; do + case "$mode" in *:0) + echo "Contract check FAILED: no ${mode%%:*} status command was produced" >&2 + exit 1 ;; + esac +done + +echo "Contract check passed ($count widget-built command(s) accepted by the real helper:" \ + "$routine routine, $quota quota, $syncstatus sync-status)" diff --git a/tests/qml/Contract.qml b/tests/qml/Contract.qml new file mode 100644 index 0000000..1dcbef6 --- /dev/null +++ b/tests/qml/Contract.qml @@ -0,0 +1,44 @@ +import QtQuick +import Quickshell +import "../../" + +// Feeds the REAL `--list-accounts` output into the real Service.qml and prints +// every command the QML would run. tests/contract.sh then executes those against +// the real helper, so the argv the widget builds is checked against the argv the +// helper accepts -- a contract nothing else in the suite covers. +Item { + property string discoveryJson: "[]" + + Service { id: svc; settings: ({ refreshIntervalSec: 10, recentFileLimit: 5 }) } + + property int step: 0 + Timer { + interval: 60; repeat: true; running: true + onTriggered: { + step += 1 + if (step === 1) { + var disc = Quickshell.runningWith("--list-accounts") + if (disc.length) disc[0].finish(0, discoveryJson) + } else if (step === 2) { + for (var i = 0; i < svc.accounts.length; i++) svc.accounts[i].refresh(false) + } else if (step === 3) { + var live = Quickshell.running() + for (var j = 0; j < live.length; j++) console.log("CMD " + JSON.stringify(live[j].command)) + for (var k = 0; k < live.length; k++) live[k].finish(0, "{}") + // Both cloud modes on the first account, which take a different argv. + if (svc.accounts.length) { + svc.accounts[0].startCloudCheck("quota") + } + } else if (step === 4) { + var q = Quickshell.running() + for (var m = 0; m < q.length; m++) console.log("CMD " + JSON.stringify(q[m].command)) + for (var n = 0; n < q.length; n++) q[n].finish(0, "{}") + if (svc.accounts.length) svc.accounts[0].startCloudCheck("sync-status") + } else if (step === 5) { + var s2 = Quickshell.running() + for (var p = 0; p < s2.length; p++) console.log("CMD " + JSON.stringify(s2[p].command)) + Qt.exit(0) + } + } + } +} diff --git a/tests/qml/Harness.qml b/tests/qml/Harness.qml new file mode 100644 index 0000000..d749b78 --- /dev/null +++ b/tests/qml/Harness.qml @@ -0,0 +1,2061 @@ +import QtQuick +import Quickshell +import "../../" + +// Executes the REAL Service.qml and Account.qml against stub Quickshell types. +// +// Everything here was previously untestable: the scheduler, the cloud semaphore, +// discovery reconciliation, the notification broker and the per-account command +// vectors live in QML, and the node suites only reach the pure JS beneath them. +// A reviewer put it plainly -- "the suite is green; that is not evidence these +// paths work" -- and this is the answer to that. +// +// Run: qml -I tests/qmlstubs tests/qml/Harness.qml (exit 0 = pass) + +Item { + id: harness + + property int failures: 0 + property int checks: 0 + property var log: [] + + function check(condition, description) { + checks += 1 + if (!condition) { + failures += 1 + console.log(" FAIL " + description) + } else { + console.log(" ok " + description) + } + } + + function discoveryPayload(names) { + var rows = [] + for (var i = 0; i < names.length; i++) { + rows.push({ + service: "onedrive@" + names[i] + ".service", + instance: names[i], + confdir: "/c/" + names[i], + description: "OneDrive sync (" + names[i] + " account)" + }) + } + return JSON.stringify(rows) + } + + function statusPayload(overrides) { + var base = { + ok: true, installed: true, serviceAvailable: true, running: true, + enabled: true, activeState: "active", serviceFailed: false, + resyncRequired: false, authenticated: true, reauthRequired: false, + syncing: false, syncStage: "", statusText: "Monitoring", syncDir: "/d", + syncMode: "Two-way", clientVersion: "v", resumeAt: 0, lastSyncTs: 0, + usedBytes: 0, quotaBytes: 0, quotaKnown: false, quotaCheckedTs: 0, + quotaError: "", remoteStatus: "Not checked", syncStatusCheckedTs: 0, + syncStatusError: "", remoteCheckedTs: 0, remoteError: "", files: [], + activity: [], lastError: "", confdir: "" + } + for (var key in overrides) base[key] = overrides[key] + return JSON.stringify(base) + } + + // The argv of the notify-send this burst produced, whether it was launched + // detached (no action) or as a tracked process (with one). + function notifySent() { + var hits = harness.detachedWith("notify-send") + var live = harness.liveWith("notify-send") + return hits.concat(live) + } + + // Complete a tracked notify-send as if the user clicked its action button. + function clickNotification() { + var live = Quickshell.running() + for (var i = 0; i < live.length; i++) { + if ((live[i].command || []).indexOf("notify-send") !== -1) { + return live[i].finish(0, "default") + } + } + return false + } + + // Every detached command issued so far whose argv contains `text`. Tests clear + // Quickshell.detached first, so this reads as "what did THIS action launch". + function detachedWith(text) { + var hits = [] + for (var i = 0; i < Quickshell.detached.length; i++) { + var command = (Quickshell.detached[i] || []).join(" ") + if (command.indexOf(text) !== -1) hits.push(command) + } + return hits + } + + // Live processes are reused objects, not new registrations, so a control or + // status command is only observable while it is running -- read it in the same + // tick that started it. + function liveWith(text) { + var hits = [] + var live = Quickshell.running() + for (var i = 0; i < live.length; i++) { + var command = (live[i].command || []).join(" ") + if (command.indexOf(text) !== -1) hits.push(command) + } + return hits + } + + // Finish every live process, so an action that chains through one (a resume + // timer cancel before the control call) can reach its next stage. + function drain(payload) { + var live = Quickshell.running() + for (var i = 0; i < live.length; i++) { + var command = (live[i].command || []).join(" ") + live[i].finish(0, command.indexOf("onedrive-status.py") === -1 ? "" : payload) + } + } + + // The live status process for a service, as an object (not just its argv), so + // a test can decide HOW it ends -- exit, or a failure to start. + function statusProcessFor(service) { + var live = Quickshell.running() + for (var i = 0; i < live.length; i++) { + var command = live[i].command || [] + if (command.indexOf("--list-accounts") !== -1) continue + if (command.join(" ").indexOf("onedrive-status.py") === -1) continue + var at = command.indexOf("--service") + if ((at === -1 ? "onedrive.service" : command[at + 1]) === service) return live[i] + } + return null + } + + // Finish whichever status process is running for a given service. + function finishStatus(service, payload, exitCode) { + var live = Quickshell.running() + for (var i = 0; i < live.length; i++) { + var command = live[i].command || [] + var isStatus = command.indexOf("--list-accounts") === -1 + && command.join(" ").indexOf("onedrive-status.py") !== -1 + if (!isStatus) continue + var at = command.indexOf("--service") + var target = at === -1 ? "onedrive.service" : command[at + 1] + if (target === service) return live[i].finish(exitCode === undefined ? 0 : exitCode, payload) + } + return false + } + + function runningStatusServices() { + var out = [] + var live = Quickshell.running() + for (var i = 0; i < live.length; i++) { + var command = live[i].command || [] + if (command.indexOf("--list-accounts") !== -1) continue + if (command.join(" ").indexOf("onedrive-status.py") === -1) continue + var at = command.indexOf("--service") + out.push(at === -1 ? "onedrive.service" : command[at + 1]) + } + return out + } + + Service { + id: svc + settings: ({ refreshIntervalSec: 10, recentFileLimit: 5, notifications: true }) + } + + property int step: 0 + property var firstObjects: [] + + Timer { + interval: 60 + repeat: true + running: true + onTriggered: { + harness.step += 1 + var s = harness.step + // A throw inside a step used to abort that step and take every assertion + // it had not yet reached with it -- silently, with the run still reporting + // "all checks passed". A step that cannot finish is a failure. + try { + harness.runStep(s) + } catch (error) { + harness.check(false, "step " + s + " threw: " + error) + Qt.exit(1) + } + } + } + + function runStep(s) { + { + + if (s === 1) { + console.log("discovery and reconciliation") + var disc = Quickshell.runningWith("--list-accounts") + harness.check(disc.length === 1, "discovery runs once at startup") + if (disc.length) disc[0].finish(0, harness.discoveryPayload(["a", "b", "c"])) + } + + else if (s === 2) { + harness.check(svc.accountCount === 3, "three accounts discovered") + harness.firstObjects = svc.accounts.slice() + // The identity the shadowing bug corrupted, read from INSIDE Account. + harness.check(svc.accounts[1].service === "onedrive@b.service", + "each Account carries its own service") + harness.check(svc.accounts[1].confdir === "/c/b", + "each Account carries its own confdir") + harness.check(svc.accounts[1].resumeUnit === "omaonedrive-resume@b", + "each Account derives its own resume unit") + harness.check(svc.aggregate.kind === "checking", + "aggregate is checking before any account reports") + } + + else if (s === 3) { + console.log("scheduler") + var before = harness.runningStatusServices().length + svc.pollNextAccount() + var after = harness.runningStatusServices() + harness.check(after.length === before + 1, "a slot starts exactly one status poll") + // A second slot while one is in flight must start nothing. + svc.pollNextAccount() + harness.check(harness.runningStatusServices().length === after.length, + "a second slot starts nothing while a poll is in flight") + harness.polled = after[after.length - 1] + } + + else if (s === 4) { + harness.finishStatus(harness.polled, harness.statusPayload({})) + svc.pollNextAccount() + var now = harness.runningStatusServices() + harness.check(now.length === 1 && now[0] !== harness.polled, + "the next slot polls a DIFFERENT account (round-robin, no monopoly)") + harness.second = now[0] + } + + else if (s === 5) { + harness.finishStatus(harness.second, harness.statusPayload({})) + svc.pollNextAccount() + var third = harness.runningStatusServices() + harness.check(third.length === 1 + && third[0] !== harness.polled && third[0] !== harness.second, + "all three accounts are reached within one round") + harness.finishStatus(third[0], harness.statusPayload({})) + } + + else if (s === 6) { + console.log("aggregate and identity preservation") + harness.check(svc.aggregate.initialized === true, + "aggregate initialises once accounts report") + harness.check(svc.accounts.length === 3, "still three accounts") + // A repeat discovery must not recreate delegates. + svc.reloadAccounts() + } + + else if (s === 7) { + var disc2 = Quickshell.runningWith("--list-accounts") + if (disc2.length) disc2[0].finish(0, harness.discoveryPayload(["a", "b", "c"])) + } + + else if (s === 8) { + var same = svc.accounts.length === harness.firstObjects.length + for (var i = 0; i < svc.accounts.length && same; i++) { + if (svc.accounts[i] !== harness.firstObjects[i]) same = false + } + harness.check(same, "a repeat discovery preserves delegate identity (no churn)") + harness.check(svc.accounts[0].initialized === true, + "...and preserves the sample, so state is not wiped every 5 minutes") + } + + else if (s === 9) { + console.log("cloud semaphore") + var beforeCloud = Quickshell.running().length + svc.accounts[0].checkQuota() + svc.accounts[1].checkQuota() + svc.accounts[2].checkFullStatus() + var quotaLive = Quickshell.runningWith("--quota").length + var syncLive = Quickshell.runningWith("--sync-status").length + harness.check(quotaLive + syncLive === 1, + "three simultaneous cloud requests start exactly one process") + } + + else if (s === 10) { + console.log("per-account control vectors") + Quickshell.detached = [] + svc.accounts[1].login() + var loginCommand = Quickshell.detached.length ? Quickshell.detached[0] : [] + harness.check(loginCommand.indexOf("--confdir") !== -1 + && loginCommand[loginCommand.indexOf("--confdir") + 1] === "/c/b", + "login targets the selected account's own confdir") + } + + else if (s === 11) { + console.log("an unknown confdir must not freeze the fleet") + // The shape that bricked the widget: a non-default service with no + // confdir yields an empty command, which must never reach a Process. + // Drain anything still in flight first, or `refreshing` is legitimately + // true from the cloud check above and the assertion below would pass or + // fail for the wrong reason. + var live = Quickshell.running() + for (var d = 0; d < live.length; d++) live[d].finish(0, harness.statusPayload({})) + harness.check(svc.accounts[0].refreshing === false, "drained before the freeze check") + var spawnsBefore = Quickshell.spawnCount + // Through discovery, for the same reason: keep the binding intact so + // later steps can repoint this account. + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" }, + { service: "onedrive@c.service", instance: "c", confdir: "/c/c", description: "C" } + ]) + // `attempted` is inherited from the polling above, so asserting it here + // passed whether or not the empty-command branch set it. Clear it first + // and the assertion is about this branch again. + svc.accounts[0].forgetSample() + harness.check(svc.accounts[0].attempted === false, + "the account starts this check having never been attempted") + svc.accounts[0].refresh(false) + harness.check(Quickshell.spawnCount === spawnsBefore, + "an account with no confdir spawns no process") + harness.check(svc.accounts[0].refreshing === false, + "...and does not latch `refreshing`, which would freeze every account") + harness.check(svc.accounts[0].attempted === true, + "...and counts as attempted, so the startup hold and ramp can end") + // Poll every remaining account by name, so this cannot pass because the + // round-robin happened to pick someone else. Count SPAWNS, not the + // `refreshing` flag: a cloud check released by the drain above already + // sets that, so the flag was true whether or not the refresh did + // anything. + var reached = 0 + for (var t2 = 0; t2 < svc.accounts.length; t2++) { + if (svc.accounts[t2] === svc.accounts[0]) continue + harness.drain(harness.statusPayload({})) + var before2 = Quickshell.spawnCount + svc.accounts[t2].refresh(false) + if (Quickshell.spawnCount === before2 + 1) reached += 1 + harness.finishStatus(svc.accounts[t2].service, harness.statusPayload({})) + } + harness.check(reached === svc.accounts.length - 1, + "...so every OTHER account still polls normally") + } + + else if (s === 12) { + console.log("notification broker") + // Step 11 deliberately blanked this account's confdir to test the freeze + // guard, so it stopped polling. Restore it through DISCOVERY, not by + // assigning the property: a direct assignment destroys the binding to + // the model role, and every later descriptor update would then be unable + // to reach it. + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" }, + { service: "onedrive@c.service", instance: "c", confdir: "/c/c", description: "C" } + ]) + Quickshell.detached = [] + harness.notifyBefore = Quickshell.spawnCount + // Three accounts report attention across a poll round. The broker must + // coalesce them into ONE popup, not one per account -- the defect that + // survived two rounds because the burst window meant "900ms of quiet", + // which a staggered scheduler never produces. + for (var n = 0; n < svc.accounts.length; n++) { + svc.accounts[n].refresh(false) + } + for (var m = 0; m < svc.accounts.length; m++) { + harness.finishStatus(svc.accounts[m].service, + harness.statusPayload({ reauthRequired: true, statusText: "Reauthentication required" })) + } + } + + else if (s === 13) { + // Nothing may have fired yet: the window spans a poll round. + var early = Quickshell.detached.length + Quickshell.runningWith("notify-send").length + harness.check(early === 0, "no notification fires before the burst window closes") + // "at most one" is satisfied by ZERO, and the window here is 10s while + // these steps are 60ms apart -- so asserting it now would pass whether + // the broker emitted one, none, or never flushed at all. Force the flush + // and assert on the real outcome instead. + svc.flushTransitions() + } + + else if (s === 14) { + var fired = Quickshell.detached.length + Quickshell.runningWith("notify-send").length + harness.check(fired === 1, + "three accounts failing in one round produce EXACTLY one notification") + var sent = Quickshell.detached.length + ? Quickshell.detached[0] + : (Quickshell.runningWith("notify-send").length + ? Quickshell.runningWith("notify-send")[0].command : []) + console.log(" notification argv: " + JSON.stringify(sent)) + var joined = sent.join(" ") + harness.check(joined.indexOf("A") !== -1 && joined.indexOf("B") !== -1 + && joined.indexOf("C") !== -1, + "...and it names every affected account rather than only one") + harness.notified = fired + } + + else if (s === 15) { + console.log("per-account pause targets only its own units") + Quickshell.detached = [] + // Step 12 left every account reauth-required, and pauseFor correctly + // refuses an account in that state. Return them to healthy first, or + // this would test the guard rather than the targeting. + for (var h = 0; h < svc.accounts.length; h++) { + svc.accounts[h].refresh(false) + } + for (var f = 0; f < svc.accounts.length; f++) { + harness.finishStatus(svc.accounts[f].service, harness.statusPayload({})) + } + var target = svc.accounts[1] + harness.check(target.reauthRequired === false && target.busy === false, + "target account is healthy and idle before the pause") + target.pauseFor(15) + // The cancel runs first; find it and check it names only this account. + var cancels = Quickshell.runningWith("stop") + var named = false + for (var c = 0; c < cancels.length; c++) { + var joined = (cancels[c].command || []).join(" ") + if (joined.indexOf("omaonedrive-resume@b") !== -1 + && joined.indexOf("omaonedrive-resume@a") === -1 + && joined.indexOf("omaonedrive-resume.timer") === -1) named = true + } + harness.check(named, "a timed pause cancels only that account's own resume unit") + for (var k = 0; k < cancels.length; k++) cancels[k].finish(0, "") + } + + else if (s === 16) { + // After the cancel, the stop for that account's own service. + var stops = Quickshell.runningWith("onedrive@b.service") + harness.check(stops.length >= 1, "the pause stops that account's own service") + var wrong = Quickshell.runningWith("onedrive@a.service").length + + Quickshell.runningWith("onedrive.service").length + harness.check(wrong === 0, "...and no other account's service is touched") + // Finish the stop so the SCHEDULE is created. Snapshotting before this + // meant the systemd-run vector -- the one that decides which account + // actually resumes -- was never examined at all. + for (var w = 0; w < stops.length; w++) stops[w].finish(0, "") + } + + else if (s === 17) { + var schedules = Quickshell.runningWith("systemd-run") + harness.check(schedules.length === 1, "a timed pause schedules exactly one resume") + var argv = schedules.length ? schedules[0].command : [] + var joined = argv.join(" ") + harness.check(joined.indexOf("--unit=omaonedrive-resume@b") !== -1, + "the resume timer is that account's own unit") + harness.check(argv[argv.length - 1] === "onedrive@b.service", + "the timer starts the SAME service the pause stopped") + harness.check(joined.indexOf("onedrive.service ") === -1 + && argv[argv.length - 1] !== "onedrive.service", + "...and not the default account's") + for (var y = 0; y < schedules.length; y++) schedules[y].finish(0, "") + var rest = Quickshell.running() + for (var z = 0; z < rest.length; z++) rest[z].finish(0, harness.statusPayload({})) + } + + else if (s === 18) { + console.log("a repointed confdir forgets the previous account's sample") + var acct = svc.accounts[2] + // Deliberately NOT assigning acct.confdir here: a direct assignment + // destroys the binding to the model role, so the descriptor update could + // never reach it and the test would fail for a reason of its own making. + harness.check(acct.initialized === true, "account is initialised before the repoint") + harness.check(acct.confdir === "/c/c", "starts on its discovered confdir") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" }, + { service: "onedrive@c.service", instance: "c", confdir: "/NEW/c", description: "C" } + ]) + harness.check(acct.confdir === "/NEW/c", "the descriptor update reaches the account") + harness.check(acct.initialized === false, + "a repointed account forgets its sample") + harness.check(acct.syncDir === "", + "...including syncDir, so Open folder cannot open the PREVIOUS directory") + } + + else if (s === 19) { + console.log("a reply from the previous confdir must not be applied") + // The condition grok pointed out the earlier repoint test lacked: a poll + // already IN FLIGHT when the account is repointed. forgetSample keeps + // in-flight processes by design, so without a generation stamp that + // reply writes the OLD directory's syncDir, initialized and edge latches + // back over the new account. + var acct2 = svc.accounts[0] + acct2.refresh(false) + harness.check(acct2.refreshing === true, "a poll is in flight before the repoint") + svc.applyDiscovery([ + { service: acct2.service, instance: "a", confdir: "/MOVED/a", description: "A" } + ]) + harness.check(acct2.confdir === "/MOVED/a", "the repoint lands") + harness.check(acct2.initialized === false, "the sample is forgotten") + // Now let the OLD process finish with the previous directory's data. + harness.finishStatus(acct2.service, harness.statusPayload({ syncDir: "/OLD/dir" })) + harness.check(acct2.syncDir !== "/OLD/dir", + "a reply started under the old confdir is discarded, not applied") + harness.check(acct2.initialized === false, + "...and does not resurrect `initialized` for the previous directory") + harness.check(acct2.attempted === false, + "...and does not mark the NEW identity attempted, which would skip its ramp") + } + + else if (s === 20) { + console.log("every routine refresh goes through the shared slot") + // Step 18 reduced the payload to one account; this needs two, so the + // selected account can differ from the one being polled. + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + // IPC refresh(), the panel's open() and the settle/delayed timers all + // reach Account.refresh, whose only guard is that account's OWN process. + // Without the coordinator's gate, automation calling refresh while the + // scheduler is mid-poll starts a second concurrent helper -- the thing + // the whole slot discipline exists to forbid. + var live0 = harness.runningStatusServices() + for (var q0 = 0; q0 < Quickshell.running().length; q0++) { + Quickshell.running()[q0].finish(0, harness.statusPayload({})) + } + svc.pollNextAccount() + var inFlight = harness.runningStatusServices() + harness.check(inFlight.length === 1, "the scheduler has exactly one poll in flight") + // The selected account must be a DIFFERENT one, or Account.refresh + // refuses it on its own process and the test passes either way. + var other = "" + for (var o = 0; o < svc.accounts.length; o++) { + if (svc.accounts[o].service !== inFlight[0]) { other = svc.accounts[o].service; break } + } + svc.selectedService = other + harness.check(svc.selectedAccount.service !== inFlight[0], + "the selected account is not the one being polled") + harness.check(svc.selectedAccount.refreshing === false, + "...and is idle, so only the shared slot can stop it") + svc.refresh(false) + harness.check(harness.runningStatusServices().length === inFlight.length, + "a facade refresh starts nothing while another account's poll is in flight") + // A cloud check is explicit intent and has its own semaphore, so it is + // still allowed through. + for (var q1 = 0; q1 < Quickshell.running().length; q1++) { + Quickshell.running()[q1].finish(0, harness.statusPayload({})) + } + } + + else if (s === 21) { + console.log("a continuous stream of events must still flush") + // The discriminating case. With burstTimer.restart() on every enqueue, + // the window means "N ms of QUIET" -- so a stream of events arriving + // closer together than the window never flushes at all, and the user is + // told nothing. With start(), the window runs from the first event and + // fires regardless. Reduce to ONE account so the window is 900ms and a + // 60ms tick loop can outrun it. + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" } + ]) + Quickshell.detached = [] + harness.streamTicks = 0 + } + + else if (s >= 22 && s <= 43) { + // One event per tick, faster than the 900ms window. + svc.enqueueTransition({ + service: "onedrive@a.service", name: "A", kind: "reauth", + summary: "OneDrive needs reauthentication", short: "Reauthentication required", + body: "b", action: "open", actionLabel: "Open OneDrive panel" + }) + harness.streamTicks += 1 + } + + else if (s === 44) { + var fired = Quickshell.detached.length + Quickshell.runningWith("notify-send").length + harness.check(fired >= 1, + "a stream of events flushes rather than being deferred forever (" + + harness.streamTicks + " events over ~" + (harness.streamTicks * 60) + "ms)") + } + + + // --------------------------------------------------------------------- + // Every panel and IPC control acts on the SELECTED account. Neutering any + // one of Service's sixteen facade functions -- pause, resume, login, + // reauthenticate, repairResync, openFolder, checkQuota, checkFullStatus -- + // left this harness green, which is the same blind spot the delegate + // property-shadowing bug hid in: the command was built correctly and sent + // to the wrong account. + else if (s === 45) { + console.log("panel controls act on the selected account, and only on it") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + harness.drain(harness.statusPayload({})) + } + + // Give the two accounts DISTINCT observable state, so a command carrying + // the wrong account's identity cannot look like the right one. + else if (s === 46 || s === 47) { + svc.pollNextAccount() + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + syncDir: "/d/a", running: true, activeState: "active" })) + // B is stopped: reauthenticate() and repairResync() both refuse a running + // account, so the fixture has to make those paths reachable at all. + harness.finishStatus("onedrive@b.service", harness.statusPayload({ + syncDir: "/d/b", running: false, activeState: "inactive" })) + } + + else if (s === 48) { + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: false, activeState: "inactive" })) + harness.check(svc.selectedAccount.service === "onedrive@b.service", + "B is selected") + harness.check(svc.accounts[0].service === "onedrive@a.service", + "...and is NOT the first account, so 'always account 0' cannot pass") + + Quickshell.detached = [] + svc.login() + harness.check(harness.detachedWith("--confdir /c/b").length === 1, + "login opens the selected account's config directory") + harness.check(harness.detachedWith("/c/a").length === 0, + "...and never the other account's") + + Quickshell.detached = [] + svc.reauthenticate() + harness.check(harness.detachedWith("--confdir /c/b --reauth").length === 1, + "reauthentication targets the selected account") + harness.check(harness.detachedWith("/c/a").length === 0, + "...and never the other account's") + + Quickshell.detached = [] + svc.repairResync() + harness.check(harness.detachedWith("--confdir /c/b --sync --resync").length === 1, + "resync repair targets the selected account") + harness.check(harness.detachedWith("/c/a").length === 0, + "...and never the other account's -- a resync on the wrong account " + + "deletes and re-downloads the wrong drive") + + Quickshell.detached = [] + svc.openFolder() + harness.check(harness.detachedWith("xdg-open /d/b").length === 1, + "Open folder opens the selected account's sync directory") + harness.check(harness.detachedWith("/d/a").length === 0, + "...and never the other account's") + } + + // The discriminating half: flip the selection and prove the commands + // FOLLOW it. Without this, hard-coding account B passes every check above. + else if (s === 49) { + svc.selectAccount("onedrive@a.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/a", running: true, activeState: "active" })) + harness.check(svc.selectedAccount.service === "onedrive@a.service", + "the selection moved to A") + + Quickshell.detached = [] + svc.login() + harness.check(harness.detachedWith("--confdir /c/a").length === 1, + "login follows the selection to the other account") + harness.check(harness.detachedWith("/c/b").length === 0, + "...and leaves the previously selected account alone") + + Quickshell.detached = [] + svc.openFolder() + harness.check(harness.detachedWith("xdg-open /d/a").length === 1, + "Open folder follows the selection too") + harness.check(harness.detachedWith("/d/b").length === 0, + "...and not the previously selected account's directory") + } + + else if (s === 50) { + console.log("cloud checks name the selected account in the command itself") + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: false, activeState: "inactive" })) + svc.checkQuota() + var quota = harness.liveWith("--quota") + harness.check(quota.length === 1, "exactly one quota check is in flight") + harness.check(quota.length === 1 + && quota[0].indexOf("--service onedrive@b.service") !== -1 + && quota[0].indexOf("--confdir /c/b") !== -1, + "the quota check carries the selected account's service AND confdir") + harness.check(quota.length === 1 && quota[0].indexOf("/c/a") === -1, + "...and nothing belonging to the other account") + } + + else if (s === 51) { + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@a.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/a", running: true, activeState: "active" })) + svc.checkFullStatus() + var full = harness.liveWith("--sync-status") + harness.check(full.length === 1, "exactly one sync-status check is in flight") + harness.check(full.length === 1 + && full[0].indexOf("--service onedrive@a.service") !== -1 + && full[0].indexOf("--confdir /c/a") !== -1, + "the full check follows the selection to the other account") + harness.check(full.length === 1 && full[0].indexOf("/c/b") === -1, + "...and carries nothing belonging to the previously selected account") + harness.drain(harness.statusPayload({})) + } + + // Pause and resume are the two that reach systemd. Each chains a resume- + // timer cancel before the control call, and BOTH halves must name the same + // account -- cancelling the wrong timer strands the other account's pause. + else if (s === 52) { + console.log("pause and resume reach the selected account's own units") + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: false, activeState: "inactive" })) + svc.resume() + harness.check(harness.liveWith("omaonedrive-resume@b").length === 1, + "resume first cancels the selected account's OWN resume timer") + harness.check(harness.liveWith("omaonedrive-resume@a").length === 0, + "...and not the other account's, which would strand its pause") + harness.drain("") + } + + else if (s === 53) { + var starts = harness.liveWith("systemctl --user start") + harness.check(starts.length === 1 + && starts[0].indexOf("onedrive@b.service") !== -1, + "...and then starts the selected account's unit") + harness.check(harness.liveWith("start onedrive@a.service").length === 0, + "...leaving the other account's unit untouched") + harness.drain("") + } + + else if (s === 54) { + svc.selectAccount("onedrive@a.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/a", running: true, activeState: "active" })) + svc.pause() + harness.check(harness.liveWith("omaonedrive-resume@a").length === 1, + "pause cancels the selected account's own resume timer") + harness.check(harness.liveWith("omaonedrive-resume@b").length === 0, + "...and not the other account's") + harness.drain("") + } + + else if (s === 55) { + var stops = harness.liveWith("systemctl --user stop onedrive@a.service") + harness.check(stops.length === 1, + "...and then stops the selected account's unit") + harness.check(harness.liveWith("stop onedrive@b.service").length === 0, + "...leaving the other account running") + harness.drain("") + } + + + // --------------------------------------------------------------------- + // The remaining facade functions. Each of these could be replaced with an + // empty body and this harness stayed green: the refresh test below only + // asserted the NEGATIVE case (that a refresh during a poll starts nothing), + // which an empty body satisfies perfectly; and the timed pause was driven + // on the Account directly, never through the coordinator the panel calls. + else if (s === 56) { + console.log("the facade actually does the thing, not just refuse to") + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: false, activeState: "inactive" })) + harness.check(harness.runningStatusServices().length === 0, + "nothing is polling, so a refresh has no excuse to do nothing") + svc.refresh(false) + harness.check(harness.runningStatusServices().length === 1, + "an idle facade refresh DOES start a poll") + harness.check(harness.runningStatusServices()[0] === "onedrive@b.service", + "...for the selected account") + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: false, activeState: "inactive" })) + } + + // The panel's "Pause for N" menu calls the COORDINATOR, not the account. + else if (s === 57) { + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: true, activeState: "active" })) + svc.pauseFor(60) + harness.check(harness.liveWith("omaonedrive-resume@b").length === 1, + "a timed pause cancels the selected account's existing timer first") + harness.drain("") + } + + else if (s === 58) { + harness.check(harness.liveWith("systemctl --user stop onedrive@b.service").length === 1, + "...then stops the selected account's unit") + harness.drain("") + } + + else if (s === 59) { + var scheduled = harness.liveWith("systemd-run") + harness.check(scheduled.length === 1, "...and schedules exactly one resume timer") + harness.check(scheduled.length === 1 + && scheduled[0].indexOf("--unit=omaonedrive-resume@b") !== -1, + "the timer is the selected account's own unit") + harness.check(scheduled.length === 1 + && scheduled[0].indexOf("--on-active=60m") !== -1, + "...for the duration the menu asked for, not a default") + harness.check(scheduled.length === 1 + && scheduled[0].indexOf("start onedrive@b.service") !== -1, + "...and on expiry it starts the account it paused, not another") + harness.check(scheduled.length === 1 && scheduled[0].indexOf("@a") === -1, + "nothing in the schedule names the other account") + harness.drain("") + } + + // The bar's own click. It has to read the CURRENT state to pick a + // direction: a toggle that always pauses is indistinguishable from a + // correct one until you click it twice. + else if (s === 60) { + console.log("the bar toggle picks its direction from the account's state") + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@a.service", false) + // Step 54 paused A, so its optimistic desired state is still "stopped" + // and will stay so until a poll agrees. Let one poll agree, then bring it + // back up -- otherwise `active` is false and this would be testing the + // resume direction twice. + harness.drain(harness.statusPayload({ + syncDir: "/d/a", running: false, activeState: "inactive" })) + svc.refresh(false) + harness.drain(harness.statusPayload({ + syncDir: "/d/a", running: true, activeState: "active" })) + harness.check(svc.selectedAccount.active === true, + "A is running, so the toggle must choose to STOP it") + svc.toggleRunning() + harness.drain("") + } + + else if (s === 61) { + harness.check(harness.liveWith("systemctl --user stop onedrive@a.service").length === 1, + "toggling a running account stops it") + harness.drain("") + } + + else if (s === 62) { + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: false, activeState: "inactive" })) + harness.check(svc.selectedAccount.active === false, "B is stopped") + svc.toggleRunning() + harness.drain("") + } + + else if (s === 63) { + harness.check(harness.liveWith("systemctl --user start onedrive@b.service").length === 1, + "toggling a stopped account starts it -- the same call, the other way") + harness.drain("") + } + + else if (s === 64) { + console.log("openWeb and openFile") + Quickshell.detached = [] + svc.openWeb() + harness.check(harness.detachedWith("xdg-open https://onedrive.live.com/").length === 1, + "Open on the web launches the OneDrive site") + Quickshell.detached = [] + svc.openFile({ path: "/d/b/Some Folder/a file.txt" }) + harness.check(harness.detachedWith("nautilus --select").length === 1, + "Open file reveals the file in the file manager") + harness.check( + harness.detachedWith("file:///d/b/Some%20Folder/a%20file.txt").length === 1, + "...with the path percent-encoded, so a space does not truncate it") + harness.check(harness.detachedWith("file:///d/b/Some Folder").length === 0, + "...and never as a raw path") + Quickshell.detached = [] + svc.openFile(null) + harness.check(Quickshell.detached.length === 0, + "a missing file launches nothing") + } + + // The cloud semaphore's release half. A queued check must actually run + // when the one in flight finishes -- otherwise the second account's storage + // figure never arrives and the panel shows "Not checked" forever. + else if (s === 65) { + console.log("a queued cloud check runs when the slot frees") + harness.drain(harness.statusPayload({})) + svc.accounts[0].checkQuota() + svc.accounts[1].checkQuota() + var live = harness.liveWith("--quota") + harness.check(live.length === 1, "only one cloud check runs at a time") + harness.check(live[0].indexOf("--confdir /c/a") !== -1, + "the first request is the one in flight") + harness.drain(harness.statusPayload({})) + } + + else if (s === 66) { + var second = harness.liveWith("--quota") + harness.check(second.length === 1, + "the queued check starts once the slot frees, rather than being dropped") + harness.check(second.length === 1 && second[0].indexOf("--confdir /c/b") !== -1, + "...and it is the account that was waiting, with its own confdir") + harness.drain(harness.statusPayload({})) + } + + else if (s === 67) { + harness.check(harness.liveWith("--quota").length === 0, + "the queue is then empty -- a drained entry is not re-run forever") + } + + + // --------------------------------------------------------------------- + // Real Quickshell takes `running` true -> false WITHOUT emitting `exited` + // when the executable cannot be started. Every cleanup lived in onExited, + // and the coordinator's single poll slot hangs off `refreshing` -- so one + // missing python3 froze routine polling for every account, permanently. + // The stub could not express this until now, which is why the suite was + // green through it. + else if (s === 68) { + console.log("a command that cannot be started must not freeze the fleet") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + harness.drain(harness.statusPayload({})) + svc.pollNextAccount() + var flight = harness.runningStatusServices() + harness.check(flight.length === 1, "a poll is in flight") + harness.stuck = flight[0] + harness.check(harness.statusProcessFor(harness.stuck).failToStart(), + "...and its executable fails to start, with no exit ever reported") + } + + else if (s === 69) { + var abandoned = svc.accounts[0].service === harness.stuck + ? svc.accounts[0] : svc.accounts[1] + harness.check(abandoned.refreshing === false, + "the account releases the poll slot rather than latching on it") + harness.check(abandoned.attempted === true, + "...and counts as attempted, so it does not monopolise the ramp") + harness.check(abandoned.lastError !== "", + "...and says what went wrong instead of sitting blank") + harness.check(svc.routinePollRunning() === false, + "the coordinator sees the slot as free") + svc.pollNextAccount() + harness.check(harness.runningStatusServices().length === 1, + "...so the fleet goes on polling") + harness.drain(harness.statusPayload({})) + } + + // A helper that starts but never exits -- a wedged python3, an os.walk over + // a stalled mount -- held the same slot forever. Nothing bounded it. + else if (s === 70) { + console.log("a helper that never exits is abandoned rather than blocking everyone") + svc.settings = ({ refreshIntervalSec: 10, recentFileLimit: 5, + notifications: true, statusTimeoutMs: 200 }) + harness.drain(harness.statusPayload({})) + svc.pollNextAccount() + var wedgedFlight = harness.runningStatusServices() + harness.check(wedgedFlight.length === 1, + "a poll is in flight and will never return") + harness.stuck = wedgedFlight.length === 1 ? wedgedFlight[0] : "" + } + + // ~240ms of nothing, against a 200ms timeout. + else if (s >= 71 && s <= 74) { /* let the watchdog run */ } + + else if (s === 75) { + var wedged = svc.accountForService(harness.stuck) + harness.check(harness.statusProcessFor(harness.stuck) === null, + "the watchdog gives up on the process") + harness.check(wedged.refreshing === false, + "...releasing the slot it was holding") + harness.check(wedged.lastError.indexOf("timed out") !== -1, + "...and the account reports a timeout rather than sitting blank: " + wedged.lastError) + harness.check(wedged.attempted === true, + "...and counts as attempted, so it does not monopolise the ramp") + harness.drain(harness.statusPayload({})) + svc.pollNextAccount() + harness.check(harness.runningStatusServices().length === 1, + "the fleet polls on") + harness.drain(harness.statusPayload({})) + } + + // --------------------------------------------------------------------- + // The pause that unpaused itself. After `systemctl stop` succeeded the + // settle loop asked five times for a confirming poll and then cleared the + // optimistic state regardless. Those asks are dropped while another + // account holds the slot, so with 2-3 accounts the bar reverted to + // "Monitoring" about six seconds after the click -- while the unit really + // was stopped. + else if (s === 76) { + console.log("a pause is not undone by a poll slot it never got") + svc.settings = ({ refreshIntervalSec: 10, recentFileLimit: 5, + notifications: true, settleIntervalMs: 20, + statusTimeoutMs: 600000 }) + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: true, activeState: "active" })) + harness.check(svc.selectedAccount.active === true, "B is running") + svc.pause() + harness.drain("") + } + + else if (s === 77) { + harness.check(harness.liveWith("systemctl --user stop onedrive@b.service").length === 1, + "the stop is issued") + harness.drain("") + // Now block the shared slot with the OTHER account, so every settle ask + // is dropped -- the multi-account condition the revert needed. + svc.accounts[0].refresh(false) + harness.check(harness.runningStatusServices().length === 1, + "the other account is holding the poll slot") + harness.check(svc.accounts[1].settling === true, + "B is waiting to learn what the stop did") + } + + // 30 settle ticks at 20ms is 600ms; these ten ticks are ~600ms. + else if (s >= 78 && s <= 87) { + harness.check(svc.accounts[1].active === false, + "B stays paused on screen while no poll can confirm it (tick " + (s - 77) + ")") + } + + else if (s === 88) { + harness.check(svc.accounts[1].active === false, + "...and after the settle loop has given up asking, it is STILL paused") + harness.check(svc.accounts[1].running === true, + "...even though the last sample, taken before the stop, says running") + // Release the slot; the confirming poll may now land and truth wins. + harness.drain(harness.statusPayload({})) + svc.accounts[1].refresh(false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: false, activeState: "inactive" })) + harness.check(svc.accounts[1].active === false, + "a poll agreeing with the pause leaves it paused") + } + + else if (s === 89) { + // ...and the other direction: a poll that DISAGREES is believed, so a + // unit something else restarted is not shown as paused forever. + svc.accounts[1].refresh(false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: true, activeState: "active" })) + harness.check(svc.accounts[1].active === true, + "a later poll showing it running is believed, not overridden forever") + svc.settings = ({ refreshIntervalSec: 10, recentFileLimit: 5, notifications: true }) + } + + + // --------------------------------------------------------------------- + // The startup seed polls `onedrive.service` before discovery has told us + // its config directory, so that poll uses the CLIENT's default. If the + // unit's ExecStart names a different --confdir, the reply describes a + // different account. The repoint guard only fired on non-empty -> non-empty, + // so this reply was applied: the wrong sync directory, quota and token + // state under this account's name, and Open folder on the wrong tree. + else if (s === 90) { + console.log("a seed poll started before discovery knew the confdir") + svc.applyDiscovery([ + { service: "onedrive.service", instance: "", confdir: "", description: "" } + ]) + harness.drain(harness.statusPayload({})) + svc.accounts[0].forgetSample() + svc.accounts[0].refresh(false) + harness.check(harness.statusProcessFor("onedrive.service") !== null, + "a poll is in flight against the client's default directory") + harness.check(svc.accounts[0].confdir === "", + "...started while the confdir was still unknown") + } + + else if (s === 91) { + svc.applyDiscovery([ + { service: "onedrive.service", instance: "", + confdir: "/CUSTOM/onedrive", description: "" } + ]) + harness.check(svc.accounts[0].confdir === "/CUSTOM/onedrive", + "discovery supplies the unit's real config directory") + harness.finishStatus("onedrive.service", harness.statusPayload({ + syncDir: "/DEFAULT/tree", statusText: "Monitoring the default account" })) + harness.check(svc.accounts[0].syncDir !== "/DEFAULT/tree", + "the seed reply is refused, not attached to the discovered directory") + harness.check(svc.accounts[0].initialized === false, + "...and does not count as this account having reported") + harness.check(svc.accounts[0].statusText.indexOf("default account") === -1, + "...so the panel shows nothing rather than another account's status") + } + + else if (s === 92) { + // ...and the ordinary case is untouched: a poll started AFTER discovery + // still applies. Without this the fix would simply never show anything. + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive.service", harness.statusPayload({ + syncDir: "/CUSTOM/tree" })) + harness.check(svc.accounts[0].syncDir === "/CUSTOM/tree", + "a poll started after discovery is applied normally") + harness.check(svc.accounts[0].initialized === true, "...and does report") + } + + // --------------------------------------------------------------------- + // The cloud queue took one entry per release. An account removed by + // discovery while queued therefore consumed the release and left every + // entry behind it stranded. + else if (s === 93) { + console.log("a cloud check queued behind a removed account still runs") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" }, + { service: "onedrive@c.service", instance: "c", confdir: "/c/c", description: "C" } + ]) + harness.drain(harness.statusPayload({})) + } + + else if (s === 94) { + harness.drain(harness.statusPayload({})) + svc.accounts[0].checkQuota() + svc.accounts[1].checkQuota() + svc.accounts[2].checkQuota() + var inflight = harness.liveWith("--quota") + harness.check(inflight.length === 1, "one cloud check runs, two are queued") + harness.check(inflight[0].indexOf("/c/a") !== -1, "A's is the one in flight") + } + + else if (s === 95) { + // B disappears -- its unit was removed, or its confdir became unreadable + // and the helper stopped reporting it. + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@c.service", instance: "c", confdir: "/c/c", description: "C" } + ]) + harness.check(svc.accountCount === 2, "B is gone") + harness.drain(harness.statusPayload({})) + } + + else if (s === 96) { + var next = harness.liveWith("--quota") + harness.check(next.length === 1, + "the queue skips the removed account and runs the one behind it") + harness.check(next.length === 1 && next[0].indexOf("/c/c") !== -1, + "...which is C, with C's own config directory") + harness.drain(harness.statusPayload({})) + } + + // --------------------------------------------------------------------- + // The badge is worst-of-N; every control acts on the SELECTED account. + // Those were unrelated, so the bar could show "reauthentication required" + // for one account while the panel opened on a healthy other one -- and the + // keyboard shortcuts then acted on the healthy one. + else if (s === 97) { + console.log("opening the panel lands on the account the badge blames") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + harness.drain(harness.statusPayload({})) + } + + else if (s === 98 || s === 99) { + svc.pollNextAccount() + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + syncDir: "/d/a", running: true, activeState: "active" })) + harness.finishStatus("onedrive@b.service", harness.statusPayload({ + syncDir: "/d/b", reauthRequired: true, statusText: "Reauthentication required" })) + } + + else if (s === 100) { + svc.selectAccount("onedrive@a.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/a", running: true, activeState: "active" })) + harness.check(svc.aggregate.kind === "reauth", "the bar is blaming an account") + harness.check(svc.aggregate.worst.service === "onedrive@b.service", + "...and it is B") + harness.check(svc.selectedService === "onedrive@a.service", + "...while the panel is still pointed at the healthy A") + svc.selectBadgedAccount() + harness.check(svc.selectedService === "onedrive@b.service", + "opening the panel moves the selection to the account being blamed") + harness.drain(harness.statusPayload({ + syncDir: "/d/b", reauthRequired: true })) + } + + else if (s === 101) { + // The other half: a selection the user made deliberately is not stolen + // when a second account also goes wrong. Make A fail too; B stays + // selected because B is itself asking for attention. + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + syncDir: "/d/a", serviceFailed: true, statusText: "Sync failed" })) + harness.check(svc.selectedService === "onedrive@b.service", "B is selected") + svc.selectBadgedAccount() + harness.check(svc.selectedService === "onedrive@b.service", + "a selection the user made for a reason is not stolen by a worse account") + harness.drain(harness.statusPayload({})) + } + + + // --------------------------------------------------------------------- + // Three fixes from an earlier round that could each be reverted wholesale + // with the entire suite green. A reviewer applied all three and reported + // "suite passed" for every one. + else if (s === 102) { + console.log("a stale cloud reply must release the shared slot") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + harness.drain(harness.statusPayload({})) + } + + else if (s === 103) { + harness.drain(harness.statusPayload({})) + svc.accounts[0].checkQuota() + svc.accounts[1].checkQuota() + harness.check(harness.liveWith("--quota").length === 1, + "A's cloud check is in flight and B's is queued behind it") + // A is repointed at a different config directory while its check runs. + // The reply that comes back describes the OLD directory and is discarded + // -- but everything the normal path releases has to be released anyway, + // or the coordinator goes on believing a cloud check is running and B's + // never starts. + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a2", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + harness.check(svc.accounts[0].confdir === "/c/a2", "A is repointed mid-check") + harness.finishStatus("onedrive@a.service", harness.statusPayload({ usedBytes: 1 })) + } + + else if (s === 104) { + var released = harness.liveWith("--quota") + harness.check(released.length === 1, + "the discarded reply still frees the slot, so the queued check runs") + harness.check(released.length === 1 && released[0].indexOf("/c/b") !== -1, + "...and it is B's, with B's own config directory") + harness.drain(harness.statusPayload({})) + } + + // A repeat click while the same check is already running used to queue a + // duplicate, which then ran the same 30-second query again the moment the + // first finished. + else if (s === 105) { + console.log("a click repeated during the check it started is dropped") + harness.drain(harness.statusPayload({})) + svc.accounts[0].checkQuota() + harness.check(harness.liveWith("--quota").length === 1, "one quota check is running") + svc.accounts[0].checkQuota() + svc.accounts[0].checkQuota() + harness.check(harness.liveWith("--quota").length === 1, + "...and the repeats start nothing more") + harness.finishStatus("onedrive@a.service", harness.statusPayload({ usedBytes: 1 })) + } + + else if (s === 106) { + harness.check(harness.liveWith("--quota").length === 0, + "...nor queue a duplicate that runs the moment the first one finishes") + // A different mode for the same account is a real request, so the + // de-duplication cannot simply be "ignore everything while busy". + harness.drain(harness.statusPayload({})) + svc.accounts[0].checkQuota() + svc.accounts[0].checkFullStatus() + harness.check(harness.liveWith("--quota").length === 1, + "the quota check is running") + harness.finishStatus("onedrive@a.service", harness.statusPayload({})) + } + + else if (s === 107) { + harness.check(harness.liveWith("--sync-status").length === 1, + "...and the sync-status check queued behind it still runs") + harness.drain(harness.statusPayload({})) + } + + // Selecting an account from IPC must not inherit the panel's behaviour of + // retrying a stale failed storage check: automation targeting an account + // in order to pause it would silently contact Microsoft. + else if (s === 108) { + console.log("selecting an account from automation does not contact the cloud") + harness.drain(harness.statusPayload({})) + harness.stale = harness.statusPayload({ + syncDir: "/d/b", quotaError: "temporary failure", quotaCheckedTs: 1 }) + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.stale) + } + + else if (s === 109) { + harness.check(svc.accounts[1].quotaError !== "", + "B has a failed storage check old enough to be retried") + harness.check(harness.liveWith("--quota").length === 0, + "an automation selection starts no cloud check") + // The discriminating half: the PANEL's selection does retry it, so this + // cannot pass by the retry being broken outright. + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@a.service", false) + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@b.service", true) + harness.drain(harness.stale) + } + + else if (s === 110) { + harness.check(harness.liveWith("--quota").length === 1, + "...while opening the panel on it does retry the stale check") + harness.check(harness.liveWith("/c/b").length === 1, + "...for B, the account that was selected") + harness.drain(harness.statusPayload({})) + } + + + // --------------------------------------------------------------------- + // The compatibility guarantee. With no template instances -- and whenever + // discovery fails outright -- the widget must still show exactly the one + // account it showed before this branch existed. + else if (s === 111) { + console.log("a machine with no discovered accounts still has one") + harness.drain(harness.statusPayload({})) + svc.applyDiscovery([]) + harness.check(svc.accountCount === 1, + "an empty discovery leaves exactly one account, not zero") + harness.check(svc.accounts[0].service === "onedrive.service", + "...the plain service, under its legacy unit name") + harness.check(svc.accounts[0].instance === "", + "...with no instance, so it keeps the legacy resume timer") + harness.check(svc.accounts[0].confdir === "", + "...and no config directory, so the client's own default is used") + harness.check(svc.selectedService === "onedrive.service", + "...and it is selected, so every control has a target") + svc.accounts[0].refresh(false) + var plain = harness.liveWith("onedrive-status.py") + harness.check(plain.length === 1 + && plain[0].indexOf("--service") === -1 + && plain[0].indexOf("--confdir") === -1, + "...so it sends exactly the command a single-account machine sent before: " + + (plain.length === 1 ? plain[0] : "none")) + harness.drain(harness.statusPayload({})) + } + + // The panel's own stale-quota retry, which is a different entry point from + // the selection one: Panel.open() calls the coordinator facade directly. + else if (s === 112) { + console.log("opening the panel retries a stale failed storage check") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + harness.drain(harness.statusPayload({})) + } + + else if (s === 113) { + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", quotaError: "temporary failure", quotaCheckedTs: 1 })) + harness.check(harness.liveWith("--quota").length === 0, + "nothing is checking storage yet") + svc.retryStaleQuotaOnOpen() + svc.refreshSelected() + harness.drain(harness.statusPayload({ + syncDir: "/d/b", quotaError: "temporary failure", quotaCheckedTs: 1 })) + } + + else if (s === 114) { + var retried = harness.liveWith("--quota") + harness.check(retried.length === 1, + "the panel's open retries the failed check once") + harness.check(retried.length === 1 && retried[0].indexOf("/c/b") !== -1, + "...for the selected account") + harness.drain(harness.statusPayload({})) + } + + // requestRefresh is the shared-slot gate for every refresh the Account + // starts on its own behalf. The existing test only asserted the NEGATIVE + // half -- that it starts nothing while another account polls -- which an + // empty body satisfies perfectly. + else if (s === 115) { + console.log("an internal refresh does reach the helper when the slot is free") + harness.drain(harness.statusPayload({})) + harness.check(svc.routinePollRunning() === false, "the slot is free") + var beforeInternal = Quickshell.spawnCount + svc.accounts[1].requestRefresh() + harness.check(Quickshell.spawnCount === beforeInternal + 1, + "a refresh routed through the coordinator starts exactly one poll") + harness.check(harness.runningStatusServices().length === 1 + && harness.runningStatusServices()[0] === "onedrive@b.service", + "...for the account that asked") + harness.drain(harness.statusPayload({})) + } + + // What happens when systemd-run refuses. The account is already stopped by + // this point, so "do nothing" leaves it paused with nothing to resume it. + else if (s === 116) { + console.log("a resume timer that cannot be scheduled recovers the account") + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: true, activeState: "active" })) + svc.pauseFor(60) + harness.drain("") + } + + else if (s === 117) { + harness.check(harness.liveWith("systemctl --user stop onedrive@b.service").length === 1, + "the account is stopped") + harness.drain("") + } + + else if (s === 118) { + var run = harness.liveWith("systemd-run") + harness.check(run.length === 1, "the resume timer is attempted") + // ...and systemd refuses it. + var live = Quickshell.running() + for (var r = 0; r < live.length; r++) { + if ((live[r].command || []).indexOf("systemd-run") !== -1) { + live[r].finish(1, "") + } + } + } + + else if (s === 119) { + harness.check(harness.liveWith("systemctl --user start onedrive@b.service").length === 1, + "a refused timer restarts the account rather than leaving it paused forever") + harness.check(svc.accounts[1].actionStatus.indexOf("resum") !== -1, + "...and says so: " + svc.accounts[1].actionStatus) + harness.check(svc.accounts[1].lastError !== "", + "...and reports why the timer could not be set") + harness.drain("") + } + + else if (s === 120) { + // The success path is the other half: it must NOT restart the account. + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@a.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/a", running: true, activeState: "active" })) + svc.pauseFor(30) + harness.drain("") + } + + else if (s === 121 || s === 122) { harness.drain("") } + + else if (s === 123) { + harness.check(harness.liveWith("systemctl --user start onedrive@a.service").length === 0, + "a timer that WAS scheduled does not restart the account it paused") + harness.check(svc.accounts[0].actionStatus === "Timed pause scheduled", + "...and reports the pause as scheduled: " + svc.accounts[0].actionStatus) + harness.drain("") + } + + + // --------------------------------------------------------------------- + // A deferred settle callback outlives the process that scheduled it. With + // a shared boolean, finishing one poll and starting another in the same + // turn let the FIRST poll's callback abandon the SECOND: refreshing + // cleared, watchdog stopped, an error posted, and the live reply ignored. + else if (s === 124) { + console.log("a settled process must not settle its successor") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + harness.drain(harness.statusPayload({})) + } + + else if (s === 125) { + harness.drain(harness.statusPayload({})) + svc.accounts[0].refresh(false) + harness.check(harness.statusProcessFor("onedrive@a.service") !== null, + "a poll is running") + // Completing it schedules the deferred failure check for THIS run... + harness.finishStatus("onedrive@a.service", harness.statusPayload({ syncDir: "/d/a" })) + // ...and a second poll starts before that callback gets to run. + svc.accounts[0].refresh(false) + harness.check(harness.statusProcessFor("onedrive@a.service") !== null, + "...and a second one starts in the same turn") + } + + else if (s === 126) { + harness.check(svc.accounts[0].refreshing === true, + "the second poll is still running, not abandoned by the first's callback") + harness.check(svc.accounts[0].lastError === "", + "...and no failure was invented for it: " + svc.accounts[0].lastError) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ syncDir: "/d/a2" })) + harness.check(svc.accounts[0].syncDir === "/d/a2", + "...so its reply is applied rather than ignored") + } + + // --------------------------------------------------------------------- + // A poll STARTED before a control can complete after it. Counting + // completed samples accepted such a poll as the confirmation, so a sample + // taken before the stop -- still reporting the account as running -- undid + // the pause. The confirmation has to have been started after the control. + else if (s === 127) { + console.log("a poll started before the control does not confirm it") + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: true, activeState: "active" })) + harness.check(svc.selectedAccount.active === true, "B is running") + svc.pause() + harness.check(harness.liveWith("omaonedrive-resume@b").length === 1, + "the resume-timer cancel is in flight") + // The scheduler can hand B a slot here: a control is running, but B's + // STATUS process is idle, which is all the poll gate looks at. + svc.accounts[1].refresh(false) + harness.check(harness.statusProcessFor("onedrive@b.service") !== null, + "...and a poll starts while it is") + } + + else if (s === 128) { + // Let the cancel and then the stop complete, leaving the pre-stop poll + // still in flight. + var live = Quickshell.running() + for (var i = 0; i < live.length; i++) { + if ((live[i].command || []).join(" ").indexOf("onedrive-status.py") === -1) { + live[i].finish(0, "") + } + } + } + + else if (s === 129) { + var live2 = Quickshell.running() + for (var j = 0; j < live2.length; j++) { + if ((live2[j].command || []).join(" ").indexOf("onedrive-status.py") === -1) { + live2[j].finish(0, "") + } + } + harness.check(harness.statusProcessFor("onedrive@b.service") !== null, + "the pre-stop poll is still in flight when the stop completes") + } + + else if (s === 130) { + // ...and now it comes back, reporting what was true BEFORE the stop. + harness.finishStatus("onedrive@b.service", harness.statusPayload({ + syncDir: "/d/b", running: true, activeState: "active" })) + harness.check(svc.accounts[1].active === false, + "a sample taken before the stop does not undo the pause") + harness.check(svc.accounts[1].settling === true, + "...and the account is still waiting for a real confirmation") + } + + else if (s === 131) { + // A poll started AFTER the stop is believed, in both directions. + svc.accounts[1].refresh(false) + harness.finishStatus("onedrive@b.service", harness.statusPayload({ + syncDir: "/d/b", running: false, activeState: "inactive" })) + harness.check(svc.accounts[1].active === false, "a confirming poll leaves it paused") + harness.check(svc.accounts[1].settling === false, "...and ends the wait") + } + + // --------------------------------------------------------------------- + // The other three failure-to-start paths. All three could be deleted + // together with the whole suite green: only the status one was driven. + else if (s === 132) { + console.log("every control that cannot start is handled, not just the poll") + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: true, activeState: "active" })) + svc.pause() + var cancel = harness.liveWith("omaonedrive-resume@b") + harness.check(cancel.length === 1, "the resume-timer cancel is in flight") + // systemctl is not on PATH, or the fork fails. + var procs = Quickshell.running() + for (var c = 0; c < procs.length; c++) { + if ((procs[c].command || []).join(" ").indexOf("omaonedrive-resume@b") !== -1) { + procs[c].failToStart() + } + } + } + + else if (s === 133) { + harness.check(harness.liveWith("systemctl --user stop onedrive@b.service").length === 1, + "a cancel that cannot start still lets the pause through") + // ...and now the stop itself cannot start. + var stops = Quickshell.running() + for (var d = 0; d < stops.length; d++) { + if ((stops[d].command || []).join(" ").indexOf("stop onedrive@b.service") !== -1) { + stops[d].failToStart() + } + } + } + + else if (s === 134) { + harness.check(svc.accounts[1].active === true, + "a control that cannot start does not leave the bar claiming a pause that never happened") + harness.check(svc.accounts[1].lastError !== "", + "...and says the command failed: " + svc.accounts[1].lastError) + harness.drain(harness.statusPayload({ + syncDir: "/d/b", running: true, activeState: "active" })) + } + + else if (s === 135) { + svc.pauseFor(45) + harness.drain("") + } + + else if (s === 136) { harness.drain("") } + + else if (s === 137) { + var run = harness.liveWith("systemd-run") + harness.check(run.length === 1, "the resume timer is attempted") + var timers = Quickshell.running() + for (var t = 0; t < timers.length; t++) { + if ((timers[t].command || []).indexOf("systemd-run") !== -1) timers[t].failToStart() + } + } + + else if (s === 138) { + harness.check(harness.liveWith("systemctl --user start onedrive@b.service").length === 1, + "a systemd-run that cannot START recovers the account, like one that fails") + harness.drain("") + } + + + // --------------------------------------------------------------------- + // The seed sample that had ALREADY been applied. Discarding the in-flight + // reply was only half of it: at startup discovery and the first poll are + // launched together, and the poll -- one helper call against the client's + // default directory -- usually wins the race against a `list-units` plus a + // `systemctl show` per unit. So the wrong sample is on screen by the time + // the confdir arrives, and the old guard explicitly refused to clear it. + else if (s === 139) { + console.log("a seed sample already on screen when discovery lands") + svc.applyDiscovery([ + { service: "onedrive.service", instance: "", confdir: "", description: "" } + ]) + harness.drain(harness.statusPayload({})) + svc.accounts[0].forgetSample() + svc.accounts[0].refresh(false) + // The helper stamps every reply with the directory it actually read. + harness.finishStatus("onedrive.service", harness.statusPayload({ + confdir: "/home/u/.config/onedrive", + syncDir: "/DEFAULT/tree", statusText: "Monitoring the default account" })) + harness.check(svc.accounts[0].initialized === true, + "the seed poll reports, and is shown -- there is nothing yet to say it is wrong") + harness.check(svc.accounts[0].syncDir === "/DEFAULT/tree", "...with its sync directory") + } + + else if (s === 140) { + // Discovery now reads this unit's ExecStart and finds it overrides the + // client default. Everything on screen belongs to another account. + svc.applyDiscovery([ + { service: "onedrive.service", instance: "", + confdir: "/CUSTOM/onedrive", description: "" } + ]) + harness.check(svc.accounts[0].confdir === "/CUSTOM/onedrive", "the unit is repointed") + harness.check(svc.accounts[0].syncDir !== "/DEFAULT/tree", + "the default account's sync directory is dropped, not left for Open folder") + harness.check(svc.accounts[0].initialized === false, + "...and the account is back to having reported nothing") + harness.check(svc.accounts[0].statusText.indexOf("default account") === -1, + "...including its status line") + } + + else if (s === 141) { + // ...and the reply for the RIGHT directory is applied normally, so the + // stamp cannot simply be refusing everything. + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive.service", harness.statusPayload({ + confdir: "/CUSTOM/onedrive", syncDir: "/CUSTOM/tree" })) + harness.check(svc.accounts[0].syncDir === "/CUSTOM/tree", + "a reply stamped with the account's own directory is applied") + harness.check(svc.accounts[0].initialized === true, "...and it reports") + } + + else if (s === 142) { + // A reply stamped with someone else's directory is refused outright, + // whatever the generation says -- the in-flight case and this one are the + // same rule now. + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive.service", harness.statusPayload({ + confdir: "/SOMEONE/else", syncDir: "/SOMEONE/tree", statusText: "Not ours" })) + harness.check(svc.accounts[0].syncDir === "/CUSTOM/tree", + "a reply from another config directory is refused") + harness.check(svc.accounts[0].statusText.indexOf("Not ours") === -1, + "...and leaves nothing of itself behind") + } + + + // The queue was drained past an account removed while QUEUED, but not past + // the one actually holding the slot: its destruction unregisters its + // process without ever emitting pollFinished. + else if (s === 143) { + console.log("removing the account holding the cloud slot frees it") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + harness.drain(harness.statusPayload({})) + } + + else if (s === 144) { + harness.drain(harness.statusPayload({})) + svc.accounts[0].checkQuota() + svc.accounts[1].checkQuota() + harness.check(harness.liveWith("--quota").length === 1, + "A holds the cloud slot and B is queued behind it") + harness.check(harness.liveWith("/c/a").length === 1, "...and it is A") + // A's unit disappears mid-check. + svc.applyDiscovery([ + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + harness.check(svc.accountCount === 1, "A is gone") + } + + else if (s === 145) { + var freed = harness.liveWith("--quota") + harness.check(freed.length === 1, + "B's queued check starts rather than waiting for an unrelated poll") + harness.check(freed.length === 1 && freed[0].indexOf("/c/b") !== -1, + "...and it is B's, with B's own config directory") + harness.drain(harness.statusPayload({})) + } + + + // A refusal must be VISIBLE. Silently ignoring a mismatched reply leaves + // the account on "Checking…" for ever with nothing to explain it -- and if + // the two directories ever disagree for a reason other than the startup + // race, that is a bug someone has to be able to see. + else if (s === 146) { + console.log("a refused reply says why it was refused") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" } + ]) + harness.drain(harness.statusPayload({ confdir: "/c/a" })) + } + + else if (s === 147) { + svc.accounts[0].forgetSample() + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + confdir: "/somewhere/else", syncDir: "/d/other" })) + harness.check(svc.accounts[0].initialized === false, "the reply is refused") + harness.check(svc.accounts[0].lastError.indexOf("/somewhere/else") !== -1, + "...and names the directory it came from: " + svc.accounts[0].lastError) + harness.check(svc.accounts[0].lastError.indexOf("/c/a") !== -1, + "...and the one it should have come from") + } + + else if (s === 148) { + // A reply carrying no stamp at all -- an older helper -- is still + // applied, or an upgrade in the wrong order bricks every account. + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + confdir: "", syncDir: "/d/unstamped" })) + harness.check(svc.accounts[0].syncDir === "/d/unstamped", + "an unstamped reply is still applied") + harness.check(svc.accounts[0].initialized === true, "...and reports") + } + + + // --------------------------------------------------------------------- + // Which conditions RAISE a notification, and what clicking one does. A + // condition sweep -- invert each `if` in turn -- found every one of these + // could be flipped with the harness green: the account could have reported + // "recovered" for a failure and stayed silent for a resync, and the repair + // button could have done nothing. + else if (s === 149) { + console.log("notification edges: each condition raises its own event") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" } + ]) + harness.drain(harness.statusPayload({ confdir: "/c/a" })) + Quickshell.detached = [] + } + + else if (s === 150) { + // Healthy first, so every latch starts clear and the baseline is sent. + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ confdir: "/c/a" })) + svc.flushTransitions() + Quickshell.detached = [] + harness.drain("") + } + + else if (s === 151) { + svc.accounts[0].refresh(false) + // A resync-required account has stopped syncing -- and repairResync + // refuses a running one, so a fixture that leaves it running would test + // the click against a guard rather than against the wiring. + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + confdir: "/c/a", resyncRequired: true, serviceFailed: true, + running: false, activeState: "inactive" })) + svc.flushTransitions() + var resync = harness.notifySent() + harness.check(resync.length === 1, "a new resync requirement notifies once") + harness.check(resync.length === 1 && resync[0].indexOf("needs a resync") !== -1, + "...as a resync, not as the failure it also sets: " + (resync[0] || "")) + harness.check(resync.length === 1 && resync[0].indexOf("--action=default=Run resync repair") !== -1, + "...offering the repair as its button: " + (resync[0] || "")) + } + + else if (s === 152) { + // Clicking "Run resync repair" must actually run it. + Quickshell.detached = [] + harness.check(harness.clickNotification(), "the notification is clickable") + harness.check(harness.detachedWith("--sync --resync").length === 1, + "clicking the repair action runs the resync for that account") + harness.check(harness.detachedWith("--confdir /c/a").length === 1, + "...against its own config directory") + harness.drain("") + } + + else if (s === 153) { + // A repeat of the same condition is NOT a new edge. + Quickshell.detached = [] + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + confdir: "/c/a", resyncRequired: true, serviceFailed: true, + running: false, activeState: "inactive" })) + svc.flushTransitions() + harness.check(harness.notifySent().length === 0, + "the same condition on the next poll does not notify again") + harness.drain("") + } + + else if (s === 154) { + // ...and clearing it reports a recovery, exactly once. + Quickshell.detached = [] + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ confdir: "/c/a" })) + svc.flushTransitions() + var back = harness.notifySent() + harness.check(back.length === 1 && back[0].indexOf("recovered") !== -1, + "clearing it reports a recovery: " + (back[0] || "")) + harness.drain("") + } + + else if (s === 155) { + Quickshell.detached = [] + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ confdir: "/c/a" })) + svc.flushTransitions() + harness.check(harness.notifySent().length === 0, + "...and a second healthy poll does not report recovering again") + harness.drain("") + } + + else if (s === 156) { + // Reauthentication is its own edge, independent of the failure ones. + Quickshell.detached = [] + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + confdir: "/c/a", reauthRequired: true })) + svc.flushTransitions() + var auth = harness.notifySent() + harness.check(auth.length === 1 && auth[0].indexOf("reauthentication") !== -1, + "a new reauthentication requirement notifies: " + (auth[0] || "")) + } + + else if (s === 157) { + // Clicking a non-repair notification opens the panel on that account. + svc.selectedService = "" + harness.check(harness.clickNotification(), "the notification is clickable") + harness.check(svc.selectedService === "onedrive@a.service", + "clicking it selects the account the notification was about") + harness.drain("") + } + + else if (s === 158) { + Quickshell.detached = [] + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ confdir: "/c/a" })) + svc.flushTransitions() + harness.drain("") + } + + else if (s === 159) { + // A plain service failure, with no resync required, is its own edge. + Quickshell.detached = [] + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + confdir: "/c/a", serviceFailed: true, lastError: "unit entered failed state" })) + svc.flushTransitions() + var failed = harness.notifySent() + harness.check(failed.length === 1 && failed[0].indexOf("sync failed") !== -1, + "a service failure notifies: " + (failed[0] || "")) + harness.check(failed.length === 1 && failed[0].indexOf("unit entered failed state") !== -1, + "...carrying the error systemd reported") + harness.drain("") + } + + else if (s === 160) { + Quickshell.detached = [] + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ confdir: "/c/a" })) + svc.flushTransitions() + harness.drain("") + } + + else if (s === 161) { + // Storage is a threshold, not a state: it latches on the way up only. + Quickshell.detached = [] + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + confdir: "/c/a", quotaKnown: true, quotaBytes: 1000, usedBytes: 970 })) + svc.flushTransitions() + var full = harness.notifySent() + harness.check(full.length === 1 && full[0].indexOf("almost full") !== -1, + "crossing the storage threshold notifies: " + (full[0] || "")) + harness.drain("") + } + + else if (s === 162) { + Quickshell.detached = [] + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + confdir: "/c/a", quotaKnown: true, quotaBytes: 1000, usedBytes: 980 })) + svc.flushTransitions() + harness.check(harness.notifySent().length === 0, + "...and staying over it does not notify again") + harness.drain("") + } + + // Discovery failing outright must leave the widget with the one account it + // had before this branch existed, not with none. + else if (s === 163) { + console.log("discovery that fails leaves the single-account fallback") + svc.applyDiscovery([]) + harness.check(svc.accountCount === 1 && svc.accounts[0].service === "onedrive.service", + "an empty discovery falls back to the plain service") + harness.drain(harness.statusPayload({})) + } + + + // --------------------------------------------------------------------- + // Polls have a watchdog; the three control processes did not. `pause()` + // refuses while `busy`, and `busy` is true for as long as one of these + // runs -- so a `systemctl --user stop` that never exits (the user bus not + // back yet after a suspend) left that account's Pause and Resume dead for + // the rest of the session. + else if (s === 164) { + console.log("a control that never exits does not kill Pause for the session") + svc.settings = ({ refreshIntervalSec: 10, recentFileLimit: 5, + notifications: true, statusTimeoutMs: 200 }) + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" } + ]) + harness.drain(harness.statusPayload({ confdir: "/c/a" })) + } + + else if (s === 165) { + harness.drain(harness.statusPayload({ + confdir: "/c/a", running: true, activeState: "active" })) + svc.selectAccount("onedrive@a.service", false) + harness.drain(harness.statusPayload({ + confdir: "/c/a", running: true, activeState: "active" })) + svc.pause() + harness.check(harness.liveWith("omaonedrive-resume@a").length === 1, + "the resume-timer cancel is in flight") + harness.check(svc.accounts[0].busy === true, "...and the account is busy") + // It never exits. + } + + else if (s >= 166 && s <= 169) { /* let the watchdog run */ } + + else if (s === 170) { + harness.check(harness.liveWith("omaonedrive-resume@a").length === 0, + "the watchdog gives up on the wedged cancel") + harness.check(harness.liveWith("systemctl --user stop onedrive@a.service").length === 1, + "...and lets the pause it precedes go ahead anyway") + harness.drain("") + } + + else if (s === 171) { + harness.check(svc.accounts[0].busy === false, + "the account is usable again rather than stuck busy for the session") + harness.drain(harness.statusPayload({ + confdir: "/c/a", running: false, activeState: "inactive" })) + } + + else if (s === 172) { + // ...and a control that hangs is abandoned too, so the next one can run. + svc.accounts[0].refresh(false) + harness.drain(harness.statusPayload({ + confdir: "/c/a", running: true, activeState: "active" })) + svc.pause() + harness.drain("") + } + + else if (s === 173) { + harness.check(harness.liveWith("systemctl --user stop onedrive@a.service").length === 1, + "a stop is in flight") + // ...and never exits. + } + + else if (s >= 174 && s <= 177) { /* let the watchdog run */ } + + else if (s === 178) { + harness.check(harness.liveWith("systemctl --user stop").length === 0, + "the watchdog gives up on the wedged control") + harness.check(svc.accounts[0].busy === false, "...and the account is usable again") + harness.check(svc.accounts[0].active === true, + "...without claiming a pause that never happened") + harness.check(svc.accounts[0].lastError !== "", + "...and saying the command failed: " + svc.accounts[0].lastError) + svc.settings = ({ refreshIntervalSec: 10, recentFileLimit: 5, notifications: true }) + harness.drain(harness.statusPayload({ confdir: "/c/a" })) + } + + + // --------------------------------------------------------------------- + // Discovery failing. A non-zero exit, unreadable output and a helper that + // cannot be started must all be non-destructive AND say so: the user keeps + // the accounts they had, and learns the list may be stale. Without the + // last of those, a missing python3 showed one account and no explanation. + else if (s === 179) { + console.log("discovery that fails is non-destructive, and says so") + svc.applyDiscovery([ + { service: "onedrive@a.service", instance: "a", confdir: "/c/a", description: "A" }, + { service: "onedrive@b.service", instance: "b", confdir: "/c/b", description: "B" } + ]) + harness.drain(harness.statusPayload({})) + svc.reloadAccounts() + var disc = Quickshell.runningWith("--list-accounts") + harness.check(disc.length === 1, "a discovery is running") + disc[0].finish(1, "") + } + + else if (s === 180) { + harness.check(svc.accountCount === 2, + "a discovery that exits non-zero keeps the accounts we had") + harness.check(svc.discoveryError !== "", + "...and says the list may be stale: " + svc.discoveryError) + svc.reloadAccounts() + var disc2 = Quickshell.runningWith("--list-accounts") + harness.check(disc2.length === 1, "another discovery is running") + disc2[0].finish(0, "this is not json") + } + + else if (s === 181) { + harness.check(svc.accountCount === 2, + "unreadable output is 'could not look', not 'found nothing'") + harness.check(svc.discoveryError.indexOf("read") !== -1, + "...and says so: " + svc.discoveryError) + svc.reloadAccounts() + var disc3 = Quickshell.runningWith("--list-accounts") + harness.check(disc3.length === 1, "a third discovery is running") + harness.check(disc3[0].failToStart(), + "...and its executable cannot be started") + } + + else if (s === 182) { + harness.check(svc.accountCount === 2, "the accounts survive that too") + // The SPECIFIC message: `discoveryError` was still set from the previous + // step, so asserting only that it is non-empty passed whether or not the + // failure-to-start path ran at all. + harness.check(svc.discoveryError === "Could not run the OneDrive status helper", + "...and a helper that never ran still reports why: " + svc.discoveryError) + harness.check(svc.lastError !== "", + "...which reaches the panel through the facade") + } + + else if (s === 183) { + // ...and a discovery that works clears it. + svc.reloadAccounts() + var disc4 = Quickshell.runningWith("--list-accounts") + disc4[0].finish(0, harness.discoveryPayload(["a", "b"])) + harness.check(svc.discoveryError === "", + "a discovery that works clears the warning") + harness.check(svc.accountCount === 2, "...and the accounts are still there") + harness.drain(harness.statusPayload({})) + } + + else if (s >= 184) { + // The count is printed so tests/run can tell "every check passed" from + // "no check ran": a qml that exits 0 without executing the harness, or a + // step loop that stops early, both used to read as success. + console.log("QML harness: " + harness.checks + " checks executed") + console.log(harness.failures === 0 + ? "QML harness: all checks passed" + : "QML harness: " + harness.failures + " FAILED") + Qt.exit(harness.failures === 0 ? 0 : 1) + } + } + } + + property string polled: "" + property string second: "" + property int notifyBefore: 0 + property int notified: 0 + property double burstStart: 0 + property int streamTicks: 0 + property string stuck: "" + property string stale: "" +} diff --git a/tests/qml/Smoke.qml b/tests/qml/Smoke.qml new file mode 100644 index 0000000..0070683 --- /dev/null +++ b/tests/qml/Smoke.qml @@ -0,0 +1,41 @@ +import QtQuick +import Quickshell +import "../../" + +Item { + Service { id: svc; settings: ({}) } + property int step: 0 + Timer { + interval: 50; repeat: true; running: true + onTriggered: { + step += 1 + if (step === 1) { + console.log("accounts:", svc.accountCount, "| spawned:", Quickshell.spawnCount) + var live = Quickshell.running() + for (var i = 0; i < live.length; i++) console.log(" running:", JSON.stringify(live[i].command)) + } + if (step === 2) { + // Answer the discovery call with three accounts. + var disc = Quickshell.runningWith("--list-accounts") + console.log("discovery processes:", disc.length) + if (disc.length) { + disc[0].finish(0, JSON.stringify([ + {service:"onedrive@a.service",instance:"a",confdir:"/c/a",description:"A"}, + {service:"onedrive@b.service",instance:"b",confdir:"/c/b",description:"B"}, + {service:"onedrive@c.service",instance:"c",confdir:"/c/c",description:"C"} + ])) + } + } + if (step === 3) { + console.log("after discovery, accounts:", svc.accountCount) + for (var j = 0; j < svc.accounts.length; j++) { + console.log(" ", svc.accounts[j].service, "confdir=" + svc.accounts[j].confdir, + "resumeUnit=" + svc.accounts[j].resumeUnit, + "name=" + svc.accounts[j].displayName) + } + console.log("aggregate:", JSON.stringify(svc.aggregate.kind), "initialized:", svc.aggregate.initialized) + } + if (step >= 4) Qt.exit(0) + } + } +} diff --git a/tests/qmlstubs/Quickshell/Io/Process.qml b/tests/qmlstubs/Quickshell/Io/Process.qml new file mode 100644 index 0000000..11af454 --- /dev/null +++ b/tests/qmlstubs/Quickshell/Io/Process.qml @@ -0,0 +1,55 @@ +import QtQuick +import Quickshell + +// A Process that never actually launches. +// +// Two behaviours here are copied from real Quickshell (0.3.1) rather than +// invented, because the widget depended on the difference: +// +// * an executable that cannot be started takes `running` true -> false and +// NEVER emits `exited`. failToStart() reproduces that. +// * an empty command does not start anything, so `running` stays false. The +// old stub latched it true, which would have hidden the freeze that an empty +// status vector caused. +QtObject { + id: proc + property var command: [] + property bool running: false + property var stdout: null + property var stderr: null + signal exited(int exitCode, int exitStatus) + + onRunningChanged: { + if (running) { + if ((command || []).length === 0) { + // Real Quickshell does not start an empty command. + running = false + return + } + Quickshell.spawnCount += 1 + lastCommand = (command || []).slice() + } + } + property var lastCommand: [] + + Component.onCompleted: Quickshell.register(proc) + Component.onDestruction: Quickshell.unregister(proc) + + // Harness: the executable could not be started. `exited` never fires, which + // is what froze every account when the only cleanup lived in onExited. + function failToStart() { + if (!running) return false + running = false + return true + } + + // Harness: complete this process with the given exit code and stdout. + function finish(exitCode, out) { + if (!running) return false + if (stdout && out !== undefined) stdout.feed(out) + if (stderr) stderr.feed("") + running = false + exited(exitCode === undefined ? 0 : exitCode, 0) + return true + } +} diff --git a/tests/qmlstubs/Quickshell/Io/StdioCollector.qml b/tests/qmlstubs/Quickshell/Io/StdioCollector.qml new file mode 100644 index 0000000..51412ed --- /dev/null +++ b/tests/qmlstubs/Quickshell/Io/StdioCollector.qml @@ -0,0 +1,8 @@ +import QtQuick + +QtObject { + property bool waitForEnd: true + property string text: "" + signal streamFinished() + function feed(value) { text = value; streamFinished() } +} diff --git a/tests/qmlstubs/Quickshell/Io/qmldir b/tests/qmlstubs/Quickshell/Io/qmldir new file mode 100644 index 0000000..184ad5b --- /dev/null +++ b/tests/qmlstubs/Quickshell/Io/qmldir @@ -0,0 +1,3 @@ +module Quickshell.Io +Process 1.0 Process.qml +StdioCollector 1.0 StdioCollector.qml diff --git a/tests/qmlstubs/Quickshell/Quickshell.qml b/tests/qmlstubs/Quickshell/Quickshell.qml new file mode 100644 index 0000000..2b1c32c --- /dev/null +++ b/tests/qmlstubs/Quickshell/Quickshell.qml @@ -0,0 +1,59 @@ +pragma Singleton +import QtQuick + +// Stand-in for the Quickshell singleton, and the harness's bus. Every Process +// the widget creates registers here, so a test can see what was launched and +// decide how it exits -- which is what makes the scheduler, the cloud semaphore +// and the notification broker observable at all. +QtObject { + property var processes: [] + property var detached: [] + property int spawnCount: 0 + + function execDetached(command) { + detached.push(command) + detachedChanged() + } + + function register(process) { + processes.push(process) + processesChanged() + } + + // An account removed by discovery takes its delegate -- and its Processes -- + // with it. Leaving them in this list handed the harness zombie objects whose + // methods no longer exist, and the exception that caused was swallowed by the + // step loop. + function unregister(process) { + var kept = [] + for (var i = 0; i < processes.length; i++) { + if (processes[i] !== process) kept.push(processes[i]) + } + processes = kept + } + + function reset() { + processes = [] + detached = [] + spawnCount = 0 + } + + // Every Process currently running, in creation order. + function running() { + var live = [] + for (var i = 0; i < processes.length; i++) { + if (processes[i].running) live.push(processes[i]) + } + return live + } + + // The running processes whose command contains a given flag or token. + function runningWith(token) { + var hits = [] + var live = running() + for (var i = 0; i < live.length; i++) { + if ((live[i].command || []).indexOf(token) !== -1) hits.push(live[i]) + } + return hits + } +} diff --git a/tests/qmlstubs/Quickshell/qmldir b/tests/qmlstubs/Quickshell/qmldir new file mode 100644 index 0000000..9f6cc64 --- /dev/null +++ b/tests/qmlstubs/Quickshell/qmldir @@ -0,0 +1,2 @@ +module Quickshell +singleton Quickshell 1.0 Quickshell.qml diff --git a/tests/run b/tests/run index 45f7b46..77091f4 100755 --- a/tests/run +++ b/tests/run @@ -4,8 +4,97 @@ set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -node --test "$root/tests/Model.test.js" "$root/tests/IpcLifecycle.test.js" +node --test "$root/tests/Model.test.js" "$root/tests/IpcLifecycle.test.js" \ + "$root/tests/Commands.test.js" "$root/tests/Aggregate.test.js" \ + "$root/tests/Discovery.test.js" "$root/tests/Notifications.test.js" \ + "$root/tests/Scheduler.test.js" "$root/tests/PanelWiring.test.js" + +# Nothing else in this suite PARSES BarWidget.qml or Panel.qml. Both derive from +# the bar's own types, so the harness cannot instantiate them, and a syntax error +# in either -- a thousand lines of panel, in Panel.qml's case -- would leave the +# whole suite green while the widget failed to load at all. +# +# qmllint cannot resolve qs.Commons or qs.Ui outside Omarchy, so most of what it +# reports here is import noise. These two categories are structural and say +# nothing about imports: a parse failure, and a property bound twice. +qmllint_runner=$(command -v qmllint || echo /usr/lib/qt6/bin/qmllint) +if [ -x "$qmllint_runner" ]; then + qmllint_bad=0 + for qml_file in "$root"/*.qml "$root"/tests/qml/*.qml; do + [ -f "$qml_file" ] || continue + lint=$("$qmllint_runner" -I "$root" -I "$root/tests/qmlstubs" "$qml_file" 2>&1 \ + | grep -E '\[(syntax|duplicate-property-binding)\]' || true) + if [ -n "$lint" ]; then + echo "$lint" >&2 + qmllint_bad=1 + fi + done + [ "$qmllint_bad" -eq 0 ] || { echo "qmllint found a structural error" >&2; exit 1; } +else + echo "qmllint SKIPPED (not installed)" >&2 +fi + +# Executes the real Service.qml and Account.qml against stub Quickshell types. +# Skipped rather than failed where qml6 is unavailable, so the suite still runs +# on a machine without Qt tooling -- but it is the only coverage of the +# scheduler, the cloud semaphore, discovery reconciliation and the per-account +# command vectors, so a skip is worth noticing. +# A floor, not an exact count: adding checks must not need a bookkeeping edit, +# but silently losing most of them must fail. Raise it when coverage grows +# substantially. +qml_min_checks=258 +qml_runner=$(command -v qml6 || echo /usr/lib/qt6/bin/qml) +if [ -x "$qml_runner" ]; then + # Capture qml's own status BEFORE any pipeline. Filtering through + # `grep ... || true` starts a new pipeline, so PIPESTATUS[0] reports `true`'s + # zero and the harness can never fail the suite -- which is exactly what it did + # until a reviewer caught it. + qml_output=$(mktemp) + trap 'rm -f -- "$qml_output"' EXIT + # `set -e` would kill the script at a non-zero subshell BEFORE the status is + # captured, so the diagnostics below -- and the cleanup -- never ran, and the + # only evidence of a harness failure was a bare exit code. `|| qml_status=$?` + # keeps the failure and keeps going. + # + # ulimit -c 0: a Qt tool that cannot initialise a platform plugin aborts, and a + # core dump per test run is noise in the user's journal. + # QT_QPA_PLATFORMTHEME is unset because an inherited gtk3 theme tries to open + # the X display even under the offscreen platform, and the run then fails for a + # reason that has nothing to do with the widget. + qml_status=0 + ( ulimit -c 0; unset QT_QPA_PLATFORMTHEME; \ + QT_QPA_PLATFORM=offscreen QT_FORCE_STDERR_LOGGING=1 \ + "$qml_runner" -I "$root/tests/qmlstubs" "$root/tests/qml/Harness.qml" >"$qml_output" 2>&1 ) \ + || qml_status=$? + grep -v 'propertyCache\|Gtk-WARNING\|cannot open display' "$qml_output" || true + + # A zero exit is not evidence the harness ran. A qml that starts and quits, a + # step loop that stops early, a Qt.exit() from somewhere unexpected -- all of + # them exited 0 while executing no assertions at all, and the suite reported + # success. Require the sentinel AND a plausible number of checks. + qml_checks=$(sed -n 's/^.*QML harness: \([0-9]*\) checks executed.*$/\1/p' "$qml_output" | tail -1) + : "${qml_checks:=0}" + if [ "$qml_status" -ne 0 ]; then + echo "QML harness failed (exit $qml_status, $qml_checks checks)" >&2 + exit 1 + fi + if ! grep -q 'QML harness: all checks passed' "$qml_output"; then + echo "QML harness did not report success -- it exited 0 without finishing" >&2 + exit 1 + fi + if [ "$qml_checks" -lt "$qml_min_checks" ]; then + echo "QML harness ran only $qml_checks checks, expected at least $qml_min_checks" >&2 + echo "If you deliberately removed coverage, lower qml_min_checks in tests/run." >&2 + exit 1 + fi +else + echo "QML harness SKIPPED (no qml6 on PATH)" >&2 +fi python3 "$root/tests/Journal.test.py" "$root/tests/Status.test.sh" +# Integration: the argv the QML builds, run against the real helper on this +# machine. Skips where qml6 or real accounts are absent. +"$root/tests/contract.sh" + echo "All OmaOneDrive tests passed" From 69a7d5b2fc7878efe8129990797658a5d9918e66 Mon Sep 17 00:00:00 2001 From: Michael Benner <36419818+mikebenner@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:06:09 -0700 Subject: [PATCH 4/4] Finish the condition sweep: pin the eleven inversions it could still miss (#3 follow-up) The invert-every-if sweep over Service.qml and Account.qml finally ran to completion. Eleven of 117 conditions survived; none were dead code, and all are now pinned by harness checks: the settings NaN-guard in both directions, error precedence on the facade, the discovery failure-to-start hook firing only on failure to start, the timed pause on an already-stopped account, the settle loop's persistence under a blocked slot, the hung (not failed) systemd-run watchdog and its final recovery message, all three cloud-check completion messages plus the routine-poll silence gate, and the account-level deferred cloud request. Test-only: 299 harness checks (was 263), no runtime file changed. Final sweep state -- zero of 117 conditions in the QML and zero of 97 in Model.js invert undetected; all 65 QML functions fail the suite when emptied. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ErMhXzfZ86BcJuhhgb242 --- tests/qml/Harness.qml | 215 +++++++++++++++++++++++++++++++++++++++++- tests/run | 2 +- 2 files changed, 215 insertions(+), 2 deletions(-) diff --git a/tests/qml/Harness.qml b/tests/qml/Harness.qml index d749b78..d59629f 100644 --- a/tests/qml/Harness.qml +++ b/tests/qml/Harness.qml @@ -998,6 +998,12 @@ Item { else if (s >= 78 && s <= 87) { harness.check(svc.accounts[1].active === false, "B stays paused on screen while no poll can confirm it (tick " + (s - 77) + ")") + // ...and it is still ASKING. The settle loop's give-up threshold could + // be inverted -- stop after one tick instead of thirty -- and nothing + // noticed; the account then lost its scheduler priority and the + // confirming poll came whenever the round-robin happened to reach it. + harness.check(svc.accounts[1].settling === true, + "...and is still waiting for its confirming poll (tick " + (s - 77) + ")") } else if (s === 88) { @@ -2037,7 +2043,214 @@ Item { harness.drain(harness.statusPayload({})) } - else if (s >= 184) { + + // --------------------------------------------------------------------- + // Three conditions a sweep could invert with the suite green. + else if (s === 184) { + console.log("settings survive garbage, and real values are actually used") + // The NaN guard: a setting that cannot parse falls back. Without the + // guard, NaN reaches the poll timer's interval arithmetic. + svc.settings = ({ refreshIntervalSec: "not-a-number", + recentFileLimit: 5, notifications: true }) + harness.check(svc.refreshIntervalSec === 30, + "a setting that cannot be a number falls back to the default") + // ...and the discriminating half: a real value is USED. Inverting the + // guard made every setting its fallback, and nothing noticed. + svc.settings = ({ refreshIntervalSec: 45, recentFileLimit: 5, notifications: true }) + harness.check(svc.refreshIntervalSec === 45, + "a real value is used, not the fallback") + svc.settings = ({ refreshIntervalSec: 10, recentFileLimit: 5, notifications: true }) + } + + else if (s === 185) { + console.log("the account's own error outranks the discovery warning") + // A clean slate first: earlier steps leave residual account errors, and + // this test is about precedence, not history. + svc.selectAccount("onedrive@a.service", false) + harness.drain(harness.statusPayload({ confdir: "/c/a" })) + harness.check(svc.selectedAccount.lastError === "", "the account starts clean") + svc.reloadAccounts() + var disc5 = Quickshell.runningWith("--list-accounts") + harness.check(disc5.length === 1, "a discovery is running") + disc5[0].finish(1, "") + harness.check(svc.discoveryError !== "" && svc.lastError === svc.discoveryError, + "with nothing else wrong, the facade shows the discovery warning") + // Now the selected account itself fails... + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", "this is not json") + harness.check(svc.selectedAccount.lastError !== "", + "the account carries its own error") + harness.check(svc.lastError === svc.selectedAccount.lastError + && svc.lastError !== svc.discoveryError, + "...which outranks the stale-list warning: the account's problem is\n" + + " actionable NOW, the warning is background") + } + + else if (s === 186) { + // Both clear, and the facade goes quiet -- pinning the empty case too. + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ confdir: "/c/a" })) + svc.reloadAccounts() + var disc6 = Quickshell.runningWith("--list-accounts") + if (disc6.length === 1) disc6[0].finish(0, harness.discoveryPayload(["a", "b"])) + harness.check(svc.lastError === "", + "a healthy poll and a working discovery clear the facade: " + svc.lastError) + } + + else if (s === 187) { + console.log("a discovery that starts normally raises no false alarm") + svc.reloadAccounts() + harness.check(Quickshell.runningWith("--list-accounts").length === 1, + "discovery is running") + } + + else if (s === 188) { + // A tick has passed, so the deferred failure-to-start check has run and + // found the process alive. Inverting its condition armed it on START + // instead: every discovery then reported "Could not run the OneDrive + // status helper" while running perfectly well. + harness.check(svc.discoveryError === "", + "no could-not-run alarm while discovery is merely still running: " + + svc.discoveryError) + var disc7 = Quickshell.runningWith("--list-accounts") + if (disc7.length === 1) disc7[0].finish(0, harness.discoveryPayload(["a", "b"])) + harness.check(svc.discoveryError === "", "...and none after it succeeds") + harness.drain(harness.statusPayload({})) + } + + + // --------------------------------------------------------------------- + // The Account.qml conditions the sweep could invert undetected. + // A timed pause on an account that is ALREADY stopped must re-arm the + // timer without issuing a stop. + else if (s === 189) { + console.log("a timed pause on a stopped account re-arms the timer only") + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + confdir: "/c/b", syncDir: "/d/b", running: false, activeState: "inactive" })) + harness.check(svc.selectedAccount.active === false, "B is stopped") + svc.pauseFor(20) + harness.drain("") + } + + else if (s === 190) { + harness.check(harness.liveWith("systemctl --user stop onedrive@b.service").length === 0, + "no stop is sent to a unit that is already stopped") + var rearm = harness.liveWith("systemd-run") + harness.check(rearm.length === 1 + && rearm[0].indexOf("--on-active=20m") !== -1 + && rearm[0].indexOf("--unit=omaonedrive-resume@b") !== -1, + "...but the resume timer is re-armed for the new duration") + harness.drain("") + } + + // A systemd-run that HANGS -- starts, never exits -- is different from one + // that fails or cannot start, and only its watchdog can end it. + else if (s === 191) { + console.log("a hanging systemd-run is abandoned and the account recovered") + svc.settings = ({ refreshIntervalSec: 10, recentFileLimit: 5, + notifications: true, statusTimeoutMs: 200 }) + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@b.service", false) + harness.drain(harness.statusPayload({ + confdir: "/c/b", syncDir: "/d/b", running: true, activeState: "active" })) + svc.pauseFor(30) + harness.drain("") + } + + else if (s === 192) { harness.drain("") } + + else if (s === 193) { + harness.check(harness.liveWith("systemd-run").length === 1, + "the schedule is in flight and will never return") + } + + else if (s >= 194 && s <= 197) { /* let the watchdog run */ } + + else if (s === 198) { + harness.check(harness.liveWith("systemd-run").length === 0, + "the watchdog gives up on the hung schedule") + harness.check(harness.liveWith("systemctl --user start onedrive@b.service").length === 1, + "...and recovery starts the account it had stopped") + // Complete the recovery, and the interim message must settle into the + // final one -- the old assert matched both, so the completion branch + // could be inverted undetected. + var rec = Quickshell.running() + for (var r = 0; r < rec.length; r++) { + if ((rec[r].command || []).join(" ").indexOf("start onedrive@b.service") !== -1) { + rec[r].finish(0, "") + } + } + harness.check(svc.accounts[1].actionStatus === "Timed pause failed; syncing resumed", + "...and says so once the recovery completes: " + svc.accounts[1].actionStatus) + svc.settings = ({ refreshIntervalSec: 10, recentFileLimit: 5, notifications: true }) + } + + // What a finished cloud check tells the user, in all three shapes -- and + // that a routine poll says nothing at all. + else if (s === 199) { + console.log("a finished cloud check reports; a routine poll stays quiet") + harness.drain(harness.statusPayload({})) + svc.selectAccount("onedrive@a.service", false) + harness.drain(harness.statusPayload({ confdir: "/c/a" })) + svc.accounts[0].checkQuota() + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + confdir: "/c/a", quotaKnown: true, quotaBytes: 100, usedBytes: 1 })) + harness.check(svc.accounts[0].actionStatus === "Storage refreshed", + "a successful storage check says so: " + svc.accounts[0].actionStatus) + } + + else if (s === 200) { + svc.accounts[0].checkFullStatus() + harness.finishStatus("onedrive@a.service", harness.statusPayload({ confdir: "/c/a" })) + harness.check(svc.accounts[0].actionStatus === "Sync verified", + "a successful sync check says so: " + svc.accounts[0].actionStatus) + } + + else if (s === 201) { + svc.accounts[0].checkQuota() + harness.finishStatus("onedrive@a.service", "", 1) + harness.check(svc.accounts[0].actionStatus !== "" + && svc.accounts[0].actionStatus === svc.accounts[0].lastError, + "a FAILED check reports its error: " + svc.accounts[0].actionStatus) + } + + else if (s === 202) { + // The other half of the gate: a routine poll must not claim anything. + svc.accounts[0].actionStatus = "" + svc.accounts[0].refresh(false) + harness.finishStatus("onedrive@a.service", harness.statusPayload({ confdir: "/c/a" })) + harness.check(svc.accounts[0].actionStatus === "", + "a routine poll sets no action message: " + svc.accounts[0].actionStatus) + } + + // A cloud check requested while that account's routine poll is in flight + // is deferred on the ACCOUNT (not the coordinator queue), and must still + // run when the poll lands. + else if (s === 203) { + console.log("a cloud check requested mid-poll still runs afterwards") + svc.accounts[0].refresh(false) + harness.check(harness.statusProcessFor("onedrive@a.service") !== null, + "a routine poll is in flight") + svc.accounts[0].checkQuota() + harness.check(harness.liveWith("--quota").length === 0, + "...so the storage check is deferred, not run concurrently") + harness.finishStatus("onedrive@a.service", harness.statusPayload({ confdir: "/c/a" })) + } + + else if (s === 204) { + var deferred = harness.liveWith("--quota") + harness.check(deferred.length === 1, + "the deferred check starts once the poll lands") + harness.check(deferred.length === 1 && deferred[0].indexOf("/c/a") !== -1, + "...for the account that asked") + harness.finishStatus("onedrive@a.service", harness.statusPayload({ + confdir: "/c/a", quotaKnown: true, quotaBytes: 100, usedBytes: 1 })) + harness.drain(harness.statusPayload({})) + } + + else if (s >= 205) { // The count is printed so tests/run can tell "every check passed" from // "no check ran": a qml that exits 0 without executing the harness, or a // step loop that stops early, both used to read as success. diff --git a/tests/run b/tests/run index 77091f4..e113c0e 100755 --- a/tests/run +++ b/tests/run @@ -42,7 +42,7 @@ fi # A floor, not an exact count: adding checks must not need a bookkeeping edit, # but silently losing most of them must fail. Raise it when coverage grows # substantially. -qml_min_checks=258 +qml_min_checks=290 qml_runner=$(command -v qml6 || echo /usr/lib/qt6/bin/qml) if [ -x "$qml_runner" ]; then # Capture qml's own status BEFORE any pipeline. Filtering through