@capagents/forgeqe 1.1.4 → 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 (56) hide show
  1. package/HOW_TO_USE.md +46 -4
  2. package/README.md +2 -1
  3. package/browserstack.example.yaml +14 -2
  4. package/docs/user-guide.html +62 -10
  5. package/forgeqe.example.yaml +18 -5
  6. package/package.json +2 -1
  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 +1 -1
  14. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  15. package/skills/playwright-typescript/SKILL.md +2 -1
  16. package/src/brain/index.ts +1 -1
  17. package/src/capabilities/registry.ts +34 -6
  18. package/src/cases/bugs.ts +209 -22
  19. package/src/cases/defectHistory.ts +193 -0
  20. package/src/cases/defects.ts +255 -0
  21. package/src/cases/github.ts +162 -0
  22. package/src/cases/goal.ts +1 -1
  23. package/src/cases/index.ts +23 -4
  24. package/src/cases/predict.ts +372 -0
  25. package/src/cases/rca.ts +258 -0
  26. package/src/cases/readiness.ts +38 -1
  27. package/src/cases/repro.ts +202 -0
  28. package/src/cases/types.ts +8 -1
  29. package/src/cli/help.ts +8 -2
  30. package/src/cli/index.ts +36 -7
  31. package/src/code/graph/graph.ts +511 -0
  32. package/src/code/graph/locators.ts +398 -0
  33. package/src/code/graph/parse.ts +439 -0
  34. package/src/code/graph/tools.ts +113 -0
  35. package/src/code/handoff.ts +93 -18
  36. package/src/code/loop.ts +30 -5
  37. package/src/code/profile.ts +14 -3
  38. package/src/code/replay.ts +1 -1
  39. package/src/code/review.ts +22 -0
  40. package/src/config.ts +142 -18
  41. package/src/mcp/attachtools.ts +223 -0
  42. package/src/mcp/commands.ts +1 -1
  43. package/src/mcp/defecttools.ts +106 -0
  44. package/src/mcp/devicetools.ts +125 -0
  45. package/src/mcp/external.ts +42 -1
  46. package/src/mcp/tools.ts +92 -9
  47. package/src/monitor/run.ts +1 -1
  48. package/src/report/data.ts +14 -0
  49. package/src/report/html.ts +15 -3
  50. package/src/report/template.html +44 -43
  51. package/src/run.ts +4 -2
  52. package/src/suite/report.ts +44 -11
  53. package/src/suite/run.ts +163 -16
  54. package/src/tui/App.tsx +9 -3
  55. package/src/usage.ts +1 -1
  56. package/testmu.example.yaml +11 -0
@@ -0,0 +1,258 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import type { AppConfig } from "../config.ts"
6
+ import { openSession, type AgentSpec, type OpencodeSession } from "../code/runtime.ts"
7
+ import { privateEnvNames } from "../childEnv.ts"
8
+ import { domainNotes } from "../domain.ts"
9
+ import { prompt } from "../prompts.ts"
10
+ import type { CodeEvent, Usage } from "../types.ts"
11
+ import { costOf, type Cost } from "../usage.ts"
12
+ import { lastPassed } from "./defectHistory.ts"
13
+ import { routeOf, type DefectCluster } from "./defects.ts"
14
+ import { reproText } from "./repro.ts"
15
+ import type { CaseResult } from "./types.ts"
16
+
17
+ /**
18
+ * Defect intelligence, part three (b): why a defect may happen. Always a hypothesis, never a verdict.
19
+ *
20
+ * - From the evidence alone, at no cost: the layer it most likely sits in (the server, the front end, access, the
21
+ * environment) and the component (the route, the script).
22
+ * - From the product's repository (defects.product_repo): suspect commits, made since the defect's cases last
23
+ * passed, whose changes mention its route or error (git log -G).
24
+ * - From a model, for new defects and regressions only (defects.analyze): a read-only agent reads the evidence, the
25
+ * suspect commits, and the product's code, and writes a hypothesis with its confidence and how to confirm it.
26
+ */
27
+
28
+ export type Suspect = { sha: string; subject: string; author: string; at: string; files: string[]; why: string }
29
+
30
+ export type RootCause = {
31
+ layer: string
32
+ component?: string
33
+ /** What the layer was read from. */
34
+ basis: string
35
+ suspects?: Suspect[]
36
+ /** The commits searched, e.g. "since 2026-10-12, when SHOP-1 last passed". */
37
+ range?: string
38
+ hypothesis?: string
39
+ confidence?: "low" | "medium" | "high"
40
+ evidence?: string[]
41
+ confirm?: string[]
42
+ files?: string[]
43
+ /** Why there is no hypothesis or no suspect commits, when there is a reason to give. */
44
+ note?: string
45
+ model?: string
46
+ tokens?: number
47
+ usage?: Usage
48
+ cost?: Cost
49
+ }
50
+
51
+ const STATUS = /returns (\d{3})/
52
+
53
+ /** The layer and component a defect points to, from its evidence. */
54
+ export function layerOf(c: DefectCluster): Pick<RootCause, "layer" | "component" | "basis"> {
55
+ if (c.kind === "request") {
56
+ const status = Number(STATUS.exec(c.title)?.[1] || 0)
57
+ const component = c.title.replace(/ (returns \d{3}|fails)$/, "")
58
+ if (status >= 500) return { layer: "Server (API)", component, basis: `the server answered ${status}` }
59
+ if (status === 401 || status === 403) return { layer: "Access: sign-in or permissions", component, basis: `the server refused with ${status}` }
60
+ if (status === 404) return { layer: "API route or deployment", component, basis: "the route was not found" }
61
+ if (status >= 400) return { layer: "API request: the page sent what the server refused", component, basis: `the server answered ${status}` }
62
+ return { layer: "Network", component, basis: "the request did not complete" }
63
+ }
64
+ if (c.kind === "exception") {
65
+ const at = / at (\S+?)(?::\d+)?$/.exec(c.evidence[0] || "")?.[1]
66
+ return { layer: "Front end (JavaScript)", ...(at ? { component: routeOf(at) } : {}), basis: "an uncaught error in the page" }
67
+ }
68
+ if (c.kind === "console") return { layer: "Front end", ...(c.page ? { component: routeOf(c.page) } : {}), basis: "an error in the browser console" }
69
+ const page = c.page ? { component: routeOf(c.page) } : {}
70
+ if (c.failure === "environment") return { layer: "Environment", ...page, basis: "the run met an environment problem" }
71
+ if (c.failure === "test_case_issue") return { layer: "Test case", ...page, basis: "the test case does not fit the application" }
72
+ if (c.failure === "automation") return { layer: "Test automation", ...page, basis: "the browser agent could not finish" }
73
+ return { layer: "Application behaviour (a page or business rule)", ...page, basis: "the page showed a wrong result with no browser error" }
74
+ }
75
+
76
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
77
+ const GENERIC = new Set(["api", "v1", "v2", "v3", "rest", "graphql", "www", "app", "index", "home"])
78
+
79
+ /** What a change that caused it would mention: the route and its words, or the error message. */
80
+ export function searchTerms(c: DefectCluster): string[] {
81
+ if (c.kind === "request" && c.term) return [c.term, ...c.term.split("/").filter((w) => w.length >= 3 && !GENERIC.has(w.toLowerCase()))]
82
+ if (c.kind === "exception" && c.term) return [c.term, ...c.term.split(/\W+/).filter((w) => w.length >= 5).slice(0, 3)]
83
+ if (c.page) {
84
+ const p = routeOf(c.page)
85
+ return p.slice(p.indexOf("/")).split("/").filter((w) => w.length >= 3 && !GENERIC.has(w.toLowerCase()))
86
+ }
87
+ return []
88
+ }
89
+
90
+ const git = (repo: string, args: string[]) => spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", maxBuffer: 20 * 1024 * 1024 })
91
+
92
+ type Commit = { sha: string; subject: string; author: string; at: string; files: string[] }
93
+
94
+ function commits(repo: string, args: string[]): Commit[] {
95
+ const out = git(repo, ["log", "--no-merges", "-n", "300", "--format=%x1e%H%x1f%an%x1f%aI%x1f%s", "--name-only", ...args])
96
+ if (out.status !== 0) return []
97
+ return out.stdout
98
+ .split("\x1e")
99
+ .filter((b) => b.trim())
100
+ .map((b) => {
101
+ const [head, ...files] = b.trim().split("\n")
102
+ const [sha, author, at, subject] = head.split("\x1f")
103
+ return { sha, author, at, subject, files: files.map((f) => f.trim()).filter(Boolean) }
104
+ })
105
+ }
106
+
107
+ /**
108
+ * Commits since `since` whose changes mention the defect's terms (strongest first), or whose files are named after
109
+ * them. Five at most; none when the folder is not a git repository.
110
+ */
111
+ export function suspectCommits(repo: string, c: DefectCluster, since: string): Suspect[] {
112
+ const terms = searchTerms(c)
113
+ if (!terms.length || !existsSync(path.join(repo, ".git"))) return []
114
+ const changed = commits(repo, [`--since=${since}`, "-i", `-G${terms.map(escape).join("|")}`])
115
+ const all = commits(repo, [`--since=${since}`])
116
+ const scored = new Map<string, { c: Commit; score: number; why: string[] }>()
117
+ for (const x of changed) scored.set(x.sha, { c: x, score: 2, why: [`its changes mention ${terms.slice(0, 2).map((t) => `\`${t}\``).join(" or ")}`] })
118
+ for (const x of all) {
119
+ const named = x.files.filter((f) => terms.slice(1).some((t) => f.toLowerCase().includes(t.toLowerCase())))
120
+ if (!named.length) continue
121
+ const s = scored.get(x.sha) || { c: x, score: 0, why: [] }
122
+ s.score += 1
123
+ s.why.push(`it changes ${named.slice(0, 2).map((f) => `\`${f}\``).join(", ")}`)
124
+ scored.set(x.sha, s)
125
+ }
126
+ return [...scored.values()]
127
+ .sort((a, b) => b.score - a.score || b.c.at.localeCompare(a.c.at))
128
+ .slice(0, 5)
129
+ .map(({ c: x, why }) => ({ sha: x.sha, subject: x.subject, author: x.author, at: x.at, files: x.files.slice(0, 8), why: why.join("; ") }))
130
+ }
131
+
132
+ /** The layer, and the suspect commits when the product's repository is known: no model, no cost. */
133
+ export function basicCause(c: DefectCluster, opts: { suiteDir: string; productRepo?: string; now?: string }): RootCause {
134
+ const rc: RootCause = { ...layerOf(c) }
135
+ if (!opts.productRepo) return rc
136
+ const passed = lastPassed(opts.suiteDir, c.cases.map((x) => x.id))
137
+ if (!passed) {
138
+ rc.note = "No earlier run where its cases passed: no commit range to search."
139
+ return rc
140
+ }
141
+ rc.range = `since ${passed.slice(0, 16).replace("T", " ")}, the last run where its cases passed`
142
+ rc.suspects = suspectCommits(opts.productRepo, c, passed)
143
+ return rc
144
+ }
145
+
146
+ export const rcaAgentPrompt = () => prompt("defect-rca")
147
+
148
+ export const RCA_AGENT: AgentSpec = {
149
+ name: "forgeqe-defect-rca",
150
+ description: "Writes a root-cause hypothesis for a defect from its evidence and the product's code",
151
+ get prompt() {
152
+ return rcaAgentPrompt()
153
+ },
154
+ permission: {
155
+ edit: "deny",
156
+ webfetch: "deny",
157
+ task: "deny",
158
+ "live_*": "deny",
159
+ bash: { "*": "deny", "git log*": "allow", "git show*": "allow", "git diff*": "allow", "git blame*": "allow", "git ls-files*": "allow", "git grep*": "allow", "ls*": "allow" },
160
+ },
161
+ steps: 25,
162
+ }
163
+
164
+ const lines = { type: "array", items: { type: "string" } } as const
165
+
166
+ export const RCA_SCHEMA = {
167
+ type: "object",
168
+ properties: {
169
+ hypothesis: { type: "string", description: "The most likely cause, in two sentences or fewer" },
170
+ confidence: { type: "string", enum: ["low", "medium", "high"] },
171
+ layer: { type: "string", description: "Where the cause most likely sits, e.g. Server (API), Front end, Data, Configuration" },
172
+ component: { type: "string", description: "The file, module, route, or service, as named in the code or the evidence" },
173
+ evidence: { ...lines, description: "The facts the hypothesis rests on, each from the evidence or a file you read, with where" },
174
+ confirm: { ...lines, description: "Checks a developer can make to confirm or reject it" },
175
+ files: { ...lines, description: "Repository paths of the code to look at first" },
176
+ },
177
+ required: ["hypothesis", "confidence", "layer", "evidence", "confirm", "files"],
178
+ } as const
179
+
180
+ type RcaReport = { hypothesis?: string; confidence?: string; layer?: string; component?: string; evidence?: string[]; confirm?: string[]; files?: string[] }
181
+
182
+ /** What the agent is told: the defect, how it happened, its history, and the suspect commits. */
183
+ export function rcaPrompt(c: DefectCluster, first: CaseResult, rc: RootCause, productRepo?: string): string {
184
+ return [
185
+ `Defect: ${c.title}`,
186
+ `Kind of failure: ${c.failureLabel}. It breaks ${c.cases.length} case${c.cases.length === 1 ? "" : "s"}: ${c.cases.map((x) => `${x.id} ${x.title}`).join("; ")}.`,
187
+ "",
188
+ "Evidence from the runs:",
189
+ ...c.evidence.map((e) => `- ${e}`),
190
+ ...(c.pages.length ? [`Pages: ${c.pages.join(", ")}`] : []),
191
+ "",
192
+ `What the browser agent reported for ${first.case.id}: ${first.summary.split("\n")[0]}`,
193
+ ...(c.repro ? ["", reproText(c.repro)] : []),
194
+ "",
195
+ `From the evidence alone: layer ${rc.layer}${rc.component ? `, component ${rc.component}` : ""} (${rc.basis}).`,
196
+ ...(c.history ? [`History: ${c.history.status}${c.history.seen ? `, seen in ${c.history.seen} earlier runs` : ""}${c.history.previousBug ? `, earlier bug ${c.history.previousBug.label}` : ""}.`] : []),
197
+ ...(rc.suspects?.length ? ["", `Suspect commits (${rc.range}):`, ...rc.suspects.map((s) => `- ${s.sha.slice(0, 10)} ${s.subject} (${s.author}, ${s.at.slice(0, 10)}): ${s.why}. Files: ${s.files.join(", ")}`)] : rc.range ? ["", `No commit ${rc.range} mentions it.`] : []),
198
+ "",
199
+ productRepo ? `The product's code is in ${productRepo}. Read it.` : "You have no access to the product's code. Reason from the evidence only, and say so in the confidence.",
200
+ ].join("\n")
201
+ }
202
+
203
+ /**
204
+ * The model's hypothesis for one defect, added to `rc`. A failure leaves `rc` with what it had and a note: the
205
+ * analysis is a hint, never a reason for a suite to fail.
206
+ */
207
+ export async function analyzeCause(
208
+ config: AppConfig,
209
+ c: DefectCluster,
210
+ first: CaseResult,
211
+ rc: RootCause,
212
+ opts: { cwd: string; productRepo?: string; signal?: AbortSignal; onEvent?: (e: CodeEvent) => void; open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<RcaReport>> },
213
+ ): Promise<RootCause> {
214
+ const repo = opts.productRepo || opts.cwd
215
+ const open = (opts.open || openSession) as (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<RcaReport>>
216
+ let session: OpencodeSession<RcaReport> | undefined
217
+ try {
218
+ session = await open({
219
+ repo,
220
+ profile: config.llm,
221
+ domain: domainNotes(config),
222
+ model: config.code.model,
223
+ bin: config.code.opencode,
224
+ readDirs: [repo],
225
+ hide: privateEnvNames(config),
226
+ signal: opts.signal,
227
+ onEvent: opts.onEvent,
228
+ agent: RCA_AGENT,
229
+ schema: RCA_SCHEMA as unknown as Record<string, unknown>,
230
+ title: `ForgeQE root cause: ${c.title}`.slice(0, 120),
231
+ role: "root-cause analyst",
232
+ })
233
+ let reply = await session.prompt(rcaPrompt(c, first, rc, opts.productRepo))
234
+ if (!reply.report && !reply.error && !opts.signal?.aborted) reply = await session.prompt("Give the structured result: hypothesis, confidence, layer, component, evidence, confirm, and files.")
235
+ const usage: Usage = { ...session.usage }
236
+ const spent = { model: session.model, tokens: usage.input + usage.output, usage, ...(costOf(usage, config.llm.price) ? { cost: costOf(usage, config.llm.price) } : {}) }
237
+ const r = reply.report
238
+ if (!r?.hypothesis) return { ...rc, ...spent, note: `No hypothesis${reply.error ? `: ${reply.error}` : "."}` }
239
+ const confidence = r.confidence === "high" || r.confidence === "medium" ? r.confidence : "low"
240
+ // A guess does not replace what the evidence says: the model's layer and component only with code behind them.
241
+ const sure = confidence !== "low"
242
+ return {
243
+ ...rc,
244
+ ...(sure && r.layer ? { layer: r.layer } : {}),
245
+ ...(sure && r.component ? { component: r.component } : {}),
246
+ hypothesis: r.hypothesis.trim(),
247
+ confidence,
248
+ evidence: (r.evidence || []).map(String).filter(Boolean).slice(0, 8),
249
+ confirm: (r.confirm || []).map(String).filter(Boolean).slice(0, 5),
250
+ files: (r.files || []).map(String).filter(Boolean).slice(0, 8),
251
+ ...spent,
252
+ }
253
+ } catch (err) {
254
+ return { ...rc, note: `No hypothesis: ${(err as Error).message.split("\n")[0].slice(0, 200)}` }
255
+ } finally {
256
+ await session?.close()
257
+ }
258
+ }
@@ -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]
@@ -49,6 +49,9 @@ Usage:
49
49
  them and exits 1 when one fails (a CI gate), --post puts the results on the PR
50
50
  forgeqe quality [run-dir] a run's WCAG rules passed and failed, Core Web Vitals, and budgets
51
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
52
55
  forgeqe notify test [channel] send a test card to the Teams and email notification channels
53
56
  forgeqe eval domain [--only id,id] [--min 0.8] does the agent apply domain and regulatory knowledge? (with the domain declared and without)
54
57
  forgeqe prompts [list] every agent prompt and where it comes from (ForgeQE's or your edited copy)
@@ -112,6 +115,9 @@ page with ranked locators and a confidence %, plus the steps of each goal's last
112
115
  --replay trust replay and finish without the LLM when every step works
113
116
  --replay off never replay; still record pages
114
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)
115
121
 
116
122
  ForgeQE's agent (forgeqe start, forgeqe run) runs on the agent runtime with the active profile's model (or code.model) and
117
123
  calls ForgeQE's tools: browser runs, reading and running test cases, writing tests and running them, page memory, pushing