@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.
@@ -4,6 +4,10 @@ llm:
4
4
 
5
5
  browser:
6
6
  # provider: local # local | browserstack | testmu
7
+ # providers: # keep BrowserStack / TestMu AI details in separate YAML files
8
+ # browserstack: ./.forgeqe/browserstack.yaml
9
+ # testmu: ./.forgeqe/testmu.yaml
10
+ # # ForgeQE's agent can switch to one of these per request with browser_provider
7
11
  headless: true
8
12
  max_steps: 40 # a checkpoint: a run still making progress carries on, up to 3x
9
13
  timeout_seconds: 600 # whole run
@@ -15,7 +19,7 @@ browser:
15
19
  judge: false # true: a second model call checks each finished run against the goal and can fail it
16
20
  overlay: true # visible browser: ForgeQE start page, edge glow, a bar with the current step, your mouse and keyboard locked while the agent works
17
21
  # start_url: https://staging.example.com # optional; omit to let the agent choose the site
18
- # remote: # only when provider is browserstack or testmu
22
+ # remote: # inline settings for the selected remote provider; override the provider file when both are set
19
23
  # browser: playwright-chromium # BrowserStack: playwright-chromium / playwright-firefox / playwright-webkit
20
24
  # browser_version: latest
21
25
  # os: osx
@@ -71,6 +75,14 @@ memory:
71
75
  dir: ./.forgeqe/memory # pages, elements, locators, and recorded flows
72
76
  replay: verify # verify | trust | off
73
77
 
78
+ # Redis cache for deterministic external data fetches, such as BI datasets. Live browser runs still execute.
79
+ # cache:
80
+ # redis_url: redis://127.0.0.1:6379
81
+ # key_prefix: forgeqe
82
+ # default_ttl_seconds: 3600
83
+ # bi_ttl_seconds: 900
84
+ # connect_timeout_ms: 3000
85
+
74
86
  code:
75
87
  # repo: ../my-app # where passed runs become tests (default: the current folder; --no-code turns it off)
76
88
  # test_command: npx playwright test # the repo's test command, for forgeqe heal; default: the agent finds it
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "1.1.2",
4
- "description": "ForgeQE: a browser agent CLI that runs goals and test cases in Chrome and turns passing runs into tests in your repo",
3
+ "version": "1.1.3",
4
+ "description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -20,7 +20,9 @@
20
20
  "README.md",
21
21
  "HOW_TO_USE.md",
22
22
  "forgeqe-architecture.html",
23
+ "browserstack.example.yaml",
23
24
  "forgeqe.example.yaml",
25
+ "testmu.example.yaml",
24
26
  "llms.example.json"
25
27
  ],
26
28
  "engines": {
@@ -53,6 +55,7 @@
53
55
  "pixelmatch": "8.0.0",
54
56
  "pngjs": "7.0.0",
55
57
  "react": "^19.3.0",
58
+ "redis": "^4.7.1",
56
59
  "yaml": "^2.9.1"
57
60
  },
58
61
  "devDependencies": {
@@ -46,6 +46,7 @@ from .llm import (
46
46
  from .browser import (
47
47
  browser_connection_label,
48
48
  browser_launch_kwargs,
49
+ local_browser_launch_kwargs,
49
50
  settle_page,
50
51
  _shutdown_browser_instance,
51
52
  _stop_agent_instance,
@@ -58,8 +58,8 @@ export function envNames(cfg?: AppConfig) {
58
58
  zephyr: { url: "ZEPHYR_URL", token: s?.zephyr?.tokenEnv || "ZEPHYR_API_TOKEN" },
59
59
  qtest: { url: "QTEST_URL", project: "QTEST_PROJECT_ID", token: s?.qtest?.tokenEnv || "QTEST_TOKEN" },
60
60
  xray: { url: "XRAY_URL", id: s?.xray.clientIdEnv || "XRAY_CLIENT_ID", secret: s?.xray.clientSecretEnv || "XRAY_CLIENT_SECRET" },
61
- browserstack: { user: b?.remote?.provider === "browserstack" ? b.remote.usernameEnv : "BROWSERSTACK_USERNAME", key: b?.remote?.provider === "browserstack" ? b.remote.accessKeyEnv : "BROWSERSTACK_ACCESS_KEY" },
62
- testmu: { user: b?.remote?.provider === "testmu" ? b.remote.usernameEnv : "LT_USERNAME", key: b?.remote?.provider === "testmu" ? b.remote.accessKeyEnv : "LT_ACCESS_KEY" },
61
+ browserstack: { user: b?.remotes?.browserstack?.usernameEnv || "BROWSERSTACK_USERNAME", key: b?.remotes?.browserstack?.accessKeyEnv || "BROWSERSTACK_ACCESS_KEY" },
62
+ testmu: { user: b?.remotes?.testmu?.usernameEnv || "LT_USERNAME", key: b?.remotes?.testmu?.accessKeyEnv || "LT_ACCESS_KEY" },
63
63
  }
64
64
  }
65
65
 
package/src/bi.ts CHANGED
@@ -7,6 +7,7 @@ import { adoSettings } from "./cases/ado.ts"
7
7
  import { FAILURE_ACTION } from "./cases/failure.ts"
8
8
  import { http } from "./cases/http.ts"
9
9
  import { jiraApi } from "./cases/jira.ts"
10
+ import { cachedJson } from "./cache.ts"
10
11
  import type { CaseResult, WriteBack } from "./cases/types.ts"
11
12
  import type { AppConfig, BiConfig, BiDatasetConfig } from "./config.ts"
12
13
  import { counts as suiteCounts } from "./suite/report.ts"
@@ -643,57 +644,71 @@ async function exportExternalDatasets(cfg: AppConfig, paths: Paths): Promise<Ext
643
644
  async function fetchAdoRows(cfg: AppConfig, dataset: Extract<BiDatasetConfig, { kind: "ado_wiql" }>): Promise<Record<string, string | number>[]> {
644
645
  const { auth, org, project: configured } = adoSettings(cfg.sources)
645
646
  const project = dataset.project || configured
646
- const base = project ? `${org}/${encodeURIComponent(project)}` : org
647
- const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { source?: { id?: number } | null; target?: { id?: number } | null }[] }>(
648
- `${base}/_apis/wit/wiql?api-version=7.1&$top=500`,
649
- { auth, body: { query: dataset.wiql } },
650
- )
651
- const ids = [...new Set([...(data.workItems || []).map((w) => w.id), ...(data.workItemRelations || []).flatMap((r) => [r.source?.id, r.target?.id]).filter((id): id is number => typeof id === "number")])].slice(0, 500)
652
- if (!ids.length) return []
653
- const fields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.AssignedTo", "System.ChangedDate", "System.IterationPath", "System.TeamProject"]
654
- const rows: Record<string, string | number>[] = []
655
- for (let i = 0; i < ids.length; i += 200) {
656
- const batch = await http<{ value?: { id: number; fields: Record<string, unknown> }[] }>(`${org}/_apis/wit/workitemsbatch?api-version=7.1`, { auth, body: { ids: ids.slice(i, i + 200), fields } })
657
- for (const item of batch.data.value || []) {
658
- rows.push({
659
- id: item.id,
660
- project: String(item.fields["System.TeamProject"] || project || ""),
661
- type: String(item.fields["System.WorkItemType"] || ""),
662
- title: String(item.fields["System.Title"] || ""),
663
- state: String(item.fields["System.State"] || ""),
664
- assigned_to: displayName(item.fields["System.AssignedTo"]),
665
- changed_at: String(item.fields["System.ChangedDate"] || ""),
666
- iteration: String(item.fields["System.IterationPath"] || ""),
667
- })
668
- }
669
- }
670
- return rows
647
+ return cachedJson(cfg.cache, {
648
+ namespace: "bi:ado_wiql",
649
+ keyParts: [org, project || "", dataset.wiql],
650
+ ttlSeconds: cfg.cache?.biTtlSeconds,
651
+ load: async () => {
652
+ const base = project ? `${org}/${encodeURIComponent(project)}` : org
653
+ const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { source?: { id?: number } | null; target?: { id?: number } | null }[] }>(
654
+ `${base}/_apis/wit/wiql?api-version=7.1&$top=500`,
655
+ { auth, body: { query: dataset.wiql } },
656
+ )
657
+ const ids = [...new Set([...(data.workItems || []).map((w) => w.id), ...(data.workItemRelations || []).flatMap((r) => [r.source?.id, r.target?.id]).filter((id): id is number => typeof id === "number")])].slice(0, 500)
658
+ if (!ids.length) return []
659
+ const fields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.AssignedTo", "System.ChangedDate", "System.IterationPath", "System.TeamProject"]
660
+ const rows: Record<string, string | number>[] = []
661
+ for (let i = 0; i < ids.length; i += 200) {
662
+ const batch = await http<{ value?: { id: number; fields: Record<string, unknown> }[] }>(`${org}/_apis/wit/workitemsbatch?api-version=7.1`, { auth, body: { ids: ids.slice(i, i + 200), fields } })
663
+ for (const item of batch.data.value || []) {
664
+ rows.push({
665
+ id: item.id,
666
+ project: String(item.fields["System.TeamProject"] || project || ""),
667
+ type: String(item.fields["System.WorkItemType"] || ""),
668
+ title: String(item.fields["System.Title"] || ""),
669
+ state: String(item.fields["System.State"] || ""),
670
+ assigned_to: displayName(item.fields["System.AssignedTo"]),
671
+ changed_at: String(item.fields["System.ChangedDate"] || ""),
672
+ iteration: String(item.fields["System.IterationPath"] || ""),
673
+ })
674
+ }
675
+ }
676
+ return rows
677
+ },
678
+ })
671
679
  }
672
680
 
673
681
  async function fetchJiraRows(cfg: AppConfig, dataset: Extract<BiDatasetConfig, { kind: "jira_jql" }>): Promise<Record<string, string>[]> {
674
682
  const jira = jiraApi(cfg.sources)
675
683
  const fields = "summary,status,statuscategory,issuetype,priority,assignee,reporter,labels,components,parent,created,updated"
676
- const rows: Record<string, string>[] = []
677
- if (jira.cloud) {
678
- let next: string | undefined
679
- do {
680
- const q = new URLSearchParams({ jql: dataset.jql, fields, maxResults: "100", ...(next ? { nextPageToken: next } : {}) })
681
- const { data } = await http<{ issues?: any[]; nextPageToken?: string; isLast?: boolean }>(`${jira.api}/search/jql?${q}`, { auth: jira.auth })
682
- for (const issue of data.issues || []) rows.push(jiraRow(jira.url, issue))
683
- next = data.isLast === false || data.nextPageToken ? data.nextPageToken : undefined
684
- } while (next)
685
- } else {
686
- let startAt = 0
687
- for (;;) {
688
- const q = new URLSearchParams({ jql: dataset.jql, fields, maxResults: "100", startAt: String(startAt) })
689
- const { data } = await http<{ issues?: any[]; total?: number }>(`${jira.api}/search?${q}`, { auth: jira.auth })
690
- const page = data.issues || []
691
- for (const issue of page) rows.push(jiraRow(jira.url, issue))
692
- startAt += page.length
693
- if (!page.length || startAt >= Number(data.total || 0)) break
694
- }
695
- }
696
- return rows
684
+ return cachedJson(cfg.cache, {
685
+ namespace: "bi:jira_jql",
686
+ keyParts: [jira.url, jira.cloud, dataset.jql, fields],
687
+ ttlSeconds: cfg.cache?.biTtlSeconds,
688
+ load: async () => {
689
+ const rows: Record<string, string>[] = []
690
+ if (jira.cloud) {
691
+ let next: string | undefined
692
+ do {
693
+ const q = new URLSearchParams({ jql: dataset.jql, fields, maxResults: "100", ...(next ? { nextPageToken: next } : {}) })
694
+ const { data } = await http<{ issues?: any[]; nextPageToken?: string; isLast?: boolean }>(`${jira.api}/search/jql?${q}`, { auth: jira.auth })
695
+ for (const issue of data.issues || []) rows.push(jiraRow(jira.url, issue))
696
+ next = data.isLast === false || data.nextPageToken ? data.nextPageToken : undefined
697
+ } while (next)
698
+ } else {
699
+ let startAt = 0
700
+ for (;;) {
701
+ const q = new URLSearchParams({ jql: dataset.jql, fields, maxResults: "100", startAt: String(startAt) })
702
+ const { data } = await http<{ issues?: any[]; total?: number }>(`${jira.api}/search?${q}`, { auth: jira.auth })
703
+ const page = data.issues || []
704
+ for (const issue of page) rows.push(jiraRow(jira.url, issue))
705
+ startAt += page.length
706
+ if (!page.length || startAt >= Number(data.total || 0)) break
707
+ }
708
+ }
709
+ return rows
710
+ },
711
+ })
697
712
  }
698
713
 
699
714
  function jiraRow(baseUrl: string, issue: Record<string, any>): Record<string, string> {
package/src/cache.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ import { createClient } from "redis"
4
+
5
+ import type { CacheConfig } from "./config.ts"
6
+
7
+ type CacheAdapter = {
8
+ get(key: string): Promise<string | null>
9
+ set(key: string, value: string, ttlSeconds: number): Promise<void>
10
+ }
11
+
12
+ let factoryOverride: ((config: CacheConfig) => Promise<CacheAdapter | null>) | undefined
13
+ const adapters = new Map<string, Promise<CacheAdapter | null>>()
14
+
15
+ function keyFor(config: CacheConfig): string {
16
+ return `${config.redisUrl}\n${config.keyPrefix}\n${config.connectTimeoutMs}`
17
+ }
18
+
19
+ function fullKey(config: CacheConfig, namespace: string, parts: unknown[]): string {
20
+ const digest = createHash("sha256").update(JSON.stringify(parts)).digest("hex")
21
+ return `${config.keyPrefix}:${namespace}:${digest}`
22
+ }
23
+
24
+ async function connectRedis(config: CacheConfig): Promise<CacheAdapter | null> {
25
+ const client = createClient({
26
+ url: config.redisUrl,
27
+ socket: { connectTimeout: config.connectTimeoutMs },
28
+ })
29
+ client.on("error", () => {})
30
+ try {
31
+ await client.connect()
32
+ } catch {
33
+ try {
34
+ client.disconnect()
35
+ } catch {}
36
+ return null
37
+ }
38
+ return {
39
+ async get(key) {
40
+ try {
41
+ return await client.get(key)
42
+ } catch {
43
+ return null
44
+ }
45
+ },
46
+ async set(key, value, ttlSeconds) {
47
+ try {
48
+ await client.set(key, value, { EX: ttlSeconds })
49
+ } catch {}
50
+ },
51
+ }
52
+ }
53
+
54
+ async function adapter(config: CacheConfig | undefined): Promise<CacheAdapter | null> {
55
+ if (!config?.enabled || !config.redisUrl) return null
56
+ const id = keyFor(config)
57
+ if (!adapters.has(id)) {
58
+ adapters.set(id, (factoryOverride || connectRedis)(config))
59
+ }
60
+ return adapters.get(id)!
61
+ }
62
+
63
+ export async function cachedJson<T>(
64
+ config: CacheConfig | undefined,
65
+ opts: {
66
+ namespace: string
67
+ keyParts: unknown[]
68
+ ttlSeconds?: number
69
+ load: () => Promise<T>
70
+ },
71
+ ): Promise<T> {
72
+ const ttlSeconds = opts.ttlSeconds || config?.defaultTtlSeconds || 3600
73
+ const store = await adapter(config)
74
+ if (!store) return opts.load()
75
+ const key = fullKey(config!, opts.namespace, opts.keyParts)
76
+ const hit = await store.get(key)
77
+ if (hit !== null) {
78
+ try {
79
+ return JSON.parse(hit) as T
80
+ } catch {
81
+ // Bad JSON should not break the current run; reload and overwrite it.
82
+ }
83
+ }
84
+ const value = await opts.load()
85
+ await store.set(key, JSON.stringify(value), ttlSeconds)
86
+ return value
87
+ }
88
+
89
+ export function setCacheFactoryForTests(factory?: (config: CacheConfig) => Promise<CacheAdapter | null>): void {
90
+ factoryOverride = factory
91
+ adapters.clear()
92
+ }