@qubiqlabs/mobiflow 0.9.1 → 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 +73 -28
  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
package/README.md CHANGED
@@ -21,15 +21,9 @@ curl -Ls "https://get.maestro.mobile.dev" | bash
21
21
 
22
22
  ## Install
23
23
 
24
- **Python (recommended):**
25
-
26
- ```bash
27
- pip install mobiflow
28
- # or from source
29
- pip install -e ".[dev]"
30
- ```
31
-
32
- **npm wrapper** (launches the Python CLI; requires Python 3.11+):
24
+ **npm** (ships the engine; no git). You still need **Python 3.11+** on PATH.
25
+ The launcher creates `~/.mobiflow/venv` and pip-installs the bundled package
26
+ there (it does not clone GitHub or write into Homebrew/system Python):
33
27
 
34
28
  ```bash
35
29
  npm install -g @qubiqlabs/mobiflow
@@ -37,7 +31,7 @@ npm install -g @qubiqlabs/mobiflow
37
31
  npx @qubiqlabs/mobiflow --help
38
32
  ```
39
33
 
40
- On Windows, the wrapper prefers `py -3.12` / `py -3` (not the Microsoft Store
34
+ On Windows, the launcher prefers `py -3.12` / `py -3` (not the Microsoft Store
41
35
  `python` stub). If detection still fails:
42
36
 
43
37
  ```bat
@@ -45,7 +39,11 @@ set MOBIFLOW_PYTHON=C:\Path\To\Python312\python.exe
45
39
  mobiflow init
46
40
  ```
47
41
 
48
- Or skip npm and run: `py -3.12 -m pip install mobiflow` then `py -3.12 -m mobiflow init`.
42
+ **From this repo** (contributors):
43
+
44
+ ```bash
45
+ pip install -e ".[dev]"
46
+ ```
49
47
 
50
48
  See [docs/PUBLISH.md](docs/PUBLISH.md) for maintainers.
51
49
 
package/bin/mobiflow.js CHANGED
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * npm bin wrapper for the Python MobiFlow CLI.
4
- * Ensures the Python package matches this package.json version, then runs
5
- * ``python -m mobiflow``.
3
+ * npm launcher for the MobiFlow engine (Python), which is shipped inside
4
+ * this package (pyproject.toml + src/mobiflow). No git clone.
6
5
  *
7
6
  * Windows note: never run ``python -c "…"`` through ``cmd.exe`` (shell:true) —
8
7
  * quoting breaks and a real 3.12 install looks like “no Python 3.11+”.
@@ -10,10 +9,13 @@
10
9
  "use strict";
11
10
 
12
11
  const { spawnSync } = require("child_process");
12
+ const fs = require("fs");
13
+ const os = require("os");
14
+ const path = require("path");
13
15
 
14
16
  const PKG = require("../package.json");
15
17
  const VERSION = PKG.version || "0.1.0";
16
- const REPO = "https://github.com/javed0211/MobiFlow.git";
18
+ const ROOT = path.resolve(__dirname, "..");
17
19
  const IS_WIN = process.platform === "win32";
18
20
 
19
21
  /** @typedef {{ cmd: string, prefixArgs?: string[], label?: string }} PyCandidate */
@@ -157,8 +159,40 @@ function installedVersion(py) {
157
159
  return ver || null;
158
160
  }
159
161
 
162
+ function bundledRoot() {
163
+ const pyproject = path.join(ROOT, "pyproject.toml");
164
+ const pkgDir = path.join(ROOT, "src", "mobiflow");
165
+ if (fs.existsSync(pyproject) && fs.existsSync(pkgDir)) return ROOT;
166
+ return null;
167
+ }
168
+
169
+ function venvDir() {
170
+ return process.env.MOBIFLOW_VENV || path.join(os.homedir(), ".mobiflow", "venv");
171
+ }
172
+
173
+ function venvPythonPath() {
174
+ const dir = venvDir();
175
+ return IS_WIN
176
+ ? path.join(dir, "Scripts", "python.exe")
177
+ : path.join(dir, "bin", "python");
178
+ }
179
+
180
+ /** Create ~/.mobiflow/venv with the discovered system Python (PEP 668 safe). */
181
+ function ensureVenv(systemPy) {
182
+ const exe = venvPythonPath();
183
+ if (fs.existsSync(exe)) {
184
+ const ver = pythonVersion({ cmd: exe });
185
+ if (ver && ver.major >= 3 && ver.minor >= 11) return exe;
186
+ }
187
+ console.error(`[mobiflow] Creating engine venv at ${venvDir()}`);
188
+ fs.mkdirSync(path.dirname(venvDir()), { recursive: true });
189
+ const r = run(systemPy, ["-m", "venv", venvDir()], { stdio: "inherit" });
190
+ if (r.status !== 0 || !fs.existsSync(exe)) return null;
191
+ return exe;
192
+ }
193
+
160
194
  function pipInstall(py, spec) {
161
- console.error(`[mobiflow] Installing Python package: ${spec}`);
195
+ console.error(`[mobiflow] Installing engine from ${spec}`);
162
196
  const r = run(py, ["-m", "pip", "install", "--upgrade", spec], {
163
197
  stdio: "inherit",
164
198
  });
@@ -170,42 +204,48 @@ function ensureMobiflow(py) {
170
204
  if (current === VERSION) return true;
171
205
  if (current) {
172
206
  console.error(
173
- `[mobiflow] Python package is ${current}; need ${VERSION} — upgrading…`
207
+ `[mobiflow] Engine is ${current}; need ${VERSION} — installing from this package…`
174
208
  );
209
+ } else {
210
+ console.error("[mobiflow] Installing engine from this npm package…");
175
211
  }
176
212
 
177
- // Prefer git tags: the npm wrapper version tracks the GitHub release.
178
- // PyPI may lag or be empty for early releases.
179
- const specs = [
180
- process.env.MOBIFLOW_PIP_SPEC,
181
- `git+${REPO}@v${VERSION}`,
182
- `mobiflow==${VERSION}`,
183
- "mobiflow",
184
- `git+${REPO}@main`,
185
- ].filter(Boolean);
213
+ const specs = [];
214
+ if (process.env.MOBIFLOW_PIP_SPEC) {
215
+ specs.push(process.env.MOBIFLOW_PIP_SPEC);
216
+ }
217
+ const bundled = bundledRoot();
218
+ if (bundled) specs.push(bundled);
219
+
220
+ if (!specs.length) {
221
+ console.error(
222
+ "[mobiflow] This npm package is missing pyproject.toml / src/mobiflow.\n" +
223
+ " Reinstall: npm install -g @qubiqlabs/mobiflow"
224
+ );
225
+ return false;
226
+ }
186
227
 
187
228
  for (const spec of specs) {
188
229
  if (!pipInstall(py, spec)) continue;
189
230
  const got = installedVersion(py);
190
231
  if (got === VERSION) return true;
191
- if (moduleAvailable(py) && (spec.includes("@main") || process.env.MOBIFLOW_PIP_SPEC)) {
232
+ if (moduleAvailable(py) && process.env.MOBIFLOW_PIP_SPEC && spec === process.env.MOBIFLOW_PIP_SPEC) {
192
233
  return true;
193
234
  }
194
235
  }
195
- // Last resort: whatever is importable (may be older — caller still runs).
196
236
  return moduleAvailable(py);
197
237
  }
198
238
 
199
239
  function main(argv) {
200
- const py = whichPython();
201
- if (!py) {
240
+ const systemPy = whichPython();
241
+ if (!systemPy) {
202
242
  const tried = whichPython._tried || [];
203
243
  console.error(
204
- "[mobiflow] Python 3.11+ is required on PATH.\n" +
244
+ "[mobiflow] Python 3.11+ is required on PATH (the npm package ships the engine;\n" +
245
+ " it does not replace Python).\n" +
205
246
  " https://www.python.org/downloads/\n" +
206
247
  " Or set MOBIFLOW_PYTHON to your python.exe, e.g.\n" +
207
- ' set MOBIFLOW_PYTHON=C:\\Users\\You\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\n' +
208
- " Or: py -3.12 -m pip install mobiflow && py -3.12 -m mobiflow --help"
248
+ " set MOBIFLOW_PYTHON=C:\\Users\\You\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"
209
249
  );
210
250
  if (tried.length) {
211
251
  console.error(" Tried: " + tried.join("; "));
@@ -213,14 +253,19 @@ function main(argv) {
213
253
  process.exit(1);
214
254
  }
215
255
 
216
- // Always ensure the Python package matches this npm wrapper version first.
217
- // Do not hand off to a stale console script on PATH (that hid new commands
218
- // like ``apps`` after npm upgrades).
256
+ const py = ensureVenv(systemPy);
257
+ if (!py) {
258
+ console.error(
259
+ `[mobiflow] Could not create a venv with "${systemPy}".\n` +
260
+ ` Try: "${systemPy}" -m venv "${venvDir()}"`
261
+ );
262
+ process.exit(1);
263
+ }
264
+
219
265
  if (!ensureMobiflow(py)) {
220
266
  console.error(
221
- "[mobiflow] Could not install the Python package.\n" +
222
- ` Try: "${py}" -m pip install "git+${REPO}@v${VERSION}"\n` +
223
- ` Or: "${py}" -m pip install "git+${REPO}@main"`
267
+ "[mobiflow] Could not install the engine from this npm package.\n" +
268
+ ` Try: "${py}" -m pip install --upgrade "${ROOT}"`
224
269
  );
225
270
  process.exit(1);
226
271
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qubiqlabs/mobiflow",
3
- "version": "0.9.1",
4
- "description": "CLI: NL \u2192 Maestro mobile flows via LLM, run on device/emulator, self-heal. (npm wrapper for the Python package)",
3
+ "version": "1.0.0",
4
+ "description": "CLI: NL \u2192 Maestro mobile flows via LLM, run on device/emulator, self-heal.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "QubiQ Labs <labs@qubiq.ai>",
7
7
  "homepage": "https://github.com/javed0211/MobiFlow",
@@ -28,6 +28,11 @@
28
28
  },
29
29
  "files": [
30
30
  "bin/",
31
+ "src/mobiflow/**/*.py",
32
+ "src/mobiflow/**/*.html",
33
+ "src/mobiflow/**/*.svg",
34
+ "src/mobiflow/**/*.jpg",
35
+ "pyproject.toml",
31
36
  "README.md",
32
37
  "LICENSE"
33
38
  ],
@@ -36,7 +41,7 @@
36
41
  },
37
42
  "scripts": {
38
43
  "mobiflow": "node bin/mobiflow.js",
39
- "prepack": "node -e \"require('fs').accessSync('bin/mobiflow.js')\""
44
+ "prepack": "node -e \"const fs=require('fs'); ['bin/mobiflow.js','pyproject.toml','src/mobiflow/cli.py'].forEach(p=>fs.accessSync(p))\""
40
45
  },
41
46
  "publishConfig": {
42
47
  "access": "public"
package/pyproject.toml ADDED
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mobiflow"
7
+ version = "1.0.0"
8
+ description = "CLI: NL → Maestro mobile flows via LLM, run on device/emulator, self-heal before you commit."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "QubiQ Labs", email = "labs@qubiq.ai" }]
13
+ keywords = ["maestro", "mobile", "android", "ios", "testing", "llm", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: Apache Software License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Testing",
23
+ ]
24
+ dependencies = [
25
+ "click>=8.1",
26
+ "questionary>=2.0",
27
+ "rich>=13.0",
28
+ "pyyaml>=6.0",
29
+ "pydantic>=2.5",
30
+ "openai>=1.40",
31
+ "httpx>=0.27",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/javed0211/MobiFlow"
36
+ Documentation = "https://github.com/javed0211/MobiFlow/blob/main/docs/CASES.md"
37
+ Repository = "https://github.com/javed0211/MobiFlow"
38
+ Issues = "https://github.com/javed0211/MobiFlow/issues"
39
+ Changelog = "https://github.com/javed0211/MobiFlow/releases"
40
+
41
+ [project.optional-dependencies]
42
+ dev = ["pytest>=8.0", "ruff>=0.5"]
43
+ anthropic = ["anthropic>=0.40"]
44
+
45
+ [project.scripts]
46
+ mobiflow = "mobiflow.cli:main"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/mobiflow"]
50
+
51
+ [tool.hatch.build.targets.wheel.sources]
52
+ "src/mobiflow" = "mobiflow"
53
+
54
+ [tool.hatch.build.targets.sdist]
55
+ include = ["src/mobiflow", "docs", "README.md", "pyproject.toml", "cases", "flows"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+ pythonpath = ["src"]
@@ -0,0 +1,9 @@
1
+ """mobiflow — NL → Maestro mobile automation in the terminal."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("mobiflow")
7
+ except PackageNotFoundError: # pragma: no cover - editable / source tree
8
+ __version__ = "1.0.0"
9
+
@@ -0,0 +1,6 @@
1
+ """Allow `python -m mobiflow`."""
2
+
3
+ from mobiflow.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,228 @@
1
+ """Screenshot baseline compare for visual smoke checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import struct
7
+ import zlib
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ @dataclass
15
+ class BaselineResult:
16
+ ok: bool
17
+ case: str
18
+ baseline: str = ""
19
+ candidate: str = ""
20
+ diff_path: str = ""
21
+ mismatch_ratio: float = 0.0
22
+ threshold: float = 0.02
23
+ message: str = ""
24
+
25
+ def to_dict(self) -> dict[str, Any]:
26
+ return {
27
+ "ok": self.ok,
28
+ "case": self.case,
29
+ "baseline": self.baseline,
30
+ "candidate": self.candidate,
31
+ "diff_path": self.diff_path,
32
+ "mismatch_ratio": self.mismatch_ratio,
33
+ "threshold": self.threshold,
34
+ "message": self.message,
35
+ }
36
+
37
+
38
+ def baseline_dir(artifacts_dir: Path, case_name: str) -> Path:
39
+ return Path(artifacts_dir) / "baselines" / case_name
40
+
41
+
42
+ def _read_png_rgba(path: Path) -> tuple[int, int, bytes]:
43
+ """Minimal PNG reader (8-bit RGBA/RGB/Gray). Returns width, height, RGBA bytes."""
44
+ data = path.read_bytes()
45
+ if data[:8] != b"\x89PNG\r\n\x1a\n":
46
+ raise ValueError(f"Not a PNG: {path}")
47
+ pos = 8
48
+ width = height = 0
49
+ bit_depth = 8
50
+ color_type = 2
51
+ raw = b""
52
+ while pos < len(data):
53
+ length = struct.unpack(">I", data[pos : pos + 4])[0]
54
+ pos += 4
55
+ ctype = data[pos : pos + 4]
56
+ pos += 4
57
+ chunk = data[pos : pos + length]
58
+ pos += length
59
+ pos += 4 # crc
60
+ if ctype == b"IHDR":
61
+ width, height, bit_depth, color_type = struct.unpack(">IIBB", chunk[:10])
62
+ elif ctype == b"IDAT":
63
+ raw += chunk
64
+ elif ctype == b"IEND":
65
+ break
66
+ if not width or not height:
67
+ raise ValueError(f"Invalid PNG header: {path}")
68
+ if bit_depth != 8 or color_type not in (0, 2, 4, 6):
69
+ raise ValueError(f"Unsupported PNG format in {path}")
70
+ decompressed = zlib.decompress(raw)
71
+ # Remove filter bytes (assume filter 0 for simplicity; handle None/sub roughly)
72
+ stride = {0: 1, 2: 3, 4: 2, 6: 4}[color_type]
73
+ row_bytes = width * stride
74
+ pixels = bytearray()
75
+ offset = 0
76
+ prev = bytearray(row_bytes)
77
+ for _y in range(height):
78
+ filter_type = decompressed[offset]
79
+ offset += 1
80
+ row = bytearray(decompressed[offset : offset + row_bytes])
81
+ offset += row_bytes
82
+ if filter_type == 1: # Sub
83
+ for i in range(row_bytes):
84
+ left = row[i - stride] if i >= stride else 0
85
+ row[i] = (row[i] + left) & 0xFF
86
+ elif filter_type == 2: # Up
87
+ for i in range(row_bytes):
88
+ row[i] = (row[i] + prev[i]) & 0xFF
89
+ elif filter_type == 3: # Average
90
+ for i in range(row_bytes):
91
+ left = row[i - stride] if i >= stride else 0
92
+ row[i] = (row[i] + ((left + prev[i]) // 2)) & 0xFF
93
+ elif filter_type == 4: # Paeth
94
+ for i in range(row_bytes):
95
+ a = row[i - stride] if i >= stride else 0
96
+ b = prev[i]
97
+ c = prev[i - stride] if i >= stride else 0
98
+ p = a + b - c
99
+ pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
100
+ pr = a if pa <= pb and pa <= pc else (b if pb <= pc else c)
101
+ row[i] = (row[i] + pr) & 0xFF
102
+ elif filter_type != 0:
103
+ raise ValueError(f"Unsupported PNG filter {filter_type} in {path}")
104
+ prev = row
105
+ if color_type == 6:
106
+ pixels.extend(row)
107
+ elif color_type == 2:
108
+ for i in range(0, len(row), 3):
109
+ pixels.extend([row[i], row[i + 1], row[i + 2], 255])
110
+ elif color_type == 0:
111
+ for v in row:
112
+ pixels.extend([v, v, v, 255])
113
+ elif color_type == 4:
114
+ for i in range(0, len(row), 2):
115
+ pixels.extend([row[i], row[i], row[i], row[i + 1]])
116
+ return width, height, bytes(pixels)
117
+
118
+
119
+ def _write_png_rgba(path: Path, width: int, height: int, rgba: bytes) -> None:
120
+ def chunk(tag: bytes, data: bytes) -> bytes:
121
+ return (
122
+ struct.pack(">I", len(data))
123
+ + tag
124
+ + data
125
+ + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
126
+ )
127
+
128
+ raw = bytearray()
129
+ stride = width * 4
130
+ for y in range(height):
131
+ raw.append(0)
132
+ raw.extend(rgba[y * stride : (y + 1) * stride])
133
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ path.write_bytes(
136
+ b"\x89PNG\r\n\x1a\n"
137
+ + chunk(b"IHDR", ihdr)
138
+ + chunk(b"IDAT", zlib.compress(bytes(raw), 9))
139
+ + chunk(b"IEND", b"")
140
+ )
141
+
142
+
143
+ def compare_images(
144
+ baseline: Path,
145
+ candidate: Path,
146
+ *,
147
+ diff_path: Path | None = None,
148
+ threshold: float = 0.02,
149
+ max_channel_delta: int = 12,
150
+ ) -> tuple[bool, float, Path | None]:
151
+ """Return (ok, mismatch_ratio, diff_path)."""
152
+ bw, bh, bp = _read_png_rgba(baseline)
153
+ cw, ch, cp = _read_png_rgba(candidate)
154
+ if (bw, bh) != (cw, ch):
155
+ # Treat size mismatch as total fail
156
+ return False, 1.0, None
157
+ total = bw * bh
158
+ mismatches = 0
159
+ diff = bytearray(len(bp))
160
+ for i in range(0, len(bp), 4):
161
+ dr = abs(bp[i] - cp[i])
162
+ dg = abs(bp[i + 1] - cp[i + 1])
163
+ db = abs(bp[i + 2] - cp[i + 2])
164
+ if max(dr, dg, db) > max_channel_delta:
165
+ mismatches += 1
166
+ diff[i] = 255
167
+ diff[i + 1] = 0
168
+ diff[i + 2] = 0
169
+ diff[i + 3] = 255
170
+ else:
171
+ # dim baseline
172
+ diff[i] = bp[i] // 3
173
+ diff[i + 1] = bp[i + 1] // 3
174
+ diff[i + 2] = bp[i + 2] // 3
175
+ diff[i + 3] = 255
176
+ ratio = mismatches / max(1, total)
177
+ out_diff = None
178
+ if diff_path is not None:
179
+ _write_png_rgba(diff_path, bw, bh, bytes(diff))
180
+ out_diff = diff_path
181
+ return ratio <= threshold, ratio, out_diff
182
+
183
+
184
+ def update_baseline(case_name: str, image: Path, artifacts_dir: Path) -> Path:
185
+ dest = baseline_dir(artifacts_dir, case_name) / "baseline.png"
186
+ dest.parent.mkdir(parents=True, exist_ok=True)
187
+ dest.write_bytes(image.read_bytes())
188
+ meta = {
189
+ "case": case_name,
190
+ "source": str(image),
191
+ "updated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
192
+ }
193
+ (dest.parent / "baseline.json").write_text(
194
+ json.dumps(meta, indent=2) + "\n", encoding="utf-8"
195
+ )
196
+ return dest
197
+
198
+
199
+ def compare_case_screenshot(
200
+ case_name: str,
201
+ candidate: Path,
202
+ artifacts_dir: Path,
203
+ *,
204
+ threshold: float = 0.02,
205
+ ) -> BaselineResult:
206
+ base = baseline_dir(artifacts_dir, case_name) / "baseline.png"
207
+ if not base.is_file():
208
+ return BaselineResult(
209
+ ok=False,
210
+ case=case_name,
211
+ candidate=str(candidate),
212
+ threshold=threshold,
213
+ message="No baseline — run `mobiflow baseline update <case> <png>`",
214
+ )
215
+ diff = baseline_dir(artifacts_dir, case_name) / "diff.png"
216
+ ok, ratio, diff_out = compare_images(
217
+ base, candidate, diff_path=diff, threshold=threshold
218
+ )
219
+ return BaselineResult(
220
+ ok=ok,
221
+ case=case_name,
222
+ baseline=str(base),
223
+ candidate=str(candidate),
224
+ diff_path=str(diff_out or ""),
225
+ mismatch_ratio=ratio,
226
+ threshold=threshold,
227
+ message="pass" if ok else f"mismatch {ratio:.3%} > {threshold:.3%}",
228
+ )