# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
from __future__ import annotations
import os
import re
import subprocess
from collections import Counter
from itertools import product
from typing import ClassVar, cast
import pytest
from boltons.iterutils import flatten
from boltons.strutils import strip_ansi
from click_extra.color import color_envvars
from click_extra.execution import args_cleanup
from extra_platforms.pytest import unless_macos
from meta_package_manager import bar_plugin
from meta_package_manager.bar_plugin_renderer import (
DARK_MENU_NEW_COLOR,
LIGHT_MENU_NEW_COLOR,
LIGHT_MENU_OLD_COLOR,
VERSION_PREFIX_COLOR,
BarPluginRenderer,
)
from meta_package_manager.version import parse_version
TYPE_CHECKING = False
if TYPE_CHECKING:
from click_extra.envvar import TEnvVars
[docs]
@pytest.mark.parametrize(
("param_string", "results"),
(
("font=Menlo", "font=Menlo"),
("font=Menlo size=12", "font=Menlo size=12"),
(" font=Menlo ", "font=Menlo"),
(" font = Menlo ", "font=Menlo"),
(" font = Menlo Menlo ", "font=Menlo"),
("", ""),
(" ", ""),
(" font= ", ""),
(" font ", ""),
(" = foo ", ""),
("=", ""),
("==", ""),
(" = = ", ""),
("random=", ""),
("RANDOM=", ""),
("Font=", ""),
("font=Menlo font=Menlo", "font=Menlo"),
("size=10 size=20", "size=20"),
("font='Comic Sans MS'", "font='Comic Sans MS'"),
('font="Comic Sans MS"', 'font="Comic Sans MS"'),
),
)
def test_normalize_params(param_string, results):
assert bar_plugin.MPMPlugin.normalize_params(param_string) == results
[docs]
def test_check_mpm_missing_binary():
"""A probe whose binary does not exist must report the error, not crash.
Regression test for the `UnboundLocalError` on the `FileNotFoundError`
path of `check_mpm()`, where `process` is never assigned.
"""
runnable, up_to_date, version, error = bar_plugin.MPMPlugin().check_mpm(
("/nonexistent/mpm-binary",),
)
assert runnable is False
assert up_to_date is False
assert version is None
assert isinstance(error, FileNotFoundError)
def _pin_plugin_env(
monkeypatch, table_rendering: bool, os_appearance: str | None = None
) -> None:
"""Pin the plugin environment variables so host values never leak in.
`os_appearance` sets SwiftBar's `OS_APPEARANCE` variable when provided; it
is deleted otherwise, so the host appearance never reaches the renderer.
"""
monkeypatch.setenv("VAR_TABLE_RENDERING", str(table_rendering))
for var in ("VAR_SUBMENU_LAYOUT", "VAR_DEFAULT_FONT", "VAR_MONOSPACE_FONT"):
monkeypatch.delenv(var, raising=False)
if os_appearance is None:
monkeypatch.delenv("OS_APPEARANCE", raising=False)
else:
monkeypatch.setenv("OS_APPEARANCE", os_appearance)
def _outdated_fixture(errors: list[str] | None = None) -> dict:
"""Deterministic outdated data in the shape `mpm outdated` produces.
Version pairs share a common prefix so all three diff segments (gray
prefix, red installed suffix, green latest suffix) are exercised. The
second package carries no upgrade CLI, like a manager without a
single-package upgrade command.
"""
return {
"fakemanager": {
"id": "fakemanager",
"name": "Fake Manager",
"packages": [
{
"id": "pkg-one",
"name": "pkg-one",
"installed_version": "8.2.1",
"latest_version": "8.3.0",
"upgrade_cli": "shell=/bin/fake param1=upgrade param2=pkg-one",
},
{
"id": "another-long-package",
"name": "another-long-package",
"installed_version": "2.0.0",
"latest_version": "2.0.1",
"upgrade_cli": None,
},
],
"errors": errors or [],
"upgrade_all_cli": "shell=/bin/fake param1=upgrade param2=--all",
},
}
[docs]
@pytest.mark.parametrize("table_rendering", (True, False))
@pytest.mark.parametrize(
("os_appearance", "present", "absent"),
(
# No OS_APPEARANCE (dark-agnostic consumer like Xbar): keep the system
# red/green (SGR 31/32); no palette override leaks in.
(None, ("\x1b[31m", "\x1b[32m"), (f"\x1b[38;5;{DARK_MENU_NEW_COLOR}m",)),
# Light menu: both suffixes darkened; no system red/green survives.
(
"Light",
(
f"\x1b[38;5;{LIGHT_MENU_OLD_COLOR}m",
f"\x1b[38;5;{LIGHT_MENU_NEW_COLOR}m",
),
("\x1b[31m", "\x1b[32m"),
),
# Dark menu: green brightened, red kept as the system red.
(
"Dark",
(f"\x1b[38;5;{DARK_MENU_NEW_COLOR}m", "\x1b[31m"),
("\x1b[32m",),
),
),
)
def test_renderer_version_diff_colors_by_appearance(
monkeypatch, table_rendering, os_appearance, present, absent
):
"""Package lines carry the appearance-appropriate version-diff colors with
`ansi=true`; the prefix gray is constant and non-package lines stay free of
escape codes."""
_pin_plugin_env(monkeypatch, table_rendering, os_appearance=os_appearance)
output = BarPluginRenderer().render(_outdated_fixture())
package_lines = [line for line in output.splitlines() if "ansi=true" in line]
# 2 packages, each rendered twice (terminal and alternate menu entries).
assert len(package_lines) == 4
for line in package_lines:
assert f"\x1b[38;5;{VERSION_PREFIX_COLOR}m" in line
assert "\x1b[0m" in line
for code in present:
assert code in line
for code in absent:
assert code not in line
for line in output.splitlines():
if "ansi=true" not in line:
assert "\x1b[" not in line
[docs]
def test_renderer_table_alignment_survives_ansi(monkeypatch):
"""Column alignment is computed on visible widths, not raw string lengths."""
_pin_plugin_env(monkeypatch, table_rendering=True)
output = BarPluginRenderer().render(_outdated_fixture())
arrow_lines = [line for line in strip_ansi(output).splitlines() if "→" in line]
assert len(arrow_lines) == 4
assert len({line.index("→") for line in arrow_lines}) == 1
assert len({line.index(" | ") for line in arrow_lines}) == 1
[docs]
def test_renderer_sanitizes_error_lines(monkeypatch):
"""ANSI codes captured from a manager's output are stripped from error
lines, which are marked `ansi=false` and would render them as raw text."""
_pin_plugin_env(monkeypatch, table_rendering=True)
output = BarPluginRenderer().render(
_outdated_fixture(errors=["\x1b[31mboom\x1b[0m went wrong"]),
)
error_lines = [line for line in output.splitlines() if "boom" in line]
assert error_lines
for line in error_lines:
assert "ansi=false" in line
assert "\x1b[" not in line
def _invocation_matrix(*iterables):
"""Pre-compute a matrix of all possible options for invocation."""
for args in product(*iterables):
yield args_cleanup(args)
def _shell_invocation_matrix():
"""Pre-compute a matrix of all possible options used for shell invocation.
See the list of shell supported by SwiftBar at:
https://github.com/swiftbar/SwiftBar/commit/366695d594884fe141bc1752ab0f25d2c43334fa
Returns
-------
```{code-block} python
(
("bash", "-c"),
("bash", "--login", "-c"),
("/bin/bash", "-c"),
("/bin/bash", "--login", "-c"),
("zsh", "-c"),
("zsh", "--login", "-c"),
("/bin/zsh", "-c"),
("/bin/zsh", "--login", "-c"),
("/usr/bin/env", "bash", "-c"),
("/usr/bin/env", "bash", "--login", "-c"),
("/usr/bin/env", "/bin/bash", "-c"),
("/usr/bin/env", "/bin/bash", "--login", "-c"),
("/usr/bin/env", "zsh", "-c"),
("/usr/bin/env", "zsh", "--login", "-c"),
("/usr/bin/env", "/bin/zsh", "-c"),
("/usr/bin/env", "/bin/zsh", "--login", "-c"),
None,
)
```
"""
return list(
_invocation_matrix(
# Env prefixes.
(None, "/usr/bin/env"),
# Naked and full binary paths.
flatten((bin_id, f"/bin/{bin_id}") for bin_id in ("bash", "zsh")),
# Options.
("-c", ("--login", "-c")),
)
) + [None]
def _python_invocation_matrix():
"""Pre-compute a matrix of all possible options used for python invocation.
Returns
-------
```{code-block} python
(
("python",),
("python3",),
("/usr/bin/env", "python"),
("/usr/bin/env", "python3"),
)
```
"""
return _invocation_matrix(
# Env prefixes.
(None, "/usr/bin/env"),
# Binary paths
("python", "python3"),
)
shell_args = pytest.mark.parametrize(
"shell_args",
tuple(
pytest.param(p, id=" ".join(args_cleanup(p)))
for p in _shell_invocation_matrix()
),
)
shell_python_args = pytest.mark.parametrize(
"shell_args,python_args",
tuple(
pytest.param(s_args, p_args, id=" ".join(args_cleanup(s_args, p_args)))
for s_args, p_args in product(
_shell_invocation_matrix(), _python_invocation_matrix()
)
),
)
def _subcmd_args(
invoke_args: tuple[str, ...] | None, *subcmd_args: str
) -> tuple[str, ...]:
"""Cleanup args and eventually concatenate all `subcmd_args` items to a space
separated string if `invoke_args` is defined and its last argument is equal to
`-c`."""
raw_args: list[str] = []
if invoke_args:
raw_args.extend(invoke_args)
if invoke_args[-1] == "-c":
subcmd_args = (" ".join(subcmd_args),)
raw_args.extend(subcmd_args)
return args_cleanup(raw_args)
# The plugin suite drives mpm end-to-end and needs at least one live package
# manager on the host, so it belongs to the integration layer even though its
# module name does not match the `test_cli*` / `test_manager_*` convention
# conftest keys on. The marker makes `-m "not integration"` and the hermetic-
# build auto-skip cover it too.
[docs]
@pytest.mark.integration
@unless_macos
class TestBarPlugin:
common_checklist: ClassVar[list] = [
# Menubar line. Required.
(r"(🎁↑\d+|📦✓)( ⚠️\d+)? \| dropdown=false$", True),
# Submenus and sections marker. Required.
(r"-{3,5}$", True),
# Upgrade all line.
# XXX Upgrade all line is not required, as it may be skipped in the
# final rendering of the plugin if no outdated packages are found:
# 📦✓ ⚠️1 | dropdown=false
# ---
# brew - 0 package | font=Menlo size=12
# ---
# cask - 0 package | font=Menlo size=12
# ...
(
(
r"(--)?🆙 Upgrade all \S+ packages? \| shell=\S+( param\d+=\S+)+ "
r"refresh=true terminal=(true|false alternate=true)$"
),
False,
),
# Error line. Optional.
(
(
r"(--)?.+ \| font=[Mm]enlo size=10 color=red trim=false "
r"ansi=false emojize=false( symbolize=false)?$"
),
False,
),
]
def _plugin_output_checks(self, checklist, extra_env: TEnvVars | None = None):
"""Run the plugin script and check its output against the checklist.
The ambient color knobs (`NO_COLOR`, `LLM`, `TERM=dumb`, ...) are
scrubbed from the subprocess environment so the run reflects a real
bar app launch: exported by the developer shell or the CI runner,
they would otherwise disable the version-diff colors mpm forces for
plugin output.
"""
env = {**os.environ, **(extra_env or {})}
for var in (*color_envvars, "TERM"):
env.pop(var, None)
process = subprocess.run(
bar_plugin.__file__,
capture_output=True,
encoding="utf-8",
env=cast("subprocess._ENV", env),
check=False,
)
assert not process.stderr
assert process.returncode == 0
checks = checklist + self.common_checklist
match_counter = Counter() # type: ignore[var-annotated]
for line in process.stdout.splitlines():
# The line is expected to match at least one regex.
matches = False
for index, (regex, _) in enumerate(checks):
if re.match(regex, line):
matches = True
match_counter[index] += 1
break
if not matches:
print(process.stdout)
msg = f"plugin output line {line!r} did not match any regex."
raise Exception(msg) # noqa: TRY002
# Check all required regex did match at least once.
for index, (regex, required) in enumerate(checks):
if required and not match_counter[index]:
print(process.stdout)
msg = f"{regex!r} regex did not match any plugin output line."
raise Exception(msg) # noqa: TRY002
# A package line declaring ansi=true must back it with actual escape
# codes: the version-diff colors survive the non-TTY pipe the plugin
# captures mpm's output through. Opportunistic, as the host may have
# no outdated package to render.
for line in process.stdout.splitlines():
if "ansi=true" in line and "→" in line:
assert "\x1b[" in line
[docs]
@pytest.mark.xdist_group(name="avoid_concurrent_plugin_runs")
@pytest.mark.parametrize("submenu_layout", (True, False, None))
@pytest.mark.parametrize("table_rendering", (True, False, None))
def test_rendering(self, submenu_layout, table_rendering):
extra_checks: list[tuple[str, bool]] = []
# XXX Package upgrade line is not required, as it may be skipped in the
# final rendering of the plugin if no outdated packages are found:
# 📦✓ ⚠️1 | dropdown=false
# ---
# brew - 0 package | font=Menlo size=12
# ---
# cask - 0 package | font=Menlo size=12
# ...
if table_rendering is False:
extra_checks.extend(
(
# Package manager section header.
(r"(⚠️ )?\d+ outdated .+ packages?", True),
# Package upgrade line.
(
(
r"(--)?[\S ]+ \S+ → \S+ \| shell=\S+( param\d+=\S+)+ "
r"ansi=true refresh=true "
r"terminal=(true|false alternate=true)$"
),
False,
),
),
)
# Default case is VAR_TABLE_RENDERING=true.
else:
extra_checks.extend(
(
# Package manager section header.
(r"(⚠️ )?\S+ - \d+ packages?\s+\| font=[Mm]enlo size=12", True),
# Package upgrade line.
(
(
r"(--)?[\S ]+\s+\S+ → \S+\s+\| shell=\S+( param\d+=\S+)+ "
r"font=[Mm]enlo size=12 ansi=true refresh=true "
r"terminal=(true|false alternate=true)?$"
),
False,
),
),
)
extra_env = {}
if submenu_layout is not None:
extra_env["VAR_SUBMENU_LAYOUT"] = str(submenu_layout)
if table_rendering is not None:
extra_env["VAR_TABLE_RENDERING"] = str(table_rendering)
self._plugin_output_checks(extra_checks, extra_env=extra_env)
[docs]
@pytest.mark.xdist_group(name="avoid_concurrent_plugin_runs")
@shell_args
def test_plugin_shell_invocation(self, shell_args):
"""Test execution of plugin on different shells.
Do not execute the complete search for outdated packages, just stop at searching
for the mpm executable and extract its version.
"""
process = subprocess.run(
_subcmd_args(shell_args, bar_plugin.__file__, "--search-mpm"),
capture_output=True,
encoding="utf-8",
check=False,
)
assert not process.stderr
assert process.returncode == 0
assert process.stdout
for line in process.stdout.splitlines():
assert re.match(
r"^.+ \| runnable: \S+ \| up to date: \S+"
r" \| version: .+ \| error: .*$",
line,
)
[docs]
@shell_python_args
def test_python_shell_invocation(self, shell_args, python_args):
"""Test any Python shell invocation is properly configured and all are
compatible with plugin requirements."""
process = subprocess.run(
_subcmd_args(shell_args, *python_args, "--version"),
capture_output=True,
encoding="utf-8",
check=False,
)
assert not process.stderr
assert process.stdout
assert process.returncode == 0
# We need to parse the version to account for alpha release,
# like Python `3.12.0a4`.
# The bar plugin itself must run on macOS system Python (3.9+), even though
# mpm requires 3.10+. The check_mpm() runnability test catches the gap.
python_version = process.stdout.split()[1]
assert parse_version(python_version) >= parse_version(
"3.9",
), f"{python_version} >= 3.9"