@capagents/forgeqe 1.1.3 → 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/README.md +6 -5
- package/browserstack.example.yaml +1 -1
- package/forgeqe.example.yaml +7 -13
- package/package.json +1 -2
- package/py/forgeqe_engine/browser.py +78 -11
- package/py/forgeqe_engine/service.py +12 -0
- package/src/bi.ts +382 -302
- package/src/capabilities/guide.ts +3 -3
- package/src/cli/help.ts +8 -5
- package/src/cli/index.ts +20 -8
- package/src/config.ts +18 -38
- package/src/run.ts +5 -0
- package/testmu.example.yaml +3 -4
- package/src/cache.ts +0 -92
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ForgeQE
|
|
2
2
|
|
|
3
|
-
Browser agent CLI. Say what you want in plain language: a goal to carry out in a browser, test cases to run from Jira, Azure DevOps, Xray or a file, a test to write, or a CI pipeline to set up. ForgeQE's agent works out what that needs and calls ForgeQE's tools. The ForgeQE engine drives Chrome locally, or BrowserStack and TestMu AI/LambdaTest through their
|
|
3
|
+
Browser agent CLI. Say what you want in plain language: a goal to carry out in a browser, test cases to run from Jira, Azure DevOps, Xray or a file, a test to write, or a CI pipeline to set up. ForgeQE's agent works out what that needs and calls ForgeQE's tools. The ForgeQE engine drives Chrome locally, or BrowserStack and TestMu AI/LambdaTest through their Chrome DevTools Protocol (CDP) endpoints, and a run that passes becomes a test in your repo's own language and framework, written by **Scribe**, ForgeQE's coding agent. Skills (`SKILL.md` folders, built in or in `.forgeqe/skills`) teach the agents your repo's conventions and the apps you test.
|
|
4
4
|
|
|
5
5
|
The engine is the browser service from test-agent-nexus, copied into this package (`py/forgeqe_engine`). It keeps the nexus behaviour: error-recovery system prompt, fast-mode agent tuning, Chrome launch flags, the search/select loop breaker, the 600 s run timeout, compact workflow YAML with semantic locators. There is no dependency on test-agent-nexus, FastAPI, or a database.
|
|
6
6
|
|
|
@@ -68,7 +68,7 @@ ForgeQE sends no telemetry and turns it off in everything it starts:
|
|
|
68
68
|
|
|
69
69
|
An unchanged copy keeps following ForgeQE's updates; once you edit it, yours wins.
|
|
70
70
|
|
|
71
|
-
For remote browsers, `forgeqe.yaml` can now point at provider-specific YAML files under `browser.providers` such as `browserstack` and `testmu`, so the BrowserStack or TestMu AI connection details do not have to live inline under `browser.remote`. `forgeqe init` drops sample files at `.forgeqe/browserstack.yaml` and `.forgeqe/testmu.yaml`. When both are present, the separate file supplies the provider defaults and the inline `browser.remote` values override only the active provider. ForgeQE's agent can also switch providers per request by passing `browser_provider` to `browser_run` or `run_test_cases`, and the CLI can pin one with `--browser-provider`.
|
|
71
|
+
For remote browsers, `forgeqe.yaml` can now point at provider-specific YAML files under `browser.providers` such as `browserstack` and `testmu`, so the BrowserStack or TestMu AI connection details do not have to live inline under `browser.remote`. `forgeqe init` drops sample files at `.forgeqe/browserstack.yaml` and `.forgeqe/testmu.yaml`. When both are present, the separate file supplies the provider defaults and the inline `browser.remote` values override only the active provider. ForgeQE's agent can also switch providers per request by passing `browser_provider` to `browser_run` or `run_test_cases`, and the CLI can pin one with `--browser-provider`. ForgeQE drives remote browsers over CDP, so `remote.browser` must be Chrome or Edge, and the default endpoints are the providers' CDP (`/puppeteer`) ones. On a remote grid, the browser stops taking real clicks and keystrokes on a tab once a password form has been submitted (most likely the browser's password manager), so after a sign-in leaves the login page ForgeQE reopens the page it landed on in a fresh tab, with the session intact, and the run log says so.
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
74
|
forgeqe models
|
|
@@ -240,7 +240,7 @@ Every suite writes `report.html`, `junit.xml`, and `suite.json` to `.forgeqe/rep
|
|
|
240
240
|
|
|
241
241
|
### Static BI
|
|
242
242
|
|
|
243
|
-
`forgeqe bi` adds an offline BI layer on top of those reports. It exports curated CSV datasets from ForgeQE's own suite history and release-readiness reports, plus optional Azure DevOps WIQL and Jira JQL datasets from `bi.datasets` in `forgeqe.yaml`,
|
|
243
|
+
`forgeqe bi` adds an offline BI layer on top of those reports. It exports curated CSV datasets from ForgeQE's own suite history and release-readiness reports, plus optional Azure DevOps WIQL and Jira JQL datasets from `bi.datasets` in `forgeqe.yaml`, into an [Evidence](https://github.com/evidence-dev/evidence) project that reads them through its CSV source, then runs `evidence sources` and `evidence build` to produce a static site in the configured build directory. ForgeQE uses the open-source Evidence line (`@evidence-dev/evidence` 40.x, pinned in the generated `package.json`), because the newer Evidence Studio CLI has no local build and keeps data in its cloud. Building it needs Node.js 18 or later; serve the folder to view it (`npx serve`), since it cannot load its data when opened from disk. When Node.js is missing or the Evidence build fails, `forgeqe bi build` writes ForgeQE's own self-contained page from the same data instead, which opens straight from disk, and says why; `--static` always does that, and `--evidence` fails instead of falling back. Dataset ids become table names (`forgeqe.<id>`), so they use letters, digits, and `_`. Pages ForgeQE generates carry a "Managed by forgeqe bi" comment in their front matter; a page without it is yours and is never overwritten. Tables with no rows yet stay out of the Evidence project (Evidence cannot build over an empty table), and their sections read "None yet." No backend service stays up between runs.
|
|
244
244
|
|
|
245
245
|
```yaml
|
|
246
246
|
bi:
|
|
@@ -260,8 +260,9 @@ bi:
|
|
|
260
260
|
```
|
|
261
261
|
|
|
262
262
|
```bash
|
|
263
|
-
forgeqe bi init
|
|
264
|
-
forgeqe bi build
|
|
263
|
+
forgeqe bi init # scaffold the Evidence project and install its packages
|
|
264
|
+
forgeqe bi build # refresh the data and build the site (ForgeQE's own page if Evidence cannot build)
|
|
265
|
+
forgeqe bi build --static # ForgeQE's own page only, no Node.js needed
|
|
265
266
|
```
|
|
266
267
|
|
|
267
268
|
## Signing in
|
package/forgeqe.example.yaml
CHANGED
|
@@ -20,7 +20,7 @@ browser:
|
|
|
20
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
|
|
21
21
|
# start_url: https://staging.example.com # optional; omit to let the agent choose the site
|
|
22
22
|
# remote: # inline settings for the selected remote provider; override the provider file when both are set
|
|
23
|
-
# browser:
|
|
23
|
+
# browser: chrome # BrowserStack: chrome or edge (ForgeQE drives the browser over CDP)
|
|
24
24
|
# browser_version: latest
|
|
25
25
|
# os: osx
|
|
26
26
|
# os_version: sequoia
|
|
@@ -75,14 +75,6 @@ memory:
|
|
|
75
75
|
dir: ./.forgeqe/memory # pages, elements, locators, and recorded flows
|
|
76
76
|
replay: verify # verify | trust | off
|
|
77
77
|
|
|
78
|
-
# Redis cache for deterministic external data fetches, such as BI datasets. Live browser runs still execute.
|
|
79
|
-
# cache:
|
|
80
|
-
# redis_url: redis://127.0.0.1:6379
|
|
81
|
-
# key_prefix: forgeqe
|
|
82
|
-
# default_ttl_seconds: 3600
|
|
83
|
-
# bi_ttl_seconds: 900
|
|
84
|
-
# connect_timeout_ms: 3000
|
|
85
|
-
|
|
86
78
|
code:
|
|
87
79
|
# repo: ../my-app # where passed runs become tests (default: the current folder; --no-code turns it off)
|
|
88
80
|
# test_command: npx playwright test # the repo's test command, for forgeqe heal; default: the agent finds it
|
|
@@ -121,13 +113,15 @@ report:
|
|
|
121
113
|
screenshots: all # all | failures | off: step screenshots in report.html
|
|
122
114
|
accessibility: true # check each page the agent visits (names, labels, image text, contrast, lang, zoom)
|
|
123
115
|
|
|
124
|
-
# Offline BI with Evidence: ForgeQE exports CSV datasets and runs Evidence
|
|
125
|
-
# forgeqe bi init scaffolds the Evidence project, forgeqe bi sync refreshes
|
|
126
|
-
#
|
|
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 _.
|
|
127
121
|
# bi:
|
|
128
122
|
# title: ForgeQE BI
|
|
129
123
|
# # evidence_dir: ./evidence # the managed Evidence project
|
|
130
|
-
# # data_dir: ./evidence/sources/forgeqe
|
|
124
|
+
# # data_dir: ./evidence/sources/forgeqe # CSV exports; copied there when set elsewhere
|
|
131
125
|
# # build_dir: ./.forgeqe/reports/bi # static HTML output from evidence build
|
|
132
126
|
# datasets:
|
|
133
127
|
# ado_current_sprint:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
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",
|
|
@@ -55,7 +55,6 @@
|
|
|
55
55
|
"pixelmatch": "8.0.0",
|
|
56
56
|
"pngjs": "7.0.0",
|
|
57
57
|
"react": "^19.3.0",
|
|
58
|
-
"redis": "^4.7.1",
|
|
59
58
|
"yaml": "^2.9.1"
|
|
60
59
|
},
|
|
61
60
|
"devDependencies": {
|
|
@@ -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,6 +44,7 @@ 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,
|
|
49
50
|
local_browser_launch_kwargs,
|
|
@@ -1105,6 +1106,14 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1105
1106
|
last = agent_ref.history.history[-1]
|
|
1106
1107
|
except Exception:
|
|
1107
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"})
|
|
1108
1117
|
results = getattr(last, "result", None) or []
|
|
1109
1118
|
if mem is not None:
|
|
1110
1119
|
mem.llm_steps = len(agent_steps(agent_ref.history.history))
|
|
@@ -1153,6 +1162,9 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1153
1162
|
if overlay is not None:
|
|
1154
1163
|
await overlay.thinking()
|
|
1155
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
|
+
|
|
1156
1168
|
async def _on_step_start(agent_ref: Any) -> None:
|
|
1157
1169
|
if not collected_steps:
|
|
1158
1170
|
await diag.start(browser)
|