diff --git a/doc/changelog/CHANGELOG.md b/doc/changelog/CHANGELOG.md index ec59a54..4322e42 100644 --- a/doc/changelog/CHANGELOG.md +++ b/doc/changelog/CHANGELOG.md @@ -35,6 +35,20 @@ not deferred to release. `release-tag.sh` promotes `[Unreleased]` → ### Added +- **Six monitoring modules for the small-tools modularization program: `btop`, + `bmon`, `iftop`, `iotop`, `nmon`, `powertop`** (`module/.module.sh` + + `test/unit/module/_spec.bats` each). All are apt-archetype `optional` + modules tagged `monitoring`, each installing its like-named apt package + (`btop`, `bmon`, `iftop`, `iotop`, `nmon`, `powertop`) via + `module_use_apt_archetype`, with module-defined `detect()`/`is_recommended()` + and a real `doctor()` that verifies the tool actually runs: `btop --version` + for btop, `bmon -V` for bmon, and `command -v ` for iftop / iotop / + nmon / powertop (which ship no clean version flag). Each declares an en + + zh-TW `DESCRIPTION`, `SUPPORTED_UBUNTU=("22.04" "24.04" "26.04")`, and a + `TEST_VERIFY_CMD` matching its doctor probe. Both standalone- and + engine-invocable; all satisfy the 10-function module contract (ADR-0002, + enforced by `test/unit/module/contract_conformance_spec.bats`). + `doc/module/INDEX.md` regenerated (54 modules). - **Six apt-archetype CLI/utility modules for the small-tools modularization program: `tig`, `git-lfs`, `tree`, `ncdu`, `net-tools`, `sshfs`** (`module/.module.sh` + `test/unit/module/_spec.bats` each). All diff --git a/doc/module/INDEX.md b/doc/module/INDEX.md index fb4fd14..4d4dec0 100644 --- a/doc/module/INDEX.md +++ b/doc/module/INDEX.md @@ -3,7 +3,7 @@ # Module Index -54 modules. Columns come from each module's metadata block +60 modules. Columns come from each module's metadata block (`NAME` / `CATEGORY` / `TAGS` / `DESCRIPTION[en]`, doc/module-spec.md §3). Per-module pages will land in a later milestone; until then each name links to the module source. @@ -12,6 +12,8 @@ to the module source. |---|---|---|---| | [anydesk](../../module/anydesk.module.sh) | optional | remote | AnyDesk — remote desktop client (upstream apt repository) | | [batcat](../../module/batcat.module.sh) | optional | cli-essentials | bat — cat clone with syntax highlighting (Ubuntu binary: batcat) | +| [bmon](../../module/bmon.module.sh) | optional | monitoring | bmon — bandwidth monitor (real-time per-interface traffic, apt bmon package) | +| [btop](../../module/btop.module.sh) | optional | monitoring | btop — modern resource monitor (CPU / memory / disk / net, apt btop package) | | [build-essential](../../module/build-essential.module.sh) | base | build | build-essential — C/C++ compiler toolchain metapackage (gcc, g++, make) | | [claude-code-config](../../module/claude-code-config.module.sh) | optional | agent config dotfile | Personal Claude Code settings (~/.claude: settings.json + statusline) | | [claude-code](../../module/claude-code.module.sh) | optional | agent | Anthropic Claude Code CLI agent (official native installer, self-updating) | @@ -33,6 +35,8 @@ to the module source. | [glow](../../module/glow.module.sh) | optional | cli-essentials | glow — render markdown on the CLI (yazi markdown-preview dependency) | | [gum](../../module/gum.module.sh) | optional | cli-essentials tui | gum — a tool for glamorous shell scripts (preferred modern TUI backend) | | [htop](../../module/htop.module.sh) | base | monitor | htop — interactive process viewer (apt htop package) | +| [iftop](../../module/iftop.module.sh) | optional | monitoring | iftop — display network bandwidth usage by host (apt iftop package) | +| [iotop](../../module/iotop.module.sh) | optional | monitoring | iotop — per-process disk I/O monitor (apt iotop package) | | [jetson-stats](../../module/jetson-stats.module.sh) | optional | hardware | jetson-stats — jtop monitor TUI for NVIDIA Jetson (iGPU, nvpmodel, clocks, fan) | | [jq](../../module/jq.module.sh) | base | cli core | jq — command-line JSON processor (apt jq package) | | [kvm](../../module/kvm.module.sh) | optional | virtualization vm | KVM / libvirt / QEMU virtualization stack + virt-manager | @@ -44,9 +48,11 @@ to the module source. | [ncdu](../../module/ncdu.module.sh) | optional | monitor disk tui | ncdu — ncurses disk usage analyzer (TUI) | | [neovim](../../module/neovim.module.sh) | recommended | editor cli | Neovim editor + nvimdots personal config | | [net-tools](../../module/net-tools.module.sh) | optional | network cli | net-tools — legacy net utilities (ifconfig/netstat/route/arp) | +| [nmon](../../module/nmon.module.sh) | optional | monitoring | nmon — system performance monitor (CPU / memory / disk / net, apt nmon package) | | [notion](../../module/notion.module.sh) | optional | notes | Notion desktop app (unofficial notion-electron .deb client) | | [nvidia-driver](../../module/nvidia-driver.module.sh) | optional | gpu nvidia hardware | NVIDIA proprietary driver (auto-detected recommended version via ubuntu-drivers) | | [pipx](../../module/pipx.module.sh) | base | python cli | pipx — install and run Python CLI apps in isolated virtualenvs (apt-managed) | +| [powertop](../../module/powertop.module.sh) | optional | monitoring | powertop — power consumption diagnostics and monitor (apt powertop package) | | [python3](../../module/python3.module.sh) | base | python runtime | python3 — Python 3 runtime + packaging toolchain (pip, dev headers, setuptools) | | [qmk-firmware](../../module/qmk-firmware.module.sh) | optional | hardware | QMK firmware dev environment — qmk CLI (pipx) + toolchain + qmk_firmware checkout | | [ripgrep](../../module/ripgrep.module.sh) | optional | cli-essentials | ripgrep (rg) — fast, recursive grep alternative (apt ripgrep package) | diff --git a/module/bmon.module.sh b/module/bmon.module.sh new file mode 100644 index 0000000..170ca50 --- /dev/null +++ b/module/bmon.module.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# module/bmon.module.sh — bmon — bandwidth monitor (apt bmon package) [archetype: apt] +# +# Part of the small-tools modularization program: each monitoring tool is an +# independently installable / removable module. Ubuntu ships the `bmon` +# package; the binary is `bmon`. +# +# Standalone usage: +# bash module/bmon.module.sh install [--dry-run] +# bash module/bmon.module.sh upgrade / remove / purge / verify / doctor +# bash module/bmon.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/bmon.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install bmon + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="bmon" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("monitoring") +HOMEPAGE="https://github.com/tgraf/bmon" +declare -gA DESCRIPTION=( + [en]="bmon — bandwidth monitor (real-time per-interface traffic, apt bmon package)" + [zh-TW]="bmon — 頻寬監視工具(即時各介面網路流量,apt bmon 套件)" +) +declare -gA POST_INSTALL_MESSAGE=() +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop" "server" "wsl" "container" "vm") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v bmon && bmon -V" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("bmon") +APT_PPA="" +CONFIG_PATHS=() +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +is_recommended() { + ! is_installed +} + +# doctor: real runtime health — the tool must be installed AND actually run +# (`bmon -V` prints the version and exits). Warns (read-only) on Sidecar +# drift (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + bmon -V + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: bmon is not installed"; return 1; } + bmon -V >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: bmon is present but not runnable"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/btop.module.sh b/module/btop.module.sh new file mode 100644 index 0000000..9e4ae94 --- /dev/null +++ b/module/btop.module.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# module/btop.module.sh — btop — modern resource monitor (apt btop package) [archetype: apt] +# +# Part of the small-tools modularization program: each monitoring tool is an +# independently installable / removable module. Ubuntu ships the `btop` +# package; the binary is `btop`. +# +# Standalone usage: +# bash module/btop.module.sh install [--dry-run] +# bash module/btop.module.sh upgrade / remove / purge / verify / doctor +# bash module/btop.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/btop.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install btop + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="btop" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("monitoring") +HOMEPAGE="https://github.com/aristocratos/btop" +declare -gA DESCRIPTION=( + [en]="btop — modern resource monitor (CPU / memory / disk / net, apt btop package)" + [zh-TW]="btop — 現代化資源監視工具(CPU / 記憶體 / 磁碟 / 網路,apt btop 套件)" +) +declare -gA POST_INSTALL_MESSAGE=() +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop" "server" "wsl" "container" "vm") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v btop && btop --version | head -n1" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("btop") +APT_PPA="" +CONFIG_PATHS=("${HOME}/.config/btop") +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +is_recommended() { + ! is_installed +} + +# doctor: real runtime health — the tool must be installed AND actually run +# (`btop --version`). Warns (read-only) on Sidecar drift (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + btop --version + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: btop is not installed"; return 1; } + btop --version >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: btop is present but not runnable"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/iftop.module.sh b/module/iftop.module.sh new file mode 100644 index 0000000..a7f1ad1 --- /dev/null +++ b/module/iftop.module.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# module/iftop.module.sh — iftop — network bandwidth by host (apt iftop package) [archetype: apt] +# +# Part of the small-tools modularization program: each monitoring tool is an +# independently installable / removable module. Ubuntu ships the `iftop` +# package; the binary is `iftop`. iftop has no clean --version flag, so the +# doctor probe verifies the binary is present and resolvable on PATH. +# +# Standalone usage: +# bash module/iftop.module.sh install [--dry-run] +# bash module/iftop.module.sh upgrade / remove / purge / verify / doctor +# bash module/iftop.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/iftop.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install iftop + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="iftop" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("monitoring") +HOMEPAGE="https://pdw.ex-parrot.com/iftop/" +declare -gA DESCRIPTION=( + [en]="iftop — display network bandwidth usage by host (apt iftop package)" + [zh-TW]="iftop — 依主機顯示網路頻寬用量(apt iftop 套件)" +) +declare -gA POST_INSTALL_MESSAGE=() +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop" "server" "wsl" "container" "vm") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v iftop" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("iftop") +APT_PPA="" +CONFIG_PATHS=() +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +is_recommended() { + ! is_installed +} + +# doctor: real runtime health — the tool must be installed AND resolvable on +# PATH (iftop has no clean --version flag). Warns (read-only) on Sidecar +# drift (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + command -v iftop + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: iftop is not installed"; return 1; } + command -v iftop >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: iftop binary not found on PATH"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/iotop.module.sh b/module/iotop.module.sh new file mode 100644 index 0000000..610876d --- /dev/null +++ b/module/iotop.module.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# module/iotop.module.sh — iotop — I/O monitor (apt iotop package) [archetype: apt] +# +# Part of the small-tools modularization program: each monitoring tool is an +# independently installable / removable module. Ubuntu ships the `iotop` +# package; the binary is `iotop`. The doctor probe verifies the binary is +# present and resolvable on PATH (iotop needs root to actually sample I/O). +# +# Standalone usage: +# bash module/iotop.module.sh install [--dry-run] +# bash module/iotop.module.sh upgrade / remove / purge / verify / doctor +# bash module/iotop.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/iotop.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install iotop + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="iotop" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("monitoring") +HOMEPAGE="https://github.com/Tomas-M/iotop" +declare -gA DESCRIPTION=( + [en]="iotop — per-process disk I/O monitor (apt iotop package)" + [zh-TW]="iotop — 依行程顯示磁碟 I/O 用量(apt iotop 套件)" +) +declare -gA POST_INSTALL_MESSAGE=() +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop" "server" "wsl" "container" "vm") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v iotop" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("iotop") +APT_PPA="" +CONFIG_PATHS=() +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +is_recommended() { + ! is_installed +} + +# doctor: real runtime health — the tool must be installed AND resolvable on +# PATH (iotop needs root to sample I/O, so PATH presence is the safe probe). +# Warns (read-only) on Sidecar drift (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + command -v iotop + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: iotop is not installed"; return 1; } + command -v iotop >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: iotop binary not found on PATH"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/nmon.module.sh b/module/nmon.module.sh new file mode 100644 index 0000000..757525c --- /dev/null +++ b/module/nmon.module.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# module/nmon.module.sh — nmon — performance monitor (apt nmon package) [archetype: apt] +# +# Part of the small-tools modularization program: each monitoring tool is an +# independently installable / removable module. Ubuntu ships the `nmon` +# package; the binary is `nmon`. The doctor probe verifies the binary is +# present and resolvable on PATH (nmon has no clean --version flag). +# +# Standalone usage: +# bash module/nmon.module.sh install [--dry-run] +# bash module/nmon.module.sh upgrade / remove / purge / verify / doctor +# bash module/nmon.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/nmon.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install nmon + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="nmon" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("monitoring") +HOMEPAGE="https://nmon.sourceforge.net/" +declare -gA DESCRIPTION=( + [en]="nmon — system performance monitor (CPU / memory / disk / net, apt nmon package)" + [zh-TW]="nmon — 系統效能監視工具(CPU / 記憶體 / 磁碟 / 網路,apt nmon 套件)" +) +declare -gA POST_INSTALL_MESSAGE=() +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop" "server" "wsl" "container" "vm") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v nmon" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("nmon") +APT_PPA="" +CONFIG_PATHS=() +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +is_recommended() { + ! is_installed +} + +# doctor: real runtime health — the tool must be installed AND resolvable on +# PATH (nmon has no clean --version flag). Warns (read-only) on Sidecar +# drift (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + command -v nmon + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: nmon is not installed"; return 1; } + command -v nmon >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: nmon binary not found on PATH"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/powertop.module.sh b/module/powertop.module.sh new file mode 100644 index 0000000..e70ba1c --- /dev/null +++ b/module/powertop.module.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# module/powertop.module.sh — powertop — power consumption monitor (apt powertop package) [archetype: apt] +# +# Part of the small-tools modularization program: each monitoring tool is an +# independently installable / removable module. Ubuntu ships the `powertop` +# package; the binary is `powertop`. The doctor probe verifies the binary is +# present and resolvable on PATH (powertop needs root to sample power). +# +# Standalone usage: +# bash module/powertop.module.sh install [--dry-run] +# bash module/powertop.module.sh upgrade / remove / purge / verify / doctor +# bash module/powertop.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/powertop.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install powertop + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="powertop" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("monitoring") +HOMEPAGE="https://github.com/fenrus75/powertop" +declare -gA DESCRIPTION=( + [en]="powertop — power consumption diagnostics and monitor (apt powertop package)" + [zh-TW]="powertop — 耗電量診斷與監視工具(apt powertop 套件)" +) +declare -gA POST_INSTALL_MESSAGE=() +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop" "server" "wsl" "container" "vm") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v powertop" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("powertop") +APT_PPA="" +CONFIG_PATHS=() +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +is_recommended() { + ! is_installed +} + +# doctor: real runtime health — the tool must be installed AND resolvable on +# PATH (powertop needs root to sample power, so PATH presence is the safe +# probe). Warns (read-only) on Sidecar drift (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + command -v powertop + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: powertop is not installed"; return 1; } + command -v powertop >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: powertop binary not found on PATH"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/test/unit/module/bmon_spec.bats b/test/unit/module/bmon_spec.bats new file mode 100644 index 0000000..74705e3 --- /dev/null +++ b/test/unit/module/bmon_spec.bats @@ -0,0 +1,524 @@ +#!/usr/bin/env bats +# test/unit/module/bmon_spec.bats — module/bmon.module.sh +# +# Monitoring tool split into its own module (small-tools modularization +# program). Mirrors the htop archetype-A spec: smoke / metadata / lifecycle +# dry-run / no-side-fx / idempotency / standalone CLI / sidecar lifecycle +# (ADR-0001) / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/bmon.module.sh + source "${MODULE_DIR}/bmon.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/bmon.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args +# (satisfies both ` --version|-V` and `command -v ` doctor probes). +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\\n"\nexit 0\n' "${1}" \ + > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "bmon module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/bmon.module.sh" + assert_success +} + +@test "bmon module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "bmon module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade \ + remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "bmon module declares NAME=bmon" { + _load_module + [[ "${NAME}" == "bmon" ]] +} + +@test "bmon module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "bmon module TAGS contains monitoring" { + _load_module + [[ " ${TAGS[*]} " == *" monitoring "* ]] +} + +@test "bmon module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "bmon DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "bmon module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"頻寬"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "bmon SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "bmon SUPPORTED_PLATFORMS covers all five form factors" { + _load_module + local _p + for _p in desktop server wsl container vm; do + [[ " ${SUPPORTED_PLATFORMS[*]} " == *" ${_p} "* ]] || { + printf 'missing platform: %s\n' "${_p}" >&2 + return 1 + } + done +} + +@test "bmon module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "bmon archetype data installs the bmon apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" bmon "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report bmon" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports bmon as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii bmon 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/bmon" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" \ + run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/bmon" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/bmon")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/bmon")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' \ + > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/bmon" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/bmon" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/bmon")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/bmon" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/bmon" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/bmon" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/bmon" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the bmon binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin bmon + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "is_outdated returns zero when apt reports bmon upgradable" { + _load_module + MOCK_APT_UPGRADABLE='bmon/noble-updates 1.2.3 amd64 [upgradable from: 1.2.2]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when bmon is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended ─────────────────────────────────────────────────────────── + +@test "is_recommended is nonzero when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run is_recommended + assert_failure +} + +@test "is_recommended is zero when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run is_recommended + assert_success +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "bmon" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: bmon" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "頻寬" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} diff --git a/test/unit/module/btop_spec.bats b/test/unit/module/btop_spec.bats new file mode 100644 index 0000000..a529769 --- /dev/null +++ b/test/unit/module/btop_spec.bats @@ -0,0 +1,524 @@ +#!/usr/bin/env bats +# test/unit/module/btop_spec.bats — module/btop.module.sh +# +# Monitoring tool split into its own module (small-tools modularization +# program). Mirrors the htop archetype-A spec: smoke / metadata / lifecycle +# dry-run / no-side-fx / idempotency / standalone CLI / sidecar lifecycle +# (ADR-0001) / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/btop.module.sh + source "${MODULE_DIR}/btop.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/btop.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args +# (satisfies both ` --version|-V` and `command -v ` doctor probes). +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\\n"\nexit 0\n' "${1}" \ + > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "btop module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/btop.module.sh" + assert_success +} + +@test "btop module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "btop module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade \ + remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "btop module declares NAME=btop" { + _load_module + [[ "${NAME}" == "btop" ]] +} + +@test "btop module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "btop module TAGS contains monitoring" { + _load_module + [[ " ${TAGS[*]} " == *" monitoring "* ]] +} + +@test "btop module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "btop DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "btop module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"資源監視"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "btop SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "btop SUPPORTED_PLATFORMS covers all five form factors" { + _load_module + local _p + for _p in desktop server wsl container vm; do + [[ " ${SUPPORTED_PLATFORMS[*]} " == *" ${_p} "* ]] || { + printf 'missing platform: %s\n' "${_p}" >&2 + return 1 + } + done +} + +@test "btop module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "btop archetype data installs the btop apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" btop "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report btop" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports btop as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii btop 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/btop" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" \ + run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/btop" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/btop")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/btop")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' \ + > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/btop" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/btop" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/btop")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/btop" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/btop" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/btop" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/btop" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the btop binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin btop + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "is_outdated returns zero when apt reports btop upgradable" { + _load_module + MOCK_APT_UPGRADABLE='btop/noble-updates 1.2.3 amd64 [upgradable from: 1.2.2]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when btop is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended ─────────────────────────────────────────────────────────── + +@test "is_recommended is nonzero when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run is_recommended + assert_failure +} + +@test "is_recommended is zero when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run is_recommended + assert_success +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "btop" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: btop" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "資源監視" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} diff --git a/test/unit/module/iftop_spec.bats b/test/unit/module/iftop_spec.bats new file mode 100644 index 0000000..7af2761 --- /dev/null +++ b/test/unit/module/iftop_spec.bats @@ -0,0 +1,524 @@ +#!/usr/bin/env bats +# test/unit/module/iftop_spec.bats — module/iftop.module.sh +# +# Monitoring tool split into its own module (small-tools modularization +# program). Mirrors the htop archetype-A spec: smoke / metadata / lifecycle +# dry-run / no-side-fx / idempotency / standalone CLI / sidecar lifecycle +# (ADR-0001) / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/iftop.module.sh + source "${MODULE_DIR}/iftop.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/iftop.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args +# (satisfies both ` --version|-V` and `command -v ` doctor probes). +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\\n"\nexit 0\n' "${1}" \ + > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "iftop module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/iftop.module.sh" + assert_success +} + +@test "iftop module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "iftop module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade \ + remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "iftop module declares NAME=iftop" { + _load_module + [[ "${NAME}" == "iftop" ]] +} + +@test "iftop module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "iftop module TAGS contains monitoring" { + _load_module + [[ " ${TAGS[*]} " == *" monitoring "* ]] +} + +@test "iftop module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "iftop DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "iftop module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"頻寬"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "iftop SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "iftop SUPPORTED_PLATFORMS covers all five form factors" { + _load_module + local _p + for _p in desktop server wsl container vm; do + [[ " ${SUPPORTED_PLATFORMS[*]} " == *" ${_p} "* ]] || { + printf 'missing platform: %s\n' "${_p}" >&2 + return 1 + } + done +} + +@test "iftop module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "iftop archetype data installs the iftop apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" iftop "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report iftop" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports iftop as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii iftop 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/iftop" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" \ + run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/iftop" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/iftop")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/iftop")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' \ + > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/iftop" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/iftop" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/iftop")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/iftop" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/iftop" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/iftop" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/iftop" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the iftop binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin iftop + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "is_outdated returns zero when apt reports iftop upgradable" { + _load_module + MOCK_APT_UPGRADABLE='iftop/noble-updates 1.2.3 amd64 [upgradable from: 1.2.2]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when iftop is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended ─────────────────────────────────────────────────────────── + +@test "is_recommended is nonzero when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run is_recommended + assert_failure +} + +@test "is_recommended is zero when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run is_recommended + assert_success +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "iftop" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: iftop" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "頻寬" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} diff --git a/test/unit/module/iotop_spec.bats b/test/unit/module/iotop_spec.bats new file mode 100644 index 0000000..32b7dc7 --- /dev/null +++ b/test/unit/module/iotop_spec.bats @@ -0,0 +1,524 @@ +#!/usr/bin/env bats +# test/unit/module/iotop_spec.bats — module/iotop.module.sh +# +# Monitoring tool split into its own module (small-tools modularization +# program). Mirrors the htop archetype-A spec: smoke / metadata / lifecycle +# dry-run / no-side-fx / idempotency / standalone CLI / sidecar lifecycle +# (ADR-0001) / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/iotop.module.sh + source "${MODULE_DIR}/iotop.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/iotop.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args +# (satisfies both ` --version|-V` and `command -v ` doctor probes). +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\\n"\nexit 0\n' "${1}" \ + > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "iotop module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/iotop.module.sh" + assert_success +} + +@test "iotop module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "iotop module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade \ + remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "iotop module declares NAME=iotop" { + _load_module + [[ "${NAME}" == "iotop" ]] +} + +@test "iotop module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "iotop module TAGS contains monitoring" { + _load_module + [[ " ${TAGS[*]} " == *" monitoring "* ]] +} + +@test "iotop module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "iotop DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "iotop module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"I/O"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "iotop SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "iotop SUPPORTED_PLATFORMS covers all five form factors" { + _load_module + local _p + for _p in desktop server wsl container vm; do + [[ " ${SUPPORTED_PLATFORMS[*]} " == *" ${_p} "* ]] || { + printf 'missing platform: %s\n' "${_p}" >&2 + return 1 + } + done +} + +@test "iotop module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "iotop archetype data installs the iotop apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" iotop "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report iotop" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports iotop as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii iotop 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/iotop" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" \ + run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/iotop" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/iotop")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/iotop")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' \ + > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/iotop" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/iotop" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/iotop")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/iotop" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/iotop" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/iotop" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/iotop" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the iotop binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin iotop + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "is_outdated returns zero when apt reports iotop upgradable" { + _load_module + MOCK_APT_UPGRADABLE='iotop/noble-updates 1.2.3 amd64 [upgradable from: 1.2.2]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when iotop is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended ─────────────────────────────────────────────────────────── + +@test "is_recommended is nonzero when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run is_recommended + assert_failure +} + +@test "is_recommended is zero when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run is_recommended + assert_success +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "iotop" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: iotop" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "I/O" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} diff --git a/test/unit/module/nmon_spec.bats b/test/unit/module/nmon_spec.bats new file mode 100644 index 0000000..efc3247 --- /dev/null +++ b/test/unit/module/nmon_spec.bats @@ -0,0 +1,524 @@ +#!/usr/bin/env bats +# test/unit/module/nmon_spec.bats — module/nmon.module.sh +# +# Monitoring tool split into its own module (small-tools modularization +# program). Mirrors the htop archetype-A spec: smoke / metadata / lifecycle +# dry-run / no-side-fx / idempotency / standalone CLI / sidecar lifecycle +# (ADR-0001) / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/nmon.module.sh + source "${MODULE_DIR}/nmon.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/nmon.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args +# (satisfies both ` --version|-V` and `command -v ` doctor probes). +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\\n"\nexit 0\n' "${1}" \ + > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "nmon module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/nmon.module.sh" + assert_success +} + +@test "nmon module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "nmon module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade \ + remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "nmon module declares NAME=nmon" { + _load_module + [[ "${NAME}" == "nmon" ]] +} + +@test "nmon module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "nmon module TAGS contains monitoring" { + _load_module + [[ " ${TAGS[*]} " == *" monitoring "* ]] +} + +@test "nmon module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "nmon DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "nmon module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"效能"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "nmon SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "nmon SUPPORTED_PLATFORMS covers all five form factors" { + _load_module + local _p + for _p in desktop server wsl container vm; do + [[ " ${SUPPORTED_PLATFORMS[*]} " == *" ${_p} "* ]] || { + printf 'missing platform: %s\n' "${_p}" >&2 + return 1 + } + done +} + +@test "nmon module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "nmon archetype data installs the nmon apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" nmon "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report nmon" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports nmon as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii nmon 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/nmon" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" \ + run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/nmon" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/nmon")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/nmon")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' \ + > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/nmon" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/nmon" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/nmon")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/nmon" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/nmon" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/nmon" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/nmon" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the nmon binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin nmon + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "is_outdated returns zero when apt reports nmon upgradable" { + _load_module + MOCK_APT_UPGRADABLE='nmon/noble-updates 1.2.3 amd64 [upgradable from: 1.2.2]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when nmon is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended ─────────────────────────────────────────────────────────── + +@test "is_recommended is nonzero when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run is_recommended + assert_failure +} + +@test "is_recommended is zero when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run is_recommended + assert_success +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "nmon" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: nmon" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "效能" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} diff --git a/test/unit/module/powertop_spec.bats b/test/unit/module/powertop_spec.bats new file mode 100644 index 0000000..6748449 --- /dev/null +++ b/test/unit/module/powertop_spec.bats @@ -0,0 +1,524 @@ +#!/usr/bin/env bats +# test/unit/module/powertop_spec.bats — module/powertop.module.sh +# +# Monitoring tool split into its own module (small-tools modularization +# program). Mirrors the htop archetype-A spec: smoke / metadata / lifecycle +# dry-run / no-side-fx / idempotency / standalone CLI / sidecar lifecycle +# (ADR-0001) / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/powertop.module.sh + source "${MODULE_DIR}/powertop.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/powertop.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args +# (satisfies both ` --version|-V` and `command -v ` doctor probes). +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\\n"\nexit 0\n' "${1}" \ + > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "powertop module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/powertop.module.sh" + assert_success +} + +@test "powertop module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "powertop module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade \ + remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "powertop module declares NAME=powertop" { + _load_module + [[ "${NAME}" == "powertop" ]] +} + +@test "powertop module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "powertop module TAGS contains monitoring" { + _load_module + [[ " ${TAGS[*]} " == *" monitoring "* ]] +} + +@test "powertop module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "powertop DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "powertop module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"耗電"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "powertop SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "powertop SUPPORTED_PLATFORMS covers all five form factors" { + _load_module + local _p + for _p in desktop server wsl container vm; do + [[ " ${SUPPORTED_PLATFORMS[*]} " == *" ${_p} "* ]] || { + printf 'missing platform: %s\n' "${_p}" >&2 + return 1 + } + done +} + +@test "powertop module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "powertop archetype data installs the powertop apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" powertop "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report powertop" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports powertop as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii powertop 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/powertop" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" \ + run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/powertop" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/powertop")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/powertop")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' \ + > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/powertop" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/powertop" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/powertop")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/powertop" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/powertop" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/powertop" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/powertop" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the powertop binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin powertop + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "is_outdated returns zero when apt reports powertop upgradable" { + _load_module + MOCK_APT_UPGRADABLE='powertop/noble-updates 1.2.3 amd64 [upgradable from: 1.2.2]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when powertop is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended ─────────────────────────────────────────────────────────── + +@test "is_recommended is nonzero when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run is_recommended + assert_failure +} + +@test "is_recommended is zero when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run is_recommended + assert_success +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "powertop" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: powertop" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "耗電" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +}