@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.
@@ -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: # only when provider is browserstack or testmu
19
- # browser: playwright-chromium # BrowserStack: playwright-chromium / playwright-firefox / playwright-webkit
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 to build static HTML, no always-on server.
113
- # forgeqe bi init scaffolds the Evidence project, forgeqe bi sync refreshes the data, forgeqe bi build does both
114
- # and runs evidence build. The datasets below are optional: local ForgeQE suite/readiness data exports either way.
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/data # CSV exports that the Evidence project reads
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.2",
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)
@@ -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?.remote?.provider === "browserstack" ? b.remote.usernameEnv : "BROWSERSTACK_USERNAME", key: b?.remote?.provider === "browserstack" ? b.remote.accessKeyEnv : "BROWSERSTACK_ACCESS_KEY" },
62
- testmu: { user: b?.remote?.provider === "testmu" ? b.remote.usernameEnv : "LT_USERNAME", key: b?.remote?.provider === "testmu" ? b.remote.accessKeyEnv : "LT_ACCESS_KEY" },
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