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