@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
package/src/run.ts CHANGED
@@ -24,6 +24,8 @@ export type ExecuteOptions = {
24
24
  config: AppConfig
25
25
  goal: string
26
26
  browserProvider?: BrowserProvider
27
+ /** A named device from the provider files (browser_devices); it also picks the provider. */
28
+ browserDevice?: string
27
29
  url?: string
28
30
  headed?: boolean
29
31
  maxSteps?: number
@@ -149,14 +151,14 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
149
151
 
150
152
  const dir = opts.outDir || runDirectory(opts.config.export.outDir)
151
153
  const maxSteps = opts.maxSteps || opts.config.browser.maxSteps
152
- const browser = browserConfigFor(opts.config, opts.browserProvider)
154
+ const browser = browserConfigFor(opts.config, opts.browserProvider, opts.browserDevice)
153
155
  const t0 = Date.now()
154
156
  const withReport = (out: { result: RunResult; files: string[] }) => {
155
157
  out.result.cost = costOf(out.result.usage, opts.config.llm.price)
156
158
  applyQuality(opts.config, dir, out.result, opts.onEvent)
157
159
  logEvent("run.done", { out_dir: dir, status: out.result.status, summary: out.result.summary, usage: out.result.usage, memory: out.result.memory, ...(out.result.skills ? { skills: out.result.skills } : {}) })
158
160
  try {
159
- out.files.push(writeRunReport(dir, out.result, reportEnv(opts.config, { url: startUrl, headed: opts.headed }), t0))
161
+ out.files.push(writeRunReport(dir, out.result, reportEnv(opts.config, { url: startUrl, headed: opts.headed, browser }), t0))
160
162
  } catch (err) {
161
163
  opts.onEvent?.({ type: "log", level: "warn", text: `report.html not written: ${(err as Error).message}` })
162
164
  }
@@ -181,6 +183,11 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
181
183
 
182
184
  const missing = missingKey(opts.config.llm)
183
185
  if (missing) throw new Error(missing)
186
+ // A remote grid's keys are checked here, before the engine starts, rather than failing inside it.
187
+ if (browser.remote) {
188
+ const unset = [browser.remote.usernameEnv, browser.remote.accessKeyEnv].filter((name) => !process.env[name]?.trim())
189
+ if (unset.length) throw new Error(`${browser.provider}: set ${unset.join(" and ")} in the environment, or save them with "forgeqe auth login ${browser.provider}".`)
190
+ }
184
191
  const log = (text: string) => opts.onEvent?.({ type: "log", level: "info", text })
185
192
  const python = await ensureEngine(log)
186
193
  // The browser agent's added instructions: forgeqe.yaml's, prompts/browser-extra.md, and the product knowledge that matches the goal.
@@ -1,6 +1,8 @@
1
1
  import { writeFileSync } from "node:fs"
2
2
  import path from "node:path"
3
3
 
4
+ import { defectLine } from "../cases/defects.ts"
5
+ import { historyText } from "../cases/defectHistory.ts"
4
6
  import { stepVerdictLines, type CaseResult } from "../cases/types.ts"
5
7
  import { renderReport } from "../report/html.ts"
6
8
  import type { Usage } from "../types.ts"
@@ -25,8 +27,9 @@ export function counts(results: CaseResult[]) {
25
27
  export function writeReports(s: Omit<SuiteResult, "files">): SuiteResult["files"] {
26
28
  const files = { junit: path.join(s.dir, "junit.xml"), html: path.join(s.dir, "report.html"), json: path.join(s.dir, "suite.json") }
27
29
  writeFileSync(files.junit, junit(s))
28
- writeFileSync(files.html, renderReport({ title: s.name, startedAt: s.startedAt, ms: s.ms, stopped: s.stopped, dir: s.dir, results: s.results, posted: s.writeBack, env: s.env }))
30
+ // suite.json first: the report's trends read the saved suites, this one included.
29
31
  writeFileSync(files.json, `${JSON.stringify({ ...s, files }, null, 2)}\n`)
32
+ writeFileSync(files.html, renderReport({ title: s.name, startedAt: s.startedAt, ms: s.ms, stopped: s.stopped, dir: s.dir, results: s.results, posted: s.writeBack, env: s.env, defects: s.defects }))
30
33
  return files
31
34
  }
32
35
 
@@ -60,17 +63,16 @@ function junit(s: Omit<SuiteResult, "files">): string {
60
63
  }
61
64
 
62
65
  /** Every case's browser and Scribe tokens together, and their cost (unset when any part has no price). */
63
- export function suiteUsage(results: CaseResult[]): { usage: Usage; cost?: Cost } {
66
+ export function suiteUsage(results: CaseResult[], analysis?: { usage: Usage; cost?: Cost }): { usage: Usage; cost?: Cost } {
64
67
  let usage: Usage = { input: 0, output: 0 }
65
68
  let cost: Cost | undefined
66
69
  let priced = true
67
- for (const r of results) {
68
- for (const [u, c] of [[r.usage, r.cost], [r.codeUsage, r.codeCost]] as const) {
69
- if (!u || (!u.input && !u.output)) continue
70
- usage = addUsage(usage, u)
71
- if (c) cost = addCost(cost, c)
72
- else priced = false
73
- }
70
+ const parts = [...results.flatMap((r) => [[r.usage, r.cost], [r.codeUsage, r.codeCost]] as const), ...(analysis ? [[analysis.usage, analysis.cost] as const] : [])]
71
+ for (const [u, c] of parts) {
72
+ if (!u || (!u.input && !u.output)) continue
73
+ usage = addUsage(usage, u)
74
+ if (c) cost = addCost(cost, c)
75
+ else priced = false
74
76
  }
75
77
  return { usage, cost: priced ? cost : undefined }
76
78
  }
@@ -80,9 +82,11 @@ export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
80
82
  const c = counts(s.results)
81
83
  const tokens = s.results.reduce((n, r) => n + r.tokens, 0)
82
84
  const head = `${s.stopped ? "■ Suite stopped" : c.passed === c.total ? "✓ Suite passed" : "✗ Suite failed"} · ${c.passed}/${c.total} passed · ${duration(s.ms)} · ${(tokens / 1000).toFixed(1)}K tokens`
83
- const total = suiteUsage(s.results)
85
+ const show = (f: string) => path.relative(cwd, f) || f
86
+ const total = suiteUsage(s.results, s.analysis)
84
87
  const lines = [head, ""]
85
88
  if (total.usage.input || total.usage.output) lines.push(`Usage ${usageLine(total.usage, total.cost)}`, "")
89
+ if (s.analysis) lines.push(`Analysis ${s.analysis.defects} root-cause ${s.analysis.defects === 1 ? "analysis" : "analyses"}: ${usageLine(s.analysis.usage, s.analysis.cost)} (in the usage above)`, "")
86
90
  for (const r of s.results) {
87
91
  const code = r.code ? ` test ${r.code.status}${r.code.testFile ? `: ${r.code.testFile}` : ""}` : ""
88
92
  lines.push(`${MARK[r.status]} ${r.case.id} ${r.case.title} (${duration(r.ms)})${code}`)
@@ -90,11 +94,20 @@ export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
90
94
  const failedStep = r.stepResults?.find((v) => v.status === "failed")
91
95
  if (r.status !== "passed" && failedStep) lines.push(` step ${failedStep.step} failed${failedStep.actual ? `: ${firstLine(failedStep.actual)}` : ""}`)
92
96
  }
97
+ const defects = s.defects || []
98
+ if (worthListing(defects)) {
99
+ lines.push("", `Defects ${defectLine(defects)}`)
100
+ for (const d of defects) {
101
+ lines.push(` ${String(d.cases.length).padStart(2)}× ${d.title}${d.history ? ` (${historyText(d.history)})` : ""}${d.bug ? ` ${d.bug.label}` : ""}`)
102
+ if (d.rca?.hypothesis) lines.push(` likely (${d.rca.confidence}): ${firstLine(d.rca.hypothesis)}`)
103
+ else if (d.rca) lines.push(` likely layer: ${d.rca.layer}${d.rca.suspects?.length ? `; ${d.rca.suspects.length} suspect commit${d.rca.suspects.length === 1 ? "" : "s"}` : ""}`)
104
+ if (d.repro?.file) lines.push(` repro: ${show(path.join(s.dir, d.repro.file))} (${d.repro.reproducibility})`)
105
+ }
106
+ }
93
107
  if (s.writeBack.length) {
94
108
  lines.push("")
95
109
  for (const w of s.writeBack) lines.push(`${w.error ? "✗" : "↑"} ${w.text}${w.url ? ` ${w.url}` : ""}${w.error ? `: ${w.error}` : ""}`)
96
110
  }
97
- const show = (f: string) => path.relative(cwd, f) || f
98
111
  lines.push("", `Report ${show(s.files.html)}`, `JUnit ${show(s.files.junit)}`)
99
112
  return lines.join("\n")
100
113
  }
@@ -112,17 +125,37 @@ export function markdownSummary(s: SuiteResult): string {
112
125
  return `| ${MARK[r.status]} | ${cell(r.case.id)} | ${cell(r.case.title)} | ${steps} | ${duration(r.ms)} | ${result} | ${test} |`
113
126
  })
114
127
  const posted = s.writeBack.filter((w) => !w.caseId || w.error).map((w) => `- ${w.error ? "✗" : "↑"} ${w.text}${w.url ? ` ${w.url}` : ""}${w.error ? `: ${w.error}` : ""}`)
128
+ const defects = s.defects || []
129
+ const defectRows = worthListing(defects)
130
+ ? [
131
+ "",
132
+ `#### Defects: ${defectLine(defects)}`,
133
+ "",
134
+ "| Cases | Defect | Kind | History | Reproducible | Likely cause | Bug |",
135
+ "|---|---|---|---|---|---|---|",
136
+ ...defects.map(
137
+ (d) =>
138
+ `| ${d.cases.length} | ${cell(d.title)} | ${cell(d.failureLabel)} | ${cell(historyText(d.history))} | ${d.repro?.reproducibility || ""} | ${cell(d.rca ? (d.rca.hypothesis ? `${d.rca.hypothesis} (${d.rca.confidence})` : d.rca.layer) : "")} | ${d.bug ? (d.bug.url ? `[${cell(d.bug.label)}](${d.bug.url})` : cell(d.bug.label)) : ""} |`,
139
+ ),
140
+ ]
141
+ : []
115
142
  return [
116
143
  `### ${head} · ${c.passed}/${c.total} passed · ${duration(s.ms)}`,
117
144
  "",
118
145
  "| | Case | Title | Steps | Time | Result | Test |",
119
146
  "|---|---|---|---|---|---|---|",
120
147
  ...rows,
148
+ ...defectRows,
121
149
  ...(posted.length ? ["", ...posted] : []),
122
150
  "",
123
151
  ].join("\n")
124
152
  }
125
153
 
154
+ /** Defects are listed when they add to the table of cases: cases share one, or one has been seen before. */
155
+ function worthListing(defects: NonNullable<SuiteResult["defects"]>): boolean {
156
+ return defects.some((d) => d.cases.length > 1 || (d.history && d.history.status !== "new") || d.history?.intermittent)
157
+ }
158
+
126
159
  function firstLine(s: string): string {
127
160
  return s.split("\n")[0].slice(0, 200)
128
161
  }
package/src/suite/run.ts CHANGED
@@ -2,12 +2,18 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
2
  import path from "node:path"
3
3
 
4
4
  import { goalFor, writeBack as postResults, type CaseResult, type TestCase, type WriteBack } from "../cases/index.ts"
5
- import { costOf } from "../cases/types.ts"
5
+ import { costOf, type Cost } from "../cases/types.ts"
6
6
  import { addUsage, NO_USAGE } from "../usage.ts"
7
- import type { AgentEvent, CodeResult, ReplayMode, RunResult } from "../types.ts"
7
+ import type { AgentEvent, CodeResult, ReplayMode, RunResult, Usage } from "../types.ts"
8
8
  import { codeSkipReason, runCodeAgent } from "../code/index.ts"
9
9
  import type { AppConfig, BrowserProvider } from "../config.ts"
10
10
  import { fileBugs } from "../cases/bugs.ts"
11
+ import { DefectBook, defectClusters, type DefectCluster } from "../cases/defects.ts"
12
+ import { analyzeCause, basicCause } from "../cases/rca.ts"
13
+ import { scorePrediction } from "../cases/predict.ts"
14
+ import { reproPack, writeReproFiles } from "../cases/repro.ts"
15
+ import { commentAnalyses } from "../cases/bugs.ts"
16
+ import { caseOutcomes, defectHistoryFile, failedInARow, noteHistory, readDefectHistory, recordRun, writeDefectHistory } from "../cases/defectHistory.ts"
11
17
  import { classifyFailure, FAILURE_LABEL } from "../cases/failure.ts"
12
18
  import { associateAdoAutomation, readMappings } from "../code/traceability.ts"
13
19
  import type { ExistingTest } from "../code/prompt.ts"
@@ -36,6 +42,10 @@ export type SuiteResult = {
36
42
  files: { junit: string; html: string; json: string }
37
43
  stopped: boolean
38
44
  env?: ReportEnv
45
+ /** Failures grouped by cause: one entry per defect with every case it breaks (src/cases/defects.ts). */
46
+ defects?: DefectCluster[]
47
+ /** The model's root-cause analyses in this suite: how many, and what they used (src/cases/rca.ts). */
48
+ analysis?: { defects: number; usage: Usage; cost?: Cost }
39
49
  }
40
50
 
41
51
  export type SuiteOptions = {
@@ -44,6 +54,8 @@ export type SuiteOptions = {
44
54
  /** Repo for the coding agent; unset: browser runs only. */
45
55
  repo?: string
46
56
  browserProvider?: BrowserProvider
57
+ /** Named devices: every case runs once on each, and each case posts one combined result back to its source. */
58
+ devices?: string[]
47
59
  parallel?: number
48
60
  outDir?: string
49
61
  writeBack?: boolean
@@ -66,6 +78,21 @@ export type SuiteOptions = {
66
78
 
67
79
  const STATUS = { complete: "passed", fail: "failed", stopped: "stopped" } as const
68
80
 
81
+ /** A case as run on one device: the device in its id and title keeps reports, history, and the shell apart. */
82
+ export function deviceCase(tc: TestCase, device: string): TestCase {
83
+ return { ...tc, id: `${tc.id}@${device}`, title: `${tc.title} · ${device}` }
84
+ }
85
+
86
+ /** The cases a suite shows: each case once per device, in the order they run. */
87
+ export function deviceCases(cases: TestCase[], devices: string[]): TestCase[] {
88
+ return devices.length ? cases.flatMap((tc) => devices.map((d) => deviceCase(tc, d))) : cases
89
+ }
90
+
91
+ /** Browsers at once: devices run side by side (up to 8) unless more is asked; remote grids queue past their plan's limit. */
92
+ export function suiteParallel(asked: number | undefined, configured: number, devices: number, jobs: number): number {
93
+ return Math.max(1, Math.min(asked || Math.max(configured, Math.min(devices, 8)), jobs))
94
+ }
95
+
69
96
  function slug(s: string): string {
70
97
  return s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "case"
71
98
  }
@@ -86,33 +113,53 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
86
113
  const t0 = Date.now()
87
114
  const startedAt = new Date(t0).toISOString()
88
115
  const name = `ForgeQE suite ${startedAt.slice(0, 16).replace("T", " ")}`
89
- const total = cases.length
116
+ // One job per case, or per case and device: a device's result carries the device in its case id and title, so
117
+ // reports, history, and the shell keep each device apart.
118
+ const devices = [...new Set(opts.devices || [])]
119
+ const jobs = devices.length
120
+ ? cases.flatMap((tc) => devices.map((device) => ({ tc, device, shown: deviceCase(tc, device) })))
121
+ : cases.map((tc) => ({ tc, device: undefined as string | undefined, shown: tc }))
122
+ const multi = devices.length > 1
123
+ const total = jobs.length
90
124
  const results: (CaseResult | undefined)[] = Array.from({ length: total }, () => undefined)
91
125
  const width = String(total).length
92
126
  let codeQueue: Promise<void> = Promise.resolve()
93
127
  /** Written back during the suite: bugs for functional defects (the suite's own bug pass skips those cases) and tests set as a test case's automation. */
94
128
  const earlyBugs: WriteBack[] = []
129
+ /** Which failures share a cause, and the bug each defect has in this run. */
130
+ const book = new DefectBook()
131
+ const historyFile = defectHistoryFile(dir)
132
+ const history = readDefectHistory(historyFile)
133
+ book.past = history.defects
134
+ const env = reportEnv(config, { url: opts.url, headed: opts.headed })
135
+ book.env = env
136
+ const earlier = caseOutcomes(dir)
137
+ const failedBefore = (id: string) => failedInARow(earlier.get(id))
138
+ book.earlier = failedBefore
139
+ // A config built in code without defects: (tests, other tools) neither reruns cases nor calls a model for them.
140
+ const defectsCfg = config.defects ?? { reproduce: false, analyze: false, maxAnalyses: 0 }
95
141
 
96
142
  const runOne = async (index: number) => {
97
- const tc = cases[index]
143
+ const { tc: original, device, shown: tc } = jobs[index]
98
144
  const runDir = path.join(dir, `${String(index + 1).padStart(width, "0")}-${slug(tc.id)}`)
99
145
  mkdirSync(runDir, { recursive: true })
100
146
  writeFileSync(path.join(runDir, "testcase.json"), `${JSON.stringify(tc, null, 2)}\n`)
101
147
  emit({ type: "case-start", index, total, case: tc, runDir })
102
148
  const start = Date.now()
103
- const explore = async (): Promise<{ run?: RunResult; error?: string }> => {
149
+ const explore = async (outDir = runDir): Promise<{ run?: RunResult; error?: string }> => {
104
150
  try {
105
151
  const { result } = await execute({
106
152
  config,
107
- goal: goalFor(tc),
153
+ goal: goalFor(original),
108
154
  browserProvider: opts.browserProvider,
155
+ browserDevice: device,
109
156
  url: tc.startUrl || opts.url,
110
157
  headed: opts.headed,
111
158
  maxSteps: opts.maxSteps,
112
159
  replay: opts.replay,
113
160
  memory: opts.memory,
114
161
  caseSteps: tc.steps.length,
115
- outDir: runDir,
162
+ outDir,
116
163
  skills: opts.skills,
117
164
  repo: opts.skillsRepo || opts.repo,
118
165
  signal: opts.signal,
@@ -138,6 +185,7 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
138
185
  const end = Date.now()
139
186
  const result: CaseResult = {
140
187
  case: tc,
188
+ ...(device ? { device } : {}),
141
189
  // Steps that passed on a page over an accessibility or performance budget: the case fails on the budget.
142
190
  status: run ? (run.status === "complete" && run.budgetExceeded?.length ? "failed" : STATUS[run.status]) : opts.signal?.aborted ? "stopped" : "error",
143
191
  summary: [run?.summary || error || "", ...(run?.budgetExceeded?.length ? [`Budget exceeded: ${run.budgetExceeded.join("; ")}`] : [])].filter(Boolean).join("\n\n"),
@@ -153,19 +201,45 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
153
201
  ...(failure ? { failure } : {}),
154
202
  ...(retried ? { retried } : {}),
155
203
  }
204
+ if (result.status === "passed") book.notePassed(result)
205
+ const defect = book.assign(result)
206
+ if (defect) result.defect = defect
207
+ // The first case of a new product defect runs once more: a defect that repeats is one a developer can reproduce.
208
+ // A known defect needs no second run (its history shows it repeating), nor does a problem of the environment.
209
+ // A case that failed in the suite before has shown it repeats already.
210
+ if (defect && defectsCfg.reproduce && failure?.type === "functional_defect" && !book.past[defect.id] && !failedBefore(tc.id) && !book.reproduced.has(defect.id) && !opts.signal?.aborted) {
211
+ book.reproduced.add(defect.id)
212
+ const again = path.join(runDir, "reproduce")
213
+ mkdirSync(again, { recursive: true })
214
+ emit({ type: "case-event", index, case: tc, event: { type: "log", level: "info", text: `New defect (${defect.title}): running ${tc.id} once more to see whether it repeats.` } })
215
+ const second = await explore(again)
216
+ if (second.run?.usage) {
217
+ spent = addUsage(spent || NO_USAGE, second.run.usage)
218
+ result.usage = spent
219
+ result.tokens = spent.input + spent.output
220
+ result.cost = costOf(spent, config.llm.price)
221
+ }
222
+ const status: CaseResult["status"] = second.run ? STATUS[second.run.status] : "error"
223
+ const twin: CaseResult = { ...result, status, summary: second.run?.summary || second.error || "", runDir: again, finalUrl: second.run?.finalUrl || undefined, failure: classifyFailure(second.run, second.error) }
224
+ const same = book.peek(twin)?.id === defect.id
225
+ result.reproduced = { runs: 2, failed: same ? 2 : 1, runDir: again }
226
+ emit({ type: "case-event", index, case: tc, event: { type: "log", level: same ? "info" : "warn", text: same ? "It failed the same way again: reproducible." : `The second run ${status === "passed" ? "passed" : "failed differently"}: the defect did not repeat.` } })
227
+ }
156
228
  results[index] = result
157
229
  emit({ type: "case-done", index, total, result })
158
230
 
159
- // A functional defect's bug is filed now, so the test written for it can name the bug it waits on.
160
- if (failure?.type === "functional_defect" && (opts.writeBack ?? config.sources.writeBack) && config.sources.bugs) {
161
- for (const item of await fileBugs([result], config.sources, name)) {
231
+ // A functional defect's bug is filed now, so the test written for it can name the bug it waits on. On several
232
+ // devices the bug waits for the combined result, so a defect seen on two devices is one bug.
233
+ if (!multi && failure?.type === "functional_defect" && (opts.writeBack ?? config.sources.writeBack) && config.sources.bugs) {
234
+ for (const item of await fileBugs([result], config.sources, name, book)) {
162
235
  earlyBugs.push(item)
163
236
  emit({ type: "writeback", item })
164
237
  if (!item.error) result.bug = { label: item.text.split(":")[0], ...(item.url ? { url: item.url } : {}) }
165
238
  }
166
239
  }
167
240
 
168
- if (!opts.repo || !config.suite.code || opts.signal?.aborted) return
241
+ // One test per case: written from its run on the first device.
242
+ if (!opts.repo || !config.suite.code || opts.signal?.aborted || (device && device !== devices[0])) return
169
243
  const skip = run ? codeSkipReason(config, run, failure) : "the browser run did not finish"
170
244
  if (skip) {
171
245
  result.codeSkipped = skip
@@ -173,7 +247,7 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
173
247
  }
174
248
  // This case goes from its browser run straight to the coding agent, and its worker waits for that before taking
175
249
  // the next case. Coding jobs still queue one after another, since they all edit the same repository.
176
- const job = codeQueue.then(() => codeOne(index, tc, runDir, result))
250
+ const job = codeQueue.then(() => codeOne(index, original, runDir, result))
177
251
  codeQueue = job.catch(() => {})
178
252
  await job
179
253
  }
@@ -263,20 +337,68 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
263
337
  const finished = results.map((r, i): CaseResult => {
264
338
  if (r) return r
265
339
  const now = new Date().toISOString()
266
- return { case: cases[i], status: "stopped", summary: "Not run: the suite was stopped.", startedAt: now, finishedAt: now, ms: 0, tokens: 0 }
340
+ return { case: jobs[i].shown, ...(jobs[i].device ? { device: jobs[i].device } : {}), status: "stopped", summary: "Not run: the suite was stopped.", startedAt: now, finishedAt: now, ms: 0, tokens: 0 }
267
341
  })
268
342
 
269
343
  const posted: WriteBack[] = []
270
344
  if (opts.writeBack ?? config.sources.writeBack) {
271
- const ran = finished.filter((r) => r.runDir)
345
+ const ran = devices.length ? combineDevices(cases, jobs, finished).filter((r) => r.runDir) : finished.filter((r) => r.runDir)
272
346
  const filed = new Set(earlyBugs.filter((b) => b.source === "bugs").map((b) => b.caseId))
273
347
  posted.push(...earlyBugs)
274
- for (const item of [...(await postResults(ran, config.sources, name)), ...(await fileBugs(ran.filter((r) => !filed.has(r.case.id)), config.sources, name))]) {
348
+ for (const item of [...(await postResults(ran, config.sources, name)), ...(await fileBugs(ran.filter((r) => !filed.has(r.case.id)), config.sources, name, book))]) {
349
+ posted.push(item)
350
+ emit({ type: "writeback", item })
351
+ }
352
+ }
353
+
354
+ // What earlier runs and the tracker know about each defect, then this run added to the history.
355
+ const defects = defectClusters(finished, posted)
356
+ for (const d of defects) {
357
+ const related = book.related.get(d.id)
358
+ if (related) d.related = related
359
+ }
360
+ noteHistory(defects, history, earlier, book.closed)
361
+
362
+ // How to make each defect happen again, and why it may happen: the layer and suspect commits for every defect, a
363
+ // model's hypothesis for the new ones and the regressions (the product's defects only, a few per suite).
364
+ const resultOf = (id: string) => finished.find((r) => r.case.id === id)
365
+ const productRepo = defectsCfg.productRepo
366
+ let analyses = 0
367
+ let analysisUsage: Usage = { ...NO_USAGE }
368
+ for (const d of defects) {
369
+ const first = resultOf(d.cases[0].id)
370
+ if (!first) continue
371
+ d.repro = reproPack(first, d, { env, seen: d.history?.status === "known" ? d.history.seen : 0, intermittent: d.history?.intermittent, suiteDir: dir })
372
+ d.rca = basicCause(d, { suiteDir: dir, productRepo })
373
+ const worth = d.failure === "functional_defect" && (d.history?.status === "new" || d.history?.status === "regression")
374
+ if (!defectsCfg.analyze || !worth || analyses >= defectsCfg.maxAnalyses || opts.signal?.aborted) continue
375
+ analyses++
376
+ const index = finished.indexOf(first)
377
+ emit({ type: "case-event", index, case: first.case, event: { type: "log", level: "info", text: `Root-cause analysis of ${d.title}${productRepo ? ` (reading ${productRepo})` : " (evidence only: set defects.product_repo to let it read the code)"}` } })
378
+ d.rca = await analyzeCause(config, d, first, d.rca, { cwd: opts.repo || process.cwd(), productRepo, signal: opts.signal, onEvent: (event) => emit({ type: "case-event", index, case: first.case, event }) })
379
+ if (d.rca.usage) analysisUsage = addUsage(analysisUsage, d.rca.usage)
380
+ }
381
+ try {
382
+ writeReproFiles(dir, defects)
383
+ } catch {}
384
+ if ((opts.writeBack ?? config.sources.writeBack) && analyses) {
385
+ for (const item of await commentAnalyses(config.sources, book, defects)) {
275
386
  posted.push(item)
276
387
  emit({ type: "writeback", item })
277
388
  }
278
389
  }
279
390
 
391
+ if (!opts.signal?.aborted) {
392
+ // The newest defect prediction made before this suite: did its defects fall where it said?
393
+ try {
394
+ scorePrediction(path.dirname(dir), path.basename(dir), startedAt, defects)
395
+ } catch {}
396
+ recordRun(history, defects, finished, path.basename(dir), startedAt)
397
+ try {
398
+ writeDefectHistory(historyFile, history)
399
+ } catch {}
400
+ }
401
+
280
402
  const finishedAt = new Date().toISOString()
281
403
  const suite: Omit<SuiteResult, "files"> = {
282
404
  name,
@@ -287,11 +409,36 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
287
409
  results: finished,
288
410
  writeBack: posted,
289
411
  stopped: Boolean(opts.signal?.aborted),
290
- env: reportEnv(config, { url: opts.url, headed: opts.headed }),
412
+ env,
413
+ defects,
414
+ ...(analyses ? { analysis: { defects: analyses, usage: analysisUsage, ...(costOf(analysisUsage, config.llm.price) ? { cost: costOf(analysisUsage, config.llm.price) } : {}) } } : {}),
291
415
  }
292
416
  return { ...suite, files: writeReports(suite) }
293
417
  }
294
418
 
419
+ const WORST: CaseResult["status"][] = ["failed", "error", "stopped", "passed"]
420
+
421
+ /**
422
+ * One result per case from its device results, for posting back: the worst status wins (a pass on one device never
423
+ * hides a failure on another), the summary lists every device, and the worst run's evidence (folder, failure, steps,
424
+ * bug) goes with it.
425
+ */
426
+ export function combineDevices(cases: TestCase[], jobs: { tc: TestCase; device?: string }[], results: CaseResult[]): CaseResult[] {
427
+ return cases.map((tc) => {
428
+ const own = results.filter((_, i) => jobs[i].tc === tc)
429
+ const worst = [...own].sort((a, b) => WORST.indexOf(a.status) - WORST.indexOf(b.status))[0]
430
+ const lines = own.map((r) => `${r.device}: ${r.status}${r.summary ? `: ${(r.summary.split("\n")[0] || "").slice(0, 200)}` : ""}`)
431
+ return {
432
+ ...worst,
433
+ case: tc,
434
+ device: undefined,
435
+ summary: [`Ran on ${own.length} device${own.length === 1 ? "" : "s"}:`, ...lines.map((l) => `- ${l}`)].join("\n"),
436
+ ms: own.reduce((n, r) => n + r.ms, 0),
437
+ tokens: own.reduce((n, r) => n + r.tokens, 0),
438
+ }
439
+ })
440
+ }
441
+
295
442
  function timelineLines(runDir: string): CaseResult["steps"] | undefined {
296
443
  const steps = readTimeline(runDir).flatMap((t) => t.actions.filter((a) => !a.done).map((a) => ({ text: a.error ? `${a.text}: ${a.error}` : a.text, ok: a.ok })))
297
444
  return steps.length ? steps : undefined
package/src/tui/App.tsx CHANGED
@@ -14,7 +14,7 @@ import { stepVerdictLines } from "../cases/types.ts"
14
14
  import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
15
15
  import { authStatus, openInBrowser, signIn, signOut, type AuthIO } from "../auth/signin.ts"
16
16
  import { HELP, parseCommand } from "../commands.ts"
17
- import { loadConfig, selectProfile, type AppConfig } from "../config.ts"
17
+ import { loadConfig, selectProfile, type AppConfig, type BrowserProvider } from "../config.ts"
18
18
  import { TOOLS, type AskQuestion, type ToolEvent } from "../mcp/tools.ts"
19
19
  import type { executeRun } from "../run.ts"
20
20
  import { bundleLogs } from "../sessionLog.ts"
@@ -89,6 +89,10 @@ export type TuiOptions = {
89
89
  /** First message, sent as if typed. */
90
90
  goal?: string
91
91
  headed?: boolean
92
+ /** Browser provider pinned for the session's runs (--browser-provider). */
93
+ browserProvider?: BrowserProvider
94
+ /** Named devices pinned for the session's runs (--devices). */
95
+ devices?: string[]
92
96
  replay?: ReplayMode
93
97
  memory?: boolean
94
98
  /** Repo the agent works in and writes tests for; null: no tests unless asked; undefined: code.repo. */
@@ -1014,6 +1018,8 @@ export function App({ options }: { options: TuiOptions }) {
1014
1018
 
1015
1019
  const toolContext = (agent: boolean, signal?: AbortSignal) => ({
1016
1020
  headed: headedRef.current,
1021
+ browserProvider: options.browserProvider,
1022
+ devices: options.devices,
1017
1023
  maxSteps: stepsRef.current,
1018
1024
  replay: options.replay,
1019
1025
  memory: options.memory,
@@ -1541,7 +1547,7 @@ export function App({ options }: { options: TuiOptions }) {
1541
1547
  let signIns: Record<string, string> = {}
1542
1548
  try {
1543
1549
  // "Azure DevOps (PAT)": how each system's server signs in, as the session panel says it.
1544
- signIns = Object.fromEntries(externalMcp(cfgHome).servers.filter((x) => x.signIn).map((x) => [x.label.replace(/ MCP$/, ""), x.signIn!.startsWith("PAT") ? "PAT" : "az login"]))
1550
+ signIns = Object.fromEntries(externalMcp(cfgHome, Bun.which, process.env, { cwd: process.cwd() }).servers.filter((x) => x.signIn).map((x) => [x.label.replace(/ MCP$/, ""), x.signIn!.startsWith("PAT") ? "PAT" : x.signIn!.startsWith("Azure") ? "az login" : x.signIn!]))
1545
1551
  } catch {}
1546
1552
  setupRef.current = {
1547
1553
  key: setupKey,
@@ -1685,7 +1691,7 @@ export function App({ options }: { options: TuiOptions }) {
1685
1691
  let mcp: string[] = []
1686
1692
  try {
1687
1693
  // "Azure DevOps (az login)": which account a server works as decides what it may see, so the panel says it.
1688
- mcp = externalMcp(cfgNow).servers.map((x) => (x.signIn ? `${x.label.replace(/ MCP$/, "")} (${x.signIn.startsWith("PAT") ? "PAT" : "az login"})` : x.label))
1694
+ mcp = externalMcp(cfgNow, Bun.which, process.env, { cwd: process.cwd() }).servers.map((x) => (x.signIn ? `${x.label.replace(/ MCP$/, "")} (${x.signIn.startsWith("PAT") ? "PAT" : x.signIn.startsWith("Azure") ? "az login" : x.signIn})` : x.label))
1689
1695
  } catch {}
1690
1696
  contextInfo.current = { key: contextKey, knowledge: readKnowledge(cfgNow.knowledge?.dir).length, mcp }
1691
1697
  }
package/src/usage.ts CHANGED
@@ -98,7 +98,7 @@ export function usageLine(usage: Usage, cost?: Cost | null): string {
98
98
  // The meter: every model session reports here, by role
99
99
  // ---------------------------------------------------------------------------------------------
100
100
 
101
- export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge" | "refiner"
101
+ export type Role = "ForgeQE's agent" | "browser agent" | "Scribe" | "reviewer" | "judge" | "refiner" | "root-cause analyst"
102
102
 
103
103
  type Entry = { usage: Usage; cost?: Cost; priced: boolean }
104
104
 
@@ -1,6 +1,6 @@
1
1
  provider: testmu
2
2
  remote:
3
- browser: pw-chromium
3
+ browser: Chrome
4
4
  browser_version: latest
5
5
  platform: Windows 11
6
6
  username_env: LT_USERNAME
@@ -9,6 +9,16 @@ remote:
9
9
  build: nightly
10
10
  name: checkout smoke
11
11
  local: false
12
- extra:
13
- "lt:options":
14
- console: true
12
+ extra: # merged into LT:Options, so keys go straight here
13
+ console: true
14
+
15
+ # Named devices: each is the remote settings above with its own on top. Run on them with --devices win11-chrome,mac-chrome-128
16
+ # (or ask ForgeQE's agent: it adds, changes, and removes devices here). Chrome only: TestMu AI's CDP endpoint serves Chrome.
17
+ # devices:
18
+ # win11-chrome:
19
+ # browser: Chrome
20
+ # platform: Windows 11
21
+ # mac-chrome-128:
22
+ # browser: Chrome
23
+ # browser_version: "128"
24
+ # platform: macOS Sequoia
package/src/cache.ts DELETED
@@ -1,92 +0,0 @@
1
- import { createHash } from "node:crypto"
2
-
3
- import { createClient } from "redis"
4
-
5
- import type { CacheConfig } from "./config.ts"
6
-
7
- type CacheAdapter = {
8
- get(key: string): Promise<string | null>
9
- set(key: string, value: string, ttlSeconds: number): Promise<void>
10
- }
11
-
12
- let factoryOverride: ((config: CacheConfig) => Promise<CacheAdapter | null>) | undefined
13
- const adapters = new Map<string, Promise<CacheAdapter | null>>()
14
-
15
- function keyFor(config: CacheConfig): string {
16
- return `${config.redisUrl}\n${config.keyPrefix}\n${config.connectTimeoutMs}`
17
- }
18
-
19
- function fullKey(config: CacheConfig, namespace: string, parts: unknown[]): string {
20
- const digest = createHash("sha256").update(JSON.stringify(parts)).digest("hex")
21
- return `${config.keyPrefix}:${namespace}:${digest}`
22
- }
23
-
24
- async function connectRedis(config: CacheConfig): Promise<CacheAdapter | null> {
25
- const client = createClient({
26
- url: config.redisUrl,
27
- socket: { connectTimeout: config.connectTimeoutMs },
28
- })
29
- client.on("error", () => {})
30
- try {
31
- await client.connect()
32
- } catch {
33
- try {
34
- client.disconnect()
35
- } catch {}
36
- return null
37
- }
38
- return {
39
- async get(key) {
40
- try {
41
- return await client.get(key)
42
- } catch {
43
- return null
44
- }
45
- },
46
- async set(key, value, ttlSeconds) {
47
- try {
48
- await client.set(key, value, { EX: ttlSeconds })
49
- } catch {}
50
- },
51
- }
52
- }
53
-
54
- async function adapter(config: CacheConfig | undefined): Promise<CacheAdapter | null> {
55
- if (!config?.enabled || !config.redisUrl) return null
56
- const id = keyFor(config)
57
- if (!adapters.has(id)) {
58
- adapters.set(id, (factoryOverride || connectRedis)(config))
59
- }
60
- return adapters.get(id)!
61
- }
62
-
63
- export async function cachedJson<T>(
64
- config: CacheConfig | undefined,
65
- opts: {
66
- namespace: string
67
- keyParts: unknown[]
68
- ttlSeconds?: number
69
- load: () => Promise<T>
70
- },
71
- ): Promise<T> {
72
- const ttlSeconds = opts.ttlSeconds || config?.defaultTtlSeconds || 3600
73
- const store = await adapter(config)
74
- if (!store) return opts.load()
75
- const key = fullKey(config!, opts.namespace, opts.keyParts)
76
- const hit = await store.get(key)
77
- if (hit !== null) {
78
- try {
79
- return JSON.parse(hit) as T
80
- } catch {
81
- // Bad JSON should not break the current run; reload and overwrite it.
82
- }
83
- }
84
- const value = await opts.load()
85
- await store.set(key, JSON.stringify(value), ttlSeconds)
86
- return value
87
- }
88
-
89
- export function setCacheFactoryForTests(factory?: (config: CacheConfig) => Promise<CacheAdapter | null>): void {
90
- factoryOverride = factory
91
- adapters.clear()
92
- }