@capagents/forgeqe 1.3.2 → 2.0.1

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 (115) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/llm.py +54 -4
  37. package/py/forgeqe_engine/model_params.py +169 -0
  38. package/py/forgeqe_mobile/__init__.py +1 -0
  39. package/py/forgeqe_mobile/__main__.py +3 -0
  40. package/py/forgeqe_mobile/appium_driver.py +342 -0
  41. package/py/forgeqe_mobile/apps.py +84 -0
  42. package/py/forgeqe_mobile/cli.py +70 -0
  43. package/py/forgeqe_mobile/explore.py +203 -0
  44. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  45. package/py/forgeqe_mobile/maestro.py +321 -0
  46. package/py/forgeqe_mobile/service.py +332 -0
  47. package/skills/testcontainers/SKILL.md +42 -0
  48. package/src/agents.ts +11 -2
  49. package/src/api/plan.ts +2 -0
  50. package/src/api/write.ts +2 -0
  51. package/src/auth/copilot.ts +130 -0
  52. package/src/auth/signin.ts +54 -1
  53. package/src/brain/index.ts +26 -4
  54. package/src/capabilities/guide.ts +2 -0
  55. package/src/capabilities/registry.ts +110 -2
  56. package/src/capabilities/status.ts +15 -0
  57. package/src/cases/rca.ts +2 -0
  58. package/src/childEnv.ts +4 -1
  59. package/src/cli/args.ts +3 -2
  60. package/src/cli/help.ts +24 -3
  61. package/src/cli/index.ts +47 -3
  62. package/src/code/find.ts +26 -0
  63. package/src/code/loop.ts +4 -1
  64. package/src/code/prompt.ts +2 -1
  65. package/src/code/prreview.ts +2 -0
  66. package/src/code/review.ts +6 -2
  67. package/src/code/runtime.ts +34 -3
  68. package/src/commands.ts +6 -1
  69. package/src/config.ts +159 -3
  70. package/src/containers/command.ts +68 -0
  71. package/src/containers/compose.ts +173 -0
  72. package/src/containers/config.ts +283 -0
  73. package/src/containers/mock.ts +156 -0
  74. package/src/containers/run.ts +362 -0
  75. package/src/containers/tests.ts +80 -0
  76. package/src/context.ts +33 -6
  77. package/src/copilot.ts +155 -0
  78. package/src/desktop/bridge.ts +229 -0
  79. package/src/desktop/command.ts +350 -0
  80. package/src/desktop/edit.ts +217 -0
  81. package/src/desktop/junit.ts +35 -0
  82. package/src/desktop/report.ts +155 -0
  83. package/src/desktop/run.ts +252 -0
  84. package/src/desktop/setup.ts +134 -0
  85. package/src/engine/bridge.ts +12 -5
  86. package/src/engine/setup.ts +3 -3
  87. package/src/environments.ts +566 -0
  88. package/src/evals/domain.ts +1 -0
  89. package/src/explore/charters.ts +2 -0
  90. package/src/guardrails.ts +145 -0
  91. package/src/heal/loop.ts +4 -1
  92. package/src/heal/prompt.ts +2 -1
  93. package/src/init.ts +5 -2
  94. package/src/llm.ts +23 -2
  95. package/src/mcp/attachtools.ts +16 -4
  96. package/src/mcp/captools.ts +27 -0
  97. package/src/mcp/containertools.ts +212 -0
  98. package/src/mcp/desktoptools.ts +334 -0
  99. package/src/mcp/mobiletools.ts +132 -0
  100. package/src/mcp/reporttools.ts +17 -2
  101. package/src/mcp/repotests.ts +22 -3
  102. package/src/mcp/routing.ts +11 -5
  103. package/src/mcp/scope.ts +210 -0
  104. package/src/mcp/server.ts +80 -5
  105. package/src/mcp/tools.ts +38 -1
  106. package/src/mobile/command.ts +245 -0
  107. package/src/mobile/run.ts +226 -0
  108. package/src/pack/pipeline.ts +44 -2
  109. package/src/refine.ts +3 -0
  110. package/src/report/html.ts +3 -0
  111. package/src/report/template.html +48 -48
  112. package/src/run.ts +7 -3
  113. package/src/tokenomics/ledger.ts +3 -1
  114. package/src/tui/App.tsx +23 -0
  115. package/src/usage.ts +1 -1
@@ -0,0 +1,305 @@
1
+ """
2
+ Explore: UFO² does a goal on the live app, in this process, with ForgeQE's model, rules and trace.
3
+
4
+ UFO is a pinned checkout that ForgeQE owns (``forgeqe desktop setup --ufo``); its source is never edited. Each run:
5
+
6
+ - writes UFO's ``config/ufo/agents.yaml`` (the ``forgeqe`` model type, no key) and ``mcp.yaml`` (UI tools only: no
7
+ ``CommandLineExecutor``, which runs shell commands, and no COM or PDF executors);
8
+ - starts the app from ``desktop.apps`` itself, so UFO never launches a program;
9
+ - wraps UFO's ``ActionExecutor.execute`` to record each action with the control's UIA properties (UFO's own log has the
10
+ control's name and a per-step id, not its AutomationId), and to refuse keys that reach outside the app;
11
+ - answers UFO's console questions (its safe guard for sensitive actions) with no, since nobody is at the console.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import builtins
16
+ import io
17
+ import os
18
+ import re
19
+ import shutil
20
+ import sys
21
+ from contextlib import contextmanager
22
+ from pathlib import Path
23
+ from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional
24
+
25
+ import yaml
26
+
27
+ from .models import DesktopModel
28
+ from .ufo_bridge import agents_config, install
29
+
30
+ # The UFO commit ForgeQE is tested with; setup checks out exactly this one.
31
+ UFO_COMMIT = "eae3c0a8d168c47cb10df0652e293e092eda1078"
32
+ UFO_REPO = "https://github.com/microsoft/UFO.git"
33
+
34
+ MCP_CONFIG: Dict[str, Any] = {
35
+ "HostAgent": {
36
+ "default": {
37
+ "data_collection": [{"namespace": "UICollector", "type": "local", "start_args": [], "reset": False}],
38
+ "action": [{"namespace": "HostUIExecutor", "type": "local", "start_args": [], "reset": False}],
39
+ }
40
+ },
41
+ "AppAgent": {
42
+ "default": {
43
+ "data_collection": [{"namespace": "UICollector", "type": "local", "start_args": [], "reset": False}],
44
+ "action": [{"namespace": "AppUIExecutor", "type": "local", "start_args": [], "reset": False}],
45
+ }
46
+ },
47
+ }
48
+
49
+ # UFO's own key syntax for the Windows and context-menu keys, which reach programs outside the app.
50
+ _REFUSED_UFO_KEYS = re.compile(r"\{\s*(VK_)?(L|R)?WIN\b|\{\s*(VK_)?APPS\b", re.I)
51
+ _TRACE_FUNCTIONS = ("click_input", "set_edit_text", "keyboard_input", "select", "click_on_coordinates", "drag_on_coordinates", "wheel_mouse_input", "texts", "summary")
52
+ # The UI actions UFO's AppUIExecutor runs at the pinned commit; anything else (a shell or COM command a later UFO might
53
+ # route through ActionExecutor) is refused rather than run.
54
+ ALLOWED_FUNCTIONS = frozenset(_TRACE_FUNCTIONS)
55
+
56
+
57
+ class ExploreError(RuntimeError):
58
+ """Explore cannot run here; the message says what to set up."""
59
+
60
+
61
+ # pywinauto's send_keys syntax: ^ % + hold Ctrl, Alt, Shift for the next key or (group); {NAME}, {NAME n},
62
+ # {NAME down} / {NAME up}; ~ is Enter.
63
+ _MOD_CHARS = {"^": "ctrl", "%": "alt", "+": "shift"}
64
+ _HELD_NAMES = {
65
+ "vk_control": "ctrl", "vk_lcontrol": "ctrl", "vk_rcontrol": "ctrl", "control": "ctrl", "ctrl": "ctrl",
66
+ "vk_menu": "alt", "vk_lmenu": "alt", "vk_rmenu": "alt", "menu": "alt", "alt": "alt",
67
+ "vk_shift": "shift", "vk_lshift": "shift", "vk_rshift": "shift", "shift": "shift",
68
+ }
69
+ _ESC = {"esc", "escape", "vk_escape"}
70
+ _TAB = {"tab", "vk_tab"}
71
+ _DEL = {"del", "delete", "vk_delete"}
72
+
73
+
74
+ def _key_presses(keys: str):
75
+ """Each key pressed by a send_keys string, with the modifiers held at that moment."""
76
+ held = set() # held with {NAME down}
77
+ group: List[set] = [] # modifiers held for a (group)
78
+ pending: set = set() # ^ % + waiting for their key
79
+ i = 0
80
+ while i < len(keys):
81
+ c = keys[i]
82
+ if c in _MOD_CHARS:
83
+ pending.add(_MOD_CHARS[c])
84
+ i += 1
85
+ continue
86
+ if c == "(":
87
+ group.append(set(pending))
88
+ pending = set()
89
+ i += 1
90
+ continue
91
+ if c == ")":
92
+ if group:
93
+ group.pop()
94
+ i += 1
95
+ continue
96
+ mods = held | pending | set().union(*group) if group else held | pending
97
+ if c == "{":
98
+ end = keys.find("}", i + 2) # "{}}" types a brace
99
+ if end < 0:
100
+ end = len(keys)
101
+ words = keys[i + 1:end].strip().lower().split()
102
+ i = end + 1
103
+ pending = set()
104
+ if not words:
105
+ continue
106
+ name = words[0]
107
+ if len(words) > 1 and words[1] in ("down", "up") and name in _HELD_NAMES:
108
+ (held.add if words[1] == "down" else held.discard)(_HELD_NAMES[name])
109
+ continue
110
+ yield name, mods
111
+ continue
112
+ yield ("enter" if c == "~" else c.lower()), mods
113
+ pending = set()
114
+ i += 1
115
+
116
+
117
+ def refused_keys(keys: str) -> bool:
118
+ """
119
+ Keys that reach outside the app: the Windows and menu keys, and the chords Windows itself handles (Ctrl+Esc and
120
+ Ctrl+Shift+Esc open the Start menu and Task Manager, Alt+Tab and Alt+Esc switch to other programs, Ctrl+Alt+Del).
121
+ """
122
+ text = keys or ""
123
+ if _REFUSED_UFO_KEYS.search(text):
124
+ return True
125
+ for name, mods in _key_presses(text):
126
+ if name in _ESC and "ctrl" in mods:
127
+ return True
128
+ if (name in _ESC or name in _TAB) and "alt" in mods:
129
+ return True
130
+ if name in _DEL and {"ctrl", "alt"} <= mods:
131
+ return True
132
+ return False
133
+
134
+
135
+ def write_ufo_config(ufo_dir: Path, profile_name: str, profile: Mapping[str, Any]) -> None:
136
+ """UFO's agents.yaml for the profile (the template's other settings kept) and an mcp.yaml with UI tools only."""
137
+ cfg_dir = ufo_dir / "config" / "ufo"
138
+ if not cfg_dir.is_dir():
139
+ raise ExploreError(f"{ufo_dir} is not a UFO checkout (no config/ufo). Run: forgeqe desktop setup --ufo")
140
+ template = cfg_dir / "agents.yaml.template"
141
+ base: Dict[str, Any] = {}
142
+ if template.is_file():
143
+ base = yaml.safe_load(template.read_text(encoding="utf-8")) or {}
144
+ base.update(agents_config(profile_name, profile))
145
+ (cfg_dir / "agents.yaml").write_text(yaml.safe_dump(base, sort_keys=False), encoding="utf-8")
146
+ (cfg_dir / "mcp.yaml").write_text(yaml.safe_dump(MCP_CONFIG, sort_keys=False), encoding="utf-8")
147
+
148
+
149
+ def _control_props(control: Any) -> Dict[str, str]:
150
+ info = getattr(control, "element_info", None)
151
+ out: Dict[str, str] = {}
152
+ for key, attr in (("automation_id", "automation_id"), ("name", "name"), ("control_type", "control_type"), ("class_name", "class_name")):
153
+ try:
154
+ value = getattr(info, attr, None)
155
+ except Exception:
156
+ value = None
157
+ if isinstance(value, str) and value:
158
+ out[key] = value
159
+ return out
160
+
161
+
162
+ def install_trace(action_executor: Any, trace: List[Dict[str, Any]]) -> Callable[[], None]:
163
+ """
164
+ Record every action UFO executes into ``trace`` (the format ``record.steps_from_trace`` reads) and refuse keys that
165
+ reach outside the app. Returns a function that puts UFO's ``execute`` back.
166
+ """
167
+ original = action_executor.execute
168
+
169
+ def execute(self: Any, action: Any, puppeteer: Any, control_dict: Dict[str, Any], application_window: Any = None) -> Any:
170
+ function = str(getattr(action, "function", "") or "")
171
+ arguments = dict(getattr(action, "arguments", {}) or {})
172
+ target = getattr(action, "target", None)
173
+ control = (control_dict or {}).get(getattr(target, "id", None)) if target is not None else None
174
+ entry: Dict[str, Any] = {"action": function, "args": arguments, "control": _control_props(control) if control is not None else {}}
175
+ if function not in ALLOWED_FUNCTIONS:
176
+ entry["ok"] = False
177
+ entry["refused"] = "not a UI action"
178
+ trace.append(entry)
179
+ raise ValueError(f"ForgeQE refused {function!r}: explore runs UI actions only.")
180
+ if function == "keyboard_input" and refused_keys(str(arguments.get("keys") or "")):
181
+ entry["ok"] = False
182
+ entry["refused"] = "keys that reach outside the app"
183
+ trace.append(entry)
184
+ raise ValueError("ForgeQE refused this key sequence: the Windows and menu keys reach programs outside the app.")
185
+ try:
186
+ result = original(self, action, puppeteer, control_dict, application_window)
187
+ except Exception as e:
188
+ entry["ok"] = False
189
+ entry["error"] = str(e)[:500]
190
+ trace.append(entry)
191
+ raise
192
+ entry["ok"] = True
193
+ if function in _TRACE_FUNCTIONS:
194
+ trace.append(entry)
195
+ return result
196
+
197
+ action_executor.execute = execute
198
+
199
+ def restore() -> None:
200
+ action_executor.execute = original
201
+
202
+ return restore
203
+
204
+
205
+ @contextmanager
206
+ def _no_console_answers(log: Callable[[str], None]) -> Iterator[None]:
207
+ original = builtins.input
208
+
209
+ def refuse(prompt: str = "") -> str:
210
+ log(f"UFO asked a question at the console and was answered no: {str(prompt)[:200]}")
211
+ return "N"
212
+
213
+ builtins.input = refuse
214
+ try:
215
+ yield
216
+ finally:
217
+ builtins.input = original
218
+
219
+
220
+ @contextmanager
221
+ def _console_to_stderr() -> Iterator[None]:
222
+ """
223
+ UFO prints its panels (with emoji) to stdout, which is the engine's JSON event stream; send them to stderr instead,
224
+ and never fail on a character the console's code page lacks (Windows' cp1252 has no emoji).
225
+ """
226
+ old_out = sys.stdout
227
+ err = sys.stderr
228
+ try:
229
+ stream: Any = io.TextIOWrapper(err.buffer, encoding=err.encoding or "utf-8", errors="replace", line_buffering=True, write_through=True)
230
+ except (AttributeError, ValueError):
231
+ stream = err
232
+ sys.stdout = stream
233
+ try:
234
+ yield
235
+ finally:
236
+ try:
237
+ stream.flush()
238
+ if stream is not err:
239
+ stream.detach()
240
+ except Exception:
241
+ pass
242
+ sys.stdout = old_out
243
+
244
+
245
+ @contextmanager
246
+ def _in_ufo(ufo_dir: Path, argv: List[str]) -> Iterator[None]:
247
+ old_cwd, old_argv, old_path = os.getcwd(), list(sys.argv), list(sys.path)
248
+ os.chdir(ufo_dir)
249
+ sys.argv = argv
250
+ sys.path.insert(0, str(ufo_dir))
251
+ try:
252
+ yield
253
+ finally:
254
+ os.chdir(old_cwd)
255
+ sys.argv = old_argv
256
+ sys.path[:] = old_path
257
+
258
+
259
+ def safe_task_name(name: str) -> str:
260
+ return re.sub(r"[^A-Za-z0-9_-]+", "-", name).strip("-")[:60] or "forgeqe"
261
+
262
+
263
+ def run_ufo(
264
+ ufo_dir: Path,
265
+ goal: str,
266
+ task: str,
267
+ profile_name: str,
268
+ profile: Mapping[str, Any],
269
+ out_dir: Path,
270
+ on_call: Optional[Callable[[Dict[str, Any]], None]] = None,
271
+ log: Callable[[str], None] = lambda s: None,
272
+ model: Optional[DesktopModel] = None,
273
+ trace: Optional[List[Dict[str, Any]]] = None,
274
+ ) -> List[Dict[str, Any]]:
275
+ """
276
+ Run UFO on ``goal`` and return the trace; UFO's own logs for the task are copied into ``out_dir/ufo``. Actions are
277
+ appended to ``trace`` as they happen, so a caller keeps what UFO did even when UFO later raises.
278
+ """
279
+ if sys.platform != "win32":
280
+ raise ExploreError("explore drives Windows apps and runs only on Windows.")
281
+ if not (ufo_dir / "ufo" / "__init__.py").is_file():
282
+ raise ExploreError(f"UFO is not set up in {ufo_dir}. Run: forgeqe desktop setup --ufo")
283
+ write_ufo_config(ufo_dir, profile_name, profile)
284
+ task = safe_task_name(task)
285
+ trace = [] if trace is None else trace
286
+ with _in_ufo(ufo_dir, ["ufo", "--task", task, "--request", goal, "--log-level", "WARNING"]):
287
+ from ufo.automator.action_execution import ActionExecutor # type: ignore
288
+ from ufo.llm.base import BaseService # type: ignore
289
+
290
+ restore_model = install(profile_name, profile, on_call=on_call, base_service=BaseService, model=model)
291
+ restore_trace = install_trace(ActionExecutor, trace)
292
+ try:
293
+ with _no_console_answers(log), _console_to_stderr():
294
+ import asyncio
295
+
296
+ from ufo import ufo as ufo_main # type: ignore # parses sys.argv when imported
297
+
298
+ asyncio.run(ufo_main.main())
299
+ finally:
300
+ restore_trace()
301
+ restore_model()
302
+ logs = ufo_dir / "logs" / task
303
+ if logs.is_dir():
304
+ shutil.copytree(logs, out_dir / "ufo", dirs_exist_ok=True)
305
+ return trace
@@ -0,0 +1,112 @@
1
+ """
2
+ Visual checks (``expect_image``): a control or window as captured now, compared with a baseline image kept next to
3
+ the workflow, in ``<workflow name>.baselines/``.
4
+
5
+ Two pixels count as different when any colour channel differs by more than ``PIXEL_THRESHOLD`` (anti-aliasing and
6
+ ClearType shift colours a little between runs); the step fails when the share of different pixels, outside the
7
+ ``ignore`` regions (a clock, a date), is more than its ``tolerance``. A first run with no baseline saves one and
8
+ passes, so a new check is recorded on a machine the team trusts and compared on every run after.
9
+
10
+ Baseline names are checked file names (``workflow.check_image_name``) and their real path must stay inside the
11
+ baselines folder, so a workflow cannot read or overwrite another file through a name or a link.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import os
17
+ import shutil
18
+ from pathlib import Path
19
+ from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
20
+
21
+ from .workflow import WorkflowError, check_image_name
22
+
23
+ PIXEL_THRESHOLD = 32
24
+ DEFAULT_TOLERANCE = 0.01
25
+
26
+
27
+ def baselines_dir(workflow_file: str | os.PathLike) -> Path:
28
+ p = Path(workflow_file)
29
+ return p.with_name(f"{p.stem}.baselines")
30
+
31
+
32
+ def baseline_path(folder: Path, name: str) -> Path:
33
+ """``folder/name`` for a checked image name, refusing a path or a link that leads out of ``folder``."""
34
+ check_image_name("expect_image", name)
35
+ folder.mkdir(parents=True, exist_ok=True)
36
+ root = Path(os.path.realpath(folder))
37
+ if root.parent != Path(os.path.realpath(folder.parent)):
38
+ raise WorkflowError(f"expect_image: {folder} is a link to another folder; baselines stay next to the workflow")
39
+ path = folder / name
40
+ real = Path(os.path.realpath(path))
41
+ if real.parent != root:
42
+ raise WorkflowError(f"expect_image: {name} leads outside {folder}")
43
+ return path
44
+
45
+
46
+ def compare(baseline: bytes, actual: bytes, ignore: Sequence[Sequence[int]] = (), threshold: int = PIXEL_THRESHOLD) -> Tuple[Optional[float], Optional[bytes], str]:
47
+ """``(share of pixels that differ, diff PNG, problem)``; the share is None when the sizes differ."""
48
+ from PIL import Image, ImageChops
49
+
50
+ a = Image.open(io.BytesIO(baseline)).convert("RGB")
51
+ b = Image.open(io.BytesIO(actual)).convert("RGB")
52
+ if a.size != b.size:
53
+ return None, None, f"the image is {b.size[0]}x{b.size[1]} now and {a.size[0]}x{a.size[1]} in the baseline (another screen scale or window size?)"
54
+ r, g, bl = ImageChops.difference(a, b).split()
55
+ worst = ImageChops.lighter(ImageChops.lighter(r, g), bl)
56
+ mask = worst.point(lambda v: 255 if v > threshold else 0)
57
+ counted = a.size[0] * a.size[1]
58
+ if ignore:
59
+ blank = Image.new("L", a.size, 0)
60
+ keep = Image.new("L", a.size, 255)
61
+ for x, y, w, h in ignore:
62
+ box = (x, y, min(x + w, a.size[0]), min(y + h, a.size[1]))
63
+ if box[0] < box[2] and box[1] < box[3]:
64
+ keep.paste(blank.crop(box), box)
65
+ mask = ImageChops.multiply(mask, keep)
66
+ counted = max(1, keep.histogram()[255])
67
+ differing = mask.histogram()[255]
68
+ share = differing / max(1, counted)
69
+ diff = None
70
+ if differing:
71
+ faded = Image.blend(b, Image.new("RGB", b.size, (255, 255, 255)), 0.6)
72
+ red = Image.new("RGB", b.size, (220, 0, 0))
73
+ out = io.BytesIO()
74
+ Image.composite(red, faded, mask).save(out, format="PNG")
75
+ diff = out.getvalue()
76
+ return share, diff, ""
77
+
78
+
79
+ def image_checker(workflow_file: str | os.PathLike, out_dir: Path, update: bool = False):
80
+ """The ``compare_image`` callback for replay: baselines next to the workflow, this run's images in ``out_dir/images``."""
81
+ folder = baselines_dir(workflow_file)
82
+ images = out_dir / "images"
83
+
84
+ def check(index: int, step: Mapping[str, Any], png: bytes) -> Tuple[bool, str, Dict[str, Any]]:
85
+ name = step["image"]
86
+ path = baseline_path(folder, name)
87
+ images.mkdir(parents=True, exist_ok=True)
88
+ actual = f"images/{index:03d}-actual.png"
89
+ (out_dir / actual).write_bytes(png)
90
+ files: Dict[str, Any] = {"name": name, "actual": actual}
91
+ if update or not path.is_file():
92
+ had = path.is_file()
93
+ path.write_bytes(png)
94
+ files["baseline_saved"] = True
95
+ return True, f"{'updated' if had else 'saved'} the baseline {name}; later runs compare against it", files
96
+ baseline = f"images/{index:03d}-baseline.png"
97
+ shutil.copyfile(path, out_dir / baseline)
98
+ files["baseline"] = baseline
99
+ tolerance = float(step.get("tolerance", DEFAULT_TOLERANCE))
100
+ share, diff, problem = compare(path.read_bytes(), png, step.get("ignore") or [])
101
+ if share is None:
102
+ return False, f"{name}: {problem}", files
103
+ files["differs"] = round(share, 5)
104
+ if diff:
105
+ files["diff"] = f"images/{index:03d}-diff.png"
106
+ (out_dir / files["diff"]).write_bytes(diff)
107
+ if share > tolerance:
108
+ return False, f"{name}: {share:.2%} of the pixels differ from the baseline (at most {tolerance:.2%} may)", files
109
+ return True, f"{name}: matches the baseline ({share:.2%} of the pixels differ)", files
110
+
111
+ return check
112
+