From 5de08da6dc189f65d031ab25a6001e3b4aaa3f43 Mon Sep 17 00:00:00 2001 From: Byeonghoon Yoo Date: Tue, 5 May 2026 22:33:24 +0900 Subject: [PATCH 1/2] ci: run pull request workflows on all bases Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .github/workflows/ci.yml | 1 - .github/workflows/docs-seo.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd9aba2..4a9d1d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,6 @@ on: branches: [main] tags: ["v*"] pull_request: - branches: [main] jobs: lint: diff --git a/.github/workflows/docs-seo.yml b/.github/workflows/docs-seo.yml index 97d4439..28b53c9 100644 --- a/.github/workflows/docs-seo.yml +++ b/.github/workflows/docs-seo.yml @@ -6,7 +6,6 @@ name: Docs & SEO Review # when the detected changes do not warrant a documentation review. on: pull_request: - branches: [main] paths: - "src/kwin_mcp/**" - "pyproject.toml" From 5e9bb9616ef85f9189ca858d24ab29ca8da466c9 Mon Sep 17 00:00:00 2001 From: Byeonghoon Yoo Date: Tue, 5 May 2026 00:19:32 +0900 Subject: [PATCH 2/2] chore(tests): bootstrap pytest infra + CI guards --- .github/workflows/ci.yml | 42 +++++++++- CLAUDE.md | 4 + pyproject.toml | 8 +- scripts/ci_guards.sh | 162 +++++++++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 24 ++++++ tests/test_smoke.py | 14 ++++ uv.lock | 69 +++++++++++++++++ 8 files changed, 318 insertions(+), 5 deletions(-) create mode 100755 scripts/ci_guards.sh create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_smoke.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a9d1d1..1848ac9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,15 @@ jobs: steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v7 - - run: uvx --python ${{ matrix.python-version }} ruff check src/ - - run: uvx --python ${{ matrix.python-version }} ruff format --check src/ + - run: uvx --python ${{ matrix.python-version }} ruff check src/ tests/ scripts/ + - run: uvx --python ${{ matrix.python-version }} ruff format --check src/ tests/ scripts/ + + guards: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run CI guards + run: bash scripts/ci_guards.sh type-check: runs-on: ubuntu-latest @@ -27,7 +34,34 @@ jobs: run: | sudo apt-get update sudo apt-get install -y libcairo2-dev libgirepository-2.0-dev libdbus-1-dev pkg-config - - run: uv run ty check src/ + - run: uv run ty check src/ tests/ scripts/ + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Skip 3.15 (alpha): rpds-py has no wheel and Rust source build fails on cpython-3.15.0a8. + python-version: ["3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev libgirepository-2.0-dev libdbus-1-dev pkg-config + - name: Run non-kwin pytest (tolerate exit 5 = no tests collected) + # Ignore tests/integration: those tests require a real KWin/Wayland session + # and import kwin_mcp.input which dlopens libei.so.1 (not available on GHA). + run: | + set +e + uv run --python ${{ matrix.python-version }} pytest -m "not kwin" --ignore=tests/integration + rc=$? + set -e + if [ "$rc" -eq 0 ] || [ "$rc" -eq 5 ]; then + exit 0 + fi + exit "$rc" build: runs-on: ubuntu-latest @@ -47,7 +81,7 @@ jobs: publish: if: startsWith(github.ref, 'refs/tags/v') - needs: [lint, type-check, build] + needs: [lint, type-check, test, build, guards] runs-on: ubuntu-latest environment: pypi permissions: diff --git a/CLAUDE.md b/CLAUDE.md index 7a8f062..141009c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,11 @@ See ROADMAP.md. Key modules: | `src/kwin_mcp/server.py` | tool-registration, code-general | Check tool count, README tool tables, CONTRIBUTING structure, **integrations/*/skills/*/SKILL.md tool list freshness** | | `src/kwin_mcp/session.py` | session-api | Check README arch diagram, CONTRIBUTING session docs | | `src/kwin_mcp/core.py` | engine-api, code-general | Check README arch description, CONTRIBUTING structure | +| `src/kwin_mcp/dbus_args.py` | code-general | Check concrete numbers, CONTRIBUTING file listing, **integrations/*/skills/*/SKILL.md "30 capabilities" reference** | +| `src/kwin_mcp/window.py` | engine-api, code-general | Check README arch description, CONTRIBUTING structure | +| `src/kwin_mcp/accessibility_worker.py` | engine-api, code-general | Check README arch description, CONTRIBUTING structure | | `src/kwin_mcp/*.py` (any) | code-general | Check concrete numbers, CONTRIBUTING file listing, **integrations/*/skills/*/SKILL.md "30 capabilities" reference** | +| `docs/design/*.md` | code-general | Check docs/design architecture notes and related docs-seo guidance | | `pyproject.toml` | package-metadata | Sync keywords with `.claude/positioning.yml`; check CLAUDE.md keyword tiers; **run `python3 scripts/sync_plugin_version.py` to propagate version to integrations manifests** | | `CHANGELOG.md` | changelog-update | Sync docs-seo.md positioning; add new search intents | | `README.md` | readme-update | Sync docs-seo.md positioning; update CLAUDE.md keyword tiers | diff --git a/pyproject.toml b/pyproject.toml index 6a93f7d..088431f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,13 @@ requires = ["uv_build>=0.10.3,<0.12.0"] build-backend = "uv_build" [dependency-groups] -dev = ["ruff>=0.11.0", "ty>=0.0.1"] +dev = ["ruff>=0.11.0", "ty>=0.0.1", "pytest>=8.0", "pytest-asyncio>=0.23"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra -q" +asyncio_mode = "auto" +markers = ["kwin: marks tests requiring a real KWin virtual session (deselect with -m 'not kwin')"] [tool.ruff] target-version = "py312" diff --git a/scripts/ci_guards.sh b/scripts/ci_guards.sh new file mode 100755 index 0000000..9374307 --- /dev/null +++ b/scripts/ci_guards.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# CI Guards - enforce backend overhaul invariants. +# +# Each guard prints a clear failure message identifying the rule and +# the offending location(s), then exits non-zero. On a clean tree, +# the script exits 0 silently (per-guard "ok" lines are emitted to +# stderr for visibility in CI logs). +# +# Guards: +# 1. No reachable `print()` in src/kwin_mcp/ (excluding __main__ blocks) +# 2. Every `kwin_wayland` invocation in tests/, scripts/, docs/ has --virtual +# 3. core.py does not import accessibility_worker at module top +# 4. No os.fork or raw multiprocessing.Process( in src/kwin_mcp/ + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +fail=0 + +log_ok() { + echo "[ci_guards] OK: $1" >&2 +} + +log_fail() { + echo "[ci_guards] FAIL: $1" >&2 + fail=1 +} + +# --------------------------------------------------------------------------- +# Guard 1: No reachable `print()` in MCP server code paths under src/kwin_mcp/. +# Excludes: +# - __main__.py (module entrypoint guard blocks) +# - cli.py (interactive REPL whose contract IS to write to stdout) +# Rationale: any print() reachable from server.py would corrupt the MCP +# stdio JSON-RPC stream. +# --------------------------------------------------------------------------- +guard_1_no_print() { + local matches + matches="$(grep -rn -E '^[[:space:]]*print\(' src/kwin_mcp/ \ + --include='*.py' 2>/dev/null \ + | grep -v '__main__' \ + | grep -v 'src/kwin_mcp/cli.py:' || true)" + if [[ -n "$matches" ]]; then + log_fail "Guard 1 - reachable print() found in MCP server code paths (forbidden, use logging instead):" + echo "$matches" >&2 + return + fi + log_ok "Guard 1 - no reachable print() in MCP server code paths" +} + +# --------------------------------------------------------------------------- +# Guard 2: Every kwin_wayland reference in tests/, scripts/, docs/ uses +# --virtual on the same line. +# --------------------------------------------------------------------------- +guard_2_kwin_virtual() { + local matches="" + for d in tests scripts docs; do + if [[ -d "$d" ]]; then + local found + # Exclusions (non-launch references): + # - this guards script itself (documents kwin_wayland) + # - pkill cleanup lines (process kill, not a launch) + # - --version queries (introspection, not a launch) + # - lines whose first non-whitespace token after `path:lineno:` is `echo` + # (documentation strings inside shell scripts) + found="$(grep -rn 'kwin_wayland' "$d" 2>/dev/null \ + | grep -v -- '--virtual' \ + | grep -v 'scripts/ci_guards.sh' \ + | grep -v 'pkill' \ + | grep -v -- '--version' \ + | grep -Pv '^\S+:\s*echo\s' || true)" + if [[ -n "$found" ]]; then + matches+="$found"$'\n' + fi + fi + done + if [[ -n "$matches" ]]; then + log_fail "Guard 2 - kwin_wayland invocation without --virtual in tests/scripts/docs:" + printf '%s' "$matches" >&2 + return + fi + log_ok "Guard 2 - all kwin_wayland references in tests/scripts/docs use --virtual" +} + +# --------------------------------------------------------------------------- +# Guard 3: src/kwin_mcp/core.py must not import accessibility_worker at module +# top level. Uses Python AST so conditional/inline imports are tolerated. +# --------------------------------------------------------------------------- +guard_3_no_module_top_worker_import() { + local target="src/kwin_mcp/core.py" + if [[ ! -f "$target" ]]; then + log_ok "Guard 3 - $target absent, skipping" + return + fi + local result + result="$(python3 - "$target" <<'PYEOF' +import ast +import sys + +path = sys.argv[1] +with open(path, encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=path) + +violations = [] +for node in tree.body: + if isinstance(node, ast.Import): + for alias in node.names: + if "accessibility_worker" in alias.name: + violations.append(f"line {node.lineno}: import {alias.name}") + elif isinstance(node, ast.ImportFrom): + mod = node.module or "" + if "accessibility_worker" in mod: + names = ", ".join(a.name for a in node.names) + violations.append(f"line {node.lineno}: from {mod} import {names}") + else: + for alias in node.names: + if "accessibility_worker" in alias.name: + violations.append( + f"line {node.lineno}: from {mod} import {alias.name}" + ) + +if violations: + print("\n".join(violations)) + sys.exit(1) +PYEOF +)" || { + log_fail "Guard 3 - core.py has module-top accessibility_worker import (must be lazy/local):" + echo "$result" >&2 + return + } + log_ok "Guard 3 - core.py has no module-top accessibility_worker import" +} + +# --------------------------------------------------------------------------- +# Guard 4: No os.fork or raw multiprocessing.Process( in src/kwin_mcp/. +# --------------------------------------------------------------------------- +guard_4_no_fork_or_process() { + local matches + matches="$(grep -rn -E 'os\.fork\b|multiprocessing\.Process\(' src/kwin_mcp/ \ + --include='*.py' 2>/dev/null || true)" + if [[ -n "$matches" ]]; then + log_fail "Guard 4 - os.fork or multiprocessing.Process( found in src/kwin_mcp/ (forbidden):" + echo "$matches" >&2 + return + fi + log_ok "Guard 4 - no os.fork or multiprocessing.Process( in src/kwin_mcp/" +} + +guard_1_no_print +guard_2_kwin_virtual +guard_3_no_module_top_worker_import +guard_4_no_fork_or_process + +if [[ "$fail" -ne 0 ]]; then + echo "[ci_guards] One or more guards failed." >&2 + exit 1 +fi + +echo "[ci_guards] All guards passed." >&2 +exit 0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d6a40b5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import pytest + +from kwin_mcp.session import Session, SessionConfig, SessionInfo + +if TYPE_CHECKING: + from collections.abc import Iterator + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="session") +def virtual_session() -> Iterator[SessionInfo]: + session = Session() + info = session.start(SessionConfig(socket_name="wayland-test")) + try: + yield info + finally: + logger.info("Stopping virtual KWin session") + session.stop() diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..44decbc --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import dbus +import dbus.bus +import pytest + + +@pytest.mark.kwin +def test_virtual_session_reachable(virtual_session) -> None: + assert virtual_session.dbus_address + + bus = dbus.bus.BusConnection(virtual_session.dbus_address) + kwin_obj = bus.get_object("org.kde.KWin", "/org/kde/KWin") + dbus.Interface(kwin_obj, "org.freedesktop.DBus.Peer").Ping() diff --git a/uv.lock b/uv.lock index 869b219..d13c38d 100644 --- a/uv.lock +++ b/uv.lock @@ -234,6 +234,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -274,6 +283,8 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, { name = "ty" }, ] @@ -288,6 +299,8 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, { name = "ruff", specifier = ">=0.11.0" }, { name = "ty", specifier = ">=0.0.1" }, ] @@ -317,6 +330,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + [[package]] name = "pillow" version = "12.1.1" @@ -386,6 +408,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycairo" version = "1.29.0" @@ -514,6 +545,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pygobject" version = "3.54.5" @@ -537,6 +577,35 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1"