@capagents/forgeqe 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +781 -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 +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,540 @@
1
+ import { XMLParser } from "fast-xml-parser"
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import type { CodeEvent, Usage } from "../types.ts"
6
+ import type { Card } from "../card.ts"
7
+ import { privateEnvNames } from "../childEnv.ts"
8
+ import type { AppConfig, FlakyConfig } from "../config.ts"
9
+ import { runDirectory } from "../run.ts"
10
+ import { openSession, type AgentSpec, type OpencodeSession } from "./runtime.ts"
11
+ import { runTestCommand, type TestRun } from "./testrun.ts"
12
+ import { CAUSE_TEXT, flakyReport, quarantined, recordResults, RELEASE_AFTER, setQuarantine, type Flakiness } from "./history.ts"
13
+ import { loadSkills } from "../skills/registry.ts"
14
+ import { prompt } from "../prompts.ts"
15
+ import { domainNotes } from "../domain.ts"
16
+
17
+ const LIST_ROUNDS = 3
18
+ const LIST_TIMEOUT_SECONDS = 180
19
+ const OUTPUT_CHARS = 6_000
20
+
21
+ export const findAgentPrompt = () => prompt("find-tests")
22
+
23
+ export const FIND_AGENT: AgentSpec = {
24
+ name: "forgeqe-find",
25
+ description: "Finds the repository's automated tests that match a request, without changing or running anything",
26
+ get prompt() {
27
+ return findAgentPrompt()
28
+ },
29
+ permission: {
30
+ edit: "deny",
31
+ bash: {
32
+ "*": "deny",
33
+ "git log*": "allow",
34
+ "git show*": "allow",
35
+ "git diff*": "allow",
36
+ "git blame*": "allow",
37
+ "git ls-files*": "allow",
38
+ "git rev-parse*": "allow",
39
+ "git status*": "allow",
40
+ "ls*": "allow",
41
+ },
42
+ webfetch: "deny",
43
+ task: "deny",
44
+ },
45
+ skills: "scribe",
46
+ }
47
+
48
+ const TEST_ITEM = {
49
+ type: "object",
50
+ properties: {
51
+ name: { type: "string", description: "The test's full name as the framework reports it, e.g. 'login › signs in with a valid user' or 'tests/test_login.py::test_valid_user'" },
52
+ file: { type: "string", description: "Path relative to the repository root" },
53
+ reason: { type: "string", description: "Why it matches (or, under related, why it was left out), from what the test does" },
54
+ added: { type: "string", description: "For a time range: the date and commit that added it" },
55
+ },
56
+ required: ["name", "reason"],
57
+ }
58
+
59
+ export const FIND_SCHEMA = {
60
+ type: "object",
61
+ properties: {
62
+ framework: { type: "string", description: "The test framework(s), e.g. 'Playwright (TypeScript)'" },
63
+ tests: { type: "array", items: TEST_ITEM, description: "The tests that match" },
64
+ related: { type: "array", items: TEST_ITEM, description: "Near misses left out, with the reason" },
65
+ list_command: { type: "string", description: "Lists exactly the selected tests without running them; empty when the framework cannot list" },
66
+ run_command: { type: "string", description: "Runs exactly the selected tests and writes JUnit XML or TRX results" },
67
+ results_file: { type: "string", description: "Where run_command writes the results (file or folder), when not the path ForgeQE gave" },
68
+ list_matches: { type: "boolean", description: "After ForgeQE listed the tests: true when the listing is exactly the selection" },
69
+ notes: { type: "string", description: "What was searched, and anything the user should know" },
70
+ },
71
+ required: ["framework", "tests", "run_command", "notes"],
72
+ } as const
73
+
74
+ export type FoundTest = { name: string; file?: string; reason: string; added?: string }
75
+
76
+ type FindReport = {
77
+ framework?: string
78
+ tests?: FoundTest[]
79
+ related?: FoundTest[]
80
+ list_command?: string
81
+ run_command?: string
82
+ results_file?: string
83
+ list_matches?: boolean
84
+ notes?: string
85
+ }
86
+
87
+ export type TestRange = { since?: string; until?: string; label?: string }
88
+
89
+ export type TestSelection = {
90
+ id: string
91
+ dir: string
92
+ repo: string
93
+ request: string
94
+ range?: TestRange
95
+ framework: string
96
+ tests: FoundTest[]
97
+ related: FoundTest[]
98
+ listCommand: string
99
+ /** ForgeQE ran listCommand and Scribe confirmed it lists exactly the selected tests. */
100
+ listed: boolean
101
+ runCommand: string
102
+ /** Where ForgeQE looks for JUnit XML or TRX after running. */
103
+ results: string[]
104
+ notes: string
105
+ model: string
106
+ usage: Usage
107
+ createdAt: string
108
+ }
109
+
110
+ const clean = (tests: unknown): FoundTest[] =>
111
+ (Array.isArray(tests) ? tests : [])
112
+ .filter((t) => t && typeof t.name === "string" && t.name.trim())
113
+ .map((t) => ({ name: String(t.name).trim(), file: t.file ? String(t.file) : undefined, reason: String(t.reason || ""), added: t.added ? String(t.added) : undefined }))
114
+
115
+ const tailOf = (s: string) => (s.length > OUTPUT_CHARS ? `…${s.slice(-OUTPUT_CHARS)}` : s)
116
+
117
+ function rangeText(r?: TestRange): string {
118
+ if (!r || (!r.since && !r.until)) return ""
119
+ const span = r.since && r.until ? `between ${r.since} and ${r.until}` : r.since ? `since ${r.since}` : `until ${r.until}`
120
+ return `Only tests added ${span}${r.label ? ` (${r.label})` : ""}, both days included.`
121
+ }
122
+
123
+ export function findPrompt(opts: { request: string; range?: TestRange; junit: string; trx: string }): string {
124
+ const today = new Date().toISOString().slice(0, 10)
125
+ const shell = process.platform === "win32" ? "cmd.exe" : "sh"
126
+ return [
127
+ `Find the tests for this request: ${opts.request}`,
128
+ rangeText(opts.range),
129
+ `Today is ${today}. Commands run in ${shell}.`,
130
+ `Write JUnit XML results to ${opts.junit}, or TRX (dotnet test) to ${opts.trx}.`,
131
+ ]
132
+ .filter(Boolean)
133
+ .join("\n")
134
+ }
135
+
136
+ function listPrompt(report: FindReport, run: TestRun): string {
137
+ const n = report.tests?.length || 0
138
+ return [
139
+ `ForgeQE ran the list command from the repository root: ${report.list_command}`,
140
+ `${run.exitCode === null ? "It timed out or was stopped" : `Exit code ${run.exitCode}`} after ${Math.round(run.ms / 1000)}s. End of the output:`,
141
+ "```",
142
+ tailOf(run.output) || "(no output)",
143
+ "```",
144
+ `Compare the listing with your ${n} selected test${n === 1 ? "" : "s"}. If it lists exactly them, give the same result with list_matches true.`,
145
+ "If it lists tests you did not select, misses some, or failed, fix the commands (or the selection, when the listing shows you misjudged a test) and give the result with list_matches false; ForgeQE lists again.",
146
+ ].join("\n")
147
+ }
148
+
149
+ const sameTests = (a: FoundTest[], b: FoundTest[]) => a.length === b.length && a.every((t, i) => t.name === b[i].name)
150
+
151
+ /**
152
+ * A read-only Scribe session picks the tests that match the request by reading them (and git history for a time range)
153
+ * and gives a list and a run command. ForgeQE runs the list command and Scribe checks the listing is exactly its selection.
154
+ * The selection is saved as selection.json in a new folder under <export.outDir>/tests.
155
+ */
156
+ export async function findTests(opts: {
157
+ config: AppConfig
158
+ repo: string
159
+ request: string
160
+ range?: TestRange
161
+ signal?: AbortSignal
162
+ onEvent?: (e: CodeEvent) => void
163
+ open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession>
164
+ runTest?: typeof runTestCommand
165
+ }): Promise<TestSelection> {
166
+ const repo = path.resolve(opts.repo)
167
+ if (!existsSync(repo) || !statSync(repo).isDirectory()) throw new Error(`Repo not found: ${repo}`)
168
+ const emit = opts.onEvent || (() => {})
169
+ const runTest = opts.runTest || runTestCommand
170
+ const dir = runDirectory(path.join(opts.config.export.outDir, "tests"))
171
+ const junit = path.join(dir, "results.xml")
172
+ const trx = path.join(dir, "results.trx")
173
+
174
+ const open = (opts.open || openSession) as (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<FindReport>>
175
+ const session = await open({
176
+ repo,
177
+ profile: opts.config.llm,
178
+ domain: domainNotes(opts.config),
179
+ model: opts.config.code.model,
180
+ bin: opts.config.code.opencode,
181
+ readDirs: [repo],
182
+ hide: privateEnvNames(opts.config),
183
+ signal: opts.signal,
184
+ onEvent: emit,
185
+ agent: FIND_AGENT,
186
+ skills: loadSkills(opts.config.skills, repo).skills,
187
+ schema: FIND_SCHEMA as unknown as Record<string, unknown>,
188
+ title: "ForgeQE find tests",
189
+ })
190
+ try {
191
+ emit({ type: "code", kind: "info", text: `Scribe (${session.model}) is looking for tests in ${repo}: ${opts.request}` })
192
+ let reply = await session.prompt(findPrompt({ request: opts.request, range: opts.range, junit, trx }))
193
+ if (!reply.report && !reply.error && !opts.signal?.aborted) reply = await session.prompt("Give the structured result: framework, tests, related, list_command, run_command, and notes.")
194
+ if (!reply.report) throw new Error(`Scribe gave no list of tests${reply.error ? `: ${reply.error}` : "."}`)
195
+ let report = reply.report
196
+ let listed = false
197
+ for (let round = 1; round <= LIST_ROUNDS && clean(report.tests).length && report.list_command?.trim() && !opts.signal?.aborted; round++) {
198
+ emit({ type: "code", kind: "info", text: `ForgeQE lists the selected tests: ${report.list_command}` })
199
+ const run = await runTest({ command: report.list_command!, cwd: repo, timeoutSeconds: LIST_TIMEOUT_SECONDS, signal: opts.signal, hide: privateEnvNames(opts.config) })
200
+ if (opts.signal?.aborted) break
201
+ const next = (await session.prompt(listPrompt(report, run))).report
202
+ if (!next) break
203
+ const unchanged = next.list_command === report.list_command && next.run_command === report.run_command && sameTests(clean(next.tests), clean(report.tests))
204
+ report = { ...report, ...next }
205
+ if (next.list_matches && unchanged) {
206
+ listed = true
207
+ break
208
+ }
209
+ }
210
+ if (opts.signal?.aborted) throw new Error("Stopped.")
211
+ const results = [report.results_file ? path.resolve(repo, report.results_file) : "", junit, trx].filter((p, i, all) => p && all.indexOf(p) === i)
212
+ const selection: TestSelection = {
213
+ id: path.basename(dir),
214
+ dir,
215
+ repo,
216
+ request: opts.request,
217
+ range: opts.range,
218
+ framework: String(report.framework || ""),
219
+ tests: clean(report.tests),
220
+ related: clean(report.related),
221
+ listCommand: String(report.list_command || ""),
222
+ listed,
223
+ runCommand: String(report.run_command || ""),
224
+ results,
225
+ notes: String(report.notes || ""),
226
+ model: session.model,
227
+ usage: { ...session.usage },
228
+ createdAt: new Date().toISOString(),
229
+ }
230
+ writeFileSync(path.join(dir, "selection.json"), JSON.stringify(selection, null, 2))
231
+ return selection
232
+ } finally {
233
+ await session.close()
234
+ }
235
+ }
236
+
237
+ export function loadSelection(config: AppConfig, id: string): TestSelection {
238
+ const file = path.join(config.export.outDir, "tests", path.basename(id), "selection.json")
239
+ if (!existsSync(file)) throw new Error(`No test selection "${id}": call find_tests first.`)
240
+ return JSON.parse(readFileSync(file, "utf8")) as TestSelection
241
+ }
242
+
243
+ export type TestOutcome = {
244
+ name: string
245
+ status: "passed" | "failed" | "skipped"
246
+ seconds?: number
247
+ message?: string
248
+ file?: string
249
+ /** Failed, then passed when run again in the same run: flaky, not failed. */
250
+ flaky?: boolean
251
+ /** In quarantine: its failure does not fail the run. */
252
+ quarantined?: boolean
253
+ }
254
+
255
+ const parser = new XMLParser({
256
+ ignoreAttributes: false,
257
+ attributeNamePrefix: "",
258
+ parseTagValue: false,
259
+ isArray: (name) => ["testsuite", "testcase", "UnitTestResult", "UnitTest", "failure", "error"].includes(name),
260
+ })
261
+
262
+ function text(v: unknown): string {
263
+ if (v == null) return ""
264
+ if (typeof v === "string") return v
265
+ if (Array.isArray(v)) return text(v[0])
266
+ if (typeof v === "object") {
267
+ const o = v as Record<string, unknown>
268
+ return String(o.message || o["#text"] || "")
269
+ }
270
+ return String(v)
271
+ }
272
+
273
+ function oneLine(s: string): string {
274
+ const t = s.replace(/\s+/g, " ").trim()
275
+ return t.length > 300 ? `${t.slice(0, 300)}…` : t
276
+ }
277
+
278
+ function junitCases(node: unknown, out: Record<string, any>[] = []): Record<string, any>[] {
279
+ if (!node || typeof node !== "object") return out
280
+ if (Array.isArray(node)) {
281
+ for (const n of node) junitCases(n, out)
282
+ return out
283
+ }
284
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
285
+ if (key === "testcase") out.push(...(value as Record<string, any>[]))
286
+ else if (typeof value === "object") junitCases(value, out)
287
+ }
288
+ return out
289
+ }
290
+
291
+ /** hh:mm:ss.fffffff (TRX) in seconds. */
292
+ function trxSeconds(d: unknown): number | undefined {
293
+ const m = typeof d === "string" ? /^(\d+):(\d+):(\d+(?:\.\d+)?)$/.exec(d) : null
294
+ return m ? Math.round((Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3])) * 100) / 100 : undefined
295
+ }
296
+
297
+ /** Per-test outcomes from a JUnit XML or TRX document. */
298
+ export function parseResults(xml: string): TestOutcome[] {
299
+ const doc = parser.parse(xml) as Record<string, any>
300
+ if (doc.TestRun) {
301
+ const results: Record<string, any>[] = doc.TestRun.Results?.UnitTestResult || []
302
+ const files = new Map<string, string>()
303
+ for (const t of doc.TestRun.TestDefinitions?.UnitTest || []) if (t.id && t.storage) files.set(t.id, String(t.storage))
304
+ return results.map((r) => {
305
+ const outcome = String(r.outcome || "")
306
+ const status = outcome === "Passed" ? "passed" : outcome === "Failed" || outcome === "Error" || outcome === "Timeout" || outcome === "Aborted" ? "failed" : "skipped"
307
+ const message = oneLine(text(r.Output?.ErrorInfo?.Message))
308
+ return { name: String(r.testName || ""), status, seconds: trxSeconds(r.duration), ...(message ? { message } : {}), ...(files.get(r.testId) ? { file: files.get(r.testId) } : {}) }
309
+ })
310
+ }
311
+ return junitCases(doc).map((c) => {
312
+ const failure = c.failure?.[0] ?? c.error?.[0]
313
+ const status = failure !== undefined ? "failed" : c.skipped !== undefined ? "skipped" : "passed"
314
+ const name = c.classname && !String(c.name || "").includes(String(c.classname)) ? `${c.classname} › ${c.name}` : String(c.name || "")
315
+ const message = failure !== undefined ? oneLine(text(failure)) : ""
316
+ const seconds = c.time !== undefined && !Number.isNaN(Number(c.time)) ? Number(c.time) : undefined
317
+ return { name, status, ...(seconds !== undefined ? { seconds } : {}), ...(message ? { message } : {}), ...(c.file ? { file: String(c.file) } : {}) }
318
+ })
319
+ }
320
+
321
+ /** Result files written at or after `since`: the given files, or the XML/TRX files in a given folder (one level down too). */
322
+ function resultFiles(paths: string[], since: number): string[] {
323
+ const fresh = (f: string) => statSync(f).mtimeMs >= since - 1000
324
+ const out: string[] = []
325
+ for (const p of paths) {
326
+ if (!existsSync(p)) continue
327
+ if (statSync(p).isFile()) {
328
+ if (fresh(p)) out.push(p)
329
+ continue
330
+ }
331
+ for (const e of readdirSync(p, { withFileTypes: true })) {
332
+ const f = path.join(p, e.name)
333
+ if (e.isFile() && /\.(xml|trx)$/i.test(e.name) && fresh(f)) out.push(f)
334
+ else if (e.isDirectory()) for (const g of readdirSync(f)) if (/\.(xml|trx)$/i.test(g) && fresh(path.join(f, g))) out.push(path.join(f, g))
335
+ }
336
+ }
337
+ return [...new Set(out)]
338
+ }
339
+
340
+ export type TestsRun = {
341
+ status: "passed" | "failed" | "timed out" | "stopped"
342
+ command: string
343
+ exitCode: number | null
344
+ seconds: number
345
+ tests: TestOutcome[]
346
+ counts: { passed: number; failed: number; skipped: number }
347
+ resultFiles: string[]
348
+ browsers: string[]
349
+ output: string
350
+ outputFile: string
351
+ dir: string
352
+ /** Tests that failed, then passed when run again. */
353
+ flaky?: string[]
354
+ /** Quarantined tests that failed: not counted against the run. */
355
+ quarantinedFailures?: string[]
356
+ /** Tests whose history now makes them flaky, and not in quarantine (flaky.quarantine: suggest). */
357
+ suggestQuarantine?: Flakiness[]
358
+ /** Put in quarantine by this run (flaky.quarantine: auto). */
359
+ quarantinedNow?: string[]
360
+ /** In quarantine, and clean for several runs in a row. */
361
+ releasable?: string[]
362
+ }
363
+
364
+ /**
365
+ * The command that runs only the tests that just failed, in the framework's own way: Playwright --last-failed,
366
+ * pytest --lf, Jest --onlyFailures. Otherwise the whole command again when the selection is small; null past that.
367
+ */
368
+ export function retryCommand(command: string, total: number): string | null {
369
+ if (/\bplaywright\s+test\b/.test(command)) return `${command} --last-failed`
370
+ if (/\bpytest\b/.test(command)) return `${command} --lf`
371
+ if (/\bjest\b/.test(command) && !/\bvitest\b/.test(command)) return `${command} --onlyFailures`
372
+ return total <= 20 ? command : null
373
+ }
374
+
375
+ /** Run a saved selection's command and read the per-test results it wrote; results.json lands in the selection's folder. */
376
+ export async function runSelection(opts: {
377
+ selection: TestSelection
378
+ timeoutSeconds: number
379
+ signal?: AbortSignal
380
+ runTest?: typeof runTestCommand
381
+ /** Variables the tests must not see: ForgeQE's own secrets (privateEnvNames). */
382
+ hide?: string[]
383
+ /** Retries and quarantine; default: one retry, quarantine suggested. */
384
+ flaky?: FlakyConfig
385
+ }): Promise<TestsRun> {
386
+ const sel = opts.selection
387
+ const flakyCfg = opts.flaky || { retries: 1, quarantine: "suggest", flakyAfter: 2 }
388
+ if (!sel.runCommand.trim()) throw new Error("The selection has no run command.")
389
+ mkdirSync(sel.dir, { recursive: true })
390
+ for (const p of sel.results) if (p.startsWith(sel.dir) && existsSync(p) && statSync(p).isFile()) rmSync(p)
391
+ const started = Date.now()
392
+ const exec = (command: string) => (opts.runTest || runTestCommand)({ command, cwd: sel.repo, timeoutSeconds: opts.timeoutSeconds, signal: opts.signal, hide: opts.hide })
393
+ const read = (since: number) => {
394
+ const out: TestOutcome[] = []
395
+ for (const f of resultFiles(sel.results, since)) {
396
+ try {
397
+ out.push(...parseResults(readFileSync(f, "utf8")))
398
+ } catch {}
399
+ }
400
+ return out
401
+ }
402
+ let run = await exec(sel.runCommand)
403
+ let files = resultFiles(sel.results, started)
404
+ let tests = read(started)
405
+ let output = run.output
406
+
407
+ // A failed test runs again: passing then means flaky, not failed.
408
+ const failedFirst = tests.filter((t) => t.status === "failed")
409
+ for (let attempt = 1; attempt <= flakyCfg.retries && failedFirst.length && run.exitCode !== null && !opts.signal?.aborted; attempt++) {
410
+ const still = tests.filter((t) => t.status === "failed")
411
+ if (!still.length) break
412
+ const cmd = retryCommand(sel.runCommand, tests.length)
413
+ if (!cmd) break
414
+ const again = Date.now()
415
+ const second = await exec(cmd)
416
+ output += `\n\n--- run again: ${still.length} failed test${still.length === 1 ? "" : "s"} (${cmd}) ---\n${second.output}`
417
+ const rerun = new Map(read(again).map((t) => [t.name, t]))
418
+ tests = tests.map((t) => (t.status === "failed" && rerun.get(t.name)?.status === "passed" ? { ...t, status: "passed", flaky: true } : t))
419
+ if (rerun.size) files = [...new Set([...files, ...resultFiles(sel.results, again)])]
420
+ if (second.exitCode === null) break
421
+ run = { ...second, ok: tests.every((t) => t.status !== "failed") }
422
+ }
423
+
424
+ // History, and quarantine: a quarantined test's failure does not count.
425
+ const q = quarantined(sel.repo)
426
+ tests = tests.map((t) => (q.has(t.name) ? { ...t, quarantined: true } : t))
427
+ recordResults(sel.repo, tests.map((t) => ({ name: t.name, status: t.status, retried: t.flaky, message: t.message, seconds: t.seconds })))
428
+ const report = new Map(flakyReport(sel.repo, flakyCfg.flakyAfter).map((f) => [f.name, f]))
429
+ const nowFlaky = tests.map((t) => report.get(t.name)).filter((f): f is Flakiness => Boolean(f?.flaky && !f.quarantined))
430
+ let quarantinedNow: string[] = []
431
+ if (flakyCfg.quarantine === "auto" && nowFlaky.length) {
432
+ quarantinedNow = setQuarantine(sel.repo, nowFlaky.map((f) => f.name), "flaky: ForgeQE quarantined it from its history", "auto")
433
+ tests = tests.map((t) => (quarantinedNow.includes(t.name) ? { ...t, quarantined: true } : t))
434
+ }
435
+ const blocking = tests.filter((t) => t.status === "failed" && !t.quarantined)
436
+ const counts = { passed: 0, failed: 0, skipped: 0 }
437
+ for (const t of tests) counts[t.status]++
438
+ const allQuarantined = tests.length > 0 && !blocking.length
439
+ const status = opts.signal?.aborted ? "stopped" : run.exitCode === null ? "timed out" : (run.ok || allQuarantined) && !blocking.length ? "passed" : "failed"
440
+ const outputFile = path.join(sel.dir, "output.log")
441
+ writeFileSync(outputFile, output)
442
+ const flakyNow = tests.filter((t) => t.flaky).map((t) => t.name)
443
+ const qFailed = tests.filter((t) => t.status === "failed" && t.quarantined).map((t) => t.name)
444
+ const releasable = tests.map((t) => report.get(t.name)).filter((f): f is Flakiness => Boolean(f?.releasable)).map((f) => f.name)
445
+ const result: TestsRun = {
446
+ status,
447
+ command: sel.runCommand,
448
+ exitCode: run.exitCode,
449
+ seconds: Math.round((Date.now() - started) / 100) / 10,
450
+ tests,
451
+ counts,
452
+ resultFiles: files,
453
+ browsers: run.browsers,
454
+ output,
455
+ outputFile,
456
+ dir: sel.dir,
457
+ ...(flakyNow.length ? { flaky: flakyNow } : {}),
458
+ ...(qFailed.length ? { quarantinedFailures: qFailed } : {}),
459
+ ...(flakyCfg.quarantine === "suggest" && nowFlaky.length ? { suggestQuarantine: nowFlaky } : {}),
460
+ ...(quarantinedNow.length ? { quarantinedNow } : {}),
461
+ ...(releasable.length ? { releasable } : {}),
462
+ }
463
+ writeFileSync(path.join(sel.dir, "results.json"), JSON.stringify({ ...result, output: undefined }, null, 2))
464
+ return result
465
+ }
466
+
467
+ const rel = (cwd: string, p: string) => {
468
+ const r = path.relative(cwd, p)
469
+ return r && !r.startsWith("..") && !path.isAbsolute(r) ? r : p
470
+ }
471
+
472
+ const LIST_MAX = 40
473
+
474
+ export function selectionCard(sel: TestSelection, cwd: string): Card {
475
+ const n = sel.tests.length
476
+ const body: string[] = []
477
+ for (const t of sel.tests.slice(0, LIST_MAX)) {
478
+ body.push(`• ${t.name}`)
479
+ const where = [t.file, t.added ? `added ${t.added}` : ""].filter(Boolean).join(", ")
480
+ body.push(` ${[where, t.reason].filter(Boolean).join(" · ")}`)
481
+ }
482
+ if (n > LIST_MAX) body.push(`and ${n - LIST_MAX} more (selection.json has all of them)`)
483
+ if (sel.related.length) {
484
+ body.push("", "Left out:")
485
+ for (const t of sel.related.slice(0, 10)) body.push(`• ${t.name}${t.reason ? ` · ${t.reason}` : ""}`)
486
+ if (sel.related.length > 10) body.push(`and ${sel.related.length - 10} more`)
487
+ }
488
+ if (n) {
489
+ body.push("")
490
+ if (sel.listCommand) body.push(`List ${sel.listCommand} (${sel.listed ? "ForgeQE ran it: lists exactly these" : "not confirmed"})`)
491
+ body.push(`Run ${sel.runCommand}`)
492
+ }
493
+ if (sel.notes) body.push("", sel.notes)
494
+ body.push(`Saved ${rel(cwd, path.join(sel.dir, "selection.json"))}`)
495
+ return {
496
+ status: "info",
497
+ title: n ? `${n} test${n === 1 ? "" : "s"} found for "${sel.request}"` : `No tests found for "${sel.request}"`,
498
+ stats: [sel.framework, sel.range?.label || [sel.range?.since, sel.range?.until].filter(Boolean).join(" to ")].filter(Boolean).join(" · "),
499
+ body,
500
+ }
501
+ }
502
+
503
+ export function resultsCard(r: TestsRun, cwd: string): Card {
504
+ const { passed, failed, skipped } = r.counts
505
+ const total = r.tests.length
506
+ const body: string[] = []
507
+ const sorted = [...r.tests.filter((t) => t.status === "failed"), ...r.tests.filter((t) => t.status !== "failed")]
508
+ for (const t of sorted.slice(0, LIST_MAX)) {
509
+ const mark = t.flaky ? "↻" : t.status === "passed" ? "✓" : t.status === "failed" ? "✗" : "–"
510
+ const note = t.flaky ? " flaky: failed, then passed when run again" : t.quarantined && t.status === "failed" ? " quarantined: not counted" : t.quarantined ? " quarantined" : ""
511
+ body.push(`${mark} ${t.name}${t.seconds !== undefined ? ` ${t.seconds}s` : ""}${note}`)
512
+ if (t.message && (t.status === "failed" || t.flaky)) body.push(` ${t.message}`)
513
+ }
514
+ for (const f of r.suggestQuarantine || []) body.push(`⚠ ${f.name} is flaky (${f.trail}): likely ${CAUSE_TEXT[f.cause]}. Quarantine it, or fix it with heal_tests.`)
515
+ if (r.quarantinedNow?.length) body.push(`Quarantined now (flaky): ${r.quarantinedNow.join(", ")}`)
516
+ if (r.releasable?.length) body.push(`Passing again ${RELEASE_AFTER} runs in a row, ready to leave quarantine: ${r.releasable.join(", ")}`)
517
+ if (total > LIST_MAX) body.push(`and ${total - LIST_MAX} more`)
518
+ if (!total) body.push("No per-test results were written; the exit code decides.", ...r.output.split("\n").slice(-8).map((l) => ` ${l}`))
519
+ body.push("", `Command ${r.command}`)
520
+ if (r.resultFiles.length) body.push(`Results ${r.resultFiles.map((f) => rel(cwd, f)).join(", ")}`)
521
+ body.push(`Output ${rel(cwd, r.outputFile)}`)
522
+ const title =
523
+ r.status === "stopped"
524
+ ? "Test run stopped"
525
+ : r.status === "timed out"
526
+ ? "Test run timed out"
527
+ : total
528
+ ? failed - (r.quarantinedFailures?.length || 0) > 0
529
+ ? `${failed - (r.quarantinedFailures?.length || 0)} of ${total} tests failed`
530
+ : `${passed} test${passed === 1 ? "" : "s"} passed${r.flaky?.length ? `, ${r.flaky.length} after a retry` : ""}${r.quarantinedFailures?.length ? `; ${r.quarantinedFailures.length} quarantined failed` : ""}`
531
+ : r.status === "passed"
532
+ ? "Tests passed"
533
+ : `Tests failed (exit code ${r.exitCode})`
534
+ return {
535
+ status: r.status === "passed" ? "ok" : r.status === "stopped" ? "stopped" : "fail",
536
+ title,
537
+ stats: [`${r.seconds}s`, skipped ? `${skipped} skipped` : "", r.browsers.length ? r.browsers.join(", ") : ""].filter(Boolean).join(" · "),
538
+ body,
539
+ }
540
+ }