From df7bd253d42cd7cd7210936a330b6b24efedd578 Mon Sep 17 00:00:00 2001 From: zvecr Date: Mon, 8 Jun 2026 11:15:02 +0100 Subject: [PATCH] Add initial test suite --- .github/workflows/cli_lint.yml | 2 +- .github/workflows/cli_setup.yml | 19 ++- .github/workflows/python-publish.yml | 3 - ci_tests | 50 +----- pyproject.toml | 13 +- qmk_cli/git.py | 72 ++++++-- qmk_cli/subcommands/clone.py | 2 +- qmk_cli/subcommands/env.py | 5 + qmk_cli/subcommands/setup.py | 62 ++----- test/conftest.py | 39 +++++ test/subcommands/test_clone.py | 40 +++++ test/subcommands/test_env.py | 51 ++++++ test/subcommands/test_setup.py | 176 +++++++++++++++++++ test/test_ci.py | 46 +++++ test/test_git.py | 117 +++++++++++++ test/test_helpers.py | 246 +++++++++++++++++++++++++++ 16 files changed, 824 insertions(+), 119 deletions(-) create mode 100644 test/conftest.py create mode 100644 test/subcommands/test_clone.py create mode 100644 test/subcommands/test_env.py create mode 100644 test/subcommands/test_setup.py create mode 100644 test/test_ci.py create mode 100644 test/test_git.py create mode 100644 test/test_helpers.py diff --git a/.github/workflows/cli_lint.yml b/.github/workflows/cli_lint.yml index 69f1525..e8e437f 100644 --- a/.github/workflows/cli_lint.yml +++ b/.github/workflows/cli_lint.yml @@ -38,4 +38,4 @@ jobs: run: ruff check --no-cache --output-format=github - name: Run format - run: yapf -r --diff qmk_cli + run: yapf -r --diff qmk_cli test diff --git a/.github/workflows/cli_setup.yml b/.github/workflows/cli_setup.yml index ef47463..3bec757 100644 --- a/.github/workflows/cli_setup.yml +++ b/.github/workflows/cli_setup.yml @@ -17,8 +17,17 @@ jobs: - name: Install dependencies run: apt-get update && apt-get install -y python3-venv + - name: Run unit test + shell: bash + run: | + python3 -m venv .ci_venv + source .ci_venv/bin/activate + python3 -m pip install -r requirements.txt + python3 -m pip install --group dev + pytest + - name: Run ci_tests - run: ./ci_tests -a + run: ./ci_tests test_cli_linux_macos: needs: test_cli_base_container @@ -39,7 +48,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Run ci_tests - run: ./ci_tests -a + run: ./ci_tests test_cli_win: needs: test_cli_base_container @@ -53,7 +62,7 @@ jobs: uses: msys2/setup-msys2@v2 with: update: true - install: git mingw-w64-x86_64-toolchain mingw-w64-x86_64-python-pip mingw-w64-x86_64-python-build mingw-w64-x86_64-python-pillow mingw-w64-x86_64-rust mingw-w64-x86_64-python-halo mingw-w64-x86_64-python-nh3 mingw-w64-x86_64-python-rpds-py + install: git mingw-w64-x86_64-toolchain mingw-w64-x86_64-python-pip mingw-w64-x86_64-python-build mingw-w64-x86_64-python-pillow mingw-w64-x86_64-rust mingw-w64-x86_64-python-halo mingw-w64-x86_64-python-nh3 mingw-w64-x86_64-python-rpds-py mingw-w64-x86_64-python-ruff # Upgrade pip due to msys packaging + pypa/build/pull/736 issues - name: (MSYS2) Install Python dependencies @@ -63,7 +72,7 @@ jobs: - name: Run ci_tests shell: msys2 {0} - run: ./ci_tests -a + run: ./ci_tests test_docker_container: needs: test_cli_base_container @@ -106,7 +115,7 @@ jobs: - name: Test run: | - docker run --rm -v ${{ github.workspace }}:/qmk_cli -w /qmk_cli ${{ env.TEST_TAG }} /qmk_cli/ci_tests -a + docker run --rm -v ${{ github.workspace }}:/qmk_cli -w /qmk_cli ${{ env.TEST_TAG }} /qmk_cli/ci_tests - name: Build All Containers uses: docker/build-push-action@v7 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 152ede1..7d5fc9c 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -30,9 +30,6 @@ jobs: - uses: actions/checkout@v7 - - name: Run ci_tests - run: ./ci_tests - - name: Install dependencies run: | python3 -m pip install --upgrade pip diff --git a/ci_tests b/ci_tests index dfa00c6..5e24546 100755 --- a/ci_tests +++ b/ci_tests @@ -3,61 +3,23 @@ set -e #set -x -ADVANCED=false TMPDIR=$(mktemp -d) -QMK_HOME="$TMPDIR/qmk_firmware" -export QMK_HOME -for arg in $@; do - if [ "$arg" = "-a" ]; then - ADVANCED=true - fi -done +trap "rm -rf $TMPDIR" EXIT -if [ $ADVANCED = true ]; then - echo "*** Running in advanced mode with tmp files in $TMPDIR" -else - echo "*** Running in basic mode with tmp files in $TMPDIR" -fi - -# Setup our virtualenv -if [ -e .ci_venv ]; then - rm -rf .ci_venv -fi +CI_VENV="$TMPDIR/.ci_venv" # Avoid issues with building pillow as we install mingw-w64-x86_64-python-pillow if [[ "$(uname -s)" =~ ^MINGW64_NT.* ]]; then CI_VENV_ARGS="--system-site-packages" fi -python3 -m venv $CI_VENV_ARGS .ci_venv -source .ci_venv/bin/activate +python3 -m venv $CI_VENV_ARGS $CI_VENV +source $CI_VENV/bin/activate # Install dependencies python3 -m pip install -U pip wheel python3 -m pip install . +python3 -m pip install --group dev -# Ensure that qmk works -echo "*** Testing 'qmk clone -h'" -qmk clone -h -#echo "*** Testing 'qmk config -a'" # Test disabled as `milc` at least 1.6.8+ returns False and thus non-zero exit code -#qmk config -a -echo "*** Testing 'qmk setup -n'" -qmk setup -n - -echo -echo "*** Basic tests completed successfully!" - -# Run advanced test if requested -if [ $ADVANCED = true ]; then - echo - echo "*** Testing 'qmk setup -y'" - qmk setup -y - - echo - echo "*** Advanced tests completed successfully!" -fi - -# Cleanup -deactivate -rm -rf .ci_venv $TMPDIR +pytest -vv -m system --qmk=real diff --git a/pyproject.toml b/pyproject.toml index 96bd83e..7e30cc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ dev = [ "build", "bumpversion", + "pytest", "ruff", "twine", "yapf", @@ -63,7 +64,10 @@ find = {namespaces = false} [tool.ruff] target-version = "py39" -include = ['qmk_cli/**/*.py'] +include = [ + "qmk_cli/**/*.py", + "test/**/*.py", +] [tool.ruff.lint] select = ["E", "F", "B", "N", "W", "COM", "C901", "PGH004"] @@ -133,3 +137,10 @@ split_penalty_for_added_line_split = 30 split_penalty_import_names = 0 split_penalty_logical_operator = 300 use_tabs = false + +[tool.pytest.ini_options] +markers = [ + "integration", + "system", +] +addopts = "-m 'not (integration or system)'" diff --git a/qmk_cli/git.py b/qmk_cli/git.py index c12768d..fdff836 100644 --- a/qmk_cli/git.py +++ b/qmk_cli/git.py @@ -1,24 +1,24 @@ -"""Helpers for working with git. -""" +"""Helpers for working with git.""" + import subprocess +from .helpers import rmtree from milc import cli -default_repo = 'qmk_firmware' -default_fork = 'qmk/' + default_repo -default_branch = 'master' +DEFAULT_UPSTREAM = 'https://github.com/qmk/qmk_firmware' def git_clone(url, destination, branch): + """Add the qmk/qmk_firmware upstream to a qmk_firmware clone.""" git_clone = [ 'git', 'clone', '--recurse-submodules', - '--branch=' + branch, + f'--branch={branch}', url, str(destination), ] - cli.log.debug('Git clone command: %s', git_clone) + cli.log.debug(f'Git clone command: {git_clone}') try: with subprocess.Popen(git_clone, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, bufsize=1, universal_newlines=True, encoding='utf-8') as p: @@ -28,13 +28,57 @@ def git_clone(url, destination, branch): except Exception as e: git_cmd = ' '.join([s.replace(' ', r'\ ') for s in git_clone]) - cli.log.error("Could not run '%s': %s: %s", git_cmd, e.__class__.__name__, e) + cli.log.error(f"Could not run '{git_cmd}': {e.__class__.__name__}:{e}") return False - if p.returncode == 0: - cli.log.info('Successfully cloned %s to %s!', url, destination) - return True - - else: - cli.log.error('git clone exited %d', p.returncode) + if p.returncode != 0: + cli.log.error(f'git clone exited {p.returncode}') return False + + cli.log.info(f'Successfully cloned {url} to {destination}!') + return True + + +def git_set_upstream(destination): + """Add the qmk/qmk_firmware upstream to a qmk_firmware clone.""" + git_remote = [ + 'git', + '-C', + destination, + 'remote', + 'add', + 'upstream', + DEFAULT_UPSTREAM, + ] + cli.log.debug(f'Git remote command: {git_remote}') + + try: + with subprocess.Popen(git_remote, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, bufsize=1, universal_newlines=True, encoding='utf-8') as p: + for line in p.stdout: + print(line, end='') + + except Exception as e: + git_cmd = ' '.join([s.replace(' ', r'\ ') for s in git_remote]) + + cli.log.error(f"Could not run '{git_cmd}': {e.__class__.__name__}:{e}") + return False + + if p.returncode != 0: + cli.log.error(f'git remote add exited {p.returncode}') + return False + + cli.log.info(f'Added {DEFAULT_UPSTREAM} as remote upstream.') + return True + + +def git_clone_fork(destination, baseurl, fork, branch, force=False): + """Clone fork and configure upstream.""" + url = '/'.join((baseurl, fork)) + + if force: + rmtree(destination) + + if not git_clone(url, destination, branch): + return False + + return git_set_upstream(destination) diff --git a/qmk_cli/subcommands/clone.py b/qmk_cli/subcommands/clone.py index 4a8d1e6..0715260 100644 --- a/qmk_cli/subcommands/clone.py +++ b/qmk_cli/subcommands/clone.py @@ -23,6 +23,6 @@ def clone(cli): # Exists (but not an empty dir) if cli.args.destination.exists() and any(cli.args.destination.iterdir()): cli.log.error('Destination already exists: %s', cli.args.destination) - exit(1) + return False return git_clone(git_url, cli.args.destination, cli.args.branch) diff --git a/qmk_cli/subcommands/env.py b/qmk_cli/subcommands/env.py index 53319ba..be5f013 100644 --- a/qmk_cli/subcommands/env.py +++ b/qmk_cli/subcommands/env.py @@ -1,6 +1,7 @@ """Prints environment information. """ import os +import sys from pathlib import Path from milc import cli @@ -26,6 +27,10 @@ def env(cli): data[converted_key] = val if cli.args.var: + if cli.args.var not in data: + print(f'Variable "{cli.args.var}" does not exist!', file=sys.stderr) + return False + # dump out requested arg print(data[cli.args.var]) else: diff --git a/qmk_cli/subcommands/setup.py b/qmk_cli/subcommands/setup.py index a13566c..56816d8 100644 --- a/qmk_cli/subcommands/setup.py +++ b/qmk_cli/subcommands/setup.py @@ -2,64 +2,26 @@ """ import os import shlex -import subprocess import sys from pathlib import Path from milc import cli from milc.questions import choice, question, yesno -from qmk_cli.git import git_clone -from qmk_cli.helpers import AbsPath, is_qmk_firmware, rmtree +from qmk_cli.git import git_clone_fork +from qmk_cli.helpers import AbsPath, is_qmk_firmware -default_base = 'https://github.com' -default_repo = 'qmk_firmware' -default_fork = 'qmk/' + default_repo -default_branch = 'master' - - -def git_upstream(destination): - """Add the qmk/qmk_firmware upstream to a qmk_firmware clone. - """ - git_url = '/'.join((cli.args.baseurl, default_fork)) - git_cmd = [ - 'git', - '-C', - destination, - 'remote', - 'add', - 'upstream', - git_url, - ] - - with subprocess.Popen(git_cmd, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, bufsize=1, universal_newlines=True, encoding='utf-8') as p: - for line in p.stdout: - print(line, end='') - - if p.returncode == 0: - cli.log.info('Added %s as remote upstream.', git_url) - return True - else: - cli.log.error('%s exited %d', ' '.join(git_cmd), p.returncode) - return False - - -def git_clone_fork(fork, branch, force=False): - if force: - rmtree(cli.args.home) - - git_url = '/'.join((cli.args.baseurl, fork)) - if git_clone(git_url, cli.args.home, branch): - git_upstream(cli.args.home) - else: - exit(1) +DEFAULT_BASE = 'https://github.com' +DEFAULT_REPO = 'qmk_firmware' +DEFAULT_FORK = 'qmk/' + DEFAULT_REPO +DEFAULT_BRANCH = 'master' @cli.argument('-n', '--no', arg_only=True, action='store_true', help='Answer no to all questions') @cli.argument('-y', '--yes', arg_only=True, action='store_true', help='Answer yes to all questions') -@cli.argument('--baseurl', arg_only=True, default=default_base, help='The URL all git operations start from. Default: %s' % default_base) -@cli.argument('-b', '--branch', arg_only=True, default=default_branch, help='The branch to clone. Default: %s' % default_branch) +@cli.argument('--baseurl', arg_only=True, default=DEFAULT_BASE, help='The URL all git operations start from. Default: %s' % DEFAULT_BASE) +@cli.argument('-b', '--branch', arg_only=True, default=DEFAULT_BRANCH, help='The branch to clone. Default: %s' % DEFAULT_BRANCH) @cli.argument('-H', '--home', arg_only=True, default=Path(os.environ['QMK_HOME']), type=AbsPath, help='The location for QMK Firmware. Default: %s' % os.environ['QMK_HOME']) -@cli.argument('fork', arg_only=True, default=default_fork, nargs='?', help='The qmk_firmware fork to clone. Default: %s' % default_fork) +@cli.argument('fork', arg_only=True, default=DEFAULT_FORK, nargs='?', help='The qmk_firmware fork to clone. Default: %s' % DEFAULT_FORK) @cli.subcommand('Setup your computer for qmk_firmware.') def setup(cli): """Guide the user through setting up their QMK environment. @@ -90,7 +52,7 @@ def setup(cli): if not yesno(delete_confirm, default=False): exit(1) - git_clone_fork(cli.args.fork, cli.args.branch, force=True) + git_clone_fork(cli.args.home, cli.args.baseurl, cli.args.fork, cli.args.branch, force=True) elif found_action == "Delete and clone a different fork": fork_name = question("Enter the name of the fork:", default=cli.args.fork) @@ -99,7 +61,7 @@ def setup(cli): if not yesno(delete_confirm, default=False): exit(1) - git_clone_fork(fork_name, branch_name, force=True) + git_clone_fork(cli.args.home, cli.args.baseurl, fork_name, branch_name, force=True) # Exists (but not an empty dir) elif cli.args.home.exists() and any(cli.args.home.iterdir()): @@ -114,7 +76,7 @@ def setup(cli): else: cli.log.error('Could not find qmk_firmware!') if yesno(clone_prompt): - git_clone_fork(cli.args.fork, cli.args.branch) + git_clone_fork(cli.args.home, cli.args.baseurl, cli.args.fork, cli.args.branch) else: cli.log.warning('Not cloning qmk_firmware due to user input or --no flag.') diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..04f9cb9 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,39 @@ +import tempfile +import pytest +from pathlib import Path +from unittest.mock import create_autospec, PropertyMock, MagicMock + +from milc import MILCInterface +from milc.configuration import Configuration + + +def pytest_addoption(parser): + parser.addoption( + '--qmk', + action='store', + default='wrapper', + help='options: ./qmk wrapper or real qmk from path', + choices=('wrapper', 'real'), + ) + + +@pytest.fixture +def temp_directory(): + """Similar behavior to the default tmpdir fixture except the folder is cleaned up.""" + with tempfile.TemporaryDirectory() as tmp_path: + yield Path(tmp_path) + + +@pytest.fixture +def mock_cli(): + """Mock cli to pass to cli subcommands,""" + ret = create_autospec(MILCInterface) + + # default behavior + type(ret).config = Configuration() + ret.config.general.unicode = True + ret.config.general.color = True + type(ret).write_config_option = MagicMock() + type(ret.args).var = PropertyMock(return_value=None) + + return ret diff --git a/test/subcommands/test_clone.py b/test/subcommands/test_clone.py new file mode 100644 index 0000000..3858f98 --- /dev/null +++ b/test/subcommands/test_clone.py @@ -0,0 +1,40 @@ +import pytest +from unittest.mock import patch, PropertyMock + + +@pytest.fixture +def subcommand(temp_directory): + tmp = temp_directory.as_posix() + with patch.dict('os.environ', {'ORIG_CWD': tmp, 'QMK_HOME': tmp}, clear=True): + import qmk_cli.subcommands.clone as clone + + yield clone + + +@pytest.fixture +def git_clone(subcommand): + with patch.object(subcommand, 'git_clone') as git_clone: + yield git_clone + + +def test_clone(subcommand, mock_cli, git_clone): + type(mock_cli.args).baseurl = PropertyMock(return_value='asdf') + type(mock_cli.args).fork = PropertyMock(return_value='fork') + git_clone.return_value = True + + ret = subcommand.clone(mock_cli) + + git_clone.assert_called_once() + assert ret is True + + +def test_clone_not_empty(subcommand, mock_cli, temp_directory, git_clone): + type(mock_cli.args).baseurl = PropertyMock(return_value='asdf') + type(mock_cli.args).fork = PropertyMock(return_value='fork') + type(mock_cli.args).destination = PropertyMock(return_value=temp_directory) + (temp_directory / 'asdf').touch() + + ret = subcommand.clone(mock_cli) + + git_clone.assert_not_called() + assert ret is False diff --git a/test/subcommands/test_env.py b/test/subcommands/test_env.py new file mode 100644 index 0000000..2774d1b --- /dev/null +++ b/test/subcommands/test_env.py @@ -0,0 +1,51 @@ +import pytest +from unittest.mock import patch, PropertyMock + + +@pytest.fixture +def subcommand(temp_directory): + tmp = temp_directory.as_posix() + with patch.dict('os.environ', {'ORIG_CWD': tmp, 'QMK_HOME': tmp}, clear=True): + import qmk_cli.subcommands.env as env + + yield env + + +@pytest.fixture +def is_qmk_firmware(subcommand): + with patch.object(subcommand, 'is_qmk_firmware') as is_qmk_firmware: + is_qmk_firmware.return_value = True + + yield is_qmk_firmware + + +def test_request_qmk_firmware_exists(subcommand, mock_cli, capsys, temp_directory, is_qmk_firmware): + subcommand.env(mock_cli) + captured = capsys.readouterr().out + assert f'QMK_HOME="{temp_directory}"' in captured + assert f'QMK_FIRMWARE="{temp_directory}"' in captured + + +def test_request_all_variables(subcommand, mock_cli, capsys, temp_directory): + subcommand.env(mock_cli) + captured = capsys.readouterr().out + assert f'QMK_HOME="{temp_directory}"' in captured + assert 'QMK_FIRMWARE=""' in captured + assert 'QMK_UNICODE="True"' in captured + + +def test_request_single_variable(subcommand, mock_cli, capsys, temp_directory): + type(mock_cli.args).var = PropertyMock(return_value='QMK_HOME') + + subcommand.env(mock_cli) + captured = capsys.readouterr().out + assert captured == f'{temp_directory}\n' + + +def test_request_single_variable_no_exits(subcommand, mock_cli, capsys): + type(mock_cli.args).var = PropertyMock(return_value='ASDF') + + ret = subcommand.env(mock_cli) + captured = capsys.readouterr().err + assert captured == 'Variable "ASDF" does not exist!\n' + assert ret is False diff --git a/test/subcommands/test_setup.py b/test/subcommands/test_setup.py new file mode 100644 index 0000000..417cd25 --- /dev/null +++ b/test/subcommands/test_setup.py @@ -0,0 +1,176 @@ +import pytest +from unittest.mock import patch, PropertyMock, ANY + + +@pytest.fixture +def subcommand(temp_directory): + tmp = temp_directory.as_posix() + with patch.dict('os.environ', {'ORIG_CWD': tmp, 'QMK_HOME': tmp}, clear=True): + import qmk_cli.subcommands.setup as setup + + yield setup + + +@pytest.fixture +def is_qmk_firmware(subcommand): + with patch.object(subcommand, 'is_qmk_firmware') as is_qmk_firmware: + is_qmk_firmware.return_value = True + + yield is_qmk_firmware + + +@pytest.fixture +def yesno(subcommand): + with patch.object(subcommand, 'yesno') as yesno: + yesno.return_value = True + + yield yesno + + +@pytest.fixture +def choice(subcommand): + with patch.object(subcommand, 'choice') as choice: + yield choice + + +@pytest.fixture +def question(subcommand): + with patch.object(subcommand, 'question') as question: + yield question + + +@pytest.fixture +def git_clone_fork(subcommand): + with patch.object(subcommand, 'git_clone_fork') as git_clone_fork: + yield git_clone_fork + + +def test_setup_both_yes_no_invalid(subcommand, mock_cli): + type(mock_cli.args).yes = PropertyMock(return_value=True) + type(mock_cli.args).no = PropertyMock(return_value=True) + + with pytest.raises(SystemExit) as e: + subcommand.setup(mock_cli) + + assert e.value.code == 1 + + +def test_setup_reclone(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, choice, git_clone_fork): + type(mock_cli.args).yes = PropertyMock(return_value=False) + type(mock_cli.args).no = PropertyMock(return_value=False) + type(mock_cli.args).home = PropertyMock(return_value=temp_directory) + type(mock_cli.args).fork = 'asdf' + choice.return_value = 'Delete and reclone asdf' + yesno.return_value = True + + subcommand.setup(mock_cli) + + yesno.assert_called_once() + assert 'This will delete your current qmk_firmware directory.' in yesno.call_args.args[0] + git_clone_fork.assert_called_once() + + +def test_setup_reclone_no(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, choice, git_clone_fork): + type(mock_cli.args).yes = PropertyMock(return_value=False) + type(mock_cli.args).no = PropertyMock(return_value=False) + type(mock_cli.args).home = PropertyMock(return_value=temp_directory) + type(mock_cli.args).fork = 'asdf' + choice.return_value = 'Delete and reclone asdf' + yesno.return_value = False + + with pytest.raises(SystemExit) as e: + subcommand.setup(mock_cli) + + assert e.value.code == 1 + git_clone_fork.assert_not_called() + + +def test_setup_clone_diff_fork(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, choice, question, git_clone_fork): + type(mock_cli.args).yes = PropertyMock(return_value=False) + type(mock_cli.args).no = PropertyMock(return_value=False) + type(mock_cli.args).home = PropertyMock(return_value=temp_directory) + choice.return_value = 'Delete and clone a different fork' + yesno.return_value = True + + subcommand.setup(mock_cli) + + yesno.assert_called_once() + assert 'This will delete your current qmk_firmware directory.' in yesno.call_args.args[0] + git_clone_fork.assert_called_once() + + +def test_setup_clone_diff_fork_no(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, choice, question, git_clone_fork): + type(mock_cli.args).yes = PropertyMock(return_value=False) + type(mock_cli.args).no = PropertyMock(return_value=False) + type(mock_cli.args).home = PropertyMock(return_value=temp_directory) + choice.return_value = 'Delete and clone a different fork' + yesno.return_value = False + + with pytest.raises(SystemExit) as e: + subcommand.setup(mock_cli) + + assert e.value.code == 1 + git_clone_fork.assert_not_called() + + +def test_setup_home_exists_not_empty(subcommand, mock_cli, is_qmk_firmware, temp_directory): + type(mock_cli.args).yes = PropertyMock(return_value=False) + type(mock_cli.args).no = PropertyMock(return_value=False) + type(mock_cli.args).home = PropertyMock(return_value=temp_directory) + is_qmk_firmware.return_value = False + (temp_directory / 'asdf').touch() + + with pytest.raises(SystemExit) as e: + subcommand.setup(mock_cli) + + assert e.value.code == 1 + + +def test_setup_missing(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, git_clone_fork): + type(mock_cli.args).yes = PropertyMock(return_value=False) + type(mock_cli.args).no = PropertyMock(return_value=False) + type(mock_cli.args).home = PropertyMock(return_value=temp_directory) + is_qmk_firmware.return_value = False + yesno.return_value = True + + subcommand.setup(mock_cli) + + yesno.assert_called_once() + assert 'Would you like to clone' in yesno.call_args.args[0] + git_clone_fork.assert_called_once() + + +def test_setup_missing_no(subcommand, mock_cli, is_qmk_firmware, temp_directory, yesno, git_clone_fork): + type(mock_cli.args).yes = PropertyMock(return_value=False) + type(mock_cli.args).no = PropertyMock(return_value=False) + type(mock_cli.args).home = PropertyMock(return_value=temp_directory) + is_qmk_firmware.return_value = False + yesno.return_value = False + + subcommand.setup(mock_cli) + + yesno.assert_called_once() + assert 'Would you like to clone' in yesno.call_args.args[0] + git_clone_fork.assert_not_called() + + +def test_setup_chains_args_to_doctor(subcommand, mock_cli, is_qmk_firmware, yesno, choice, git_clone_fork): + type(mock_cli.args).yes = PropertyMock(return_value=True) + type(mock_cli.args).no = PropertyMock(return_value=False) + + subcommand.setup(mock_cli) + + mock_cli.run.assert_called_once() + assert mock_cli.run.call_args.args[0] == [ANY, '--color', '--unicode', 'doctor', '-y'] + + +def test_setup_chains_args_to_doctor_no(subcommand, mock_cli, is_qmk_firmware, yesno, choice, git_clone_fork): + type(mock_cli.args).yes = PropertyMock(return_value=False) + type(mock_cli.args).no = PropertyMock(return_value=True) + mock_cli.config.general.color = False + mock_cli.config.general.unicode = False + + subcommand.setup(mock_cli) + + mock_cli.run.assert_called_once() + assert mock_cli.run.call_args.args[0] == [ANY, '--no-color', '--no-unicode', 'doctor', '-n'] diff --git a/test/test_ci.py b/test/test_ci.py new file mode 100644 index 0000000..2e2c7cc --- /dev/null +++ b/test/test_ci.py @@ -0,0 +1,46 @@ +import pytest +import subprocess + + +@pytest.fixture(autouse=True) +def temp_env(temp_directory, monkeypatch): + qmk_firmware = temp_directory / 'qmk_firmware' + monkeypatch.setenv("QMK_HOME", qmk_firmware.as_posix()) + yield qmk_firmware + + +@pytest.fixture +def run_cli(request): + def run_cli_wrapper(args): + cmd = f'{request.config.rootpath}/qmk' if request.config.getoption("--qmk") == 'wrapper' else 'qmk' + + return subprocess.run([cmd, *args], capture_output=True, text=True) + + return run_cli_wrapper + + +@pytest.mark.system +def test_cli_help(run_cli): + completed_process = run_cli(['clone', '--help']) + + assert 'usage:' in completed_process.stdout + assert completed_process.returncode == 0 + + +@pytest.mark.system +def test_cli_setup_no(run_cli): + completed_process = run_cli(['setup', '-n']) + + assert 'Could not find qmk_firmware!' in completed_process.stderr + assert 'Not cloning qmk_firmware due to user input or --no flag.' in completed_process.stderr + assert completed_process.returncode == 0 + + +@pytest.mark.system +def test_cli_setup_yes(run_cli): + completed_process = run_cli(['setup', '-y']) + + assert 'Successfully cloned https://github.com/qmk/qmk_firmware' in completed_process.stderr + assert 'Added https://github.com/qmk/qmk_firmware as remote upstream' in completed_process.stderr + assert 'QMK Doctor is checking your environment' in completed_process.stderr + assert completed_process.returncode == 0 diff --git a/test/test_git.py b/test/test_git.py new file mode 100644 index 0000000..2b6e4e1 --- /dev/null +++ b/test/test_git.py @@ -0,0 +1,117 @@ +import pytest +import subprocess +from unittest.mock import patch, PropertyMock + +import qmk_cli.git + + +@patch("qmk_cli.git.subprocess.Popen", side_effect=Exception("foobar")) +@patch("qmk_cli.git.cli.log.error") +def test_git_clone_except(mock_log_error, mock_popen): + ret = qmk_cli.git.git_clone('https://github.com/qmk/qmk_firmware', '/tmp', 'master') + + assert "Could not run" in str(mock_log_error.call_args_list) + assert "Exception:foobar" in str(mock_log_error.call_args_list) + assert ret is False + + +@patch("qmk_cli.git.subprocess.Popen") +def test_git_clone_logs_output(mock_popen, capsys): + type(mock_popen().__enter__()).returncode = PropertyMock(return_value=0) + type(mock_popen().__enter__()).stdout = PropertyMock(return_value=['asdf']) + + ret = qmk_cli.git.git_clone('https://github.com/qmk/qmk_firmware', '/tmp', 'master') + + captured = capsys.readouterr().out + assert 'asdf' in captured + assert ret is True + + +@patch("qmk_cli.git.subprocess.Popen") +def test_git_clone_captures_exit_code(mock_popen): + type(mock_popen().__enter__()).returncode = PropertyMock(return_value=1) + + ret = qmk_cli.git.git_clone('https://github.com/qmk/qmk_firmware', '/tmp', 'master') + + assert ret is False + + +@patch("qmk_cli.git.subprocess.Popen", side_effect=Exception("foobar")) +@patch("qmk_cli.git.cli.log.error") +def test_git_set_upstream_except(mock_log_error, mock_popen): + ret = qmk_cli.git.git_set_upstream('/tmp') + + assert "Could not run" in str(mock_log_error.call_args_list) + assert "Exception:foobar" in str(mock_log_error.call_args_list) + assert ret is False + + +@patch("qmk_cli.git.subprocess.Popen") +def test_git_set_upstream_logs_output(mock_popen, capsys): + type(mock_popen().__enter__()).returncode = PropertyMock(return_value=0) + type(mock_popen().__enter__()).stdout = PropertyMock(return_value=['asdf']) + + ret = qmk_cli.git.git_set_upstream('/tmp') + + captured = capsys.readouterr().out + assert 'asdf' in captured + assert ret is True + + +@patch("qmk_cli.git.subprocess.Popen") +def test_git_set_upstream_captures_exit_code(mock_popen): + type(mock_popen().__enter__()).returncode = PropertyMock(return_value=1) + + ret = qmk_cli.git.git_set_upstream('/tmp') + + assert ret is False + + +@patch.object(qmk_cli.git, 'git_clone') +@patch.object(qmk_cli.git, 'git_set_upstream') +def test_git_clone_fork_fail(git_set_upstream, git_clone, temp_directory): + git_clone.return_value = False + git_set_upstream.return_value = False + + ret = qmk_cli.git.git_clone_fork(temp_directory.as_posix(), 'https://github.com', 'qmk/qmk_firmware', 'master', False) + + assert ret is False + + +@patch.object(qmk_cli.git, 'git_clone') +@patch.object(qmk_cli.git, 'git_set_upstream') +def test_git_clone_fork_force(git_set_upstream, git_clone, temp_directory): + (temp_directory / 'asdf').touch() + + git_clone.return_value = True + git_set_upstream.return_value = True + + ret = qmk_cli.git.git_clone_fork(temp_directory.as_posix(), 'https://github.com', 'qmk/qmk_firmware', 'master', True) + + assert ret is True + assert (temp_directory / 'asdf').exists() is False + + +@pytest.mark.integration +def test_git_clone(temp_directory): + qmk_firmware = temp_directory / 'qmk_firmware' + ret = qmk_cli.git.git_clone('https://github.com/qmk/qmk_firmware', qmk_firmware.as_posix(), 'master') + + from qmk_cli.helpers import is_qmk_firmware + + assert is_qmk_firmware(qmk_firmware) is True + assert ret is True + + +@pytest.mark.integration +def test_git_clone_fork(temp_directory): + qmk_firmware = temp_directory / 'qmk_firmware' + qmk_firmware.mkdir() + (qmk_firmware / 'asdf').touch() + ret = qmk_cli.git.git_clone_fork(qmk_firmware.as_posix(), 'https://github.com', 'qmk/qmk_firmware', 'master', True) + + from qmk_cli.helpers import is_qmk_firmware + + assert is_qmk_firmware(qmk_firmware) is True + assert 'https://github.com/qmk/qmk_firmware' in subprocess.run(['git', '-C', qmk_firmware.as_posix(), 'remote', 'get-url', 'upstream'], capture_output=True, text=True).stdout + assert ret is True diff --git a/test/test_helpers.py b/test/test_helpers.py new file mode 100644 index 0000000..75fd1b0 --- /dev/null +++ b/test/test_helpers.py @@ -0,0 +1,246 @@ +import os +import stat +import pytest +from pathlib import Path +from unittest.mock import patch, PropertyMock + +import qmk_cli.helpers + + +@pytest.fixture(autouse=True) +def reset_env(): + cur_dir = os.getcwd() + + yield cur_dir + + os.chdir(cur_dir) + qmk_cli.helpers.find_qmk_firmware.cache_clear() + qmk_cli.helpers.in_qmk_firmware.cache_clear() + qmk_cli.helpers.find_qmk_userspace.cache_clear() + qmk_cli.helpers.in_qmk_userspace.cache_clear() + + +def test_abspath(temp_directory): + tmp = temp_directory.absolute() + ret = qmk_cli.helpers.AbsPath(tmp) + assert ret.samefile(tmp) + + +def test_abspath_str(temp_directory): + tmp = temp_directory.absolute().as_posix() + ret = qmk_cli.helpers.AbsPath(tmp) + assert ret.samefile(tmp) + + +def test_abspath_rel(temp_directory): + with patch.dict('os.environ', {'ORIG_CWD': temp_directory.as_posix()}, clear=True): + tmp = './asdf' + ret = qmk_cli.helpers.AbsPath(tmp) + assert ret.as_posix() == (temp_directory / 'asdf').as_posix() + + +def test_rmtree(temp_directory): + tmp = temp_directory / 'asdf.txt' + tmp.touch() + + qmk_cli.helpers.rmtree(temp_directory) + + assert tmp.exists() is False + + +@pytest.mark.skipif(os.name != "nt", reason="Only triggers issue on windows") +def test_rmtree_readonly(temp_directory): + tmp = temp_directory / 'asdf.txt' + tmp.touch() + os.chmod(tmp, stat.S_IREAD) + + qmk_cli.helpers.rmtree(temp_directory) + + assert tmp.exists() is False + + +def test_is_qmk_firmware(temp_directory): + (temp_directory / 'quantum').mkdir(parents=True) + (temp_directory / 'lib/python/qmk/cli').mkdir(parents=True) + (temp_directory / 'requirements.txt').touch() + (temp_directory / 'requirements-dev.txt').touch() + (temp_directory / 'lib/python/qmk/cli/__init__.py').touch() + + assert qmk_cli.helpers.is_qmk_firmware(temp_directory) is True + + +def test_is_qmk_firmware_partially_missing(temp_directory): + (temp_directory / 'lib/python/qmk/cli').mkdir(parents=True) + (temp_directory / 'lib/python/qmk/cli/__init__.py').touch() + + assert qmk_cli.helpers.is_qmk_firmware(temp_directory) is False + + +def test_find_qmk_firmware(temp_directory): + (temp_directory / 'quantum').mkdir(parents=True) + (temp_directory / 'lib/python/qmk/cli').mkdir(parents=True) + (temp_directory / 'requirements.txt').touch() + (temp_directory / 'requirements-dev.txt').touch() + (temp_directory / 'lib/python/qmk/cli/__init__.py').touch() + os.chdir(temp_directory) + + assert qmk_cli.helpers.find_qmk_firmware().as_posix() == temp_directory.as_posix() + + +def test_find_qmk_firmware_env(temp_directory): + with patch.dict('os.environ', {'QMK_HOME': temp_directory.as_posix()}, clear=True): + assert qmk_cli.helpers.find_qmk_firmware().as_posix() == temp_directory.as_posix() + + +def test_find_qmk_firmware_env_missing(temp_directory): + with patch.dict('os.environ', {'QMK_HOME': (temp_directory / 'asdf').as_posix()}, clear=True): + assert qmk_cli.helpers.find_qmk_firmware().as_posix() == (temp_directory / 'asdf').as_posix() + + +def test_find_qmk_firmware_config(temp_directory): + with patch.object(qmk_cli.helpers, 'cli') as cli: + type(cli.config.user).qmk_home = PropertyMock(return_value=temp_directory.as_posix()) + + assert qmk_cli.helpers.find_qmk_firmware().as_posix() == temp_directory.as_posix() + + +def test_find_qmk_firmware_config_user(): + with patch.object(qmk_cli.helpers, 'cli') as cli: + type(cli.config.user).qmk_home = PropertyMock(return_value='~/asdf') + + assert qmk_cli.helpers.find_qmk_firmware().as_posix() == (Path.home() / 'asdf').as_posix() + + +def test_find_qmk_firmware_default(temp_directory): + with patch('pathlib.Path.home') as home: + home.return_value = temp_directory + + assert qmk_cli.helpers.find_qmk_firmware().as_posix() == (temp_directory / 'qmk_firmware').as_posix() + + +def test_in_qmk_firmware(temp_directory): + (temp_directory / 'quantum').mkdir(parents=True) + (temp_directory / 'lib/python/qmk/cli').mkdir(parents=True) + (temp_directory / 'requirements.txt').touch() + (temp_directory / 'requirements-dev.txt').touch() + (temp_directory / 'lib/python/qmk/cli/__init__.py').touch() + os.chdir(temp_directory) + + assert qmk_cli.helpers.in_qmk_firmware().as_posix() == temp_directory.as_posix() + + +def test_in_qmk_firmware_sub(temp_directory): + (temp_directory / 'quantum').mkdir(parents=True) + (temp_directory / 'lib/python/qmk/cli').mkdir(parents=True) + (temp_directory / 'requirements.txt').touch() + (temp_directory / 'requirements-dev.txt').touch() + (temp_directory / 'lib/python/qmk/cli/__init__.py').touch() + os.chdir(temp_directory / 'quantum') + + assert qmk_cli.helpers.in_qmk_firmware().as_posix() == temp_directory.as_posix() + + +def test_in_qmk_firmware_sub_sub(temp_directory): + (temp_directory / 'quantum').mkdir(parents=True) + (temp_directory / 'lib/python/qmk/cli').mkdir(parents=True) + (temp_directory / 'requirements.txt').touch() + (temp_directory / 'requirements-dev.txt').touch() + (temp_directory / 'lib/python/qmk/cli/__init__.py').touch() + os.chdir(temp_directory / 'lib' / 'python') + + assert qmk_cli.helpers.in_qmk_firmware().as_posix() == temp_directory.as_posix() + + +def test_in_qmk_firmware_invalid(temp_directory): + os.chdir(temp_directory) + + assert qmk_cli.helpers.in_qmk_firmware() is None + + +def test_is_qmk_userspace(temp_directory): + (temp_directory / 'qmk.json').write_text('{"userspace_version": 2}') + + assert qmk_cli.helpers.is_qmk_userspace(temp_directory) is True + + +def test_is_qmk_userspace_missing(temp_directory): + assert qmk_cli.helpers.is_qmk_userspace(temp_directory) is False + + +def test_is_qmk_userspace_invalid(temp_directory): + (temp_directory / 'qmk.json').write_text('asdf') + + assert qmk_cli.helpers.is_qmk_userspace(temp_directory) is False + + +def test_is_qmk_userspace_empty(temp_directory): + (temp_directory / 'qmk.json').touch() + + assert qmk_cli.helpers.is_qmk_userspace(temp_directory) is False + + +def test_find_qmk_userspace(temp_directory): + (temp_directory / 'qmk.json').write_text('{"userspace_version": 2}') + os.chdir(temp_directory) + + assert qmk_cli.helpers.find_qmk_userspace().as_posix() == temp_directory.as_posix() + + +def test_find_qmk_userspace_env(temp_directory): + with patch.dict('os.environ', {'QMK_USERSPACE': temp_directory.as_posix()}, clear=True): + assert qmk_cli.helpers.find_qmk_userspace().as_posix() == temp_directory.as_posix() + + +def test_find_qmk_userspace_env_missing(temp_directory): + with patch.dict('os.environ', {'QMK_USERSPACE': (temp_directory / 'asdf').as_posix()}, clear=True): + assert qmk_cli.helpers.find_qmk_userspace().as_posix() == (temp_directory / 'asdf').as_posix() + + +def test_find_qmk_userspace_config(temp_directory): + with patch.object(qmk_cli.helpers, 'cli') as cli: + type(cli.config.user).overlay_dir = PropertyMock(return_value=temp_directory.as_posix()) + + assert qmk_cli.helpers.find_qmk_userspace().as_posix() == temp_directory.as_posix() + + +def test_find_qmk_userspace_config_user(): + with patch.object(qmk_cli.helpers, 'cli') as cli: + type(cli.config.user).overlay_dir = PropertyMock(return_value='~/asdf') + + assert qmk_cli.helpers.find_qmk_userspace().as_posix() == (Path.home() / 'asdf').as_posix() + + +def test_find_qmk_userspace_default(temp_directory): + with patch('pathlib.Path.home') as home: + home.return_value = temp_directory + + assert qmk_cli.helpers.find_qmk_userspace().as_posix() == (temp_directory / 'qmk_userspace').as_posix() + + +def test_in_qmk_userspace(temp_directory): + (temp_directory / 'qmk.json').write_text('{"userspace_version": 2}') + os.chdir(temp_directory) + + assert qmk_cli.helpers.in_qmk_userspace().as_posix() == temp_directory.as_posix() + + +def test_in_qmk_userspace_sub(temp_directory): + (temp_directory / 'qmk.json').write_text('{"userspace_version": 2}') + (temp_directory / 'keymaps' / 'test').mkdir(parents=True) + os.chdir(temp_directory / 'keymaps') + + assert qmk_cli.helpers.in_qmk_userspace().as_posix() == temp_directory.as_posix() + + +def test_in_qmk_userspace_sub_sub(temp_directory): + (temp_directory / 'qmk.json').write_text('{"userspace_version": 2}') + (temp_directory / 'keymaps' / 'test').mkdir(parents=True) + os.chdir(temp_directory / 'keymaps' / 'test') + + assert qmk_cli.helpers.in_qmk_userspace().as_posix() == temp_directory.as_posix() + + +def test_in_qmk_userspace_invalid(temp_directory): + os.chdir(temp_directory) + + assert qmk_cli.helpers.in_qmk_userspace() is None