@capagents/forgeqe 0.0.0-stage → 1.0.1
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 +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +781 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +146 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { latestRunDir } from "../code/index.ts"
|
|
5
|
+
import { expandHeaders, runContracts } from "../api/run.ts"
|
|
6
|
+
import { setFromSpec } from "../api/spec.ts"
|
|
7
|
+
import { loadSet, newSetId, runCard, saveSet, scenarioRunCard, scenariosCard, setCard } from "../api/store.ts"
|
|
8
|
+
import { readNetwork, setFromRun } from "../api/traffic.ts"
|
|
9
|
+
import { addScenarios, runScenarios, scenarioFromRun, scenariosFromSpec } from "../api/scenario.ts"
|
|
10
|
+
import { planScenarios } from "../api/plan.ts"
|
|
11
|
+
import { loadCases } from "../cases/index.ts"
|
|
12
|
+
import { writeApiTests } from "../api/write.ts"
|
|
13
|
+
import type { ContractSet, Scenario } from "../api/types.ts"
|
|
14
|
+
import type { McpTool, ToolContext } from "./tools.ts"
|
|
15
|
+
|
|
16
|
+
const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : typeof v === "string" && v.trim() ? v.split(",").map((s) => s.trim()) : [])
|
|
17
|
+
|
|
18
|
+
/** A run folder from a path or a run id, else the latest run. */
|
|
19
|
+
function runFolder(ref: unknown, ctx: ToolContext): string {
|
|
20
|
+
const out = ctx.config.export.outDir
|
|
21
|
+
if (ref) {
|
|
22
|
+
const r = String(ref)
|
|
23
|
+
for (const c of [path.resolve(ctx.cwd, r), path.join(out, r)]) if (existsSync(path.join(c, "run.json"))) return c
|
|
24
|
+
throw new Error(`No run "${r}": give the run folder or its id.`)
|
|
25
|
+
}
|
|
26
|
+
const latest = latestRunDir(out)
|
|
27
|
+
if (!latest) throw new Error("No browser run yet. Run the flow in the browser first: its API calls are recorded as it runs.")
|
|
28
|
+
return latest
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function headers(ctx: ToolContext): Record<string, string> {
|
|
32
|
+
const { headers, missing } = expandHeaders(ctx.config.api?.headers)
|
|
33
|
+
if (missing.length) ctx.emit?.({ type: "info", text: `api.headers reads ${[...new Set(missing)].join(", ")}, which ${missing.length === 1 ? "is" : "are"} not set: those headers are not sent`, ok: false })
|
|
34
|
+
return headers
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const summary = (set: ContractSet) =>
|
|
38
|
+
set.contracts.map((c) => ({ id: c.id, protocol: c.protocol, expect_status: c.expect.status, changes_data: c.writes || undefined, needs_data: c.needsData, key_fields: c.expect.fields?.map((f) => f.path), seen: c.source.samples }))
|
|
39
|
+
|
|
40
|
+
/** Ask before running scenarios that change data; true when they may run. */
|
|
41
|
+
async function confirmWrites(ctx: ToolContext, scenarios: { name: string; writes: boolean }[], where: string): Promise<boolean> {
|
|
42
|
+
const writing = scenarios.filter((s) => s.writes)
|
|
43
|
+
if (!writing.length || !ctx.ask) return true
|
|
44
|
+
const answer = await ctx.ask({
|
|
45
|
+
title: `Run ${writing.length} scenario${writing.length === 1 ? "" : "s"} that change data?`,
|
|
46
|
+
detail: `Against ${where}. They create, change, or delete data: only on a test environment.`,
|
|
47
|
+
lines: writing.slice(0, 10).map((s) => `• ${s.name}`),
|
|
48
|
+
options: [
|
|
49
|
+
{ id: "yes", label: "Yes, run them" },
|
|
50
|
+
{ id: "no", label: "No, read-only scenarios only" },
|
|
51
|
+
],
|
|
52
|
+
})
|
|
53
|
+
return answer === "yes"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The contract set to build scenarios on: a given set, a description to read, the run's own, or the newest. */
|
|
57
|
+
async function baseSet(args: Record<string, any>, ctx: ToolContext): Promise<{ set: ContractSet; fresh: boolean }> {
|
|
58
|
+
if (args.spec) {
|
|
59
|
+
const ref = String(args.spec).trim()
|
|
60
|
+
const src = /^(https?|grpcs?):\/\//i.test(ref) ? ref : path.resolve(ctx.cwd, ref)
|
|
61
|
+
ctx.emit?.({ type: "info", text: `Reading the API description: ${ref}` })
|
|
62
|
+
return { set: await setFromSpec(src, newSetId(ctx.config, String(args.name || ref)), { headers: headers(ctx), name: args.name ? String(args.name) : undefined, baseUrl: args.base_url ? String(args.base_url) : undefined }), fresh: true }
|
|
63
|
+
}
|
|
64
|
+
return { set: loadSet(ctx.config, args.set_id ? String(args.set_id) : undefined).set, fresh: false }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** API and contract tests: contracts from browser runs and API descriptions, run directly or written as tests. */
|
|
68
|
+
export const API_TOOLS: McpTool[] = [
|
|
69
|
+
{
|
|
70
|
+
name: "api_contracts",
|
|
71
|
+
description:
|
|
72
|
+
"Make API contracts: one per API operation, with an example call and what the answer must have (status, content type, JSON schema, key fields). from: run turns the calls a browser run saw the application make (XHR and fetch; REST, GraphQL, SOAP and gRPC-web) into contracts, grouped by operation, without a model; from: spec reads an API description from a link or file: OpenAPI 3 or Swagger 2 (JSON, YAML, or the Swagger UI page), WSDL (SOAP), a GraphQL endpoint (introspection), an SDL or introspection file, .proto files, or grpc://host:port with reflection. Shows the contracts and returns set_id for run_api_contracts and write_api_tests. Credentials are never stored.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
from: { type: "string", enum: ["run", "spec"], description: "run (default when no spec is given): a browser run's API calls; spec: an API description" },
|
|
77
|
+
run_dir: { type: "string", description: "from run: the run folder or id (default: the latest run)" },
|
|
78
|
+
spec: { type: "string", description: "from spec: link or file path of the API description" },
|
|
79
|
+
include: { type: "array", items: { type: "string" }, description: "Only operations whose id or name contains one of these (e.g. 'orders', 'POST /api/cart')" },
|
|
80
|
+
name: { type: "string", description: "A name for the set" },
|
|
81
|
+
base_url: { type: "string", description: "Base URL to run against, when it is not the one in the description or run" },
|
|
82
|
+
third_party: { type: "boolean", description: "from run: keep calls to other sites than the page's (default: api.third_party, off)" },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
async run(args, ctx) {
|
|
86
|
+
const fromSpec = args.from === "spec" || (args.from !== "run" && args.spec)
|
|
87
|
+
let set: ContractSet
|
|
88
|
+
if (fromSpec) {
|
|
89
|
+
const ref = String(args.spec || "").trim()
|
|
90
|
+
if (!ref) throw new Error("spec is required: the link or file of the API description.")
|
|
91
|
+
const src = /^(https?|grpcs?):\/\//i.test(ref) ? ref : path.resolve(ctx.cwd, ref)
|
|
92
|
+
ctx.emit?.({ type: "info", text: `Reading the API description: ${ref}` })
|
|
93
|
+
set = await setFromSpec(src, newSetId(ctx.config, String(args.name || ref)), { headers: headers(ctx), include: list(args.include), name: args.name ? String(args.name) : undefined, baseUrl: args.base_url ? String(args.base_url) : undefined })
|
|
94
|
+
} else {
|
|
95
|
+
const dir = runFolder(args.run_dir, ctx)
|
|
96
|
+
ctx.emit?.({ type: "info", text: `Reading the API calls of run ${path.basename(dir)}` })
|
|
97
|
+
set = setFromRun(dir, { id: newSetId(ctx.config, String(args.name || `run-${path.basename(dir)}`)), name: args.name ? String(args.name) : undefined, include: list(args.include), thirdParty: args.third_party === true || ctx.config.api?.thirdParty === true })
|
|
98
|
+
if (args.base_url) set.baseUrl = String(args.base_url)
|
|
99
|
+
}
|
|
100
|
+
const saved = saveSet(ctx.config, set)
|
|
101
|
+
ctx.emit?.({ type: "card", card: setCard(set, saved.dir, ctx.cwd) })
|
|
102
|
+
const reads = set.contracts.filter((c) => !c.writes && !c.needsData?.length).length
|
|
103
|
+
return {
|
|
104
|
+
set_id: set.id,
|
|
105
|
+
name: set.name,
|
|
106
|
+
base_url: set.baseUrl || undefined,
|
|
107
|
+
contracts: summary(set),
|
|
108
|
+
skipped: set.skipped,
|
|
109
|
+
files: { contracts: saved.file, brief: saved.brief },
|
|
110
|
+
next_step: set.contracts.length
|
|
111
|
+
? `${reads} of ${set.contracts.length} can run as they are (read-only, with real values). Offer to run them now (run_api_contracts, fast, no browser) and to write them as tests in the repository (write_api_tests).${set.contracts.some((c) => c.needsData?.length) ? " Others need real values: the user can edit the examples in contracts.json." : ""}`
|
|
112
|
+
: "No operations found. Say what was left out and why.",
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "run_api_contracts",
|
|
118
|
+
description:
|
|
119
|
+
"Run a contract set straight against the API (no browser, no model): makes each example call and checks status, content type, schema, and key fields (GraphQL: no errors; SOAP: no fault and the expected elements; gRPC through grpcurl). Calls that change data run only with allow_writes, after the user agrees; calls without real example values are skipped unless include_needs_data. Credentials come from api.headers in forgeqe.yaml. Writes results.json and junit.xml.",
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
set_id: { type: "string", description: "From api_contracts (default: the newest set), or a contracts.json path" },
|
|
124
|
+
base_url: { type: "string", description: "Run against this base URL (another environment)" },
|
|
125
|
+
only: { type: "array", items: { type: "string" }, description: "Only contracts whose id or name contains one of these" },
|
|
126
|
+
allow_writes: { type: "boolean", description: "Also run calls that change data (POST, PUT, PATCH, DELETE, mutations). Only on a test environment, after the user agrees." },
|
|
127
|
+
include_needs_data: { type: "boolean", description: "Also run calls whose example values were made up from the description" },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
async run(args, ctx) {
|
|
131
|
+
const { set, dir } = loadSet(ctx.config, args.set_id ? String(args.set_id) : undefined)
|
|
132
|
+
let allowWrites = args.allow_writes === true
|
|
133
|
+
const writes = set.contracts.filter((c) => c.writes)
|
|
134
|
+
if (allowWrites && writes.length && ctx.ask) {
|
|
135
|
+
const ok = await ctx.ask({
|
|
136
|
+
title: `Run ${writes.length} call${writes.length === 1 ? "" : "s"} that change data?`,
|
|
137
|
+
detail: `Against ${args.base_url || ctx.config.api?.baseUrl || set.baseUrl}. Only do this on a test environment.`,
|
|
138
|
+
lines: writes.slice(0, 10).map((c) => `• ${c.id}`),
|
|
139
|
+
options: [
|
|
140
|
+
{ id: "yes", label: "Yes, run them" },
|
|
141
|
+
{ id: "no", label: "No, read-only calls only" },
|
|
142
|
+
],
|
|
143
|
+
})
|
|
144
|
+
allowWrites = ok === "yes"
|
|
145
|
+
}
|
|
146
|
+
const runDir = path.join(dir, "runs", new Date().toISOString().replace(/[:.]/g, "-"))
|
|
147
|
+
const baseUrl = args.base_url ? String(args.base_url) : ctx.config.api?.baseUrl || set.baseUrl
|
|
148
|
+
ctx.emit?.({ type: "info", text: `Running ${set.contracts.length} API contracts against ${baseUrl || "their own URLs"}` })
|
|
149
|
+
const run = await runContracts(set, {
|
|
150
|
+
baseUrl,
|
|
151
|
+
headers: headers(ctx),
|
|
152
|
+
allowWrites,
|
|
153
|
+
includeNeedsData: args.include_needs_data === true,
|
|
154
|
+
only: list(args.only),
|
|
155
|
+
timeoutSeconds: ctx.config.api?.timeoutSeconds,
|
|
156
|
+
signal: ctx.signal,
|
|
157
|
+
dir: runDir,
|
|
158
|
+
})
|
|
159
|
+
ctx.emit?.({ type: "card", card: runCard(run, set, ctx.cwd) })
|
|
160
|
+
return {
|
|
161
|
+
counts: run.counts,
|
|
162
|
+
base_url: run.baseUrl,
|
|
163
|
+
failed: run.results.filter((r) => r.status === "failed" || r.status === "error").map((r) => ({ id: r.id, http_status: r.httpStatus, reason: r.reason, failed_checks: r.checks.filter((c) => !c.ok) })),
|
|
164
|
+
skipped: run.results.filter((r) => r.status === "skipped").map((r) => ({ id: r.id, reason: r.reason })),
|
|
165
|
+
results: path.join(runDir, "results.json"),
|
|
166
|
+
junit: path.join(runDir, "junit.xml"),
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "write_api_tests",
|
|
172
|
+
description:
|
|
173
|
+
"Scribe, the coding agent, writes API and contract tests for a contract set in the repository's own framework and HTTP client (Playwright request, pytest with requests, REST Assured, HttpClient...), and ForgeQE runs them until they pass. No browser: faster and steadier than UI tests for the same logic. Calls that change data are left out unless include_writes. Takes a few minutes.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: {
|
|
177
|
+
set_id: { type: "string", description: "From api_contracts (default: the newest set)" },
|
|
178
|
+
repo: { type: "string", description: "Repository folder (default: the working directory)" },
|
|
179
|
+
only: { type: "array", items: { type: "string" }, description: "Only contracts whose id or name contains one of these" },
|
|
180
|
+
include_writes: { type: "boolean", description: "Also write tests for calls that change data" },
|
|
181
|
+
base_url: { type: "string", description: "Base URL the tests default to" },
|
|
182
|
+
scenarios: { type: "array", items: { type: "string" }, description: "Write these scenarios (ids, names, or test case ids from api_scenarios; ['all'] for every one) as end-to-end tests, on the repository's existing API helpers, instead of one test per contract" },
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
async run(args, ctx) {
|
|
186
|
+
const loaded = loadSet(ctx.config, args.set_id ? String(args.set_id) : undefined)
|
|
187
|
+
const only = list(args.only).map((s) => s.toLowerCase())
|
|
188
|
+
const set = only.length ? { ...loaded.set, contracts: loaded.set.contracts.filter((c) => only.some((o) => `${c.id} ${c.name}`.toLowerCase().includes(o))) } : loaded.set
|
|
189
|
+
if (!set.contracts.length) throw new Error("No contracts to write tests for.")
|
|
190
|
+
const wanted = list(args.scenarios).map((s) => s.toLowerCase())
|
|
191
|
+
let scenarios: Scenario[] | undefined
|
|
192
|
+
if (wanted.length) {
|
|
193
|
+
scenarios = (loaded.set.scenarios || []).filter((sc) => wanted.includes("all") || wanted.some((w) => `${sc.id} ${sc.name} ${sc.caseId || ""}`.toLowerCase().includes(w)))
|
|
194
|
+
if (!scenarios.length) throw new Error(`No scenarios match ${wanted.join(", ")} in set ${loaded.set.id}: make them with api_scenarios first.`)
|
|
195
|
+
}
|
|
196
|
+
const repo = args.repo ? path.resolve(ctx.cwd, String(args.repo)) : ctx.cwd
|
|
197
|
+
const started = Date.now()
|
|
198
|
+
const r = await writeApiTests({
|
|
199
|
+
config: ctx.config,
|
|
200
|
+
repo,
|
|
201
|
+
set,
|
|
202
|
+
setDir: loaded.dir,
|
|
203
|
+
baseUrl: args.base_url ? String(args.base_url) : undefined,
|
|
204
|
+
writes: args.include_writes === true,
|
|
205
|
+
scenarios,
|
|
206
|
+
signal: ctx.signal,
|
|
207
|
+
onEvent: (event) => ctx.emit?.({ type: "agent", event }),
|
|
208
|
+
open: ctx.openSession,
|
|
209
|
+
runTest: ctx.runTest,
|
|
210
|
+
})
|
|
211
|
+
const secs = Math.round((Date.now() - started) / 1000)
|
|
212
|
+
ctx.emit?.({
|
|
213
|
+
type: "card",
|
|
214
|
+
card: {
|
|
215
|
+
status: r.status === "passed" ? "ok" : r.status === "stopped" ? "stopped" : "fail",
|
|
216
|
+
title: r.status === "passed" ? `API tests written and passing: ${set.name}` : `API tests ${r.status}: ${set.name}`,
|
|
217
|
+
stats: [`${r.attempts} attempt${r.attempts === 1 ? "" : "s"}`, `${secs}s`, r.model].filter(Boolean).join(" · "),
|
|
218
|
+
body: [
|
|
219
|
+
r.testFile ? `Test ${r.testFile}` : "",
|
|
220
|
+
r.command ? `Run ${r.command}` : "",
|
|
221
|
+
r.reused.length ? `Reused ${r.reused.join(", ")}` : "",
|
|
222
|
+
r.changed.length ? `Changed ${r.changed.join(", ")}` : "",
|
|
223
|
+
r.helpersChanged.length ? `Helpers ${r.helpersChanged.join(", ")} changed; the tests that use them still pass` : "",
|
|
224
|
+
...r.mapped.map((m) => `Mapped ${m.caseId} → ${m.testName}`),
|
|
225
|
+
...r.writeBack.map((w) => `${w.error ? "✗" : "✓"} ${w.text}${w.error ? `: ${w.error}` : ""}`),
|
|
226
|
+
r.notes ? `\n${r.notes}` : "",
|
|
227
|
+
].filter(Boolean),
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
return { status: r.status, test_file: r.testFile, command: r.command, changed: r.changed, reused: r.reused, helpers_changed: r.helpersChanged, mapped: r.mapped, write_back: r.writeBack, notes: r.notes, attempts: r.attempts, output: r.status === "passed" ? undefined : r.output.slice(-3000), usage: r.usage }
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "api_scenarios",
|
|
235
|
+
description:
|
|
236
|
+
"Make end-to-end API scenarios: calls in order, each using values earlier answers gave (an order id from 'create order' in 'get order'), with checks at each step. from: run turns a browser run's API calls into one chained scenario, finding the values that flowed from one answer into a later request (no model). from: spec makes, from an API description or contract set, each resource's lifecycle (create, read, update, delete, then 404), browsing (list, then an item), the description's links, and negative cases (unknown id → 404, no credentials → 401/403, empty body → 400/422) (no model). from: cases plans one scenario per test case (Azure DevOps, Jira, Xray, TestRail, Zephyr, qTest, files) and from: text plans one from plain English, both by the model against the set's operations. Shows the scenarios step by step and saves them in the contract set; nothing runs.",
|
|
237
|
+
inputSchema: {
|
|
238
|
+
type: "object",
|
|
239
|
+
properties: {
|
|
240
|
+
from: { type: "string", enum: ["run", "spec", "cases", "text"], description: "run: a browser run; spec: an API description or contract set; cases: test cases; text: plain English" },
|
|
241
|
+
run_dir: { type: "string", description: "from run: the run folder or id (default: the latest run)" },
|
|
242
|
+
spec: { type: "string", description: "Link or file of an API description to plan against (else set_id, else the newest contract set)" },
|
|
243
|
+
set_id: { type: "string", description: "Contract set to plan against and save into (default: the newest)" },
|
|
244
|
+
cases: { type: "array", items: { type: "string" }, description: "from cases: test case references as find_test_cases takes them (ado:2238, ado:plan/12/suite/34, jira:SHOP-12, testrail:C12, a file path)" },
|
|
245
|
+
text: { type: "string", description: "from text: the scenario in plain English, or a story's text and acceptance criteria" },
|
|
246
|
+
negative: { type: "boolean", description: "from spec: also the negative cases (default true)" },
|
|
247
|
+
name: { type: "string" },
|
|
248
|
+
base_url: { type: "string" },
|
|
249
|
+
},
|
|
250
|
+
required: ["from"],
|
|
251
|
+
},
|
|
252
|
+
async run(args, ctx) {
|
|
253
|
+
const from = String(args.from)
|
|
254
|
+
let set: ContractSet
|
|
255
|
+
let made: Scenario[] = []
|
|
256
|
+
const extra: string[] = []
|
|
257
|
+
let replace: string[] = []
|
|
258
|
+
if (from === "run") {
|
|
259
|
+
const dir = runFolder(args.run_dir, ctx)
|
|
260
|
+
ctx.emit?.({ type: "info", text: `Chaining the API calls of run ${path.basename(dir)}` })
|
|
261
|
+
const thirdParty = ctx.config.api?.thirdParty === true
|
|
262
|
+
set = setFromRun(dir, { id: newSetId(ctx.config, String(args.name || `run-${path.basename(dir)}`)), name: args.name ? String(args.name) : undefined, thirdParty })
|
|
263
|
+
if (args.base_url) set.baseUrl = String(args.base_url)
|
|
264
|
+
const goal = set.name.replace(/^API calls of: /, "")
|
|
265
|
+
const flow = scenarioFromRun(readNetwork(dir), set, { goal, runId: path.basename(dir), thirdParty })
|
|
266
|
+
if (flow) made = [flow]
|
|
267
|
+
} else if (from === "spec") {
|
|
268
|
+
const base = await baseSet(args, ctx)
|
|
269
|
+
set = base.set
|
|
270
|
+
const out = scenariosFromSpec(set, { negative: args.negative !== false })
|
|
271
|
+
made = out.scenarios
|
|
272
|
+
extra.push(...out.notes)
|
|
273
|
+
replace = ["lifecycle", "browse", "linked", "negative"]
|
|
274
|
+
} else if (from === "cases" || from === "text") {
|
|
275
|
+
set = (await baseSet(args, ctx)).set
|
|
276
|
+
const cases = from === "cases" ? await loadCases(list(args.cases), ctx.config.sources) : undefined
|
|
277
|
+
if (from === "cases" && !cases?.length) throw new Error("No test cases found for those references.")
|
|
278
|
+
if (from === "text" && !String(args.text || "").trim()) throw new Error("text is required: the scenario in plain English.")
|
|
279
|
+
const plan = await planScenarios({ config: ctx.config, set, cases, text: args.text ? String(args.text) : undefined, cwd: ctx.cwd, signal: ctx.signal, onEvent: (event) => ctx.emit?.({ type: "agent", event }), open: ctx.openSession })
|
|
280
|
+
made = plan.scenarios
|
|
281
|
+
extra.push(...plan.unmatched.map((u) => `No operation for: ${u}`), ...plan.notRunnable.map((n) => `${n.caseId} not planned: ${n.reason}`))
|
|
282
|
+
} else throw new Error("from must be run, spec, cases, or text.")
|
|
283
|
+
const updated = addScenarios(set, made, replace)
|
|
284
|
+
const saved = saveSet(ctx.config, updated)
|
|
285
|
+
const ids = new Set(made.map((m) => m.id))
|
|
286
|
+
const final = (updated.scenarios || []).filter((s) => ids.has(s.id) || made.includes(s))
|
|
287
|
+
ctx.emit?.({ type: "card", card: scenariosCard(updated, final, saved.dir, ctx.cwd, extra) })
|
|
288
|
+
return {
|
|
289
|
+
set_id: updated.id,
|
|
290
|
+
scenarios: final.map((s) => ({ id: s.id, name: s.name, kind: s.kind, case_id: s.caseId, changes_data: s.writes || undefined, steps: s.steps.map((st) => `${st.method} ${st.path}${st.save ? ` (saves ${Object.keys(st.save).join(", ")})` : ""}`), notes: s.notes })),
|
|
291
|
+
notes: extra.length ? extra : undefined,
|
|
292
|
+
files: { contracts: saved.file, brief: saved.brief },
|
|
293
|
+
next_step: final.length
|
|
294
|
+
? "Show the user the scenarios (they see the card). Offer to run them now with run_api_scenarios (it asks before any that change data) and to write them as end-to-end tests on the repository's existing API helpers with write_api_tests and scenarios."
|
|
295
|
+
: "No scenarios were made: say why from the notes.",
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: "run_api_scenarios",
|
|
301
|
+
description:
|
|
302
|
+
"Run a contract set's API scenarios straight against the API (no browser, no model): each step in order, values passed from one answer to the next, every step's checks, and the cleanup steps when a scenario fails part-way. Scenarios that change data run only after the user agrees. Writes scenario-results.json and scenarios-junit.xml.",
|
|
303
|
+
inputSchema: {
|
|
304
|
+
type: "object",
|
|
305
|
+
properties: {
|
|
306
|
+
set_id: { type: "string", description: "Default: the newest set" },
|
|
307
|
+
only: { type: "array", items: { type: "string" }, description: "Only scenarios whose id, name, or test case id contains one of these" },
|
|
308
|
+
base_url: { type: "string" },
|
|
309
|
+
allow_writes: { type: "boolean", description: "Run scenarios that change data (the user is asked first). Default true: most scenarios create data; the question is the consent." },
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
async run(args, ctx) {
|
|
313
|
+
const { set, dir } = loadSet(ctx.config, args.set_id ? String(args.set_id) : undefined)
|
|
314
|
+
if (!set.scenarios?.length) throw new Error(`Set ${set.id} has no scenarios: make some with api_scenarios.`)
|
|
315
|
+
const only = list(args.only).map((s) => s.toLowerCase())
|
|
316
|
+
const chosen = set.scenarios.filter((sc) => !only.length || only.some((o) => `${sc.id} ${sc.name} ${sc.caseId || ""}`.toLowerCase().includes(o)))
|
|
317
|
+
const baseUrl = args.base_url ? String(args.base_url) : ctx.config.api?.baseUrl || set.baseUrl
|
|
318
|
+
const allowWrites = args.allow_writes !== false && (await confirmWrites(ctx, chosen, baseUrl || "the API"))
|
|
319
|
+
const runDir = path.join(dir, "runs", `scenarios-${new Date().toISOString().replace(/[:.]/g, "-")}`)
|
|
320
|
+
ctx.emit?.({ type: "info", text: `Running ${chosen.length} API scenario${chosen.length === 1 ? "" : "s"} against ${baseUrl || "their own URLs"}` })
|
|
321
|
+
const run = await runScenarios(set, { baseUrl, headers: headers(ctx), allowWrites, only: list(args.only), timeoutSeconds: ctx.config.api?.timeoutSeconds, signal: ctx.signal, dir: runDir })
|
|
322
|
+
ctx.emit?.({ type: "card", card: scenarioRunCard(run, set, ctx.cwd) })
|
|
323
|
+
return {
|
|
324
|
+
counts: run.counts,
|
|
325
|
+
base_url: run.baseUrl,
|
|
326
|
+
failed: run.results
|
|
327
|
+
.filter((r) => r.status === "failed" || r.status === "error")
|
|
328
|
+
.map((r) => ({ scenario: r.name, reason: r.reason, failed_step: r.steps.find((s) => s.status === "failed" || s.status === "error")?.name, checks: r.steps.flatMap((s) => s.checks.filter((c) => !c.ok)), cleanup: r.cleanup?.map((c) => `${c.name}: ${c.status}`) })),
|
|
329
|
+
skipped: run.results.filter((r) => r.status === "skipped").map((r) => ({ scenario: r.name, reason: r.reason })),
|
|
330
|
+
results: path.join(runDir, "scenario-results.json"),
|
|
331
|
+
junit: path.join(runDir, "scenarios-junit.xml"),
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { capability, CAPABILITIES } from "../capabilities/registry.ts"
|
|
2
|
+
import { capabilityDetail, capabilityList } from "../capabilities/render.ts"
|
|
3
|
+
import type { McpTool } from "./tools.ts"
|
|
4
|
+
|
|
5
|
+
/** What ForgeQE can do, from src/capabilities/registry.ts, with whether each capability works in this setup. */
|
|
6
|
+
export const CAPABILITY_TOOLS: McpTool[] = [
|
|
7
|
+
{
|
|
8
|
+
name: "capabilities",
|
|
9
|
+
description:
|
|
10
|
+
"What ForgeQE can do. Without ids: every capability by area, with a summary and whether it works in this setup (ready, partial: works but some parts need setup, needs_setup), and what ForgeQE does not do (out_of_scope). With ids: each capability's details: the tools that do it, example requests, what is missing here and how to set it up, what the request must supply, its limits, and its documentation sections in full. Use it for any question about what ForgeQE can do, whether it supports something, or what a feature needs; nothing runs.",
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
ids: { type: "array", items: { type: "string", enum: CAPABILITIES.map((c) => c.id) }, description: "Capabilities to describe (from the list or the session notes)" },
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
async run(args, ctx) {
|
|
18
|
+
const ids: string[] = Array.isArray(args.ids) ? args.ids.map(String) : []
|
|
19
|
+
if (!ids.length) return capabilityList(ctx.config)
|
|
20
|
+
const unknown = ids.filter((id) => !capability(id))
|
|
21
|
+
if (unknown.length) throw new Error(`No capability ${unknown.join(", ")}. Known: ${CAPABILITIES.map((c) => c.id).join(", ")}.`)
|
|
22
|
+
return { capabilities: ids.map((id) => capabilityDetail(capability(id)!, ctx.config)) }
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Card } from "../card.ts"
|
|
2
|
+
import { casesFrom, type CaseSelector } from "../cases/index.ts"
|
|
3
|
+
import { gatherContext, type Context } from "../cases/context.ts"
|
|
4
|
+
import { checkCases, checkSummary, type CaseCheck } from "../cases/quality.ts"
|
|
5
|
+
import type { TestCase } from "../cases/types.ts"
|
|
6
|
+
import type { AppConfig } from "../config.ts"
|
|
7
|
+
import { readPages } from "../memory.ts"
|
|
8
|
+
import { DEFAULT_THRESHOLDS, releaseReadiness, saveReadiness, type Readiness } from "../cases/readiness.ts"
|
|
9
|
+
import { updateAdoCase } from "../cases/save.ts"
|
|
10
|
+
import { writeAdoWikiPage } from "../cases/wiki.ts"
|
|
11
|
+
import type { McpTool, ToolContext } from "./tools.ts"
|
|
12
|
+
import { notifiedLine, notify, readinessNotice } from "../notify/events.ts"
|
|
13
|
+
|
|
14
|
+
const MARK = { ready: "✓", review: "▲", not_runnable: "✗" } as const
|
|
15
|
+
|
|
16
|
+
/** Page memory, when it is on: every page and element earlier runs met. */
|
|
17
|
+
const pagesOf = (config: AppConfig) => (config.memory?.enabled !== false && config.memory?.dir ? readPages(config.memory.dir) : [])
|
|
18
|
+
|
|
19
|
+
/** At most this many cases get their requirements, documents, and passages gathered in one check. */
|
|
20
|
+
const MAX_CONTEXT_CASES = 15
|
|
21
|
+
|
|
22
|
+
/** The check as a card: each case's verdict, what it tests (its requirement chain), and its findings. */
|
|
23
|
+
export function checkCard(checks: CaseCheck[], context?: Context): Card {
|
|
24
|
+
const body: string[] = []
|
|
25
|
+
for (const c of checks) {
|
|
26
|
+
body.push(`${MARK[c.verdict]} ${c.id} ${c.title}`)
|
|
27
|
+
const cc = context?.cases.get(c.id)
|
|
28
|
+
if (cc?.chain.length) body.push(` tests ${cc.chain.map((id) => { const r = context!.requirements.get(id); return r ? `${r.type} ${id.replace(/^ADO-/, "")} "${r.title}"` : id }).join(" ← ")}`)
|
|
29
|
+
for (const u of cc?.uncovered.slice(0, 3) || []) body.push(` criterion maybe not covered: ${u.length > 100 ? `${u.slice(0, 99)}…` : u}`)
|
|
30
|
+
for (const f of c.findings.slice(0, 6)) body.push(` ${f.step ? `step ${f.step}: ` : ""}${f.message}`)
|
|
31
|
+
if (c.findings.length > 6) body.push(` and ${c.findings.length - 6} more`)
|
|
32
|
+
}
|
|
33
|
+
const bad = checks.some((c) => c.verdict === "not_runnable")
|
|
34
|
+
return { status: bad ? "fail" : checks.some((c) => c.verdict === "review") ? "info" : "ok", title: "Test case check", stats: checkSummary(checks), body }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What the tools return for a check, for the agent to add its own judgement to; with the gathered context when there is one. */
|
|
38
|
+
export function checkResult(checks: CaseCheck[], context?: Context) {
|
|
39
|
+
return {
|
|
40
|
+
summary: checkSummary(checks),
|
|
41
|
+
cases: checks.map((c) => {
|
|
42
|
+
const cc = context?.cases.get(c.id)
|
|
43
|
+
return {
|
|
44
|
+
id: c.id,
|
|
45
|
+
title: c.title,
|
|
46
|
+
link: c.url,
|
|
47
|
+
verdict: c.verdict,
|
|
48
|
+
findings: c.findings,
|
|
49
|
+
...(cc
|
|
50
|
+
? {
|
|
51
|
+
tests_requirements: cc.chain.length ? cc.chain : undefined,
|
|
52
|
+
criteria_maybe_not_covered: cc.uncovered.length ? cc.uncovered : undefined,
|
|
53
|
+
documents: cc.docs.length ? cc.docs : undefined,
|
|
54
|
+
passages: cc.passages.length ? cc.passages : undefined,
|
|
55
|
+
context_errors: cc.errors.length ? cc.errors : undefined,
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
58
|
+
}
|
|
59
|
+
}),
|
|
60
|
+
...(context?.requirements.size ? { requirements: Object.fromEntries(context.requirements) } : {}),
|
|
61
|
+
...(context?.docs.size ? { documents: Object.fromEntries([...context.docs].map(([url, d]) => [url, { title: d.title, text: d.text }])) } : {}),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Check cases with the rules, show the card, and (with suite.case_check: block) say which ones not to run. */
|
|
66
|
+
export function precheck(cases: Parameters<typeof checkCases>[0], ctx: ToolContext): { checks: CaseCheck[]; skip: Set<string> } {
|
|
67
|
+
const mode = ctx.config.suite.caseCheck || "warn"
|
|
68
|
+
if (mode === "off") return { checks: [], skip: new Set() }
|
|
69
|
+
const checks = checkCases(cases, pagesOf(ctx.config))
|
|
70
|
+
if (checks.some((c) => c.verdict !== "ready")) ctx.emit?.({ type: "card", card: checkCard(checks) })
|
|
71
|
+
const skip = new Set(mode === "block" ? checks.filter((c) => c.verdict === "not_runnable").map((c) => c.id) : [])
|
|
72
|
+
return { checks, skip }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const CASE_TOOLS: McpTool[] = [
|
|
76
|
+
{
|
|
77
|
+
name: "check_test_cases",
|
|
78
|
+
description:
|
|
79
|
+
"Check test cases before running them, without running anything. Rules: no steps, no expected results, vague expected results (\"works correctly\"), steps without the test data they need, unfilled placeholders, credentials in the case, several actions in one step, no start page, duplicates. Page memory: steps naming a field or control earlier runs never saw on that page. Context: the requirements each case tests (its story or PBI, then feature and epic, with acceptance criteria; Jira's linked issues and parents), the Confluence pages they link to, and passages from the RAG service when one is configured, with acceptance criteria no step seems to cover. Returns a verdict per case with findings and the context, and shows a card; judge the cases against their requirements.",
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
sources: { type: "array", description: "The same sources as find_test_cases", items: { type: "object" } },
|
|
84
|
+
depth: { type: "string", enum: ["full", "quick"], description: "full (default): also read the requirements each case tests (story, feature, epic), their linked Confluence pages, and the RAG service when configured. quick: rules and page memory only, no reads" },
|
|
85
|
+
},
|
|
86
|
+
required: ["sources"],
|
|
87
|
+
},
|
|
88
|
+
async run(args, ctx) {
|
|
89
|
+
const cases: TestCase[] = await casesFrom(args.sources as CaseSelector[], ctx.config.sources, ctx.cwd)
|
|
90
|
+
const checks = checkCases(cases, pagesOf(ctx.config))
|
|
91
|
+
const full = args.depth !== "quick"
|
|
92
|
+
if (full) ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text: `Reading what ${cases.length === 1 ? "the case tests" : "the cases test"}: requirements above ${cases.length === 1 ? "it" : "them"}, linked documents${ctx.config.rag ? ", and the RAG service" : ""}…` } })
|
|
93
|
+
const context = full ? await gatherContext(cases.slice(0, MAX_CONTEXT_CASES), ctx.config) : undefined
|
|
94
|
+
ctx.emit?.({ type: "card", card: checkCard(checks, context) })
|
|
95
|
+
return {
|
|
96
|
+
...checkResult(checks, context),
|
|
97
|
+
...(full && cases.length > MAX_CONTEXT_CASES ? { note: `Requirements were read for the first ${MAX_CONTEXT_CASES} cases; check the rest in smaller groups.` } : {}),
|
|
98
|
+
next_step: full
|
|
99
|
+
? "For each case, judge it against what it tests: the acceptance criteria of its requirements (tests_requirements, nearest first), the linked documents, and the passages. Say which criteria no step covers, steps that check something no requirement asks for, expected results that contradict the requirement, and steps the page memory findings show the application does not have. Then what to fix, in plain words. Offer to rewrite a case and save it with update_test_case."
|
|
100
|
+
: checks.some((c) => c.verdict !== "ready")
|
|
101
|
+
? "Tell the user what to fix in each case, in plain words, with your own findings added. Offer to rewrite a case and save it with update_test_case."
|
|
102
|
+
: "The cases are ready to run.",
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "update_test_case",
|
|
108
|
+
description:
|
|
109
|
+
"Rewrite an Azure DevOps Test Case's steps (and optionally its title) after the user agrees: ForgeQE shows the current and the new steps and asks (Update / Don't update); the reason goes into the work item's history, and the old steps stay there. 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), or a case check_test_cases found unclear. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed to these exact steps.",
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {
|
|
113
|
+
id: { type: "string", description: "The test case: ADO-2236, 2236, or its link" },
|
|
114
|
+
title: { type: "string", description: "A new title, only when the old one no longer fits" },
|
|
115
|
+
steps: {
|
|
116
|
+
type: "array",
|
|
117
|
+
items: { type: "object", properties: { action: { type: "string" }, expected: { type: "string" } }, required: ["action"] },
|
|
118
|
+
description: "Every step of the case as it should be, each with the expected result the page shows",
|
|
119
|
+
},
|
|
120
|
+
reason: { type: "string", description: "Why, in one sentence, e.g. 'The page has no email field; the form is the automation practice form.'" },
|
|
121
|
+
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to these steps" },
|
|
122
|
+
},
|
|
123
|
+
required: ["id", "steps", "reason"],
|
|
124
|
+
},
|
|
125
|
+
async run(args, ctx) {
|
|
126
|
+
const id = Number(/(\d+)\s*$/.exec(String(args.id).replace(/[/?#].*$/, ""))?.[1] || /edit\/(\d+)/.exec(String(args.id))?.[1])
|
|
127
|
+
if (!id) throw new Error(`"${args.id}" is not an Azure DevOps test case id.`)
|
|
128
|
+
if (!ctx.config.sources.ado) throw new Error("Azure DevOps is not set up: updating test cases works for Azure DevOps Test Cases.")
|
|
129
|
+
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
|
+
if (!steps.length) throw new Error("steps is empty.")
|
|
131
|
+
const [current] = await casesFrom([{ source: "azure_devops", ids: [String(id)] } as CaseSelector], ctx.config.sources, ctx.cwd)
|
|
132
|
+
const show = (list: { action: string; expected?: string }[]) => list.map((s, i) => ` ${i + 1}. ${s.action}${s.expected ? ` → ${s.expected}` : ""}`)
|
|
133
|
+
const lines = ["Now:", ...show(current?.steps || []), "", "New:", ...show(steps), "", `Why: ${args.reason}`]
|
|
134
|
+
if (ctx.ask) {
|
|
135
|
+
const choice = await ctx.ask({
|
|
136
|
+
title: `Update test case ${id}${args.title ? ` and rename it "${args.title}"` : ""}?`,
|
|
137
|
+
detail: current?.url,
|
|
138
|
+
lines,
|
|
139
|
+
options: [
|
|
140
|
+
{ id: "update", label: "Update test case" },
|
|
141
|
+
{ id: "no", label: "Don't update" },
|
|
142
|
+
],
|
|
143
|
+
})
|
|
144
|
+
if (choice !== "update") return { updated: false, reason: "The user chose not to update the test case." }
|
|
145
|
+
} else if (args.confirmed !== true) {
|
|
146
|
+
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
|
+
}
|
|
148
|
+
const url = await updateAdoCase(ctx.config.sources, id, { title: args.title ? String(args.title) : undefined, steps, reason: String(args.reason) })
|
|
149
|
+
ctx.emit?.({ type: "card", card: { status: "ok", title: `Test case ${id} updated`, stats: `${steps.length} steps`, body: [...show(steps), "", `Why: ${args.reason}`, url] } })
|
|
150
|
+
return { updated: true, url, steps: steps.length, next_step: "Offer to run it again with run_test_cases." }
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "release_readiness",
|
|
155
|
+
description:
|
|
156
|
+
"Requirement coverage and release readiness for an Azure DevOps scope: a sprint (or 'current'), an area path, an epic or feature and everything under it, or work item ids. For each story: its test cases, whether each is automated, and its last result (Azure DevOps test points, else ForgeQE's own runs); stories with no test case, cases not automated, open bugs by severity, flaky and quarantined tests; and a verdict, GO, GO WITH RISKS, or NO-GO, with the blocking reasons and the risks (thresholds in forgeqe.yaml under release:). Saves readiness.md and shows a card; with publish, writes it to the project wiki after the user confirms.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
sprint: { type: "string", description: "A sprint name, or 'current'" },
|
|
161
|
+
area: { type: "string", description: "An area path, e.g. QI-Claims\\Portal" },
|
|
162
|
+
parent: { type: "number", description: "An epic or feature id: every story under it" },
|
|
163
|
+
ids: { type: "array", items: { type: "number" }, description: "Story or PBI ids" },
|
|
164
|
+
project: { type: "string", description: "The project, when sources.azure_devops.project is not set" },
|
|
165
|
+
publish: { type: "string", description: "A wiki page path to publish the report to, e.g. /Release readiness/Sprint 12 (asks first)" },
|
|
166
|
+
confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to publish" },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
async run(args, ctx) {
|
|
170
|
+
if (!ctx.config.sources.ado) throw new Error("Release readiness reads Azure DevOps: set up sources.azure_devops first.")
|
|
171
|
+
const scope = {
|
|
172
|
+
...(args.sprint ? { sprint: String(args.sprint) } : {}),
|
|
173
|
+
...(args.area ? { area: String(args.area) } : {}),
|
|
174
|
+
...(args.parent ? { parent: Number(args.parent) } : {}),
|
|
175
|
+
...(Array.isArray(args.ids) && args.ids.length ? { ids: (args.ids as unknown[]).map(Number).filter(Boolean) } : {}),
|
|
176
|
+
...(args.project ? { project: String(args.project) } : {}),
|
|
177
|
+
}
|
|
178
|
+
ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text: "Reading stories, test cases, results, and bugs…" } })
|
|
179
|
+
const r = await releaseReadiness(ctx.config, ctx.cwd, scope, ctx.config.release || DEFAULT_THRESHOLDS)
|
|
180
|
+
const saved = saveReadiness(ctx.config, r)
|
|
181
|
+
ctx.emit?.({ type: "card", card: readinessCard(r, saved.dir) })
|
|
182
|
+
let published: string | undefined
|
|
183
|
+
if (args.publish) {
|
|
184
|
+
const pagePath = String(args.publish).startsWith("/") ? String(args.publish) : `/${args.publish}`
|
|
185
|
+
let go = args.confirmed === true
|
|
186
|
+
if (ctx.ask) go = (await ctx.ask({ title: `Publish the report to the wiki page ${pagePath}?`, lines: [`${r.verdict} · ${r.scope}`], options: [{ id: "yes", label: "Publish" }, { id: "no", label: "Don't publish" }] })) === "yes"
|
|
187
|
+
if (go) published = (await writeAdoWikiPage(ctx.config.sources, { path: pagePath, content: saved.markdown, ...(scope.project ? { project: scope.project } : {}) })).url
|
|
188
|
+
}
|
|
189
|
+
const notified = ctx.session === "shell" ? [] : await notify(ctx.config, "release", readinessNotice(r, ctx.config, { file: `${saved.dir}/readiness.md`, published }))
|
|
190
|
+
return {
|
|
191
|
+
...(notified.length ? { notified: notifiedLine(notified) } : {}),
|
|
192
|
+
scope: r.scope,
|
|
193
|
+
verdict: r.verdict,
|
|
194
|
+
blocking: r.blockers,
|
|
195
|
+
risks: r.risks,
|
|
196
|
+
counts: r.counts,
|
|
197
|
+
pass_rate: r.passRate,
|
|
198
|
+
untested_stories: r.stories.filter((s) => !s.cases.length).map((s) => ({ id: s.id, title: s.title })),
|
|
199
|
+
failing: r.stories.flatMap((s) => s.cases.filter((c) => c.outcome === "failed").map((c) => ({ story: s.id, case: c.id, title: c.title }))),
|
|
200
|
+
open_bugs: r.bugs.map((b) => ({ id: b.id, title: b.title, severity: b.severityText })),
|
|
201
|
+
report: `${saved.dir}/readiness.md`,
|
|
202
|
+
...(published ? { published } : {}),
|
|
203
|
+
notes: r.notes.length ? r.notes : undefined,
|
|
204
|
+
next_step: "Give the verdict first, then what blocks it and the risks, in plain words. Offer to publish to the wiki (publish), to post the verdict to the team's notification channels (notify, when this session has them), to run the cases not run (run_test_cases), and an Azure DevOps dashboard (create_ado_dashboard) for the team.",
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
function readinessCard(r: Readiness, dir: string): Card {
|
|
211
|
+
const c = r.counts
|
|
212
|
+
return {
|
|
213
|
+
status: r.verdict === "GO" ? "ok" : r.verdict === "NO-GO" ? "fail" : "info",
|
|
214
|
+
title: `${r.verdict}: ${r.scope}`,
|
|
215
|
+
stats: `${c.stories} stories · ${c.cases} test cases · ${c.automated} automated · pass rate ${r.passRate === null ? "–" : `${r.passRate}%`} · ${r.bugs.length} open bugs`,
|
|
216
|
+
body: [
|
|
217
|
+
...r.blockers.map((b) => `✗ ${b}`),
|
|
218
|
+
...r.risks.map((b) => `▲ ${b}`),
|
|
219
|
+
...(r.blockers.length || r.risks.length ? [""] : []),
|
|
220
|
+
`Report ${dir}/readiness.md`,
|
|
221
|
+
],
|
|
222
|
+
}
|
|
223
|
+
}
|