@capagents/forgeqe 1.2.0 → 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 +54 -3
- 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 +66 -8
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +36 -4
- package/prompts/browser-api.md +13 -0
- package/prompts/browser-capture.md +2 -2
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/api_step.py +130 -0
- package/py/forgeqe_engine/capture.py +53 -2
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +70 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +177 -5
- package/src/api/fromSteps.ts +111 -0
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/quality.ts +3 -1
- 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/handoff.ts +20 -0
- package/src/code/loop.ts +27 -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/replay.ts +33 -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 +32 -2
- package/src/suite/report.ts +22 -4
- package/src/suite/run.ts +13 -5
- package/src/summary.ts +6 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +90 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/tui/transcript.tsx +5 -4
- package/src/types.ts +11 -2
|
@@ -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
|
+
]
|
package/src/mcp/tools.ts
CHANGED
|
@@ -11,14 +11,18 @@ import { createAdoDashboard, type DashboardWidget } from "../cases/dashboards.ts
|
|
|
11
11
|
import { writeAdoWikiPage } from "../cases/wiki.ts"
|
|
12
12
|
import { readConfluence } from "../cases/confluence.ts"
|
|
13
13
|
import { casesFrom, type CaseSelector, type TestCase } from "../cases/index.ts"
|
|
14
|
-
import { saveAdoCases, saveJiraCases, type NewCase } from "../cases/save.ts"
|
|
14
|
+
import { saveAdoCases, saveJiraCases, type NewCase, type SaveStatus } from "../cases/save.ts"
|
|
15
15
|
import { buildHandoff } from "../code/handoff.ts"
|
|
16
16
|
import { latestRunDir, runCodeAgent } from "../code/index.ts"
|
|
17
17
|
import type { OpencodeSession } from "../code/runtime.ts"
|
|
18
18
|
import { runTestCommand } from "../code/testrun.ts"
|
|
19
|
-
import { EXISTING_TEST_FILE, findWrittenTest } from "../code/notes.ts"
|
|
19
|
+
import { EXISTING_TEST_FILE, EXTEND_TEST_FILE, findRelatedTests, findWrittenTest, sameFlow, writtenTestFor, writtenTests } from "../code/notes.ts"
|
|
20
|
+
import { buildGraph } from "../code/graph/graph.ts"
|
|
21
|
+
import { walk } from "../code/profile.ts"
|
|
22
|
+
import { indexTests, oneTestCommand, shortlistTests } from "../code/shortlist.ts"
|
|
23
|
+
import { recordDecision } from "../code/decisions.ts"
|
|
20
24
|
import { browserProvider, type AppConfig } from "../config.ts"
|
|
21
|
-
import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
|
|
25
|
+
import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow, readPages } from "../memory.ts"
|
|
22
26
|
import { executeRun, runDirectory } from "../run.ts"
|
|
23
27
|
import { counts } from "../suite/report.ts"
|
|
24
28
|
import { deviceCases, runSuite, suiteParallel, type SuiteEvent, type SuiteResult } from "../suite/run.ts"
|
|
@@ -27,6 +31,7 @@ import { PR_TOOLS } from "./prtools.ts"
|
|
|
27
31
|
import { CASE_TOOLS, checkResult, precheck } from "./casetools.ts"
|
|
28
32
|
import { REPO_TEST_TOOLS } from "./repotests.ts"
|
|
29
33
|
import { DEFECT_TOOLS } from "./defecttools.ts"
|
|
34
|
+
import { PACK_TOOLS } from "./packtools.ts"
|
|
30
35
|
import { API_TOOLS } from "./apitools.ts"
|
|
31
36
|
import { EXPLORE_TOOLS } from "./exploretools.ts"
|
|
32
37
|
import { QUALITY_TOOLS } from "./qualitytools.ts"
|
|
@@ -330,21 +335,42 @@ function devicesFor(args: Record<string, unknown>, ctx: ToolContext): string[] {
|
|
|
330
335
|
return [...new Set(devices)]
|
|
331
336
|
}
|
|
332
337
|
|
|
338
|
+
type ExistingFirst = { passed?: Record<string, unknown>; failed?: { testFile: string; command: string; output: string; why: string } }
|
|
339
|
+
|
|
340
|
+
function repoOf(ctx: ToolContext): string {
|
|
341
|
+
try {
|
|
342
|
+
return realpathSync(ctx.cwd)
|
|
343
|
+
} catch {
|
|
344
|
+
return ctx.cwd
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
333
348
|
/**
|
|
334
349
|
* Runs the test an earlier job wrote for this goal, if there is one. `passed`: the tool's answer, the test having
|
|
335
350
|
* passed in a browser. `failed`: the test to update, for write_test. Null when there is no such test.
|
|
336
351
|
*/
|
|
337
|
-
async function existingTestFirst(goal: string, runDir: string, ctx: ToolContext): Promise<
|
|
352
|
+
async function existingTestFirst(goal: string, runDir: string, ctx: ToolContext): Promise<ExistingFirst | null> {
|
|
338
353
|
const cfg = ctx.config
|
|
339
354
|
if (!cfg.memory.enabled) return null
|
|
340
|
-
|
|
341
|
-
try {
|
|
342
|
-
repo = realpathSync(ctx.cwd)
|
|
343
|
-
} catch {}
|
|
355
|
+
const repo = repoOf(ctx)
|
|
344
356
|
const known = findWrittenTest(cfg.memory.dir, repo, goal, (f) => existsSync(path.resolve(repo, f)))
|
|
345
357
|
if (!known) return null
|
|
358
|
+
ctx.emit?.({ type: "info", text: `This flow already has a test, ${known.test_file} (written ${known.at.slice(0, 10)}): ForgeQE runs it first` })
|
|
359
|
+
recordDecision(repo, { goal, stage: "chosen", where: "browser_run", by: "history", mode: "reuse", file: known.test_file, why: `an earlier job wrote it for the same flow (${known.at.slice(0, 10)})` })
|
|
360
|
+
return runKnownTest(known, repo, runDir, ctx, goal)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** A test to run as it is: one an earlier job wrote (with the goal it was written for), or one found in the repository. */
|
|
364
|
+
type RunnableTest = { test_file: string; command: string; test?: string; goal?: string; at?: string; derived?: boolean }
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Runs an existing test as it is: passing in a browser answers the goal; failing, Scribe updates it after the run.
|
|
368
|
+
* A command ForgeQE built itself (derived) that opens no browser ran nothing: the browser runs, and nothing is updated.
|
|
369
|
+
*/
|
|
370
|
+
async function runKnownTest(known: RunnableTest, repo: string, runDir: string, ctx: ToolContext, goal: string, asCheck = false): Promise<ExistingFirst | null> {
|
|
371
|
+
const outcome = (status: string) => recordDecision(repo, { goal, stage: "outcome", where: "browser_run", mode: "reuse", file: known.test_file, ...(known.test ? { test: known.test } : {}), status, run_dir: runDir })
|
|
372
|
+
const cfg = ctx.config
|
|
346
373
|
const emit = ctx.emit || (() => {})
|
|
347
|
-
emit({ type: "info", text: `This flow already has a test, ${known.test_file} (written ${known.at.slice(0, 10)}): ForgeQE runs it first` })
|
|
348
374
|
mkdirSync(runDir, { recursive: true })
|
|
349
375
|
const run = await (ctx.runTest || runTestCommand)({
|
|
350
376
|
command: known.command,
|
|
@@ -356,33 +382,151 @@ async function existingTestFirst(goal: string, runDir: string, ctx: ToolContext)
|
|
|
356
382
|
})
|
|
357
383
|
if (ctx.signal?.aborted) return null
|
|
358
384
|
const secs = Math.round(run.ms / 1000)
|
|
385
|
+
const name = known.test ? `${known.test} (${known.test_file})` : known.test_file
|
|
359
386
|
if (run.ok && run.browsers.length) {
|
|
387
|
+
outcome("passed")
|
|
360
388
|
emit({
|
|
361
389
|
type: "card",
|
|
362
390
|
card: {
|
|
363
391
|
status: "ok",
|
|
364
392
|
title: "Existing test passed",
|
|
365
393
|
stats: `${secs}s · no browser run or new test needed`,
|
|
366
|
-
body: [`Test ${
|
|
394
|
+
body: [`Test ${name}`, `Command ${known.command}`, `Browser ${run.browsers.join(", ")}`, ...(known.at ? [`Written ${known.at.slice(0, 10)} by an earlier job`] : [])],
|
|
367
395
|
},
|
|
368
396
|
})
|
|
369
397
|
return {
|
|
370
398
|
passed: {
|
|
371
399
|
status: "passed",
|
|
372
400
|
reused_test: known.test_file,
|
|
373
|
-
|
|
401
|
+
...(known.test ? { test: known.test } : {}),
|
|
402
|
+
...(known.goal ? { written_for: known.goal } : {}),
|
|
374
403
|
command: known.command,
|
|
375
404
|
browsers: run.browsers,
|
|
376
405
|
seconds: secs,
|
|
377
|
-
note:
|
|
406
|
+
note: asCheck
|
|
407
|
+
? "The user's check is answered by this existing test: it does what the check asks and passed just now in a real browser, so no browser run was needed. Report that it passed, naming the test and the command; nothing is written for a check."
|
|
408
|
+
: `An existing test${known.goal ? " written for written_for" : ""} passed as it is, in a real browser: no browser run and no new test were needed. If the user's request asks for something that test does not do or check, call browser_run again with existing_test set to ${known.test ? "test" : "reused_test"} and existing_mode extend or sibling (the browser runs, and that test is extended, or a test is added beside it, rather than a new one written); otherwise say in one or two lines that the existing test passed.`,
|
|
378
409
|
},
|
|
379
410
|
}
|
|
380
411
|
}
|
|
412
|
+
if (run.ok && known.derived) {
|
|
413
|
+
outcome("ran nothing")
|
|
414
|
+
emit({ type: "info", text: `${known.command} ran no browser test: ForgeQE could not run ${name} on its own, so the browser runs the flow` })
|
|
415
|
+
return null
|
|
416
|
+
}
|
|
417
|
+
outcome(run.ok ? "opened no browser" : "failed")
|
|
381
418
|
const why = run.ok ? "It exits 0 but opened no browser." : `It fails now (${run.exitCode === null ? "timed out" : `exit ${run.exitCode}`}): the application or the page changed since it was written.`
|
|
382
|
-
emit({ type: "info", text: `${
|
|
419
|
+
emit({ type: "info", text: asCheck ? `${name} ${run.ok ? "opened no browser" : "fails now"}: ForgeQE runs the check in the browser` : `${name} ${run.ok ? "opened no browser" : "fails now"}: ForgeQE runs the flow in the browser, and Scribe updates that test instead of writing a new one` })
|
|
383
420
|
return { failed: { testFile: known.test_file, command: known.command, output: run.output, why } }
|
|
384
421
|
}
|
|
385
422
|
|
|
423
|
+
type ChosenMode = "reuse" | "extend" | "sibling"
|
|
424
|
+
type Chosen = { mode: ChosenMode; file: string; test?: string; runnable?: RunnableTest; repo: string }
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The existing test ForgeQE's agent chose: a test id from existing_tests_found (Class.Method) or a file. Reusing it
|
|
428
|
+
* needs a command: the one an earlier job ran it with, else one built the way the repository runs a single test.
|
|
429
|
+
*/
|
|
430
|
+
async function chosenTest(ref: string, mode: ChosenMode, ctx: ToolContext): Promise<Chosen> {
|
|
431
|
+
const repo = repoOf(ctx)
|
|
432
|
+
const graph = await buildGraph(repo).catch(() => null)
|
|
433
|
+
const tests = graph ? indexTests(graph).map((t) => t.entry) : []
|
|
434
|
+
const clean = ref.replace(/^\.\//, "")
|
|
435
|
+
const byId = tests.filter((t) => t.test === clean || t.test.endsWith(`.${clean}`))
|
|
436
|
+
const file = byId[0]?.file || clean
|
|
437
|
+
if (!existsSync(path.resolve(repo, file))) throw new Error(`existing_test ${ref} is not a test in ${repo}. Pass a test or test_file from existing_tests_found, or fresh: true.`)
|
|
438
|
+
const inFile = tests.filter((t) => t.file === file)
|
|
439
|
+
const test = byId[0]?.test || (inFile.length === 1 ? inFile[0]!.test : undefined)
|
|
440
|
+
if (mode !== "reuse") return { mode, file, ...(test ? { test } : {}), repo }
|
|
441
|
+
const known = ctx.config.memory.enabled ? writtenTestFor(ctx.config.memory.dir, repo, file) : null
|
|
442
|
+
// An earlier job's command runs the test it wrote; for another test in that file, a command is built.
|
|
443
|
+
const method = test?.split(".").pop() || ""
|
|
444
|
+
if (known?.command && (!test || inFile.length <= 1 || known.command.includes(method))) return { mode, file, ...(test ? { test } : {}), repo, runnable: { ...known, ...(test ? { test } : {}) } }
|
|
445
|
+
if (!test && inFile.length > 1) throw new Error(`${file} has ${inFile.length} tests: pass the one to reuse as existing_test (Class.Method, from existing_tests_found).`)
|
|
446
|
+
const knownCommands = ctx.config.memory.enabled ? writtenTests(ctx.config.memory.dir, repo).map((t) => t.command) : []
|
|
447
|
+
const command = test ? oneTestCommand(repo, { test, file }, knownCommands, walk(repo)) : null
|
|
448
|
+
if (!command || !test) throw new Error(`ForgeQE cannot tell how to run ${test || file} on its own: call browser_run with existing_mode extend or sibling to work from it, or write_test with mode reuse after the run.`)
|
|
449
|
+
return { mode, file, test, repo, runnable: { test_file: file, test, command, derived: true } }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Goals the existing-tests check already stopped once in this process: asked again, the browser runs. */
|
|
453
|
+
const gated = new Set<string>()
|
|
454
|
+
/** Existing tests ForgeQE's agent has been shown: fresh: true skips the check only for tests it has seen. */
|
|
455
|
+
const shown = new Set<string>()
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Before the browser explores anything: the repository's tests closest to this goal, ranked from the code graph and
|
|
459
|
+
* page memory without a model (shortlistTests), with the tests earlier jobs wrote for close goals. ForgeQE's agent
|
|
460
|
+
* compares them with the request and picks: reuse one, extend one, add a test beside one, or run fresh for a new test
|
|
461
|
+
* that reuses the repository's code. Null when there are none, or this goal was already stopped once.
|
|
462
|
+
*/
|
|
463
|
+
/** A request sent as a task is shown the repository's tests only when they match it this closely. */
|
|
464
|
+
const TASK_FROM = 0.4
|
|
465
|
+
|
|
466
|
+
async function existingTestsFirst(goal: string, url: string | undefined, fresh: boolean, ctx: ToolContext, asTask = false): Promise<Record<string, unknown> | null> {
|
|
467
|
+
const cfg = ctx.config
|
|
468
|
+
if (!cfg.memory.enabled) return null
|
|
469
|
+
const repo = repoOf(ctx)
|
|
470
|
+
const key = `${repo}\n${goal.replace(/\s+/g, " ").trim().toLowerCase()}`
|
|
471
|
+
if (gated.has(key)) {
|
|
472
|
+
// Shown already: the browser runs, which is the agent's choice, made or not.
|
|
473
|
+
recordDecision(repo, { goal, stage: "chosen", where: "browser_run", by: "agent", mode: "fresh", why: fresh ? (asTask ? "a task, not a test" : "none of the tests shown is this journey") : "asked again without choosing" })
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
const exists = (f: string) => existsSync(path.resolve(repo, f))
|
|
477
|
+
// fresh: true asks for a new run of a flow that has a test; for tests only close to it, before the agent has seen
|
|
478
|
+
// them, it is a guess, not a decision: it sees them first.
|
|
479
|
+
const written = findRelatedTests(cfg.memory.dir, repo, goal, exists).filter((t) => !fresh || !sameFlow(t.goal, goal))
|
|
480
|
+
const graph = await buildGraph(repo).catch(() => null)
|
|
481
|
+
const list = graph ? shortlistTests({ graph, pages: readPages(cfg.memory.dir), request: goal, url }) : null
|
|
482
|
+
type Shown = { test?: string; test_file: string; line?: number; lines?: number; tests_in_class?: number; tags?: string[]; steps?: string[]; covers?: string[]; lacks?: string[]; why?: string[]; score?: number; written_for?: string; command?: string; written?: string }
|
|
483
|
+
const out: Shown[] = (list?.candidates || []).filter((c) => !asTask || c.score >= TASK_FROM).map((c) => ({ test: c.test, test_file: c.file, line: c.line, lines: c.lines, tests_in_class: c.siblings + 1, tags: c.tags, steps: c.steps, covers: c.covers, lacks: c.lacks, why: c.why, score: c.score }))
|
|
484
|
+
// A test an earlier job wrote: its goal and command on the entry for its file, or an entry of its own.
|
|
485
|
+
for (const w of asTask ? written.filter((t) => t.similarity >= TASK_FROM) : written) {
|
|
486
|
+
const same = out.find((o) => o.test_file === w.test_file)
|
|
487
|
+
if (same) Object.assign(same, { written_for: w.goal, command: w.command, written: w.at.slice(0, 10) })
|
|
488
|
+
else out.push({ test_file: w.test_file, written_for: w.goal, command: w.command, written: w.at.slice(0, 10), score: Number(w.similarity.toFixed(2)) })
|
|
489
|
+
}
|
|
490
|
+
const keyOf = (o: Shown) => `${repo}\n${o.test_file}#${o.test || ""}`
|
|
491
|
+
if (!out.length) return null
|
|
492
|
+
if (fresh && out.every((o) => shown.has(keyOf(o)))) {
|
|
493
|
+
recordDecision(repo, { goal, stage: "chosen", where: "browser_run", by: "agent", mode: "fresh", why: asTask ? "a task, not a test" : "none of the tests shown is this journey" })
|
|
494
|
+
return null
|
|
495
|
+
}
|
|
496
|
+
gated.add(key)
|
|
497
|
+
recordDecision(repo, { goal, stage: "shown", where: "browser_run", ...(asTask ? { why: "sent as a task" } : {}), shown: out.map((o) => ({ ...(o.test ? { test: o.test } : {}), file: o.test_file, ...(o.score !== undefined ? { score: o.score } : {}), ...(o.covers ? { covers: o.covers, lacks: o.lacks } : {}) })) })
|
|
498
|
+
for (const o of out) shown.add(keyOf(o))
|
|
499
|
+
const body = out.map((o) => {
|
|
500
|
+
const what = o.covers?.length || o.lacks?.length ? ` covers ${o.covers!.length} of ${o.covers!.length + o.lacks!.length}${o.lacks!.length ? `; lacks ${o.lacks!.slice(0, 3).join(", ")}${o.lacks!.length > 3 ? ` +${o.lacks!.length - 3}` : ""}` : ""}` : ""
|
|
501
|
+
return `${o.test || o.test_file}${o.line ? ` (${o.test_file}:${o.line}, ${o.lines} lines)` : ""}${what}${o.written_for ? ` · written for "${o.written_for.replace(/\s+/g, " ").slice(0, 80)}"` : ""}`
|
|
502
|
+
})
|
|
503
|
+
ctx.emit?.({
|
|
504
|
+
type: "card",
|
|
505
|
+
card: {
|
|
506
|
+
status: "info",
|
|
507
|
+
title: `${out.length === 1 ? "An existing test is" : `${out.length} existing tests are`} close to this flow`,
|
|
508
|
+
stats: `no browser run yet: reuse, extend, or build on them before exploring${list ? ` · ${list.tests} tests searched in ${list.ms}ms` : ""}`,
|
|
509
|
+
body: [...body, ...(list?.unlocated.length ? [`not in any page object yet: ${list.unlocated.join(", ")}`] : [])],
|
|
510
|
+
},
|
|
511
|
+
})
|
|
512
|
+
return {
|
|
513
|
+
status: "existing_tests_found",
|
|
514
|
+
browser: "not started",
|
|
515
|
+
searched: list ? `${list.tests} tests, ranked from the code graph and page memory` : "the tests earlier jobs wrote",
|
|
516
|
+
...(list?.elements.length ? { request_elements: list.elements } : {}),
|
|
517
|
+
...(list?.unlocated.length ? { not_in_page_objects: list.unlocated } : {}),
|
|
518
|
+
existing_tests: out,
|
|
519
|
+
...(asTask
|
|
520
|
+
? {
|
|
521
|
+
sent_as: "task",
|
|
522
|
+
note: "This was sent as a task, but the repository has tests close to it. When one of them does what the request asks, answer the check with it: call browser_run again with the same goal and purpose, existing_test set to that test, and existing_mode reuse. ForgeQE runs it, and the browser runs only when it fails; nothing is written. When the user asked for a test, call browser_run with purpose test and one of the choices in next_step. Only when no test does what it asks, or it needs data a passing test does not give (a value to read or report), call it again with fresh: true.",
|
|
523
|
+
}
|
|
524
|
+
: {}),
|
|
525
|
+
next_step:
|
|
526
|
+
"Before any browser run, compare each test's steps, covers, lacks, and written_for with the user's request, then call browser_run again with the same goal and one of these: existing_test (its test, or test_file) with existing_mode reuse, when it already does and checks everything the request asks (ForgeQE runs it; no browser run when it passes); existing_mode extend, when it covers the start of the journey and the request adds a few steps or checks to it; existing_mode sibling, when the request is a variant of it (another path, other data, or more than a few added steps, or the test is long already): a new test beside it in its class, reusing its setup and steps; or fresh: true, when none of them is this journey: a new test that still reuses the repository's page objects and helpers.",
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
386
530
|
export const TOOLS: McpTool[] = [
|
|
387
531
|
{
|
|
388
532
|
name: "browser_run",
|
|
@@ -419,7 +563,17 @@ export const TOOLS: McpTool[] = [
|
|
|
419
563
|
fresh: {
|
|
420
564
|
type: "boolean",
|
|
421
565
|
description:
|
|
422
|
-
"true: run in the browser even when an earlier job wrote a test for this goal. Only when the user asks for a new browser run or a new test; by default an existing test is run first and reused when it passes.",
|
|
566
|
+
"true: run in the browser even when an earlier job wrote a test for this goal or one close to it. Only when the user asks for a new browser run or a new test, or when existing_tests_found listed none that is this journey; by default an existing test is run first and reused when it passes.",
|
|
567
|
+
},
|
|
568
|
+
existing_test: {
|
|
569
|
+
type: "string",
|
|
570
|
+
description: "A test (Class.Method) or test_file from existing_tests_found, or one the user named, to work from, with existing_mode.",
|
|
571
|
+
},
|
|
572
|
+
existing_mode: {
|
|
573
|
+
type: "string",
|
|
574
|
+
enum: ["reuse", "extend", "sibling"],
|
|
575
|
+
description:
|
|
576
|
+
"With existing_test. reuse: the test already does and checks everything the request asks; ForgeQE runs it, and the browser runs only when it fails. extend: the request adds a few steps or checks to that test's journey; the browser runs the goal and write_test extends that test. sibling: the request is a variant of that test; the browser runs the goal and write_test adds a test beside it in its class, reusing its setup and steps.",
|
|
423
577
|
},
|
|
424
578
|
},
|
|
425
579
|
required: ["goal", "purpose"],
|
|
@@ -439,8 +593,30 @@ export const TOOLS: McpTool[] = [
|
|
|
439
593
|
const emit = ctx.emit || (() => {})
|
|
440
594
|
// The same flow asked again: an earlier job's test for it runs first. Passing in a browser, it answers the goal
|
|
441
595
|
// in seconds, with no browser run and no new test; failing, the run goes ahead and Scribe updates that test.
|
|
442
|
-
const
|
|
596
|
+
const mayReuse = ctx.writeTests && args.purpose !== "task" && !resumeFrom && devices.length <= 1
|
|
597
|
+
// Sent as a task, but close to the repository's tests: the agent may have taken a test of the app for a lookup.
|
|
598
|
+
// A check of the application, answered by running the existing test that does it: no browser run when it passes.
|
|
599
|
+
const checkByTest = ctx.writeTests && args.purpose === "task" && !resumeFrom && devices.length <= 1
|
|
600
|
+
if (checkByTest && !args.existing_test) {
|
|
601
|
+
const found = await existingTestsFirst(goal, url, Boolean(args.fresh), ctx, true)
|
|
602
|
+
if (found) return found
|
|
603
|
+
}
|
|
604
|
+
const mode: ChosenMode = args.existing_mode === "extend" || args.existing_mode === "sibling" ? args.existing_mode : "reuse"
|
|
605
|
+
if (checkByTest && args.existing_test && mode !== "reuse") throw new Error(`existing_mode ${mode} writes a test: call browser_run with purpose test for it, or existing_mode reuse to answer the check with that test.`)
|
|
606
|
+
const chosen = (mayReuse || checkByTest) && args.existing_test ? await chosenTest(String(args.existing_test), mode, ctx) : null
|
|
607
|
+
let reuse: ExistingFirst | null = null
|
|
608
|
+
if (chosen) recordDecision(chosen.repo, { goal, stage: "chosen", where: "browser_run", by: "agent", mode: chosen.mode, file: chosen.file, ...(chosen.test ? { test: chosen.test } : {}) })
|
|
609
|
+
if (chosen?.runnable) reuse = await runKnownTest(chosen.runnable, chosen.repo, runDir, ctx, goal, checkByTest)
|
|
610
|
+
else if (mayReuse && !chosen) {
|
|
611
|
+
if (!args.fresh) reuse = await existingTestFirst(goal, runDir, ctx)
|
|
612
|
+
// Not the same flow, but close to existing tests: decide reuse, extend, or build on one before the browser explores.
|
|
613
|
+
if (!reuse) {
|
|
614
|
+
const found = await existingTestsFirst(goal, url, Boolean(args.fresh), ctx)
|
|
615
|
+
if (found) return found
|
|
616
|
+
}
|
|
617
|
+
}
|
|
443
618
|
if (reuse?.passed) return reuse.passed
|
|
619
|
+
const extend = chosen && chosen.mode !== "reuse" ? chosen : undefined
|
|
444
620
|
const t0 = Date.now()
|
|
445
621
|
emit({ type: "run-start", goal, url })
|
|
446
622
|
try {
|
|
@@ -470,10 +646,15 @@ export const TOOLS: McpTool[] = [
|
|
|
470
646
|
}
|
|
471
647
|
if (ctx.writeTests && result.status === "complete" && result.driver !== "synthetic") {
|
|
472
648
|
if (reuse?.failed) writeFileSync(path.join(runDir, EXISTING_TEST_FILE), `${JSON.stringify(reuse.failed, null, 2)}\n`)
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
649
|
+
else if (extend) writeFileSync(path.join(runDir, EXTEND_TEST_FILE), `${JSON.stringify({ testFile: extend.file, ...(extend.test ? { test: extend.test } : {}), mode: extend.mode }, null, 2)}\n`)
|
|
650
|
+
const how = reuse?.failed
|
|
651
|
+
? ` It updates the existing test ${reuse.failed.testFile} instead of writing a new one.`
|
|
652
|
+
: extend?.mode === "sibling"
|
|
653
|
+
? ` It adds a test beside ${extend.test || "the existing test"} in ${extend.file}, reusing its setup and steps, instead of writing a new file.`
|
|
654
|
+
: extend
|
|
655
|
+
? ` It extends the existing test ${extend.file} instead of writing a new one.`
|
|
656
|
+
: ""
|
|
657
|
+
return { ...summary, next_step: `This session turns passed runs into tests: call write_test with run_dir ${runDir} now, before you reply.${how}` }
|
|
477
658
|
}
|
|
478
659
|
return summary
|
|
479
660
|
} catch (err) {
|
|
@@ -507,7 +688,7 @@ export const TOOLS: McpTool[] = [
|
|
|
507
688
|
{
|
|
508
689
|
name: "save_test_cases",
|
|
509
690
|
description:
|
|
510
|
-
"Save test cases you wrote from a story into Azure DevOps (Test Case work items with steps and expected results, linked to the story as Tests / Tested By, in the story's area and iteration) or Jira (issues of type Test by default, steps in the description, linked to the story).
|
|
691
|
+
"Save test cases you wrote from a story into Azure DevOps (Test Case work items with steps and expected results, linked to the story as Tests / Tested By, in the story's area and iteration) or Jira (issues of type Test by default, steps in the description, linked to the story). Never duplicates: each case is compared with the cases already linked to the story. Same steps (under any title): unchanged, nothing written. Same title, other steps: differs, nothing written; propose the new steps with update_test_case. A close title or mostly the same steps: similar, nothing written; update that case, or save again with the title in force_new when it is really a different case. Returns each case's status, id, and link, and shows them as a card.",
|
|
511
692
|
inputSchema: {
|
|
512
693
|
type: "object",
|
|
513
694
|
properties: {
|
|
@@ -531,6 +712,7 @@ export const TOOLS: McpTool[] = [
|
|
|
531
712
|
},
|
|
532
713
|
issue_type: { type: "string", description: "Jira only: issue type for the cases (default Test)" },
|
|
533
714
|
link_type: { type: "string", description: "Jira only: link type to the story (default Relates)" },
|
|
715
|
+
force_new: { type: "array", items: { type: "string" }, description: "Titles to create although a similar case is linked, only after the user said they are different cases" },
|
|
534
716
|
},
|
|
535
717
|
required: ["system", "story", "cases"],
|
|
536
718
|
},
|
|
@@ -539,29 +721,55 @@ export const TOOLS: McpTool[] = [
|
|
|
539
721
|
if (!cases.length) throw new Error("cases is empty.")
|
|
540
722
|
const story = String(args.story || "").trim()
|
|
541
723
|
if (!story) throw new Error("story is required.")
|
|
724
|
+
const forceNew = Array.isArray(args.force_new) ? (args.force_new as unknown[]).map(String) : undefined
|
|
542
725
|
const saved =
|
|
543
726
|
args.system === "jira"
|
|
544
|
-
? await saveJiraCases(ctx.config.sources, story, cases, { issueType: args.issue_type ? String(args.issue_type) : undefined, linkType: args.link_type ? String(args.link_type) : undefined })
|
|
545
|
-
: await saveAdoCases(ctx.config.sources, Number(story.replace(/^#/, "")), cases)
|
|
546
|
-
const
|
|
547
|
-
const
|
|
727
|
+
? await saveJiraCases(ctx.config.sources, story, cases, { issueType: args.issue_type ? String(args.issue_type) : undefined, linkType: args.link_type ? String(args.link_type) : undefined, forceNew })
|
|
728
|
+
: await saveAdoCases(ctx.config.sources, Number(story.replace(/^#/, "")), cases, { forceNew })
|
|
729
|
+
const count = (st: SaveStatus) => saved.cases.filter((c) => c.status === st).length
|
|
730
|
+
const MARKS: Record<SaveStatus, string> = { created: "+", unchanged: "=", differs: "~", similar: "?", failed: "✗" }
|
|
731
|
+
const failed = count("failed")
|
|
548
732
|
ctx.emit?.({
|
|
549
733
|
type: "card",
|
|
550
734
|
card: {
|
|
551
|
-
status: failed ? "fail" : "ok",
|
|
735
|
+
status: failed ? "fail" : count("differs") || count("similar") ? "info" : "ok",
|
|
552
736
|
title: `Test cases saved for ${saved.story.id}${saved.story.title ? ` ${saved.story.title}` : ""}`,
|
|
553
|
-
stats: [`${
|
|
737
|
+
stats: [`${count("created")} created`, count("unchanged") ? `${count("unchanged")} already linked` : "", count("differs") ? `${count("differs")} differ from the linked case` : "", count("similar") ? `${count("similar")} like a linked case` : "", failed ? `${failed} failed` : ""].filter(Boolean).join(" · "),
|
|
554
738
|
body: [
|
|
555
739
|
`Story ${saved.story.url}`,
|
|
556
740
|
"",
|
|
557
|
-
...saved.cases.flatMap((c) =>
|
|
741
|
+
...saved.cases.flatMap((c) =>
|
|
742
|
+
c.id
|
|
743
|
+
? [
|
|
744
|
+
`${MARKS[c.status]} ${c.id} ${c.title}`,
|
|
745
|
+
...(c.existing && c.existing.title !== c.title ? [` linked as "${c.existing.title}"`] : []),
|
|
746
|
+
` ${c.url}`,
|
|
747
|
+
...(c.error ? [` ${c.error}`] : []),
|
|
748
|
+
]
|
|
749
|
+
: [`✗ ${c.title}: ${c.error}`],
|
|
750
|
+
),
|
|
558
751
|
],
|
|
559
752
|
},
|
|
560
753
|
})
|
|
754
|
+
const show = (steps: NewCase["steps"]) => steps.map((st, i) => `${i + 1}. ${st.action}${st.expected ? ` → ${st.expected}` : ""}`)
|
|
755
|
+
const pending = saved.cases.filter((c) => c.status === "differs" || c.status === "similar")
|
|
561
756
|
return {
|
|
562
757
|
story: saved.story,
|
|
563
|
-
cases: saved.cases.map((c) => ({
|
|
564
|
-
|
|
758
|
+
cases: saved.cases.map((c) => ({
|
|
759
|
+
title: c.title,
|
|
760
|
+
status: c.status,
|
|
761
|
+
id: c.id || undefined,
|
|
762
|
+
url: c.url || undefined,
|
|
763
|
+
...(c.existing ? { linked_case: { id: c.existing.id, title: c.existing.title, steps: show(c.existing.steps) } } : {}),
|
|
764
|
+
error: c.error,
|
|
765
|
+
})),
|
|
766
|
+
...(saved.system === "azure_devops" && saved.cases.some((c) => c.id) ? { run_with: { source: "azure_devops", ids: [...new Set(saved.cases.filter((c) => c.id).map((c) => c.id))] } } : {}),
|
|
767
|
+
...(pending.length
|
|
768
|
+
? {
|
|
769
|
+
next_step:
|
|
770
|
+
"Nothing was written for the cases with status differs or similar. Do not say they were saved or updated. For each one, show the user how it differs from linked_case and offer update_test_case with the steps you wrote (id: linked_case.id). Save again with the title in force_new only when the user says it is a different case.",
|
|
771
|
+
}
|
|
772
|
+
: {}),
|
|
565
773
|
}
|
|
566
774
|
},
|
|
567
775
|
},
|
|
@@ -918,6 +1126,7 @@ export const TOOLS: McpTool[] = [
|
|
|
918
1126
|
...COMMAND_TOOLS,
|
|
919
1127
|
...REPO_TEST_TOOLS,
|
|
920
1128
|
...DEFECT_TOOLS,
|
|
1129
|
+
...PACK_TOOLS,
|
|
921
1130
|
...PR_TOOLS,
|
|
922
1131
|
...CASE_TOOLS,
|
|
923
1132
|
...API_TOOLS,
|