@capagents/forgeqe 2.2.1 → 2.2.3

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.
@@ -86,9 +86,11 @@ browser:
86
86
  max_steps: 40 # a checkpoint: a run still making progress carries on, up to 3x
87
87
  timeout_seconds: 600 # whole run
88
88
  vision_detail: low # low | high
89
- viewport:
89
+ viewport: # headless page size; a visible window too with window: viewport
90
90
  width: 1280
91
91
  height: 800
92
+ window: maximized # visible browser: maximized | viewport
93
+ video: true # video.mp4 of each run, shown in its report
92
94
  allowed_hosts: []
93
95
  judge: false # true: a second model call checks each finished run against the goal and can fail it
94
96
  overlay: true # visible browser: ForgeQE start page, edge glow, a bar with the current step, your mouse and keyboard locked while the agent works
package/llms.example.json CHANGED
@@ -50,10 +50,8 @@
50
50
  },
51
51
  "copilot": {
52
52
  "provider": "github_copilot",
53
- "model": "claude-sonnet-4.6",
54
- "engine_model": "openai/gpt-4.1",
55
- "github_org": "",
56
- "description": "GitHub Copilot seat for ForgeQE's agent and Scribe (forgeqe auth login copilot); the browser engine uses GitHub Models with your GitHub sign-in"
53
+ "model": "gpt-4.1",
54
+ "description": "GitHub Copilot seat for the browser engine, through GitHub's Copilot SDK (forgeqe auth login copilot: a fine-grained token with Copilot Requests, or the GitHub CLI's sign-in)"
57
55
  }
58
56
  }
59
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
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": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "bin/",
13
+ "reporters/",
13
14
  "dist/",
14
15
  "py/forgeqe_engine/*.py",
15
16
  "py/forgeqe_desktop/*.py",
@@ -39,11 +40,13 @@
39
40
  "forgeqe": "bun src/index.ts",
40
41
  "start": "bun src/index.ts start",
41
42
  "test": "bun test",
43
+ "test:release": "bun scripts/release-tests.ts",
42
44
  "typecheck": "tsc --noEmit",
43
45
  "build:report": "cd report-ui && npm install --no-audit --no-fund && npm run build && cp dist/index.html ../src/report/template.html",
44
46
  "build": "bun scripts/build.ts",
45
47
  "notices": "bun scripts/gen-notices.ts",
46
- "prepack": "bun scripts/build.ts && bun scripts/gen-notices.ts"
48
+ "prepack": "bun scripts/build.ts && bun scripts/gen-notices.ts",
49
+ "prepublishOnly": "bun run typecheck && bun scripts/release-tests.ts"
47
50
  },
48
51
  "keywords": [
49
52
  "forgeqe",
package/prompts/README.md CHANGED
@@ -9,6 +9,7 @@ project; leave it as it is to keep getting ForgeQE's improvements when you updat
9
9
  | test-agent.md | Scribe, the coding agent: turns a verified browser run into a test in your repository |
10
10
  | test-rules.md | Rules for any test the agents write or change (included in test-agent, test-reviewer, heal) |
11
11
  | test-reviewer.md | The reviewer that checks a passing test tests the right thing the right way |
12
+ | test-plan.md | Plans how a test you recorded by hand (`forgeqe record`) fits your repository, for you to agree before Scribe writes it |
12
13
  | heal.md | Heals tests the application has outgrown, and tells them apart from real bugs |
13
14
  | find-tests.md | Finds the automated tests that match a request |
14
15
  | pr-review.md | Reviews pull requests |
package/prompts/brain.md CHANGED
@@ -255,6 +255,8 @@ When the user asks for a regression pack, or which tests to run for a release, a
255
255
  - You MUST report the tests by why they are in, the changes no test covers with the closest test for each, and the risk areas.
256
256
  - When the user did not say where to run the pack, you MUST ask one question: on this machine, on TestMu AI, or in a pipeline. You MUST NOT run a pack until the user agrees.
257
257
  - After a run, you MUST name the failing tests with their messages, and the tests that fail now but passed in the last run.
258
+ - You MUST give the triage of the run: each failing test's route (flaky, environment, test code broken, heal, or an AI re-run decides) and why. You MUST offer only what the triage says. For heal and AI re-run tests, offer heal_tests with pack. When the environment was down, offer to run the pack again first. You MUST NOT offer heal_tests for flaky, environment, or test code failures.
259
+ - You MUST file a bug only for an app_bug that heal_tests reports, not for a failure the triage routed.
258
260
 
259
261
  ### Writing tests
260
262
  When the user asks, or when the setup below says that passed runs become tests:
@@ -281,7 +283,7 @@ When the user asks, or when the setup below says that passed runs become tests:
281
283
 
282
284
  ### Repairing broken tests
283
285
  When the user says that tests fail, broke after a release, or need healing:
284
- - You MUST call heal_tests. Give the command when the user gave one or you know it. Otherwise use the test command below, or let the healing agent find it.
286
+ - You MUST call heal_tests. After a regression pack run, give pack, and tests when the user named some. Otherwise give the command when the user gave one or you know it. Otherwise use the test command below, or let the healing agent find it.
285
287
  - You MUST report the verdict for each test: healed and confirmed, an application bug, or not healed. For an application bug, give what the browser run found.
286
288
  - You MUST also report the files that changed, and heal.md.
287
289
  - You MUST NOT delete, skip, or make tests weaker yourself to make a run pass.
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: test-plan
3
+ description: "Plans how a test recorded by hand fits the repository, for the tester to agree before Scribe writes it."
4
+ used_by: "forgeqe record, write_test for a recording"
5
+ kind: structured
6
+ ---
7
+ {{> conventions}}
8
+
9
+ ## Role
10
+ You plan how a test that a tester recorded by hand fits a test repository. You do not write or change files.
11
+
12
+ ## Objective
13
+ Tell the tester where the test goes and which code it uses. The tester agrees before Scribe writes it.
14
+
15
+ ## Inputs
16
+ - The handoff: the recorded steps in order, their locators, the tester's checks, variables, test data, and notes.
17
+ - The repository's test setup: the framework, the page objects, the fixtures, and the test data.
18
+ - The tools code_outline, code_element, and code_references. They show what the page objects already locate and do.
19
+
20
+ ## Rules
21
+ Reading:
22
+ - You MUST find the page-object members and methods that already locate each element of the steps. Use the code tools.
23
+ - You SHOULD read only the files that the plan needs.
24
+
25
+ Where the test goes:
26
+ - You MUST name the test file and the test name. Follow the names and folders of the tests that are there.
27
+ - When an existing test already does the first steps, you MAY propose to extend it. Name that test.
28
+
29
+ Steps:
30
+ - For each step, you MUST say which existing member or method does it, or which new one the test adds, and where.
31
+ - A new member MUST go in the page object of the page where the step is.
32
+ - You MUST NOT change what an existing member does for the other tests.
33
+
34
+ Checks and data:
35
+ - For each CHECK line, you MUST say the assertion and the step after which it comes.
36
+ - A check that the tester gave only in words MUST stay in the plan. Say what on the page will show it.
37
+ - For each value marked as test data, you MUST name its source: a fixture, a data file, or a parameter.
38
+ - You MUST keep the secrets as environment variables.
39
+
40
+ Questions:
41
+ - You MUST ask a question only when the repository allows two good answers and the tester's choice changes the code.
42
+ - You MUST NOT ask more than 3 questions. Give each question 2 to 4 short options, the recommended option first.
43
+ - You MUST NOT ask what the handoff or the repository already answers.
44
+
45
+ ## Output
46
+ The structured result: test_file, test_name, mode, steps, new members, checks, test data, questions, and a short summary.
47
+
48
+ ## Examples
49
+ ### Example 1
50
+ Handoff: open the shop, search "pliers", open the first result, add it to the cart, CHECK the cart badge shows "1". The repository has pages/HomePage.ts with searchFor() and no product page object.
51
+ - test_file tests/add-to-cart.spec.ts, test_name "adds pliers to the cart", mode new.
52
+ - Steps: search with HomePage.searchFor(); open the product with a new HomePage.product(name); add to the cart with a new ProductPage.addToCart() in pages/ProductPage.ts.
53
+ - Checks: after the add, expect ProductPage.cartQuantity to have the text "1".
54
+ - Questions: none.
55
+
56
+ ### Example 2
57
+ Handoff: sign in, type the coupon "SAVE10" (test data: coupon), CHECK the total shows text like "$40.00". The repository has tests/data/coupons.json and a fixture that signs in.
58
+ - Steps: sign in with the signedIn fixture; type the coupon with a new CartPage.applyCoupon(code).
59
+ - Test data: read the coupon from tests/data/coupons.json, with SAVE10 as the first entry.
60
+ - Checks: after the coupon, expect CartPage.total to match /^\$\d+\.\d{2}$/.
61
+ - Questions: "Add the coupon test to tests/cart.spec.ts, or a new file?" Options: "tests/cart.spec.ts", "A new file".
@@ -192,7 +192,20 @@ def _testmu_caps(remote: Dict[str, Any]) -> Dict[str, Any]:
192
192
  }
193
193
 
194
194
 
195
+ # browser.provider docker: ForgeQE starts the browser container and passes its gateway's endpoint (with its token) here.
196
+ DOCKER_CDP_ENV = "FORGEQE_BROWSER_CDP_URL"
197
+
198
+
199
+ def docker_browser_launch_kwargs() -> Dict[str, Any]:
200
+ url = (os.getenv(DOCKER_CDP_ENV) or "").strip()
201
+ if not url.startswith("ws://127.0.0.1:"):
202
+ raise RuntimeError(f"docker: {DOCKER_CDP_ENV} is not set to the browser container's local endpoint; ForgeQE starts that container itself.")
203
+ return {"cdp_url": url}
204
+
205
+
195
206
  def remote_browser_launch_kwargs(provider: str, remote: Dict[str, Any]) -> Dict[str, Any]:
207
+ if provider == "docker":
208
+ return docker_browser_launch_kwargs()
196
209
  endpoint = str(remote.get("endpoint") or "").strip().rstrip("/")
197
210
  if not endpoint:
198
211
  raise RuntimeError(f"{provider}: a remote endpoint is required.")
@@ -212,6 +225,8 @@ def browser_launch_kwargs(*, provider: str, headless: bool, remote: Optional[Dic
212
225
  def browser_connection_label(provider: str, launch: Dict[str, Any], remote: Optional[Dict[str, Any]] = None) -> str:
213
226
  if provider == "local":
214
227
  return str(launch.get("channel") or "chrome")
228
+ if provider == "docker":
229
+ return "Chromium in a container"
215
230
  browser = str((remote or {}).get("browser") or "")
216
231
  target = str((remote or {}).get("platform") or "").strip() or " ".join(
217
232
  x for x in [str((remote or {}).get("os") or "").strip(), str((remote or {}).get("os_version") or (remote or {}).get("osVersion") or "").strip()] if x
@@ -296,6 +311,93 @@ async def reopen_in_fresh_tab(browser: Any, url: str) -> None:
296
311
  await close.event_result(raise_if_any=False, raise_if_none=False)
297
312
 
298
313
 
314
+ VIDEO_FILE = "video.mp4"
315
+
316
+
317
+ def _size(value: Any) -> Optional[Dict[str, int]]:
318
+ """browser.viewport as [width, height], or None when it is not two sizes a screen can have."""
319
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
320
+ return None
321
+ try:
322
+ w, h = int(value[0]), int(value[1])
323
+ except (TypeError, ValueError):
324
+ return None
325
+ return {"width": w, "height": h} if 200 <= w <= 10000 and 200 <= h <= 10000 else None
326
+
327
+
328
+ def window_launch_kwargs(browser_cfg: Dict[str, Any], *, headless: bool) -> Dict[str, Any]:
329
+ """
330
+ The size of a local browser. Headless: the page is browser.viewport. Visible: the window is that size with
331
+ browser.window: viewport, else the library's screen-sized window, which `maximize_window` then maximises.
332
+ """
333
+ if str(browser_cfg.get("provider") or "local") != "local":
334
+ return {}
335
+ size = _size(browser_cfg.get("viewport"))
336
+ if not size:
337
+ return {}
338
+ if headless:
339
+ return {"viewport": size}
340
+ return {"window_size": size} if browser_cfg.get("window") == "viewport" else {}
341
+
342
+
343
+ def wants_maximized(browser_cfg: Dict[str, Any], *, headless: bool) -> bool:
344
+ return not headless and str(browser_cfg.get("provider") or "local") == "local" and browser_cfg.get("window") != "viewport"
345
+
346
+
347
+ async def maximize_window(browser: Any) -> bool:
348
+ """Maximise the visible browser's window (the library only sizes it to the screen, under the menu bar and Dock)."""
349
+ try:
350
+ session = await browser.get_or_create_cdp_session()
351
+ send = browser.cdp_client.send.Browser
352
+ found = await send.getWindowForTarget(params={"targetId": session.target_id})
353
+ window_id = found["windowId"]
354
+ await send.setWindowBounds(params={"windowId": window_id, "bounds": {"windowState": "maximized"}})
355
+ # The page takes the new size a moment later; the video is sized from it.
356
+ for _ in range(10):
357
+ bounds = (await send.getWindowBounds(params={"windowId": window_id})).get("bounds") or {}
358
+ if bounds.get("windowState") == "maximized":
359
+ break
360
+ await asyncio.sleep(0.1)
361
+ await asyncio.sleep(0.3)
362
+ return True
363
+ except Exception as ex:
364
+ logger.debug("maximise window failed: %s", ex)
365
+ return False
366
+
367
+
368
+ def video_available() -> bool:
369
+ try:
370
+ from browser_use.browser.video_recorder import IMAGEIO_AVAILABLE
371
+ except Exception:
372
+ return False
373
+ return bool(IMAGEIO_AVAILABLE)
374
+
375
+
376
+ async def start_video(browser: Any, out_dir: Path) -> Optional[Path]:
377
+ """Record the agent's tab to <out_dir>/video.mp4 until `stop_video`; None when it could not start."""
378
+ watchdog = getattr(browser, "_recording_watchdog", None)
379
+ if watchdog is None or not video_available():
380
+ return None
381
+ try:
382
+ return Path(await watchdog.start_recording(out_dir / VIDEO_FILE))
383
+ except Exception as ex:
384
+ logger.debug("video recording did not start: %s", ex)
385
+ return None
386
+
387
+
388
+ async def stop_video(browser: Any) -> Optional[Path]:
389
+ """Finish the video file; None when nothing was recorded."""
390
+ watchdog = getattr(browser, "_recording_watchdog", None)
391
+ if watchdog is None or not getattr(watchdog, "is_recording", False):
392
+ return None
393
+ try:
394
+ path = await asyncio.wait_for(watchdog.stop_recording(), timeout=120.0)
395
+ except Exception as ex:
396
+ logger.debug("video recording did not finish: %s", ex)
397
+ return None
398
+ return Path(path) if path and Path(path).is_file() and Path(path).stat().st_size else None
399
+
400
+
299
401
  async def _shutdown_browser_instance(browser: Any) -> None:
300
402
  """Stop the library gracefully, falling back to kill()."""
301
403
  if browser is None:
@@ -348,7 +450,10 @@ async def browser_profile(browser: Any, launch: Dict[str, Any], *, provider: str
348
450
  "extra_http_headers": {},
349
451
  "provider": provider,
350
452
  }
351
- if provider != "local":
453
+ if provider == "docker":
454
+ # Not a browser on another machine: the coding agent's replay and live browser use the same kind of container.
455
+ profile["container"] = True
456
+ elif provider != "local":
352
457
  host = urlparse(str(launch.get("cdp_url") or "")).netloc
353
458
  profile["remote_browser"] = f"{'BrowserStack' if provider == 'browserstack' else 'TestMu AI'} via {host}"
354
459
  if remote:
@@ -9,9 +9,11 @@ Engine process protocol, used by the Bun CLI:
9
9
 
10
10
  python -m forgeqe_engine run
11
11
  python -m forgeqe_engine login (stdin: {url, logins}, then "save")
12
+ python -m forgeqe_engine record (stdin: {start_url, out_dir, ...}, then commands; "stop" or EOF ends it)
12
13
  python -m forgeqe_engine doctor
13
14
  python -m forgeqe_engine token (stdin: an llms.json profile with auth: entra; prints {token, expires_on})
14
15
  python -m forgeqe_engine probe (stdin: an llms.json profile; prints {ok, ms, structured, vision, error?})
16
+ python -m forgeqe_engine copilot-gateway (GitHub Copilot as an OpenAI-style endpoint on 127.0.0.1; see copilot_gateway.py)
15
17
  """
16
18
  from __future__ import annotations
17
19
 
@@ -72,6 +74,14 @@ def _doctor(out: TextIO) -> int:
72
74
  info["azure_identity"] = version("azure-identity")
73
75
  except Exception:
74
76
  pass
77
+ try:
78
+ info["copilot_sdk"] = version("github-copilot-sdk")
79
+ except Exception:
80
+ pass
81
+ try:
82
+ info["video"] = version("imageio")
83
+ except Exception:
84
+ pass
75
85
  try:
76
86
  from .service import local_browser_launch_kwargs
77
87
 
@@ -155,6 +165,47 @@ def _login(out: TextIO) -> int:
155
165
  return asyncio.run(main())
156
166
 
157
167
 
168
+ def _record(out: TextIO) -> int:
169
+ """stdin line 1: the recording request (recordsession.py); later lines are commands ("stop", or JSON {"cmd": ...}); EOF stops."""
170
+ from .recordsession import record_web
171
+
172
+ line = sys.stdin.readline()
173
+ payload = json.loads(line) if line.strip() else {}
174
+ lock = threading.Lock()
175
+
176
+ def emit(event: Dict[str, Any]) -> None:
177
+ with lock:
178
+ out.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
179
+ out.flush()
180
+
181
+ async def main() -> int:
182
+ loop = asyncio.get_running_loop()
183
+ commands: asyncio.Queue = asyncio.Queue()
184
+
185
+ def watch_stdin() -> None:
186
+ for raw in sys.stdin:
187
+ text = raw.strip()
188
+ if not text:
189
+ continue
190
+ try:
191
+ cmd = json.loads(text) if text.startswith("{") else {"cmd": text.lower()}
192
+ except ValueError:
193
+ continue
194
+ if isinstance(cmd, dict):
195
+ loop.call_soon_threadsafe(commands.put_nowait, cmd)
196
+ loop.call_soon_threadsafe(commands.put_nowait, {"cmd": "stop"})
197
+
198
+ threading.Thread(target=watch_stdin, daemon=True).start()
199
+ try:
200
+ result = await record_web(payload, emit, commands)
201
+ except Exception as e:
202
+ result = {"success": False, "error": f"{type(e).__name__}: {e}"}
203
+ emit({"type": "done", "result": result})
204
+ return 0 if result.get("success") else 1
205
+
206
+ return asyncio.run(main())
207
+
208
+
158
209
  def _token(out: TextIO) -> int:
159
210
  """stdin: an llms.json profile with auth: entra. Prints {token, expires_on} for its scope, or {error}."""
160
211
  from .llm import entra_credential, entra_scope
@@ -204,9 +255,15 @@ def main(argv: list[str] | None = None) -> int:
204
255
  return _run(out)
205
256
  if cmd == "login":
206
257
  return _login(out)
258
+ if cmd == "record":
259
+ return _record(out)
207
260
  if cmd == "token":
208
261
  return _token(out)
209
262
  if cmd == "probe":
210
263
  return _probe(out)
264
+ if cmd == "copilot-gateway":
265
+ from .copilot_gateway import main as copilot_gateway
266
+
267
+ return copilot_gateway(out)
211
268
  out.write(json.dumps({"type": "done", "result": {"success": False, "error": f"Unknown command {cmd}"}}) + "\n")
212
269
  return 2