|
| 1 | +================================== |
| 2 | +New Features (2026-06) — QA Layer |
| 3 | +================================== |
| 4 | + |
| 5 | +Nine additions that turn AutoControl's automation primitives into a |
| 6 | +full **test framework**: assert screen state, drive scripts from data, |
| 7 | +detect and quarantine flaky tests, run a scored suite, emit CI-native |
| 8 | +reports, audit accessibility / i18n, fan a script across a device |
| 9 | +matrix, and assert on audio / video. Every feature ships with a |
| 10 | +headless Python API, an ``AC_*`` executor command, an ``ac_*`` MCP |
| 11 | +tool, and a Qt GUI tab — same pattern as the rest of the framework. |
| 12 | + |
| 13 | +.. contents:: |
| 14 | + :local: |
| 15 | + :depth: 2 |
| 16 | + |
| 17 | + |
| 18 | +Assertions |
| 19 | +========== |
| 20 | + |
| 21 | +Assertion DSL |
| 22 | +------------- |
| 23 | + |
| 24 | +Verify the screen state instead of only driving it. Each ``assert_*`` |
| 25 | +observes the current state, returns an :class:`AssertionResult`, and |
| 26 | +(by default) raises ``AutoControlAssertionException`` on mismatch so a |
| 27 | +script / test / scheduled run fails loudly at the broken assumption:: |
| 28 | + |
| 29 | + from je_auto_control import ( |
| 30 | + assert_text, assert_image, assert_pixel, assert_window, |
| 31 | + ) |
| 32 | + |
| 33 | + assert_text("Login successful", region=[0, 0, 800, 200]) |
| 34 | + assert_image("checkmark.png", threshold=0.9) |
| 35 | + assert_pixel(100, 200, [0, 200, 0], tolerance=10) |
| 36 | + assert_window("Settings", exists=True) |
| 37 | + |
| 38 | +``assert_text`` accepts ``regex=True`` and ``present=False`` (assert |
| 39 | +*absence*); every helper takes ``raise_on_fail`` and ``capture_on_fail`` |
| 40 | +(saves a screenshot of the failing screen under |
| 41 | +``~/.je_auto_control/assertions/``). |
| 42 | + |
| 43 | +Executor: ``AC_assert_text / _image / _pixel / _window``. |
| 44 | +MCP: ``ac_assert_*``. GUI: **Assertions** tab. |
| 45 | + |
| 46 | + |
| 47 | +Off-screen and system assertions |
| 48 | +--------------------------------- |
| 49 | + |
| 50 | +The DSL also verifies state that is not on the screen:: |
| 51 | + |
| 52 | + from je_auto_control import ( |
| 53 | + assert_clipboard, assert_process, assert_file, assert_http, |
| 54 | + ) |
| 55 | + |
| 56 | + assert_clipboard("ORDER-12345", mode="contains") |
| 57 | + assert_process("chrome", running=True) |
| 58 | + assert_file("export.csv", min_size=1, contains="total") |
| 59 | + assert_http("https://localhost:8080/health", status=200) |
| 60 | + |
| 61 | +* ``assert_clipboard`` — clipboard text by ``equals`` / ``contains`` / |
| 62 | + ``regex``; ``present=False`` confirms a secret was *cleared*. |
| 63 | +* ``assert_process`` — a process whose name contains the argument is (or |
| 64 | + is not) running, via ``psutil``. |
| 65 | +* ``assert_file`` — existence / substring / SHA-256 / minimum size of a |
| 66 | + file; the path is ``realpath``-normalised before any I/O. Verifies a |
| 67 | + download or export. |
| 68 | +* ``assert_http`` — an ``http``/``https`` endpoint returns a status code |
| 69 | + (and optional body substring), always with an explicit ``timeout``. |
| 70 | + Only ``http``/``https`` schemes are accepted; an unreachable host is a |
| 71 | + failed assertion, not a crash. |
| 72 | + |
| 73 | +Executor: ``AC_assert_clipboard / _process / _file / _http``. |
| 74 | +MCP: ``ac_assert_clipboard / ac_assert_process / ac_assert_file / |
| 75 | +ac_assert_http``. |
| 76 | + |
| 77 | + |
| 78 | +Assertion combinators (group / OR / poll) |
| 79 | +----------------------------------------- |
| 80 | + |
| 81 | +Compose the eight assertion kinds with declarative *specs* — plain dicts |
| 82 | +like ``{"kind": "text", "text": "Saved"}`` — so the same checks are |
| 83 | +reachable from Python, JSON, and MCP without passing callables:: |
| 84 | + |
| 85 | + from je_auto_control import assert_all, assert_any, assert_eventually |
| 86 | + |
| 87 | + # soft assertions: run the whole batch, collect every failure |
| 88 | + assert_all([ |
| 89 | + {"kind": "window", "title": "Dashboard"}, |
| 90 | + {"kind": "text", "text": "Welcome"}, |
| 91 | + ]) |
| 92 | + |
| 93 | + # OR: pass when at least one spec passes (short-circuits) |
| 94 | + assert_any([ |
| 95 | + {"kind": "text", "text": "Success"}, |
| 96 | + {"kind": "window", "title": "Redirecting"}, |
| 97 | + ]) |
| 98 | + |
| 99 | + # poll any spec until it passes or times out |
| 100 | + assert_eventually({"kind": "http", "url": "http://localhost:8080/health"}, |
| 101 | + timeout=30, interval=0.5) |
| 102 | + |
| 103 | +``assert_all`` (AND) never short-circuits and returns a |
| 104 | +:class:`GroupAssertionResult` summarising every sub-result; |
| 105 | +``assert_any`` (OR) stops at the first pass; ``assert_eventually`` |
| 106 | +re-checks one spec on an interval until it holds — ideal for waiting on a |
| 107 | +service to come up or a download file to appear. |
| 108 | + |
| 109 | +Executor: ``AC_assert_all / AC_assert_any / AC_assert_eventually``. |
| 110 | +MCP: ``ac_assert_all / ac_assert_any / ac_assert_eventually``. |
| 111 | + |
| 112 | + |
| 113 | +Media assertions (audio / video) |
| 114 | +-------------------------------- |
| 115 | + |
| 116 | +Assert that something actually *played* or *animated*:: |
| 117 | + |
| 118 | + from je_auto_control import assert_audio_activity, assert_video_changes |
| 119 | + |
| 120 | + assert_audio_activity(duration_s=1.0, threshold=0.01, expect_sound=True) |
| 121 | + assert_video_changes("clip.mp4", start_s=0, end_s=3, expect_motion=True) |
| 122 | + |
| 123 | +``assert_audio_activity`` records from an input device and compares the |
| 124 | +RMS level to a threshold (sound vs silence). ``assert_video_changes`` |
| 125 | +measures mean frame-to-frame difference over a video segment (motion vs |
| 126 | +static), with an optional ``region`` crop. The numeric cores |
| 127 | +(``rms``, ``mean_frame_diff``, ``measure_audio_rms``, |
| 128 | +``video_segment_motion``) are public and pure. ``sounddevice`` / |
| 129 | +OpenCV are lazy dependencies. |
| 130 | + |
| 131 | +Executor: ``AC_assert_audio / AC_assert_video_changes``. |
| 132 | +MCP: ``ac_assert_audio / ac_assert_video_changes``. GUI: **Media |
| 133 | +Checks** tab. |
| 134 | + |
| 135 | + |
| 136 | +Data-driven execution |
| 137 | +===================== |
| 138 | + |
| 139 | +Feed rows from CSV / JSON / SQLite / Excel / inline literals into a |
| 140 | +``${var}`` script, then run the same body once per row:: |
| 141 | + |
| 142 | + from je_auto_control import load_rows |
| 143 | + |
| 144 | + rows = load_rows({"kind": "csv", "path": "users.csv"}) |
| 145 | + |
| 146 | +In a JSON action file the new ``AC_for_each_row`` block command loads a |
| 147 | +data source and binds each row to a variable whose columns are |
| 148 | +addressable as ``${row.column}``:: |
| 149 | + |
| 150 | + ["AC_for_each_row", { |
| 151 | + "source": {"kind": "csv", "path": "users.csv"}, |
| 152 | + "as": "row", |
| 153 | + "body": [ |
| 154 | + ["AC_type_keyboard", {"keys": "${row.username}"}], |
| 155 | + ["AC_assert_text", {"text": "${row.expected}"}] |
| 156 | + ] |
| 157 | + }] |
| 158 | + |
| 159 | +The SQLite connector accepts a **single read-only** ``SELECT`` / ``WITH`` |
| 160 | +statement only (multi-statement / write queries are rejected); all file |
| 161 | +paths are ``realpath``-validated. ``${var}`` interpolation now resolves |
| 162 | +dotted paths into dict keys and list indices (``${row.user}``, |
| 163 | +``${results.0}``) while preserving value types. |
| 164 | + |
| 165 | +Executor: ``AC_load_data`` + ``AC_for_each_row``. |
| 166 | +MCP: ``ac_load_data``. GUI: **Data Sources** tab. |
| 167 | + |
| 168 | + |
| 169 | +Flaky-test detection & quarantine |
| 170 | +================================== |
| 171 | + |
| 172 | +Flaky report |
| 173 | +------------ |
| 174 | + |
| 175 | +Score intermittent failures from the SQLite run-history store. Runs are |
| 176 | +grouped by ``script_path`` (or ``source_id``); the report counts |
| 177 | +pass/fail outcomes and pass↔fail *flips* in chronological order so a |
| 178 | +flaky script ranks above one that is consistently green or red:: |
| 179 | + |
| 180 | + from je_auto_control import analyze_flakiness |
| 181 | + |
| 182 | + report = analyze_flakiness(min_runs=3) |
| 183 | + for entry in report.entries: |
| 184 | + print(entry.key, entry.flip_rate, entry.flaky) |
| 185 | + |
| 186 | +Executor: ``AC_flaky_report``. MCP: ``ac_flaky_report``. |
| 187 | +GUI: **Flaky Tests** tab. |
| 188 | + |
| 189 | + |
| 190 | +Quarantine (closing the loop) |
| 191 | +----------------------------- |
| 192 | + |
| 193 | +A quarantined case name is *skipped* by the suite runner (recorded as |
| 194 | +``skipped`` with reason ``quarantined``) so a known-flaky case stops |
| 195 | +poisoning the suite's red/green status until it is fixed. The store is a |
| 196 | +small JSON file (mode 0600 on POSIX) that persists across restarts:: |
| 197 | + |
| 198 | + from je_auto_control import ( |
| 199 | + default_quarantine_store, auto_quarantine_from_flakiness, |
| 200 | + ) |
| 201 | + |
| 202 | + default_quarantine_store().add("login_suite", reason="under triage") |
| 203 | + auto_quarantine_from_flakiness(flip_rate_threshold=0.5) |
| 204 | + |
| 205 | +``auto_quarantine_from_flakiness`` reads the flakiness report and |
| 206 | +quarantines every group above the flip-rate threshold. |
| 207 | + |
| 208 | +Executor: ``AC_quarantine_add / _remove / _list / _clear / _auto``. |
| 209 | +MCP: ``ac_quarantine_*``. GUI: quarantine panel on the **Test Suites** |
| 210 | +tab. |
| 211 | + |
| 212 | + |
| 213 | +QA suite runner + CI reports |
| 214 | +============================ |
| 215 | + |
| 216 | +Suite orchestration |
| 217 | +------------------- |
| 218 | + |
| 219 | +Turn flat action lists into scored test cases with setup / teardown, |
| 220 | +tags, and per-case pass/fail. A case carrying a ``data`` source expands |
| 221 | +to one scored case per row:: |
| 222 | + |
| 223 | + from je_auto_control import run_suite |
| 224 | + |
| 225 | + spec = { |
| 226 | + "name": "Login", |
| 227 | + "setup": [["AC_focus_window", {"title": "MyApp"}]], |
| 228 | + "teardown": [["AC_close_window", {"title": "MyApp"}]], |
| 229 | + "cases": [ |
| 230 | + {"name": "valid login", "tags": ["smoke"], |
| 231 | + "actions": [["AC_assert_text", {"text": "Welcome"}]]}, |
| 232 | + {"name": "each user", "as": "row", |
| 233 | + "data": {"kind": "csv", "path": "users.csv"}, |
| 234 | + "actions": [["AC_assert_text", {"text": "${row.expected}"}]]}, |
| 235 | + ], |
| 236 | + } |
| 237 | + result = run_suite(spec, tags=["smoke"]) |
| 238 | + print(result.passed, result.failed, result.errored, result.skipped) |
| 239 | + |
| 240 | +An ``AutoControlAssertionException`` marks a case **failed**; any other |
| 241 | +exception marks it **error**; a clean run is **passed**. Quarantined |
| 242 | +case names are recorded as **skipped**. |
| 243 | + |
| 244 | +Executor: ``AC_run_suite``. MCP: ``ac_run_suite``. |
| 245 | +GUI: **Test Suites** tab. |
| 246 | + |
| 247 | + |
| 248 | +CI-native reports (JUnit / Allure) |
| 249 | +---------------------------------- |
| 250 | + |
| 251 | +Emit reports that Jenkins, GitHub Actions, GitLab CI, and Allure parse |
| 252 | +natively:: |
| 253 | + |
| 254 | + from je_auto_control import write_junit_xml, write_allure_results |
| 255 | + |
| 256 | + write_junit_xml(result, "reports/junit.xml") |
| 257 | + write_allure_results(result, "reports/allure") |
| 258 | + |
| 259 | +``AC_run_suite`` writes them inline when given ``junit_path`` / |
| 260 | +``allure_dir``:: |
| 261 | + |
| 262 | + ["AC_run_suite", {"spec": {...}, "junit_path": "reports/junit.xml"}] |
| 263 | + |
| 264 | +Only report *generation* happens here (never parsing untrusted XML), so |
| 265 | +the stdlib ``xml.etree.ElementTree`` writer is safe. |
| 266 | + |
| 267 | + |
| 268 | +Accessibility & i18n audit |
| 269 | +========================== |
| 270 | + |
| 271 | +Reuse the accessibility tree and OCR layer to *inspect* a UI for common |
| 272 | +accessibility / localisation defects rather than to drive it:: |
| 273 | + |
| 274 | + from je_auto_control import run_audit, contrast_ratio |
| 275 | + |
| 276 | + report = run_audit( |
| 277 | + app_name="MyApp", |
| 278 | + contrast_pairs=[{"foreground": [120, 120, 120], |
| 279 | + "background": [255, 255, 255], "label": "hint"}], |
| 280 | + texts=["Save chang…"], # OCR strings to scan for truncation |
| 281 | + ) |
| 282 | + |
| 283 | +Checks: |
| 284 | + |
| 285 | +* **Missing labels** — interactive widgets (button, menu item, link, |
| 286 | + field …) exposed through the a11y tree with no accessible name. |
| 287 | +* **Contrast** — WCAG 2.x relative-luminance contrast ratio with AA / |
| 288 | + AAA thresholds (``contrast_ratio([0,0,0],[255,255,255]) == 21.0``). |
| 289 | +* **Truncation** — OCR strings ending in an ellipsis (clipped after |
| 290 | + translation). |
| 291 | + |
| 292 | +Executor: ``AC_audit_accessibility / AC_audit_contrast``. |
| 293 | +MCP: ``ac_audit_*``. GUI: **A11y Audit** tab. |
| 294 | + |
| 295 | + |
| 296 | +Mobile device matrix |
| 297 | +==================== |
| 298 | + |
| 299 | +Fan a single action list out across many Android / iOS devices **in |
| 300 | +parallel**, each on its own isolated executor (so runtime variable |
| 301 | +scopes never collide between threads). The script targets the current |
| 302 | +device through a bound ``${device.*}`` variable:: |
| 303 | + |
| 304 | + from je_auto_control import run_on_devices |
| 305 | + |
| 306 | + report = run_on_devices( |
| 307 | + actions=[["AC_android_tap", {"x": 100, "y": 200, |
| 308 | + "serial": "${device.serial}"}]], |
| 309 | + devices=[{"platform": "android", "serial": "emulator-5554"}, |
| 310 | + {"platform": "android", "serial": "emulator-5556"}], |
| 311 | + max_parallel=4, |
| 312 | + ) |
| 313 | + print(report.passed, report.failed) |
| 314 | + |
| 315 | +A failure on one device is isolated — it never aborts the others. |
| 316 | + |
| 317 | +Executor: ``AC_run_device_matrix``. MCP: ``ac_run_device_matrix``. |
| 318 | +GUI: **Device Matrix** tab. |
0 commit comments