Install
$ agentstack add skill-affaan-m-ecc-windows-desktop-e2e Open-source listing, not yet scanned by AgentStack. Follow the source repository for install instructions.
Security review
⚠ Flagged1 finding(s); flagged for manual review. · v0.1.0 How review works →
- • Prompt-injection patterns
- • Secret / credential exfiltration
- • Dangerous shell & filesystem operations
- • Untrusted network calls
- • Known-malicious package signatures
- high Dangerous shell/eval execution.
What it can access
- ✓ Network access No
- ● Filesystem access Used
- ● Shell / process execution Used
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Windows Desktop E2E Testing
End-to-end testing for Windows native desktop applications using pywinauto backed by Windows UI Automation (UIA). Covers WPF, WinForms, Win32/MFC, and Qt (5.x / 6.x) — with Qt-specific guidance as a dedicated section.
When to Activate
- Writing or running E2E tests for a Windows native desktop application
- Setting up a desktop GUI test suite from scratch
- Diagnosing flaky or failing desktop automation tests
- Adding testability (AutomationId, accessible names) to an existing app
- Integrating desktop E2E into a CI/CD pipeline (GitHub Actions
windows-latest)
When NOT to Use
- Web applications → use
e2e-testingskill (Playwright) - Electron / CEF / WebView2 apps → the HTML layer needs browser automation, not UIA
- Mobile apps → use platform-specific tools (UIAutomator, XCUITest)
- Pure unit or integration tests that don't need a running GUI
Core Concepts
All Windows desktop automation relies on UI Automation (UIA), a Windows-built-in accessibility API. Every supported framework exposes a tree of UIA elements with properties Claude can read and act on:
Your test (Python)
└── pywinauto (UIA backend)
└── Windows UI Automation API ← built into Windows, framework-agnostic
└── App's UIA provider ← each framework ships its own
└── Running .exe
UIA quality by framework:
| Framework | AutomationId | Reliability | Notes | |-----------|-------------|-------------|-------| | WPF | 5/5 | Excellent | x:Name maps directly to AutomationId | | WinForms | 4/5 | Good | AccessibleName = AutomationId | | UWP / WinUI 3 | 5/5 | Excellent | Full Microsoft support | | Qt 6.x | 5/5 | Excellent | Accessibility enabled by default; class names change to Qt6* | | Qt 5.15+ | 4/5 | Good | Improved Accessibility module | | Qt 5.7–5.14 | 3/5 | Fair | Needs QT_ACCESSIBILITY=1; objectName manual | | Win32 / MFC | 3/5 | Fair | Control IDs accessible; text matching common |
Setup & Prerequisites
# Python 3.8+, Windows only
pip install pywinauto pytest pytest-html Pillow pytest-timeout
# Optional: screen recording
# Install ffmpeg and add to PATH: https://ffmpeg.org/download.html
Verify UIA is reachable:
from pywinauto import Desktop
Desktop(backend="uia").windows() # lists all top-level windows
Install Accessibility Insights for Windows (free, from Microsoft) — your DevTools equivalent for inspecting the UIA element tree before writing any test.
Testability Setup (by Framework)
The single most impactful thing you can do is give every interactive control a stable AutomationId before writing tests.
WPF
WinForms
// Set in designer or code
usernameInput.AccessibleName = "usernameInput";
passwordInput.AccessibleName = "passwordInput";
btnLogin.AccessibleName = "btnLogin";
lblError.AccessibleName = "lblError";
Win32 / MFC
// Control resource IDs in .rc file are exposed as AutomationId strings
// IDC_EDIT_USERNAME -> AutomationId "1001"
// Prefer SetWindowText for Name; add IAccessible for richer support
Qt — see dedicated section below
Page Object Model
tests/
├── conftest.py # app launch fixture, failure screenshot
├── pytest.ini
├── config.py
├── pages/
│ ├── __init__.py # required for imports
│ ├── base_page.py # locators, wait, screenshot helpers
│ ├── login_page.py
│ └── main_page.py
├── tests/
│ ├── __init__.py
│ ├── test_login.py
│ └── test_main_flow.py
└── artifacts/ # screenshots, videos, logs
base_page.py
import os, time
from pywinauto import Desktop
from config import ACTION_TIMEOUT, ARTIFACT_DIR
class BasePage:
def __init__(self, window):
self.window = window
# --- Locators (priority order) ---
def by_id(self, auto_id, **kw):
"""AutomationId — most stable. Use as first choice."""
return self.window.child_window(auto_id=auto_id, **kw)
def by_name(self, name, **kw):
"""Visible text / accessible name."""
return self.window.child_window(title=name, **kw)
def by_class(self, cls, index=0, **kw):
"""Control class + index — fragile, avoid if possible."""
return self.window.child_window(class_name=cls, found_index=index, **kw)
# --- Waits ---
def wait_visible(self, spec, timeout=ACTION_TIMEOUT):
spec.wait("visible", timeout=timeout)
return spec
def wait_gone(self, spec, timeout=ACTION_TIMEOUT):
spec.wait_not("visible", timeout=timeout)
return spec
def wait_window(self, title, timeout=ACTION_TIMEOUT):
"""Wait for a new top-level window (dialogs, child windows)."""
dlg = Desktop(backend="uia").window(title=title)
dlg.wait("visible", timeout=timeout)
return dlg
def wait_until(self, fn, timeout=ACTION_TIMEOUT, interval=0.3):
"""Poll an arbitrary condition — use when UIA events are unreliable."""
deadline = time.time() + timeout
while time.time() For new projects prefer the **Tier 1 sandbox fixture** (see below) — it adds filesystem isolation at zero extra cost. This basic fixture is for minimal/legacy setups only.
```python
import os, pytest
os.environ["QT_ACCESSIBILITY"] = "1" # Required for Qt 5.x UIA support
from pywinauto import Application
from config import APP_PATH, MAIN_WINDOW_TITLE, LAUNCH_TIMEOUT, ARTIFACT_DIR
@pytest.fixture
def app(request):
if not APP_PATH:
pytest.exit("APP_PATH environment variable is not set", returncode=1)
proc = Application(backend="uia").start(APP_PATH, timeout=LAUNCH_TIMEOUT)
win = proc.window(title=MAIN_WINDOW_TITLE)
win.wait("visible", timeout=LAUNCH_TIMEOUT)
yield win
# Screenshot on failure
if getattr(getattr(request.node, "rep_call", None), "failed", False):
os.makedirs(ARTIFACT_DIR, exist_ok=True)
try:
win.capture_as_image().save(
os.path.join(ARTIFACT_DIR, f"FAIL_{request.node.name}.png")
)
except Exception:
pass
# Graceful exit first, force-kill as fallback
# proc is a pywinauto Application — use wait_for_process_exit(), not wait_for_process()
try:
win.close()
proc.wait_for_process_exit(timeout=5)
except Exception:
proc.kill()
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
setattr(item, f"rep_{outcome.get_result().when}", outcome.get_result())
config.py
import os
APP_PATH = os.environ.get("APP_PATH", "") # set via env — no default path
MAIN_WINDOW_TITLE = os.environ.get("APP_TITLE", "")
LAUNCH_TIMEOUT = int(os.environ.get("LAUNCH_TIMEOUT", "15"))
ACTION_TIMEOUT = int(os.environ.get("ACTION_TIMEOUT", "10"))
ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts")
pytest.ini
[pytest]
testpaths = tests
markers =
smoke: fast smoke tests for critical paths
flaky: known-unstable tests
addopts = -v --tb=short --html=artifacts/report.html --self-contained-html
Locator Strategy
AutomationId > Name (text) > ClassName + index > XPath
(stable) (readable) (fragile) (last resort)
Inspect with Accessibility Insights → Properties pane → look for AutomationId first.
# Inspect at runtime — paste into a REPL to explore the tree
win.print_control_identifiers()
# or narrow scope:
win.child_window(auto_id="groupBox1").print_control_identifiers()
Wait Patterns
# Wait for control to appear
page.wait_visible(page.by_id("statusLabel"))
# Wait for control to disappear (e.g. loading spinner)
page.wait_gone(page.by_id("spinnerOverlay"))
# Wait for a dialog to pop up
dlg = page.wait_window("Confirm Delete")
# Custom condition (e.g. text changes)
page.wait_until(lambda: page.get_text(page.by_id("lblStatus")) == "Ready")
Never use time.sleep() as primary synchronization — use wait() or wait_until().
Artifact Management
# Screenshot on demand
page.screenshot("after_login")
# Full-screen capture (when window is off-screen or minimised)
import pyautogui
pyautogui.screenshot("artifacts/fullscreen.png")
# Screen recording with ffmpeg (start before test, stop after)
import subprocess
def start_recording(name):
return subprocess.Popen([
"ffmpeg", "-f", "gdigrab", "-framerate", "10",
"-i", "desktop", "-y", f"artifacts/videos/{name}.mp4"
], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def stop_recording(proc):
proc.stdin.write(b"q"); proc.stdin.flush(); proc.wait(timeout=10)
Per-Step Trace (opt-in)
The default failure screenshot is often too thin for diagnosing flaky tests. The step-level trace below is off by default — enable it only when reproducing a flaky case.
Enable
E2E_TRACE=1 pytest tests/test_login.py -v
# Include typed text in the JSONL log (DO NOT use on tests that type credentials/PII):
E2E_TRACE=1 E2E_TRACE_INCLUDE_TEXT=1 pytest ...
Patch into BasePage
import os, json, time
TRACE_ENABLED = os.environ.get("E2E_TRACE") == "1"
TRACE_INCLUDE_TEXT = os.environ.get("E2E_TRACE_INCLUDE_TEXT") == "1"
class BasePage:
_step = 0
def _trace(self, action, spec=None, text=None):
if not TRACE_ENABLED:
return
BasePage._step += 1
idx = f"{BasePage._step:03d}"
os.makedirs(ARTIFACT_DIR, exist_ok=True)
try:
self.window.capture_as_image().save(
os.path.join(ARTIFACT_DIR, f"step_{idx}_{action}.png"))
except Exception:
pass # capture failure must not break the test
rec = {
"ts": time.time(), "step": BasePage._step, "action": action,
"locator": getattr(spec, "criteria", None),
"text": text if TRACE_INCLUDE_TEXT else ("" if text else None),
}
with open(os.path.join(ARTIFACT_DIR, "trace.jsonl"), "a") as f:
f.write(json.dumps(rec) + "\n")
def click(self, spec):
self.wait_visible(spec); self._trace("click_before", spec)
spec.click_input(); self._trace("click_after", spec)
def type_text(self, spec, text):
self.wait_visible(spec); self._trace("type_before", spec, text)
# ... existing set_edit_text / keyboard fallback ...
self._trace("type_after", spec)
Caveats
- PII / credentials:
type_textcontent is `by default. Never setE2ETRACEINCLUDE_TEXT=1` on login or payment flows. - Overhead: ~50–200ms per action + one PNG per step on disk. Don't enable on the default CI matrix — only on a dedicated flake-repro job.
- Artifact bloat: a long flow produces tens of MB; tune
retention-daysaccordingly. - Parallel/rerun hygiene: this simple example appends to
trace.jsonland uses a class-level counter. Clear the artifact directory before reruns, and use per-worker artifact dirs for parallel tests. - Coverage gap: actions performed outside
BasePage(rawpywinautocalls in test code) are not traced.
Flaky Test Handling
# Quarantine — equivalent to Playwright's test.fixme()
@pytest.mark.skip(reason="Flaky: animation race on slow CI. Issue #42")
def test_animated_transition(self, app): ...
# Skip in CI only
@pytest.mark.skipif(os.environ.get("CI") == "true", reason="Flaky in CI #43")
def test_heavy_load(self, app): ...
Common causes and fixes:
| Cause | Fix | |-------|-----| | Control not ready | Replace time.sleep with wait_visible | | Window not focused | Add win.set_focus() before interactions | | Animation in progress | wait_until(lambda: not loading_indicator.exists()) | | Dialog timing | wait_window(title, timeout=15) | | CI display not ready | Set DISPLAY or use virtual desktop in CI | | set_edit_text raises NotImplementedError | UIA ValuePattern missing (common on Qt 5.x) — BasePage.type_text already falls back to keyboard.send_keys | | Control exists but wait_visible times out | Window minimised or off-screen — call win.restore() + win.set_focus() before waiting |
Test Isolation & Sandbox
Three tiers of isolation — use the lightest tier that satisfies your needs.
Tier 1 — Filesystem Isolation (default, always use)
Each test gets its own APPDATA / LOCALAPPDATA / TEMP via subprocess.Popen and Application.connect(). pytest's tmp_path fixture handles cleanup automatically.
# conftest.py — replace the basic `app` fixture with this
import os, subprocess, pytest
from pywinauto import Application
from config import APP_PATH, APP_ARGS, APP_TITLE, LAUNCH_TIMEOUT, ACTION_TIMEOUT, ARTIFACT_DIR
@pytest.fixture(scope="function")
def app(request, tmp_path):
"""Fresh process + isolated user-data dirs per test."""
if not APP_PATH:
pytest.exit("APP_PATH not set", returncode=1)
# Redirect all per-user storage to an isolated tmp directory
sandbox_env = os.environ.copy()
sandbox_env["QT_ACCESSIBILITY"] = "1"
sandbox_env["APPDATA"] = str(tmp_path / "AppData" / "Roaming")
sandbox_env["LOCALAPPDATA"] = str(tmp_path / "AppData" / "Local")
sandbox_env["TEMP"] = sandbox_env["TMP"] = str(tmp_path / "Temp")
for p in (sandbox_env["APPDATA"], sandbox_env["LOCALAPPDATA"], sandbox_env["TEMP"]):
os.makedirs(p, exist_ok=True)
if not APP_TITLE:
pytest.exit("APP_TITLE environment variable is not set", returncode=1)
# shlex.split handles quoted args with spaces; plain split() breaks on them
import shlex
# Launch via subprocess so we can pass env; connect pywinauto by PID
proc = subprocess.Popen(
[APP_PATH] + shlex.split(APP_ARGS),
env=sandbox_env,
)
pw_app = Application(backend="uia").connect(process=proc.pid, timeout=LAUNCH_TIMEOUT)
win = pw_app.window(title=APP_TITLE)
win.wait("visible", timeout=LAUNCH_TIMEOUT)
yield win
if getattr(getattr(request.node, "rep_call", None), "failed", False):
os.makedirs(ARTIFACT_DIR, exist_ok=True)
try:
win.capture_as_image().save(
os.path.join(ARTIFACT_DIR, f"FAIL_{request.node.name}.png")
)
except Exception:
pass
try:
win.close()
proc.wait(timeout=5)
except Exception:
proc.kill()
# tmp_path is cleaned up automatically by pytest
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
setattr(item, f"rep_{outcome.get_result().when}", outcome.get_result())
Tier 2 — Windows Job Object (optional: process-lifetime containment)
Attach the process to a Job Object so it is automatically terminated when the test fixture's job handle is GC'd. Also prevents the app from spawning child processes that escape fixture cleanup.
> Scope of isolation: Job Objects do NOT virtualize filesystem access or > block network traffic. File-write and network isolation require AppContainer, > Windows Firewall rules, or Tier 3 (Windows Sandbox). Use Tier 2 only for > process-lifetime and child-process containment.
Requires no extra dependencies.
import ctypes, ctypes.wintypes as wt
def restrict_process(pid: int):
"""
Attach the process to a Job Object that prevents it from:
- spawning processes outside the job (LIMIT_KILL_ON_JOB_CLOSE)
Does NOT block network — use Windows Firewall rules for that.
"""
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
# Minimal rights: SET_QUOTA (0x0100) | TERMINATE (0x0001)
PROCESS_SET_QUOTA_AND_TERMINATE = 0x0101
kernel32 = ctypes.windll.kernel32
job = kernel32.CreateJobObjectW(None, None)
hproc = kernel32.OpenProcess(PROCESS_SET_QUOTA_AND_TERMINATE, False, pid)
# Cor
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [affaan-m](https://github.com/affaan-m)
- **Source:** [affaan-m/ECC](https://github.com/affaan-m/ECC)
- **License:** MIT
- **Homepage:** https://ecc.tools
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.