@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
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,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* npm
|
|
4
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
181
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
234
|
-
` Try: "${py}" -m pip install "
|
|
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.
|
|
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
|
+
)
|