@capagents/forgeqe 1.1.2 → 1.1.4
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 +5 -5
- package/README.md +8 -4
- package/browserstack.example.yaml +14 -0
- package/docs/security-data-flow.html +154 -86
- package/docs/user-guide.html +305 -131
- package/forgeqe.example.yaml +12 -6
- package/package.json +4 -2
- package/py/forgeqe_engine/browser.py +78 -11
- package/py/forgeqe_engine/service.py +13 -0
- package/src/auth/signin.ts +2 -2
- package/src/bi.ts +338 -243
- package/src/capabilities/guide.ts +322 -140
- package/src/capabilities/registry.ts +3 -3
- package/src/cli/help.ts +16 -7
- package/src/cli/index.ts +51 -9
- package/src/config.ts +115 -12
- package/src/heal/loop.ts +3 -1
- package/src/init.ts +18 -0
- package/src/mcp/tools.ts +26 -4
- package/src/monitor/run.ts +3 -1
- package/src/run.ts +9 -2
- package/src/suite/run.ts +3 -1
- package/testmu.example.yaml +13 -0
package/forgeqe.example.yaml
CHANGED
|
@@ -4,6 +4,10 @@ llm:
|
|
|
4
4
|
|
|
5
5
|
browser:
|
|
6
6
|
# provider: local # local | browserstack | testmu
|
|
7
|
+
# providers: # keep BrowserStack / TestMu AI details in separate YAML files
|
|
8
|
+
# browserstack: ./.forgeqe/browserstack.yaml
|
|
9
|
+
# testmu: ./.forgeqe/testmu.yaml
|
|
10
|
+
# # ForgeQE's agent can switch to one of these per request with browser_provider
|
|
7
11
|
headless: true
|
|
8
12
|
max_steps: 40 # a checkpoint: a run still making progress carries on, up to 3x
|
|
9
13
|
timeout_seconds: 600 # whole run
|
|
@@ -15,8 +19,8 @@ browser:
|
|
|
15
19
|
judge: false # true: a second model call checks each finished run against the goal and can fail it
|
|
16
20
|
overlay: true # visible browser: ForgeQE start page, edge glow, a bar with the current step, your mouse and keyboard locked while the agent works
|
|
17
21
|
# start_url: https://staging.example.com # optional; omit to let the agent choose the site
|
|
18
|
-
# remote: #
|
|
19
|
-
# browser:
|
|
22
|
+
# remote: # inline settings for the selected remote provider; override the provider file when both are set
|
|
23
|
+
# browser: chrome # BrowserStack: chrome or edge (ForgeQE drives the browser over CDP)
|
|
20
24
|
# browser_version: latest
|
|
21
25
|
# os: osx
|
|
22
26
|
# os_version: sequoia
|
|
@@ -109,13 +113,15 @@ report:
|
|
|
109
113
|
screenshots: all # all | failures | off: step screenshots in report.html
|
|
110
114
|
accessibility: true # check each page the agent visits (names, labels, image text, contrast, lang, zoom)
|
|
111
115
|
|
|
112
|
-
# Offline BI with Evidence: ForgeQE exports CSV datasets and runs Evidence
|
|
113
|
-
# forgeqe bi init scaffolds the Evidence project, forgeqe bi sync refreshes
|
|
114
|
-
#
|
|
116
|
+
# Offline BI with Evidence: ForgeQE exports CSV datasets and runs Evidence (@evidence-dev/evidence 40.x, needs Node.js)
|
|
117
|
+
# to build a static site, no always-on server. forgeqe bi init scaffolds the Evidence project, forgeqe bi sync refreshes
|
|
118
|
+
# the data, forgeqe bi build does both and builds the site; without Node.js, or with --static, it writes ForgeQE's own
|
|
119
|
+
# page instead. The datasets below are optional: local ForgeQE suite/readiness data exports either way.
|
|
120
|
+
# Dataset ids are table names (forgeqe.<id>): letters, digits, and _.
|
|
115
121
|
# bi:
|
|
116
122
|
# title: ForgeQE BI
|
|
117
123
|
# # evidence_dir: ./evidence # the managed Evidence project
|
|
118
|
-
# # data_dir: ./evidence/sources/forgeqe
|
|
124
|
+
# # data_dir: ./evidence/sources/forgeqe # CSV exports; copied there when set elsewhere
|
|
119
125
|
# # build_dir: ./.forgeqe/reports/bi # static HTML output from evidence build
|
|
120
126
|
# datasets:
|
|
121
127
|
# ado_current_sprint:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "ForgeQE: a browser agent CLI that runs goals and test cases in Chrome and turns passing runs into tests in your repo",
|
|
3
|
+
"version": "1.1.4",
|
|
4
|
+
"description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
"README.md",
|
|
21
21
|
"HOW_TO_USE.md",
|
|
22
22
|
"forgeqe-architecture.html",
|
|
23
|
+
"browserstack.example.yaml",
|
|
23
24
|
"forgeqe.example.yaml",
|
|
25
|
+
"testmu.example.yaml",
|
|
24
26
|
"llms.example.json"
|
|
25
27
|
],
|
|
26
28
|
"engines": {
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import contextlib
|
|
6
7
|
import json
|
|
7
8
|
import logging
|
|
8
9
|
import os
|
|
9
10
|
import time
|
|
10
|
-
from importlib.metadata import version as package_version
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any, Dict, Optional
|
|
13
13
|
from urllib.parse import quote, urlparse
|
|
@@ -140,13 +140,6 @@ def _remote_secret(name: str, provider: str, label: str) -> str:
|
|
|
140
140
|
raise RuntimeError(f'{provider}: set {name} ({label}) in the environment, or save it with "forgeqe auth login {provider}".')
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
def _playwright_version() -> Optional[str]:
|
|
144
|
-
try:
|
|
145
|
-
return package_version("playwright")
|
|
146
|
-
except Exception:
|
|
147
|
-
return None
|
|
148
|
-
|
|
149
|
-
|
|
150
143
|
def _browserstack_caps(remote: Dict[str, Any]) -> Dict[str, Any]:
|
|
151
144
|
caps: Dict[str, Any] = {
|
|
152
145
|
"browser": str(remote.get("browser") or ""),
|
|
@@ -164,9 +157,6 @@ def _browserstack_caps(remote: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
164
157
|
caps["name"] = str(remote["name"])
|
|
165
158
|
if remote.get("local") is True:
|
|
166
159
|
caps["browserstack.local"] = True
|
|
167
|
-
pw = _playwright_version()
|
|
168
|
-
if pw:
|
|
169
|
-
caps["client.playwrightVersion"] = pw
|
|
170
160
|
extra = remote.get("extra")
|
|
171
161
|
if isinstance(extra, dict):
|
|
172
162
|
caps.update(extra)
|
|
@@ -229,6 +219,83 @@ def browser_connection_label(provider: str, launch: Dict[str, Any], remote: Opti
|
|
|
229
219
|
return " · ".join(x for x in [provider, browser, target] if x)
|
|
230
220
|
|
|
231
221
|
|
|
222
|
+
_PAGE_PASSWORD_JS = """(() => ({
|
|
223
|
+
url: location.href,
|
|
224
|
+
filled: Array.from(document.querySelectorAll('input[type=password]')).some((e) => (e.value || '').length > 0),
|
|
225
|
+
}))()"""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def typed_into_password(history_item: Any) -> bool:
|
|
229
|
+
"""Whether a step typed into a password field: its actions line up with the elements they touched."""
|
|
230
|
+
actions = getattr(getattr(history_item, "model_output", None), "action", None) or []
|
|
231
|
+
elements = getattr(getattr(history_item, "state", None), "interacted_element", None) or []
|
|
232
|
+
for action, element in zip(actions, elements):
|
|
233
|
+
attributes = (getattr(element, "attributes", None) or {}) if element is not None else {}
|
|
234
|
+
if str(attributes.get("type") or "").lower() != "password":
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
name = next(iter(action.model_dump(exclude_unset=True)), "")
|
|
238
|
+
except Exception:
|
|
239
|
+
name = ""
|
|
240
|
+
if any(word in name for word in ("input", "type", "fill")):
|
|
241
|
+
return True
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class FreshTabAfterPassword:
|
|
246
|
+
"""
|
|
247
|
+
Remote grids only: after a password form is submitted, TestMu AI's Chrome stops taking real mouse and keyboard
|
|
248
|
+
input on that tab (most likely its password manager's bubble), while the page itself still works: clicks report
|
|
249
|
+
success and change nothing. The same URL in a fresh tab takes input again, with the session's cookies intact.
|
|
250
|
+
Armed when a step types into a password field (or one is filled when a step ends); fires once the page has left
|
|
251
|
+
that URL with no filled password field, so a failed sign-in that stays on the form does not move tabs.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def __init__(self) -> None:
|
|
255
|
+
self.armed_at: Optional[str] = None
|
|
256
|
+
|
|
257
|
+
async def after_step(self, browser: Any, history_item: Any) -> Optional[str]:
|
|
258
|
+
"""Returns the URL reopened in a fresh tab, or None."""
|
|
259
|
+
if typed_into_password(history_item):
|
|
260
|
+
self.armed_at = str(getattr(getattr(history_item, "state", None), "url", "") or "")
|
|
261
|
+
try:
|
|
262
|
+
session = await asyncio.wait_for(browser.get_or_create_cdp_session(), timeout=2)
|
|
263
|
+
r = await asyncio.wait_for(
|
|
264
|
+
session.cdp_client.send.Runtime.evaluate(
|
|
265
|
+
params={"expression": _PAGE_PASSWORD_JS, "returnByValue": True}, session_id=session.session_id
|
|
266
|
+
),
|
|
267
|
+
timeout=2,
|
|
268
|
+
)
|
|
269
|
+
state = ((r or {}).get("result") or {}).get("value") or {}
|
|
270
|
+
except Exception:
|
|
271
|
+
return None
|
|
272
|
+
url = str(state.get("url") or "")
|
|
273
|
+
if self.armed_at is None:
|
|
274
|
+
if state.get("filled"):
|
|
275
|
+
self.armed_at = url
|
|
276
|
+
return None
|
|
277
|
+
if not url.startswith("http") or url == self.armed_at or state.get("filled"):
|
|
278
|
+
return None
|
|
279
|
+
self.armed_at = None
|
|
280
|
+
await reopen_in_fresh_tab(browser, url)
|
|
281
|
+
return url
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def reopen_in_fresh_tab(browser: Any, url: str) -> None:
|
|
285
|
+
"""Open url in a new tab, which the agent then works in, and close the tab it was on."""
|
|
286
|
+
from browser_use.browser.events import CloseTabEvent, NavigateToUrlEvent
|
|
287
|
+
|
|
288
|
+
old = getattr(browser, "agent_focus_target_id", None)
|
|
289
|
+
event = browser.event_bus.dispatch(NavigateToUrlEvent(url=url, new_tab=True))
|
|
290
|
+
await event
|
|
291
|
+
await event.event_result(raise_if_any=True, raise_if_none=False)
|
|
292
|
+
if old and old != getattr(browser, "agent_focus_target_id", None):
|
|
293
|
+
with contextlib.suppress(Exception):
|
|
294
|
+
close = browser.event_bus.dispatch(CloseTabEvent(target_id=old))
|
|
295
|
+
await close
|
|
296
|
+
await close.event_result(raise_if_any=False, raise_if_none=False)
|
|
297
|
+
|
|
298
|
+
|
|
232
299
|
async def _shutdown_browser_instance(browser: Any) -> None:
|
|
233
300
|
"""Stop the library gracefully, falling back to kill()."""
|
|
234
301
|
if browser is None:
|
|
@@ -44,8 +44,10 @@ from .llm import (
|
|
|
44
44
|
_agent_usage,
|
|
45
45
|
)
|
|
46
46
|
from .browser import (
|
|
47
|
+
FreshTabAfterPassword,
|
|
47
48
|
browser_connection_label,
|
|
48
49
|
browser_launch_kwargs,
|
|
50
|
+
local_browser_launch_kwargs,
|
|
49
51
|
settle_page,
|
|
50
52
|
_shutdown_browser_instance,
|
|
51
53
|
_stop_agent_instance,
|
|
@@ -1104,6 +1106,14 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1104
1106
|
last = agent_ref.history.history[-1]
|
|
1105
1107
|
except Exception:
|
|
1106
1108
|
return
|
|
1109
|
+
if fresh_tab is not None:
|
|
1110
|
+
try:
|
|
1111
|
+
reopened = await fresh_tab.after_step(browser, last)
|
|
1112
|
+
except Exception as ex:
|
|
1113
|
+
reopened = None
|
|
1114
|
+
logger.warning("could not reopen the page in a fresh tab after sign-in: %s", ex)
|
|
1115
|
+
if reopened:
|
|
1116
|
+
emit({"type": "log", "level": "info", "text": f"Signed in on {browser_provider}: reopened {reopened} in a fresh tab, so the page takes input again"})
|
|
1107
1117
|
results = getattr(last, "result", None) or []
|
|
1108
1118
|
if mem is not None:
|
|
1109
1119
|
mem.llm_steps = len(agent_steps(agent_ref.history.history))
|
|
@@ -1152,6 +1162,9 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1152
1162
|
if overlay is not None:
|
|
1153
1163
|
await overlay.thinking()
|
|
1154
1164
|
|
|
1165
|
+
# Remote grids stop taking input on a tab after a password form is submitted: see FreshTabAfterPassword.
|
|
1166
|
+
fresh_tab = FreshTabAfterPassword() if browser_provider != "local" else None
|
|
1167
|
+
|
|
1155
1168
|
async def _on_step_start(agent_ref: Any) -> None:
|
|
1156
1169
|
if not collected_steps:
|
|
1157
1170
|
await diag.start(browser)
|
package/src/auth/signin.ts
CHANGED
|
@@ -58,8 +58,8 @@ export function envNames(cfg?: AppConfig) {
|
|
|
58
58
|
zephyr: { url: "ZEPHYR_URL", token: s?.zephyr?.tokenEnv || "ZEPHYR_API_TOKEN" },
|
|
59
59
|
qtest: { url: "QTEST_URL", project: "QTEST_PROJECT_ID", token: s?.qtest?.tokenEnv || "QTEST_TOKEN" },
|
|
60
60
|
xray: { url: "XRAY_URL", id: s?.xray.clientIdEnv || "XRAY_CLIENT_ID", secret: s?.xray.clientSecretEnv || "XRAY_CLIENT_SECRET" },
|
|
61
|
-
browserstack: { user: b?.
|
|
62
|
-
testmu: { user: b?.
|
|
61
|
+
browserstack: { user: b?.remotes?.browserstack?.usernameEnv || "BROWSERSTACK_USERNAME", key: b?.remotes?.browserstack?.accessKeyEnv || "BROWSERSTACK_ACCESS_KEY" },
|
|
62
|
+
testmu: { user: b?.remotes?.testmu?.usernameEnv || "LT_USERNAME", key: b?.remotes?.testmu?.accessKeyEnv || "LT_ACCESS_KEY" },
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|