@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
@@ -0,0 +1,106 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { mkdirSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { areaFacts, changeOf, defectTrends, predict, predictionLines, savePrediction, trendsMarkdown, type Prediction } from "../cases/predict.ts"
6
+ import type { McpTool } from "./tools.ts"
7
+
8
+ /** The branch a change is compared with when none is named: the remote's default branch, else main, else master. */
9
+ export function defaultBase(repo: string): string {
10
+ const ok = (ref: string) => spawnSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", ref], { encoding: "utf8" }).status === 0
11
+ const head = spawnSync("git", ["-C", repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], { encoding: "utf8" })
12
+ if (head.status === 0 && head.stdout.trim()) return head.stdout.trim()
13
+ for (const ref of ["origin/main", "main", "origin/master", "master"]) if (ok(ref)) return ref
14
+ throw new Error("No base branch found (origin/HEAD, main, or master): name one with base.")
15
+ }
16
+
17
+ const pct = (a: number, b: number) => (b ? `${Math.round((a / b) * 100)}%` : "—")
18
+
19
+ export const DEFECT_TOOLS: McpTool[] = [
20
+ {
21
+ name: "defect_trends",
22
+ description:
23
+ "How the defects of the saved suite runs went over time, from defect-history.json next to the suite folders: how many are still there, gone, or came back after a fix, the time they took to go away, the most seen ones with their bugs, defects by area (page or API route), defects per suite, and how often defect predictions were right. Writes defect-trends.md next to the suites. Shows a card. Nothing runs.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ folder: { type: "string", description: "Folder of saved suites (default: suite.out_dir in forgeqe.yaml)" },
28
+ },
29
+ },
30
+ async run(args, ctx) {
31
+ const folder = args.folder ? path.resolve(ctx.cwd, String(args.folder)) : ctx.config.suite.outDir
32
+ const t = defectTrends(folder)
33
+ const md = trendsMarkdown(t)
34
+ mkdirSync(folder, { recursive: true })
35
+ const file = path.join(folder, "defect-trends.md")
36
+ writeFileSync(file, md)
37
+ const p = t.predictions
38
+ ctx.emit?.({
39
+ type: "card",
40
+ card: {
41
+ status: t.defects ? (t.open ? "fail" : "ok") : "info",
42
+ title: t.defects ? `${t.defects} defect${t.defects === 1 ? "" : "s"}: ${t.open} still there, ${t.gone} gone, ${t.regressions} came back` : "No defect history yet",
43
+ stats: `${t.suites} suite run${t.suites === 1 ? "" : "s"}`,
44
+ body: t.defects
45
+ ? [
46
+ ...(t.daysToFix ? [`Time to go away: median ${t.daysToFix.median} days over ${t.daysToFix.count}`] : []),
47
+ ...(p ? [`Predictions (${p.scored} scored): ${p.inPredicted} of ${p.defects} defects (${pct(p.inPredicted, p.defects)}) were in a predicted area; ${p.highWithDefect} of ${p.high} high-risk areas (${pct(p.highWithDefect, p.high)}) had one`] : []),
48
+ "",
49
+ "Most seen:",
50
+ ...t.recurring.slice(0, 5).map((d) => ` ${d.seen}× ${d.title}${d.open ? "" : " (gone)"}${d.returns ? `, came back ${d.returns}×` : ""}${d.bug ? ` ${d.bug.label}` : ""}`),
51
+ ...(t.areas.length ? ["", "By area:", ...t.areas.slice(0, 5).map((a) => ` ${a.area}: ${a.defects} defect${a.defects === 1 ? "" : "s"}${a.runs ? `, ${a.failed} of ${a.runs} case runs failed` : ""}`)] : []),
52
+ "",
53
+ `Report: ${path.relative(ctx.cwd, file) || file}`,
54
+ ]
55
+ : ["Run suites into this folder first: each one adds its defects to defect-history.json."],
56
+ },
57
+ })
58
+ return { ...t, report: file }
59
+ },
60
+ },
61
+ {
62
+ name: "predict_defects",
63
+ description:
64
+ "Where a change is likely to cause defects. Compares a git range of the product's repository (base...head; default: the default branch to HEAD, in defects.product_repo or the working directory) with what the saved suites know about each area (page or API route): the change touches an area when it changes a file named after it or its diff mentions the route; the area's earlier defects, defects that came back, defects still open, and case failures raise its risk. Gives each area high, medium, or low with every reason, the cases to run first, and changed code no case reaches. Saves the prediction; the next suite scores it, and defect_trends says how often predictions were right. A heuristic, not a model. Shows a card. Nothing runs.",
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: {
68
+ repo: { type: "string", description: "The product's repository (default: defects.product_repo, else the working directory)" },
69
+ base: { type: "string", description: "Base ref of the change (default: origin/HEAD, main, or master)" },
70
+ head: { type: "string", description: "Head ref of the change (default: HEAD)" },
71
+ folder: { type: "string", description: "Folder of saved suites (default: suite.out_dir in forgeqe.yaml)" },
72
+ },
73
+ },
74
+ async run(args, ctx) {
75
+ const repo = args.repo ? path.resolve(ctx.cwd, String(args.repo)) : ctx.config.defects?.productRepo || ctx.cwd
76
+ const folder = args.folder ? path.resolve(ctx.cwd, String(args.folder)) : ctx.config.suite.outDir
77
+ const base = args.base ? String(args.base) : defaultBase(repo)
78
+ const head = args.head ? String(args.head) : "HEAD"
79
+ const change = changeOf(repo, base, head)
80
+ const facts = areaFacts(folder)
81
+ const at = new Date().toISOString()
82
+ const p: Prediction = { id: at.replace(/[:.]/g, "-"), at, repo, base, head, changed: change.files, ...predict(facts, change) }
83
+ mkdirSync(folder, { recursive: true })
84
+ savePrediction(folder, p)
85
+ const high = p.areas.filter((a) => a.level === "high").length
86
+ ctx.emit?.({
87
+ type: "card",
88
+ card: {
89
+ status: high ? "fail" : p.areas.length ? "info" : "ok",
90
+ title: p.areas.length ? `${high} high-risk area${high === 1 ? "" : "s"} in ${base}...${head}` : `No known area touched by ${base}...${head}`,
91
+ stats: `${p.changed.length} changed file${p.changed.length === 1 ? "" : "s"} · ${facts.size} known area${facts.size === 1 ? "" : "s"}`,
92
+ body: [...predictionLines(p), ...(facts.size ? [] : ["", "ForgeQE knows no areas yet: run suites into this folder first."])],
93
+ },
94
+ })
95
+ return {
96
+ base,
97
+ head,
98
+ changed: p.changed,
99
+ areas: p.areas,
100
+ run_first: p.runFirst,
101
+ untested: p.untested,
102
+ next_step: p.runFirst.length ? "Offer to run the run_first cases before the others (run_test_cases)." : undefined,
103
+ }
104
+ },
105
+ },
106
+ ]
@@ -0,0 +1,125 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import YAML from "yaml"
4
+
5
+ import { browserProvider, DEVICE_NAME, loadConfig, type AppConfig, type BrowserDevice, type RemoteBrowserProvider } from "../config.ts"
6
+ import { projectDir } from "../tools.ts"
7
+ import type { McpTool } from "./tools.ts"
8
+
9
+ /**
10
+ * Named remote browsers ("devices") the user describes in conversation: kept under devices: in the provider's YAML
11
+ * file (browser.providers.<provider> in forgeqe.yaml, created when missing), so runs and CI see the same list.
12
+ */
13
+
14
+ /** A device as the agent reads it. */
15
+ export function deviceView(d: BrowserDevice) {
16
+ const r = d.remote
17
+ return {
18
+ name: d.name,
19
+ provider: d.provider,
20
+ browser: r.browser,
21
+ browser_version: r.browserVersion,
22
+ ...(r.platform ? { platform: r.platform } : {}),
23
+ ...(r.os ? { os: r.os } : {}),
24
+ ...(r.osVersion ? { os_version: r.osVersion } : {}),
25
+ }
26
+ }
27
+
28
+ /** The provider's YAML file, from forgeqe.yaml; when there is none, .forgeqe/<provider>.yaml, added to forgeqe.yaml. */
29
+ function providerFile(config: AppConfig, provider: RemoteBrowserProvider): { file: string; added: boolean } {
30
+ const known = config.browser.providerFiles[provider]
31
+ if (known) return { file: known, added: false }
32
+ const configPath = config.configPath
33
+ if (!configPath) throw new Error("There is no forgeqe.yaml here to add a device to: run forgeqe init first.")
34
+ const file = path.join(projectDir(path.dirname(configPath)), `${provider}.yaml`)
35
+ const doc = YAML.parseDocument(readFileSync(configPath, "utf8"))
36
+ if (!YAML.isMap(doc.get("browser"))) doc.set("browser", doc.createNode({}))
37
+ doc.setIn(["browser", "providers", provider], `./${path.relative(path.dirname(configPath), file).split(path.sep).join("/")}`)
38
+ writeFileSync(configPath, doc.toString())
39
+ return { file, added: true }
40
+ }
41
+
42
+ /**
43
+ * Write a change to a provider file and forgeqe.yaml, then load the whole configuration again: a device that does not
44
+ * load (a missing platform, a browser the engine cannot drive) puts both files back and fails, so a bad device never
45
+ * breaks the configuration. The session's configuration takes the new browser settings.
46
+ */
47
+ function applyChange(config: AppConfig, provider: RemoteBrowserProvider, change: (doc: YAML.Document) => void): { file: string } {
48
+ const configPath = config.configPath
49
+ const before = configPath && existsSync(configPath) ? readFileSync(configPath, "utf8") : undefined
50
+ const { file, added } = providerFile(config, provider)
51
+ const old = existsSync(file) ? readFileSync(file, "utf8") : undefined
52
+ const doc = YAML.parseDocument(old ?? `provider: ${provider}\n`)
53
+ change(doc)
54
+ mkdirSync(path.dirname(file), { recursive: true })
55
+ writeFileSync(file, doc.toString())
56
+ try {
57
+ const fresh = loadConfig({ configPath, profile: config.activeProfile })
58
+ Object.assign(config.browser, fresh.browser)
59
+ } catch (err) {
60
+ if (old === undefined) rmSync(file, { force: true })
61
+ else writeFileSync(file, old)
62
+ if (added && configPath && before !== undefined) writeFileSync(configPath, before)
63
+ throw err
64
+ }
65
+ return { file }
66
+ }
67
+
68
+ const str = (v: unknown) => (v === undefined || v === null ? "" : String(v).trim())
69
+
70
+ export const DEVICE_TOOLS: McpTool[] = [
71
+ {
72
+ name: "browser_devices",
73
+ description:
74
+ "List, add or change, and remove named remote browsers (devices) on BrowserStack or TestMu AI/LambdaTest, kept in the provider's YAML file so every later run and CI sees them. When the user describes the devices or browsers to test on, save each one here, then run on them by passing their names as devices to browser_run or run_test_cases. ForgeQE drives remote browsers over the Chrome DevTools Protocol, so a device is Chrome (TestMu AI: Chrome only) or Edge (BrowserStack) on a Windows or macOS version; Safari, Firefox, and real mobile devices are not supported, so say so instead of saving them.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ action: { type: "string", enum: ["list", "save", "remove"], description: "list the devices; save adds a device or changes one; remove deletes one" },
79
+ name: { type: "string", description: "Short device name for save/remove, e.g. win11-chrome, mac-sequoia-edge" },
80
+ provider: { type: "string", enum: ["browserstack", "testmu"], description: "For save: the grid the device is on (testmu is TestMu AI / LambdaTest). Needed for a new device." },
81
+ browser: { type: "string", description: "For save: TestMu: Chrome; BrowserStack: chrome or edge" },
82
+ browser_version: { type: "string", description: "For save: a version such as 128, or latest, latest-1 (default latest)" },
83
+ platform: { type: "string", description: "For save on TestMu: the platform, e.g. Windows 11, Windows 10, macOS Sequoia" },
84
+ os: { type: "string", description: "For save on BrowserStack: Windows or OS X" },
85
+ os_version: { type: "string", description: "For save on BrowserStack: 11, 10, Sequoia, Sonoma, ..." },
86
+ },
87
+ required: ["action"],
88
+ },
89
+ async run(args, ctx) {
90
+ const config = ctx.config
91
+ const action = str(args.action) || "list"
92
+ const list = () => ({
93
+ devices: Object.values(config.browser.devices || {}).map(deviceView),
94
+ providers: Object.fromEntries(Object.entries(config.browser.providerFiles).map(([p, f]) => [p, f])),
95
+ })
96
+ if (action === "list") return list()
97
+ const name = str(args.name)
98
+ if (!DEVICE_NAME.test(name)) throw new Error('name is required: a short name such as "win11-chrome" (letters, digits, ".", "_", "-").')
99
+ const existing = config.browser.devices?.[name]
100
+ if (action === "remove") {
101
+ if (!existing) throw new Error(`No device named ${name}.`)
102
+ const { file } = applyChange(config, existing.provider, (doc) => {
103
+ doc.deleteIn(["devices", name])
104
+ const left = doc.get("devices")
105
+ if (YAML.isMap(left) && !left.items.length) doc.delete("devices")
106
+ })
107
+ return { removed: name, file, ...list() }
108
+ }
109
+ if (action !== "save") throw new Error(`Unknown action ${action}: use list, save, or remove.`)
110
+ const asked = str(args.provider)
111
+ const provider = (asked ? browserProvider(asked) : existing?.provider) as RemoteBrowserProvider | "local" | undefined
112
+ if (!provider || provider === "local") throw new Error("provider is required for a new device: browserstack or testmu.")
113
+ if (existing && existing.provider !== provider) throw new Error(`Device ${name} is on ${existing.provider}; remove it first to move it to ${provider}.`)
114
+ const fields: Record<string, string> = {}
115
+ for (const key of ["browser", "browser_version", "platform", "os", "os_version"]) if (str(args[key])) fields[key] = str(args[key])
116
+ if (!existing && !fields.browser) throw new Error("browser is required for a new device: Chrome or Edge.")
117
+ const { file } = applyChange(config, provider, (doc) => {
118
+ if (!doc.get("provider")) doc.set("provider", provider)
119
+ for (const [key, value] of Object.entries(fields)) doc.setIn(["devices", name, key], value)
120
+ })
121
+ const saved = config.browser.devices[name]
122
+ return { saved: deviceView(saved), file, ...list() }
123
+ },
124
+ },
125
+ ]
@@ -1,3 +1,6 @@
1
+ import { spawnSync } from "node:child_process"
2
+
3
+ import { ghAuthToken } from "../cases/github.ts"
1
4
  import type { AppConfig } from "../config.ts"
2
5
 
3
6
  /**
@@ -22,6 +25,15 @@ const ADO_DOMAIN_WORDS: Record<string, string> = {
22
25
  "advanced-security": "security alerts",
23
26
  }
24
27
  export const ROVO_URL = "https://mcp.atlassian.com/v2/mcp"
28
+ export const GITHUB_MCP_URL = "https://api.githubcopilot.com/mcp/"
29
+
30
+ /** The GitHub repository (owner/name) the folder's git remotes point at, or null. */
31
+ export function githubRemote(cwd: string): string | null {
32
+ const res = spawnSync("git", ["config", "--get-regexp", "^remote\\..*\\.url$"], { cwd, encoding: "utf8", timeout: 5000 })
33
+ if (res.status !== 0) return null
34
+ const m = /github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\s*$/m.exec(res.stdout)
35
+ return m ? `${m[1]}/${m[2]}` : null
36
+ }
25
37
  /** Also the time allowed for the first start, when npx or uvx downloads the server. */
26
38
  const TIMEOUT_MS = 120_000
27
39
 
@@ -61,7 +73,12 @@ export function adoOrganization(orgUrl: string): string | null {
61
73
  * server for Jira, and anything under `mcp:`. Credentials stay in the environment: configs refer to them as {env:NAME}.
62
74
  * A system whose server cannot start here is listed in `skipped`; ForgeQE's own API tools still cover it.
63
75
  */
64
- export function externalMcp(cfg: AppConfig, which: Which = Bun.which, env: NodeJS.ProcessEnv = process.env): ExternalPlan {
76
+ export function externalMcp(
77
+ cfg: AppConfig,
78
+ which: Which = Bun.which,
79
+ env: NodeJS.ProcessEnv = process.env,
80
+ opts: { cwd?: string; ghToken?: () => string | null; remote?: (cwd: string) => string | null } = {},
81
+ ): ExternalPlan {
65
82
  const servers: ExternalServer[] = []
66
83
  const skipped: ExternalPlan["skipped"] = []
67
84
  const { ado, jira, confluence: conf } = cfg.sources
@@ -175,6 +192,30 @@ export function externalMcp(cfg: AppConfig, which: Which = Bun.which, env: NodeJ
175
192
  }
176
193
  }
177
194
 
195
+ // GitHub's hosted MCP server, when the project is on GitHub: sources.github in forgeqe.yaml, or a GitHub remote in the
196
+ // folder. Its tools cost tokens on every request, so a project that is not on GitHub does not get them.
197
+ const gh = cfg.sources.github
198
+ // The folder the agent works in, when the caller names it: a GitHub remote there turns the server on.
199
+ const remote = gh?.mcp === false || cfg.mcp?.github || !opts.cwd ? null : (opts.remote || githubRemote)(opts.cwd)
200
+ if (gh?.mcp !== false && !cfg.mcp?.github && (gh || remote)) {
201
+ const label = "GitHub MCP"
202
+ const token = env[gh?.tokenEnv || "GITHUB_TOKEN"]?.trim() || (opts.ghToken || ghAuthToken)()
203
+ if (gh && gh.apiUrl !== "https://api.github.com") skipped.push({ label, why: "GitHub's hosted server is for github.com; add your Enterprise server's MCP under mcp:" })
204
+ else if (!token) skipped.push({ label, why: `sign in with forgeqe auth login github, or set ${gh?.tokenEnv || "GITHUB_TOKEN"}` })
205
+ else {
206
+ const toolsets = gh?.toolsets?.length ? gh.toolsets : ["repos", "issues", "pull_requests", "actions"]
207
+ const repo = gh?.repo || remote
208
+ servers.push({
209
+ name: "github",
210
+ label,
211
+ about: `GitHub's MCP server (${toolsets.join(", ")})${repo ? `; this project is ${repo}, so use it unless the user names another repository` : ""}. It cannot upload files to issues; ForgeQE's own tools write test results and bugs to GitHub issues.`,
212
+ config: { type: "remote", url: GITHUB_MCP_URL, headers: { Authorization: "Bearer {env:FORGEQE_GITHUB_MCP_TOKEN}", "X-MCP-Toolsets": toolsets.join(",") }, oauth: false, enabled: true, timeout: TIMEOUT_MS },
213
+ secrets: { FORGEQE_GITHUB_MCP_TOKEN: token },
214
+ signIn: env[gh?.tokenEnv || "GITHUB_TOKEN"]?.trim() ? `token in ${gh?.tokenEnv || "GITHUB_TOKEN"}` : "GitHub CLI sign-in",
215
+ })
216
+ }
217
+ }
218
+
178
219
  for (const [name, server] of Object.entries(cfg.mcp || {})) {
179
220
  const at = servers.findIndex((s) => s.name === name)
180
221
  if (at >= 0) servers.splice(at, 1)
package/src/mcp/tools.ts CHANGED
@@ -19,17 +19,20 @@ import { browserProvider, type AppConfig } from "../config.ts"
19
19
  import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
20
20
  import { executeRun, runDirectory } from "../run.ts"
21
21
  import { counts } from "../suite/report.ts"
22
- import { runSuite, type SuiteEvent, type SuiteResult } from "../suite/run.ts"
22
+ import { deviceCases, runSuite, suiteParallel, type SuiteEvent, type SuiteResult } from "../suite/run.ts"
23
23
  import { COMMAND_TOOLS, TASK_FILE } from "./commands.ts"
24
24
  import { PR_TOOLS } from "./prtools.ts"
25
25
  import { CASE_TOOLS, checkResult, precheck } from "./casetools.ts"
26
26
  import { REPO_TEST_TOOLS } from "./repotests.ts"
27
+ import { DEFECT_TOOLS } from "./defecttools.ts"
27
28
  import { API_TOOLS } from "./apitools.ts"
28
29
  import { EXPLORE_TOOLS } from "./exploretools.ts"
29
30
  import { QUALITY_TOOLS } from "./qualitytools.ts"
30
31
  import { CAPABILITY_TOOLS } from "./captools.ts"
31
32
  import { NOTIFY_TOOLS } from "./notifytools.ts"
32
33
  import { REPORT_TOOLS } from "./reporttools.ts"
34
+ import { DEVICE_TOOLS } from "./devicetools.ts"
35
+ import { ATTACH_TOOLS } from "./attachtools.ts"
33
36
  import { capabilityIndex } from "../capabilities/render.ts"
34
37
  import { domainNotes } from "../domain.ts"
35
38
  import { readNetwork } from "../api/traffic.ts"
@@ -124,6 +127,8 @@ export type ToolContext = {
124
127
  cwd: string
125
128
  headed?: boolean
126
129
  browserProvider?: AppConfig["browser"]["provider"]
130
+ /** Named devices the user pinned for this session (--devices): runs go to each of them. */
131
+ devices?: string[]
127
132
  maxSteps?: number
128
133
  replay?: ReplayMode
129
134
  memory?: boolean
@@ -190,14 +195,14 @@ const SOURCES_SCHEMA = {
190
195
  properties: {
191
196
  source: {
192
197
  type: "string",
193
- enum: ["azure_devops", "jira", "xray", "testrail", "zephyr", "qtest", "file", "text"],
198
+ enum: ["azure_devops", "jira", "xray", "testrail", "zephyr", "qtest", "github", "file", "text"],
194
199
  description:
195
- "azure_devops: Test Case work items or a Test Plan. jira: issues. xray: Xray Test issues or a Test Plan. testrail: cases, a run, a plan, or a suite. zephyr: Zephyr Scale test cases, a test cycle, or a folder. qtest: qTest test cases, a test cycle, or a suite. file: .txt/.md/.csv/.yaml/.json/.feature files. text: test case text the user wrote (also cases you wrote from requirements).",
200
+ "azure_devops: Test Case work items or a Test Plan. github: GitHub issues (by number, labels, or an issue search). jira: issues. xray: Xray Test issues or a Test Plan. testrail: cases, a run, a plan, or a suite. zephyr: Zephyr Scale test cases, a test cycle, or a folder. qtest: qTest test cases, a test cycle, or a suite. file: .txt/.md/.csv/.yaml/.json/.feature files. text: test case text the user wrote (also cases you wrote from requirements).",
196
201
  },
197
202
  ids: {
198
203
  type: "array",
199
204
  items: { type: "string" },
200
- description: "Work item ids (azure_devops), issue keys (jira, xray), case ids like C12 (testrail), keys like SHOP-T3 (zephyr), or test case ids (qtest)",
205
+ description: "Work item ids (azure_devops), issue keys (jira, xray), case ids like C12 (testrail), keys like SHOP-T3 (zephyr), test case ids (qtest), or issue numbers (github: 12, or owner/repo#12)",
201
206
  },
202
207
  plan: { type: "string", description: "Test plan id (azure_devops, testrail) or Test Plan issue key (xray)" },
203
208
  suite: { type: "string", description: "Suite id: inside the Azure DevOps plan (omit for every suite), a TestRail suite, or a qTest test suite" },
@@ -205,6 +210,9 @@ const SOURCES_SCHEMA = {
205
210
  cycle: { type: "string", description: "zephyr: test cycle key like SHOP-R5. qtest: test cycle id" },
206
211
  folder: { type: "string", description: "zephyr: folder id" },
207
212
  jql: { type: "string", description: "JQL query (jira, xray)" },
213
+ repo: { type: "string", description: "github: owner/repo (default: sources.github.repo); ids may also be owner/repo#12" },
214
+ labels: { type: "array", items: { type: "string" }, description: "github: open issues with all these labels" },
215
+ query: { type: "string", description: 'github: a GitHub issue search, e.g. "login in:title is:open label:test-case"' },
208
216
  paths: { type: "array", items: { type: "string" }, description: "Files, relative to the working directory" },
209
217
  text: { type: "string", description: "The test case itself: title on the first line, then steps and expected results" },
210
218
  },
@@ -253,6 +261,61 @@ function summarizeRun(result: RunResult, runDir: string, files: string[]) {
253
261
  }
254
262
  }
255
263
 
264
+ const DEVICES_SCHEMA = {
265
+ type: "array",
266
+ items: { type: "string" },
267
+ description:
268
+ "Names of saved devices (browser_devices) to run on, when the user asked for specific devices or browsers: each runs in parallel on its own remote browser and is reported separately. A device's name also picks its provider.",
269
+ }
270
+
271
+ /** One goal on several devices at once: a one-off case through the suite, so each device runs and reports on its own. */
272
+ async function runGoalOnDevices(goal: string, url: string | undefined, devices: string[], args: Record<string, unknown>, ctx: ToolContext) {
273
+ const tc: TestCase = { id: "goal", title: clip(goal.split("\n")[0], 80) || "Goal", source: "text", description: goal, steps: [], ...(url ? { startUrl: url } : {}) }
274
+ const shown = deviceCases([tc], devices)
275
+ const parallel = suiteParallel(undefined, ctx.config.suite.parallel, devices.length, shown.length)
276
+ const emit = ctx.emit || (() => {})
277
+ emit({ type: "suite-start", cases: shown, parallel })
278
+ const suite = await runSuite({
279
+ config: ctx.config,
280
+ cases: [tc],
281
+ devices,
282
+ parallel,
283
+ writeBack: false,
284
+ headed: ctx.headed,
285
+ url,
286
+ maxSteps: ctx.maxSteps,
287
+ replay: ctx.replay,
288
+ memory: ctx.memory,
289
+ skills: skillNames(args.skills),
290
+ skillsRepo: ctx.cwd,
291
+ signal: ctx.signal,
292
+ execute: ctx.execute,
293
+ onEvent: (event) => emit({ type: "suite-event", event }),
294
+ })
295
+ emit({ type: "suite-done", suite })
296
+ const c = counts(suite.results)
297
+ return {
298
+ devices: suite.results.map((r) => ({ device: r.device, status: r.status, summary: clip(r.summary, 600), run_dir: r.runDir, final_url: r.finalUrl })),
299
+ passed: c.passed,
300
+ failed: c.failed + c.errors,
301
+ report: suite.files.html,
302
+ note: "Each device ran the goal on its own remote browser. To turn it into a test, run write_test with one device's run_dir.",
303
+ }
304
+ }
305
+
306
+ /** Devices a tool call asked for, else the session's pinned ones; names checked against the configuration. */
307
+ function devicesFor(args: Record<string, unknown>, ctx: ToolContext): string[] {
308
+ const asked = Array.isArray(args.devices) ? args.devices.map((d) => String(d).trim()).filter(Boolean) : []
309
+ const devices = asked.length ? asked : ctx.devices || []
310
+ if (!devices.length) return []
311
+ const known = ctx.config.browser?.devices || {}
312
+ const missing = devices.filter((d) => !known[d])
313
+ if (missing.length) {
314
+ throw new Error(`No device named ${missing.join(", ")}. ${Object.keys(known).length ? `Saved devices: ${Object.keys(known).join(", ")}.` : "No device is saved yet."} Save it with browser_devices first.`)
315
+ }
316
+ return [...new Set(devices)]
317
+ }
318
+
256
319
  export const TOOLS: McpTool[] = [
257
320
  {
258
321
  name: "browser_run",
@@ -273,6 +336,7 @@ export const TOOLS: McpTool[] = [
273
336
  description:
274
337
  "Override the browser provider for this run when the user asked for one specifically. local uses the machine's own browser; browserstack and testmu use their configured YAML or browser.remote settings.",
275
338
  },
339
+ devices: DEVICES_SCHEMA,
276
340
  resume_from: {
277
341
  type: "string",
278
342
  description:
@@ -295,6 +359,10 @@ export const TOOLS: McpTool[] = [
295
359
  const resumeFrom = from ? (existsSync(path.resolve(ctx.cwd, from)) ? path.resolve(ctx.cwd, from) : from) : undefined
296
360
  const url = args.start_url ? String(args.start_url) : ctx.url
297
361
  const selectedBrowser = args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
362
+ // Pinned devices are for testing the app: a task (reading a work item, looking something up) runs once, here.
363
+ const devices = args.purpose === "task" && !Array.isArray(args.devices) ? [] : devicesFor(args, ctx)
364
+ // Several devices: the goal runs as a one-off case on each, in parallel, through the suite (no write-back).
365
+ if (devices.length > 1) return runGoalOnDevices(goal, url, devices, args, ctx)
298
366
  const runDir = runDirectory(ctx.config.export.outDir)
299
367
  const emit = ctx.emit || (() => {})
300
368
  const t0 = Date.now()
@@ -303,7 +371,8 @@ export const TOOLS: McpTool[] = [
303
371
  const { result, files } = await (ctx.execute || executeRun)({
304
372
  config: ctx.config,
305
373
  goal,
306
- browserProvider: selectedBrowser,
374
+ browserProvider: devices.length ? undefined : selectedBrowser,
375
+ browserDevice: devices[0],
307
376
  url,
308
377
  headed: ctx.headed,
309
378
  maxSteps: ctx.maxSteps,
@@ -432,6 +501,7 @@ export const TOOLS: McpTool[] = [
432
501
  description:
433
502
  "Override the browser provider for every case in this suite when the user asked for one specifically.",
434
503
  },
504
+ devices: DEVICES_SCHEMA,
435
505
  skills: SKILLS_SCHEMA,
436
506
  },
437
507
  required: ["sources"],
@@ -443,10 +513,12 @@ export const TOOLS: McpTool[] = [
443
513
  const cases = all.filter((c) => !skip.has(c.id))
444
514
  if (!cases.length) return { ran: false, reason: "No case can run as written (suite.case_check: block).", check: checkResult(checks) }
445
515
  if (!cases.length) throw new Error("No test cases found.")
446
- const parallel = Math.min(Number(args.parallel) || ctx.config.suite.parallel, cases.length)
447
- const selectedBrowser = args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
516
+ const devices = devicesFor(args, ctx)
517
+ const shown = deviceCases(cases, devices)
518
+ const parallel = suiteParallel(Number(args.parallel) || undefined, ctx.config.suite.parallel, devices.length, shown.length)
519
+ const selectedBrowser = devices.length ? undefined : args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
448
520
  const emit = ctx.emit || (() => {})
449
- emit({ type: "suite-start", cases, parallel })
521
+ emit({ type: "suite-start", cases: shown, parallel })
450
522
  // In a session that writes tests, each passed case goes to the coding agent right after its own browser run,
451
523
  // inside the suite, rather than every case being explored first and handed over at the end.
452
524
  const coder: typeof runCodeAgent = async (o) => {
@@ -472,6 +544,7 @@ export const TOOLS: McpTool[] = [
472
544
  cases,
473
545
  ...(ctx.writeTests ? { repo: ctx.cwd, coder } : {}),
474
546
  browserProvider: selectedBrowser,
547
+ devices,
475
548
  parallel,
476
549
  writeBack: typeof args.write_back === "boolean" ? args.write_back : undefined,
477
550
  headed: ctx.headed,
@@ -498,8 +571,10 @@ export const TOOLS: McpTool[] = [
498
571
  stopped: suite.stopped,
499
572
  report: suite.files.html,
500
573
  junit: suite.files.junit,
574
+ ...(devices.length > 1 ? { devices, posted_back: "one combined result per case: it fails when any device fails" } : {}),
501
575
  results: suite.results.map((r) => ({
502
576
  id: r.case.id,
577
+ ...(r.device ? { device: r.device } : {}),
503
578
  title: r.case.title,
504
579
  status: r.status,
505
580
  summary: clip(r.summary, 600),
@@ -762,6 +837,7 @@ export const TOOLS: McpTool[] = [
762
837
  },
763
838
  ...COMMAND_TOOLS,
764
839
  ...REPO_TEST_TOOLS,
840
+ ...DEFECT_TOOLS,
765
841
  ...PR_TOOLS,
766
842
  ...CASE_TOOLS,
767
843
  ...API_TOOLS,
@@ -770,6 +846,8 @@ export const TOOLS: McpTool[] = [
770
846
  ...CAPABILITY_TOOLS,
771
847
  ...NOTIFY_TOOLS,
772
848
  ...REPORT_TOOLS,
849
+ ...DEVICE_TOOLS,
850
+ ...ATTACH_TOOLS,
773
851
  ]
774
852
 
775
853
  /** The setup the agent should know about: where it works, which systems are configured, and the defaults. */
@@ -782,7 +860,7 @@ function knowledgeNotes(cfg: AppConfig): string {
782
860
  : `No product knowledge yet: the team can describe the product, roles, rules, and test data in ${cfg.knowledge.dir} (forgeqe init adds templates).`
783
861
  }
784
862
 
785
- export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url" | "browserProvider">): string {
863
+ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url" | "browserProvider" | "devices">): string {
786
864
  const { config: cfg } = ctx
787
865
  const s = cfg.sources
788
866
  const systems = [
@@ -823,6 +901,7 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
823
901
  const browserSkills = skillsFor(loadSkills(cfg.skills, ctx.cwd), "browser")
824
902
  const browserProvider = browser.provider || "local"
825
903
  const remoteProviders = (["browserstack", "testmu"] as const).filter((name) => browser.remotes?.[name])
904
+ const devices = Object.values(browser.devices || {}).map((d) => `${d.name} (${d.provider}: ${d.remote.browser} ${d.remote.browserVersion} on ${d.remote.platform || [d.remote.os, d.remote.osVersion].filter(Boolean).join(" ")})`)
826
905
  const skills = browserSkills.length
827
906
  ? [`Browser skills (pass the ones a goal needs as skills to browser_run or run_test_cases):`, ...catalogLines(browserSkills)].join("\n")
828
907
  : ""
@@ -853,6 +932,10 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
853
932
  keys.length ? `Set in ForgeQE's environment (names only, usable with set_ci_secret): ${keys.join(", ")}.` : "",
854
933
  ctx.url ? `The user pinned this start URL for browser runs: ${ctx.url}` : "",
855
934
  ctx.browserProvider ? `The user pinned this browser provider for runs in this session: ${ctx.browserProvider}.` : "",
935
+ devices.length
936
+ ? `Saved devices (pass names as devices to browser_run or run_test_cases; browser_devices adds, changes, or removes them): ${devices.join("; ")}.`
937
+ : "No devices are saved yet: when the user names devices or browsers to test on, save them with browser_devices.",
938
+ ctx.devices?.length ? `The user pinned these devices for runs in this session: ${ctx.devices.join(", ")}.` : "",
856
939
  skills,
857
940
  domainNotes(cfg),
858
941
  knowledgeNotes(cfg),
@@ -234,7 +234,7 @@ export function formatRound(r: RoundResult, nextAt?: Date, cwd = process.cwd()):
234
234
  if (ok.length) lines.push(` ↑ Alert sent: ${ok.join(", ")}`)
235
235
  for (const s of r.sent.filter((s) => !s.ok)) lines.push(` ✗ ${CHANNEL[s.channel]} alert not sent: ${s.error}`)
236
236
  if (r.suite) {
237
- const total = suiteUsage(r.suite.results)
237
+ const total = suiteUsage(r.suite.results, r.suite.analysis)
238
238
  if (total.usage.input || total.usage.output) lines.push(` Usage ${usageLine(total.usage, total.cost)}`)
239
239
  lines.push(` Report ${path.relative(cwd, r.suite.files.html) || r.suite.files.html}`)
240
240
  }
@@ -3,6 +3,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
3
3
  import path from "node:path"
4
4
 
5
5
  import type { CaseResult, CaseStatus, Cost, WriteBack } from "../cases/types.ts"
6
+ import type { DefectCluster } from "../cases/defects.ts"
7
+ import { DEFECT_HISTORY } from "../cases/defectHistory.ts"
8
+ import { defectTrends, type DefectTrends } from "../cases/predict.ts"
6
9
  import type { ReportEnv } from "./html.ts"
7
10
  import { addCost } from "../usage.ts"
8
11
  import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
@@ -106,6 +109,10 @@ export type ReportData = {
106
109
  cases: ReportCase[]
107
110
  posted: WriteBack[]
108
111
  history: HistoryRun[]
112
+ /** Failures grouped by cause, from the suite; unset for a single run and for reports written before defects. */
113
+ defects?: DefectCluster[]
114
+ /** How the defects of the suites in this folder went over time, when they have a history. */
115
+ defectTrends?: DefectTrends
109
116
  }
110
117
 
111
118
  export type ReportCase = {
@@ -183,6 +190,7 @@ export type DataInput = {
183
190
  results: CaseResult[]
184
191
  posted: WriteBack[]
185
192
  env?: ReportEnv
193
+ defects?: DefectCluster[]
186
194
  }
187
195
 
188
196
  export type DataOptions = {
@@ -243,6 +251,12 @@ export function buildReportData(p: DataInput, opts: DataOptions = {}): ReportDat
243
251
  cases,
244
252
  posted: p.posted.filter((w) => !w.caseId),
245
253
  history: [],
254
+ ...(p.defects ? { defects: p.defects } : {}),
255
+ }
256
+ if (p.kind === "suite" && existsSync(path.join(path.dirname(p.dir), DEFECT_HISTORY))) {
257
+ try {
258
+ data.defectTrends = defectTrends(path.dirname(p.dir))
259
+ } catch {}
246
260
  }
247
261
  if (p.kind === "suite") {
248
262
  data.history = [
@@ -4,7 +4,8 @@ import path from "node:path"
4
4
 
5
5
  import type { RunResult } from "../types.ts"
6
6
  import { costOf, type CaseResult, type WriteBack } from "../cases/types.ts"
7
- import type { AppConfig } from "../config.ts"
7
+ import type { AppConfig, BrowserConfig } from "../config.ts"
8
+ import type { DefectCluster } from "../cases/defects.ts"
8
9
  import { packageVersion } from "../tui/theme.ts"
9
10
  import { buildReportData, type DataOptions } from "./data.ts"
10
11
 
@@ -38,11 +39,21 @@ export function ciInfo(env = process.env): Pick<ReportEnv, "ci" | "commit" | "br
38
39
  return {}
39
40
  }
40
41
 
41
- export function reportEnv(config: AppConfig, opts: { url?: string; headed?: boolean } = {}): ReportEnv {
42
+ export function reportEnv(
43
+ config: AppConfig,
44
+ opts: { url?: string; headed?: boolean; browser?: Pick<BrowserConfig, "provider" | "remote"> & { device?: string } } = {},
45
+ ): ReportEnv {
42
46
  const llm = config.llm
47
+ const remote = opts.browser?.remote
48
+ const where = remote
49
+ ? `${remote.browser} ${remote.browserVersion} on ${remote.platform || [remote.os, remote.osVersion].filter(Boolean).join(" ")} (${opts.browser!.provider}${opts.browser!.device ? `, device ${opts.browser!.device}` : ""})`
50
+ : undefined
43
51
  return {
44
52
  model: `${config.activeProfile} (${llm.provider}/${llm.deployment || llm.model})`,
45
- browser: llm.provider === "mock" ? "synthetic page (mock profile)" : `Chrome, ${opts.headed || !config.browser.headless ? "visible" : "headless"}, ${config.browser.width}×${config.browser.height}`,
53
+ browser:
54
+ llm.provider === "mock"
55
+ ? "synthetic page (mock profile)"
56
+ : where || `Chrome, ${opts.headed || !config.browser.headless ? "visible" : "headless"}, ${config.browser.width}×${config.browser.height}`,
46
57
  startUrl: opts.url || config.browser.startUrl,
47
58
  version: packageVersion(),
48
59
  machine: `${os.hostname()} · ${process.platform}`,
@@ -68,6 +79,7 @@ export type ReportInput = {
68
79
  env?: ReportEnv
69
80
  /** "run" opens on the case itself; "suite" (the default) on the overview. */
70
81
  kind?: "suite" | "run"
82
+ defects?: DefectCluster[]
71
83
  }
72
84
 
73
85
  /** Built from report-ui/ by `bun run build:report`: one HTML file with the whole React app inlined. */