@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,715 @@
|
|
|
1
|
+
"""Plain-text mobile test cases.
|
|
2
|
+
|
|
3
|
+
Canonical template (see ``EXAMPLE_CASE``)::
|
|
4
|
+
|
|
5
|
+
@smoke
|
|
6
|
+
appId: org.wikipedia
|
|
7
|
+
platform: android
|
|
8
|
+
# Run knobs (optional — CLI overrides these; these override config)
|
|
9
|
+
codegen: true
|
|
10
|
+
retries: 0
|
|
11
|
+
heal: 2
|
|
12
|
+
task: |
|
|
13
|
+
1. …
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
EXAMPLE_CASE = """\
|
|
24
|
+
# MobiFlow case template — copy to cases/<name>.txt and edit.
|
|
25
|
+
# Precedence for run knobs: CLI flags > this file > mobiflow.config.yaml
|
|
26
|
+
#
|
|
27
|
+
# Required: task (or numbered steps). Recommended: appId, platform.
|
|
28
|
+
# Numbered steps enable --incremental / incremental: true gap-explore.
|
|
29
|
+
|
|
30
|
+
@smoke
|
|
31
|
+
appId: org.wikipedia
|
|
32
|
+
platform: android
|
|
33
|
+
# device: emulator-5554
|
|
34
|
+
# flow: flows/example.yaml
|
|
35
|
+
# clearState: false
|
|
36
|
+
|
|
37
|
+
# --- Run options (all optional) ---
|
|
38
|
+
codegen: true # false → reuse frozen flows/<case>.yaml (no LLM)
|
|
39
|
+
# reuseFlow: false # alias of codegen: false when true
|
|
40
|
+
# incremental: false # gap-explore only newly appended numbered steps
|
|
41
|
+
# extendExplore: false # full explore + extend codegen from prior YAML
|
|
42
|
+
retries: 0 # re-run same YAML before heal
|
|
43
|
+
heal: 2 # YAML repair attempts (0 = off)
|
|
44
|
+
explore: true # discovery LLM before codegen
|
|
45
|
+
# exploreSteps: 5
|
|
46
|
+
# genOnly: false # author YAML only (skip device)
|
|
47
|
+
# adaptive: true
|
|
48
|
+
# timeout: 180
|
|
49
|
+
|
|
50
|
+
# env:
|
|
51
|
+
# USERNAME = MY_USER
|
|
52
|
+
data: data/example.json # relative to case dir or repo; also absolute OK
|
|
53
|
+
# expect:
|
|
54
|
+
# - Search
|
|
55
|
+
|
|
56
|
+
task: |
|
|
57
|
+
Open the Wikipedia app, dismiss any onboarding, and confirm Search is visible.
|
|
58
|
+
Use test data via Maestro ${SEARCH_QUERY} / ${ARTICLE_HINT} / ${USER_NAME}.
|
|
59
|
+
|
|
60
|
+
1. Launch the app
|
|
61
|
+
2. Dismiss onboarding if shown
|
|
62
|
+
3. Confirm Search is visible
|
|
63
|
+
4. Tap Search and type ${SEARCH_QUERY}
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
# Canonical meta keys → attribute / CaseRunOptions field
|
|
67
|
+
_META_ALIASES: dict[str, str] = {
|
|
68
|
+
"appid": "app_id",
|
|
69
|
+
"platform": "platform",
|
|
70
|
+
"device": "device_id",
|
|
71
|
+
"deviceid": "device_id",
|
|
72
|
+
"task": "task",
|
|
73
|
+
"goal": "task",
|
|
74
|
+
"flow": "flow",
|
|
75
|
+
"clearstate": "clear_state",
|
|
76
|
+
"env": "env",
|
|
77
|
+
"expect": "expect",
|
|
78
|
+
"data": "data_path",
|
|
79
|
+
"datapath": "data_path",
|
|
80
|
+
"datafile": "data_path",
|
|
81
|
+
"testdata": "data_path",
|
|
82
|
+
# Run knobs
|
|
83
|
+
"codegen": "codegen",
|
|
84
|
+
"reuse": "reuse_flow",
|
|
85
|
+
"reuseflow": "reuse_flow",
|
|
86
|
+
"incremental": "incremental",
|
|
87
|
+
"extendexplore": "extend_explore",
|
|
88
|
+
"retries": "retries",
|
|
89
|
+
"retry": "retries",
|
|
90
|
+
"heal": "heal",
|
|
91
|
+
"noheal": "no_heal",
|
|
92
|
+
"explore": "explore",
|
|
93
|
+
"exploresteps": "explore_steps",
|
|
94
|
+
"genonly": "gen_only",
|
|
95
|
+
"adaptive": "adaptive",
|
|
96
|
+
"timeout": "timeout_s",
|
|
97
|
+
"timeouts": "timeout_s",
|
|
98
|
+
"strict": "strict",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_KNOWN_META_DISPLAY = sorted(
|
|
102
|
+
{
|
|
103
|
+
"appId",
|
|
104
|
+
"platform",
|
|
105
|
+
"device",
|
|
106
|
+
"task",
|
|
107
|
+
"goal",
|
|
108
|
+
"flow",
|
|
109
|
+
"clearState",
|
|
110
|
+
"env",
|
|
111
|
+
"expect",
|
|
112
|
+
"data",
|
|
113
|
+
"codegen",
|
|
114
|
+
"reuseFlow",
|
|
115
|
+
"incremental",
|
|
116
|
+
"extendExplore",
|
|
117
|
+
"retries",
|
|
118
|
+
"heal",
|
|
119
|
+
"noHeal",
|
|
120
|
+
"explore",
|
|
121
|
+
"exploreSteps",
|
|
122
|
+
"genOnly",
|
|
123
|
+
"adaptive",
|
|
124
|
+
"timeout",
|
|
125
|
+
"strict",
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class CaseRunOptions:
|
|
132
|
+
"""Optional per-case run overrides (None = inherit from CLI/config)."""
|
|
133
|
+
|
|
134
|
+
codegen: bool | None = None
|
|
135
|
+
reuse_flow: bool | None = None
|
|
136
|
+
incremental: bool | None = None
|
|
137
|
+
extend_explore: bool | None = None
|
|
138
|
+
retries: int | None = None
|
|
139
|
+
heal: int | None = None
|
|
140
|
+
no_heal: bool | None = None
|
|
141
|
+
explore: bool | None = None
|
|
142
|
+
explore_steps: int | None = None
|
|
143
|
+
gen_only: bool | None = None
|
|
144
|
+
adaptive: bool | None = None
|
|
145
|
+
timeout_s: int | None = None
|
|
146
|
+
strict: bool = False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class ResolvedRunOptions:
|
|
151
|
+
"""Effective run settings after CLI > case > config merge."""
|
|
152
|
+
|
|
153
|
+
gen_only: bool
|
|
154
|
+
reuse_flow: bool
|
|
155
|
+
incremental: bool
|
|
156
|
+
extend_explore: bool
|
|
157
|
+
heal: int
|
|
158
|
+
retries: int
|
|
159
|
+
explore: bool
|
|
160
|
+
explore_steps: int
|
|
161
|
+
adaptive: bool
|
|
162
|
+
timeout_s: int | None
|
|
163
|
+
sources: dict[str, str] = field(default_factory=dict)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class TestCase:
|
|
168
|
+
name: str
|
|
169
|
+
task: str
|
|
170
|
+
app_id: str = ""
|
|
171
|
+
platform: str = "android"
|
|
172
|
+
device_id: str | None = None
|
|
173
|
+
tags: list[str] = field(default_factory=list)
|
|
174
|
+
steps: list[str] = field(default_factory=list)
|
|
175
|
+
flow: str = "" # optional frozen Maestro YAML path
|
|
176
|
+
clear_state: bool = False
|
|
177
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
178
|
+
expect: list[str] = field(default_factory=list) # forced assertVisible texts
|
|
179
|
+
data_path: str = "" # relative or absolute path to JSON/YAML/.env
|
|
180
|
+
run: CaseRunOptions = field(default_factory=CaseRunOptions)
|
|
181
|
+
parse_warnings: list[str] = field(default_factory=list)
|
|
182
|
+
source_path: Path | None = None
|
|
183
|
+
|
|
184
|
+
def explore_task(self, *, data_block: str = "") -> str:
|
|
185
|
+
parts: list[str] = []
|
|
186
|
+
if self.steps:
|
|
187
|
+
numbered = "\n".join(f"{i}. {s}" for i, s in enumerate(self.steps, 1))
|
|
188
|
+
parts.append(
|
|
189
|
+
f"{self.task.strip()}\n\nGuided steps:\n{numbered}".strip()
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
parts.append(self.task.strip())
|
|
193
|
+
if data_block.strip():
|
|
194
|
+
parts.append(data_block.strip())
|
|
195
|
+
return "\n\n".join(parts)
|
|
196
|
+
|
|
197
|
+
def guidance_steps(self) -> list[str]:
|
|
198
|
+
"""Numbered guidance used for incremental classify (steps field or task body)."""
|
|
199
|
+
from mobiflow.incremental import extract_numbered_steps, normalize_guidance
|
|
200
|
+
|
|
201
|
+
if self.steps:
|
|
202
|
+
return normalize_guidance(self.steps)
|
|
203
|
+
return extract_numbered_steps(self.task)
|
|
204
|
+
|
|
205
|
+
def has_tag(self, tag: str) -> bool:
|
|
206
|
+
want = tag.strip().lstrip("@").lower()
|
|
207
|
+
return any(t.lower() == want for t in self.tags)
|
|
208
|
+
|
|
209
|
+
def load_data(
|
|
210
|
+
self,
|
|
211
|
+
*,
|
|
212
|
+
repo: Path | str | None = None,
|
|
213
|
+
) -> tuple[Path | None, dict[str, Any], dict[str, str]]:
|
|
214
|
+
"""Resolve ``data:`` path and load flattened env map.
|
|
215
|
+
|
|
216
|
+
Returns ``(resolved_path, raw_dict, flat_env)``. Empty when no data set.
|
|
217
|
+
"""
|
|
218
|
+
from mobiflow.casedata import load_data_file, resolve_data_path
|
|
219
|
+
|
|
220
|
+
if not (self.data_path or "").strip():
|
|
221
|
+
return None, {}, {}
|
|
222
|
+
path = resolve_data_path(
|
|
223
|
+
self.data_path,
|
|
224
|
+
case_path=self.source_path,
|
|
225
|
+
repo=Path(repo).resolve() if repo else None,
|
|
226
|
+
)
|
|
227
|
+
raw, flat = load_data_file(path)
|
|
228
|
+
flat = dict(flat)
|
|
229
|
+
flat.setdefault("DATA_PATH", str(path))
|
|
230
|
+
return path, raw, flat
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_META_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$")
|
|
235
|
+
_TAG_RE = re.compile(r"^@(\w+)\s*$")
|
|
236
|
+
_STEP_RE = re.compile(r"^\d+\.\s+(.+)$")
|
|
237
|
+
_ENV_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")
|
|
238
|
+
_LOOSE_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _parse_bool(val: str) -> bool:
|
|
242
|
+
return val.strip().lower() in {"1", "true", "yes", "on", "y"}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _strip_inline_comment(val: str) -> str:
|
|
246
|
+
"""Drop trailing ``# comment`` from meta values (not inside quotes)."""
|
|
247
|
+
s = val.strip()
|
|
248
|
+
if not s or s.startswith("#"):
|
|
249
|
+
return s
|
|
250
|
+
in_single = False
|
|
251
|
+
in_double = False
|
|
252
|
+
for i, ch in enumerate(s):
|
|
253
|
+
if ch == "'" and not in_double:
|
|
254
|
+
in_single = not in_single
|
|
255
|
+
elif ch == '"' and not in_single:
|
|
256
|
+
in_double = not in_double
|
|
257
|
+
elif ch == "#" and not in_single and not in_double:
|
|
258
|
+
if i == 0 or s[i - 1].isspace():
|
|
259
|
+
return s[:i].rstrip()
|
|
260
|
+
return s
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parse_optional_bool(val: str) -> bool | None:
|
|
265
|
+
s = val.strip().lower()
|
|
266
|
+
if s in {"", "-", "null", "none", "default", "inherit"}:
|
|
267
|
+
return None
|
|
268
|
+
if s in {"1", "true", "yes", "on", "y"}:
|
|
269
|
+
return True
|
|
270
|
+
if s in {"0", "false", "no", "off", "n"}:
|
|
271
|
+
return False
|
|
272
|
+
raise ValueError(f"Expected boolean, got {val!r}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _parse_optional_int(val: str, *, lo: int, hi: int) -> int | None:
|
|
276
|
+
s = val.strip().lower()
|
|
277
|
+
if s in {"", "-", "null", "none", "default", "inherit"}:
|
|
278
|
+
return None
|
|
279
|
+
n = int(s)
|
|
280
|
+
return max(lo, min(hi, n))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _normalize_meta_key(raw: str) -> str | None:
|
|
284
|
+
key = raw.strip().lower().replace("-", "").replace("_", "")
|
|
285
|
+
return _META_ALIASES.get(key)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _pick(
|
|
289
|
+
cli: Any,
|
|
290
|
+
case_val: Any,
|
|
291
|
+
config_val: Any,
|
|
292
|
+
*,
|
|
293
|
+
name: str,
|
|
294
|
+
sources: dict[str, str],
|
|
295
|
+
) -> Any:
|
|
296
|
+
if cli is not None:
|
|
297
|
+
sources[name] = "cli"
|
|
298
|
+
return cli
|
|
299
|
+
if case_val is not None:
|
|
300
|
+
sources[name] = "case"
|
|
301
|
+
return case_val
|
|
302
|
+
sources[name] = "config"
|
|
303
|
+
return config_val
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def resolve_run_options(
|
|
307
|
+
case: TestCase,
|
|
308
|
+
cfg: Any,
|
|
309
|
+
*,
|
|
310
|
+
gen_only: bool = False,
|
|
311
|
+
no_heal: bool = False,
|
|
312
|
+
reuse_flow: bool | None = None,
|
|
313
|
+
incremental: bool | None = None,
|
|
314
|
+
extend_explore: bool | None = None,
|
|
315
|
+
) -> ResolvedRunOptions:
|
|
316
|
+
"""Merge CLI > case > ``cfg.run`` into effective options.
|
|
317
|
+
|
|
318
|
+
``codegen: false`` on the case implies ``reuse_flow`` unless the case also
|
|
319
|
+
sets ``reuseFlow`` / incremental / extendExplore explicitly.
|
|
320
|
+
"""
|
|
321
|
+
run = case.run
|
|
322
|
+
sources: dict[str, str] = {}
|
|
323
|
+
|
|
324
|
+
# Derive case-level reuse from codegen when reuseFlow omitted
|
|
325
|
+
case_reuse = run.reuse_flow
|
|
326
|
+
if case_reuse is None and run.codegen is not None:
|
|
327
|
+
case_reuse = not run.codegen
|
|
328
|
+
sources["reuse_flow"] = "case(codegen)"
|
|
329
|
+
|
|
330
|
+
case_heal = run.heal
|
|
331
|
+
if case_heal is None and run.no_heal is True:
|
|
332
|
+
case_heal = 0
|
|
333
|
+
|
|
334
|
+
# CLI gen_only / no_heal are flags (False means unset)
|
|
335
|
+
cli_gen = True if gen_only else None
|
|
336
|
+
effective_gen = bool(
|
|
337
|
+
_pick(cli_gen, run.gen_only, False, name="gen_only", sources=sources)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
effective_reuse = bool(
|
|
341
|
+
_pick(
|
|
342
|
+
reuse_flow,
|
|
343
|
+
case_reuse,
|
|
344
|
+
bool(getattr(cfg.run, "reuse_flow", False)),
|
|
345
|
+
name="reuse_flow",
|
|
346
|
+
sources=sources,
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
effective_incr = bool(
|
|
350
|
+
_pick(
|
|
351
|
+
incremental,
|
|
352
|
+
run.incremental,
|
|
353
|
+
bool(getattr(cfg.run, "incremental", False)),
|
|
354
|
+
name="incremental",
|
|
355
|
+
sources=sources,
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
effective_extend = bool(
|
|
359
|
+
_pick(
|
|
360
|
+
extend_explore,
|
|
361
|
+
run.extend_explore,
|
|
362
|
+
bool(getattr(cfg.run, "extend_explore", False)),
|
|
363
|
+
name="extend_explore",
|
|
364
|
+
sources=sources,
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# codegen:true with reuse still unset → force off reuse when only codegen said true
|
|
369
|
+
if (
|
|
370
|
+
reuse_flow is None
|
|
371
|
+
and run.codegen is True
|
|
372
|
+
and run.reuse_flow is None
|
|
373
|
+
and sources.get("reuse_flow") == "case(codegen)"
|
|
374
|
+
):
|
|
375
|
+
effective_reuse = False
|
|
376
|
+
|
|
377
|
+
exclusive = sum(
|
|
378
|
+
bool(x) for x in (effective_reuse, effective_incr, effective_extend)
|
|
379
|
+
)
|
|
380
|
+
if exclusive > 1:
|
|
381
|
+
raise ValueError(
|
|
382
|
+
"Use only one of reuseFlow / incremental / extendExplore "
|
|
383
|
+
"(case file or CLI). Conflict: "
|
|
384
|
+
f"reuse={effective_reuse} incremental={effective_incr} "
|
|
385
|
+
f"extendExplore={effective_extend}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
cli_heal: int | None = 0 if no_heal else None
|
|
389
|
+
effective_heal = int(
|
|
390
|
+
_pick(
|
|
391
|
+
cli_heal,
|
|
392
|
+
case_heal,
|
|
393
|
+
int(getattr(cfg.run, "heal", 2)),
|
|
394
|
+
name="heal",
|
|
395
|
+
sources=sources,
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
if effective_gen:
|
|
399
|
+
effective_heal = 0
|
|
400
|
+
|
|
401
|
+
effective_retries = int(
|
|
402
|
+
_pick(
|
|
403
|
+
None, # no CLI flag today for retries on single run (suite uses config)
|
|
404
|
+
run.retries,
|
|
405
|
+
int(getattr(cfg.run, "retries", 0)),
|
|
406
|
+
name="retries",
|
|
407
|
+
sources=sources,
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
effective_explore = bool(
|
|
411
|
+
_pick(
|
|
412
|
+
None,
|
|
413
|
+
run.explore,
|
|
414
|
+
bool(getattr(cfg.run, "explore", True)),
|
|
415
|
+
name="explore",
|
|
416
|
+
sources=sources,
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
effective_explore_steps = int(
|
|
420
|
+
_pick(
|
|
421
|
+
None,
|
|
422
|
+
run.explore_steps,
|
|
423
|
+
int(getattr(cfg.run, "explore_steps", 5)),
|
|
424
|
+
name="explore_steps",
|
|
425
|
+
sources=sources,
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
effective_adaptive = bool(
|
|
429
|
+
_pick(
|
|
430
|
+
None,
|
|
431
|
+
run.adaptive,
|
|
432
|
+
bool(getattr(cfg.run, "adaptive", True)),
|
|
433
|
+
name="adaptive",
|
|
434
|
+
sources=sources,
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
timeout_s = _pick(
|
|
438
|
+
None,
|
|
439
|
+
run.timeout_s,
|
|
440
|
+
None,
|
|
441
|
+
name="timeout_s",
|
|
442
|
+
sources=sources,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return ResolvedRunOptions(
|
|
446
|
+
gen_only=effective_gen,
|
|
447
|
+
reuse_flow=effective_reuse,
|
|
448
|
+
incremental=effective_incr,
|
|
449
|
+
extend_explore=effective_extend,
|
|
450
|
+
heal=max(0, int(effective_heal)),
|
|
451
|
+
retries=max(0, min(int(effective_retries), 10)),
|
|
452
|
+
explore=effective_explore,
|
|
453
|
+
explore_steps=max(1, min(int(effective_explore_steps), 12)),
|
|
454
|
+
adaptive=effective_adaptive,
|
|
455
|
+
timeout_s=int(timeout_s) if timeout_s is not None else None,
|
|
456
|
+
sources=sources,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def parse_case_text(text: str, *, name: str = "case") -> TestCase:
|
|
461
|
+
app_id = ""
|
|
462
|
+
platform = "android"
|
|
463
|
+
device_id = None
|
|
464
|
+
task_parts: list[str] = []
|
|
465
|
+
tags: list[str] = []
|
|
466
|
+
steps: list[str] = []
|
|
467
|
+
flow = ""
|
|
468
|
+
clear_state = False
|
|
469
|
+
env: dict[str, str] = {}
|
|
470
|
+
expect: list[str] = []
|
|
471
|
+
data_path = ""
|
|
472
|
+
run = CaseRunOptions()
|
|
473
|
+
warnings: list[str] = []
|
|
474
|
+
in_task = False
|
|
475
|
+
in_env = False
|
|
476
|
+
in_expect = False
|
|
477
|
+
strict = False
|
|
478
|
+
|
|
479
|
+
def _apply_run(field: str, raw_val: str) -> None:
|
|
480
|
+
nonlocal strict
|
|
481
|
+
try:
|
|
482
|
+
if field == "codegen":
|
|
483
|
+
run.codegen = _parse_optional_bool(raw_val)
|
|
484
|
+
elif field == "reuse_flow":
|
|
485
|
+
run.reuse_flow = _parse_optional_bool(raw_val)
|
|
486
|
+
elif field == "incremental":
|
|
487
|
+
run.incremental = _parse_optional_bool(raw_val)
|
|
488
|
+
elif field == "extend_explore":
|
|
489
|
+
run.extend_explore = _parse_optional_bool(raw_val)
|
|
490
|
+
elif field == "retries":
|
|
491
|
+
run.retries = _parse_optional_int(raw_val, lo=0, hi=10)
|
|
492
|
+
elif field == "heal":
|
|
493
|
+
run.heal = _parse_optional_int(raw_val, lo=0, hi=20)
|
|
494
|
+
elif field == "no_heal":
|
|
495
|
+
run.no_heal = _parse_optional_bool(raw_val)
|
|
496
|
+
elif field == "explore":
|
|
497
|
+
run.explore = _parse_optional_bool(raw_val)
|
|
498
|
+
elif field == "explore_steps":
|
|
499
|
+
run.explore_steps = _parse_optional_int(raw_val, lo=1, hi=12)
|
|
500
|
+
elif field == "gen_only":
|
|
501
|
+
run.gen_only = _parse_optional_bool(raw_val)
|
|
502
|
+
elif field == "adaptive":
|
|
503
|
+
run.adaptive = _parse_optional_bool(raw_val)
|
|
504
|
+
elif field == "timeout_s":
|
|
505
|
+
run.timeout_s = _parse_optional_int(raw_val, lo=30, hi=7200)
|
|
506
|
+
elif field == "strict":
|
|
507
|
+
strict = bool(_parse_optional_bool(raw_val))
|
|
508
|
+
run.strict = strict
|
|
509
|
+
except ValueError as exc:
|
|
510
|
+
raise ValueError(f"Invalid {field}: {exc}") from exc
|
|
511
|
+
|
|
512
|
+
for raw in text.splitlines():
|
|
513
|
+
line = raw.rstrip()
|
|
514
|
+
stripped = line.strip()
|
|
515
|
+
if not stripped or stripped.startswith("#"):
|
|
516
|
+
continue
|
|
517
|
+
tag_m = _TAG_RE.match(stripped)
|
|
518
|
+
if tag_m:
|
|
519
|
+
tags.append(tag_m.group(1))
|
|
520
|
+
in_env = False
|
|
521
|
+
in_expect = False
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
meta_m = _META_RE.match(stripped)
|
|
525
|
+
if meta_m:
|
|
526
|
+
raw_key = meta_m.group(1)
|
|
527
|
+
val = _strip_inline_comment(meta_m.group(2).strip())
|
|
528
|
+
field = _normalize_meta_key(raw_key)
|
|
529
|
+
if field is None:
|
|
530
|
+
msg = (
|
|
531
|
+
f"Unknown case key '{raw_key}'. "
|
|
532
|
+
f"Known keys: {', '.join(_KNOWN_META_DISPLAY)}"
|
|
533
|
+
)
|
|
534
|
+
if strict or run.strict:
|
|
535
|
+
raise ValueError(msg)
|
|
536
|
+
warnings.append(msg)
|
|
537
|
+
# Do not fold unknown keys into the task
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
in_env = False
|
|
541
|
+
in_expect = False
|
|
542
|
+
if field == "app_id":
|
|
543
|
+
app_id = val
|
|
544
|
+
in_task = False
|
|
545
|
+
elif field == "platform":
|
|
546
|
+
platform = val.lower()
|
|
547
|
+
in_task = False
|
|
548
|
+
elif field == "device_id":
|
|
549
|
+
device_id = val
|
|
550
|
+
in_task = False
|
|
551
|
+
elif field == "task":
|
|
552
|
+
task_parts = [val]
|
|
553
|
+
in_task = True
|
|
554
|
+
sm = _STEP_RE.match(val)
|
|
555
|
+
if sm:
|
|
556
|
+
steps.append(sm.group(1).strip())
|
|
557
|
+
task_parts = [val]
|
|
558
|
+
elif field == "flow":
|
|
559
|
+
flow = val
|
|
560
|
+
in_task = False
|
|
561
|
+
elif field == "data_path":
|
|
562
|
+
data_path = val.strip().strip("\"'")
|
|
563
|
+
in_task = False
|
|
564
|
+
elif field == "clear_state":
|
|
565
|
+
clear_state = _parse_bool(val)
|
|
566
|
+
in_task = False
|
|
567
|
+
elif field == "env":
|
|
568
|
+
in_env = True
|
|
569
|
+
in_task = False
|
|
570
|
+
if val and val not in {"|", ">"}:
|
|
571
|
+
em = _ENV_LINE_RE.match(val)
|
|
572
|
+
if em:
|
|
573
|
+
env[em.group(1)] = em.group(2).strip().strip("\"'")
|
|
574
|
+
elif field == "expect":
|
|
575
|
+
in_expect = True
|
|
576
|
+
in_task = False
|
|
577
|
+
if val and val not in {"|", ">"}:
|
|
578
|
+
expect.append(val.strip().strip("\"'"))
|
|
579
|
+
else:
|
|
580
|
+
# run knobs
|
|
581
|
+
_apply_run(field, val)
|
|
582
|
+
in_task = False
|
|
583
|
+
continue
|
|
584
|
+
|
|
585
|
+
# Bare "MaybeKey: …" that didn't match? already handled.
|
|
586
|
+
# Unknown key-looking lines outside meta: warn if looks like key
|
|
587
|
+
if not in_task and not in_env and not in_expect:
|
|
588
|
+
loose = _LOOSE_KEY_RE.match(stripped)
|
|
589
|
+
if loose and _normalize_meta_key(loose.group(1)) is None:
|
|
590
|
+
msg = f"Unknown case key '{loose.group(1)}' (ignored)"
|
|
591
|
+
if strict or run.strict:
|
|
592
|
+
raise ValueError(msg)
|
|
593
|
+
warnings.append(msg)
|
|
594
|
+
continue
|
|
595
|
+
|
|
596
|
+
if in_env:
|
|
597
|
+
em = _ENV_LINE_RE.match(stripped)
|
|
598
|
+
if em:
|
|
599
|
+
env[em.group(1)] = em.group(2).strip().strip("\"'")
|
|
600
|
+
continue
|
|
601
|
+
in_env = False
|
|
602
|
+
if in_expect:
|
|
603
|
+
step_m = _STEP_RE.match(stripped)
|
|
604
|
+
if step_m:
|
|
605
|
+
expect.append(step_m.group(1).strip())
|
|
606
|
+
continue
|
|
607
|
+
if stripped.startswith("-"):
|
|
608
|
+
expect.append(stripped.lstrip("-").strip().strip("\"'"))
|
|
609
|
+
continue
|
|
610
|
+
if not _META_RE.match(stripped) and not _TAG_RE.match(stripped):
|
|
611
|
+
expect.append(stripped.strip("\"'"))
|
|
612
|
+
continue
|
|
613
|
+
step_m = _STEP_RE.match(stripped)
|
|
614
|
+
if step_m:
|
|
615
|
+
steps.append(step_m.group(1).strip())
|
|
616
|
+
if in_task:
|
|
617
|
+
task_parts.append(stripped)
|
|
618
|
+
else:
|
|
619
|
+
in_env = False
|
|
620
|
+
in_expect = False
|
|
621
|
+
continue
|
|
622
|
+
if in_task:
|
|
623
|
+
task_parts.append(stripped)
|
|
624
|
+
elif not task_parts and not steps:
|
|
625
|
+
# bare paragraph = task
|
|
626
|
+
task_parts.append(stripped)
|
|
627
|
+
in_task = True
|
|
628
|
+
|
|
629
|
+
# Prefer preserving multiline task text for NL goals
|
|
630
|
+
task = "\n".join(task_parts).strip() or "\n".join(steps).strip()
|
|
631
|
+
if task in {"|", ">"}:
|
|
632
|
+
task = "\n".join(steps).strip()
|
|
633
|
+
# Drop YAML block markers left in task
|
|
634
|
+
if task.startswith("|") or task.startswith(">"):
|
|
635
|
+
task = task.lstrip("|>").strip()
|
|
636
|
+
if not task:
|
|
637
|
+
raise ValueError("Case needs a task: line or numbered steps.")
|
|
638
|
+
# If numbered steps only appeared inside the task body, keep them on the case
|
|
639
|
+
if not steps:
|
|
640
|
+
from mobiflow.incremental import extract_numbered_steps
|
|
641
|
+
|
|
642
|
+
steps = extract_numbered_steps(task)
|
|
643
|
+
|
|
644
|
+
# Soft conflict check on case alone
|
|
645
|
+
case_reuse = run.reuse_flow
|
|
646
|
+
if case_reuse is None and run.codegen is False:
|
|
647
|
+
case_reuse = True
|
|
648
|
+
modes = [
|
|
649
|
+
m
|
|
650
|
+
for m, on in (
|
|
651
|
+
("reuseFlow", case_reuse),
|
|
652
|
+
("incremental", run.incremental),
|
|
653
|
+
("extendExplore", run.extend_explore),
|
|
654
|
+
)
|
|
655
|
+
if on
|
|
656
|
+
]
|
|
657
|
+
if len(modes) > 1:
|
|
658
|
+
raise ValueError(
|
|
659
|
+
"Case sets multiple exclusive modes: " + ", ".join(modes)
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
return TestCase(
|
|
663
|
+
name=name,
|
|
664
|
+
task=task,
|
|
665
|
+
app_id=app_id,
|
|
666
|
+
platform=platform,
|
|
667
|
+
device_id=device_id,
|
|
668
|
+
tags=tags,
|
|
669
|
+
steps=steps,
|
|
670
|
+
flow=flow,
|
|
671
|
+
clear_state=clear_state,
|
|
672
|
+
env=env,
|
|
673
|
+
expect=expect,
|
|
674
|
+
data_path=data_path,
|
|
675
|
+
run=run,
|
|
676
|
+
parse_warnings=warnings,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def load_case(path: Path | str) -> TestCase:
|
|
681
|
+
p = Path(path).expanduser().resolve()
|
|
682
|
+
text = p.read_text(encoding="utf-8")
|
|
683
|
+
case = parse_case_text(text, name=p.stem)
|
|
684
|
+
case.source_path = p
|
|
685
|
+
return case
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def discover_cases(
|
|
689
|
+
root: Path | str,
|
|
690
|
+
*,
|
|
691
|
+
tags: list[str] | None = None,
|
|
692
|
+
recursive: bool = True,
|
|
693
|
+
) -> list[TestCase]:
|
|
694
|
+
"""Load ``*.txt`` cases under ``root`` (file or directory), optionally filtered by tags."""
|
|
695
|
+
path = Path(root).expanduser().resolve()
|
|
696
|
+
files: list[Path]
|
|
697
|
+
if path.is_file():
|
|
698
|
+
files = [path]
|
|
699
|
+
elif path.is_dir():
|
|
700
|
+
pattern = "**/*.txt" if recursive else "*.txt"
|
|
701
|
+
files = sorted(p for p in path.glob(pattern) if p.is_file())
|
|
702
|
+
else:
|
|
703
|
+
raise FileNotFoundError(f"Case path not found: {path}")
|
|
704
|
+
|
|
705
|
+
cases: list[TestCase] = []
|
|
706
|
+
for fp in files:
|
|
707
|
+
if fp.name.startswith("."):
|
|
708
|
+
continue
|
|
709
|
+
cases.append(load_case(fp))
|
|
710
|
+
|
|
711
|
+
if tags:
|
|
712
|
+
wanted = {t.strip().lstrip("@").lower() for t in tags if t and t.strip()}
|
|
713
|
+
if wanted:
|
|
714
|
+
cases = [c for c in cases if any(c.has_tag(t) for t in wanted)]
|
|
715
|
+
return cases
|