- name
- test-convt-cli
- description
- Test convt conversions end to end through the `convt` CLI, the engines in convt-engines, routing in convt-core, and the Linux file-manager menus in integrations/linux. Use when changing an engine, a format, the registry, the CLI, or install.py, or when asked to reproduce a conversion bug or prove a conversion works.
# Test convt conversions
The CLI is the fastest way to prove engine and routing behavior. The desktop app, the Finder extension and the Linux menus all call the same `convt_engines::default_registry()`, so a CLI result is the ground truth for what a conversion produces.
## Doctor
Run from the repository root before trusting a result. Read only.
```sh
cargo locate-project --workspace --message-format plain # must be this checkout's Cargo.toml
cargo build -p convt-cli && ./target/debug/convt engines
```
`convt engines` prints each ready engine (`image`, `svg`, `libheif`, `ffmpeg`, `pdfium`, `libreoffice`) and each unavailable one with its reason. A conversion that fails with "no route" usually means an engine is missing here, not a routing bug. Fix the environment first:
- `pdfium` unavailable: `bash scripts/fetch-pdfium.sh` (writes `vendor/pdfium`, gitignored).
- `ffmpeg` or `libreoffice` unavailable: install the package, or point `CONVT_FFMPEG` / `CONVT_SOFFICE` at a binary. See `crates/convt-engines/src/lib.rs` for discovery order.
Always run `./target/debug/convt` from this checkout. A `convt` on `PATH` may be an older install.
## Fixtures
Use synthetic inputs in a fresh temporary directory, never files from a user's home.
```sh
work=$(mktemp -d /tmp/convt-test.XXXXXX)
bash .agents/skills/test-convt-cli/scripts/fixtures.sh "$work/in"
```
This writes `sample.png`, `sample.mp4` (3 s, video and audio), `sample.wav`, `sample.svg`, `sample.txt`, and `sample.pdf` when LibreOffice is present. Add new fixtures to that script rather than committing binaries.
## Drive
Write outputs to a separate directory with `-o` so a same-format or failed run cannot touch the inputs.
```sh
c=./target/debug/convt; mkdir -p "$work/out"
$c targets "$work/in/sample.mp4" # what the menus would offer
$c "$work/in/sample.mp4" --to webm -o "$work/out"
$c "$work/in/sample.png" --to avif -o "$work/out"
$c "$work/in/sample.svg" --to jpeg -o "$work/out"
$c "$work/in/sample.pdf" --to png -o "$work/out" # one file per page: sample.png, sample-2.png, ...
```
Options, presets and batches:
```sh
$c "$work/in/sample.png" --to jpeg -q 60 --max-size 64 -o "$work/out"
# Transparent areas: white by default for JPEG and PPM, or --background black,
# "#ff8800" or transparent (refused for formats that can't store it).
$c "$work/in/sample.png" --to jpeg --background black -o "$work/out"
$c "$work/in/sample.pdf" --to webp --pages 2- --dpi 72 -o "$work/out" # named by page: sample-2.webp, ...
$c "$work/in/sample.mp4" --to mp4 --video-height 120 -o "$work/out"
export CONVT_CONFIG_DIR="$work/cfg" # never the user's real presets
mkdir -p "$CONVT_CONFIG_DIR/presets"
printf 'to = "jpeg"\nquality = 60\n' > "$CONVT_CONFIG_DIR/presets/thumb.toml"
$c presets && $c "$work/in/sample.png" --preset thumb -o "$work/out"
$c "$work/in" -r --to webp -j 2 --json -o "$work/batch" # subfolders mirrored under -o
```
`--json` prints one event per line on stdout (`started`, `progress`, `done`, `failed` with a `kind`, then `summary`). Folder files that can't reach the target, or already are it, are skipped quietly; files named directly report their errors. Exit codes: 0 all done, 1 any failure, 130 cancelled.
To test cancellation, start a conversion of a long video in the background, `kill -INT` that PID (never `pkill`), and check that it exits 130, that its FFmpeg child is gone (`ps -o pid= --ppid <pid>` before the kill), and that the output directory holds no `.convt-*` staging folder or partial file.
HEIC and AVIF decode through the patched libheif (`libheif.so.1`, or `CONVT_LIBHEIF_DIR`). Unpatched system libraries are unavailable because they cannot disable implicit plugin loading. For full host verification, select the built bundle with absolute `CONVT_LIBHEIF_DIR` and `CONVT_LIBHEIF_PLUGIN_DIR`, and prepend its `lib` directory to `LD_LIBRARY_PATH`. Set `CONVT_FFMPEG` and `CONVT_FFPROBE` to the bundle tools so the independent AVIF validator has container support. The Python fixture helper separately needs a trusted absolute `LIBHEIF_PLUGIN_PATH`; the engine ignores that variable. The matrix generates its own HEIC with alpha. It first tries a libheif HEVC encoder, then uses FFmpeg's libx265 to encode the color and alpha items in a small HEIF container. It generates AVIF with libheif's AV1 encoder, independently of convt's AVIF encoder. When no HEIC encoder is available, `CONVT_TEST_HEIC=/path/to/sample.heic` opts into a local sample, copied into the temporary fixture directory and compared against separately decoded source pixels. No real sample belongs in the repository.
Pick the paths your change touches. For a routing change, also check that `targets` does not offer something it should not. A still image must never list a video or audio format through a multi-hop route; `crates/convt-core/src/registry.rs` has tests for this.
## Licensing
Builds from source skip the license check, so `convert` works without a key. To try the trial and activation flow, make a development key pair and opt in at run time. Always set `CONVT_LICENSE_STORE=file`: without it a key goes to the user's real Secret Service keyring.
```sh
cargo run -p convt-license --example dev-keys -- keygen # writes .convt-dev/ (gitignored)
key=$(cargo run -q -p convt-license --example dev-keys -- issue you@example.com 2027-10-02)
export CONVT_CONFIG_DIR="$work/cfg" CONVT_DATA_DIR="$work/data" CONVT_LICENSE_STORE=file
export CONVT_LICENSE_ENFORCE=1 CONVT_LICENSE_PUBKEY=$(cat .convt-dev/license.pub)
$c license # Free trial: 7 days, starting with ...
$c "$work/in/sample.png" --to jpeg -o "$work/out" # starts the trial: $work/data/trial
echo 2026-01-01 > "$work/data/trial"; $c "$work/in/sample.png" --to jpeg -o "$work/out" # trial ended, exit 1
echo "$key" | $c license activate && $c license # key in $work/cfg/license.key, mode 0600
$c license remove
```
`formats`, `targets`, `engines` and `presets` never need a license; only conversions do. A key issued with an `updates_until` before today's build date shows the "newer than your license covers" message. Delete `.convt-dev` when done unless you need the key again, and never commit it.
The Secret Service path has one ignored test. Run it only on a private bus with its own runtime and data directories, so it can't reach the user's keyring:
```sh
T=$(mktemp -d); mkdir -m 700 "$T/run"; mkdir "$T/data"
env -u DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR="$T/run" XDG_DATA_HOME="$T/data" dbus-run-session -- sh -c \
'echo -n test | gnome-keyring-daemon --unlock --components=secrets >/dev/null; cargo test -p convt-license --features client -- --ignored secret_service'
rm -rf "$T"
```
The Keychain on macOS and Credential Manager on Windows have no test path here; report them as unverified.
## Evidence
A zero exit code and a `-> path` line are not enough. Inspect the output:
```sh
ffprobe -v error -show_entries stream=codec_name,width,height:format=duration -of compact "$work/out/sample.webm"
file "$work/out/"*
```
Check codec, dimensions, duration, and page count against what the change should produce. For images, open the file with the Read tool to look at it. Report which engines were unavailable, because those routes were not exercised.
## Linux file-manager menus
Format entries run `convt-app open --to <format> -- <files>`, so the app shows progress and errors. Every supported Linux menu also has “More options…” which runs `convt-app open -- <files>` to open Quick convert without choosing a target. `install.py` reads `convt formats --json` and `convt targets` to build them, and also installs `convt-app.desktop` (Open with, and the `convt://` handler). It needs both binaries on `PATH`.
Start with the unit tests. They run each generated command the way the file manager would: Dolphin entries and `convt-app.desktop` through `gio launch`, Thunar through `sh -c`, Nemo through GLib's argv parser, and the Nautilus extension against stubbed GObject classes. A fake app in a folder with spaces and quotes records its arguments, and the file names include quotes, `$HOME`, a backtick, `100%` and a leading dash.
```sh
python3 -m unittest discover -s integrations/linux
```
Tests that need GLib, `gio` or `desktop-file-validate` skip without them; say which ran. Then check the real output against a throwaway home, since `install.py` writes into `$HOME` and runs `xdg-mime`:
```sh
cargo build -p convt-cli && cargo build -p convt-app # see test-convt-desktop for the app's build needs
h="$work/home"
HOME="$h" XDG_DATA_HOME="$h/.local/share" XDG_CONFIG_HOME="$h/.config" PATH="$PWD/target/debug:$PATH" \
python3 integrations/linux/install.py --dolphin --nemo --thunar --nautilus
find "$h" -type f | head
desktop-file-validate "$h/.local/share/applications/convt-app.desktop"
```
Check that each `Exec`/`command` points at `target/debug/convt-app`, that Dolphin `.desktop` files are mode 0755, and that `uca.xml` parses (`python3 -c 'import xml.dom.minidom,sys;xml.dom.minidom.parse(sys.argv[1])' "$h/.config/Thunar/uca.xml"`). The unit tests emulate Nemo and Thunar from their source and Dolphin through GIO; none of them runs inside the real file manager. Seeing the menu in Dolphin, Nemo, Thunar or Nautilus means launching the file manager, which needs the user's explicit permission in the current request. Without it, report the menus as verified by emulation only. With it, use the recipe below on a private display, never the user's desktop session.
What each menu looks like in the real app, so you can tell a regression from the expected shape:
- Nautilus 46 (nautilus-python 4.0): a "Convert with convt" submenu with the targets in `convt targets` order, then "More options…". A video's 23 targets run past a 900 pixel screen; the popover scrolls.
- Nemo 6.0: no submenus. Each target is a top-level "Convert to X" item, in action file name order, with "More options…" last because its file is `convt-zz-more-options.nemo_action`. Nemo compares file names byte by byte. Nemo 6.0 has no action layout file, so a submenu is not possible there.
- Dolphin 23.08 (KIO 5.115): a "Convert with convt" submenu. KIO sorts a submenu by action ID, not label, so the IDs are `convert_<target>` and `options`, which puts "More options…" last.
### Real file managers without root
Nautilus 46 and Thunar are installed system-wide here; Nemo, Dolphin and nautilus-python are not, and there is no passwordless sudo. Install them into a throwaway prefix instead. `apt-get -s install` works without root and lists exactly the packages this machine is missing:
```sh
REPO=$PWD W=$(mktemp -d /tmp/convt-fm.XXXXXX); cd "$W"; mkdir debs root bin home
for p in nemo dolphin python3-nautilus; do apt-get -s install $p | awk '/^Inst /{print $2}'; done | sort -u > all.txt
(cd debs && apt-get download $(cat ../all.txt)) # about 250 packages, 57 MB
for d in debs/*.deb; do dpkg -x "$d" root; done # about 270 MB
ln -s "$REPO/target/debug/convt" bin/convt; ln -s "$REPO/target/debug/convt-app" bin/convt-app
```
Package postinst scripts never run, so do their work by hand. Nemo needs its schemas compiled together with the system ones: copy `/usr/share/glib-2.0/schemas/*.xml` and `*.override` plus `root/usr/share/glib-2.0/schemas/*` into `$W/schemas` and run `glib-compile-schemas $W/schemas`. Dolphin needs `kbuildsycoca5` run once with its environment set (below).
Nautilus loads extensions only from the directory compiled into the binary, `/usr/lib/x86_64-linux-gnu/nautilus/extensions-4`; no environment variable changes it. Copy `/usr/bin/nautilus` to `$W/bin/nautilus` and overwrite that string with a shorter private path padded with NUL bytes, then put `libnautilus-python.so` from `root/usr/lib/x86_64-linux-gnu/nautilus/extensions-4` in that directory:
```sh
mkdir "$W/nx"; cp root/usr/lib/x86_64-linux-gnu/nautilus/extensions-4/libnautilus-python.so "$W/nx/"
python3 - "$W" <<'EOF'
import sys
old = b"/usr/lib/x86_64-linux-gnu/nautilus/extensions-4\0"; new = sys.argv[1].encode() + b"/nx"
data = open("/usr/bin/nautilus", "rb").read()
assert data.count(old) == 1 and len(new) < len(old)
open(sys.argv[1] + "/bin/nautilus", "wb").write(data.replace(old, new + b"\0" * (len(old) - len(new))))
EOF
chmod +x "$W/bin/nautilus"
```
nautilus-python reads Python extensions from `$XDG_DATA_HOME/nautilus-python/extensions`, which `install.py --nautilus` fills in the private home.
Export the private home first, then start Xvfb, a private D-Bus bus and `fake-portal.py` as in `test-convt-desktop` (Launch headless on Linux), with `$W` as the work directory. The order matters: services the bus starts on demand (dconf-service, gvfsd-metadata, tracker-miner-fs, goa-daemon) inherit the bus daemon's environment, not the file manager's. A bus started with the real `HOME` writes Nemo and Nautilus settings into the user's real `~/.config/dconf/user` and rewrites `~/.cache/tracker3`, even though every file manager has a private home. Then run `install.py --nautilus --nemo --dolphin` in that environment:
```sh
export HOME=$W/home XDG_DATA_HOME=$W/home/.local/share XDG_CONFIG_HOME=$W/home/.config XDG_CACHE_HOME=$W/home/.cache
export XDG_RUNTIME_DIR=$W/run CONVT_RUNTIME_DIR=$W/run CONVT_CONFIG_DIR=$W/cfg CONVT_DATA_DIR=$W/data CONVT_LICENSE_STORE=file
export PATH=$W/bin:/usr/bin:/bin NO_AT_BRIDGE=1
# Now start Xvfb, dbus-daemon and fake-portal.py, then:
python3 "$REPO/integrations/linux/install.py" --nautilus --nemo --dolphin
R=$W/root L=$R/usr/lib/x86_64-linux-gnu
GI_TYPELIB_PATH=$L/girepository-1.0 nautilus "$HOME/Samples" &
LD_LIBRARY_PATH=$L XDG_DATA_DIRS=$R/usr/share:/usr/share GSETTINGS_SCHEMA_DIR=$W/schemas $R/usr/bin/nemo "$HOME/Samples" &
LD_LIBRARY_PATH=$L QT_PLUGIN_PATH=$L/qt5/plugins QML2_IMPORT_PATH=$L/qt5/qml QT_QPA_PLATFORM=xcb \
XDG_DATA_DIRS=$R/usr/share:/usr/share XDG_CONFIG_DIRS=$R/etc/xdg:/etc/xdg PATH=$W/bin:$R/usr/bin:/usr/bin:/bin \
sh -c 'kbuildsycoca5 >/dev/null 2>&1; exec dolphin "$HOME/Samples"' &
```
Run one file manager at a time so their windows don't overlap. Nautilus forks, so `$!` is not its PID, and each one starts helpers on the private bus (gvfsd, dconf-service, tracker-miner-fs, goa-daemon and others). Find all of them by their environment and kill only those:
```sh
bus=$DBUS_SESSION_BUS_ADDRESS
View on GitHub