@capagents/forgeqe 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +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 +757 -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 +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import { setPromptDirs } from "./prompts.ts"
|
|
2
|
+
import { qualityConfig, type QualityConfig } from "./quality/budgets.ts"
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
|
|
7
|
+
import { forgeqeHome, projectDir, renamed } from "./tools.ts"
|
|
8
|
+
|
|
9
|
+
import YAML from "yaml"
|
|
10
|
+
|
|
11
|
+
import type { LlmProfile } from "./llm.ts"
|
|
12
|
+
import type { ReplayMode } from "./types.ts"
|
|
13
|
+
import { loadDotEnv } from "./env.ts"
|
|
14
|
+
import { systemLocale } from "./locale.ts"
|
|
15
|
+
import { currencyCode, currencyForLocale } from "./money.ts"
|
|
16
|
+
import { listPrice, pricesDate, type DeploymentType, type ListPrice } from "./prices.ts"
|
|
17
|
+
import { ragConfig, type RagConfig } from "./cases/rag.ts"
|
|
18
|
+
|
|
19
|
+
export type BrowserConfig = {
|
|
20
|
+
headless: boolean
|
|
21
|
+
width: number
|
|
22
|
+
height: number
|
|
23
|
+
maxSteps: number
|
|
24
|
+
timeoutSeconds: number
|
|
25
|
+
visionDetail: "low" | "high"
|
|
26
|
+
allowedHosts: string[]
|
|
27
|
+
startUrl?: string
|
|
28
|
+
/** ForgeQE overlay in a visible browser: edge glow, a bar with the current step, the person's input locked. */
|
|
29
|
+
overlay: boolean
|
|
30
|
+
/** A second model call checks the finished run against the goal, and its verdict can fail a run the agent completed. */
|
|
31
|
+
judge: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type MemoryConfig = {
|
|
35
|
+
enabled: boolean
|
|
36
|
+
dir: string
|
|
37
|
+
replay: ReplayMode
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function runtimeBin(value: unknown, base: string): string | undefined {
|
|
41
|
+
if (!value) return undefined
|
|
42
|
+
const s = String(value)
|
|
43
|
+
return /[\\/]/.test(s) ? path.resolve(base, s) : s
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type CodeConfig = {
|
|
47
|
+
/** Repo the coding agent writes the test into. Unset: no coding step after runs. */
|
|
48
|
+
repo?: string
|
|
49
|
+
maxAttempts: number
|
|
50
|
+
testTimeoutSeconds: number
|
|
51
|
+
/** Scribe's turn stops when one shell command it runs has failed this many times since ForgeQE last ran the test. */
|
|
52
|
+
stallAfter?: number
|
|
53
|
+
/** Give Scribe a Playwright-driven browser, set up like the run's, switched on once a step fails. */
|
|
54
|
+
liveBrowser?: boolean
|
|
55
|
+
/** Before Scribe starts, replay the run's steps as a Playwright script and hand over what worked and where it failed. */
|
|
56
|
+
replay?: boolean
|
|
57
|
+
/** Model for ForgeQE's agent and Scribe as provider/model, instead of the run's llms.json profile. */
|
|
58
|
+
model?: string
|
|
59
|
+
/** llms.json profile for the reviewer, which only reads and judges: a smaller, cheaper model is enough. */
|
|
60
|
+
reviewProfile?: string
|
|
61
|
+
/** Stop a coding job once it has used this many tokens (cached included), and report how far it got. */
|
|
62
|
+
maxTokens?: number
|
|
63
|
+
/** Most tool-calling steps in one of Scribe's turns (code.max_steps, default 50). */
|
|
64
|
+
maxSteps?: number
|
|
65
|
+
/** Move an unfinished test to a needs-attention folder (code.needs_attention, default true). */
|
|
66
|
+
needsAttention?: boolean
|
|
67
|
+
/** A working test with coding-standard issues gets one round to fix them (code.tidy, default true). */
|
|
68
|
+
tidy?: boolean
|
|
69
|
+
/** Agent runtime binary (`code.runtime`, or the older `code.opencode`); default: PATH, then the managed download. */
|
|
70
|
+
opencode?: string
|
|
71
|
+
/** Command that runs the repo's tests, for forgeqe heal; unset: the agent works it out. */
|
|
72
|
+
testCommand?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type SourcesConfig = {
|
|
76
|
+
/** Post results back to Azure DevOps, Jira, or Xray after a suite. */
|
|
77
|
+
writeBack: boolean
|
|
78
|
+
/** Test cases the agent writes from an Azure DevOps or Jira story are saved there, linked to the story (`save_cases`, default true). */
|
|
79
|
+
saveCases?: boolean
|
|
80
|
+
/** `mcp`: give ForgeQE's agent Microsoft's Azure DevOps MCP server (default true), loading `mcpDomains`. `project` limits ForgeQE's own test, sprint, and bug calls; omit it and those calls cover every project. The MCP server is the organization either way. */
|
|
81
|
+
ado?: { orgUrl: string; project?: string; tokenEnv: string; mcp?: boolean; mcpDomains?: string[] }
|
|
82
|
+
/**
|
|
83
|
+
* `cloud` (*.atlassian.net): email + API token. Server/Data Center: personal access token.
|
|
84
|
+
* `mcp`: which Jira MCP server ForgeQE's agent gets (default mcp-atlassian), or false for none.
|
|
85
|
+
*/
|
|
86
|
+
jira?: { url: string; cloud: boolean; emailEnv: string; tokenEnv: string; mcp?: JiraMcp | false }
|
|
87
|
+
/** Xray Cloud API; Xray Server/Data Center goes through the jira settings. */
|
|
88
|
+
xray: { url: string; clientIdEnv: string; clientSecretEnv: string }
|
|
89
|
+
/** Confluence pages as requirements for ForgeQE's agent. Cloud: email + API token; Server/DC: personal access token. */
|
|
90
|
+
confluence?: { url: string; cloud: boolean; emailEnv: string; tokenEnv: string; space?: string }
|
|
91
|
+
/** TestRail: email + API key. */
|
|
92
|
+
testrail?: { url: string; emailEnv: string; tokenEnv: string; projectId?: number }
|
|
93
|
+
/** Zephyr Scale Cloud: API access token. */
|
|
94
|
+
zephyr?: { url: string; tokenEnv: string; projectKey?: string }
|
|
95
|
+
/** qTest Manager: API bearer token. `statuses` are the automation result names mapped in qTest. */
|
|
96
|
+
qtest?: { url: string; tokenEnv: string; projectId: number; statuses: { passed: string; failed: string; error: string } }
|
|
97
|
+
/** File a bug for each failed case, or comment on the open one ForgeQE filed before. */
|
|
98
|
+
bugs?: BugsConfig
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type BugsConfig = {
|
|
102
|
+
system: "jira" | "azure_devops"
|
|
103
|
+
/** Jira project key. Azure DevOps uses this, or sources.azure_devops.project. */
|
|
104
|
+
project?: string
|
|
105
|
+
/** Jira issue type or Azure DevOps work item type. */
|
|
106
|
+
type: string
|
|
107
|
+
labels: string[]
|
|
108
|
+
/** Azure DevOps area path. */
|
|
109
|
+
areaPath?: string
|
|
110
|
+
/** Also file bugs for cases that could not run (status error). */
|
|
111
|
+
onError: boolean
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A site the browser agent can sign in to. Values come from the environment; the agent only sees placeholders. */
|
|
115
|
+
export type LoginConfig = {
|
|
116
|
+
name: string
|
|
117
|
+
/** Engine domain patterns, e.g. https://staging.example.com or https://*.example.com. */
|
|
118
|
+
domains: string[]
|
|
119
|
+
url?: string
|
|
120
|
+
usernameEnv?: string
|
|
121
|
+
passwordEnv?: string
|
|
122
|
+
/** Authenticator (TOTP) secret, base32. */
|
|
123
|
+
totpEnv?: string
|
|
124
|
+
/** Cookies and local storage kept between runs; unset when save_session is false. */
|
|
125
|
+
sessionFile?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ReportConfig = {
|
|
129
|
+
/** Step screenshots in reports: every step, only failed runs, or none. */
|
|
130
|
+
screenshots: "all" | "failures" | "off"
|
|
131
|
+
/** Accessibility checks on each page the browser agent visits. */
|
|
132
|
+
accessibility: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type ReleaseThresholds = {
|
|
136
|
+
/** Open bugs of severity 1 (critical) and 2 (high) allowed. */
|
|
137
|
+
maxCritical: number
|
|
138
|
+
maxHigh: number
|
|
139
|
+
/** Executed test cases that must pass, in percent. */
|
|
140
|
+
minPassRate: number
|
|
141
|
+
/** A story with no test case is a risk (true) or ignored. */
|
|
142
|
+
requireTests: boolean
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const DEFAULT_THRESHOLDS: ReleaseThresholds = { maxCritical: 0, maxHigh: 0, minPassRate: 95, requireTests: true }
|
|
146
|
+
|
|
147
|
+
function releaseThresholds(raw: unknown): ReleaseThresholds {
|
|
148
|
+
const r = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}
|
|
149
|
+
const num = (v: unknown, d: number) => (v === undefined || v === null || v === "" || Number.isNaN(Number(v)) ? d : Number(v))
|
|
150
|
+
return {
|
|
151
|
+
maxCritical: num(r.max_critical, DEFAULT_THRESHOLDS.maxCritical),
|
|
152
|
+
maxHigh: num(r.max_high, DEFAULT_THRESHOLDS.maxHigh),
|
|
153
|
+
minPassRate: num(r.min_pass_rate, DEFAULT_THRESHOLDS.minPassRate),
|
|
154
|
+
requireTests: r.require_tests !== false,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export type FlakyConfig = {
|
|
159
|
+
/** Runs of a failed test in the same run, to tell a flaky failure from a real one (flaky.retries, default 1). */
|
|
160
|
+
retries: number
|
|
161
|
+
/** suggest (default): the agent offers to quarantine a flaky test; auto: ForgeQE does; off: history only. */
|
|
162
|
+
quarantine: "suggest" | "auto" | "off"
|
|
163
|
+
/** Flakiness score (passes on retry, plus pairs of flips, in the last 20 runs) from which a test counts as flaky. */
|
|
164
|
+
flakyAfter: number
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type ApiConfig = {
|
|
168
|
+
/** Record the application's API calls in every browser run (network.json), for API and contract tests (api.capture, default on). */
|
|
169
|
+
capture: boolean
|
|
170
|
+
/** Base URL contracts run against when it is not the one they were recorded or described with. */
|
|
171
|
+
baseUrl?: string
|
|
172
|
+
/** Headers every API call sends, with ${NAME} for values from the environment (e.g. Authorization: Bearer ${API_TOKEN}). */
|
|
173
|
+
headers: Record<string, string>
|
|
174
|
+
/** Calls to other sites than the page's (CDNs, identity providers) are left out of contracts unless this is on. */
|
|
175
|
+
thirdParty: boolean
|
|
176
|
+
timeoutSeconds: number
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type SuiteConfig = {
|
|
180
|
+
/** Browser runs at the same time. Coding runs one at a time. */
|
|
181
|
+
parallel: number
|
|
182
|
+
/** Write a test for each passed case when a repo is set. */
|
|
183
|
+
code: boolean
|
|
184
|
+
outDir: string
|
|
185
|
+
/** Check test cases before running them: warn (show the findings), block (skip cases that cannot run), or off. */
|
|
186
|
+
caseCheck?: "warn" | "block" | "off"
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type SkillsConfig = {
|
|
190
|
+
enabled: boolean
|
|
191
|
+
/** .forgeqe/skills next to forgeqe.yaml (the current folder without one). */
|
|
192
|
+
projectDir: string
|
|
193
|
+
/** ~/.forgeqe/skills, or under FORGEQE_HOME. */
|
|
194
|
+
userDir: string
|
|
195
|
+
/** `skills.dirs`, resolved against forgeqe.yaml; they win over every other source. */
|
|
196
|
+
dirs: string[]
|
|
197
|
+
/** Skill names turned off. */
|
|
198
|
+
disable: string[]
|
|
199
|
+
/** Also the skills other tools use: ~/.claude/skills, ~/.agents/skills, and the repository's .claude/.agents skills. */
|
|
200
|
+
external: boolean
|
|
201
|
+
/** Cap on the skill text added to the browser agent's prompt. */
|
|
202
|
+
browserMaxChars: number
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type MonitorConfig = {
|
|
206
|
+
/** Test case sources checked every round, as forgeqe suite takes them. */
|
|
207
|
+
checks: string[]
|
|
208
|
+
/** Time from the start of one round to the start of the next. */
|
|
209
|
+
everyMs: number
|
|
210
|
+
replay: ReplayMode
|
|
211
|
+
parallel: number
|
|
212
|
+
/** Failing rounds in a row before a check's first alert. */
|
|
213
|
+
failAfter: number
|
|
214
|
+
/** Alert again while a check keeps failing; 0: never. */
|
|
215
|
+
remindMs: number
|
|
216
|
+
/** Round folders kept in outDir; older ones are deleted. */
|
|
217
|
+
keep: number
|
|
218
|
+
writeBack: boolean
|
|
219
|
+
outDir: string
|
|
220
|
+
/** Where outDir is served, so alerts can link to each round's report. */
|
|
221
|
+
reportUrl?: string
|
|
222
|
+
alerts: MonitorAlerts
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export type MonitorAlerts = {
|
|
226
|
+
/** A Teams incoming webhook or Workflows "when a webhook request is received" URL, from the environment. */
|
|
227
|
+
teams?: { webhookEnv: string }
|
|
228
|
+
email?: EmailConfig
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** What a channel is told about without being asked (notify: in forgeqe.yaml). */
|
|
232
|
+
export const NOTIFY_EVENTS = ["suite_failed", "suite_passed", "release", "eval_failed"] as const
|
|
233
|
+
export type NotifyEvent = (typeof NOTIFY_EVENTS)[number]
|
|
234
|
+
|
|
235
|
+
/** A named place notifications go: a Teams channel's webhook, email, or both, and the events it gets. */
|
|
236
|
+
export type NotifyChannel = MonitorAlerts & { name: string; on: NotifyEvent[] }
|
|
237
|
+
|
|
238
|
+
export type NotifyConfig = {
|
|
239
|
+
/** Where the reports folder is published, so cards link to each report; in CI the build's page is linked too. */
|
|
240
|
+
reportUrl?: string
|
|
241
|
+
channels: NotifyChannel[]
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function notifyConfig(raw: unknown): NotifyConfig | undefined {
|
|
245
|
+
const n = asRecord(raw)
|
|
246
|
+
const channels = Object.entries(asRecord(n.channels)).map(([name, value]): NotifyChannel => {
|
|
247
|
+
const c = asRecord(value)
|
|
248
|
+
const where = `notify.channels.${name}`
|
|
249
|
+
const alerts = monitorAlerts(c, where)
|
|
250
|
+
if (!alerts.teams && !alerts.email) throw new Error(`${where} needs teams (a webhook_env) or email.`)
|
|
251
|
+
const on = c.on === undefined ? ["suite_failed", "release", "eval_failed"] : (Array.isArray(c.on) ? c.on : [c.on]).map(String)
|
|
252
|
+
const unknown = on.filter((e) => !(NOTIFY_EVENTS as readonly string[]).includes(e))
|
|
253
|
+
if (unknown.length) throw new Error(`${where}.on: ${unknown.join(", ")} is not an event. Events: ${NOTIFY_EVENTS.join(", ")}.`)
|
|
254
|
+
return { name, ...alerts, on: on as NotifyEvent[] }
|
|
255
|
+
})
|
|
256
|
+
if (!channels.length) return undefined
|
|
257
|
+
return { channels, ...(n.report_url ? { reportUrl: trimUrl(n.report_url) } : {}) }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export type EmailConfig = {
|
|
261
|
+
host: string
|
|
262
|
+
port: number
|
|
263
|
+
/** TLS from the start (port 465); otherwise STARTTLS when the server offers it. */
|
|
264
|
+
secure: boolean
|
|
265
|
+
usernameEnv: string
|
|
266
|
+
passwordEnv: string
|
|
267
|
+
/** Default: the SMTP user name. */
|
|
268
|
+
from?: string
|
|
269
|
+
to: string[]
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type JiraMcp = "mcp-atlassian" | "rovo"
|
|
273
|
+
|
|
274
|
+
/** An extra MCP server for ForgeQE's agent. Values may use {env:NAME}; the agent runtime fills them in. */
|
|
275
|
+
export type ExtraMcpServer =
|
|
276
|
+
| { type: "local"; command: string[]; environment?: Record<string, string>; cwd?: string; timeout?: number; enabled?: boolean }
|
|
277
|
+
| { type: "remote"; url: string; headers?: Record<string, string>; timeout?: number; enabled?: boolean }
|
|
278
|
+
|
|
279
|
+
export type AppConfig = {
|
|
280
|
+
configPath?: string
|
|
281
|
+
/** `mcp:` in forgeqe.yaml: more MCP servers for ForgeQE's agent, by name. */
|
|
282
|
+
mcp?: Record<string, ExtraMcpServer>
|
|
283
|
+
activeProfile: string
|
|
284
|
+
profiles: Record<string, LlmProfile>
|
|
285
|
+
llm: LlmProfile
|
|
286
|
+
browser: BrowserConfig
|
|
287
|
+
/**
|
|
288
|
+
* stepInstruction and guiderails are added to the browser agent's prompt. dirs: where edited prompts are looked for,
|
|
289
|
+
* highest precedence first (prompts.dir, the project's .forgeqe/prompts, ~/.forgeqe/prompts); see src/prompts.ts.
|
|
290
|
+
*/
|
|
291
|
+
prompts: { stepInstruction: string; guiderails: string; dirs?: { dir: string; source: string }[] }
|
|
292
|
+
/** Product knowledge (.forgeqe/knowledge): see src/knowledge.ts. */
|
|
293
|
+
knowledge?: { dir: string; browserMaxChars: number }
|
|
294
|
+
export: { outDir: string; format: string }
|
|
295
|
+
memory: MemoryConfig
|
|
296
|
+
code: CodeConfig
|
|
297
|
+
sources: SourcesConfig
|
|
298
|
+
suite: SuiteConfig
|
|
299
|
+
logins: LoginConfig[]
|
|
300
|
+
/** Where saved browser sessions are kept: .forgeqe/sessions next to forgeqe.yaml. */
|
|
301
|
+
sessionsDir?: string
|
|
302
|
+
/** Session logs for debugging: .forgeqe/logs next to forgeqe.yaml. */
|
|
303
|
+
logsDir?: string
|
|
304
|
+
report: ReportConfig
|
|
305
|
+
monitor: MonitorConfig
|
|
306
|
+
skills: SkillsConfig
|
|
307
|
+
/** An optional requirements search (RAG) the test case check asks for passages: see src/cases/rag.ts. */
|
|
308
|
+
rag?: RagConfig
|
|
309
|
+
/** Flaky tests: retries in one run, and what to do with tests that keep flip-flopping (see src/code/history.ts). */
|
|
310
|
+
flaky?: FlakyConfig
|
|
311
|
+
/** Thresholds of the release-readiness verdict (release: in forgeqe.yaml). */
|
|
312
|
+
release?: ReleaseThresholds
|
|
313
|
+
/** API and contract tests (api: in forgeqe.yaml): see src/api/. */
|
|
314
|
+
api?: ApiConfig
|
|
315
|
+
/** Accessibility standard and accessibility/performance budgets (quality: in forgeqe.yaml): see src/quality/. */
|
|
316
|
+
quality?: QualityConfig
|
|
317
|
+
/** The product's industry and the regulations in scope (domain: in forgeqe.yaml): see src/domain.ts. */
|
|
318
|
+
domain?: DomainConfig
|
|
319
|
+
/** Teams and email notifications for suites, release readiness, and evaluations (notify: in forgeqe.yaml): see src/notify/. */
|
|
320
|
+
notify?: NotifyConfig
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type DomainConfig = {
|
|
324
|
+
/** In the team's words, e.g. "retail banking", "property and casualty insurance". */
|
|
325
|
+
industry: string
|
|
326
|
+
/** Regulations and standards in scope, e.g. DORA, PSD2, GDPR, ISO 27001. Their applicable controls go in the knowledge folder. */
|
|
327
|
+
regulations: string[]
|
|
328
|
+
/** Anything else the agents should know about the domain, in one or two sentences. */
|
|
329
|
+
notes?: string
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function domainConfig(raw: unknown): DomainConfig | undefined {
|
|
333
|
+
const d = asRecord(raw)
|
|
334
|
+
const industry = String(d.industry || "").trim()
|
|
335
|
+
const list = Array.isArray(d.regulations) ? d.regulations : typeof d.regulations === "string" ? d.regulations.split(",") : []
|
|
336
|
+
const regulations = [...new Set(list.map((r) => String(r).trim()).filter(Boolean))]
|
|
337
|
+
if (!industry && !regulations.length) return undefined
|
|
338
|
+
return { industry, regulations, ...(d.notes ? { notes: String(d.notes).trim() } : {}) }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function replayMode(raw: unknown): ReplayMode {
|
|
342
|
+
const mode = String(raw || "verify").toLowerCase()
|
|
343
|
+
if (mode === "trust" || mode === "off") return mode
|
|
344
|
+
if (mode !== "verify") throw new Error(`Unknown replay mode "${String(raw)}". Use verify, trust or off.`)
|
|
345
|
+
return "verify"
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const MOCK_PROFILE: LlmProfile = { provider: "mock", model: "mock", temperature: 0 }
|
|
349
|
+
const NO_PROFILE: LlmProfile = { provider: "none", model: "" }
|
|
350
|
+
|
|
351
|
+
export function userConfigDir(): string {
|
|
352
|
+
return process.env.FORGEQE_CONFIG_DIR || renamed(path.join(os.homedir(), ".config"), "forgeqe", "webpilot")
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
356
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function readJson(file: string): Record<string, unknown> {
|
|
360
|
+
if (!existsSync(file)) return {}
|
|
361
|
+
const data = JSON.parse(readFileSync(file, "utf8"))
|
|
362
|
+
return asRecord(data)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function readYaml(file: string): Record<string, unknown> {
|
|
366
|
+
if (!existsSync(file)) return {}
|
|
367
|
+
return asRecord(YAML.parse(readFileSync(file, "utf8")))
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function visionDetail(raw: unknown): BrowserConfig["visionDetail"] {
|
|
371
|
+
return String(raw || "low").toLowerCase() === "high" ? "high" : "low"
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function profileFromRaw(name: string, raw: Record<string, unknown>): LlmProfile {
|
|
375
|
+
const provider = String(raw.provider || "openai") as LlmProfile["provider"]
|
|
376
|
+
return {
|
|
377
|
+
provider,
|
|
378
|
+
model: String(raw.model || "gpt-4o"),
|
|
379
|
+
endpoint: raw.endpoint ? String(raw.endpoint) : undefined,
|
|
380
|
+
deployment: raw.deployment ? String(raw.deployment) : undefined,
|
|
381
|
+
apiKey: raw.api_key ? String(raw.api_key) : undefined,
|
|
382
|
+
apiKeyEnv: raw.api_key_env ? String(raw.api_key_env) : undefined,
|
|
383
|
+
...(String(raw.auth || "").toLowerCase() === "entra" ? { auth: "entra" as const } : {}),
|
|
384
|
+
managedIdentityClientId: raw.managed_identity_client_id ? String(raw.managed_identity_client_id) : undefined,
|
|
385
|
+
tokenScope: raw.token_scope ? String(raw.token_scope) : undefined,
|
|
386
|
+
apiVersion: raw.api_version ? String(raw.api_version) : undefined,
|
|
387
|
+
temperature: typeof raw.temperature === "number" ? raw.temperature : undefined,
|
|
388
|
+
...priceFor(name, raw, provider, String(raw.model || "gpt-4o")),
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A profile's price: llms.json's when it sets one (a negotiated rate wins), with the cached-input price filled from
|
|
394
|
+
* the model's list discount when llms.json leaves it out; otherwise the model's list price. Also a warning when
|
|
395
|
+
* llms.json's price differs from the list price, which is usually a typo or an old price.
|
|
396
|
+
*/
|
|
397
|
+
function priceFor(name: string, raw: Record<string, unknown>, provider: string, model: string): Pick<LlmProfile, "price" | "priceWarning"> {
|
|
398
|
+
const deployment: DeploymentType = /data.?zone|^dz$/i.test(String(raw.deployment_type || "")) ? "data_zone" : "global"
|
|
399
|
+
const list = listPrice(provider, model, deployment)
|
|
400
|
+
const date = pricesDate()
|
|
401
|
+
const where = list ? `${list.source}${date ? `, ${date}` : ""}` : ""
|
|
402
|
+
const own = priceFromRaw(name, raw.price)
|
|
403
|
+
if (!own) {
|
|
404
|
+
if (!list) return {}
|
|
405
|
+
return {
|
|
406
|
+
price: {
|
|
407
|
+
input: list.input,
|
|
408
|
+
output: list.output,
|
|
409
|
+
currency: "USD",
|
|
410
|
+
...(list.cacheRead !== undefined ? { cachedInput: list.cacheRead } : {}),
|
|
411
|
+
...(list.cacheWrite !== undefined ? { cacheWrite: list.cacheWrite } : {}),
|
|
412
|
+
note: `list price of ${model}${provider === "azure" ? (deployment === "data_zone" ? " (Azure Data Zone)" : " (Azure Global)") : ""} from ${where}`,
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const price = { ...own }
|
|
417
|
+
// A list in US dollars can only be compared with, or fill in, a price in US dollars.
|
|
418
|
+
const usable = list && own.currency === "USD" ? list : null
|
|
419
|
+
if (usable && price.cachedInput === undefined && usable.cacheRead !== undefined && usable.input > 0) {
|
|
420
|
+
const share = usable.cacheRead / usable.input
|
|
421
|
+
price.cachedInput = Math.round(price.input * share * 1e6) / 1e6
|
|
422
|
+
price.estimate = true
|
|
423
|
+
price.note = `cached input at ${Math.round(share * 100)}% of the input price, ${model}'s cache discount in its list price (${where})`
|
|
424
|
+
}
|
|
425
|
+
if (usable && price.cacheWrite === undefined && usable.cacheWrite !== undefined && usable.input > 0) price.cacheWrite = Math.round(price.input * (usable.cacheWrite / usable.input) * 1e6) / 1e6
|
|
426
|
+
return { price, ...(usable ? priceWarning(name, model, own, usable, where) : {}) }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function priceWarning(name: string, model: string, own: NonNullable<LlmProfile["price"]>, list: ListPrice, where: string): Pick<LlmProfile, "priceWarning"> {
|
|
430
|
+
const off = (a: number | undefined, b: number | undefined) => a !== undefined && b !== undefined && Math.abs(a - b) > Math.max(0.005, b * 0.01)
|
|
431
|
+
const diffs = [
|
|
432
|
+
off(own.input, list.input) ? `input ${money(own.input)} (list ${money(list.input)})` : "",
|
|
433
|
+
off(own.output, list.output) ? `output ${money(own.output)} (list ${money(list.output)})` : "",
|
|
434
|
+
off(own.cachedInput, list.cacheRead) ? `cached input ${money(own.cachedInput!)} (list ${money(list.cacheRead!)})` : "",
|
|
435
|
+
].filter(Boolean)
|
|
436
|
+
if (!diffs.length) return {}
|
|
437
|
+
return {
|
|
438
|
+
priceWarning: `llms.json prices "${name}" (${model}) at ${diffs.join(", ")} per million tokens; the list price is from ${where}. Keep it for a negotiated rate, or remove the price to use the list price.`,
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const money = (n: number) => `$${n < 1 ? n.toFixed(3).replace(/0+$/, "").replace(/\.$/, "") : n.toFixed(2)}`
|
|
443
|
+
|
|
444
|
+
function priceFromRaw(profile: string, raw: unknown): LlmProfile["price"] {
|
|
445
|
+
if (raw === undefined || raw === null) return undefined
|
|
446
|
+
const p = asRecord(raw)
|
|
447
|
+
const input = Number(p.input)
|
|
448
|
+
const output = Number(p.output)
|
|
449
|
+
if (!Number.isFinite(input) || !Number.isFinite(output) || input < 0 || output < 0) {
|
|
450
|
+
throw new Error(`llms.json: "${profile}".price needs input and output, the price per million tokens (e.g. { "input": 2, "output": 8 }).`)
|
|
451
|
+
}
|
|
452
|
+
const currency = p.currency ? currencyCode(String(p.currency)) : currencyForLocale(systemLocale())
|
|
453
|
+
if (!currency) throw new Error(`llms.json: "${profile}".price.currency must be an ISO currency code (USD, EUR, GBP, …). Leave it out to use this machine's currency.`)
|
|
454
|
+
const rawCached = p.cached_input ?? p.cachedInput
|
|
455
|
+
if (rawCached === undefined || rawCached === null) return { input, output, currency }
|
|
456
|
+
const cachedInput = Number(rawCached)
|
|
457
|
+
if (!Number.isFinite(cachedInput) || cachedInput < 0) throw new Error(`llms.json: "${profile}".price.cached_input must be the price per million cached input tokens (e.g. 0.25).`)
|
|
458
|
+
return { input, output, currency, cachedInput }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Profiles from llms.json, without the mock entries older templates wrote; fallback is "" when there are none. */
|
|
462
|
+
function loadProfiles(modelsFile: string | undefined): { profiles: Record<string, LlmProfile>; fallback: string } {
|
|
463
|
+
if (!modelsFile || !existsSync(modelsFile)) return { profiles: {}, fallback: "" }
|
|
464
|
+
const doc = readJson(modelsFile)
|
|
465
|
+
const models = asRecord(doc.models)
|
|
466
|
+
const profiles: Record<string, LlmProfile> = {}
|
|
467
|
+
for (const [name, value] of Object.entries(models)) {
|
|
468
|
+
const profile = profileFromRaw(name, asRecord(value))
|
|
469
|
+
if (profile.provider !== "mock" && name !== "mock") profiles[name] = profile
|
|
470
|
+
}
|
|
471
|
+
const fallback = typeof doc.default === "string" && profiles[doc.default] ? doc.default : Object.keys(profiles)[0] || ""
|
|
472
|
+
return { profiles, fallback }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function findConfigFile(explicit?: string): string | undefined {
|
|
476
|
+
if (explicit) {
|
|
477
|
+
const file = path.resolve(explicit)
|
|
478
|
+
if (!existsSync(file)) throw new Error(`Config not found: ${file}`)
|
|
479
|
+
return file
|
|
480
|
+
}
|
|
481
|
+
if (process.env.WP_CONFIG) {
|
|
482
|
+
const file = path.resolve(process.env.WP_CONFIG)
|
|
483
|
+
if (!existsSync(file)) throw new Error(`Config not found: ${file}`)
|
|
484
|
+
return file
|
|
485
|
+
}
|
|
486
|
+
const local = renamed(process.cwd(), "forgeqe.yaml", "webpilot.yaml")
|
|
487
|
+
if (existsSync(local)) return local
|
|
488
|
+
const global = renamed(userConfigDir(), "forgeqe.yaml", "webpilot.yaml")
|
|
489
|
+
if (existsSync(global)) return global
|
|
490
|
+
return undefined
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Tokens one coding job (Scribe and its reviewer) may use when forgeqe.yaml sets no code.max_tokens. */
|
|
494
|
+
export const DEFAULT_CODE_MAX_TOKENS = 1_500_000
|
|
495
|
+
|
|
496
|
+
/** code.max_tokens: unset is the default budget, 0 (or less) turns the budget off. */
|
|
497
|
+
function codeBudget(raw: unknown): number | undefined {
|
|
498
|
+
const n = raw === undefined || raw === null || raw === "" ? DEFAULT_CODE_MAX_TOKENS : Number(raw)
|
|
499
|
+
return n > 0 ? n : undefined
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function loadConfig(opts?: { configPath?: string; profile?: string }): AppConfig {
|
|
503
|
+
// FORGEQE_CWD_ENV=off: tests never read the .env of the folder they run in (the developer's own keys).
|
|
504
|
+
if (process.env.FORGEQE_CWD_ENV !== "off") loadDotEnv(process.cwd())
|
|
505
|
+
const configPath = findConfigFile(opts?.configPath)
|
|
506
|
+
if (configPath) loadDotEnv(path.dirname(configPath))
|
|
507
|
+
loadDotEnv(userConfigDir())
|
|
508
|
+
|
|
509
|
+
const yaml = configPath ? readYaml(configPath) : {}
|
|
510
|
+
const base = configPath ? path.dirname(configPath) : process.cwd()
|
|
511
|
+
const llm = asRecord(yaml.llm)
|
|
512
|
+
const browser = asRecord(yaml.browser)
|
|
513
|
+
const viewport = asRecord(browser.viewport)
|
|
514
|
+
const prompts = asRecord(yaml.prompts)
|
|
515
|
+
const exp = asRecord(yaml.export)
|
|
516
|
+
const memory = asRecord(yaml.memory)
|
|
517
|
+
const code = asRecord(yaml.code)
|
|
518
|
+
const sources = asRecord(yaml.sources)
|
|
519
|
+
const suite = asRecord(yaml.suite)
|
|
520
|
+
const outDir = exp.out_dir ? path.resolve(base, String(exp.out_dir)) : path.join(projectDir(base), "reports")
|
|
521
|
+
|
|
522
|
+
const modelsRel = process.env.WP_LLMS_JSON || (llm.models_file ? String(llm.models_file) : "./llms.json")
|
|
523
|
+
const projectModels = path.resolve(base, modelsRel)
|
|
524
|
+
const globalModels = path.join(userConfigDir(), "llms.json")
|
|
525
|
+
const modelsFile = existsSync(projectModels) ? projectModels : existsSync(globalModels) ? globalModels : undefined
|
|
526
|
+
const { profiles, fallback } = loadProfiles(modelsFile)
|
|
527
|
+
|
|
528
|
+
const asked = opts?.profile || process.env.WP_LLM_PROFILE
|
|
529
|
+
if (asked === "mock") profiles.mock = { ...MOCK_PROFILE }
|
|
530
|
+
const active = llm.active && String(llm.active) !== "mock" ? String(llm.active) : ""
|
|
531
|
+
const requested = asked || active || fallback
|
|
532
|
+
const activeProfile = profiles[requested] ? requested : fallback
|
|
533
|
+
if (opts?.profile && !profiles[opts.profile]) {
|
|
534
|
+
throw new Error(`Unknown LLM profile "${opts.profile}". Run: forgeqe models`)
|
|
535
|
+
}
|
|
536
|
+
if (code.review_profile && !profiles[String(code.review_profile)]) {
|
|
537
|
+
throw new Error(`code.review_profile in forgeqe.yaml names "${code.review_profile}", which is not a profile in llms.json. Run: forgeqe models`)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const hosts = Array.isArray(browser.allowed_hosts)
|
|
541
|
+
? browser.allowed_hosts.map((h) => String(h)).filter(Boolean)
|
|
542
|
+
: []
|
|
543
|
+
|
|
544
|
+
const sessionsDir = configPath ? path.join(projectDir(base), "sessions") : path.join(forgeqeHome(), "sessions")
|
|
545
|
+
const config: AppConfig = {
|
|
546
|
+
configPath,
|
|
547
|
+
mcp: extraMcpServers(yaml.mcp, base),
|
|
548
|
+
activeProfile,
|
|
549
|
+
profiles,
|
|
550
|
+
llm: profiles[activeProfile] || { ...NO_PROFILE },
|
|
551
|
+
browser: {
|
|
552
|
+
headless: browser.headless !== false,
|
|
553
|
+
width: Number(viewport.width || 1280),
|
|
554
|
+
height: Number(viewport.height || 800),
|
|
555
|
+
maxSteps: Number(browser.max_steps || 40),
|
|
556
|
+
timeoutSeconds: Number(browser.timeout_seconds || 600),
|
|
557
|
+
visionDetail: visionDetail(browser.vision_detail),
|
|
558
|
+
allowedHosts: hosts,
|
|
559
|
+
startUrl: browser.start_url ? String(browser.start_url) : undefined,
|
|
560
|
+
overlay: browser.overlay !== false,
|
|
561
|
+
judge: browser.judge === true,
|
|
562
|
+
},
|
|
563
|
+
prompts: {
|
|
564
|
+
stepInstruction: String(prompts.step_instruction || ""),
|
|
565
|
+
guiderails: String(prompts.guiderails || ""),
|
|
566
|
+
dirs: promptDirsFor(prompts, base, configPath),
|
|
567
|
+
},
|
|
568
|
+
knowledge: knowledgeConfig(asRecord(yaml.knowledge), base, configPath),
|
|
569
|
+
export: {
|
|
570
|
+
outDir,
|
|
571
|
+
format: String(exp.format || "playwright"),
|
|
572
|
+
},
|
|
573
|
+
memory: {
|
|
574
|
+
enabled: memory.enabled !== false,
|
|
575
|
+
dir: memory.dir
|
|
576
|
+
? path.resolve(base, String(memory.dir))
|
|
577
|
+
: configPath
|
|
578
|
+
? path.join(projectDir(base), "memory")
|
|
579
|
+
: path.join(forgeqeHome(), "memory"),
|
|
580
|
+
replay: replayMode(memory.replay),
|
|
581
|
+
},
|
|
582
|
+
code: {
|
|
583
|
+
repo: code.repo ? path.resolve(base, String(code.repo)) : undefined,
|
|
584
|
+
maxAttempts: Number(code.max_attempts || 3),
|
|
585
|
+
testTimeoutSeconds: Number(code.test_timeout_seconds || 600),
|
|
586
|
+
stallAfter: Math.max(2, Number(code.stall_after || 5)),
|
|
587
|
+
liveBrowser: code.live_browser !== false,
|
|
588
|
+
replay: code.replay !== false,
|
|
589
|
+
model: code.model ? String(code.model) : undefined,
|
|
590
|
+
...(code.review_profile ? { reviewProfile: String(code.review_profile) } : {}),
|
|
591
|
+
...(codeBudget(code.max_tokens) ? { maxTokens: codeBudget(code.max_tokens) } : {}),
|
|
592
|
+
maxSteps: Number(code.max_steps) > 0 ? Number(code.max_steps) : 50,
|
|
593
|
+
needsAttention: code.needs_attention !== false,
|
|
594
|
+
tidy: code.tidy !== false,
|
|
595
|
+
opencode: runtimeBin(code.runtime || code.opencode, base),
|
|
596
|
+
...(code.test_command ? { testCommand: String(code.test_command) } : {}),
|
|
597
|
+
},
|
|
598
|
+
sources: sourcesConfig(sources),
|
|
599
|
+
...(ragConfig(yaml.rag) ? { rag: ragConfig(yaml.rag) } : {}),
|
|
600
|
+
flaky: flakyConfig(asRecord(yaml.flaky)),
|
|
601
|
+
release: releaseThresholds(yaml.release),
|
|
602
|
+
api: apiConfig(asRecord(yaml.api)),
|
|
603
|
+
quality: withBaselineDir(qualityConfig(yaml.quality), base, configPath),
|
|
604
|
+
domain: domainConfig(yaml.domain),
|
|
605
|
+
notify: notifyConfig(yaml.notify),
|
|
606
|
+
suite: {
|
|
607
|
+
parallel: Math.max(1, Number(suite.parallel || 1)),
|
|
608
|
+
code: suite.code !== false,
|
|
609
|
+
caseCheck: suite.case_check === "block" || suite.case_check === "off" || suite.case_check === false ? (suite.case_check === false ? "off" : suite.case_check) : "warn",
|
|
610
|
+
outDir: suite.out_dir ? path.resolve(base, String(suite.out_dir)) : path.join(outDir, "suites"),
|
|
611
|
+
},
|
|
612
|
+
logins: loginsConfig(yaml.logins, sessionsDir),
|
|
613
|
+
sessionsDir,
|
|
614
|
+
logsDir: path.join(path.dirname(sessionsDir), "logs"),
|
|
615
|
+
report: { screenshots: screenshotsMode(asRecord(yaml.report).screenshots), accessibility: asRecord(yaml.report).accessibility !== false },
|
|
616
|
+
monitor: monitorConfig(asRecord(yaml.monitor), base, outDir),
|
|
617
|
+
skills: skillsConfig(yaml.skills, base),
|
|
618
|
+
}
|
|
619
|
+
// Prompts resolve from these folders from now on (src/prompts.ts).
|
|
620
|
+
setPromptDirs(config.prompts.dirs || [])
|
|
621
|
+
return config
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function skillsConfig(raw: unknown, base: string): SkillsConfig {
|
|
625
|
+
const s = asRecord(raw)
|
|
626
|
+
const list = (v: unknown) => (Array.isArray(v) ? v.map((x) => String(x).trim()).filter(Boolean) : [])
|
|
627
|
+
return {
|
|
628
|
+
enabled: raw !== false && s.enabled !== false,
|
|
629
|
+
projectDir: path.join(projectDir(base), "skills"),
|
|
630
|
+
userDir: path.join(forgeqeHome(), "skills"),
|
|
631
|
+
dirs: list(s.dirs).map((d) => path.resolve(base, d)),
|
|
632
|
+
disable: list(s.disable),
|
|
633
|
+
external: s.external === true,
|
|
634
|
+
browserMaxChars: positiveInt(s.browser_max_chars, 6000, "skills.browser_max_chars"),
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** "90s", "15m", "1h30m", "1d" in milliseconds. */
|
|
639
|
+
export function parseDuration(raw: unknown, where: string): number {
|
|
640
|
+
const text = String(raw ?? "").trim().toLowerCase()
|
|
641
|
+
const parts = [...text.matchAll(/(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)/g)]
|
|
642
|
+
if (!text || !parts.length || parts.map((p) => p[0]).join("").replace(/\s/g, "") !== text.replace(/\s/g, "")) {
|
|
643
|
+
throw new Error(`${where} must be a duration such as 30s, 15m, 1h or 1h30m; got "${String(raw ?? "")}".`)
|
|
644
|
+
}
|
|
645
|
+
const unit = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
|
|
646
|
+
return parts.reduce((ms, p) => ms + Number(p[1]) * unit[p[2] as keyof typeof unit], 0)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function positiveInt(raw: unknown, fallback: number, where: string): number {
|
|
650
|
+
if (raw === undefined || raw === null) return fallback
|
|
651
|
+
const n = Number(raw)
|
|
652
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`${where} must be a whole number of at least 1; got "${String(raw)}".`)
|
|
653
|
+
return n
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function monitorConfig(m: Record<string, unknown>, base: string, outDir: string): MonitorConfig {
|
|
657
|
+
const checks = Array.isArray(m.checks) ? m.checks.map(String).filter(Boolean) : m.checks ? [String(m.checks)] : []
|
|
658
|
+
const everyMs = parseDuration(m.every ?? "15m", "monitor.every")
|
|
659
|
+
if (everyMs < 10_000) throw new Error("monitor.every must be at least 10s.")
|
|
660
|
+
const remind = m.remind_every
|
|
661
|
+
return {
|
|
662
|
+
checks,
|
|
663
|
+
everyMs,
|
|
664
|
+
replay: replayMode(m.replay),
|
|
665
|
+
parallel: positiveInt(m.parallel, 1, "monitor.parallel"),
|
|
666
|
+
failAfter: positiveInt(m.fail_after, 1, "monitor.fail_after"),
|
|
667
|
+
remindMs: remind === undefined || remind === null || remind === 0 || remind === false || remind === "0" ? 0 : parseDuration(remind, "monitor.remind_every"),
|
|
668
|
+
keep: positiveInt(m.keep, 100, "monitor.keep"),
|
|
669
|
+
writeBack: m.write_back === true,
|
|
670
|
+
outDir: m.out_dir ? path.resolve(base, String(m.out_dir)) : path.join(outDir, "monitor"),
|
|
671
|
+
...(m.report_url ? { reportUrl: trimUrl(m.report_url) } : {}),
|
|
672
|
+
alerts: monitorAlerts(asRecord(m.alerts)),
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function monitorAlerts(a: Record<string, unknown>, where = "monitor.alerts"): MonitorAlerts {
|
|
677
|
+
const out: MonitorAlerts = {}
|
|
678
|
+
if (a.teams !== undefined && a.teams !== null && a.teams !== false) {
|
|
679
|
+
out.teams = { webhookEnv: String(asRecord(a.teams).webhook_env || "TEAMS_WEBHOOK_URL") }
|
|
680
|
+
}
|
|
681
|
+
if (a.email !== undefined && a.email !== null && a.email !== false) {
|
|
682
|
+
const e = asRecord(a.email)
|
|
683
|
+
const host = String(e.host || process.env.SMTP_HOST || "").trim()
|
|
684
|
+
if (!host) throw new Error(`${where}.email needs host (the SMTP server), or SMTP_HOST.`)
|
|
685
|
+
const to = (Array.isArray(e.to) ? e.to.map(String) : String(e.to || process.env.ALERT_EMAIL_TO || "").split(/[,;]/)).map((s) => s.trim()).filter(Boolean)
|
|
686
|
+
if (!to.length) throw new Error(`${where}.email needs to: the addresses alerts go to.`)
|
|
687
|
+
const port = Number(e.port || process.env.SMTP_PORT || 587)
|
|
688
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${where}.email.port is not a port: ${String(e.port)}`)
|
|
689
|
+
out.email = {
|
|
690
|
+
host,
|
|
691
|
+
port,
|
|
692
|
+
secure: e.secure === undefined ? port === 465 : e.secure === true,
|
|
693
|
+
usernameEnv: String(e.username_env || "SMTP_USERNAME"),
|
|
694
|
+
passwordEnv: String(e.password_env || "SMTP_PASSWORD"),
|
|
695
|
+
...(e.from ? { from: String(e.from) } : {}),
|
|
696
|
+
to,
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return out
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function screenshotsMode(raw: unknown): ReportConfig["screenshots"] {
|
|
703
|
+
const v = raw === false ? "off" : String(raw ?? "all").toLowerCase()
|
|
704
|
+
if (v === "all" || v === "failures" || v === "off") return v
|
|
705
|
+
throw new Error(`report.screenshots must be all, failures, or off; got "${String(raw)}".`)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Domain pattern for a login URL: scheme and host, without port or path, as the engine matches them. */
|
|
709
|
+
export function loginDomain(url: string): string {
|
|
710
|
+
const u = new URL(url)
|
|
711
|
+
return `${u.protocol}//${u.hostname}`
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Re-read `logins:` from forgeqe.yaml so a login added while ForgeQE runs is found. */
|
|
715
|
+
export function reloadLogins(cfg: AppConfig): LoginConfig[] {
|
|
716
|
+
if (!cfg.configPath || !existsSync(cfg.configPath)) return cfg.logins
|
|
717
|
+
const sessionsDir = path.join(projectDir(path.dirname(cfg.configPath)), "sessions")
|
|
718
|
+
cfg.logins = loginsConfig(readYaml(cfg.configPath).logins, sessionsDir)
|
|
719
|
+
return cfg.logins
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** `logins:` in forgeqe.yaml, a map of names to { url | domains, username_env, password_env, totp_env, save_session }. */
|
|
723
|
+
function loginsConfig(raw: unknown, sessionsDir: string): LoginConfig[] {
|
|
724
|
+
if (raw === undefined || raw === null) return []
|
|
725
|
+
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("logins: in forgeqe.yaml must be a map of login names.")
|
|
726
|
+
return Object.entries(raw as Record<string, unknown>).map(([name, value]) => {
|
|
727
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`Login name "${name}" may only use letters, digits, - and _.`)
|
|
728
|
+
const l = asRecord(value)
|
|
729
|
+
const url = l.url ? String(l.url) : undefined
|
|
730
|
+
let domains: string[]
|
|
731
|
+
try {
|
|
732
|
+
domains = Array.isArray(l.domains) ? l.domains.map(String).filter(Boolean) : url ? [loginDomain(url)] : []
|
|
733
|
+
} catch {
|
|
734
|
+
throw new Error(`logins.${name}.url is not a URL: ${url}`)
|
|
735
|
+
}
|
|
736
|
+
if (!domains.length) throw new Error(`logins.${name} needs a url (or domains) saying which site it signs in to.`)
|
|
737
|
+
const env = (k: string) => (l[k] ? String(l[k]) : undefined)
|
|
738
|
+
const login: LoginConfig = {
|
|
739
|
+
name,
|
|
740
|
+
domains,
|
|
741
|
+
...(url ? { url } : {}),
|
|
742
|
+
...(env("username_env") ? { usernameEnv: env("username_env") } : {}),
|
|
743
|
+
...(env("password_env") ? { passwordEnv: env("password_env") } : {}),
|
|
744
|
+
...(env("totp_env") ? { totpEnv: env("totp_env") } : {}),
|
|
745
|
+
...(l.save_session === false ? {} : { sessionFile: path.join(sessionsDir, `${name}.json`) }),
|
|
746
|
+
}
|
|
747
|
+
if (!login.usernameEnv && !login.passwordEnv && !login.totpEnv && !login.sessionFile) {
|
|
748
|
+
throw new Error(`logins.${name} has nothing to use: set username_env, password_env, totp_env, or keep save_session on.`)
|
|
749
|
+
}
|
|
750
|
+
return login
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function sourcesConfig(raw: Record<string, unknown>): SourcesConfig {
|
|
755
|
+
const ado = asRecord(raw.azure_devops)
|
|
756
|
+
const jira = asRecord(raw.jira)
|
|
757
|
+
const xray = asRecord(raw.xray)
|
|
758
|
+
const orgUrl = String(ado.org_url || process.env.ADO_ORG_URL || "").replace(/\/+$/, "")
|
|
759
|
+
const projectRaw = String(ado.project ?? process.env.ADO_PROJECT ?? "").trim()
|
|
760
|
+
const project = !projectRaw || projectRaw === "*" || /^all$/i.test(projectRaw) ? undefined : projectRaw
|
|
761
|
+
const jiraUrl = String(jira.url || process.env.JIRA_URL || "").replace(/\/+$/, "")
|
|
762
|
+
let cloud = false
|
|
763
|
+
if (jiraUrl) {
|
|
764
|
+
try {
|
|
765
|
+
cloud = jira.cloud === undefined ? new URL(jiraUrl).hostname.endsWith(".atlassian.net") : jira.cloud === true
|
|
766
|
+
} catch {
|
|
767
|
+
throw new Error(`sources.jira.url is not a URL: ${jiraUrl}`)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const domains = Array.isArray(ado.mcp_domains) ? ado.mcp_domains.map(String).filter(Boolean) : undefined
|
|
771
|
+
const jiraCfg = jiraUrl ? { url: jiraUrl, cloud, emailEnv: String(jira.email_env || "JIRA_EMAIL"), tokenEnv: String(jira.token_env || "JIRA_API_TOKEN") } : undefined
|
|
772
|
+
return {
|
|
773
|
+
writeBack: raw.write_back !== false,
|
|
774
|
+
saveCases: raw.save_cases !== false,
|
|
775
|
+
ado: orgUrl
|
|
776
|
+
? { orgUrl, ...(project ? { project } : {}), tokenEnv: String(ado.token_env || "ADO_PAT"), mcp: ado.mcp !== false, ...(domains?.length ? { mcpDomains: domains } : {}) }
|
|
777
|
+
: undefined,
|
|
778
|
+
jira: jiraCfg ? { ...jiraCfg, mcp: jiraMcp(jira.mcp) } : undefined,
|
|
779
|
+
xray: {
|
|
780
|
+
url: String(xray.url || process.env.XRAY_URL || "https://xray.cloud.getxray.app").replace(/\/+$/, ""),
|
|
781
|
+
clientIdEnv: String(xray.client_id_env || "XRAY_CLIENT_ID"),
|
|
782
|
+
clientSecretEnv: String(xray.client_secret_env || "XRAY_CLIENT_SECRET"),
|
|
783
|
+
},
|
|
784
|
+
...optional("confluence", confluenceConfig(raw.confluence, jiraCfg)),
|
|
785
|
+
...optional("testrail", testrailConfig(asRecord(raw.testrail))),
|
|
786
|
+
...optional("zephyr", zephyrConfig(raw.zephyr)),
|
|
787
|
+
...optional("qtest", qtestConfig(asRecord(raw.qtest))),
|
|
788
|
+
...optional("bugs", bugsConfig(raw.bugs)),
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function flakyConfig(f: Record<string, unknown>): FlakyConfig {
|
|
793
|
+
const q = String(f.quarantine ?? "suggest")
|
|
794
|
+
return {
|
|
795
|
+
retries: f.retries === undefined ? 1 : Math.max(0, Math.min(3, Number(f.retries) || 0)),
|
|
796
|
+
quarantine: q === "auto" || q === "off" || q === "false" ? (q === "false" ? "off" : (q as "auto" | "off")) : "suggest",
|
|
797
|
+
flakyAfter: Number(f.flaky_after) > 0 ? Number(f.flaky_after) : 2,
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
/** Where edited prompts are looked for, highest precedence first: prompts.dir, the project's, then the user's. */
|
|
803
|
+
function promptDirsFor(p: Record<string, unknown>, base: string, configPath: string | undefined): { dir: string; source: string }[] {
|
|
804
|
+
const dirs = [
|
|
805
|
+
...(p.dir ? [{ dir: path.resolve(base, String(p.dir)), source: "prompts.dir" }] : []),
|
|
806
|
+
...(configPath ? [{ dir: path.join(projectDir(base), "prompts"), source: "project" }] : []),
|
|
807
|
+
{ dir: path.join(forgeqeHome(), "prompts"), source: "user" },
|
|
808
|
+
]
|
|
809
|
+
return dirs.filter((d, i) => dirs.findIndex((x) => path.resolve(x.dir) === path.resolve(d.dir)) === i)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function knowledgeConfig(k: Record<string, unknown>, base: string, configPath: string | undefined): NonNullable<AppConfig["knowledge"]> {
|
|
813
|
+
const dir = k.dir ? path.resolve(base, String(k.dir)) : configPath ? path.join(projectDir(base), "knowledge") : path.join(forgeqeHome(), "knowledge")
|
|
814
|
+
const max = Number(k.browser_max_chars)
|
|
815
|
+
return { dir, browserMaxChars: Number.isFinite(max) && max >= 0 && k.browser_max_chars !== undefined ? max : 1500 }
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Visual baselines next to forgeqe.yaml (.forgeqe/visual-baselines, to commit), or under ~/.forgeqe without one. */
|
|
819
|
+
function withBaselineDir(q: QualityConfig, base: string, configPath: string | undefined): QualityConfig {
|
|
820
|
+
const dir = q.visual.baselineDir
|
|
821
|
+
? path.resolve(base, q.visual.baselineDir)
|
|
822
|
+
: configPath
|
|
823
|
+
? path.join(projectDir(base), "visual-baselines")
|
|
824
|
+
: path.join(forgeqeHome(), "visual-baselines")
|
|
825
|
+
return { ...q, visual: { ...q.visual, baselineDir: dir } }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function apiConfig(a: Record<string, unknown>): ApiConfig {
|
|
829
|
+
const headers = asRecord(a.headers)
|
|
830
|
+
return {
|
|
831
|
+
capture: a.capture !== false,
|
|
832
|
+
...(a.base_url || a.baseUrl ? { baseUrl: trimUrl(a.base_url || a.baseUrl) } : {}),
|
|
833
|
+
headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, String(v ?? "")])),
|
|
834
|
+
thirdParty: a.third_party === true,
|
|
835
|
+
timeoutSeconds: Number(a.timeout_seconds) > 0 ? Number(a.timeout_seconds) : 30,
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function optional<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
|
|
840
|
+
return value === undefined ? {} : ({ [key]: value } as Record<K, V>)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const trimUrl = (s: unknown) => String(s || "").trim().replace(/\/+$/, "")
|
|
844
|
+
|
|
845
|
+
function isCloud(url: string, explicit: unknown, what: string): boolean {
|
|
846
|
+
try {
|
|
847
|
+
return explicit === undefined ? new URL(url).hostname.endsWith(".atlassian.net") : explicit === true
|
|
848
|
+
} catch {
|
|
849
|
+
throw new Error(`${what} is not a URL: ${url}`)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** `confluence: true` uses the Jira Cloud site's /wiki with the same account; a map sets url, email_env, token_env, space. */
|
|
854
|
+
function confluenceConfig(raw: unknown, jira?: { url: string; cloud: boolean; emailEnv: string; tokenEnv: string }): SourcesConfig["confluence"] {
|
|
855
|
+
if (raw === false) return undefined
|
|
856
|
+
const c = asRecord(raw)
|
|
857
|
+
const url = trimUrl(c.url || process.env.CONFLUENCE_URL || (raw === true && jira?.cloud ? `${jira.url}/wiki` : ""))
|
|
858
|
+
if (!url) {
|
|
859
|
+
if (raw === true) throw new Error("sources.confluence: true needs a Jira Cloud site in sources.jira; otherwise give confluence.url.")
|
|
860
|
+
return undefined
|
|
861
|
+
}
|
|
862
|
+
const cloud = isCloud(url, c.cloud, "sources.confluence.url")
|
|
863
|
+
const sameSite = Boolean(jira?.cloud && cloud && new URL(url).host === new URL(jira.url).host)
|
|
864
|
+
return {
|
|
865
|
+
url,
|
|
866
|
+
cloud,
|
|
867
|
+
emailEnv: String(c.email_env || (sameSite ? jira!.emailEnv : "CONFLUENCE_EMAIL")),
|
|
868
|
+
tokenEnv: String(c.token_env || (sameSite ? jira!.tokenEnv : "CONFLUENCE_TOKEN")),
|
|
869
|
+
...(c.space ? { space: String(c.space) } : {}),
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function testrailConfig(t: Record<string, unknown>): SourcesConfig["testrail"] {
|
|
874
|
+
const url = trimUrl(t.url || process.env.TESTRAIL_URL)
|
|
875
|
+
if (!url) return undefined
|
|
876
|
+
return {
|
|
877
|
+
url,
|
|
878
|
+
emailEnv: String(t.email_env || "TESTRAIL_EMAIL"),
|
|
879
|
+
tokenEnv: String(t.token_env || "TESTRAIL_API_KEY"),
|
|
880
|
+
...(t.project_id ? { projectId: Number(t.project_id) } : {}),
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function zephyrConfig(raw: unknown): SourcesConfig["zephyr"] {
|
|
885
|
+
if (raw === false) return undefined
|
|
886
|
+
const z = asRecord(raw)
|
|
887
|
+
const tokenEnv = String(z.token_env || "ZEPHYR_API_TOKEN")
|
|
888
|
+
if (raw === undefined && !process.env[tokenEnv]) return undefined
|
|
889
|
+
return {
|
|
890
|
+
url: trimUrl(z.url || process.env.ZEPHYR_URL || "https://api.zephyrscale.smartbear.com/v2"),
|
|
891
|
+
tokenEnv,
|
|
892
|
+
...(z.project_key ? { projectKey: String(z.project_key) } : {}),
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function qtestConfig(q: Record<string, unknown>): SourcesConfig["qtest"] {
|
|
897
|
+
const url = trimUrl(q.url || process.env.QTEST_URL)
|
|
898
|
+
if (!url) return undefined
|
|
899
|
+
const projectId = Number(q.project_id || process.env.QTEST_PROJECT_ID)
|
|
900
|
+
if (!projectId) throw new Error("sources.qtest needs project_id (the number in the project's URL), or QTEST_PROJECT_ID.")
|
|
901
|
+
const s = asRecord(q.statuses)
|
|
902
|
+
return {
|
|
903
|
+
url,
|
|
904
|
+
tokenEnv: String(q.token_env || "QTEST_TOKEN"),
|
|
905
|
+
projectId,
|
|
906
|
+
statuses: { passed: String(s.passed || "PASS"), failed: String(s.failed || "FAIL"), error: String(s.error || "BLOCKED") },
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function bugsConfig(raw: unknown): BugsConfig | undefined {
|
|
911
|
+
if (raw === undefined || raw === null || raw === false) return undefined
|
|
912
|
+
const b = asRecord(raw)
|
|
913
|
+
const system = String(b.system || "").toLowerCase().replace(/[\s-]/g, "_")
|
|
914
|
+
if (system !== "jira" && system !== "azure_devops") throw new Error(`sources.bugs.system must be jira or azure_devops; got "${String(b.system ?? "")}".`)
|
|
915
|
+
if (system === "jira" && !b.project) throw new Error("sources.bugs.project is required for Jira: the project key bugs are filed in.")
|
|
916
|
+
return {
|
|
917
|
+
system,
|
|
918
|
+
...(b.project ? { project: String(b.project) } : {}),
|
|
919
|
+
type: String(b.type || "Bug"),
|
|
920
|
+
labels: Array.isArray(b.labels) ? b.labels.map(String).filter(Boolean) : ["forgeqe"],
|
|
921
|
+
...(b.area_path ? { areaPath: String(b.area_path) } : {}),
|
|
922
|
+
onError: b.on_error === true,
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function jiraMcp(raw: unknown): JiraMcp | false {
|
|
927
|
+
if (raw === false || raw === "false" || raw === "off") return false
|
|
928
|
+
if (raw === undefined || raw === null || raw === true || raw === "mcp-atlassian") return "mcp-atlassian"
|
|
929
|
+
if (raw === "rovo") return "rovo"
|
|
930
|
+
throw new Error(`sources.jira.mcp must be mcp-atlassian, rovo, or false; got "${String(raw)}".`)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function stringMap(raw: unknown, where: string): Record<string, string> | undefined {
|
|
934
|
+
if (raw === undefined || raw === null) return undefined
|
|
935
|
+
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where} must be a map of names to values.`)
|
|
936
|
+
return Object.fromEntries(Object.entries(raw as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** `mcp:` servers: `command` (a list, or a string split on spaces) with `args`, `env`, `cwd`; or `url` with `headers`. */
|
|
940
|
+
function extraMcpServers(raw: unknown, base: string): Record<string, ExtraMcpServer> | undefined {
|
|
941
|
+
if (raw === undefined || raw === null) return undefined
|
|
942
|
+
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("mcp: in forgeqe.yaml must be a map of server names.")
|
|
943
|
+
const out: Record<string, ExtraMcpServer> = {}
|
|
944
|
+
for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
945
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`MCP server name "${name}" may only use letters, digits, - and _.`)
|
|
946
|
+
if (name === "forgeqe") throw new Error('The MCP server name "forgeqe" is reserved for ForgeQE\'s own tools.')
|
|
947
|
+
const s = asRecord(value)
|
|
948
|
+
const common = {
|
|
949
|
+
...(s.timeout !== undefined ? { timeout: Number(s.timeout) } : {}),
|
|
950
|
+
...(s.enabled === false ? { enabled: false } : {}),
|
|
951
|
+
}
|
|
952
|
+
if (s.url) {
|
|
953
|
+
const headers = stringMap(s.headers, `mcp.${name}.headers`)
|
|
954
|
+
out[name] = { type: "remote", url: String(s.url), ...(headers ? { headers } : {}), ...common }
|
|
955
|
+
} else if (s.command) {
|
|
956
|
+
const command = [...(Array.isArray(s.command) ? s.command.map(String) : String(s.command).split(/\s+/).filter(Boolean)), ...(Array.isArray(s.args) ? s.args.map(String) : [])]
|
|
957
|
+
const environment = stringMap(s.env ?? s.environment, `mcp.${name}.env`)
|
|
958
|
+
out[name] = { type: "local", command, ...(environment ? { environment } : {}), ...(s.cwd ? { cwd: path.resolve(base, String(s.cwd)) } : {}), ...common }
|
|
959
|
+
} else {
|
|
960
|
+
throw new Error(`mcp.${name} needs a command (local server) or a url (remote server).`)
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return out
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export function selectProfile(cfg: AppConfig, name: string): AppConfig {
|
|
967
|
+
const llm = cfg.profiles[name]
|
|
968
|
+
if (!llm) throw new Error(`Unknown LLM profile "${name}". Run: forgeqe models`)
|
|
969
|
+
return { ...cfg, activeProfile: name, llm }
|
|
970
|
+
}
|