Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 11 additions & 8 deletions .github/workflows/unit-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 3 additions & 3 deletions load.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
5 changes: 3 additions & 2 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
141 changes: 141 additions & 0 deletions tests/edmc/TkScheduler.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion tests/pytest.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[pytest]
markers =
live_requests: run the harness with the live requests backend
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
15 changes: 14 additions & 1 deletion tests/test_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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!")

Expand All @@ -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
Expand All @@ -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')

Expand All @@ -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
Expand All @@ -111,13 +114,15 @@ 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"
assert journal.is_beta == False

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)

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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'])
Loading