@capagents/forgeqe 1.3.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/llm.py +54 -4
  37. package/py/forgeqe_engine/model_params.py +169 -0
  38. package/py/forgeqe_mobile/__init__.py +1 -0
  39. package/py/forgeqe_mobile/__main__.py +3 -0
  40. package/py/forgeqe_mobile/appium_driver.py +342 -0
  41. package/py/forgeqe_mobile/apps.py +84 -0
  42. package/py/forgeqe_mobile/cli.py +70 -0
  43. package/py/forgeqe_mobile/explore.py +203 -0
  44. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  45. package/py/forgeqe_mobile/maestro.py +321 -0
  46. package/py/forgeqe_mobile/service.py +332 -0
  47. package/skills/testcontainers/SKILL.md +42 -0
  48. package/src/agents.ts +11 -2
  49. package/src/api/plan.ts +2 -0
  50. package/src/api/write.ts +2 -0
  51. package/src/auth/copilot.ts +130 -0
  52. package/src/auth/signin.ts +54 -1
  53. package/src/brain/index.ts +26 -4
  54. package/src/capabilities/guide.ts +2 -0
  55. package/src/capabilities/registry.ts +110 -2
  56. package/src/capabilities/status.ts +15 -0
  57. package/src/cases/rca.ts +2 -0
  58. package/src/childEnv.ts +4 -1
  59. package/src/cli/args.ts +3 -2
  60. package/src/cli/help.ts +24 -3
  61. package/src/cli/index.ts +47 -3
  62. package/src/code/find.ts +26 -0
  63. package/src/code/loop.ts +4 -1
  64. package/src/code/prompt.ts +2 -1
  65. package/src/code/prreview.ts +2 -0
  66. package/src/code/review.ts +6 -2
  67. package/src/code/runtime.ts +34 -3
  68. package/src/commands.ts +6 -1
  69. package/src/config.ts +159 -3
  70. package/src/containers/command.ts +68 -0
  71. package/src/containers/compose.ts +173 -0
  72. package/src/containers/config.ts +283 -0
  73. package/src/containers/mock.ts +156 -0
  74. package/src/containers/run.ts +362 -0
  75. package/src/containers/tests.ts +80 -0
  76. package/src/context.ts +33 -6
  77. package/src/copilot.ts +155 -0
  78. package/src/desktop/bridge.ts +229 -0
  79. package/src/desktop/command.ts +350 -0
  80. package/src/desktop/edit.ts +217 -0
  81. package/src/desktop/junit.ts +35 -0
  82. package/src/desktop/report.ts +155 -0
  83. package/src/desktop/run.ts +252 -0
  84. package/src/desktop/setup.ts +134 -0
  85. package/src/engine/bridge.ts +12 -5
  86. package/src/engine/setup.ts +3 -3
  87. package/src/environments.ts +566 -0
  88. package/src/evals/domain.ts +1 -0
  89. package/src/explore/charters.ts +2 -0
  90. package/src/guardrails.ts +145 -0
  91. package/src/heal/loop.ts +4 -1
  92. package/src/heal/prompt.ts +2 -1
  93. package/src/init.ts +5 -2
  94. package/src/llm.ts +23 -2
  95. package/src/mcp/attachtools.ts +16 -4
  96. package/src/mcp/captools.ts +27 -0
  97. package/src/mcp/containertools.ts +212 -0
  98. package/src/mcp/desktoptools.ts +334 -0
  99. package/src/mcp/mobiletools.ts +132 -0
  100. package/src/mcp/reporttools.ts +17 -2
  101. package/src/mcp/repotests.ts +22 -3
  102. package/src/mcp/routing.ts +11 -5
  103. package/src/mcp/scope.ts +210 -0
  104. package/src/mcp/server.ts +80 -5
  105. package/src/mcp/tools.ts +38 -1
  106. package/src/mobile/command.ts +245 -0
  107. package/src/mobile/run.ts +226 -0
  108. package/src/pack/pipeline.ts +44 -2
  109. package/src/refine.ts +3 -0
  110. package/src/report/html.ts +3 -0
  111. package/src/report/template.html +48 -48
  112. package/src/run.ts +7 -3
  113. package/src/tokenomics/ledger.ts +3 -1
  114. package/src/tui/App.tsx +23 -0
  115. package/src/usage.ts +1 -1
@@ -0,0 +1,208 @@
1
+ """
2
+ The desktop agent's model: the same llms.json profile as the browser engine.
3
+
4
+ UFO pins its own ``openai`` version, which the browser engine's library does not run with, so the desktop engine calls
5
+ the profile through the ``openai`` SDK directly. Keys, Azure endpoints and Microsoft Entra ID come from the same helpers
6
+ as the browser engine (``forgeqe_engine.llm``), so a profile means the same thing in both. Calls are synchronous: UFO
7
+ calls its model clients synchronously, from inside its own event loop.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import time
14
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
15
+
16
+ from forgeqe_engine.llm import (
17
+ AZURE_API_VERSION_DEFAULT,
18
+ _resolve_api_key,
19
+ entra_token_provider,
20
+ normalize_azure_endpoint,
21
+ uses_entra,
22
+ )
23
+ from forgeqe_engine.model_params import call_with_params, params_for
24
+
25
+ _DATA_URL = re.compile(r"^data:image/(?:png|jpeg|gif|webp);base64,", re.I)
26
+
27
+
28
+ class ProfileError(ValueError):
29
+ """An llms.json profile that cannot drive the desktop agent."""
30
+
31
+
32
+ def pick_profile(llms: Mapping[str, Any], name: Optional[str] = None) -> Tuple[str, Dict[str, Any]]:
33
+ """
34
+ ``(name, profile)`` from a parsed llms.json: the one asked for, else ``default``, else the first, the way the CLI
35
+ picks it. Mock profiles are skipped.
36
+ """
37
+ models = llms.get("models") if isinstance(llms, Mapping) else None
38
+ if not isinstance(models, Mapping):
39
+ raise ProfileError("llms.json has no models")
40
+ profiles = {k: dict(v) for k, v in models.items() if isinstance(v, Mapping) and k != "mock" and v.get("provider") != "mock"}
41
+ if name:
42
+ if name not in profiles:
43
+ raise ProfileError(f'Unknown LLM profile "{name}". Run: forgeqe models')
44
+ return name, profiles[name]
45
+ default = llms.get("default")
46
+ if isinstance(default, str) and default in profiles:
47
+ return default, profiles[default]
48
+ if not profiles:
49
+ raise ProfileError("llms.json has no profiles")
50
+ first = next(iter(profiles))
51
+ return first, profiles[first]
52
+
53
+
54
+ def load_profile(llms_path: str, name: Optional[str] = None) -> Tuple[str, Dict[str, Any]]:
55
+ with open(llms_path, "r", encoding="utf-8") as f:
56
+ return pick_profile(json.load(f), name)
57
+
58
+
59
+ def takes_images(profile: Mapping[str, Any]) -> bool:
60
+ """``vision`` when the profile sets it; otherwise yes, except for ollama, whose local models are mostly text-only."""
61
+ if "vision" in profile:
62
+ return profile.get("vision") is True
63
+ return str(profile.get("provider") or "azure").lower() != "ollama"
64
+
65
+
66
+ def require_vision(name: str, profile: Mapping[str, Any]) -> None:
67
+ if not takes_images(profile):
68
+ raise ProfileError(
69
+ f'llms.json profile "{name}" does not take images, and the desktop agent sends screenshots. '
70
+ 'Pick a vision model, or set "vision": true on the profile if it does take them.'
71
+ )
72
+
73
+
74
+ def check_messages(messages: List[Mapping[str, Any]]) -> List[Dict[str, Any]]:
75
+ """
76
+ OpenAI-style chat messages as they go to the model. Images must be inline ``data:`` screenshots: a link would make
77
+ the model provider fetch a URL that the app under test, or text injected into it, chose.
78
+ """
79
+ out: List[Dict[str, Any]] = []
80
+ for m in messages:
81
+ if not isinstance(m, Mapping):
82
+ raise ValueError("each message must be a mapping")
83
+ content = m.get("content")
84
+ if isinstance(content, list):
85
+ for p in content:
86
+ if isinstance(p, Mapping) and p.get("type") in ("image_url", "input_image"):
87
+ raw = p.get("image_url")
88
+ url = raw.get("url") if isinstance(raw, Mapping) else raw
89
+ if not _DATA_URL.match(str(url or "")):
90
+ raise ValueError("images must be inline data: URLs (png, jpeg, gif or webp)")
91
+ out.append(dict(m))
92
+ return out
93
+
94
+
95
+ def build_client(profile: Mapping[str, Any]) -> Tuple[Any, str]:
96
+ """An ``openai`` client and the model (or Azure deployment) name for a profile."""
97
+ import openai
98
+
99
+ cfg = dict(profile)
100
+ provider = str(cfg.get("provider") or "azure").lower()
101
+ entra = uses_entra(cfg)
102
+ if entra and provider not in ("azure", "openai_compatible"):
103
+ raise ProfileError(f"auth: entra works with the azure and openai_compatible providers, not {provider}.")
104
+ api_key = "" if entra else _resolve_api_key(cfg)
105
+ model = str(cfg.get("model") or "gpt-4o")
106
+
107
+ if provider == "azure":
108
+ endpoint = normalize_azure_endpoint(cfg.get("endpoint") or cfg.get("apiEndpoint") or "")
109
+ deployment = str(cfg.get("deployment") or cfg.get("deploymentName") or model)
110
+ api_version = str(cfg.get("api_version") or cfg.get("apiVersion") or AZURE_API_VERSION_DEFAULT)
111
+ if not endpoint:
112
+ raise ProfileError("Azure OpenAI needs an endpoint: check it in llms.json.")
113
+ kw: Dict[str, Any] = {"azure_endpoint": endpoint, "api_version": api_version}
114
+ if entra:
115
+ kw["azure_ad_token_provider"] = entra_token_provider(cfg)
116
+ elif api_key:
117
+ kw["api_key"] = api_key
118
+ else:
119
+ raise ProfileError(
120
+ "Azure OpenAI needs an API key: set the profile's api_key_env variable (see llms.json), "
121
+ "or set auth: entra to sign in with Microsoft Entra ID instead."
122
+ )
123
+ return openai.AzureOpenAI(**kw), deployment
124
+
125
+ if provider == "openai":
126
+ if not api_key:
127
+ raise ProfileError("OpenAI needs an API key: set OPENAI_API_KEY or the profile's api_key_env.")
128
+ return openai.OpenAI(api_key=api_key), model
129
+
130
+ if provider in ("openai_compatible", "ollama"):
131
+ base = str(cfg.get("endpoint") or cfg.get("apiEndpoint") or ("http://127.0.0.1:11434/v1" if provider == "ollama" else "")).strip()
132
+ if not base:
133
+ raise ProfileError("openai_compatible profiles need an endpoint (base URL).")
134
+ if provider == "ollama" and not re.search(r"/v1/?$", base):
135
+ base = base.rstrip("/") + "/v1"
136
+ if entra:
137
+ import httpx
138
+
139
+ token = entra_token_provider(cfg)
140
+
141
+ class _Bearer(httpx.Auth):
142
+ def auth_flow(self, request: httpx.Request):
143
+ request.headers["Authorization"] = f"Bearer {token()}"
144
+ yield request
145
+
146
+ return openai.OpenAI(base_url=base, api_key="entra", http_client=httpx.Client(auth=_Bearer())), model
147
+ return openai.OpenAI(base_url=base, api_key=api_key or "local"), model
148
+
149
+ raise ProfileError(f"Unsupported LLM provider for the desktop engine: {provider}")
150
+
151
+
152
+ def _usage(response: Any) -> Dict[str, int]:
153
+ u = getattr(response, "usage", None)
154
+ if u is None:
155
+ return {}
156
+ out = {"input": int(getattr(u, "prompt_tokens", 0) or 0), "output": int(getattr(u, "completion_tokens", 0) or 0)}
157
+ details = getattr(u, "prompt_tokens_details", None)
158
+ cached = int(getattr(details, "cached_tokens", 0) or 0) if details is not None else 0
159
+ if cached:
160
+ out["cached"] = cached
161
+ return out
162
+
163
+
164
+ class DesktopModel:
165
+ """A profile's chat model; each call's time and tokens go to ``on_call``, never the prompt or the reply."""
166
+
167
+ def __init__(
168
+ self,
169
+ profile: Mapping[str, Any],
170
+ on_call: Optional[Callable[[Dict[str, Any]], None]] = None,
171
+ client: Any = None,
172
+ model: Optional[str] = None,
173
+ timeout: float = 120.0,
174
+ ) -> None:
175
+ if client is None:
176
+ client, built_model = build_client(profile)
177
+ model = model or built_model
178
+ self.client = client
179
+ self.model = model or str(profile.get("deployment") or profile.get("model") or "gpt-4o")
180
+ self.temperature = profile.get("temperature")
181
+ self.on_call = on_call
182
+ self.timeout = timeout
183
+ # The parameters this model takes (token limit's name, temperature, ...), shared with every other call to it.
184
+ self.params = params_for(profile)
185
+
186
+ def complete(self, messages: List[Mapping[str, Any]], max_tokens: Optional[int] = None, json_mode: bool = False) -> str:
187
+ kw: Dict[str, Any] = {"model": self.model, "messages": check_messages(messages), "timeout": self.timeout}
188
+ if isinstance(self.temperature, (int, float)):
189
+ kw["temperature"] = self.temperature
190
+ if max_tokens:
191
+ kw["max_tokens"] = max_tokens
192
+ if json_mode:
193
+ kw["response_format"] = {"type": "json_object"}
194
+ started = time.time()
195
+ response: Any = None
196
+ ok = False
197
+ try:
198
+ response = call_with_params(self.params, self.client.chat.completions.create, kw)
199
+ ok = True
200
+ finally:
201
+ if self.on_call is not None:
202
+ try:
203
+ self.on_call({"ms": int((time.time() - started) * 1000), "ok": ok, **_usage(response)})
204
+ except Exception:
205
+ pass
206
+ choices = getattr(response, "choices", None) or []
207
+ content = getattr(getattr(choices[0], "message", None), "content", "") if choices else ""
208
+ return content or ""
@@ -0,0 +1,217 @@
1
+ """
2
+ The Windows driver for replay: pywinauto with the UIA backend. Windows only; the module imports anywhere so the key
3
+ translation can be tested on any OS.
4
+
5
+ The app is started with ``subprocess.Popen([path, *args])``, no shell, and only controls inside that app's own windows
6
+ are searched. Typed text is escaped so pywinauto never reads it as key presses.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import time
12
+ from typing import Any, Dict, List, Mapping, Optional
13
+
14
+ from .apps import AppSpec
15
+ from .replay import ControlNotFound
16
+ from .workflow import MODIFIERS, parse_chord
17
+
18
+ _SEND_KEYS_SPECIAL = set("+^%~(){}[]")
19
+ _SEND_KEYS_MODS = {"ctrl": "^", "shift": "+", "alt": "%"}
20
+ _SEND_KEYS_NAMES = {
21
+ "enter": "ENTER", "tab": "TAB", "esc": "ESC", "space": "SPACE", "backspace": "BACKSPACE", "delete": "DELETE",
22
+ "insert": "INSERT", "home": "HOME", "end": "END", "pageup": "PGUP", "pagedown": "PGDN",
23
+ "up": "UP", "down": "DOWN", "left": "LEFT", "right": "RIGHT", **{f"f{n}": f"F{n}" for n in range(1, 13)},
24
+ }
25
+
26
+
27
+ def escape_send_keys(text: str) -> str:
28
+ """``text`` for pywinauto's ``type_keys`` so each character is typed as itself: ``^{ESC}`` types ``^{ESC}``."""
29
+ return "".join("{%s}" % c if c in _SEND_KEYS_SPECIAL else c for c in text)
30
+
31
+
32
+ def to_send_keys(chord: List[str]) -> str:
33
+ """A parsed chord (``["ctrl", "s"]``) in pywinauto's key syntax (``"^s"``)."""
34
+ parse_chord("+".join(chord))
35
+ *mods, key = chord
36
+ prefix = "".join(_SEND_KEYS_MODS[m] for m in mods if m in MODIFIERS)
37
+ name = _SEND_KEYS_NAMES.get(key)
38
+ return prefix + ("{%s}" % name if name else escape_send_keys(key))
39
+
40
+
41
+ def _criteria(target: Mapping[str, Any]) -> Dict[str, Any]:
42
+ crit: Dict[str, Any] = {}
43
+ if target.get("automation_id"):
44
+ crit["auto_id"] = target["automation_id"]
45
+ if target.get("name"):
46
+ crit["title"] = target["name"]
47
+ if target.get("control_type"):
48
+ crit["control_type"] = target["control_type"]
49
+ if target.get("class_name"):
50
+ crit["class_name"] = target["class_name"]
51
+ if "index" in target:
52
+ crit["found_index"] = int(target["index"])
53
+ return crit
54
+
55
+
56
+ class PywinautoDriver:
57
+ # Drives the real desktop, so a run first checks the session can take input (session.py).
58
+ needs_desktop = True
59
+
60
+ def __init__(self, launch_timeout: float = 30.0, poll: float = 0.25) -> None:
61
+ self.launch_timeout = launch_timeout
62
+ self.poll = poll
63
+ self.app: Any = None
64
+ self.proc: Optional[subprocess.Popen] = None
65
+
66
+ def launch(self, app: AppSpec, timeout: Optional[float] = None) -> None:
67
+ from pywinauto import Application
68
+
69
+ wait = self.launch_timeout if timeout is None else timeout
70
+ self.proc = subprocess.Popen(app.argv(), shell=False)
71
+ if app.window:
72
+ # A launcher that starts the real app and exits: connect by the main window's title instead.
73
+ self.app = Application(backend="uia").connect(title_re=app.window, timeout=wait)
74
+ else:
75
+ self.app = Application(backend="uia").connect(process=self.proc.pid, timeout=wait)
76
+
77
+ def _require_app(self) -> Any:
78
+ if self.app is None:
79
+ raise RuntimeError("the app is not running: the workflow needs a launch step first")
80
+ return self.app
81
+
82
+ def find(self, target: Mapping[str, Any], timeout: float) -> Any:
83
+ from pywinauto.findwindows import ElementAmbiguousError, ElementNotFoundError
84
+
85
+ app = self._require_app()
86
+ deadline = time.time() + timeout
87
+ last = "no window"
88
+ while True:
89
+ for win in app.windows():
90
+ spec = app.window(handle=win.handle)
91
+ for part in target.get("path") or []:
92
+ spec = spec.child_window(**{k: v for k, v in (("control_type", part.get("control_type")), ("title", part.get("name"))) if v})
93
+ try:
94
+ return spec.child_window(**_criteria(target)).wrapper_object()
95
+ except ElementAmbiguousError:
96
+ raise ControlNotFound(f"several controls match {dict(target)}; add an index or more properties")
97
+ except ElementNotFoundError as e:
98
+ last = str(e) or "not found"
99
+ if time.time() >= deadline:
100
+ raise ControlNotFound(f"no control matches {dict(target)} ({last})")
101
+ time.sleep(self.poll)
102
+
103
+ def wait_ready(self, control: Any, timeout: float) -> None:
104
+ """Wait until the control is visible and enabled (a form still loading greys its buttons out)."""
105
+ deadline = time.time() + timeout
106
+ while True:
107
+ try:
108
+ if control.is_visible() and control.is_enabled():
109
+ return
110
+ except Exception:
111
+ return
112
+ if time.time() >= deadline:
113
+ raise TimeoutError(f"the control is on screen but stayed disabled or hidden for {timeout:g} s")
114
+ time.sleep(self.poll)
115
+
116
+ def click(self, control: Any, double: bool = False) -> None:
117
+ if double:
118
+ control.double_click_input()
119
+ else:
120
+ control.click_input()
121
+
122
+ def type_text(self, control: Any, text: str) -> None:
123
+ control.set_focus()
124
+ setter = getattr(control, "set_edit_text", None)
125
+ if setter is not None:
126
+ try:
127
+ setter(text)
128
+ return
129
+ except Exception:
130
+ pass
131
+ control.type_keys(escape_send_keys(text), with_spaces=True, with_tabs=True, with_newlines=True, set_foreground=True)
132
+
133
+ def select(self, control: Any, value: str) -> None:
134
+ control.select(value)
135
+
136
+ def press(self, chord: List[str]) -> None:
137
+ from pywinauto.keyboard import send_keys
138
+
139
+ app = self._require_app()
140
+ app.top_window().set_focus()
141
+ send_keys(to_send_keys(chord))
142
+
143
+ def text_of(self, control: Any) -> str:
144
+ texts = [str(control.window_text() or "")]
145
+ try:
146
+ texts.append(str(control.get_value() or ""))
147
+ except Exception:
148
+ pass
149
+ return "\n".join(t for t in texts if t)
150
+
151
+ def describe(self, limit: int = 400, texts: bool = False) -> List[Dict[str, str]]:
152
+ """
153
+ The UIA properties of the controls in the app's windows, at most ``limit``: for healing a step, and with
154
+ ``texts`` the text each control shows, for choosing checks.
155
+ """
156
+ app = self._require_app()
157
+ out: List[Dict[str, str]] = []
158
+ for win in app.windows():
159
+ for c in [win, *win.descendants()]:
160
+ info = c.element_info
161
+ props = {k: v for k, v in (("automation_id", info.automation_id), ("name", info.name), ("control_type", info.control_type), ("class_name", info.class_name)) if isinstance(v, str) and v}
162
+ if props and texts:
163
+ try:
164
+ text = self.text_of(c)
165
+ except Exception:
166
+ text = ""
167
+ if text:
168
+ props["text"] = text
169
+ if props:
170
+ out.append(props)
171
+ if len(out) >= limit:
172
+ return out
173
+ return out
174
+
175
+ def image_png(self, control: Any = None) -> Optional[bytes]:
176
+ """The control, or the app's top window, as PNG for expect_image; None when it cannot be captured."""
177
+ import io
178
+
179
+ try:
180
+ target = control if control is not None else self._require_app().top_window().wrapper_object()
181
+ image = target.capture_as_image()
182
+ if image is None:
183
+ return None
184
+ buf = io.BytesIO()
185
+ image.save(buf, format="PNG")
186
+ return buf.getvalue()
187
+ except Exception:
188
+ return None
189
+
190
+ def screenshot_png(self) -> Optional[bytes]:
191
+ """The app's top window as PNG, or None when it cannot be captured (minimised, locked screen)."""
192
+ import io
193
+
194
+ if self.app is None:
195
+ return None
196
+ try:
197
+ image = self.app.top_window().wrapper_object().capture_as_image()
198
+ buf = io.BytesIO()
199
+ image.save(buf, format="PNG")
200
+ return buf.getvalue()
201
+ except Exception:
202
+ return None
203
+
204
+ def close(self) -> None:
205
+ """Close the app this driver started, and only that one: a polite close first, then terminate."""
206
+ if self.app is not None:
207
+ try:
208
+ self.app.kill(soft=True)
209
+ except Exception:
210
+ pass
211
+ if self.proc is not None and self.proc.poll() is None:
212
+ try:
213
+ self.proc.wait(timeout=5)
214
+ except subprocess.TimeoutExpired:
215
+ self.proc.terminate()
216
+ self.app = None
217
+ self.proc = None
@@ -0,0 +1,150 @@
1
+ """
2
+ A desktop run's trace as workflow steps.
3
+
4
+ The UFO adapter normalises each action UFO took to ``{"action", "control", "args", "ok"}``, where ``control`` holds the
5
+ UIA properties of the control it acted on. Failed actions are left out; actions that have no step form are returned
6
+ in ``skipped`` so the run report can show them. A typed value equal to a known secret is written as its placeholder.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
12
+
13
+ from .workflow import FORMAT, LOCATOR_KEYS, WorkflowError, parse_chord, validate
14
+
15
+ _CONTROL_KEYS = {
16
+ "automation_id": ("automation_id", "auto_id", "AutomationId", "automationId"),
17
+ "name": ("name", "title", "Name", "control_text"),
18
+ "control_type": ("control_type", "ControlType", "controlType"),
19
+ "class_name": ("class_name", "ClassName", "className", "control_class"),
20
+ }
21
+ _PYWINAUTO_NAMES = {
22
+ "enter": "enter", "tab": "tab", "esc": "esc", "escape": "esc", "space": "space",
23
+ "backspace": "backspace", "bs": "backspace", "bksp": "backspace", "delete": "delete", "del": "delete",
24
+ "insert": "insert", "ins": "insert", "home": "home", "end": "end", "pgup": "pageup", "pgdn": "pagedown",
25
+ "up": "up", "down": "down", "left": "left", "right": "right",
26
+ **{f"f{n}": f"f{n}" for n in range(1, 13)},
27
+ }
28
+ _PYWINAUTO_MODS = {"^": "ctrl", "+": "shift", "%": "alt"}
29
+ _PYWINAUTO_CHORD = re.compile(r"^([\^+%]*)(\{[A-Za-z0-9_]+\}|[^\^+%~{}()\[\]])$")
30
+
31
+
32
+ def target_of(control: Any) -> Optional[Dict[str, Any]]:
33
+ """The locator for a control's UIA properties, or None when it has neither an AutomationId nor a name."""
34
+ if not isinstance(control, Mapping):
35
+ return None
36
+ target: Dict[str, Any] = {}
37
+ for key, names in _CONTROL_KEYS.items():
38
+ for n in names:
39
+ value = control.get(n)
40
+ if isinstance(value, str) and value.strip():
41
+ target[key] = value.strip()
42
+ break
43
+ if not target.get("automation_id") and not target.get("name"):
44
+ return None
45
+ return {k: target[k] for k in LOCATOR_KEYS if k in target}
46
+
47
+
48
+ def chord_from_pywinauto(keys: str) -> Optional[str]:
49
+ """``"^s"`` as ``"ctrl+s"``, ``"%{F4}"`` as ``"alt+f4"``; None when it is not one chord the workflow allows."""
50
+ m = _PYWINAUTO_CHORD.match((keys or "").strip())
51
+ if not m:
52
+ return None
53
+ mods = [_PYWINAUTO_MODS[c] for c in m.group(1)]
54
+ key = m.group(2)
55
+ if key.startswith("{"):
56
+ key = _PYWINAUTO_NAMES.get(key[1:-1].lower(), "")
57
+ if not key:
58
+ return None
59
+ chord = "+".join([*mods, key.lower()])
60
+ try:
61
+ parse_chord(chord)
62
+ except WorkflowError:
63
+ return None
64
+ return chord
65
+
66
+
67
+ def _mask(text: str, secrets: Mapping[str, str]) -> str:
68
+ # Longest first, so a secret that contains another is replaced whole.
69
+ for name, value in sorted(secrets.items(), key=lambda kv: -len(kv[1] or "")):
70
+ if value:
71
+ text = text.replace(value, "{{secret:%s}}" % name)
72
+ return text
73
+
74
+
75
+ def steps_from_trace(
76
+ trace: List[Mapping[str, Any]],
77
+ app: str,
78
+ name: str = "",
79
+ secrets: Optional[Mapping[str, str]] = None,
80
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
81
+ """``(workflow, skipped)`` for a trace; the workflow starts by launching ``app`` and is validated before return."""
82
+ secrets = dict(secrets or {})
83
+ steps: List[Dict[str, Any]] = [{"op": "launch"}]
84
+ skipped: List[Dict[str, Any]] = []
85
+ variables: Dict[str, str] = {}
86
+
87
+ def literal(text: str) -> Optional[str]:
88
+ """
89
+ Text for a step, secrets masked. Text that contains ``{{…}}`` (a model told to type ``{{secret:X}}``) is stored
90
+ as a variable's value, which replay types as it is; None when it also holds a secret and can be neither.
91
+ """
92
+ masked = _mask(text, secrets)
93
+ if "{{" not in text and "}}" not in text:
94
+ return masked
95
+ if masked != text:
96
+ return None
97
+ var = f"text_{len(variables) + 1}"
98
+ variables[var] = text
99
+ return "{{%s}}" % var
100
+
101
+ for i, entry in enumerate(trace, 1):
102
+ if not isinstance(entry, Mapping) or entry.get("ok") is False:
103
+ continue
104
+ action = str(entry.get("action") or "").strip().lower()
105
+ args = entry.get("args") if isinstance(entry.get("args"), Mapping) else {}
106
+ target = target_of(entry.get("control"))
107
+
108
+ def skip(reason: str) -> None:
109
+ skipped.append({"index": i, "action": action, "reason": reason})
110
+
111
+ if action in ("click_input", "click", "double_click"):
112
+ if target is None:
113
+ skip("the control has no AutomationId or name")
114
+ continue
115
+ double = action == "double_click" or bool(args.get("double"))
116
+ steps.append({"op": "double_click" if double else "click", "target": target})
117
+ elif action in ("set_edit_text", "type", "input_text"):
118
+ text = args.get("text")
119
+ if target is None or not isinstance(text, str):
120
+ skip("no control or no text")
121
+ continue
122
+ filled = literal(text)
123
+ if filled is None:
124
+ skip("text with both a secret and {{…}}")
125
+ continue
126
+ steps.append({"op": "type", "target": target, "text": filled})
127
+ elif action in ("select", "select_item"):
128
+ value = args.get("value", args.get("text"))
129
+ if target is None or not isinstance(value, str):
130
+ skip("no control or no value")
131
+ continue
132
+ filled = literal(value)
133
+ if filled is None:
134
+ skip("value with both a secret and {{…}}")
135
+ continue
136
+ steps.append({"op": "select", "target": target, "value": filled})
137
+ elif action in ("keyboard_input", "keys", "type_keys"):
138
+ keys = str(args.get("keys") or args.get("text") or "")
139
+ chord = chord_from_pywinauto(keys)
140
+ if chord:
141
+ steps.append({"op": "keys", "keys": chord})
142
+ elif target is not None and keys and not any(c in keys for c in "^+%~{}()[]"):
143
+ steps.append({"op": "type", "target": target, "text": _mask(keys, secrets)})
144
+ else:
145
+ skip("keys that are not one allowed chord")
146
+ else:
147
+ skip("no workflow step for this action")
148
+
149
+ workflow = {"format": FORMAT, "name": name, "app": app, "variables": variables, "steps": steps}
150
+ return validate(workflow), skipped