@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/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
|
}
|
package/src/suite/report.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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
|