From f2dd1c7e578af48d0292ca3ea6e33ebf589ee6bf Mon Sep 17 00:00:00 2001 From: zhaoyilun Date: Sun, 5 Jul 2026 14:29:29 +0800 Subject: [PATCH] feat: add csharp win helper probe --- ...26-07-05-isphere-win-helper-first-phase.md | 49 +++++ native/ISphereWinHelper/HelperProtocol.cs | 133 ++++++++++++++ native/ISphereWinHelper/Program.cs | 88 +++++++++ native/ISphereWinHelper/UiaDumper.cs | 161 ++++++++++++++++ native/ISphereWinHelper/WindowScanner.cs | 172 ++++++++++++++++++ scripts/build-win-helper.ps1 | 83 +++++++++ tests/test_win_helper.py | 130 +++++++++++++ 7 files changed, 816 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-isphere-win-helper-first-phase.md create mode 100644 native/ISphereWinHelper/HelperProtocol.cs create mode 100644 native/ISphereWinHelper/Program.cs create mode 100644 native/ISphereWinHelper/UiaDumper.cs create mode 100644 native/ISphereWinHelper/WindowScanner.cs create mode 100644 scripts/build-win-helper.ps1 create mode 100644 tests/test_win_helper.py diff --git a/docs/superpowers/plans/2026-07-05-isphere-win-helper-first-phase.md b/docs/superpowers/plans/2026-07-05-isphere-win-helper-first-phase.md new file mode 100644 index 0000000..f01887f --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-isphere-win-helper-first-phase.md @@ -0,0 +1,49 @@ +# ISphereWinHelper First Phase Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Build the first C# read-only Windows helper for `version`, `self_check`, `scan_windows`, and `dump_uia` using the agreed Go/C# JSON boundary. + +**Architecture:** The helper is a one-shot CLI process. Go will later send one JSON request on stdin and read one JSON response from stdout. This phase implements only read-only desktop/UIA probing and a PowerShell build script. + +**Tech Stack:** C# .NET Framework, `csc.exe`, Windows UI Automation, Python `unittest` wrapper tests. + +--- + +### Task 1: Contract and build test + +**Files:** +- Create: `tests/test_win_helper.py` +- Create: `scripts/build-win-helper.ps1` +- Create: `native/ISphereWinHelper/Program.cs` +- Create: `native/ISphereWinHelper/HelperProtocol.cs` +- Create: `native/ISphereWinHelper/WindowScanner.cs` +- Create: `native/ISphereWinHelper/UiaDumper.cs` + +- [x] Write a Python unittest that expects `scripts/build-win-helper.ps1` to build `runs/win-helper/ISphereWinHelper.exe`. +- [x] Run the test and verify it fails because the build script/helper does not exist yet. +- [x] Add the minimal C# helper and build script. +- [x] Run the build test and verify it passes. + +### Task 2: JSON operation tests + +**Files:** +- Modify: `tests/test_win_helper.py` +- Modify: `native/ISphereWinHelper/*.cs` + +- [x] Add tests for `version`, `self_check`, `scan_windows`, invalid JSON, and unsupported op. +- [x] Run tests and verify they fail for missing behavior. +- [x] Implement JSON request/response handling and the four first-phase operations. +- [x] Run tests and verify they pass. + +### Task 3: Verification + +**Files:** +- No new production files. + +- [x] Run `python -m unittest discover -s tests -p test_win_helper.py -v`. +- [x] Run `python -m compileall -q tests`. +- [x] Run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-win-helper.ps1`. +- [x] Run direct `version`, `scan_windows`, and dedicated WinForms `dump_uia` JSON requests against the helper. + + diff --git a/native/ISphereWinHelper/HelperProtocol.cs b/native/ISphereWinHelper/HelperProtocol.cs new file mode 100644 index 0000000..44f96cb --- /dev/null +++ b/native/ISphereWinHelper/HelperProtocol.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Web.Script.Serialization; + +namespace ISphereWinHelper +{ + internal static class HelperProtocol + { + public const string Protocol = "isphere.helper.v1"; + private static readonly JavaScriptSerializer Serializer = new JavaScriptSerializer { MaxJsonLength = 16 * 1024 * 1024, RecursionLimit = 256 }; + + public static bool TryParseRequest(string text, out Dictionary request, out string requestId, out string op) + { + request = null; + requestId = ""; + op = ""; + + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + try + { + object decoded = Serializer.DeserializeObject(text); + request = decoded as Dictionary; + if (request == null) + { + return false; + } + + string protocol = GetString(request, "protocol", ""); + requestId = GetString(request, "request_id", ""); + op = GetString(request, "op", ""); + + if (protocol != Protocol || string.IsNullOrWhiteSpace(op)) + { + return false; + } + + return true; + } + catch + { + return false; + } + } + + public static Dictionary Success(string requestId, string op, object data) + { + return Envelope(requestId, op, true, data, null); + } + + public static Dictionary Failure(string requestId, string op, string code, string message) + { + var error = new Dictionary + { + { "code", code }, + { "message", message } + }; + return Envelope(requestId, op, false, null, error); + } + + private static Dictionary Envelope(string requestId, string op, bool ok, object data, object error) + { + return new Dictionary + { + { "protocol", Protocol }, + { "request_id", requestId ?? "" }, + { "op", op ?? "" }, + { "ok", ok }, + { "data", data }, + { "error", error }, + { "warnings", new object[0] } + }; + } + + public static string ToJson(object value) + { + return Serializer.Serialize(value); + } + + public static Dictionary GetObject(Dictionary source, string key) + { + if (source == null || !source.ContainsKey(key) || source[key] == null) + { + return new Dictionary(); + } + return source[key] as Dictionary ?? new Dictionary(); + } + + public static string GetString(Dictionary source, string key, string defaultValue) + { + if (source == null || !source.ContainsKey(key) || source[key] == null) + { + return defaultValue; + } + return Convert.ToString(source[key]); + } + + public static bool GetBool(Dictionary source, string key, bool defaultValue) + { + if (source == null || !source.ContainsKey(key) || source[key] == null) + { + return defaultValue; + } + try + { + return Convert.ToBoolean(source[key]); + } + catch + { + return defaultValue; + } + } + + public static int GetInt(Dictionary source, string key, int defaultValue) + { + if (source == null || !source.ContainsKey(key) || source[key] == null) + { + return defaultValue; + } + try + { + return Convert.ToInt32(source[key]); + } + catch + { + return defaultValue; + } + } + } +} diff --git a/native/ISphereWinHelper/Program.cs b/native/ISphereWinHelper/Program.cs new file mode 100644 index 0000000..a95d562 --- /dev/null +++ b/native/ISphereWinHelper/Program.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace ISphereWinHelper +{ + internal static class Program + { + [STAThread] + private static int Main(string[] args) + { + string requestText = Console.In.ReadToEnd(); + Dictionary request; + string requestId = ""; + string op = ""; + + if (!HelperProtocol.TryParseRequest(requestText, out request, out requestId, out op)) + { + Console.WriteLine(HelperProtocol.ToJson(HelperProtocol.Failure(requestId, op, "INVALID_REQUEST", "request must be valid JSON object"))); + return 0; + } + + try + { + Dictionary opArgs = HelperProtocol.GetObject(request, "args"); + Dictionary response; + + switch (op) + { + case "version": + response = HelperProtocol.Success(requestId, op, VersionData()); + break; + case "self_check": + response = HelperProtocol.Success(requestId, op, SelfCheckData()); + break; + case "scan_windows": + response = HelperProtocol.Success(requestId, op, WindowScanner.Scan(opArgs)); + break; + case "dump_uia": + response = UiaDumper.Dump(requestId, op, opArgs); + break; + default: + response = HelperProtocol.Failure(requestId, op, "UNSUPPORTED_OP", "unsupported op: " + op); + break; + } + + Console.WriteLine(HelperProtocol.ToJson(response)); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.ToString()); + Console.WriteLine(HelperProtocol.ToJson(HelperProtocol.Failure(requestId, op, "HELPER_FAILED", ex.Message))); + return 0; + } + } + + private static Dictionary VersionData() + { + return new Dictionary + { + { "helper_name", "ISphereWinHelper" }, + { "helper_version", "0.1.0" }, + { "protocol", HelperProtocol.Protocol }, + { "runtime", ".NET Framework " + Environment.Version } + }; + } + + private static Dictionary SelfCheckData() + { + bool uiaAvailable; + try + { + uiaAvailable = System.Windows.Automation.AutomationElement.RootElement != null; + } + catch + { + uiaAvailable = false; + } + + return new Dictionary + { + { "desktop_access", Environment.UserInteractive }, + { "uia_available", uiaAvailable } + }; + } + } +} diff --git a/native/ISphereWinHelper/UiaDumper.cs b/native/ISphereWinHelper/UiaDumper.cs new file mode 100644 index 0000000..4fb2539 --- /dev/null +++ b/native/ISphereWinHelper/UiaDumper.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Windows.Automation; + +namespace ISphereWinHelper +{ + internal static class UiaDumper + { + public static Dictionary Dump(string requestId, string op, Dictionary args) + { + string hwndText = HelperProtocol.GetString(args, "hwnd", ""); + IntPtr hwnd; + if (!WindowScanner.TryParseHwnd(hwndText, out hwnd)) + { + return HelperProtocol.Failure(requestId, op, "WINDOW_NOT_FOUND", "invalid or empty hwnd"); + } + + int maxDepth = HelperProtocol.GetInt(args, "max_depth", 8); + if (maxDepth < 0) maxDepth = 0; + if (maxDepth > 20) maxDepth = 20; + bool includeText = HelperProtocol.GetBool(args, "include_text", true); + int maxChildren = HelperProtocol.GetInt(args, "max_children", 200); + if (maxChildren < 1) maxChildren = 1; + if (maxChildren > 1000) maxChildren = 1000; + + try + { + AutomationElement rootElement = AutomationElement.FromHandle(hwnd); + if (rootElement == null) + { + return HelperProtocol.Failure(requestId, op, "WINDOW_NOT_FOUND", "no UI Automation element for hwnd"); + } + + var data = new Dictionary + { + { "hwnd", WindowScanner.FormatHwnd(hwnd) }, + { "root", DumpElement(rootElement, 0, maxDepth, includeText, maxChildren) } + }; + return HelperProtocol.Success(requestId, op, data); + } + catch (Exception ex) + { + return HelperProtocol.Failure(requestId, op, "UIA_DUMP_FAILED", ex.Message); + } + } + + private static Dictionary DumpElement(AutomationElement element, int depth, int maxDepth, bool includeText, int maxChildren) + { + var node = new Dictionary + { + { "name", SafeString(delegate { return element.Current.Name; }) }, + { "control_type", SafeControlType(element) }, + { "automation_id", SafeString(delegate { return element.Current.AutomationId; }) }, + { "class_name", SafeString(delegate { return element.Current.ClassName; }) }, + { "localized_control_type", SafeString(delegate { return element.Current.LocalizedControlType; }) }, + { "framework_id", SafeString(delegate { return element.Current.FrameworkId; }) }, + { "is_enabled", SafeBool(delegate { return element.Current.IsEnabled; }) }, + { "is_offscreen", SafeBool(delegate { return element.Current.IsOffscreen; }) }, + { "bounds", SafeBounds(element) } + }; + + if (includeText) + { + node["value"] = SafeValue(element); + } + + var children = new List>(); + if (depth < maxDepth) + { + try + { + AutomationElementCollection found = element.FindAll(TreeScope.Children, Condition.TrueCondition); + int limit = Math.Min(found.Count, maxChildren); + for (int i = 0; i < limit; i++) + { + children.Add(DumpElement(found[i], depth + 1, maxDepth, includeText, maxChildren)); + } + if (found.Count > limit) + { + node["truncated_children"] = found.Count - limit; + } + } + catch + { + node["children_error"] = true; + } + } + node["children"] = children; + return node; + } + + private delegate string StringGetter(); + private delegate bool BoolGetter(); + + private static string SafeString(StringGetter getter) + { + try { return getter() ?? ""; } + catch { return ""; } + } + + private static bool SafeBool(BoolGetter getter) + { + try { return getter(); } + catch { return false; } + } + + private static string SafeControlType(AutomationElement element) + { + try + { + string name = element.Current.ControlType.ProgrammaticName ?? ""; + return name.StartsWith("ControlType.", StringComparison.Ordinal) ? name.Substring("ControlType.".Length) : name; + } + catch + { + return ""; + } + } + + private static Dictionary SafeBounds(AutomationElement element) + { + try + { + System.Windows.Rect rect = element.Current.BoundingRectangle; + return new Dictionary + { + { "x", rect.X }, + { "y", rect.Y }, + { "width", rect.Width }, + { "height", rect.Height } + }; + } + catch + { + return new Dictionary + { + { "x", 0 }, + { "y", 0 }, + { "width", 0 }, + { "height", 0 } + }; + } + } + + private static string SafeValue(AutomationElement element) + { + try + { + object pattern; + if (element.TryGetCurrentPattern(ValuePattern.Pattern, out pattern)) + { + return ((ValuePattern)pattern).Current.Value ?? ""; + } + } + catch + { + } + return ""; + } + } +} diff --git a/native/ISphereWinHelper/WindowScanner.cs b/native/ISphereWinHelper/WindowScanner.cs new file mode 100644 index 0000000..bb4d688 --- /dev/null +++ b/native/ISphereWinHelper/WindowScanner.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace ISphereWinHelper +{ + internal static class WindowScanner + { + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll", SetLastError = true)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll", SetLastError = true)] + private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + public static Dictionary Scan(Dictionary args) + { + bool includeAllVisible = HelperProtocol.GetBool(args, "include_all_visible", false); + var windows = new List>(); + + EnumWindows(delegate(IntPtr hwnd, IntPtr lParam) + { + if (!IsWindowVisible(hwnd)) + { + return true; + } + + string title = GetWindowTextValue(hwnd); + string className = GetClassNameValue(hwnd); + uint pidRaw; + GetWindowThreadProcessId(hwnd, out pidRaw); + int pid = unchecked((int)pidRaw); + string processName = GetProcessName(pid); + int score = ScoreWindow(processName, title, className); + + if (!includeAllVisible && score <= 0) + { + return true; + } + + RECT rect; + GetWindowRect(hwnd, out rect); + windows.Add(new Dictionary + { + { "hwnd", FormatHwnd(hwnd) }, + { "pid", pid }, + { "process_name", processName }, + { "title", title }, + { "class_name", className }, + { "visible", true }, + { "bounds", new Dictionary + { + { "x", rect.Left }, + { "y", rect.Top }, + { "width", Math.Max(0, rect.Right - rect.Left) }, + { "height", Math.Max(0, rect.Bottom - rect.Top) } + } + }, + { "score", score } + }); + + return true; + }, IntPtr.Zero); + + windows.Sort(delegate(Dictionary left, Dictionary right) + { + return Convert.ToInt32(right["score"]).CompareTo(Convert.ToInt32(left["score"])); + }); + + return new Dictionary { { "windows", windows } }; + } + + public static bool TryParseHwnd(string text, out IntPtr hwnd) + { + hwnd = IntPtr.Zero; + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + string normalized = text.Trim(); + try + { + long value; + if (normalized.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + value = Convert.ToInt64(normalized.Substring(2), 16); + } + else + { + value = Convert.ToInt64(normalized, 10); + } + hwnd = new IntPtr(value); + return hwnd != IntPtr.Zero; + } + catch + { + return false; + } + } + + public static string FormatHwnd(IntPtr hwnd) + { + return "0x" + hwnd.ToInt64().ToString("X"); + } + + private static string GetWindowTextValue(IntPtr hwnd) + { + var buffer = new StringBuilder(512); + GetWindowText(hwnd, buffer, buffer.Capacity); + return buffer.ToString(); + } + + private static string GetClassNameValue(IntPtr hwnd) + { + var buffer = new StringBuilder(256); + GetClassName(hwnd, buffer, buffer.Capacity); + return buffer.ToString(); + } + + private static string GetProcessName(int pid) + { + try + { + using (Process process = Process.GetProcessById(pid)) + { + return process.ProcessName; + } + } + catch + { + return ""; + } + } + + private static int ScoreWindow(string processName, string title, string className) + { + int score = 0; + string combined = ((processName ?? "") + " " + (title ?? "") + " " + (className ?? "")).ToLowerInvariant(); + if (combined.Contains("implatformclient")) score += 60; + if (combined.Contains("isphere")) score += 50; + if (combined.Contains("impp")) score += 30; + if (combined.Contains("importal")) score += 20; + if ((className ?? "").IndexOf("WindowsForms", StringComparison.OrdinalIgnoreCase) >= 0) score += 5; + return score; + } + } +} diff --git a/scripts/build-win-helper.ps1 b/scripts/build-win-helper.ps1 new file mode 100644 index 0000000..4f818f5 --- /dev/null +++ b/scripts/build-win-helper.ps1 @@ -0,0 +1,83 @@ +param( + [string]$OutputDir = "runs/win-helper", + [string]$Configuration = "Release", + [ValidateSet("anycpu", "x86", "x64")] + [string]$Platform = "anycpu" +) + +$ErrorActionPreference = "Stop" + +$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") +$sourceDir = Join-Path $repo "native/ISphereWinHelper" +$outDirPath = Join-Path $repo $OutputDir +$outExe = Join-Path $outDirPath "ISphereWinHelper.exe" + +if (-not (Test-Path -LiteralPath $sourceDir)) { + throw "source directory not found: $sourceDir" +} + +$candidates = @( + "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe", + "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" +) +$csc = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 +if (-not $csc) { + throw ".NET Framework csc.exe v4.0.30319 not found" +} + +New-Item -ItemType Directory -Force -Path $outDirPath | Out-Null + +$sources = Get-ChildItem -LiteralPath $sourceDir -Filter '*.cs' | Sort-Object Name | ForEach-Object { $_.FullName } +if (-not $sources) { + throw "no C# sources found in $sourceDir" +} + +$referenceRoots = @( + "C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8", + "C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.7.2", + "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\WPF", + "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\WPF" +) +function Resolve-Reference([string]$name) { + foreach ($root in $referenceRoots) { + $candidate = Join-Path $root $name + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + } + return $name +} + +$uiaClient = Resolve-Reference "UIAutomationClient.dll" +$uiaTypes = Resolve-Reference "UIAutomationTypes.dll" +$windowsBase = Resolve-Reference "WindowsBase.dll" + +& $csc ` + /nologo ` + /target:exe ` + /platform:$Platform ` + /optimize+ ` + /warn:0 ` + /out:$outExe ` + /reference:System.dll ` + /reference:System.Core.dll ` + /reference:System.Web.Extensions.dll ` + /reference:System.Windows.Forms.dll ` + /reference:$uiaClient ` + /reference:$uiaTypes ` + /reference:$windowsBase ` + $sources + +if ($LASTEXITCODE -ne 0) { + throw "win helper compilation failed with exit code $LASTEXITCODE" +} + +$result = [ordered]@{ + ok = $true + configuration = $Configuration + platform = $Platform + csc = $csc + source_dir = $sourceDir + output = $outExe +} +$result | ConvertTo-Json -Depth 4 -Compress diff --git a/tests/test_win_helper.py b/tests/test_win_helper.py new file mode 100644 index 0000000..43259c7 --- /dev/null +++ b/tests/test_win_helper.py @@ -0,0 +1,130 @@ +import json +import subprocess +import unittest +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +BUILD_SCRIPT = REPO / "scripts" / "build-win-helper.ps1" +HELPER_EXE = REPO / "runs" / "win-helper" / "ISphereWinHelper.exe" + + +def build_helper(): + result = subprocess.run( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(BUILD_SCRIPT), + ], + cwd=REPO, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=60, + ) + if result.returncode != 0: + raise AssertionError(result.stdout + result.stderr) + + +def invoke_helper(payload): + if isinstance(payload, str): + stdin = payload + else: + stdin = json.dumps(payload, ensure_ascii=False) + result = subprocess.run( + [str(HELPER_EXE), "--json"], + cwd=REPO, + input=stdin, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + ) + try: + decoded = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise AssertionError(f"stdout was not JSON: {result.stdout!r}; stderr={result.stderr!r}") from exc + return result, decoded + + +class WinHelperBuildTests(unittest.TestCase): + def test_build_script_creates_helper_exe(self): + if HELPER_EXE.exists(): + HELPER_EXE.unlink() + + build_helper() + + self.assertTrue(HELPER_EXE.exists(), f"helper exe missing: {HELPER_EXE}") + + +class WinHelperProtocolTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + build_helper() + + def request(self, op, args=None): + result, decoded = invoke_helper( + { + "protocol": "isphere.helper.v1", + "request_id": f"test-{op}", + "op": op, + "timeout_ms": 5000, + "args": args or {}, + } + ) + self.assertEqual(result.returncode, 0, result.stderr) + return decoded + + def test_version_returns_contract_metadata(self): + decoded = self.request("version") + + self.assertTrue(decoded["ok"]) + self.assertEqual(decoded["protocol"], "isphere.helper.v1") + self.assertEqual(decoded["request_id"], "test-version") + self.assertEqual(decoded["op"], "version") + self.assertEqual(decoded["data"]["helper_name"], "ISphereWinHelper") + self.assertEqual(decoded["data"]["protocol"], "isphere.helper.v1") + + def test_self_check_reports_desktop_and_uia_status(self): + decoded = self.request("self_check") + + self.assertTrue(decoded["ok"]) + self.assertIn("desktop_access", decoded["data"]) + self.assertIn("uia_available", decoded["data"]) + + def test_scan_windows_returns_json_window_list(self): + decoded = self.request("scan_windows", {"include_all_visible": True}) + + self.assertTrue(decoded["ok"]) + self.assertIsInstance(decoded["data"]["windows"], list) + + def test_unsupported_op_returns_structured_error(self): + decoded = self.request("does_not_exist") + + self.assertFalse(decoded["ok"]) + self.assertEqual(decoded["error"]["code"], "UNSUPPORTED_OP") + + def test_invalid_json_returns_structured_error(self): + result, decoded = invoke_helper("{not json") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(decoded["ok"]) + self.assertEqual(decoded["protocol"], "isphere.helper.v1") + self.assertEqual(decoded["error"]["code"], "INVALID_REQUEST") + + def test_dump_uia_with_missing_window_returns_structured_error(self): + decoded = self.request("dump_uia", {"hwnd": "0x0"}) + + self.assertFalse(decoded["ok"]) + self.assertIn(decoded["error"]["code"], {"WINDOW_NOT_FOUND", "UIA_DUMP_FAILED"}) + + +if __name__ == "__main__": + unittest.main()