@capagents/forgeqe 1.3.1 → 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.
- package/HOW_TO_USE.md +373 -9
- package/README.md +55 -4
- package/docs/ARCHITECTURE.md +62 -1
- package/docs/CONTAINERS.md +227 -0
- package/docs/COST_ARCHITECTURE.md +32 -0
- package/docs/DESKTOP-CI.md +87 -0
- package/docs/DESKTOP.md +387 -0
- package/docs/MOBILE.md +228 -0
- package/docs/feature-test-prompts.md +25 -0
- package/docs/security-data-flow.html +440 -3
- package/docs/user-guide.html +797 -415
- package/env.example.yaml +67 -0
- package/forgeqe.example.yaml +56 -1
- package/llms.example.json +7 -0
- package/package.json +4 -1
- package/prompts/brain.md +31 -0
- package/prompts/find-tests.md +1 -0
- package/py/forgeqe_desktop/__init__.py +1 -0
- package/py/forgeqe_desktop/__main__.py +3 -0
- package/py/forgeqe_desktop/apps.py +80 -0
- package/py/forgeqe_desktop/checks.py +168 -0
- package/py/forgeqe_desktop/cli.py +90 -0
- package/py/forgeqe_desktop/handhooks.py +293 -0
- package/py/forgeqe_desktop/handrecord.py +239 -0
- package/py/forgeqe_desktop/heal.py +107 -0
- package/py/forgeqe_desktop/models.py +208 -0
- package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
- package/py/forgeqe_desktop/record.py +150 -0
- package/py/forgeqe_desktop/replay.py +345 -0
- package/py/forgeqe_desktop/service.py +356 -0
- package/py/forgeqe_desktop/session.py +60 -0
- package/py/forgeqe_desktop/ufo_bridge.py +86 -0
- package/py/forgeqe_desktop/ufo_runner.py +305 -0
- package/py/forgeqe_desktop/visual.py +112 -0
- package/py/forgeqe_desktop/workflow.py +338 -0
- package/py/forgeqe_engine/cli.py +10 -0
- package/py/forgeqe_engine/llm.py +54 -4
- package/py/forgeqe_engine/model_params.py +169 -0
- package/py/forgeqe_engine/summary.py +6 -0
- package/py/forgeqe_mobile/__init__.py +1 -0
- package/py/forgeqe_mobile/__main__.py +3 -0
- package/py/forgeqe_mobile/appium_driver.py +342 -0
- package/py/forgeqe_mobile/apps.py +84 -0
- package/py/forgeqe_mobile/cli.py +70 -0
- package/py/forgeqe_mobile/explore.py +203 -0
- package/py/forgeqe_mobile/hyperexecute.py +261 -0
- package/py/forgeqe_mobile/maestro.py +321 -0
- package/py/forgeqe_mobile/service.py +332 -0
- package/skills/testcontainers/SKILL.md +42 -0
- package/src/agents.ts +11 -2
- package/src/api/plan.ts +2 -0
- package/src/api/write.ts +2 -0
- package/src/auth/copilot.ts +130 -0
- package/src/auth/signin.ts +54 -1
- package/src/brain/index.ts +26 -4
- package/src/capabilities/guide.ts +2 -0
- package/src/capabilities/registry.ts +110 -2
- package/src/capabilities/status.ts +15 -0
- package/src/cases/defects.ts +2 -1
- package/src/cases/rca.ts +2 -0
- package/src/childEnv.ts +4 -1
- package/src/cli/args.ts +3 -2
- package/src/cli/help.ts +24 -3
- package/src/cli/index.ts +47 -3
- package/src/code/find.ts +26 -0
- package/src/code/loop.ts +4 -1
- package/src/code/prompt.ts +2 -1
- package/src/code/prreview.ts +2 -0
- package/src/code/review.ts +6 -2
- package/src/code/runtime.ts +34 -3
- package/src/commands.ts +6 -1
- package/src/config.ts +159 -3
- package/src/containers/command.ts +68 -0
- package/src/containers/compose.ts +173 -0
- package/src/containers/config.ts +283 -0
- package/src/containers/mock.ts +156 -0
- package/src/containers/run.ts +362 -0
- package/src/containers/tests.ts +80 -0
- package/src/context.ts +33 -6
- package/src/copilot.ts +155 -0
- package/src/desktop/bridge.ts +229 -0
- package/src/desktop/command.ts +350 -0
- package/src/desktop/edit.ts +217 -0
- package/src/desktop/junit.ts +35 -0
- package/src/desktop/report.ts +155 -0
- package/src/desktop/run.ts +252 -0
- package/src/desktop/setup.ts +134 -0
- package/src/engine/bridge.ts +12 -5
- package/src/engine/setup.ts +6 -3
- package/src/environments.ts +566 -0
- package/src/evals/domain.ts +1 -0
- package/src/explore/charters.ts +2 -0
- package/src/guardrails.ts +145 -0
- package/src/heal/loop.ts +4 -1
- package/src/heal/prompt.ts +2 -1
- package/src/init.ts +5 -2
- package/src/llm.ts +23 -2
- package/src/mcp/attachtools.ts +16 -4
- package/src/mcp/captools.ts +27 -0
- package/src/mcp/containertools.ts +212 -0
- package/src/mcp/desktoptools.ts +334 -0
- package/src/mcp/mobiletools.ts +132 -0
- package/src/mcp/reporttools.ts +17 -2
- package/src/mcp/repotests.ts +22 -3
- package/src/mcp/routing.ts +11 -5
- package/src/mcp/scope.ts +210 -0
- package/src/mcp/server.ts +80 -5
- package/src/mcp/tools.ts +38 -1
- package/src/mobile/command.ts +245 -0
- package/src/mobile/run.ts +226 -0
- package/src/pack/pipeline.ts +44 -2
- package/src/refine.ts +3 -0
- package/src/report/html.ts +3 -0
- package/src/report/template.html +48 -48
- package/src/run.ts +7 -3
- package/src/tokenomics/ledger.ts +3 -1
- package/src/tui/App.tsx +23 -0
- package/src/update.ts +118 -8
- 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
|