Ctrl K
ComunidadGuías…

tests/checks-untested.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: GPL-3.0-or-later
/**
 * Behavior tests for checks that previously had none (analysis §7): oom, wifi,
 * orphans, packages, fs, cache. They ran only through the generic all-fail
 * path, so a parser regression would have been invisible. (boot/hardware are
 * already covered in tests/checks.test.js.)
 */
import { test } from "node:test";
import assert from "node:assert/strict";

import { detectDistro } from "../src/distro.js";
import { loadThresholds } from "../src/thresholds.js";
import { oom } from "../src/checks/oom.js";
import { wifi } from "../src/checks/wifi.js";
import { orphans } from "../src/checks/orphans.js";
import { backup } from "../src/checks/backup.js";
import { foreignLockHolders, packages } from "../src/checks/packages.js";
import { fs } from "../src/checks/fs.js";
import { cache } from "../src/checks/cache.js";

function stubCtx(map, osRelease = { id: "ubuntu", id_like: "debian" }) {
  return {
    osRelease,
    dist: detectDistro(osRelease),
    thresholds: loadThresholds({}),
    run: async (cmd) => {
      const entry = map[cmd];
      if (entry === undefined) return { ok: false, code: 1, stdout: "", stderr: "" };
      return { ok: true, code: 0, stdout: entry, stderr: "" };
    },
  };
}

// ---------------------------------------------------------------- oom ---------

test("oom: one kill (two log lines, same pid) is a single medium finding", async () => {
  const ctx = stubCtx({
    "command -v journalctl 2>/dev/null": "/usr/bin/journalctl\n",
    "command -v dmesg 2>/dev/null": "/usr/bin/dmesg\n",
    "journalctl -k --no-pager -n 500 2>/dev/null | grep -iE 'Out of memory|Killed process|oom-killer|oom_reaper' | tail -n 20": "Out of memory: Killed process 1234 (chrome)\noom_reaper: reaped process 1234 (chrome)\n",
    "dmesg 2>/dev/null | grep -iE 'Out of memory|Killed process|oom-killer' | tail -n 20": "",
  });
  const findings = await oom.run(ctx);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].code, "oom/kills");
  assert.equal(findings[0].severity, "medium", "a single kill must not be a 'pattern'");
  assert.match(findings[0].title, /1 OOM kill/);
});

test("oom: two kills (two pids) are high", async () => {
  const ctx = stubCtx({
    "command -v journalctl 2>/dev/null": "/usr/bin/journalctl\n",
    "command -v dmesg 2>/dev/null": "/usr/bin/dmesg\n",
    "journalctl -k --no-pager -n 500 2>/dev/null | grep -iE 'Out of memory|Killed process|oom-killer|oom_reaper' | tail -n 20": "Out of memory: Killed process 1234 (chrome)\nOut of memory: Killed process 5678 (firefox)\n",
    "dmesg 2>/dev/null | grep -iE 'Out of memory|Killed process|oom-killer' | tail -n 20": "",
  });
  const findings = await oom.run(ctx);
  assert.equal(findings[0].severity, "high");
  assert.match(findings[0].title, /2 OOM kill/);
});

// --------------------------------------------------------------- wifi ---------

test("wifi: soft-blocked is medium", async () => {
  const ctx = stubCtx({
    "rfkill list wifi 2>/dev/null; rfkill list wlan 2>/dev/null": "0: phy0: Wireless LAN\n\tSoft blocked: yes\n\tHard blocked: no\n",
    "nmcli radio wifi 2>/dev/null; nmcli device status 2>/dev/null | grep -i wifi": "enabled\n",
    "lspci -nn 2>/dev/null | grep -iE 'network|wireless|wlan'": "0b:00.0 Network controller: Intel\n",
  });
  const findings = await wifi.run(ctx);
  assert.equal(findings[0].code, "wifi/blocked");
  assert.equal(findings[0].severity, "medium");
});

test("wifi: disabled in NetworkManager is medium", async () => {
  const ctx = stubCtx({
    "rfkill list wifi 2>/dev/null; rfkill list wlan 2>/dev/null": "0: phy0: Wireless LAN\n\tSoft blocked: no\n",
    "nmcli radio wifi 2>/dev/null; nmcli device status 2>/dev/null | grep -i wifi": "disabled\n",
    "lspci -nn 2>/dev/null | grep -iE 'network|wireless|wlan'": "0b:00.0 Network controller: Intel\n",
  });
  const findings = await wifi.run(ctx);
  assert.equal(findings[0].code, "wifi/disabled");
});

test("wifi: no adapter at all is informational", async () => {
  const findings = await wifi.run(stubCtx({}));
  assert.equal(findings.length, 1);
  assert.equal(findings[0].code, "wifi/no-adapter");
  assert.equal(findings[0].severity, "info");
});

// ------------------------------------------------------------ orphans ---------

test("orphans: 12 removable apt packages is medium", async () => {
  const remv = Array.from({ length: 12 }, (_, i) => `Remv lib${i} [1.0]`).join("\n") + "\n";
  const ctx = stubCtx({ "apt-get -s autoremove 2>/dev/null": remv });
  const findings = await orphans.run(ctx);
  assert.equal(findings[0].code, "orphans/many");
  assert.equal(findings[0].severity, "medium");
  assert.match(findings[0].fix, /apt autoremove/);
});

test("orphans: none is informational", async () => {
  const ctx = stubCtx({ "apt-get -s autoremove 2>/dev/null": "" });
  const findings = await orphans.run(ctx);
  assert.equal(findings[0].code, "orphans/none");
});

test("orphans: a couple of arch orphans is informational", async () => {
  const ctx = stubCtx({ "pacman -Qtdq 2>&1": "libfoo\nlibbar\n" }, { id: "arch" });
  const findings = await orphans.run(ctx);
  assert.equal(findings[0].code, "orphans/some");
  assert.equal(findings[0].severity, "info");
});

// ----------------------------------------------------------- packages ---------

// The dpkg lock probe exactly as src/checks/packages.js builds it. It compares
// process groups, so a holder inside linux-doctor's own group (its own
// `apt-get check`, or the `updates` / `orphans` probes) is not reported as
// another process. That self-accusation was #24.
const LOCK_PROBE =
  "printf 'self %s\\n' \"$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')\"; " +
  "for pid in $(fuser /var/lib/dpkg/lock /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock 2>/dev/null); do " +
  'printf \'%s %s\\n\' "$pid" "$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d \' \')"; done';


// Immutable/atomic systems (Bazzite, Silverblue, bootc) ship a pre-solved
// image, so the full depsolve only re-confirms the image build — and at ~15 s
// on a 2 900-package image it is slow enough to be killed by the command cap
// and answer nothing at all. The check verifies the rpmdb instead, which is
// fast, and says plainly that the dependency check was not run.
const ATOMIC_OS = { id: "bazzite", id_like: "fedora", variant: "kinoite", image_version: "44" };

test("packages: an immutable system verifies the rpmdb and names what it did not run", async () => {
  const ctx = stubCtx({ "rpm --verifydb 2>&1": "" }, ATOMIC_OS);
  const findings = await packages.run(ctx);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].code, "packages/ok");
  assert.equal(findings[0].severity, "info");
  assert.match(findings[0].evidence, /rpm --verifydb: ok/);
  assert.match(findings[0].evidence, /dependency check: not run/);
});

test("packages: an unreadable rpmdb on an immutable system claims nothing", async () => {
  // No entry in the stub map means the command failed: the check must stay
  // silent rather than report an intact database it never read.
  const ctx = stubCtx({}, ATOMIC_OS);
  const findings = await packages.run(ctx);
  assert.deepEqual(findings, []);
});

test("packages: a broken dpkg database is high", async () => {  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "The following packages are in a mess due to serious problems during installation:\n libfoo\n",
    "apt-get check 2>&1 | head -20": "",
    [LOCK_PROBE]: "",
  });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/broken");
  assert.equal(findings[0].severity, "high");
});

test("packages: a held apt lock is medium, and the evidence is the PID alone", async () => {
  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "",
    "apt-get check 2>&1 | head -20": "",
    [LOCK_PROBE]: "self 77\n4242 5150\n",
  });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/locked");
  assert.equal(findings[0].severity, "medium");
  // The probe prints PIDs only, so a lock path can never reach the evidence as
  // if it were the holder.
  assert.equal(findings[0].evidence, "lock held by PID 4242");
});

test("packages: an unheld lock file is not a lock", async () => {
  // The old probe listed the lock paths and tested the whole output for a
  // digit, so anything numeric-looking could pass as a holder. Only PIDs count.
  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "",
    "apt-get check 2>&1 | head -20": "",
    [LOCK_PROBE]: "self 77\n/var/lib/dpkg/lock\n/var/lib/apt/lists/lock\n",
  });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/ok");
});

// `apt-get check` above, plus the `updates` and `orphans` checks, take the same
// dpkg lock while this check runs. A plain `fuser` therefore reported
// linux-doctor itself as "another process": run as root on a healthy machine,
// the user was told to wait for, or kill, the tool they were running (#24).
//
// The probe reports groups (`self <pgid>`, then `<pid> <pgid>`) and
// `foreignLockHolders` applies the rule, so these assert the behaviour rather
// than the spelling of the shell pipeline.

test("packages: a holder in linux-doctor's own process group is not a lock (#24)", async () => {
  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "",
    "apt-get check 2>&1 | head -20": "",
    [LOCK_PROBE]: "self 77\n4242 77\n",
  });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/ok", "the tool must not report itself as a lock holder");
});

test("packages: a holder whose process group cannot be read is not a lock", async () => {
  // No group means it exited between `fuser` and `ps`, so it holds nothing.
  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "",
    "apt-get check 2>&1 | head -20": "",
    [LOCK_PROBE]: "self 77\n4242\n",
  });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/ok");
});

test("packages: an unreadable own process group reports nothing rather than guessing", async () => {
  // Without `ps -o pgid=` every holder would look foreign — which is exactly
  // the false positive #24 removed. Stay quiet instead of reinstating it.
  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "",
    "apt-get check 2>&1 | head -20": "",
    [LOCK_PROBE]: "self\n4242 5150\n",
  });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/ok");
});

test("foreignLockHolders: keeps outside pids and drops our own", () => {
  assert.deepEqual(foreignLockHolders("self 77\n4242 5150\n9001 77\n"), ["4242"]);
  assert.deepEqual(foreignLockHolders("self 77\n"), []);
  assert.deepEqual(foreignLockHolders(""), []);
});

// Regression for #14: `apt-get check` needs the dpkg frontend lock, which an
// unprivileged run cannot take. `2>&1` folds its refusal into the same string,
// and the bare `E:` / `error` predicate then read that refusal as broken
// dependencies — so every non-root run on a perfectly healthy Debian box got a
// high "Package manager reports broken dependencies" whose evidence was apt
// complaining it is not root.
const APT_LOCK_REFUSAL =
  "E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)\n" +
  "E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?\n";

test("packages: a non-root apt lock refusal is not a broken package", async () => {
  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "",
    "apt-get check 2>&1 | head -20": APT_LOCK_REFUSAL,
    [LOCK_PROBE]: "",
  });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/ok");
  assert.equal(findings[0].severity, "info");
});

test("packages: the healthy finding records that apt-get check needed root", async () => {
  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "",
    "apt-get check 2>&1 | head -20": APT_LOCK_REFUSAL,
    [LOCK_PROBE]: "",
  });
  const findings = await packages.run(ctx);
  // Claiming "apt-get check: ok" would be a lie — it never ran.
  assert.match(findings[0].evidence, /needs root/i);
  assert.doesNotMatch(findings[0].evidence, /apt-get check: ok/);
});

test("packages: real unmet dependencies are still high", async () => {
  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "",
    "apt-get check 2>&1 | head -20":
      "Reading package lists...\nBuilding dependency tree...\n" +
      "E: Unmet dependencies. Try 'apt --fix-broken install' with no packages (or specify a solution).\n",
    [LOCK_PROBE]: "",
  });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/broken");
  assert.equal(findings[0].severity, "high");
});

test("packages: a lock refusal does not mask a genuinely broken dpkg database", async () => {
  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "The following packages are in a mess due to serious problems during installation:\n libfoo\n",
    "apt-get check 2>&1 | head -20": APT_LOCK_REFUSAL,
    [LOCK_PROBE]: "",
  });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/broken");
  assert.equal(findings[0].severity, "high");
});

// Regression: `pacman -Dk` prints "No database errors have been found!" on a
// clean database. A bare /error/ matched that sentence, so a clean Arch system
// was reported as "Pacman database has errors" (high) with the success line as
// its evidence. Found by the clean-image baseline gate on its first run.
test("packages: pacman reporting no database errors is informational", async () => {
  const ctx = stubCtx({
    "pacman -Dk 2>&1":
      "warning: database file for 'core' does not exist (use '-Sy' to download)\n" +
      "warning: database file for 'extra' does not exist (use '-Sy' to download)\n" +
      "No database errors have been found!\n",
  }, { id: "arch" });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/ok");
  assert.equal(findings[0].severity, "info");
});

test("packages: a real pacman database error is still high", async () => {
  const ctx = stubCtx({
    "pacman -Dk 2>&1": "error: package libfoo: missing 'libbar' dependency\n",
  }, { id: "arch" });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/broken");
  assert.equal(findings[0].severity, "high");
});

test("packages: a healthy apt database is informational", async () => {
  const ctx = stubCtx({
    "dpkg --audit 2>&1 | head -20": "",
    "apt-get check 2>&1 | head -20": "",
    [LOCK_PROBE]: "",
  });
  const findings = await packages.run(ctx);
  assert.equal(findings[0].code, "packages/ok");
});

// ----------------------------------------------------------------- fs ---------

test("fs: a read-only remount is high", async () => {
  const ctx = stubCtx({
    "command -v journalctl 2>/dev/null": "/usr/bin/journalctl\n",
    "command -v dmesg 2>/dev/null": "",
    "journalctl -k --no-pager -n 500 2>/dev/null | grep -iE 'EXT4-fs error|I/O error|buffer I/O error|BTRFS.*(error|critical|failed|corrupt)|XFS.*error|I/O stall' | tail -n 20": "",
    "dmesg 2>/dev/null | grep -iE 'EXT4-fs error|I/O error|buffer I/O error|BTRFS.*(error|critical|failed|corrupt)|XFS.*error|Remounting filesystem read-only' | tail -n 20": "EXT4-fs error (device sda2): remounting filesystem read-only\n",
    "dmesg 2>/dev/null | grep -i 'Remounting filesystem read-only' | tail -n 5": "EXT4-fs (sda2): Remounting filesystem read-only\n",
  });
  const findings = await fs.run(ctx);
  assert.equal(findings[0].code, "fs/readonly-remount");
  assert.equal(findings[0].severity, "high");
});

test("fs: a clean kernel log is informational", async () => {
  const ctx = stubCtx({
    "command -v journalctl 2>/dev/null": "/usr/bin/journalctl\n",
    "command -v dmesg 2>/dev/null": "",
    "journalctl -k --no-pager -n 500 2>/dev/null | grep -iE 'EXT4-fs error|I/O error|buffer I/O error|BTRFS.*(error|critical|failed|corrupt)|XFS.*error|I/O stall' | tail -n 20": "",
    "dmesg 2>/dev/null | grep -iE 'EXT4-fs error|I/O error|buffer I/O error|BTRFS.*(error|critical|failed|corrupt)|XFS.*error|Remounting filesystem read-only' | tail -n 20": "",
    "dmesg 2>/dev/null | grep -i 'Remounting filesystem read-only' | tail -n 5": "",
  });
  const findings = await fs.run(ctx);
  assert.equal(findings[0].code, "fs/ok");
  assert.equal(findings[0].severity, "info");
});

// Regression for #13: "Btrfs loaded, zoned=yes, fsverity=yes" is the module load
// banner. The kernel prints it at every boot on any build with btrfs compiled in,
// whether or not a btrfs filesystem is mounted — so it is neither an error nor
// evidence that btrfs is in use.

test("fs: the btrfs module load banner is not a filesystem error", async () => {
  const osRelease = { id: "ubuntu", id_like: "debian" };
  const ctx = {
    osRelease,
    dist: detectDistro(osRelease),
    thresholds: loadThresholds({}),
    // Force the banner through whichever log grep the check uses, so this stays
    // an assertion about the check's logic rather than about one command string.
    run: async (cmd) => {
      if (cmd.startsWith("command -v")) return { ok: true, code: 0, stdout: "/usr/bin/dmesg\n", stderr: "" };
      if (cmd.includes("tail -n 5")) return { ok: true, code: 0, stdout: "", stderr: "" };
      return { ok: true, code: 0, stdout: "[    2.446464] Btrfs loaded, zoned=yes, fsverity=yes\n", stderr: "" };
    },
  };
  const findings = await fs.run(ctx);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].code, "fs/ok", "the module load banner must not be read as an error");
  assert.equal(findings[0].severity, "info");
});

test("fs: a real btrfs error is still reported as high", async () => {
  const ctx = stubCtx({
    "command -v journalctl 2>/dev/null": "/usr/bin/journalctl\n",
    "command -v dmesg 2>/dev/null": "",
    "journalctl -k --no-pager -n 500 2>/dev/null | grep -iE 'EXT4-fs error|I/O error|buffer I/O error|BTRFS.*(error|critical|failed|corrupt)|XFS.*error|I/O stall' | tail -n 20":
      "BTRFS error (device sda2): bdev /dev/sda2 errs: wr 0, rd 0, flush 0, corrupt 12, gen 0\n" +
      "BTRFS critical (device sda2): corrupt leaf: root=5 block=12345 slot=0\n" +
      "BTRFS: failed to read chunk tree on sda2\n",
    "dmesg 2>/dev/null | grep -iE 'EXT4-fs error|I/O error|buffer I/O error|BTRFS.*(error|critical|failed|corrupt)|XFS.*error|Remounting filesystem read-only' | tail -n 20": "",
    "dmesg 2>/dev/null | grep -i 'Remounting filesystem read-only' | tail -n 5": "",
  });
  const findings = await fs.run(ctx);
  assert.equal(findings[0].code, "fs/btrfs-errors");
  assert.equal(findings[0].severity, "high");
  // "critical" and "failed" carry no literal "error" — the corruption case must
  // survive the banner fix, not just the lines that happen to say "error".
  assert.match(findings[0].evidence, /BTRFS critical/);
  assert.match(findings[0].evidence, /failed to read chunk tree/);
});

test("fs: the dmesg grep never matches BTRFS on its own", async () => {
  const seen = [];
  const osRelease = { id: "ubuntu", id_like: "debian" };
  const ctx = {
    osRelease,
    dist: detectDistro(osRelease),
    thresholds: loadThresholds({}),
    run: async (cmd) => {
      seen.push(cmd);
      return { ok: true, code: 0, stdout: "", stderr: "" };
    },
  };
  await fs.run(ctx);
  const dmesgGrep = seen.find((c) => c.startsWith("dmesg") && c.includes("tail -n 20"));
  assert.ok(dmesgGrep, "the check must still grep dmesg for errors");
  assert.ok(
    !/\|BTRFS\|/.test(dmesgGrep),
    "a bare BTRFS alternation matches the harmless module load banner",
  );
  assert.match(
    dmesgGrep,
    /BTRFS\.\*\((?=[^)]*error)(?=[^)]*critical)(?=[^)]*failed)(?=[^)]*corrupt)[^)]*\)/,
    "BTRFS must be qualified by the severity keywords, not dropped entirely",
  );
});

// -------------------------------------------------------------- cache ---------

test("cache: a 12 GB user cache is medium", async () => {
  const ctx = stubCtx({
    'du -sb "$HOME/.cache" 2>/dev/null | cut -f1': "12000000000\n",
    'du -sb "$HOME/.local/share/Trash" 2>/dev/null | cut -f1': "",
  });
  const findings = await cache.run(ctx);
  assert.equal(findings[0].code, "cache/large");
  assert.equal(findings[0].severity, "medium");
});

test("cache: a small cache is silent (no false positive)", async () => {
  const ctx = stubCtx({
    'du -sb "$HOME/.cache" 2>/dev/null | cut -f1': "100000000\n",
    'du -sb "$HOME/.local/share/Trash" 2>/dev/null | cut -f1': "",
  });
  const findings = await cache.run(ctx);
  assert.ok(!findings.some((f) => /medium|high/.test(f.severity)), "a 100 MB cache must not alarm");
});

// ------------------------------------------------- packages: dnf needs root --

test("packages: dnf without root is 'skipped (needs root)', not a broken database", async () => {
  // On Fedora an unprivileged `dnf check` cannot create its cache and exits 1
  // with "filesystem error: cannot create directories: Permission denied".
  // The old probe piped through `head`, so the exit status belonged to head,
  // the failure looked successful, the /error/ predicate matched that line and
  // a healthy machine got a HIGH "package manager reports problems" — the same
  // false positive the apt lock refusal used to cause. Verified in a Fedora
  // container as a non-root user.
  const ctx = stubCtx({
    "dnf check 2>&1": "filesystem error: cannot create directories: Permission denied [/.local/state]\n",
  }, { id: "fedora", id_like: "fedora" });
  const findings = await packages.run(ctx);
  assert.ok(!findings.some((f) => f.code === "packages/broken"), `a permission refusal is not a broken database: ${JSON.stringify(findings.map((f) => f.code))}`);
  assert.equal(findings[0].code, "packages/ok");
  assert.match(findings[0].evidence, /skipped \(needs root\)/);
});

test("packages: a failed pacman check stays silent instead of claiming health", async () => {
  // `pacman -Dk` does not need root (verified: it exits 0 unprivileged), so the
  // dead exit-status gate here was benign — but a silent guard belongs in the
  // tests, so a future edit cannot turn a failure into "database is healthy".
  const ctx = stubCtx({
  }, { id: "arch", id_like: "arch" });
  const findings = await packages.run(ctx);
  assert.deepEqual(findings, []);
});

// ------------------------------------------------------- orphans: dnf query --


test("orphans: a failed apt query is not 'no orphaned packages'", async () => {
  // The pipeline's exit status was grep's, so a locked/failed apt printed
  // nothing and the check called the database tidy. Silence, not a clean bill.
  const ctx = stubCtx({}, { id: "debian", id_like: "debian" });
  assert.deepEqual(await orphans.run(ctx), []);
});

test("orphans: a failed zypper query is not 'no orphaned packages'", async () => {
  const ctx = stubCtx({}, { id: "opensuse-tumbleweed", id_like: "suse" });
  assert.deepEqual(await orphans.run(ctx), []);
});

test("orphans: a failed pacman query is not 'no orphaned packages'", async () => {
  // pacman -Qtdq exits 1 both when there are no orphans and on a real failure;
  // the failure is told apart by its stderr, which 2>&1 folds in.
  const ctx = stubCtx({ "pacman -Qtdq 2>&1": "error: could not open database: Success\n" }, { id: "arch", id_like: "arch" });
  assert.deepEqual(await orphans.run(ctx), []);
});

test("orphans: pacman with no orphans (exit 1, empty) still reports none", async () => {
  const ctx = stubCtx({ "pacman -Qtdq 2>&1": "" }, { id: "arch", id_like: "arch" });
  const findings = await orphans.run(ctx);
  assert.equal(findings[0].code, "orphans/none", `an empty result is 'none', not silence: ${JSON.stringify(findings.map((f) => f.code))}`);
});

test("orphans: a failed dnf query is not 'no orphaned packages'", async () => {
  // `dnf repoquery --unneeded | wc -l` always exits 0 (wc's status), so a
  // failed query printed 0 and the check reported a tidy database. Verified
  // in a Fedora container: unprivileged dnf exits 1 and prints the same
  // permission error. "Could not determine" must be silence, not a clean bill.
  const ctx = stubCtx({
  }, { id: "fedora", id_like: "fedora" });
  const findings = await orphans.run(ctx);
  assert.deepEqual(findings, [], "if neither query ran, say nothing");
});


test("backup: no systemctl is 'could not check', not 'nothing scheduled'", async () => {
  // A non-systemd system with a backup tool got a false "nothing is scheduled":
  // the timer probe was empty because systemctl is absent, not because the
  // backup is unscheduled.
  const ctx = stubCtx({
    'for t in borg restic rclone duplicity timeshift pika-backup backintime deja-dup; do command -v "$t" 2>/dev/null; done': "/usr/bin/restic\n",
  }, { id: "alpine", id_like: "" });
  const findings = await backup.run(ctx);
  assert.equal(findings[0].code, "backup/unknown", `expected 'could not check': ${JSON.stringify(findings.map((f) => f.code))}`);
  assert.ok(!findings.some((f) => f.code === "backup/unscheduled"), "must not claim unscheduled");
});

// ------------------------------------------------- cache: Flatpak caches -----

test("cache: Flatpak app caches are measured too, not just ~/.cache", async () => {
  // Seen on a Bazzite box: ~/.cache held 3.7 GB while ~/.var/app held 5 GB of
  // per-app Flatpak caches (a game launcher at 2.6 GB, a chat app at 1.8 GB)
  // that the check never looked at. So the report said "cache is getting
  // large" while the real reclaimable space was invisible.
  const ctx = stubCtx({
    'du -sb "$HOME/.cache" 2>/dev/null | cut -f1': String(3 * 1024 ** 3) + "\n",
    'du -sb "$HOME/.local/share/Trash" 2>/dev/null | cut -f1': "0\n",
    'du -sb "$HOME/.var/app"/*/cache 2>/dev/null': [
      `${Math.round(2.6 * 1024 ** 3)}\t/home/u/.var/app/org.vinegarhq.Sober/cache`,
      `${Math.round(1.8 * 1024 ** 3)}\t/home/u/.var/app/com.ktechpit.whatsie/cache`,
      "",
    ].join("\n"),
  });
  const findings = await cache.run(ctx);
  const large = findings.find((f) => f.code === "cache/large");
  assert.ok(large, `a 4.4 GB Flatpak cache must not be invisible: ${JSON.stringify(findings.map((f) => f.code))}`);
  assert.match(large.evidence, /Sober/, "name the biggest offender");
});

test("cache: Flatpak caches count toward the size thresholds", async () => {
  // ~/.cache alone is under the warn threshold; with the Flatpak caches it is
  // over it, so the finding must appear.
  const ctx = stubCtx({
    'du -sb "$HOME/.cache" 2>/dev/null | cut -f1': String(1 * 1024 ** 3) + "\n",
    'du -sb "$HOME/.local/share/Trash" 2>/dev/null | cut -f1': "0\n",
    'du -sb "$HOME/.var/app"/*/cache 2>/dev/null': `${Math.round(6 * 1024 ** 3)}\t/home/u/.var/app/com.example.App/cache\n`,
  });
  const findings = await cache.run(ctx);
  assert.ok(findings.some((f) => f.code === "cache/large"), "1 GB + 6 GB is over the warn threshold");
});