@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,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)
|