@capagents/forgeqe 1.3.2 → 2.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/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,345 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Replay a desktop workflow without a model.
|
|
3
|
+
|
|
4
|
+
Steps run in order on a ``Driver`` and the run stops at the first failure. When a step's control is not found and a
|
|
5
|
+
``heal`` callback is given, it may propose a new target once; the step is retried with it and the proposal is
|
|
6
|
+
returned for review, never written into the workflow here.
|
|
7
|
+
|
|
8
|
+
A step waits up to its ``timeout`` for its control, closing any ``dismiss`` dialog that is in the way, and is tried
|
|
9
|
+
again up to ``retries`` times; ``expect_image`` hands what it captured to ``compare_image``.
|
|
10
|
+
|
|
11
|
+
``{{secret:NAME}}`` is filled only for names the caller allows (``forgeqe.yaml`` ``desktop.secrets``), so a workflow
|
|
12
|
+
cannot ask for ForgeQE's own keys or any other variable in the environment.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Protocol
|
|
19
|
+
|
|
20
|
+
from .apps import AppSpec, resolve_app
|
|
21
|
+
from .workflow import MOBILE_FORMAT, MOBILE_RETRY_OPS, RETRY_OPS, WorkflowError, env_secret, fill, fill_targets, parse_chord, secret_names, validate
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ControlNotFound(LookupError):
|
|
25
|
+
"""No control in the app's windows matches a step's target."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Driver(Protocol):
|
|
29
|
+
def launch(self, app: AppSpec) -> None: ...
|
|
30
|
+
def find(self, target: Mapping[str, Any], timeout: float) -> Any: ...
|
|
31
|
+
def click(self, control: Any, double: bool = False) -> None: ...
|
|
32
|
+
def type_text(self, control: Any, text: str) -> None: ...
|
|
33
|
+
def select(self, control: Any, value: str) -> None: ...
|
|
34
|
+
def press(self, chord: List[str]) -> None: ...
|
|
35
|
+
def text_of(self, control: Any) -> str: ...
|
|
36
|
+
def close(self) -> None: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class StepResult:
|
|
41
|
+
index: int
|
|
42
|
+
op: str
|
|
43
|
+
ok: bool
|
|
44
|
+
ms: int
|
|
45
|
+
error: str = ""
|
|
46
|
+
healed_target: Optional[Dict[str, Any]] = None
|
|
47
|
+
attempts: int = 1
|
|
48
|
+
# Labels of the dialogs dismissed while this step ran.
|
|
49
|
+
dismissed: List[str] = field(default_factory=list)
|
|
50
|
+
# expect_image: the files written for the step (baseline, actual, diff) and how much differed.
|
|
51
|
+
images: Optional[Dict[str, Any]] = None
|
|
52
|
+
note: str = ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ReplayResult:
|
|
57
|
+
ok: bool
|
|
58
|
+
steps: List[StepResult] = field(default_factory=list)
|
|
59
|
+
stopped: bool = False
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def healed(self) -> List[StepResult]:
|
|
63
|
+
return [s for s in self.steps if s.healed_target is not None]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
Heal = Callable[[int, Dict[str, Any], str], Optional[Dict[str, Any]]]
|
|
67
|
+
# expect_image: (step index, the step, the PNG captured now) -> (ok, message, images) from the caller, which owns the
|
|
68
|
+
# baselines folder and the run folder.
|
|
69
|
+
CompareImage = Callable[[int, Mapping[str, Any], bytes], "tuple[bool, str, Dict[str, Any]]"]
|
|
70
|
+
|
|
71
|
+
POLL_SECONDS = 0.25
|
|
72
|
+
MAX_DISMISS_ROUNDS = 3
|
|
73
|
+
MAX_SCROLLS = 15
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _label(target: Mapping[str, Any]) -> str:
|
|
77
|
+
return str(target.get("name") or target.get("automation_id") or "")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def replay(
|
|
81
|
+
workflow: Mapping[str, Any],
|
|
82
|
+
driver: Driver,
|
|
83
|
+
apps: Mapping[str, AppSpec],
|
|
84
|
+
variables: Optional[Mapping[str, Any]] = None,
|
|
85
|
+
allowed_secrets: Iterable[str] = (),
|
|
86
|
+
secret: Callable[[str], Optional[str]] = env_secret,
|
|
87
|
+
heal: Optional[Heal] = None,
|
|
88
|
+
find_timeout: float = 10.0,
|
|
89
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
90
|
+
must_exist: bool = True,
|
|
91
|
+
on_step: Optional[Callable[[StepResult], None]] = None,
|
|
92
|
+
should_stop: Callable[[], bool] = lambda: False,
|
|
93
|
+
compare_image: Optional[CompareImage] = None,
|
|
94
|
+
clock: Callable[[], float] = time.time,
|
|
95
|
+
) -> ReplayResult:
|
|
96
|
+
wf = validate(dict(workflow))
|
|
97
|
+
spec = resolve_app(wf["app"], apps, must_exist=must_exist)
|
|
98
|
+
allowed = {str(n) for n in allowed_secrets}
|
|
99
|
+
values: Dict[str, Any] = {**wf["variables"], **dict(variables or {})}
|
|
100
|
+
wf = fill_targets(wf, values)
|
|
101
|
+
|
|
102
|
+
def allowed_secret(name: str) -> Optional[str]:
|
|
103
|
+
if name not in allowed:
|
|
104
|
+
raise WorkflowError(f"secret {name} is not listed in forgeqe.yaml desktop.secrets")
|
|
105
|
+
return secret(name)
|
|
106
|
+
|
|
107
|
+
# Every placeholder is checked before the app starts, so a bad workflow fails without touching the desktop.
|
|
108
|
+
for step in wf["steps"]:
|
|
109
|
+
for k in ("text", "value"):
|
|
110
|
+
if k in step:
|
|
111
|
+
for name in secret_names(step[k]):
|
|
112
|
+
if name not in allowed:
|
|
113
|
+
raise WorkflowError(f"secret {name} is not listed in forgeqe.yaml desktop.secrets")
|
|
114
|
+
if step["op"] == "expect_image" and compare_image is None:
|
|
115
|
+
raise WorkflowError("this workflow has expect_image steps, and this replay has nowhere to keep baseline images")
|
|
116
|
+
|
|
117
|
+
runner = _Runner(driver, spec, values, allowed_secret, wf.get("timeout", find_timeout), wf.get("dismiss") or [], sleep, clock, compare_image)
|
|
118
|
+
runner.mobile = wf["format"] == MOBILE_FORMAT
|
|
119
|
+
retry_ops = MOBILE_RETRY_OPS if runner.mobile else RETRY_OPS
|
|
120
|
+
default_retries = int(wf.get("retries", 0))
|
|
121
|
+
result = ReplayResult(ok=True)
|
|
122
|
+
try:
|
|
123
|
+
for i, step in enumerate(wf["steps"], 1):
|
|
124
|
+
if should_stop():
|
|
125
|
+
result.ok, result.stopped = False, True
|
|
126
|
+
break
|
|
127
|
+
started = clock()
|
|
128
|
+
op = step["op"]
|
|
129
|
+
res = StepResult(index=i, op=op, ok=True, ms=0)
|
|
130
|
+
runner.res = res
|
|
131
|
+
tries = 1 + (int(step.get("retries", default_retries)) if op in retry_ops else 0)
|
|
132
|
+
error: Optional[Exception] = None
|
|
133
|
+
for attempt in range(1, tries + 1):
|
|
134
|
+
res.attempts = attempt
|
|
135
|
+
try:
|
|
136
|
+
runner.run(i, step)
|
|
137
|
+
error = None
|
|
138
|
+
break
|
|
139
|
+
except WorkflowError as e:
|
|
140
|
+
# A workflow that cannot run as written does not get better by trying again.
|
|
141
|
+
error = e
|
|
142
|
+
break
|
|
143
|
+
except Exception as e:
|
|
144
|
+
error = e
|
|
145
|
+
if attempt < tries:
|
|
146
|
+
sleep(min(1.0 * attempt, 5.0))
|
|
147
|
+
runner.dismiss_dialogs()
|
|
148
|
+
if isinstance(error, ControlNotFound) and "target" in step:
|
|
149
|
+
proposal = heal(i, dict(step["target"]), str(error)) if heal else None
|
|
150
|
+
if proposal is not None:
|
|
151
|
+
try:
|
|
152
|
+
# The proposal is checked like any target, then tried once.
|
|
153
|
+
healed = validate({**wf, "steps": [{**step, "target": proposal}]})["steps"][0]
|
|
154
|
+
runner.run(i, healed)
|
|
155
|
+
res.healed_target = healed["target"]
|
|
156
|
+
error = None
|
|
157
|
+
except Exception as e2:
|
|
158
|
+
error = ControlNotFound(f"{error}; the healed target failed too: {e2}")
|
|
159
|
+
if error is not None:
|
|
160
|
+
res.ok, res.error = False, str(error) + (f" (after {res.attempts} tries)" if res.attempts > 1 else "")
|
|
161
|
+
res.ms = int((clock() - started) * 1000)
|
|
162
|
+
result.steps.append(res)
|
|
163
|
+
if on_step is not None:
|
|
164
|
+
on_step(res)
|
|
165
|
+
if not res.ok:
|
|
166
|
+
result.ok = False
|
|
167
|
+
break
|
|
168
|
+
finally:
|
|
169
|
+
if not result.ok or not wf["steps"] or wf["steps"][-1]["op"] != "close":
|
|
170
|
+
try:
|
|
171
|
+
driver.close()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _Runner:
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
driver: Driver,
|
|
181
|
+
spec: AppSpec,
|
|
182
|
+
values: Mapping[str, Any],
|
|
183
|
+
secret: Callable[[str], Optional[str]],
|
|
184
|
+
timeout: float,
|
|
185
|
+
dismiss: List[Mapping[str, Any]],
|
|
186
|
+
sleep: Callable[[float], None],
|
|
187
|
+
clock: Callable[[], float],
|
|
188
|
+
compare_image: Optional[CompareImage],
|
|
189
|
+
) -> None:
|
|
190
|
+
self.driver, self.spec, self.values, self.secret = driver, spec, values, secret
|
|
191
|
+
self.timeout, self.dismiss, self.sleep, self.clock, self.compare_image = timeout, dismiss, sleep, clock, compare_image
|
|
192
|
+
self.res = StepResult(index=0, op="", ok=True, ms=0)
|
|
193
|
+
self.mobile = False
|
|
194
|
+
|
|
195
|
+
def dismiss_dialogs(self) -> bool:
|
|
196
|
+
"""Click every ``dismiss`` control that is on screen now; whether any was."""
|
|
197
|
+
clicked = False
|
|
198
|
+
for target in self.dismiss:
|
|
199
|
+
try:
|
|
200
|
+
control = self.driver.find(target, 0)
|
|
201
|
+
except ControlNotFound:
|
|
202
|
+
continue
|
|
203
|
+
except Exception:
|
|
204
|
+
continue
|
|
205
|
+
try:
|
|
206
|
+
self.driver.click(control)
|
|
207
|
+
self.res.dismissed.append(_label(target))
|
|
208
|
+
clicked = True
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
return clicked
|
|
212
|
+
|
|
213
|
+
def find(self, target: Mapping[str, Any], timeout: float, ready: bool = False) -> Any:
|
|
214
|
+
"""The control, waiting up to ``timeout``; a dialog in ``dismiss`` that hides it is closed and the search goes on."""
|
|
215
|
+
rounds = 0
|
|
216
|
+
while True:
|
|
217
|
+
started = self.clock()
|
|
218
|
+
try:
|
|
219
|
+
control = self.driver.find(target, timeout)
|
|
220
|
+
wait_ready = getattr(self.driver, "wait_ready", None)
|
|
221
|
+
if ready and wait_ready is not None:
|
|
222
|
+
wait_ready(control, max(0.0, timeout - (self.clock() - started)))
|
|
223
|
+
return control
|
|
224
|
+
except ControlNotFound:
|
|
225
|
+
# A dialog was in the way: close it and look again, a few times at most (a dialog that keeps coming
|
|
226
|
+
# back is a failure to see in the report, not a loop).
|
|
227
|
+
if rounds < MAX_DISMISS_ROUNDS and self.dismiss and self.dismiss_dialogs():
|
|
228
|
+
rounds += 1
|
|
229
|
+
continue
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
def run(self, index: int, step: Mapping[str, Any]) -> None:
|
|
233
|
+
op = step["op"]
|
|
234
|
+
timeout = float(step.get("timeout", self.timeout))
|
|
235
|
+
driver = self.driver
|
|
236
|
+
if op == "launch":
|
|
237
|
+
if "timeout" in step:
|
|
238
|
+
driver.launch(self.spec, timeout=timeout) # type: ignore[call-arg]
|
|
239
|
+
else:
|
|
240
|
+
driver.launch(self.spec)
|
|
241
|
+
elif op == "close":
|
|
242
|
+
driver.close()
|
|
243
|
+
elif op == "wait":
|
|
244
|
+
self.sleep(float(step["seconds"]))
|
|
245
|
+
elif op == "keys" and self.mobile:
|
|
246
|
+
driver.press_key(step["keys"]) # type: ignore[attr-defined]
|
|
247
|
+
elif op == "keys":
|
|
248
|
+
driver.press(parse_chord(step["keys"]))
|
|
249
|
+
elif op == "back":
|
|
250
|
+
driver.back() # type: ignore[attr-defined]
|
|
251
|
+
elif op == "hide_keyboard":
|
|
252
|
+
driver.hide_keyboard() # type: ignore[attr-defined]
|
|
253
|
+
elif op == "swipe":
|
|
254
|
+
control = self.find(step["target"], timeout) if "target" in step else None
|
|
255
|
+
driver.swipe(step["direction"], control) # type: ignore[attr-defined]
|
|
256
|
+
elif op == "scroll_to":
|
|
257
|
+
self._scroll_to(step, timeout)
|
|
258
|
+
elif op in ("tap", "double_tap"):
|
|
259
|
+
driver.click(self.find(step["target"], timeout, ready=True), double=op == "double_tap")
|
|
260
|
+
elif op == "long_press":
|
|
261
|
+
driver.long_press(self.find(step["target"], timeout, ready=True)) # type: ignore[attr-defined]
|
|
262
|
+
elif op == "wait_for":
|
|
263
|
+
self._wait_for(step, timeout)
|
|
264
|
+
elif op == "expect_image":
|
|
265
|
+
self._expect_image(index, step, timeout)
|
|
266
|
+
else:
|
|
267
|
+
control = self.find(step["target"], timeout, ready=op in ("click", "double_click", "type", "select"))
|
|
268
|
+
if op in ("click", "double_click"):
|
|
269
|
+
driver.click(control, double=op == "double_click")
|
|
270
|
+
elif op == "type":
|
|
271
|
+
driver.type_text(control, fill(step["text"], self.values, self.secret))
|
|
272
|
+
elif op == "select":
|
|
273
|
+
driver.select(control, fill(step["value"], self.values, self.secret))
|
|
274
|
+
elif op == "expect" and "text" in step:
|
|
275
|
+
want = fill(step["text"], self.values, self.secret)
|
|
276
|
+
got = driver.text_of(control)
|
|
277
|
+
if want not in got:
|
|
278
|
+
if secret_names(step["text"]):
|
|
279
|
+
raise AssertionError("the control's text does not contain the expected value (it holds a secret, so it is not shown)")
|
|
280
|
+
raise AssertionError(f"expected {want!r} in the control's text, found {got!r}")
|
|
281
|
+
|
|
282
|
+
def _scroll_to(self, step: Mapping[str, Any], timeout: float) -> None:
|
|
283
|
+
"""Scroll (default: down the page) until the target is on screen, up to the timeout or MAX_SCROLLS."""
|
|
284
|
+
direction = step.get("direction", "down")
|
|
285
|
+
deadline = self.clock() + timeout
|
|
286
|
+
for _ in range(MAX_SCROLLS):
|
|
287
|
+
try:
|
|
288
|
+
self.driver.find(step["target"], 0)
|
|
289
|
+
return
|
|
290
|
+
except ControlNotFound:
|
|
291
|
+
pass
|
|
292
|
+
if self.clock() >= deadline:
|
|
293
|
+
break
|
|
294
|
+
self.driver.scroll(direction) # type: ignore[attr-defined]
|
|
295
|
+
self.sleep(POLL_SECONDS)
|
|
296
|
+
self.driver.find(step["target"], 0)
|
|
297
|
+
|
|
298
|
+
def _wait_for(self, step: Mapping[str, Any], timeout: float) -> None:
|
|
299
|
+
target = step["target"]
|
|
300
|
+
deadline = self.clock() + timeout
|
|
301
|
+
if step.get("gone"):
|
|
302
|
+
while True:
|
|
303
|
+
try:
|
|
304
|
+
self.driver.find(target, 0)
|
|
305
|
+
except ControlNotFound:
|
|
306
|
+
return
|
|
307
|
+
if self.clock() >= deadline:
|
|
308
|
+
raise AssertionError(f"{_label(target)!r} was still there after {timeout:g} s")
|
|
309
|
+
# A dialog ("Save changes?") can be what keeps it there.
|
|
310
|
+
self.dismiss_dialogs()
|
|
311
|
+
self.sleep(POLL_SECONDS)
|
|
312
|
+
control = self.find(target, timeout)
|
|
313
|
+
if "text" not in step:
|
|
314
|
+
return
|
|
315
|
+
want = fill(step["text"], self.values, self.secret)
|
|
316
|
+
while True:
|
|
317
|
+
try:
|
|
318
|
+
got = self.driver.text_of(control)
|
|
319
|
+
except Exception:
|
|
320
|
+
got = ""
|
|
321
|
+
if want in got:
|
|
322
|
+
return
|
|
323
|
+
if self.clock() >= deadline:
|
|
324
|
+
if secret_names(step["text"]):
|
|
325
|
+
raise AssertionError(f"the control did not show the expected value within {timeout:g} s (it holds a secret, so it is not shown)")
|
|
326
|
+
raise AssertionError(f"expected {want!r} in the control's text within {timeout:g} s, found {got!r}")
|
|
327
|
+
self.sleep(POLL_SECONDS)
|
|
328
|
+
try:
|
|
329
|
+
control = self.driver.find(target, 0)
|
|
330
|
+
except ControlNotFound:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
def _expect_image(self, index: int, step: Mapping[str, Any], timeout: float) -> None:
|
|
334
|
+
capture = getattr(self.driver, "image_png", None)
|
|
335
|
+
if capture is None or self.compare_image is None:
|
|
336
|
+
raise WorkflowError("this driver cannot capture images for expect_image")
|
|
337
|
+
control = self.find(step["target"], timeout) if "target" in step else None
|
|
338
|
+
png = capture(control)
|
|
339
|
+
if not png:
|
|
340
|
+
raise AssertionError("the control could not be captured (is the window minimised, or the screen locked?)")
|
|
341
|
+
ok, message, images = self.compare_image(index, step, png)
|
|
342
|
+
self.res.images = images
|
|
343
|
+
self.res.note = message if ok else ""
|
|
344
|
+
if not ok:
|
|
345
|
+
raise AssertionError(message)
|