@qubiqlabs/mobiflow 0.9.0 → 1.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/README.md +9 -11
- package/bin/mobiflow.js +94 -61
- package/package.json +8 -3
- package/pyproject.toml +59 -0
- package/src/mobiflow/__init__.py +9 -0
- package/src/mobiflow/__main__.py +6 -0
- package/src/mobiflow/baseline.py +228 -0
- package/src/mobiflow/casedata.py +159 -0
- package/src/mobiflow/cases/__init__.py +715 -0
- package/src/mobiflow/cli.py +1423 -0
- package/src/mobiflow/cloud/__init__.py +28 -0
- package/src/mobiflow/cloud/base.py +272 -0
- package/src/mobiflow/cloud/browserstack.py +330 -0
- package/src/mobiflow/cloud/maestro_cloud.py +141 -0
- package/src/mobiflow/cloud/media.py +269 -0
- package/src/mobiflow/cloud/runner.py +156 -0
- package/src/mobiflow/cloud/testmu.py +378 -0
- package/src/mobiflow/config/__init__.py +538 -0
- package/src/mobiflow/deps.py +377 -0
- package/src/mobiflow/devices.py +717 -0
- package/src/mobiflow/explore.py +623 -0
- package/src/mobiflow/incremental.py +198 -0
- package/src/mobiflow/init/__init__.py +794 -0
- package/src/mobiflow/llm.py +462 -0
- package/src/mobiflow/llm_catalog.py +232 -0
- package/src/mobiflow/maestro/__init__.py +1506 -0
- package/src/mobiflow/maestro/lifecycle.py +279 -0
- package/src/mobiflow/pipeline.py +600 -0
- package/src/mobiflow/report/__init__.py +617 -0
- package/src/mobiflow/report/static/favicon.jpg +0 -0
- package/src/mobiflow/report/static/favicon.svg +1 -0
- package/src/mobiflow/report/static/icons.svg +24 -0
- package/src/mobiflow/report/static/index.html +99 -0
- package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
- package/src/mobiflow/reporting.py +682 -0
- package/src/mobiflow/sample_apps.py +259 -0
- package/src/mobiflow/secrets.py +90 -0
- package/src/mobiflow/selectors.py +128 -0
- package/src/mobiflow/suite.py +263 -0
|
@@ -0,0 +1,1423 @@
|
|
|
1
|
+
"""Terminal CLI entrypoint — no UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from mobiflow import __version__
|
|
16
|
+
from mobiflow.config import (
|
|
17
|
+
config_warnings,
|
|
18
|
+
effective_config_dict,
|
|
19
|
+
find_config,
|
|
20
|
+
load_config,
|
|
21
|
+
)
|
|
22
|
+
from mobiflow.init import run_init
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _setup_logging(verbose: bool) -> None:
|
|
28
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
29
|
+
logging.basicConfig(level=level, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_config_or_exit(repo: str | None):
|
|
33
|
+
try:
|
|
34
|
+
try:
|
|
35
|
+
return load_config(repo)
|
|
36
|
+
except FileNotFoundError as e:
|
|
37
|
+
found = find_config()
|
|
38
|
+
if not found:
|
|
39
|
+
console.print(f"[red]{e}[/red]")
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
return load_config(found.parent)
|
|
42
|
+
except yaml.YAMLError as e:
|
|
43
|
+
console.print(f"[red]Invalid mobiflow.config.yaml:[/red] {e}")
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
except ValidationError as e:
|
|
46
|
+
console.print("[red]Invalid mobiflow.config.yaml:[/red]")
|
|
47
|
+
for err in e.errors():
|
|
48
|
+
loc = ".".join(str(p) for p in err["loc"])
|
|
49
|
+
console.print(f" [yellow]{loc}[/yellow]: {err['msg']}")
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _print_warnings(cfg) -> None:
|
|
54
|
+
for w in config_warnings(cfg):
|
|
55
|
+
console.print(f"[yellow]warning:[/yellow] {w}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@click.group()
|
|
59
|
+
@click.version_option(__version__, prog_name="mobiflow")
|
|
60
|
+
@click.option("-v", "--verbose", is_flag=True, help="Debug logging")
|
|
61
|
+
@click.pass_context
|
|
62
|
+
def main(ctx: click.Context, verbose: bool) -> None:
|
|
63
|
+
"""mobiflow — NL → Maestro mobile flows via LLM (CLI only)."""
|
|
64
|
+
ctx.ensure_object(dict)
|
|
65
|
+
ctx.obj["verbose"] = verbose
|
|
66
|
+
_setup_logging(verbose)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@main.command("init")
|
|
70
|
+
@click.option(
|
|
71
|
+
"--mode",
|
|
72
|
+
type=click.Choice(["existing", "local", "new"]),
|
|
73
|
+
default=None,
|
|
74
|
+
help="Setup mode (skip prompt)",
|
|
75
|
+
)
|
|
76
|
+
@click.option("--path", "project_path", default=None, help="Project path")
|
|
77
|
+
@click.option("--yes", "-y", is_flag=True, help="Non-interactive defaults")
|
|
78
|
+
@click.option(
|
|
79
|
+
"--install-deps/--no-install-deps",
|
|
80
|
+
default=None,
|
|
81
|
+
help="Auto-install missing Maestro/JDK/Python packages (default: ask, or on with --yes)",
|
|
82
|
+
)
|
|
83
|
+
def init_cmd(
|
|
84
|
+
mode: str | None,
|
|
85
|
+
project_path: str | None,
|
|
86
|
+
yes: bool,
|
|
87
|
+
install_deps: bool | None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Interactive project setup (LLM catalog, device defaults, example case)."""
|
|
90
|
+
try:
|
|
91
|
+
run_init(
|
|
92
|
+
mode=mode,
|
|
93
|
+
path=project_path,
|
|
94
|
+
yes=yes,
|
|
95
|
+
install_deps=install_deps,
|
|
96
|
+
)
|
|
97
|
+
except SystemExit:
|
|
98
|
+
raise
|
|
99
|
+
except Exception as e: # noqa: BLE001
|
|
100
|
+
console.print(f"[red]mobiflow init failed:[/red] {e}")
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@main.command("setup")
|
|
105
|
+
@click.option("--repo", default=None, help="Project path (for Anthropic profile detection)")
|
|
106
|
+
@click.option(
|
|
107
|
+
"--install-adb/--no-install-adb",
|
|
108
|
+
default=False,
|
|
109
|
+
help="Also install Android platform-tools (adb) via Homebrew",
|
|
110
|
+
)
|
|
111
|
+
@click.option(
|
|
112
|
+
"--check-only",
|
|
113
|
+
is_flag=True,
|
|
114
|
+
help="Only report status; do not install",
|
|
115
|
+
)
|
|
116
|
+
def setup_cmd(repo: str | None, install_adb: bool, check_only: bool) -> None:
|
|
117
|
+
"""Detect missing packages/tools and optionally install them."""
|
|
118
|
+
from mobiflow.deps import (
|
|
119
|
+
catalog_wants_anthropic,
|
|
120
|
+
install_missing,
|
|
121
|
+
probe_dependencies,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
root = Path(repo).expanduser().resolve() if repo else Path.cwd()
|
|
125
|
+
want_anthropic = catalog_wants_anthropic(root)
|
|
126
|
+
|
|
127
|
+
items = probe_dependencies(want_anthropic=want_anthropic)
|
|
128
|
+
console.print("[bold]Dependency check[/bold]\n")
|
|
129
|
+
for item in items:
|
|
130
|
+
mark = "[green]OK[/green]" if item.ok else "[yellow]MISSING[/yellow]"
|
|
131
|
+
req = "required" if item.required else "optional"
|
|
132
|
+
console.print(f" {mark} {item.label} [dim]({req})[/dim]")
|
|
133
|
+
if not item.ok and item.detail:
|
|
134
|
+
console.print(f" [dim]{item.detail}[/dim]")
|
|
135
|
+
|
|
136
|
+
missing_req = [i for i in items if not i.ok and i.required]
|
|
137
|
+
if check_only:
|
|
138
|
+
if missing_req:
|
|
139
|
+
console.print(f"\n[yellow]{len(missing_req)} required dependency(ies) missing.[/yellow]")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
console.print("\n[green]All required dependencies available.[/green]")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
installable = [i for i in items if not i.ok and i.installable]
|
|
145
|
+
if not installable:
|
|
146
|
+
if missing_req:
|
|
147
|
+
console.print("\n[yellow]Missing items are not auto-installable — see notes above.[/yellow]")
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
console.print("\n[green]Nothing to install.[/green]")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
def _log(msg: str) -> None:
|
|
153
|
+
console.print(f"[dim]{msg}[/dim]")
|
|
154
|
+
|
|
155
|
+
console.print("\n[bold]Installing missing packages…[/bold]")
|
|
156
|
+
report = install_missing(
|
|
157
|
+
want_anthropic=want_anthropic,
|
|
158
|
+
install_adb=install_adb,
|
|
159
|
+
log=_log,
|
|
160
|
+
)
|
|
161
|
+
for a in report.actions:
|
|
162
|
+
console.print(f" [green]✓[/green] {a}")
|
|
163
|
+
for e in report.errors:
|
|
164
|
+
console.print(f" [yellow]![/yellow] {e}")
|
|
165
|
+
|
|
166
|
+
still = [i for i in report.items if not i.ok and i.required]
|
|
167
|
+
if still:
|
|
168
|
+
console.print("\n[yellow]Still missing (manual):[/yellow]")
|
|
169
|
+
for i in still:
|
|
170
|
+
console.print(f" · {i.label}: {i.detail}")
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
console.print("\n[green]Required dependencies look good.[/green]")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@main.group("apps")
|
|
176
|
+
def apps_group() -> None:
|
|
177
|
+
"""Download / install FOSS sample apps (Wikipedia, Joplin)."""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@apps_group.command("list")
|
|
181
|
+
def apps_list() -> None:
|
|
182
|
+
"""Show sample apps that can be downloaded/installed."""
|
|
183
|
+
from mobiflow.sample_apps import list_sample_apps
|
|
184
|
+
|
|
185
|
+
console.print("[bold]Sample FOSS apps[/bold] (APKs download on demand — not shipped)\n")
|
|
186
|
+
for app in list_sample_apps():
|
|
187
|
+
console.print(f" [cyan]{app.name}[/cyan] {app.label}")
|
|
188
|
+
console.print(f" android={app.app_id_android} ios={app.app_id_ios}")
|
|
189
|
+
console.print(f" [dim]{app.notes}[/dim]")
|
|
190
|
+
console.print(
|
|
191
|
+
"\n[dim]Install: mobiflow apps install wikipedia[/dim]\n"
|
|
192
|
+
"[dim]Binaries land in ./builds/ (gitignored).[/dim]"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@apps_group.command("install")
|
|
197
|
+
@click.argument("name")
|
|
198
|
+
@click.option("--repo", default=None, help="Project path (builds/ lives here)")
|
|
199
|
+
@click.option("--platform", default="android", show_default=True, help="android|ios")
|
|
200
|
+
@click.option("--device", "device_id", default=None, help="adb serial / UDID")
|
|
201
|
+
@click.option("--apk", "apk_path", default=None, type=click.Path(exists=True), help="Local APK override")
|
|
202
|
+
@click.option("--app", "app_bundle", default=None, type=click.Path(exists=True), help="iOS .app bundle path")
|
|
203
|
+
@click.option("--download-only", is_flag=True, help="Download APK only; skip adb install")
|
|
204
|
+
@click.option("--force", is_flag=True, help="Re-download even if builds/<name>.apk exists")
|
|
205
|
+
def apps_install(
|
|
206
|
+
name: str,
|
|
207
|
+
repo: str | None,
|
|
208
|
+
platform: str,
|
|
209
|
+
device_id: str | None,
|
|
210
|
+
apk_path: str | None,
|
|
211
|
+
app_bundle: str | None,
|
|
212
|
+
download_only: bool,
|
|
213
|
+
force: bool,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Download (Android) and install a sample app on the connected device.
|
|
216
|
+
|
|
217
|
+
Examples:
|
|
218
|
+
mobiflow apps install wikipedia
|
|
219
|
+
mobiflow apps install joplin --device emulator-5554
|
|
220
|
+
mobiflow apps install wikipedia --download-only
|
|
221
|
+
"""
|
|
222
|
+
import asyncio
|
|
223
|
+
|
|
224
|
+
from mobiflow.sample_apps import default_builds_dir, install_sample_app
|
|
225
|
+
|
|
226
|
+
root = Path(repo).expanduser().resolve() if repo else Path.cwd()
|
|
227
|
+
dest = default_builds_dir(root)
|
|
228
|
+
|
|
229
|
+
def progress(msg: str) -> None:
|
|
230
|
+
console.print(f" [dim]→[/dim] {msg}")
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
result = asyncio.run(
|
|
234
|
+
install_sample_app(
|
|
235
|
+
name,
|
|
236
|
+
platform=platform,
|
|
237
|
+
device_id=device_id,
|
|
238
|
+
apk_path=apk_path,
|
|
239
|
+
app_path=app_bundle,
|
|
240
|
+
dest_dir=dest,
|
|
241
|
+
download_only=download_only,
|
|
242
|
+
force_download=force,
|
|
243
|
+
progress=progress,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
except ValueError as e:
|
|
247
|
+
console.print(f"[red]{e}[/red]")
|
|
248
|
+
sys.exit(2)
|
|
249
|
+
except RuntimeError as e:
|
|
250
|
+
console.print(f"[red]{e}[/red]")
|
|
251
|
+
sys.exit(1)
|
|
252
|
+
|
|
253
|
+
if result.get("ok"):
|
|
254
|
+
console.print(f"[bold green]OK[/bold green] {result.get('message')}")
|
|
255
|
+
if result.get("app_id"):
|
|
256
|
+
console.print(f" appId={result['app_id']}")
|
|
257
|
+
if result.get("apk_path"):
|
|
258
|
+
console.print(f" apk={result['apk_path']}")
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
console.print(
|
|
262
|
+
f"[bold red]FAIL[/bold red] {result.get('message') or result.get('error')}"
|
|
263
|
+
)
|
|
264
|
+
sys.exit(1)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@main.group("config")
|
|
268
|
+
def config_group() -> None:
|
|
269
|
+
"""Show / inspect configuration."""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@config_group.command("show")
|
|
273
|
+
@click.option("--repo", default=None, help="Project path containing mobiflow.config.yaml")
|
|
274
|
+
def config_show(repo: str | None) -> None:
|
|
275
|
+
cfg = _load_config_or_exit(repo)
|
|
276
|
+
_print_warnings(cfg)
|
|
277
|
+
console.print_json(data=effective_config_dict(cfg))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@main.group("llm")
|
|
281
|
+
def llm_group() -> None:
|
|
282
|
+
"""List / inspect models from llm.json."""
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@llm_group.command("list")
|
|
286
|
+
@click.option("--repo", default=None, help="Project path containing llm.json")
|
|
287
|
+
def llm_list(repo: str | None) -> None:
|
|
288
|
+
"""Show catalog profiles and which ones config selects."""
|
|
289
|
+
from mobiflow.llm_catalog import load_catalog
|
|
290
|
+
|
|
291
|
+
cfg = None
|
|
292
|
+
try:
|
|
293
|
+
cfg = _load_config_or_exit(repo)
|
|
294
|
+
root = cfg.repo_path()
|
|
295
|
+
except SystemExit:
|
|
296
|
+
root = Path(repo).expanduser().resolve() if repo else Path.cwd()
|
|
297
|
+
cfg = None
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
catalog = load_catalog(root)
|
|
301
|
+
except FileNotFoundError as e:
|
|
302
|
+
console.print(f"[red]{e}[/red]")
|
|
303
|
+
raise SystemExit(1) from e
|
|
304
|
+
|
|
305
|
+
console.print(
|
|
306
|
+
f"[bold]llm.json[/bold] — {len(catalog.models)} profile(s) in {root / 'llm.json'}\n"
|
|
307
|
+
)
|
|
308
|
+
for name in catalog.names():
|
|
309
|
+
entry = catalog.models[name]
|
|
310
|
+
mark = ""
|
|
311
|
+
if cfg:
|
|
312
|
+
if cfg.llm.discovery == name:
|
|
313
|
+
mark += " [cyan]discovery[/cyan]"
|
|
314
|
+
if cfg.llm.codegen == name:
|
|
315
|
+
mark += " [magenta]codegen[/magenta]"
|
|
316
|
+
key_ok = "OK" if __import__("os").environ.get(entry.api_key_env) else "-"
|
|
317
|
+
console.print(
|
|
318
|
+
f" [bold]{name}[/bold]{mark}\n"
|
|
319
|
+
f" {entry.display_name} · {entry.provider}/{entry.model}\n"
|
|
320
|
+
f" key {key_ok} ${entry.api_key_env}"
|
|
321
|
+
+ (f" · endpoint {entry.endpoint}" if entry.endpoint else "")
|
|
322
|
+
)
|
|
323
|
+
if cfg:
|
|
324
|
+
console.print(
|
|
325
|
+
f"\n[dim]Selected: discovery={cfg.llm.discovery} codegen={cfg.llm.codegen}[/dim]"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@main.command("status")
|
|
330
|
+
@click.option("--repo", default=None, help="Project path")
|
|
331
|
+
def status_cmd(repo: str | None) -> None:
|
|
332
|
+
"""Show Maestro CLI, Java, local devices, and cloud lab readiness."""
|
|
333
|
+
from mobiflow.cloud import cloud_readiness
|
|
334
|
+
from mobiflow.maestro import get_status
|
|
335
|
+
|
|
336
|
+
cfg = None
|
|
337
|
+
# Config optional for status
|
|
338
|
+
try:
|
|
339
|
+
cfg = _load_config_or_exit(repo)
|
|
340
|
+
_print_warnings(cfg)
|
|
341
|
+
except SystemExit:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
status = asyncio.run(get_status())
|
|
345
|
+
console.print(f"[bold]Maestro[/bold] installed={status['installed']} "
|
|
346
|
+
f"version={status.get('version') or '-'} ready={status['ready']}")
|
|
347
|
+
console.print(f" binary: {status.get('binary') or '-'}")
|
|
348
|
+
console.print(f" JAVA_HOME: {status.get('java_home') or '-'}")
|
|
349
|
+
host = status.get("host") or {}
|
|
350
|
+
console.print(
|
|
351
|
+
f" host: {host.get('os')} adb={host.get('android_adb') or '-'} "
|
|
352
|
+
f"emulator={host.get('android_emulator') or '-'} "
|
|
353
|
+
f"ios_simctl={host.get('ios_simctl')}"
|
|
354
|
+
)
|
|
355
|
+
console.print(f" {status.get('message')}")
|
|
356
|
+
devices = status.get("devices") or []
|
|
357
|
+
if devices:
|
|
358
|
+
console.print(f"\n[bold]Online[/bold] ({len(devices)})")
|
|
359
|
+
for d in devices:
|
|
360
|
+
plat = (d.get("platform") or "?").replace("[", "\\[")
|
|
361
|
+
console.print(
|
|
362
|
+
f" · \\[{plat}] {d.get('name')} id={d.get('id')} "
|
|
363
|
+
f"({d.get('source')})"
|
|
364
|
+
)
|
|
365
|
+
startable = [t for t in (status.get("targets") or []) if t.get("startable") == "true"]
|
|
366
|
+
if startable:
|
|
367
|
+
console.print(f"\n[bold]Can auto-start[/bold] ({len(startable)})")
|
|
368
|
+
for d in startable[:12]:
|
|
369
|
+
plat = (d.get("platform") or "?").replace("[", "\\[")
|
|
370
|
+
console.print(
|
|
371
|
+
f" · \\[{plat}] {d.get('name')} id={d.get('id')} "
|
|
372
|
+
f"({d.get('source')})"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if cfg is not None:
|
|
376
|
+
ready = cloud_readiness(cfg.device)
|
|
377
|
+
console.print(
|
|
378
|
+
f"\n[bold]Cloud[/bold] provider={ready.get('provider')} "
|
|
379
|
+
f"ready={ready.get('ready')}"
|
|
380
|
+
)
|
|
381
|
+
console.print(f" {ready.get('message')}")
|
|
382
|
+
if ready.get("cloud"):
|
|
383
|
+
console.print(
|
|
384
|
+
f" app_path={ready.get('app_path') or '-'} "
|
|
385
|
+
f"app_url={ready.get('app_url') or '-'}"
|
|
386
|
+
)
|
|
387
|
+
if ready.get("username_env"):
|
|
388
|
+
console.print(
|
|
389
|
+
f" creds: ${ready.get('username_env')} / "
|
|
390
|
+
f"${ready.get('access_key_env')}"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@main.command("devices")
|
|
395
|
+
@click.option(
|
|
396
|
+
"--all",
|
|
397
|
+
"show_all",
|
|
398
|
+
is_flag=True,
|
|
399
|
+
help="Include startable AVDs / shutdown iOS simulators",
|
|
400
|
+
)
|
|
401
|
+
@click.option(
|
|
402
|
+
"--start/--no-start",
|
|
403
|
+
default=False,
|
|
404
|
+
help="Auto-start an emulator/simulator if none are online",
|
|
405
|
+
)
|
|
406
|
+
@click.option("--platform", default=None, help="android|ios (used with --start)")
|
|
407
|
+
@click.option("--id", "device_id", default=None, help="AVD name, adb serial, or iOS UDID")
|
|
408
|
+
@click.option("--repo", default=None, help="Project path (reads device.auto_start defaults)")
|
|
409
|
+
def devices_cmd(
|
|
410
|
+
show_all: bool,
|
|
411
|
+
start: bool,
|
|
412
|
+
platform: str | None,
|
|
413
|
+
device_id: str | None,
|
|
414
|
+
repo: str | None,
|
|
415
|
+
) -> None:
|
|
416
|
+
"""List connected devices; optionally auto-start Android AVD or iOS Simulator."""
|
|
417
|
+
from mobiflow.devices import ensure_device, host_capabilities, list_all_targets, list_connected_devices
|
|
418
|
+
|
|
419
|
+
caps = host_capabilities()
|
|
420
|
+
console.print(
|
|
421
|
+
f"[dim]OS={caps['os']} can_start_android={caps['can_start_android']} "
|
|
422
|
+
f"can_start_ios={caps['can_start_ios']}[/dim]\n"
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if start:
|
|
426
|
+
plat = platform
|
|
427
|
+
auto = True
|
|
428
|
+
boot_timeout = 120
|
|
429
|
+
use_maestro_cli = True
|
|
430
|
+
device_model = ""
|
|
431
|
+
device_os = ""
|
|
432
|
+
device_locale = ""
|
|
433
|
+
try:
|
|
434
|
+
cfg = _load_config_or_exit(repo)
|
|
435
|
+
plat = plat or cfg.device.platform
|
|
436
|
+
boot_timeout = cfg.device.boot_timeout_s
|
|
437
|
+
device_id = device_id or cfg.device.device_id
|
|
438
|
+
use_maestro_cli = bool(cfg.device.use_maestro_cli)
|
|
439
|
+
device_model = str(cfg.device.device_model or "")
|
|
440
|
+
device_os = str(cfg.device.device_os or "")
|
|
441
|
+
device_locale = str(cfg.device.device_locale or "")
|
|
442
|
+
except SystemExit:
|
|
443
|
+
plat = plat or "android"
|
|
444
|
+
|
|
445
|
+
def progress(msg: str) -> None:
|
|
446
|
+
console.print(f" [dim]→[/dim] {msg}")
|
|
447
|
+
|
|
448
|
+
result = asyncio.run(
|
|
449
|
+
ensure_device(
|
|
450
|
+
platform_pref=plat or "android",
|
|
451
|
+
device_id=device_id,
|
|
452
|
+
auto_start=auto,
|
|
453
|
+
timeout_s=float(boot_timeout),
|
|
454
|
+
progress=progress,
|
|
455
|
+
use_maestro_cli=use_maestro_cli,
|
|
456
|
+
device_model=device_model,
|
|
457
|
+
device_os=device_os,
|
|
458
|
+
device_locale=device_locale,
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
if result.get("ok"):
|
|
462
|
+
d = result["device"]
|
|
463
|
+
started = "auto-started" if result.get("started") else "already online"
|
|
464
|
+
plat = (d.get("platform") or "?").replace("[", "\\[")
|
|
465
|
+
console.print(
|
|
466
|
+
f"[green]OK[/green] \\[{plat}] {d.get('name')} "
|
|
467
|
+
f"id={d.get('id')} ({started})"
|
|
468
|
+
)
|
|
469
|
+
return
|
|
470
|
+
console.print(f"[red]{result.get('message') or result.get('error')}[/red]")
|
|
471
|
+
sys.exit(1)
|
|
472
|
+
|
|
473
|
+
devices = asyncio.run(list_all_targets() if show_all else list_connected_devices())
|
|
474
|
+
if not devices:
|
|
475
|
+
console.print(
|
|
476
|
+
"[yellow]No devices found.[/yellow]\n"
|
|
477
|
+
" • Android: install Android Studio, create an AVD, ensure emulator on PATH\n"
|
|
478
|
+
" • iOS (macOS): install Xcode, open Simulator once\n"
|
|
479
|
+
" • Then: [cyan]mobiflow devices --start[/cyan]"
|
|
480
|
+
)
|
|
481
|
+
return
|
|
482
|
+
for d in devices:
|
|
483
|
+
state = d.get("state") or ("online" if d.get("startable") != "true" else "available")
|
|
484
|
+
flag = "online" if state == "online" else "startable"
|
|
485
|
+
# Escape brackets — Rich treats [ios] as markup
|
|
486
|
+
plat = (d.get("platform") or "?").replace("[", "\\[")
|
|
487
|
+
console.print(
|
|
488
|
+
f"\\[{plat}] {d.get('name')} id={d.get('id')} "
|
|
489
|
+
f"{flag} ({d.get('source')})"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@main.command("run")
|
|
494
|
+
@click.argument("case_path", type=click.Path(exists=True))
|
|
495
|
+
@click.option("--repo", default=None, help="Project path containing mobiflow.config.yaml")
|
|
496
|
+
@click.option("--device", "device_id", default=None, help="Override device id")
|
|
497
|
+
@click.option("--gen-only", is_flag=True, help="Author YAML only (skip device run)")
|
|
498
|
+
@click.option("--no-heal", is_flag=True, help="Skip YAML repair loop")
|
|
499
|
+
@click.option(
|
|
500
|
+
"--reuse-flow/--no-reuse-flow",
|
|
501
|
+
default=None,
|
|
502
|
+
help="Use flows/<case>.yaml (or case flow:) instead of LLM codegen",
|
|
503
|
+
)
|
|
504
|
+
@click.option(
|
|
505
|
+
"--incremental/--no-incremental",
|
|
506
|
+
default=None,
|
|
507
|
+
help="Classify numbered steps; gap-explore only newly appended ones",
|
|
508
|
+
)
|
|
509
|
+
@click.option(
|
|
510
|
+
"--extend-explore/--no-extend-explore",
|
|
511
|
+
default=None,
|
|
512
|
+
help="Full explore + extend codegen seeded from prior flows/<case>.yaml",
|
|
513
|
+
)
|
|
514
|
+
@click.option(
|
|
515
|
+
"--tag",
|
|
516
|
+
"tags",
|
|
517
|
+
multiple=True,
|
|
518
|
+
help="Suite only: include cases with this @tag (repeatable)",
|
|
519
|
+
)
|
|
520
|
+
@click.option(
|
|
521
|
+
"--fail-fast/--no-fail-fast",
|
|
522
|
+
default=None,
|
|
523
|
+
help="Suite only: stop after first failure (default: run.fail_fast)",
|
|
524
|
+
)
|
|
525
|
+
@click.option(
|
|
526
|
+
"--jobs",
|
|
527
|
+
default=None,
|
|
528
|
+
type=int,
|
|
529
|
+
help="Suite only: parallel case workers (default: run.jobs)",
|
|
530
|
+
)
|
|
531
|
+
def run_cmd(
|
|
532
|
+
case_path: str,
|
|
533
|
+
repo: str | None,
|
|
534
|
+
device_id: str | None,
|
|
535
|
+
gen_only: bool,
|
|
536
|
+
no_heal: bool,
|
|
537
|
+
reuse_flow: bool | None,
|
|
538
|
+
incremental: bool | None,
|
|
539
|
+
extend_explore: bool | None,
|
|
540
|
+
tags: tuple[str, ...],
|
|
541
|
+
fail_fast: bool | None,
|
|
542
|
+
jobs: int | None,
|
|
543
|
+
) -> None:
|
|
544
|
+
"""Run a case file, or a directory of cases as a suite.
|
|
545
|
+
|
|
546
|
+
Examples::
|
|
547
|
+
|
|
548
|
+
mobiflow run cases/example.txt
|
|
549
|
+
mobiflow run cases/ --tag smoke
|
|
550
|
+
mobiflow run cases/wiki.txt --incremental
|
|
551
|
+
"""
|
|
552
|
+
from mobiflow.pipeline import run_pipeline
|
|
553
|
+
from mobiflow.suite import run_suite
|
|
554
|
+
|
|
555
|
+
cfg = _load_config_or_exit(repo)
|
|
556
|
+
_print_warnings(cfg)
|
|
557
|
+
path = Path(case_path)
|
|
558
|
+
if path.is_dir():
|
|
559
|
+
if jobs is not None:
|
|
560
|
+
cfg.run.jobs = max(1, min(int(jobs), 32))
|
|
561
|
+
try:
|
|
562
|
+
suite = run_suite(
|
|
563
|
+
path,
|
|
564
|
+
cfg,
|
|
565
|
+
tags=list(tags) or None,
|
|
566
|
+
gen_only=gen_only,
|
|
567
|
+
device_id=device_id,
|
|
568
|
+
no_heal=no_heal,
|
|
569
|
+
fail_fast=fail_fast,
|
|
570
|
+
reuse_flow=reuse_flow,
|
|
571
|
+
incremental=incremental,
|
|
572
|
+
extend_explore=extend_explore,
|
|
573
|
+
)
|
|
574
|
+
except ValueError as exc:
|
|
575
|
+
console.print(f"[red]{exc}[/red]")
|
|
576
|
+
sys.exit(2)
|
|
577
|
+
if not suite.success:
|
|
578
|
+
sys.exit(1)
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
if tags:
|
|
582
|
+
console.print(
|
|
583
|
+
"[yellow]--tag is ignored for a single case file "
|
|
584
|
+
"(use a cases/ directory).[/yellow]"
|
|
585
|
+
)
|
|
586
|
+
try:
|
|
587
|
+
result = run_pipeline(
|
|
588
|
+
path,
|
|
589
|
+
cfg,
|
|
590
|
+
gen_only=gen_only,
|
|
591
|
+
device_id=device_id,
|
|
592
|
+
no_heal=no_heal,
|
|
593
|
+
reuse_flow=reuse_flow,
|
|
594
|
+
incremental=incremental,
|
|
595
|
+
extend_explore=extend_explore,
|
|
596
|
+
)
|
|
597
|
+
except ValueError as exc:
|
|
598
|
+
console.print(f"[red]{exc}[/red]")
|
|
599
|
+
sys.exit(2)
|
|
600
|
+
if not result.get("success"):
|
|
601
|
+
sys.exit(1)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@main.command("suite")
|
|
605
|
+
@click.argument(
|
|
606
|
+
"cases_path",
|
|
607
|
+
required=False,
|
|
608
|
+
default=None,
|
|
609
|
+
type=click.Path(exists=True),
|
|
610
|
+
)
|
|
611
|
+
@click.option("--repo", default=None, help="Project path containing mobiflow.config.yaml")
|
|
612
|
+
@click.option("--device", "device_id", default=None, help="Override device id")
|
|
613
|
+
@click.option("--gen-only", is_flag=True, help="Author YAML only (skip device run)")
|
|
614
|
+
@click.option("--no-heal", is_flag=True, help="Skip YAML repair loop")
|
|
615
|
+
@click.option(
|
|
616
|
+
"--reuse-flow/--no-reuse-flow",
|
|
617
|
+
default=None,
|
|
618
|
+
help="Use flows/<case>.yaml instead of LLM codegen",
|
|
619
|
+
)
|
|
620
|
+
@click.option(
|
|
621
|
+
"--incremental/--no-incremental",
|
|
622
|
+
default=None,
|
|
623
|
+
help="Classify numbered steps; gap-explore only newly appended ones",
|
|
624
|
+
)
|
|
625
|
+
@click.option(
|
|
626
|
+
"--extend-explore/--no-extend-explore",
|
|
627
|
+
default=None,
|
|
628
|
+
help="Full explore + extend codegen seeded from prior flows/<case>.yaml",
|
|
629
|
+
)
|
|
630
|
+
@click.option(
|
|
631
|
+
"--tag",
|
|
632
|
+
"tags",
|
|
633
|
+
multiple=True,
|
|
634
|
+
help="Include cases with this @tag (repeatable)",
|
|
635
|
+
)
|
|
636
|
+
@click.option(
|
|
637
|
+
"--fail-fast/--no-fail-fast",
|
|
638
|
+
default=None,
|
|
639
|
+
help="Stop after first failure (default: run.fail_fast)",
|
|
640
|
+
)
|
|
641
|
+
@click.option(
|
|
642
|
+
"--jobs",
|
|
643
|
+
default=None,
|
|
644
|
+
type=int,
|
|
645
|
+
help="Parallel case workers (default: run.jobs)",
|
|
646
|
+
)
|
|
647
|
+
def suite_cmd(
|
|
648
|
+
cases_path: str | None,
|
|
649
|
+
repo: str | None,
|
|
650
|
+
device_id: str | None,
|
|
651
|
+
gen_only: bool,
|
|
652
|
+
no_heal: bool,
|
|
653
|
+
reuse_flow: bool | None,
|
|
654
|
+
incremental: bool | None,
|
|
655
|
+
extend_explore: bool | None,
|
|
656
|
+
tags: tuple[str, ...],
|
|
657
|
+
fail_fast: bool | None,
|
|
658
|
+
jobs: int | None,
|
|
659
|
+
) -> None:
|
|
660
|
+
"""Run a suite of cases and write aggregate JUnit/HTML reports.
|
|
661
|
+
|
|
662
|
+
Defaults to ``stack.cases_dir`` when no path is given. Equivalent to
|
|
663
|
+
``mobiflow run <dir>``.
|
|
664
|
+
"""
|
|
665
|
+
from mobiflow.suite import run_suite
|
|
666
|
+
|
|
667
|
+
cfg = _load_config_or_exit(repo)
|
|
668
|
+
_print_warnings(cfg)
|
|
669
|
+
if jobs is not None:
|
|
670
|
+
cfg.run.jobs = max(1, min(int(jobs), 32))
|
|
671
|
+
target = Path(cases_path) if cases_path else cfg.cases_dir_path()
|
|
672
|
+
if not target.exists():
|
|
673
|
+
console.print(f"[red]Cases path not found:[/red] {target}")
|
|
674
|
+
sys.exit(1)
|
|
675
|
+
try:
|
|
676
|
+
suite = run_suite(
|
|
677
|
+
target,
|
|
678
|
+
cfg,
|
|
679
|
+
tags=list(tags) or None,
|
|
680
|
+
gen_only=gen_only,
|
|
681
|
+
device_id=device_id,
|
|
682
|
+
no_heal=no_heal,
|
|
683
|
+
fail_fast=fail_fast,
|
|
684
|
+
reuse_flow=reuse_flow,
|
|
685
|
+
incremental=incremental,
|
|
686
|
+
extend_explore=extend_explore,
|
|
687
|
+
)
|
|
688
|
+
except ValueError as exc:
|
|
689
|
+
console.print(f"[red]{exc}[/red]")
|
|
690
|
+
sys.exit(2)
|
|
691
|
+
if not suite.cases:
|
|
692
|
+
sys.exit(2)
|
|
693
|
+
if not suite.success:
|
|
694
|
+
sys.exit(1)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@main.command("report")
|
|
698
|
+
@click.option("--repo", default=None, help="Project path")
|
|
699
|
+
@click.option(
|
|
700
|
+
"--out",
|
|
701
|
+
"out_dir",
|
|
702
|
+
default=None,
|
|
703
|
+
type=click.Path(file_okay=False),
|
|
704
|
+
help="Output directory (default: .mobiflow/reports/latest-pack)",
|
|
705
|
+
)
|
|
706
|
+
@click.option(
|
|
707
|
+
"--title",
|
|
708
|
+
default="MobiFlow Execution Report",
|
|
709
|
+
help="Pack title shown in the SPA",
|
|
710
|
+
)
|
|
711
|
+
@click.option(
|
|
712
|
+
"--open/--no-open",
|
|
713
|
+
"open_browser",
|
|
714
|
+
default=False,
|
|
715
|
+
help="Open index.html after writing",
|
|
716
|
+
)
|
|
717
|
+
def report_cmd(
|
|
718
|
+
repo: str | None,
|
|
719
|
+
out_dir: str | None,
|
|
720
|
+
title: str,
|
|
721
|
+
open_browser: bool,
|
|
722
|
+
) -> None:
|
|
723
|
+
"""Build a rich HTML pack report from recent .mobiflow/runs/*.json."""
|
|
724
|
+
import json
|
|
725
|
+
import webbrowser
|
|
726
|
+
|
|
727
|
+
from mobiflow.report import (
|
|
728
|
+
case_record_from_report_case,
|
|
729
|
+
build_pack,
|
|
730
|
+
env_from_config,
|
|
731
|
+
write_pack_html,
|
|
732
|
+
write_pack_json,
|
|
733
|
+
)
|
|
734
|
+
from mobiflow.reporting import ReportCase
|
|
735
|
+
|
|
736
|
+
cfg = _load_config_or_exit(repo)
|
|
737
|
+
runs_dir = cfg.artifacts_dir() / "runs"
|
|
738
|
+
if not runs_dir.is_dir():
|
|
739
|
+
console.print(f"[red]No runs found under[/red] {runs_dir}")
|
|
740
|
+
sys.exit(2)
|
|
741
|
+
|
|
742
|
+
# Prefer stamped run jsons (case-timestamp.json), skip .latest.json
|
|
743
|
+
files = sorted(
|
|
744
|
+
[
|
|
745
|
+
p
|
|
746
|
+
for p in runs_dir.glob("*.json")
|
|
747
|
+
if p.is_file() and not p.name.endswith(".latest.json")
|
|
748
|
+
],
|
|
749
|
+
key=lambda p: p.stat().st_mtime,
|
|
750
|
+
reverse=True,
|
|
751
|
+
)
|
|
752
|
+
# Dedupe by case name (newest wins)
|
|
753
|
+
by_case: dict[str, Path] = {}
|
|
754
|
+
for p in files:
|
|
755
|
+
try:
|
|
756
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
757
|
+
except (OSError, json.JSONDecodeError):
|
|
758
|
+
continue
|
|
759
|
+
name = str(data.get("case") or p.stem)
|
|
760
|
+
if name not in by_case:
|
|
761
|
+
by_case[name] = p
|
|
762
|
+
if len(by_case) >= 50:
|
|
763
|
+
break
|
|
764
|
+
|
|
765
|
+
if not by_case:
|
|
766
|
+
console.print(f"[red]No run JSON files in[/red] {runs_dir}")
|
|
767
|
+
sys.exit(2)
|
|
768
|
+
|
|
769
|
+
report_cases: list[ReportCase] = []
|
|
770
|
+
for name, path in sorted(by_case.items()):
|
|
771
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
772
|
+
report_cases.append(
|
|
773
|
+
ReportCase(
|
|
774
|
+
name=name,
|
|
775
|
+
success=bool(data.get("success")),
|
|
776
|
+
summary=str(data.get("summary") or ""),
|
|
777
|
+
error=str(data.get("error") or ""),
|
|
778
|
+
task=str(data.get("task") or ""),
|
|
779
|
+
platform=str(data.get("platform") or ""),
|
|
780
|
+
provider=str(data.get("provider") or "local"),
|
|
781
|
+
device_id=str(data.get("device_id") or ""),
|
|
782
|
+
duration_s=float(data.get("duration_s") or 0.0),
|
|
783
|
+
flow_path=str(data.get("flow_path") or ""),
|
|
784
|
+
dashboard_url=str((data.get("run") or {}).get("dashboard_url") or ""),
|
|
785
|
+
build_id=str((data.get("run") or {}).get("build_id") or ""),
|
|
786
|
+
stdout=str((data.get("run") or {}).get("stdout") or ""),
|
|
787
|
+
stderr=str((data.get("run") or {}).get("stderr") or ""),
|
|
788
|
+
logs=[str(x) for x in (data.get("logs") or [])],
|
|
789
|
+
synthesis_only=bool(data.get("synthesis_only")),
|
|
790
|
+
screenshot_paths=[str(x) for x in (data.get("screenshots") or [])],
|
|
791
|
+
artifact_dir=str(data.get("artifact_dir") or ""),
|
|
792
|
+
started_at=str(data.get("started_at") or ""),
|
|
793
|
+
video_url=str((data.get("run") or {}).get("video_url") or ""),
|
|
794
|
+
explore_usage=dict(data.get("explore_usage") or {}),
|
|
795
|
+
codegen_usage=dict(data.get("codegen_usage") or {}),
|
|
796
|
+
)
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
records = [case_record_from_report_case(c) for c in report_cases]
|
|
800
|
+
pack = build_pack(
|
|
801
|
+
records,
|
|
802
|
+
title=title,
|
|
803
|
+
env=env_from_config(cfg),
|
|
804
|
+
)
|
|
805
|
+
dest = Path(out_dir) if out_dir else cfg.report_dir_path() / "latest-pack"
|
|
806
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
807
|
+
html_path = write_pack_html(pack, dest / "index.html")
|
|
808
|
+
json_path = write_pack_json(pack, dest / "pack.json")
|
|
809
|
+
(dest / "report.html").write_text(html_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
810
|
+
console.print(f"[green]Rich report[/green] → {html_path}")
|
|
811
|
+
console.print(f"[dim]Pack JSON → {json_path}[/dim]")
|
|
812
|
+
console.print("[dim]Serve artifacts with: mobiflow serve[/dim]")
|
|
813
|
+
if open_browser:
|
|
814
|
+
webbrowser.open(html_path.resolve().as_uri())
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
@main.command("serve")
|
|
818
|
+
@click.option("--repo", default=None, help="Project path")
|
|
819
|
+
@click.option("--port", default=8765, show_default=True, type=int)
|
|
820
|
+
@click.option("--bind", default="127.0.0.1", show_default=True)
|
|
821
|
+
def serve_cmd(repo: str | None, port: int, bind: str) -> None:
|
|
822
|
+
"""HTTP-serve ``.mobiflow/`` so report screenshots/videos load in the SPA."""
|
|
823
|
+
import http.server
|
|
824
|
+
import socketserver
|
|
825
|
+
|
|
826
|
+
cfg = _load_config_or_exit(repo)
|
|
827
|
+
root = cfg.artifacts_dir()
|
|
828
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
829
|
+
|
|
830
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
831
|
+
def __init__(self, *args, **kwargs):
|
|
832
|
+
super().__init__(*args, directory=str(root), **kwargs)
|
|
833
|
+
|
|
834
|
+
def log_message(self, fmt: str, *args) -> None: # noqa: A003
|
|
835
|
+
console.print(f"[dim]{self.address_string()}[/dim] {fmt % args}")
|
|
836
|
+
|
|
837
|
+
console.print(f"[bold]Serving[/bold] {root} at http://{bind}:{port}/")
|
|
838
|
+
console.print("[dim]Open a report index.html via file:// or copy under this root.[/dim]")
|
|
839
|
+
with socketserver.TCPServer((bind, port), Handler) as httpd:
|
|
840
|
+
try:
|
|
841
|
+
httpd.serve_forever()
|
|
842
|
+
except KeyboardInterrupt:
|
|
843
|
+
console.print("\n[dim]stopped[/dim]")
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
@main.command("gen")
|
|
847
|
+
@click.argument("goal")
|
|
848
|
+
@click.option("--repo", default=None, help="Project path")
|
|
849
|
+
@click.option("--platform", default=None, help="android|ios")
|
|
850
|
+
@click.option("--app-id", "app_id", default=None, help="Maestro appId")
|
|
851
|
+
@click.option(
|
|
852
|
+
"--out",
|
|
853
|
+
"out_path",
|
|
854
|
+
default=None,
|
|
855
|
+
type=click.Path(dir_okay=False),
|
|
856
|
+
help="Write YAML to this path (JS scripts go beside it under scripts/)",
|
|
857
|
+
)
|
|
858
|
+
@click.option(
|
|
859
|
+
"--js/--no-js",
|
|
860
|
+
default=None,
|
|
861
|
+
help="Allow Maestro JavaScript (default: from stack.language)",
|
|
862
|
+
)
|
|
863
|
+
def gen_cmd(
|
|
864
|
+
goal: str,
|
|
865
|
+
repo: str | None,
|
|
866
|
+
platform: str | None,
|
|
867
|
+
app_id: str | None,
|
|
868
|
+
out_path: str | None,
|
|
869
|
+
js: bool | None,
|
|
870
|
+
) -> None:
|
|
871
|
+
"""Generate Maestro YAML (+ optional JS) from a natural-language goal."""
|
|
872
|
+
from mobiflow.maestro import generate_flow_bundle
|
|
873
|
+
|
|
874
|
+
cfg = _load_config_or_exit(repo)
|
|
875
|
+
_print_warnings(cfg)
|
|
876
|
+
plat = platform or cfg.device.platform
|
|
877
|
+
aid = app_id or cfg.device.app_id
|
|
878
|
+
allow_js = cfg.stack.js_enabled() if js is None else js
|
|
879
|
+
|
|
880
|
+
def progress(msg: str) -> None:
|
|
881
|
+
console.print(f" [dim]→[/dim] {msg}")
|
|
882
|
+
|
|
883
|
+
bundle = asyncio.run(
|
|
884
|
+
generate_flow_bundle(
|
|
885
|
+
goal,
|
|
886
|
+
app_id=aid,
|
|
887
|
+
platform=plat,
|
|
888
|
+
profile=cfg.codegen_profile(),
|
|
889
|
+
allow_js=allow_js,
|
|
890
|
+
progress=progress,
|
|
891
|
+
)
|
|
892
|
+
)
|
|
893
|
+
if out_path:
|
|
894
|
+
p = Path(out_path).expanduser().resolve()
|
|
895
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
896
|
+
p.write_text(bundle.flow_yaml, encoding="utf-8")
|
|
897
|
+
console.print(f"[green]Wrote[/green] {p}")
|
|
898
|
+
for rel, body in bundle.scripts.items():
|
|
899
|
+
sp = p.parent / rel
|
|
900
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
901
|
+
sp.write_text(body, encoding="utf-8")
|
|
902
|
+
console.print(f"[green]Wrote[/green] {sp}")
|
|
903
|
+
else:
|
|
904
|
+
console.print(bundle.flow_yaml)
|
|
905
|
+
for rel, body in bundle.scripts.items():
|
|
906
|
+
console.print(f"\n[bold]// {rel}[/bold]\n{body}")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@main.command("explore")
|
|
910
|
+
@click.argument("goal", required=False, default=None)
|
|
911
|
+
@click.option("--repo", default=None, help="Project path")
|
|
912
|
+
@click.option("--device", "device_id", default=None, help="adb serial / UDID")
|
|
913
|
+
@click.option("--platform", default=None, help="android|ios")
|
|
914
|
+
@click.option("--app-id", "app_id", default=None, help="Maestro appId")
|
|
915
|
+
@click.option(
|
|
916
|
+
"--interactive/--auto",
|
|
917
|
+
default=False,
|
|
918
|
+
help="Confirm each discovery action (separate interactive session mode)",
|
|
919
|
+
)
|
|
920
|
+
@click.option("--steps", default=None, type=int, help="Max explore steps (default from config)")
|
|
921
|
+
@click.option(
|
|
922
|
+
"--gen/--no-gen",
|
|
923
|
+
default=False,
|
|
924
|
+
help="After explore, run codegen and print/write YAML",
|
|
925
|
+
)
|
|
926
|
+
@click.option(
|
|
927
|
+
"--out",
|
|
928
|
+
"out_path",
|
|
929
|
+
default=None,
|
|
930
|
+
type=click.Path(dir_okay=False),
|
|
931
|
+
help="With --gen, write YAML to this path",
|
|
932
|
+
)
|
|
933
|
+
@click.option("--case", "case_file", default=None, type=click.Path(exists=True, dir_okay=False),
|
|
934
|
+
help="Load goal/appId/platform from a case file")
|
|
935
|
+
def explore_cmd(
|
|
936
|
+
goal: str | None,
|
|
937
|
+
repo: str | None,
|
|
938
|
+
device_id: str | None,
|
|
939
|
+
platform: str | None,
|
|
940
|
+
app_id: str | None,
|
|
941
|
+
interactive: bool,
|
|
942
|
+
steps: int | None,
|
|
943
|
+
gen: bool,
|
|
944
|
+
out_path: str | None,
|
|
945
|
+
case_file: str | None,
|
|
946
|
+
) -> None:
|
|
947
|
+
"""Explore an app with the discovery LLM (auto or interactive).
|
|
948
|
+
|
|
949
|
+
Separate from ``mobiflow run``: does not execute the final test unless
|
|
950
|
+
you pass ``--gen`` (codegen only) or use ``run`` afterward.
|
|
951
|
+
|
|
952
|
+
Interactive mode prompts Accept / Edit / Skip / Done for each proposed
|
|
953
|
+
Maestro action. It does not start Maestro Studio (see ``mobiflow studio``).
|
|
954
|
+
"""
|
|
955
|
+
import json
|
|
956
|
+
from datetime import UTC, datetime
|
|
957
|
+
|
|
958
|
+
from mobiflow.cases import load_case
|
|
959
|
+
from mobiflow.devices import ensure_device
|
|
960
|
+
from mobiflow.explore import explore_app, plan_only_explore
|
|
961
|
+
from mobiflow.maestro import generate_flow_bundle
|
|
962
|
+
|
|
963
|
+
cfg = _load_config_or_exit(repo)
|
|
964
|
+
_print_warnings(cfg)
|
|
965
|
+
|
|
966
|
+
case_goal = ""
|
|
967
|
+
if case_file:
|
|
968
|
+
case = load_case(case_file)
|
|
969
|
+
case_goal = case.explore_task()
|
|
970
|
+
app_id = app_id or case.app_id or cfg.device.app_id
|
|
971
|
+
platform = platform or case.platform or cfg.device.platform
|
|
972
|
+
device_id = device_id or case.device_id or cfg.device.device_id
|
|
973
|
+
else:
|
|
974
|
+
app_id = app_id or cfg.device.app_id
|
|
975
|
+
platform = platform or cfg.device.platform
|
|
976
|
+
device_id = device_id or cfg.device.device_id
|
|
977
|
+
|
|
978
|
+
goal_text = (goal or case_goal or "").strip()
|
|
979
|
+
if not goal_text:
|
|
980
|
+
console.print("[red]Provide a goal argument or --case file.[/red]")
|
|
981
|
+
sys.exit(1)
|
|
982
|
+
|
|
983
|
+
if cfg.device.is_cloud():
|
|
984
|
+
console.print(
|
|
985
|
+
"[yellow]Explore interactive/live device mode is local-only. "
|
|
986
|
+
"Using plan-only explore for cloud provider.[/yellow]"
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
max_steps = steps if steps is not None else cfg.run.explore_steps
|
|
990
|
+
|
|
991
|
+
def progress(msg: str) -> None:
|
|
992
|
+
console.print(f" [dim]→[/dim] {msg}")
|
|
993
|
+
|
|
994
|
+
async def _run():
|
|
995
|
+
if cfg.device.is_cloud():
|
|
996
|
+
return await plan_only_explore(
|
|
997
|
+
goal=goal_text,
|
|
998
|
+
app_id=app_id or "",
|
|
999
|
+
platform=platform or "android",
|
|
1000
|
+
profile=cfg.discovery_profile(),
|
|
1001
|
+
progress=progress,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
ensured = await ensure_device(
|
|
1005
|
+
platform_pref=platform or "android",
|
|
1006
|
+
device_id=device_id,
|
|
1007
|
+
auto_start=cfg.device.auto_start,
|
|
1008
|
+
timeout_s=float(cfg.device.boot_timeout_s),
|
|
1009
|
+
progress=progress,
|
|
1010
|
+
use_maestro_cli=bool(cfg.device.use_maestro_cli),
|
|
1011
|
+
device_model=str(cfg.device.device_model or ""),
|
|
1012
|
+
device_os=str(cfg.device.device_os or ""),
|
|
1013
|
+
device_locale=str(cfg.device.device_locale or ""),
|
|
1014
|
+
)
|
|
1015
|
+
if not ensured.get("ok") or not ensured.get("device"):
|
|
1016
|
+
console.print(
|
|
1017
|
+
f"[yellow]{ensured.get('message') or 'No device'} — plan-only explore.[/yellow]"
|
|
1018
|
+
)
|
|
1019
|
+
return await plan_only_explore(
|
|
1020
|
+
goal=goal_text,
|
|
1021
|
+
app_id=app_id or "",
|
|
1022
|
+
platform=platform or "android",
|
|
1023
|
+
profile=cfg.discovery_profile(),
|
|
1024
|
+
progress=progress,
|
|
1025
|
+
)
|
|
1026
|
+
selected = ensured["device"].get("id") or device_id
|
|
1027
|
+
if interactive:
|
|
1028
|
+
console.print(
|
|
1029
|
+
"[bold]Interactive explore[/bold] — confirm each discovery action. "
|
|
1030
|
+
"Maestro Studio is separate: [cyan]mobiflow studio[/cyan]"
|
|
1031
|
+
)
|
|
1032
|
+
return await explore_app(
|
|
1033
|
+
goal_text,
|
|
1034
|
+
app_id=app_id or "",
|
|
1035
|
+
platform=platform or "android",
|
|
1036
|
+
device_id=str(selected),
|
|
1037
|
+
profile=cfg.discovery_profile(),
|
|
1038
|
+
max_steps=max_steps,
|
|
1039
|
+
progress=progress,
|
|
1040
|
+
interactive=interactive,
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
exploration = asyncio.run(_run())
|
|
1044
|
+
|
|
1045
|
+
console.print(
|
|
1046
|
+
f"\n[bold]Exploration[/bold] mode={exploration.mode} "
|
|
1047
|
+
f"completed={exploration.completed} steps={len(exploration.steps)}"
|
|
1048
|
+
)
|
|
1049
|
+
if exploration.plan:
|
|
1050
|
+
console.print("[bold]Plan[/bold]")
|
|
1051
|
+
for i, step in enumerate(exploration.plan, 1):
|
|
1052
|
+
console.print(f" {i}. {step}")
|
|
1053
|
+
if exploration.selectors:
|
|
1054
|
+
console.print("[bold]Selectors[/bold]")
|
|
1055
|
+
for sel in exploration.selectors[:20]:
|
|
1056
|
+
console.print(
|
|
1057
|
+
f" · {sel.get('label') or '-'} → {sel.get('text') or '-'}"
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
art = cfg.artifacts_dir() / "explore"
|
|
1061
|
+
art.mkdir(parents=True, exist_ok=True)
|
|
1062
|
+
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
|
1063
|
+
out_json = art / f"explore-{stamp}.json"
|
|
1064
|
+
out_json.write_text(
|
|
1065
|
+
json.dumps(exploration.to_dict(), indent=2) + "\n", encoding="utf-8"
|
|
1066
|
+
)
|
|
1067
|
+
console.print(f"[dim]Saved → {out_json}[/dim]")
|
|
1068
|
+
|
|
1069
|
+
if not gen:
|
|
1070
|
+
return
|
|
1071
|
+
|
|
1072
|
+
def progress_gen(msg: str) -> None:
|
|
1073
|
+
console.print(f" [dim]→[/dim] {msg}")
|
|
1074
|
+
|
|
1075
|
+
bundle = asyncio.run(
|
|
1076
|
+
generate_flow_bundle(
|
|
1077
|
+
goal_text,
|
|
1078
|
+
app_id=app_id or exploration.app_id,
|
|
1079
|
+
platform=platform or exploration.platform,
|
|
1080
|
+
profile=cfg.codegen_profile(),
|
|
1081
|
+
hierarchy=exploration.final_hierarchy,
|
|
1082
|
+
exploration=exploration.to_prompt_block(),
|
|
1083
|
+
allow_js=cfg.stack.js_enabled(),
|
|
1084
|
+
progress=progress_gen,
|
|
1085
|
+
)
|
|
1086
|
+
)
|
|
1087
|
+
if out_path:
|
|
1088
|
+
p = Path(out_path).expanduser().resolve()
|
|
1089
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1090
|
+
p.write_text(bundle.flow_yaml, encoding="utf-8")
|
|
1091
|
+
console.print(f"[green]Wrote[/green] {p}")
|
|
1092
|
+
else:
|
|
1093
|
+
console.print("\n[bold]Generated flow[/bold]\n")
|
|
1094
|
+
console.print(bundle.flow_yaml)
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
@main.command("import-flow")
|
|
1098
|
+
@click.argument("flow_file", type=click.Path(exists=True, dir_okay=False))
|
|
1099
|
+
@click.option("--repo", default=None, help="Project path")
|
|
1100
|
+
@click.option(
|
|
1101
|
+
"--case",
|
|
1102
|
+
"case_out",
|
|
1103
|
+
default=None,
|
|
1104
|
+
type=click.Path(dir_okay=False),
|
|
1105
|
+
help="Write case file here (default: cases/<stem>.txt)",
|
|
1106
|
+
)
|
|
1107
|
+
@click.option("--tag", "tags", multiple=True, help="Add @tag to the case (repeatable)")
|
|
1108
|
+
@click.option(
|
|
1109
|
+
"--copy-flow/--no-copy-flow",
|
|
1110
|
+
default=True,
|
|
1111
|
+
help="Copy YAML into flows/ and set flow: meta (default: on)",
|
|
1112
|
+
)
|
|
1113
|
+
def import_flow_cmd(
|
|
1114
|
+
flow_file: str,
|
|
1115
|
+
repo: str | None,
|
|
1116
|
+
case_out: str | None,
|
|
1117
|
+
tags: tuple[str, ...],
|
|
1118
|
+
copy_flow: bool,
|
|
1119
|
+
) -> None:
|
|
1120
|
+
"""Turn a Maestro YAML (e.g. Studio export) into a MobiFlow case.
|
|
1121
|
+
|
|
1122
|
+
The case uses paste-YAML / reuse-flow so ``mobiflow run`` executes it
|
|
1123
|
+
without LLM authoring.
|
|
1124
|
+
"""
|
|
1125
|
+
import re
|
|
1126
|
+
|
|
1127
|
+
from mobiflow.maestro import looks_like_maestro_yaml
|
|
1128
|
+
|
|
1129
|
+
cfg = _load_config_or_exit(repo)
|
|
1130
|
+
src = Path(flow_file).expanduser().resolve()
|
|
1131
|
+
text = src.read_text(encoding="utf-8")
|
|
1132
|
+
if not looks_like_maestro_yaml(text):
|
|
1133
|
+
console.print("[red]File does not look like Maestro YAML.[/red]")
|
|
1134
|
+
sys.exit(1)
|
|
1135
|
+
|
|
1136
|
+
app_id = ""
|
|
1137
|
+
platform = cfg.device.platform or "android"
|
|
1138
|
+
m = re.search(r"(?m)^appId:\s*(\S+)", text)
|
|
1139
|
+
if m:
|
|
1140
|
+
app_id = m.group(1).strip().strip("\"'")
|
|
1141
|
+
|
|
1142
|
+
flow_rel = ""
|
|
1143
|
+
if copy_flow:
|
|
1144
|
+
dest = cfg.flow_dir_path() / src.name
|
|
1145
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1146
|
+
dest.write_text(text, encoding="utf-8")
|
|
1147
|
+
try:
|
|
1148
|
+
flow_rel = str(dest.relative_to(cfg.repo_path()))
|
|
1149
|
+
except ValueError:
|
|
1150
|
+
flow_rel = str(dest)
|
|
1151
|
+
console.print(f"[green]Copied flow[/green] → {dest}")
|
|
1152
|
+
|
|
1153
|
+
case_path = (
|
|
1154
|
+
Path(case_out).expanduser().resolve()
|
|
1155
|
+
if case_out
|
|
1156
|
+
else cfg.cases_dir_path() / f"{src.stem}.txt"
|
|
1157
|
+
)
|
|
1158
|
+
case_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1159
|
+
lines = ["# Imported from Maestro YAML / Studio export", ""]
|
|
1160
|
+
for tag in tags:
|
|
1161
|
+
lines.append(f"@{tag.lstrip('@')}")
|
|
1162
|
+
if app_id:
|
|
1163
|
+
lines.append(f"appId: {app_id}")
|
|
1164
|
+
lines.append(f"platform: {platform}")
|
|
1165
|
+
if flow_rel:
|
|
1166
|
+
lines.append(f"flow: {flow_rel}")
|
|
1167
|
+
lines.append(f"task: Run imported Maestro flow {src.name}")
|
|
1168
|
+
# Embed YAML so paste-to-run works even without reuse-flow
|
|
1169
|
+
if not flow_rel:
|
|
1170
|
+
lines.append("")
|
|
1171
|
+
lines.append(text.rstrip())
|
|
1172
|
+
case_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
1173
|
+
console.print(f"[green]Wrote case[/green] → {case_path}")
|
|
1174
|
+
console.print(
|
|
1175
|
+
f"[dim]Run with:[/dim] mobiflow run {case_path}"
|
|
1176
|
+
+ (" --reuse-flow" if flow_rel else "")
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
@main.group("baseline")
|
|
1181
|
+
def baseline_group() -> None:
|
|
1182
|
+
"""Manage visual screenshot baselines."""
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
@baseline_group.command("update")
|
|
1186
|
+
@click.argument("case_name")
|
|
1187
|
+
@click.argument("image", type=click.Path(exists=True, dir_okay=False))
|
|
1188
|
+
@click.option("--repo", default=None, help="Project path")
|
|
1189
|
+
def baseline_update_cmd(case_name: str, image: str, repo: str | None) -> None:
|
|
1190
|
+
"""Save ``image`` as the baseline PNG for ``case_name``."""
|
|
1191
|
+
from mobiflow.baseline import update_baseline
|
|
1192
|
+
|
|
1193
|
+
cfg = _load_config_or_exit(repo)
|
|
1194
|
+
dest = update_baseline(case_name, Path(image), cfg.artifacts_dir())
|
|
1195
|
+
console.print(f"[green]Baseline updated[/green] → {dest}")
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
@baseline_group.command("compare")
|
|
1199
|
+
@click.argument("case_name")
|
|
1200
|
+
@click.argument("image", type=click.Path(exists=True, dir_okay=False))
|
|
1201
|
+
@click.option("--repo", default=None, help="Project path")
|
|
1202
|
+
@click.option("--threshold", default=0.02, type=float, help="Max mismatch ratio")
|
|
1203
|
+
def baseline_compare_cmd(
|
|
1204
|
+
case_name: str, image: str, repo: str | None, threshold: float
|
|
1205
|
+
) -> None:
|
|
1206
|
+
"""Compare a screenshot to the stored baseline."""
|
|
1207
|
+
from mobiflow.baseline import compare_case_screenshot
|
|
1208
|
+
|
|
1209
|
+
cfg = _load_config_or_exit(repo)
|
|
1210
|
+
result = compare_case_screenshot(
|
|
1211
|
+
case_name,
|
|
1212
|
+
Path(image),
|
|
1213
|
+
cfg.artifacts_dir(),
|
|
1214
|
+
threshold=threshold,
|
|
1215
|
+
)
|
|
1216
|
+
if result.ok:
|
|
1217
|
+
console.print(
|
|
1218
|
+
f"[green]PASS[/green] {case_name} mismatch={result.mismatch_ratio:.3%}"
|
|
1219
|
+
)
|
|
1220
|
+
else:
|
|
1221
|
+
console.print(
|
|
1222
|
+
f"[red]FAIL[/red] {case_name}: {result.message}"
|
|
1223
|
+
+ (f"\n diff → {result.diff_path}" if result.diff_path else "")
|
|
1224
|
+
)
|
|
1225
|
+
sys.exit(1)
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
@main.command("studio")
|
|
1229
|
+
@click.option("--repo", default=None, help="Project path")
|
|
1230
|
+
@click.option("--device", "device_id", default=None, help="adb serial / UDID")
|
|
1231
|
+
def studio_cmd(repo: str | None, device_id: str | None) -> None:
|
|
1232
|
+
"""Open Maestro Studio (official interactive UI) for a local device.
|
|
1233
|
+
|
|
1234
|
+
This is separate from ``mobiflow explore --interactive`` (LLM-guided
|
|
1235
|
+
confirmations in the terminal).
|
|
1236
|
+
"""
|
|
1237
|
+
import os
|
|
1238
|
+
import subprocess
|
|
1239
|
+
|
|
1240
|
+
from mobiflow.maestro import resolve_java_home, resolve_maestro_binary
|
|
1241
|
+
|
|
1242
|
+
cfg = None
|
|
1243
|
+
try:
|
|
1244
|
+
cfg = _load_config_or_exit(repo)
|
|
1245
|
+
device_id = device_id or cfg.device.device_id
|
|
1246
|
+
if cfg.device.is_cloud():
|
|
1247
|
+
console.print(
|
|
1248
|
+
"[red]Maestro Studio requires a local device "
|
|
1249
|
+
"(device.provider=local).[/red]"
|
|
1250
|
+
)
|
|
1251
|
+
sys.exit(1)
|
|
1252
|
+
except SystemExit:
|
|
1253
|
+
if repo:
|
|
1254
|
+
raise
|
|
1255
|
+
|
|
1256
|
+
binary = resolve_maestro_binary()
|
|
1257
|
+
if not binary:
|
|
1258
|
+
console.print(
|
|
1259
|
+
"[red]Maestro CLI not found.[/red] Install: "
|
|
1260
|
+
"curl -Ls https://get.maestro.mobile.dev | bash"
|
|
1261
|
+
)
|
|
1262
|
+
sys.exit(1)
|
|
1263
|
+
|
|
1264
|
+
args = [binary, "studio"]
|
|
1265
|
+
if device_id:
|
|
1266
|
+
args.extend(["--device", device_id])
|
|
1267
|
+
|
|
1268
|
+
env = dict(os.environ)
|
|
1269
|
+
env.setdefault("MAESTRO_CLI_NO_ANALYTICS", "1")
|
|
1270
|
+
jh = resolve_java_home()
|
|
1271
|
+
if jh:
|
|
1272
|
+
env.setdefault("JAVA_HOME", jh)
|
|
1273
|
+
env["PATH"] = str(Path(binary).parent) + os.pathsep + env.get("PATH", "")
|
|
1274
|
+
|
|
1275
|
+
console.print(f"[dim]Launching:[/dim] {' '.join(args)}")
|
|
1276
|
+
try:
|
|
1277
|
+
raise SystemExit(subprocess.call(args, env=env))
|
|
1278
|
+
except FileNotFoundError:
|
|
1279
|
+
console.print(f"[red]Failed to launch[/red] {binary}")
|
|
1280
|
+
sys.exit(1)
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
@main.command("test-flow")
|
|
1284
|
+
@click.argument("flow_file", type=click.Path(exists=True, dir_okay=False))
|
|
1285
|
+
@click.option("--device", "device_id", default=None, help="Local device id or cloud device name")
|
|
1286
|
+
@click.option("--repo", default=None, help="Project path (for timeout/config)")
|
|
1287
|
+
def test_flow_cmd(flow_file: str, device_id: str | None, repo: str | None) -> None:
|
|
1288
|
+
"""Run an existing Maestro YAML file on a local or cloud device."""
|
|
1289
|
+
import time
|
|
1290
|
+
from datetime import datetime, timezone
|
|
1291
|
+
|
|
1292
|
+
from mobiflow.maestro import run_flow_yaml
|
|
1293
|
+
from mobiflow.reporting import (
|
|
1294
|
+
ReportCase,
|
|
1295
|
+
collect_screenshots,
|
|
1296
|
+
write_run_reports,
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
cfg = None
|
|
1300
|
+
timeout = 180
|
|
1301
|
+
device_config = None
|
|
1302
|
+
platform = None
|
|
1303
|
+
try:
|
|
1304
|
+
cfg = _load_config_or_exit(repo)
|
|
1305
|
+
timeout = cfg.run.timeout_s
|
|
1306
|
+
if cfg.device.is_cloud():
|
|
1307
|
+
timeout = max(timeout, int(cfg.device.cloud_timeout_s or 1800))
|
|
1308
|
+
device_id = device_id or cfg.device.device_id
|
|
1309
|
+
device_config = cfg.device
|
|
1310
|
+
platform = cfg.device.platform
|
|
1311
|
+
except SystemExit:
|
|
1312
|
+
pass
|
|
1313
|
+
|
|
1314
|
+
flow_path = Path(flow_file).expanduser().resolve()
|
|
1315
|
+
yaml_text = flow_path.read_text(encoding="utf-8")
|
|
1316
|
+
case_name = flow_path.stem
|
|
1317
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
1318
|
+
artifact_dir = None
|
|
1319
|
+
if cfg is not None and cfg.run.save_artifacts:
|
|
1320
|
+
artifact_dir = cfg.artifacts_dir() / "runs" / f"{case_name}-{stamp}"
|
|
1321
|
+
artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
1322
|
+
|
|
1323
|
+
# Load companion JS referenced by runScript (same as pipeline reuse path)
|
|
1324
|
+
scripts: dict[str, str] = {}
|
|
1325
|
+
if cfg is not None:
|
|
1326
|
+
from mobiflow.pipeline import _load_companion_scripts
|
|
1327
|
+
|
|
1328
|
+
scripts = _load_companion_scripts(flow_path, cfg)
|
|
1329
|
+
else:
|
|
1330
|
+
scripts_dir = flow_path.parent / "scripts"
|
|
1331
|
+
if scripts_dir.is_dir():
|
|
1332
|
+
for js in scripts_dir.rglob("*.js"):
|
|
1333
|
+
rel = js.relative_to(flow_path.parent)
|
|
1334
|
+
scripts[str(rel).replace("\\", "/")] = js.read_text(encoding="utf-8")
|
|
1335
|
+
|
|
1336
|
+
def progress(msg: str) -> None:
|
|
1337
|
+
console.print(f" [dim]→[/dim] {msg}")
|
|
1338
|
+
|
|
1339
|
+
t0 = time.monotonic()
|
|
1340
|
+
result = asyncio.run(
|
|
1341
|
+
run_flow_yaml(
|
|
1342
|
+
yaml_text,
|
|
1343
|
+
device_id=device_id,
|
|
1344
|
+
timeout_s=timeout,
|
|
1345
|
+
scripts=scripts or None,
|
|
1346
|
+
progress=progress,
|
|
1347
|
+
device_config=device_config,
|
|
1348
|
+
platform=platform,
|
|
1349
|
+
artifact_dir=artifact_dir,
|
|
1350
|
+
)
|
|
1351
|
+
)
|
|
1352
|
+
duration_s = time.monotonic() - t0
|
|
1353
|
+
|
|
1354
|
+
if cfg is not None and cfg.run.save_artifacts and artifact_dir is not None:
|
|
1355
|
+
shots_dir = artifact_dir / "screenshots"
|
|
1356
|
+
shot_rels: list[str] = []
|
|
1357
|
+
for src in collect_screenshots(artifact_dir):
|
|
1358
|
+
shots_dir.mkdir(parents=True, exist_ok=True)
|
|
1359
|
+
dest = shots_dir / src.name
|
|
1360
|
+
if not dest.exists():
|
|
1361
|
+
dest.write_bytes(src.read_bytes())
|
|
1362
|
+
shot_rels.append(f"../screenshots/{dest.name}")
|
|
1363
|
+
if cfg.run.reports:
|
|
1364
|
+
report_case = ReportCase(
|
|
1365
|
+
name=case_name,
|
|
1366
|
+
success=bool(result.get("ok")),
|
|
1367
|
+
summary="passed" if result.get("ok") else (result.get("error") or "failed"),
|
|
1368
|
+
error=str(result.get("error") or ""),
|
|
1369
|
+
platform=str(platform or ""),
|
|
1370
|
+
provider=str(
|
|
1371
|
+
(cfg.device.provider if cfg is not None else "local") or "local"
|
|
1372
|
+
),
|
|
1373
|
+
device_id=str(device_id or ""),
|
|
1374
|
+
duration_s=duration_s,
|
|
1375
|
+
flow_path=str(Path(flow_file).resolve()),
|
|
1376
|
+
dashboard_url=str(result.get("dashboard_url") or ""),
|
|
1377
|
+
build_id=str(result.get("build_id") or ""),
|
|
1378
|
+
stdout=str(result.get("stdout") or ""),
|
|
1379
|
+
stderr=str(result.get("stderr") or ""),
|
|
1380
|
+
screenshot_paths=shot_rels,
|
|
1381
|
+
artifact_dir=str(artifact_dir),
|
|
1382
|
+
)
|
|
1383
|
+
junit_src = (
|
|
1384
|
+
Path(result["maestro_junit"]) if result.get("maestro_junit") else None
|
|
1385
|
+
)
|
|
1386
|
+
write_run_reports(
|
|
1387
|
+
report_case,
|
|
1388
|
+
artifact_dir / "report",
|
|
1389
|
+
formats=cfg.run.reports,
|
|
1390
|
+
maestro_junit=junit_src,
|
|
1391
|
+
)
|
|
1392
|
+
mirror_dir = cfg.report_dir_path() / f"{case_name}-{stamp}"
|
|
1393
|
+
mirrored = write_run_reports(
|
|
1394
|
+
report_case,
|
|
1395
|
+
mirror_dir,
|
|
1396
|
+
formats=cfg.run.reports,
|
|
1397
|
+
maestro_junit=junit_src,
|
|
1398
|
+
)
|
|
1399
|
+
src_shots = artifact_dir / "screenshots"
|
|
1400
|
+
if src_shots.is_dir():
|
|
1401
|
+
mirror_shots = mirror_dir / "screenshots"
|
|
1402
|
+
mirror_shots.mkdir(parents=True, exist_ok=True)
|
|
1403
|
+
for img in src_shots.iterdir():
|
|
1404
|
+
if img.is_file():
|
|
1405
|
+
(mirror_shots / img.name).write_bytes(img.read_bytes())
|
|
1406
|
+
if mirrored.get("html"):
|
|
1407
|
+
console.print(f"[green]HTML report[/green] → {mirrored['html']}")
|
|
1408
|
+
if mirrored.get("junit"):
|
|
1409
|
+
console.print(f"[green]JUnit[/green] → {mirrored['junit']}")
|
|
1410
|
+
console.print(f"[dim]Artifacts → {artifact_dir}[/dim]")
|
|
1411
|
+
|
|
1412
|
+
if result.get("ok"):
|
|
1413
|
+
console.print("[bold green]PASSED[/bold green]")
|
|
1414
|
+
if result.get("dashboard_url"):
|
|
1415
|
+
console.print(f"[dim]Dashboard:[/dim] {result['dashboard_url']}")
|
|
1416
|
+
else:
|
|
1417
|
+
console.print("[bold red]FAILED[/bold red]")
|
|
1418
|
+
err = (result.get("stderr") or result.get("stdout") or "")[:2000]
|
|
1419
|
+
if err:
|
|
1420
|
+
console.print(err)
|
|
1421
|
+
if result.get("dashboard_url"):
|
|
1422
|
+
console.print(f"[dim]Dashboard:[/dim] {result['dashboard_url']}")
|
|
1423
|
+
sys.exit(1)
|