@capagents/forgeqe 1.1.3 → 1.1.5
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 +46 -4
- package/README.md +7 -5
- package/browserstack.example.yaml +15 -3
- package/docs/user-guide.html +62 -10
- package/forgeqe.example.yaml +25 -18
- package/package.json +2 -2
- package/prompts/README.md +1 -0
- package/prompts/brain.md +7 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +12 -2
- package/prompts/test-reviewer.md +5 -2
- package/prompts/test-rules.md +8 -4
- package/py/forgeqe_engine/browser.py +79 -12
- package/py/forgeqe_engine/service.py +12 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/bi.ts +382 -302
- package/src/brain/index.ts +1 -1
- package/src/capabilities/guide.ts +3 -3
- 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 +16 -7
- package/src/cli/index.ts +56 -15
- package/src/code/graph/graph.ts +511 -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/config.ts +160 -56
- 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/tools.ts +92 -9
- 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 +9 -2
- package/src/suite/report.ts +44 -11
- package/src/suite/run.ts +163 -16
- package/src/tui/App.tsx +9 -3
- package/src/usage.ts +1 -1
- package/testmu.example.yaml +14 -4
- package/src/cache.ts +0 -92
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { areaFacts, changeOf, defectTrends, predict, predictionLines, savePrediction, trendsMarkdown, type Prediction } from "../cases/predict.ts"
|
|
6
|
+
import type { McpTool } from "./tools.ts"
|
|
7
|
+
|
|
8
|
+
/** The branch a change is compared with when none is named: the remote's default branch, else main, else master. */
|
|
9
|
+
export function defaultBase(repo: string): string {
|
|
10
|
+
const ok = (ref: string) => spawnSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", ref], { encoding: "utf8" }).status === 0
|
|
11
|
+
const head = spawnSync("git", ["-C", repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], { encoding: "utf8" })
|
|
12
|
+
if (head.status === 0 && head.stdout.trim()) return head.stdout.trim()
|
|
13
|
+
for (const ref of ["origin/main", "main", "origin/master", "master"]) if (ok(ref)) return ref
|
|
14
|
+
throw new Error("No base branch found (origin/HEAD, main, or master): name one with base.")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const pct = (a: number, b: number) => (b ? `${Math.round((a / b) * 100)}%` : "—")
|
|
18
|
+
|
|
19
|
+
export const DEFECT_TOOLS: McpTool[] = [
|
|
20
|
+
{
|
|
21
|
+
name: "defect_trends",
|
|
22
|
+
description:
|
|
23
|
+
"How the defects of the saved suite runs went over time, from defect-history.json next to the suite folders: how many are still there, gone, or came back after a fix, the time they took to go away, the most seen ones with their bugs, defects by area (page or API route), defects per suite, and how often defect predictions were right. Writes defect-trends.md next to the suites. Shows a card. Nothing runs.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
folder: { type: "string", description: "Folder of saved suites (default: suite.out_dir in forgeqe.yaml)" },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
async run(args, ctx) {
|
|
31
|
+
const folder = args.folder ? path.resolve(ctx.cwd, String(args.folder)) : ctx.config.suite.outDir
|
|
32
|
+
const t = defectTrends(folder)
|
|
33
|
+
const md = trendsMarkdown(t)
|
|
34
|
+
mkdirSync(folder, { recursive: true })
|
|
35
|
+
const file = path.join(folder, "defect-trends.md")
|
|
36
|
+
writeFileSync(file, md)
|
|
37
|
+
const p = t.predictions
|
|
38
|
+
ctx.emit?.({
|
|
39
|
+
type: "card",
|
|
40
|
+
card: {
|
|
41
|
+
status: t.defects ? (t.open ? "fail" : "ok") : "info",
|
|
42
|
+
title: t.defects ? `${t.defects} defect${t.defects === 1 ? "" : "s"}: ${t.open} still there, ${t.gone} gone, ${t.regressions} came back` : "No defect history yet",
|
|
43
|
+
stats: `${t.suites} suite run${t.suites === 1 ? "" : "s"}`,
|
|
44
|
+
body: t.defects
|
|
45
|
+
? [
|
|
46
|
+
...(t.daysToFix ? [`Time to go away: median ${t.daysToFix.median} days over ${t.daysToFix.count}`] : []),
|
|
47
|
+
...(p ? [`Predictions (${p.scored} scored): ${p.inPredicted} of ${p.defects} defects (${pct(p.inPredicted, p.defects)}) were in a predicted area; ${p.highWithDefect} of ${p.high} high-risk areas (${pct(p.highWithDefect, p.high)}) had one`] : []),
|
|
48
|
+
"",
|
|
49
|
+
"Most seen:",
|
|
50
|
+
...t.recurring.slice(0, 5).map((d) => ` ${d.seen}× ${d.title}${d.open ? "" : " (gone)"}${d.returns ? `, came back ${d.returns}×` : ""}${d.bug ? ` ${d.bug.label}` : ""}`),
|
|
51
|
+
...(t.areas.length ? ["", "By area:", ...t.areas.slice(0, 5).map((a) => ` ${a.area}: ${a.defects} defect${a.defects === 1 ? "" : "s"}${a.runs ? `, ${a.failed} of ${a.runs} case runs failed` : ""}`)] : []),
|
|
52
|
+
"",
|
|
53
|
+
`Report: ${path.relative(ctx.cwd, file) || file}`,
|
|
54
|
+
]
|
|
55
|
+
: ["Run suites into this folder first: each one adds its defects to defect-history.json."],
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
return { ...t, report: file }
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "predict_defects",
|
|
63
|
+
description:
|
|
64
|
+
"Where a change is likely to cause defects. Compares a git range of the product's repository (base...head; default: the default branch to HEAD, in defects.product_repo or the working directory) with what the saved suites know about each area (page or API route): the change touches an area when it changes a file named after it or its diff mentions the route; the area's earlier defects, defects that came back, defects still open, and case failures raise its risk. Gives each area high, medium, or low with every reason, the cases to run first, and changed code no case reaches. Saves the prediction; the next suite scores it, and defect_trends says how often predictions were right. A heuristic, not a model. Shows a card. Nothing runs.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
repo: { type: "string", description: "The product's repository (default: defects.product_repo, else the working directory)" },
|
|
69
|
+
base: { type: "string", description: "Base ref of the change (default: origin/HEAD, main, or master)" },
|
|
70
|
+
head: { type: "string", description: "Head ref of the change (default: HEAD)" },
|
|
71
|
+
folder: { type: "string", description: "Folder of saved suites (default: suite.out_dir in forgeqe.yaml)" },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
async run(args, ctx) {
|
|
75
|
+
const repo = args.repo ? path.resolve(ctx.cwd, String(args.repo)) : ctx.config.defects?.productRepo || ctx.cwd
|
|
76
|
+
const folder = args.folder ? path.resolve(ctx.cwd, String(args.folder)) : ctx.config.suite.outDir
|
|
77
|
+
const base = args.base ? String(args.base) : defaultBase(repo)
|
|
78
|
+
const head = args.head ? String(args.head) : "HEAD"
|
|
79
|
+
const change = changeOf(repo, base, head)
|
|
80
|
+
const facts = areaFacts(folder)
|
|
81
|
+
const at = new Date().toISOString()
|
|
82
|
+
const p: Prediction = { id: at.replace(/[:.]/g, "-"), at, repo, base, head, changed: change.files, ...predict(facts, change) }
|
|
83
|
+
mkdirSync(folder, { recursive: true })
|
|
84
|
+
savePrediction(folder, p)
|
|
85
|
+
const high = p.areas.filter((a) => a.level === "high").length
|
|
86
|
+
ctx.emit?.({
|
|
87
|
+
type: "card",
|
|
88
|
+
card: {
|
|
89
|
+
status: high ? "fail" : p.areas.length ? "info" : "ok",
|
|
90
|
+
title: p.areas.length ? `${high} high-risk area${high === 1 ? "" : "s"} in ${base}...${head}` : `No known area touched by ${base}...${head}`,
|
|
91
|
+
stats: `${p.changed.length} changed file${p.changed.length === 1 ? "" : "s"} · ${facts.size} known area${facts.size === 1 ? "" : "s"}`,
|
|
92
|
+
body: [...predictionLines(p), ...(facts.size ? [] : ["", "ForgeQE knows no areas yet: run suites into this folder first."])],
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
return {
|
|
96
|
+
base,
|
|
97
|
+
head,
|
|
98
|
+
changed: p.changed,
|
|
99
|
+
areas: p.areas,
|
|
100
|
+
run_first: p.runFirst,
|
|
101
|
+
untested: p.untested,
|
|
102
|
+
next_step: p.runFirst.length ? "Offer to run the run_first cases before the others (run_test_cases)." : undefined,
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
]
|
|
@@ -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/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"
|
|
@@ -124,6 +127,8 @@ export type ToolContext = {
|
|
|
124
127
|
cwd: string
|
|
125
128
|
headed?: boolean
|
|
126
129
|
browserProvider?: AppConfig["browser"]["provider"]
|
|
130
|
+
/** Named devices the user pinned for this session (--devices): runs go to each of them. */
|
|
131
|
+
devices?: string[]
|
|
127
132
|
maxSteps?: number
|
|
128
133
|
replay?: ReplayMode
|
|
129
134
|
memory?: boolean
|
|
@@ -190,14 +195,14 @@ const SOURCES_SCHEMA = {
|
|
|
190
195
|
properties: {
|
|
191
196
|
source: {
|
|
192
197
|
type: "string",
|
|
193
|
-
enum: ["azure_devops", "jira", "xray", "testrail", "zephyr", "qtest", "file", "text"],
|
|
198
|
+
enum: ["azure_devops", "jira", "xray", "testrail", "zephyr", "qtest", "github", "file", "text"],
|
|
194
199
|
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).",
|
|
200
|
+
"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
201
|
},
|
|
197
202
|
ids: {
|
|
198
203
|
type: "array",
|
|
199
204
|
items: { type: "string" },
|
|
200
|
-
description: "Work item ids (azure_devops), issue keys (jira, xray), case ids like C12 (testrail), keys like SHOP-T3 (zephyr),
|
|
205
|
+
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
206
|
},
|
|
202
207
|
plan: { type: "string", description: "Test plan id (azure_devops, testrail) or Test Plan issue key (xray)" },
|
|
203
208
|
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 +210,9 @@ const SOURCES_SCHEMA = {
|
|
|
205
210
|
cycle: { type: "string", description: "zephyr: test cycle key like SHOP-R5. qtest: test cycle id" },
|
|
206
211
|
folder: { type: "string", description: "zephyr: folder id" },
|
|
207
212
|
jql: { type: "string", description: "JQL query (jira, xray)" },
|
|
213
|
+
repo: { type: "string", description: "github: owner/repo (default: sources.github.repo); ids may also be owner/repo#12" },
|
|
214
|
+
labels: { type: "array", items: { type: "string" }, description: "github: open issues with all these labels" },
|
|
215
|
+
query: { type: "string", description: 'github: a GitHub issue search, e.g. "login in:title is:open label:test-case"' },
|
|
208
216
|
paths: { type: "array", items: { type: "string" }, description: "Files, relative to the working directory" },
|
|
209
217
|
text: { type: "string", description: "The test case itself: title on the first line, then steps and expected results" },
|
|
210
218
|
},
|
|
@@ -253,6 +261,61 @@ function summarizeRun(result: RunResult, runDir: string, files: string[]) {
|
|
|
253
261
|
}
|
|
254
262
|
}
|
|
255
263
|
|
|
264
|
+
const DEVICES_SCHEMA = {
|
|
265
|
+
type: "array",
|
|
266
|
+
items: { type: "string" },
|
|
267
|
+
description:
|
|
268
|
+
"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.",
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** One goal on several devices at once: a one-off case through the suite, so each device runs and reports on its own. */
|
|
272
|
+
async function runGoalOnDevices(goal: string, url: string | undefined, devices: string[], args: Record<string, unknown>, ctx: ToolContext) {
|
|
273
|
+
const tc: TestCase = { id: "goal", title: clip(goal.split("\n")[0], 80) || "Goal", source: "text", description: goal, steps: [], ...(url ? { startUrl: url } : {}) }
|
|
274
|
+
const shown = deviceCases([tc], devices)
|
|
275
|
+
const parallel = suiteParallel(undefined, ctx.config.suite.parallel, devices.length, shown.length)
|
|
276
|
+
const emit = ctx.emit || (() => {})
|
|
277
|
+
emit({ type: "suite-start", cases: shown, parallel })
|
|
278
|
+
const suite = await runSuite({
|
|
279
|
+
config: ctx.config,
|
|
280
|
+
cases: [tc],
|
|
281
|
+
devices,
|
|
282
|
+
parallel,
|
|
283
|
+
writeBack: false,
|
|
284
|
+
headed: ctx.headed,
|
|
285
|
+
url,
|
|
286
|
+
maxSteps: ctx.maxSteps,
|
|
287
|
+
replay: ctx.replay,
|
|
288
|
+
memory: ctx.memory,
|
|
289
|
+
skills: skillNames(args.skills),
|
|
290
|
+
skillsRepo: ctx.cwd,
|
|
291
|
+
signal: ctx.signal,
|
|
292
|
+
execute: ctx.execute,
|
|
293
|
+
onEvent: (event) => emit({ type: "suite-event", event }),
|
|
294
|
+
})
|
|
295
|
+
emit({ type: "suite-done", suite })
|
|
296
|
+
const c = counts(suite.results)
|
|
297
|
+
return {
|
|
298
|
+
devices: suite.results.map((r) => ({ device: r.device, status: r.status, summary: clip(r.summary, 600), run_dir: r.runDir, final_url: r.finalUrl })),
|
|
299
|
+
passed: c.passed,
|
|
300
|
+
failed: c.failed + c.errors,
|
|
301
|
+
report: suite.files.html,
|
|
302
|
+
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.",
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Devices a tool call asked for, else the session's pinned ones; names checked against the configuration. */
|
|
307
|
+
function devicesFor(args: Record<string, unknown>, ctx: ToolContext): string[] {
|
|
308
|
+
const asked = Array.isArray(args.devices) ? args.devices.map((d) => String(d).trim()).filter(Boolean) : []
|
|
309
|
+
const devices = asked.length ? asked : ctx.devices || []
|
|
310
|
+
if (!devices.length) return []
|
|
311
|
+
const known = ctx.config.browser?.devices || {}
|
|
312
|
+
const missing = devices.filter((d) => !known[d])
|
|
313
|
+
if (missing.length) {
|
|
314
|
+
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.`)
|
|
315
|
+
}
|
|
316
|
+
return [...new Set(devices)]
|
|
317
|
+
}
|
|
318
|
+
|
|
256
319
|
export const TOOLS: McpTool[] = [
|
|
257
320
|
{
|
|
258
321
|
name: "browser_run",
|
|
@@ -273,6 +336,7 @@ export const TOOLS: McpTool[] = [
|
|
|
273
336
|
description:
|
|
274
337
|
"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
338
|
},
|
|
339
|
+
devices: DEVICES_SCHEMA,
|
|
276
340
|
resume_from: {
|
|
277
341
|
type: "string",
|
|
278
342
|
description:
|
|
@@ -295,6 +359,10 @@ export const TOOLS: McpTool[] = [
|
|
|
295
359
|
const resumeFrom = from ? (existsSync(path.resolve(ctx.cwd, from)) ? path.resolve(ctx.cwd, from) : from) : undefined
|
|
296
360
|
const url = args.start_url ? String(args.start_url) : ctx.url
|
|
297
361
|
const selectedBrowser = args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
|
|
362
|
+
// Pinned devices are for testing the app: a task (reading a work item, looking something up) runs once, here.
|
|
363
|
+
const devices = args.purpose === "task" && !Array.isArray(args.devices) ? [] : devicesFor(args, ctx)
|
|
364
|
+
// Several devices: the goal runs as a one-off case on each, in parallel, through the suite (no write-back).
|
|
365
|
+
if (devices.length > 1) return runGoalOnDevices(goal, url, devices, args, ctx)
|
|
298
366
|
const runDir = runDirectory(ctx.config.export.outDir)
|
|
299
367
|
const emit = ctx.emit || (() => {})
|
|
300
368
|
const t0 = Date.now()
|
|
@@ -303,7 +371,8 @@ export const TOOLS: McpTool[] = [
|
|
|
303
371
|
const { result, files } = await (ctx.execute || executeRun)({
|
|
304
372
|
config: ctx.config,
|
|
305
373
|
goal,
|
|
306
|
-
browserProvider: selectedBrowser,
|
|
374
|
+
browserProvider: devices.length ? undefined : selectedBrowser,
|
|
375
|
+
browserDevice: devices[0],
|
|
307
376
|
url,
|
|
308
377
|
headed: ctx.headed,
|
|
309
378
|
maxSteps: ctx.maxSteps,
|
|
@@ -432,6 +501,7 @@ export const TOOLS: McpTool[] = [
|
|
|
432
501
|
description:
|
|
433
502
|
"Override the browser provider for every case in this suite when the user asked for one specifically.",
|
|
434
503
|
},
|
|
504
|
+
devices: DEVICES_SCHEMA,
|
|
435
505
|
skills: SKILLS_SCHEMA,
|
|
436
506
|
},
|
|
437
507
|
required: ["sources"],
|
|
@@ -443,10 +513,12 @@ export const TOOLS: McpTool[] = [
|
|
|
443
513
|
const cases = all.filter((c) => !skip.has(c.id))
|
|
444
514
|
if (!cases.length) return { ran: false, reason: "No case can run as written (suite.case_check: block).", check: checkResult(checks) }
|
|
445
515
|
if (!cases.length) throw new Error("No test cases found.")
|
|
446
|
-
const
|
|
447
|
-
const
|
|
516
|
+
const devices = devicesFor(args, ctx)
|
|
517
|
+
const shown = deviceCases(cases, devices)
|
|
518
|
+
const parallel = suiteParallel(Number(args.parallel) || undefined, ctx.config.suite.parallel, devices.length, shown.length)
|
|
519
|
+
const selectedBrowser = devices.length ? undefined : args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
|
|
448
520
|
const emit = ctx.emit || (() => {})
|
|
449
|
-
emit({ type: "suite-start", cases, parallel })
|
|
521
|
+
emit({ type: "suite-start", cases: shown, parallel })
|
|
450
522
|
// In a session that writes tests, each passed case goes to the coding agent right after its own browser run,
|
|
451
523
|
// inside the suite, rather than every case being explored first and handed over at the end.
|
|
452
524
|
const coder: typeof runCodeAgent = async (o) => {
|
|
@@ -472,6 +544,7 @@ export const TOOLS: McpTool[] = [
|
|
|
472
544
|
cases,
|
|
473
545
|
...(ctx.writeTests ? { repo: ctx.cwd, coder } : {}),
|
|
474
546
|
browserProvider: selectedBrowser,
|
|
547
|
+
devices,
|
|
475
548
|
parallel,
|
|
476
549
|
writeBack: typeof args.write_back === "boolean" ? args.write_back : undefined,
|
|
477
550
|
headed: ctx.headed,
|
|
@@ -498,8 +571,10 @@ export const TOOLS: McpTool[] = [
|
|
|
498
571
|
stopped: suite.stopped,
|
|
499
572
|
report: suite.files.html,
|
|
500
573
|
junit: suite.files.junit,
|
|
574
|
+
...(devices.length > 1 ? { devices, posted_back: "one combined result per case: it fails when any device fails" } : {}),
|
|
501
575
|
results: suite.results.map((r) => ({
|
|
502
576
|
id: r.case.id,
|
|
577
|
+
...(r.device ? { device: r.device } : {}),
|
|
503
578
|
title: r.case.title,
|
|
504
579
|
status: r.status,
|
|
505
580
|
summary: clip(r.summary, 600),
|
|
@@ -762,6 +837,7 @@ export const TOOLS: McpTool[] = [
|
|
|
762
837
|
},
|
|
763
838
|
...COMMAND_TOOLS,
|
|
764
839
|
...REPO_TEST_TOOLS,
|
|
840
|
+
...DEFECT_TOOLS,
|
|
765
841
|
...PR_TOOLS,
|
|
766
842
|
...CASE_TOOLS,
|
|
767
843
|
...API_TOOLS,
|
|
@@ -770,6 +846,8 @@ export const TOOLS: McpTool[] = [
|
|
|
770
846
|
...CAPABILITY_TOOLS,
|
|
771
847
|
...NOTIFY_TOOLS,
|
|
772
848
|
...REPORT_TOOLS,
|
|
849
|
+
...DEVICE_TOOLS,
|
|
850
|
+
...ATTACH_TOOLS,
|
|
773
851
|
]
|
|
774
852
|
|
|
775
853
|
/** The setup the agent should know about: where it works, which systems are configured, and the defaults. */
|
|
@@ -782,7 +860,7 @@ function knowledgeNotes(cfg: AppConfig): string {
|
|
|
782
860
|
: `No product knowledge yet: the team can describe the product, roles, rules, and test data in ${cfg.knowledge.dir} (forgeqe init adds templates).`
|
|
783
861
|
}
|
|
784
862
|
|
|
785
|
-
export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url" | "browserProvider">): string {
|
|
863
|
+
export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url" | "browserProvider" | "devices">): string {
|
|
786
864
|
const { config: cfg } = ctx
|
|
787
865
|
const s = cfg.sources
|
|
788
866
|
const systems = [
|
|
@@ -823,6 +901,7 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
|
|
|
823
901
|
const browserSkills = skillsFor(loadSkills(cfg.skills, ctx.cwd), "browser")
|
|
824
902
|
const browserProvider = browser.provider || "local"
|
|
825
903
|
const remoteProviders = (["browserstack", "testmu"] as const).filter((name) => browser.remotes?.[name])
|
|
904
|
+
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
905
|
const skills = browserSkills.length
|
|
827
906
|
? [`Browser skills (pass the ones a goal needs as skills to browser_run or run_test_cases):`, ...catalogLines(browserSkills)].join("\n")
|
|
828
907
|
: ""
|
|
@@ -853,6 +932,10 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
|
|
|
853
932
|
keys.length ? `Set in ForgeQE's environment (names only, usable with set_ci_secret): ${keys.join(", ")}.` : "",
|
|
854
933
|
ctx.url ? `The user pinned this start URL for browser runs: ${ctx.url}` : "",
|
|
855
934
|
ctx.browserProvider ? `The user pinned this browser provider for runs in this session: ${ctx.browserProvider}.` : "",
|
|
935
|
+
devices.length
|
|
936
|
+
? `Saved devices (pass names as devices to browser_run or run_test_cases; browser_devices adds, changes, or removes them): ${devices.join("; ")}.`
|
|
937
|
+
: "No devices are saved yet: when the user names devices or browsers to test on, save them with browser_devices.",
|
|
938
|
+
ctx.devices?.length ? `The user pinned these devices for runs in this session: ${ctx.devices.join(", ")}.` : "",
|
|
856
939
|
skills,
|
|
857
940
|
domainNotes(cfg),
|
|
858
941
|
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. */
|