diff --git a/docs/SKILL.md b/docs/SKILL.md index 94ab41e45..e4eaf3b93 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -39,6 +39,7 @@ repository's local catalog with `docs/skills/hive.md` as the preflight guard. | Change a GNOME setting or dconf key | [`dconf-consistency.md`](skills/dconf-consistency.md) | | Work on Bazaar config or hooks | [`bazaar.md`](skills/bazaar.md) | | Edit `system_files/shared/` or `bluefin/` | [`submodule-boundary.md`](skills/submodule-boundary.md) | +| Add a systemd user unit or quadlet, or write a ujust toggle for one | [`systemd-user-units.md`](skills/systemd-user-units.md) | | Touch any image reference or registry path | [`image-registry.md`](skills/image-registry.md) | | Add an image variant or write `image-info.json` | [`image-identity.md`](skills/image-identity.md) | | Modify the `Containerfile` or add a binary | [`containerfile/SKILL.md`](skills/containerfile/SKILL.md) | diff --git a/docs/skills/index.json b/docs/skills/index.json index 7023f9428..a71b6c2eb 100644 --- a/docs/skills/index.json +++ b/docs/skills/index.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-09-26", + "generated_at": "2026-09-27", "schema_version": "1.0", "skills": [ { @@ -762,6 +762,25 @@ "last_updated": "2026-06-23", "doc_type": "reference" }, + { + "id": "systemd-user-units", + "name": "systemd-user-units", + "one_line_purpose": "Ship systemd user units and rootless quadlets from common, and toggle them per user.", + "entry_point": "docs/skills/systemd-user-units.md", + "category": "platform", + "status": "active", + "tags": [ + "systemd", + "quadlet", + "user-units", + "ujust", + "system_files" + ], + "description": "systemd user services, presets, and rootless quadlet containers shipped by common. Use when adding or editing a unit under system_files/shared/, writing a ujust enable/disable toggle for one, or debugging why a unit starts anyway.", + "version": "1.0", + "last_updated": "2026-09-25", + "doc_type": "reference" + }, { "id": "workflow-map", "name": "workflow-map", diff --git a/docs/skills/index.md b/docs/skills/index.md index a8077d069..c97bcf368 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -3,7 +3,7 @@ This file is a human-readable mirror of `index.json`. Both are generated by `scripts/generate_skill_index.py` — do not hand-edit either file. -Generated: 2026-09-26 · schema 1.0 · 45 skills +Generated: 2026-09-27 · schema 1.0 · 46 skills | id | category | status | one-line purpose | |---|---|---|---| @@ -50,5 +50,6 @@ Generated: 2026-09-26 · schema 1.0 · 45 skills | [skill-drift](skill-drift.md) | meta | deprecated | Record why the skill-drift CI check was retired and what replaced it. | | [skill-improvement](skill-improvement.md) | meta | active | Capture durable agent learnings in maintained skill docs. | | [submodule-boundary](submodule-boundary.md) | meta | active | Decide where a system_files change belongs across variants. | +| [systemd-user-units](systemd-user-units.md) | platform | active | Ship systemd user units and rootless quadlets from common, and toggle them per user. | | [workflow-map](workflow-map.md) | ci-ops | active | Understand what each GitHub workflow in common does. | | [write-a-skill](write-a-skill.md) | meta | active | Author a new skill doc following front-matter and size rules. | diff --git a/docs/skills/shell-scripts/references/bats-patterns.md b/docs/skills/shell-scripts/references/bats-patterns.md index e1cf08ec5..4e9fa69b5 100644 --- a/docs/skills/shell-scripts/references/bats-patterns.md +++ b/docs/skills/shell-scripts/references/bats-patterns.md @@ -70,6 +70,93 @@ _extract_script() { } ``` +### Extract by recipe name, not just the first shebang + +A file with several recipes needs the name-anchored form. Match the recipe +header as a regex so parameterised recipes are covered too, and stop at the +first line that returns to column zero: + +```bash +_extract_recipe() { + awk -v name="$1" ' + $0 ~ ("^" name "([[:space:]].*)?:$") { in_recipe = 1; next } + in_recipe && $0 ~ /^[^[:space:]]/ { exit } + in_recipe && $0 ~ /^ / { sub(/^ /, ""); print } + ' "${JUSTFILE}" +} +``` + +`tests/test_shared_just.bats` and `tests/test_update_just.bats` both use this +shape; the trailing `([[:space:]].*)?` is what makes +`toggle-ffmpeg-thumbnailer ACTION="prompt":` extractable. + +### Substitute `{{ ... }}` before running the body + +The extracted text is not yet valid bash — `just` replaces `{{ NAME }}` before +the shell runs, so a test that executes the raw body runs a literal +`{{ NAME }}` and fails in a way that looks like a recipe bug. Pipe the +extraction through `sed`, mirroring the recipe's own default: + +```bash +_extract_toggle_recipe() { + _extract_recipe toggle-ffmpeg-thumbnailer \ + | sed 's/{{ ACTION }}/${ACTION:-prompt}/g' +} +``` + +### Never write `{{` in a recipe body + +`just` interpolates recipe bodies, shebang recipes included, so a literal +`{{` aborts parsing of the whole file. This bites when a recipe shells out to +something that takes its own templating syntax: + +``` +error: unknown start of token '.' + ——▶ util.just:3:22 + │ podman ps --format '{{.Status}}' +``` + +Avoid the argument (drop `--format`, or use JSON plus `jq`), or escape the +opening braces as `{{{{` — `{{{{.Status}}` reaches the shell as `{{.Status}}`. +The closing braces need no escaping. + +### Assert the full command line + +Stub the binary to append `"$*"` to a call log, then assert the whole logical +call with a fixed-string line match rather than a substring, so a test cannot +pass on a partial match: + +```bash +run grep -Fqx "systemctl --user mask ffmpeg-thumbnailer-daemon.service" <<< "$(_calls)" +[ "${status}" -eq 0 ] +``` + +For ordering guarantees (stop before mask, prompt before mutation), compare +`grep -n` line numbers from the same log. + +### Assert the flags a stub cannot feel + +A stub returns instantly, so it can never reproduce a blocking call. Anything +whose real cost is latency — `systemctl start` on a quadlet that pulls an +image, a network fetch, a `--wait` flag — has to be pinned as a flag assertion +instead. Assert over every matching call, not just the one you remember: + +```bash +run grep -E "^systemctl --user (start|enable --now)( |$)" <<< "$(_calls)" +[ "${status}" -eq 0 ] +while read -r line; do + [[ "${line}" == *" --no-block"* ]] +done <<< "${output}" +``` + +### Give each real failure mode its own stub knob + +One knob per mock behaviour, not one per unit state. `systemctl cat` and +`systemctl is-enabled` fail independently on a real system (a masked unit can +fail the first and still answer the second), so the stub needs +`MOCK_UNIT_PRESENT` *and* `MOCK_CAT_FAIL`. Collapsing them into one flag makes +the suite unable to express the case a fallback exists for. + Then run: `bash "${extracted_script}"` with mocked PATH binaries. ## Pitfall: literal `*` in bats grep assertions diff --git a/docs/skills/systemd-user-units.md b/docs/skills/systemd-user-units.md new file mode 100644 index 000000000..c12953eff --- /dev/null +++ b/docs/skills/systemd-user-units.md @@ -0,0 +1,207 @@ +--- +name: systemd-user-units +version: "1.0" +last_updated: "2026-09-25" +id: systemd-user-units +one_line_purpose: Ship systemd user units and rootless quadlets from common, and toggle them per user. +entry_point: docs/skills/systemd-user-units.md +category: platform +mcp_compliance_level: partial +optimization_status: draft +status: active +dependencies: [] +tags: [systemd, quadlet, user-units, ujust, system_files] +description: >- + systemd user services, presets, and rootless quadlet containers shipped by + common. Use when adding or editing a unit under system_files/shared/, writing + a ujust enable/disable toggle for one, or debugging why a unit starts anyway. +metadata: + type: reference +--- + +# systemd user units and quadlets — projectbluefin/common + +Covers the units this repo ships in `system_files/shared/`, how they get +enabled on the image, and the only reliable per-user way to switch them off. + +## When to Use + +- Adding or editing anything under `system_files/shared/usr/lib/systemd/user/`, + `usr/lib/systemd/user-preset/`, or `usr/share/containers/systemd/users/` +- Writing a `ujust` recipe that enables, disables, or reports on such a unit + (see `toggle-ffmpeg-thumbnailer` / `status-ffmpeg-thumbnailer` in + [`shared.just`](../../system_files/shared/usr/share/ublue-os/just/shared.just)) +- A user reports a unit still starting after they disabled it + +## Where units live + +| Path | What it is | +|---|---| +| `usr/lib/systemd/user/*.service` | Static user units. Image content, read-only at runtime. | +| `usr/lib/systemd/user-preset/*.preset` | `enable`/`disable` defaults applied when the unit is first seen (image install / first user creation). | +| `usr/lib/systemd/user/.wants/` | Static pull-in symlink. Part of the image, not of the enable state. | +| `usr/share/containers/systemd/users/*.container` | Rootless quadlets. The quadlet generator turns `foo.container` into `foo.service` at user-manager start. | + +Quadlet naming is the one thing to get right when writing `After=` lines or a +toggle: the generated unit is `.service`, so +`ffmpeg-thumbnailer-nvidia.container` is `ffmpeg-thumbnailer-nvidia.service`, +regardless of the `ContainerName=` inside the file. Do not assume a `.container` +file can be masked by its own name. + +## Enabling: `[Install]` + the quadlet generator + +Downstream image repos do **not** run `systemctl --global enable`. What starts +a unit here is one of three things, and it is worth checking which one before +writing a toggle: + +- An `[Install] WantedBy=` line plus something that acts on it — a shipped + `usr/lib/systemd/user-preset/*.preset` entry, or the user running + `systemctl --user enable`. `ffmpeg-thumbnailer-daemon.service` takes this + route: it ships `WantedBy=graphical-session.target default.target` and + `common` ships no preset for it, so the `enable` branch of the toggle is what + installs the wants symlink. +- A static `usr/lib/systemd/user/.wants/` symlink baked into the + image. `common` uses this for system timers (`timers.target.wants/`), not for + the thumbnailer. +- The quadlet generator. `ffmpeg-thumbnailer.container` and + `ffmpeg-thumbnailer-nvidia.container` carry no `[Install]` section at all; + the generator materialises `.service` at user-manager start, but + nothing in the image pulls those services in. The daemon only orders itself + `After=` them, which does not start them, so the sole starter today is the + `enable` branch of `ujust toggle-ffmpeg-thumbnailer`. + +Two consequences for anything you write in `common`: + +- A `WantedBy=` line alone is inert. If a unit must start for every user + without a ujust opt-in, it needs a preset line or a static `wants/` symlink. +- A per-user toggle cannot rely on `systemctl --user disable`, because two of + the three units have no install state at all. See below. + +## Disabling per user: mask, not disable + +`systemctl --user disable ` only removes symlinks the enable verb +created. For the quadlet-generated units it has nothing to act on — the +generated `.service` has no install state systemctl can edit, so only the +generator decides whether it exists and what pulls it in. For the daemon it +does work, but it is not durable: a preset added downstream, or a later +`enable`, reinstates the wants symlink, and `disable` does nothing to stop a +unit another unit already pulled in. + +`systemctl --user mask ` writes a symlink to `/dev/null` into the user's +own unit directory (no `sudo`) and outranks the image and generator locations, +so systemd refuses to load the unit no matter what pulls it in. That is the +one mechanism that works uniformly for all three units, which is why the +thumbnailer toggle masks the daemon *and* both container quadlets instead of +stopping them: + +```bash +systemctl --user stop ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service +systemctl --user mask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service +# later, to restore image behaviour: +systemctl --user unmask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service +``` + +Re-enabling also needs `systemctl --user daemon-reload`, and starting the +container quadlets explicitly if thumbnails must work before the next login. +Start them with `--no-block`: they carry `TimeoutStartSec=900` and pull a +111 MB image on first start, so a blocking `start` stalls the caller for +minutes. A mask is durable user state: it survives image updates until +`unmask` (or deleting the symlink) removes it. + +Stop before mask, in that order: the daemon unlinks its socket on `SIGTERM`, so +masking a live unit can leave a stale socket path behind. A single +`systemctl --user stop a b c` is enough — argument order does not sequence +anything; the daemon's `After=` on the container units is what makes systemd +stop it first. + +## Masking is not enough: the `environment.d` drop-in + +The thumbnailer also ships a system `environment.d` drop-in that puts its shim +directory first in `XDG_DATA_DIRS` for video MIME types. That variable is read +by the user session at login, not by a unit, so masking the units does nothing +to it: the shim `.thumbnailer` entries keep winning and video thumbnails keep +failing to the masked daemon. + +`environment.d` resolves same-named files by name across its search path, and +the user directory outranks `/usr/lib`, so writing an empty (comment-only) file +at the same name shadows the system drop-in completely: + +```bash +ENV_OVERRIDE="${XDG_CONFIG_HOME:-$HOME/.config}/environment.d/10-ffmpeg-thumbnailer.conf" +mkdir -p "$(dirname "${ENV_OVERRIDE}")" +printf '# masks the system drop-in\n' > "${ENV_OVERRIDE}" # disable +rm -f "${ENV_OVERRIDE}" # enable +``` + +Two consequences for any toggle that follows this pattern: + +- The override is durable user state, like a mask, and it takes effect at the + next login rather than immediately. Say so in *both* toggle branches: removing + the file on enable is as deferred as writing it on disable, so a user who + enables mid-session sees active units and still no thumbnails until re-login. +- Report it. A user who unmasks the units by hand, without the toggle, still + gets no thumbnails and has nothing pointing at the leftover file — + `status-ffmpeg-thumbnailer` prints whether it is present for exactly that + reason. The status recipe also short-circuits on the same "is it installed?" + probe as the toggle, so the pair never disagrees about an absent thumbnailer. + +## Writing the toggle recipe + +Recipe bodies are bash, so test them by extracting the body and stubbing +`systemctl` on `PATH` (see +[`shell-scripts/references/bats-patterns.md`](shell-scripts/references/bats-patterns.md)). +Two things to carry into the recipe: + +- Resolve every unit through one array so stop/mask/start cannot drift apart, + and probe for the unit (`systemctl --user cat `) before mutating + anything — `shared.just` is also consumed by images that do not ship the + thumbnailer, where the recipe must report "not installed" instead of + masking a unit that does not exist. +- Never write `{{` in the body. `just` interpolates it before bash ever sees + the script, and a `podman ps --format '{{.Status}}'` style argument fails to + parse the entire recipe. Write the literal as `{{{{` if one is unavoidable. + +## Red Flags + +- A toggle that calls `systemctl --user disable` on a quadlet-generated + `.service` — there is no install state to remove, so nothing changes +- Masking a quadlet's `.container` filename instead of the generated `.service` +- Assuming `systemctl --user cat .service` fails merely because the + file is in `usr/share/containers/systemd/` — the generator makes the unit + loadable at user-manager start, and `cat` is the right existence probe +- A recipe body containing `{{ ... }}` +- A toggle that masks units but leaves a session-level `environment.d` drop-in + in place, or a status recipe that never reports the override it wrote +- Documenting enablement as `systemctl --global enable`; presets, static + `wants/` symlinks, or an explicit `systemctl --user enable` own it + +## Verification + +Re-derive each fact on a live system rather than trusting this page: + +```bash +# Real unit search path and its order for the user manager: +systemd-analyze --user unit-paths + +# Enable state: "masked" is the state this skill's toggles produce. +systemctl --user is-enabled ffmpeg-thumbnailer-daemon.service + +# Where a mask landed, and what it shadows: +ls -l "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/" + +# Whether a user environment.d override is shadowing the system drop-in: +ls -l "${XDG_CONFIG_HOME:-$HOME/.config}/environment.d/" +systemd-analyze --user cat-config environment.d + +# What actually pulls a unit in (preset, static wants, or generator): +systemctl --user show -p LoadState -p UnitFileState -p WantedBy -p RequiredBy \ + ffmpeg-thumbnailer-daemon.service +grep -rn "ffmpeg-thumbnailer" system_files/shared/usr/lib/systemd/ \ + system_files/shared/usr/share/containers/systemd/ +``` + +## See Also + +- [`shell-scripts/SKILL.md`](shell-scripts/SKILL.md) — testing recipe bodies and shell scripts +- [`submodule-boundary.md`](submodule-boundary.md) — which `system_files/` tree a unit belongs in +- [`dconf-consistency.md`](dconf-consistency.md) — the same override-vs-lock pattern for desktop settings diff --git a/system_files/shared/usr/share/ublue-os/just/shared.just b/system_files/shared/usr/share/ublue-os/just/shared.just index 24b462e74..1f9c5d57e 100644 --- a/system_files/shared/usr/share/ublue-os/just/shared.just +++ b/system_files/shared/usr/share/ublue-os/just/shared.just @@ -21,3 +21,170 @@ powerwash: exit 0 fi sudo bootc install reset --experimental + +# Toggle the rootless ffmpeg video thumbnailer (container quadlets + host daemon) +# Pass `enable`, `disable`, or `cancel` (case-insensitive) as ACTION, or run +# without arguments for the interactive prompt. +[group('System')] +toggle-ffmpeg-thumbnailer ACTION="prompt": + #!/usr/bin/bash + ACTION_VALUE="{{ ACTION }}" + + DAEMON_UNIT="ffmpeg-thumbnailer-daemon.service" + # Array order is cosmetic: stop/mask/unmask take all three units in one + # call, where argument order does not affect sequencing. The daemon still + # gets SIGTERM before the containers go away because its unit orders itself + # with After= on them, which is what lets it unlink its socket. + THUMBNAILER_UNITS=( + ffmpeg-thumbnailer-daemon.service + ffmpeg-thumbnailer.service + ffmpeg-thumbnailer-nvidia.service + ) + # A same-named user drop-in overrides /usr/lib/environment.d, which + # otherwise keeps the shim first in XDG_DATA_DIRS for video MIME types. + ENV_OVERRIDE="${XDG_CONFIG_HOME:-${HOME}/.config}/environment.d/10-ffmpeg-thumbnailer.conf" + + if ! systemctl --user cat "${DAEMON_UNIT}" >/dev/null 2>&1 \ + && [[ -z "$(systemctl --user is-enabled "${DAEMON_UNIT}" 2>/dev/null)" ]]; then + echo "The ffmpeg video thumbnailer is not installed on this system." + exit 0 + fi + + _is_masked() { + [[ "$(systemctl --user is-enabled "$1" 2>/dev/null)" == "masked" ]] + } + + case "${ACTION_VALUE,,}" in + enable) + SELECTED_OPTION="Enable" + ;; + disable) + SELECTED_OPTION="Disable" + ;; + cancel) + exit 0 + ;; + *) + CURRENT_STATE="enabled" + if _is_masked "${DAEMON_UNIT}"; then + CURRENT_STATE="disabled" + fi + echo "The ffmpeg video thumbnailer is currently ${CURRENT_STATE}" + SELECTED_OPTION="$(gum choose --header="Toggle the ffmpeg video thumbnailer?" "Enable" "Disable" "Cancel")" + if [[ "${SELECTED_OPTION}" != "Enable" && "${SELECTED_OPTION}" != "Disable" ]]; then + echo "No changes made." + exit 0 + fi + ;; + esac + + if [[ "${SELECTED_OPTION}" == "Disable" ]]; then + systemctl --user stop "${THUMBNAILER_UNITS[@]}" >/dev/null 2>&1 || true + # Mask, not disable: the container units are quadlet-generated, so they + # carry no install state `systemctl --user disable` can edit, and the + # daemon ships `WantedBy=graphical-session.target default.target`, which + # a preset or a re-enable can reinstate. Only a mask overrides both. + # The per-user mask also blocks the container quadlets, so the 111 MB + # image is never pulled again while the toggle is off. + if systemctl --user mask "${THUMBNAILER_UNITS[@]}"; then + mkdir -p "$(dirname "${ENV_OVERRIDE}")" + printf '# Written by ujust toggle-ffmpeg-thumbnailer: masks the system drop-in.\n' > "${ENV_OVERRIDE}" + echo "The ffmpeg video thumbnailer has been disabled" + echo "Its container and daemon units are masked and stopped;" + echo "after you log out and back in, video thumbnails fall back to the in-sandbox thumbnailer." + else + echo "Failed to disable the ffmpeg video thumbnailer" >&2 + exit 1 + fi + else + if systemctl --user unmask "${THUMBNAILER_UNITS[@]}"; then + rm -f "${ENV_OVERRIDE}" + systemctl --user daemon-reload >/dev/null 2>&1 || true + systemctl --user enable --now --no-block "${DAEMON_UNIT}" >/dev/null 2>&1 || true + # --no-block: the container quadlets carry TimeoutStartSec=900 and + # pull a 111 MB image on their first start, so a blocking start + # would stall this recipe for minutes with its output discarded. + systemctl --user start --no-block "${THUMBNAILER_UNITS[@]}" >/dev/null 2>&1 || true + echo "The ffmpeg video thumbnailer has been enabled" + echo "Its units are unmasked and starting, but the shim only returns to" + echo "XDG_DATA_DIRS at the next login, so video thumbnails come back" + echo "after you log out and back in." + echo "The container image is pulled in the background by that start, so" + echo "the first thumbnails after logging back in may fail until it lands." + echo "Run 'ujust status-ffmpeg-thumbnailer' to see whether it is up." + else + echo "Failed to enable the ffmpeg video thumbnailer" >&2 + exit 1 + fi + fi + +# Inspect the rootless ffmpeg video thumbnailer: units, container, socket, and +# recent daemon activity. +[group('System')] +status-ffmpeg-thumbnailer: + #!/usr/bin/bash + DAEMON_UNIT="ffmpeg-thumbnailer-daemon.service" + THUMBNAILER_UNITS=( + ffmpeg-thumbnailer.service + ffmpeg-thumbnailer-nvidia.service + ffmpeg-thumbnailer-daemon.service + ) + CONTAINER_NAME="${FFT_CONTAINER:-ffmpeg-thumbnailer}" + # Must match SOCK_PATH in /usr/libexec/ffmpeg-thumbnailer/ffmpeg-thumbnailer-daemon. + SOCKET_PATH="${FFT_SOCKET:-${XDG_CACHE_HOME:-${HOME}/.cache}/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer/sock}" + # Must match ENV_OVERRIDE in toggle-ffmpeg-thumbnailer. + ENV_OVERRIDE="${XDG_CONFIG_HOME:-${HOME}/.config}/environment.d/10-ffmpeg-thumbnailer.conf" + + # Same probe as toggle-ffmpeg-thumbnailer, so both recipes agree on whether + # the thumbnailer exists instead of printing unknown/absent rows for it. + # The is-enabled fallback keeps a masked daemon counting as installed even + # if `systemctl --user cat` fails on it. + if ! systemctl --user cat "${DAEMON_UNIT}" >/dev/null 2>&1 \ + && [[ -z "$(systemctl --user is-enabled "${DAEMON_UNIT}" 2>/dev/null)" ]]; then + echo "The ffmpeg video thumbnailer is not installed on this system." + exit 0 + fi + + echo "Units (enabled-state / active-state):" + for unit in "${THUMBNAILER_UNITS[@]}"; do + ENABLED_STATE="$(systemctl --user is-enabled "${unit}" 2>/dev/null || true)" + ACTIVE_STATE="$(systemctl --user is-active "${unit}" 2>/dev/null || true)" + printf ' %-38s %-10s %s\n' "${unit}" "${ENABLED_STATE:-unknown}" "${ACTIVE_STATE:-unknown}" + done + + echo "Container:" + if command -v podman >/dev/null 2>&1; then + if podman container exists "${CONTAINER_NAME}"; then + podman ps --all --filter "name=^${CONTAINER_NAME}$" + else + echo " ${CONTAINER_NAME}: not created (masked, stopped, or never started)" + fi + else + echo " podman is not installed" + fi + + echo "Environment override:" + if [[ -e "${ENV_OVERRIDE}" ]]; then + echo " ${ENV_OVERRIDE}: present" + echo " It shadows the system environment.d drop-in, so the shim stays out of" + echo " XDG_DATA_DIRS and video thumbnails stay off even if the units are unmasked." + echo " Run 'ujust toggle-ffmpeg-thumbnailer enable' to remove it." + else + echo " ${ENV_OVERRIDE}: absent (system environment.d drop-in in effect)" + fi + + echo "Daemon socket:" + if [[ -S "${SOCKET_PATH}" ]]; then + echo " ${SOCKET_PATH}: listening" + elif [[ -e "${SOCKET_PATH}" ]]; then + echo " ${SOCKET_PATH}: present but not a socket (stale)" + else + echo " ${SOCKET_PATH}: missing (daemon not running)" + fi + + echo "Recent daemon activity:" + if command -v journalctl >/dev/null 2>&1; then + journalctl --user -u "${DAEMON_UNIT}" -n 15 --no-pager 2>/dev/null || echo " no journal entries recorded" + else + echo " journalctl is not available" + fi diff --git a/tests/test_shared_just.bats b/tests/test_shared_just.bats index 5fa9b9472..f5ce3a4b2 100644 --- a/tests/test_shared_just.bats +++ b/tests/test_shared_just.bats @@ -1,28 +1,39 @@ #!/usr/bin/env bats # Tests for system_files/shared/usr/share/ublue-os/just/shared.just # -# Covers the two recipes defined there: -# - powerwash (destructive factory reset, double-confirmation gated) -# - toggle-tpm2 (LUKS TPM2 auto-unlock toggle) +# Covers the recipes defined there: +# - powerwash (destructive factory reset, double-confirmation gated) +# - toggle-tpm2 (LUKS TPM2 auto-unlock toggle) +# - toggle-ffmpeg-thumbnailer (opt-in toggle for the ffmpeg thumbnailer daemon) +# - status-ffmpeg-thumbnailer (read-only inspection of the same units) # # The powerwash recipe body is a `#!/usr/bin/bash` shebang recipe with no just # interpolation, so it can be extracted verbatim and executed against stubbed # bctl/gum/sudo binaries. That exercises the real control flow instead of -# grepping the recipe text. +# grepping the recipe text. The same extraction approach is used for the +# thumbnailer recipes with every systemctl/podman/journalctl call stubbed. SHARED_JUST="${BATS_TEST_DIRNAME}/../system_files/shared/usr/share/ublue-os/just/shared.just" WORKDIR="" # Extract a shebang recipe body from shared.just and dedent it by four spaces. +# The recipe pattern also matches parameterised recipes (`name ARG="x":`). _extract_recipe() { awk -v name="$1" ' - $0 == name ":" { in_recipe = 1; next } + $0 ~ ("^" name "([[:space:]].*)?:$") { in_recipe = 1; next } in_recipe && $0 ~ /^[^[:space:]]/ { exit } in_recipe && $0 ~ /^ / { sub(/^ /, ""); print } in_recipe && $0 == "" { print } ' "${SHARED_JUST}" } +# Same extraction, but resolves the `{{ ACTION }}` just interpolation the way +# the just parser would so the body is runnable bash (see test_update_just.bats). +_extract_toggle_recipe() { + _extract_recipe toggle-ffmpeg-thumbnailer \ + | sed 's/{{ ACTION }}/${ACTION:-prompt}/g' +} + setup() { WORKDIR="$(mktemp -d)" mkdir -p "${WORKDIR}/bin" @@ -52,7 +63,73 @@ EOF printf 'bctl %s\n' "$*" >> "${CALLS}" EOF - chmod +x "${WORKDIR}/bin/gum" "${WORKDIR}/bin/sudo" "${WORKDIR}/bin/bctl" + # systemctl stub. MOCK_UNIT_PRESENT=0 makes both `systemctl --user cat` and + # `systemctl --user is-enabled` fail, which is how the recipes detect that + # the thumbnailer is not installed. MOCK_CAT_FAIL=1 fails only `cat`, the + # way a masked unit can, so the is-enabled fallback is exercised. + cat > "${WORKDIR}/bin/systemctl" <<'EOF' +#!/usr/bin/bash +printf 'systemctl %s\n' "$*" >> "${CALLS}" +[[ "${1:-}" == "--user" ]] && shift +case "${1:-}" in + cat) + [[ "${MOCK_UNIT_PRESENT:-1}" == "1" && "${MOCK_CAT_FAIL:-0}" == "0" ]] || exit 1 + exit 0 + ;; + is-enabled) + [[ "${MOCK_UNIT_PRESENT:-1}" == "1" ]] || exit 1 + [[ "${MOCK_STATE_FAIL:-0}" == "1" ]] && exit 1 + if [[ "${MOCK_MASKED:-0}" == "1" ]]; then + printf 'masked\n' + exit 1 + fi + printf '%s\n' "${MOCK_ENABLED_STATE:-enabled}" + ;; + is-active) + [[ "${MOCK_UNIT_PRESENT:-1}" == "1" ]] || exit 1 + [[ "${MOCK_STATE_FAIL:-0}" == "1" ]] && exit 1 + printf '%s\n' "${MOCK_ACTIVE_STATE:-inactive}" + ;; + mask) + exit "${MOCK_MASK_STATUS:-0}" + ;; + unmask) + exit "${MOCK_UNMASK_STATUS:-0}" + ;; +esac +exit 0 +EOF + + cat > "${WORKDIR}/bin/podman" <<'EOF' +#!/usr/bin/bash +printf 'podman %s\n' "$*" >> "${CALLS}" +if [[ "${1:-}" == "container" && "${2:-}" == "exists" ]]; then + exit "${MOCK_CONTAINER_EXISTS:-0}" +fi +if [[ "${1:-}" == "ps" ]]; then + printf 'CONTAINER ID IMAGE STATUS\n' + printf 'a1b2c3d4e5f6 jrottenberg/ffmpeg:9.0-alpine320 Up 2 hours\n' +fi +exit 0 +EOF + + cat > "${WORKDIR}/bin/journalctl" <<'EOF' +#!/usr/bin/bash +printf 'journalctl %s\n' "$*" >> "${CALLS}" +if [[ "${MOCK_JOURNAL_ENTRIES:-1}" == "1" ]]; then + printf 'Sep 25 12:00:00 host ffmpeg-thumbnailer-daemon: listening on /tmp/x.sock\n' + exit 0 +fi +exit 1 +EOF + + chmod +x "${WORKDIR}/bin/gum" "${WORKDIR}/bin/sudo" "${WORKDIR}/bin/bctl" \ + "${WORKDIR}/bin/systemctl" "${WORKDIR}/bin/podman" "${WORKDIR}/bin/journalctl" + + _extract_toggle_recipe > "${WORKDIR}/toggle-thumbnailer.sh" + chmod +x "${WORKDIR}/toggle-thumbnailer.sh" + _extract_recipe status-ffmpeg-thumbnailer > "${WORKDIR}/status-thumbnailer.sh" + chmod +x "${WORKDIR}/status-thumbnailer.sh" } teardown() { @@ -209,5 +286,420 @@ _calls() { } @test "shared.just: recipes are grouped under System" { - [ "$(grep -c "^\[group('System')\]" "${SHARED_JUST}")" -eq 2 ] + [ "$(grep -c "^\[group('System')\]" "${SHARED_JUST}")" -eq 4 ] +} + +# --- ffmpeg thumbnailer toggle ------------------------------------------------ + +# Run an extracted thumbnailer recipe against the stubs. Every argument after +# the script name is passed to `env -i` as a KEY=VALUE assignment, so each test +# states exactly which mock behaviour it needs. +_run_thumbnailer() { + local script="$1" + shift + run /usr/bin/env -i \ + PATH="${WORKDIR}/bin:/usr/bin:/bin" \ + HOME="${WORKDIR}/home" \ + CALLS="${WORKDIR}/calls.log" \ + GUM_ANSWERS="${WORKDIR}/gum-answers" \ + "$@" \ + /usr/bin/bash "${WORKDIR}/${script}" +} + +_SOCKET_REL="cache/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer/sock" + +_socket_path() { + printf '%s\n' "${WORKDIR}/${_SOCKET_REL}" +} + +_make_socket() { + mkdir -p "$(dirname "$(_socket_path)")" + python3 -c 'import socket, sys; socket.socket(socket.AF_UNIX).bind(sys.argv[1])' \ + "$(_socket_path)" +} + +@test "toggle-ffmpeg-thumbnailer: recipe body is extractable and resolves ACTION" { + [ -s "${WORKDIR}/toggle-thumbnailer.sh" ] + run head -n1 "${WORKDIR}/toggle-thumbnailer.sh" + [ "${output}" = "#!/usr/bin/bash" ] + # The just-injected value must be substituted, not left as a literal + # {{ ACTION }} expression the shell cannot evaluate. + run grep -Fq 'ACTION_VALUE="${ACTION:-prompt}"' "${WORKDIR}/toggle-thumbnailer.sh" + [ "${status}" -eq 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: disable stops and masks all three units" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable + result="${output}" + + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user stop ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user mask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] + [[ "${result}" == *"has been disabled"* ]] +} + +@test "toggle-ffmpeg-thumbnailer: disable overrides the environment.d drop-in and enable removes it" { + override="${WORKDIR}/home/.config/environment.d/10-ffmpeg-thumbnailer.conf" + + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable + [ "${status}" -eq 0 ] + [ -f "${override}" ] + run grep -q XDG_DATA_DIRS "${override}" + [ "${status}" -ne 0 ] + + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable + [ "${status}" -eq 0 ] + [ ! -e "${override}" ] +} + +@test "toggle-ffmpeg-thumbnailer: disable stops the units before masking them" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable + + stop_line="$(grep -n '^systemctl --user stop ' "${WORKDIR}/calls.log" | cut -d: -f1)" + mask_line="$(grep -n '^systemctl --user mask ' "${WORKDIR}/calls.log" | cut -d: -f1)" + [ -n "${stop_line}" ] + [ -n "${mask_line}" ] + [ "${stop_line}" -lt "${mask_line}" ] +} + +@test "toggle-ffmpeg-thumbnailer: disable never unmasks" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable + + run grep -q "^systemctl --user unmask " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: enable unmasks, reloads, and starts daemon plus containers" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable + result="${output}" + + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user unmask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user daemon-reload" <<< "$(_calls)" + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user enable --now --no-block ffmpeg-thumbnailer-daemon.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user start --no-block ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] + [[ "${result}" == *"has been enabled"* ]] +} + +@test "toggle-ffmpeg-thumbnailer: enable never issues a blocking start" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable + [ "${status}" -eq 0 ] + + # Quadlets carry TimeoutStartSec=900 and pull a 111 MB image on first + # start, so every start/enable --now must hand back control immediately. + run grep -E "^systemctl --user (start|enable --now)( |$)" <<< "$(_calls)" + [ "${status}" -eq 0 ] + while read -r line; do + [[ "${line}" == *" --no-block"* ]] + done <<< "${output}" +} + +@test "toggle-ffmpeg-thumbnailer: a masked daemon still counts as installed" { + # A masked unit can fail the `cat` probe; is-enabled still reports it, so + # the recipe must not claim the thumbnailer is missing and skip the toggle. + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable MOCK_CAT_FAIL=1 MOCK_MASKED=1 + + [ "${status}" -eq 0 ] + [[ "${output}" != *"not installed"* ]] + run grep -Fqx "systemctl --user unmask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "status-ffmpeg-thumbnailer: a masked daemon still counts as installed" { + _run_thumbnailer status-thumbnailer.sh MOCK_CAT_FAIL=1 MOCK_MASKED=1 + + [ "${status}" -eq 0 ] + [[ "${output}" != *"not installed"* ]] + [[ "${output}" == *"Units (enabled-state / active-state):"* ]] +} + +@test "toggle-ffmpeg-thumbnailer: disable and enable both state the re-login caveat" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable + [ "${status}" -eq 0 ] + [[ "${output}" == *"log out and back in"* ]] + + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable + [ "${status}" -eq 0 ] + [[ "${output}" == *"XDG_DATA_DIRS"* ]] + [[ "${output}" == *"log out and back in"* ]] +} + +@test "toggle-ffmpeg-thumbnailer: enable never masks" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable + + run grep -q "^systemctl --user mask " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: ACTION is case-insensitive" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=DISABLE + + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user mask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: ACTION=cancel changes nothing" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=cancel + + [ "${status}" -eq 0 ] + run grep -Eq "^systemctl --user (mask|unmask|stop|start) " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: the prompt reports the enabled state" { + _queue_answers "Cancel" + + _run_thumbnailer toggle-thumbnailer.sh + result="${output}" + + [ "${status}" -eq 0 ] + [[ "${result}" == *"currently enabled"* ]] + run grep -q "^gum choose " <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: the prompt reports the masked state" { + _queue_answers "Cancel" + + _run_thumbnailer toggle-thumbnailer.sh MOCK_MASKED=1 + result="${output}" + + [ "${status}" -eq 0 ] + [[ "${result}" == *"currently disabled"* ]] +} + +@test "toggle-ffmpeg-thumbnailer: the prompt offers Cancel and Cancel is a no-op" { + _queue_answers "Cancel" + + _run_thumbnailer toggle-thumbnailer.sh + result="${output}" + + run grep -Fq "Cancel" <<< "$(grep '^gum choose ' "${WORKDIR}/calls.log")" + [ "${status}" -eq 0 ] + [[ "${result}" == *"No changes made."* ]] + run grep -Eq "^systemctl --user (mask|unmask|stop|start) " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: an unrelated prompt answer changes nothing" { + _queue_answers "maybe" + + _run_thumbnailer toggle-thumbnailer.sh + + [ "${status}" -eq 0 ] + run grep -Eq "^systemctl --user (mask|unmask|stop|start) " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: prompting then choosing Disable masks the units" { + _queue_answers "Disable" + + _run_thumbnailer toggle-thumbnailer.sh + + [ "${status}" -eq 0 ] + run grep -q "^systemctl --user mask " <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: prompting then choosing Enable unmasks the units" { + _queue_answers "Enable" + + _run_thumbnailer toggle-thumbnailer.sh MOCK_MASKED=1 + + [ "${status}" -eq 0 ] + run grep -q "^systemctl --user unmask " <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: a missing thumbnailer is reported, not masked" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable MOCK_UNIT_PRESENT=0 + result="${output}" + + [ "${status}" -eq 0 ] + [[ "${result}" == *"not installed"* ]] + run grep -Eq "^systemctl --user (mask|unmask|stop|start) " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: a failed mask exits non-zero and explains why" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable MOCK_MASK_STATUS=1 + result="${output}" + + [ "${status}" -eq 1 ] + [[ "${result}" == *"Failed to disable"* ]] +} + +@test "toggle-ffmpeg-thumbnailer: a failed unmask exits non-zero and explains why" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable MOCK_UNMASK_STATUS=1 + result="${output}" + + [ "${status}" -eq 1 ] + [[ "${result}" == *"Failed to enable"* ]] +} + +# --- ffmpeg thumbnailer status ------------------------------------------------ + +@test "status-ffmpeg-thumbnailer: a missing thumbnailer is reported like the toggle does" { + _run_thumbnailer status-thumbnailer.sh \ + MOCK_UNIT_PRESENT=0 XDG_CACHE_HOME="${WORKDIR}/cache" + result="${output}" + + [ "${status}" -eq 0 ] + [[ "${result}" == *"not installed"* ]] + [[ "${result}" != *"Units (enabled-state"* ]] + [[ "${result}" != *"Environment override:"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports every unit's enabled and active state" { + _run_thumbnailer status-thumbnailer.sh \ + MOCK_MASKED=1 MOCK_ACTIVE_STATE=inactive XDG_CACHE_HOME="${WORKDIR}/cache" + result="${output}" + + [ "${status}" -eq 0 ] + for unit in ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service \ + ffmpeg-thumbnailer-daemon.service; do + [[ "${result}" == *"${unit}"* ]] + done + [[ "${result}" == *"masked"* ]] + [[ "${result}" == *"inactive"* ]] +} + +@test "status-ffmpeg-thumbnailer: never changes unit state" { + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + run grep -Eq "^systemctl --user (mask|unmask|stop|start|enable) " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "status-ffmpeg-thumbnailer: reports a listening daemon socket" { + _make_socket + + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"listening"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports a missing daemon socket" { + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"daemon not running"* ]] +} + +@test "status-ffmpeg-thumbnailer: the default socket path matches the daemon's SOCK_PATH" { + # ffmpeg-thumbnailer-daemon binds + # ${XDG_CACHE_HOME}/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer/sock + # (GST_CACHE_DIR + "ffmpeg-thumbnailer" + "sock"). A drifting default here + # makes the report claim the daemon is down while it is serving. + run grep -Fq \ + 'SOCKET_PATH="${FFT_SOCKET:-${XDG_CACHE_HOME:-${HOME}/.cache}/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer/sock}"' \ + "${WORKDIR}/status-thumbnailer.sh" + [ "${status}" -eq 0 ] +} + +@test "status-ffmpeg-thumbnailer: reports a stale non-socket path" { + mkdir -p "$(dirname "$(_socket_path)")" + : > "$(_socket_path)" + + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"present but not a socket"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports a running container" { + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"Up 2 hours"* ]] + run grep -q "^podman ps " <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "status-ffmpeg-thumbnailer: reports a container that was never created" { + _run_thumbnailer status-thumbnailer.sh MOCK_CONTAINER_EXISTS=1 XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"not created"* ]] +} + +@test "status-ffmpeg-thumbnailer: degrades when podman is absent" { + rm -f "${WORKDIR}/bin/podman" + + _run_thumbnailer status-thumbnailer.sh PATH="${WORKDIR}/bin" XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"podman is not installed"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports the environment.d override when present" { + mkdir -p "${WORKDIR}/home/.config/environment.d" + printf '# stub\n' > "${WORKDIR}/home/.config/environment.d/10-ffmpeg-thumbnailer.conf" + + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"10-ffmpeg-thumbnailer.conf: present"* ]] + [[ "${output}" == *"toggle-ffmpeg-thumbnailer enable"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports an absent environment.d override" { + rm -f "${WORKDIR}/home/.config/environment.d/10-ffmpeg-thumbnailer.conf" + + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"10-ffmpeg-thumbnailer.conf: absent"* ]] +} + +@test "status-ffmpeg-thumbnailer: the override path matches the toggle's ENV_OVERRIDE" { + # A drifting path here makes the report claim no override while the toggle's + # file is still shadowing the system drop-in. + override_line='ENV_OVERRIDE="${XDG_CONFIG_HOME:-${HOME}/.config}/environment.d/10-ffmpeg-thumbnailer.conf"' + run grep -Fq "${override_line}" "${WORKDIR}/status-thumbnailer.sh" + [ "${status}" -eq 0 ] + run grep -Fq "${override_line}" "${WORKDIR}/toggle-thumbnailer.sh" + [ "${status}" -eq 0 ] +} + +@test "status-ffmpeg-thumbnailer: never changes the environment.d override" { + mkdir -p "${WORKDIR}/home/.config/environment.d" + printf '# stub\n' > "${WORKDIR}/home/.config/environment.d/10-ffmpeg-thumbnailer.conf" + + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [ -f "${WORKDIR}/home/.config/environment.d/10-ffmpeg-thumbnailer.conf" ] +} + +@test "status-ffmpeg-thumbnailer: shows recent daemon activity" { + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"listening on /tmp/x.sock"* ]] +} + +@test "status-ffmpeg-thumbnailer: says so when the journal has no entries" { + _run_thumbnailer status-thumbnailer.sh MOCK_JOURNAL_ENTRIES=0 XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"no journal entries recorded"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports unknown state instead of failing" { + rm -f "${WORKDIR}/bin/podman" "${WORKDIR}/bin/journalctl" + + _run_thumbnailer status-thumbnailer.sh PATH="${WORKDIR}/bin" MOCK_STATE_FAIL=1 XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"unknown"* ]] }