@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.
@@ -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,26 +17,32 @@ 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
- forgeqe bi [build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]
38
- export ForgeQE + ADO/Jira datasets into an Evidence project and build static BI HTML
39
- forgeqe bi init scaffold or refresh the managed Evidence project files
41
+ forgeqe bi [build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR] [--static | --evidence]
42
+ export ForgeQE + ADO/Jira datasets into an Evidence project and build a static
43
+ site; without Node.js or when Evidence fails, ForgeQE's own page (--static: always,
44
+ --evidence: fail instead)
45
+ forgeqe bi init scaffold the Evidence project and install its packages
40
46
  forgeqe bi sync refresh the CSV datasets and generated Evidence pages, without building
41
47
  forgeqe pr-tests [pr] [--base BRANCH] [--agent] [--run] [--post]
42
48
  the tests a pull request (or the commits since BRANCH) can break; --run runs
@@ -52,6 +58,7 @@ Usage:
52
58
  forgeqe visual approve [run-dir] [--page a,b] [--yes]
53
59
  make the run's screenshots the new baselines (an intended change)
54
60
  forgeqe explore <story> [--url URL] [--charters N] [--roles a,b] [--focus TEXT] [--yes] [--file-bugs]
61
+ [--browser-provider local|browserstack|testmu]
55
62
  exploratory testing of a story (ado:123, SHOP-12, a link, or its text):
56
63
  charters beyond the scripted cases, defects confirmed by a second run,
57
64
  proposed test cases, bugs for confirmed defects; exits 1 on a defect
@@ -104,6 +111,7 @@ page with ranked locators and a confidence %, plus the steps of each goal's last
104
111
  --replay verify replay a known goal without the LLM, then one LLM step checks the result (default)
105
112
  --replay trust replay and finish without the LLM when every step works
106
113
  --replay off never replay; still record pages
114
+ --browser-provider pick which configured browser provider runs this command's browser work
107
115
 
108
116
  ForgeQE's agent (forgeqe start, forgeqe run) runs on the agent runtime with the active profile's model (or code.model) and
109
117
  calls ForgeQE's tools: browser runs, reading and running test cases, writing tests and running them, page memory, pushing
@@ -132,8 +140,9 @@ The report is report.html (step verdicts, timeline with screenshots, timings, en
132
140
  out/suites/<time>/; every run folder also has its own report.html.
133
141
 
134
142
  Static BI (bi: in forgeqe.yaml): \`forgeqe bi\` writes curated CSV datasets from ForgeQE's own suite and readiness
135
- reports plus optional Azure DevOps WIQL and Jira JQL datasets, scaffolds an Evidence project, and runs
136
- \`evidence build\` to produce static HTML in the build directory. No server stays up between runs.
143
+ reports plus optional Azure DevOps WIQL and Jira JQL datasets, scaffolds an Evidence project (@evidence-dev/evidence
144
+ 40.x, needs Node.js), and runs \`evidence sources\` and \`evidence build\` to produce a static site in the build directory;
145
+ when that cannot run, it writes ForgeQE's own self-contained page instead. No server stays up between runs.
137
146
 
138
147
  Healing (forgeqe heal): ForgeQE runs the tests (the command given, code.test_command, or one the agent finds).
139
148
  For each failing test, Scribe re-runs its flow in the real browser: when the flow still works, the test is out
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
  }
@@ -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") },
@@ -474,11 +489,16 @@ async function cmdBi(args: string[], flags: Flags): Promise<number> {
474
489
  dataDir: strFlag(flags, "data-dir"),
475
490
  buildDir: strFlag(flags, "build-dir") || strFlag(flags, "out"),
476
491
  }
492
+ const shown = (file: string) => path.relative(process.cwd(), file) || file
493
+ const printNotes = (notes: string[]) => {
494
+ for (const note of notes) console.log(`note ${note}`)
495
+ }
477
496
  switch (action) {
478
497
  case "init": {
479
498
  const out = await initBiProject(cfg, overrides)
480
499
  console.log(`Evidence project ${out.evidenceDir}`)
481
- for (const file of out.files) console.log(`generated ${path.relative(process.cwd(), file) || file}`)
500
+ for (const file of out.files) console.log(`generated ${shown(file)}`)
501
+ printNotes(out.notes)
482
502
  return 0
483
503
  }
484
504
  case "sync": {
@@ -490,23 +510,30 @@ async function cmdBi(args: string[], flags: Flags): Promise<number> {
490
510
  console.log(`Case results ${out.caseResults}`)
491
511
  console.log(`Readiness reports ${out.readinessReports}`)
492
512
  console.log(`External datasets ${out.externalDatasets}`)
493
- console.log(`Summary ${path.relative(process.cwd(), out.summaryFile) || out.summaryFile}`)
513
+ console.log(`Summary ${shown(out.summaryFile)}`)
514
+ printNotes(out.notes)
494
515
  return 0
495
516
  }
496
517
  case "build": {
497
- const out = await buildBi(cfg, overrides)
518
+ const out = await buildBi(cfg, overrides, boolFlag(flags, "static") ? "static" : boolFlag(flags, "evidence") ? "evidence" : "auto")
498
519
  console.log(`Evidence project ${out.evidenceDir}`)
499
520
  console.log(`Static site ${out.buildDir}`)
500
- console.log(`Index ${path.relative(process.cwd(), out.indexFile) || out.indexFile}`)
501
- console.log(`Summary ${path.relative(process.cwd(), out.summaryFile) || out.summaryFile}`)
521
+ console.log(`Index ${shown(out.indexFile)}`)
522
+ console.log(`Summary ${shown(out.summaryFile)}`)
502
523
  console.log(`Suite runs ${out.suites}`)
503
524
  console.log(`Case results ${out.caseResults}`)
504
525
  console.log(`Readiness reports ${out.readinessReports}`)
505
526
  console.log(`External datasets ${out.externalDatasets}`)
527
+ if (out.engine === "evidence") {
528
+ console.log("Built with Evidence. Serve the folder to view it (npx serve, or evidence preview): opened from disk it cannot load its data.")
529
+ } else {
530
+ console.log(`Built with ForgeQE's own page, without Evidence (${out.fallbackReason}). It opens straight from disk.`)
531
+ }
532
+ printNotes(out.notes)
506
533
  return 0
507
534
  }
508
535
  default:
509
- throw new Error("Usage: forgeqe bi [init|sync|build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]")
536
+ throw new Error("Usage: forgeqe bi [init|sync|build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR] [--static | --evidence]")
510
537
  }
511
538
  }
512
539
 
@@ -913,6 +940,7 @@ async function runAgent(cfg: AppConfig, flags: Flags, text: string, maxSteps: nu
913
940
  signal: controller.signal,
914
941
  context: () => ({
915
942
  headed: boolFlag(flags, "headed"),
943
+ browserProvider: browserProviderFlag(flags),
916
944
  maxSteps,
917
945
  url: strFlag(flags, "url"),
918
946
  ...memoryFlags(flags),
@@ -973,7 +1001,17 @@ async function cmdMcp(args: string[], flags: Flags): Promise<number> {
973
1001
  const repo = repoFlag(flags)
974
1002
  const cwd = repo || process.cwd()
975
1003
  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" })
1004
+ const context = (): ToolContext => ({
1005
+ config: cfg,
1006
+ cwd,
1007
+ headed: boolFlag(flags, "headed"),
1008
+ browserProvider: browserProviderFlag(flags),
1009
+ maxSteps: maxStepsFlag(flags),
1010
+ url: strFlag(flags, "url"),
1011
+ ...memoryFlags(flags),
1012
+ emit: log,
1013
+ session: "server",
1014
+ })
977
1015
  const server = createMcpServer({ tools: TOOLS, context, instructions: () => environmentNotes(context()), version: packageVersion() })
978
1016
  process.stderr.write(`ForgeQE MCP server on stdio (${TOOLS.length} tools, working in ${cwd})\n`)
979
1017
  await serveStdio(server)
@@ -988,6 +1026,7 @@ async function runGoal(cfg: AppConfig, flags: Flags, goal: string, maxSteps: num
988
1026
  const { result, files } = await executeRun({
989
1027
  config: cfg,
990
1028
  goal,
1029
+ browserProvider: browserProviderFlag(flags),
991
1030
  url: strFlag(flags, "url"),
992
1031
  headed: boolFlag(flags, "headed"),
993
1032
  maxSteps,
@@ -1129,6 +1168,7 @@ async function cmdHeal(args: string[], flags: Flags): Promise<number> {
1129
1168
  repo,
1130
1169
  command,
1131
1170
  outDir: strFlag(flags, "out"),
1171
+ browserProvider: browserProviderFlag(flags),
1132
1172
  headed: boolFlag(flags, "headed"),
1133
1173
  maxSteps: maxStepsFlag(flags),
1134
1174
  url: strFlag(flags, "url"),
@@ -1200,6 +1240,7 @@ async function cmdMonitor(refs: string[], flags: Flags): Promise<number> {
1200
1240
  config: cfg,
1201
1241
  checks,
1202
1242
  once,
1243
+ browserProvider: browserProviderFlag(flags),
1203
1244
  headed: boolFlag(flags, "headed"),
1204
1245
  url: strFlag(flags, "url"),
1205
1246
  skills: skillFlags(flags),
@@ -1238,6 +1279,7 @@ async function cmdSuite(refs: string[], flags: Flags): Promise<number> {
1238
1279
  config: cfg,
1239
1280
  cases,
1240
1281
  repo,
1282
+ browserProvider: browserProviderFlag(flags),
1241
1283
  parallel,
1242
1284
  outDir: strFlag(flags, "out"),
1243
1285
  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. */
@@ -435,43 +439,104 @@ const REMOTE_BROWSER_DEFAULTS: Record<RemoteBrowserProvider, Pick<BrowserRemoteC
435
439
  browserstack: {
436
440
  usernameEnv: "BROWSERSTACK_USERNAME",
437
441
  accessKeyEnv: "BROWSERSTACK_ACCESS_KEY",
438
- endpoint: "wss://cdp.browserstack.com/playwright",
442
+ endpoint: "wss://cdp.browserstack.com/puppeteer",
439
443
  },
440
444
  testmu: {
441
445
  usernameEnv: "LT_USERNAME",
442
446
  accessKeyEnv: "LT_ACCESS_KEY",
443
- endpoint: "wss://cdp.lambdatest.com/playwright",
447
+ endpoint: "wss://cdp.lambdatest.com/puppeteer",
444
448
  },
445
449
  }
446
450
 
451
+ const REMOTE_BROWSER_PROVIDERS = Object.keys(REMOTE_BROWSER_DEFAULTS) as RemoteBrowserProvider[]
452
+ const REMOTE_BROWSER_KEYS = new Set([
453
+ "browser",
454
+ "browser_version",
455
+ "browserVersion",
456
+ "os",
457
+ "os_version",
458
+ "osVersion",
459
+ "platform",
460
+ "username_env",
461
+ "usernameEnv",
462
+ "access_key_env",
463
+ "accessKeyEnv",
464
+ "endpoint",
465
+ "project",
466
+ "build",
467
+ "name",
468
+ "local",
469
+ "local_testing",
470
+ "extra",
471
+ "capabilities",
472
+ ])
473
+
447
474
  function objectMap(raw: unknown, where: string): Record<string, unknown> | undefined {
448
475
  if (raw === undefined || raw === null) return undefined
449
476
  if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where} must be a map of names to values.`)
450
477
  return { ...(raw as Record<string, unknown>) }
451
478
  }
452
479
 
453
- function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProvider): BrowserRemoteConfig | undefined {
480
+ function browserProviderFiles(raw: Record<string, unknown>, base: string): BrowserProviderFiles {
481
+ const files = objectMap(raw.providers, "browser.providers")
482
+ const out: BrowserProviderFiles = {}
483
+ for (const provider of REMOTE_BROWSER_PROVIDERS) {
484
+ const file = files?.[provider]
485
+ if (file === undefined || file === null || file === false || String(file).trim() === "") continue
486
+ out[provider] = path.resolve(base, String(file))
487
+ }
488
+ return out
489
+ }
490
+
491
+ function browserProviderFileSection(file: string, provider: RemoteBrowserProvider): Record<string, unknown> {
492
+ if (!existsSync(file)) throw new Error(`browser.providers.${provider} not found: ${file}`)
493
+ const doc = readYaml(file)
494
+ const section = Object.keys(asRecord(doc.browser)).length ? { ...asRecord(doc.browser) } : { ...doc }
495
+ if (!section.provider && doc.provider !== undefined) section.provider = doc.provider
496
+ if (!Object.keys(asRecord(section.remote)).length) {
497
+ const rootRemote = asRecord(doc.remote)
498
+ if (Object.keys(rootRemote).length) section.remote = rootRemote
499
+ else {
500
+ const directRemote = Object.fromEntries(Object.entries(section).filter(([k]) => REMOTE_BROWSER_KEYS.has(k)))
501
+ if (Object.keys(directRemote).length) section.remote = directRemote
502
+ }
503
+ }
504
+ if (section.provider !== undefined && browserProvider(section.provider) !== provider) {
505
+ throw new Error(`browser.providers.${provider} points to ${file}, but that file declares provider ${String(section.provider)}.`)
506
+ }
507
+ return section
508
+ }
509
+
510
+ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProvider, where = "browser"): BrowserRemoteConfig | undefined {
454
511
  if (provider === "local") return undefined
455
512
  const remote = asRecord(raw.remote)
456
513
  const defaults = REMOTE_BROWSER_DEFAULTS[provider]
457
514
  const browser = String(remote.browser || "").trim()
458
- if (!browser) throw new Error(`browser.remote.browser is required when browser.provider is ${provider}.`)
515
+ if (!browser) throw new Error(`${where}.remote.browser is required when browser.provider is ${provider}.`)
516
+ // The engine drives the browser over the Chrome DevTools Protocol, so the grid must hand back a Chromium browser.
517
+ if (!/chrome|edge|chromium/i.test(browser) || /^(playwright|pw)-/i.test(browser)) {
518
+ throw new Error(`${where}.remote.browser is "${browser}": ForgeQE drives remote browsers over the Chrome DevTools Protocol, so use chrome or edge.`)
519
+ }
459
520
  const browserVersion = String(remote.browser_version || remote.browserVersion || "latest").trim() || "latest"
460
521
  const os = String(remote.os || "").trim() || undefined
461
522
  const osVersion = String(remote.os_version || remote.osVersion || "").trim() || undefined
462
523
  const platform = String(remote.platform || "").trim() || undefined
463
524
  if (provider === "browserstack" && (!os || !osVersion)) {
464
- throw new Error("browser.remote.os and browser.remote.os_version are required when browser.provider is browserstack.")
525
+ throw new Error(`${where}.remote.os and ${where}.remote.os_version are required when browser.provider is browserstack.`)
465
526
  }
466
527
  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).")
528
+ throw new Error(`${where}.remote.platform is required when browser.provider is testmu, or set ${where}.remote.os (and optionally os_version).`)
529
+ }
530
+ const endpoint = String(remote.endpoint || defaults.endpoint).trim().replace(/\/+$/, "")
531
+ if (/\/playwright$/i.test(endpoint)) {
532
+ throw new Error(`${where}.remote.endpoint ${endpoint} speaks Playwright's own protocol; ForgeQE needs the CDP endpoint (${defaults.endpoint}).`)
468
533
  }
469
- const extra = objectMap(remote.extra ?? remote.capabilities, "browser.remote.extra")
534
+ const extra = objectMap(remote.extra ?? remote.capabilities, `${where}.remote.extra`)
470
535
  return {
471
536
  provider,
472
537
  usernameEnv: String(remote.username_env || remote.usernameEnv || defaults.usernameEnv),
473
538
  accessKeyEnv: String(remote.access_key_env || remote.accessKeyEnv || defaults.accessKeyEnv),
474
- endpoint: String(remote.endpoint || defaults.endpoint).trim().replace(/\/+$/, ""),
539
+ endpoint,
475
540
  browser,
476
541
  browserVersion,
477
542
  ...(os ? { os } : {}),
@@ -485,6 +550,35 @@ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProv
485
550
  }
486
551
  }
487
552
 
553
+ function browserRemoteConfigs(browser: Record<string, unknown>, provider: BrowserProvider, base: string): { remotes: BrowserRemoteConfigs; providerFiles: BrowserProviderFiles } {
554
+ const providerFiles = browserProviderFiles(browser, base)
555
+ const remotes: BrowserRemoteConfigs = {}
556
+ for (const remoteProvider of REMOTE_BROWSER_PROVIDERS) {
557
+ const fromFile = providerFiles[remoteProvider] ? browserProviderFileSection(providerFiles[remoteProvider]!, remoteProvider) : undefined
558
+ const inline = provider === remoteProvider ? browser : undefined
559
+ if (!fromFile && !inline) continue
560
+ const where = fromFile ? `browser.providers.${remoteProvider}` : "browser"
561
+ remotes[remoteProvider] = browserRemoteConfig(
562
+ {
563
+ ...(fromFile || {}),
564
+ ...(inline || {}),
565
+ remote: { ...asRecord(fromFile?.remote), ...asRecord(inline?.remote) },
566
+ },
567
+ remoteProvider,
568
+ where,
569
+ )
570
+ }
571
+ return { remotes, providerFiles }
572
+ }
573
+
574
+ export function browserConfigFor(config: Pick<AppConfig, "browser">, provider = config.browser.provider): Pick<BrowserConfig, "provider" | "remote"> {
575
+ const chosen = provider || config.browser.provider || "local"
576
+ if (chosen === "local") return { provider: chosen }
577
+ const remote = config.browser.remotes?.[chosen] || ((config.browser.provider === chosen || !config.browser.provider) ? config.browser.remote : undefined)
578
+ if (remote) return { provider: chosen, remote }
579
+ throw new Error(`Browser provider ${chosen} is not configured. Add browser.providers.${chosen} to forgeqe.yaml, or set browser.provider: ${chosen} with browser.remote.`)
580
+ }
581
+
488
582
  function profileFromRaw(name: string, raw: Record<string, unknown>): LlmProfile {
489
583
  const provider = String(raw.provider || "openai") as LlmProfile["provider"]
490
584
  return {
@@ -656,6 +750,8 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
656
750
  : []
657
751
 
658
752
  const sessionsDir = configPath ? path.join(projectDir(base), "sessions") : path.join(forgeqeHome(), "sessions")
753
+ const provider = browserProvider(browser.provider)
754
+ const { remotes, providerFiles } = browserRemoteConfigs(browser, provider, base)
659
755
  const config: AppConfig = {
660
756
  configPath,
661
757
  mcp: extraMcpServers(yaml.mcp, base),
@@ -663,7 +759,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
663
759
  profiles,
664
760
  llm: profiles[activeProfile] || { ...NO_PROFILE },
665
761
  browser: {
666
- provider: browserProvider(browser.provider),
762
+ provider,
667
763
  headless: browser.headless !== false,
668
764
  width: Number(viewport.width || 1280),
669
765
  height: Number(viewport.height || 800),
@@ -672,7 +768,9 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
672
768
  visionDetail: visionDetail(browser.vision_detail),
673
769
  allowedHosts: hosts,
674
770
  startUrl: browser.start_url ? String(browser.start_url) : undefined,
675
- remote: browserRemoteConfig(browser, browserProvider(browser.provider)),
771
+ remote: provider === "local" ? undefined : remotes[provider],
772
+ remotes,
773
+ providerFiles,
676
774
  overlay: browser.overlay !== false,
677
775
  judge: browser.judge === true,
678
776
  },
@@ -953,9 +1051,12 @@ function apiConfig(a: Record<string, unknown>): ApiConfig {
953
1051
  }
954
1052
  }
955
1053
 
1054
+ /** The tables forgeqe bi always exports; a dataset id may not reuse one. */
1055
+ export const BI_BUILTIN_TABLES = ["suites", "case_results", "step_results", "writebacks", "readiness", "readiness_stories", "readiness_cases", "external_datasets"]
1056
+
956
1057
  function biConfig(b: Record<string, unknown>, base: string, outDir: string, configPath: string | undefined): BiConfig {
957
1058
  const evidenceDir = path.resolve(base, String(b.evidence_dir || path.join(".", "evidence")))
958
- const defaultDataDir = path.join(evidenceDir, "sources", "forgeqe", "data")
1059
+ const defaultDataDir = path.join(evidenceDir, "sources", "forgeqe")
959
1060
  const title = String(b.title || "ForgeQE BI").trim() || "ForgeQE BI"
960
1061
  const datasets = Object.entries(asRecord(b.datasets)).map(([id, value]) => biDataset(id, asRecord(value)))
961
1062
  return {
@@ -968,7 +1069,9 @@ function biConfig(b: Record<string, unknown>, base: string, outDir: string, conf
968
1069
  }
969
1070
 
970
1071
  function biDataset(id: string, raw: Record<string, unknown>): BiDatasetConfig {
971
- if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`bi.datasets.${id}: ids may only use letters, digits, - and _.`)
1072
+ // The id is the dataset's table name in Evidence's SQL (forgeqe.<id>), so it must be a plain identifier.
1073
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(id)) throw new Error(`bi.datasets.${id}: ids may only use letters, digits and _, and must not start with a digit.`)
1074
+ if (BI_BUILTIN_TABLES.includes(id)) throw new Error(`bi.datasets.${id}: ${id} is one of ForgeQE's own BI tables; choose another id.`)
972
1075
  const title = String(raw.title || id.replace(/[-_]+/g, " ").trim()).trim()
973
1076
  const kind = String(raw.kind || "").trim().toLowerCase()
974
1077
  if (kind === "ado_wiql") {
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),