@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,600 @@
|
|
|
1
|
+
"""Orchestrate case → generate → run → heal → reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from mobiflow.cases import load_case, resolve_run_options
|
|
15
|
+
from mobiflow.config import MobiflowConfig
|
|
16
|
+
from mobiflow.incremental import (
|
|
17
|
+
classify_guidance,
|
|
18
|
+
format_gap_task,
|
|
19
|
+
load_guidance,
|
|
20
|
+
save_guidance,
|
|
21
|
+
)
|
|
22
|
+
from mobiflow.maestro import run_mobile_task
|
|
23
|
+
from mobiflow.reporting import (
|
|
24
|
+
ReportCase,
|
|
25
|
+
collect_screenshots,
|
|
26
|
+
index_artifacts,
|
|
27
|
+
write_run_reports,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_scripts(flow_dir: Path, scripts: dict[str, str]) -> list[Path]:
|
|
35
|
+
written: list[Path] = []
|
|
36
|
+
for rel, body in (scripts or {}).items():
|
|
37
|
+
# Scripts are relative to flow dir (e.g. scripts/helpers.js)
|
|
38
|
+
path = flow_dir / rel
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
path.write_text(body, encoding="utf-8")
|
|
41
|
+
written.append(path)
|
|
42
|
+
return written
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_flow_path(case: Any, cfg: MobiflowConfig) -> Path | None:
|
|
46
|
+
"""Return existing frozen YAML from case.flow or flows/<case>.yaml."""
|
|
47
|
+
if case.flow:
|
|
48
|
+
p = Path(case.flow).expanduser()
|
|
49
|
+
if not p.is_absolute():
|
|
50
|
+
p = cfg.repo_path() / p
|
|
51
|
+
return p.resolve() if p.is_file() else None
|
|
52
|
+
candidate = cfg.flow_dir_path() / f"{case.name}.yaml"
|
|
53
|
+
return candidate if candidate.is_file() else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_reuse_flow_path(
|
|
57
|
+
case: Any,
|
|
58
|
+
cfg: MobiflowConfig,
|
|
59
|
+
*,
|
|
60
|
+
reuse_flow: bool | None = None,
|
|
61
|
+
) -> Path | None:
|
|
62
|
+
"""Return a frozen YAML path from case.flow or flows/<case>.yaml when enabled."""
|
|
63
|
+
if case.flow:
|
|
64
|
+
return resolve_flow_path(case, cfg)
|
|
65
|
+
want = cfg.run.reuse_flow if reuse_flow is None else reuse_flow
|
|
66
|
+
if not want:
|
|
67
|
+
return None
|
|
68
|
+
return resolve_flow_path(case, cfg)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_companion_scripts(flow_path: Path, cfg: MobiflowConfig) -> dict[str, str]:
|
|
72
|
+
scripts: dict[str, str] = {}
|
|
73
|
+
# Prefer scripts next to the flow, then stack.scripts_dir
|
|
74
|
+
for root in (flow_path.parent, cfg.scripts_dir_path(), cfg.flow_dir_path()):
|
|
75
|
+
scripts_dir = root / "scripts" if (root / "scripts").is_dir() else root
|
|
76
|
+
if not scripts_dir.is_dir():
|
|
77
|
+
continue
|
|
78
|
+
for js in scripts_dir.rglob("*.js"):
|
|
79
|
+
try:
|
|
80
|
+
rel = js.relative_to(flow_path.parent)
|
|
81
|
+
except ValueError:
|
|
82
|
+
rel = Path("scripts") / js.name
|
|
83
|
+
key = str(rel).replace("\\", "/")
|
|
84
|
+
if key not in scripts:
|
|
85
|
+
scripts[key] = js.read_text(encoding="utf-8")
|
|
86
|
+
return scripts
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_incremental_plan(
|
|
90
|
+
case: Any,
|
|
91
|
+
cfg: MobiflowConfig,
|
|
92
|
+
*,
|
|
93
|
+
incremental: bool,
|
|
94
|
+
extend_explore: bool,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
"""Classify case growth and decide reuse / gap-explore / seeded extend."""
|
|
97
|
+
plan: dict[str, Any] = {
|
|
98
|
+
"mode": "full",
|
|
99
|
+
"reuse_yaml": None,
|
|
100
|
+
"reuse_scripts": {},
|
|
101
|
+
"prior_yaml": None,
|
|
102
|
+
"prior_scripts": {},
|
|
103
|
+
"extend": False,
|
|
104
|
+
"replay_prefix": False,
|
|
105
|
+
"explore_goal": None,
|
|
106
|
+
"codegen_goal": None,
|
|
107
|
+
}
|
|
108
|
+
if not incremental and not extend_explore:
|
|
109
|
+
return plan
|
|
110
|
+
|
|
111
|
+
prior_path = resolve_flow_path(case, cfg)
|
|
112
|
+
if prior_path is None:
|
|
113
|
+
console.print(
|
|
114
|
+
" [yellow]incremental/extend: no prior flows/<case>.yaml — full run[/yellow]"
|
|
115
|
+
)
|
|
116
|
+
return plan
|
|
117
|
+
|
|
118
|
+
prior_yaml = prior_path.read_text(encoding="utf-8")
|
|
119
|
+
prior_scripts = _load_companion_scripts(prior_path, cfg)
|
|
120
|
+
current = case.guidance_steps()
|
|
121
|
+
prior_g = load_guidance(cfg.repo_path(), case.name)
|
|
122
|
+
|
|
123
|
+
if extend_explore and not incremental:
|
|
124
|
+
console.print(
|
|
125
|
+
f" [cyan]extend-explore[/cyan] seeding codegen from {prior_path.name}"
|
|
126
|
+
)
|
|
127
|
+
plan.update(
|
|
128
|
+
{
|
|
129
|
+
"mode": "extend",
|
|
130
|
+
"prior_yaml": prior_yaml,
|
|
131
|
+
"prior_scripts": prior_scripts,
|
|
132
|
+
"extend": True,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
return plan
|
|
136
|
+
|
|
137
|
+
# --incremental
|
|
138
|
+
if not prior_g:
|
|
139
|
+
# Flow exists but no stamped guidance — treat as dirty (seeded full)
|
|
140
|
+
diff_mode = "dirty"
|
|
141
|
+
common = 0
|
|
142
|
+
console.print(
|
|
143
|
+
" [cyan]incremental[/cyan] no saved guidance stamp — "
|
|
144
|
+
"seeded full explore (dirty)"
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
diff = classify_guidance(prior_g, current)
|
|
148
|
+
diff_mode = diff.mode
|
|
149
|
+
common = diff.common_prefix
|
|
150
|
+
console.print(
|
|
151
|
+
f" [cyan]incremental[/cyan] guidance {diff_mode} "
|
|
152
|
+
f"(common prefix {common}/{len(current) or len(prior_g)})"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if diff_mode == "unchanged":
|
|
156
|
+
plan.update(
|
|
157
|
+
{
|
|
158
|
+
"mode": "unchanged",
|
|
159
|
+
"reuse_yaml": prior_yaml,
|
|
160
|
+
"reuse_scripts": prior_scripts,
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
return plan
|
|
164
|
+
|
|
165
|
+
if diff_mode == "append" and current:
|
|
166
|
+
new_steps = current[common:]
|
|
167
|
+
gap = format_gap_task(
|
|
168
|
+
title=case.task.split("\n", 1)[0][:120],
|
|
169
|
+
new_steps=new_steps,
|
|
170
|
+
start_index=common + 1,
|
|
171
|
+
app_id=case.app_id or cfg.device.app_id or "",
|
|
172
|
+
)
|
|
173
|
+
console.print(f" → gap explore for {len(new_steps)} new step(s)")
|
|
174
|
+
plan.update(
|
|
175
|
+
{
|
|
176
|
+
"mode": "append",
|
|
177
|
+
"prior_yaml": prior_yaml,
|
|
178
|
+
"prior_scripts": prior_scripts,
|
|
179
|
+
"extend": True,
|
|
180
|
+
"replay_prefix": True,
|
|
181
|
+
"explore_goal": gap,
|
|
182
|
+
"codegen_goal": gap,
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
return plan
|
|
186
|
+
|
|
187
|
+
# dirty | fresh-with-prior
|
|
188
|
+
plan.update(
|
|
189
|
+
{
|
|
190
|
+
"mode": "dirty",
|
|
191
|
+
"prior_yaml": prior_yaml,
|
|
192
|
+
"prior_scripts": prior_scripts,
|
|
193
|
+
"extend": True,
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
return plan
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_pipeline(
|
|
200
|
+
case_file: Path | str,
|
|
201
|
+
cfg: MobiflowConfig,
|
|
202
|
+
*,
|
|
203
|
+
gen_only: bool = False,
|
|
204
|
+
device_id: str | None = None,
|
|
205
|
+
no_heal: bool = False,
|
|
206
|
+
reuse_flow: bool | None = None,
|
|
207
|
+
incremental: bool | None = None,
|
|
208
|
+
extend_explore: bool | None = None,
|
|
209
|
+
) -> dict[str, Any]:
|
|
210
|
+
case = load_case(case_file)
|
|
211
|
+
flow_dir = cfg.flow_dir_path()
|
|
212
|
+
flow_dir.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
|
|
214
|
+
app_id = case.app_id or cfg.device.app_id
|
|
215
|
+
platform = case.platform or cfg.device.platform
|
|
216
|
+
selected_device = device_id or case.device_id or cfg.device.device_id
|
|
217
|
+
allow_js = cfg.stack.js_enabled()
|
|
218
|
+
|
|
219
|
+
codegen = cfg.codegen_profile()
|
|
220
|
+
discovery = cfg.discovery_profile()
|
|
221
|
+
|
|
222
|
+
opts = resolve_run_options(
|
|
223
|
+
case,
|
|
224
|
+
cfg,
|
|
225
|
+
gen_only=gen_only,
|
|
226
|
+
no_heal=no_heal,
|
|
227
|
+
reuse_flow=reuse_flow,
|
|
228
|
+
incremental=incremental,
|
|
229
|
+
extend_explore=extend_explore,
|
|
230
|
+
)
|
|
231
|
+
gen_only = opts.gen_only
|
|
232
|
+
want_reuse = opts.reuse_flow
|
|
233
|
+
want_incr = opts.incremental
|
|
234
|
+
want_extend = opts.extend_explore
|
|
235
|
+
|
|
236
|
+
from mobiflow.casedata import format_data_prompt_block
|
|
237
|
+
from mobiflow.secrets import merge_flow_env, redact_text
|
|
238
|
+
|
|
239
|
+
data_path_resolved = None
|
|
240
|
+
data_flat: dict[str, str] = {}
|
|
241
|
+
data_block = ""
|
|
242
|
+
if case.data_path:
|
|
243
|
+
try:
|
|
244
|
+
data_path_resolved, _raw, data_flat = case.load_data(repo=cfg.repo_path())
|
|
245
|
+
data_block = format_data_prompt_block(
|
|
246
|
+
data_flat,
|
|
247
|
+
path=str(data_path_resolved) if data_path_resolved else case.data_path,
|
|
248
|
+
)
|
|
249
|
+
except (OSError, ValueError, FileNotFoundError) as exc:
|
|
250
|
+
raise ValueError(f"Case data: {exc}") from exc
|
|
251
|
+
|
|
252
|
+
task_text = case.explore_task(data_block=data_block)
|
|
253
|
+
|
|
254
|
+
def progress(msg: str) -> None:
|
|
255
|
+
console.print(f" [dim]→[/dim] {msg}")
|
|
256
|
+
|
|
257
|
+
console.print(f"[bold]Case[/bold] {case.name}")
|
|
258
|
+
console.print(f" task: {task_text[:200]}")
|
|
259
|
+
for w in case.parse_warnings:
|
|
260
|
+
console.print(f" [yellow]case warning:[/yellow] {w}")
|
|
261
|
+
if data_path_resolved is not None:
|
|
262
|
+
console.print(
|
|
263
|
+
f" [cyan]data[/cyan] {data_path_resolved} ({len(data_flat)} key(s))"
|
|
264
|
+
)
|
|
265
|
+
provider = cfg.device.provider or "local"
|
|
266
|
+
console.print(
|
|
267
|
+
f" provider={provider} platform={platform} appId={app_id or '(infer)'} "
|
|
268
|
+
f"device={selected_device or '(auto)'}"
|
|
269
|
+
)
|
|
270
|
+
if cfg.device.is_cloud():
|
|
271
|
+
console.print(
|
|
272
|
+
f" cloud app_path={cfg.device.app_path or '-'} "
|
|
273
|
+
f"app_url={cfg.device.app_url or '-'}"
|
|
274
|
+
)
|
|
275
|
+
console.print(
|
|
276
|
+
f" LLM codegen={cfg.llm.codegen} discovery={cfg.llm.discovery} "
|
|
277
|
+
f"lang={cfg.stack.language}"
|
|
278
|
+
)
|
|
279
|
+
mode_bits = []
|
|
280
|
+
if want_reuse:
|
|
281
|
+
mode_bits.append("reuseFlow")
|
|
282
|
+
if want_incr:
|
|
283
|
+
mode_bits.append("incremental")
|
|
284
|
+
if want_extend:
|
|
285
|
+
mode_bits.append("extendExplore")
|
|
286
|
+
if opts.gen_only:
|
|
287
|
+
mode_bits.append("genOnly")
|
|
288
|
+
console.print(
|
|
289
|
+
f" run: heal={opts.heal} retries={opts.retries} "
|
|
290
|
+
f"explore={str(opts.explore).lower()} "
|
|
291
|
+
f"modes={','.join(mode_bits) or 'full'} "
|
|
292
|
+
f"[dim]({', '.join(f'{k}={v}' for k, v in sorted(opts.sources.items()))})[/dim]"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
reuse_yaml = None
|
|
296
|
+
reuse_scripts: dict[str, str] = {}
|
|
297
|
+
prior_yaml = None
|
|
298
|
+
prior_scripts: dict[str, str] = {}
|
|
299
|
+
extend = False
|
|
300
|
+
replay_prefix = False
|
|
301
|
+
explore_goal = None
|
|
302
|
+
codegen_goal = None
|
|
303
|
+
incr_mode = "full"
|
|
304
|
+
|
|
305
|
+
if want_reuse and not gen_only:
|
|
306
|
+
reuse_path = resolve_reuse_flow_path(case, cfg, reuse_flow=True)
|
|
307
|
+
if reuse_path is not None:
|
|
308
|
+
reuse_yaml = reuse_path.read_text(encoding="utf-8")
|
|
309
|
+
reuse_scripts = _load_companion_scripts(reuse_path, cfg)
|
|
310
|
+
console.print(f" [cyan]reuse-flow[/cyan] {reuse_path}")
|
|
311
|
+
else:
|
|
312
|
+
console.print(
|
|
313
|
+
f" [yellow]reuse-flow requested but no YAML at "
|
|
314
|
+
f"{cfg.flow_dir_path() / (case.name + '.yaml')} — generating[/yellow]"
|
|
315
|
+
)
|
|
316
|
+
elif want_incr or want_extend:
|
|
317
|
+
plan = _resolve_incremental_plan(
|
|
318
|
+
case,
|
|
319
|
+
cfg,
|
|
320
|
+
incremental=bool(want_incr),
|
|
321
|
+
extend_explore=bool(want_extend),
|
|
322
|
+
)
|
|
323
|
+
incr_mode = str(plan["mode"])
|
|
324
|
+
reuse_yaml = plan.get("reuse_yaml")
|
|
325
|
+
reuse_scripts = dict(plan.get("reuse_scripts") or {})
|
|
326
|
+
prior_yaml = plan.get("prior_yaml")
|
|
327
|
+
prior_scripts = dict(plan.get("prior_scripts") or {})
|
|
328
|
+
extend = bool(plan.get("extend"))
|
|
329
|
+
# gen-only: skip device prefix replay
|
|
330
|
+
replay_prefix = bool(plan.get("replay_prefix")) and not gen_only
|
|
331
|
+
explore_goal = plan.get("explore_goal")
|
|
332
|
+
codegen_goal = plan.get("codegen_goal")
|
|
333
|
+
|
|
334
|
+
# Merge order: config env < data file < case env (case wins)
|
|
335
|
+
flow_env = merge_flow_env(cfg.run.env, data_flat, case.env)
|
|
336
|
+
if flow_env:
|
|
337
|
+
console.print(f" env keys: {', '.join(sorted(flow_env))}")
|
|
338
|
+
|
|
339
|
+
import asyncio
|
|
340
|
+
|
|
341
|
+
run_timeout = (
|
|
342
|
+
opts.timeout_s
|
|
343
|
+
if opts.timeout_s is not None
|
|
344
|
+
else max(cfg.run.timeout_s, cfg.device.boot_timeout_s)
|
|
345
|
+
)
|
|
346
|
+
if cfg.device.is_cloud():
|
|
347
|
+
run_timeout = max(run_timeout, int(cfg.device.cloud_timeout_s or 1800))
|
|
348
|
+
|
|
349
|
+
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
|
350
|
+
started_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
351
|
+
run_artifact_dir: Path | None = None
|
|
352
|
+
if cfg.run.save_artifacts and not gen_only:
|
|
353
|
+
run_artifact_dir = cfg.artifacts_dir() / "runs" / f"{case.name}-{stamp}"
|
|
354
|
+
run_artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
355
|
+
|
|
356
|
+
t0 = time.monotonic()
|
|
357
|
+
result = asyncio.run(
|
|
358
|
+
run_mobile_task(
|
|
359
|
+
task_text,
|
|
360
|
+
codegen_profile=codegen,
|
|
361
|
+
discovery_profile=discovery,
|
|
362
|
+
app_id=app_id,
|
|
363
|
+
platform=platform,
|
|
364
|
+
device_id=selected_device,
|
|
365
|
+
heal=opts.heal,
|
|
366
|
+
adaptive=opts.adaptive and not gen_only,
|
|
367
|
+
explore=opts.explore and not gen_only and reuse_yaml is None,
|
|
368
|
+
explore_steps=opts.explore_steps,
|
|
369
|
+
timeout_s=run_timeout,
|
|
370
|
+
live=not gen_only,
|
|
371
|
+
allow_js=allow_js,
|
|
372
|
+
auto_start_device=cfg.device.auto_start and not gen_only and not cfg.device.is_cloud(),
|
|
373
|
+
progress=progress,
|
|
374
|
+
device_config=cfg.device,
|
|
375
|
+
artifact_dir=run_artifact_dir,
|
|
376
|
+
clear_state=bool(case.clear_state),
|
|
377
|
+
preflight=list(cfg.run.preflight or []),
|
|
378
|
+
app_path=cfg.device.app_path or "",
|
|
379
|
+
retries=0 if gen_only else opts.retries,
|
|
380
|
+
reuse_flow_yaml=reuse_yaml,
|
|
381
|
+
reuse_scripts=reuse_scripts or None,
|
|
382
|
+
flow_env=flow_env or None,
|
|
383
|
+
expect=list(case.expect or []),
|
|
384
|
+
prior_flow_yaml=prior_yaml,
|
|
385
|
+
prior_scripts=prior_scripts or None,
|
|
386
|
+
extend=extend,
|
|
387
|
+
replay_prefix=replay_prefix,
|
|
388
|
+
explore_goal=explore_goal,
|
|
389
|
+
codegen_goal=codegen_goal,
|
|
390
|
+
record_video=bool(cfg.run.video) and not cfg.device.is_cloud(),
|
|
391
|
+
include_tags=list(cfg.run.include_tags or []),
|
|
392
|
+
exclude_tags=list(cfg.run.exclude_tags or []),
|
|
393
|
+
maestro_config=(cfg.run.maestro_config or "").strip() or None,
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
duration_s = time.monotonic() - t0
|
|
397
|
+
|
|
398
|
+
flow_yaml = result.get("flow_yaml") or ""
|
|
399
|
+
scripts = result.get("scripts") or {}
|
|
400
|
+
out_flow = flow_dir / f"{case.name}.yaml"
|
|
401
|
+
if flow_yaml:
|
|
402
|
+
out_flow.write_text(flow_yaml, encoding="utf-8")
|
|
403
|
+
console.print(f"[green]Wrote flow[/green] → {out_flow}")
|
|
404
|
+
for sp in _write_scripts(flow_dir, scripts):
|
|
405
|
+
console.print(f"[green]Wrote script[/green] → {sp}")
|
|
406
|
+
|
|
407
|
+
# Stamp guidance after a successful generation / reuse so tomorrow's
|
|
408
|
+
# --incremental can classify append vs dirty.
|
|
409
|
+
if result.get("success") and case.guidance_steps():
|
|
410
|
+
try:
|
|
411
|
+
gpath = save_guidance(
|
|
412
|
+
cfg.repo_path(),
|
|
413
|
+
case.name,
|
|
414
|
+
case.guidance_steps(),
|
|
415
|
+
flow_path=str(out_flow) if flow_yaml else "",
|
|
416
|
+
mode=incr_mode,
|
|
417
|
+
)
|
|
418
|
+
console.print(f"[dim]Guidance stamp → {gpath}[/dim]")
|
|
419
|
+
except OSError as exc:
|
|
420
|
+
logger.warning("Could not save guidance stamp: %s", exc)
|
|
421
|
+
|
|
422
|
+
run_meta = result.get("run") or {}
|
|
423
|
+
reports_written: dict[str, str] = {}
|
|
424
|
+
screenshot_rels: list[str] = []
|
|
425
|
+
|
|
426
|
+
if cfg.run.save_artifacts:
|
|
427
|
+
art = cfg.artifacts_dir()
|
|
428
|
+
art.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
(art / "runs").mkdir(exist_ok=True)
|
|
430
|
+
|
|
431
|
+
# Prefer the durable per-run dir created above
|
|
432
|
+
if run_artifact_dir is None:
|
|
433
|
+
run_artifact_dir = art / "runs" / f"{case.name}-{stamp}"
|
|
434
|
+
run_artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
|
|
436
|
+
# Collect screenshots from Maestro debug/output dirs (all attempts)
|
|
437
|
+
shot_paths: list[Path] = []
|
|
438
|
+
for candidate in (
|
|
439
|
+
run_artifact_dir,
|
|
440
|
+
Path(run_meta.get("maestro_debug_dir") or ""),
|
|
441
|
+
Path(run_meta.get("maestro_output_dir") or ""),
|
|
442
|
+
Path(run_meta.get("artifact_dir") or ""),
|
|
443
|
+
):
|
|
444
|
+
if candidate and str(candidate) not in {"", "."}:
|
|
445
|
+
shot_paths.extend(collect_screenshots(candidate))
|
|
446
|
+
# Dedupe while preserving order
|
|
447
|
+
seen: set[str] = set()
|
|
448
|
+
unique_shots: list[Path] = []
|
|
449
|
+
for p in shot_paths:
|
|
450
|
+
key = str(p.resolve())
|
|
451
|
+
if key in seen:
|
|
452
|
+
continue
|
|
453
|
+
seen.add(key)
|
|
454
|
+
unique_shots.append(p)
|
|
455
|
+
|
|
456
|
+
shots_dir = run_artifact_dir / "screenshots"
|
|
457
|
+
if unique_shots:
|
|
458
|
+
shots_dir.mkdir(parents=True, exist_ok=True)
|
|
459
|
+
for i, src in enumerate(unique_shots[:40], start=1):
|
|
460
|
+
dest = shots_dir / f"{i:02d}-{src.name}"
|
|
461
|
+
try:
|
|
462
|
+
if not dest.exists():
|
|
463
|
+
dest.write_bytes(src.read_bytes())
|
|
464
|
+
screenshot_rels.append(str(Path("screenshots") / dest.name))
|
|
465
|
+
except OSError:
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
# Reports live under report_dir/<case>-<stamp>/ and also copied into run dir
|
|
469
|
+
case_report_dir = cfg.report_dir_path() / f"{case.name}-{stamp}"
|
|
470
|
+
report_case = ReportCase(
|
|
471
|
+
name=case.name,
|
|
472
|
+
success=bool(result.get("success")),
|
|
473
|
+
summary=str(result.get("summary") or ""),
|
|
474
|
+
error=str(result.get("error") or ""),
|
|
475
|
+
task=task_text,
|
|
476
|
+
platform=str(result.get("platform") or platform or ""),
|
|
477
|
+
provider=str(result.get("provider") or provider),
|
|
478
|
+
device_id=str(result.get("device_id") or selected_device or ""),
|
|
479
|
+
duration_s=duration_s,
|
|
480
|
+
flow_path=str(out_flow) if flow_yaml else "",
|
|
481
|
+
dashboard_url=str(run_meta.get("dashboard_url") or ""),
|
|
482
|
+
build_id=str(run_meta.get("build_id") or ""),
|
|
483
|
+
stdout=redact_text(str(run_meta.get("stdout") or ""), flow_env),
|
|
484
|
+
stderr=redact_text(str(run_meta.get("stderr") or ""), flow_env),
|
|
485
|
+
logs=[redact_text(str(x), flow_env) for x in (result.get("logs") or [])],
|
|
486
|
+
synthesis_only=bool(result.get("synthesis_only")),
|
|
487
|
+
screenshot_paths=screenshot_rels,
|
|
488
|
+
artifact_dir=str(run_artifact_dir),
|
|
489
|
+
started_at=started_at,
|
|
490
|
+
video_url=str(run_meta.get("video_url") or ""),
|
|
491
|
+
explore_usage=dict(result.get("explore_usage") or {}),
|
|
492
|
+
codegen_usage=dict(result.get("codegen_usage") or {}),
|
|
493
|
+
)
|
|
494
|
+
maestro_junit = None
|
|
495
|
+
if run_meta.get("maestro_junit"):
|
|
496
|
+
maestro_junit = Path(str(run_meta["maestro_junit"]))
|
|
497
|
+
# Prefer last attempt junit inside run dir
|
|
498
|
+
if maestro_junit is None:
|
|
499
|
+
found = list(run_artifact_dir.rglob("maestro-junit.xml"))
|
|
500
|
+
if found:
|
|
501
|
+
maestro_junit = found[-1]
|
|
502
|
+
|
|
503
|
+
if cfg.run.reports:
|
|
504
|
+
# Write into run dir/report so screenshot relative paths resolve in HTML
|
|
505
|
+
embedded_report_dir = run_artifact_dir / "report"
|
|
506
|
+
# Fix screenshot paths relative to report dir
|
|
507
|
+
report_case.screenshot_paths = [
|
|
508
|
+
f"../screenshots/{Path(p).name}" for p in screenshot_rels
|
|
509
|
+
]
|
|
510
|
+
reports_written = write_run_reports(
|
|
511
|
+
report_case,
|
|
512
|
+
embedded_report_dir,
|
|
513
|
+
formats=cfg.run.reports,
|
|
514
|
+
maestro_junit=maestro_junit,
|
|
515
|
+
)
|
|
516
|
+
# Also mirror under configured report_dir for CI convenience
|
|
517
|
+
mirrored = write_run_reports(
|
|
518
|
+
report_case,
|
|
519
|
+
case_report_dir,
|
|
520
|
+
formats=cfg.run.reports,
|
|
521
|
+
maestro_junit=maestro_junit,
|
|
522
|
+
)
|
|
523
|
+
# Copy screenshots next to mirrored HTML so it still renders
|
|
524
|
+
if screenshot_rels:
|
|
525
|
+
mirror_shots = case_report_dir / "screenshots"
|
|
526
|
+
mirror_shots.mkdir(parents=True, exist_ok=True)
|
|
527
|
+
src_shots = run_artifact_dir / "screenshots"
|
|
528
|
+
if src_shots.is_dir():
|
|
529
|
+
for img in src_shots.iterdir():
|
|
530
|
+
if img.is_file():
|
|
531
|
+
(mirror_shots / img.name).write_bytes(img.read_bytes())
|
|
532
|
+
reports_written = {**mirrored, **{f"run_{k}": v for k, v in reports_written.items()}}
|
|
533
|
+
if reports_written.get("html"):
|
|
534
|
+
console.print(f"[green]HTML report[/green] → {reports_written['html']}")
|
|
535
|
+
if reports_written.get("junit"):
|
|
536
|
+
console.print(f"[green]JUnit[/green] → {reports_written['junit']}")
|
|
537
|
+
|
|
538
|
+
inventory = index_artifacts(run_artifact_dir)
|
|
539
|
+
payload = {
|
|
540
|
+
"case": case.name,
|
|
541
|
+
"task": task_text,
|
|
542
|
+
"data_path": str(data_path_resolved) if data_path_resolved else "",
|
|
543
|
+
"data_keys": sorted(data_flat.keys()),
|
|
544
|
+
"success": result.get("success"),
|
|
545
|
+
"summary": result.get("summary"),
|
|
546
|
+
"flow_path": str(out_flow),
|
|
547
|
+
"scripts": list(scripts.keys()),
|
|
548
|
+
"language": cfg.stack.language,
|
|
549
|
+
"device_id": result.get("device_id"),
|
|
550
|
+
"platform": result.get("platform"),
|
|
551
|
+
"provider": result.get("provider") or provider,
|
|
552
|
+
"synthesis_only": result.get("synthesis_only"),
|
|
553
|
+
"logs": result.get("logs"),
|
|
554
|
+
"error": result.get("error"),
|
|
555
|
+
"run": result.get("run"),
|
|
556
|
+
"attempts": result.get("attempts"),
|
|
557
|
+
"exploration": result.get("exploration"),
|
|
558
|
+
"explore_usage": result.get("explore_usage") or {},
|
|
559
|
+
"codegen_usage": result.get("codegen_usage") or {},
|
|
560
|
+
"incremental_mode": incr_mode,
|
|
561
|
+
"duration_s": duration_s,
|
|
562
|
+
"started_at": started_at,
|
|
563
|
+
"artifact_dir": str(run_artifact_dir),
|
|
564
|
+
"screenshots": screenshot_rels,
|
|
565
|
+
"artifacts": inventory,
|
|
566
|
+
"reports": reports_written,
|
|
567
|
+
}
|
|
568
|
+
run_json = run_artifact_dir / "run.json"
|
|
569
|
+
run_json.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
570
|
+
# Compat: also keep flat latest pointer under .mobiflow/runs/
|
|
571
|
+
latest = art / "runs" / f"{case.name}.latest.json"
|
|
572
|
+
latest.write_text(run_json.read_text(encoding="utf-8"), encoding="utf-8")
|
|
573
|
+
stamp_json = art / "runs" / f"{case.name}-{stamp}.json"
|
|
574
|
+
stamp_json.write_text(run_json.read_text(encoding="utf-8"), encoding="utf-8")
|
|
575
|
+
if flow_yaml:
|
|
576
|
+
(art / "flows").mkdir(exist_ok=True)
|
|
577
|
+
(art / "flows" / f"{case.name}.yaml").write_text(flow_yaml, encoding="utf-8")
|
|
578
|
+
for rel, body in scripts.items():
|
|
579
|
+
sp = art / "flows" / rel
|
|
580
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
581
|
+
sp.write_text(body, encoding="utf-8")
|
|
582
|
+
console.print(f"[dim]Artifacts → {run_artifact_dir}[/dim]")
|
|
583
|
+
result["reports"] = reports_written
|
|
584
|
+
result["artifact_dir"] = str(run_artifact_dir)
|
|
585
|
+
result["screenshots"] = screenshot_rels
|
|
586
|
+
|
|
587
|
+
result["duration_s"] = duration_s
|
|
588
|
+
result["started_at"] = started_at
|
|
589
|
+
result["flow_path"] = str(out_flow) if flow_yaml else str(result.get("flow_path") or "")
|
|
590
|
+
result["case"] = case.name
|
|
591
|
+
result["incremental_mode"] = incr_mode
|
|
592
|
+
|
|
593
|
+
ok = bool(result.get("success"))
|
|
594
|
+
if ok:
|
|
595
|
+
console.print(f"[bold green]OK[/bold green] {result.get('summary')}")
|
|
596
|
+
else:
|
|
597
|
+
console.print(
|
|
598
|
+
f"[bold red]FAIL[/bold red] {result.get('summary') or result.get('error')}"
|
|
599
|
+
)
|
|
600
|
+
return result
|