@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
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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * npm bin wrapper for the Python MobiFlow CLI.
4
- * Resolves: PATH mobiflow python -m mobiflow pip install → retry.
3
+ * npm launcher for the MobiFlow engine (Python), which is shipped inside
4
+ * this package (pyproject.toml + src/mobiflow). No git clone.
5
5
  *
6
6
  * Windows note: never run ``python -c "…"`` through ``cmd.exe`` (shell:true) —
7
7
  * quoting breaks and a real 3.12 install looks like “no Python 3.11+”.
@@ -10,11 +10,12 @@
10
10
 
11
11
  const { spawnSync } = require("child_process");
12
12
  const fs = require("fs");
13
+ const os = require("os");
13
14
  const path = require("path");
14
15
 
15
16
  const PKG = require("../package.json");
16
17
  const VERSION = PKG.version || "0.1.0";
17
- const REPO = "https://github.com/javed0211/MobiFlow.git";
18
+ const ROOT = path.resolve(__dirname, "..");
18
19
  const IS_WIN = process.platform === "win32";
19
20
 
20
21
  /** @typedef {{ cmd: string, prefixArgs?: string[], label?: string }} PyCandidate */
@@ -145,8 +146,53 @@ function moduleAvailable(py) {
145
146
  return r.status === 0;
146
147
  }
147
148
 
149
+ /** Installed Python package version, or null if missing / unreadable. */
150
+ function installedVersion(py) {
151
+ const code =
152
+ "from importlib.metadata import version\n" +
153
+ "print(version('mobiflow'))";
154
+ const r = run(py, ["-c", code], {
155
+ stdio: ["ignore", "pipe", "pipe"],
156
+ });
157
+ if (r.status !== 0 || !r.stdout) return null;
158
+ const ver = r.stdout.toString().trim().split(/\r?\n/).pop() || "";
159
+ return ver || null;
160
+ }
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
+
148
194
  function pipInstall(py, spec) {
149
- console.error(`[mobiflow] Installing Python package: ${spec}`);
195
+ console.error(`[mobiflow] Installing engine from ${spec}`);
150
196
  const r = run(py, ["-m", "pip", "install", "--upgrade", spec], {
151
197
  stdio: "inherit",
152
198
  });
@@ -154,38 +200,52 @@ function pipInstall(py, spec) {
154
200
  }
155
201
 
156
202
  function ensureMobiflow(py) {
157
- if (moduleAvailable(py)) return true;
203
+ const current = installedVersion(py);
204
+ if (current === VERSION) return true;
205
+ if (current) {
206
+ console.error(
207
+ `[mobiflow] Engine is ${current}; need ${VERSION} — installing from this package…`
208
+ );
209
+ } else {
210
+ console.error("[mobiflow] Installing engine from this npm package…");
211
+ }
158
212
 
159
- const specs = [
160
- process.env.MOBIFLOW_PIP_SPEC,
161
- `mobiflow==${VERSION}`,
162
- "mobiflow",
163
- `git+${REPO}@v${VERSION}`,
164
- `git+${REPO}@main`,
165
- ].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);
166
219
 
167
- for (const spec of specs) {
168
- if (pipInstall(py, spec) && moduleAvailable(py)) return true;
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;
169
226
  }
170
- return false;
171
- }
172
227
 
173
- function pathLookup(binName) {
174
- // Use where.exe explicitly — ``where`` with shell can behave oddly.
175
- const cmd = IS_WIN ? "where.exe" : "which";
176
- return run(cmd, [binName], { stdio: ["ignore", "pipe", "ignore"] });
228
+ for (const spec of specs) {
229
+ if (!pipInstall(py, spec)) continue;
230
+ const got = installedVersion(py);
231
+ if (got === VERSION) return true;
232
+ if (moduleAvailable(py) && process.env.MOBIFLOW_PIP_SPEC && spec === process.env.MOBIFLOW_PIP_SPEC) {
233
+ return true;
234
+ }
235
+ }
236
+ return moduleAvailable(py);
177
237
  }
178
238
 
179
239
  function main(argv) {
180
- const py = whichPython();
181
- if (!py) {
240
+ const systemPy = whichPython();
241
+ if (!systemPy) {
182
242
  const tried = whichPython._tried || [];
183
243
  console.error(
184
- "[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" +
185
246
  " https://www.python.org/downloads/\n" +
186
247
  " Or set MOBIFLOW_PYTHON to your python.exe, e.g.\n" +
187
- ' set MOBIFLOW_PYTHON=C:\\Users\\You\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\n' +
188
- " 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"
189
249
  );
190
250
  if (tried.length) {
191
251
  console.error(" Tried: " + tried.join("; "));
@@ -193,46 +253,19 @@ function main(argv) {
193
253
  process.exit(1);
194
254
  }
195
255
 
196
- // Prefer an already-installed console script on PATH (avoid recursion).
197
- const self = path.resolve(__filename);
198
- const onPath = pathLookup("mobiflow");
199
- if (onPath.status === 0 && onPath.stdout) {
200
- const candidates = onPath.stdout
201
- .toString()
202
- .split(/\r?\n/)
203
- .map((s) => s.trim())
204
- .filter(Boolean);
205
- for (const bin of candidates) {
206
- let resolved = bin;
207
- try {
208
- resolved = fs.realpathSync(bin);
209
- } catch {
210
- /* ignore */
211
- }
212
- if (resolved === self) continue;
213
- if (resolved.includes(`${path.sep}node_modules${path.sep}`)) continue;
214
- if (resolved.includes(`${path.sep}@qubiqlabs${path.sep}mobiflow${path.sep}`)) {
215
- continue;
216
- }
217
- if (resolved.includes(`${path.sep}mobiflow${path.sep}bin${path.sep}`)) {
218
- continue;
219
- }
220
- // Console scripts on Windows are often .cmd — shell helps those only.
221
- const r = spawnSync(bin, argv, {
222
- stdio: "inherit",
223
- windowsHide: true,
224
- env: process.env,
225
- shell: IS_WIN && /\.(cmd|bat)$/i.test(bin),
226
- });
227
- process.exit(r.status ?? 1);
228
- }
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);
229
263
  }
230
264
 
231
265
  if (!ensureMobiflow(py)) {
232
266
  console.error(
233
- "[mobiflow] Could not install the Python package.\n" +
234
- ` Try: "${py}" -m pip install "git+${REPO}@main"\n` +
235
- ` Or: "${py}" -m pip install mobiflow`
267
+ "[mobiflow] Could not install the engine from this npm package.\n" +
268
+ ` Try: "${py}" -m pip install --upgrade "${ROOT}"`
236
269
  );
237
270
  process.exit(1);
238
271
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qubiqlabs/mobiflow",
3
- "version": "0.9.0",
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
+ )