@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.
- package/README.md +9 -11
- package/bin/mobiflow.js +73 -28
- 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
package/README.md
CHANGED
|
@@ -21,15 +21,9 @@ curl -Ls "https://get.maestro.mobile.dev" | bash
|
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
24
|
-
**
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
4
|
-
*
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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) &&
|
|
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
|
|
201
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
222
|
-
` Try: "${py}" -m pip install "
|
|
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.
|
|
4
|
-
"description": "CLI: NL \u2192 Maestro mobile flows via LLM, run on device/emulator, self-heal.
|
|
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')
|
|
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,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
|
+
)
|