@capagents/forgeqe 1.1.4 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +64 -17
- package/README.md +2 -1
- package/browserstack.example.yaml +14 -2
- package/docs/user-guide.html +78 -23
- package/forgeqe.example.yaml +18 -5
- package/package.json +2 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +8 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +13 -2
- package/prompts/test-reviewer.md +8 -3
- package/prompts/test-rules.md +10 -4
- package/py/forgeqe_engine/browser.py +1 -1
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/brain/index.ts +1 -1
- package/src/capabilities/registry.ts +34 -6
- package/src/cases/bugs.ts +209 -22
- package/src/cases/defectHistory.ts +193 -0
- package/src/cases/defects.ts +255 -0
- package/src/cases/github.ts +162 -0
- package/src/cases/goal.ts +1 -1
- package/src/cases/index.ts +23 -4
- package/src/cases/predict.ts +372 -0
- package/src/cases/rca.ts +258 -0
- package/src/cases/readiness.ts +38 -1
- package/src/cases/repro.ts +202 -0
- package/src/cases/types.ts +8 -1
- package/src/cli/help.ts +8 -2
- package/src/cli/index.ts +36 -7
- package/src/code/graph/graph.ts +518 -0
- package/src/code/graph/locators.ts +398 -0
- package/src/code/graph/parse.ts +439 -0
- package/src/code/graph/tools.ts +113 -0
- package/src/code/handoff.ts +93 -18
- package/src/code/loop.ts +30 -5
- package/src/code/profile.ts +14 -3
- package/src/code/replay.ts +1 -1
- package/src/code/review.ts +22 -0
- package/src/code/runtime.ts +8 -1
- package/src/config.ts +142 -18
- package/src/mcp/attachtools.ts +223 -0
- package/src/mcp/commands.ts +1 -1
- package/src/mcp/defecttools.ts +106 -0
- package/src/mcp/devicetools.ts +125 -0
- package/src/mcp/external.ts +42 -1
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools.ts +95 -10
- package/src/monitor/run.ts +1 -1
- package/src/report/data.ts +14 -0
- package/src/report/html.ts +15 -3
- package/src/report/template.html +44 -43
- package/src/run.ts +4 -2
- package/src/suite/report.ts +44 -11
- package/src/suite/run.ts +163 -16
- package/src/tui/App.tsx +153 -94
- package/src/tui/components.tsx +27 -23
- package/src/tui/inspector.tsx +140 -83
- package/src/tui/landing.ts +3 -4
- package/src/tui/refine-panel.tsx +102 -52
- package/src/tui/shortcuts.tsx +2 -0
- package/src/tui/transcript.tsx +32 -2
- package/src/types.ts +3 -1
- package/src/usage.ts +1 -1
- package/testmu.example.yaml +11 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import YAML from "yaml"
|
|
4
|
+
|
|
5
|
+
import { browserProvider, DEVICE_NAME, loadConfig, type AppConfig, type BrowserDevice, type RemoteBrowserProvider } from "../config.ts"
|
|
6
|
+
import { projectDir } from "../tools.ts"
|
|
7
|
+
import type { McpTool } from "./tools.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Named remote browsers ("devices") the user describes in conversation: kept under devices: in the provider's YAML
|
|
11
|
+
* file (browser.providers.<provider> in forgeqe.yaml, created when missing), so runs and CI see the same list.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A device as the agent reads it. */
|
|
15
|
+
export function deviceView(d: BrowserDevice) {
|
|
16
|
+
const r = d.remote
|
|
17
|
+
return {
|
|
18
|
+
name: d.name,
|
|
19
|
+
provider: d.provider,
|
|
20
|
+
browser: r.browser,
|
|
21
|
+
browser_version: r.browserVersion,
|
|
22
|
+
...(r.platform ? { platform: r.platform } : {}),
|
|
23
|
+
...(r.os ? { os: r.os } : {}),
|
|
24
|
+
...(r.osVersion ? { os_version: r.osVersion } : {}),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The provider's YAML file, from forgeqe.yaml; when there is none, .forgeqe/<provider>.yaml, added to forgeqe.yaml. */
|
|
29
|
+
function providerFile(config: AppConfig, provider: RemoteBrowserProvider): { file: string; added: boolean } {
|
|
30
|
+
const known = config.browser.providerFiles[provider]
|
|
31
|
+
if (known) return { file: known, added: false }
|
|
32
|
+
const configPath = config.configPath
|
|
33
|
+
if (!configPath) throw new Error("There is no forgeqe.yaml here to add a device to: run forgeqe init first.")
|
|
34
|
+
const file = path.join(projectDir(path.dirname(configPath)), `${provider}.yaml`)
|
|
35
|
+
const doc = YAML.parseDocument(readFileSync(configPath, "utf8"))
|
|
36
|
+
if (!YAML.isMap(doc.get("browser"))) doc.set("browser", doc.createNode({}))
|
|
37
|
+
doc.setIn(["browser", "providers", provider], `./${path.relative(path.dirname(configPath), file).split(path.sep).join("/")}`)
|
|
38
|
+
writeFileSync(configPath, doc.toString())
|
|
39
|
+
return { file, added: true }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Write a change to a provider file and forgeqe.yaml, then load the whole configuration again: a device that does not
|
|
44
|
+
* load (a missing platform, a browser the engine cannot drive) puts both files back and fails, so a bad device never
|
|
45
|
+
* breaks the configuration. The session's configuration takes the new browser settings.
|
|
46
|
+
*/
|
|
47
|
+
function applyChange(config: AppConfig, provider: RemoteBrowserProvider, change: (doc: YAML.Document) => void): { file: string } {
|
|
48
|
+
const configPath = config.configPath
|
|
49
|
+
const before = configPath && existsSync(configPath) ? readFileSync(configPath, "utf8") : undefined
|
|
50
|
+
const { file, added } = providerFile(config, provider)
|
|
51
|
+
const old = existsSync(file) ? readFileSync(file, "utf8") : undefined
|
|
52
|
+
const doc = YAML.parseDocument(old ?? `provider: ${provider}\n`)
|
|
53
|
+
change(doc)
|
|
54
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
55
|
+
writeFileSync(file, doc.toString())
|
|
56
|
+
try {
|
|
57
|
+
const fresh = loadConfig({ configPath, profile: config.activeProfile })
|
|
58
|
+
Object.assign(config.browser, fresh.browser)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (old === undefined) rmSync(file, { force: true })
|
|
61
|
+
else writeFileSync(file, old)
|
|
62
|
+
if (added && configPath && before !== undefined) writeFileSync(configPath, before)
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
return { file }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const str = (v: unknown) => (v === undefined || v === null ? "" : String(v).trim())
|
|
69
|
+
|
|
70
|
+
export const DEVICE_TOOLS: McpTool[] = [
|
|
71
|
+
{
|
|
72
|
+
name: "browser_devices",
|
|
73
|
+
description:
|
|
74
|
+
"List, add or change, and remove named remote browsers (devices) on BrowserStack or TestMu AI/LambdaTest, kept in the provider's YAML file so every later run and CI sees them. When the user describes the devices or browsers to test on, save each one here, then run on them by passing their names as devices to browser_run or run_test_cases. ForgeQE drives remote browsers over the Chrome DevTools Protocol, so a device is Chrome (TestMu AI: Chrome only) or Edge (BrowserStack) on a Windows or macOS version; Safari, Firefox, and real mobile devices are not supported, so say so instead of saving them.",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
action: { type: "string", enum: ["list", "save", "remove"], description: "list the devices; save adds a device or changes one; remove deletes one" },
|
|
79
|
+
name: { type: "string", description: "Short device name for save/remove, e.g. win11-chrome, mac-sequoia-edge" },
|
|
80
|
+
provider: { type: "string", enum: ["browserstack", "testmu"], description: "For save: the grid the device is on (testmu is TestMu AI / LambdaTest). Needed for a new device." },
|
|
81
|
+
browser: { type: "string", description: "For save: TestMu: Chrome; BrowserStack: chrome or edge" },
|
|
82
|
+
browser_version: { type: "string", description: "For save: a version such as 128, or latest, latest-1 (default latest)" },
|
|
83
|
+
platform: { type: "string", description: "For save on TestMu: the platform, e.g. Windows 11, Windows 10, macOS Sequoia" },
|
|
84
|
+
os: { type: "string", description: "For save on BrowserStack: Windows or OS X" },
|
|
85
|
+
os_version: { type: "string", description: "For save on BrowserStack: 11, 10, Sequoia, Sonoma, ..." },
|
|
86
|
+
},
|
|
87
|
+
required: ["action"],
|
|
88
|
+
},
|
|
89
|
+
async run(args, ctx) {
|
|
90
|
+
const config = ctx.config
|
|
91
|
+
const action = str(args.action) || "list"
|
|
92
|
+
const list = () => ({
|
|
93
|
+
devices: Object.values(config.browser.devices || {}).map(deviceView),
|
|
94
|
+
providers: Object.fromEntries(Object.entries(config.browser.providerFiles).map(([p, f]) => [p, f])),
|
|
95
|
+
})
|
|
96
|
+
if (action === "list") return list()
|
|
97
|
+
const name = str(args.name)
|
|
98
|
+
if (!DEVICE_NAME.test(name)) throw new Error('name is required: a short name such as "win11-chrome" (letters, digits, ".", "_", "-").')
|
|
99
|
+
const existing = config.browser.devices?.[name]
|
|
100
|
+
if (action === "remove") {
|
|
101
|
+
if (!existing) throw new Error(`No device named ${name}.`)
|
|
102
|
+
const { file } = applyChange(config, existing.provider, (doc) => {
|
|
103
|
+
doc.deleteIn(["devices", name])
|
|
104
|
+
const left = doc.get("devices")
|
|
105
|
+
if (YAML.isMap(left) && !left.items.length) doc.delete("devices")
|
|
106
|
+
})
|
|
107
|
+
return { removed: name, file, ...list() }
|
|
108
|
+
}
|
|
109
|
+
if (action !== "save") throw new Error(`Unknown action ${action}: use list, save, or remove.`)
|
|
110
|
+
const asked = str(args.provider)
|
|
111
|
+
const provider = (asked ? browserProvider(asked) : existing?.provider) as RemoteBrowserProvider | "local" | undefined
|
|
112
|
+
if (!provider || provider === "local") throw new Error("provider is required for a new device: browserstack or testmu.")
|
|
113
|
+
if (existing && existing.provider !== provider) throw new Error(`Device ${name} is on ${existing.provider}; remove it first to move it to ${provider}.`)
|
|
114
|
+
const fields: Record<string, string> = {}
|
|
115
|
+
for (const key of ["browser", "browser_version", "platform", "os", "os_version"]) if (str(args[key])) fields[key] = str(args[key])
|
|
116
|
+
if (!existing && !fields.browser) throw new Error("browser is required for a new device: Chrome or Edge.")
|
|
117
|
+
const { file } = applyChange(config, provider, (doc) => {
|
|
118
|
+
if (!doc.get("provider")) doc.set("provider", provider)
|
|
119
|
+
for (const [key, value] of Object.entries(fields)) doc.setIn(["devices", name, key], value)
|
|
120
|
+
})
|
|
121
|
+
const saved = config.browser.devices[name]
|
|
122
|
+
return { saved: deviceView(saved), file, ...list() }
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
]
|
package/src/mcp/external.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
import { ghAuthToken } from "../cases/github.ts"
|
|
1
4
|
import type { AppConfig } from "../config.ts"
|
|
2
5
|
|
|
3
6
|
/**
|
|
@@ -22,6 +25,15 @@ const ADO_DOMAIN_WORDS: Record<string, string> = {
|
|
|
22
25
|
"advanced-security": "security alerts",
|
|
23
26
|
}
|
|
24
27
|
export const ROVO_URL = "https://mcp.atlassian.com/v2/mcp"
|
|
28
|
+
export const GITHUB_MCP_URL = "https://api.githubcopilot.com/mcp/"
|
|
29
|
+
|
|
30
|
+
/** The GitHub repository (owner/name) the folder's git remotes point at, or null. */
|
|
31
|
+
export function githubRemote(cwd: string): string | null {
|
|
32
|
+
const res = spawnSync("git", ["config", "--get-regexp", "^remote\\..*\\.url$"], { cwd, encoding: "utf8", timeout: 5000 })
|
|
33
|
+
if (res.status !== 0) return null
|
|
34
|
+
const m = /github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\s*$/m.exec(res.stdout)
|
|
35
|
+
return m ? `${m[1]}/${m[2]}` : null
|
|
36
|
+
}
|
|
25
37
|
/** Also the time allowed for the first start, when npx or uvx downloads the server. */
|
|
26
38
|
const TIMEOUT_MS = 120_000
|
|
27
39
|
|
|
@@ -61,7 +73,12 @@ export function adoOrganization(orgUrl: string): string | null {
|
|
|
61
73
|
* server for Jira, and anything under `mcp:`. Credentials stay in the environment: configs refer to them as {env:NAME}.
|
|
62
74
|
* A system whose server cannot start here is listed in `skipped`; ForgeQE's own API tools still cover it.
|
|
63
75
|
*/
|
|
64
|
-
export function externalMcp(
|
|
76
|
+
export function externalMcp(
|
|
77
|
+
cfg: AppConfig,
|
|
78
|
+
which: Which = Bun.which,
|
|
79
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
80
|
+
opts: { cwd?: string; ghToken?: () => string | null; remote?: (cwd: string) => string | null } = {},
|
|
81
|
+
): ExternalPlan {
|
|
65
82
|
const servers: ExternalServer[] = []
|
|
66
83
|
const skipped: ExternalPlan["skipped"] = []
|
|
67
84
|
const { ado, jira, confluence: conf } = cfg.sources
|
|
@@ -175,6 +192,30 @@ export function externalMcp(cfg: AppConfig, which: Which = Bun.which, env: NodeJ
|
|
|
175
192
|
}
|
|
176
193
|
}
|
|
177
194
|
|
|
195
|
+
// GitHub's hosted MCP server, when the project is on GitHub: sources.github in forgeqe.yaml, or a GitHub remote in the
|
|
196
|
+
// folder. Its tools cost tokens on every request, so a project that is not on GitHub does not get them.
|
|
197
|
+
const gh = cfg.sources.github
|
|
198
|
+
// The folder the agent works in, when the caller names it: a GitHub remote there turns the server on.
|
|
199
|
+
const remote = gh?.mcp === false || cfg.mcp?.github || !opts.cwd ? null : (opts.remote || githubRemote)(opts.cwd)
|
|
200
|
+
if (gh?.mcp !== false && !cfg.mcp?.github && (gh || remote)) {
|
|
201
|
+
const label = "GitHub MCP"
|
|
202
|
+
const token = env[gh?.tokenEnv || "GITHUB_TOKEN"]?.trim() || (opts.ghToken || ghAuthToken)()
|
|
203
|
+
if (gh && gh.apiUrl !== "https://api.github.com") skipped.push({ label, why: "GitHub's hosted server is for github.com; add your Enterprise server's MCP under mcp:" })
|
|
204
|
+
else if (!token) skipped.push({ label, why: `sign in with forgeqe auth login github, or set ${gh?.tokenEnv || "GITHUB_TOKEN"}` })
|
|
205
|
+
else {
|
|
206
|
+
const toolsets = gh?.toolsets?.length ? gh.toolsets : ["repos", "issues", "pull_requests", "actions"]
|
|
207
|
+
const repo = gh?.repo || remote
|
|
208
|
+
servers.push({
|
|
209
|
+
name: "github",
|
|
210
|
+
label,
|
|
211
|
+
about: `GitHub's MCP server (${toolsets.join(", ")})${repo ? `; this project is ${repo}, so use it unless the user names another repository` : ""}. It cannot upload files to issues; ForgeQE's own tools write test results and bugs to GitHub issues.`,
|
|
212
|
+
config: { type: "remote", url: GITHUB_MCP_URL, headers: { Authorization: "Bearer {env:FORGEQE_GITHUB_MCP_TOKEN}", "X-MCP-Toolsets": toolsets.join(",") }, oauth: false, enabled: true, timeout: TIMEOUT_MS },
|
|
213
|
+
secrets: { FORGEQE_GITHUB_MCP_TOKEN: token },
|
|
214
|
+
signIn: env[gh?.tokenEnv || "GITHUB_TOKEN"]?.trim() ? `token in ${gh?.tokenEnv || "GITHUB_TOKEN"}` : "GitHub CLI sign-in",
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
178
219
|
for (const [name, server] of Object.entries(cfg.mcp || {})) {
|
|
179
220
|
const at = servers.findIndex((s) => s.name === name)
|
|
180
221
|
if (at >= 0) servers.splice(at, 1)
|
package/src/mcp/server.ts
CHANGED
|
@@ -62,9 +62,11 @@ export function createMcpServer(opts: McpServerOptions) {
|
|
|
62
62
|
base.emit?.(e)
|
|
63
63
|
}
|
|
64
64
|
: undefined
|
|
65
|
+
const callId = `${tool.name}-${String(id)}-${started}`
|
|
65
66
|
const said = (ok: boolean) => {
|
|
66
|
-
if (tool.line && emit) emit({ type: "call", text: tool.line(params.arguments || {}), ok })
|
|
67
|
+
if (tool.line && emit) emit({ type: "call", text: tool.line(params.arguments || {}), ok, id: callId })
|
|
67
68
|
}
|
|
69
|
+
if (tool.line && emit) emit({ type: "call-start", text: tool.line(params.arguments || {}), id: callId })
|
|
68
70
|
let out: unknown
|
|
69
71
|
try {
|
|
70
72
|
out = await tool.run(params.arguments || {}, { ...base, emit, signal: controller.signal })
|
package/src/mcp/tools.ts
CHANGED
|
@@ -19,17 +19,20 @@ import { browserProvider, type AppConfig } from "../config.ts"
|
|
|
19
19
|
import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
|
|
20
20
|
import { executeRun, runDirectory } from "../run.ts"
|
|
21
21
|
import { counts } from "../suite/report.ts"
|
|
22
|
-
import { runSuite, type SuiteEvent, type SuiteResult } from "../suite/run.ts"
|
|
22
|
+
import { deviceCases, runSuite, suiteParallel, type SuiteEvent, type SuiteResult } from "../suite/run.ts"
|
|
23
23
|
import { COMMAND_TOOLS, TASK_FILE } from "./commands.ts"
|
|
24
24
|
import { PR_TOOLS } from "./prtools.ts"
|
|
25
25
|
import { CASE_TOOLS, checkResult, precheck } from "./casetools.ts"
|
|
26
26
|
import { REPO_TEST_TOOLS } from "./repotests.ts"
|
|
27
|
+
import { DEFECT_TOOLS } from "./defecttools.ts"
|
|
27
28
|
import { API_TOOLS } from "./apitools.ts"
|
|
28
29
|
import { EXPLORE_TOOLS } from "./exploretools.ts"
|
|
29
30
|
import { QUALITY_TOOLS } from "./qualitytools.ts"
|
|
30
31
|
import { CAPABILITY_TOOLS } from "./captools.ts"
|
|
31
32
|
import { NOTIFY_TOOLS } from "./notifytools.ts"
|
|
32
33
|
import { REPORT_TOOLS } from "./reporttools.ts"
|
|
34
|
+
import { DEVICE_TOOLS } from "./devicetools.ts"
|
|
35
|
+
import { ATTACH_TOOLS } from "./attachtools.ts"
|
|
33
36
|
import { capabilityIndex } from "../capabilities/render.ts"
|
|
34
37
|
import { domainNotes } from "../domain.ts"
|
|
35
38
|
import { readNetwork } from "../api/traffic.ts"
|
|
@@ -99,7 +102,9 @@ export type ToolEvent =
|
|
|
99
102
|
/** write_report saved a report: its page and its Markdown. */
|
|
100
103
|
| { type: "report"; title: string; html: string; markdown: string }
|
|
101
104
|
/** A finished call of a tool that has no card of its own, as one line: "Azure DevOps API · QI-Claims/_apis/build/builds (top 5)". */
|
|
102
|
-
| { type: "call"; text: string; ok: boolean }
|
|
105
|
+
| { type: "call"; text: string; ok: boolean; id?: string }
|
|
106
|
+
/** The same call as it starts, so a long one is visible while it runs; its `call` with the same `id` follows. */
|
|
107
|
+
| { type: "call-start"; text: string; id: string }
|
|
103
108
|
|
|
104
109
|
const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira: "Jira", xray: "Xray", confluence: "Confluence" }
|
|
105
110
|
|
|
@@ -124,6 +129,8 @@ export type ToolContext = {
|
|
|
124
129
|
cwd: string
|
|
125
130
|
headed?: boolean
|
|
126
131
|
browserProvider?: AppConfig["browser"]["provider"]
|
|
132
|
+
/** Named devices the user pinned for this session (--devices): runs go to each of them. */
|
|
133
|
+
devices?: string[]
|
|
127
134
|
maxSteps?: number
|
|
128
135
|
replay?: ReplayMode
|
|
129
136
|
memory?: boolean
|
|
@@ -190,14 +197,14 @@ const SOURCES_SCHEMA = {
|
|
|
190
197
|
properties: {
|
|
191
198
|
source: {
|
|
192
199
|
type: "string",
|
|
193
|
-
enum: ["azure_devops", "jira", "xray", "testrail", "zephyr", "qtest", "file", "text"],
|
|
200
|
+
enum: ["azure_devops", "jira", "xray", "testrail", "zephyr", "qtest", "github", "file", "text"],
|
|
194
201
|
description:
|
|
195
|
-
"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).",
|
|
202
|
+
"azure_devops: Test Case work items or a Test Plan. github: GitHub issues (by number, labels, or an issue search). 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).",
|
|
196
203
|
},
|
|
197
204
|
ids: {
|
|
198
205
|
type: "array",
|
|
199
206
|
items: { type: "string" },
|
|
200
|
-
description: "Work item ids (azure_devops), issue keys (jira, xray), case ids like C12 (testrail), keys like SHOP-T3 (zephyr),
|
|
207
|
+
description: "Work item ids (azure_devops), issue keys (jira, xray), case ids like C12 (testrail), keys like SHOP-T3 (zephyr), test case ids (qtest), or issue numbers (github: 12, or owner/repo#12)",
|
|
201
208
|
},
|
|
202
209
|
plan: { type: "string", description: "Test plan id (azure_devops, testrail) or Test Plan issue key (xray)" },
|
|
203
210
|
suite: { type: "string", description: "Suite id: inside the Azure DevOps plan (omit for every suite), a TestRail suite, or a qTest test suite" },
|
|
@@ -205,6 +212,9 @@ const SOURCES_SCHEMA = {
|
|
|
205
212
|
cycle: { type: "string", description: "zephyr: test cycle key like SHOP-R5. qtest: test cycle id" },
|
|
206
213
|
folder: { type: "string", description: "zephyr: folder id" },
|
|
207
214
|
jql: { type: "string", description: "JQL query (jira, xray)" },
|
|
215
|
+
repo: { type: "string", description: "github: owner/repo (default: sources.github.repo); ids may also be owner/repo#12" },
|
|
216
|
+
labels: { type: "array", items: { type: "string" }, description: "github: open issues with all these labels" },
|
|
217
|
+
query: { type: "string", description: 'github: a GitHub issue search, e.g. "login in:title is:open label:test-case"' },
|
|
208
218
|
paths: { type: "array", items: { type: "string" }, description: "Files, relative to the working directory" },
|
|
209
219
|
text: { type: "string", description: "The test case itself: title on the first line, then steps and expected results" },
|
|
210
220
|
},
|
|
@@ -253,6 +263,61 @@ function summarizeRun(result: RunResult, runDir: string, files: string[]) {
|
|
|
253
263
|
}
|
|
254
264
|
}
|
|
255
265
|
|
|
266
|
+
const DEVICES_SCHEMA = {
|
|
267
|
+
type: "array",
|
|
268
|
+
items: { type: "string" },
|
|
269
|
+
description:
|
|
270
|
+
"Names of saved devices (browser_devices) to run on, when the user asked for specific devices or browsers: each runs in parallel on its own remote browser and is reported separately. A device's name also picks its provider.",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** One goal on several devices at once: a one-off case through the suite, so each device runs and reports on its own. */
|
|
274
|
+
async function runGoalOnDevices(goal: string, url: string | undefined, devices: string[], args: Record<string, unknown>, ctx: ToolContext) {
|
|
275
|
+
const tc: TestCase = { id: "goal", title: clip(goal.split("\n")[0], 80) || "Goal", source: "text", description: goal, steps: [], ...(url ? { startUrl: url } : {}) }
|
|
276
|
+
const shown = deviceCases([tc], devices)
|
|
277
|
+
const parallel = suiteParallel(undefined, ctx.config.suite.parallel, devices.length, shown.length)
|
|
278
|
+
const emit = ctx.emit || (() => {})
|
|
279
|
+
emit({ type: "suite-start", cases: shown, parallel })
|
|
280
|
+
const suite = await runSuite({
|
|
281
|
+
config: ctx.config,
|
|
282
|
+
cases: [tc],
|
|
283
|
+
devices,
|
|
284
|
+
parallel,
|
|
285
|
+
writeBack: false,
|
|
286
|
+
headed: ctx.headed,
|
|
287
|
+
url,
|
|
288
|
+
maxSteps: ctx.maxSteps,
|
|
289
|
+
replay: ctx.replay,
|
|
290
|
+
memory: ctx.memory,
|
|
291
|
+
skills: skillNames(args.skills),
|
|
292
|
+
skillsRepo: ctx.cwd,
|
|
293
|
+
signal: ctx.signal,
|
|
294
|
+
execute: ctx.execute,
|
|
295
|
+
onEvent: (event) => emit({ type: "suite-event", event }),
|
|
296
|
+
})
|
|
297
|
+
emit({ type: "suite-done", suite })
|
|
298
|
+
const c = counts(suite.results)
|
|
299
|
+
return {
|
|
300
|
+
devices: suite.results.map((r) => ({ device: r.device, status: r.status, summary: clip(r.summary, 600), run_dir: r.runDir, final_url: r.finalUrl })),
|
|
301
|
+
passed: c.passed,
|
|
302
|
+
failed: c.failed + c.errors,
|
|
303
|
+
report: suite.files.html,
|
|
304
|
+
note: "Each device ran the goal on its own remote browser. To turn it into a test, run write_test with one device's run_dir.",
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Devices a tool call asked for, else the session's pinned ones; names checked against the configuration. */
|
|
309
|
+
function devicesFor(args: Record<string, unknown>, ctx: ToolContext): string[] {
|
|
310
|
+
const asked = Array.isArray(args.devices) ? args.devices.map((d) => String(d).trim()).filter(Boolean) : []
|
|
311
|
+
const devices = asked.length ? asked : ctx.devices || []
|
|
312
|
+
if (!devices.length) return []
|
|
313
|
+
const known = ctx.config.browser?.devices || {}
|
|
314
|
+
const missing = devices.filter((d) => !known[d])
|
|
315
|
+
if (missing.length) {
|
|
316
|
+
throw new Error(`No device named ${missing.join(", ")}. ${Object.keys(known).length ? `Saved devices: ${Object.keys(known).join(", ")}.` : "No device is saved yet."} Save it with browser_devices first.`)
|
|
317
|
+
}
|
|
318
|
+
return [...new Set(devices)]
|
|
319
|
+
}
|
|
320
|
+
|
|
256
321
|
export const TOOLS: McpTool[] = [
|
|
257
322
|
{
|
|
258
323
|
name: "browser_run",
|
|
@@ -273,6 +338,7 @@ export const TOOLS: McpTool[] = [
|
|
|
273
338
|
description:
|
|
274
339
|
"Override the browser provider for this run when the user asked for one specifically. local uses the machine's own browser; browserstack and testmu use their configured YAML or browser.remote settings.",
|
|
275
340
|
},
|
|
341
|
+
devices: DEVICES_SCHEMA,
|
|
276
342
|
resume_from: {
|
|
277
343
|
type: "string",
|
|
278
344
|
description:
|
|
@@ -295,6 +361,10 @@ export const TOOLS: McpTool[] = [
|
|
|
295
361
|
const resumeFrom = from ? (existsSync(path.resolve(ctx.cwd, from)) ? path.resolve(ctx.cwd, from) : from) : undefined
|
|
296
362
|
const url = args.start_url ? String(args.start_url) : ctx.url
|
|
297
363
|
const selectedBrowser = args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
|
|
364
|
+
// Pinned devices are for testing the app: a task (reading a work item, looking something up) runs once, here.
|
|
365
|
+
const devices = args.purpose === "task" && !Array.isArray(args.devices) ? [] : devicesFor(args, ctx)
|
|
366
|
+
// Several devices: the goal runs as a one-off case on each, in parallel, through the suite (no write-back).
|
|
367
|
+
if (devices.length > 1) return runGoalOnDevices(goal, url, devices, args, ctx)
|
|
298
368
|
const runDir = runDirectory(ctx.config.export.outDir)
|
|
299
369
|
const emit = ctx.emit || (() => {})
|
|
300
370
|
const t0 = Date.now()
|
|
@@ -303,7 +373,8 @@ export const TOOLS: McpTool[] = [
|
|
|
303
373
|
const { result, files } = await (ctx.execute || executeRun)({
|
|
304
374
|
config: ctx.config,
|
|
305
375
|
goal,
|
|
306
|
-
browserProvider: selectedBrowser,
|
|
376
|
+
browserProvider: devices.length ? undefined : selectedBrowser,
|
|
377
|
+
browserDevice: devices[0],
|
|
307
378
|
url,
|
|
308
379
|
headed: ctx.headed,
|
|
309
380
|
maxSteps: ctx.maxSteps,
|
|
@@ -432,6 +503,7 @@ export const TOOLS: McpTool[] = [
|
|
|
432
503
|
description:
|
|
433
504
|
"Override the browser provider for every case in this suite when the user asked for one specifically.",
|
|
434
505
|
},
|
|
506
|
+
devices: DEVICES_SCHEMA,
|
|
435
507
|
skills: SKILLS_SCHEMA,
|
|
436
508
|
},
|
|
437
509
|
required: ["sources"],
|
|
@@ -443,10 +515,12 @@ export const TOOLS: McpTool[] = [
|
|
|
443
515
|
const cases = all.filter((c) => !skip.has(c.id))
|
|
444
516
|
if (!cases.length) return { ran: false, reason: "No case can run as written (suite.case_check: block).", check: checkResult(checks) }
|
|
445
517
|
if (!cases.length) throw new Error("No test cases found.")
|
|
446
|
-
const
|
|
447
|
-
const
|
|
518
|
+
const devices = devicesFor(args, ctx)
|
|
519
|
+
const shown = deviceCases(cases, devices)
|
|
520
|
+
const parallel = suiteParallel(Number(args.parallel) || undefined, ctx.config.suite.parallel, devices.length, shown.length)
|
|
521
|
+
const selectedBrowser = devices.length ? undefined : args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
|
|
448
522
|
const emit = ctx.emit || (() => {})
|
|
449
|
-
emit({ type: "suite-start", cases, parallel })
|
|
523
|
+
emit({ type: "suite-start", cases: shown, parallel })
|
|
450
524
|
// In a session that writes tests, each passed case goes to the coding agent right after its own browser run,
|
|
451
525
|
// inside the suite, rather than every case being explored first and handed over at the end.
|
|
452
526
|
const coder: typeof runCodeAgent = async (o) => {
|
|
@@ -472,6 +546,7 @@ export const TOOLS: McpTool[] = [
|
|
|
472
546
|
cases,
|
|
473
547
|
...(ctx.writeTests ? { repo: ctx.cwd, coder } : {}),
|
|
474
548
|
browserProvider: selectedBrowser,
|
|
549
|
+
devices,
|
|
475
550
|
parallel,
|
|
476
551
|
writeBack: typeof args.write_back === "boolean" ? args.write_back : undefined,
|
|
477
552
|
headed: ctx.headed,
|
|
@@ -498,8 +573,10 @@ export const TOOLS: McpTool[] = [
|
|
|
498
573
|
stopped: suite.stopped,
|
|
499
574
|
report: suite.files.html,
|
|
500
575
|
junit: suite.files.junit,
|
|
576
|
+
...(devices.length > 1 ? { devices, posted_back: "one combined result per case: it fails when any device fails" } : {}),
|
|
501
577
|
results: suite.results.map((r) => ({
|
|
502
578
|
id: r.case.id,
|
|
579
|
+
...(r.device ? { device: r.device } : {}),
|
|
503
580
|
title: r.case.title,
|
|
504
581
|
status: r.status,
|
|
505
582
|
summary: clip(r.summary, 600),
|
|
@@ -762,6 +839,7 @@ export const TOOLS: McpTool[] = [
|
|
|
762
839
|
},
|
|
763
840
|
...COMMAND_TOOLS,
|
|
764
841
|
...REPO_TEST_TOOLS,
|
|
842
|
+
...DEFECT_TOOLS,
|
|
765
843
|
...PR_TOOLS,
|
|
766
844
|
...CASE_TOOLS,
|
|
767
845
|
...API_TOOLS,
|
|
@@ -770,6 +848,8 @@ export const TOOLS: McpTool[] = [
|
|
|
770
848
|
...CAPABILITY_TOOLS,
|
|
771
849
|
...NOTIFY_TOOLS,
|
|
772
850
|
...REPORT_TOOLS,
|
|
851
|
+
...DEVICE_TOOLS,
|
|
852
|
+
...ATTACH_TOOLS,
|
|
773
853
|
]
|
|
774
854
|
|
|
775
855
|
/** The setup the agent should know about: where it works, which systems are configured, and the defaults. */
|
|
@@ -782,7 +862,7 @@ function knowledgeNotes(cfg: AppConfig): string {
|
|
|
782
862
|
: `No product knowledge yet: the team can describe the product, roles, rules, and test data in ${cfg.knowledge.dir} (forgeqe init adds templates).`
|
|
783
863
|
}
|
|
784
864
|
|
|
785
|
-
export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url" | "browserProvider">): string {
|
|
865
|
+
export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url" | "browserProvider" | "devices">): string {
|
|
786
866
|
const { config: cfg } = ctx
|
|
787
867
|
const s = cfg.sources
|
|
788
868
|
const systems = [
|
|
@@ -823,6 +903,7 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
|
|
|
823
903
|
const browserSkills = skillsFor(loadSkills(cfg.skills, ctx.cwd), "browser")
|
|
824
904
|
const browserProvider = browser.provider || "local"
|
|
825
905
|
const remoteProviders = (["browserstack", "testmu"] as const).filter((name) => browser.remotes?.[name])
|
|
906
|
+
const devices = Object.values(browser.devices || {}).map((d) => `${d.name} (${d.provider}: ${d.remote.browser} ${d.remote.browserVersion} on ${d.remote.platform || [d.remote.os, d.remote.osVersion].filter(Boolean).join(" ")})`)
|
|
826
907
|
const skills = browserSkills.length
|
|
827
908
|
? [`Browser skills (pass the ones a goal needs as skills to browser_run or run_test_cases):`, ...catalogLines(browserSkills)].join("\n")
|
|
828
909
|
: ""
|
|
@@ -853,6 +934,10 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
|
|
|
853
934
|
keys.length ? `Set in ForgeQE's environment (names only, usable with set_ci_secret): ${keys.join(", ")}.` : "",
|
|
854
935
|
ctx.url ? `The user pinned this start URL for browser runs: ${ctx.url}` : "",
|
|
855
936
|
ctx.browserProvider ? `The user pinned this browser provider for runs in this session: ${ctx.browserProvider}.` : "",
|
|
937
|
+
devices.length
|
|
938
|
+
? `Saved devices (pass names as devices to browser_run or run_test_cases; browser_devices adds, changes, or removes them): ${devices.join("; ")}.`
|
|
939
|
+
: "No devices are saved yet: when the user names devices or browsers to test on, save them with browser_devices.",
|
|
940
|
+
ctx.devices?.length ? `The user pinned these devices for runs in this session: ${ctx.devices.join(", ")}.` : "",
|
|
856
941
|
skills,
|
|
857
942
|
domainNotes(cfg),
|
|
858
943
|
knowledgeNotes(cfg),
|
package/src/monitor/run.ts
CHANGED
|
@@ -234,7 +234,7 @@ export function formatRound(r: RoundResult, nextAt?: Date, cwd = process.cwd()):
|
|
|
234
234
|
if (ok.length) lines.push(` ↑ Alert sent: ${ok.join(", ")}`)
|
|
235
235
|
for (const s of r.sent.filter((s) => !s.ok)) lines.push(` ✗ ${CHANNEL[s.channel]} alert not sent: ${s.error}`)
|
|
236
236
|
if (r.suite) {
|
|
237
|
-
const total = suiteUsage(r.suite.results)
|
|
237
|
+
const total = suiteUsage(r.suite.results, r.suite.analysis)
|
|
238
238
|
if (total.usage.input || total.usage.output) lines.push(` Usage ${usageLine(total.usage, total.cost)}`)
|
|
239
239
|
lines.push(` Report ${path.relative(cwd, r.suite.files.html) || r.suite.files.html}`)
|
|
240
240
|
}
|
package/src/report/data.ts
CHANGED
|
@@ -3,6 +3,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
|
|
5
5
|
import type { CaseResult, CaseStatus, Cost, WriteBack } from "../cases/types.ts"
|
|
6
|
+
import type { DefectCluster } from "../cases/defects.ts"
|
|
7
|
+
import { DEFECT_HISTORY } from "../cases/defectHistory.ts"
|
|
8
|
+
import { defectTrends, type DefectTrends } from "../cases/predict.ts"
|
|
6
9
|
import type { ReportEnv } from "./html.ts"
|
|
7
10
|
import { addCost } from "../usage.ts"
|
|
8
11
|
import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
|
|
@@ -106,6 +109,10 @@ export type ReportData = {
|
|
|
106
109
|
cases: ReportCase[]
|
|
107
110
|
posted: WriteBack[]
|
|
108
111
|
history: HistoryRun[]
|
|
112
|
+
/** Failures grouped by cause, from the suite; unset for a single run and for reports written before defects. */
|
|
113
|
+
defects?: DefectCluster[]
|
|
114
|
+
/** How the defects of the suites in this folder went over time, when they have a history. */
|
|
115
|
+
defectTrends?: DefectTrends
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
export type ReportCase = {
|
|
@@ -183,6 +190,7 @@ export type DataInput = {
|
|
|
183
190
|
results: CaseResult[]
|
|
184
191
|
posted: WriteBack[]
|
|
185
192
|
env?: ReportEnv
|
|
193
|
+
defects?: DefectCluster[]
|
|
186
194
|
}
|
|
187
195
|
|
|
188
196
|
export type DataOptions = {
|
|
@@ -243,6 +251,12 @@ export function buildReportData(p: DataInput, opts: DataOptions = {}): ReportDat
|
|
|
243
251
|
cases,
|
|
244
252
|
posted: p.posted.filter((w) => !w.caseId),
|
|
245
253
|
history: [],
|
|
254
|
+
...(p.defects ? { defects: p.defects } : {}),
|
|
255
|
+
}
|
|
256
|
+
if (p.kind === "suite" && existsSync(path.join(path.dirname(p.dir), DEFECT_HISTORY))) {
|
|
257
|
+
try {
|
|
258
|
+
data.defectTrends = defectTrends(path.dirname(p.dir))
|
|
259
|
+
} catch {}
|
|
246
260
|
}
|
|
247
261
|
if (p.kind === "suite") {
|
|
248
262
|
data.history = [
|
package/src/report/html.ts
CHANGED
|
@@ -4,7 +4,8 @@ import path from "node:path"
|
|
|
4
4
|
|
|
5
5
|
import type { RunResult } from "../types.ts"
|
|
6
6
|
import { costOf, type CaseResult, type WriteBack } from "../cases/types.ts"
|
|
7
|
-
import type { AppConfig } from "../config.ts"
|
|
7
|
+
import type { AppConfig, BrowserConfig } from "../config.ts"
|
|
8
|
+
import type { DefectCluster } from "../cases/defects.ts"
|
|
8
9
|
import { packageVersion } from "../tui/theme.ts"
|
|
9
10
|
import { buildReportData, type DataOptions } from "./data.ts"
|
|
10
11
|
|
|
@@ -38,11 +39,21 @@ export function ciInfo(env = process.env): Pick<ReportEnv, "ci" | "commit" | "br
|
|
|
38
39
|
return {}
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export function reportEnv(
|
|
42
|
+
export function reportEnv(
|
|
43
|
+
config: AppConfig,
|
|
44
|
+
opts: { url?: string; headed?: boolean; browser?: Pick<BrowserConfig, "provider" | "remote"> & { device?: string } } = {},
|
|
45
|
+
): ReportEnv {
|
|
42
46
|
const llm = config.llm
|
|
47
|
+
const remote = opts.browser?.remote
|
|
48
|
+
const where = remote
|
|
49
|
+
? `${remote.browser} ${remote.browserVersion} on ${remote.platform || [remote.os, remote.osVersion].filter(Boolean).join(" ")} (${opts.browser!.provider}${opts.browser!.device ? `, device ${opts.browser!.device}` : ""})`
|
|
50
|
+
: undefined
|
|
43
51
|
return {
|
|
44
52
|
model: `${config.activeProfile} (${llm.provider}/${llm.deployment || llm.model})`,
|
|
45
|
-
browser:
|
|
53
|
+
browser:
|
|
54
|
+
llm.provider === "mock"
|
|
55
|
+
? "synthetic page (mock profile)"
|
|
56
|
+
: where || `Chrome, ${opts.headed || !config.browser.headless ? "visible" : "headless"}, ${config.browser.width}×${config.browser.height}`,
|
|
46
57
|
startUrl: opts.url || config.browser.startUrl,
|
|
47
58
|
version: packageVersion(),
|
|
48
59
|
machine: `${os.hostname()} · ${process.platform}`,
|
|
@@ -68,6 +79,7 @@ export type ReportInput = {
|
|
|
68
79
|
env?: ReportEnv
|
|
69
80
|
/** "run" opens on the case itself; "suite" (the default) on the overview. */
|
|
70
81
|
kind?: "suite" | "run"
|
|
82
|
+
defects?: DefectCluster[]
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
/** Built from report-ui/ by `bun run build:report`: one HTML file with the whole React app inlined. */
|