@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.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +781 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +146 -0
- package/src/update.ts +311 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
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
|
+
}
|