@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.
- 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/llm.py +54 -4
- package/py/forgeqe_engine/model_params.py +169 -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/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 +3 -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/usage.ts +1 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The Appium driver: a small W3C WebDriver client over HTTP (no extra packages), implementing the replay ``Driver`` that
|
|
3
|
+
the desktop engine's replay, healing, checks and visual checks already use.
|
|
4
|
+
|
|
5
|
+
Targets use the same keys as desktop workflows, mapped to each platform's attributes:
|
|
6
|
+
|
|
7
|
+
| target key | Android (UiAutomator2) | iOS (XCUITest) |
|
|
8
|
+
|----------------|---------------------------------|---------------------------|
|
|
9
|
+
| automation_id | resource-id (or its id after :id/) | name (accessibility id) |
|
|
10
|
+
| name | text or content-desc | label, name or value |
|
|
11
|
+
| control_type | class (android.widget.Button) | type (XCUIElementTypeButton) |
|
|
12
|
+
|
|
13
|
+
Every value goes into the XPath as a quoted literal, so a target cannot change the query. The Appium server is the
|
|
14
|
+
team's own (default http://127.0.0.1:4723) or a device cloud; a cloud's user and key come from environment variables.
|
|
15
|
+
TLS is verified as usual.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import json
|
|
21
|
+
import socket
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import urllib.request
|
|
26
|
+
import xml.etree.ElementTree as ET
|
|
27
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional
|
|
28
|
+
|
|
29
|
+
from forgeqe_desktop.replay import ControlNotFound
|
|
30
|
+
|
|
31
|
+
from .apps import MobileAppSpec
|
|
32
|
+
|
|
33
|
+
ANDROID_KEYCODES = {"enter": 66, "back": 4, "tab": 61, "delete": 67, "search": 84}
|
|
34
|
+
MAX_SOURCE = 20_000_000
|
|
35
|
+
# How long a device cloud may take to start a session: its queue for a free device, then installing the app.
|
|
36
|
+
REMOTE_SESSION_TIMEOUT = 900
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AppiumError(RuntimeError):
|
|
40
|
+
"""The Appium server refused a command or could not be reached; the message says which."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def xpath_literal(value: str) -> str:
|
|
44
|
+
"""``value`` as an XPath 1.0 string literal, whatever quotes it holds."""
|
|
45
|
+
if "'" not in value:
|
|
46
|
+
return f"'{value}'"
|
|
47
|
+
if '"' not in value:
|
|
48
|
+
return f'"{value}"'
|
|
49
|
+
parts = value.split("'")
|
|
50
|
+
return "concat(" + ", \"'\", ".join(f"'{p}'" for p in parts) + ")"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def target_xpath(target: Mapping[str, Any], platform: str) -> str:
|
|
54
|
+
"""The XPath for a target's properties on ``platform``: every given property must match."""
|
|
55
|
+
conds: List[str] = []
|
|
56
|
+
aid = target.get("automation_id")
|
|
57
|
+
name = target.get("name")
|
|
58
|
+
kind = target.get("control_type") or target.get("class_name")
|
|
59
|
+
if platform == "android":
|
|
60
|
+
if aid:
|
|
61
|
+
lit, suffix = xpath_literal(str(aid)), xpath_literal(":id/" + str(aid))
|
|
62
|
+
conds.append(f"(@resource-id={lit} or substring(@resource-id, string-length(@resource-id) - string-length({suffix}) + 1) = {suffix})")
|
|
63
|
+
if name:
|
|
64
|
+
lit = xpath_literal(str(name))
|
|
65
|
+
conds.append(f"(@text={lit} or @content-desc={lit})")
|
|
66
|
+
if kind:
|
|
67
|
+
conds.append(f"@class={xpath_literal(str(kind))}")
|
|
68
|
+
else:
|
|
69
|
+
if aid:
|
|
70
|
+
conds.append(f"@name={xpath_literal(str(aid))}")
|
|
71
|
+
if name:
|
|
72
|
+
lit = xpath_literal(str(name))
|
|
73
|
+
conds.append(f"(@label={lit} or @name={lit} or @value={lit})")
|
|
74
|
+
if kind:
|
|
75
|
+
conds.append(f"@type={xpath_literal(str(kind))}")
|
|
76
|
+
return "//*" + (f"[{' and '.join(conds)}]" if conds else "")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def element_props(node: ET.Element, platform: str) -> Dict[str, str]:
|
|
80
|
+
"""A page-source node's properties as target keys, plus the text it shows."""
|
|
81
|
+
a = node.attrib
|
|
82
|
+
if platform == "android":
|
|
83
|
+
rid = a.get("resource-id", "")
|
|
84
|
+
props = {
|
|
85
|
+
"automation_id": rid.split(":id/", 1)[1] if ":id/" in rid else rid,
|
|
86
|
+
"name": a.get("text") or a.get("content-desc") or "",
|
|
87
|
+
"control_type": a.get("class") or node.tag,
|
|
88
|
+
"text": a.get("text") or a.get("content-desc") or "",
|
|
89
|
+
# The app the control belongs to: a system dialog (a permission prompt) is another package's.
|
|
90
|
+
"package": a.get("package") or "",
|
|
91
|
+
}
|
|
92
|
+
else:
|
|
93
|
+
props = {
|
|
94
|
+
"automation_id": a.get("name", ""),
|
|
95
|
+
"name": a.get("label") or a.get("name") or "",
|
|
96
|
+
"control_type": a.get("type") or node.tag,
|
|
97
|
+
"text": a.get("value") or a.get("label") or "",
|
|
98
|
+
}
|
|
99
|
+
return {k: v for k, v in props.items() if isinstance(v, str) and v}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AppiumDriver:
|
|
103
|
+
needs_desktop = False
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
url: str = "http://127.0.0.1:4723",
|
|
108
|
+
capabilities: Optional[Mapping[str, Any]] = None,
|
|
109
|
+
username: Optional[str] = None,
|
|
110
|
+
access_key: Optional[str] = None,
|
|
111
|
+
command_timeout: float = 120.0,
|
|
112
|
+
poll: float = 0.4,
|
|
113
|
+
opener: Optional[Callable[..., Any]] = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
if not url.lower().startswith(("http://", "https://")):
|
|
116
|
+
raise AppiumError("mobile.appium.url must be an http(s) URL")
|
|
117
|
+
self.url = url.rstrip("/")
|
|
118
|
+
self.extra_caps = dict(capabilities or {})
|
|
119
|
+
self.auth = "Basic " + base64.b64encode(f"{username}:{access_key}".encode()).decode() if username and access_key else None
|
|
120
|
+
self.command_timeout = command_timeout
|
|
121
|
+
self.poll = poll
|
|
122
|
+
self.open = opener or urllib.request.urlopen
|
|
123
|
+
self.session: Optional[str] = None
|
|
124
|
+
self.platform = "android"
|
|
125
|
+
self.app: Optional[MobileAppSpec] = None
|
|
126
|
+
|
|
127
|
+
# -- WebDriver over HTTP --------------------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _call(self, method: str, path: str, body: Optional[Mapping[str, Any]] = None, timeout: Optional[float] = None) -> Any:
|
|
130
|
+
data = json.dumps(body).encode() if body is not None else (b"{}" if method == "POST" else None)
|
|
131
|
+
req = urllib.request.Request(self.url + path, data=data, method=method, headers={"Content-Type": "application/json; charset=utf-8"})
|
|
132
|
+
if self.auth:
|
|
133
|
+
req.add_header("Authorization", self.auth)
|
|
134
|
+
try:
|
|
135
|
+
with self.open(req, timeout=timeout or self.command_timeout) as resp:
|
|
136
|
+
raw = resp.read(MAX_SOURCE)
|
|
137
|
+
except urllib.error.HTTPError as e:
|
|
138
|
+
raw = e.read(100_000)
|
|
139
|
+
try:
|
|
140
|
+
value = json.loads(raw or b"{}").get("value") or {}
|
|
141
|
+
except ValueError:
|
|
142
|
+
value = {}
|
|
143
|
+
error = value.get("error") if isinstance(value, dict) else ""
|
|
144
|
+
message = value.get("message") if isinstance(value, dict) else ""
|
|
145
|
+
if error == "no such element":
|
|
146
|
+
raise ControlNotFound(str(message or "no such element")[:500])
|
|
147
|
+
raise AppiumError(f"Appium: {error or e.code}: {str(message or e.reason)[:500]}")
|
|
148
|
+
except urllib.error.URLError as e:
|
|
149
|
+
if isinstance(e.reason, (TimeoutError, socket.timeout)):
|
|
150
|
+
raise AppiumError(self._timed_out(path, timeout or self.command_timeout))
|
|
151
|
+
raise AppiumError(f"cannot reach the Appium server at {self.url} ({e.reason}); start it with: appium")
|
|
152
|
+
except (TimeoutError, socket.timeout):
|
|
153
|
+
raise AppiumError(self._timed_out(path, timeout or self.command_timeout))
|
|
154
|
+
try:
|
|
155
|
+
return json.loads(raw or b"{}").get("value")
|
|
156
|
+
except ValueError:
|
|
157
|
+
raise AppiumError("Appium answered with something that is not JSON")
|
|
158
|
+
|
|
159
|
+
def _timed_out(self, path: str, seconds: float) -> str:
|
|
160
|
+
if path == "/session":
|
|
161
|
+
return (
|
|
162
|
+
f"the Appium server at {self.url} started no session within {int(seconds)} s"
|
|
163
|
+
+ ("; a device cloud may have queued it behind other sessions: run fewer at once" if self.remote else "")
|
|
164
|
+
)
|
|
165
|
+
return f"the Appium server at {self.url} did not answer within {int(seconds)} s"
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def remote(self) -> bool:
|
|
169
|
+
"""A device cloud or another machine, where a new session can wait in a queue for a free device."""
|
|
170
|
+
host = (urllib.parse.urlsplit(self.url).hostname or "").lower()
|
|
171
|
+
return host not in ("127.0.0.1", "localhost", "::1")
|
|
172
|
+
|
|
173
|
+
def _s(self, path: str = "") -> str:
|
|
174
|
+
if not self.session:
|
|
175
|
+
raise RuntimeError("the app is not running: the workflow needs a launch step first")
|
|
176
|
+
return f"/session/{self.session}{path}"
|
|
177
|
+
|
|
178
|
+
def _script(self, name: str, args: Mapping[str, Any]) -> Any:
|
|
179
|
+
return self._call("POST", self._s("/execute/sync"), {"script": name, "args": [dict(args)]})
|
|
180
|
+
|
|
181
|
+
# -- Driver ---------------------------------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def launch(self, app: MobileAppSpec, timeout: Optional[float] = None) -> None:
|
|
184
|
+
self.app = app
|
|
185
|
+
self.platform = app.platform
|
|
186
|
+
if self.session:
|
|
187
|
+
self._script("mobile: activateApp", {"appId": app.app_id} if app.platform == "android" else {"bundleId": app.app_id})
|
|
188
|
+
return
|
|
189
|
+
caps = {**app.capabilities(), **self.extra_caps}
|
|
190
|
+
# TestMu AI (LambdaTest) reads the app from LT:Options: the app's own build, unless the config names one there.
|
|
191
|
+
lt = caps.get("LT:Options")
|
|
192
|
+
if isinstance(lt, dict) and app.app and "app" not in lt:
|
|
193
|
+
caps["LT:Options"] = {**lt, "app": app.app}
|
|
194
|
+
# A cloud queues a session until a device is free (TestMu up to 10 minutes) and then installs the app.
|
|
195
|
+
wait = max(timeout or 0, REMOTE_SESSION_TIMEOUT) if self.remote else (timeout or 300)
|
|
196
|
+
value = self._call("POST", "/session", {"capabilities": {"alwaysMatch": caps, "firstMatch": [{}]}}, timeout=wait)
|
|
197
|
+
session = value.get("sessionId") if isinstance(value, dict) else None
|
|
198
|
+
if not isinstance(session, str) or not session:
|
|
199
|
+
raise AppiumError("Appium started no session")
|
|
200
|
+
self.session = session
|
|
201
|
+
|
|
202
|
+
def find(self, target: Mapping[str, Any], timeout: float) -> Any:
|
|
203
|
+
xpath = target_xpath(target, self.platform)
|
|
204
|
+
index = int(target.get("index", 0) or 0)
|
|
205
|
+
deadline = time.time() + timeout
|
|
206
|
+
while True:
|
|
207
|
+
found = self._call("POST", self._s("/elements"), {"using": "xpath", "value": xpath}) or []
|
|
208
|
+
if isinstance(found, list) and len(found) > index:
|
|
209
|
+
el = found[index]
|
|
210
|
+
if isinstance(el, dict):
|
|
211
|
+
ref = el.get("element-6066-11e4-a52e-4f735466cecf") or el.get("ELEMENT")
|
|
212
|
+
if isinstance(ref, str):
|
|
213
|
+
return ref
|
|
214
|
+
if time.time() >= deadline:
|
|
215
|
+
raise ControlNotFound(f"no control matches {dict(target)}")
|
|
216
|
+
time.sleep(self.poll)
|
|
217
|
+
|
|
218
|
+
def wait_ready(self, control: Any, timeout: float) -> None:
|
|
219
|
+
deadline = time.time() + timeout
|
|
220
|
+
while True:
|
|
221
|
+
try:
|
|
222
|
+
if self._call("GET", self._s(f"/element/{control}/displayed")) and self._call("GET", self._s(f"/element/{control}/enabled")):
|
|
223
|
+
return
|
|
224
|
+
except AppiumError:
|
|
225
|
+
return
|
|
226
|
+
if time.time() >= deadline:
|
|
227
|
+
raise TimeoutError(f"the control is on screen but stayed disabled or hidden for {timeout:g} s")
|
|
228
|
+
time.sleep(self.poll)
|
|
229
|
+
|
|
230
|
+
def click(self, control: Any, double: bool = False) -> None:
|
|
231
|
+
if not double:
|
|
232
|
+
self._call("POST", self._s(f"/element/{control}/click"))
|
|
233
|
+
elif self.platform == "android":
|
|
234
|
+
self._script("mobile: doubleClickGesture", {"elementId": control})
|
|
235
|
+
else:
|
|
236
|
+
self._script("mobile: doubleTap", {"elementId": control})
|
|
237
|
+
|
|
238
|
+
def long_press(self, control: Any) -> None:
|
|
239
|
+
if self.platform == "android":
|
|
240
|
+
self._script("mobile: longClickGesture", {"elementId": control, "duration": 1000})
|
|
241
|
+
else:
|
|
242
|
+
self._script("mobile: touchAndHold", {"elementId": control, "duration": 1.0})
|
|
243
|
+
|
|
244
|
+
def type_text(self, control: Any, text: str) -> None:
|
|
245
|
+
self._call("POST", self._s(f"/element/{control}/clear"))
|
|
246
|
+
self._call("POST", self._s(f"/element/{control}/value"), {"text": text, "value": list(text)})
|
|
247
|
+
|
|
248
|
+
def select(self, control: Any, value: str) -> None:
|
|
249
|
+
self.click(control)
|
|
250
|
+
self.click(self.find({"name": value}, 10))
|
|
251
|
+
|
|
252
|
+
def text_of(self, control: Any) -> str:
|
|
253
|
+
texts = [str(self._call("GET", self._s(f"/element/{control}/text")) or "")]
|
|
254
|
+
for attr in (("content-desc",) if self.platform == "android" else ("label", "value")):
|
|
255
|
+
try:
|
|
256
|
+
v = self._call("GET", self._s(f"/element/{control}/attribute/{attr}"))
|
|
257
|
+
if isinstance(v, str) and v and v not in texts:
|
|
258
|
+
texts.append(v)
|
|
259
|
+
except (AppiumError, ControlNotFound):
|
|
260
|
+
pass
|
|
261
|
+
return "\n".join(t for t in texts if t)
|
|
262
|
+
|
|
263
|
+
def press_key(self, key: str) -> None:
|
|
264
|
+
if self.platform == "android":
|
|
265
|
+
self._script("mobile: pressKey", {"keycode": ANDROID_KEYCODES[key]})
|
|
266
|
+
elif key == "enter":
|
|
267
|
+
self._call("POST", self._s("/actions"), {"actions": [{"type": "key", "id": "keyboard", "actions": [{"type": "keyDown", "value": ""}, {"type": "keyUp", "value": ""}]}]})
|
|
268
|
+
else:
|
|
269
|
+
raise AppiumError(f"the {key} key does not exist on iOS")
|
|
270
|
+
|
|
271
|
+
def back(self) -> None:
|
|
272
|
+
self._call("POST", self._s("/back"))
|
|
273
|
+
|
|
274
|
+
def hide_keyboard(self) -> None:
|
|
275
|
+
try:
|
|
276
|
+
self._call("POST", self._s("/appium/device/hide_keyboard"))
|
|
277
|
+
except AppiumError:
|
|
278
|
+
pass # no keyboard was shown
|
|
279
|
+
|
|
280
|
+
def _screen(self) -> Dict[str, int]:
|
|
281
|
+
rect = self._call("GET", self._s("/window/rect")) or {}
|
|
282
|
+
return {"left": 0, "top": int(rect.get("height", 0) * 0.15), "width": int(rect.get("width", 0)), "height": int(rect.get("height", 0) * 0.7)}
|
|
283
|
+
|
|
284
|
+
def swipe(self, direction: str, control: Any = None) -> None:
|
|
285
|
+
"""A finger swipe in ``direction`` (on the control, or across the screen)."""
|
|
286
|
+
if self.platform == "android":
|
|
287
|
+
area = {"elementId": control} if control else self._screen()
|
|
288
|
+
self._script("mobile: swipeGesture", {**area, "direction": direction, "percent": 0.75})
|
|
289
|
+
else:
|
|
290
|
+
self._script("mobile: swipe", {"direction": direction, **({"elementId": control} if control else {})})
|
|
291
|
+
|
|
292
|
+
def scroll(self, direction: str) -> None:
|
|
293
|
+
"""Move the content so that what is further ``direction`` (down: further down the page) comes into view."""
|
|
294
|
+
if self.platform == "android":
|
|
295
|
+
self._script("mobile: scrollGesture", {**self._screen(), "direction": direction, "percent": 0.8})
|
|
296
|
+
else:
|
|
297
|
+
self._script("mobile: scroll", {"direction": direction})
|
|
298
|
+
|
|
299
|
+
def describe(self, limit: int = 400, texts: bool = False) -> List[Dict[str, str]]:
|
|
300
|
+
source = self._call("GET", self._s("/source")) or ""
|
|
301
|
+
try:
|
|
302
|
+
root = ET.fromstring(source.encode("utf-8") if isinstance(source, str) else source)
|
|
303
|
+
except ET.ParseError:
|
|
304
|
+
return []
|
|
305
|
+
out: List[Dict[str, str]] = []
|
|
306
|
+
for node in root.iter():
|
|
307
|
+
props = element_props(node, self.platform)
|
|
308
|
+
if not texts:
|
|
309
|
+
props.pop("text", None)
|
|
310
|
+
if props.get("automation_id") or props.get("name"):
|
|
311
|
+
out.append(props)
|
|
312
|
+
if len(out) >= limit:
|
|
313
|
+
break
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
def screenshot_png(self) -> Optional[bytes]:
|
|
317
|
+
if not self.session:
|
|
318
|
+
return None
|
|
319
|
+
try:
|
|
320
|
+
return base64.b64decode(self._call("GET", self._s("/screenshot")) or "")
|
|
321
|
+
except Exception:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
def image_png(self, control: Any = None) -> Optional[bytes]:
|
|
325
|
+
try:
|
|
326
|
+
raw = self._call("GET", self._s(f"/element/{control}/screenshot" if control else "/screenshot"))
|
|
327
|
+
return base64.b64decode(raw or "") or None
|
|
328
|
+
except Exception:
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
def close(self) -> None:
|
|
332
|
+
if self.session:
|
|
333
|
+
try:
|
|
334
|
+
if self.app is not None:
|
|
335
|
+
self._script("mobile: terminateApp", {"appId": self.app.app_id} if self.platform == "android" else {"bundleId": self.app.app_id})
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
try:
|
|
339
|
+
self._call("DELETE", self._s())
|
|
340
|
+
except Exception:
|
|
341
|
+
pass
|
|
342
|
+
self.session = None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The mobile apps a workflow may start: ``mobile.apps`` in forgeqe.yaml and the active environment's mobile apps, never
|
|
3
|
+
an app named by the workflow itself. Each is a platform and a package (Android) or bundle id (iOS), with an optional
|
|
4
|
+
Android activity and an optional app file or cloud upload to install.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ntpath
|
|
9
|
+
import posixpath
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Dict, Mapping, Optional
|
|
13
|
+
|
|
14
|
+
from forgeqe_desktop.workflow import APP_NAME, WorkflowError
|
|
15
|
+
|
|
16
|
+
APP_ID = re.compile(r"^[A-Za-z0-9_.-]{1,200}$")
|
|
17
|
+
ACTIVITY = re.compile(r"^[A-Za-z0-9_.$]{1,200}$")
|
|
18
|
+
APP_FILE = re.compile(r"\.(apk|aab|ipa|app|zip)$", re.I)
|
|
19
|
+
# Uploads on device clouds, by their own schemes only: bs://<id> (BrowserStack), lt://<id> (LambdaTest),
|
|
20
|
+
# storage:filename=… (Sauce Labs). Never file:// or any other scheme.
|
|
21
|
+
CLOUD_APP = re.compile(r"^((bs|lt)://[A-Za-z0-9._-]{1,200}|storage:filename=[A-Za-z0-9._-]{1,200})$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class MobileAppSpec:
|
|
26
|
+
name: str
|
|
27
|
+
platform: str
|
|
28
|
+
app_id: str
|
|
29
|
+
activity: Optional[str] = None
|
|
30
|
+
app: Optional[str] = None
|
|
31
|
+
# replay's resolve_app checks `path` exists for desktop apps; a mobile app has no local program to check.
|
|
32
|
+
path: str = ""
|
|
33
|
+
|
|
34
|
+
def capabilities(self) -> Dict[str, Any]:
|
|
35
|
+
"""The W3C capabilities that start this app through Appium."""
|
|
36
|
+
if self.platform == "android":
|
|
37
|
+
caps: Dict[str, Any] = {"platformName": "Android", "appium:automationName": "UiAutomator2", "appium:appPackage": self.app_id}
|
|
38
|
+
if self.activity:
|
|
39
|
+
caps["appium:appActivity"] = self.activity
|
|
40
|
+
else:
|
|
41
|
+
caps = {"platformName": "iOS", "appium:automationName": "XCUITest", "appium:bundleId": self.app_id}
|
|
42
|
+
if self.app:
|
|
43
|
+
caps["appium:app"] = self.app
|
|
44
|
+
return caps
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _check_app_file(where: str, value: Any) -> Optional[str]:
|
|
48
|
+
if value is None or value == "":
|
|
49
|
+
return None
|
|
50
|
+
if not isinstance(value, str) or any(ord(c) < 32 for c in value):
|
|
51
|
+
raise WorkflowError(f"{where}.app must be text")
|
|
52
|
+
v = value.strip()
|
|
53
|
+
if v.lower().startswith("https://") or CLOUD_APP.match(v):
|
|
54
|
+
return v
|
|
55
|
+
if (ntpath.isabs(v) or posixpath.isabs(v)) and APP_FILE.search(v):
|
|
56
|
+
return v
|
|
57
|
+
raise WorkflowError(f"{where}.app must be an absolute path to an .apk, .aab, .ipa, .app or .zip, an https URL, or a device cloud upload (bs://…)")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def mobile_app_spec(name: str, raw: Any) -> MobileAppSpec:
|
|
61
|
+
where = f"mobile.apps.{name}"
|
|
62
|
+
if not isinstance(name, str) or not APP_NAME.match(name):
|
|
63
|
+
raise WorkflowError(f"{where}: app names are letters, digits and _ . -")
|
|
64
|
+
if not isinstance(raw, Mapping):
|
|
65
|
+
raise WorkflowError(f"{where} must be a mapping with platform and app_id")
|
|
66
|
+
platform = raw.get("platform")
|
|
67
|
+
if platform not in ("android", "ios"):
|
|
68
|
+
raise WorkflowError(f"{where}.platform must be android or ios")
|
|
69
|
+
app_id = raw.get("app_id", raw.get("appId"))
|
|
70
|
+
if not isinstance(app_id, str) or not APP_ID.match(app_id):
|
|
71
|
+
raise WorkflowError(f"{where}.app_id must be the app's package (Android) or bundle id (iOS)")
|
|
72
|
+
activity = raw.get("activity")
|
|
73
|
+
if activity is not None and (not isinstance(activity, str) or not ACTIVITY.match(activity) or platform != "android"):
|
|
74
|
+
raise WorkflowError(f"{where}.activity must be an Android activity name such as .MainActivity")
|
|
75
|
+
return MobileAppSpec(name=name, platform=platform, app_id=app_id, activity=activity or None, app=_check_app_file(where, raw.get("app")))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_mobile_apps(mobile: Mapping[str, Any]) -> Dict[str, MobileAppSpec]:
|
|
79
|
+
apps = mobile.get("apps") if isinstance(mobile, Mapping) else None
|
|
80
|
+
if apps is None:
|
|
81
|
+
return {}
|
|
82
|
+
if not isinstance(apps, Mapping):
|
|
83
|
+
raise WorkflowError("mobile.apps must be a mapping of app name to {platform, app_id}")
|
|
84
|
+
return {name: mobile_app_spec(name, raw) for name, raw in apps.items()}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``python -m forgeqe_mobile``: ``serve`` is what ``forgeqe mobile`` runs; ``flow`` prints a mobile workflow as a Maestro
|
|
3
|
+
flow, ``check-flow`` checks a team's Maestro flow against the allowlist, ``doctor`` says whether Appium and Maestro
|
|
4
|
+
answer.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from forgeqe_desktop.workflow import WorkflowError, load
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def doctor(appium_url: str, maestro: str) -> dict:
|
|
19
|
+
out: dict = {"appium_url": appium_url}
|
|
20
|
+
try:
|
|
21
|
+
req = urllib.request.Request(appium_url.rstrip("/") + "/status", method="GET")
|
|
22
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
23
|
+
value = json.loads(resp.read(100_000) or b"{}").get("value") or {}
|
|
24
|
+
out["appium"] = True
|
|
25
|
+
out["appium_version"] = str((value.get("build") or {}).get("version") or "")
|
|
26
|
+
except Exception as e:
|
|
27
|
+
out["appium"] = False
|
|
28
|
+
out["appium_error"] = str(getattr(e, "reason", e))[:200]
|
|
29
|
+
found = shutil.which(maestro)
|
|
30
|
+
out["maestro"] = bool(found)
|
|
31
|
+
if found:
|
|
32
|
+
out["maestro_path"] = found
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
37
|
+
parser = argparse.ArgumentParser(prog="forgeqe_mobile")
|
|
38
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
39
|
+
sub.add_parser("serve", help="one JSON request on stdin, JSON events on stdout (what the forgeqe CLI runs)")
|
|
40
|
+
f = sub.add_parser("flow", help="a mobile workflow as a Maestro flow")
|
|
41
|
+
f.add_argument("workflow")
|
|
42
|
+
f.add_argument("--app-id", required=True)
|
|
43
|
+
c = sub.add_parser("check-flow", help="check a Maestro flow (and the flows it runs) against the allowlist")
|
|
44
|
+
c.add_argument("flow")
|
|
45
|
+
c.add_argument("--root", required=True)
|
|
46
|
+
d = sub.add_parser("doctor", help="whether the Appium server and maestro answer, as JSON")
|
|
47
|
+
d.add_argument("--appium-url", default="http://127.0.0.1:4723")
|
|
48
|
+
d.add_argument("--maestro", default="maestro")
|
|
49
|
+
args = parser.parse_args(argv)
|
|
50
|
+
if args.command == "serve":
|
|
51
|
+
from .service import serve
|
|
52
|
+
|
|
53
|
+
return serve()
|
|
54
|
+
try:
|
|
55
|
+
if args.command == "flow":
|
|
56
|
+
from .maestro import to_flow
|
|
57
|
+
|
|
58
|
+
flow, _ = to_flow(load(args.workflow), args.app_id)
|
|
59
|
+
sys.stdout.write(flow)
|
|
60
|
+
return 0
|
|
61
|
+
if args.command == "check-flow":
|
|
62
|
+
from .maestro import check_flow
|
|
63
|
+
|
|
64
|
+
print(f"ok: {len(check_flow(args.flow, args.root))} flow files checked")
|
|
65
|
+
return 0
|
|
66
|
+
print(json.dumps(doctor(args.appium_url, args.maestro)))
|
|
67
|
+
return 0
|
|
68
|
+
except (WorkflowError, ValueError, OSError) as e:
|
|
69
|
+
print(f"error: {e}", file=sys.stderr)
|
|
70
|
+
return 2
|