@capagents/forgeqe 1.1.3 → 1.1.5

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.
Files changed (60) hide show
  1. package/HOW_TO_USE.md +46 -4
  2. package/README.md +7 -5
  3. package/browserstack.example.yaml +15 -3
  4. package/docs/user-guide.html +62 -10
  5. package/forgeqe.example.yaml +25 -18
  6. package/package.json +2 -2
  7. package/prompts/README.md +1 -0
  8. package/prompts/brain.md +7 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +12 -2
  11. package/prompts/test-reviewer.md +5 -2
  12. package/prompts/test-rules.md +8 -4
  13. package/py/forgeqe_engine/browser.py +79 -12
  14. package/py/forgeqe_engine/service.py +12 -0
  15. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  16. package/skills/playwright-typescript/SKILL.md +2 -1
  17. package/src/bi.ts +382 -302
  18. package/src/brain/index.ts +1 -1
  19. package/src/capabilities/guide.ts +3 -3
  20. package/src/capabilities/registry.ts +34 -6
  21. package/src/cases/bugs.ts +209 -22
  22. package/src/cases/defectHistory.ts +193 -0
  23. package/src/cases/defects.ts +255 -0
  24. package/src/cases/github.ts +162 -0
  25. package/src/cases/goal.ts +1 -1
  26. package/src/cases/index.ts +23 -4
  27. package/src/cases/predict.ts +372 -0
  28. package/src/cases/rca.ts +258 -0
  29. package/src/cases/readiness.ts +38 -1
  30. package/src/cases/repro.ts +202 -0
  31. package/src/cases/types.ts +8 -1
  32. package/src/cli/help.ts +16 -7
  33. package/src/cli/index.ts +56 -15
  34. package/src/code/graph/graph.ts +511 -0
  35. package/src/code/graph/locators.ts +398 -0
  36. package/src/code/graph/parse.ts +439 -0
  37. package/src/code/graph/tools.ts +113 -0
  38. package/src/code/handoff.ts +93 -18
  39. package/src/code/loop.ts +30 -5
  40. package/src/code/profile.ts +14 -3
  41. package/src/code/replay.ts +1 -1
  42. package/src/code/review.ts +22 -0
  43. package/src/config.ts +160 -56
  44. package/src/mcp/attachtools.ts +223 -0
  45. package/src/mcp/commands.ts +1 -1
  46. package/src/mcp/defecttools.ts +106 -0
  47. package/src/mcp/devicetools.ts +125 -0
  48. package/src/mcp/external.ts +42 -1
  49. package/src/mcp/tools.ts +92 -9
  50. package/src/monitor/run.ts +1 -1
  51. package/src/report/data.ts +14 -0
  52. package/src/report/html.ts +15 -3
  53. package/src/report/template.html +44 -43
  54. package/src/run.ts +9 -2
  55. package/src/suite/report.ts +44 -11
  56. package/src/suite/run.ts +163 -16
  57. package/src/tui/App.tsx +9 -3
  58. package/src/usage.ts +1 -1
  59. package/testmu.example.yaml +14 -4
  60. package/src/cache.ts +0 -92
@@ -68,6 +68,38 @@ export function verdictOf(r: Pick<Readiness, "stories" | "bugs" | "counts" | "pa
68
68
  return { verdict: blockers.length ? "NO-GO" : risks.length ? "GO WITH RISKS" : "GO", blockers, risks }
69
69
  }
70
70
 
71
+ type SavedDefect = { title: string; failureLabel?: string; cases: { id: string }[]; bug?: { label: string }; history?: { status?: string; previousBug?: { label: string } } }
72
+
73
+ /**
74
+ * What the newest suite with defects says about the test cases in scope (Azure DevOps ids): each regression is a
75
+ * blocker, each new defect without a bug a risk. Cases are matched by their work item (ADO-123, or the id in ref).
76
+ */
77
+ export function defectSignals(suitesDir: string, inScope: Set<number>): { blockers: string[]; risks: string[] } {
78
+ const out = { blockers: [] as string[], risks: [] as string[] }
79
+ if (!existsSync(suitesDir) || !inScope.size) return out
80
+ const dirs = readdirSync(suitesDir).filter((d) => existsSync(path.join(suitesDir, d, "suite.json"))).sort().reverse()
81
+ for (const dir of dirs) {
82
+ let suite: { results?: { case: { id: string; ref?: { workItem?: number } } }[]; defects?: SavedDefect[] }
83
+ try {
84
+ suite = JSON.parse(readFileSync(path.join(suitesDir, dir, "suite.json"), "utf8"))
85
+ } catch {
86
+ continue
87
+ }
88
+ if (!Array.isArray(suite.defects)) continue
89
+ const workItem = new Map((suite.results || []).map((r) => [r.case.id, Number(r.case.ref?.workItem || /^ADO-(\d+)/.exec(r.case.id)?.[1] || 0)]))
90
+ const ids = (d: SavedDefect) => d.cases.map((c) => workItem.get(c.id) || workItem.get(c.id.split("@")[0]) || Number(/^ADO-(\d+)/.exec(c.id)?.[1] || 0)).filter((n) => inScope.has(n))
91
+ for (const d of suite.defects) {
92
+ const hit = ids(d)
93
+ if (!hit.length) continue
94
+ const cases = `test case${hit.length === 1 ? "" : "s"} ${[...new Set(hit)].join(", ")}`
95
+ if (d.history?.status === "regression") out.blockers.push(`regression: ${d.title} came back${d.history.previousBug ? ` after ${d.history.previousBug.label}` : ""} (${cases})${d.bug ? `, ${d.bug.label}` : ""}`)
96
+ else if (d.history?.status === "new" && !d.bug) out.risks.push(`new defect with no bug: ${d.title} (${cases})`)
97
+ }
98
+ break
99
+ }
100
+ return out
101
+ }
102
+
71
103
  /** The newest result per test case in ForgeQE's suite reports. */
72
104
  function forgeqeResults(suitesDir: string): Map<number, { outcome: Outcome; when: string }> {
73
105
  const out = new Map<number, { outcome: Outcome; when: string }>()
@@ -228,7 +260,12 @@ export async function releaseReadiness(cfg: AppConfig, repo: string, scope: Scop
228
260
  }
229
261
  const passRate = executed.length ? Math.round((passed / executed.length) * 1000) / 10 : null
230
262
  const v = verdictOf({ stories, bugs, counts, passRate }, thresholds)
231
- return { scope: label, stories, bugs, counts, passRate, ...v, notes }
263
+ // ForgeQE's latest suite: a regression on a case in scope blocks the release; a new defect without a bug is a risk.
264
+ const inScope = new Set(stories.flatMap((st) => st.cases.map((c) => c.id)))
265
+ const d = defectSignals(cfg.suite.outDir, inScope)
266
+ const blockers = [...v.blockers, ...d.blockers]
267
+ const risks = [...v.risks, ...d.risks]
268
+ return { scope: label, stories, bugs, counts, passRate, verdict: blockers.length ? "NO-GO" : risks.length ? "GO WITH RISKS" : "GO", blockers, risks, notes }
232
269
  }
233
270
 
234
271
  const MARK: Record<Outcome, string> = { passed: "✅ passed", failed: "❌ failed", blocked: "⛔ blocked", "not run": "⚪ not run" }
@@ -0,0 +1,202 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { ReportEnv } from "../report/html.ts"
5
+ import { readTimeline } from "../report/data.ts"
6
+ import { routeOf, type CaseDefect, type DefectCluster } from "./defects.ts"
7
+ import { stepVerdictLines, type CaseResult } from "./types.ts"
8
+
9
+ /**
10
+ * Defect intelligence, part three (a): how to make a defect happen again. Built from what the run of its first case
11
+ * recorded, with nothing new sent anywhere: the case's steps with the agent's verdicts, what the browser did, where
12
+ * and with what it ran, and the failing request as a curl command. The engine has already taken credentials out of
13
+ * the requests it records (py/forgeqe_engine/network.py), so the command carries no secret.
14
+ */
15
+
16
+ export type Reproducibility = "always" | "intermittent" | "once"
17
+
18
+ export type FailingRequest = { method: string; url: string; status?: number; requestBody?: string; responseBody?: string; curl: string }
19
+
20
+ export type ReproPack = {
21
+ reproducibility: Reproducibility
22
+ /** Why: "failed the same way in 2 of 2 runs", "seen in 3 earlier runs". */
23
+ detail: string
24
+ /** The case's steps, with the agent's verdict on each when it gave one. */
25
+ steps: string[]
26
+ /** What the browser did before it failed: the actions that worked, in order. */
27
+ actions: string[]
28
+ startUrl?: string
29
+ endUrl?: string
30
+ /** When the run ended (ISO): the report shows it in the reader's time; the env line gives it in UTC. */
31
+ at: string
32
+ env: string[]
33
+ request?: FailingRequest
34
+ /** Files from the run that help: its folder relative to the suite folder, and each file. */
35
+ files: { name: string; path: string }[]
36
+ /** The repro file written for the defect, relative to the suite folder. */
37
+ file?: string
38
+ }
39
+
40
+ type Call = { method?: string; url?: string; request?: { headers?: Record<string, string>; body?: string }; response?: { status?: number; body?: string } }
41
+
42
+ const MAX_ACTIONS = 15
43
+ const MAX_BODY = 600
44
+
45
+ const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
46
+ const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
47
+
48
+ /** The request a "request" defect is about, from the run's network.json: the last call to its route with its status. */
49
+ export function failingRequest(runDir: string | undefined, d: CaseDefect): FailingRequest | undefined {
50
+ if (d.kind !== "request" || !runDir) return undefined
51
+ const file = path.join(runDir, "network.json")
52
+ if (!existsSync(file)) return undefined
53
+ let calls: Call[] = []
54
+ try {
55
+ calls = (JSON.parse(readFileSync(file, "utf8")) as { calls?: Call[] }).calls || []
56
+ } catch {
57
+ return undefined
58
+ }
59
+ const hit = [...calls].reverse().find((c) => c.url && (c.response?.status || 0) >= 400 && d.title.startsWith(`${routeOf(c.url)} `))
60
+ if (!hit?.url) return undefined
61
+ const method = (hit.method || "GET").toUpperCase()
62
+ const headers = Object.entries(hit.request?.headers || {}).map(([k, v]) => `-H ${quote(`${k}: ${v}`)}`)
63
+ const body = hit.request?.body
64
+ const curl = ["curl -i", ...(method === "GET" ? [] : [`-X ${method}`]), quote(hit.url), ...headers, ...(body ? [`--data-raw ${quote(body)}`] : [])].join(" ")
65
+ return {
66
+ method,
67
+ url: hit.url,
68
+ ...(hit.response?.status ? { status: hit.response.status } : {}),
69
+ ...(body ? { requestBody: clip(body, MAX_BODY) } : {}),
70
+ ...(hit.response?.body ? { responseBody: clip(hit.response.body, MAX_BODY) } : {}),
71
+ curl,
72
+ }
73
+ }
74
+
75
+ /** The browser's actions that worked, without repeats in a row and without the agent's own bookkeeping. */
76
+ function browserActions(runDir?: string): string[] {
77
+ const out: string[] = []
78
+ for (const t of readTimeline(runDir)) {
79
+ for (const a of t.actions || []) {
80
+ if (!a.ok || a.done || !a.text.trim() || /^(done|wait\b)/i.test(a.text)) continue
81
+ if (out[out.length - 1] !== a.text) out.push(a.text)
82
+ }
83
+ }
84
+ return out.length > MAX_ACTIONS ? [...out.slice(0, MAX_ACTIONS - 1), `… and ${out.length - MAX_ACTIONS + 1} more (steps.json)`] : out
85
+ }
86
+
87
+ /** How sure it is that the defect happens again: a second run of it, else its history, else seen once. */
88
+ export function reproducibility(r: CaseResult, seen = 0, intermittent?: { failed: number; runs: number }): { reproducibility: Reproducibility; detail: string } {
89
+ if (intermittent) return { reproducibility: "intermittent", detail: `its case failed in ${intermittent.failed} of the last ${intermittent.runs} runs` }
90
+ if (r.reproduced) {
91
+ const { runs, failed } = r.reproduced
92
+ return failed >= runs
93
+ ? { reproducibility: "always", detail: `failed the same way in ${failed} of ${runs} runs` }
94
+ : { reproducibility: "intermittent", detail: `failed the same way in ${failed} of ${runs} runs` }
95
+ }
96
+ if (seen) return { reproducibility: "always", detail: `seen in ${seen} earlier run${seen === 1 ? "" : "s"} too` }
97
+ return { reproducibility: "once", detail: "seen once; not run again" }
98
+ }
99
+
100
+ /** The repro pack of a defect, from the run of one of its cases (the first). `suiteDir` makes file paths relative. */
101
+ export function reproPack(r: CaseResult, d: CaseDefect, opts: { env?: ReportEnv; seen?: number; intermittent?: { failed: number; runs: number }; suiteDir?: string } = {}): ReproPack {
102
+ const verdicts = stepVerdictLines(r)
103
+ const steps = verdicts.length ? verdicts : r.case.steps.map((s, i) => `${i + 1}. ${s.action.trim()}${s.data ? ` (data: ${s.data})` : ""}${s.expected ? ` — expected: ${s.expected}` : ""}`)
104
+ const e = opts.env
105
+ const env = [
106
+ ...(e?.browser ? [`Browser: ${e.browser}${r.device ? `, device ${r.device}` : ""}`] : r.device ? [`Device: ${r.device}`] : []),
107
+ ...(e?.ci ? [`Build: ${e.ci.name}${e.ci.url ? ` ${e.ci.url}` : ""}`] : []),
108
+ ...(e?.commit ? [`Commit: ${e.commit}${e.branch ? ` on ${e.branch}` : ""}`] : []),
109
+ ...(e?.version ? [`ForgeQE ${e.version} on ${e.machine}`] : []),
110
+ `When: ${r.finishedAt.slice(0, 16).replace("T", " ")} UTC`,
111
+ ]
112
+ const rel = (f: string) => (opts.suiteDir ? path.relative(opts.suiteDir, f) : f)
113
+ const files: ReproPack["files"] = []
114
+ if (r.runDir) {
115
+ for (const [name, what] of [["failure.png", "Last screenshot"], ["workflow.yaml", "Replay flow (forgeqe replay)"], ["steps.json", "Every browser step"], ["network.json", "Every API call, secrets removed"], ["report.html", "The run's report"]]) {
116
+ const f = path.join(r.runDir, name)
117
+ if (existsSync(f)) files.push({ name: what, path: rel(f) })
118
+ }
119
+ }
120
+ if (r.code?.knownDefect && r.code.testFile) files.push({ name: "Test that asserts the correct behaviour (an expected failure)", path: r.code.testFile })
121
+ const request = failingRequest(r.runDir, d)
122
+ return {
123
+ ...reproducibility(r, opts.seen, opts.intermittent),
124
+ steps,
125
+ actions: browserActions(r.runDir),
126
+ ...(r.case.startUrl || e?.startUrl ? { startUrl: r.case.startUrl || e?.startUrl } : {}),
127
+ ...(r.finalUrl ? { endUrl: r.finalUrl } : {}),
128
+ at: r.finishedAt,
129
+ env,
130
+ ...(request ? { request } : {}),
131
+ files,
132
+ }
133
+ }
134
+
135
+ /** The pack as plain text, for a bug's description: reproducibility, the steps, and the failing request. */
136
+ export function reproText(p: ReproPack): string {
137
+ const lines = [`Reproducibility: ${p.reproducibility} (${p.detail}).`]
138
+ if (p.startUrl) lines.push(`Start at: ${p.startUrl}`)
139
+ if (p.actions.length) lines.push("", "What the browser did:", ...p.actions.map((a, i) => `${i + 1}. ${a}`))
140
+ if (p.request) {
141
+ lines.push("", `Failing request: ${p.request.method} ${p.request.url}${p.request.status ? ` → ${p.request.status}` : ""}`, "Run it again (secrets already removed):", p.request.curl)
142
+ if (p.request.responseBody) lines.push(`Response: ${p.request.responseBody}`)
143
+ }
144
+ if (p.env.length) lines.push("", "Environment:", ...p.env.map((l) => `- ${l}`))
145
+ return lines.join("\n")
146
+ }
147
+
148
+ const fence = (s: string) => `\`\`\`\n${s}\n\`\`\``
149
+
150
+ /** defects/<id>.md in the suite folder, one per defect with a pack: everything to make it happen again, and the analysis. */
151
+ export function writeReproFiles(suiteDir: string, clusters: DefectCluster[]): void {
152
+ const dir = path.join(suiteDir, "defects")
153
+ for (const c of clusters) {
154
+ const p = c.repro
155
+ if (!p) continue
156
+ const rca = c.rca
157
+ const md = [
158
+ `# ${c.title}`,
159
+ "",
160
+ `${c.failureLabel} · ${c.cases.length} case${c.cases.length === 1 ? "" : "s"} · reproducibility: **${p.reproducibility}** (${p.detail})${c.bug ? ` · bug ${c.bug.url ? `[${c.bug.label}](${c.bug.url})` : c.bug.label}` : ""}`,
161
+ "",
162
+ "## Cases that fail this way",
163
+ ...c.cases.map((x) => `- ${x.id} ${x.title} (${x.status})`),
164
+ "",
165
+ "## Evidence",
166
+ ...c.evidence.map((e) => `- ${e}`),
167
+ "",
168
+ "## To reproduce",
169
+ ...(p.startUrl ? [`Start at ${p.startUrl}.`, ""] : []),
170
+ ...p.steps.map((s) => `- ${s}`),
171
+ ...(p.actions.length ? ["", "What the browser did:", ...p.actions.map((a, i) => `${i + 1}. ${a}`)] : []),
172
+ ...(p.endUrl ? ["", `It ended on ${p.endUrl}.`] : []),
173
+ ...(p.request
174
+ ? ["", "## Failing request", `${p.request.method} ${p.request.url}${p.request.status ? ` → ${p.request.status}` : ""}. Secrets were removed when the run recorded it.`, "", fence(p.request.curl), ...(p.request.requestBody ? ["", "Request body:", fence(p.request.requestBody)] : []), ...(p.request.responseBody ? ["", "Response body:", fence(p.request.responseBody)] : [])]
175
+ : []),
176
+ "",
177
+ "## Environment",
178
+ ...p.env.map((l) => `- ${l}`),
179
+ ...(p.files.length ? ["", "## Files", ...p.files.map((f) => `- ${f.name}: \`${f.path}\``)] : []),
180
+ ...(rca ? ["", "## Likely cause (a hypothesis, not a verdict)", ...rcaLines(rca)] : []),
181
+ "",
182
+ ].join("\n")
183
+ mkdirSync(dir, { recursive: true })
184
+ const name = `${c.id}.md`
185
+ writeFileSync(path.join(dir, name), md)
186
+ p.file = `defects/${name}`
187
+ }
188
+ }
189
+
190
+ /** The analysis as markdown lines, for the repro file and the bug comment. */
191
+ export function rcaLines(rca: NonNullable<DefectCluster["rca"]>): string[] {
192
+ return [
193
+ `- Layer: ${rca.layer}${rca.component ? ` · component: ${rca.component}` : ""} (${rca.basis})`,
194
+ ...(rca.hypothesis ? [`- Hypothesis (${rca.confidence || "low"} confidence): ${rca.hypothesis}`] : []),
195
+ ...(rca.evidence?.length ? ["- Based on:", ...rca.evidence.map((e) => ` - ${e}`)] : []),
196
+ ...(rca.confirm?.length ? ["- To confirm:", ...rca.confirm.map((e) => ` - ${e}`)] : []),
197
+ ...(rca.files?.length ? [`- Code to look at: ${rca.files.join(", ")}`] : []),
198
+ ...(rca.suspects?.length ? [`- Suspect commits (${rca.range}):`, ...rca.suspects.map((s) => ` - ${s.sha.slice(0, 10)} ${s.subject} (${s.author}, ${s.at.slice(0, 10)}): ${s.why}`)] : rca.range ? [`- No commit ${rca.range} mentions it.`] : []),
199
+ ...(rca.note ? [`- ${rca.note}`] : []),
200
+ ...(rca.model ? [`- Written by ${rca.model}${rca.tokens ? `, ${rca.tokens} tokens` : ""}${rca.cost ? `, ${rca.cost.amount.toFixed(2)} ${rca.cost.currency}` : ""}.`] : []),
201
+ ]
202
+ }
@@ -1,10 +1,11 @@
1
1
  import type { CodeStatus, StepVerdict, Usage } from "../types.ts"
2
2
  import type { Cost } from "../usage.ts"
3
+ import type { CaseDefect, Reproduced } from "./defects.ts"
3
4
  import type { Failure } from "./failure.ts"
4
5
 
5
6
  export type CaseStep = { action: string; data?: string; expected?: string }
6
7
 
7
- export type CaseSource = "text" | "file" | "ado" | "jira" | "xray" | "testrail" | "zephyr" | "qtest"
8
+ export type CaseSource = "text" | "file" | "ado" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "github"
8
9
 
9
10
  /** A test case from any source, in one shape. It becomes one browser goal and, once it passes, one test. */
10
11
  export type TestCase = {
@@ -33,6 +34,8 @@ export type CaseStatus = "passed" | "failed" | "stopped" | "error"
33
34
  export type CaseResult = {
34
35
  case: TestCase
35
36
  status: CaseStatus
37
+ /** The named device this result ran on (browser_devices); its case id and title carry the device too. */
38
+ device?: string
36
39
  /** The browser agent's account of the run, or why it could not run. */
37
40
  summary: string
38
41
  startedAt: string
@@ -73,6 +76,10 @@ export type CaseResult = {
73
76
  codeSkipped?: string
74
77
  /** For a case that did not pass: what kind of failure it was, and so what happened next (see src/cases/failure.ts). */
75
78
  failure?: Failure
79
+ /** For a case that failed or could not run: the defect it belongs to, shared with every case failing the same way. */
80
+ defect?: CaseDefect
81
+ /** The case was run once more to see whether its defect repeats (the first case of each new defect). */
82
+ reproduced?: Reproduced
76
83
  /** The bug filed (or found open) for a functional defect, when bugs are set up. */
77
84
  bug?: { label: string; url?: string }
78
85
  /** The browser run was repeated once because the first one met an environment problem. */
package/src/cli/help.ts CHANGED
@@ -14,7 +14,7 @@ Usage:
14
14
  forgeqe features [--config forgeqe.yaml] what ForgeQE can do, and what each capability still needs here
15
15
  forgeqe docs [--guide|--open] [--copy DIR] the guides and the architecture page (--guide: the user guide in the browser; --open: the architecture page)
16
16
  forgeqe start [--profile name] [--url URL] [--goal TEXT] [--headed] [--replay MODE] [--no-memory]
17
- [--repo DIR] [--no-code]
17
+ [--repo DIR] [--no-code] [--browser-provider local|browserstack|testmu] [--devices a,b]
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
20
  [--browser-provider local|browserstack|testmu]
@@ -23,7 +23,7 @@ Usage:
23
23
  forgeqe run --direct "<goal>" [--out DIR] [--skill NAME] [--browser-provider local|browserstack|testmu] ... one browser goal, no agent
24
24
  forgeqe suite <source...> [--parallel N] [--repo DIR] [--no-code] [--no-write-back] [--out DIR]
25
25
  [--profile name] [--url URL] [--headed] [--plain] [--attempts N] [--skill NAME]
26
- [--browser-provider local|browserstack|testmu]
26
+ [--browser-provider local|browserstack|testmu] [--devices a,b]
27
27
  run test cases the same way every time (what pipelines call)
28
28
  forgeqe cases <source...> [--goal] list the test cases a source gives (--goal: the goal each runs)
29
29
  forgeqe code [run-dir] [--repo DIR] [--attempts N] [--code-model provider/model]
@@ -38,15 +38,20 @@ Usage:
38
38
  forgeqe mcp [--repo DIR] [--profile name] [--url URL] [--headed] [--browser-provider local|browserstack|testmu]
39
39
  ForgeQE's tools as an MCP server on stdio, for other agents
40
40
  forgeqe mcp list the Azure DevOps, Jira and other MCP servers ForgeQE's agent gets
41
- forgeqe bi [build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]
42
- export ForgeQE + ADO/Jira datasets into an Evidence project and build static BI HTML
43
- 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
44
46
  forgeqe bi sync refresh the CSV datasets and generated Evidence pages, without building
45
47
  forgeqe pr-tests [pr] [--base BRANCH] [--agent] [--run] [--post]
46
48
  the tests a pull request (or the commits since BRANCH) can break; --run runs
47
49
  them and exits 1 when one fails (a CI gate), --post puts the results on the PR
48
50
  forgeqe quality [run-dir] a run's WCAG rules passed and failed, Core Web Vitals, and budgets
49
51
  forgeqe quality trends [page] accessibility and timings per page over time: what got worse or better
52
+ forgeqe defects [--folder DIR] defects over the saved suites: still there, gone, came back, time to fix, by area
53
+ forgeqe defects predict [--repo DIR] [--base REF] [--head REF]
54
+ where a change is likely to cause defects, with reasons and the cases to run first
50
55
  forgeqe notify test [channel] send a test card to the Teams and email notification channels
51
56
  forgeqe eval domain [--only id,id] [--min 0.8] does the agent apply domain and regulatory knowledge? (with the domain declared and without)
52
57
  forgeqe prompts [list] every agent prompt and where it comes from (ForgeQE's or your edited copy)
@@ -110,6 +115,9 @@ page with ranked locators and a confidence %, plus the steps of each goal's last
110
115
  --replay trust replay and finish without the LLM when every step works
111
116
  --replay off never replay; still record pages
112
117
  --browser-provider pick which configured browser provider runs this command's browser work
118
+ --devices a,b run on each named device (devices: in the BrowserStack/TestMu YAML file; in the shell, describe
119
+ them and ForgeQE's agent saves them): devices run side by side, each reported on its own, and each
120
+ case posts one combined result back (it fails when any device fails)
113
121
 
114
122
  ForgeQE's agent (forgeqe start, forgeqe run) runs on the agent runtime with the active profile's model (or code.model) and
115
123
  calls ForgeQE's tools: browser runs, reading and running test cases, writing tests and running them, page memory, pushing
@@ -138,8 +146,9 @@ The report is report.html (step verdicts, timeline with screenshots, timings, en
138
146
  out/suites/<time>/; every run folder also has its own report.html.
139
147
 
140
148
  Static BI (bi: in forgeqe.yaml): \`forgeqe bi\` writes curated CSV datasets from ForgeQE's own suite and readiness
141
- reports plus optional Azure DevOps WIQL and Jira JQL datasets, scaffolds an Evidence project, and runs
142
- \`evidence build\` to produce static HTML in the build directory. No server stays up between runs.
149
+ reports plus optional Azure DevOps WIQL and Jira JQL datasets, scaffolds an Evidence project (@evidence-dev/evidence
150
+ 40.x, needs Node.js), and runs \`evidence sources\` and \`evidence build\` to produce a static site in the build directory;
151
+ when that cannot run, it writes ForgeQE's own self-contained page instead. No server stays up between runs.
143
152
 
144
153
  Healing (forgeqe heal): ForgeQE runs the tests (the command given, code.test_command, or one the agent finds).
145
154
  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 { browserProvider, loadConfig, parseDuration, replayMode, selectProfile, userConfigDir, type AppConfig, type BrowserProvider } from "../config.ts"
13
+ import { browserConfigFor, 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"
@@ -30,7 +30,7 @@ import { externalMcp } from "../mcp/external.ts"
30
30
  import { createMcpServer, serveStdio } from "../mcp/server.ts"
31
31
  import { TOOLS, environmentNotes, type ToolContext } from "../mcp/tools.ts"
32
32
  import { formatSuite } from "../suite/report.ts"
33
- import { runSuite, suiteExitCode } from "../suite/run.ts"
33
+ import { runSuite, suiteExitCode, suiteParallel } from "../suite/run.ts"
34
34
  import { packageVersion } from "../tui/theme.ts"
35
35
  import { update } from "../update.ts"
36
36
  import { meterSnapshot, requestLine } from "../usage.ts"
@@ -67,6 +67,12 @@ function browserProviderFlag(flags: Flags): BrowserProvider | undefined {
67
67
  return value ? browserProvider(value) : undefined
68
68
  }
69
69
 
70
+ /** --devices win11-chrome,mac-edge: run on each named device (browser_devices, or devices: in a provider file). */
71
+ function devicesFlag(flags: Flags): string[] | undefined {
72
+ const names = (strFlag(flags, "devices") || "").split(",").map((d) => d.trim()).filter(Boolean)
73
+ return names.length ? [...new Set(names)] : undefined
74
+ }
75
+
70
76
  function debugRequested(flags: Flags): boolean {
71
77
  return boolFlag(flags, "debug") || /^(1|true|yes|on)$/i.test(process.env.FORGEQE_DEBUG || "")
72
78
  }
@@ -217,10 +223,23 @@ export async function main(argv: string[]): Promise<number> {
217
223
  const cfg = loadConfig(configOpts(flags))
218
224
  const trends = positionals[1] === "trends"
219
225
  const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
220
- const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
226
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
221
227
  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[] }[] }
222
228
  return out.budget_exceeded?.length || out.pages?.some((p) => p.slower.length || p.newly_failing_rules.length) ? 1 : 0
223
229
  }
230
+ case "defects": {
231
+ // forgeqe defects [--folder DIR]: trends; forgeqe defects predict [--repo DIR] [--base REF] [--head REF].
232
+ const cfg = loadConfig(configOpts(flags))
233
+ const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
234
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
235
+ const str = (k: string) => (typeof flags.get(k) === "string" ? String(flags.get(k)) : undefined)
236
+ if (positionals[1] === "predict") {
237
+ await TOOLS.find((t) => t.name === "predict_defects")!.run({ repo: str("repo"), base: str("base"), head: str("head"), folder: str("folder") }, ctx)
238
+ return 0
239
+ }
240
+ await TOOLS.find((t) => t.name === "defect_trends")!.run({ folder: str("folder") }, ctx)
241
+ return 0
242
+ }
224
243
  case "logs":
225
244
  return cmdLogs(positionals.slice(1), flags)
226
245
  case "skills":
@@ -382,7 +401,7 @@ async function cmdApi(args: string[], flags: Flags): Promise<number> {
382
401
  const cfg = loadConfig(configOpts(flags))
383
402
  const action = args[0] || "list"
384
403
  const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
385
- const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
404
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
386
405
  const tool = (name: string) => TOOLS.find((t) => t.name === name)!
387
406
  const csv = (name: string) => (strFlag(flags, name) || "").split(",").map((s) => s.trim()).filter(Boolean)
388
407
  switch (action) {
@@ -489,11 +508,16 @@ async function cmdBi(args: string[], flags: Flags): Promise<number> {
489
508
  dataDir: strFlag(flags, "data-dir"),
490
509
  buildDir: strFlag(flags, "build-dir") || strFlag(flags, "out"),
491
510
  }
511
+ const shown = (file: string) => path.relative(process.cwd(), file) || file
512
+ const printNotes = (notes: string[]) => {
513
+ for (const note of notes) console.log(`note ${note}`)
514
+ }
492
515
  switch (action) {
493
516
  case "init": {
494
517
  const out = await initBiProject(cfg, overrides)
495
518
  console.log(`Evidence project ${out.evidenceDir}`)
496
- for (const file of out.files) console.log(`generated ${path.relative(process.cwd(), file) || file}`)
519
+ for (const file of out.files) console.log(`generated ${shown(file)}`)
520
+ printNotes(out.notes)
497
521
  return 0
498
522
  }
499
523
  case "sync": {
@@ -505,23 +529,30 @@ async function cmdBi(args: string[], flags: Flags): Promise<number> {
505
529
  console.log(`Case results ${out.caseResults}`)
506
530
  console.log(`Readiness reports ${out.readinessReports}`)
507
531
  console.log(`External datasets ${out.externalDatasets}`)
508
- console.log(`Summary ${path.relative(process.cwd(), out.summaryFile) || out.summaryFile}`)
532
+ console.log(`Summary ${shown(out.summaryFile)}`)
533
+ printNotes(out.notes)
509
534
  return 0
510
535
  }
511
536
  case "build": {
512
- const out = await buildBi(cfg, overrides)
537
+ const out = await buildBi(cfg, overrides, boolFlag(flags, "static") ? "static" : boolFlag(flags, "evidence") ? "evidence" : "auto")
513
538
  console.log(`Evidence project ${out.evidenceDir}`)
514
539
  console.log(`Static site ${out.buildDir}`)
515
- console.log(`Index ${path.relative(process.cwd(), out.indexFile) || out.indexFile}`)
516
- console.log(`Summary ${path.relative(process.cwd(), out.summaryFile) || out.summaryFile}`)
540
+ console.log(`Index ${shown(out.indexFile)}`)
541
+ console.log(`Summary ${shown(out.summaryFile)}`)
517
542
  console.log(`Suite runs ${out.suites}`)
518
543
  console.log(`Case results ${out.caseResults}`)
519
544
  console.log(`Readiness reports ${out.readinessReports}`)
520
545
  console.log(`External datasets ${out.externalDatasets}`)
546
+ if (out.engine === "evidence") {
547
+ console.log("Built with Evidence. Serve the folder to view it (npx serve, or evidence preview): opened from disk it cannot load its data.")
548
+ } else {
549
+ console.log(`Built with ForgeQE's own page, without Evidence (${out.fallbackReason}). It opens straight from disk.`)
550
+ }
551
+ printNotes(out.notes)
521
552
  return 0
522
553
  }
523
554
  default:
524
- throw new Error("Usage: forgeqe bi [init|sync|build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR]")
555
+ throw new Error("Usage: forgeqe bi [init|sync|build] [--evidence-dir DIR] [--data-dir DIR] [--build-dir DIR] [--static | --evidence]")
525
556
  }
526
557
  }
527
558
 
@@ -784,6 +815,8 @@ async function cmdStart(flags: Flags): Promise<number> {
784
815
  url: strFlag(flags, "url"),
785
816
  goal: strFlag(flags, "goal"),
786
817
  headed: boolFlag(flags, "headed"),
818
+ browserProvider: browserProviderFlag(flags),
819
+ devices: devicesFlag(flags),
787
820
  verbose: boolFlag(flags, "verbose"),
788
821
  ...memoryFlags(flags),
789
822
  repo: boolFlag(flags, "no-code") ? null : strFlag(flags, "repo") ? path.resolve(strFlag(flags, "repo")!) : undefined,
@@ -929,6 +962,7 @@ async function runAgent(cfg: AppConfig, flags: Flags, text: string, maxSteps: nu
929
962
  context: () => ({
930
963
  headed: boolFlag(flags, "headed"),
931
964
  browserProvider: browserProviderFlag(flags),
965
+ devices: devicesFlag(flags),
932
966
  maxSteps,
933
967
  url: strFlag(flags, "url"),
934
968
  ...memoryFlags(flags),
@@ -962,7 +996,7 @@ async function runAgent(cfg: AppConfig, flags: Flags, text: string, maxSteps: nu
962
996
  }
963
997
 
964
998
  function cmdMcpList(cfg: AppConfig): number {
965
- const { servers, skipped } = externalMcp(cfg)
999
+ const { servers, skipped } = externalMcp(cfg, Bun.which, process.env, { cwd: process.cwd() })
966
1000
  if (!servers.length && !skipped.length) {
967
1001
  console.log("No MCP servers for other systems. Set sources.azure_devops or sources.jira, or add servers under mcp:, in forgeqe.yaml.")
968
1002
  }
@@ -994,6 +1028,7 @@ async function cmdMcp(args: string[], flags: Flags): Promise<number> {
994
1028
  cwd,
995
1029
  headed: boolFlag(flags, "headed"),
996
1030
  browserProvider: browserProviderFlag(flags),
1031
+ devices: devicesFlag(flags),
997
1032
  maxSteps: maxStepsFlag(flags),
998
1033
  url: strFlag(flags, "url"),
999
1034
  ...memoryFlags(flags),
@@ -1011,10 +1046,13 @@ async function runGoal(cfg: AppConfig, flags: Flags, goal: string, maxSteps: num
1011
1046
  const out = runDirectory(cfg.export.outDir, strFlag(flags, "out"))
1012
1047
  const t0 = Date.now()
1013
1048
  let steps = 0
1049
+ const devices = devicesFlag(flags) || []
1050
+ if (devices.length > 1) throw new Error("run --direct runs one browser: pass one device, or use forgeqe suite or ForgeQE's agent for several.")
1014
1051
  const { result, files } = await executeRun({
1015
1052
  config: cfg,
1016
1053
  goal,
1017
- browserProvider: browserProviderFlag(flags),
1054
+ browserProvider: devices.length ? undefined : browserProviderFlag(flags),
1055
+ browserDevice: devices[0],
1018
1056
  url: strFlag(flags, "url"),
1019
1057
  headed: boolFlag(flags, "headed"),
1020
1058
  maxSteps,
@@ -1253,11 +1291,13 @@ async function cmdSuite(refs: string[], flags: Flags): Promise<number> {
1253
1291
  cfg = withCodeFlags(cfg, flags)
1254
1292
  const parallelRaw = strFlag(flags, "parallel")
1255
1293
  if (parallelRaw && !(Number(parallelRaw) >= 1)) throw new Error("--parallel must be a positive number")
1256
- const parallel = parallelRaw ? Number(parallelRaw) : cfg.suite.parallel
1257
1294
  const cases = await loadCases(refs, cfg.sources)
1295
+ const devices = devicesFlag(flags) || []
1296
+ for (const d of devices) browserConfigFor(cfg, undefined, d)
1297
+ const parallel = suiteParallel(parallelRaw ? Number(parallelRaw) : undefined, cfg.suite.parallel, devices.length, cases.length * Math.max(1, devices.length))
1258
1298
  const repo = codeRepo(cfg, flags)
1259
1299
  const color = Boolean(process.stdout.isTTY) && !boolFlag(flags, "plain")
1260
- console.log(`${cases.length} test case${cases.length === 1 ? "" : "s"}${parallel > 1 ? `, ${parallel} at a time` : ""}${repo ? `; passed cases get a test in ${repo}` : ""}`)
1300
+ console.log(`${cases.length} test case${cases.length === 1 ? "" : "s"}${devices.length ? ` on ${devices.length} device${devices.length === 1 ? "" : "s"} (${devices.join(", ")})` : ""}${parallel > 1 ? `, ${parallel} at a time` : ""}${repo ? `; passed cases get a test in ${repo}` : ""}`)
1261
1301
 
1262
1302
  const controller = new AbortController()
1263
1303
  const onSigint = () => controller.abort()
@@ -1267,7 +1307,8 @@ async function cmdSuite(refs: string[], flags: Flags): Promise<number> {
1267
1307
  config: cfg,
1268
1308
  cases,
1269
1309
  repo,
1270
- browserProvider: browserProviderFlag(flags),
1310
+ browserProvider: devices.length ? undefined : browserProviderFlag(flags),
1311
+ devices,
1271
1312
  parallel,
1272
1313
  outDir: strFlag(flags, "out"),
1273
1314
  writeBack: boolFlag(flags, "no-write-back") ? false : undefined,