@capagents/forgeqe 1.1.2 → 1.1.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.
@@ -61,11 +61,11 @@ export const CAPABILITIES: Capability[] = [
61
61
  area: "Browser",
62
62
  title: "Run any goal in a real browser",
63
63
  summary:
64
- "Carries out a plain-language goal in Chrome one step at a time and verifies the outcome, with screenshots, a report, and the steps it took. Page memory replays steps that already worked on a site, so repeat runs are faster and cheaper.",
64
+ "Carries out a plain-language goal in a real browser one step at a time and verifies the outcome, with screenshots, a report, and the steps it took. The browser comes from forgeqe.yaml: local Chrome or Chromium by default, or BrowserStack or TestMu AI/LambdaTest when configured. Page memory replays steps that already worked on a site, so repeat runs are faster and cheaper.",
65
65
  tools: ["browser_run", "run_details", "page_memory"],
66
66
  examples: ["Go to booking.com and search hotels in Mumbai for next weekend", "What does ForgeQE know about the checkout page?"],
67
67
  requires: [{ kind: "live_model" }],
68
- limits: ["Web applications in Chrome or Chromium only: not native mobile or desktop applications."],
68
+ limits: ["Web applications only: local Chrome or Chromium, or configured remote web grids such as BrowserStack and TestMu AI/LambdaTest; not native mobile or desktop applications."],
69
69
  docs: [
70
70
  { doc: "HOW_TO_USE.md", heading: "4. Without the shell" },
71
71
  { doc: "HOW_TO_USE.md", heading: "5. Page memory" },
@@ -345,7 +345,7 @@ export const CAPABILITIES: Capability[] = [
345
345
  * Only add what is certain.
346
346
  */
347
347
  export const OUT_OF_SCOPE: { what: string; instead?: string }[] = [
348
- { what: "Native mobile apps (iOS, Android) and desktop applications", instead: "web applications in Chrome or Chromium" },
348
+ { what: "Native mobile apps (iOS, Android) and desktop applications", instead: "web applications in local Chrome or Chromium, or configured remote web grids such as BrowserStack and TestMu AI/LambdaTest" },
349
349
  { what: "Load, stress, or soak testing with many simultaneous users", instead: "per-page Core Web Vitals with budgets" },
350
350
  { what: "Penetration testing or dynamic security scanning", instead: "security findings in pull request reviews" },
351
351
  ]
package/src/cli/help.ts CHANGED
@@ -17,21 +17,25 @@ Usage:
17
17
  [--repo DIR] [--no-code]
18
18
  forgeqe run "<request>" [--profile name] [--url URL] [--headed] [--plain] [--max-steps N]
19
19
  [--repo DIR] [--no-code] [--replay MODE] [--no-memory] [--attempts N] [--code-model provider/model]
20
+ [--browser-provider local|browserstack|testmu]
20
21
  ForgeQE's agent works out what the request needs: a browser goal,
21
22
  test cases from Jira, Azure DevOps, Xray or files, a test, a pipeline
22
- forgeqe run --direct "<goal>" [--out DIR] [--skill NAME] ... one browser goal, no agent
23
+ forgeqe run --direct "<goal>" [--out DIR] [--skill NAME] [--browser-provider local|browserstack|testmu] ... one browser goal, no agent
23
24
  forgeqe suite <source...> [--parallel N] [--repo DIR] [--no-code] [--no-write-back] [--out DIR]
24
25
  [--profile name] [--url URL] [--headed] [--plain] [--attempts N] [--skill NAME]
26
+ [--browser-provider local|browserstack|testmu]
25
27
  run test cases the same way every time (what pipelines call)
26
28
  forgeqe cases <source...> [--goal] list the test cases a source gives (--goal: the goal each runs)
27
29
  forgeqe code [run-dir] [--repo DIR] [--attempts N] [--code-model provider/model]
28
30
  write a test for a run (default: the latest) with Scribe
29
31
  forgeqe heal ["<test command>"] [--repo DIR] [--attempts N] [--code-model provider/model] [--headed] [--plain]
32
+ [--browser-provider local|browserstack|testmu]
30
33
  run the repo's tests and heal the ones the app has outgrown
31
34
  forgeqe monitor [source...] [--every 15m] [--once] [--replay MODE] [--parallel N] [--out DIR] [--verbose] [--skill NAME]
35
+ [--browser-provider local|browserstack|testmu]
32
36
  check flows on a schedule and alert Teams or email when one breaks
33
37
  forgeqe monitor --test-alerts send a sample alert to check the Teams and email settings
34
- forgeqe mcp [--repo DIR] [--profile name] [--url URL] [--headed]
38
+ forgeqe mcp [--repo DIR] [--profile name] [--url URL] [--headed] [--browser-provider local|browserstack|testmu]
35
39
  ForgeQE's tools as an MCP server on stdio, for other agents
36
40
  forgeqe mcp list the Azure DevOps, Jira and other MCP servers ForgeQE's agent gets
37
41
  forgeqe bi [build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]
@@ -52,6 +56,7 @@ Usage:
52
56
  forgeqe visual approve [run-dir] [--page a,b] [--yes]
53
57
  make the run's screenshots the new baselines (an intended change)
54
58
  forgeqe explore <story> [--url URL] [--charters N] [--roles a,b] [--focus TEXT] [--yes] [--file-bugs]
59
+ [--browser-provider local|browserstack|testmu]
55
60
  exploratory testing of a story (ado:123, SHOP-12, a link, or its text):
56
61
  charters beyond the scripted cases, defects confirmed by a second run,
57
62
  proposed test cases, bugs for confirmed defects; exits 1 on a defect
@@ -104,6 +109,7 @@ page with ranked locators and a confidence %, plus the steps of each goal's last
104
109
  --replay verify replay a known goal without the LLM, then one LLM step checks the result (default)
105
110
  --replay trust replay and finish without the LLM when every step works
106
111
  --replay off never replay; still record pages
112
+ --browser-provider pick which configured browser provider runs this command's browser work
107
113
 
108
114
  ForgeQE's agent (forgeqe start, forgeqe run) runs on the agent runtime with the active profile's model (or code.model) and
109
115
  calls ForgeQE's tools: browser runs, reading and running test cases, writing tests and running them, page memory, pushing
package/src/cli/index.ts CHANGED
@@ -10,7 +10,7 @@ import { goalFor, loadCases } from "../cases/index.ts"
10
10
  import { codeSkipReason, latestRunDir, runCodeAgent } from "../code/index.ts"
11
11
  import { detailFrom } from "../detail.ts"
12
12
  import { ensureOpencode, INSTALL_HINT } from "../code/runtime.ts"
13
- import { loadConfig, parseDuration, replayMode, selectProfile, userConfigDir, type AppConfig } from "../config.ts"
13
+ import { browserProvider, loadConfig, parseDuration, replayMode, selectProfile, userConfigDir, type AppConfig, type BrowserProvider } from "../config.ts"
14
14
  import { runHeal } from "../heal/loop.ts"
15
15
  import { formatHeal } from "../heal/report.ts"
16
16
  import { backgroundMonitors, stopBackgroundMonitors } from "../monitor/background.ts"
@@ -62,6 +62,11 @@ const LOGGED_COMMANDS = new Set([...AGENT_COMMANDS, "mcp", "login", "setup", "bi
62
62
  /** How much of the agents' work to print: brief unless --verbose. */
63
63
  const detailOf = (flags: Flags) => detailFrom(boolFlag(flags, "verbose"))
64
64
 
65
+ function browserProviderFlag(flags: Flags): BrowserProvider | undefined {
66
+ const value = strFlag(flags, "browser-provider")
67
+ return value ? browserProvider(value) : undefined
68
+ }
69
+
65
70
  function debugRequested(flags: Flags): boolean {
66
71
  return boolFlag(flags, "debug") || /^(1|true|yes|on)$/i.test(process.env.FORGEQE_DEBUG || "")
67
72
  }
@@ -212,7 +217,7 @@ export async function main(argv: string[]): Promise<number> {
212
217
  const cfg = loadConfig(configOpts(flags))
213
218
  const trends = positionals[1] === "trends"
214
219
  const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
215
- const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
220
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
216
221
  const out = (await TOOLS.find((t) => t.name === (trends ? "quality_trends" : "quality_report"))!.run(trends ? { page: positionals[2] } : { run_dir: positionals[1] }, ctx)) as { budget_exceeded?: unknown[]; pages?: { slower: unknown[]; newly_failing_rules: unknown[] }[] }
217
222
  return out.budget_exceeded?.length || out.pages?.some((p) => p.slower.length || p.newly_failing_rules.length) ? 1 : 0
218
223
  }
@@ -377,7 +382,7 @@ async function cmdApi(args: string[], flags: Flags): Promise<number> {
377
382
  const cfg = loadConfig(configOpts(flags))
378
383
  const action = args[0] || "list"
379
384
  const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
380
- const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
385
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
381
386
  const tool = (name: string) => TOOLS.find((t) => t.name === name)!
382
387
  const csv = (name: string) => (strFlag(flags, name) || "").split(",").map((s) => s.trim()).filter(Boolean)
383
388
  switch (action) {
@@ -454,7 +459,17 @@ async function cmdExplore(args: string[], flags: Flags): Promise<number> {
454
459
  process.once("SIGINT", onSigint)
455
460
  try {
456
461
  const ask = process.stdin.isTTY && !boolFlag(flags, "yes") ? terminalAsk(controller.signal) : undefined
457
- const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", signal: controller.signal, headed: boolFlag(flags, "headed"), maxSteps: maxStepsFlag(flags), emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)), ...(ask ? { ask } : {}) }
462
+ const ctx: ToolContext = {
463
+ config: cfg,
464
+ cwd: process.cwd(),
465
+ session: "once",
466
+ signal: controller.signal,
467
+ headed: boolFlag(flags, "headed"),
468
+ browserProvider: browserProviderFlag(flags),
469
+ maxSteps: maxStepsFlag(flags),
470
+ emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)),
471
+ ...(ask ? { ask } : {}),
472
+ }
458
473
  const roles = (strFlag(flags, "roles") || "").split(",").map((s) => s.trim()).filter(Boolean)
459
474
  const out = (await TOOLS.find((t) => t.name === "explore_story")!.run(
460
475
  { story, url: strFlag(flags, "url"), charters: Number(strFlag(flags, "charters")) || undefined, ...(roles.length ? { roles } : {}), focus: strFlag(flags, "focus"), file_bugs: boolFlag(flags, "file-bugs") },
@@ -913,6 +928,7 @@ async function runAgent(cfg: AppConfig, flags: Flags, text: string, maxSteps: nu
913
928
  signal: controller.signal,
914
929
  context: () => ({
915
930
  headed: boolFlag(flags, "headed"),
931
+ browserProvider: browserProviderFlag(flags),
916
932
  maxSteps,
917
933
  url: strFlag(flags, "url"),
918
934
  ...memoryFlags(flags),
@@ -973,7 +989,17 @@ async function cmdMcp(args: string[], flags: Flags): Promise<number> {
973
989
  const repo = repoFlag(flags)
974
990
  const cwd = repo || process.cwd()
975
991
  const log = toolPrinter(false, (line) => process.stderr.write(`${line}\n`), detailOf(flags))
976
- const context = (): ToolContext => ({ config: cfg, cwd, headed: boolFlag(flags, "headed"), maxSteps: maxStepsFlag(flags), url: strFlag(flags, "url"), ...memoryFlags(flags), emit: log, session: "server" })
992
+ const context = (): ToolContext => ({
993
+ config: cfg,
994
+ cwd,
995
+ headed: boolFlag(flags, "headed"),
996
+ browserProvider: browserProviderFlag(flags),
997
+ maxSteps: maxStepsFlag(flags),
998
+ url: strFlag(flags, "url"),
999
+ ...memoryFlags(flags),
1000
+ emit: log,
1001
+ session: "server",
1002
+ })
977
1003
  const server = createMcpServer({ tools: TOOLS, context, instructions: () => environmentNotes(context()), version: packageVersion() })
978
1004
  process.stderr.write(`ForgeQE MCP server on stdio (${TOOLS.length} tools, working in ${cwd})\n`)
979
1005
  await serveStdio(server)
@@ -988,6 +1014,7 @@ async function runGoal(cfg: AppConfig, flags: Flags, goal: string, maxSteps: num
988
1014
  const { result, files } = await executeRun({
989
1015
  config: cfg,
990
1016
  goal,
1017
+ browserProvider: browserProviderFlag(flags),
991
1018
  url: strFlag(flags, "url"),
992
1019
  headed: boolFlag(flags, "headed"),
993
1020
  maxSteps,
@@ -1129,6 +1156,7 @@ async function cmdHeal(args: string[], flags: Flags): Promise<number> {
1129
1156
  repo,
1130
1157
  command,
1131
1158
  outDir: strFlag(flags, "out"),
1159
+ browserProvider: browserProviderFlag(flags),
1132
1160
  headed: boolFlag(flags, "headed"),
1133
1161
  maxSteps: maxStepsFlag(flags),
1134
1162
  url: strFlag(flags, "url"),
@@ -1200,6 +1228,7 @@ async function cmdMonitor(refs: string[], flags: Flags): Promise<number> {
1200
1228
  config: cfg,
1201
1229
  checks,
1202
1230
  once,
1231
+ browserProvider: browserProviderFlag(flags),
1203
1232
  headed: boolFlag(flags, "headed"),
1204
1233
  url: strFlag(flags, "url"),
1205
1234
  skills: skillFlags(flags),
@@ -1238,6 +1267,7 @@ async function cmdSuite(refs: string[], flags: Flags): Promise<number> {
1238
1267
  config: cfg,
1239
1268
  cases,
1240
1269
  repo,
1270
+ browserProvider: browserProviderFlag(flags),
1241
1271
  parallel,
1242
1272
  outDir: strFlag(flags, "out"),
1243
1273
  writeBack: boolFlag(flags, "no-write-back") ? false : undefined,
package/src/config.ts CHANGED
@@ -18,6 +18,8 @@ import { ragConfig, type RagConfig } from "./cases/rag.ts"
18
18
 
19
19
  export type BrowserProvider = "local" | "browserstack" | "testmu"
20
20
  export type RemoteBrowserProvider = Exclude<BrowserProvider, "local">
21
+ export type BrowserRemoteConfigs = Partial<Record<RemoteBrowserProvider, BrowserRemoteConfig>>
22
+ export type BrowserProviderFiles = Partial<Record<RemoteBrowserProvider, string>>
21
23
 
22
24
  export type BrowserRemoteConfig = {
23
25
  provider: RemoteBrowserProvider
@@ -47,6 +49,8 @@ export type BrowserConfig = {
47
49
  allowedHosts: string[]
48
50
  startUrl?: string
49
51
  remote?: BrowserRemoteConfig
52
+ remotes: BrowserRemoteConfigs
53
+ providerFiles: BrowserProviderFiles
50
54
  /** ForgeQE overlay in a visible browser: edge glow, a bar with the current step, the person's input locked. */
51
55
  overlay: boolean
52
56
  /** A second model call checks the finished run against the goal, and its verdict can fail a run the agent completed. */
@@ -59,6 +63,20 @@ export type MemoryConfig = {
59
63
  replay: ReplayMode
60
64
  }
61
65
 
66
+ export type CacheConfig = {
67
+ enabled: boolean
68
+ /** Redis connection string, e.g. redis://127.0.0.1:6379. */
69
+ redisUrl: string
70
+ /** Prefix for every Redis key this ForgeQE workspace writes. */
71
+ keyPrefix: string
72
+ /** Default TTL for cached JSON payloads, in seconds. */
73
+ defaultTtlSeconds: number
74
+ /** TTL for BI external dataset exports, in seconds. */
75
+ biTtlSeconds: number
76
+ /** Socket connect timeout for Redis, in milliseconds. */
77
+ connectTimeoutMs: number
78
+ }
79
+
62
80
  function runtimeBin(value: unknown, base: string): string | undefined {
63
81
  if (!value) return undefined
64
82
  const s = String(value)
@@ -330,6 +348,8 @@ export type AppConfig = {
330
348
  configPath?: string
331
349
  /** `mcp:` in forgeqe.yaml: more MCP servers for ForgeQE's agent, by name. */
332
350
  mcp?: Record<string, ExtraMcpServer>
351
+ /** Optional Redis-backed cache for deterministic external data fetches. */
352
+ cache?: CacheConfig
333
353
  activeProfile: string
334
354
  profiles: Record<string, LlmProfile>
335
355
  llm: LlmProfile
@@ -390,6 +410,22 @@ function domainConfig(raw: unknown): DomainConfig | undefined {
390
410
  return { industry, regulations, ...(d.notes ? { notes: String(d.notes).trim() } : {}) }
391
411
  }
392
412
 
413
+ function cacheConfig(raw: unknown): CacheConfig | undefined {
414
+ const c = asRecord(raw)
415
+ const asked = raw !== undefined || Boolean(process.env.FORGEQE_REDIS_URL)
416
+ if (!asked || c.enabled === false) return undefined
417
+ const redisUrl = String(c.redis_url || c.url || process.env.FORGEQE_REDIS_URL || "").trim()
418
+ if (!redisUrl) throw new Error('cache.redis_url is required when cache is enabled. Example: cache: { redis_url: "redis://127.0.0.1:6379" }')
419
+ return {
420
+ enabled: true,
421
+ redisUrl,
422
+ keyPrefix: String(c.key_prefix || "forgeqe").trim() || "forgeqe",
423
+ defaultTtlSeconds: positiveInt(c.default_ttl_seconds, 3600, "cache.default_ttl_seconds"),
424
+ biTtlSeconds: positiveInt(c.bi_ttl_seconds, 900, "cache.bi_ttl_seconds"),
425
+ connectTimeoutMs: positiveInt(c.connect_timeout_ms, 3000, "cache.connect_timeout_ms"),
426
+ }
427
+ }
428
+
393
429
  export function replayMode(raw: unknown): ReplayMode {
394
430
  const mode = String(raw || "verify").toLowerCase()
395
431
  if (mode === "trust" || mode === "off") return mode
@@ -444,29 +480,82 @@ const REMOTE_BROWSER_DEFAULTS: Record<RemoteBrowserProvider, Pick<BrowserRemoteC
444
480
  },
445
481
  }
446
482
 
483
+ const REMOTE_BROWSER_PROVIDERS = Object.keys(REMOTE_BROWSER_DEFAULTS) as RemoteBrowserProvider[]
484
+ const REMOTE_BROWSER_KEYS = new Set([
485
+ "browser",
486
+ "browser_version",
487
+ "browserVersion",
488
+ "os",
489
+ "os_version",
490
+ "osVersion",
491
+ "platform",
492
+ "username_env",
493
+ "usernameEnv",
494
+ "access_key_env",
495
+ "accessKeyEnv",
496
+ "endpoint",
497
+ "project",
498
+ "build",
499
+ "name",
500
+ "local",
501
+ "local_testing",
502
+ "extra",
503
+ "capabilities",
504
+ ])
505
+
447
506
  function objectMap(raw: unknown, where: string): Record<string, unknown> | undefined {
448
507
  if (raw === undefined || raw === null) return undefined
449
508
  if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where} must be a map of names to values.`)
450
509
  return { ...(raw as Record<string, unknown>) }
451
510
  }
452
511
 
453
- function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProvider): BrowserRemoteConfig | undefined {
512
+ function browserProviderFiles(raw: Record<string, unknown>, base: string): BrowserProviderFiles {
513
+ const files = objectMap(raw.providers, "browser.providers")
514
+ const out: BrowserProviderFiles = {}
515
+ for (const provider of REMOTE_BROWSER_PROVIDERS) {
516
+ const file = files?.[provider]
517
+ if (file === undefined || file === null || file === false || String(file).trim() === "") continue
518
+ out[provider] = path.resolve(base, String(file))
519
+ }
520
+ return out
521
+ }
522
+
523
+ function browserProviderFileSection(file: string, provider: RemoteBrowserProvider): Record<string, unknown> {
524
+ if (!existsSync(file)) throw new Error(`browser.providers.${provider} not found: ${file}`)
525
+ const doc = readYaml(file)
526
+ const section = Object.keys(asRecord(doc.browser)).length ? { ...asRecord(doc.browser) } : { ...doc }
527
+ if (!section.provider && doc.provider !== undefined) section.provider = doc.provider
528
+ if (!Object.keys(asRecord(section.remote)).length) {
529
+ const rootRemote = asRecord(doc.remote)
530
+ if (Object.keys(rootRemote).length) section.remote = rootRemote
531
+ else {
532
+ const directRemote = Object.fromEntries(Object.entries(section).filter(([k]) => REMOTE_BROWSER_KEYS.has(k)))
533
+ if (Object.keys(directRemote).length) section.remote = directRemote
534
+ }
535
+ }
536
+ if (section.provider !== undefined && browserProvider(section.provider) !== provider) {
537
+ throw new Error(`browser.providers.${provider} points to ${file}, but that file declares provider ${String(section.provider)}.`)
538
+ }
539
+ return section
540
+ }
541
+
542
+ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProvider, where = "browser"): BrowserRemoteConfig | undefined {
454
543
  if (provider === "local") return undefined
455
544
  const remote = asRecord(raw.remote)
456
545
  const defaults = REMOTE_BROWSER_DEFAULTS[provider]
457
546
  const browser = String(remote.browser || "").trim()
458
- if (!browser) throw new Error(`browser.remote.browser is required when browser.provider is ${provider}.`)
547
+ if (!browser) throw new Error(`${where}.remote.browser is required when browser.provider is ${provider}.`)
459
548
  const browserVersion = String(remote.browser_version || remote.browserVersion || "latest").trim() || "latest"
460
549
  const os = String(remote.os || "").trim() || undefined
461
550
  const osVersion = String(remote.os_version || remote.osVersion || "").trim() || undefined
462
551
  const platform = String(remote.platform || "").trim() || undefined
463
552
  if (provider === "browserstack" && (!os || !osVersion)) {
464
- throw new Error("browser.remote.os and browser.remote.os_version are required when browser.provider is browserstack.")
553
+ throw new Error(`${where}.remote.os and ${where}.remote.os_version are required when browser.provider is browserstack.`)
465
554
  }
466
555
  if (provider === "testmu" && !platform && !os) {
467
- throw new Error("browser.remote.platform is required when browser.provider is testmu, or set browser.remote.os (and optionally os_version).")
556
+ throw new Error(`${where}.remote.platform is required when browser.provider is testmu, or set ${where}.remote.os (and optionally os_version).`)
468
557
  }
469
- const extra = objectMap(remote.extra ?? remote.capabilities, "browser.remote.extra")
558
+ const extra = objectMap(remote.extra ?? remote.capabilities, `${where}.remote.extra`)
470
559
  return {
471
560
  provider,
472
561
  usernameEnv: String(remote.username_env || remote.usernameEnv || defaults.usernameEnv),
@@ -485,6 +574,35 @@ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProv
485
574
  }
486
575
  }
487
576
 
577
+ function browserRemoteConfigs(browser: Record<string, unknown>, provider: BrowserProvider, base: string): { remotes: BrowserRemoteConfigs; providerFiles: BrowserProviderFiles } {
578
+ const providerFiles = browserProviderFiles(browser, base)
579
+ const remotes: BrowserRemoteConfigs = {}
580
+ for (const remoteProvider of REMOTE_BROWSER_PROVIDERS) {
581
+ const fromFile = providerFiles[remoteProvider] ? browserProviderFileSection(providerFiles[remoteProvider]!, remoteProvider) : undefined
582
+ const inline = provider === remoteProvider ? browser : undefined
583
+ if (!fromFile && !inline) continue
584
+ const where = fromFile ? `browser.providers.${remoteProvider}` : "browser"
585
+ remotes[remoteProvider] = browserRemoteConfig(
586
+ {
587
+ ...(fromFile || {}),
588
+ ...(inline || {}),
589
+ remote: { ...asRecord(fromFile?.remote), ...asRecord(inline?.remote) },
590
+ },
591
+ remoteProvider,
592
+ where,
593
+ )
594
+ }
595
+ return { remotes, providerFiles }
596
+ }
597
+
598
+ export function browserConfigFor(config: Pick<AppConfig, "browser">, provider = config.browser.provider): Pick<BrowserConfig, "provider" | "remote"> {
599
+ const chosen = provider || config.browser.provider || "local"
600
+ if (chosen === "local") return { provider: chosen }
601
+ const remote = config.browser.remotes?.[chosen] || ((config.browser.provider === chosen || !config.browser.provider) ? config.browser.remote : undefined)
602
+ if (remote) return { provider: chosen, remote }
603
+ throw new Error(`Browser provider ${chosen} is not configured. Add browser.providers.${chosen} to forgeqe.yaml, or set browser.provider: ${chosen} with browser.remote.`)
604
+ }
605
+
488
606
  function profileFromRaw(name: string, raw: Record<string, unknown>): LlmProfile {
489
607
  const provider = String(raw.provider || "openai") as LlmProfile["provider"]
490
608
  return {
@@ -656,14 +774,17 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
656
774
  : []
657
775
 
658
776
  const sessionsDir = configPath ? path.join(projectDir(base), "sessions") : path.join(forgeqeHome(), "sessions")
777
+ const provider = browserProvider(browser.provider)
778
+ const { remotes, providerFiles } = browserRemoteConfigs(browser, provider, base)
659
779
  const config: AppConfig = {
660
780
  configPath,
661
781
  mcp: extraMcpServers(yaml.mcp, base),
782
+ cache: cacheConfig(yaml.cache),
662
783
  activeProfile,
663
784
  profiles,
664
785
  llm: profiles[activeProfile] || { ...NO_PROFILE },
665
786
  browser: {
666
- provider: browserProvider(browser.provider),
787
+ provider,
667
788
  headless: browser.headless !== false,
668
789
  width: Number(viewport.width || 1280),
669
790
  height: Number(viewport.height || 800),
@@ -672,7 +793,9 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
672
793
  visionDetail: visionDetail(browser.vision_detail),
673
794
  allowedHosts: hosts,
674
795
  startUrl: browser.start_url ? String(browser.start_url) : undefined,
675
- remote: browserRemoteConfig(browser, browserProvider(browser.provider)),
796
+ remote: provider === "local" ? undefined : remotes[provider],
797
+ remotes,
798
+ providerFiles,
676
799
  overlay: browser.overlay !== false,
677
800
  judge: browser.judge === true,
678
801
  },
package/src/heal/loop.ts CHANGED
@@ -7,7 +7,7 @@ import { addUsage, costOf, meterSince, meterSnapshot, type Cost } from "../usage
7
7
  import { openSession, type OpencodeSession } from "../code/runtime.ts"
8
8
  import { runTestCommand, type TestRun } from "../code/testrun.ts"
9
9
  import { changedFiles, ownPaths, protectPatterns, restoreRemoved, snapshot } from "../code/worktree.ts"
10
- import type { AppConfig } from "../config.ts"
10
+ import type { AppConfig, BrowserProvider } from "../config.ts"
11
11
  import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "../mcp/server.ts"
12
12
  import { TOOLS, environmentNotes, type ToolContext, type ToolEvent } from "../mcp/tools.ts"
13
13
  import { runDirectory } from "../run.ts"
@@ -79,6 +79,7 @@ export type HealOptions = {
79
79
  /** The repo's test command; default code.test_command, else the agent finds it. */
80
80
  command?: string
81
81
  outDir?: string
82
+ browserProvider?: BrowserProvider
82
83
  headed?: boolean
83
84
  maxSteps?: number
84
85
  url?: string
@@ -149,6 +150,7 @@ export async function runHeal(opts: HealOptions): Promise<HealResult> {
149
150
  config: runsConfig,
150
151
  cwd: repo,
151
152
  headed: opts.headed,
153
+ browserProvider: opts.browserProvider,
152
154
  maxSteps: opts.maxSteps,
153
155
  url: opts.url,
154
156
  signal: opts.signal,
package/src/init.ts CHANGED
@@ -64,6 +64,24 @@ export function initWorkspace(
64
64
  // Prompts, skills, and product knowledge templates, to read and change: .forgeqe/ in a project, ~/.forgeqe for the user.
65
65
  const root = home ? forgeqeHome() : projectDir(directory)
66
66
  const shown = (p: string) => (home ? p : path.relative(directory, p) || p)
67
+ for (const [srcName, destName] of [
68
+ ["browserstack.example.yaml", "browserstack.yaml"],
69
+ ["testmu.example.yaml", "testmu.yaml"],
70
+ ] as const) {
71
+ const src = path.join(ROOT, srcName)
72
+ const dest = path.join(root, destName)
73
+ if (!existsSync(src)) {
74
+ notes.push(`Template missing: ${src}`)
75
+ continue
76
+ }
77
+ if (existsSync(dest) && !opts.force) {
78
+ notes.push(`Kept existing ${shown(dest)}`)
79
+ continue
80
+ }
81
+ mkdirSync(path.dirname(dest), { recursive: true })
82
+ writeFileSync(dest, readFileSync(src, "utf8"), "utf8")
83
+ created.push(dest)
84
+ }
67
85
  for (const { what, src, dest, keys } of [
68
86
  { what: "prompts", src: PROMPTS_DIR, dest: path.join(root, "prompts"), keys: entries(PROMPTS_DIR, (e) => e.endsWith(".md")) },
69
87
  { what: "skills", src: PREBUILT_DIR, dest: path.join(root, "skills"), keys: entries(PREBUILT_DIR, (e) => existsSync(path.join(PREBUILT_DIR, e, "SKILL.md"))) },
package/src/mcp/tools.ts CHANGED
@@ -15,7 +15,7 @@ import { buildHandoff } from "../code/handoff.ts"
15
15
  import { latestRunDir, runCodeAgent } from "../code/index.ts"
16
16
  import type { OpencodeSession } from "../code/runtime.ts"
17
17
  import { runTestCommand } from "../code/testrun.ts"
18
- import type { AppConfig } from "../config.ts"
18
+ import { browserProvider, type AppConfig } from "../config.ts"
19
19
  import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
20
20
  import { executeRun, runDirectory } from "../run.ts"
21
21
  import { counts } from "../suite/report.ts"
@@ -123,6 +123,7 @@ export type ToolContext = {
123
123
  /** The repository or folder the agent works in. */
124
124
  cwd: string
125
125
  headed?: boolean
126
+ browserProvider?: AppConfig["browser"]["provider"]
126
127
  maxSteps?: number
127
128
  replay?: ReplayMode
128
129
  memory?: boolean
@@ -256,7 +257,7 @@ export const TOOLS: McpTool[] = [
256
257
  {
257
258
  name: "browser_run",
258
259
  description:
259
- "Carry out a goal in a real browser with ForgeQE's browser agent and verify the outcome. The agent picks the site from the goal unless start_url is given. Returns pass/fail, the agent's summary, the steps, and the run folder (run.json, workflow.yaml with ranked locators, trace.md, steps and screenshots, report.html). Takes minutes.",
260
+ "Carry out a goal in a real browser with ForgeQE's browser agent and verify the outcome. The browser comes from forgeqe.yaml: local Chrome or Chromium by default, or BrowserStack or TestMu AI/LambdaTest when browser.provider selects one. The agent picks the site from the goal unless start_url is given. Returns pass/fail, the agent's summary, the steps, and the run folder (run.json, workflow.yaml with ranked locators, trace.md, steps and screenshots, report.html). Takes minutes.",
260
261
  inputSchema: {
261
262
  type: "object",
262
263
  properties: {
@@ -266,6 +267,12 @@ export const TOOLS: McpTool[] = [
266
267
  "The final instructions for the browser agent: what to do and what to check, in plain language, with every value it needs. Only the decided task; never your own questions, alternatives, or reasoning about it.",
267
268
  },
268
269
  start_url: { type: "string", description: "Page to open first, only when the user named one" },
270
+ browser_provider: {
271
+ type: "string",
272
+ enum: ["local", "browserstack", "testmu"],
273
+ description:
274
+ "Override the browser provider for this run when the user asked for one specifically. local uses the machine's own browser; browserstack and testmu use their configured YAML or browser.remote settings.",
275
+ },
269
276
  resume_from: {
270
277
  type: "string",
271
278
  description:
@@ -287,6 +294,7 @@ export const TOOLS: McpTool[] = [
287
294
  const from = args.resume_from ? String(args.resume_from) : ""
288
295
  const resumeFrom = from ? (existsSync(path.resolve(ctx.cwd, from)) ? path.resolve(ctx.cwd, from) : from) : undefined
289
296
  const url = args.start_url ? String(args.start_url) : ctx.url
297
+ const selectedBrowser = args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
290
298
  const runDir = runDirectory(ctx.config.export.outDir)
291
299
  const emit = ctx.emit || (() => {})
292
300
  const t0 = Date.now()
@@ -295,6 +303,7 @@ export const TOOLS: McpTool[] = [
295
303
  const { result, files } = await (ctx.execute || executeRun)({
296
304
  config: ctx.config,
297
305
  goal,
306
+ browserProvider: selectedBrowser,
298
307
  url,
299
308
  headed: ctx.headed,
300
309
  maxSteps: ctx.maxSteps,
@@ -410,13 +419,19 @@ export const TOOLS: McpTool[] = [
410
419
  {
411
420
  name: "run_test_cases",
412
421
  description:
413
- "Run test cases in real browsers: each case is one browser goal whose steps are done in order and whose expected results are checked, with a verdict for every step. Writes report.html (steps, screenshots, timings) and junit.xml, posts results back to Azure DevOps, Jira, Xray, TestRail, Zephyr Scale, or qTest unless write_back is false, and files or updates bugs for failures when bugs are set up. In a session that writes tests, each passed case is handed to ForgeQE's coding agent as soon as its browser run ends, and its test is written and run before the next case starts. Returns each case's result, step verdicts, run folder, and test (file, command, status, or why none was written). Takes minutes.",
422
+ "Run test cases in real browsers: each case is one browser goal whose steps are done in order and whose expected results are checked, with a verdict for every step. The browser comes from forgeqe.yaml: local Chrome or Chromium by default, or BrowserStack or TestMu AI/LambdaTest when browser.provider selects one. Writes report.html (steps, screenshots, timings) and junit.xml, posts results back to Azure DevOps, Jira, Xray, TestRail, Zephyr Scale, or qTest unless write_back is false, and files or updates bugs for failures when bugs are set up. In a session that writes tests, each passed case is handed to ForgeQE's coding agent as soon as its browser run ends, and its test is written and run before the next case starts. Returns each case's result, step verdicts, run folder, and test (file, command, status, or why none was written). Takes minutes.",
414
423
  inputSchema: {
415
424
  type: "object",
416
425
  properties: {
417
426
  sources: SOURCES_SCHEMA,
418
427
  parallel: { type: "integer", minimum: 1, maximum: 8, description: "Browsers at once (default from config)" },
419
428
  write_back: { type: "boolean", description: "Post results to the source system (default from config)" },
429
+ browser_provider: {
430
+ type: "string",
431
+ enum: ["local", "browserstack", "testmu"],
432
+ description:
433
+ "Override the browser provider for every case in this suite when the user asked for one specifically.",
434
+ },
420
435
  skills: SKILLS_SCHEMA,
421
436
  },
422
437
  required: ["sources"],
@@ -429,6 +444,7 @@ export const TOOLS: McpTool[] = [
429
444
  if (!cases.length) return { ran: false, reason: "No case can run as written (suite.case_check: block).", check: checkResult(checks) }
430
445
  if (!cases.length) throw new Error("No test cases found.")
431
446
  const parallel = Math.min(Number(args.parallel) || ctx.config.suite.parallel, cases.length)
447
+ const selectedBrowser = args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
432
448
  const emit = ctx.emit || (() => {})
433
449
  emit({ type: "suite-start", cases, parallel })
434
450
  // In a session that writes tests, each passed case goes to the coding agent right after its own browser run,
@@ -455,6 +471,7 @@ export const TOOLS: McpTool[] = [
455
471
  config: ctx.writeTests ? { ...ctx.config, suite: { ...ctx.config.suite, code: true } } : ctx.config,
456
472
  cases,
457
473
  ...(ctx.writeTests ? { repo: ctx.cwd, coder } : {}),
474
+ browserProvider: selectedBrowser,
458
475
  parallel,
459
476
  writeBack: typeof args.write_back === "boolean" ? args.write_back : undefined,
460
477
  headed: ctx.headed,
@@ -765,7 +782,7 @@ function knowledgeNotes(cfg: AppConfig): string {
765
782
  : `No product knowledge yet: the team can describe the product, roles, rules, and test data in ${cfg.knowledge.dir} (forgeqe init adds templates).`
766
783
  }
767
784
 
768
- export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url">): string {
785
+ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url" | "browserProvider">): string {
769
786
  const { config: cfg } = ctx
770
787
  const s = cfg.sources
771
788
  const systems = [
@@ -802,13 +819,17 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
802
819
  const monitor = m.checks.length || channels.length
803
820
  ? `Monitoring (monitor: in forgeqe.yaml): ${m.checks.length ? `checks ${m.checks.join(" ")}` : "no checks yet"}, every ${Math.round(m.everyMs / 60_000)} min, alerts to ${channels.length ? channels.join(" and ") : "nobody yet"}.`
804
821
  : ""
822
+ const browser = cfg.browser || {}
805
823
  const browserSkills = skillsFor(loadSkills(cfg.skills, ctx.cwd), "browser")
824
+ const browserProvider = browser.provider || "local"
825
+ const remoteProviders = (["browserstack", "testmu"] as const).filter((name) => browser.remotes?.[name])
806
826
  const skills = browserSkills.length
807
827
  ? [`Browser skills (pass the ones a goal needs as skills to browser_run or run_test_cases):`, ...catalogLines(browserSkills)].join("\n")
808
828
  : ""
809
829
  return [
810
830
  `Working directory: ${ctx.cwd}`,
811
831
  `Browser runs are saved under ${cfg.export.outDir}; page memory is in ${cfg.memory.dir}.`,
832
+ `Browser provider default: ${browserProvider}.${remoteProviders.length ? ` Also configured: ${remoteProviders.join(", ")}. Pass browser_provider to browser_run or run_test_cases when the user asks for one of them.` : ""}`,
812
833
  `Test case systems configured: ${systems.length ? systems.join("; ") : "none (files and text still work)"}.`,
813
834
  s.confluence ? `Confluence: ${s.confluence.url}${s.confluence.space ? `, space ${s.confluence.space}` : ""} (read_confluence).` : "",
814
835
  `Sprints (list_sprints, find_tests sprint): ${[s.ado ? "Azure DevOps iterations" : "", s.jira ? "Jira scrum boards" : ""].filter(Boolean).join(" and ") || "none configured; ask the user for dates"}.`,
@@ -831,6 +852,7 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
831
852
  `Browser model profile: ${cfg.activeProfile} (${cfg.llm.provider}/${cfg.llm.deployment || cfg.llm.model}).`,
832
853
  keys.length ? `Set in ForgeQE's environment (names only, usable with set_ci_secret): ${keys.join(", ")}.` : "",
833
854
  ctx.url ? `The user pinned this start URL for browser runs: ${ctx.url}` : "",
855
+ ctx.browserProvider ? `The user pinned this browser provider for runs in this session: ${ctx.browserProvider}.` : "",
834
856
  skills,
835
857
  domainNotes(cfg),
836
858
  knowledgeNotes(cfg),
@@ -3,7 +3,7 @@ import path from "node:path"
3
3
 
4
4
  import type { Card } from "../card.ts"
5
5
  import { loadCases, type TestCase } from "../cases/index.ts"
6
- import type { AppConfig } from "../config.ts"
6
+ import type { AppConfig, BrowserProvider } from "../config.ts"
7
7
  import { counts, suiteUsage } from "../suite/report.ts"
8
8
  import { runSuite, type SuiteEvent, type SuiteOptions, type SuiteResult } from "../suite/run.ts"
9
9
  import { formatDuration, formatTokens } from "../tui/theme.ts"
@@ -28,6 +28,7 @@ export type MonitorOptions = {
28
28
  config: AppConfig
29
29
  /** Test case sources; default monitor.checks. */
30
30
  checks: string[]
31
+ browserProvider?: BrowserProvider
31
32
  headed?: boolean
32
33
  url?: string
33
34
  /** Browser skills for every check (empty: none); unset: the engine picks once per check for the monitor's lifetime. */
@@ -67,6 +68,7 @@ export async function monitorRound(opts: MonitorOptions): Promise<RoundResult> {
67
68
  suite = await runSuite({
68
69
  config: { ...config, suite: { ...config.suite, outDir: m.outDir, code: false } },
69
70
  cases,
71
+ browserProvider: opts.browserProvider,
70
72
  parallel: Math.min(m.parallel, cases.length),
71
73
  writeBack: m.writeBack,
72
74
  replay: m.replay,
package/src/run.ts CHANGED
@@ -5,7 +5,7 @@ import path from "node:path"
5
5
  import { assertGoalUrlsAllowed, hostAllowed, normalizeHttpUrl } from "./guard.ts"
6
6
  import { missingKey } from "./llm.ts"
7
7
  import type { AgentEvent, ReplayMode, RunResult } from "./types.ts"
8
- import type { AppConfig } from "./config.ts"
8
+ import { browserConfigFor, type AppConfig, type BrowserProvider } from "./config.ts"
9
9
  import { runEngine } from "./engine/bridge.ts"
10
10
  import { MOCK_ENGINE } from "./engine/mock.ts"
11
11
  import { ensureEngine } from "./engine/setup.ts"
@@ -23,6 +23,7 @@ import { knowledgeBlock, readKnowledge, relevantKnowledge } from "./knowledge.ts
23
23
  export type ExecuteOptions = {
24
24
  config: AppConfig
25
25
  goal: string
26
+ browserProvider?: BrowserProvider
26
27
  url?: string
27
28
  headed?: boolean
28
29
  maxSteps?: number
@@ -148,6 +149,7 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
148
149
 
149
150
  const dir = opts.outDir || runDirectory(opts.config.export.outDir)
150
151
  const maxSteps = opts.maxSteps || opts.config.browser.maxSteps
152
+ const browser = browserConfigFor(opts.config, opts.browserProvider)
151
153
  const t0 = Date.now()
152
154
  const withReport = (out: { result: RunResult; files: string[] }) => {
153
155
  out.result.cost = costOf(out.result.usage, opts.config.llm.price)
@@ -216,7 +218,7 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
216
218
  headed: Boolean(opts.headed) || !opts.config.browser.headless,
217
219
  maxSteps,
218
220
  allowedHosts: allowed,
219
- browser: { provider: opts.config.browser.provider, remote: opts.config.browser.remote },
221
+ browser,
220
222
  extendSystemMessage: extend || undefined,
221
223
  memory,
222
224
  env,