@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,198 @@
1
+ """Incremental / extend-explore helpers for case growth.
2
+
3
+ Modes:
4
+ - ``unchanged`` — prior guidance matches; reuse frozen YAML
5
+ - ``append`` — new steps only after a common prefix; explore the gap, extend YAML
6
+ - ``dirty`` — earlier steps changed; full regenerate seeded with prior YAML
7
+ - ``fresh`` — no prior guidance / no prior flow
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ _NUMBERED_RE = re.compile(r"^\s*(\d+)\.\s+(.+)$")
19
+ _STOP_APP_RE = re.compile(r"(?m)^\s*-\s*stopApp\b.*(?:\n(?:[ \t]+.+)*)?")
20
+
21
+
22
+ @dataclass
23
+ class GuidanceDiff:
24
+ mode: str # unchanged | append | dirty | fresh
25
+ common_prefix: int = 0
26
+ prior_guidance: list[str] = field(default_factory=list)
27
+ current_guidance: list[str] = field(default_factory=list)
28
+
29
+ @property
30
+ def new_guidance(self) -> list[str]:
31
+ return self.current_guidance[self.common_prefix :]
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return {
35
+ "mode": self.mode,
36
+ "common_prefix": self.common_prefix,
37
+ "prior_guidance": list(self.prior_guidance),
38
+ "current_guidance": list(self.current_guidance),
39
+ "new_guidance": list(self.new_guidance),
40
+ }
41
+
42
+
43
+ def extract_numbered_steps(text: str) -> list[str]:
44
+ """Pull ``1. …`` / ``2. …`` lines from free text (task body or case file)."""
45
+ steps: list[str] = []
46
+ for line in (text or "").splitlines():
47
+ m = _NUMBERED_RE.match(line.rstrip())
48
+ if m:
49
+ steps.append(m.group(2).strip())
50
+ return steps
51
+
52
+
53
+ def normalize_guidance(steps: list[str] | None) -> list[str]:
54
+ return [str(s).strip() for s in (steps or []) if str(s).strip()]
55
+
56
+
57
+ def classify_guidance(
58
+ prior_guidance: list[str] | None,
59
+ current_guidance: list[str] | None,
60
+ ) -> GuidanceDiff:
61
+ prior = normalize_guidance(prior_guidance)
62
+ current = normalize_guidance(current_guidance)
63
+ if not prior:
64
+ return GuidanceDiff(mode="fresh", prior_guidance=prior, current_guidance=current)
65
+ if prior == current:
66
+ return GuidanceDiff(
67
+ mode="unchanged",
68
+ common_prefix=len(prior),
69
+ prior_guidance=prior,
70
+ current_guidance=current,
71
+ )
72
+ n = 0
73
+ for a, b in zip(prior, current):
74
+ if a != b:
75
+ break
76
+ n += 1
77
+ if n == len(prior) and len(current) > len(prior):
78
+ return GuidanceDiff(
79
+ mode="append",
80
+ common_prefix=n,
81
+ prior_guidance=prior,
82
+ current_guidance=current,
83
+ )
84
+ return GuidanceDiff(
85
+ mode="dirty",
86
+ common_prefix=n,
87
+ prior_guidance=prior,
88
+ current_guidance=current,
89
+ )
90
+
91
+
92
+ def format_gap_task(
93
+ *,
94
+ title: str,
95
+ new_steps: list[str],
96
+ start_index: int = 1,
97
+ app_id: str = "",
98
+ ) -> str:
99
+ """Narrow explore/codegen goal for newly appended steps only."""
100
+ if not new_steps:
101
+ return title or "Verify the current screen state."
102
+ numbered = "\n".join(f"{i}. {s}" for i, s in enumerate(new_steps, start_index))
103
+ head = (title or "Continue the scenario").strip().split("\n")[0]
104
+ app_hint = f" App under test: {app_id}." if app_id else ""
105
+ return (
106
+ f"{head}\n\n"
107
+ f"You are ALREADY past the earlier completed steps of this flow.{app_hint} "
108
+ f"Do NOT relaunch the app or redo onboarding/search/setup already done. "
109
+ f"Execute ONLY the following new steps from the current screen:\n{numbered}"
110
+ )
111
+
112
+
113
+ def guidance_path(repo: Path, case_name: str) -> Path:
114
+ return Path(repo).resolve() / ".mobiflow" / "guidance" / f"{case_name}.json"
115
+
116
+
117
+ def load_guidance(repo: Path, case_name: str) -> list[str]:
118
+ path = guidance_path(repo, case_name)
119
+ if not path.is_file():
120
+ return []
121
+ try:
122
+ data = json.loads(path.read_text(encoding="utf-8"))
123
+ except (OSError, json.JSONDecodeError):
124
+ return []
125
+ raw = data.get("guidance_steps") if isinstance(data, dict) else None
126
+ if isinstance(raw, list):
127
+ return normalize_guidance([str(x) for x in raw])
128
+ return []
129
+
130
+
131
+ def save_guidance(
132
+ repo: Path,
133
+ case_name: str,
134
+ guidance_steps: list[str],
135
+ *,
136
+ flow_path: str = "",
137
+ mode: str = "",
138
+ ) -> Path:
139
+ path = guidance_path(repo, case_name)
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+ payload = {
142
+ "case": case_name,
143
+ "guidance_steps": normalize_guidance(guidance_steps),
144
+ "flow_path": flow_path,
145
+ "mode": mode,
146
+ }
147
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
148
+ return path
149
+
150
+
151
+ def strip_trailing_stop_app(flow_yaml: str) -> str:
152
+ """Remove final stopApp so a prefix flow can leave the app open for gap explore."""
153
+ text = (flow_yaml or "").rstrip() + "\n"
154
+ # Remove all stopApp commands for replay-as-prefix; caller may re-add later
155
+ cleaned = _STOP_APP_RE.sub("", text)
156
+ # Collapse excess blank lines
157
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).rstrip() + "\n"
158
+ return cleaned
159
+
160
+
161
+ def merge_flow_yaml(prior_yaml: str, extension_yaml: str, *, app_id: str = "") -> str:
162
+ """Merge prior flow with an extension (full rewrite or delta commands).
163
+
164
+ If ``extension_yaml`` looks like a complete flow (has appId / ---), prefer it
165
+ when it already contains prior commands; otherwise append extension body
166
+ commands after the prior prefix (minus stopApp).
167
+ """
168
+ from mobiflow.maestro import ensure_flow_yaml, ensure_stop_app, looks_like_maestro_yaml
169
+
170
+ prior = (prior_yaml or "").strip()
171
+ ext = (extension_yaml or "").strip()
172
+ if not prior:
173
+ return ensure_stop_app(ensure_flow_yaml(ext, app_id))
174
+ if not ext:
175
+ return ensure_stop_app(prior)
176
+
177
+ # If extension is a full flow and substantially longer / includes launchApp,
178
+ # trust the LLM rewrite (extend prompt asks for complete YAML).
179
+ if looks_like_maestro_yaml(ext) and (
180
+ "launchApp" in ext or len(ext) >= max(80, int(len(prior) * 0.6))
181
+ ):
182
+ return ensure_stop_app(ensure_flow_yaml(ext, app_id))
183
+
184
+ # Treat extension as command delta
185
+ body = ext
186
+ if "---" in body:
187
+ body = body.split("---", 1)[-1]
188
+ body = body.strip()
189
+ # Drop leading launchApp from delta (already in prior)
190
+ body_lines = [
191
+ ln
192
+ for ln in body.splitlines()
193
+ if not re.match(r"^\s*-\s*launchApp\b", ln)
194
+ ]
195
+ delta = "\n".join(body_lines).strip()
196
+ prefix = strip_trailing_stop_app(prior).rstrip()
197
+ merged = prefix + "\n" + delta + "\n" if delta else prefix + "\n"
198
+ return ensure_stop_app(merged)