From da1fcea89e50d26c64ef5e6f2e826da8c366058b Mon Sep 17 00:00:00 2001 From: cyc Date: Sat, 11 Jul 2026 22:33:43 +0800 Subject: [PATCH] feat(module): add bpytop, gpustat, thefuck pipx tool modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three custom-archetype (archetype D) tool modules for the small-tools modularization program, mirroring the tmuxp / claude-monitor pipx pattern. Each DEPENDS_ON=("pipx") and installs as the invoking user via `pipx install ` (no inline pipx bootstrap — the engine installs the pipx module first), with `pipx upgrade` / `pipx uninstall` for the rest of the lifecycle: - bpytop — terminal resource monitor; tags monitoring cli; doctor: bpytop --version. - gpustat — compact per-GPU status monitor; tags monitoring gpu; doctor: gpustat --version. - thefuck — corrects the previous mistyped console command; tags shell cli; doctor: command -v thefuck (a --version check needs the shell alias, so presence on PATH is the reliable signal); its POST_INSTALL_MESSAGE explains the required eval "$(thefuck --alias)" shell-rc setup. Each hand-defines all 10 mandatory lifecycle functions with a real doctor() (ADR-0002, #305 contract-conformance), declares en + zh-TW DESCRIPTION, SUPPORTED_UBUNTU=(22.04 24.04 26.04), VERSION_PROVIDED="pipx-managed", and a TEST_VERIFY_CMD matching its doctor probe, plus the dual-mode standalone/engine entry convention. Adds a per-module unit spec each (metadata sanity, is_installed via pipx ownership, dry-run no-ops, idempotency, sidecar lifecycle ADR-0001, standalone CLI, verify/doctor with a real-binary probe). Regenerates doc/module/INDEX.md (67 modules) and records a CHANGELOG entry. Gates green in Docker: test-unit (incl. contract_conformance / #305), test-integration, coverage, lint. Claude-Session: https://claude.ai/code/session_01NX5H2vuMTv4mBmjpPYoS3s Co-Authored-By: Claude Opus 4.8 --- doc/changelog/CHANGELOG.md | 23 ++ doc/module/INDEX.md | 5 +- module/bpytop.module.sh | 212 ++++++++++ module/gpustat.module.sh | 214 ++++++++++ module/thefuck.module.sh | 215 ++++++++++ test/unit/module/bpytop_spec.bats | 639 +++++++++++++++++++++++++++++ test/unit/module/gpustat_spec.bats | 634 ++++++++++++++++++++++++++++ test/unit/module/thefuck_spec.bats | 628 ++++++++++++++++++++++++++++ 8 files changed, 2569 insertions(+), 1 deletion(-) create mode 100644 module/bpytop.module.sh create mode 100644 module/gpustat.module.sh create mode 100644 module/thefuck.module.sh create mode 100644 test/unit/module/bpytop_spec.bats create mode 100644 test/unit/module/gpustat_spec.bats create mode 100644 test/unit/module/thefuck_spec.bats diff --git a/doc/changelog/CHANGELOG.md b/doc/changelog/CHANGELOG.md index f0e517c..e60c6f0 100644 --- a/doc/changelog/CHANGELOG.md +++ b/doc/changelog/CHANGELOG.md @@ -35,6 +35,29 @@ not deferred to release. `release-tag.sh` promotes `[Unreleased]` → ### Added +- **Three pipx-installed tool modules for the small-tools modularization + program: `bpytop`, `gpustat`, `thefuck`** (`module/.module.sh` + + `test/unit/module/_spec.bats` each). All three are custom-archetype + (archetype D) `optional` modules that `DEPENDS_ON=("pipx")` and install as + the invoking user via `pipx install ` (no inline pipx bootstrap — the + engine installs the `pipx` module first), mirroring the existing + `tmuxp` / `claude-monitor` pipx pattern (`pipx install` / `pipx upgrade` / + `pipx uninstall`). Each hand-defines all 10 mandatory lifecycle functions + (ADR-0002, enforced by `test/unit/module/contract_conformance_spec.bats`, + the #305 contract-conformance meta-test) with a real `doctor()`: + - `bpytop` — terminal resource monitor; tags `monitoring cli`; doctor probes + `bpytop --version`. + - `gpustat` — compact per-GPU status monitor; tags `monitoring gpu`; doctor + probes `gpustat --version`. + - `thefuck` — corrects the previous mistyped console command; tags + `shell cli`; doctor probes `command -v thefuck` (a `thefuck --version` + check needs the shell alias, so presence on PATH is the reliable signal), + and its `POST_INSTALL_MESSAGE` explains the required + `eval "$(thefuck --alias)"` shell-rc setup. + Each declares an en + zh-TW `DESCRIPTION`, `SUPPORTED_UBUNTU=("22.04" + "24.04" "26.04")`, `VERSION_PROVIDED="pipx-managed"`, and a `TEST_VERIFY_CMD` + matching its doctor probe, plus the dual-mode standalone/engine entry + convention. `doc/module/INDEX.md` regenerated (67 modules). - **Four monitoring modules for the small-tools modularization program: `powerstat`, `dstat`, `ifstat`, `nmap`** (`module/.module.sh` + `test/unit/module/_spec.bats` each). All four are apt-archetype diff --git a/doc/module/INDEX.md b/doc/module/INDEX.md index 4c7b318..2cb77bc 100644 --- a/doc/module/INDEX.md +++ b/doc/module/INDEX.md @@ -3,7 +3,7 @@ # Module Index -64 modules. Columns come from each module's metadata block +67 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. @@ -13,6 +13,7 @@ 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) | +| [bpytop](../../module/bpytop.module.sh) | optional | monitoring cli | bpytop — terminal resource monitor (CPU / memory / disk / network / processes), pipx-managed | | [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) | @@ -34,6 +35,7 @@ to the module source. | [git-lfs](../../module/git-lfs.module.sh) | optional | git vcs | git-lfs — Git Large File Storage (versions large files via pointers) | | [git](../../module/git.module.sh) | base | vcs | git — distributed version control system (apt git package) | | [glow](../../module/glow.module.sh) | optional | cli-essentials | glow — render markdown on the CLI (yazi markdown-preview dependency) | +| [gpustat](../../module/gpustat.module.sh) | optional | monitoring gpu | gpustat — compact per-GPU status monitor (utilization / memory / temperature), pipx-managed | | [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) | | [ifstat](../../module/ifstat.module.sh) | optional | monitoring | ifstat — report network interface bandwidth statistics (apt ifstat package) | @@ -63,6 +65,7 @@ to the module source. | [shell](../../module/shell.module.sh) | recommended | shell core | Shell session basics: ssh client, keychain, xclip | | [ssh-config](../../module/ssh-config.module.sh) | optional | config ssh dotfile | Personal ~/.ssh/config templates (per-host alias and key map) | | [sshfs](../../module/sshfs.module.sh) | optional | network filesystem | sshfs — mount a remote filesystem over SSH (FUSE) | +| [thefuck](../../module/thefuck.module.sh) | optional | shell cli | thefuck — corrects your previous mistyped console command, pipx-managed | | [tig](../../module/tig.module.sh) | optional | git vcs tui | tig — text-mode interface for git (log/diff/blame browser) | | [tmux](../../module/tmux.module.sh) | recommended | terminal multiplexer | Terminal multiplexer + powerline config | | [tmuxp](../../module/tmuxp.module.sh) | optional | terminal session | tmuxp — tmux session manager (freeze/restore sessions from YAML), pipx-managed | diff --git a/module/bpytop.module.sh b/module/bpytop.module.sh new file mode 100644 index 0000000..e352f83 --- /dev/null +++ b/module/bpytop.module.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# module/bpytop.module.sh — bpytop: resource monitor (pipx-managed) [archetype: custom] +# +# New module for the small-tools modularization program. bpytop is a Python +# terminal resource monitor (CPU / memory / disk / network / processes). It +# ships on PyPI and its sanctioned install path is pipx (isolated venv + a +# launcher on PATH), so it is a user-scoped tool with no clean apt package and +# no GitHub-release tarball — archetype D (custom). +# +# pipx runs as the invoking user and drops the launcher under ~/.local/bin, so +# every phase is user-home (no sudo). pipx is DEPENDS_ON (foundation program): +# the engine installs the pipx module first, so this module assumes pipx is +# present rather than bootstrapping it inline. +# +# Standalone usage: +# bash module/bpytop.module.sh install [--dry-run] +# bash module/bpytop.module.sh upgrade / remove / purge / verify / doctor +# bash module/bpytop.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/bpytop.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install bpytop + +# ── 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="bpytop" +VERSION_PROVIDED="pipx-managed" +CATEGORY="optional" +TAGS=("monitoring" "cli") +HOMEPAGE="https://github.com/aristocratos/bpytop" +declare -gA DESCRIPTION=( + [en]="bpytop — terminal resource monitor (CPU / memory / disk / network / processes), pipx-managed" + [zh-TW]="bpytop — 終端機資源監控器(CPU / 記憶體 / 磁碟 / 網路 / 行程),以 pipx 管理" +) +declare -gA POST_INSTALL_MESSAGE=( + [en]="bpytop is installed via pipx (~/.local/bin). Ensure ~/.local/bin is on PATH ('pipx ensurepath', then restart your shell)." + [zh-TW]="bpytop 已透過 pipx 安裝(~/.local/bin)。請確認 ~/.local/bin 在 PATH 中(執行 'pipx ensurepath' 後重開 shell)。" +) +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop" "server" "wsl") +DEPENDS_ON=("pipx") +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=true +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="user-home" +TEST_VERIFY_CMD="command -v bpytop && bpytop --version" + +# 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}" + +# Version recorded in the Sidecar by the phase-invocation wrapper after a +# successful install/upgrade (overrides the generic VERSION_PROVIDED default). +module_provided_version() { _bpytop_version; } + +# ── Archetype D — custom (pipx install, user-level) ───────────────────────── +PIPX_PKG="bpytop" # PyPI distribution + pipx venv name +BPYTOP_BIN="bpytop" # launcher shim dropped on PATH + +# ── Lifecycle ─────────────────────────────────────────────────────────────── + +# is_installed: pipx ownership is authoritative (`pipx list --short` prints +# " " per venv). +is_installed() { + _bpytop_pipx_owns +} + +# install: pipx install. pipx is a declared dependency (DEPENDS_ON), so the +# engine installs it first — no inline pipx bootstrap here. +install() { + module_dryrun_guard install "pipx install ${PIPX_PKG}" && return 0 + module_skip_if_installed && return 0 + _bpytop_pipx_install || return $? +} + +# upgrade: pipx upgrade in place; fall through to install when nothing is +# pipx-managed yet (e.g. first run). +upgrade() { + module_dryrun_guard upgrade "pipx upgrade ${PIPX_PKG}" && return 0 + if ! is_installed; then + log_info "[${NAME}] not pipx-managed yet — running install instead" + install + return $? + fi + _bpytop_pipx_upgrade || return $? +} + +# remove: pipx uninstall (leaves user config under ~/.config/bpytop). +remove() { + module_dryrun_guard remove "pipx uninstall ${PIPX_PKG}" && return 0 + module_skip_if_not_installed && return 0 + _bpytop_pipx_uninstall || return $? +} + +# purge: same uninstall path. bpytop keeps no init_ubuntu-managed system +# config; user config under ~/.config/bpytop is user data and is intentionally +# preserved (remove vs purge differ only by that promise). +purge() { + module_dryrun_guard purge "pipx uninstall ${PIPX_PKG} (user config kept)" && return 0 + if is_installed; then + _bpytop_pipx_uninstall || return $? + fi +} + +verify() { + module_default_verify +} + +# detect: any apt-based Ubuntu host — pipx is apt-installable and bpytop is +# pure-Python (arch-independent). +detect() { + command -v apt-get >/dev/null 2>&1 +} + +# is_recommended: yes when not yet installed; the engine still gates it behind +# CATEGORY=optional for Quick Setup. +is_recommended() { + ! is_installed +} + +# is_outdated: pipx exposes no cheap offline "outdated" query, and pipx upgrade +# is idempotent, so this defers to upgrade rather than probing PyPI. Never +# reports known-outdated (returns nonzero); `upgrade` still refreshes to latest. +is_outdated() { + return 1 +} + +# doctor: health check — pipx-managed, then the bpytop binary answers +# --version, then Sidecar presence (warn-only: an out-of-band pipx install has +# no Sidecar). +doctor() { + if ! is_installed; then + log_warn "[${NAME}] doctor: bpytop is not pipx-managed" + return 1 + fi + local _bin + _bin="$(command -v "${BPYTOP_BIN}" 2>/dev/null)" || { + log_warn "[${NAME}] doctor: ${BPYTOP_BIN} not found on PATH (run 'pipx ensurepath')" + return 1 + } + if ! "${_bin}" --version >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: ${_bin} --version failed" + return 1 + fi + module_sidecar_get_version "${NAME}" >/dev/null 2>&1 \ + || log_warn "[${NAME}] doctor: sidecar missing (installed outside init_ubuntu?)" + return 0 +} + +# ── Private helpers ───────────────────────────────────────────────────────── + +# Single pipx-execution seam (user-level, no sudo). Tests shadow this to record +# argv without a real pipx on PATH. +_bpytop_pipx() { + pipx "$@" +} + +# pipx ownership: `pipx list --short` prints " " per venv. +_bpytop_pipx_owns() { + _bpytop_pipx list --short 2>/dev/null | grep -q "^${PIPX_PKG} " +} + +_bpytop_pipx_install() { + log_info "[${NAME}] pipx install ${PIPX_PKG}" + _bpytop_pipx install "${PIPX_PKG}" || return 1 +} + +_bpytop_pipx_upgrade() { + log_info "[${NAME}] pipx upgrade ${PIPX_PKG}" + _bpytop_pipx upgrade "${PIPX_PKG}" || return 1 +} + +_bpytop_pipx_uninstall() { + log_info "[${NAME}] pipx uninstall ${PIPX_PKG}" + _bpytop_pipx uninstall "${PIPX_PKG}" || return 1 +} + +# Version string for the Sidecar: parse `pipx list --short` (one +# " " line per venv), falling back to the literal "pipx-managed" +# when pipx has no answer. +_bpytop_version() { + local _ver="" + _ver="$(_bpytop_pipx list --short 2>/dev/null \ + | awk -v p="${PIPX_PKG}" '$1==p {print $2; exit}')" || _ver="" + printf '%s' "${_ver:-pipx-managed}" +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/gpustat.module.sh b/module/gpustat.module.sh new file mode 100644 index 0000000..79df0b6 --- /dev/null +++ b/module/gpustat.module.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# module/gpustat.module.sh — gpustat: GPU status monitor (pipx-managed) [archetype: custom] +# +# New module for the small-tools modularization program. gpustat is a Python +# CLI that prints a compact per-GPU status line (utilization / memory / +# temperature / processes) on top of NVIDIA's nvidia-smi. It ships on PyPI and +# its sanctioned install path is pipx (isolated venv + a launcher on PATH), so +# it is a user-scoped tool with no clean apt package and no GitHub-release +# tarball — archetype D (custom). +# +# pipx runs as the invoking user and drops the launcher under ~/.local/bin, so +# every phase is user-home (no sudo). pipx is DEPENDS_ON (foundation program): +# the engine installs the pipx module first, so this module assumes pipx is +# present rather than bootstrapping it inline. +# +# Standalone usage: +# bash module/gpustat.module.sh install [--dry-run] +# bash module/gpustat.module.sh upgrade / remove / purge / verify / doctor +# bash module/gpustat.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/gpustat.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install gpustat + +# ── 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="gpustat" +VERSION_PROVIDED="pipx-managed" +CATEGORY="optional" +TAGS=("monitoring" "gpu") +HOMEPAGE="https://github.com/wookayin/gpustat" +declare -gA DESCRIPTION=( + [en]="gpustat — compact per-GPU status monitor (utilization / memory / temperature), pipx-managed" + [zh-TW]="gpustat — 精簡的 GPU 狀態監控器(使用率 / 記憶體 / 溫度),以 pipx 管理" +) +declare -gA POST_INSTALL_MESSAGE=( + [en]="gpustat is installed via pipx (~/.local/bin). Ensure ~/.local/bin is on PATH ('pipx ensurepath', then restart your shell). gpustat reads GPU data via nvidia-smi." + [zh-TW]="gpustat 已透過 pipx 安裝(~/.local/bin)。請確認 ~/.local/bin 在 PATH 中(執行 'pipx ensurepath' 後重開 shell)。gpustat 透過 nvidia-smi 讀取 GPU 資料。" +) +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop" "server" "wsl") +DEPENDS_ON=("pipx") +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=true +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="user-home" +TEST_VERIFY_CMD="command -v gpustat && gpustat --version" + +# 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}" + +# Version recorded in the Sidecar by the phase-invocation wrapper after a +# successful install/upgrade (overrides the generic VERSION_PROVIDED default). +module_provided_version() { _gpustat_version; } + +# ── Archetype D — custom (pipx install, user-level) ───────────────────────── +PIPX_PKG="gpustat" # PyPI distribution + pipx venv name +GPUSTAT_BIN="gpustat" # launcher shim dropped on PATH + +# ── Lifecycle ─────────────────────────────────────────────────────────────── + +# is_installed: pipx ownership is authoritative (`pipx list --short` prints +# " " per venv). +is_installed() { + _gpustat_pipx_owns +} + +# install: pipx install. pipx is a declared dependency (DEPENDS_ON), so the +# engine installs it first — no inline pipx bootstrap here. +install() { + module_dryrun_guard install "pipx install ${PIPX_PKG}" && return 0 + module_skip_if_installed && return 0 + _gpustat_pipx_install || return $? +} + +# upgrade: pipx upgrade in place; fall through to install when nothing is +# pipx-managed yet (e.g. first run). +upgrade() { + module_dryrun_guard upgrade "pipx upgrade ${PIPX_PKG}" && return 0 + if ! is_installed; then + log_info "[${NAME}] not pipx-managed yet — running install instead" + install + return $? + fi + _gpustat_pipx_upgrade || return $? +} + +# remove: pipx uninstall (leaves any user config in place). +remove() { + module_dryrun_guard remove "pipx uninstall ${PIPX_PKG}" && return 0 + module_skip_if_not_installed && return 0 + _gpustat_pipx_uninstall || return $? +} + +# purge: same uninstall path. gpustat keeps no init_ubuntu-managed system +# config; any user config is user data and is intentionally preserved (remove +# vs purge differ only by that promise). +purge() { + module_dryrun_guard purge "pipx uninstall ${PIPX_PKG} (user config kept)" && return 0 + if is_installed; then + _gpustat_pipx_uninstall || return $? + fi +} + +verify() { + module_default_verify +} + +# detect: any apt-based Ubuntu host — pipx is apt-installable and gpustat is +# pure-Python (arch-independent). It reports "no GPU" gracefully on hosts +# without one, so no hardware gate is applied here. +detect() { + command -v apt-get >/dev/null 2>&1 +} + +# is_recommended: yes when not yet installed; the engine still gates it behind +# CATEGORY=optional for Quick Setup. +is_recommended() { + ! is_installed +} + +# is_outdated: pipx exposes no cheap offline "outdated" query, and pipx upgrade +# is idempotent, so this defers to upgrade rather than probing PyPI. Never +# reports known-outdated (returns nonzero); `upgrade` still refreshes to latest. +is_outdated() { + return 1 +} + +# doctor: health check — pipx-managed, then the gpustat binary answers +# --version, then Sidecar presence (warn-only: an out-of-band pipx install has +# no Sidecar). +doctor() { + if ! is_installed; then + log_warn "[${NAME}] doctor: gpustat is not pipx-managed" + return 1 + fi + local _bin + _bin="$(command -v "${GPUSTAT_BIN}" 2>/dev/null)" || { + log_warn "[${NAME}] doctor: ${GPUSTAT_BIN} not found on PATH (run 'pipx ensurepath')" + return 1 + } + if ! "${_bin}" --version >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: ${_bin} --version failed" + return 1 + fi + module_sidecar_get_version "${NAME}" >/dev/null 2>&1 \ + || log_warn "[${NAME}] doctor: sidecar missing (installed outside init_ubuntu?)" + return 0 +} + +# ── Private helpers ───────────────────────────────────────────────────────── + +# Single pipx-execution seam (user-level, no sudo). Tests shadow this to record +# argv without a real pipx on PATH. +_gpustat_pipx() { + pipx "$@" +} + +# pipx ownership: `pipx list --short` prints " " per venv. +_gpustat_pipx_owns() { + _gpustat_pipx list --short 2>/dev/null | grep -q "^${PIPX_PKG} " +} + +_gpustat_pipx_install() { + log_info "[${NAME}] pipx install ${PIPX_PKG}" + _gpustat_pipx install "${PIPX_PKG}" || return 1 +} + +_gpustat_pipx_upgrade() { + log_info "[${NAME}] pipx upgrade ${PIPX_PKG}" + _gpustat_pipx upgrade "${PIPX_PKG}" || return 1 +} + +_gpustat_pipx_uninstall() { + log_info "[${NAME}] pipx uninstall ${PIPX_PKG}" + _gpustat_pipx uninstall "${PIPX_PKG}" || return 1 +} + +# Version string for the Sidecar: parse `pipx list --short` (one +# " " line per venv), falling back to the literal "pipx-managed" +# when pipx has no answer. +_gpustat_version() { + local _ver="" + _ver="$(_gpustat_pipx list --short 2>/dev/null \ + | awk -v p="${PIPX_PKG}" '$1==p {print $2; exit}')" || _ver="" + printf '%s' "${_ver:-pipx-managed}" +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/thefuck.module.sh b/module/thefuck.module.sh new file mode 100644 index 0000000..634d577 --- /dev/null +++ b/module/thefuck.module.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# module/thefuck.module.sh — thefuck: command corrector (pipx-managed) [archetype: custom] +# +# New module for the small-tools modularization program. thefuck is a Python +# CLI that suggests corrections for the previous mistyped console command. It +# ships on PyPI and its sanctioned install path is pipx (isolated venv + a +# launcher on PATH), so it is a user-scoped tool with no clean apt package and +# no GitHub-release tarball — archetype D (custom). +# +# pipx runs as the invoking user and drops the launcher under ~/.local/bin, so +# every phase is user-home (no sudo). pipx is DEPENDS_ON (foundation program): +# the engine installs the pipx module first, so this module assumes pipx is +# present rather than bootstrapping it inline. +# +# thefuck is only useful after wiring a shell alias (eval "$(thefuck --alias)") +# into the user's shell rc — a manual step surfaced via POST_INSTALL_MESSAGE. +# Because `thefuck --version` needs that alias/shell context to behave, the +# doctor()/verify() probes use `command -v thefuck` (presence on PATH), which is +# the reliable, side-effect-free health signal for this tool. +# +# Standalone usage: +# bash module/thefuck.module.sh install [--dry-run] +# bash module/thefuck.module.sh upgrade / remove / purge / verify / doctor +# bash module/thefuck.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/thefuck.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install thefuck + +# ── 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="thefuck" +VERSION_PROVIDED="pipx-managed" +CATEGORY="optional" +TAGS=("shell" "cli") +HOMEPAGE="https://github.com/nvbn/thefuck" +declare -gA DESCRIPTION=( + [en]="thefuck — corrects your previous mistyped console command, pipx-managed" + [zh-TW]="thefuck — 修正你上一個打錯的終端機命令,以 pipx 管理" +) +declare -gA POST_INSTALL_MESSAGE=( + [en]="thefuck is installed via pipx (~/.local/bin). Ensure ~/.local/bin is on PATH ('pipx ensurepath'), then add the shell alias so it works: put eval \"\$(thefuck --alias)\" in your ~/.bashrc (or ~/.config/fish/config.fish: thefuck --alias | source) and restart your shell." + [zh-TW]="thefuck 已透過 pipx 安裝(~/.local/bin)。請確認 ~/.local/bin 在 PATH 中(執行 'pipx ensurepath'),再加入 shell alias 才能使用:在 ~/.bashrc 加入 eval \"\$(thefuck --alias)\"(fish 於 ~/.config/fish/config.fish:thefuck --alias | source)後重開 shell。" +) +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop" "server" "wsl") +DEPENDS_ON=("pipx") +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=true +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="user-home" +TEST_VERIFY_CMD="command -v thefuck" + +# 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}" + +# Version recorded in the Sidecar by the phase-invocation wrapper after a +# successful install/upgrade (overrides the generic VERSION_PROVIDED default). +module_provided_version() { _thefuck_version; } + +# ── Archetype D — custom (pipx install, user-level) ───────────────────────── +PIPX_PKG="thefuck" # PyPI distribution + pipx venv name +THEFUCK_BIN="thefuck" # launcher shim dropped on PATH + +# ── Lifecycle ─────────────────────────────────────────────────────────────── + +# is_installed: pipx ownership is authoritative (`pipx list --short` prints +# " " per venv). +is_installed() { + _thefuck_pipx_owns +} + +# install: pipx install. pipx is a declared dependency (DEPENDS_ON), so the +# engine installs it first — no inline pipx bootstrap here. +install() { + module_dryrun_guard install "pipx install ${PIPX_PKG}" && return 0 + module_skip_if_installed && return 0 + _thefuck_pipx_install || return $? +} + +# upgrade: pipx upgrade in place; fall through to install when nothing is +# pipx-managed yet (e.g. first run). +upgrade() { + module_dryrun_guard upgrade "pipx upgrade ${PIPX_PKG}" && return 0 + if ! is_installed; then + log_info "[${NAME}] not pipx-managed yet — running install instead" + install + return $? + fi + _thefuck_pipx_upgrade || return $? +} + +# remove: pipx uninstall. The shell alias the user added to their rc is left +# in place (user data); removing it is the user's call. +remove() { + module_dryrun_guard remove "pipx uninstall ${PIPX_PKG}" && return 0 + module_skip_if_not_installed && return 0 + _thefuck_pipx_uninstall || return $? +} + +# purge: same uninstall path. thefuck keeps no init_ubuntu-managed system +# config; the user's rc alias is user data and is intentionally preserved +# (remove vs purge differ only by that promise). +purge() { + module_dryrun_guard purge "pipx uninstall ${PIPX_PKG} (user shell alias kept)" && return 0 + if is_installed; then + _thefuck_pipx_uninstall || return $? + fi +} + +verify() { + module_default_verify +} + +# detect: any apt-based Ubuntu host — pipx is apt-installable and thefuck is +# pure-Python (arch-independent). +detect() { + command -v apt-get >/dev/null 2>&1 +} + +# is_recommended: yes when not yet installed; the engine still gates it behind +# CATEGORY=optional for Quick Setup. +is_recommended() { + ! is_installed +} + +# is_outdated: pipx exposes no cheap offline "outdated" query, and pipx upgrade +# is idempotent, so this defers to upgrade rather than probing PyPI. Never +# reports known-outdated (returns nonzero); `upgrade` still refreshes to latest. +is_outdated() { + return 1 +} + +# doctor: health check — pipx-managed, then the thefuck launcher is on PATH. +# `thefuck --version` needs the shell alias/context to behave, so presence on +# PATH (command -v) is the reliable, side-effect-free probe here. Sidecar +# presence is warn-only (an out-of-band pipx install has no Sidecar). +doctor() { + if ! is_installed; then + log_warn "[${NAME}] doctor: thefuck is not pipx-managed" + return 1 + fi + if ! command -v "${THEFUCK_BIN}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: ${THEFUCK_BIN} not found on PATH (run 'pipx ensurepath')" + return 1 + fi + module_sidecar_get_version "${NAME}" >/dev/null 2>&1 \ + || log_warn "[${NAME}] doctor: sidecar missing (installed outside init_ubuntu?)" + return 0 +} + +# ── Private helpers ───────────────────────────────────────────────────────── + +# Single pipx-execution seam (user-level, no sudo). Tests shadow this to record +# argv without a real pipx on PATH. +_thefuck_pipx() { + pipx "$@" +} + +# pipx ownership: `pipx list --short` prints " " per venv. +_thefuck_pipx_owns() { + _thefuck_pipx list --short 2>/dev/null | grep -q "^${PIPX_PKG} " +} + +_thefuck_pipx_install() { + log_info "[${NAME}] pipx install ${PIPX_PKG}" + _thefuck_pipx install "${PIPX_PKG}" || return 1 +} + +_thefuck_pipx_upgrade() { + log_info "[${NAME}] pipx upgrade ${PIPX_PKG}" + _thefuck_pipx upgrade "${PIPX_PKG}" || return 1 +} + +_thefuck_pipx_uninstall() { + log_info "[${NAME}] pipx uninstall ${PIPX_PKG}" + _thefuck_pipx uninstall "${PIPX_PKG}" || return 1 +} + +# Version string for the Sidecar: parse `pipx list --short` (one +# " " line per venv), falling back to the literal "pipx-managed" +# when pipx has no answer. +_thefuck_version() { + local _ver="" + _ver="$(_thefuck_pipx list --short 2>/dev/null \ + | awk -v p="${PIPX_PKG}" '$1==p {print $2; exit}')" || _ver="" + printf '%s' "${_ver:-pipx-managed}" +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/test/unit/module/bpytop_spec.bats b/test/unit/module/bpytop_spec.bats new file mode 100644 index 0000000..02e3a84 --- /dev/null +++ b/test/unit/module/bpytop_spec.bats @@ -0,0 +1,639 @@ +#!/usr/bin/env bats +# test/unit/module/bpytop_spec.bats — module/bpytop.module.sh +# +# Per Q29: smoke / metadata / lifecycle dry-run / no-side-fx / idempotency / +# standalone CLI / module-specific (custom archetype: user-level pipx install, +# Sidecar lifecycle ADR-0001, real doctor probe). Small-tools modularization +# program (pipx tier). Mirrors the tmuxp/claude-monitor custom-archetype spec. + +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/bpytop.module.sh + source "${MODULE_DIR}/bpytop.module.sh" +} + +# _standalone_module runs the module as a self-contained CLI (the same entry +# users hit when they type `bash module/bpytop.module.sh ...`). +_standalone_module() { + bash "${MODULE_DIR}/bpytop.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── +# Each shadowed function is defined exactly once and parameterized via MOCK_* +# variables, so every definition stays reachable for the linter (avoids SC2317 +# without disable directives). + +# pipx seam mock: records argv into MOCK_PIPX_LOG (one line per call). `list` +# emits MOCK_PIPX_LIST (used by is_installed / version via `list --short`); +# every other subcommand returns MOCK_PIPX_RC. +_mock_pipx() { + MOCK_PIPX_LOG="${INIT_UBUNTU_TEST_SCRATCH}/pipx.log" + : > "${MOCK_PIPX_LOG}" + _bpytop_pipx() { + printf '%s\n' "$*" >> "${MOCK_PIPX_LOG}" + case "${1:-}" in + list) printf '%s\n' "${MOCK_PIPX_LIST:-}" ;; + *) return "${MOCK_PIPX_RC:-0}" ;; + esac + } +} + +# Fake bpytop binary answering --version. +_fake_bpytop_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "bpytop version: 1.0.68\\n"\n' \ + > "${INIT_UBUNTU_TEST_SCRATCH}/bin/bpytop" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/bpytop" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" +} + +# Put a fake executable on PATH so `command -v ` succeeds. +_fake_bin() { + local _name="${1:?bin name required}" + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${_name}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${_name}" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "bpytop module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/bpytop.module.sh" + assert_success +} + +@test "bpytop module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "bpytop module defines all 10 lifecycle functions" { + _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 "bpytop module declares NAME=bpytop" { + _load_module + [[ "${NAME}" == "bpytop" ]] +} + +@test "bpytop module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "bpytop module TAGS contains monitoring" { + _load_module + [[ " ${TAGS[*]} " == *" monitoring "* ]] +} + +@test "bpytop module DEPENDS_ON contains pipx" { + _load_module + [[ " ${DEPENDS_ON[*]} " == *" pipx "* ]] +} + +@test "bpytop 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 "bpytop module_get_description returns language-specific text" { + _load_module + [[ "$(module_get_description en)" == *"monitor"* ]] + [[ -n "$(module_get_description zh-TW)" ]] + # Unknown language falls back to en. + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "bpytop module VERSION_PROVIDED=pipx-managed" { + _load_module + [[ "${VERSION_PROVIDED}" == "pipx-managed" ]] +} + +@test "bpytop module INSTALL_TARGET_DEFAULT=user-home (pipx is user-level)" { + _load_module + [[ "${INSTALL_TARGET_DEFAULT}" == "user-home" ]] +} + +@test "bpytop module SUPPORTS_USER_HOME=true" { + _load_module + [[ "${SUPPORTS_USER_HOME}" == "true" ]] +} + +@test "bpytop module RISK_LEVEL=low" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] +} + +@test "bpytop module SUPPORTED_UBUNTU covers 22.04 / 24.04 / 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "bpytop HOMEPAGE points at the aristocratos/bpytop project" { + _load_module + [[ "${HOMEPAGE}" == *"aristocratos/bpytop"* ]] +} + +@test "bpytop POST_INSTALL_MESSAGE mentions pipx / PATH" { + _load_module + [[ "$(module_get_post_install_message en)" == *"pipx"* ]] + [[ -n "$(module_get_post_install_message zh-TW)" ]] +} + +@test "TEST_VERIFY_CMD exercises the bpytop binary" { + _load_module + [[ "${TEST_VERIFY_CMD}" == *"bpytop"* ]] +} + +# ── is_installed: pipx ownership ───────────────────────────────────────────── + +@test "is_installed returns zero when pipx list shows bpytop" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + run is_installed + assert_success +} + +@test "is_installed returns nonzero when pipx list is empty" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run is_installed + assert_failure +} + +@test "is_installed ignores an unrelated pipx package" { + _load_module + MOCK_PIPX_LIST="some-other-pkg 2.0.0" + _mock_pipx + run is_installed + assert_failure +} + +# ── Lifecycle dry-run (AC-12 pattern) ──────────────────────────────────────── + +@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" +} + +# ── 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/bpytop" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "dry-run install never shells out to pipx" { + _load_module + _mock_pipx + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -s "${MOCK_PIPX_LOG}" ]] +} + +@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}" ]] +} + +# ── install / upgrade / remove: pipx paths (custom archetype core) ─────────── + +@test "install runs pipx install bpytop" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + install + grep -q '^install bpytop$' "${MOCK_PIPX_LOG}" +} + +@test "install skips when already pipx-managed (idempotent fast path)" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + run install + assert_success + assert_output --partial "already installed" + run grep -q '^install bpytop$' "${MOCK_PIPX_LOG}" + assert_failure +} + +@test "failed pipx install propagates the failure" { + _load_module + MOCK_PIPX_LIST="" + MOCK_PIPX_RC=1 + _mock_pipx + run install + assert_failure +} + +@test "upgrade on a missing install falls through to install" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run upgrade + assert_success + grep -q '^install bpytop$' "${MOCK_PIPX_LOG}" +} + +@test "upgrade uses pipx upgrade when already pipx-managed" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + run upgrade + assert_success + grep -q '^upgrade bpytop$' "${MOCK_PIPX_LOG}" +} + +@test "failed pipx upgrade propagates the failure" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + MOCK_PIPX_RC=1 + _mock_pipx + run upgrade + assert_failure +} + +@test "remove uses pipx uninstall" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + run remove + assert_success + grep -q '^uninstall bpytop$' "${MOCK_PIPX_LOG}" +} + +@test "remove on a not-installed bpytop is a no-op" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run remove + assert_success + assert_output --partial "nothing to do" +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the pipx-reported version" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + # is_installed must be false so install actually runs; flip via a + # one-shot override that reports "not installed", then let the version + # helper read the mocked pipx list. + is_installed() { return 1; } + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/bpytop" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/bpytop")" == "1.0.68" ]] +} + +@test "install sidecar falls back to pipx-managed when pipx list is empty" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/bpytop")" == "pipx-managed" ]] +} + +@test "install never touches state.json (ADR-0001 / AC-23 pattern)" { + _load_module + printf '{"schema_version":"0.1.0","installed":{}}\n' \ + > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + MOCK_PIPX_LIST="" + _mock_pipx + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed install leaves no sidecar (ADR-0015)" { + _load_module + MOCK_PIPX_LIST="" + MOCK_PIPX_RC=1 + _mock_pipx + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/bpytop" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.68\n' > "${INIT_UBUNTU_STATE_DIR}/versions/bpytop" + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/bpytop" ]] + grep -q '^uninstall bpytop$' "${MOCK_PIPX_LOG}" +} + +@test "purge uninstalls via pipx and deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.68\n' > "${INIT_UBUNTU_STATE_DIR}/versions/bpytop" + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/bpytop" ]] + grep -q '^uninstall bpytop$' "${MOCK_PIPX_LOG}" +} + +# ── Idempotency (AC-5 pattern) ─────────────────────────────────────────────── + +@test "install twice exits 0 both times" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run install + assert_success + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + run remove + assert_success + MOCK_PIPX_LIST="" + _mock_pipx + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run doctor + assert_failure +} + +@test "doctor passes when bpytop answers --version" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + _fake_bpytop_bin + run doctor + assert_success +} + +@test "doctor fails when the bpytop binary is not on PATH" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "is_outdated returns nonzero (pipx upgrade is idempotent; no cheap probe)" { + _load_module + run is_outdated + assert_failure +} + +# ── is_recommended / detect ────────────────────────────────────────────────── + +@test "is_recommended is zero when not installed" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run is_recommended + assert_success +} + +@test "is_recommended is nonzero when already installed" { + _load_module + MOCK_PIPX_LIST="bpytop 1.0.68" + _mock_pipx + run is_recommended + assert_failure +} + +@test "detect succeeds when apt-get is available" { + _load_module + _fake_bin apt-get + run detect + assert_success +} + +@test "detect fails without apt-get 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 "bpytop" +} + +@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: bpytop" + assert_output --partial "category: optional" + assert_output --partial "monitoring" +} + +@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: upgrade --dry-run exits 0" { + run _standalone_module upgrade --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: remove --dry-run exits 0" { + run _standalone_module remove --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: purge --dry-run exits 0" { + run _standalone_module purge --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: verify --dry-run exits 0" { + run _standalone_module verify --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)" { + # Fake pipx on PATH: the test container has no pipx, and a bare + # command-not-found (127) would trip bats' BW01 warning. + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-recommended is implemented (exit != 2)" { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run _standalone_module is-recommended + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-outdated is implemented (exit != 2)" { + run _standalone_module is-outdated + [[ "${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/gpustat_spec.bats b/test/unit/module/gpustat_spec.bats new file mode 100644 index 0000000..f682ad9 --- /dev/null +++ b/test/unit/module/gpustat_spec.bats @@ -0,0 +1,634 @@ +#!/usr/bin/env bats +# test/unit/module/gpustat_spec.bats — module/gpustat.module.sh +# +# Per Q29: smoke / metadata / lifecycle dry-run / no-side-fx / idempotency / +# standalone CLI / module-specific (custom archetype: user-level pipx install, +# Sidecar lifecycle ADR-0001, real doctor probe). Small-tools modularization +# program (pipx tier). Mirrors the tmuxp/claude-monitor custom-archetype spec. + +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/gpustat.module.sh + source "${MODULE_DIR}/gpustat.module.sh" +} + +# _standalone_module runs the module as a self-contained CLI (the same entry +# users hit when they type `bash module/gpustat.module.sh ...`). +_standalone_module() { + bash "${MODULE_DIR}/gpustat.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── +# Each shadowed function is defined exactly once and parameterized via MOCK_* +# variables, so every definition stays reachable for the linter (avoids SC2317 +# without disable directives). + +# pipx seam mock: records argv into MOCK_PIPX_LOG (one line per call). `list` +# emits MOCK_PIPX_LIST (used by is_installed / version via `list --short`); +# every other subcommand returns MOCK_PIPX_RC. +_mock_pipx() { + MOCK_PIPX_LOG="${INIT_UBUNTU_TEST_SCRATCH}/pipx.log" + : > "${MOCK_PIPX_LOG}" + _gpustat_pipx() { + printf '%s\n' "$*" >> "${MOCK_PIPX_LOG}" + case "${1:-}" in + list) printf '%s\n' "${MOCK_PIPX_LIST:-}" ;; + *) return "${MOCK_PIPX_RC:-0}" ;; + esac + } +} + +# Fake gpustat binary answering --version. +_fake_gpustat_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "gpustat 1.1.1\\n"\n' \ + > "${INIT_UBUNTU_TEST_SCRATCH}/bin/gpustat" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/gpustat" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" +} + +# Put a fake executable on PATH so `command -v ` succeeds. +_fake_bin() { + local _name="${1:?bin name required}" + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${_name}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${_name}" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "gpustat module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/gpustat.module.sh" + assert_success +} + +@test "gpustat module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "gpustat module defines all 10 lifecycle functions" { + _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 "gpustat module declares NAME=gpustat" { + _load_module + [[ "${NAME}" == "gpustat" ]] +} + +@test "gpustat module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "gpustat module TAGS contains gpu" { + _load_module + [[ " ${TAGS[*]} " == *" gpu "* ]] +} + +@test "gpustat module DEPENDS_ON contains pipx" { + _load_module + [[ " ${DEPENDS_ON[*]} " == *" pipx "* ]] +} + +@test "gpustat 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 "gpustat module_get_description returns language-specific text" { + _load_module + [[ "$(module_get_description en)" == *"GPU"* ]] + [[ -n "$(module_get_description zh-TW)" ]] + # Unknown language falls back to en. + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "gpustat module VERSION_PROVIDED=pipx-managed" { + _load_module + [[ "${VERSION_PROVIDED}" == "pipx-managed" ]] +} + +@test "gpustat module INSTALL_TARGET_DEFAULT=user-home (pipx is user-level)" { + _load_module + [[ "${INSTALL_TARGET_DEFAULT}" == "user-home" ]] +} + +@test "gpustat module SUPPORTS_USER_HOME=true" { + _load_module + [[ "${SUPPORTS_USER_HOME}" == "true" ]] +} + +@test "gpustat module RISK_LEVEL=low" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] +} + +@test "gpustat module SUPPORTED_UBUNTU covers 22.04 / 24.04 / 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "gpustat HOMEPAGE points at the wookayin/gpustat project" { + _load_module + [[ "${HOMEPAGE}" == *"wookayin/gpustat"* ]] +} + +@test "gpustat POST_INSTALL_MESSAGE mentions pipx / PATH" { + _load_module + [[ "$(module_get_post_install_message en)" == *"pipx"* ]] + [[ -n "$(module_get_post_install_message zh-TW)" ]] +} + +@test "TEST_VERIFY_CMD exercises the gpustat binary" { + _load_module + [[ "${TEST_VERIFY_CMD}" == *"gpustat"* ]] +} + +# ── is_installed: pipx ownership ───────────────────────────────────────────── + +@test "is_installed returns zero when pipx list shows gpustat" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + run is_installed + assert_success +} + +@test "is_installed returns nonzero when pipx list is empty" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run is_installed + assert_failure +} + +@test "is_installed ignores an unrelated pipx package" { + _load_module + MOCK_PIPX_LIST="some-other-pkg 2.0.0" + _mock_pipx + run is_installed + assert_failure +} + +# ── Lifecycle dry-run (AC-12 pattern) ──────────────────────────────────────── + +@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" +} + +# ── 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/gpustat" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "dry-run install never shells out to pipx" { + _load_module + _mock_pipx + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -s "${MOCK_PIPX_LOG}" ]] +} + +@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}" ]] +} + +# ── install / upgrade / remove: pipx paths (custom archetype core) ─────────── + +@test "install runs pipx install gpustat" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + install + grep -q '^install gpustat$' "${MOCK_PIPX_LOG}" +} + +@test "install skips when already pipx-managed (idempotent fast path)" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + run install + assert_success + assert_output --partial "already installed" + run grep -q '^install gpustat$' "${MOCK_PIPX_LOG}" + assert_failure +} + +@test "failed pipx install propagates the failure" { + _load_module + MOCK_PIPX_LIST="" + MOCK_PIPX_RC=1 + _mock_pipx + run install + assert_failure +} + +@test "upgrade on a missing install falls through to install" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run upgrade + assert_success + grep -q '^install gpustat$' "${MOCK_PIPX_LOG}" +} + +@test "upgrade uses pipx upgrade when already pipx-managed" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + run upgrade + assert_success + grep -q '^upgrade gpustat$' "${MOCK_PIPX_LOG}" +} + +@test "failed pipx upgrade propagates the failure" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + MOCK_PIPX_RC=1 + _mock_pipx + run upgrade + assert_failure +} + +@test "remove uses pipx uninstall" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + run remove + assert_success + grep -q '^uninstall gpustat$' "${MOCK_PIPX_LOG}" +} + +@test "remove on a not-installed gpustat is a no-op" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run remove + assert_success + assert_output --partial "nothing to do" +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the pipx-reported version" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + is_installed() { return 1; } + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/gpustat" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/gpustat")" == "1.1.1" ]] +} + +@test "install sidecar falls back to pipx-managed when pipx list is empty" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/gpustat")" == "pipx-managed" ]] +} + +@test "install never touches state.json (ADR-0001 / AC-23 pattern)" { + _load_module + printf '{"schema_version":"0.1.0","installed":{}}\n' \ + > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + MOCK_PIPX_LIST="" + _mock_pipx + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed install leaves no sidecar (ADR-0015)" { + _load_module + MOCK_PIPX_LIST="" + MOCK_PIPX_RC=1 + _mock_pipx + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/gpustat" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.1.1\n' > "${INIT_UBUNTU_STATE_DIR}/versions/gpustat" + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/gpustat" ]] + grep -q '^uninstall gpustat$' "${MOCK_PIPX_LOG}" +} + +@test "purge uninstalls via pipx and deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.1.1\n' > "${INIT_UBUNTU_STATE_DIR}/versions/gpustat" + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/gpustat" ]] + grep -q '^uninstall gpustat$' "${MOCK_PIPX_LOG}" +} + +# ── Idempotency (AC-5 pattern) ─────────────────────────────────────────────── + +@test "install twice exits 0 both times" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run install + assert_success + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + run remove + assert_success + MOCK_PIPX_LIST="" + _mock_pipx + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run doctor + assert_failure +} + +@test "doctor passes when gpustat answers --version" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + _fake_gpustat_bin + run doctor + assert_success +} + +@test "doctor fails when the gpustat binary is not on PATH" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "is_outdated returns nonzero (pipx upgrade is idempotent; no cheap probe)" { + _load_module + run is_outdated + assert_failure +} + +# ── is_recommended / detect ────────────────────────────────────────────────── + +@test "is_recommended is zero when not installed" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run is_recommended + assert_success +} + +@test "is_recommended is nonzero when already installed" { + _load_module + MOCK_PIPX_LIST="gpustat 1.1.1" + _mock_pipx + run is_recommended + assert_failure +} + +@test "detect succeeds when apt-get is available" { + _load_module + _fake_bin apt-get + run detect + assert_success +} + +@test "detect fails without apt-get 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 "gpustat" +} + +@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: gpustat" + assert_output --partial "category: optional" + assert_output --partial "gpu" +} + +@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: upgrade --dry-run exits 0" { + run _standalone_module upgrade --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: remove --dry-run exits 0" { + run _standalone_module remove --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: purge --dry-run exits 0" { + run _standalone_module purge --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: verify --dry-run exits 0" { + run _standalone_module verify --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)" { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-recommended is implemented (exit != 2)" { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run _standalone_module is-recommended + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-outdated is implemented (exit != 2)" { + run _standalone_module is-outdated + [[ "${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/thefuck_spec.bats b/test/unit/module/thefuck_spec.bats new file mode 100644 index 0000000..d1dc95e --- /dev/null +++ b/test/unit/module/thefuck_spec.bats @@ -0,0 +1,628 @@ +#!/usr/bin/env bats +# test/unit/module/thefuck_spec.bats — module/thefuck.module.sh +# +# Per Q29: smoke / metadata / lifecycle dry-run / no-side-fx / idempotency / +# standalone CLI / module-specific (custom archetype: user-level pipx install, +# Sidecar lifecycle ADR-0001, real doctor probe). Small-tools modularization +# program (pipx tier). Mirrors the tmuxp/claude-monitor custom-archetype spec. +# thefuck's doctor()/verify() use `command -v thefuck` (presence on PATH) because +# `thefuck --version` needs the shell alias/context to behave. + +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/thefuck.module.sh + source "${MODULE_DIR}/thefuck.module.sh" +} + +# _standalone_module runs the module as a self-contained CLI (the same entry +# users hit when they type `bash module/thefuck.module.sh ...`). +_standalone_module() { + bash "${MODULE_DIR}/thefuck.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── +# Each shadowed function is defined exactly once and parameterized via MOCK_* +# variables, so every definition stays reachable for the linter (avoids SC2317 +# without disable directives). + +# pipx seam mock: records argv into MOCK_PIPX_LOG (one line per call). `list` +# emits MOCK_PIPX_LIST (used by is_installed / version via `list --short`); +# every other subcommand returns MOCK_PIPX_RC. +_mock_pipx() { + MOCK_PIPX_LOG="${INIT_UBUNTU_TEST_SCRATCH}/pipx.log" + : > "${MOCK_PIPX_LOG}" + _thefuck_pipx() { + printf '%s\n' "$*" >> "${MOCK_PIPX_LOG}" + case "${1:-}" in + list) printf '%s\n' "${MOCK_PIPX_LIST:-}" ;; + *) return "${MOCK_PIPX_RC:-0}" ;; + esac + } +} + +# Put a fake executable on PATH so `command -v ` succeeds. +_fake_bin() { + local _name="${1:?bin name required}" + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${_name}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${_name}" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "thefuck module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/thefuck.module.sh" + assert_success +} + +@test "thefuck module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "thefuck module defines all 10 lifecycle functions" { + _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 "thefuck module declares NAME=thefuck" { + _load_module + [[ "${NAME}" == "thefuck" ]] +} + +@test "thefuck module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "thefuck module TAGS contains shell" { + _load_module + [[ " ${TAGS[*]} " == *" shell "* ]] +} + +@test "thefuck module DEPENDS_ON contains pipx" { + _load_module + [[ " ${DEPENDS_ON[*]} " == *" pipx "* ]] +} + +@test "thefuck 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 "thefuck module_get_description returns language-specific text" { + _load_module + [[ "$(module_get_description en)" == *"command"* ]] + [[ -n "$(module_get_description zh-TW)" ]] + # Unknown language falls back to en. + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "thefuck module VERSION_PROVIDED=pipx-managed" { + _load_module + [[ "${VERSION_PROVIDED}" == "pipx-managed" ]] +} + +@test "thefuck module INSTALL_TARGET_DEFAULT=user-home (pipx is user-level)" { + _load_module + [[ "${INSTALL_TARGET_DEFAULT}" == "user-home" ]] +} + +@test "thefuck module SUPPORTS_USER_HOME=true" { + _load_module + [[ "${SUPPORTS_USER_HOME}" == "true" ]] +} + +@test "thefuck module RISK_LEVEL=low" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] +} + +@test "thefuck module SUPPORTED_UBUNTU covers 22.04 / 24.04 / 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "thefuck HOMEPAGE points at the nvbn/thefuck project" { + _load_module + [[ "${HOMEPAGE}" == *"nvbn/thefuck"* ]] +} + +@test "thefuck POST_INSTALL_MESSAGE explains the required shell alias" { + _load_module + [[ "$(module_get_post_install_message en)" == *"alias"* ]] + [[ "$(module_get_post_install_message en)" == *"pipx"* ]] + [[ -n "$(module_get_post_install_message zh-TW)" ]] +} + +@test "TEST_VERIFY_CMD probes thefuck presence (command -v, not --version)" { + _load_module + [[ "${TEST_VERIFY_CMD}" == "command -v thefuck" ]] +} + +# ── is_installed: pipx ownership ───────────────────────────────────────────── + +@test "is_installed returns zero when pipx list shows thefuck" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + run is_installed + assert_success +} + +@test "is_installed returns nonzero when pipx list is empty" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run is_installed + assert_failure +} + +@test "is_installed ignores an unrelated pipx package" { + _load_module + MOCK_PIPX_LIST="some-other-pkg 2.0.0" + _mock_pipx + run is_installed + assert_failure +} + +# ── Lifecycle dry-run (AC-12 pattern) ──────────────────────────────────────── + +@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" +} + +# ── 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/thefuck" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "dry-run install never shells out to pipx" { + _load_module + _mock_pipx + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -s "${MOCK_PIPX_LOG}" ]] +} + +@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}" ]] +} + +# ── install / upgrade / remove: pipx paths (custom archetype core) ─────────── + +@test "install runs pipx install thefuck" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + install + grep -q '^install thefuck$' "${MOCK_PIPX_LOG}" +} + +@test "install skips when already pipx-managed (idempotent fast path)" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + run install + assert_success + assert_output --partial "already installed" + run grep -q '^install thefuck$' "${MOCK_PIPX_LOG}" + assert_failure +} + +@test "failed pipx install propagates the failure" { + _load_module + MOCK_PIPX_LIST="" + MOCK_PIPX_RC=1 + _mock_pipx + run install + assert_failure +} + +@test "upgrade on a missing install falls through to install" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run upgrade + assert_success + grep -q '^install thefuck$' "${MOCK_PIPX_LOG}" +} + +@test "upgrade uses pipx upgrade when already pipx-managed" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + run upgrade + assert_success + grep -q '^upgrade thefuck$' "${MOCK_PIPX_LOG}" +} + +@test "failed pipx upgrade propagates the failure" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + MOCK_PIPX_RC=1 + _mock_pipx + run upgrade + assert_failure +} + +@test "remove uses pipx uninstall" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + run remove + assert_success + grep -q '^uninstall thefuck$' "${MOCK_PIPX_LOG}" +} + +@test "remove on a not-installed thefuck is a no-op" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run remove + assert_success + assert_output --partial "nothing to do" +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the pipx-reported version" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + is_installed() { return 1; } + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/thefuck" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/thefuck")" == "3.32" ]] +} + +@test "install sidecar falls back to pipx-managed when pipx list is empty" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/thefuck")" == "pipx-managed" ]] +} + +@test "install never touches state.json (ADR-0001 / AC-23 pattern)" { + _load_module + printf '{"schema_version":"0.1.0","installed":{}}\n' \ + > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + MOCK_PIPX_LIST="" + _mock_pipx + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed install leaves no sidecar (ADR-0015)" { + _load_module + MOCK_PIPX_LIST="" + MOCK_PIPX_RC=1 + _mock_pipx + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/thefuck" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '3.32\n' > "${INIT_UBUNTU_STATE_DIR}/versions/thefuck" + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/thefuck" ]] + grep -q '^uninstall thefuck$' "${MOCK_PIPX_LOG}" +} + +@test "purge uninstalls via pipx and deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '3.32\n' > "${INIT_UBUNTU_STATE_DIR}/versions/thefuck" + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/thefuck" ]] + grep -q '^uninstall thefuck$' "${MOCK_PIPX_LOG}" +} + +# ── Idempotency (AC-5 pattern) ─────────────────────────────────────────────── + +@test "install twice exits 0 both times" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run install + assert_success + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + run remove + assert_success + MOCK_PIPX_LIST="" + _mock_pipx + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run verify + assert_failure +} + +@test "verify passes when installed and thefuck is on PATH (command -v probe)" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + _fake_bin thefuck + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run doctor + assert_failure +} + +@test "doctor passes when thefuck is on PATH" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + _fake_bin thefuck + run doctor + assert_success +} + +@test "doctor fails when the thefuck binary is not on PATH" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "is_outdated returns nonzero (pipx upgrade is idempotent; no cheap probe)" { + _load_module + run is_outdated + assert_failure +} + +# ── is_recommended / detect ────────────────────────────────────────────────── + +@test "is_recommended is zero when not installed" { + _load_module + MOCK_PIPX_LIST="" + _mock_pipx + run is_recommended + assert_success +} + +@test "is_recommended is nonzero when already installed" { + _load_module + MOCK_PIPX_LIST="thefuck 3.32" + _mock_pipx + run is_recommended + assert_failure +} + +@test "detect succeeds when apt-get is available" { + _load_module + _fake_bin apt-get + run detect + assert_success +} + +@test "detect fails without apt-get 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 "thefuck" +} + +@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: thefuck" + assert_output --partial "category: optional" + assert_output --partial "shell" +} + +@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: upgrade --dry-run exits 0" { + run _standalone_module upgrade --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: remove --dry-run exits 0" { + run _standalone_module remove --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: purge --dry-run exits 0" { + run _standalone_module purge --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: verify --dry-run exits 0" { + run _standalone_module verify --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)" { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-recommended is implemented (exit != 2)" { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/pipx" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run _standalone_module is-recommended + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-outdated is implemented (exit != 2)" { + run _standalone_module is-outdated + [[ "${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" +}