Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4d37344
Give three skipped tests the isolation their skip reason asked for
JE-Chen Aug 20, 2026
c3c9728
Make the two standing quality thresholds ratchet instead of sit still
JE-Chen Aug 20, 2026
7356b12
Silence the Semgrep audit rule on the mypy invocation
JE-Chen Aug 20, 2026
ac9fe7f
Give the platform seam a type contract instead of an accidental one
JE-Chen Aug 21, 2026
7867900
Clear the linux_wayland cluster off the typing contract
JE-Chen Aug 21, 2026
c6075d7
Clear the x11 and macOS backends off the typing contract
JE-Chen Aug 21, 2026
74027f8
Fix what the typing gate found in the Windows backend
JE-Chen Aug 21, 2026
ca08ffa
Make the folder-sync tests wait for the baseline instead of guessing
JE-Chen Aug 22, 2026
f9c1fc6
Clear the utils/remote_desktop cluster off the typing contract
JE-Chen Aug 22, 2026
9cde59d
Clear fifteen more modules off the typing contract
JE-Chen Aug 22, 2026
edbf086
Settle the Win32 ctypes exemptions with 28 justified suppressions
JE-Chen Aug 22, 2026
283e320
Clear the WinUSB and clipboard clusters, and fix what they were hiding
JE-Chen Aug 22, 2026
31c2d5e
Clear the GUI cluster, and three failures the mixin noise was hiding
JE-Chen Aug 22, 2026
f322a53
Fix a command that had never worked, and clear the MCP cluster
JE-Chen Aug 22, 2026
b7735d9
Clear the long tail, and two invariants only the call graph knew
JE-Chen Aug 22, 2026
4fa556e
Clear the action executor off the typing contract
JE-Chen Aug 22, 2026
d0b49cf
Clear thirteen more, and give lastrowid one place to be checked
JE-Chen Aug 22, 2026
f68e02b
Empty the typing contract's exemption list but for one module
JE-Chen Aug 22, 2026
080a434
Empty the typing contract's exemption list
JE-Chen Aug 22, 2026
d43f7ea
Give the seam's last two names a contract, one per platform
JE-Chen Aug 23, 2026
0463801
Measure coverage from before pytest loads this package, not after
JE-Chen Aug 23, 2026
bb7e8b9
Pin the scroll guard's platform instead of taking the host's
JE-Chen Aug 23, 2026
272457e
Point the dev setup at the coverage command that measures correctly
JE-Chen Aug 23, 2026
cef9f36
Set the coverage floor from the corrected matrix: 69
JE-Chen Aug 23, 2026
d87653a
Say plainly that both halves of the threshold entry are done
JE-Chen Aug 23, 2026
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
42 changes: 36 additions & 6 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ jobs:
shell: bash
# Quoted: `--only-binary :all:` puts a colon-space inside the
# scalar, which YAML reads as a mapping and refuses.
run: "pip install --only-binary :all: ruff==0.15.22 bandit==1.9.4 pytest==9.1.1 pytest-timeout==2.4.0 pytest-rerunfailures==15.1 pytest-cov==7.0.0 PySide6==6.11.1"
run: "pip install --only-binary :all: ruff==0.15.22 bandit==1.9.4 pytest==9.1.1 pytest-timeout==2.4.0 pytest-rerunfailures==15.1 coverage==7.15.4 PySide6==6.11.1"

# Paths come from `testpaths` in pyproject.toml. Do NOT pass an explicit
# path here: an argument overrides testpaths, which previously meant the
Expand All @@ -137,20 +137,46 @@ jobs:
# Linux runs under a real Xvfb rather than Qt's offscreen platform:
# the X11 backend opens a display at import time, and offscreen
# would hide exactly the breakage this job exists to find.
- name: Run headless pytest suite
#
# `coverage run -m pytest`, NOT `pytest --cov`, and that is load-bearing:
# this package registers a `pytest11` entry point, so pytest imports
# `je_auto_control.utils.pytest_plugin.plugin` while loading plugins —
# which executes `je_auto_control/__init__.py`, the facade, and with it
# several hundred modules. pytest-cov only starts measuring after that,
# so every one of those modules' import-time lines was recorded as never
# executed. Measured on this tree: 11,962 statements, 24 points, and the
# worst-hit files were the biggest ones (`action_executor` +786,
# `_handlers` +684). A package that registers a pytest plugin cannot
# measure itself with `pytest --cov`; `coverage run` starts first.
- name: Run headless pytest suite under coverage
shell: bash
run: >-
${{ runner.os == 'Linux' && 'xvfb-run -a -s "-screen 0 1280x800x24"' || '' }}
pytest -v --tb=short --timeout=120
--cov=je_auto_control --cov-report=term-missing
--cov-report=xml --cov-fail-under=35
python -m coverage run -m pytest -v --tb=short --timeout=120

# XML before the floor check, so a square that fails the floor still
# uploads the report that says which lines it was short of.
- name: Write the coverage XML
shell: bash
run: python -m coverage xml

- name: Enforce the coverage floor
shell: bash
run: python -m coverage report # the floor is `fail_under` in pyproject.toml, so the ratchet lives in exactly one place

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.os }}-${{ matrix.python-version }}
path: coverage.xml

# The job id is still `typing-stable-api` because it is a required check and
# renaming it silently drops the requirement, but the scope is no longer the
# stable API alone: mypy now checks the whole package, minus the shrink-only
# list in `test/verify/typing_contract_exempt.txt`. The verify script also
# runs the three target platforms mypy can be pointed at, so the Windows and
# macOS backends are checked from this Ubuntu runner rather than skipped.
typing-stable-api:
runs-on: ubuntu-latest
steps:
Expand All @@ -160,4 +186,8 @@ jobs:
python-version: "3.12"
- run: pip install -e . # NOSONAR githubactions:S8541,githubactions:S8544 # reason: installs the checked-out project itself, there is no upstream version to lock and the build must run
- run: "pip install --only-binary :all: mypy==2.3.0"
- run: mypy je_auto_control/api je_auto_control/utils/failure_bundle
# Deliberately NOT installing the optional extras: the contract forces
# every non-base third-party module to `Any` so the result cannot depend
# on what is installed, and installing them here would only hide a
# regression in that arrangement.
- run: python test/verify/typing_contract_verify.py
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,29 @@ only when documented here with a migration path.

### Changed

- **`mouse_scroll()` rejects a scroll direction the platform has no axis for.**
A name outside `special_mouse_keys_table` used to be passed down to the
backend unchanged, which meant `int('scroll_upp')` on Wayland and uinput and
an Xlib failure on X11 — deep in the backend, with the offending name nowhere
in the message. It now raises `AutoControlCantFindKeyException` naming the
direction, the same answer the button table has always given for an unknown
button name. Windows and macOS are unaffected: they have a single wheel axis
and never read the direction.
- `je_auto_control.stop_record()` returns an empty list where it used to
return `None`. It has always been annotated `-> list`, but the failure path
fell off the end of the function, so a caller that did not write
`stop_record() or []` iterated over `None` and raised in its own code
instead. `stop_record_timeline()` already returned `[]` on the same
failure; the two now agree.
- `je_auto_control.mouse_scroll()` reports its return type as
`Tuple[int, Union[int, str]]`. The value has not changed — X11 and Wayland
still hand back the backend axis code the direction name resolved to, and
every other platform the name itself — the signature just no longer claims
it is always a `str`.
- The Windows screen backend's `size()` returns a `tuple`, not a `list`.
The macOS, X11 and Wayland backends all returned tuples already, and the
public `screen_size()` has always been annotated `Tuple[int, int]`; every
caller unpacks the two values, so nothing that used it needs changing.
- The MCP HTTP transport answers `GET /mcp` differently. It used to return
`405` with `{"error": "GET stream not supported"}` for every request; it now
serves the session's SSE stream when the request carries
Expand Down Expand Up @@ -357,6 +380,51 @@ only when documented here with a migration path.

### Fixed

- **Typing text through the key-event route raised `AttributeError` on the
three platforms that cannot do it.** `type_unicode_keys()` (and
`AC_type_unicode_keys` / `ac_type_unicode_keys`) called the backend's
`type_unicode_unit` outright, and only Windows has one, so macOS, X11 and
Wayland raised an exception from outside the `AutoControlException` family
that the executor, the background poll loops and the request handlers each
catch in one `except` — it escaped every containment boundary in the
project. It now raises `AutoControlKeyboardException` pointing at
`type_unicode_text()`, which picks a route that works on any platform.
- **A backend that could not report the cursor aborted the script instead of
raising what the API promises.** `press_mouse` / `release_mouse` /
`click_mouse` with an omitted `x` or `y` unpacked `get_mouse_position()`
without checking it for `None`, so a backend that answers "I don't know"
raised `TypeError` from the unpacking — outside the
`AutoControlMouseException` family every containment boundary catches. It
now raises `AutoControlMouseException`. `mouse_scroll` reached the same
unpacking through `_scroll_to` and now skips the pre-move instead, which is
the graceful degradation its own comment already documented for backends
that cannot report the cursor.
- **`je_auto_control.windows.message.window_message` could not be imported
at all.** It did `from ...windows_window_manage import FindWindowW`, and
that module has no such name — `FindWindowW` is a method on its private
`user32` handle — so importing `window_message` raised `ImportError` on
every Windows machine. It now calls the module's public
`get_one_window_hwnd`, which is also the one that declares HWND-width
argtypes rather than letting ctypes truncate a 64-bit handle to `c_int`.
- **Importing the Win32 input backend no longer writes into
`ctypes.wintypes`.** `win32_ctype_input` set `wintypes.ULONG_PTR =
wintypes.WPARAM` on the standard library's own module. Nothing in this
package ever read it back, so the only effect the assignment could have was
on some other library in the same process asking `ctypes.wintypes` whether
it has `ULONG_PTR`.
- **Stopping an X11 recording that was never started raised instead of
returning nothing.** The X11 listener's `stop_record()` handed back the
`None` its queue attribute was constructed with, and the recorder one frame
up reads `.queue` off that result, so `stop_record()` without a preceding
`record()` produced an `AttributeError` that the wrapper caught and logged
as a failure. It now returns an empty queue, so the public `stop_record()`
returns the empty list it documents.
- **`check_key_is_press()` passed `None` to the backend for an unknown key
name.** A name the virtual-key table has no entry for became `None` and was
handed to the platform backend anyway: a `TypeError` on Windows and a silent
`False` on X11 — that is, "no, it is not pressed" for a key that does not
exist. It now logs the lookup failure and returns `None`, which is the
documented "could not answer" value.
- The MCP HTTP transport no longer tries to drain a request body it has
already read. Any `4xx` decided *after* the body was parsed — the new
unknown-session `404` and duplicate-stream `409`, and the pre-existing
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,20 @@ pip install -r dev_requirements.txt # dev deps
pip install -e .[gui] # + GUI extra
python -m pytest test/unit_test/headless # headless unit tests
python -m pytest test/integrated_test/ # cross-module workflows
python -m coverage run -m pytest # the suite WITH coverage (see below)
python -m coverage report # enforces `fail_under` from pyproject
python -m build # build
```

**Coverage is measured with `coverage run -m pytest`, never `pytest --cov`.**
This package registers a `pytest11` entry point, so pytest imports
`je_auto_control.utils.pytest_plugin.plugin` — and with it the whole facade —
while loading plugins, before pytest-cov starts. Several hundred modules then
have their import-time lines recorded as never executed: measured, that is
11,962 statements and ~24 percentage points (52.22% vs 72.05% on the same
suite). `test/unit_test/headless/test_coverage_measurement.py` holds CI to the
correct spelling.

`pyproject.toml` pins `python_files = ["test_*.py"]` on purpose: the `*_test.py` files under `test/unit_test/` are manual demo scripts whose module bodies drive the real mouse and keyboard on import. Never loosen that setting.

## Feature Delivery Rules
Expand Down
Loading
Loading