@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.
Files changed (39) hide show
  1. package/README.md +9 -11
  2. package/bin/mobiflow.js +94 -61
  3. package/package.json +8 -3
  4. package/pyproject.toml +59 -0
  5. package/src/mobiflow/__init__.py +9 -0
  6. package/src/mobiflow/__main__.py +6 -0
  7. package/src/mobiflow/baseline.py +228 -0
  8. package/src/mobiflow/casedata.py +159 -0
  9. package/src/mobiflow/cases/__init__.py +715 -0
  10. package/src/mobiflow/cli.py +1423 -0
  11. package/src/mobiflow/cloud/__init__.py +28 -0
  12. package/src/mobiflow/cloud/base.py +272 -0
  13. package/src/mobiflow/cloud/browserstack.py +330 -0
  14. package/src/mobiflow/cloud/maestro_cloud.py +141 -0
  15. package/src/mobiflow/cloud/media.py +269 -0
  16. package/src/mobiflow/cloud/runner.py +156 -0
  17. package/src/mobiflow/cloud/testmu.py +378 -0
  18. package/src/mobiflow/config/__init__.py +538 -0
  19. package/src/mobiflow/deps.py +377 -0
  20. package/src/mobiflow/devices.py +717 -0
  21. package/src/mobiflow/explore.py +623 -0
  22. package/src/mobiflow/incremental.py +198 -0
  23. package/src/mobiflow/init/__init__.py +794 -0
  24. package/src/mobiflow/llm.py +462 -0
  25. package/src/mobiflow/llm_catalog.py +232 -0
  26. package/src/mobiflow/maestro/__init__.py +1506 -0
  27. package/src/mobiflow/maestro/lifecycle.py +279 -0
  28. package/src/mobiflow/pipeline.py +600 -0
  29. package/src/mobiflow/report/__init__.py +617 -0
  30. package/src/mobiflow/report/static/favicon.jpg +0 -0
  31. package/src/mobiflow/report/static/favicon.svg +1 -0
  32. package/src/mobiflow/report/static/icons.svg +24 -0
  33. package/src/mobiflow/report/static/index.html +99 -0
  34. package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
  35. package/src/mobiflow/reporting.py +682 -0
  36. package/src/mobiflow/sample_apps.py +259 -0
  37. package/src/mobiflow/secrets.py +90 -0
  38. package/src/mobiflow/selectors.py +128 -0
  39. 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)