tests/checks-untested.test.js
Rama main · réplica de lectura del repositorio original.
// 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");
});