@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,475 @@
1
+ import type { QualityReport } from "../quality/budgets.ts"
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import type { CaseResult, CaseStatus, Cost, WriteBack } from "../cases/types.ts"
6
+ import type { ReportEnv } from "./html.ts"
7
+ import { addCost } from "../usage.ts"
8
+ import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
9
+
10
+ /** Something that went wrong in the browser during a step: a console error, an uncaught exception, a failed or slow request. */
11
+ export type BrowserProblem = {
12
+ kind: "console" | "exception" | "network" | "slow"
13
+ level: "error" | "warning"
14
+ text: string
15
+ url?: string
16
+ status?: number
17
+ ms?: number
18
+ line?: number
19
+ count?: number
20
+ }
21
+
22
+ /** One entry of a run's steps.json: a step replayed from memory or taken by the agent. */
23
+ export type TimelineStep = {
24
+ n: number
25
+ source: "agent" | "memory"
26
+ url: string
27
+ title?: string
28
+ goal?: string
29
+ evaluation?: string
30
+ memory?: string
31
+ /** `box`: where the action's element sat on the screenshot, as fractions of its width and height. */
32
+ actions: { text: string; ok: boolean; error?: string; done?: boolean; box?: { x: number; y: number; w: number; h: number } }[]
33
+ /** Start (epoch seconds), duration, and the part of it spent waiting for the model. */
34
+ t0?: number
35
+ ms?: number
36
+ model_ms?: number
37
+ screenshot?: string
38
+ problems?: BrowserProblem[]
39
+ more_problems?: number
40
+ }
41
+
42
+ /** The console errors, uncaught exceptions, and failed requests of a run, in order. */
43
+ export function browserErrors(runDir?: string): BrowserProblem[] {
44
+ return readTimeline(runDir).flatMap((t) => (t.problems || []).filter((p) => p.level === "error"))
45
+ }
46
+
47
+ export function problemLine(p: BrowserProblem): string {
48
+ const times = p.count && p.count > 1 ? ` (×${p.count})` : ""
49
+ if (p.kind === "network") return `Request failed${p.status ? ` (${p.status})` : ""}: ${p.url || p.text}${times}`
50
+ if (p.kind === "exception") return `Uncaught error: ${p.text.split("\n")[0]}${p.url ? ` at ${p.url}${p.line ? `:${p.line}` : ""}` : ""}${times}`
51
+ if (p.kind === "slow") return `Slow request (${((p.ms || 0) / 1000).toFixed(1)}s): ${p.url}${times}`
52
+ return `Console ${p.level}: ${p.text}${times}`
53
+ }
54
+
55
+ /** A memory step whose recorded locators no longer matched the page; `used` is the one that still worked. */
56
+ export type Heal = { step: number; line: string; label?: string; stale: string[]; used?: string }
57
+
58
+ /** What page memory did in a run (run.json "memory"), plus the last full agent run of the case to compare with. */
59
+ export type CaseMemory = {
60
+ mode: string
61
+ replayed: number
62
+ replayTotal: number
63
+ agentSteps: number
64
+ flow?: string
65
+ stopped?: string
66
+ repaired?: { step: number; reason: string; agentSteps: number }
67
+ heals?: Heal[]
68
+ baseline?: { runId: string; at: string; ms: number; tokens: number }
69
+ }
70
+
71
+ export type Diagnostics = {
72
+ consoleErrors: number
73
+ consoleWarnings: number
74
+ failedRequests: number
75
+ slowRequests: number
76
+ pagesChecked: number
77
+ accessibilityIssues: number
78
+ }
79
+
80
+ export type A11yIssue = {
81
+ rule: string
82
+ wcag?: string
83
+ impact: string
84
+ help: string
85
+ count: number
86
+ examples?: { html?: string; text?: string; role?: string }[]
87
+ }
88
+ export type A11yPage = { url: string; title?: string; checked?: number; issues: A11yIssue[] }
89
+
90
+ /** The same case's last passing run: its screenshot of the page this run failed on, and what it did. */
91
+ export type LastGood = { runId: string; at: string; href?: string; shot?: string; step?: number; url?: string; steps: string[] }
92
+
93
+ /** Mirrors report-ui/src/types.ts; the UI reads it from window.__FORGEQE_REPORT__. */
94
+ export type CaseVisual = { page: string; url: string; title?: string; status: string; diffPercent?: number; limit?: number; over?: boolean; resized?: string; current: string; baseline?: string; diff?: string }
95
+ export type CaseQuality = { standard: string; engine: string; passed: string[]; failed: number; performance: QualityReport["performance"]["pages"]; budgets: QualityReport["budgets"]; breaches: QualityReport["breaches"]; visual?: CaseVisual[] }
96
+
97
+ export type ReportData = {
98
+ kind: "suite" | "run"
99
+ title: string
100
+ id: string
101
+ startedAt: string
102
+ ms: number
103
+ stopped: boolean
104
+ generatedAt: string
105
+ env?: ReportEnv
106
+ cases: ReportCase[]
107
+ posted: WriteBack[]
108
+ history: HistoryRun[]
109
+ }
110
+
111
+ export type ReportCase = {
112
+ id: string
113
+ title: string
114
+ source: string
115
+ url?: string
116
+ description?: string
117
+ preconditions?: string[]
118
+ tags?: string[]
119
+ group?: string
120
+ requirements?: string[]
121
+ startUrl?: string
122
+ status: CaseStatus
123
+ summary: string
124
+ startedAt: string
125
+ finishedAt: string
126
+ ms: number
127
+ /** Browser agent and coding agent together. */
128
+ tokens: number
129
+ usage?: { input: number; output: number }
130
+ /** What the case cost in all: the browser run and, when one ran, the coding agent's job. */
131
+ cost?: Cost
132
+ /** The two parts of cost, when the coding agent ran. */
133
+ browserCost?: Cost
134
+ codeCost?: Cost
135
+ codeTokens?: number
136
+ finalUrl?: string
137
+ memory?: CaseMemory
138
+ diagnostics?: Diagnostics
139
+ accessibility?: A11yPage[]
140
+ /** WCAG rules passed and failed, each page's Core Web Vitals, and the budgets exceeded (quality.json). */
141
+ quality?: CaseQuality
142
+ lastGood?: LastGood
143
+ steps: { n: number; action: string; data?: string; expected?: string; status?: "passed" | "failed" | "not_run"; actual?: string }[]
144
+ timeline: (Omit<TimelineStep, "screenshot"> & { shot?: string })[]
145
+ agentSteps?: { text: string; ok: boolean }[]
146
+ posted: WriteBack[]
147
+ code?: CaseResult["code"]
148
+ /** What kind of failure it was and what happened because of it (src/cases/failure.ts). */
149
+ failure?: CaseResult["failure"] & { label: string; action: string }
150
+ bug?: CaseResult["bug"]
151
+ retried?: boolean
152
+ /** Skills the browser agent followed in this run (run.json "skills"). */
153
+ skills?: string[]
154
+ lastShot?: string
155
+ files: { name: string; href: string; text?: string; truncated?: boolean }[]
156
+ runDir?: string
157
+ }
158
+
159
+ export type HistoryRun = {
160
+ id: string
161
+ title: string
162
+ at: string
163
+ ms: number
164
+ total: number
165
+ passed: number
166
+ failed: number
167
+ error: number
168
+ stopped: number
169
+ tokens: number
170
+ cost?: Cost
171
+ current?: boolean
172
+ href?: string
173
+ cases: { id: string; title: string; status: CaseStatus; ms: number; tokens: number; cost?: number }[]
174
+ }
175
+
176
+ export type DataInput = {
177
+ kind: "suite" | "run"
178
+ title: string
179
+ startedAt: string
180
+ ms: number
181
+ stopped?: boolean
182
+ dir: string
183
+ results: CaseResult[]
184
+ posted: WriteBack[]
185
+ env?: ReportEnv
186
+ }
187
+
188
+ export type DataOptions = {
189
+ /** Screenshot bytes embedded in the page; later screenshots are linked by relative path instead. */
190
+ embedBytes?: number
191
+ /** Largest text file embedded for the Files tab. */
192
+ textBytes?: number
193
+ /** Earlier suite runs read for trends and flaky tests. */
194
+ historyRuns?: number
195
+ }
196
+
197
+ const TEXT_FILES = ["trace.md", "workflow.yaml", "handoff.md"]
198
+ const LINK_FILES = ["steps.json", "run.json", "accessibility.json", "performance.json", "quality.json", "code.json", "testcase.json"]
199
+ const MIME: Record<string, string> = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif" }
200
+
201
+ /** Visual results with their images embedded (changed pages first, as they matter most). */
202
+ function visualPages(pages: NonNullable<QualityReport["visual"]>["pages"], image: (file: string) => string): CaseVisual[] {
203
+ const img = (file?: string) => (file && existsSync(file) ? image(file) : undefined)
204
+ return [...pages]
205
+ .sort((a, b) => Number(b.status === "changed") - Number(a.status === "changed") || (b.diffPercent || 0) - (a.diffPercent || 0))
206
+ .map((p) => ({ page: p.page, url: p.url, title: p.title, status: p.status, diffPercent: p.diffPercent, limit: p.limit, over: p.over, resized: p.resized, current: img(p.current) || "", baseline: p.status === "new" ? undefined : img(p.baseline), diff: img(p.diff) }))
207
+ }
208
+
209
+ export function buildReportData(p: DataInput, opts: DataOptions = {}): ReportData {
210
+ const rel = (file: string) => path.relative(p.dir, file).split(path.sep).map(encodeURIComponent).join("/") || "."
211
+ let budget = opts.embedBytes ?? 40 * 1024 * 1024
212
+ const textBytes = opts.textBytes ?? 100 * 1024
213
+
214
+ const image = (file: string): string => {
215
+ const size = statSync(file).size
216
+ const mime = MIME[path.extname(file).toLowerCase()]
217
+ if (!mime || size > budget) return rel(file)
218
+ budget -= size
219
+ return `data:${mime};base64,${readFileSync(file).toString("base64")}`
220
+ }
221
+
222
+ const earlier = p.kind === "suite" ? savedSuites(p).filter((s) => s.startedAt < p.startedAt).slice(-(opts.historyRuns ?? 30)) : []
223
+
224
+ // Failed cases claim the embedding budget first: their screenshots matter most.
225
+ const order = p.results.map((r, i) => ({ r, i })).sort((a, b) => Number(a.r.status === "passed") - Number(b.r.status === "passed") || a.i - b.i)
226
+ const cases: ReportCase[] = new Array(p.results.length)
227
+ for (const { r, i } of order) {
228
+ const c = reportCase(r, p.posted, rel, image, textBytes)
229
+ if (c.memory?.replayed) c.memory.baseline = fullAgentRun(r, earlier)
230
+ if (r.status === "failed" || r.status === "error") c.lastGood = lastGood(r, c, earlier, image)
231
+ cases[i] = c
232
+ }
233
+
234
+ const data: ReportData = {
235
+ kind: p.kind,
236
+ title: p.title,
237
+ id: path.basename(p.dir),
238
+ startedAt: p.startedAt,
239
+ ms: p.ms,
240
+ stopped: Boolean(p.stopped),
241
+ generatedAt: new Date().toISOString(),
242
+ env: p.env,
243
+ cases,
244
+ posted: p.posted.filter((w) => !w.caseId),
245
+ history: [],
246
+ }
247
+ if (p.kind === "suite") {
248
+ data.history = [
249
+ ...earlier.map((s) => historyRun(s.name, s.title, s.startedAt, s.ms, s.results, s.href)),
250
+ { ...historyRun(path.basename(p.dir), p.title, p.startedAt, p.ms, p.results), current: true },
251
+ ]
252
+ }
253
+ return data
254
+ }
255
+
256
+ function reportCase(r: CaseResult, posted: WriteBack[], rel: (f: string) => string, image: (f: string) => string, textBytes: number): ReportCase {
257
+ const tc = r.case
258
+ const inRun = (name: string) => (r.runDir && existsSync(path.join(r.runDir, name)) ? path.join(r.runDir, name) : null)
259
+ const verdicts = new Map((r.stepResults || []).map((v) => [v.step, v]))
260
+ const timeline = readTimeline(r.runDir).map(({ screenshot, ...t }) => {
261
+ const file = screenshot ? inRun(screenshot) : null
262
+ return { ...t, shot: file ? image(file) : undefined }
263
+ })
264
+ const failure = inRun("failure.png")
265
+ const files: ReportCase["files"] = []
266
+ for (const name of TEXT_FILES) {
267
+ const file = inRun(name)
268
+ if (!file) continue
269
+ const buf = readFileSync(file)
270
+ files.push({ name, href: rel(file), text: buf.subarray(0, textBytes).toString("utf8"), truncated: buf.length > textBytes || undefined })
271
+ }
272
+ for (const name of LINK_FILES) {
273
+ const file = inRun(name)
274
+ if (file) files.push({ name, href: rel(file) })
275
+ }
276
+ if (failure) files.push({ name: "failure.png", href: rel(failure) })
277
+ const run = readJson<RunJson>(inRun("run.json"))
278
+ const a11y = readJson<A11yPage[]>(inRun("accessibility.json"))
279
+ const q = readJson<QualityReport>(inRun("quality.json"))
280
+
281
+ return {
282
+ id: tc.id,
283
+ title: tc.title,
284
+ source: tc.source,
285
+ url: tc.url,
286
+ description: tc.description?.trim() || undefined,
287
+ preconditions: tc.preconditions?.length ? tc.preconditions : undefined,
288
+ tags: tc.tags?.length ? tc.tags : undefined,
289
+ group: tc.group,
290
+ requirements: tc.requirements?.length ? tc.requirements : undefined,
291
+ startUrl: tc.startUrl,
292
+ status: r.status,
293
+ summary: r.summary,
294
+ startedAt: r.startedAt,
295
+ finishedAt: r.finishedAt,
296
+ ms: r.ms,
297
+ tokens: r.tokens,
298
+ usage: r.usage,
299
+ cost: addCost(r.cost, r.codeCost),
300
+ ...(r.codeCost || r.codeUsage ? { browserCost: r.cost, codeCost: r.codeCost, codeTokens: r.codeUsage ? r.codeUsage.input + r.codeUsage.output : undefined } : {}),
301
+ finalUrl: r.finalUrl,
302
+ memory: caseMemory(run?.memory),
303
+ diagnostics: diagnostics(run?.diagnostics),
304
+ accessibility: Array.isArray(a11y) && a11y.length ? a11y : undefined,
305
+ ...(q ? { quality: { standard: q.standard, engine: q.engine, passed: q.accessibility.passed, failed: q.accessibility.failed.length, performance: q.performance.pages, budgets: q.budgets, breaches: q.breaches, ...(q.visual?.pages.length ? { visual: visualPages(q.visual.pages, image) } : {}) } } : {}),
306
+ steps: tc.steps.map((s, i) => {
307
+ const v = verdicts.get(i + 1)
308
+ return { n: i + 1, action: s.action, data: s.data, expected: s.expected, status: v?.status, actual: v?.actual }
309
+ }),
310
+ timeline,
311
+ agentSteps: !timeline.length && r.steps?.length ? r.steps.map((s) => ({ text: s.text, ok: s.ok })) : undefined,
312
+ posted: posted.filter((w) => w.caseId === tc.id),
313
+ code: r.code,
314
+ ...(r.failure ? { failure: { ...r.failure, label: FAILURE_LABEL[r.failure.type], action: FAILURE_ACTION[r.failure.type] } } : {}),
315
+ ...(r.bug ? { bug: r.bug } : {}),
316
+ ...(r.retried ? { retried: true } : {}),
317
+ skills: Array.isArray(run?.skills) && run.skills.length ? run.skills.map(String) : undefined,
318
+ lastShot: failure ? image(failure) : undefined,
319
+ files,
320
+ runDir: r.runDir ? rel(r.runDir) : undefined,
321
+ }
322
+ }
323
+
324
+ export function readTimeline(runDir?: string): TimelineStep[] {
325
+ const file = runDir && path.join(runDir, "steps.json")
326
+ if (!file || !existsSync(file)) return []
327
+ try {
328
+ const data = JSON.parse(readFileSync(file, "utf8"))
329
+ return Array.isArray(data) ? data : []
330
+ } catch {
331
+ return []
332
+ }
333
+ }
334
+
335
+ type RunJson = {
336
+ memory?: {
337
+ mode?: string
338
+ replayed?: number
339
+ replay_total?: number
340
+ llm_steps?: number
341
+ flow?: string | null
342
+ replay_stopped?: string
343
+ repaired?: { step?: number; reason?: string; agent_steps?: number }
344
+ heals?: Heal[]
345
+ }
346
+ diagnostics?: Record<string, number>
347
+ skills?: unknown[]
348
+ }
349
+
350
+ function readJson<T>(file: string | null | undefined): T | undefined {
351
+ if (!file || !existsSync(file)) return undefined
352
+ try {
353
+ return JSON.parse(readFileSync(file, "utf8")) as T
354
+ } catch {
355
+ return undefined
356
+ }
357
+ }
358
+
359
+ function caseMemory(m: RunJson["memory"]): CaseMemory | undefined {
360
+ if (!m) return undefined
361
+ return {
362
+ mode: m.mode || "verify",
363
+ replayed: m.replayed || 0,
364
+ replayTotal: m.replay_total || 0,
365
+ agentSteps: m.llm_steps || 0,
366
+ flow: m.flow || undefined,
367
+ stopped: m.replay_stopped || undefined,
368
+ repaired: m.repaired ? { step: m.repaired.step || 0, reason: m.repaired.reason || "", agentSteps: m.repaired.agent_steps || 0 } : undefined,
369
+ heals: m.heals?.length ? m.heals : undefined,
370
+ }
371
+ }
372
+
373
+ function diagnostics(d: RunJson["diagnostics"]): Diagnostics | undefined {
374
+ if (!d) return undefined
375
+ return {
376
+ consoleErrors: d.console_errors || 0,
377
+ consoleWarnings: d.console_warnings || 0,
378
+ failedRequests: d.failed_requests || 0,
379
+ slowRequests: d.slow_requests || 0,
380
+ pagesChecked: d.pages_checked || 0,
381
+ accessibilityIssues: d.accessibility_issues || 0,
382
+ }
383
+ }
384
+
385
+ type Saved = { name: string; title: string; startedAt: string; ms: number; results: CaseResult[]; dir: string; href?: string }
386
+
387
+ /** Earlier suite runs saved next to this one (out/suites/<time>/suite.json), oldest first. */
388
+ function savedSuites(p: DataInput): Saved[] {
389
+ const parent = path.dirname(p.dir)
390
+ const self = path.basename(p.dir)
391
+ let names: string[] = []
392
+ try {
393
+ names = readdirSync(parent).filter((n) => n !== self && existsSync(path.join(parent, n, "suite.json")))
394
+ } catch {}
395
+ const out: Saved[] = []
396
+ for (const name of names) {
397
+ const s = readJson<{ name?: string; startedAt?: string; ms?: number; results?: CaseResult[] }>(path.join(parent, name, "suite.json"))
398
+ if (!s?.startedAt || !Array.isArray(s.results)) continue
399
+ const html = path.join(parent, name, "report.html")
400
+ const href = existsSync(html) ? `../${encodeURIComponent(name)}/report.html` : undefined
401
+ out.push({ name, title: s.name || name, startedAt: s.startedAt, ms: s.ms || 0, results: s.results, dir: path.join(parent, name), href })
402
+ }
403
+ return out.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
404
+ }
405
+
406
+ /** A saved result's run folder: next to its suite.json if the runs were moved, else where it was written. */
407
+ function runDirOf(r: CaseResult, s: Saved): string | undefined {
408
+ if (!r.runDir) return undefined
409
+ const moved = path.join(s.dir, path.basename(r.runDir))
410
+ return existsSync(moved) ? moved : existsSync(r.runDir) ? r.runDir : undefined
411
+ }
412
+
413
+ /** The newest earlier passing run of this case where the agent took every step (no page memory replay). */
414
+ function fullAgentRun(r: CaseResult, earlier: Saved[]): CaseMemory["baseline"] {
415
+ for (const s of [...earlier].reverse()) {
416
+ const old = s.results.find((o) => o.case.id === r.case.id && o.status === "passed")
417
+ if (!old) continue
418
+ const dir = runDirOf(old, s)
419
+ if (readJson<RunJson>(dir && path.join(dir, "run.json"))?.memory?.replayed) continue
420
+ return { runId: s.name, at: s.startedAt, ms: old.ms, tokens: old.tokens }
421
+ }
422
+ return undefined
423
+ }
424
+
425
+ const urlPath = (u?: string) => {
426
+ try {
427
+ return u ? new URL(u).pathname : undefined
428
+ } catch {
429
+ return u
430
+ }
431
+ }
432
+
433
+ function lastGood(r: CaseResult, c: ReportCase, earlier: Saved[], image: (f: string) => string): LastGood | undefined {
434
+ const passed = [...earlier].reverse().flatMap((s) => {
435
+ const old = s.results.find((o) => o.case.id === r.case.id && o.status === "passed")
436
+ return old ? [{ s, dir: runDirOf(old, s) }] : []
437
+ })
438
+ // The newest passing run whose folder is still there; else just the newest passing run.
439
+ const pick = passed.find((p) => p.dir) || passed[0]
440
+ if (!pick) return undefined
441
+ const { s, dir } = pick
442
+ const steps = readTimeline(dir)
443
+ const failedAt = urlPath(c.timeline[c.timeline.length - 1]?.url || r.finalUrl)
444
+ const shots = steps.filter((t) => t.screenshot && dir && existsSync(path.join(dir, t.screenshot)))
445
+ const match = [...shots].reverse().find((t) => urlPath(t.url) === failedAt) || shots[shots.length - 1]
446
+ return {
447
+ runId: s.name,
448
+ at: s.startedAt,
449
+ href: s.href,
450
+ shot: match && dir ? image(path.join(dir, match.screenshot!)) : undefined,
451
+ step: match?.n,
452
+ url: match?.url,
453
+ steps: steps.map((t) => t.actions.map((a) => a.text).join("; ") || t.goal || t.url),
454
+ }
455
+ }
456
+
457
+ function historyRun(id: string, title: string, at: string, ms: number, results: CaseResult[], href?: string): HistoryRun {
458
+ const n = (s: CaseStatus) => results.filter((r) => r.status === s).length
459
+ const costs = results.map((r) => addCost(r.cost, r.codeCost)).filter((c): c is Cost => !!c)
460
+ return {
461
+ id,
462
+ title,
463
+ at,
464
+ ms,
465
+ total: results.length,
466
+ passed: n("passed"),
467
+ failed: n("failed"),
468
+ error: n("error"),
469
+ stopped: n("stopped"),
470
+ tokens: results.reduce((a, r) => a + (r.tokens || 0), 0),
471
+ cost: costs.reduce<Cost | undefined>((sum, c) => addCost(sum, c), undefined),
472
+ href,
473
+ cases: results.map((r) => ({ id: r.case.id, title: r.case.title, status: r.status, ms: r.ms, tokens: r.tokens || 0, cost: addCost(r.cost, r.codeCost)?.amount })),
474
+ }
475
+ }
@@ -0,0 +1,106 @@
1
+ import { readFileSync, writeFileSync } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import type { RunResult } from "../types.ts"
6
+ import { costOf, type CaseResult, type WriteBack } from "../cases/types.ts"
7
+ import type { AppConfig } from "../config.ts"
8
+ import { packageVersion } from "../tui/theme.ts"
9
+ import { buildReportData, type DataOptions } from "./data.ts"
10
+
11
+ export { readTimeline, type TimelineStep } from "./data.ts"
12
+
13
+ /** Where and how the run happened, shown at the top of the report. */
14
+ export type ReportEnv = {
15
+ model: string
16
+ browser: string
17
+ startUrl?: string
18
+ version: string
19
+ machine: string
20
+ ci?: { name: string; url?: string }
21
+ commit?: string
22
+ branch?: string
23
+ /** Price per million tokens of the browser agent's model, when set in llms.json. */
24
+ price?: { input: number; output: number; currency: string; cachedInput?: number; note?: string; estimate?: boolean }
25
+ }
26
+
27
+ export function ciInfo(env = process.env): Pick<ReportEnv, "ci" | "commit" | "branch"> {
28
+ if (env.GITHUB_ACTIONS === "true") {
29
+ const url = env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined
30
+ return { ci: { name: `GitHub Actions${env.GITHUB_WORKFLOW ? ` · ${env.GITHUB_WORKFLOW}` : ""}`, url }, commit: env.GITHUB_SHA, branch: env.GITHUB_REF_NAME }
31
+ }
32
+ if ((env.TF_BUILD || "").toLowerCase() === "true") {
33
+ const url = env.SYSTEM_COLLECTIONURI && env.SYSTEM_TEAMPROJECT && env.BUILD_BUILDID ? `${env.SYSTEM_COLLECTIONURI}${encodeURIComponent(env.SYSTEM_TEAMPROJECT)}/_build/results?buildId=${env.BUILD_BUILDID}` : undefined
34
+ return { ci: { name: `Azure Pipelines${env.BUILD_DEFINITIONNAME ? ` · ${env.BUILD_DEFINITIONNAME}` : ""}`, url }, commit: env.BUILD_SOURCEVERSION, branch: env.BUILD_SOURCEBRANCHNAME }
35
+ }
36
+ if (env.CI_JOB_URL) return { ci: { name: "GitLab CI", url: env.CI_JOB_URL }, commit: env.CI_COMMIT_SHA, branch: env.CI_COMMIT_REF_NAME }
37
+ if (env.BUILD_URL) return { ci: { name: "CI", url: env.BUILD_URL }, commit: env.GIT_COMMIT, branch: env.GIT_BRANCH }
38
+ return {}
39
+ }
40
+
41
+ export function reportEnv(config: AppConfig, opts: { url?: string; headed?: boolean } = {}): ReportEnv {
42
+ const llm = config.llm
43
+ return {
44
+ model: `${config.activeProfile} (${llm.provider}/${llm.deployment || llm.model})`,
45
+ browser: llm.provider === "mock" ? "synthetic page (mock profile)" : `Chrome, ${opts.headed || !config.browser.headless ? "visible" : "headless"}, ${config.browser.width}×${config.browser.height}`,
46
+ startUrl: opts.url || config.browser.startUrl,
47
+ version: packageVersion(),
48
+ machine: `${os.hostname()} · ${process.platform}`,
49
+ ...(llm.price ? { price: llm.price } : {}),
50
+ ...ciInfo(),
51
+ }
52
+ }
53
+
54
+ export function duration(ms: number): string {
55
+ const s = Math.round(ms / 1000)
56
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`
57
+ }
58
+
59
+ export type ReportInput = {
60
+ title: string
61
+ startedAt: string
62
+ ms: number
63
+ stopped?: boolean
64
+ /** Folder the report is written to; links are relative to it. */
65
+ dir: string
66
+ results: CaseResult[]
67
+ posted: WriteBack[]
68
+ env?: ReportEnv
69
+ /** "run" opens on the case itself; "suite" (the default) on the overview. */
70
+ kind?: "suite" | "run"
71
+ }
72
+
73
+ /** Built from report-ui/ by `bun run build:report`: one HTML file with the whole React app inlined. */
74
+ const TEMPLATE = new URL("./template.html", import.meta.url)
75
+
76
+ /** A self-contained report page: the report-ui app with this run's data (and screenshots) embedded. */
77
+ export function renderReport(p: ReportInput, opts?: DataOptions): string {
78
+ const data = buildReportData({ kind: p.kind || "suite", ...p }, opts)
79
+ const json = JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")
80
+ const title = p.title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
81
+ return readFileSync(TEMPLATE, "utf8")
82
+ .replace(/<title>[^<]*<\/title>/, () => `<title>${title}</title>`)
83
+ .replace("<head>", () => `<head>\n<script>window.__FORGEQE_REPORT__=${json}</script>`)
84
+ }
85
+
86
+ /** report.html for one browser run, next to its other files. */
87
+ export function writeRunReport(dir: string, run: RunResult, env?: ReportEnv, t0 = Date.now()): string {
88
+ const status = run.status === "complete" ? "passed" : run.status === "fail" ? "failed" : "stopped"
89
+ const finished = new Date().toISOString()
90
+ const result: CaseResult = {
91
+ case: { id: "run", title: run.goal.split("\n")[0].slice(0, 200), source: "text", description: run.goal.includes("\n") ? run.goal : undefined, steps: [] },
92
+ status,
93
+ summary: run.summary,
94
+ startedAt: new Date(t0).toISOString(),
95
+ finishedAt: finished,
96
+ ms: Date.now() - t0,
97
+ tokens: run.usage.input + run.usage.output,
98
+ usage: run.usage,
99
+ cost: costOf(run.usage, env?.price),
100
+ runDir: dir,
101
+ finalUrl: run.finalUrl || undefined,
102
+ }
103
+ const file = path.join(dir, "report.html")
104
+ writeFileSync(file, renderReport({ title: "ForgeQE run", startedAt: result.startedAt, ms: result.ms, dir, results: [result], posted: [], env, kind: "run" }))
105
+ return file
106
+ }