diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dc4827c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.ruff_cache +.pytest_cache +__pycache__ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e738907 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: + - "*" + + - package-ecosystem: docker + directories: + - "/tests/e2e" + schedule: + interval: weekly + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b0e3cbe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test-and-lint: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install package and tooling + shell: bash + run: | + set -euo pipefail + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install ruff + + - name: Ruff (lint) + shell: bash + run: | + set -euo pipefail + ruff check . + + - name: Ruff (format check) + shell: bash + run: | + set -euo pipefail + ruff format --check . + + - name: Unit tests + shell: bash + run: | + set -euo pipefail + make test-unit + + - name: Integration tests + shell: bash + run: | + set -euo pipefail + make test-integration + + docs: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Markdown and mermaid lint + shell: bash + run: | + set -euo pipefail + make lint-docs + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: End-to-end tests in a container + shell: bash + run: | + set -euo pipefail + make test-e2e diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..de10c33 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: CodeQL + +on: + push: + pull_request: + schedule: + - cron: "27 4 * * 1" + +jobs: + analyze: + name: Check security + runs-on: ubuntu-latest + timeout-minutes: 20 + + permissions: + security-events: write + packages: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: python + build-mode: none + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06c26bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..4b282be --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,13 @@ +{ + "config": { + "default": true, + "MD013": false, + "MD033": false, + "MD041": false + }, + "ignores": [ + "node_modules", + ".agents", + ".claude" + ] +} diff --git a/MIRRORS b/MIRRORS new file mode 100644 index 0000000..dca8437 --- /dev/null +++ b/MIRRORS @@ -0,0 +1,4 @@ +git@github.com:kevinveenbirkenbach/cli-gnome-extension-manager.git +ssh://git@git.veen.world:2201/kevinveenbirkenbach/cli-gnome-extension-manager.git +ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/cli-gnome-extension-manager.git +https://pypi.org/project/cli-gnome-extension-manager/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..86ae7d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +SHELL := /usr/bin/env bash + +.PHONY: help lint lint-docs format test test-unit test-integration test-e2e clean + +PY ?= python3 +E2E_IMAGE ?= cli-gnome-extension-manager-e2e +MD_LINT_IMAGE ?= davidanson/markdownlint-cli2:latest +MERMAID_IMAGE ?= minlag/mermaid-cli:latest + +export PYTHONPATH := $(CURDIR)/src + +help: + @echo "Targets:" + @echo " make lint - ruff check + ruff format --check" + @echo " make lint-docs - markdownlint + mermaid diagram validation" + @echo " make format - apply ruff format" + @echo " make test - unit + integration tests" + @echo " make test-unit - unit tests only" + @echo " make test-integration - integration tests only" + @echo " make test-e2e - install and exercise the CLI in a container" + @echo " make clean - remove caches" + +lint: + ruff check . + ruff format --check . + +# The repository is mounted read-only: mermaid-cli writes its SVGs next to the +# output file, which must never land in the working tree. +lint-docs: + docker run --rm -v "$(CURDIR)":/workdir $(MD_LINT_IMAGE) "**/*.md" + docker run --rm -v "$(CURDIR)":/data:ro -w /tmp $(MERMAID_IMAGE) -i /data/README.md -o /tmp/out.md + +format: + ruff format . + +test: test-unit test-integration + +test-unit: + $(PY) -m unittest discover -s tests/unit -t . -p "test_*.py" + +test-integration: + $(PY) -m unittest discover -s tests/integration -t . -p "test_*.py" + +test-e2e: + docker build -f tests/e2e/Dockerfile -t $(E2E_IMAGE) . + docker run --rm $(E2E_IMAGE) + +clean: + rm -rf .pytest_cache .ruff_cache .mypy_cache + find . -type d -name "__pycache__" -print0 | xargs -0 -r rm -rf diff --git a/README.md b/README.md index 80ec216..7b114a3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,35 @@ # CLI GNOME Extension Manager πŸš€ + [![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate) +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](./LICENSE) [![Python Version](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org) [![GitHub stars](https://img.shields.io/github/stars/kevinveenbirkenbach/cli-gnome-extension-manager.svg?style=social)](https://github.com/kevinveenbirkenbach/cli-gnome-extension-manager/stargazers) -[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](./LICENSE) [![Bash Version](https://img.shields.io/badge/Bash-4.x%2B-green.svg)](https://www.gnu.org/software/bash/) [![GitHub stars](https://img.shields.io/github/stars/kevinveenbirkenbach/cli-gnome-extension-manager.svg?style=social)](https://github.com/kevinveenbirkenbach/cli-gnome-extension-manager/stargazers) +Manage your GNOME extensions easily from the command line with **CLI GNOME Extension Manager**. This Python CLI lets you install, update, enable, and disable GNOME extensions directly from your terminal. -Manage your GNOME extensions easily from the command line with **CLI GNOME Extension Manager**. This Bash script lets you install, update, enable, and disable GNOME extensions directly from your terminal. +## How it works 🧭 + +```mermaid +flowchart TD + A["cli-gnome-extension-manager ACTION NAME [REPO]"] --> B{"action"} + B -- disable --> C["gnome-extensions disable"] + B -- enable --> D{"REPO argument given?"} + D -- no --> H["gnome-extensions enable"] + D -- yes --> E{"extension folder already there?"} + E -- no --> F["git clone into the extensions dir"] + E -- yes --> G{"is it a git checkout?"} + G -- yes --> G1["git pull"] + G -- no --> G2["leave it untouched"] + F --> I{"ships a Makefile?"} + G1 --> I + G2 --> I + I -- yes --> J["move to a scratch dir, make install, drop the scratch dir"] + I -- no --> H + J --> H + C --> Z["Installation complete"] + H --> Z +``` + +Any failing step - `git`, `make` or `gnome-extensions` - stops the run and its exit code is passed through. ## Features ✨ @@ -15,55 +40,84 @@ Manage your GNOME extensions easily from the command line with **CLI GNOME Exten ## Requirements πŸ”§ +- **Python 3.10+** 🐍 - **GNOME Shell** (version 3.36+) -- **Bash** (version 4.x+) -- **Git** (for cloning repositories) -- **Make** (optional, for compiling extensions) -- **gnome-extensions** CLI tool +- **`gnome-extensions`** on `PATH` β€” always required +- **`git`** on `PATH` β€” only when a repository argument is given +- **`make`** on `PATH` β€” only when the extension ships a `Makefile` + +If a required command is missing, the tool exits with code `127` and a one‑line error instead of a traceback. ## Installation πŸ“¦ -You can install **CLI GNOME Extension Manager** using [Kevin's Package Manager](https://github.com/kevinveenbirkenbach/pkgmgr): - ```bash -pkgmgr install goexma +pip install cli-gnome-extension-manager ``` -Alternatively, clone this repository: +pip is the single supported installation path. -```bash -git clone https://github.com/kevinveenbirkenbach/cli-gnome-extension-manager.git -cd cli-gnome-extension-manager -``` +The package installs **two** identical commands: `cli-gnome-extension-manager` (primary) and `goexma` (kept for older documentation and scripts). ## Usage βš™οΈ To **install and enable** an extension, run: ```bash -goexma enable +cli-gnome-extension-manager enable +``` + +To **enable** an already installed extension, drop the repository argument: + +```bash +cli-gnome-extension-manager enable ``` To **disable** an extension, run: ```bash -goexma disable +cli-gnome-extension-manager disable ``` -The script will: +The tool will: + - Clone the repository if the extension isn't installed. - Pull updates if the extension is already a Git repository. -- Compile the extension if a Makefile is present. +- Compile the extension if a Makefile is present β€” the checkout is moved into a scratch directory, `make install` runs there, and the scratch directory is removed afterwards. - Enable or disable the extension using `gnome-extensions`. +Extensions live in `~/.local/share/gnome-shell/extensions` by default; `--extensions-dir` points the tool elsewhere. + +### Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Finished. | +| `2` | Invalid command line arguments, including an action other than `enable` or `disable`. | +| `127` | A required command is not installed. | +| other | The exit code of the failing `git`, `make` or `gnome-extensions` call. | + +## Development πŸ§ͺ + +```bash +make lint # ruff check + ruff format --check +make format # apply ruff format +make test # unit + integration tests +make test-unit +make test-integration +make test-e2e # install the package in a container and exercise the CLI +``` + +Tests run against the working tree β€” the `Makefile` puts `src/` on `PYTHONPATH`, so no install is needed. The integration tests build a real throwaway git repository and stub `gnome-extensions` on `PATH`, so no extension of yours is ever touched. + ## License πŸ“œ This project is licensed under the GNU Affero General Public License v3.0. See the [LICENSE](./LICENSE) file for details. ## Author πŸ‘¨β€πŸ’» -**Kevin Veen-Birkenbach** -- πŸ“§ [kevin@veen.world](mailto:kevin@veen.world) +Developed by **Kevin Veen-Birkenbach** + +- πŸ“§ [kevin@veen.world](mailto:kevin@veen.world) - 🌐 [https://www.veen.world/](https://www.veen.world/) --- diff --git a/main.sh b/main.sh deleted file mode 100755 index 9ab17b6..0000000 --- a/main.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash - -# @param $1 enable|disable -# @param $2 extension name -# @param $3 repository path [optional] - -action_type="$1" -extension_name="$2" -extension_repository_path="$3" -extension_folder="$HOME/.local/share/gnome-shell/extensions/$extension_name/" - -echo "=== GNOME Extension Installer ===" - -echo "Installing GNOME extension \"$extension_name\"..." - -if [ "$action_type" == "enable" ]; then - if [ ! -z "$extension_repository_path" ]; then - echo "Generating extension based on $3" - if [ -d "$extension_folder" ]; then - if [ -d "$extension_folder.git" ]; then - echo "Pulling changes from git..." - (cd "$extension_folder" && git pull) || exit 1 - else - echo "No git repository. Extension will not be updated." - fi - else - echo "Cloning repository..." - git clone "$extension_repository_path" "$extension_folder" || exit 1 - fi - - if [ -f "$extension_folder/Makefile" ]; then - tmp_extension_folder="/tmp/$extension_name" - mv "$extension_folder" "$tmp_extension_folder" - echo "Compiling extension..." - (cd "$tmp_extension_folder" && make install) || exit 1 "Compilation failed." - echo "Cleaning up temporary extension folder..." - rm -fr "$tmp_extension_folder" || exit 1 - else - echo "No Makefile found. Skipping compilation..." - fi - fi - - echo "Enabling GNOME extension \"$extension_name\"..." - gnome-extensions enable "$extension_name" || exit 1 -fi - -if [ "$action_type" == "disable" ]; then - echo "Disabling GNOME extension \"$extension_name\"..." - gnome-extensions disable "$extension_name" || exit 1 -fi - -echo "Installation complete." diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a10de63 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "cli-gnome-extension-manager" +version = "0.0.0" +description = "Install, enable and disable GNOME shell extensions from the shell." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "AGPL-3.0-or-later" } +authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }] +keywords = ["gnome", "extensions", "desktop", "cli"] +dependencies = [] + +[project.urls] +Homepage = "https://www.veen.world/" +Repository = "https://github.com/kevinveenbirkenbach/cli-gnome-extension-manager" + +[project.scripts] +cli-gnome-extension-manager = "gnome_extension_manager.cli:main" +goexma = "gnome_extension_manager.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/gnome_extension_manager"] +core-metadata-version = "2.4" + +[tool.hatch.build.targets.sdist] +core-metadata-version = "2.4" + +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] +ignore = ["E501"] diff --git a/src/gnome_extension_manager/__init__.py b/src/gnome_extension_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gnome_extension_manager/__main__.py b/src/gnome_extension_manager/__main__.py new file mode 100644 index 0000000..e20a88c --- /dev/null +++ b/src/gnome_extension_manager/__main__.py @@ -0,0 +1,6 @@ +import sys + +from gnome_extension_manager.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/gnome_extension_manager/cli.py b/src/gnome_extension_manager/cli.py new file mode 100644 index 0000000..8bbaa6a --- /dev/null +++ b/src/gnome_extension_manager/cli.py @@ -0,0 +1,98 @@ +import argparse +import sys +from pathlib import Path + +from gnome_extension_manager.core import ( + EXIT_MISSING_BINARY, + MissingBinaryError, + build_extension, + default_extensions_dir, + gnome_extensions, + sync_repository, +) + +ACTIONS = ("enable", "disable") + + +def build_parser(): + """Return the argument parser for the cli-gnome-extension-manager command.""" + parser = argparse.ArgumentParser( + description="Install, enable and disable GNOME shell extensions." + ) + parser.add_argument( + "action", + choices=ACTIONS, + help="Whether to enable or disable the extension.", + ) + parser.add_argument( + "extension_name", + help="UUID of the GNOME extension.", + ) + parser.add_argument( + "repository_path", + nargs="?", + default="", + help="Git repository the extension is installed from (optional).", + ) + parser.add_argument( + "--extensions-dir", + default=str(default_extensions_dir()), + help="Directory holding the installed extensions.", + ) + return parser + + +def main(argv=None): + """Install and toggle a GNOME shell extension. + + Args: + argv: Argument list, defaults to sys.argv[1:]. + + Returns: + Process exit code. + """ + try: + return _run(build_parser().parse_args(argv)) + except MissingBinaryError as error: + print(f"Error: {error}", file=sys.stderr) + return EXIT_MISSING_BINARY + + +def _run(args): + """Perform the requested action and return the exit code. + + Args: + args: Parsed command line arguments. + """ + folder = Path(args.extensions_dir) / args.extension_name + + print("=== GNOME Extension Installer ===") + + if args.action == "disable": + print(f'Disabling GNOME extension "{args.extension_name}"...') + returncode = gnome_extensions("disable", args.extension_name) + if returncode != 0: + return returncode + print("Installation complete.") + return 0 + + print(f'Installing GNOME extension "{args.extension_name}"...') + + if args.repository_path: + print(f"Generating extension based on {args.repository_path}") + + returncode = sync_repository(folder, args.repository_path) + if returncode != 0: + return returncode + + returncode = build_extension(folder) + if returncode != 0: + return returncode + + print(f'Enabling GNOME extension "{args.extension_name}"...') + returncode = gnome_extensions("enable", args.extension_name) + if returncode != 0: + return returncode + + print("Installation complete.") + return 0 diff --git a/src/gnome_extension_manager/core.py b/src/gnome_extension_manager/core.py new file mode 100644 index 0000000..1ad40b7 --- /dev/null +++ b/src/gnome_extension_manager/core.py @@ -0,0 +1,99 @@ +import shutil +import subprocess +import tempfile +from pathlib import Path + +REQUIRED_BINARIES = ("gnome-extensions",) +REPOSITORY_BINARIES = ("git", "make") +EXIT_MISSING_BINARY = 127 + + +class MissingBinaryError(RuntimeError): + """A required external command is not installed. + + Args: + binary: Name of the missing executable. + """ + + def __init__(self, binary): + super().__init__(f"required command '{binary}' is not installed") + self.binary = binary + + +def default_extensions_dir(): + """Return the per-user GNOME shell extensions directory.""" + return Path.home() / ".local" / "share" / "gnome-shell" / "extensions" + + +def run(cmd, cwd=None): + """Run a command and return its exit code. + + Args: + cmd: Command argument list. + cwd: Working directory for the command. + """ + try: + return subprocess.run(cmd, cwd=cwd, check=False).returncode + except FileNotFoundError as error: + raise MissingBinaryError(cmd[0]) from error + + +def sync_repository(folder, repository_path): + """Clone the extension repository, or pull it when already checked out. + + Args: + folder: Target folder of the extension. + repository_path: Git URL or path of the extension repository. + + Returns: + Exit code, non-zero when git failed. + """ + if not folder.exists(): + print("Cloning repository...") + return run(["git", "clone", repository_path, str(folder)]) + + if not (folder / ".git").is_dir(): + print("No git repository. Extension will not be updated.") + return 0 + + print("Pulling changes from git...") + return run(["git", "pull"], cwd=str(folder)) + + +def build_extension(folder): + """Build an extension shipping a Makefile, then drop the build folder. + + The checkout is moved into a scratch directory before 'make install' runs, + mirroring the original shell implementation, which installed from /tmp and + removed the checkout afterwards. + + Args: + folder: Folder holding the checked out extension. + + Returns: + Exit code, non-zero when the build failed. + """ + if not (folder / "Makefile").is_file(): + print("No Makefile found. Skipping compilation...") + return 0 + + with tempfile.TemporaryDirectory() as build_root: + build_dir = Path(build_root) / folder.name + shutil.move(str(folder), str(build_dir)) + + print("Compiling extension...") + returncode = run(["make", "install"], cwd=str(build_dir)) + if returncode != 0: + print("Compilation failed.") + + return returncode + + +def gnome_extensions(action, extension_name): + """Enable or disable an extension through the gnome-extensions tool. + + Args: + action: Either 'enable' or 'disable'. + extension_name: UUID of the GNOME extension. + """ + return run(["gnome-extensions", action, extension_name]) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/Dockerfile b/tests/e2e/Dockerfile new file mode 100644 index 0000000..e97f7ba --- /dev/null +++ b/tests/e2e/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git make \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . /src + +RUN pip install --no-cache-dir . + +CMD ["sh", "/src/tests/e2e/run.sh"] diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100644 index 0000000..8c08179 --- /dev/null +++ b/tests/e2e/run.sh @@ -0,0 +1,147 @@ +#!/bin/sh +# gnome-extensions is stubbed on purpose: pulling in GNOME Shell would dwarf +# the package under test. git and make are the real binaries. +set -eu + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +stub_gnome_extensions() { + mkdir -p /tmp/bin + printf '%s\n' "#!/bin/sh" 'echo "$@" >>/tmp/gnome.log' "$1" >/tmp/bin/gnome-extensions + chmod +x /tmp/bin/gnome-extensions + : >/tmp/gnome.log +} + +make_source_repo() { + mkdir -p "$1" + echo '{"uuid": "ext@example"}' >"$1/metadata.json" + if [ "$2" = "with-makefile" ]; then + printf 'install:\n\ttouch /tmp/built.marker\n' >"$1/Makefile" + fi + git -C "$1" init -q -b main + git -C "$1" add -A + git -C "$1" -c user.email=e2e@example.com -c user.name=E2E commit -qm initial +} + +MANAGER="$(command -v cli-gnome-extension-manager)" || fail "cli-gnome-extension-manager is not on PATH after install" +echo "ok: cli-gnome-extension-manager installed at ${MANAGER}" + +command -v goexma >/dev/null || fail "the goexma alias is not on PATH after install" +echo "ok: goexma alias installed" + +case "${MANAGER}" in + /usr/local/bin/*) ;; + *) fail "cli-gnome-extension-manager resolved to ${MANAGER}, outside the install prefix" ;; +esac +echo "ok: resolved from the installed package, not the source tree" + +command -v git >/dev/null || fail "git is missing from the image" +command -v make >/dev/null || fail "make is missing from the image" +echo "ok: real git and make present" + +cli-gnome-extension-manager --help | grep -q "GNOME shell extensions" || fail "--help does not describe the tool" +echo "ok: --help" + +set +e +output="$(cli-gnome-extension-manager restart ext@example 2>&1)" +status=$? +set -e +[ "${status}" -eq 2 ] || fail "an unknown action exited ${status}, expected 2" +echo "${output}" | grep -q "invalid choice" || fail "unknown action message wrong" +echo "ok: an unknown action exits 2" + +set +e +output="$(env PATH=/nonexistent-bin "${MANAGER}" disable ext@example 2>&1)" +status=$? +set -e +[ "${status}" -eq 127 ] || fail "missing gnome-extensions exited ${status}, expected 127" +echo "${output}" | grep -q "required command 'gnome-extensions' is not installed" || fail "missing gnome-extensions message wrong" +if echo "${output}" | grep -q "Traceback"; then + fail "missing gnome-extensions produced a traceback" +fi +echo "ok: missing gnome-extensions exits 127 with a clean message" + +stub_gnome_extensions "exit 0" +make_source_repo /tmp/source with-makefile + +env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \ + enable ext@example /tmp/source --extensions-dir /tmp/extensions >/tmp/first.log 2>&1 \ + || fail "the first enable run failed: $(cat /tmp/first.log)" + +[ -f /tmp/built.marker ] || fail "make install did not run" +grep -q "enable ext@example" /tmp/gnome.log || fail "gnome-extensions enable was not called" +grep -q "Installation complete." /tmp/first.log || fail "the completion banner is missing" +echo "ok: real git clone, real make install, extension enabled" + +stub_gnome_extensions "exit 0" +make_source_repo /tmp/source2 without-makefile + +env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \ + enable plain@example /tmp/source2 --extensions-dir /tmp/extensions >/tmp/plain.log 2>&1 \ + || fail "the plain enable run failed: $(cat /tmp/plain.log)" + +grep -q "No Makefile found" /tmp/plain.log || fail "the missing Makefile was not reported" +[ -f /tmp/extensions/plain@example/metadata.json ] || fail "the extension without a Makefile was not kept" +echo "ok: an extension without a Makefile stays installed" + +env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \ + enable plain@example /tmp/source2 --extensions-dir /tmp/extensions >/tmp/second.log 2>&1 \ + || fail "the second enable run failed: $(cat /tmp/second.log)" + +grep -q "Pulling changes from git..." /tmp/second.log || fail "the second run did not pull" +echo "ok: a second run pulls instead of cloning" + +stub_gnome_extensions "exit 0" +set +e +env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \ + enable broken@example /nonexistent/repo --extensions-dir /tmp/extensions >/tmp/clone.log 2>&1 +status=$? +set -e +[ "${status}" -ne 0 ] || fail "a failing git clone was reported as success" +[ ! -s /tmp/gnome.log ] || fail "gnome-extensions ran even though the clone failed" +[ ! -d /tmp/extensions/broken@example ] || fail "a failed clone left an extension folder behind" +echo "ok: a failing git clone stops before enabling and leaves nothing behind" + +stub_gnome_extensions "exit 0" +mkdir -p /tmp/source3 +echo '{"uuid": "bad@example"}' >/tmp/source3/metadata.json +printf 'install:\n\tfalse\n' >/tmp/source3/Makefile +git -C /tmp/source3 init -q -b main +git -C /tmp/source3 add -A +git -C /tmp/source3 -c user.email=e2e@example.com -c user.name=E2E commit -qm initial + +set +e +env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \ + enable bad@example /tmp/source3 --extensions-dir /tmp/extensions >/tmp/build.log 2>&1 +status=$? +set -e +[ "${status}" -ne 0 ] || fail "a failing make install was reported as success" +grep -q "Compilation failed." /tmp/build.log || fail "the build failure was not reported" +[ ! -s /tmp/gnome.log ] || fail "gnome-extensions ran even though the build failed" +echo "ok: a failing make install stops before enabling" + +stub_gnome_extensions "exit 0" +mkdir -p /tmp/extensions/manual@example +echo '{"uuid": "manual@example"}' >/tmp/extensions/manual@example/metadata.json + +env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \ + enable manual@example /tmp/source2 --extensions-dir /tmp/extensions >/tmp/manual.log 2>&1 \ + || fail "enabling a manually installed extension failed: $(cat /tmp/manual.log)" + +grep -q "No git repository. Extension will not be updated." /tmp/manual.log \ + || fail "a non-git extension folder was not detected" +grep -q "enable manual@example" /tmp/gnome.log || fail "the manual extension was not enabled" +echo "ok: a manually installed extension is not overwritten but still enabled" + +stub_gnome_extensions "exit 3" +set +e +env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager disable ext@example >/dev/null 2>&1 +status=$? +set -e +[ "${status}" -eq 3 ] || fail "a failing gnome-extensions exited ${status}, expected 3" +echo "ok: a failing gnome-extensions propagates its exit code" + +echo "ALL E2E CHECKS PASSED" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py new file mode 100644 index 0000000..349c1ac --- /dev/null +++ b/tests/integration/test_cli.py @@ -0,0 +1,222 @@ +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SRC_DIR = REPO_ROOT / "src" + +GNOME_STUB = '#!/bin/sh\necho "$@" >> "$GNOME_LOG"\nexit 0\n' +GNOME_STUB_FAILING = "#!/bin/sh\nexit 3\n" +EXTENSION_MAKEFILE = "install:\n\ttouch $$MARKER\n" + + +def git(*args, cwd): + """Run git with a deterministic identity.""" + subprocess.run( + [ + "git", + "-c", + "user.email=test@example.com", + "-c", + "user.name=Test", + *args, + ], + cwd=cwd, + check=True, + capture_output=True, + ) + + +def make_source_repo(path, with_makefile): + """Create a git repository holding a minimal GNOME extension. + + Args: + path: Directory the repository is created in. + with_makefile: Add an install target writing the $MARKER file. + """ + path.mkdir(parents=True) + (path / "metadata.json").write_text('{"uuid": "ext@example"}') + if with_makefile: + (path / "Makefile").write_text(EXTENSION_MAKEFILE) + + git("init", "-b", "main", cwd=path) + git("add", "-A", cwd=path) + git("commit", "-m", "initial", cwd=path) + return path + + +def write_stub(directory, body): + """Write an executable gnome-extensions stub into directory.""" + path = directory / "gnome-extensions" + path.write_text(body) + path.chmod(0o755) + return path + + +def run_cli(*args, stub_dir=None, gnome_log=None, marker=None, isolated_path=False): + """Run 'python -m gnome_extension_manager' with a stubbed gnome-extensions. + + Args: + args: Command line arguments for the CLI. + stub_dir: Directory holding the gnome-extensions stub. + gnome_log: File the stub appends its arguments to. + marker: File the extension Makefile touches on install. + isolated_path: Use only stub_dir as PATH, hiding the real binaries. + """ + env = dict(os.environ) + env["PYTHONPATH"] = str(SRC_DIR) + if isolated_path: + env["PATH"] = str(stub_dir) + elif stub_dir: + env["PATH"] = f"{stub_dir}{os.pathsep}{env['PATH']}" + if gnome_log: + env["GNOME_LOG"] = str(gnome_log) + if marker: + env["MARKER"] = str(marker) + + return subprocess.run( + [sys.executable, "-m", "gnome_extension_manager", *args], + capture_output=True, + text=True, + env=env, + check=False, + ) + + +class TestCli(unittest.TestCase): + def test_help_exits_zero(self): + result = run_cli("--help") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("GNOME shell extensions", result.stdout) + + def test_unknown_action_is_rejected(self): + result = run_cli("restart", "ext@example") + + self.assertEqual(result.returncode, 2) + self.assertIn("invalid choice", result.stderr) + + def test_disable_calls_gnome_extensions(self): + with tempfile.TemporaryDirectory() as base: + base_path = Path(base) + stub_dir = base_path / "bin" + stub_dir.mkdir() + write_stub(stub_dir, GNOME_STUB) + gnome_log = base_path / "calls.log" + + result = run_cli( + "disable", + "ext@example", + stub_dir=stub_dir, + gnome_log=gnome_log, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(gnome_log.read_text().strip(), "disable ext@example") + + def test_enable_clones_builds_and_enables(self): + with tempfile.TemporaryDirectory() as base: + base_path = Path(base) + stub_dir = base_path / "bin" + stub_dir.mkdir() + write_stub(stub_dir, GNOME_STUB) + gnome_log = base_path / "calls.log" + marker = base_path / "built.marker" + source = make_source_repo(base_path / "source", with_makefile=True) + extensions_dir = base_path / "extensions" + + result = run_cli( + "enable", + "ext@example", + str(source), + "--extensions-dir", + str(extensions_dir), + stub_dir=stub_dir, + gnome_log=gnome_log, + marker=marker, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(marker.exists(), "make install did not run") + self.assertEqual(gnome_log.read_text().strip(), "enable ext@example") + self.assertIn("Installation complete.", result.stdout) + + def test_extension_without_makefile_stays_installed(self): + with tempfile.TemporaryDirectory() as base: + base_path = Path(base) + stub_dir = base_path / "bin" + stub_dir.mkdir() + write_stub(stub_dir, GNOME_STUB) + gnome_log = base_path / "calls.log" + source = make_source_repo(base_path / "source", with_makefile=False) + extensions_dir = base_path / "extensions" + + result = run_cli( + "enable", + "ext@example", + str(source), + "--extensions-dir", + str(extensions_dir), + stub_dir=stub_dir, + gnome_log=gnome_log, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("No Makefile found", result.stdout) + self.assertTrue((extensions_dir / "ext@example" / "metadata.json").exists()) + + def test_second_run_pulls_instead_of_cloning(self): + with tempfile.TemporaryDirectory() as base: + base_path = Path(base) + stub_dir = base_path / "bin" + stub_dir.mkdir() + write_stub(stub_dir, GNOME_STUB) + gnome_log = base_path / "calls.log" + source = make_source_repo(base_path / "source", with_makefile=False) + extensions_dir = base_path / "extensions" + + arguments = ( + "enable", + "ext@example", + str(source), + "--extensions-dir", + str(extensions_dir), + ) + run_cli(*arguments, stub_dir=stub_dir, gnome_log=gnome_log) + result = run_cli(*arguments, stub_dir=stub_dir, gnome_log=gnome_log) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Pulling changes from git...", result.stdout) + + def test_missing_gnome_extensions_fails_cleanly(self): + with tempfile.TemporaryDirectory() as base: + empty = Path(base) / "empty" + empty.mkdir() + + result = run_cli( + "disable", "ext@example", stub_dir=empty, isolated_path=True + ) + + self.assertEqual(result.returncode, 127) + self.assertIn( + "required command 'gnome-extensions' is not installed", result.stderr + ) + self.assertNotIn("Traceback", result.stderr) + + def test_failing_gnome_extensions_propagates_the_exit_code(self): + with tempfile.TemporaryDirectory() as base: + base_path = Path(base) + stub_dir = base_path / "bin" + stub_dir.mkdir() + write_stub(stub_dir, GNOME_STUB_FAILING) + + result = run_cli("disable", "ext@example", stub_dir=stub_dir) + + self.assertEqual(result.returncode, 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..2b12693 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,96 @@ +import unittest +from unittest import mock + +from gnome_extension_manager.cli import main + + +class TestMain(unittest.TestCase): + def _patched(self, **returns): + return ( + mock.patch( + "gnome_extension_manager.cli.sync_repository", + return_value=returns.get("sync", 0), + ), + mock.patch( + "gnome_extension_manager.cli.build_extension", + return_value=returns.get("build", 0), + ), + mock.patch( + "gnome_extension_manager.cli.gnome_extensions", + return_value=returns.get("toggle", 0), + ), + ) + + def test_disable_only_toggles(self): + sync, build, toggle = self._patched() + with sync as sync_mock, build as build_mock, toggle as toggle_mock: + exit_code = main(["disable", "ext@example"]) + + sync_mock.assert_not_called() + build_mock.assert_not_called() + toggle_mock.assert_called_once_with("disable", "ext@example") + self.assertEqual(exit_code, 0) + + def test_enable_without_repository_skips_installation(self): + sync, build, toggle = self._patched() + with sync as sync_mock, build as build_mock, toggle as toggle_mock: + exit_code = main(["enable", "ext@example"]) + + sync_mock.assert_not_called() + build_mock.assert_not_called() + toggle_mock.assert_called_once_with("enable", "ext@example") + self.assertEqual(exit_code, 0) + + def test_empty_repository_argument_is_treated_as_absent(self): + sync, build, toggle = self._patched() + with sync as sync_mock, build, toggle: + exit_code = main(["enable", "ext@example", ""]) + + sync_mock.assert_not_called() + self.assertEqual(exit_code, 0) + + def test_enable_with_repository_installs_into_the_extensions_dir(self): + sync, build, toggle = self._patched() + with sync as sync_mock, build as build_mock, toggle: + exit_code = main( + [ + "enable", + "ext@example", + "https://example.com/ext.git", + "--extensions-dir", + "/opt/ext", + ] + ) + + folder = sync_mock.call_args.args[0] + self.assertEqual(str(folder), "/opt/ext/ext@example") + build_mock.assert_called_once() + self.assertEqual(exit_code, 0) + + def test_failed_clone_stops_before_building(self): + sync, build, toggle = self._patched(sync=128) + with sync, build as build_mock, toggle as toggle_mock: + exit_code = main(["enable", "ext@example", "https://example.com/ext.git"]) + + build_mock.assert_not_called() + toggle_mock.assert_not_called() + self.assertEqual(exit_code, 128) + + def test_failed_build_stops_before_enabling(self): + sync, build, toggle = self._patched(build=2) + with sync, build, toggle as toggle_mock: + exit_code = main(["enable", "ext@example", "https://example.com/ext.git"]) + + toggle_mock.assert_not_called() + self.assertEqual(exit_code, 2) + + def test_failed_toggle_propagates(self): + sync, build, toggle = self._patched(toggle=1) + with sync, build, toggle: + exit_code = main(["enable", "ext@example"]) + + self.assertEqual(exit_code, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py new file mode 100644 index 0000000..3cc1fb0 --- /dev/null +++ b/tests/unit/test_core.py @@ -0,0 +1,136 @@ +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from gnome_extension_manager.core import ( + REPOSITORY_BINARIES, + REQUIRED_BINARIES, + MissingBinaryError, + build_extension, + default_extensions_dir, + gnome_extensions, + run, + sync_repository, +) + + +class TestRequiredBinaries(unittest.TestCase): + def test_declares_every_external_command_the_package_calls(self): + declared = set(REQUIRED_BINARIES) | set(REPOSITORY_BINARIES) + + self.assertEqual(declared, {"gnome-extensions", "git", "make"}) + + def test_repository_binaries_are_not_declared_as_always_required(self): + self.assertFalse(set(REQUIRED_BINARIES) & set(REPOSITORY_BINARIES)) + + def test_missing_binary_is_reported_with_its_name(self): + with mock.patch( + "gnome_extension_manager.core.subprocess.run" + ) as subprocess_run: + subprocess_run.side_effect = FileNotFoundError + + with self.assertRaises(MissingBinaryError) as caught: + run(["git", "clone", "x", "y"]) + + self.assertEqual(caught.exception.binary, "git") + + +class TestDefaultExtensionsDir(unittest.TestCase): + def test_points_at_the_per_user_gnome_directory(self): + self.assertEqual( + default_extensions_dir(), + Path.home() / ".local" / "share" / "gnome-shell" / "extensions", + ) + + +class TestSyncRepository(unittest.TestCase): + def test_missing_folder_is_cloned(self): + with ( + tempfile.TemporaryDirectory() as base, + mock.patch("gnome_extension_manager.core.run", return_value=0) as run, + ): + folder = Path(base) / "ext" + + self.assertEqual(sync_repository(folder, "https://example.com/ext.git"), 0) + + run.assert_called_once_with( + ["git", "clone", "https://example.com/ext.git", str(folder)] + ) + + def test_existing_checkout_is_pulled(self): + with tempfile.TemporaryDirectory() as base: + folder = Path(base) / "ext" + (folder / ".git").mkdir(parents=True) + + with mock.patch("gnome_extension_manager.core.run", return_value=0) as run: + self.assertEqual(sync_repository(folder, "https://e.example/x"), 0) + + run.assert_called_once_with(["git", "pull"], cwd=str(folder)) + + def test_existing_folder_without_git_is_left_alone(self): + with tempfile.TemporaryDirectory() as base: + folder = Path(base) / "ext" + folder.mkdir() + + with mock.patch("gnome_extension_manager.core.run") as run: + self.assertEqual(sync_repository(folder, "https://e.example/x"), 0) + + run.assert_not_called() + + def test_failed_clone_propagates_the_exit_code(self): + with ( + tempfile.TemporaryDirectory() as base, + mock.patch("gnome_extension_manager.core.run", return_value=128), + ): + self.assertEqual( + sync_repository(Path(base) / "ext", "https://e.example/x"), 128 + ) + + +class TestBuildExtension(unittest.TestCase): + def test_extension_without_makefile_is_kept_in_place(self): + with tempfile.TemporaryDirectory() as base: + folder = Path(base) / "ext" + folder.mkdir() + + with mock.patch("gnome_extension_manager.core.run") as run: + self.assertEqual(build_extension(folder), 0) + + run.assert_not_called() + self.assertTrue(folder.exists()) + + def test_makefile_extension_is_built_and_the_checkout_removed(self): + with tempfile.TemporaryDirectory() as base: + folder = Path(base) / "ext" + folder.mkdir() + (folder / "Makefile").write_text("install:\n\ttrue\n") + + with mock.patch("gnome_extension_manager.core.run", return_value=0) as run: + self.assertEqual(build_extension(folder), 0) + + self.assertEqual(run.call_args.args[0], ["make", "install"]) + self.assertFalse(folder.exists()) + + def test_failed_build_propagates_the_exit_code(self): + with tempfile.TemporaryDirectory() as base: + folder = Path(base) / "ext" + folder.mkdir() + (folder / "Makefile").write_text("install:\n\tfalse\n") + + with mock.patch("gnome_extension_manager.core.run", return_value=2): + self.assertEqual(build_extension(folder), 2) + + +class TestGnomeExtensions(unittest.TestCase): + def test_delegates_to_the_gnome_extensions_tool(self): + with mock.patch("gnome_extension_manager.core.run", return_value=0) as run: + gnome_extensions("enable", "dash-to-dock@example") + + run.assert_called_once_with( + ["gnome-extensions", "enable", "dash-to-dock@example"] + ) + + +if __name__ == "__main__": + unittest.main()