@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.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,295 @@
1
+ import { readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import YAML from "yaml"
5
+
6
+ import type { CaseStep, TestCase } from "./types.ts"
7
+
8
+ /** A goal typed or passed as plain text: one test case, the text is its description. */
9
+ export function textCase(text: string, n = 1): TestCase {
10
+ const body = text.trim()
11
+ const [first, ...rest] = body.split("\n")
12
+ return {
13
+ id: `text#${n}`,
14
+ title: clip(first.replace(/^#+\s*/, "").trim(), 100),
15
+ source: "text",
16
+ description: rest.join("\n").trim() || (first.length > 100 ? body : undefined),
17
+ steps: [],
18
+ }
19
+ }
20
+
21
+ /** Test cases from a file: .yaml/.yml/.json, .csv, .feature (Gherkin), or any text file split on `---` lines. */
22
+ export function fileCases(file: string): TestCase[] {
23
+ const text = readFileSync(file, "utf8")
24
+ const base = path.basename(file)
25
+ const ext = path.extname(file).toLowerCase()
26
+ const cases =
27
+ ext === ".yaml" || ext === ".yml" || ext === ".json"
28
+ ? structuredCases(ext === ".json" ? JSON.parse(text) : YAML.parse(text), base)
29
+ : ext === ".csv"
30
+ ? csvCases(text, base)
31
+ : ext === ".feature"
32
+ ? featureCases(text, base)
33
+ : textFileCases(text, base)
34
+ if (!cases.length) throw new Error(`No test cases in ${file}.`)
35
+ return cases
36
+ }
37
+
38
+ function textFileCases(text: string, base: string): TestCase[] {
39
+ return text
40
+ .split(/^\s*-{3,}\s*$/m)
41
+ .map((block) => block.trim())
42
+ .filter(Boolean)
43
+ .map((block, i) => ({ ...textCase(block, i + 1), id: `${base}#${i + 1}`, source: "file" as const }))
44
+ }
45
+
46
+ // -- YAML / JSON --------------------------------------------------------------------------------
47
+
48
+ type Raw = Record<string, unknown>
49
+
50
+ function structuredCases(doc: unknown, base: string): TestCase[] {
51
+ const items = Array.isArray(doc) ? doc : Array.isArray((doc as Raw)?.cases) ? ((doc as Raw).cases as unknown[]) : doc ? [doc] : []
52
+ return items.map((item, i) => {
53
+ if (typeof item === "string") return { ...textCase(item, i + 1), id: `${base}#${i + 1}`, source: "file" as const }
54
+ const raw = (item || {}) as Raw
55
+ const steps = Array.isArray(raw.steps) ? raw.steps.map(stepFrom).filter((s): s is CaseStep => !!s) : []
56
+ const description = str(raw.description) || str(raw.goal)
57
+ const title = str(raw.title) || str(raw.name) || (description ? clip(description.split("\n")[0], 100) : `Case ${i + 1}`)
58
+ return {
59
+ id: str(raw.id) || `${base}#${i + 1}`,
60
+ title,
61
+ source: "file" as const,
62
+ description: description && description !== title ? description : undefined,
63
+ preconditions: list(raw.preconditions),
64
+ steps,
65
+ startUrl: str(raw.url) || str(raw.start_url),
66
+ tags: list(raw.tags),
67
+ group: str(raw.group) || str(raw.suite) || str(raw.area),
68
+ requirements: list(raw.requirements) || list(raw.refs),
69
+ }
70
+ })
71
+ }
72
+
73
+ function stepFrom(raw: unknown): CaseStep | null {
74
+ if (typeof raw === "string") return raw.trim() ? { action: raw.trim() } : null
75
+ const r = (raw || {}) as Raw
76
+ const action = str(r.action) || str(r.step)
77
+ if (!action) return null
78
+ return { action, data: str(r.data), expected: str(r.expected) || str(r.result) }
79
+ }
80
+
81
+ // -- CSV ------------------------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Header row, then one row per case or per step: `id, title, url, description, preconditions, tags, action, data,
85
+ * expected`. A row with an empty id, or the same id as the row above, adds a step to that case.
86
+ */
87
+ function csvCases(text: string, base: string): TestCase[] {
88
+ const rows = parseCsv(text)
89
+ if (rows.length < 2) return []
90
+ const header = rows[0].map((h) => h.trim().toLowerCase().replace(/[\s-]+/g, "_"))
91
+ const col = (row: string[], ...names: string[]) => {
92
+ for (const n of names) {
93
+ const i = header.indexOf(n)
94
+ if (i >= 0 && row[i]?.trim()) return row[i].trim()
95
+ }
96
+ return undefined
97
+ }
98
+ const cases: TestCase[] = []
99
+ for (const row of rows.slice(1)) {
100
+ if (row.every((c) => !c.trim())) continue
101
+ const id = col(row, "id", "key", "test_id")
102
+ const last = cases[cases.length - 1]
103
+ const step: CaseStep | null = col(row, "action", "step")
104
+ ? { action: col(row, "action", "step")!, data: col(row, "data", "test_data"), expected: col(row, "expected", "expected_result", "result") }
105
+ : null
106
+ if (last && (!id || id === last.id)) {
107
+ if (step) last.steps.push(step)
108
+ continue
109
+ }
110
+ const description = col(row, "description", "goal", "steps")
111
+ const title = col(row, "title", "name", "summary") || (description ? clip(description.split("\n")[0], 100) : `Case ${cases.length + 1}`)
112
+ cases.push({
113
+ id: id || `${base}#${cases.length + 1}`,
114
+ title,
115
+ source: "file",
116
+ description: description && description !== title ? description : undefined,
117
+ preconditions: splitList(col(row, "preconditions", "precondition")),
118
+ steps: step ? [step] : [],
119
+ startUrl: col(row, "url", "start_url"),
120
+ tags: splitList(col(row, "tags", "labels")),
121
+ group: col(row, "group", "suite", "area"),
122
+ requirements: splitList(col(row, "requirements", "refs")),
123
+ })
124
+ }
125
+ return cases
126
+ }
127
+
128
+ /** RFC 4180: quoted fields, doubled quotes, newlines inside quotes. */
129
+ export function parseCsv(text: string): string[][] {
130
+ const rows: string[][] = []
131
+ let row: string[] = []
132
+ let field = ""
133
+ let quoted = false
134
+ const src = text.replace(/^\uFEFF/, "")
135
+ for (let i = 0; i < src.length; i++) {
136
+ const c = src[i]
137
+ if (quoted) {
138
+ if (c === '"' && src[i + 1] === '"') {
139
+ field += '"'
140
+ i++
141
+ } else if (c === '"') quoted = false
142
+ else field += c
143
+ continue
144
+ }
145
+ if (c === '"') quoted = true
146
+ else if (c === ",") {
147
+ row.push(field)
148
+ field = ""
149
+ } else if (c === "\n" || c === "\r") {
150
+ if (c === "\r" && src[i + 1] === "\n") i++
151
+ row.push(field)
152
+ rows.push(row)
153
+ row = []
154
+ field = ""
155
+ } else field += c
156
+ }
157
+ if (field || row.length) {
158
+ row.push(field)
159
+ rows.push(row)
160
+ }
161
+ return rows
162
+ }
163
+
164
+ // -- Gherkin ------------------------------------------------------------------------------------
165
+
166
+ const STEP_KEYWORDS = ["Given", "When", "Then", "And", "But", "*"]
167
+
168
+ /** Each Scenario is a case; a Scenario Outline gives one case per Examples row. Then-steps are expected results. */
169
+ function featureCases(text: string, base: string): TestCase[] {
170
+ type Scenario = { name: string; tags: string[]; steps: string[]; examples: string[][] }
171
+ let feature = ""
172
+ let featureTags: string[] = []
173
+ let pendingTags: string[] = []
174
+ let background: string[] = []
175
+ let current: Scenario | null = null
176
+ let inBackground = false
177
+ let inExamples = false
178
+ let docString: string | null = null
179
+ const scenarios: Scenario[] = []
180
+
181
+ const appendToLastStep = (extra: string) => {
182
+ const target = inBackground ? background : current?.steps
183
+ if (target?.length) target[target.length - 1] += `\n${extra}`
184
+ }
185
+
186
+ for (const rawLine of text.split(/\r?\n/)) {
187
+ const line = rawLine.trim()
188
+ if (docString !== null) {
189
+ if (line === '"""' || line === "```") {
190
+ appendToLastStep(docString.trimEnd())
191
+ docString = null
192
+ } else docString += `${line}\n`
193
+ continue
194
+ }
195
+ if (!line || line.startsWith("#")) continue
196
+ if (line === '"""' || line === "```") {
197
+ docString = ""
198
+ continue
199
+ }
200
+ if (line.startsWith("@")) {
201
+ pendingTags.push(...line.split(/\s+/).map((t) => t.slice(1)).filter(Boolean))
202
+ continue
203
+ }
204
+ const keyword = line.match(/^(Feature|Background|Scenario Outline|Scenario Template|Scenario|Example|Examples|Scenarios|Rule):\s*(.*)$/)
205
+ if (keyword) {
206
+ const [, kind, name] = keyword
207
+ inBackground = false
208
+ inExamples = false
209
+ if (kind === "Feature") {
210
+ feature = name
211
+ featureTags = pendingTags
212
+ } else if (kind === "Background") inBackground = true
213
+ else if (kind === "Examples" || kind === "Scenarios") inExamples = true
214
+ else if (kind !== "Rule") {
215
+ current = { name, tags: [...featureTags, ...pendingTags], steps: [], examples: [] }
216
+ scenarios.push(current)
217
+ }
218
+ pendingTags = []
219
+ continue
220
+ }
221
+ if (line.startsWith("|")) {
222
+ const cells = line.slice(1, line.endsWith("|") ? -1 : undefined).split("|").map((c) => c.trim())
223
+ if (inExamples && current) current.examples.push(cells)
224
+ else appendToLastStep(line)
225
+ continue
226
+ }
227
+ const kw = STEP_KEYWORDS.find((k) => line === k || line.startsWith(`${k} `))
228
+ if (kw) {
229
+ if (inBackground) background.push(line)
230
+ else current?.steps.push(line)
231
+ }
232
+ }
233
+
234
+ const cases: TestCase[] = []
235
+ for (const sc of scenarios) {
236
+ const header = sc.examples[0]
237
+ const rows = header ? sc.examples.slice(1) : [[]]
238
+ rows.forEach((row, r) => {
239
+ const fill = (s: string) => (header ? header.reduce((acc, h, i) => acc.split(`<${h}>`).join(row[i] ?? ""), s) : s)
240
+ const n = cases.length + 1
241
+ cases.push({
242
+ id: `${base}#${n}`,
243
+ title: fill(sc.name) + (header && !sc.name.includes("<") ? ` (example ${r + 1})` : ""),
244
+ source: "file",
245
+ description: feature ? `Feature: ${feature}` : undefined,
246
+ steps: gherkinSteps([...background, ...sc.steps].map(fill)),
247
+ tags: sc.tags.length ? sc.tags : undefined,
248
+ })
249
+ })
250
+ }
251
+ return cases
252
+ }
253
+
254
+ function gherkinSteps(lines: string[]): CaseStep[] {
255
+ const steps: CaseStep[] = []
256
+ let inThen = false
257
+ for (const line of lines) {
258
+ const kw = STEP_KEYWORDS.find((k) => line === k || line.startsWith(`${k} `))!
259
+ if (kw === "Then") inThen = true
260
+ else if (kw === "Given" || kw === "When") inThen = false
261
+ if (inThen) {
262
+ const last = steps[steps.length - 1]
263
+ if (last) last.expected = last.expected ? `${last.expected}\n${line}` : line
264
+ else steps.push({ action: "Check the page", expected: line })
265
+ } else steps.push({ action: line })
266
+ }
267
+ return steps
268
+ }
269
+
270
+ // -- helpers ------------------------------------------------------------------------------------
271
+
272
+ function str(v: unknown): string | undefined {
273
+ if (v === undefined || v === null) return undefined
274
+ const s = String(v).trim()
275
+ return s || undefined
276
+ }
277
+
278
+ function list(v: unknown): string[] | undefined {
279
+ if (Array.isArray(v)) {
280
+ const out = v.map((x) => String(x).trim()).filter(Boolean)
281
+ return out.length ? out : undefined
282
+ }
283
+ return splitList(str(v))
284
+ }
285
+
286
+ function splitList(v?: string): string[] | undefined {
287
+ if (!v) return undefined
288
+ const out = v.split(/[;\n]/).map((s) => s.trim()).filter(Boolean)
289
+ return out.length ? out : undefined
290
+ }
291
+
292
+ export function clip(s: string, n: number): string {
293
+ const one = s.replace(/\s+/g, " ").trim()
294
+ return one.length <= n ? one : `${one.slice(0, n - 1)}…`
295
+ }
@@ -0,0 +1,53 @@
1
+ import type { TestCase } from "./types.ts"
2
+
3
+ const SOURCE_NAME = { text: "text", file: "file", ado: "Azure DevOps", jira: "Jira", xray: "Xray", testrail: "TestRail", zephyr: "Zephyr Scale", qtest: "qTest" } as const
4
+
5
+ /**
6
+ * The browser goal for a test case: every step with its data and expected result, and an instruction to check each
7
+ * expected result on the page. The agent's own verdict on those checks decides pass or fail.
8
+ */
9
+ export function goalFor(tc: TestCase): string {
10
+ const lines = [`Test case ${tc.id}: ${tc.title}`]
11
+ if (tc.description?.trim()) lines.push("", tc.description.trim())
12
+ if (tc.preconditions?.length) {
13
+ lines.push("", "Preconditions:")
14
+ for (const p of tc.preconditions) lines.push(`- ${p}`)
15
+ }
16
+ if (tc.steps.length) {
17
+ lines.push("", "Steps:")
18
+ tc.steps.forEach((s, i) => {
19
+ lines.push(`${i + 1}. ${s.action}`)
20
+ if (s.data?.trim()) lines.push(` Data: ${s.data.trim()}`)
21
+ if (s.expected?.trim()) lines.push(` Expected: ${s.expected.trim()}`)
22
+ })
23
+ }
24
+ const checks = tc.steps.some((s) => s.expected?.trim())
25
+ lines.push(
26
+ "",
27
+ checks
28
+ ? "Carry out the steps in order and check every expected result on the page. Finish with success only if every expected result holds; otherwise finish with failure and say which step's expected result did not hold and what the page showed instead."
29
+ : "Carry out the test case and check that the outcome it describes holds on the page. Finish with success only if it does; otherwise finish with failure and say what the page showed instead.",
30
+ )
31
+ return lines.join("\n")
32
+ }
33
+
34
+ /** Markdown for the coding agent's handoff: where the case came from and what it checks. */
35
+ export function caseMarkdown(tc: TestCase): string {
36
+ const lines = ["## Test case", "", `${tc.id}: ${tc.title}`, `Source: ${SOURCE_NAME[tc.source]}${tc.url ? ` (${tc.url})` : ""}`]
37
+ if (tc.tags?.length) lines.push(`Tags: ${tc.tags.join(", ")}`)
38
+ if (tc.description?.trim()) lines.push("", tc.description.trim())
39
+ if (tc.preconditions?.length) lines.push("", "Preconditions:", ...tc.preconditions.map((p) => `- ${p}`))
40
+ if (tc.steps.length) {
41
+ lines.push("", "| # | Action | Data | Expected |", "|---|--------|------|----------|")
42
+ tc.steps.forEach((s, i) => lines.push(`| ${i + 1} | ${cell(s.action)} | ${cell(s.data)} | ${cell(s.expected)} |`))
43
+ }
44
+ lines.push(
45
+ "",
46
+ `Name the test after the case, e.g. \`${tc.id} ${tc.title}\`, and assert each expected result. Keep the id in the test title so results can be traced back to ${SOURCE_NAME[tc.source]}.`,
47
+ )
48
+ return lines.join("\n")
49
+ }
50
+
51
+ function cell(s?: string): string {
52
+ return (s || "").replace(/\|/g, "\\|").replace(/\s*\n\s*/g, "<br>")
53
+ }
@@ -0,0 +1,111 @@
1
+ /** JSON over HTTP for the test-management APIs. Errors name the method, URL path, and status, never credentials. */
2
+ export async function http<T = unknown>(
3
+ url: string,
4
+ opts: { method?: string; auth: string; body?: unknown; contentType?: string; headers?: Record<string, string> },
5
+ ): Promise<{ data: T; headers: Headers }> {
6
+ const res = await fetch(url, {
7
+ method: opts.method || (opts.body === undefined ? "GET" : "POST"),
8
+ headers: {
9
+ Accept: "application/json",
10
+ ...(opts.auth ? { Authorization: opts.auth } : {}),
11
+ ...(opts.body === undefined ? {} : { "Content-Type": opts.contentType || "application/json" }),
12
+ ...opts.headers,
13
+ },
14
+ body: opts.body === undefined ? undefined : typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body),
15
+ })
16
+ const text = await res.text()
17
+ if (!res.ok) {
18
+ const where = new URL(url)
19
+ const hint = res.status === 401 || res.status === 403 ? " Check the token and its permissions." : ""
20
+ throw new Error(`${opts.method || (opts.body === undefined ? "GET" : "POST")} ${where.host}${where.pathname} failed: ${res.status} ${res.statusText}.${hint} ${brief(text)}`.trim())
21
+ }
22
+ let data: unknown = text
23
+ try {
24
+ data = text ? JSON.parse(text) : null
25
+ } catch {}
26
+ return { data: data as T, headers: res.headers }
27
+ }
28
+
29
+ function brief(text: string): string {
30
+ const one = text.replace(/\s+/g, " ").trim()
31
+ return one.length > 300 ? `${one.slice(0, 300)}…` : one
32
+ }
33
+
34
+ export function basic(user: string, secret: string): string {
35
+ return `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`
36
+ }
37
+
38
+ export function envSecret(name: string, what: string): string {
39
+ const value = process.env[name]
40
+ if (!value) throw new Error(`${what} needs ${name} in the environment (or a .env next to forgeqe.yaml), or a sign-in: run forgeqe auth login, or type /auth in the shell.`)
41
+ return value
42
+ }
43
+
44
+ /** Rich text from Azure DevOps (HTML, often entity-encoded) as plain text with line breaks. */
45
+ export function htmlToText(html: string | undefined): string {
46
+ if (!html) return ""
47
+ const decoded = decodeEntities(html)
48
+ return decodeEntities(
49
+ decoded
50
+ .replace(/<\s*br\s*\/?>/gi, "\n")
51
+ .replace(/<\s*\/\s*(p|div|li|tr|h[1-6])\s*>/gi, "\n")
52
+ .replace(/<\s*li[^>]*>/gi, "- ")
53
+ .replace(/<[^>]+>/g, ""),
54
+ )
55
+ .split("\n")
56
+ .map((l) => l.replace(/[ \t\u00a0]+/g, " ").trim())
57
+ .filter(Boolean)
58
+ .join("\n")
59
+ }
60
+
61
+ function decodeEntities(s: string): string {
62
+ return s
63
+ .replace(/&lt;/g, "<")
64
+ .replace(/&gt;/g, ">")
65
+ .replace(/&quot;/g, '"')
66
+ .replace(/&#39;|&apos;/g, "'")
67
+ .replace(/&nbsp;/g, " ")
68
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
69
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
70
+ .replace(/&amp;/g, "&")
71
+ }
72
+
73
+ type AdfNode = { type?: string; text?: string; content?: AdfNode[]; attrs?: Record<string, unknown> }
74
+
75
+ /** Jira Cloud's Atlassian Document Format as plain text. Strings (Jira Server wiki text) pass through. */
76
+ export function adfToText(doc: unknown): string {
77
+ if (typeof doc === "string") return doc.trim()
78
+ if (!doc || typeof doc !== "object") return ""
79
+ const out: string[] = []
80
+ const walk = (node: AdfNode, prefix = ""): string => {
81
+ if (node.type === "text") return node.text || ""
82
+ if (node.type === "hardBreak") return "\n"
83
+ const inner = (node.content || []).map((c) => walk(c, prefix)).join("")
84
+ switch (node.type) {
85
+ case "paragraph":
86
+ case "heading":
87
+ return `${inner}\n`
88
+ case "listItem":
89
+ return `- ${inner.trim()}\n`
90
+ case "codeBlock":
91
+ return `${inner}\n`
92
+ default:
93
+ return inner
94
+ }
95
+ }
96
+ out.push(walk(doc as AdfNode))
97
+ return out.join("").split("\n").map((l) => l.trimEnd()).filter((l, i, a) => l || (i > 0 && a[i - 1])).join("\n").trim()
98
+ }
99
+
100
+ /** Plain text (paragraphs separated by blank lines) as an ADF document for Jira Cloud comments. */
101
+ export function textToAdf(text: string): Record<string, unknown> {
102
+ const paragraphs = text.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean)
103
+ return {
104
+ type: "doc",
105
+ version: 1,
106
+ content: paragraphs.map((p) => ({
107
+ type: "paragraph",
108
+ content: p.split("\n").flatMap((line, i) => [...(i ? [{ type: "hardBreak" }] : []), { type: "text", text: line }]),
109
+ })),
110
+ }
111
+ }
@@ -0,0 +1,131 @@
1
+ import { existsSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { SourcesConfig } from "../config.ts"
5
+ import { adoCases, adoWriteBack } from "./ado.ts"
6
+ import { fileCases, textCase } from "./files.ts"
7
+ import { jiraCases, jiraWriteBack } from "./jira.ts"
8
+ import { qtestCases, qtestWriteBack } from "./qtest.ts"
9
+ import { testrailCases, testrailWriteBack } from "./testrail.ts"
10
+ import type { CaseResult, TestCase, WriteBack } from "./types.ts"
11
+ import { xrayCases, xrayWriteBack } from "./xray.ts"
12
+ import { zephyrCases, zephyrWriteBack } from "./zephyr.ts"
13
+
14
+ export { caseMarkdown, goalFor } from "./goal.ts"
15
+ export type { CaseResult, CaseStatus, CaseStep, TestCase, WriteBack } from "./types.ts"
16
+
17
+ /**
18
+ * Where a reference points: `ado:…`, `jira:…`, `xray:…`, `testrail:…`, `zephyr:…`, `qtest:…`, an existing file, or
19
+ * otherwise plain text that is itself the test case.
20
+ */
21
+ export function refKind(ref: string): "ado" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "file" | "text" {
22
+ const scheme = ref.match(/^(ado|azure|jira|xray|testrail|zephyr|qtest):/i)?.[1]?.toLowerCase()
23
+ if (scheme === "ado" || scheme === "azure") return "ado"
24
+ if (scheme) return scheme as "jira" | "xray" | "testrail" | "zephyr" | "qtest"
25
+ const file = path.resolve(ref)
26
+ if (ref.length < 1024 && existsSync(file) && statSync(file).isFile()) return "file"
27
+ return "text"
28
+ }
29
+
30
+ export async function loadCases(refs: string[], cfg: SourcesConfig): Promise<TestCase[]> {
31
+ const cases: TestCase[] = []
32
+ let texts = 0
33
+ for (const ref of refs.map((r) => r.trim()).filter(Boolean)) {
34
+ const kind = refKind(ref)
35
+ const rest = ref.slice(ref.indexOf(":") + 1).trim()
36
+ if (kind === "ado") cases.push(...(await adoCases(rest, cfg)))
37
+ else if (kind === "jira") cases.push(...(await jiraCases(rest, cfg)))
38
+ else if (kind === "xray") cases.push(...(await xrayCases(rest, cfg)))
39
+ else if (kind === "testrail") cases.push(...(await testrailCases(rest, cfg)))
40
+ else if (kind === "zephyr") cases.push(...(await zephyrCases(rest, cfg)))
41
+ else if (kind === "qtest") cases.push(...(await qtestCases(rest, cfg)))
42
+ else if (kind === "file") cases.push(...fileCases(path.resolve(ref)))
43
+ else cases.push(textCase(ref, ++texts))
44
+ }
45
+ return uniqueIds(cases)
46
+ }
47
+
48
+ /** Where the agent wants test cases from, as it decided from the user's request. */
49
+ export type CaseSelector = {
50
+ source: "azure_devops" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "file" | "text"
51
+ ids?: string[]
52
+ plan?: string
53
+ suite?: string
54
+ run?: string
55
+ cycle?: string
56
+ folder?: string
57
+ jql?: string
58
+ paths?: string[]
59
+ text?: string
60
+ }
61
+
62
+ /** Test cases for structured selectors; relative paths resolve against `cwd`. */
63
+ export async function casesFrom(selectors: CaseSelector[], cfg: SourcesConfig, cwd = process.cwd()): Promise<TestCase[]> {
64
+ const cases: TestCase[] = []
65
+ let texts = 0
66
+ const ids = (s: CaseSelector, what: string) => {
67
+ const list = (s.ids || []).map((i) => String(i).trim()).filter(Boolean)
68
+ if (!list.length) throw new Error(`${what}: give ids, a plan, or a query.`)
69
+ return list.join(",")
70
+ }
71
+ for (const s of selectors) {
72
+ if (s.source === "azure_devops") {
73
+ cases.push(...(await adoCases(s.plan ? `plan=${s.plan}${s.suite ? `/suite=${s.suite}` : ""}` : ids(s, "Azure DevOps"), cfg)))
74
+ } else if (s.source === "jira") {
75
+ cases.push(...(await jiraCases(s.jql ? `jql=${s.jql}` : ids(s, "Jira"), cfg)))
76
+ } else if (s.source === "xray") {
77
+ cases.push(...(await xrayCases(s.plan ? `plan=${s.plan}` : s.jql ? `jql=${s.jql}` : ids(s, "Xray"), cfg)))
78
+ } else if (s.source === "testrail") {
79
+ const sel = s.run ? `run=${s.run}` : s.plan ? `plan=${s.plan}` : s.suite ? `suite=${s.suite}` : ids(s, "TestRail")
80
+ cases.push(...(await testrailCases(sel, cfg)))
81
+ } else if (s.source === "zephyr") {
82
+ cases.push(...(await zephyrCases(s.cycle ? `cycle=${s.cycle}` : s.folder ? `folder=${s.folder}` : ids(s, "Zephyr Scale"), cfg)))
83
+ } else if (s.source === "qtest") {
84
+ cases.push(...(await qtestCases(s.cycle ? `cycle=${s.cycle}` : s.suite ? `suite=${s.suite}` : ids(s, "qTest"), cfg)))
85
+ } else if (s.source === "file") {
86
+ if (!s.paths?.length) throw new Error("File source: give the file paths.")
87
+ for (const p of s.paths) {
88
+ const file = path.resolve(cwd, p)
89
+ if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`Test case file not found: ${file}`)
90
+ cases.push(...fileCases(file))
91
+ }
92
+ } else {
93
+ if (!s.text?.trim()) throw new Error("Text source: give the test case text.")
94
+ cases.push(textCase(s.text, ++texts))
95
+ }
96
+ }
97
+ return uniqueIds(cases)
98
+ }
99
+
100
+ function uniqueIds(cases: TestCase[]): TestCase[] {
101
+ const seen = new Map<string, number>()
102
+ return cases.map((c) => {
103
+ const n = (seen.get(c.id) || 0) + 1
104
+ seen.set(c.id, n)
105
+ return n === 1 ? c : { ...c, id: `${c.id}~${n}` }
106
+ })
107
+ }
108
+
109
+ /** Post each result to the system its case came from. Text and file cases have nowhere to go. */
110
+ export async function writeBack(results: CaseResult[], cfg: SourcesConfig, runName: string): Promise<WriteBack[]> {
111
+ const of = (s: TestCase["source"]) => results.filter((r) => r.case.source === s && r.status !== "stopped")
112
+ const out: WriteBack[] = []
113
+ const tasks: [TestCase["source"], (rs: CaseResult[]) => Promise<WriteBack[]>][] = [
114
+ ["ado", (rs) => adoWriteBack(rs, cfg, runName)],
115
+ ["jira", (rs) => jiraWriteBack(rs, cfg)],
116
+ ["xray", (rs) => xrayWriteBack(rs, cfg, runName)],
117
+ ["testrail", (rs) => testrailWriteBack(rs, cfg, runName)],
118
+ ["zephyr", (rs) => zephyrWriteBack(rs, cfg, runName)],
119
+ ["qtest", (rs) => qtestWriteBack(rs, cfg, runName)],
120
+ ]
121
+ for (const [source, post] of tasks) {
122
+ const rs = of(source)
123
+ if (!rs.length) continue
124
+ try {
125
+ out.push(...(await post(rs)))
126
+ } catch (err) {
127
+ out.push({ source, text: `${source} results not recorded`, error: (err as Error).message })
128
+ }
129
+ }
130
+ return out
131
+ }