From 83a597eb3d4ac5e24717c35a9bfde682469d5fdb Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:47:45 +0800 Subject: [PATCH 01/13] feat: Add draft implementation of E2E tests --- .github/workflows/test.yml | 48 ++++++++++++++++++++++++++ scripts/build_test_e2e.sh | 13 +++++++ tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 8 +++++ tests/e2e/result.py | 41 ++++++++++++++++++++++ tests/e2e/runner.py | 71 ++++++++++++++++++++++++++++++++++++++ tests/e2e/test_version.py | 9 +++++ 7 files changed, 190 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100755 scripts/build_test_e2e.sh create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/result.py create mode 100644 tests/e2e/runner.py create mode 100644 tests/e2e/test_version.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..23ac15c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,48 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +jobs: + test-e2e: + name: E2E Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.13"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Build binary + run: | + pyinstaller --onefile main.py --name gitmastery + + - name: Set binary path (Unix) + if: runner.os != 'Windows' + run: echo "GITMASTERY_BINARY=./dist/gitmastery" >> $GITHUB_ENV + + - name: Set binary path (Windows) + if: runner.os == 'Windows' + run: echo "GITMASTERY_BINARY=./dist/gitmastery.exe" >> $env:GITHUB_ENV + + - name: Run E2E tests + run: | + python -m pytest tests/e2e/ -v diff --git a/scripts/build_test_e2e.sh b/scripts/build_test_e2e.sh new file mode 100755 index 0000000..398460e --- /dev/null +++ b/scripts/build_test_e2e.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Build and run E2E tests for gitmastery + +set -e +FILENAME="gitmastery" + +echo "Building gitmastery binary..." +pyinstaller --onefile main.py --name $FILENAME + +echo "Running E2E tests..." +pytest tests/e2e -v + +echo "All E2E tests passed!" diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..8562ddf --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from .runner import BinaryRunner + + +@pytest.fixture(scope="session") +def runner() -> BinaryRunner: + return BinaryRunner.from_env() diff --git a/tests/e2e/result.py b/tests/e2e/result.py new file mode 100644 index 0000000..b67fd88 --- /dev/null +++ b/tests/e2e/result.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from typing import List, Self +import re + + +@dataclass(frozen=True) +class RunResult: + """Immutable result from a binary execution.""" + + stdout: str + stderr: str + returncode: int + command: List[str] + + def assert_success(self) -> Self: + """Assert the command exited with code 0.""" + ERROR_MSG = ( + f"Expected exit code 0, got {self.returncode}\n" + f"Command: {' '.join(self.command)}\n" + f"stdout:\n{self.stdout}\n" + f"stderr:\n{self.stderr}" + ) + assert self.returncode == 0, ERROR_MSG + return self + + def assert_stdout_contains(self, text: str) -> Self: + """Assert stdout contains the given text.""" + ERROR_MSG = ( + f"Expected stdout to contain {text!r}\nActual stdout:\n{self.stdout}" + ) + assert text in self.stdout, ERROR_MSG + return self + + def assert_stdout_matches(self, pattern: str, flags: int = 0) -> Self: + """Assert stdout matches a regex pattern.""" + ERROR_MSG = ( + f"Expected stdout to match pattern {pattern!r}\n" + f"Actual stdout:\n{self.stdout}" + ) + assert re.search(pattern, self.stdout, flags), ERROR_MSG + return self diff --git a/tests/e2e/runner.py b/tests/e2e/runner.py new file mode 100644 index 0000000..2574228 --- /dev/null +++ b/tests/e2e/runner.py @@ -0,0 +1,71 @@ +import os +import platform +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Sequence, Self +from .result import RunResult + + +@dataclass +class BinaryRunner: + """Cross-platform runner for the gitmastery binary.""" + + binary_path: str + project_root: Path + + @classmethod + def from_env( + cls, + env_var: str = "GITMASTERY_BINARY", + project_root: Optional[Path] = None, + ) -> Self: + """Build a runner from an environment variable.""" + if project_root is None: + project_root = Path(__file__).resolve().parents[2] + + raw = os.environ.get(env_var, "").strip() + if raw: + binary_path = raw + else: + system = platform.system().lower() + if system == "windows": + binary_path = str(project_root / "dist" / "gitmastery.exe") + else: + binary_path = str(project_root / "dist" / "gitmastery") + + return cls(binary_path=binary_path, project_root=project_root) + + def run( + self, + args: Sequence[str] = (), + *, + cwd: Optional[Path] = None, + env: Optional[Dict[str, str]] = None, + timeout: int = 30, + stdin_text: Optional[str] = None, + ) -> RunResult: + """Execute the binary with args and return a RunResult.""" + cmd = [self.binary_path] + list(args) + run_env = os.environ.copy() + if env: + run_env.update(env) + + run_env.setdefault("NO_COLOR", "1") + run_env.setdefault("PYTHONIOENCODING", "utf-8") + + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else str(self.project_root), + env=run_env, + capture_output=True, + text=True, + timeout=timeout if timeout > 0 else None, + input=stdin_text, + ) + return RunResult( + stdout=proc.stdout, + stderr=proc.stderr, + returncode=proc.returncode, + command=cmd, + ) diff --git a/tests/e2e/test_version.py b/tests/e2e/test_version.py new file mode 100644 index 0000000..bc9d638 --- /dev/null +++ b/tests/e2e/test_version.py @@ -0,0 +1,9 @@ +from .runner import BinaryRunner + + +def test_version(runner: BinaryRunner) -> None: + """Test the version command output.""" + res = runner.run(["version"]) + res.assert_success() + res.assert_stdout_contains("Git-Mastery app is") + res.assert_stdout_matches(r"v\d+\.\d+\.\d+") From c85dbc4b395894064c9959d8137df5992ecbb9f8 Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:51:02 +0800 Subject: [PATCH 02/13] chore: Add pytest as dependency --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 4c51d2e..f44867f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ click +pytest mypy pyinstaller requests From 97239434ba646eedf4846f36739355283003860e Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:41:48 +0800 Subject: [PATCH 03/13] chore: Add pytest utilities for e2e testing --- tests/e2e/conftest.py | 34 ++++++++++++++++++++++++++++++++++ tests/e2e/result.py | 2 +- tests/e2e/runner.py | 2 ++ tests/e2e/utils.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/utils.py diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 8562ddf..0300361 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,8 +1,42 @@ +from collections.abc import Generator +from pathlib import Path + import pytest +from .utils import rmtree from .runner import BinaryRunner @pytest.fixture(scope="session") def runner() -> BinaryRunner: + """ + Return a BinaryRunner instance for the gitmastery binary. + """ return BinaryRunner.from_env() + + +@pytest.fixture(scope="session") +def exercises_dir( + runner: BinaryRunner, tmp_path_factory: pytest.TempPathFactory +) -> Generator[Path, None, None]: + """ + Run setup once and return the path to the exercises directory. + Tears down by deleting the entire working directory after all tests complete. + """ + work_dir = tmp_path_factory.mktemp("e2e-tests-tmp") + + # Send newline to accept the default directory name prompt + res = runner.run(["setup"], cwd=work_dir, stdin_text="\n") + assert res.returncode == 0, ( + f"Setup failed with exit code {res.returncode}\n" + f"stdout:\n{res.stdout}\nstderr:\n{res.stderr}" + ) + + exercises_path = work_dir / "gitmastery-exercises" + assert exercises_path.is_dir(), ( + f"Expected directory {exercises_path} to exist after setup" + ) + + yield exercises_path + + rmtree(work_dir) diff --git a/tests/e2e/result.py b/tests/e2e/result.py index b67fd88..2322f17 100644 --- a/tests/e2e/result.py +++ b/tests/e2e/result.py @@ -5,7 +5,7 @@ @dataclass(frozen=True) class RunResult: - """Immutable result from a binary execution.""" + """Represents the result of running a command-line process.""" stdout: str stderr: str diff --git a/tests/e2e/runner.py b/tests/e2e/runner.py index 2574228..ebfc5f0 100644 --- a/tests/e2e/runner.py +++ b/tests/e2e/runner.py @@ -51,6 +51,7 @@ def run( if env: run_env.update(env) + # Disable color output and set encoding to utf-8 for consistent output across OS run_env.setdefault("NO_COLOR", "1") run_env.setdefault("PYTHONIOENCODING", "utf-8") @@ -63,6 +64,7 @@ def run( timeout=timeout if timeout > 0 else None, input=stdin_text, ) + return RunResult( stdout=proc.stdout, stderr=proc.stderr, diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py new file mode 100644 index 0000000..fe24236 --- /dev/null +++ b/tests/e2e/utils.py @@ -0,0 +1,35 @@ +import os +import shutil +import stat +import time +from pathlib import Path +from typing import Union + +MAX_DELETE_RETRIES = 20 +MAX_RETRY_INTERVAL = 0.2 + + +def rmtree(folder_name: Union[str, Path]) -> None: + """ + Remove a directory tree. + + Raises RuntimeError if the folder still exists after max retries. + """ + if not os.path.exists(folder_name): + return + + def force_remove_readonly(func, path, _): + os.chmod(path, stat.S_IWRITE) + func(path) + + shutil.rmtree(folder_name, onerror=force_remove_readonly) + + # Wait for folder to be fully deleted (Windows can be slow with permissions) + max_retries = MAX_DELETE_RETRIES + for _ in range(max_retries): + if not os.path.exists(folder_name): + return + time.sleep(MAX_RETRY_INTERVAL) + + # If folder still exists after retries, raise error + raise RuntimeError(f"Failed to delete {folder_name} after {max_retries} retries") From 0696e75a605dd0c4558408c3a4f23d751eef3420 Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:42:13 +0800 Subject: [PATCH 04/13] feat: Add setup command test --- tests/e2e/commands/__init__.py | 0 tests/e2e/commands/test_setup.py | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 tests/e2e/commands/__init__.py create mode 100644 tests/e2e/commands/test_setup.py diff --git a/tests/e2e/commands/__init__.py b/tests/e2e/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/commands/test_setup.py b/tests/e2e/commands/test_setup.py new file mode 100644 index 0000000..ea19f50 --- /dev/null +++ b/tests/e2e/commands/test_setup.py @@ -0,0 +1,19 @@ +from pathlib import Path + + +def test_setup(exercises_dir: Path) -> None: + """ + Test that setup creates the progress directory, progress.json, .gitmastery.json and .gitmastery.log + Setup command already called in conftest.py for test setup + """ + progress_dir = exercises_dir / "progress" + assert progress_dir.is_dir(), f"Expected {progress_dir} to exist" + + progress_file = progress_dir / "progress.json" + assert progress_file.is_file(), f"Expected {progress_file} to exist" + + config_file = exercises_dir / ".gitmastery.json" + assert config_file.is_file(), f"Expected {config_file} to exist" + + log_file = exercises_dir / ".gitmastery.log" + assert log_file.is_file(), f"Expected {log_file} to exist" From 7ad517dbda1af0ad895eea672b368686c3a78d75 Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:42:27 +0800 Subject: [PATCH 05/13] feat: Add check command test --- tests/e2e/commands/test_check.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/e2e/commands/test_check.py diff --git a/tests/e2e/commands/test_check.py b/tests/e2e/commands/test_check.py new file mode 100644 index 0000000..0a20e4f --- /dev/null +++ b/tests/e2e/commands/test_check.py @@ -0,0 +1,15 @@ +from ..runner import BinaryRunner + + +def test_check_git(runner: BinaryRunner) -> None: + """Test the check git command output.""" + res = runner.run(["check", "git"]) + res.assert_success() + res.assert_stdout_contains("Git is installed") + + +def test_check_github(runner: BinaryRunner) -> None: + """Test the check gh command output.""" + res = runner.run(["check", "github"]) + res.assert_success() + res.assert_stdout_contains("Github CLI is installed") From 3069c92c1c994afce56b06f5364be6d83ed4826e Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:42:44 +0800 Subject: [PATCH 06/13] feat: Add download command test --- tests/e2e/commands/test_download.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/e2e/commands/test_download.py diff --git a/tests/e2e/commands/test_download.py b/tests/e2e/commands/test_download.py new file mode 100644 index 0000000..664ca9a --- /dev/null +++ b/tests/e2e/commands/test_download.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from ..runner import BinaryRunner + + +def test_download_exercise(runner: BinaryRunner, exercises_dir: Path) -> None: + """Test the download command output successfully performs the download for exercise.""" + res = runner.run(["download", "under-control"], cwd=exercises_dir) + res.assert_success() + + exercise_folder = exercises_dir / "under-control" + assert exercise_folder.is_dir() + + exercise_config = exercise_folder / ".gitmastery-exercise.json" + assert exercise_config.is_file() + + exercise_readme = exercise_folder / "README.md" + assert exercise_readme.is_file() + + +def test_download_hands_on(runner: BinaryRunner, exercises_dir: Path) -> None: + """Test the download command output successfully performs the download for hands-on.""" + res = runner.run(["download", "hp-first-commit"], cwd=exercises_dir) + res.assert_success() + + hands_on_folder = exercises_dir / "hp-first-commit" + assert hands_on_folder.is_dir() From 529b37c138721ef10fcdc9af5a95ee99c1927ba3 Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:42:52 +0800 Subject: [PATCH 07/13] feat: Add progress command test --- tests/e2e/commands/test_progress.py | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/e2e/commands/test_progress.py diff --git a/tests/e2e/commands/test_progress.py b/tests/e2e/commands/test_progress.py new file mode 100644 index 0000000..f23c744 --- /dev/null +++ b/tests/e2e/commands/test_progress.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from ..runner import BinaryRunner + + +def test_progress_show(runner: BinaryRunner, exercises_dir: Path) -> None: + """Test that progress show displays progress.""" + res = runner.run(["progress", "show"], cwd=exercises_dir) + res.assert_success() + res.assert_stdout_contains("Your Git-Mastery progress:") + + +def test_progress_sync_on_then_off(runner: BinaryRunner, exercises_dir: Path) -> None: + """Test that progress sync on followed by sync off works correctly.""" + # Enable sync + res_on = runner.run(["progress", "sync", "on"], cwd=exercises_dir) + res_on.assert_success() + res_on.assert_stdout_contains( + "You have setup the progress tracker for Git-Mastery!" + ) + + # Disable sync (send 'y' to confirm) + res_off = runner.run( + ["progress", "sync", "off"], cwd=exercises_dir, stdin_text="y\n" + ) + res_off.assert_success() + res_off.assert_stdout_contains("Successfully removed your remote sync") + + +def test_progress_reset(runner: BinaryRunner, exercises_dir: Path) -> None: + """Test that progress reset works correctly.""" + # TODO: Implement this test + pass From f079fbbab2ac5fce9aefaa089703ec501a26958f Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:03:50 +0800 Subject: [PATCH 08/13] chore: Clean up code --- tests/e2e/commands/test_download.py | 9 +++++---- tests/e2e/conftest.py | 7 ++++--- tests/e2e/constants.py | 2 ++ 3 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/constants.py diff --git a/tests/e2e/commands/test_download.py b/tests/e2e/commands/test_download.py index 664ca9a..a9bf839 100644 --- a/tests/e2e/commands/test_download.py +++ b/tests/e2e/commands/test_download.py @@ -1,14 +1,15 @@ from pathlib import Path +from ..constants import EXERCISE_NAME, HANDS_ON_NAME from ..runner import BinaryRunner def test_download_exercise(runner: BinaryRunner, exercises_dir: Path) -> None: """Test the download command output successfully performs the download for exercise.""" - res = runner.run(["download", "under-control"], cwd=exercises_dir) + res = runner.run(["download", EXERCISE_NAME], cwd=exercises_dir) res.assert_success() - exercise_folder = exercises_dir / "under-control" + exercise_folder = exercises_dir / EXERCISE_NAME assert exercise_folder.is_dir() exercise_config = exercise_folder / ".gitmastery-exercise.json" @@ -20,8 +21,8 @@ def test_download_exercise(runner: BinaryRunner, exercises_dir: Path) -> None: def test_download_hands_on(runner: BinaryRunner, exercises_dir: Path) -> None: """Test the download command output successfully performs the download for hands-on.""" - res = runner.run(["download", "hp-first-commit"], cwd=exercises_dir) + res = runner.run(["download", HANDS_ON_NAME], cwd=exercises_dir) res.assert_success() - hands_on_folder = exercises_dir / "hp-first-commit" + hands_on_folder = exercises_dir / HANDS_ON_NAME assert hands_on_folder.is_dir() diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 0300361..c73f5c0 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -37,6 +37,7 @@ def exercises_dir( f"Expected directory {exercises_path} to exist after setup" ) - yield exercises_path - - rmtree(work_dir) + try: + yield exercises_path + finally: + rmtree(work_dir) # ensure cleanup even if tests fail diff --git a/tests/e2e/constants.py b/tests/e2e/constants.py new file mode 100644 index 0000000..ceccff4 --- /dev/null +++ b/tests/e2e/constants.py @@ -0,0 +1,2 @@ +EXERCISE_NAME = "under-control" +HANDS_ON_NAME = "hp-first-commit" From e6cbb723238487d21f6f467901819787584aa6da Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:03:58 +0800 Subject: [PATCH 09/13] feat: Add progress and verify tests --- tests/e2e/commands/test_progress.py | 9 ++++++--- tests/e2e/commands/test_verify.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/commands/test_verify.py diff --git a/tests/e2e/commands/test_progress.py b/tests/e2e/commands/test_progress.py index f23c744..c6aeb74 100644 --- a/tests/e2e/commands/test_progress.py +++ b/tests/e2e/commands/test_progress.py @@ -1,5 +1,6 @@ from pathlib import Path +from ..constants import EXERCISE_NAME from ..runner import BinaryRunner @@ -28,6 +29,8 @@ def test_progress_sync_on_then_off(runner: BinaryRunner, exercises_dir: Path) -> def test_progress_reset(runner: BinaryRunner, exercises_dir: Path) -> None: - """Test that progress reset works correctly.""" - # TODO: Implement this test - pass + """Test that progress reset works correctly after verify has run.""" + exercise_dir = exercises_dir / EXERCISE_NAME + res = runner.run(["progress", "reset"], cwd=exercise_dir) + # TODO: verify that the progress has actually been reset + res.assert_success() diff --git a/tests/e2e/commands/test_verify.py b/tests/e2e/commands/test_verify.py new file mode 100644 index 0000000..af09a1b --- /dev/null +++ b/tests/e2e/commands/test_verify.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from ..constants import EXERCISE_NAME +from ..runner import BinaryRunner + + +def test_verify_exercise(runner: BinaryRunner, exercises_dir: Path) -> None: + """Test that verify runs on a downloaded exercise.""" + exercise_dir = exercises_dir / EXERCISE_NAME + res = runner.run(["verify"], cwd=exercise_dir) + res.assert_success() + # TODO: check that the correct tests have been run + res.assert_stdout_contains("Starting verification of") + res.assert_stdout_contains("Verification completed.") From 5859c9a5f3276db11ea29c1e552b2a7cd7abefd8 Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:41:16 +0800 Subject: [PATCH 10/13] fix: Update gitmastery binary location --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 23ac15c..1328f40 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,11 +37,11 @@ jobs: - name: Set binary path (Unix) if: runner.os != 'Windows' - run: echo "GITMASTERY_BINARY=./dist/gitmastery" >> $GITHUB_ENV + run: echo "GITMASTERY_BINARY=${{ github.workspace }}/dist/gitmastery" >> $GITHUB_ENV - name: Set binary path (Windows) if: runner.os == 'Windows' - run: echo "GITMASTERY_BINARY=./dist/gitmastery.exe" >> $env:GITHUB_ENV + run: echo "GITMASTERY_BINARY=${{ github.workspace }}/dist/gitmastery.exe" >> $env:GITHUB_ENV - name: Run E2E tests run: | From 596013a4cd35e52c298e963123339dc429c75505 Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:54:46 +0800 Subject: [PATCH 11/13] chore: Set up git and gh in CI --- .github/workflows/test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1328f40..d8c33c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,13 @@ jobs: if: runner.os == 'Windows' run: echo "GITMASTERY_BINARY=${{ github.workspace }}/dist/gitmastery.exe" >> $env:GITHUB_ENV + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + - name: Run E2E tests + env: + GH_TOKEN: ${{ secrets.GH_PAT }} run: | python -m pytest tests/e2e/ -v From 0b042aaf9c85b634a826aa878ae741419088b194 Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:05:20 +0800 Subject: [PATCH 12/13] chore: update test.yml to improve security --- .github/workflows/test.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8c33c0..676a0f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,14 +1,20 @@ name: Test on: - pull_request: + pull_request_target: + workflow_dispatch: push: branches: - main +permissions: + contents: read + pull-requests: read + jobs: test-e2e: name: E2E Tests (${{ matrix.os }}) + environment: e2e-test runs-on: ${{ matrix.os }} strategy: @@ -18,7 +24,15 @@ jobs: python-version: ["3.13"] steps: - - name: Checkout code + - name: Checkout on pull_request_target + if: github.event_name == 'pull_request_target' + uses: actions/checkout@v4 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + + - name: Checkout on push to main and workflow_dispatch + if: github.event_name != 'pull_request_target' uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} From 570e851830eb32956d5415d13ea3e86dce5a2c2a Mon Sep 17 00:00:00 2001 From: jovnc <95868357+jovnc@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:33:56 +0800 Subject: [PATCH 13/13] chore: split test into 2 workflows --- .github/workflows/test.yml | 54 ++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 676a0f1..0d8406e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,8 +12,9 @@ permissions: pull-requests: read jobs: - test-e2e: + test-e2e-pr: name: E2E Tests (${{ matrix.os }}) + if: github.event_name == 'pull_request_target' environment: e2e-test runs-on: ${{ matrix.os }} @@ -24,15 +25,58 @@ jobs: python-version: ["3.13"] steps: - - name: Checkout on pull_request_target - if: github.event_name == 'pull_request_target' + - name: Checkout uses: actions/checkout@v4 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} - - name: Checkout on push to main and workflow_dispatch - if: github.event_name != 'pull_request_target' + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Build binary + run: | + pyinstaller --onefile main.py --name gitmastery + + - name: Set binary path (Unix) + if: runner.os != 'Windows' + run: echo "GITMASTERY_BINARY=${{ github.workspace }}/dist/gitmastery" >> $GITHUB_ENV + + - name: Set binary path (Windows) + if: runner.os == 'Windows' + run: echo "GITMASTERY_BINARY=${{ github.workspace }}/dist/gitmastery.exe" >> $env:GITHUB_ENV + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Run E2E tests + env: + GH_TOKEN: ${{ secrets.GH_PAT }} + run: | + python -m pytest tests/e2e/ -v + + test-e2e: + name: E2E Tests (${{ matrix.os }}) + if: github.event_name != 'pull_request_target' + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.13"] + + steps: + - name: Checkout uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }}