@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,321 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Maestro (maestro.mobile.dev): replay ForgeQE mobile workflows as Maestro flows, and run the team's own flows.
|
|
3
|
+
|
|
4
|
+
- ``to_flow`` writes a workflow as a Maestro flow. Variables are filled in first; ``{{secret:NAME}}`` becomes
|
|
5
|
+
``${MAESTRO_NAME}``, which Maestro reads from the environment ForgeQE gives it, so a secret's value never lands in
|
|
6
|
+
the flow file or on a command line.
|
|
7
|
+
- ``check_flow`` checks a flow someone else wrote before it runs. Maestro evaluates ``${…}`` as JavaScript and has
|
|
8
|
+
commands that run scripts, call AI services or write files, so only an allowlist of UI commands passes, ``${…}`` may
|
|
9
|
+
only name a variable, and ``runFlow`` files must stay inside the project.
|
|
10
|
+
- ``run_maestro`` starts ``maestro test`` with an argument list (no shell) and reads its JUnit report and its debug
|
|
11
|
+
output for the result of each command.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from forgeqe_desktop.workflow import WorkflowError, fill, fill_targets, secret_names, validate
|
|
26
|
+
|
|
27
|
+
_JS_EXPR = re.compile(r"\$\{([^}]*)\}")
|
|
28
|
+
_JS_VAR = re.compile(r"^\s*[A-Za-z_][A-Za-z0-9_]*\s*$")
|
|
29
|
+
_REGEX_SPECIAL = re.compile(r"([\\.^$|?*+()\[\]{}])")
|
|
30
|
+
MAESTRO_KEYS = {"enter": "Enter", "back": "Back", "tab": "Tab", "delete": "Backspace"}
|
|
31
|
+
MAX_FLOW_DEPTH = 5
|
|
32
|
+
|
|
33
|
+
# Maestro commands that only act on the app's UI. Everything else (runScript, evalScript, assertTrue, the AI commands,
|
|
34
|
+
# takeScreenshot and recordings that write files, addMedia) is refused in flows ForgeQE did not write.
|
|
35
|
+
ALLOWED_COMMANDS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"launchApp", "stopApp", "killApp", "clearState", "clearKeychain", "tapOn", "doubleTapOn", "longPressOn",
|
|
38
|
+
"inputText", "inputRandomText", "inputRandomNumber", "inputRandomEmail", "inputRandomPersonName", "eraseText",
|
|
39
|
+
"pressKey", "back", "hideKeyboard", "scroll", "scrollUntilVisible", "swipe", "assertVisible", "assertNotVisible",
|
|
40
|
+
"waitForAnimationToEnd", "extendedWaitUntil", "copyTextFrom", "pasteText", "openLink", "setLocation", "travel",
|
|
41
|
+
"setAirplaneMode", "toggleAirplaneMode", "runFlow", "repeat", "retry",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
_REFUSED_LINK = re.compile(r"^\s*(javascript|file|data|content|intent|vbscript):", re.I)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def regex_literal(text: str) -> str:
|
|
48
|
+
"""``text`` as a Maestro (Java) regex that matches it literally."""
|
|
49
|
+
return _REGEX_SPECIAL.sub(r"\\\1", text)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _selector(target: Mapping[str, Any], text: Optional[str] = None) -> Dict[str, Any]:
|
|
53
|
+
sel: Dict[str, Any] = {}
|
|
54
|
+
if target.get("automation_id"):
|
|
55
|
+
sel["id"] = regex_literal(str(target["automation_id"]))
|
|
56
|
+
if text is not None:
|
|
57
|
+
sel["text"] = f".*{regex_literal(text)}.*"
|
|
58
|
+
elif target.get("name"):
|
|
59
|
+
sel["text"] = regex_literal(str(target["name"]))
|
|
60
|
+
if "index" in target:
|
|
61
|
+
sel["index"] = int(target["index"])
|
|
62
|
+
if not sel:
|
|
63
|
+
raise WorkflowError("a Maestro step needs an automation_id or a name")
|
|
64
|
+
return sel
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ms(seconds: Any) -> int:
|
|
68
|
+
return int(float(seconds) * 1000)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def to_flow(workflow: Mapping[str, Any], app_id: str, variables: Optional[Mapping[str, Any]] = None, allowed_secrets: Iterable[str] = ()) -> Tuple[str, List[str]]:
|
|
72
|
+
"""``(flow text, secret names)`` for a mobile workflow; the secrets are read from MAESTRO_<NAME> when it runs."""
|
|
73
|
+
wf = validate(dict(workflow))
|
|
74
|
+
if wf["format"] != "forgeqe-mobile-v1":
|
|
75
|
+
raise WorkflowError("only mobile workflows (forgeqe-mobile-v1) run on Maestro")
|
|
76
|
+
allowed = {str(n) for n in allowed_secrets}
|
|
77
|
+
values: Dict[str, Any] = {**wf["variables"], **dict(variables or {})}
|
|
78
|
+
wf = fill_targets(wf, values)
|
|
79
|
+
used: List[str] = []
|
|
80
|
+
|
|
81
|
+
def secret(name: str) -> Optional[str]:
|
|
82
|
+
if name not in allowed:
|
|
83
|
+
raise WorkflowError(f"secret {name} is not listed in forgeqe.yaml mobile.secrets")
|
|
84
|
+
if name not in used:
|
|
85
|
+
used.append(name)
|
|
86
|
+
return "${MAESTRO_%s}" % name
|
|
87
|
+
|
|
88
|
+
def text(raw: str) -> str:
|
|
89
|
+
for name in secret_names(raw):
|
|
90
|
+
secret(name)
|
|
91
|
+
# A value with ${…} would be run by Maestro as JavaScript.
|
|
92
|
+
filled_values = fill(raw, values, lambda n: "")
|
|
93
|
+
if "${" in filled_values:
|
|
94
|
+
raise WorkflowError("text for Maestro may not contain ${…}: Maestro would run it as JavaScript")
|
|
95
|
+
return fill(raw, values, secret)
|
|
96
|
+
|
|
97
|
+
timeout = wf.get("timeout")
|
|
98
|
+
cmds: List[Any] = []
|
|
99
|
+
for i, step in enumerate(wf["steps"], 1):
|
|
100
|
+
op = step["op"]
|
|
101
|
+
t = step.get("timeout", timeout)
|
|
102
|
+
try:
|
|
103
|
+
if op == "launch":
|
|
104
|
+
cmds.append("launchApp")
|
|
105
|
+
# dismiss: Maestro has no handler for a dialog that may show, so each is tapped if it is there after a
|
|
106
|
+
# launch, where system prompts (permissions) show.
|
|
107
|
+
for d in wf.get("dismiss") or []:
|
|
108
|
+
cmds.append({"tapOn": {**_selector(d), "optional": True}})
|
|
109
|
+
elif op == "close":
|
|
110
|
+
cmds.append("stopApp")
|
|
111
|
+
elif op in ("tap", "double_tap", "long_press"):
|
|
112
|
+
cmds.append({{"tap": "tapOn", "double_tap": "doubleTapOn", "long_press": "longPressOn"}[op]: _selector(step["target"])})
|
|
113
|
+
elif op == "type":
|
|
114
|
+
cmds += [{"tapOn": _selector(step["target"])}, "eraseText", {"inputText": text(step["text"])}]
|
|
115
|
+
elif op == "expect":
|
|
116
|
+
cmds.append({"assertVisible": _selector(step["target"], text(step["text"]) if "text" in step else None)})
|
|
117
|
+
elif op == "wait_for":
|
|
118
|
+
sel = _selector(step["target"], text(step["text"]) if "text" in step else None)
|
|
119
|
+
cmds.append({"extendedWaitUntil": {("notVisible" if step.get("gone") else "visible"): sel, "timeout": _ms(t or 10)}})
|
|
120
|
+
elif op == "wait":
|
|
121
|
+
# Maestro has no sleep: wait for something that never appears, and carry on.
|
|
122
|
+
cmds.append({"extendedWaitUntil": {"visible": {"id": "forgeqe-wait-never-present"}, "timeout": _ms(step["seconds"]), "optional": True}})
|
|
123
|
+
elif op == "swipe":
|
|
124
|
+
swipe: Dict[str, Any] = {"direction": step["direction"].upper()}
|
|
125
|
+
if "target" in step:
|
|
126
|
+
swipe["from"] = _selector(step["target"])
|
|
127
|
+
cmds.append({"swipe": swipe})
|
|
128
|
+
elif op == "scroll_to":
|
|
129
|
+
cmds.append({"scrollUntilVisible": {"element": _selector(step["target"]), "direction": step.get("direction", "down").upper(), **({"timeout": _ms(t)} if t else {})}})
|
|
130
|
+
elif op == "back":
|
|
131
|
+
cmds.append("back")
|
|
132
|
+
elif op == "hide_keyboard":
|
|
133
|
+
cmds.append("hideKeyboard")
|
|
134
|
+
elif op == "keys":
|
|
135
|
+
if step["keys"] not in MAESTRO_KEYS:
|
|
136
|
+
raise WorkflowError(f"the {step['keys']} key has no Maestro equivalent; replay with the Appium driver")
|
|
137
|
+
cmds.append({"pressKey": MAESTRO_KEYS[step["keys"]]})
|
|
138
|
+
elif op == "expect_image":
|
|
139
|
+
raise WorkflowError("expect_image needs the Appium driver (Maestro does not compare images)")
|
|
140
|
+
else:
|
|
141
|
+
raise WorkflowError(f"op {op} has no Maestro equivalent")
|
|
142
|
+
except WorkflowError as e:
|
|
143
|
+
raise WorkflowError(f"step {i} ({op}): {e}")
|
|
144
|
+
header = {"appId": app_id, **({"name": wf["name"]} if wf["name"] else {})}
|
|
145
|
+
body = yaml.safe_dump(header, sort_keys=False, allow_unicode=True) + "---\n" + yaml.safe_dump(cmds, sort_keys=False, allow_unicode=True)
|
|
146
|
+
return body, used
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# -- flows the team wrote ------------------------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check_strings(value: Any, where: str) -> None:
|
|
153
|
+
if isinstance(value, str):
|
|
154
|
+
for m in _JS_EXPR.finditer(value):
|
|
155
|
+
if not _JS_VAR.match(m.group(1)):
|
|
156
|
+
raise WorkflowError(f"{where}: ${{{m.group(1)}}} is JavaScript; flows may only use ${{NAME}} variables")
|
|
157
|
+
elif isinstance(value, Mapping):
|
|
158
|
+
for k, v in value.items():
|
|
159
|
+
_check_strings(k, where)
|
|
160
|
+
_check_strings(v, where)
|
|
161
|
+
elif isinstance(value, list):
|
|
162
|
+
for v in value:
|
|
163
|
+
_check_strings(v, where)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _inside(path: Path, root: Path) -> bool:
|
|
167
|
+
try:
|
|
168
|
+
Path(os.path.realpath(path)).relative_to(Path(os.path.realpath(root)))
|
|
169
|
+
return True
|
|
170
|
+
except ValueError:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _check_commands(cmds: Any, where: str, base: Path, root: Path, depth: int, seen: List[Path]) -> None:
|
|
175
|
+
if cmds is None:
|
|
176
|
+
return
|
|
177
|
+
if not isinstance(cmds, list):
|
|
178
|
+
raise WorkflowError(f"{where}: commands must be a list")
|
|
179
|
+
for i, cmd in enumerate(cmds, 1):
|
|
180
|
+
at = f"{where} command {i}"
|
|
181
|
+
if isinstance(cmd, str):
|
|
182
|
+
name, args = cmd, None
|
|
183
|
+
elif isinstance(cmd, Mapping) and len([k for k in cmd if k not in ("optional", "label")]) == 1:
|
|
184
|
+
name = next(k for k in cmd if k not in ("optional", "label"))
|
|
185
|
+
args = cmd[name]
|
|
186
|
+
else:
|
|
187
|
+
raise WorkflowError(f"{at}: one command per list item")
|
|
188
|
+
if name not in ALLOWED_COMMANDS:
|
|
189
|
+
raise WorkflowError(f"{at}: {name} is not allowed (only UI commands run; scripts, AI commands and file writes are refused)")
|
|
190
|
+
_check_strings(args, at)
|
|
191
|
+
if isinstance(args, Mapping) and isinstance(args.get("when"), Mapping) and "true" in args["when"]:
|
|
192
|
+
raise WorkflowError(f"{at}: when.true is JavaScript")
|
|
193
|
+
if name == "openLink":
|
|
194
|
+
link = args.get("link") if isinstance(args, Mapping) else args
|
|
195
|
+
if not isinstance(link, str) or _REFUSED_LINK.match(link):
|
|
196
|
+
raise WorkflowError(f"{at}: openLink must be an http(s) link or the app's own deep link")
|
|
197
|
+
if name in ("repeat", "retry") and isinstance(args, Mapping):
|
|
198
|
+
_check_commands(args.get("commands"), at, base, root, depth, seen)
|
|
199
|
+
if name == "retry" and args.get("file"):
|
|
200
|
+
_check_flow_file(base / str(args["file"]), root, depth + 1, seen)
|
|
201
|
+
if name == "runFlow":
|
|
202
|
+
if isinstance(args, str):
|
|
203
|
+
_check_flow_file(base / args, root, depth + 1, seen)
|
|
204
|
+
elif isinstance(args, Mapping):
|
|
205
|
+
if isinstance(args.get("when"), Mapping) and "true" in args["when"]:
|
|
206
|
+
raise WorkflowError(f"{at}: when.true is JavaScript")
|
|
207
|
+
if args.get("file"):
|
|
208
|
+
_check_flow_file(base / str(args["file"]), root, depth + 1, seen)
|
|
209
|
+
_check_commands(args.get("commands"), at, base, root, depth, seen)
|
|
210
|
+
else:
|
|
211
|
+
raise WorkflowError(f"{at}: runFlow needs a file or commands")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _check_flow_file(path: Path, root: Path, depth: int, seen: List[Path]) -> None:
|
|
215
|
+
if depth > MAX_FLOW_DEPTH:
|
|
216
|
+
raise WorkflowError(f"{path.name}: runFlow is nested more than {MAX_FLOW_DEPTH} deep")
|
|
217
|
+
if path.suffix.lower() not in (".yaml", ".yml"):
|
|
218
|
+
raise WorkflowError(f"{path.name} is not a Maestro flow (.yaml)")
|
|
219
|
+
if not path.is_file():
|
|
220
|
+
raise WorkflowError(f"no flow at {path}")
|
|
221
|
+
if not _inside(path, root):
|
|
222
|
+
raise WorkflowError(f"{path} is outside the project folder; flows are read from it only")
|
|
223
|
+
real = Path(os.path.realpath(path))
|
|
224
|
+
if real in seen:
|
|
225
|
+
return
|
|
226
|
+
seen.append(real)
|
|
227
|
+
docs = [d for d in yaml.safe_load_all(real.read_text(encoding="utf-8"))]
|
|
228
|
+
if not docs:
|
|
229
|
+
raise WorkflowError(f"{path.name} is empty")
|
|
230
|
+
config, commands = (docs[0], docs[1] if len(docs) > 1 else None) if isinstance(docs[0], Mapping) else ({}, docs[0])
|
|
231
|
+
if len(docs) > 2:
|
|
232
|
+
raise WorkflowError(f"{path.name}: a flow is a config and one list of commands")
|
|
233
|
+
if not isinstance(config, Mapping):
|
|
234
|
+
raise WorkflowError(f"{path.name}: the first part must be the flow's config (appId: …)")
|
|
235
|
+
for key in config:
|
|
236
|
+
if key not in ("appId", "url", "name", "tags", "env", "onFlowStart", "onFlowComplete", "properties"):
|
|
237
|
+
raise WorkflowError(f"{path.name}: config key {key} is not allowed")
|
|
238
|
+
_check_strings({k: v for k, v in config.items() if k not in ("onFlowStart", "onFlowComplete")}, path.name)
|
|
239
|
+
for hook in ("onFlowStart", "onFlowComplete"):
|
|
240
|
+
_check_commands(config.get(hook), f"{path.name} {hook}", real.parent, root, depth, seen)
|
|
241
|
+
_check_commands(commands, path.name, real.parent, root, depth, seen)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def check_flow(path: str | os.PathLike, root: str | os.PathLike) -> List[str]:
|
|
245
|
+
"""Check a flow (and every flow it runs) before Maestro gets it; returns the flow files checked."""
|
|
246
|
+
seen: List[Path] = []
|
|
247
|
+
_check_flow_file(Path(path), Path(root), 0, seen)
|
|
248
|
+
return [str(p) for p in seen]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# -- running ---------------------------------------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _junit(path: Path) -> List[Dict[str, Any]]:
|
|
255
|
+
if not path.is_file():
|
|
256
|
+
return []
|
|
257
|
+
try:
|
|
258
|
+
root = ET.parse(path).getroot()
|
|
259
|
+
except ET.ParseError:
|
|
260
|
+
return []
|
|
261
|
+
out: List[Dict[str, Any]] = []
|
|
262
|
+
for case in root.iter("testcase"):
|
|
263
|
+
failure = case.find("failure")
|
|
264
|
+
error = case.find("error")
|
|
265
|
+
bad = failure if failure is not None else error
|
|
266
|
+
out.append({"name": case.get("name") or "", "ok": bad is None, **({"error": ((bad.get("message") or "") + " " + (bad.text or "")).strip()[:2000]} if bad is not None else {})})
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_INTERNAL_COMMANDS = frozenset({"applyConfigurationCommand", "defineVariablesCommand"})
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def command_results(debug_dir: Path) -> List[Dict[str, Any]]:
|
|
274
|
+
"""The status of each command Maestro ran (its debug output's commands-*.json), in order; [] when not written."""
|
|
275
|
+
results: List[Dict[str, Any]] = []
|
|
276
|
+
for f in sorted(debug_dir.rglob("commands-*.json")):
|
|
277
|
+
try:
|
|
278
|
+
doc = json.loads(f.read_text(encoding="utf-8", errors="replace"))
|
|
279
|
+
except ValueError:
|
|
280
|
+
continue
|
|
281
|
+
for entry in doc if isinstance(doc, list) else []:
|
|
282
|
+
command = entry.get("command") if isinstance(entry, Mapping) else None
|
|
283
|
+
# Maestro's own bookkeeping (the flow's config, its env), not a command of the flow.
|
|
284
|
+
if isinstance(command, Mapping) and set(command) & _INTERNAL_COMMANDS:
|
|
285
|
+
continue
|
|
286
|
+
meta = entry.get("metadata") if isinstance(entry, Mapping) else None
|
|
287
|
+
status = str((meta or {}).get("status") or "").upper()
|
|
288
|
+
err = (meta or {}).get("error")
|
|
289
|
+
results.append({"status": status, **({"error": str(err.get("message") if isinstance(err, Mapping) else err)[:1000]} if err else {})})
|
|
290
|
+
return results
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def run_maestro(
|
|
294
|
+
command: Sequence[str],
|
|
295
|
+
flows: Sequence[str],
|
|
296
|
+
out_dir: Path,
|
|
297
|
+
env: Mapping[str, str],
|
|
298
|
+
device: Optional[str] = None,
|
|
299
|
+
timeout: float = 3600,
|
|
300
|
+
run: Callable[..., Any] = subprocess.run,
|
|
301
|
+
) -> Dict[str, Any]:
|
|
302
|
+
"""Run ``maestro test`` on ``flows``; ``{ok, flows: [{name, ok, error}], commands: [...], log}``."""
|
|
303
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
junit = out_dir / "maestro-junit.xml"
|
|
305
|
+
debug = out_dir / "maestro-debug"
|
|
306
|
+
args = [*command, *(["--device", device] if device else []), "test", "--format", "junit", "--output", str(junit), "--debug-output", str(debug), *flows]
|
|
307
|
+
try:
|
|
308
|
+
proc = run(args, env=dict(env), capture_output=True, text=True, timeout=timeout, shell=False)
|
|
309
|
+
except FileNotFoundError:
|
|
310
|
+
raise WorkflowError(f"Maestro is not installed or not on PATH ({command[0]}); see https://maestro.mobile.dev")
|
|
311
|
+
except subprocess.TimeoutExpired:
|
|
312
|
+
return {"ok": False, "flows": [], "commands": [], "error": f"Maestro did not finish within {int(timeout)} s", "log": ""}
|
|
313
|
+
log = ((proc.stdout or "") + (proc.stderr or ""))[-8000:]
|
|
314
|
+
(out_dir / "maestro.log").write_text(log, encoding="utf-8")
|
|
315
|
+
cases = _junit(junit)
|
|
316
|
+
ok = proc.returncode == 0 and all(c["ok"] for c in cases)
|
|
317
|
+
result: Dict[str, Any] = {"ok": ok, "flows": cases, "commands": command_results(debug), "log": log[-2000:]}
|
|
318
|
+
if not ok:
|
|
319
|
+
failed = [c for c in cases if not c["ok"]]
|
|
320
|
+
result["error"] = (failed[0].get("error") if failed else "") or f"maestro exited with {proc.returncode}: {log.strip().splitlines()[-1] if log.strip() else ''}"
|
|
321
|
+
return result
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``python -m forgeqe_mobile serve``: one request as a JSON line on stdin, events as JSON lines on stdout, like the
|
|
3
|
+
desktop engine. A later ``stop`` line on stdin stops a replay between steps, or an explore between actions.
|
|
4
|
+
|
|
5
|
+
Request: ``mode`` (``replay``, ``explore`` or ``maestro``), ``out_dir``, ``mobile`` (``apps``, ``secrets``,
|
|
6
|
+
``appium`` {``url``, ``capabilities``, ``username_env``, ``access_key_env``}, ``maestro`` {``command``, ``device``},
|
|
7
|
+
``driver``), ``llm`` and ``profile_name`` (healing and explore), and per mode: ``workflow``, ``variables``, ``heal``,
|
|
8
|
+
``update_baselines``, ``driver`` (replay); ``goal``, ``app``, ``name`` (explore); ``flows``, ``root`` (maestro: the
|
|
9
|
+
team's own flows, checked first).
|
|
10
|
+
|
|
11
|
+
Events: ``log``, ``step`` (one per replayed or explored step), ``call`` (one model call's time and tokens), ``done``.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import IO, Any, Callable, Dict, List, Mapping, Optional
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from forgeqe_desktop.replay import replay
|
|
26
|
+
from forgeqe_desktop.service import Events, _out_dir, _secret_names, _step_fields
|
|
27
|
+
from forgeqe_desktop.workflow import MOBILE_FORMAT, WorkflowError, load, save
|
|
28
|
+
from forgeqe_desktop.visual import image_checker
|
|
29
|
+
|
|
30
|
+
from .apps import load_mobile_apps
|
|
31
|
+
from .hyperexecute import run_hyperexecute, testmu_settings
|
|
32
|
+
from .maestro import check_flow, run_maestro, to_flow
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _mobile(req: Mapping[str, Any]) -> Dict[str, Any]:
|
|
36
|
+
m = req.get("mobile")
|
|
37
|
+
return dict(m) if isinstance(m, Mapping) else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _model(req: Mapping[str, Any], events: Events, model_factory: Optional[Callable[..., Any]]) -> Any:
|
|
41
|
+
from forgeqe_desktop.models import DesktopModel
|
|
42
|
+
|
|
43
|
+
profile = req.get("llm")
|
|
44
|
+
if not isinstance(profile, dict):
|
|
45
|
+
raise WorkflowError("this mode needs an llms.json profile")
|
|
46
|
+
return (model_factory or DesktopModel)(profile, on_call=lambda c: events.emit({"type": "call", **c}))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def appium_driver(mobile: Mapping[str, Any]) -> Any:
|
|
50
|
+
from .appium_driver import AppiumDriver
|
|
51
|
+
|
|
52
|
+
a = mobile.get("appium") if isinstance(mobile.get("appium"), Mapping) else {}
|
|
53
|
+
user_env, key_env = a.get("username_env"), a.get("access_key_env")
|
|
54
|
+
return AppiumDriver(
|
|
55
|
+
url=str(a.get("url") or "http://127.0.0.1:4723"),
|
|
56
|
+
capabilities=a.get("capabilities") if isinstance(a.get("capabilities"), Mapping) else {},
|
|
57
|
+
username=os.environ.get(str(user_env)) if user_env else None,
|
|
58
|
+
access_key=os.environ.get(str(key_env)) if key_env else None,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _maestro_command(mobile: Mapping[str, Any]) -> List[str]:
|
|
63
|
+
m = mobile.get("maestro") if isinstance(mobile.get("maestro"), Mapping) else {}
|
|
64
|
+
cmd = m.get("command") or ["maestro"]
|
|
65
|
+
if isinstance(cmd, str):
|
|
66
|
+
cmd = [cmd]
|
|
67
|
+
if not isinstance(cmd, list) or not cmd or not all(isinstance(c, str) and c for c in cmd):
|
|
68
|
+
raise WorkflowError("mobile.maestro.command must be the maestro program (and its arguments) as a list")
|
|
69
|
+
return list(cmd)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _maestro_env(names: List[str], secrets: Mapping[str, str]) -> Dict[str, str]:
|
|
73
|
+
"""The environment for maestro: this process's (already only system settings and listed secrets), with each secret
|
|
74
|
+
as MAESTRO_<NAME>, the only form a flow can read it in."""
|
|
75
|
+
env = dict(os.environ)
|
|
76
|
+
for n in names:
|
|
77
|
+
if secrets.get(n) is not None:
|
|
78
|
+
env[f"MAESTRO_{n}"] = secrets[n]
|
|
79
|
+
return env
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_replay(req: Mapping[str, Any], events: Events, stop: threading.Event, driver_factory: Optional[Callable[[], Any]] = None, model_factory: Optional[Callable[..., Any]] = None, maestro_run: Optional[Callable[..., Any]] = None, cloud_run: Optional[Callable[..., Any]] = None) -> Dict[str, Any]:
|
|
83
|
+
out = _out_dir(req)
|
|
84
|
+
mobile = _mobile(req)
|
|
85
|
+
workflow_file = str(req.get("workflow") or "")
|
|
86
|
+
wf = load(workflow_file)
|
|
87
|
+
if wf["format"] != MOBILE_FORMAT:
|
|
88
|
+
raise WorkflowError(f"{workflow_file} is a desktop workflow; run it with forgeqe desktop replay")
|
|
89
|
+
apps = load_mobile_apps(mobile)
|
|
90
|
+
if wf["app"] not in apps:
|
|
91
|
+
raise WorkflowError(f"app {wf['app']!r} is not in mobile.apps or the environment's mobile apps (configured: {', '.join(sorted(apps)) or 'none'})")
|
|
92
|
+
names = _secret_names(mobile)
|
|
93
|
+
driver_name = str(req.get("driver") or mobile.get("driver") or "appium")
|
|
94
|
+
base = {"mode": "replay", "driver": driver_name, "name": wf["name"], "app": wf["app"], "workflow": workflow_file, "total": len(wf["steps"])}
|
|
95
|
+
|
|
96
|
+
if driver_name == "maestro":
|
|
97
|
+
spec = apps[wf["app"]]
|
|
98
|
+
flow, used = to_flow(wf, spec.app_id, req.get("variables") or {}, names)
|
|
99
|
+
flow_file = out / "flow.yaml"
|
|
100
|
+
flow_file.write_text(flow, encoding="utf-8")
|
|
101
|
+
secrets = {n: os.environ[n] for n in used if os.environ.get(n) is not None}
|
|
102
|
+
missing = [n for n in used if n not in secrets]
|
|
103
|
+
if missing:
|
|
104
|
+
raise WorkflowError(f"secret {missing[0]} is not set")
|
|
105
|
+
m = mobile.get("maestro") if isinstance(mobile.get("maestro"), Mapping) else {}
|
|
106
|
+
testmu = testmu_settings(mobile)
|
|
107
|
+
if testmu:
|
|
108
|
+
if used:
|
|
109
|
+
raise WorkflowError("this workflow types secrets, which Maestro on TestMu cannot be given safely yet; replay it on TestMu with --driver appium")
|
|
110
|
+
events.log(f"Running {wf['name'] or 'the workflow'} on Maestro on TestMu ({', '.join(testmu['devices'])}).")
|
|
111
|
+
res = (cloud_run or run_hyperexecute)([(flow_file, "flow.yaml")], ["flow.yaml"], spec.app, spec.platform, testmu, out, wf["name"] or wf["app"], dict(os.environ))
|
|
112
|
+
else:
|
|
113
|
+
events.log(f"Running {wf['name'] or 'the workflow'} on Maestro ({flow_file}).")
|
|
114
|
+
res = (maestro_run or run_maestro)(_maestro_command(mobile), [str(flow_file)], out, _maestro_env(used, secrets), device=m.get("device"))
|
|
115
|
+
steps = _maestro_steps(wf, res.get("commands") or [], res["ok"])
|
|
116
|
+
for s in steps:
|
|
117
|
+
events.emit({"type": "step", **s})
|
|
118
|
+
return {**base, "ok": res["ok"], "steps": steps, "heals": [], "flow": str(flow_file), **({"job": res["job"]} if res.get("job") else {}), **({"error": res["error"]} if res.get("error") else {})}
|
|
119
|
+
|
|
120
|
+
from forgeqe_desktop.heal import make_heal
|
|
121
|
+
from forgeqe_desktop.models import takes_images
|
|
122
|
+
|
|
123
|
+
driver = (driver_factory or (lambda: appium_driver(mobile)))()
|
|
124
|
+
heal = make_heal(_model(req, events, model_factory), driver, log=events.log, images=takes_images(req.get("llm") or {})) if req.get("heal") else None
|
|
125
|
+
shots = bool(mobile.get("screenshots", True))
|
|
126
|
+
(out / "steps").mkdir(exist_ok=True)
|
|
127
|
+
|
|
128
|
+
def on_step(res: Any) -> None:
|
|
129
|
+
shot = None
|
|
130
|
+
if shots and res.op != "close":
|
|
131
|
+
png = driver.screenshot_png()
|
|
132
|
+
if png:
|
|
133
|
+
shot = f"steps/{res.index:03d}.png"
|
|
134
|
+
(out / shot).write_bytes(png)
|
|
135
|
+
events.emit({"type": "step", **_step_fields(res), "screenshot": shot})
|
|
136
|
+
|
|
137
|
+
result = replay(
|
|
138
|
+
wf,
|
|
139
|
+
driver,
|
|
140
|
+
apps,
|
|
141
|
+
variables=req.get("variables") or {},
|
|
142
|
+
allowed_secrets=names,
|
|
143
|
+
heal=heal,
|
|
144
|
+
must_exist=False,
|
|
145
|
+
on_step=on_step,
|
|
146
|
+
should_stop=stop.is_set,
|
|
147
|
+
compare_image=image_checker(workflow_file, out, update=bool(req.get("update_baselines"))),
|
|
148
|
+
)
|
|
149
|
+
steps = []
|
|
150
|
+
for s in result.steps:
|
|
151
|
+
shot = f"steps/{s.index:03d}.png"
|
|
152
|
+
steps.append({**_step_fields(s), "screenshot": shot if (out / shot).is_file() else None, "target": wf["steps"][s.index - 1].get("target")})
|
|
153
|
+
return {
|
|
154
|
+
**base,
|
|
155
|
+
"ok": result.ok,
|
|
156
|
+
"stopped": result.stopped,
|
|
157
|
+
"steps": steps,
|
|
158
|
+
"heals": [{"index": s.index, "old": wf["steps"][s.index - 1].get("target"), "new": s.healed_target} for s in result.healed],
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _maestro_steps(wf: Mapping[str, Any], commands: List[Mapping[str, Any]], ok: bool) -> List[Dict[str, Any]]:
|
|
163
|
+
"""Each workflow step's result from Maestro's command results (a type step is three Maestro commands)."""
|
|
164
|
+
per_step = [3 if s["op"] == "type" else 1 for s in wf["steps"]]
|
|
165
|
+
if not commands and not ok:
|
|
166
|
+
# No step's result is known (Maestro wrote none, or the job's results did not come back): the run's error says
|
|
167
|
+
# what failed, rather than a step that may have passed.
|
|
168
|
+
return []
|
|
169
|
+
out: List[Dict[str, Any]] = []
|
|
170
|
+
pos = 0
|
|
171
|
+
for i, (step, n) in enumerate(zip(wf["steps"], per_step), 1):
|
|
172
|
+
chunk = commands[pos : pos + n]
|
|
173
|
+
pos += n
|
|
174
|
+
if not commands:
|
|
175
|
+
status_ok, error = ok, ""
|
|
176
|
+
elif not chunk:
|
|
177
|
+
break # Maestro stopped before this step
|
|
178
|
+
else:
|
|
179
|
+
failed = [c for c in chunk if c.get("status") == "FAILED"]
|
|
180
|
+
status_ok = not failed and all(c.get("status") in ("COMPLETED", "SKIPPED", "WARNED") for c in chunk)
|
|
181
|
+
error = failed[0].get("error", "failed") if failed else ""
|
|
182
|
+
out.append({"index": i, "op": step["op"], "ok": status_ok, "ms": 0, "error": error, "healed_target": None, "target": step.get("target")})
|
|
183
|
+
if not status_ok:
|
|
184
|
+
break
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_explore(req: Mapping[str, Any], events: Events, stop: threading.Event, driver_factory: Optional[Callable[[], Any]] = None, model_factory: Optional[Callable[..., Any]] = None) -> Dict[str, Any]:
|
|
189
|
+
from forgeqe_desktop.models import require_vision, takes_images
|
|
190
|
+
|
|
191
|
+
from .explore import explore
|
|
192
|
+
|
|
193
|
+
out = _out_dir(req)
|
|
194
|
+
mobile = _mobile(req)
|
|
195
|
+
goal = str(req.get("goal") or "").strip()
|
|
196
|
+
if not goal:
|
|
197
|
+
raise WorkflowError("explore needs a goal")
|
|
198
|
+
app = str(req.get("app") or "")
|
|
199
|
+
apps = load_mobile_apps(mobile)
|
|
200
|
+
if app not in apps:
|
|
201
|
+
raise WorkflowError(f"app {app!r} is not in mobile.apps or the environment's mobile apps (configured: {', '.join(sorted(apps)) or 'none'})")
|
|
202
|
+
profile = req.get("llm") or {}
|
|
203
|
+
require_vision(str(req.get("profile_name") or "profile"), profile)
|
|
204
|
+
names = _secret_names(mobile)
|
|
205
|
+
secrets = {n: os.environ[n] for n in names if os.environ.get(n) is not None}
|
|
206
|
+
driver = (driver_factory or (lambda: appium_driver(mobile)))()
|
|
207
|
+
name = str(req.get("name") or goal)[:120]
|
|
208
|
+
started = time.time()
|
|
209
|
+
error = ""
|
|
210
|
+
workflow: Optional[Dict[str, Any]] = None
|
|
211
|
+
outcome: Dict[str, Any] = {}
|
|
212
|
+
events.log(f"Starting {app} and exploring: {goal}")
|
|
213
|
+
try:
|
|
214
|
+
workflow, outcome = explore(
|
|
215
|
+
_model(req, events, model_factory), driver, apps[app], goal, name, secrets, images=takes_images(profile), log=events.log,
|
|
216
|
+
on_step=lambda s: events.emit({"type": "step", "ok": True, "ms": 0, **s}), should_stop=stop.is_set,
|
|
217
|
+
)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
error = f"Explore stopped with an error: {type(e).__name__}: {e}"[:2000]
|
|
220
|
+
events.log(error, "error")
|
|
221
|
+
finally:
|
|
222
|
+
try:
|
|
223
|
+
driver.close()
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
recorded = len(workflow["steps"]) - 1 - int(outcome.get("checks", 0)) if workflow else 0
|
|
227
|
+
workflow_file = out / "workflow.yaml"
|
|
228
|
+
if workflow and recorded:
|
|
229
|
+
save(workflow, workflow_file)
|
|
230
|
+
done = bool(outcome.get("done"))
|
|
231
|
+
return {
|
|
232
|
+
"mode": "explore",
|
|
233
|
+
"ok": recorded > 0 and done and not error,
|
|
234
|
+
"goal": goal,
|
|
235
|
+
"app": app,
|
|
236
|
+
"name": name,
|
|
237
|
+
"seconds": int(time.time() - started),
|
|
238
|
+
"recorded": recorded,
|
|
239
|
+
"checks": int(outcome.get("checks", 0)),
|
|
240
|
+
"values": outcome.get("values") or {},
|
|
241
|
+
"ufo_complete": done,
|
|
242
|
+
"workflow": str(workflow_file) if workflow and recorded else None,
|
|
243
|
+
"steps": [{"index": i, "op": s["op"], "ok": True, "ms": 0, "target": s.get("target")} for i, s in enumerate(workflow["steps"], 1)] if workflow else [],
|
|
244
|
+
"error": error or ("" if done else f"The model did not finish the goal: {outcome.get('reason') or 'no reason given'}"),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def run_flows(req: Mapping[str, Any], events: Events, maestro_run: Optional[Callable[..., Any]] = None, cloud_run: Optional[Callable[..., Any]] = None) -> Dict[str, Any]:
|
|
249
|
+
"""The team's own Maestro flows: each checked against the allowlist, then run together."""
|
|
250
|
+
out = _out_dir(req)
|
|
251
|
+
mobile = _mobile(req)
|
|
252
|
+
root = str(req.get("root") or "")
|
|
253
|
+
flows = req.get("flows")
|
|
254
|
+
if not root or not isinstance(flows, list) or not flows or not all(isinstance(f, str) for f in flows):
|
|
255
|
+
raise WorkflowError("maestro mode needs flows (a list of files) and root (the project folder)")
|
|
256
|
+
checked: List[str] = []
|
|
257
|
+
for f in flows:
|
|
258
|
+
checked += check_flow(f, root)
|
|
259
|
+
names = _secret_names(mobile)
|
|
260
|
+
secrets = {n: os.environ[n] for n in names if os.environ.get(n) is not None}
|
|
261
|
+
m = mobile.get("maestro") if isinstance(mobile.get("maestro"), Mapping) else {}
|
|
262
|
+
testmu = testmu_settings(mobile)
|
|
263
|
+
name = f"{len(flows)} Maestro flow{'' if len(flows) == 1 else 's'}"
|
|
264
|
+
if testmu:
|
|
265
|
+
spec = _app_of_flows(flows, load_mobile_apps(mobile))
|
|
266
|
+
events.log(f"Checked {len(checked)} flow file{'' if len(checked) == 1 else 's'}; running {len(flows)} on Maestro on TestMu.")
|
|
267
|
+
# A flow that runs another (runFlow) needs it in the project too: every checked file goes, in its layout.
|
|
268
|
+
real_root = Path(os.path.realpath(root))
|
|
269
|
+
rel = lambda f: Path(os.path.realpath(f)).relative_to(real_root).as_posix() # noqa: E731
|
|
270
|
+
res = (cloud_run or run_hyperexecute)([(Path(f), rel(f)) for f in checked], [rel(f) for f in flows], spec.app, spec.platform, testmu, out, name, dict(os.environ))
|
|
271
|
+
else:
|
|
272
|
+
events.log(f"Checked {len(checked)} flow file{'' if len(checked) == 1 else 's'}; running {len(flows)} on Maestro.")
|
|
273
|
+
res = (maestro_run or run_maestro)(_maestro_command(mobile), [str(Path(f).resolve()) for f in flows], out, _maestro_env(names, secrets), device=m.get("device"))
|
|
274
|
+
return {"mode": "maestro", "ok": res["ok"], "flows": res.get("flows") or [], "checked": checked, "name": name, **({"job": res["job"]} if res.get("job") else {}), **({"error": res["error"]} if res.get("error") else {})}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _app_of_flows(flows: List[str], apps: Mapping[str, Any]) -> Any:
|
|
278
|
+
"""The configured mobile app the flows are for, by their appId: TestMu installs it from its `app`."""
|
|
279
|
+
ids = set()
|
|
280
|
+
for f in flows:
|
|
281
|
+
docs = list(yaml.safe_load_all(Path(f).read_text(encoding="utf-8")))
|
|
282
|
+
if docs and isinstance(docs[0], Mapping) and isinstance(docs[0].get("appId"), str):
|
|
283
|
+
ids.add(docs[0]["appId"])
|
|
284
|
+
if len(ids) != 1:
|
|
285
|
+
raise WorkflowError("to run on TestMu, the flows must all be for one app (one appId)")
|
|
286
|
+
app_id = ids.pop()
|
|
287
|
+
for spec in apps.values():
|
|
288
|
+
if spec.app_id == app_id:
|
|
289
|
+
return spec
|
|
290
|
+
raise WorkflowError(f"no mobile app with app_id {app_id} is configured; add it (with its app build or lt:// id) to env.yaml or mobile.apps")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _watch_stop(stream: IO[str], stop: threading.Event) -> None:
|
|
294
|
+
for line in stream:
|
|
295
|
+
if line.strip() == "stop":
|
|
296
|
+
stop.set()
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def serve(
|
|
301
|
+
stdin: IO[str] = sys.stdin,
|
|
302
|
+
stdout: IO[str] = sys.stdout,
|
|
303
|
+
driver_factory: Optional[Callable[[], Any]] = None,
|
|
304
|
+
model_factory: Optional[Callable[..., Any]] = None,
|
|
305
|
+
maestro_run: Optional[Callable[..., Any]] = None,
|
|
306
|
+
cloud_run: Optional[Callable[..., Any]] = None,
|
|
307
|
+
) -> int:
|
|
308
|
+
events = Events(stdout)
|
|
309
|
+
stop = threading.Event()
|
|
310
|
+
out: Optional[Path] = None
|
|
311
|
+
try:
|
|
312
|
+
req = json.loads(stdin.readline() or "{}")
|
|
313
|
+
if not isinstance(req, dict):
|
|
314
|
+
raise WorkflowError("the request must be a JSON object")
|
|
315
|
+
threading.Thread(target=_watch_stop, args=(stdin, stop), daemon=True).start()
|
|
316
|
+
out = _out_dir(req)
|
|
317
|
+
mode = req.get("mode")
|
|
318
|
+
if mode == "replay":
|
|
319
|
+
result = run_replay(req, events, stop, driver_factory, model_factory, maestro_run, cloud_run)
|
|
320
|
+
elif mode == "explore":
|
|
321
|
+
result = run_explore(req, events, stop, driver_factory, model_factory)
|
|
322
|
+
elif mode == "maestro":
|
|
323
|
+
result = run_flows(req, events, maestro_run, cloud_run)
|
|
324
|
+
else:
|
|
325
|
+
raise WorkflowError(f"mode must be replay, explore or maestro, not {mode!r}")
|
|
326
|
+
except Exception as e:
|
|
327
|
+
result = {"ok": False, "error": f"{type(e).__name__}: {e}" if not isinstance(e, (WorkflowError, ValueError)) else str(e)}
|
|
328
|
+
result.setdefault("format", MOBILE_FORMAT)
|
|
329
|
+
if out is not None:
|
|
330
|
+
(out / "run.json").write_text(json.dumps(result, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
|
331
|
+
events.emit({"type": "done", "result": result})
|
|
332
|
+
return 0 if result.get("ok") else 1
|