@capagents/forgeqe 1.1.4 → 1.1.6
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 +64 -17
- package/README.md +2 -1
- package/browserstack.example.yaml +14 -2
- package/docs/user-guide.html +78 -23
- package/forgeqe.example.yaml +18 -5
- package/package.json +2 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +8 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +13 -2
- package/prompts/test-reviewer.md +8 -3
- package/prompts/test-rules.md +10 -4
- package/py/forgeqe_engine/browser.py +1 -1
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/brain/index.ts +1 -1
- package/src/capabilities/registry.ts +34 -6
- package/src/cases/bugs.ts +209 -22
- package/src/cases/defectHistory.ts +193 -0
- package/src/cases/defects.ts +255 -0
- package/src/cases/github.ts +162 -0
- package/src/cases/goal.ts +1 -1
- package/src/cases/index.ts +23 -4
- package/src/cases/predict.ts +372 -0
- package/src/cases/rca.ts +258 -0
- package/src/cases/readiness.ts +38 -1
- package/src/cases/repro.ts +202 -0
- package/src/cases/types.ts +8 -1
- package/src/cli/help.ts +8 -2
- package/src/cli/index.ts +36 -7
- package/src/code/graph/graph.ts +518 -0
- package/src/code/graph/locators.ts +398 -0
- package/src/code/graph/parse.ts +439 -0
- package/src/code/graph/tools.ts +113 -0
- package/src/code/handoff.ts +93 -18
- package/src/code/loop.ts +30 -5
- package/src/code/profile.ts +14 -3
- package/src/code/replay.ts +1 -1
- package/src/code/review.ts +22 -0
- package/src/code/runtime.ts +8 -1
- package/src/config.ts +142 -18
- package/src/mcp/attachtools.ts +223 -0
- package/src/mcp/commands.ts +1 -1
- package/src/mcp/defecttools.ts +106 -0
- package/src/mcp/devicetools.ts +125 -0
- package/src/mcp/external.ts +42 -1
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools.ts +95 -10
- package/src/monitor/run.ts +1 -1
- package/src/report/data.ts +14 -0
- package/src/report/html.ts +15 -3
- package/src/report/template.html +44 -43
- package/src/run.ts +4 -2
- package/src/suite/report.ts +44 -11
- package/src/suite/run.ts +163 -16
- package/src/tui/App.tsx +153 -94
- package/src/tui/components.tsx +27 -23
- package/src/tui/inspector.tsx +140 -83
- package/src/tui/landing.ts +3 -4
- package/src/tui/refine-panel.tsx +102 -52
- package/src/tui/shortcuts.tsx +2 -0
- package/src/tui/transcript.tsx +32 -2
- package/src/types.ts +3 -1
- package/src/usage.ts +1 -1
- package/testmu.example.yaml +11 -0
package/src/cases/readiness.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/cases/types.ts
CHANGED
|
@@ -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
|
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
|
}
|
|
@@ -221,6 +227,19 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
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":
|
|
@@ -796,6 +815,8 @@ async function cmdStart(flags: Flags): Promise<number> {
|
|
|
796
815
|
url: strFlag(flags, "url"),
|
|
797
816
|
goal: strFlag(flags, "goal"),
|
|
798
817
|
headed: boolFlag(flags, "headed"),
|
|
818
|
+
browserProvider: browserProviderFlag(flags),
|
|
819
|
+
devices: devicesFlag(flags),
|
|
799
820
|
verbose: boolFlag(flags, "verbose"),
|
|
800
821
|
...memoryFlags(flags),
|
|
801
822
|
repo: boolFlag(flags, "no-code") ? null : strFlag(flags, "repo") ? path.resolve(strFlag(flags, "repo")!) : undefined,
|
|
@@ -941,6 +962,7 @@ async function runAgent(cfg: AppConfig, flags: Flags, text: string, maxSteps: nu
|
|
|
941
962
|
context: () => ({
|
|
942
963
|
headed: boolFlag(flags, "headed"),
|
|
943
964
|
browserProvider: browserProviderFlag(flags),
|
|
965
|
+
devices: devicesFlag(flags),
|
|
944
966
|
maxSteps,
|
|
945
967
|
url: strFlag(flags, "url"),
|
|
946
968
|
...memoryFlags(flags),
|
|
@@ -974,7 +996,7 @@ async function runAgent(cfg: AppConfig, flags: Flags, text: string, maxSteps: nu
|
|
|
974
996
|
}
|
|
975
997
|
|
|
976
998
|
function cmdMcpList(cfg: AppConfig): number {
|
|
977
|
-
const { servers, skipped } = externalMcp(cfg)
|
|
999
|
+
const { servers, skipped } = externalMcp(cfg, Bun.which, process.env, { cwd: process.cwd() })
|
|
978
1000
|
if (!servers.length && !skipped.length) {
|
|
979
1001
|
console.log("No MCP servers for other systems. Set sources.azure_devops or sources.jira, or add servers under mcp:, in forgeqe.yaml.")
|
|
980
1002
|
}
|
|
@@ -1006,6 +1028,7 @@ async function cmdMcp(args: string[], flags: Flags): Promise<number> {
|
|
|
1006
1028
|
cwd,
|
|
1007
1029
|
headed: boolFlag(flags, "headed"),
|
|
1008
1030
|
browserProvider: browserProviderFlag(flags),
|
|
1031
|
+
devices: devicesFlag(flags),
|
|
1009
1032
|
maxSteps: maxStepsFlag(flags),
|
|
1010
1033
|
url: strFlag(flags, "url"),
|
|
1011
1034
|
...memoryFlags(flags),
|
|
@@ -1023,10 +1046,13 @@ async function runGoal(cfg: AppConfig, flags: Flags, goal: string, maxSteps: num
|
|
|
1023
1046
|
const out = runDirectory(cfg.export.outDir, strFlag(flags, "out"))
|
|
1024
1047
|
const t0 = Date.now()
|
|
1025
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.")
|
|
1026
1051
|
const { result, files } = await executeRun({
|
|
1027
1052
|
config: cfg,
|
|
1028
1053
|
goal,
|
|
1029
|
-
browserProvider: browserProviderFlag(flags),
|
|
1054
|
+
browserProvider: devices.length ? undefined : browserProviderFlag(flags),
|
|
1055
|
+
browserDevice: devices[0],
|
|
1030
1056
|
url: strFlag(flags, "url"),
|
|
1031
1057
|
headed: boolFlag(flags, "headed"),
|
|
1032
1058
|
maxSteps,
|
|
@@ -1265,11 +1291,13 @@ async function cmdSuite(refs: string[], flags: Flags): Promise<number> {
|
|
|
1265
1291
|
cfg = withCodeFlags(cfg, flags)
|
|
1266
1292
|
const parallelRaw = strFlag(flags, "parallel")
|
|
1267
1293
|
if (parallelRaw && !(Number(parallelRaw) >= 1)) throw new Error("--parallel must be a positive number")
|
|
1268
|
-
const parallel = parallelRaw ? Number(parallelRaw) : cfg.suite.parallel
|
|
1269
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))
|
|
1270
1298
|
const repo = codeRepo(cfg, flags)
|
|
1271
1299
|
const color = Boolean(process.stdout.isTTY) && !boolFlag(flags, "plain")
|
|
1272
|
-
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}` : ""}`)
|
|
1273
1301
|
|
|
1274
1302
|
const controller = new AbortController()
|
|
1275
1303
|
const onSigint = () => controller.abort()
|
|
@@ -1279,7 +1307,8 @@ async function cmdSuite(refs: string[], flags: Flags): Promise<number> {
|
|
|
1279
1307
|
config: cfg,
|
|
1280
1308
|
cases,
|
|
1281
1309
|
repo,
|
|
1282
|
-
browserProvider: browserProviderFlag(flags),
|
|
1310
|
+
browserProvider: devices.length ? undefined : browserProviderFlag(flags),
|
|
1311
|
+
devices,
|
|
1283
1312
|
parallel,
|
|
1284
1313
|
outDir: strFlag(flags, "out"),
|
|
1285
1314
|
writeBack: boolFlag(flags, "no-write-back") ? false : undefined,
|