@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.
- package/HOW_TO_USE.md +5 -5
- package/README.md +3 -0
- package/browserstack.example.yaml +14 -0
- package/docs/security-data-flow.html +154 -86
- package/docs/user-guide.html +305 -131
- package/forgeqe.example.yaml +13 -1
- package/package.json +5 -2
- package/py/forgeqe_engine/service.py +1 -0
- package/src/auth/signin.ts +2 -2
- package/src/bi.ts +61 -46
- package/src/cache.ts +92 -0
- package/src/capabilities/guide.ts +322 -140
- package/src/capabilities/registry.ts +3 -3
- package/src/cli/help.ts +8 -2
- package/src/cli/index.ts +35 -5
- package/src/config.ts +130 -7
- package/src/heal/loop.ts +3 -1
- package/src/init.ts +18 -0
- package/src/mcp/tools.ts +26 -4
- package/src/monitor/run.ts +3 -1
- package/src/run.ts +4 -2
- package/src/suite/run.ts +3 -1
- package/testmu.example.yaml +14 -0
package/forgeqe.example.yaml
CHANGED
|
@@ -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: #
|
|
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.
|
|
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": {
|
package/src/auth/signin.ts
CHANGED
|
@@ -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?.
|
|
62
|
-
testmu: { user: b?.
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
+
}
|