@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
package/src/mcp/tools.ts
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { AgentEvent, CodeEvent, CodeResult, ReplayMode, RunResult } from "../types.ts"
|
|
5
|
+
import type { Card } from "../card.ts"
|
|
6
|
+
import { privateEnvNames } from "../childEnv.ts"
|
|
7
|
+
import { usageLine } from "../usage.ts"
|
|
8
|
+
import { queryApi } from "../cases/api.ts"
|
|
9
|
+
import { createAdoDashboard, type DashboardWidget } from "../cases/dashboards.ts"
|
|
10
|
+
import { writeAdoWikiPage } from "../cases/wiki.ts"
|
|
11
|
+
import { readConfluence } from "../cases/confluence.ts"
|
|
12
|
+
import { casesFrom, type CaseSelector, type TestCase } from "../cases/index.ts"
|
|
13
|
+
import { saveAdoCases, saveJiraCases, type NewCase } from "../cases/save.ts"
|
|
14
|
+
import { buildHandoff } from "../code/handoff.ts"
|
|
15
|
+
import { latestRunDir, runCodeAgent } from "../code/index.ts"
|
|
16
|
+
import type { OpencodeSession } from "../code/runtime.ts"
|
|
17
|
+
import { runTestCommand } from "../code/testrun.ts"
|
|
18
|
+
import type { AppConfig } from "../config.ts"
|
|
19
|
+
import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
|
|
20
|
+
import { executeRun, runDirectory } from "../run.ts"
|
|
21
|
+
import { counts } from "../suite/report.ts"
|
|
22
|
+
import { runSuite, type SuiteEvent, type SuiteResult } from "../suite/run.ts"
|
|
23
|
+
import { COMMAND_TOOLS, TASK_FILE } from "./commands.ts"
|
|
24
|
+
import { PR_TOOLS } from "./prtools.ts"
|
|
25
|
+
import { CASE_TOOLS, checkResult, precheck } from "./casetools.ts"
|
|
26
|
+
import { REPO_TEST_TOOLS } from "./repotests.ts"
|
|
27
|
+
import { API_TOOLS } from "./apitools.ts"
|
|
28
|
+
import { EXPLORE_TOOLS } from "./exploretools.ts"
|
|
29
|
+
import { QUALITY_TOOLS } from "./qualitytools.ts"
|
|
30
|
+
import { CAPABILITY_TOOLS } from "./captools.ts"
|
|
31
|
+
import { NOTIFY_TOOLS } from "./notifytools.ts"
|
|
32
|
+
import { capabilityIndex } from "../capabilities/render.ts"
|
|
33
|
+
import { domainNotes } from "../domain.ts"
|
|
34
|
+
import { readNetwork } from "../api/traffic.ts"
|
|
35
|
+
import { qualityLine } from "../quality/budgets.ts"
|
|
36
|
+
import { knowledgeIndex, readKnowledge } from "../knowledge.ts"
|
|
37
|
+
import { allLogins, sessionLogins } from "../sessionLogins.ts"
|
|
38
|
+
import { catalogLines, loadSkills, skillsFor } from "../skills/registry.ts"
|
|
39
|
+
import { buildCodeSummary, codeCard } from "../summary.ts"
|
|
40
|
+
import { pushBranch, setCiSecret } from "./git.ts"
|
|
41
|
+
import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
|
|
42
|
+
import { notifiedLine, notifySuite } from "../notify/events.ts"
|
|
43
|
+
|
|
44
|
+
/** Hosts of the systems ForgeQE reads test cases and work items from: browsing them is a task, never a flow to test. */
|
|
45
|
+
function toolHosts(cfg: AppConfig): string[] {
|
|
46
|
+
const s = cfg.sources
|
|
47
|
+
const urls = [s?.ado?.orgUrl, s?.jira?.url, s?.confluence?.url, s?.testrail?.url, s?.zephyr?.url, s?.qtest?.url, s?.xray?.url]
|
|
48
|
+
const hosts = urls.flatMap((u) => {
|
|
49
|
+
try {
|
|
50
|
+
return u ? [new URL(u).hostname.toLowerCase()] : []
|
|
51
|
+
} catch {
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
return [...new Set([...hosts, "dev.azure.com", "visualstudio.com"])]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Why a browser_run is a task rather than a flow to automate, or "" when it is a test. */
|
|
59
|
+
export function taskReason(purpose: unknown, goal: string, urls: (string | undefined)[], cfg: AppConfig): string {
|
|
60
|
+
if (purpose === "task") return "the run was a task, not a flow to test"
|
|
61
|
+
const hosts = toolHosts(cfg)
|
|
62
|
+
const seen = [...urls, ...(goal.match(/https?:\/\/[^\s"'<>)]+/g) || [])].flatMap((u) => {
|
|
63
|
+
try {
|
|
64
|
+
return u ? [new URL(u).hostname.toLowerCase()] : []
|
|
65
|
+
} catch {
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
const hit = seen.find((h) => hosts.some((t) => h === t || h.endsWith(`.${t}`)))
|
|
70
|
+
return hit ? `the run was in ${hit}, a system ForgeQE reads test cases and work items from, not the app under test` : ""
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The goal a run folder's run.json records, or "" when it has none. */
|
|
74
|
+
function goalOf(runDir: string): string {
|
|
75
|
+
try {
|
|
76
|
+
return (JSON.parse(readFileSync(path.join(runDir, "run.json"), "utf8")) as { goal?: string }).goal || ""
|
|
77
|
+
} catch {
|
|
78
|
+
return ""
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What a tool is doing, for whoever shows it (the shell transcript, or stderr for `forgeqe mcp`). */
|
|
83
|
+
export type ToolEvent =
|
|
84
|
+
| { type: "run-start"; goal: string; url?: string }
|
|
85
|
+
| { type: "run-event"; event: AgentEvent }
|
|
86
|
+
| { type: "run-done"; result?: RunResult; error?: string; files: string[]; runDir: string; ms: number }
|
|
87
|
+
| { type: "suite-start"; cases: TestCase[]; parallel: number }
|
|
88
|
+
| { type: "suite-event"; event: SuiteEvent }
|
|
89
|
+
| { type: "suite-done"; suite: SuiteResult }
|
|
90
|
+
| { type: "info"; text: string; ok?: boolean }
|
|
91
|
+
/** What a coding or healing agent started by a tool is doing. */
|
|
92
|
+
| { type: "agent"; event: CodeEvent }
|
|
93
|
+
/** write_test hands a passed browser run to the coding agent, and what came of it. */
|
|
94
|
+
| { type: "code-start"; runDir: string; repo: string; goal: string }
|
|
95
|
+
| { type: "code-done"; result?: CodeResult; error?: string; ms: number }
|
|
96
|
+
/** A tool's result to show as a panel (a test written, tests healed, a monitoring round). */
|
|
97
|
+
| { type: "card"; card: Card }
|
|
98
|
+
|
|
99
|
+
export type ToolContext = {
|
|
100
|
+
config: AppConfig
|
|
101
|
+
/** The repository or folder the agent works in. */
|
|
102
|
+
cwd: string
|
|
103
|
+
headed?: boolean
|
|
104
|
+
maxSteps?: number
|
|
105
|
+
replay?: ReplayMode
|
|
106
|
+
memory?: boolean
|
|
107
|
+
/** Start URL the user pinned for this session. */
|
|
108
|
+
url?: string
|
|
109
|
+
signal?: AbortSignal
|
|
110
|
+
emit?: (e: ToolEvent) => void
|
|
111
|
+
/** How long this process lives: the shell, one `forgeqe run`, or `forgeqe mcp`. Background monitors need shell or server. */
|
|
112
|
+
session?: "shell" | "once" | "server"
|
|
113
|
+
/** Passed browser runs become tests in cwd (the shell and `forgeqe run` without --no-code). */
|
|
114
|
+
writeTests?: boolean
|
|
115
|
+
/** Ask the user to do something by hand, then continue (true) or cancel (false). Missing where nobody can answer. */
|
|
116
|
+
waitForUser?: (prompt: string) => Promise<boolean>
|
|
117
|
+
/** Ask the user to pick one option; the chosen id, or null when they cancel. Missing where nobody can answer. */
|
|
118
|
+
ask?: (q: AskQuestion) => Promise<string | null>
|
|
119
|
+
/** Replaces executeRun in tests. */
|
|
120
|
+
execute?: typeof executeRun
|
|
121
|
+
/** Replaces openSession for write_test and heal_tests in tests. */
|
|
122
|
+
openSession?: (o: any) => Promise<OpencodeSession<any>>
|
|
123
|
+
/** Replaces write_test's confirming run in tests. */
|
|
124
|
+
runTest?: typeof runTestCommand
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `lines`: context shown above the choice (the list being confirmed, sprint dates). */
|
|
128
|
+
export type AskQuestion = { title: string; detail?: string; lines?: string[]; options: { id: string; label: string }[] }
|
|
129
|
+
|
|
130
|
+
export type McpTool = {
|
|
131
|
+
name: string
|
|
132
|
+
description: string
|
|
133
|
+
inputSchema: Record<string, unknown>
|
|
134
|
+
run(args: Record<string, any>, ctx: ToolContext): Promise<unknown>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const clip = (s: string | undefined, n: number) => {
|
|
138
|
+
const t = (s || "").trim()
|
|
139
|
+
return t.length <= n ? t : `${t.slice(0, n - 1)}…`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const SKILLS_SCHEMA = {
|
|
143
|
+
type: "array",
|
|
144
|
+
items: { type: "string" },
|
|
145
|
+
description:
|
|
146
|
+
"Names of the browser skills (listed in this session's notes) the goal clearly needs, for example a playbook for the site or widget it uses; [] when none fits. Leave it out to let ForgeQE choose.",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The `skills` argument as names; undefined when the caller left it out. */
|
|
150
|
+
function skillNames(raw: unknown): string[] | undefined {
|
|
151
|
+
if (!Array.isArray(raw)) return undefined
|
|
152
|
+
return [...new Set(raw.map((n) => String(n).trim()).filter(Boolean))]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const SOURCES_SCHEMA = {
|
|
156
|
+
type: "array",
|
|
157
|
+
minItems: 1,
|
|
158
|
+
description: "Where the test cases are. Several entries mix sources.",
|
|
159
|
+
items: {
|
|
160
|
+
type: "object",
|
|
161
|
+
properties: {
|
|
162
|
+
source: {
|
|
163
|
+
type: "string",
|
|
164
|
+
enum: ["azure_devops", "jira", "xray", "testrail", "zephyr", "qtest", "file", "text"],
|
|
165
|
+
description:
|
|
166
|
+
"azure_devops: Test Case work items or a Test Plan. jira: issues. xray: Xray Test issues or a Test Plan. testrail: cases, a run, a plan, or a suite. zephyr: Zephyr Scale test cases, a test cycle, or a folder. qtest: qTest test cases, a test cycle, or a suite. file: .txt/.md/.csv/.yaml/.json/.feature files. text: test case text the user wrote (also cases you wrote from requirements).",
|
|
167
|
+
},
|
|
168
|
+
ids: {
|
|
169
|
+
type: "array",
|
|
170
|
+
items: { type: "string" },
|
|
171
|
+
description: "Work item ids (azure_devops), issue keys (jira, xray), case ids like C12 (testrail), keys like SHOP-T3 (zephyr), or test case ids (qtest)",
|
|
172
|
+
},
|
|
173
|
+
plan: { type: "string", description: "Test plan id (azure_devops, testrail) or Test Plan issue key (xray)" },
|
|
174
|
+
suite: { type: "string", description: "Suite id: inside the Azure DevOps plan (omit for every suite), a TestRail suite, or a qTest test suite" },
|
|
175
|
+
run: { type: "string", description: "testrail: run id; results go to that run" },
|
|
176
|
+
cycle: { type: "string", description: "zephyr: test cycle key like SHOP-R5. qtest: test cycle id" },
|
|
177
|
+
folder: { type: "string", description: "zephyr: folder id" },
|
|
178
|
+
jql: { type: "string", description: "JQL query (jira, xray)" },
|
|
179
|
+
paths: { type: "array", items: { type: "string" }, description: "Files, relative to the working directory" },
|
|
180
|
+
text: { type: "string", description: "The test case itself: title on the first line, then steps and expected results" },
|
|
181
|
+
},
|
|
182
|
+
required: ["source"],
|
|
183
|
+
},
|
|
184
|
+
} as const
|
|
185
|
+
|
|
186
|
+
/** How many of the application's API calls the run recorded. */
|
|
187
|
+
function apiCalls(runDir: string): number {
|
|
188
|
+
return readNetwork(runDir).filter((c) => !c.third_party).length
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function summarizeRun(result: RunResult, runDir: string, files: string[]) {
|
|
192
|
+
return {
|
|
193
|
+
status: result.status === "complete" && !result.budgetExceeded?.length ? "passed" : result.status === "fail" || result.status === "complete" ? "failed" : "stopped",
|
|
194
|
+
...(result.budgetExceeded?.length ? { budget_exceeded: result.budgetExceeded, meaning_of_budget: "The steps passed, but a page is over an accessibility or performance budget in forgeqe.yaml, so the run fails. A test can still be written for it." } : {}),
|
|
195
|
+
...(result.quality ? { quality: qualityLine(result.quality) } : {}),
|
|
196
|
+
...(result.outcome === "assertion_failed"
|
|
197
|
+
? {
|
|
198
|
+
outcome: "assertion_failed",
|
|
199
|
+
meaning: "The steps were done and an expected result does not hold: the test case failed, which is a valid result to report. Do not retry it, change the expectation, or treat it as a ForgeQE problem.",
|
|
200
|
+
failed_expectations: (result.failedChecks || []).map((c) => ({ expected: c.expected, page_shows: c.actual })),
|
|
201
|
+
}
|
|
202
|
+
: {}),
|
|
203
|
+
summary: result.summary,
|
|
204
|
+
final_url: result.finalUrl,
|
|
205
|
+
run_dir: runDir,
|
|
206
|
+
files: files.map((f) => path.basename(f)),
|
|
207
|
+
...(apiCalls(runDir) ? { api_calls: `${apiCalls(runDir)} API calls recorded (network.json): api_contracts from run turns them into API tests` } : {}),
|
|
208
|
+
tokens: result.usage.input + result.usage.output,
|
|
209
|
+
usage: usageLine(result.usage, result.cost),
|
|
210
|
+
memory: result.memory ? { replayed: result.memory.replayed, llm_steps: result.memory.llmSteps, flow: result.memory.flow } : undefined,
|
|
211
|
+
driver: result.driver,
|
|
212
|
+
...(result.skills?.length ? { skills: result.skills } : {}),
|
|
213
|
+
...(result.status === "fail" && result.memory?.resume
|
|
214
|
+
? {
|
|
215
|
+
resume: {
|
|
216
|
+
steps_that_worked: result.memory.resume.lines.map((l, i) => `${i + 1}. ${clip(l, 160)}`),
|
|
217
|
+
went_wrong_at_step: result.memory.resume.failedAt,
|
|
218
|
+
next_step:
|
|
219
|
+
`To retry, call browser_run with resume_from "${runDir}": these steps are replayed without the model and the agent carries on from there. ` +
|
|
220
|
+
"Keep the same site and values, and say in the goal what to do differently from the failed attempt.",
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
: {}),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const TOOLS: McpTool[] = [
|
|
228
|
+
{
|
|
229
|
+
name: "browser_run",
|
|
230
|
+
description:
|
|
231
|
+
"Carry out a goal in a real browser with ForgeQE's browser agent and verify the outcome. The agent picks the site from the goal unless start_url is given. Returns pass/fail, the agent's summary, the steps, and the run folder (run.json, workflow.yaml with ranked locators, trace.md, steps and screenshots, report.html). Takes minutes.",
|
|
232
|
+
inputSchema: {
|
|
233
|
+
type: "object",
|
|
234
|
+
properties: {
|
|
235
|
+
goal: {
|
|
236
|
+
type: "string",
|
|
237
|
+
description:
|
|
238
|
+
"The final instructions for the browser agent: what to do and what to check, in plain language, with every value it needs. Only the decided task; never your own questions, alternatives, or reasoning about it.",
|
|
239
|
+
},
|
|
240
|
+
start_url: { type: "string", description: "Page to open first, only when the user named one" },
|
|
241
|
+
resume_from: {
|
|
242
|
+
type: "string",
|
|
243
|
+
description:
|
|
244
|
+
"run_dir of a failed browser_run that returned resume steps: replay the steps that worked there, without the model, then carry on with this goal",
|
|
245
|
+
},
|
|
246
|
+
skills: SKILLS_SCHEMA,
|
|
247
|
+
purpose: {
|
|
248
|
+
type: "string",
|
|
249
|
+
enum: ["test", "task"],
|
|
250
|
+
description:
|
|
251
|
+
"test: a user flow of the app under test, to verify and turn into an automated test. task: anything else done in a browser, such as looking up or linking work items, checking a relationship, or reading data from a tool; no test is written for it.",
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
required: ["goal", "purpose"],
|
|
255
|
+
},
|
|
256
|
+
async run(args, ctx) {
|
|
257
|
+
const goal = String(args.goal || "").trim()
|
|
258
|
+
if (!goal) throw new Error("goal is required")
|
|
259
|
+
const from = args.resume_from ? String(args.resume_from) : ""
|
|
260
|
+
const resumeFrom = from ? (existsSync(path.resolve(ctx.cwd, from)) ? path.resolve(ctx.cwd, from) : from) : undefined
|
|
261
|
+
const url = args.start_url ? String(args.start_url) : ctx.url
|
|
262
|
+
const runDir = runDirectory(ctx.config.export.outDir)
|
|
263
|
+
const emit = ctx.emit || (() => {})
|
|
264
|
+
const t0 = Date.now()
|
|
265
|
+
emit({ type: "run-start", goal, url })
|
|
266
|
+
try {
|
|
267
|
+
const { result, files } = await (ctx.execute || executeRun)({
|
|
268
|
+
config: ctx.config,
|
|
269
|
+
goal,
|
|
270
|
+
url,
|
|
271
|
+
headed: ctx.headed,
|
|
272
|
+
maxSteps: ctx.maxSteps,
|
|
273
|
+
outDir: runDir,
|
|
274
|
+
replay: ctx.replay,
|
|
275
|
+
memory: ctx.memory,
|
|
276
|
+
resumeFrom,
|
|
277
|
+
skills: skillNames(args.skills),
|
|
278
|
+
repo: ctx.cwd,
|
|
279
|
+
signal: ctx.signal,
|
|
280
|
+
onEvent: (event) => emit({ type: "run-event", event }),
|
|
281
|
+
})
|
|
282
|
+
emit({ type: "run-done", result, files, runDir, ms: Date.now() - t0 })
|
|
283
|
+
const summary = summarizeRun(result, runDir, files)
|
|
284
|
+
const task = taskReason(args.purpose, goal, [url, result.startUrl, result.finalUrl], ctx.config)
|
|
285
|
+
if (task) {
|
|
286
|
+
writeFileSync(path.join(runDir, TASK_FILE), `${JSON.stringify({ reason: task }, null, 2)}\n`)
|
|
287
|
+
return summary
|
|
288
|
+
}
|
|
289
|
+
if (ctx.writeTests && result.status === "complete" && result.driver !== "synthetic") {
|
|
290
|
+
return { ...summary, next_step: `This session turns passed runs into tests: call write_test with run_dir ${runDir} now, before you reply.` }
|
|
291
|
+
}
|
|
292
|
+
return summary
|
|
293
|
+
} catch (err) {
|
|
294
|
+
emit({ type: "run-done", error: (err as Error).message, files: [], runDir, ms: Date.now() - t0 })
|
|
295
|
+
throw err
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: "find_test_cases",
|
|
301
|
+
description:
|
|
302
|
+
"Read test cases from Azure DevOps, Jira, Xray, TestRail, Zephyr Scale, qTest, files, or text, without running them. Returns each case's id, title, start URL, steps with expected results, and link.",
|
|
303
|
+
inputSchema: { type: "object", properties: { sources: SOURCES_SCHEMA }, required: ["sources"] },
|
|
304
|
+
async run(args, ctx) {
|
|
305
|
+
const cases = await casesFrom(args.sources as CaseSelector[], ctx.config.sources, ctx.cwd)
|
|
306
|
+
return {
|
|
307
|
+
count: cases.length,
|
|
308
|
+
cases: cases.map((c) => ({
|
|
309
|
+
id: c.id,
|
|
310
|
+
title: c.title,
|
|
311
|
+
source: c.source,
|
|
312
|
+
link: c.url,
|
|
313
|
+
start_url: c.startUrl,
|
|
314
|
+
description: clip(c.description, 600) || undefined,
|
|
315
|
+
preconditions: c.preconditions,
|
|
316
|
+
steps: c.steps.map((s) => ({ action: clip(s.action, 300), data: s.data, expected: s.expected })),
|
|
317
|
+
})),
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "save_test_cases",
|
|
323
|
+
description:
|
|
324
|
+
"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). A case whose title matches one already linked to the story is reused, not duplicated. Returns each case's id and link, and shows them as a card.",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
type: "object",
|
|
327
|
+
properties: {
|
|
328
|
+
system: { type: "string", enum: ["azure_devops", "jira"], description: "Where the story is" },
|
|
329
|
+
story: { type: "string", description: "The story's work item id (Azure DevOps) or issue key (Jira)" },
|
|
330
|
+
cases: {
|
|
331
|
+
type: "array",
|
|
332
|
+
items: {
|
|
333
|
+
type: "object",
|
|
334
|
+
properties: {
|
|
335
|
+
title: { type: "string" },
|
|
336
|
+
steps: {
|
|
337
|
+
type: "array",
|
|
338
|
+
items: { type: "object", properties: { action: { type: "string" }, expected: { type: "string" } }, required: ["action"] },
|
|
339
|
+
},
|
|
340
|
+
preconditions: { type: "array", items: { type: "string" } },
|
|
341
|
+
description: { type: "string" },
|
|
342
|
+
},
|
|
343
|
+
required: ["title", "steps"],
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
issue_type: { type: "string", description: "Jira only: issue type for the cases (default Test)" },
|
|
347
|
+
link_type: { type: "string", description: "Jira only: link type to the story (default Relates)" },
|
|
348
|
+
},
|
|
349
|
+
required: ["system", "story", "cases"],
|
|
350
|
+
},
|
|
351
|
+
async run(args, ctx) {
|
|
352
|
+
const cases = (Array.isArray(args.cases) ? args.cases : []) as NewCase[]
|
|
353
|
+
if (!cases.length) throw new Error("cases is empty.")
|
|
354
|
+
const story = String(args.story || "").trim()
|
|
355
|
+
if (!story) throw new Error("story is required.")
|
|
356
|
+
const saved =
|
|
357
|
+
args.system === "jira"
|
|
358
|
+
? 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 })
|
|
359
|
+
: await saveAdoCases(ctx.config.sources, Number(story.replace(/^#/, "")), cases)
|
|
360
|
+
const made = saved.cases.filter((c) => c.created).length
|
|
361
|
+
const failed = saved.cases.filter((c) => !c.id).length
|
|
362
|
+
ctx.emit?.({
|
|
363
|
+
type: "card",
|
|
364
|
+
card: {
|
|
365
|
+
status: failed ? "fail" : "ok",
|
|
366
|
+
title: `Test cases saved for ${saved.story.id}${saved.story.title ? ` ${saved.story.title}` : ""}`,
|
|
367
|
+
stats: [`${made} created`, saved.cases.length - made - failed ? `${saved.cases.length - made - failed} already linked` : "", failed ? `${failed} failed` : ""].filter(Boolean).join(" · "),
|
|
368
|
+
body: [
|
|
369
|
+
`Story ${saved.story.url}`,
|
|
370
|
+
"",
|
|
371
|
+
...saved.cases.flatMap((c) => (c.id ? [`${c.created ? "+" : "="} ${c.id} ${c.title}`, ` ${c.url}`, ...(c.error ? [` ${c.error}`] : [])] : [`✗ ${c.title}: ${c.error}`])),
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
return {
|
|
376
|
+
story: saved.story,
|
|
377
|
+
cases: saved.cases.map((c) => ({ title: c.title, id: c.id || undefined, url: c.url || undefined, created: c.created, error: c.error })),
|
|
378
|
+
...(saved.system === "azure_devops" && saved.cases.some((c) => c.id) ? { run_with: { source: "azure_devops", ids: saved.cases.filter((c) => c.id).map((c) => c.id) } } : {}),
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: "run_test_cases",
|
|
384
|
+
description:
|
|
385
|
+
"Run test cases in real browsers: each case is one browser goal whose steps are done in order and whose expected results are checked, with a verdict for every step. Writes report.html (steps, screenshots, timings) and junit.xml, posts results back to Azure DevOps, Jira, Xray, TestRail, Zephyr Scale, or qTest unless write_back is false, and files or updates bugs for failures when bugs are set up. In a session that writes tests, each passed case is handed to ForgeQE's coding agent as soon as its browser run ends, and its test is written and run before the next case starts. Returns each case's result, step verdicts, run folder, and test (file, command, status, or why none was written). Takes minutes.",
|
|
386
|
+
inputSchema: {
|
|
387
|
+
type: "object",
|
|
388
|
+
properties: {
|
|
389
|
+
sources: SOURCES_SCHEMA,
|
|
390
|
+
parallel: { type: "integer", minimum: 1, maximum: 8, description: "Browsers at once (default from config)" },
|
|
391
|
+
write_back: { type: "boolean", description: "Post results to the source system (default from config)" },
|
|
392
|
+
skills: SKILLS_SCHEMA,
|
|
393
|
+
},
|
|
394
|
+
required: ["sources"],
|
|
395
|
+
},
|
|
396
|
+
async run(args, ctx) {
|
|
397
|
+
const all = await casesFrom(args.sources as CaseSelector[], ctx.config.sources, ctx.cwd)
|
|
398
|
+
// Cases a browser cannot test as written cost a run and fail for the case's sake: checked first.
|
|
399
|
+
const { checks, skip } = precheck(all, ctx)
|
|
400
|
+
const cases = all.filter((c) => !skip.has(c.id))
|
|
401
|
+
if (!cases.length) return { ran: false, reason: "No case can run as written (suite.case_check: block).", check: checkResult(checks) }
|
|
402
|
+
if (!cases.length) throw new Error("No test cases found.")
|
|
403
|
+
const parallel = Math.min(Number(args.parallel) || ctx.config.suite.parallel, cases.length)
|
|
404
|
+
const emit = ctx.emit || (() => {})
|
|
405
|
+
emit({ type: "suite-start", cases, parallel })
|
|
406
|
+
// In a session that writes tests, each passed case goes to the coding agent right after its own browser run,
|
|
407
|
+
// inside the suite, rather than every case being explored first and handed over at the end.
|
|
408
|
+
const coder: typeof runCodeAgent = async (o) => {
|
|
409
|
+
const t0 = Date.now()
|
|
410
|
+
emit({ type: "code-start", runDir: o.runDir, repo: o.repo, goal: goalOf(o.runDir) })
|
|
411
|
+
try {
|
|
412
|
+
const r = await runCodeAgent({
|
|
413
|
+
...o,
|
|
414
|
+
onEvent: (event) => emit({ type: "agent", event }),
|
|
415
|
+
open: ctx.openSession as never,
|
|
416
|
+
runTest: ctx.runTest,
|
|
417
|
+
})
|
|
418
|
+
emit({ type: "code-done", result: r, ms: Date.now() - t0 })
|
|
419
|
+
emit({ type: "card", card: codeCard(buildCodeSummary(r, Date.now() - t0)) })
|
|
420
|
+
return r
|
|
421
|
+
} catch (err) {
|
|
422
|
+
emit({ type: "code-done", error: (err as Error).message, ms: Date.now() - t0 })
|
|
423
|
+
throw err
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const suite = await runSuite({
|
|
427
|
+
config: ctx.writeTests ? { ...ctx.config, suite: { ...ctx.config.suite, code: true } } : ctx.config,
|
|
428
|
+
cases,
|
|
429
|
+
...(ctx.writeTests ? { repo: ctx.cwd, coder } : {}),
|
|
430
|
+
parallel,
|
|
431
|
+
writeBack: typeof args.write_back === "boolean" ? args.write_back : undefined,
|
|
432
|
+
headed: ctx.headed,
|
|
433
|
+
url: ctx.url,
|
|
434
|
+
maxSteps: ctx.maxSteps,
|
|
435
|
+
replay: ctx.replay,
|
|
436
|
+
memory: ctx.memory,
|
|
437
|
+
skills: skillNames(args.skills),
|
|
438
|
+
skillsRepo: ctx.cwd,
|
|
439
|
+
signal: ctx.signal,
|
|
440
|
+
execute: ctx.execute,
|
|
441
|
+
onEvent: (event) => emit({ type: "suite-event", event }),
|
|
442
|
+
})
|
|
443
|
+
emit({ type: "suite-done", suite })
|
|
444
|
+
const c = counts(suite.results)
|
|
445
|
+
// In the shell people try things out: the team hears about a suite only from runs nobody is watching.
|
|
446
|
+
const notified = ctx.session === "shell" ? [] : await notifySuite(ctx.config, suite)
|
|
447
|
+
return {
|
|
448
|
+
...(notified.length ? { notified: notifiedLine(notified) } : {}),
|
|
449
|
+
passed: c.passed,
|
|
450
|
+
failed: c.failed,
|
|
451
|
+
errors: c.errors,
|
|
452
|
+
not_run: c.stopped,
|
|
453
|
+
stopped: suite.stopped,
|
|
454
|
+
report: suite.files.html,
|
|
455
|
+
junit: suite.files.junit,
|
|
456
|
+
results: suite.results.map((r) => ({
|
|
457
|
+
id: r.case.id,
|
|
458
|
+
title: r.case.title,
|
|
459
|
+
status: r.status,
|
|
460
|
+
summary: clip(r.summary, 600),
|
|
461
|
+
steps: r.stepResults?.map((s) => `${s.step}. ${s.status}${s.status === "failed" && s.cause ? ` (cause: ${s.cause})` : ""}${s.actual ? `: ${clip(s.actual, 200)}` : ""}`),
|
|
462
|
+
...(r.failure ? { failure: { type: r.failure.type, label: FAILURE_LABEL[r.failure.type], reason: r.failure.reason, what_happens: FAILURE_ACTION[r.failure.type], ...(r.failure.step ? { step: r.failure.step } : {}) } } : {}),
|
|
463
|
+
...(r.retried ? { retried: "the first run met an environment problem, so the case ran once more" } : {}),
|
|
464
|
+
...(r.bug ? { bug: r.bug } : {}),
|
|
465
|
+
run_dir: r.runDir,
|
|
466
|
+
final_url: r.finalUrl,
|
|
467
|
+
...(r.code
|
|
468
|
+
? {
|
|
469
|
+
test: {
|
|
470
|
+
status: r.code.status,
|
|
471
|
+
test_file: r.code.testFile,
|
|
472
|
+
test_name: r.code.testName,
|
|
473
|
+
command: r.code.command,
|
|
474
|
+
attempts: r.code.attempts,
|
|
475
|
+
handoff: r.code.handoff,
|
|
476
|
+
error: r.code.error,
|
|
477
|
+
...(r.code.knownDefect ? { known_defect: "asserts the correct behaviour, marked as an expected failure until the defect is fixed" } : {}),
|
|
478
|
+
...(r.code.attention ? { needs_attention: `${r.code.attention}/ (README.md there says why and how to pick it up)` } : {}),
|
|
479
|
+
},
|
|
480
|
+
}
|
|
481
|
+
: r.codeSkipped
|
|
482
|
+
? { test: { status: "not written", why: r.codeSkipped } }
|
|
483
|
+
: {}),
|
|
484
|
+
})),
|
|
485
|
+
write_back: suite.writeBack,
|
|
486
|
+
...(checks.some((c) => c.verdict !== "ready") ? { case_check: checkResult(checks.filter((c) => c.verdict !== "ready")) } : {}),
|
|
487
|
+
...(skip.size ? { not_run_as_written: [...skip] } : {}),
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
name: "run_details",
|
|
493
|
+
description:
|
|
494
|
+
"Everything about a browser run needed to write a test for it: goal, result, every step with the value typed (secrets as env var names), and each element's ranked locators with confidence from page memory. Defaults to the latest run.",
|
|
495
|
+
inputSchema: { type: "object", properties: { run_dir: { type: "string", description: "Run folder from browser_run or run_test_cases" } } },
|
|
496
|
+
async run(args, ctx) {
|
|
497
|
+
const dir = args.run_dir ? path.resolve(ctx.cwd, String(args.run_dir)) : latestRunDir(ctx.config.export.outDir)
|
|
498
|
+
if (!dir || !existsSync(dir)) throw new Error("No run found. Run a goal first.")
|
|
499
|
+
const h = buildHandoff(dir, ctx.config.memory.dir)
|
|
500
|
+
return { run_dir: dir, passed: h.success, handoff_file: h.file, handoff: h.markdown }
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: "run_test",
|
|
505
|
+
description:
|
|
506
|
+
"ForgeQE runs a test command itself (from the working directory, CI=1, no prompts) and returns the exit code, the end of the output, and the browsers the test launched. A test you write only counts as done when this passes with a browser.",
|
|
507
|
+
inputSchema: {
|
|
508
|
+
type: "object",
|
|
509
|
+
properties: {
|
|
510
|
+
command: { type: "string", description: "Command that runs only the test, non-interactively" },
|
|
511
|
+
cwd: { type: "string", description: "Folder to run it in, relative to the working directory (default: the working directory)" },
|
|
512
|
+
},
|
|
513
|
+
required: ["command"],
|
|
514
|
+
},
|
|
515
|
+
async run(args, ctx) {
|
|
516
|
+
const command = String(args.command || "").trim()
|
|
517
|
+
if (!command) throw new Error("command is required")
|
|
518
|
+
const cwd = args.cwd ? path.resolve(ctx.cwd, String(args.cwd)) : ctx.cwd
|
|
519
|
+
ctx.emit?.({ type: "info", text: `ForgeQE runs: ${command}` })
|
|
520
|
+
const run = await runTestCommand({ command, cwd, timeoutSeconds: ctx.config.code.testTimeoutSeconds, signal: ctx.signal, hide: privateEnvNames(ctx.config) })
|
|
521
|
+
const why = run.ok ? `passed in ${Math.round(run.ms / 1000)}s` : run.exitCode === null ? `timed out after ${ctx.config.code.testTimeoutSeconds}s` : `failed, exit ${run.exitCode}`
|
|
522
|
+
ctx.emit?.({ type: "info", text: `${command.length > 80 ? `${command.slice(0, 79)}…` : command} ${why}`, ok: run.ok })
|
|
523
|
+
return { passed: run.ok, exit_code: run.exitCode, seconds: Math.round(run.ms / 1000), browsers: run.browsers, output: run.output }
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
name: "page_memory",
|
|
528
|
+
description:
|
|
529
|
+
"What ForgeQE's page memory knows: with no target, every host with its pages, elements, and recorded flows; with a host or URL, its elements and best locators with confidence. action flows: the recorded goals (steps: with their steps). action clear: delete what memory knows about a host, or everything; only when the user asks for it.",
|
|
530
|
+
inputSchema: {
|
|
531
|
+
type: "object",
|
|
532
|
+
properties: {
|
|
533
|
+
target: { type: "string", description: "Host (booking.com) or page URL" },
|
|
534
|
+
action: { type: "string", enum: ["show", "flows", "clear"], description: "show (default), flows, or clear" },
|
|
535
|
+
steps: { type: "boolean", description: "flows: include each flow's steps" },
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
async run(args, ctx) {
|
|
539
|
+
const dir = ctx.config.memory.dir
|
|
540
|
+
if (args.action === "flows") return memoryFlows(dir, { steps: args.steps === true })
|
|
541
|
+
if (args.action === "clear") {
|
|
542
|
+
const files = memoryFilesFor(dir, args.target ? String(args.target) : undefined)
|
|
543
|
+
clearMemory(files)
|
|
544
|
+
const text = files.length ? `Deleted ${files.length} memory files${args.target ? ` for ${args.target}` : ""}.` : "Nothing to clear."
|
|
545
|
+
ctx.emit?.({ type: "info", text, ok: true })
|
|
546
|
+
return text
|
|
547
|
+
}
|
|
548
|
+
return args.target ? memoryShow(dir, String(args.target), { locators: true, limit: 40 }) : memoryOverview(dir)
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: "read_confluence",
|
|
553
|
+
description:
|
|
554
|
+
"Read Confluence pages as text (requirements, specs, acceptance criteria), by page id or link, or by a CQL search. Tables keep their cells apart with |.",
|
|
555
|
+
inputSchema: {
|
|
556
|
+
type: "object",
|
|
557
|
+
properties: {
|
|
558
|
+
pages: { type: "array", items: { type: "string" }, description: "Page ids or page links" },
|
|
559
|
+
cql: { type: "string", description: 'CQL search, e.g. title ~ "checkout" AND type = page, or label = "requirements"' },
|
|
560
|
+
limit: { type: "integer", minimum: 1, maximum: 25, description: "Most pages to return (default 10)" },
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
async run(args, ctx) {
|
|
564
|
+
const pages = Array.isArray(args.pages) ? args.pages.map(String) : undefined
|
|
565
|
+
return readConfluence(ctx.config.sources, { pages, cql: args.cql ? String(args.cql) : undefined, limit: Number(args.limit) || undefined })
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
name: "query_api",
|
|
570
|
+
description:
|
|
571
|
+
"Read anything from Azure DevOps, Jira, Xray, or Confluence through their APIs with the credentials in ForgeQE's settings: work items, bugs, stories, queries, pipeline runs, issues, JQL searches, Xray tests and executions, Confluence pages and spaces. Use it for what the other tools and connected MCP servers don't cover, or when a system has no MCP server. Read-only: GET requests, WIQL searches for Azure DevOps work items, and GraphQL queries for Xray Cloud.",
|
|
572
|
+
inputSchema: {
|
|
573
|
+
type: "object",
|
|
574
|
+
properties: {
|
|
575
|
+
system: { type: "string", enum: ["azure_devops", "jira", "xray", "confluence"] },
|
|
576
|
+
path: {
|
|
577
|
+
type: "string",
|
|
578
|
+
description:
|
|
579
|
+
"azure_devops: REST path under the organization URL, e.g. Shop/_apis/wit/workitems/12?$expand=all (api-version=7.1 is added when missing). jira: REST path under the site URL, e.g. /rest/api/3/search/jql?jql=project%3DSHOP&fields=summary,status (Server/Data Center: /rest/api/2/..., Xray Server: /rest/raven/1.0/...). confluence: REST path under the Confluence URL, e.g. /rest/api/space or /api/v2/pages/123/children (Cloud).",
|
|
580
|
+
},
|
|
581
|
+
query: { type: "string", description: "xray (Cloud): a GraphQL query, e.g. { getTests(jql: \"project = SHOP\", limit: 10) { results { jira(fields: [\"key\"]) } } }" },
|
|
582
|
+
wiql: {
|
|
583
|
+
type: "string",
|
|
584
|
+
description:
|
|
585
|
+
"azure_devops: a WIQL query to search work items (instead of path), e.g. SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = 'User Story' ORDER BY [System.ChangedDate] DESC. Returns the first 50 with id, type, title, state, assignee, changed date, and iteration. Use it to find or list work items; the Analytics (_odata) endpoints need a token with the Analytics scope.",
|
|
586
|
+
},
|
|
587
|
+
project: { type: "string", description: "azure_devops wiql: the project @project refers to (default: the configured project)" },
|
|
588
|
+
variables: { type: "object", description: "xray: GraphQL variables" },
|
|
589
|
+
},
|
|
590
|
+
required: ["system"],
|
|
591
|
+
},
|
|
592
|
+
async run(args, ctx) {
|
|
593
|
+
return queryApi(ctx.config.sources, { system: args.system, path: args.path, query: args.query, variables: args.variables, wiql: args.wiql, project: args.project })
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
name: "create_ado_dashboard",
|
|
598
|
+
description:
|
|
599
|
+
"Create an Azure DevOps dashboard through the API with ForgeQE's credentials, only when the user asks for one. Each list or count widget gets its own shared query (saved under Shared Queries/ForgeQE, updated when one of the same name is already there), then the dashboard is created with a work item list or a count tile for each, plus optional markdown text. Every WIQL query is run first, so a bad query fails before anything is saved. Charts (pie, bar, trend) are not supported here.",
|
|
600
|
+
inputSchema: {
|
|
601
|
+
type: "object",
|
|
602
|
+
properties: {
|
|
603
|
+
project: { type: "string", description: "Project name; the configured project when left out" },
|
|
604
|
+
team: { type: "string", description: "Team name for a team dashboard; leave out for a project dashboard" },
|
|
605
|
+
name: { type: "string", description: "Dashboard name" },
|
|
606
|
+
description: { type: "string" },
|
|
607
|
+
widgets: {
|
|
608
|
+
type: "array",
|
|
609
|
+
minItems: 1,
|
|
610
|
+
items: {
|
|
611
|
+
type: "object",
|
|
612
|
+
properties: {
|
|
613
|
+
kind: { type: "string", enum: ["list", "count", "markdown"], description: "list (default): the query's work items. count: a tile with how many it returns. markdown: text." },
|
|
614
|
+
title: { type: "string", description: "Widget title, which also names its saved query" },
|
|
615
|
+
wiql: {
|
|
616
|
+
type: "string",
|
|
617
|
+
description:
|
|
618
|
+
"list and count: a flat-list WIQL query whose SELECT columns are the columns shown, e.g. SELECT [System.Id], [System.Title], [System.State], [System.AssignedTo] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = 'Bug' ORDER BY [System.State]. For the current sprint, name the team: [System.IterationPath] = @CurrentIteration('[Project]\\\\Team name').",
|
|
619
|
+
},
|
|
620
|
+
text: { type: "string", description: "markdown: the text" },
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
required: ["name", "widgets"],
|
|
626
|
+
},
|
|
627
|
+
async run(args, ctx) {
|
|
628
|
+
const widgets: DashboardWidget[] = (Array.isArray(args.widgets) ? args.widgets : []).map((w: Record<string, unknown>) =>
|
|
629
|
+
w.kind === "markdown"
|
|
630
|
+
? { kind: "markdown", title: w.title ? String(w.title) : undefined, text: String(w.text || "") }
|
|
631
|
+
: { kind: w.kind === "count" ? "count" : "list", title: String(w.title || ""), wiql: String(w.wiql || "") },
|
|
632
|
+
)
|
|
633
|
+
const out = await createAdoDashboard(ctx.config.sources, {
|
|
634
|
+
project: args.project ? String(args.project) : undefined,
|
|
635
|
+
team: args.team ? String(args.team) : undefined,
|
|
636
|
+
name: String(args.name || ""),
|
|
637
|
+
description: args.description ? String(args.description) : undefined,
|
|
638
|
+
widgets,
|
|
639
|
+
})
|
|
640
|
+
ctx.emit?.({ type: "info", text: `Azure DevOps dashboard "${out.name}" created in ${out.project}${out.team ? ` for ${out.team}` : ""}: ${out.url}`, ok: true })
|
|
641
|
+
return out
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
name: "write_ado_wiki_page",
|
|
646
|
+
description:
|
|
647
|
+
"Create or update an Azure DevOps wiki page through the API with ForgeQE's credentials, only when the user asks for one. When the project has no wiki yet, its project wiki is created first (the Azure DevOps MCP server cannot create a wiki). Missing parent pages are created empty; an existing page at the path is replaced with the new content.",
|
|
648
|
+
inputSchema: {
|
|
649
|
+
type: "object",
|
|
650
|
+
properties: {
|
|
651
|
+
project: { type: "string", description: "Project name; the configured project when left out" },
|
|
652
|
+
wiki: { type: "string", description: "Wiki name or id; leave out for the project wiki" },
|
|
653
|
+
path: { type: "string", description: "Page path, e.g. /Release notes/Sprint 12" },
|
|
654
|
+
content: { type: "string", description: "The page as Markdown" },
|
|
655
|
+
},
|
|
656
|
+
required: ["path", "content"],
|
|
657
|
+
},
|
|
658
|
+
async run(args, ctx) {
|
|
659
|
+
const out = await writeAdoWikiPage(ctx.config.sources, {
|
|
660
|
+
project: args.project ? String(args.project) : undefined,
|
|
661
|
+
wiki: args.wiki ? String(args.wiki) : undefined,
|
|
662
|
+
path: String(args.path || ""),
|
|
663
|
+
content: String(args.content ?? ""),
|
|
664
|
+
})
|
|
665
|
+
const text = `Wiki page ${out.path} ${out.created ? "created" : "updated"} in ${out.project}${out.wikiCreated ? ` (created the ${out.wiki} wiki)` : ""}: ${out.url}`
|
|
666
|
+
ctx.emit?.({ type: "info", text, ok: true })
|
|
667
|
+
return out
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
name: "push_branch",
|
|
672
|
+
description:
|
|
673
|
+
"Push the current commit to a branch on the remote so a pipeline can run it. Refuses the default branch, main, and master, and never forces. This is the only way to push; git push in the shell is blocked.",
|
|
674
|
+
inputSchema: {
|
|
675
|
+
type: "object",
|
|
676
|
+
properties: {
|
|
677
|
+
branch: { type: "string", description: "Branch to push to, e.g. forgeqe/ci-pipeline" },
|
|
678
|
+
remote: { type: "string", description: "Remote name (default origin)" },
|
|
679
|
+
},
|
|
680
|
+
required: ["branch"],
|
|
681
|
+
},
|
|
682
|
+
async run(args, ctx) {
|
|
683
|
+
const text = await pushBranch(ctx.cwd, String(args.branch || ""), args.remote ? String(args.remote) : undefined)
|
|
684
|
+
ctx.emit?.({ type: "info", text: text.split("\n")[0], ok: true })
|
|
685
|
+
return text
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
name: "set_ci_secret",
|
|
690
|
+
description:
|
|
691
|
+
"Store one of ForgeQE's environment variables (an API key or token the pipeline needs) as a GitHub Actions secret or an Azure Pipelines secret variable. You give only the name; ForgeQE reads the value from its environment and never shows it to you.",
|
|
692
|
+
inputSchema: {
|
|
693
|
+
type: "object",
|
|
694
|
+
properties: {
|
|
695
|
+
platform: { type: "string", enum: ["github", "azure_devops"] },
|
|
696
|
+
name: { type: "string", description: "Environment variable name, which is also the secret name" },
|
|
697
|
+
repo: { type: "string", description: "github: owner/repo when it is not the repo in the working directory" },
|
|
698
|
+
pipeline: { type: "string", description: "azure_devops: pipeline name" },
|
|
699
|
+
},
|
|
700
|
+
required: ["platform", "name"],
|
|
701
|
+
},
|
|
702
|
+
async run(args, ctx) {
|
|
703
|
+
const ado = ctx.config.sources.ado
|
|
704
|
+
const text = await setCiSecret(ctx.cwd, {
|
|
705
|
+
platform: args.platform === "azure_devops" ? "azure_devops" : "github",
|
|
706
|
+
name: String(args.name || ""),
|
|
707
|
+
repo: args.repo ? String(args.repo) : undefined,
|
|
708
|
+
pipeline: args.pipeline ? String(args.pipeline) : undefined,
|
|
709
|
+
org: ado?.orgUrl,
|
|
710
|
+
project: ado?.project,
|
|
711
|
+
})
|
|
712
|
+
ctx.emit?.({ type: "info", text, ok: true })
|
|
713
|
+
return text
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
...COMMAND_TOOLS,
|
|
717
|
+
...REPO_TEST_TOOLS,
|
|
718
|
+
...PR_TOOLS,
|
|
719
|
+
...CASE_TOOLS,
|
|
720
|
+
...API_TOOLS,
|
|
721
|
+
...EXPLORE_TOOLS,
|
|
722
|
+
...QUALITY_TOOLS,
|
|
723
|
+
...CAPABILITY_TOOLS,
|
|
724
|
+
...NOTIFY_TOOLS,
|
|
725
|
+
]
|
|
726
|
+
|
|
727
|
+
/** The setup the agent should know about: where it works, which systems are configured, and the defaults. */
|
|
728
|
+
/** Product knowledge files, for the agent to read the ones a request needs. */
|
|
729
|
+
function knowledgeNotes(cfg: AppConfig): string {
|
|
730
|
+
if (!cfg.knowledge) return ""
|
|
731
|
+
const files = readKnowledge(cfg.knowledge.dir)
|
|
732
|
+
return files.length
|
|
733
|
+
? [`Product knowledge (the team's notes about the product, in ${cfg.knowledge.dir}): read the files a request needs before planning tests, writing test cases, judging results, or answering about the product. The live application wins where they disagree.`, ...knowledgeIndex(files)].join("\n")
|
|
734
|
+
: `No product knowledge yet: the team can describe the product, roles, rules, and test data in ${cfg.knowledge.dir} (forgeqe init adds templates).`
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url">): string {
|
|
738
|
+
const { config: cfg } = ctx
|
|
739
|
+
const s = cfg.sources
|
|
740
|
+
const systems = [
|
|
741
|
+
s.ado ? `Azure DevOps: ${s.ado.orgUrl}${s.ado.project ? `, project ${s.ado.project}` : ", every project"} (token in ${s.ado.tokenEnv})` : "",
|
|
742
|
+
s.jira ? `Jira ${s.jira.cloud ? "Cloud" : "Server/Data Center"}: ${s.jira.url} (${s.jira.cloud ? `${s.jira.emailEnv} + ` : ""}${s.jira.tokenEnv})` : "",
|
|
743
|
+
s.xray.url && process.env[s.xray.clientIdEnv] ? `Xray Cloud (${s.xray.clientIdEnv}, ${s.xray.clientSecretEnv})` : s.jira && !s.jira.cloud ? "Xray Server/Data Center through the Jira settings" : "",
|
|
744
|
+
s.testrail ? `TestRail: ${s.testrail.url}${s.testrail.projectId ? `, project ${s.testrail.projectId}` : ""}` : "",
|
|
745
|
+
s.zephyr ? `Zephyr Scale Cloud${s.zephyr.projectKey ? `, project ${s.zephyr.projectKey}` : ""}` : "",
|
|
746
|
+
s.qtest ? `qTest: ${s.qtest.url}, project ${s.qtest.projectId}` : "",
|
|
747
|
+
].filter(Boolean)
|
|
748
|
+
const keys = [
|
|
749
|
+
cfg.llm.apiKeyEnv,
|
|
750
|
+
s.ado?.tokenEnv,
|
|
751
|
+
s.jira?.emailEnv,
|
|
752
|
+
s.jira?.tokenEnv,
|
|
753
|
+
s.xray.clientIdEnv,
|
|
754
|
+
s.xray.clientSecretEnv,
|
|
755
|
+
s.confluence?.emailEnv,
|
|
756
|
+
s.confluence?.tokenEnv,
|
|
757
|
+
s.testrail?.emailEnv,
|
|
758
|
+
s.testrail?.tokenEnv,
|
|
759
|
+
s.zephyr?.tokenEnv,
|
|
760
|
+
s.qtest?.tokenEnv,
|
|
761
|
+
...cfg.logins.flatMap((l) => [l.usernameEnv, l.passwordEnv, l.totpEnv]),
|
|
762
|
+
cfg.monitor.alerts.teams?.webhookEnv,
|
|
763
|
+
cfg.monitor.alerts.email?.usernameEnv,
|
|
764
|
+
cfg.monitor.alerts.email?.passwordEnv,
|
|
765
|
+
].filter((k, i, all): k is string => Boolean(k && process.env[k] && all.indexOf(k) === i))
|
|
766
|
+
const bugs = s.bugs
|
|
767
|
+
? `Failed cases get a ${s.bugs.system === "jira" ? `Jira ${s.bugs.type} in ${s.bugs.project}` : `Azure DevOps ${s.bugs.type}`} when results are posted back; a case still failing gets a comment on its open bug instead of a new one.`
|
|
768
|
+
: ""
|
|
769
|
+
const m = cfg.monitor
|
|
770
|
+
const channels = [m.alerts.teams ? `Teams (${m.alerts.teams.webhookEnv})` : "", m.alerts.email ? `email to ${m.alerts.email.to.join(", ")} via ${m.alerts.email.host}` : ""].filter(Boolean)
|
|
771
|
+
const monitor = m.checks.length || channels.length
|
|
772
|
+
? `Monitoring (monitor: in forgeqe.yaml): ${m.checks.length ? `checks ${m.checks.join(" ")}` : "no checks yet"}, every ${Math.round(m.everyMs / 60_000)} min, alerts to ${channels.length ? channels.join(" and ") : "nobody yet"}.`
|
|
773
|
+
: ""
|
|
774
|
+
const browserSkills = skillsFor(loadSkills(cfg.skills, ctx.cwd), "browser")
|
|
775
|
+
const skills = browserSkills.length
|
|
776
|
+
? [`Browser skills (pass the ones a goal needs as skills to browser_run or run_test_cases):`, ...catalogLines(browserSkills)].join("\n")
|
|
777
|
+
: ""
|
|
778
|
+
return [
|
|
779
|
+
`Working directory: ${ctx.cwd}`,
|
|
780
|
+
`Browser runs are saved under ${cfg.export.outDir}; page memory is in ${cfg.memory.dir}.`,
|
|
781
|
+
`Test case systems configured: ${systems.length ? systems.join("; ") : "none (files and text still work)"}.`,
|
|
782
|
+
s.confluence ? `Confluence: ${s.confluence.url}${s.confluence.space ? `, space ${s.confluence.space}` : ""} (read_confluence).` : "",
|
|
783
|
+
`Sprints (list_sprints, find_tests sprint): ${[s.ado ? "Azure DevOps iterations" : "", s.jira ? "Jira scrum boards" : ""].filter(Boolean).join(" and ") || "none configured; ask the user for dates"}.`,
|
|
784
|
+
`Suites: ${cfg.suite.parallel} browser(s) at once by default; results ${s.writeBack ? "are" : "are not"} posted back to the source by default.`,
|
|
785
|
+
bugs,
|
|
786
|
+
(s.ado || s.jira) && s.saveCases !== false
|
|
787
|
+
? "Test cases you write from an Azure DevOps or Jira story are saved there with save_test_cases, linked to the story, unless the user says not to."
|
|
788
|
+
: (s.ado || s.jira)
|
|
789
|
+
? "Test cases you write from a story are not saved anywhere unless the user asks (sources.save_cases is false)."
|
|
790
|
+
: "",
|
|
791
|
+
allLogins(cfg).length
|
|
792
|
+
? `Saved logins (the browser agent signs in with them and reuses the session; never ask the user for these passwords or put them in goals): ${allLogins(cfg).map((l) => `${l.name} for ${l.domains.join(", ")}${sessionLogins().some((s) => s.name === l.name) ? " (remembered this session)" : ""}`).join("; ")}.`
|
|
793
|
+
: "",
|
|
794
|
+
`Test commands time out after ${cfg.code.testTimeoutSeconds}s; give up on a test after ${cfg.code.maxAttempts} failed runs.`,
|
|
795
|
+
cfg.code.testCommand ? `The repository's test command (code.test_command): ${cfg.code.testCommand}` : "",
|
|
796
|
+
monitor,
|
|
797
|
+
cfg.notify
|
|
798
|
+
? `Notification channels (post to them with notify only when the user asks; each also gets its automatic events from runs outside the shell): ${cfg.notify.channels.map((c) => `${c.name} (${[c.teams ? "Teams" : "", c.email ? "email" : ""].filter(Boolean).join(" and ")}; automatic: ${c.on.join(", ") || "none"})`).join("; ")}.`
|
|
799
|
+
: "",
|
|
800
|
+
`Browser model profile: ${cfg.activeProfile} (${cfg.llm.provider}/${cfg.llm.deployment || cfg.llm.model}).`,
|
|
801
|
+
keys.length ? `Set in ForgeQE's environment (names only, usable with set_ci_secret): ${keys.join(", ")}.` : "",
|
|
802
|
+
ctx.url ? `The user pinned this start URL for browser runs: ${ctx.url}` : "",
|
|
803
|
+
skills,
|
|
804
|
+
domainNotes(cfg),
|
|
805
|
+
knowledgeNotes(cfg),
|
|
806
|
+
capabilityIndex(cfg),
|
|
807
|
+
]
|
|
808
|
+
.filter(Boolean)
|
|
809
|
+
.join("\n")
|
|
810
|
+
}
|