@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,303 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { goalFor, writeBack as postResults, type CaseResult, type TestCase, type WriteBack } from "../cases/index.ts"
5
+ import { costOf } from "../cases/types.ts"
6
+ import { addUsage, NO_USAGE } from "../usage.ts"
7
+ import type { AgentEvent, CodeResult, ReplayMode, RunResult } from "../types.ts"
8
+ import { codeSkipReason, runCodeAgent } from "../code/index.ts"
9
+ import type { AppConfig } from "../config.ts"
10
+ import { fileBugs } from "../cases/bugs.ts"
11
+ import { classifyFailure, FAILURE_LABEL } from "../cases/failure.ts"
12
+ import { associateAdoAutomation, readMappings } from "../code/traceability.ts"
13
+ import type { ExistingTest } from "../code/prompt.ts"
14
+ import { runTestCommand } from "../code/testrun.ts"
15
+ import { privateEnvNames } from "../childEnv.ts"
16
+ import { readTimeline, reportEnv, type ReportEnv } from "../report/html.ts"
17
+ import { executeRun, runDirectory } from "../run.ts"
18
+ import { writeReports } from "./report.ts"
19
+
20
+ export type SuiteEvent =
21
+ | { type: "case-start"; index: number; total: number; case: TestCase; runDir: string }
22
+ | { type: "case-event"; index: number; case: TestCase; event: AgentEvent }
23
+ | { type: "case-done"; index: number; total: number; result: CaseResult }
24
+ | { type: "code-start"; index: number; case: TestCase }
25
+ | { type: "code-done"; index: number; case: TestCase; result: CaseResult }
26
+ | { type: "writeback"; item: WriteBack }
27
+
28
+ export type SuiteResult = {
29
+ name: string
30
+ dir: string
31
+ startedAt: string
32
+ finishedAt: string
33
+ ms: number
34
+ results: CaseResult[]
35
+ writeBack: WriteBack[]
36
+ files: { junit: string; html: string; json: string }
37
+ stopped: boolean
38
+ env?: ReportEnv
39
+ }
40
+
41
+ export type SuiteOptions = {
42
+ config: AppConfig
43
+ cases: TestCase[]
44
+ /** Repo for the coding agent; unset: browser runs only. */
45
+ repo?: string
46
+ parallel?: number
47
+ outDir?: string
48
+ writeBack?: boolean
49
+ headed?: boolean
50
+ url?: string
51
+ maxSteps?: number
52
+ replay?: ReplayMode
53
+ memory?: boolean
54
+ /** Browser skills for every case (empty: none); unset: the engine picks per case. */
55
+ skills?: string[]
56
+ /** Folder whose .forgeqe/skills count as project skills; default: repo. */
57
+ skillsRepo?: string
58
+ signal?: AbortSignal
59
+ onEvent?: (e: SuiteEvent) => void
60
+ execute?: typeof executeRun
61
+ coder?: typeof runCodeAgent
62
+ /** Replaces runTestCommand for a case's existing test. */
63
+ runTest?: typeof runTestCommand
64
+ }
65
+
66
+ const STATUS = { complete: "passed", fail: "failed", stopped: "stopped" } as const
67
+
68
+ function slug(s: string): string {
69
+ return s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "case"
70
+ }
71
+
72
+ /**
73
+ * Run each test case as one browser goal, `parallel` at a time. With a repo set, a passed case is handed to the coding
74
+ * agent as soon as its browser run ends, and that case's worker waits for its test before starting the next case:
75
+ * explore, hand off, code, then the next case. Coding jobs run one after another so two never edit the repo at once.
76
+ * Results are posted back to their source, then written as JUnit XML, HTML, and JSON.
77
+ */
78
+ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
79
+ const { config, cases } = opts
80
+ const execute = opts.execute || executeRun
81
+ const coder = opts.coder || runCodeAgent
82
+ const emit = opts.onEvent || (() => {})
83
+ const dir = runDirectory(config.suite.outDir, opts.outDir)
84
+ mkdirSync(dir, { recursive: true })
85
+ const t0 = Date.now()
86
+ const startedAt = new Date(t0).toISOString()
87
+ const name = `ForgeQE suite ${startedAt.slice(0, 16).replace("T", " ")}`
88
+ const total = cases.length
89
+ const results: (CaseResult | undefined)[] = Array.from({ length: total }, () => undefined)
90
+ const width = String(total).length
91
+ let codeQueue: Promise<void> = Promise.resolve()
92
+ /** Written back during the suite: bugs for functional defects (the suite's own bug pass skips those cases) and tests set as a test case's automation. */
93
+ const earlyBugs: WriteBack[] = []
94
+
95
+ const runOne = async (index: number) => {
96
+ const tc = cases[index]
97
+ const runDir = path.join(dir, `${String(index + 1).padStart(width, "0")}-${slug(tc.id)}`)
98
+ mkdirSync(runDir, { recursive: true })
99
+ writeFileSync(path.join(runDir, "testcase.json"), `${JSON.stringify(tc, null, 2)}\n`)
100
+ emit({ type: "case-start", index, total, case: tc, runDir })
101
+ const start = Date.now()
102
+ const explore = async (): Promise<{ run?: RunResult; error?: string }> => {
103
+ try {
104
+ const { result } = await execute({
105
+ config,
106
+ goal: goalFor(tc),
107
+ url: tc.startUrl || opts.url,
108
+ headed: opts.headed,
109
+ maxSteps: opts.maxSteps,
110
+ replay: opts.replay,
111
+ memory: opts.memory,
112
+ caseSteps: tc.steps.length,
113
+ outDir: runDir,
114
+ skills: opts.skills,
115
+ repo: opts.skillsRepo || opts.repo,
116
+ signal: opts.signal,
117
+ onEvent: (event) => emit({ type: "case-event", index, case: tc, event }),
118
+ })
119
+ return { run: result }
120
+ } catch (err) {
121
+ return { error: (err as Error).message || String(err) }
122
+ }
123
+ }
124
+ let { run, error } = await explore()
125
+ let failure = opts.signal?.aborted ? undefined : classifyFailure(run, error)
126
+ let spent = run?.usage
127
+ // An environment problem (the site down, a 5xx, a network failure) is often gone a moment later: one more run.
128
+ let retried = false
129
+ if (failure?.type === "environment" && !opts.signal?.aborted) {
130
+ emit({ type: "case-event", index, case: tc, event: { type: "log", level: "warn", text: `${FAILURE_LABEL.environment}: ${failure.reason} Running the case once more.` } })
131
+ retried = true
132
+ ;({ run, error } = await explore())
133
+ failure = opts.signal?.aborted ? undefined : classifyFailure(run, error)
134
+ spent = run?.usage ? addUsage(spent || NO_USAGE, run.usage) : spent
135
+ }
136
+ const end = Date.now()
137
+ const result: CaseResult = {
138
+ case: tc,
139
+ // Steps that passed on a page over an accessibility or performance budget: the case fails on the budget.
140
+ status: run ? (run.status === "complete" && run.budgetExceeded?.length ? "failed" : STATUS[run.status]) : opts.signal?.aborted ? "stopped" : "error",
141
+ summary: [run?.summary || error || "", ...(run?.budgetExceeded?.length ? [`Budget exceeded: ${run.budgetExceeded.join("; ")}`] : [])].filter(Boolean).join("\n\n"),
142
+ startedAt: new Date(start).toISOString(),
143
+ finishedAt: new Date(end).toISOString(),
144
+ ms: end - start,
145
+ tokens: spent ? spent.input + spent.output : 0,
146
+ ...(spent ? { usage: spent, cost: costOf(spent, config.llm.price) } : {}),
147
+ runDir,
148
+ finalUrl: run?.finalUrl || undefined,
149
+ steps: timelineLines(runDir),
150
+ ...(run?.caseReport?.steps.length ? { stepResults: run.caseReport.steps } : {}),
151
+ ...(failure ? { failure } : {}),
152
+ ...(retried ? { retried } : {}),
153
+ }
154
+ results[index] = result
155
+ emit({ type: "case-done", index, total, result })
156
+
157
+ // A functional defect's bug is filed now, so the test written for it can name the bug it waits on.
158
+ if (failure?.type === "functional_defect" && (opts.writeBack ?? config.sources.writeBack) && config.sources.bugs) {
159
+ for (const item of await fileBugs([result], config.sources, name)) {
160
+ earlyBugs.push(item)
161
+ emit({ type: "writeback", item })
162
+ if (!item.error) result.bug = { label: item.text.split(":")[0], ...(item.url ? { url: item.url } : {}) }
163
+ }
164
+ }
165
+
166
+ if (!opts.repo || !config.suite.code || opts.signal?.aborted) return
167
+ const skip = run ? codeSkipReason(config, run, failure) : "the browser run did not finish"
168
+ if (skip) {
169
+ result.codeSkipped = skip
170
+ return
171
+ }
172
+ // This case goes from its browser run straight to the coding agent, and its worker waits for that before taking
173
+ // the next case. Coding jobs still queue one after another, since they all edit the same repository.
174
+ const job = codeQueue.then(() => codeOne(index, tc, runDir, result))
175
+ codeQueue = job.catch(() => {})
176
+ await job
177
+ }
178
+
179
+ const codeOne = async (index: number, tc: TestCase, runDir: string, result: CaseResult) => {
180
+ if (opts.signal?.aborted) return
181
+ // A case that already has a test (from an earlier job): run it first. Passing, it needs no coding job at all;
182
+ // failing, the job updates it instead of writing a new one.
183
+ const mapped = readMappings(opts.repo!).cases[tc.id]
184
+ let existingTest: ExistingTest | undefined
185
+ if (mapped?.testFile && mapped.command && (mapped.status === "automated" || mapped.status === "known_defect") && existsSync(path.join(opts.repo!, mapped.testFile))) {
186
+ emit({ type: "case-event", index, case: tc, event: { type: "code", kind: "info", text: `${tc.id} already has a test, ${mapped.testFile}: ForgeQE runs it first` } })
187
+ const run = await (opts.runTest || runTestCommand)({ command: mapped.command, cwd: opts.repo!, timeoutSeconds: config.code.testTimeoutSeconds, signal: opts.signal, hide: privateEnvNames(config), save: path.join(runDir, "existing-test") })
188
+ const defectNow = result.failure?.type === "functional_defect"
189
+ // Reused as it is: a normal test that passes on a case that passes, or a known-defect test (an expected failure)
190
+ // on a case that still shows the defect.
191
+ const fits = mapped.status === "known_defect" ? defectNow : !defectNow
192
+ if (run.ok && fits) {
193
+ emit({ type: "case-event", index, case: tc, event: { type: "code", kind: "test", ok: true, attempt: 0, text: `${mapped.testFile} passes as it is: no new test needed` } })
194
+ result.code = { status: "passed", testFile: mapped.testFile, command: mapped.command, attempts: 0, existing: "reused", ...(mapped.testName ? { testName: mapped.testName } : {}), ...(mapped.status === "known_defect" ? { knownDefect: true } : {}) }
195
+ emit({ type: "code-done", index, case: tc, result })
196
+ return
197
+ }
198
+ if (opts.signal?.aborted) return
199
+ const why =
200
+ mapped.status === "known_defect" && !defectNow
201
+ ? `It is marked as an expected failure for a known defect${mapped.bug ? ` (${mapped.bug.label})` : ""}, and the browser run now passes: the defect looks fixed. Remove the expected-failure marker and keep the assertions.`
202
+ : defectNow && run.ok
203
+ ? "It passes, although this run found a functional defect (below): its assertions miss it. Assert the expected result the defect breaks, and mark the test as an expected failure."
204
+ : defectNow
205
+ ? "It fails because of the functional defect this run found (below): keep its assertions and mark it as an expected failure naming the defect."
206
+ : "It fails now: the application or the page changed since it was written."
207
+ existingTest = { testFile: mapped.testFile, command: mapped.command, output: run.output, why }
208
+ }
209
+ emit({ type: "code-start", index, case: tc })
210
+ let code: CodeResult | undefined
211
+ let error: string | undefined
212
+ try {
213
+ code = await coder({
214
+ config,
215
+ repo: opts.repo!,
216
+ runDir,
217
+ signal: opts.signal,
218
+ onEvent: (event) => emit({ type: "case-event", index, case: tc, event }),
219
+ testCase: tc,
220
+ ...(existingTest ? { existingTest } : {}),
221
+ ...(result.failure?.type === "functional_defect" ? { knownDefect: { reason: result.failure.reason, ...(result.bug ? { bug: result.bug } : {}) } } : {}),
222
+ })
223
+ } catch (err) {
224
+ error = (err as Error).message || String(err)
225
+ emit({ type: "case-event", index, case: tc, event: { type: "log", level: "error", text: `Coding agent: ${error}` } })
226
+ }
227
+ result.code = code
228
+ ? {
229
+ status: code.status,
230
+ testFile: code.testFile,
231
+ command: code.command,
232
+ attempts: code.attempts,
233
+ ...(code.handoff ? { handoff: code.handoff } : {}),
234
+ ...(code.skills?.length ? { skills: code.skills } : {}),
235
+ ...(code.testName ? { testName: code.testName } : {}),
236
+ ...(code.knownDefect ? { knownDefect: true } : {}),
237
+ ...(code.attention ? { attention: code.attention.dir } : {}),
238
+ ...(existingTest ? { existing: "updated" as const } : {}),
239
+ }
240
+ : { status: "failed", attempts: 0, ...(error ? { error } : {}) }
241
+ result.tokens += code ? code.usage.input + code.usage.output : 0
242
+ if (code) {
243
+ result.codeUsage = code.usage
244
+ result.codeCost = code.cost
245
+ }
246
+ // A passing test for an Azure DevOps test case is set as its Associated Automation.
247
+ if (code?.status === "passed" && code.testFile && tc.source === "ado" && (opts.writeBack ?? config.sources.writeBack)) {
248
+ const item = await associateAdoAutomation(config.sources, tc, { name: code.testName || `${tc.id} ${tc.title}`, file: code.testFile, command: code.command })
249
+ earlyBugs.push(item)
250
+ emit({ type: "writeback", item })
251
+ }
252
+ emit({ type: "code-done", index, case: tc, result })
253
+ }
254
+
255
+ let next = 0
256
+ const workers = Array.from({ length: Math.min(Math.max(1, opts.parallel || config.suite.parallel), total) }, async () => {
257
+ while (next < total && !opts.signal?.aborted) await runOne(next++)
258
+ })
259
+ await Promise.all(workers)
260
+
261
+ const finished = results.map((r, i): CaseResult => {
262
+ if (r) return r
263
+ const now = new Date().toISOString()
264
+ return { case: cases[i], status: "stopped", summary: "Not run: the suite was stopped.", startedAt: now, finishedAt: now, ms: 0, tokens: 0 }
265
+ })
266
+
267
+ const posted: WriteBack[] = []
268
+ if (opts.writeBack ?? config.sources.writeBack) {
269
+ const ran = finished.filter((r) => r.runDir)
270
+ const filed = new Set(earlyBugs.filter((b) => b.source === "bugs").map((b) => b.caseId))
271
+ posted.push(...earlyBugs)
272
+ for (const item of [...(await postResults(ran, config.sources, name)), ...(await fileBugs(ran.filter((r) => !filed.has(r.case.id)), config.sources, name))]) {
273
+ posted.push(item)
274
+ emit({ type: "writeback", item })
275
+ }
276
+ }
277
+
278
+ const finishedAt = new Date().toISOString()
279
+ const suite: Omit<SuiteResult, "files"> = {
280
+ name,
281
+ dir,
282
+ startedAt,
283
+ finishedAt,
284
+ ms: Date.now() - t0,
285
+ results: finished,
286
+ writeBack: posted,
287
+ stopped: Boolean(opts.signal?.aborted),
288
+ env: reportEnv(config, { url: opts.url, headed: opts.headed }),
289
+ }
290
+ return { ...suite, files: writeReports(suite) }
291
+ }
292
+
293
+ function timelineLines(runDir: string): CaseResult["steps"] | undefined {
294
+ const steps = readTimeline(runDir).flatMap((t) => t.actions.filter((a) => !a.done).map((a) => ({ text: a.error ? `${a.text}: ${a.error}` : a.text, ok: a.ok })))
295
+ return steps.length ? steps : undefined
296
+ }
297
+
298
+ /** 0 when every case passed (and every test written for it passes), 2 when stopped, otherwise 1. */
299
+ export function suiteExitCode(s: Pick<SuiteResult, "results" | "stopped">): number {
300
+ if (s.stopped) return 2
301
+ const ok = s.results.every((r) => r.status === "passed" && (!r.code || r.code.status === "passed"))
302
+ return ok ? 0 : 1
303
+ }
package/src/summary.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { qualityLine, type QualityReport } from "./quality/budgets.ts"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import type { CodeResult, CodeStatus, MemoryInfo, RunStatus, Usage } from "./types.ts"
6
+ import { usageLine, type Cost } from "./usage.ts"
7
+ import type { Card } from "./card.ts"
8
+ import { formatDuration, formatTokens } from "./tui/theme.ts"
9
+
10
+ export type RunSummary = {
11
+ status: RunStatus
12
+ /** "Task complete", "Task failed", or "Test case failed" when an expected result did not hold. */
13
+ title: string
14
+ /** Expected results that did not hold: "Expected: … · the page shows: …". */
15
+ failed: string[]
16
+ /** The agent's own account of what it did and found, or the reason it stopped. */
17
+ text: string
18
+ finalUrl: string
19
+ stats: string
20
+ /** What page memory replayed and learned, e.g. "replayed all 4 steps · 1 LLM step · 3 elements on 1 page". */
21
+ memory: string
22
+ savedDir: string
23
+ savedFiles: string[]
24
+ /** Tokens (in, cached, out) and cost, e.g. "52.7K tokens (50.1K in, 31K of it cached · 2.6K out) · US$0.07". */
25
+ usage: string
26
+ /** WCAG rules passed and failed, Core Web Vitals, and whether the budgets held. */
27
+ quality?: string
28
+ }
29
+
30
+ export const STATUS_TITLE: Record<RunStatus, string> = {
31
+ complete: "Task complete",
32
+ fail: "Task failed",
33
+ stopped: "Run stopped",
34
+ }
35
+
36
+ export const STATUS_MARK: Record<RunStatus, string> = { complete: "✓", fail: "✗", stopped: "■" }
37
+
38
+ /** Host and path without the query string: booking.com/searchresults.en-gb.html */
39
+ export function shortUrl(url: string, max = 70): string {
40
+ if (!url || url === "about:blank") return ""
41
+ try {
42
+ const u = new URL(url)
43
+ const s = `${u.hostname.replace(/^www\./, "")}${u.pathname === "/" ? "" : u.pathname}`
44
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`
45
+ } catch {
46
+ return url.length <= max ? url : `${url.slice(0, max - 1)}…`
47
+ }
48
+ }
49
+
50
+ export function runStats(ms: number, tokens: number, steps: number): string {
51
+ return `${steps} ${steps === 1 ? "step" : "steps"} · ${formatDuration(ms)} · ${formatTokens(tokens)} tokens`
52
+ }
53
+
54
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
55
+
56
+ export function memoryLine(m?: MemoryInfo): string {
57
+ if (!m) return ""
58
+ const parts: string[] = []
59
+ if (m.replayTotal) {
60
+ parts.push(m.replayed === m.replayTotal ? `replayed all ${m.replayed} steps` : `replayed ${m.replayed} of ${m.replayTotal} steps`)
61
+ parts.push(plural(m.llmSteps, "LLM step"))
62
+ }
63
+ if (m.elements) parts.push(`${plural(m.elements, "element")} on ${plural(m.pages, "page")}${m.newElements ? ` (${m.newElements} new)` : ""}`)
64
+ if (m.flow) parts.push(m.flow === "saved" ? "flow saved for replay" : "flow updated")
65
+ return parts.join(" · ")
66
+ }
67
+
68
+ export function buildSummary(opts: {
69
+ status: RunStatus
70
+ text: string
71
+ finalUrl: string
72
+ ms: number
73
+ tokens: number
74
+ steps: number
75
+ files: string[]
76
+ memory?: MemoryInfo
77
+ usage?: Usage
78
+ cost?: Cost
79
+ outcome?: "passed" | "assertion_failed" | "error"
80
+ failedChecks?: { expected: string; actual: string }[]
81
+ quality?: QualityReport
82
+ budgetExceeded?: string[]
83
+ }): RunSummary {
84
+ const dir = opts.files.length ? path.relative(process.cwd(), path.dirname(opts.files[0])) || "." : ""
85
+ const assertion = opts.outcome === "assertion_failed"
86
+ // Steps that passed on a page over budget: the run fails on the budget.
87
+ const overBudget = opts.status === "complete" && Boolean(opts.budgetExceeded?.length)
88
+ return {
89
+ status: overBudget ? "fail" : opts.status,
90
+ title: assertion ? "Test case failed: expected result not met" : overBudget ? "Budget exceeded: the steps passed, a page is over budget" : STATUS_TITLE[opts.status],
91
+ failed: [...(assertion ? (opts.failedChecks || []).map((c) => `Expected: ${c.expected} · the page shows: ${c.actual}`) : []), ...(opts.budgetExceeded || []).map((b) => `Budget: ${b}`)],
92
+ text: opts.text.trim() || (opts.status === "complete" ? "Finished without a written result." : "No details were reported."),
93
+ finalUrl: shortUrl(opts.finalUrl),
94
+ stats: runStats(opts.ms, opts.tokens, opts.steps),
95
+ memory: memoryLine(opts.memory),
96
+ savedDir: dir,
97
+ savedFiles: opts.files.map((f) => path.basename(f)),
98
+ usage: opts.usage ? usageLine(opts.usage, opts.cost) : "",
99
+ ...(opts.quality && qualityLine(opts.quality) ? { quality: qualityLine(opts.quality) } : {}),
100
+ }
101
+ }
102
+
103
+ /** Plain-text block for `forgeqe run`. */
104
+ export function formatSummary(s: RunSummary): string {
105
+ const lines = [`${STATUS_MARK[s.status]} ${s.title} · ${s.stats}`, "", ...(s.failed.length ? [...s.failed.map((f) => `✗ ${f}`), ""] : []), s.text, ""]
106
+ if (s.finalUrl) lines.push(`Ended on ${s.finalUrl}`)
107
+ if (s.memory) lines.push(`Memory ${s.memory}`)
108
+ if (s.quality) lines.push(`Quality ${s.quality}`)
109
+ if (s.usage) lines.push(`Usage ${s.usage}`)
110
+ if (s.savedDir) lines.push(`Saved ${s.savedDir}/ ${s.savedFiles.join(" · ")}`)
111
+ return lines.join("\n")
112
+ }
113
+
114
+ export type CodeSummary = {
115
+ status: CodeStatus
116
+ stats: string
117
+ text: string
118
+ testFile: string
119
+ command: string
120
+ reused: string
121
+ changed: string
122
+ repo: string
123
+ browser: string
124
+ review: string
125
+ usage: string
126
+ }
127
+
128
+ export const CODE_TITLE: Record<CodeStatus, string> = {
129
+ passed: "Test passed",
130
+ failed: "Test not passing",
131
+ stopped: "Coding stopped",
132
+ blocked: "Test blocked by the app",
133
+ }
134
+
135
+ export const CODE_MARK: Record<CodeStatus, string> = { passed: "✓", failed: "✗", stopped: "■", blocked: "✗" }
136
+
137
+ export function buildCodeSummary(r: CodeResult, ms: number): CodeSummary {
138
+ const attempts = `${r.attempts}/${r.maxAttempts} ${r.maxAttempts === 1 ? "attempt" : "attempts"}`
139
+ const text =
140
+ r.status === "passed"
141
+ ? r.notes || "ForgeQE ran the new test and it passed."
142
+ : r.status === "stopped"
143
+ ? "Stopped before the test passed."
144
+ : r.notes || (r.command ? "The test still fails when ForgeQE runs it." : "Scribe did not report a test to run.")
145
+ return {
146
+ status: r.status,
147
+ stats: `${attempts} · ${formatDuration(ms)} · ${formatTokens(r.usage.input + r.usage.output)} tokens · ${r.model || "Scribe"}`,
148
+ text,
149
+ testFile: r.testFile || "",
150
+ command: r.command || "",
151
+ reused: r.reused.join(", "),
152
+ changed: r.changed.join(", "),
153
+ repo: shortPath(r.repo),
154
+ browser: (r.browsers || []).join(", "),
155
+ review: r.review
156
+ ? r.review.standardsLeft
157
+ ? `approved: it tests the goal; coding standards still to fix: ${r.review.issues.filter((i) => i.kind === "standards").map((i) => i.problem).join(" ")}`
158
+ : `${r.review.approved ? "approved" : `not approved, ${r.review.issues.length} ${r.review.issues.length === 1 ? "issue" : "issues"}`}: ${r.review.summary}`
159
+ : "",
160
+ usage: usageLine(r.usage, r.cost),
161
+ }
162
+ }
163
+
164
+ /** A path as the user reads it: relative inside the working directory, otherwise ~/… or absolute. */
165
+ export function shortPath(dir: string): string {
166
+ const rel = path.relative(process.cwd(), dir)
167
+ if (!rel) return "."
168
+ if (!rel.startsWith("..") && !path.isAbsolute(rel)) return rel
169
+ const home = os.homedir()
170
+ return dir === home || dir.startsWith(home + path.sep) ? "~" + dir.slice(home.length) : dir
171
+ }
172
+
173
+ export function formatCodeSummary(s: CodeSummary): string {
174
+ const lines = [`${CODE_MARK[s.status]} ${CODE_TITLE[s.status]} · ${s.stats}`, "", s.text, ""]
175
+ if (s.testFile) lines.push(`Test ${s.testFile} (in ${s.repo})`)
176
+ if (s.command) lines.push(`Command ${s.command}`)
177
+ if (s.reused) lines.push(`Reused ${s.reused}`)
178
+ if (s.changed) lines.push(`Changed ${s.changed}`)
179
+ if (s.browser) lines.push(`Browser ${s.browser}`)
180
+ if (s.review) lines.push(`Review ${s.review}`)
181
+ if (s.usage) lines.push(`Usage ${s.usage}`)
182
+ return lines.join("\n")
183
+ }
184
+
185
+ export function codeCard(s: CodeSummary): Card {
186
+ const [, , ...body] = formatCodeSummary(s).split("\n")
187
+ return { status: s.status === "passed" ? "ok" : s.status === "stopped" ? "stopped" : "fail", title: CODE_TITLE[s.status], stats: s.stats, body }
188
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { createHash } from "node:crypto"
3
+ import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+
7
+ export type Log = (text: string) => void
8
+
9
+ export const WINDOWS = process.platform === "win32"
10
+ export const EXE = WINDOWS ? ".exe" : ""
11
+
12
+ /** ForgeQE was called WebPilot: a folder that exists only under the old name is still used, so earlier setups keep working. */
13
+ export function renamed(dir: string, name: string, legacy: string): string {
14
+ const now = path.join(dir, name)
15
+ const old = path.join(dir, legacy)
16
+ return !existsSync(now) && existsSync(old) ? old : now
17
+ }
18
+
19
+ export function forgeqeHome(): string {
20
+ return process.env.FORGEQE_HOME || renamed(os.homedir(), ".forgeqe", ".webpilot")
21
+ }
22
+
23
+ /** A project's ForgeQE folder next to forgeqe.yaml: .forgeqe, or .webpilot from before the rename. */
24
+ export function projectDir(base: string): string {
25
+ return renamed(base, ".forgeqe", ".webpilot")
26
+ }
27
+
28
+ /** Where ForgeQE keeps the programs it downloads: <name>-<version>/<binary>. */
29
+ export function toolsDir(): string {
30
+ return path.join(forgeqeHome(), "tools")
31
+ }
32
+
33
+ export function toolPath(name: string, version: string, binary: string): string {
34
+ return path.join(toolsDir(), `${name}-${version}`, binary)
35
+ }
36
+
37
+ export function isMusl(): boolean {
38
+ if (process.platform !== "linux") return false
39
+ try {
40
+ return readdirSync("/lib").some((f) => f.startsWith("ld-musl-"))
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ /** Windows ships bsdtar (reads zip) in System32; a Git-for-Windows GNU tar earlier on PATH cannot. macOS tar reads zip too. */
47
+ function tarCommand(): string {
48
+ const system = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe")
49
+ return WINDOWS && existsSync(system) ? system : "tar"
50
+ }
51
+
52
+ function findFile(dir: string, name: string): string | null {
53
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
54
+ const full = path.join(dir, entry.name)
55
+ if (entry.isFile() && entry.name === name) return full
56
+ if (entry.isDirectory()) {
57
+ const found = findFile(full, name)
58
+ if (found) return found
59
+ }
60
+ }
61
+ return null
62
+ }
63
+
64
+ export async function fetchBytes(url: string, headers?: Record<string, string>): Promise<Buffer> {
65
+ const res = await fetch(url, { redirect: "follow", headers })
66
+ if (!res.ok) throw new Error(`${url} returned ${res.status}`)
67
+ return Buffer.from(await res.arrayBuffer())
68
+ }
69
+
70
+ /** SHA-256 GitHub records for a release asset, or null when the API does not answer (rate limit, proxy). */
71
+ export async function githubDigest(repo: string, tag: string, asset: string): Promise<string | null> {
72
+ try {
73
+ const body = await fetchBytes(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, { Accept: "application/vnd.github+json" })
74
+ const found = (JSON.parse(body.toString("utf8")).assets || []).find((a: { name: string }) => a.name === asset)
75
+ const digest = String(found?.digest || "")
76
+ return digest.startsWith("sha256:") ? digest.slice(7).toLowerCase() : null
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Download a release archive (zip or tar.gz), check its SHA-256 when one is known, and put `binary` at
84
+ * ~/.forgeqe/tools/<name>-<version>/<binary>. Older versions of the same tool are removed.
85
+ * No admin rights, PATH or shell profile changes.
86
+ */
87
+ export async function installTool(opts: {
88
+ name: string
89
+ version: string
90
+ asset: string
91
+ url: string
92
+ binary: string
93
+ sha256: string | null
94
+ log: Log
95
+ /** How the logs name the tool; default: name, version, and asset. */
96
+ label?: string
97
+ }): Promise<string> {
98
+ const { name, version, asset, binary, log } = opts
99
+ const label = opts.label || `${name} ${version} (${asset})`
100
+ log(`Downloading ${label} into ${toolsDir()} (one time).`)
101
+ const archive = await fetchBytes(opts.url)
102
+ if (opts.sha256) {
103
+ if (createHash("sha256").update(archive).digest("hex") !== opts.sha256) throw new Error(`checksum mismatch for ${opts.label || asset}`)
104
+ } else {
105
+ log(`No published checksum for ${opts.label || asset} could be fetched; relying on the HTTPS download.`)
106
+ }
107
+
108
+ const work = path.join(toolsDir(), `.${name}-${process.pid}`)
109
+ rmSync(work, { recursive: true, force: true })
110
+ mkdirSync(work, { recursive: true })
111
+ try {
112
+ const file = path.join(work, asset)
113
+ writeFileSync(file, archive)
114
+ const r = spawnSync(tarCommand(), ["-xf", file, "-C", work], { encoding: "utf8" })
115
+ if (r.error || r.status !== 0) throw new Error(`could not unpack ${asset}: ${r.error?.message || r.stderr}`)
116
+ const found = findFile(work, binary)
117
+ if (!found) throw new Error(`${binary} not found in ${asset}`)
118
+ const target = toolPath(name, version, binary)
119
+ rmSync(path.dirname(target), { recursive: true, force: true })
120
+ mkdirSync(path.dirname(target), { recursive: true })
121
+ renameSync(found, target)
122
+ if (!WINDOWS) chmodSync(target, 0o755)
123
+ for (const old of readdirSync(toolsDir())) {
124
+ if (old.startsWith(`${name}-`) && old !== `${name}-${version}`) rmSync(path.join(toolsDir(), old), { recursive: true, force: true })
125
+ }
126
+ return target
127
+ } finally {
128
+ rmSync(work, { recursive: true, force: true })
129
+ }
130
+ }