@capagents/forgeqe 1.2.1 → 1.3.0
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 +39 -2
- package/README.md +1 -1
- package/docs/COST_ARCHITECTURE.md +280 -0
- package/docs/feature-test-prompts.md +95 -0
- package/docs/user-guide.html +53 -6
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +35 -4
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +59 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +155 -3
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/readiness.ts +35 -6
- package/src/cases/save.ts +241 -48
- package/src/cases/types.ts +11 -0
- package/src/cli/help.ts +8 -0
- package/src/cli/index.ts +64 -1
- package/src/code/coverage.ts +22 -3
- package/src/code/decisions.ts +73 -0
- package/src/code/find.ts +3 -1
- package/src/code/graph/graph.ts +33 -2
- package/src/code/loop.ts +17 -1
- package/src/code/notes.ts +48 -1
- package/src/code/prompt.ts +31 -9
- package/src/code/protect.ts +103 -0
- package/src/code/review.ts +2 -0
- package/src/code/runtime.ts +35 -3
- package/src/code/shortlist.ts +309 -0
- package/src/code/testrun.ts +3 -1
- package/src/code/traceability.ts +2 -0
- package/src/commands.ts +6 -1
- package/src/config.ts +46 -0
- package/src/engine/bridge.ts +47 -5
- package/src/mcp/casetools.ts +107 -11
- package/src/mcp/commands.ts +74 -20
- package/src/mcp/packtools.ts +187 -0
- package/src/mcp/tools.ts +238 -29
- package/src/pack/collect.ts +251 -0
- package/src/pack/index.ts +134 -0
- package/src/pack/init.ts +45 -0
- package/src/pack/pack.ts +327 -0
- package/src/pack/pipeline.ts +373 -0
- package/src/pack/run.ts +155 -0
- package/src/registry/db.ts +249 -0
- package/src/registry/index.ts +72 -0
- package/src/registry/resolve.ts +57 -0
- package/src/registry/tier0.ts +81 -0
- package/src/report/data.ts +7 -0
- package/src/report/html.ts +14 -0
- package/src/report/template.html +52 -48
- package/src/report/usage.ts +113 -0
- package/src/run.ts +22 -2
- package/src/suite/run.ts +12 -5
- package/src/summary.ts +1 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +89 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/types.ts +9 -2
package/src/mcp/casetools.ts
CHANGED
|
@@ -6,7 +6,8 @@ import type { TestCase } from "../cases/types.ts"
|
|
|
6
6
|
import type { AppConfig } from "../config.ts"
|
|
7
7
|
import { readPages } from "../memory.ts"
|
|
8
8
|
import { DEFAULT_THRESHOLDS, releaseReadiness, saveReadiness, type Readiness } from "../cases/readiness.ts"
|
|
9
|
-
import { updateAdoCase } from "../cases/save.ts"
|
|
9
|
+
import { jiraCase, storyCases, updateAdoCase, updateJiraCase, type NewCase } from "../cases/save.ts"
|
|
10
|
+
import { planJourneys, type JourneyCase } from "../cases/journey.ts"
|
|
10
11
|
import { writeAdoWikiPage } from "../cases/wiki.ts"
|
|
11
12
|
import type { McpTool, ToolContext } from "./tools.ts"
|
|
12
13
|
import { notifiedLine, notify, readinessNotice } from "../notify/events.ts"
|
|
@@ -106,11 +107,11 @@ export const CASE_TOOLS: McpTool[] = [
|
|
|
106
107
|
{
|
|
107
108
|
name: "update_test_case",
|
|
108
109
|
description:
|
|
109
|
-
"Rewrite
|
|
110
|
+
"Rewrite a test case's steps (and optionally its title) in place after the user agrees: an Azure DevOps Test Case (the reason goes into its history, the old steps stay there) or a Jira test issue (the steps in its description are replaced, the text above them kept, and the reason added as a comment). ForgeQE shows the current and the new steps and asks (Update / Don't update). Use it for a case run_test_cases classified as test case needs updating (write the steps from what the run found the application does: run_details has every step and element), a case check_test_cases found unclear, a change the user asks for after reviewing a case, and a case save_test_cases returned as differs or similar. The case's automated test, when it has one, is brought up to date on its next run. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed to these exact steps.",
|
|
110
111
|
inputSchema: {
|
|
111
112
|
type: "object",
|
|
112
113
|
properties: {
|
|
113
|
-
id: { type: "string", description: "The test case: ADO-2236, 2236, or its link" },
|
|
114
|
+
id: { type: "string", description: "The test case: ADO-2236, 2236, or its link in Azure DevOps; SHOP-31 in Jira" },
|
|
114
115
|
title: { type: "string", description: "A new title, only when the old one no longer fits" },
|
|
115
116
|
steps: {
|
|
116
117
|
type: "array",
|
|
@@ -123,17 +124,23 @@ export const CASE_TOOLS: McpTool[] = [
|
|
|
123
124
|
required: ["id", "steps", "reason"],
|
|
124
125
|
},
|
|
125
126
|
async run(args, ctx) {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
const raw = String(args.id).trim()
|
|
128
|
+
const jiraKey = /^(?!ADO-)[A-Z][A-Z0-9_]*-\d+$/i.test(raw) ? raw.toUpperCase() : /\/browse\/([A-Z][A-Z0-9_]*-\d+)/i.exec(raw)?.[1]?.toUpperCase()
|
|
129
|
+
const id = jiraKey ? 0 : Number(/(\d+)\s*$/.exec(raw.replace(/[/?#].*$/, ""))?.[1] || /edit\/(\d+)/.exec(raw)?.[1])
|
|
130
|
+
if (!jiraKey && !id) throw new Error(`"${args.id}" is not an Azure DevOps test case id or a Jira issue key.`)
|
|
131
|
+
if (jiraKey && !ctx.config.sources.jira) throw new Error("Jira is not set up: add sources.jira to forgeqe.yaml.")
|
|
132
|
+
if (!jiraKey && !ctx.config.sources.ado) throw new Error("Azure DevOps is not set up: add sources.azure_devops to forgeqe.yaml.")
|
|
129
133
|
const steps = (args.steps as { action: string; expected?: string }[]).filter((s) => s?.action?.trim()).map((s) => ({ action: s.action.trim(), ...(s.expected?.trim() ? { expected: s.expected.trim() } : {}) }))
|
|
130
134
|
if (!steps.length) throw new Error("steps is empty.")
|
|
131
|
-
const
|
|
135
|
+
const current: { steps: NewCase["steps"]; url?: string } | undefined = jiraKey
|
|
136
|
+
? await jiraCase(ctx.config.sources, jiraKey)
|
|
137
|
+
: (await casesFrom([{ source: "azure_devops", ids: [String(id)] } as CaseSelector], ctx.config.sources, ctx.cwd))[0]
|
|
138
|
+
const label = jiraKey || String(id)
|
|
132
139
|
const show = (list: { action: string; expected?: string }[]) => list.map((s, i) => ` ${i + 1}. ${s.action}${s.expected ? ` → ${s.expected}` : ""}`)
|
|
133
140
|
const lines = ["Now:", ...show(current?.steps || []), "", "New:", ...show(steps), "", `Why: ${args.reason}`]
|
|
134
141
|
if (ctx.ask) {
|
|
135
142
|
const choice = await ctx.ask({
|
|
136
|
-
title: `Update test case ${
|
|
143
|
+
title: `Update test case ${label}${args.title ? ` and rename it "${args.title}"` : ""}?`,
|
|
137
144
|
detail: current?.url,
|
|
138
145
|
lines,
|
|
139
146
|
options: [
|
|
@@ -145,9 +152,98 @@ export const CASE_TOOLS: McpTool[] = [
|
|
|
145
152
|
} else if (args.confirmed !== true) {
|
|
146
153
|
return { updated: false, needs_confirmation: true, current: current?.steps, proposed: steps, next_step: "Show the user the current and new steps; call update_test_case again with confirmed: true only after they agree." }
|
|
147
154
|
}
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
155
|
+
const change = { title: args.title ? String(args.title) : undefined, steps, reason: String(args.reason) }
|
|
156
|
+
const url = jiraKey ? await updateJiraCase(ctx.config.sources, jiraKey, change) : await updateAdoCase(ctx.config.sources, id, change)
|
|
157
|
+
ctx.emit?.({ type: "card", card: { status: "ok", title: `Test case ${label} updated`, stats: `${steps.length} steps`, body: [...show(steps), "", `Why: ${args.reason}`, url] } })
|
|
158
|
+
return { updated: true, url, steps: steps.length, next_step: "Offer to run it again with run_test_cases; its automated test, when it has one, is updated to the new steps in that run." }
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "story_test_cases",
|
|
163
|
+
description:
|
|
164
|
+
"The test cases already linked to an Azure DevOps or Jira story, with their steps and expected results. Call it before writing test cases for a story, so you write only the cases its acceptance criteria still need, and propose updates (update_test_case) to linked cases that no longer fit, instead of writing them again. Reads only.",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
system: { type: "string", enum: ["azure_devops", "jira"], description: "Where the story is" },
|
|
169
|
+
story: { type: "string", description: "The story's work item id (Azure DevOps) or issue key (Jira)" },
|
|
170
|
+
},
|
|
171
|
+
required: ["system", "story"],
|
|
172
|
+
},
|
|
173
|
+
async run(args, ctx) {
|
|
174
|
+
const story = String(args.story || "").trim()
|
|
175
|
+
if (!story) throw new Error("story is required.")
|
|
176
|
+
const found = await storyCases(ctx.config.sources, args.system === "jira" ? "jira" : "azure_devops", story)
|
|
177
|
+
ctx.emit?.({
|
|
178
|
+
type: "card",
|
|
179
|
+
card: {
|
|
180
|
+
status: "info",
|
|
181
|
+
title: `Test cases linked to ${found.story.id}${found.story.title ? ` ${found.story.title}` : ""}`,
|
|
182
|
+
stats: `${found.cases.length} linked`,
|
|
183
|
+
body: found.cases.length ? found.cases.map((c) => `${c.id} ${c.title} (${c.steps.length} steps)`) : ["None yet."],
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
return {
|
|
187
|
+
story: found.story,
|
|
188
|
+
cases: found.cases.map((c) => ({ id: c.id, title: c.title, url: c.url, steps: c.steps.map((st, i) => `${i + 1}. ${st.action}${st.expected ? ` → ${st.expected}` : ""}`) })),
|
|
189
|
+
next_step: found.cases.length
|
|
190
|
+
? "Compare these with the story's acceptance criteria. Write only the cases for criteria and rules no linked case covers, and save only those. For a linked case that no longer fits the story, propose its new steps with update_test_case. Tell the user which criteria the linked cases already cover."
|
|
191
|
+
: "No case is linked yet: write the cases from the story.",
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "e2e_journey",
|
|
197
|
+
description:
|
|
198
|
+
"Plan end-to-end journeys from test cases that already exist, instead of writing them again: from the cases linked to a story (system and story) or any find_test_cases sources. Groups the cases that start the same way (same start page and first steps, such as signing in), keeps those shared steps once, and lays out each case's remaining steps as a section of one journey, with which case and steps each section comes from. Flags cases that look like negative paths, which may end the session. Writes nothing.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
system: { type: "string", enum: ["azure_devops", "jira"], description: "With story: the story's system" },
|
|
203
|
+
story: { type: "string", description: "A story whose linked cases to use" },
|
|
204
|
+
sources: { type: "array", description: "Or the same sources as find_test_cases", items: { type: "object" } },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
async run(args, ctx) {
|
|
208
|
+
let cases: JourneyCase[]
|
|
209
|
+
let story: { id: string; title: string; url: string } | undefined
|
|
210
|
+
if (args.story) {
|
|
211
|
+
const found = await storyCases(ctx.config.sources, args.system === "jira" ? "jira" : "azure_devops", String(args.story).trim())
|
|
212
|
+
story = found.story
|
|
213
|
+
cases = found.cases.map((c) => ({ id: c.id, title: c.title, url: c.url, steps: c.steps }))
|
|
214
|
+
} else if (Array.isArray(args.sources) && args.sources.length) {
|
|
215
|
+
cases = (await casesFrom(args.sources as CaseSelector[], ctx.config.sources, ctx.cwd)).map((c) => ({ id: c.id, title: c.title, url: c.url, startUrl: c.startUrl, steps: c.steps }))
|
|
216
|
+
} else throw new Error("Give a story (with system), or sources.")
|
|
217
|
+
const plan = planJourneys(cases)
|
|
218
|
+
ctx.emit?.({
|
|
219
|
+
type: "card",
|
|
220
|
+
card: {
|
|
221
|
+
status: plan.journeys.length ? "ok" : "info",
|
|
222
|
+
title: `End-to-end journeys${story ? ` for ${story.id}` : ""}`,
|
|
223
|
+
stats: `${plan.journeys.length} journeys from ${cases.length} cases · ${plan.stepsSaved} repeated steps saved`,
|
|
224
|
+
body: [
|
|
225
|
+
...plan.journeys.flatMap((j, n) => [
|
|
226
|
+
`Journey ${n + 1}: ${j.shared.length} shared steps, then ${j.sections.length} cases`,
|
|
227
|
+
...j.sections.map((sec) => ` ${sec.negative ? "▲" : "·"} ${sec.caseId} ${sec.title} (steps ${sec.from}-${sec.to})`),
|
|
228
|
+
]),
|
|
229
|
+
...(plan.alone.length ? ["", `On their own: ${plan.alone.map((c) => c.id).join(", ")}`] : []),
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
return {
|
|
234
|
+
...(story ? { story } : {}),
|
|
235
|
+
journeys: plan.journeys.map((j) => ({
|
|
236
|
+
start_url: j.startUrl,
|
|
237
|
+
shared_steps: j.shared.length,
|
|
238
|
+
steps: j.steps.map((st, i) => `${i + 1}. ${st.action}${st.expected ? ` → ${st.expected}` : ""}`),
|
|
239
|
+
sections: j.sections.map((sec) => ({ case: sec.caseId, title: sec.title, steps: `${sec.from}-${sec.to}`, ...(sec.negative ? { negative: true } : {}) })),
|
|
240
|
+
repeated_steps_saved: j.saved,
|
|
241
|
+
})),
|
|
242
|
+
on_their_own: plan.alone.map((c) => ({ case: c.id, title: c.title })),
|
|
243
|
+
next_step: plan.journeys.length
|
|
244
|
+
? "Order the sections so each leaves the application in the state the next one needs. A negative section (an error, a refusal, signing out, deleting shared data) goes last or stays its own case. Show the user each journey with the cases it covers. When they agree, save each as one case with save_test_cases, titled \"E2E: …\", with a description naming the cases it covers; keep the original cases. Run a journey instead of its separate cases only when the user asks for an end-to-end run."
|
|
245
|
+
: "The cases share no first steps, so a journey saves nothing: offer to run them as they are.",
|
|
246
|
+
}
|
|
151
247
|
},
|
|
152
248
|
},
|
|
153
249
|
{
|
package/src/mcp/commands.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { coverageLines, EXTEND_FROM, measureCoverage, type Candidate } from "../code/coverage.ts"
|
|
1
|
+
import { coverageLines, EXTEND_FROM, measureCoverage, settle, type Candidate } from "../code/coverage.ts"
|
|
2
2
|
import { buildGraph } from "../code/graph/graph.ts"
|
|
3
|
-
import { EXISTING_TEST_FILE } from "../code/notes.ts"
|
|
3
|
+
import { EXISTING_TEST_FILE, EXTEND_TEST_FILE } from "../code/notes.ts"
|
|
4
|
+
import { recordDecision, type DecisionRecord } from "../code/decisions.ts"
|
|
4
5
|
import type { ExistingTest, TestTarget } from "../code/prompt.ts"
|
|
5
6
|
import { existsSync, readFileSync } from "node:fs"
|
|
6
7
|
import path from "node:path"
|
|
@@ -33,19 +34,54 @@ const clip = (s: string | undefined, n: number) => {
|
|
|
33
34
|
/** Marks a browser_run that was a task (a lookup in a tool), not a flow to automate; write_test refuses such runs. */
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
|
-
* Reuse, extend, or new, from what the repository's tests already cover of the run (code graph
|
|
37
|
-
* The clear cases go ahead and say why; where the evidence fits more than one way, the user picks,
|
|
38
|
-
* option's effect. A mode the user gave
|
|
37
|
+
* Reuse, extend, add a test beside one, or new, from what the repository's tests already cover of the run (code graph
|
|
38
|
+
* and page memory). The clear cases go ahead and say why; where the evidence fits more than one way, the user picks,
|
|
39
|
+
* seeing each option's effect, and with no one to ask a fixed policy decides (settle). A mode the user gave is followed
|
|
40
|
+
* as it is; one ForgeQE's agent chose before the browser run is followed too, but a test is not made overlong.
|
|
39
41
|
*/
|
|
40
42
|
export async function chooseTarget(runDir: string, repo: string, args: Record<string, any>, ctx: ToolContext): Promise<{ target?: TestTarget } | { cancelled: true }> {
|
|
41
|
-
const asked = typeof args.mode === "string" && args.mode !== "auto" ? (args.mode as "new" | "extend" | "reuse" | "update") : null
|
|
42
|
-
|
|
43
|
+
const asked = typeof args.mode === "string" && args.mode !== "auto" ? (args.mode as "new" | "extend" | "reuse" | "update" | "sibling") : null
|
|
44
|
+
// ForgeQE's agent chose it before the browser run (args.why); otherwise the user named it.
|
|
45
|
+
const byAgent = Boolean(args.why)
|
|
46
|
+
const goal = ((): string => {
|
|
47
|
+
try {
|
|
48
|
+
return (JSON.parse(readFileSync(path.join(runDir, "run.json"), "utf8")) as { goal?: string }).goal || ""
|
|
49
|
+
} catch {
|
|
50
|
+
return ""
|
|
51
|
+
}
|
|
52
|
+
})()
|
|
53
|
+
const note = (by: NonNullable<DecisionRecord["by"]>, mode: NonNullable<DecisionRecord["mode"]>, c: Candidate | undefined, why: string) =>
|
|
54
|
+
recordDecision(repo, { goal, stage: "chosen", where: "write_test", by, mode, ...(c ? { test: c.test, file: c.file } : {}), why, run_dir: runDir })
|
|
55
|
+
if (asked === "new") {
|
|
56
|
+
note(byAgent ? "agent" : "user", "new", undefined, "asked for a new test")
|
|
57
|
+
return {}
|
|
58
|
+
}
|
|
43
59
|
const graph = await buildGraph(repo).catch(() => null)
|
|
44
60
|
if (!graph) return {}
|
|
45
61
|
const cov = measureCoverage(runDir, graph, ctx.config.memory.dir)
|
|
46
|
-
const
|
|
62
|
+
const file = args.test_file ? String(args.test_file).replace(/^\.\//, "") : ""
|
|
63
|
+
// A test named but not measured (its page objects do not locate this run's elements) is still the one to work from.
|
|
64
|
+
const measurable = cov.items.flatMap((it, i) => (it.members.length ? [i] : []))
|
|
65
|
+
// In a file of many tests, the one named (Class.Method, or its method name) when there is one.
|
|
66
|
+
const testName = args.test_name ? String(args.test_name) : ""
|
|
67
|
+
const sameTest = (c: Candidate) => !testName || c.test === testName || c.test.endsWith(`.${testName}`)
|
|
68
|
+
const lineCount = (test: string) => graph.files.find((f) => f.file === file)?.classes.flatMap((k) => k.members.map((m) => ({ k, m }))).find(({ k, m }) => `${k.name}.${m.name}` === test || m.name === test)
|
|
69
|
+
const unmeasured = (): Candidate => {
|
|
70
|
+
const test = testName || path.basename(file).replace(/\.[^.]+$/, "")
|
|
71
|
+
const at = lineCount(test)
|
|
72
|
+
return { test: at ? `${at.k.name}.${at.m.name}` : test, file, line: at?.m.line || 1, lines: at ? at.m.endLine - at.m.line + 1 : undefined, covered: [], missing: measurable, share: 0 }
|
|
73
|
+
}
|
|
74
|
+
const named = file ? cov.candidates.find((c) => c.file === file && sameTest(c)) || unmeasured() : undefined
|
|
47
75
|
let decision = cov.decision
|
|
48
|
-
if (asked) decision = { mode: asked, ask: false, target: named || cov.candidates[0], why: "the user asked for it" }
|
|
76
|
+
if (asked) decision = { mode: asked, ask: false, target: named || cov.candidates[0], why: args.why ? String(args.why) : "the user asked for it" }
|
|
77
|
+
// Not the user's own choice, and no one to ask, or the agent's: ForgeQE's policy settles it.
|
|
78
|
+
// Who decided: the user, ForgeQE's agent, the coverage measure, or ForgeQE's policy when it changed or settled it.
|
|
79
|
+
let by: NonNullable<DecisionRecord["by"]> = asked ? (byAgent ? "agent" : "user") : "coverage"
|
|
80
|
+
if ((!asked || byAgent) && (byAgent || !decision.ask || !ctx.ask)) {
|
|
81
|
+
const settled = settle(decision)
|
|
82
|
+
if (settled.mode !== decision.mode || decision.ask) by = "policy"
|
|
83
|
+
decision = settled
|
|
84
|
+
}
|
|
49
85
|
const lines = coverageLines(cov)
|
|
50
86
|
const toTarget = (mode: TestTarget["mode"], c: Candidate): TestTarget => ({
|
|
51
87
|
mode,
|
|
@@ -54,7 +90,7 @@ export async function chooseTarget(runDir: string, repo: string, args: Record<st
|
|
|
54
90
|
line: c.line,
|
|
55
91
|
covered: c.covered.map((i) => cov.items[i]!.label),
|
|
56
92
|
missing: c.missing.map((i) => cov.items[i]!.label),
|
|
57
|
-
why: `it covers ${c.covered.length} of ${c.covered.length + c.missing.length} steps and checks this run located
|
|
93
|
+
why: c.covered.length ? `it covers ${c.covered.length} of ${c.covered.length + c.missing.length} steps and checks this run located` : decision.why,
|
|
58
94
|
})
|
|
59
95
|
const say = (title: string, why: string) =>
|
|
60
96
|
ctx.emit?.({ type: "card", card: { status: "info", title, stats: why, body: lines.length ? ["What the repository's tests already cover of this run:", ...lines] : [] } })
|
|
@@ -62,7 +98,7 @@ export async function chooseTarget(runDir: string, repo: string, args: Record<st
|
|
|
62
98
|
const options = cov.candidates.filter((c) => c.share >= EXTEND_FROM).slice(0, 2).flatMap((c, n) => {
|
|
63
99
|
const name = `${path.basename(c.file)}`
|
|
64
100
|
return c.missing.length
|
|
65
|
-
? [{ id: `extend:${n}`, label: `Extend ${name} (adds ${c.missing.length})` }]
|
|
101
|
+
? [{ id: `extend:${n}`, label: `Extend ${name} (adds ${c.missing.length})` }, ...(n === 0 ? [{ id: `sibling:${n}`, label: `Add a test beside ${c.test.split(".").pop()} in ${name}` }] : [])]
|
|
66
102
|
: [{ id: `reuse:${n}`, label: `Reuse ${name} as it is` }]
|
|
67
103
|
})
|
|
68
104
|
const answer = await ctx.ask({
|
|
@@ -72,18 +108,26 @@ export async function chooseTarget(runDir: string, repo: string, args: Record<st
|
|
|
72
108
|
options: [...options, { id: "new", label: "Write a new test" }, { id: "cancel", label: "Cancel" }],
|
|
73
109
|
})
|
|
74
110
|
if (!answer || answer === "cancel") return { cancelled: true }
|
|
75
|
-
if (answer === "new")
|
|
76
|
-
|
|
111
|
+
if (answer === "new") {
|
|
112
|
+
note("user", "new", undefined, `${decision.why}; the user chose a new test`)
|
|
113
|
+
say("Writing a new test", "the user chose it")
|
|
114
|
+
return {}
|
|
115
|
+
}
|
|
116
|
+
const [mode, n] = answer.split(":") as ["reuse" | "extend" | "sibling", string]
|
|
77
117
|
const c = cov.candidates.filter((x) => x.share >= EXTEND_FROM)[Number(n)]!
|
|
78
|
-
|
|
118
|
+
note("user", mode, c, `${decision.why}; the user chose it`)
|
|
119
|
+
say(mode === "reuse" ? `Reusing ${c.test}` : mode === "sibling" ? `Adding a test beside ${c.test}` : `Extending ${c.test}`, "the user chose it")
|
|
79
120
|
return { target: toTarget(mode, c) }
|
|
80
121
|
}
|
|
81
122
|
if (decision.mode === "new" || !decision.target) {
|
|
123
|
+
note(by, "new", undefined, decision.why)
|
|
82
124
|
if (cov.candidates.length) say("Writing a new test", decision.why)
|
|
83
125
|
return {}
|
|
84
126
|
}
|
|
85
|
-
const mode: TestTarget["mode"] = decision.mode === "reuse"
|
|
86
|
-
|
|
127
|
+
const mode: TestTarget["mode"] = decision.mode === "reuse" || decision.mode === "update" || decision.mode === "sibling" ? decision.mode : "extend"
|
|
128
|
+
const verb = { reuse: "Reusing", update: "Updating", sibling: "Adding a test beside", extend: "Extending" }[mode]
|
|
129
|
+
note(by, mode, decision.target, decision.why)
|
|
130
|
+
say(`${verb} ${decision.target.test}`, decision.why)
|
|
87
131
|
return { target: toTarget(mode, decision.target) }
|
|
88
132
|
}
|
|
89
133
|
|
|
@@ -158,11 +202,12 @@ export const COMMAND_TOOLS: McpTool[] = [
|
|
|
158
202
|
repo: { type: "string", description: "Repository to write the test in, relative to the working directory (default: the working directory)" },
|
|
159
203
|
mode: {
|
|
160
204
|
type: "string",
|
|
161
|
-
enum: ["auto", "new", "extend", "reuse", "update"],
|
|
205
|
+
enum: ["auto", "new", "extend", "reuse", "update", "sibling"],
|
|
162
206
|
description:
|
|
163
|
-
"auto (default): ForgeQE measures which existing tests already cover the run, then reuses one that covers it all, extends one that covers most, or writes a new test, and asks the user when that is not clear. new, extend, reuse, update: only when the user said which (\"extend the login test\", \"write a separate test\", \"update it\" after a reused test failed).",
|
|
207
|
+
"auto (default): ForgeQE measures which existing tests already cover the run, then reuses one that covers it all, extends one that covers most (or adds a test beside it, in its class, when it is already long), or writes a new test that reuses the repository's code, and asks the user when that is not clear. new, extend, reuse, update, sibling: only when the user said which (\"extend the login test\", \"add a separate test next to it\", \"write a separate test\", \"update it\" after a reused test failed).",
|
|
164
208
|
},
|
|
165
|
-
test_file: { type: "string", description: "With mode extend, reuse, or
|
|
209
|
+
test_file: { type: "string", description: "With mode extend, reuse, update, or sibling: the existing test file (from the user, or from an earlier write_test result)" },
|
|
210
|
+
test_name: { type: "string", description: "With test_file: the test in it (Class.Method or the method name), when the file has more than one" },
|
|
166
211
|
},
|
|
167
212
|
},
|
|
168
213
|
async run(args, ctx) {
|
|
@@ -177,8 +222,16 @@ export const COMMAND_TOOLS: McpTool[] = [
|
|
|
177
222
|
const goal = (readJson(path.join(runDir, "run.json")) as { goal?: string } | null)?.goal || ""
|
|
178
223
|
// browser_run found an earlier test for this flow and it failed: Scribe updates it rather than adding another.
|
|
179
224
|
const existingTest = readJson(path.join(runDir, EXISTING_TEST_FILE)) as ExistingTest | null
|
|
225
|
+
// ForgeQE's agent chose, before the browser ran, to extend an earlier test: that test, unless the user says otherwise.
|
|
226
|
+
const extend = readJson(path.join(runDir, EXTEND_TEST_FILE)) as { testFile?: string; test?: string; mode?: "extend" | "sibling" } | null
|
|
227
|
+
// The agent repeating that choice to write_test (its mode and file) is still the agent's choice, not the user's.
|
|
228
|
+
const sameChoice = extend?.testFile && args.mode === (extend.mode || "extend") && (!args.test_file || String(args.test_file).replace(/^\.\//, "") === extend.testFile)
|
|
229
|
+
const how =
|
|
230
|
+
extend?.testFile && (!args.mode || args.mode === "auto" || sameChoice)
|
|
231
|
+
? { ...args, mode: extend.mode || "extend", test_file: extend.testFile, ...(extend.test ? { test_name: extend.test } : {}), why: "chosen before the browser run, from the existing tests closest to this flow" }
|
|
232
|
+
: args
|
|
180
233
|
// Otherwise: what the repository's tests already cover of this run decides reuse, extend, or new.
|
|
181
|
-
const chosen = existingTest?.testFile ? { target: undefined } : await chooseTarget(runDir, repo,
|
|
234
|
+
const chosen = existingTest?.testFile ? { target: undefined } : await chooseTarget(runDir, repo, how, ctx)
|
|
182
235
|
if ("cancelled" in chosen) return { status: "cancelled", notes: "The user cancelled before Scribe started; nothing was changed." }
|
|
183
236
|
const target = chosen.target
|
|
184
237
|
ctx.emit?.({ type: "code-start", runDir, repo, goal })
|
|
@@ -200,6 +253,7 @@ export const COMMAND_TOOLS: McpTool[] = [
|
|
|
200
253
|
throw err
|
|
201
254
|
}
|
|
202
255
|
ctx.emit?.({ type: "code-done", result: r, ms: Date.now() - t0 })
|
|
256
|
+
recordDecision(repo, { goal, stage: "outcome", where: "write_test", status: r.status, ...(r.existing ? { existing: r.existing } : {}), ...(r.testFile ? { file: r.testFile } : {}), ...(target ? { mode: target.mode, test: target.test } : existingTest ? { mode: "update" } : { mode: "new" }), ...(r.breaches ? { breaches: r.breaches } : {}), run_dir: runDir })
|
|
203
257
|
ctx.emit?.({ type: "card", card: codeCard(buildCodeSummary(r, Date.now() - t0)) })
|
|
204
258
|
return {
|
|
205
259
|
status: r.status,
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { realpathSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { listPacks, loadPack, makePack, packCard, packMarkdown, packRunCard, runPack, type PackSources } from "../pack/index.ts"
|
|
5
|
+
import { packsDir } from "../pack/pack.ts"
|
|
6
|
+
import { runPackOnPipeline } from "../pack/pipeline.ts"
|
|
7
|
+
import type { McpTool, ToolContext } from "./tools.ts"
|
|
8
|
+
|
|
9
|
+
const clip = (s: string | undefined, n: number) => {
|
|
10
|
+
const t = (s || "").trim()
|
|
11
|
+
return t.length <= n ? t : `…${t.slice(-(n - 1))}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function repoOf(ctx: ToolContext, given?: unknown): string {
|
|
15
|
+
const p = given ? path.resolve(ctx.cwd, String(given)) : ctx.cwd
|
|
16
|
+
try {
|
|
17
|
+
return realpathSync(p)
|
|
18
|
+
} catch {
|
|
19
|
+
return p
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ITEM_SCHEMA = {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
source: { type: "string", description: "The tool it came from: jira, github, azure_devops, confluence, linear…" },
|
|
27
|
+
id: { type: "string" },
|
|
28
|
+
title: { type: "string" },
|
|
29
|
+
text: { type: "string", description: "What changes, in its words: description, acceptance criteria, release note" },
|
|
30
|
+
url: { type: "string" },
|
|
31
|
+
type: { type: "string", description: "Story, Bug, Feature, Task…" },
|
|
32
|
+
kind: { type: "string", enum: ["work_item", "pr", "commit", "note"] },
|
|
33
|
+
files: { type: "array", items: { type: "string" }, description: "Files it changed, when known" },
|
|
34
|
+
links: { type: "array", items: { type: "string" }, description: "Work items it refers to: ado:123, jira:PAY-12, github:45" },
|
|
35
|
+
test_cases: { type: "array", items: { type: "string" }, description: "Test cases that test it (Azure DevOps ids, or ADO-123)" },
|
|
36
|
+
},
|
|
37
|
+
required: ["title"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const PACK_TOOLS: McpTool[] = [
|
|
41
|
+
{
|
|
42
|
+
name: "build_regression_pack",
|
|
43
|
+
description:
|
|
44
|
+
"Build a regression pack: the repository's tests that a release's changes can break, each with why it is in (it automates a test case of a changed work item; the change is in the test code it uses; the work item's words match what it does; it works in a risk area the release touches; it is a smoke test), and the changes no test covers, each with the closest test and what it lacks. Changes come from any mix of: an Azure DevOps sprint, area, epic, or work item ids (with their linked pull requests' files); the commits between two refs of a repository; pull requests by number or merged in the last days; and items you read from any other connected tool (Jira, GitHub issues, a release note), passed in items. Saves the pack by name in .forgeqe/packs and shows a card. Nothing runs. Takes seconds.",
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
name: { type: "string", description: "The pack's name, e.g. release-1.5 (default: release-<date>)" },
|
|
49
|
+
repo: { type: "string", description: "The test repository (default: the working directory)" },
|
|
50
|
+
sprint: { type: "string", description: "Azure DevOps sprint name of the team, or current" },
|
|
51
|
+
iteration: { type: "string", description: "Azure DevOps iteration path and everything under it: a release such as Release 2026.09, or Project\\Release 2026.09\\Sprint 13" },
|
|
52
|
+
area: { type: "string", description: "Azure DevOps area path" },
|
|
53
|
+
parent: { type: "number", description: "An Azure DevOps epic or feature: everything under it" },
|
|
54
|
+
work_items: { type: "array", items: { type: "number" }, description: "Azure DevOps work item ids" },
|
|
55
|
+
project: { type: "string", description: "Azure DevOps project (default: sources.azure_devops.project)" },
|
|
56
|
+
range_repo: { type: "string", description: "Repository for from/to (the product's, or the tests' own); default: the test repository" },
|
|
57
|
+
from: { type: "string", description: "Commits after this ref: a tag, branch, or commit (e.g. release/1.4)" },
|
|
58
|
+
to: { type: "string", description: "Commits up to this ref (default HEAD)" },
|
|
59
|
+
prs: { type: "array", items: { type: "string" }, description: "Pull request numbers or URLs" },
|
|
60
|
+
merged_days: { type: "number", description: "Pull requests merged in the last N days" },
|
|
61
|
+
pr_repo: { type: "string", description: "Repository the pull requests are in (default: the test repository)" },
|
|
62
|
+
items: { type: "array", items: ITEM_SCHEMA, description: "Changes you read from other connected tools, as they are" },
|
|
63
|
+
risk: { type: "boolean", description: "Add tests for risk areas the release touches (default true)" },
|
|
64
|
+
smoke: { type: "boolean", description: "Add smoke-tagged tests (default true)" },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
async run(args, ctx) {
|
|
68
|
+
const repo = repoOf(ctx, args.repo)
|
|
69
|
+
const sources: PackSources = {
|
|
70
|
+
...(args.sprint || args.iteration || args.area || args.parent || (Array.isArray(args.work_items) && args.work_items.length)
|
|
71
|
+
? { ado: { ...(args.sprint ? { sprint: String(args.sprint) } : {}), ...(args.iteration ? { iteration: String(args.iteration) } : {}), ...(args.area ? { area: String(args.area) } : {}), ...(args.parent ? { parent: Number(args.parent) } : {}), ...(Array.isArray(args.work_items) ? { ids: args.work_items.map(Number).filter(Boolean) } : {}), ...(args.project ? { project: String(args.project) } : {}) } }
|
|
72
|
+
: {}),
|
|
73
|
+
...(args.from ? { range: { repo: repoOf(ctx, args.range_repo || args.repo), from: String(args.from), ...(args.to ? { to: String(args.to) } : {}) } } : {}),
|
|
74
|
+
...(Array.isArray(args.prs) && args.prs.length ? { prs: args.prs.map(String) } : {}),
|
|
75
|
+
...(Number(args.merged_days) > 0 ? { mergedDays: Number(args.merged_days) } : {}),
|
|
76
|
+
...(args.pr_repo ? { prRepo: repoOf(ctx, args.pr_repo) } : {}),
|
|
77
|
+
...(Array.isArray(args.items) && args.items.length ? { items: args.items } : {}),
|
|
78
|
+
}
|
|
79
|
+
const pack = await makePack({ config: ctx.config, repo, name: args.name ? String(args.name) : undefined, sources, risk: args.risk !== false, smoke: args.smoke !== false })
|
|
80
|
+
const md = path.join(packsDir(repo), `${pack.name}.md`)
|
|
81
|
+
writeFileSync(md, `${packMarkdown(pack)}\n`)
|
|
82
|
+
ctx.emit?.({ type: "card", card: packCard(pack) })
|
|
83
|
+
return {
|
|
84
|
+
name: pack.name,
|
|
85
|
+
tests: pack.tests.map((t) => ({ test: t.test, file: t.file, why: [...new Set(t.reasons.map((r) => r.text))], ...(t.flaky ? { flaky: t.flaky } : {}) })),
|
|
86
|
+
changes: pack.items.length,
|
|
87
|
+
from: pack.scope,
|
|
88
|
+
not_covered: pack.uncovered,
|
|
89
|
+
risk_areas: pack.risk,
|
|
90
|
+
notes: pack.notes.length ? pack.notes : undefined,
|
|
91
|
+
file: md,
|
|
92
|
+
next_step: [
|
|
93
|
+
pack.tests.length ? `Offer to run it: run_regression_pack with name ${pack.name} and on local, testmu, or pipeline.` : "",
|
|
94
|
+
pack.uncovered.length ? "Name the changes no test covers. For one with a closest test, offer to extend that test (browser_run with existing_test and existing_mode extend), so the next pack covers it." : "",
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(" "),
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "run_regression_pack",
|
|
103
|
+
description:
|
|
104
|
+
"Run a saved regression pack after the user confirms: on local (this machine's browsers), testmu (TestMu AI's cloud browsers: the repository's tests connect to them through FORGEQE_PW_WS_ENDPOINT), or pipeline (a CI pipeline runs it). Reads each test's result, keeps the run with the pack, and says what changed since its last run. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed. Takes minutes.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
name: { type: "string", description: "The pack's name" },
|
|
109
|
+
on: { type: "string", enum: ["local", "testmu", "pipeline"], description: "Where to run it (default local)" },
|
|
110
|
+
pipeline: { type: "string", enum: ["github", "azure_devops"], description: "With on pipeline: which one (default: packs.pipeline.default in forgeqe.yaml)" },
|
|
111
|
+
browsers: { type: "string", enum: ["local", "testmu"], description: "With on pipeline: the runner's own browser, or TestMu AI's (default local)" },
|
|
112
|
+
repo: { type: "string", description: "The test repository (default: the working directory)" },
|
|
113
|
+
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to this run" },
|
|
114
|
+
},
|
|
115
|
+
required: ["name"],
|
|
116
|
+
},
|
|
117
|
+
async run(args, ctx) {
|
|
118
|
+
const repo = repoOf(ctx, args.repo)
|
|
119
|
+
const pack = loadPack(repo, String(args.name))
|
|
120
|
+
const on = args.on === "testmu" || args.on === "pipeline" ? args.on : "local"
|
|
121
|
+
const browsers = args.browsers === "testmu" ? "testmu" : "local"
|
|
122
|
+
const where = on === "testmu" ? "on TestMu AI" : on === "pipeline" ? `in the ${args.pipeline === "azure_devops" ? "Azure DevOps" : args.pipeline === "github" ? "GitHub Actions" : "configured"} pipeline${browsers === "testmu" ? " on TestMu AI" : ""}` : "on this machine"
|
|
123
|
+
const what = `${pack.tests.length} test${pack.tests.length === 1 ? "" : "s"}`
|
|
124
|
+
if (ctx.ask) {
|
|
125
|
+
const choice = await ctx.ask({
|
|
126
|
+
title: `Run pack ${pack.name} (${what}) ${where}?`,
|
|
127
|
+
lines: pack.tests.slice(0, 20).map((t) => ` • ${t.test}`).concat(pack.tests.length > 20 ? [` and ${pack.tests.length - 20} more`] : []),
|
|
128
|
+
options: [
|
|
129
|
+
{ id: "run", label: `Run ${what} ${where}` },
|
|
130
|
+
{ id: "no", label: "Don't run" },
|
|
131
|
+
],
|
|
132
|
+
})
|
|
133
|
+
if (choice !== "run") return { ran: false, reason: "The user chose not to run it." }
|
|
134
|
+
} else if (args.confirmed !== true) {
|
|
135
|
+
return { ran: false, needs_confirmation: true, tests: pack.tests.map((t) => t.test), on, next_step: "Show the user the pack and where it runs; call run_regression_pack again with confirmed: true only after they say yes." }
|
|
136
|
+
}
|
|
137
|
+
const { run, record, notes, pack: after } =
|
|
138
|
+
on === "pipeline"
|
|
139
|
+
? await runPackOnPipeline({
|
|
140
|
+
config: ctx.config,
|
|
141
|
+
repo,
|
|
142
|
+
name: pack.name,
|
|
143
|
+
...(args.pipeline === "github" || args.pipeline === "azure_devops" ? { kind: args.pipeline } : {}),
|
|
144
|
+
target: browsers,
|
|
145
|
+
signal: ctx.signal,
|
|
146
|
+
onProgress: (text) => ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text } }),
|
|
147
|
+
})
|
|
148
|
+
: await runPack({ config: ctx.config, repo, name: pack.name, on, signal: ctx.signal, runTest: ctx.runTest })
|
|
149
|
+
ctx.emit?.({ type: "card", card: packRunCard(after, run, record, notes) })
|
|
150
|
+
const failed = run.tests.filter((t) => t.status === "failed")
|
|
151
|
+
return {
|
|
152
|
+
ran: true,
|
|
153
|
+
on: record.target,
|
|
154
|
+
status: run.status,
|
|
155
|
+
passed: run.counts.passed,
|
|
156
|
+
failed: run.counts.failed,
|
|
157
|
+
skipped: run.counts.skipped,
|
|
158
|
+
seconds: run.seconds,
|
|
159
|
+
failures: failed.length ? failed.map((t) => ({ name: t.name, message: t.message })) : undefined,
|
|
160
|
+
...(run.flaky?.length ? { flaky: run.flaky } : {}),
|
|
161
|
+
previous: after.runs.at(-2) || undefined,
|
|
162
|
+
output: run.status === "passed" ? undefined : clip(run.output, 3000),
|
|
163
|
+
folder: run.dir,
|
|
164
|
+
...(record.url ? { pipeline_run: record.url } : {}),
|
|
165
|
+
notes: notes.length ? notes : undefined,
|
|
166
|
+
next_step: failed.length ? "Name the failing tests with their messages; offer heal_tests for a test the app outgrew, or file the defects (file_bug)." : undefined,
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "list_regression_packs",
|
|
172
|
+
description: "The saved regression packs of a repository: name, when built, from what, how many tests and gaps, and their last run. Nothing runs.",
|
|
173
|
+
inputSchema: { type: "object", properties: { repo: { type: "string" } } },
|
|
174
|
+
async run(args, ctx) {
|
|
175
|
+
return {
|
|
176
|
+
packs: listPacks(repoOf(ctx, args.repo)).map((p) => ({
|
|
177
|
+
name: p.name,
|
|
178
|
+
built: p.createdAt.slice(0, 16).replace("T", " "),
|
|
179
|
+
from: p.scope,
|
|
180
|
+
tests: p.tests.length,
|
|
181
|
+
not_covered: p.uncovered.length,
|
|
182
|
+
last_run: p.runs.at(-1) ? { at: p.runs.at(-1)!.at.slice(0, 16).replace("T", " "), on: p.runs.at(-1)!.target, status: p.runs.at(-1)!.status, failed: p.runs.at(-1)!.counts.failed } : undefined,
|
|
183
|
+
})),
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
]
|