diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..23515b4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: Build Release +on: + release: + types: + - published + +permissions: + contents: write + pull-requests: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + + - name: Get the Release Tag + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Update release file + run: | + # Overwrite the file 'release' with the tag name + echo "${{ env.VERSION }}" | sed 's/^v//' > version + + - name: Commit and push updated version file + # Adds a commit with the updated version file and pushes it to the master branch. + # The version file is used by the auto-update script to determine the latest version of the plugin. + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add version + git diff --cached --quiet || git commit -m "Update version to ${{ env.VERSION }}" + git push origin HEAD:master + + - name: Zip Folder + # Create a zip file of the repository, excluding unnecessary files and folders. + # The zip file will be attached to the release. + run: zip -r ${{ github.event.repository.name }}.zip . -x ".git/*" ".github/*" ".DS_Store" ".gitignore" ".python-version" ".editorconfig" ".isort.cfg" "requirements*.txt" "history/*" "tests/*" + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: ${{ github.event.repository.name }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: List files for debugging + run: ls -R + + - name: VirusTotal Scan + # Will run a VirustTotal scan and add the results to the release notes. Requires a VT API key in the repository secrets. + uses: cssnr/virustotal-action@v1 + with: + vt_api_key: ${{ secrets.VT_API_KEY }} + file_globs: ${{ github.event.repository.name }}.zip + release_heading: '### Virus Scan' + update_release: true diff --git a/.github/workflows/unit-testing.yml b/.github/workflows/unit-testing.yml index e3f3873..1096025 100644 --- a/.github/workflows/unit-testing.yml +++ b/.github/workflows/unit-testing.yml @@ -22,20 +22,23 @@ jobs: uses: actions/setup-python@v5.4.0 with: python-version: "3.11" + - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y libjpeg-dev zlib1g-dev libpng-dev python -m pip install --upgrade pip - pip install pytest pytest-xvfb + pip install flake8 pytest pytest-xvfb if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi - #- name: Lint with flake8 - # run: | - # pip install flake8 + + - name: Lint with flake8 + run: | # stop the build if there are Python syntax errors or undefined names - # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + flake8 . --extend-exclude .venv,tests/edmc,utils/dateutil --count --select=E9,F63,F7,F82 --show-source --statistics + + # Enable if you wish for more comprehensive linting, but it can be noisy and may require code changes to pass + #flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with xvfb-pytest run: | - xvfb-run pytest + xvfb-run pytest -m "not slow" diff --git a/README.md b/README.md index 9a604ed..3c9feb4 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,15 @@ Examples using the harness include [Navl's Neutron Dancer](https://github.com/dw ## utils A Library of utilities for EDMC plugins and an EDMC headless test harness. Some utilities are drop-in ready to go, some may require some configuration, and others may need adapting to your plugin. They have comments or README's describing their functionality. + +## .github/workflows + +Some useful `GitHub` workflow scripts. + +### release.yml + +Creates a release `.zip` and puts it through VirusTotal and adds the result to the release notes + +### unit-testing.yml + +Runs `flake8` and `pytest` when code is pushed to the main branch or a PR is created for the main branch. \ No newline at end of file diff --git a/load.py b/load.py index 05f6ced..70e8d6a 100644 --- a/load.py +++ b/load.py @@ -68,7 +68,7 @@ def prefs_changed(cmdr: str, is_beta: bool) -> None: def journal_entry(cmdr, is_beta, system, station, entry, state): """ Parse an incoming journal entry and store the data we need """ - global journal + journal.cmdr = cmdr journal.is_beta = is_beta journal.system = system @@ -78,12 +78,12 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): def dashboard_entry(cmdr:str, is_beta:bool, entry:dict) -> None: """ Handle dashboard state changes """ - global dashboard + dashboard.cmdr = cmdr dashboard.is_beta = is_beta dashboard.entry = entry def capi_fleetcarrier(data:CAPIData): """ Handle Fleet carrier data """ - global carrier + carrier.data = data diff --git a/requirements-dev.txt b/requirements-dev.txt index 8ba2e57..2d37bdb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,8 +1,9 @@ semantic-version ~= 2.10.0 sentry-sdk ~= 2.50.0 l10n ~= 0.1.0 -pytest ~= 7.0.0 requests ~= 2.31.0 +psutil ~= 5.9.0 +pytest ~= 7.0.0 pytest-cov ~= 3.0.0 pytest-mock ~= 3.10.0 -psutil ~= 5.9.0 +flake8 \ No newline at end of file diff --git a/tests/edmc/TkScheduler.py b/tests/edmc/TkScheduler.py new file mode 100644 index 0000000..a6d11c8 --- /dev/null +++ b/tests/edmc/TkScheduler.py @@ -0,0 +1,141 @@ +import threading +import heapq +import tkinter as tk +from typing import Callable +from typing import Optional, Callable, Dict +from time import sleep, monotonic + +class HarnessTkScheduler: + """Thread-safe scheduler for Tk callbacks during tests.""" + + def __init__(self, root: tk.Tk): + self.root = root + self.main_thread_id = threading.get_ident() + self._lock = threading.Lock() + self._pending: list[tuple[float, str, Callable, tuple]] = [] + self._cancelled: set[str] = set() + self._counter = 0 + self._installed = False + + self._orig_after = None + self._orig_after_idle = None + self._orig_after_cancel = None + + self.enqueued_count = 0 + self.executed_count = 0 + self.failures: list[str] = [] + + def install(self) -> None: + """Install Tk monkeypatches for thread-safe scheduling.""" + if self._installed: + return + + self._orig_after = tk.Misc.after + self._orig_after_idle = tk.Misc.after_idle + self._orig_after_cancel = tk.Misc.after_cancel + + scheduler = self + orig_after = self._orig_after + orig_after_idle = self._orig_after_idle + orig_after_cancel = self._orig_after_cancel + + def patched_after(self, ms, func=None, *args): + if func is None: + func = lambda: None + args = () + + if threading.get_ident() == scheduler.main_thread_id: + return orig_after(self, ms, func, *args) + + try: + delay_ms = int(ms) + except Exception: + delay_ms = 0 + + with scheduler._lock: + token = f"harness-after-{scheduler._counter}" + scheduler._counter += 1 + scheduler.enqueued_count += 1 + heapq.heappush( + scheduler._pending, + (monotonic() + max(delay_ms, 0) / 1000.0, token, func, args), + ) + + return token + + def patched_after_idle(self, func, *args): + if threading.get_ident() == scheduler.main_thread_id: + return orig_after_idle(self, func, *args) + + with scheduler._lock: + token = f"harness-after-{scheduler._counter}" + scheduler._counter += 1 + scheduler.enqueued_count += 1 + heapq.heappush(scheduler._pending, (monotonic(), token, func, args)) + + return token + + def patched_after_cancel(self, id): + if isinstance(id, str) and id.startswith("harness-after-"): + with scheduler._lock: + scheduler._cancelled.add(id) + return None + + return orig_after_cancel(self, id) + + tk.Misc.after = patched_after # type: ignore[assignment] + tk.Misc.after_idle = patched_after_idle # type: ignore[assignment] + tk.Misc.after_cancel = patched_after_cancel # type: ignore[assignment] + self._installed = True + + def uninstall(self) -> None: + """Restore original Tk behavior.""" + if not self._installed: + return + + if self._orig_after is not None: + tk.Misc.after = self._orig_after + if self._orig_after_idle is not None: + tk.Misc.after_idle = self._orig_after_idle + if self._orig_after_cancel is not None: + tk.Misc.after_cancel = self._orig_after_cancel + + self._installed = False + + def pending_count(self) -> int: + with self._lock: + return len(self._pending) + + def drain_due_callbacks(self) -> int: + """Run all queued callbacks that are due on the main thread.""" + if threading.get_ident() != self.main_thread_id: + return 0 + + now = monotonic() + ready: list[tuple[str, Callable, tuple]] = [] + + with self._lock: + while self._pending and self._pending[0][0] <= now: + _, token, func, args = heapq.heappop(self._pending) + ready.append((token, func, args)) + + ran = 0 + for token, func, args in ready: + with self._lock: + if token in self._cancelled: + self._cancelled.remove(token) + continue + + try: + func(*args) + ran += 1 + self.executed_count += 1 + except Exception as exc: + self.failures.append(f"Scheduler callback {token}: {type(exc).__name__}: {exc}") + + return ran + + def consume_failures(self) -> list[str]: + failures = self.failures[:] + self.failures.clear() + return failures diff --git a/tests/pytest.ini b/tests/pytest.ini index 8e1643b..57e4957 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -1,3 +1,4 @@ [pytest] markers = - live_requests: run the harness with the live requests backend \ No newline at end of file + live_requests: run the harness with the live requests backend + slow: tests that take a long time so should not be run by an automation \ No newline at end of file diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 91e2b45..b35970e 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -49,7 +49,6 @@ def test_harness_initialization(self, harness:TestHarness) -> None: def test_plugin_registration(self, harness:TestHarness) -> None: """Test that the plugin registered correctly.""" - global plugin assert harness.plugin_dir != "" assert harness.parent is not None @@ -60,6 +59,7 @@ def test_plugin_registration(self, harness:TestHarness) -> None: def test_mock_config(self, harness:TestHarness) -> None: """Test the mock config.""" + harness.config.set('DummyPlugin_intval', 42) harness.config.set('DummyPlugin_strval', "Hello, World!") @@ -69,6 +69,7 @@ def test_mock_config(self, harness:TestHarness) -> None: def test_load_state(self, harness:TestHarness) -> None: """Test that state files are loaded correctly.""" + assert harness.monitor.state['Credits'] == 1000000 state_data = harness.load_state('state.json') assert state_data is not None @@ -81,6 +82,7 @@ def test_load_state(self, harness:TestHarness) -> None: class TestHTTPRequests: def test_mock_http_requests(self, harness:TestHarness) -> None: """Test that mock requests work.""" + queue_response('get', MockResponse(200, url='https://testy.com/file.txt', json_data={'result': 'success'}), url='https://testy.com/file.txt') @@ -100,6 +102,7 @@ def test_live_http_requests(self, harness:TestHarness) -> None: def test_mock_capi_event(self, harness) -> None: """ Test a capi event is processed and saved correctly. """ + # Load a minimalist sample CAPI json and verify it doesn't fail. capi_data:dict = harness.get_config_data('capi_data.json') assert capi_data is not None @@ -111,6 +114,7 @@ class TestJournalEvents: def test_null_event(self, harness) -> None: """ Just a music event to test the machinery of loading and playing events. """ + harness.load_events("journal_events.json") harness.play_sequence("null", 0.1) assert journal.cmdr == "Testy" @@ -118,6 +122,7 @@ def test_null_event(self, harness) -> None: def test_startup_events(self, harness) -> None: """ Test a sequence of journal events are processed and saved correctly. """ + harness.load_events("journal_events.json") harness.play_sequence("startup", 0.1) @@ -127,6 +132,7 @@ def test_startup_events(self, harness) -> None: def test_cargo_event_state(self, harness) -> None: """ Test cargo events. Verify the cargo count is updated in the state and the Cargo.json is saved. """ + amt:int = 1298 assert harness.monitor.state['Cargo']['steel'] == 0 harness.load_events("journal_events.json", count=amt, price=4179) @@ -136,6 +142,7 @@ def test_cargo_event_state(self, harness) -> None: def test_cargo_event_json(self, harness) -> None: """ Test cargo events. Verify the cargo count is updated in the state and the Cargo.json is saved. """ + amt:int = 1298 harness.load_events("journal_events.json", count=amt, price=4179) harness.play_sequence("cargo", 0.1) @@ -147,6 +154,7 @@ def test_cargo_event_json(self, harness) -> None: def incomplete_test_backpack_event(self, harness) -> None: """ Test backpack events. Verify the cargo count is updated in the state and the Backpack.json is saved. """ + seq:dict = harness.load_events("journal_events.json") harness.play_sequence("backpack", 0.1) @@ -158,6 +166,7 @@ def incomplete_test_backpack_event(self, harness) -> None: def test_event_sequence(self, harness) -> None: """ Test a sequence of journal events are processed and saved correctly. """ + harness.load_events("journal_events.json") harness.play_sequence("jump", 0.1) @@ -166,6 +175,10 @@ def test_event_sequence(self, harness) -> None: assert journal.system == "Bleae Thua ED-D c12-5" assert journal.entry['event'] == "NavBeaconScan" + @pytest.mark.slow + def test_manual_only(self, harness) -> None: + """ A demo slow test that won't be run by the unit-testing.yml. """ + assert True if __name__ == '__main__': pytest.main([__file__, '-v', '--tb=short'])