-
Notifications
You must be signed in to change notification settings - Fork 9
Add E2E test #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add E2E test #54
Changes from all commits
83a597e
c85dbc4
9723943
0696e75
7ad517d
3069c92
529b37c
f079fbb
e6cbb72
5859c9a
596013a
0b042aa
570e851
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| name: Test | ||
|
|
||
| on: | ||
| pull_request_target: | ||
| workflow_dispatch: | ||
| push: | ||
| branches: | ||
| - main | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
|
|
||
| jobs: | ||
| test-e2e-pr: | ||
| name: E2E Tests (${{ matrix.os }}) | ||
| if: github.event_name == 'pull_request_target' | ||
| environment: e2e-test | ||
| 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 | ||
| with: | ||
| repository: ${{ github.event.pull_request.head.repo.full_name }} | ||
| ref: ${{ github.event.pull_request.head.sha }} | ||
|
|
||
| - 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 }} | ||
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| click | ||
| pytest | ||
| mypy | ||
| pyinstaller | ||
| requests | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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!" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
VikramGoyal23 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| 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", EXERCISE_NAME], cwd=exercises_dir) | ||
| res.assert_success() | ||
|
|
||
| exercise_folder = exercises_dir / EXERCISE_NAME | ||
| 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", HANDS_ON_NAME], cwd=exercises_dir) | ||
| res.assert_success() | ||
|
|
||
| hands_on_folder = exercises_dir / HANDS_ON_NAME | ||
| assert hands_on_folder.is_dir() | ||
jovnc marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| from pathlib import Path | ||
|
|
||
| from ..constants import EXERCISE_NAME | ||
| 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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not very sure if it is necessary here, but would it be better to check that
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For a start, I'll test the command output, but we can refactor in the future to test more in depth, just need to verify that this works properly first before introducing more thorough tests. |
||
|
|
||
|
|
||
| def test_progress_reset(runner: BinaryRunner, exercises_dir: Path) -> None: | ||
| """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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that it is fine for us to leave this for now. Are we going to implement E2E test for reset for all/most of the exercises? Some exercises are easier to reset, while some are more prone to regressions.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We shouldn't test the behaviour of the reset command for individual exercises. E2E testing is not meant to catch all bugs, as they're expensive to run and we can't possibly test every possibility. They're mainly there to verify the basic behaviour of the command, to ensure no significant regressions are introduced. |
||
| res.assert_success() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| 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" | ||
| ) | ||
|
|
||
| try: | ||
| yield exercises_path | ||
| finally: | ||
| rmtree(work_dir) # ensure cleanup even if tests fail |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| EXERCISE_NAME = "under-control" | ||
| HANDS_ON_NAME = "hp-first-commit" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| from dataclasses import dataclass | ||
| from typing import List, Self | ||
| import re | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class RunResult: | ||
| """Represents the result of running a command-line process.""" | ||
|
|
||
| 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 | ||
jovnc marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Uh oh!
There was an error while loading. Please reload this page.