@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.
- package/HOW_TO_USE.md +373 -9
- package/README.md +55 -4
- package/docs/ARCHITECTURE.md +62 -1
- package/docs/CONTAINERS.md +227 -0
- package/docs/COST_ARCHITECTURE.md +32 -0
- package/docs/DESKTOP-CI.md +87 -0
- package/docs/DESKTOP.md +387 -0
- package/docs/MOBILE.md +228 -0
- package/docs/feature-test-prompts.md +25 -0
- package/docs/security-data-flow.html +440 -3
- package/docs/user-guide.html +797 -415
- package/env.example.yaml +67 -0
- package/forgeqe.example.yaml +56 -1
- package/llms.example.json +7 -0
- package/package.json +4 -1
- package/prompts/brain.md +31 -0
- package/prompts/find-tests.md +1 -0
- package/py/forgeqe_desktop/__init__.py +1 -0
- package/py/forgeqe_desktop/__main__.py +3 -0
- package/py/forgeqe_desktop/apps.py +80 -0
- package/py/forgeqe_desktop/checks.py +168 -0
- package/py/forgeqe_desktop/cli.py +90 -0
- package/py/forgeqe_desktop/handhooks.py +293 -0
- package/py/forgeqe_desktop/handrecord.py +239 -0
- package/py/forgeqe_desktop/heal.py +107 -0
- package/py/forgeqe_desktop/models.py +208 -0
- package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
- package/py/forgeqe_desktop/record.py +150 -0
- package/py/forgeqe_desktop/replay.py +345 -0
- package/py/forgeqe_desktop/service.py +356 -0
- package/py/forgeqe_desktop/session.py +60 -0
- package/py/forgeqe_desktop/ufo_bridge.py +86 -0
- package/py/forgeqe_desktop/ufo_runner.py +305 -0
- package/py/forgeqe_desktop/visual.py +112 -0
- package/py/forgeqe_desktop/workflow.py +338 -0
- package/py/forgeqe_engine/llm.py +54 -4
- package/py/forgeqe_engine/model_params.py +169 -0
- package/py/forgeqe_mobile/__init__.py +1 -0
- package/py/forgeqe_mobile/__main__.py +3 -0
- package/py/forgeqe_mobile/appium_driver.py +342 -0
- package/py/forgeqe_mobile/apps.py +84 -0
- package/py/forgeqe_mobile/cli.py +70 -0
- package/py/forgeqe_mobile/explore.py +203 -0
- package/py/forgeqe_mobile/hyperexecute.py +261 -0
- package/py/forgeqe_mobile/maestro.py +321 -0
- package/py/forgeqe_mobile/service.py +332 -0
- package/skills/testcontainers/SKILL.md +42 -0
- package/src/agents.ts +11 -2
- package/src/api/plan.ts +2 -0
- package/src/api/write.ts +2 -0
- package/src/auth/copilot.ts +130 -0
- package/src/auth/signin.ts +54 -1
- package/src/brain/index.ts +26 -4
- package/src/capabilities/guide.ts +2 -0
- package/src/capabilities/registry.ts +110 -2
- package/src/capabilities/status.ts +15 -0
- package/src/cases/rca.ts +2 -0
- package/src/childEnv.ts +4 -1
- package/src/cli/args.ts +3 -2
- package/src/cli/help.ts +24 -3
- package/src/cli/index.ts +47 -3
- package/src/code/find.ts +26 -0
- package/src/code/loop.ts +4 -1
- package/src/code/prompt.ts +2 -1
- package/src/code/prreview.ts +2 -0
- package/src/code/review.ts +6 -2
- package/src/code/runtime.ts +34 -3
- package/src/commands.ts +6 -1
- package/src/config.ts +159 -3
- package/src/containers/command.ts +68 -0
- package/src/containers/compose.ts +173 -0
- package/src/containers/config.ts +283 -0
- package/src/containers/mock.ts +156 -0
- package/src/containers/run.ts +362 -0
- package/src/containers/tests.ts +80 -0
- package/src/context.ts +33 -6
- package/src/copilot.ts +155 -0
- package/src/desktop/bridge.ts +229 -0
- package/src/desktop/command.ts +350 -0
- package/src/desktop/edit.ts +217 -0
- package/src/desktop/junit.ts +35 -0
- package/src/desktop/report.ts +155 -0
- package/src/desktop/run.ts +252 -0
- package/src/desktop/setup.ts +134 -0
- package/src/engine/bridge.ts +12 -5
- package/src/engine/setup.ts +3 -3
- package/src/environments.ts +566 -0
- package/src/evals/domain.ts +1 -0
- package/src/explore/charters.ts +2 -0
- package/src/guardrails.ts +145 -0
- package/src/heal/loop.ts +4 -1
- package/src/heal/prompt.ts +2 -1
- package/src/init.ts +5 -2
- package/src/llm.ts +23 -2
- package/src/mcp/attachtools.ts +16 -4
- package/src/mcp/captools.ts +27 -0
- package/src/mcp/containertools.ts +212 -0
- package/src/mcp/desktoptools.ts +334 -0
- package/src/mcp/mobiletools.ts +132 -0
- package/src/mcp/reporttools.ts +17 -2
- package/src/mcp/repotests.ts +22 -3
- package/src/mcp/routing.ts +11 -5
- package/src/mcp/scope.ts +210 -0
- package/src/mcp/server.ts +80 -5
- package/src/mcp/tools.ts +38 -1
- package/src/mobile/command.ts +245 -0
- package/src/mobile/run.ts +226 -0
- package/src/pack/pipeline.ts +44 -2
- package/src/refine.ts +3 -0
- package/src/report/html.ts +3 -0
- package/src/report/template.html +48 -48
- package/src/run.ts +7 -3
- package/src/tokenomics/ledger.ts +3 -1
- package/src/tui/App.tsx +23 -0
- 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
|
+
|