@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,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