@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,541 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import YAML from "yaml"
|
|
5
|
+
|
|
6
|
+
import { caseMarkdown } from "../cases/goal.ts"
|
|
7
|
+
import type { TestCase } from "../cases/types.ts"
|
|
8
|
+
import { findPage, readPages, type MemoryElement, type MemoryPage } from "../memory.ts"
|
|
9
|
+
import { replayOptions, replaySection, type Replay } from "./replay.ts"
|
|
10
|
+
import { sliceFor } from "./slices.ts"
|
|
11
|
+
|
|
12
|
+
/** What the coding agent gets from a browser run: the verified steps, their locators, and page memory. */
|
|
13
|
+
|
|
14
|
+
/** `verified`: during the run this locator found exactly the acted-on element on the live page. */
|
|
15
|
+
export type Locator = { type?: string; value?: string; confidence?: number | null; css?: string; attr?: string; verified?: boolean; playwright?: string; matches?: number }
|
|
16
|
+
|
|
17
|
+
export type Step = {
|
|
18
|
+
op?: string
|
|
19
|
+
url?: string
|
|
20
|
+
page?: string
|
|
21
|
+
page_url?: string
|
|
22
|
+
label?: string
|
|
23
|
+
text?: string
|
|
24
|
+
text_env?: string
|
|
25
|
+
keys?: string
|
|
26
|
+
totp_env?: string
|
|
27
|
+
clear?: boolean
|
|
28
|
+
seconds?: number
|
|
29
|
+
down?: boolean
|
|
30
|
+
pages?: number
|
|
31
|
+
action_keys?: string[]
|
|
32
|
+
best_locator?: string | null
|
|
33
|
+
locators?: Locator[]
|
|
34
|
+
locator?: Record<string, unknown>
|
|
35
|
+
selector_candidates?: Locator[]
|
|
36
|
+
/** evaluate: the JavaScript the browser agent ran. */
|
|
37
|
+
code?: string
|
|
38
|
+
/** The browser agent's own words for the step that did this: what it saw and what it set out to do. */
|
|
39
|
+
note?: string
|
|
40
|
+
/** evaluate that changed the page: the run did this from a script, which a test cannot repeat. */
|
|
41
|
+
scripted?: boolean
|
|
42
|
+
/** click: the control sat in an overlay (cookie banner, sign-in prompt, dialog) that this click dismissed. */
|
|
43
|
+
overlay?: { kind?: string; name?: string; id?: string }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An element the engine found on the live page, with its locators (verified ones first). */
|
|
47
|
+
export type CapturedElement = { tag?: string; role?: string; name?: string; locators?: Locator[] }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A result the browser agent verified: the expected result in its words, the exact text the page showed for it, the
|
|
51
|
+
* element showing it and its container (found on the live page), and whether the text is live data.
|
|
52
|
+
*/
|
|
53
|
+
export type Check = {
|
|
54
|
+
expected: string
|
|
55
|
+
text: string
|
|
56
|
+
url?: string
|
|
57
|
+
dynamic?: boolean
|
|
58
|
+
element?: CapturedElement
|
|
59
|
+
container?: CapturedElement
|
|
60
|
+
/** The repeating item the element belongs to (a result card, a row): its selector, how many, and the element's selector inside one. */
|
|
61
|
+
item?: { selector: string; count: number; inside?: string | null }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A check as the Playwright replay found it on the final page: the locator that matched its text, and where. */
|
|
65
|
+
export type CheckResult = Check & {
|
|
66
|
+
locator?: string
|
|
67
|
+
matches: number
|
|
68
|
+
where?: string[]
|
|
69
|
+
field?: boolean
|
|
70
|
+
/** For a heading: the part of the page it names, with a locator when the element has a stable one. */
|
|
71
|
+
section?: { locator: string; how: string; tag: string; links: number; items: number }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type BrowserProfile = {
|
|
75
|
+
driver?: string
|
|
76
|
+
channel?: string
|
|
77
|
+
executable?: string
|
|
78
|
+
headless?: boolean
|
|
79
|
+
version?: string
|
|
80
|
+
user_agent?: string
|
|
81
|
+
webdriver?: boolean
|
|
82
|
+
language?: string
|
|
83
|
+
timezone?: string
|
|
84
|
+
viewport?: number[]
|
|
85
|
+
removed_default_args?: string[]
|
|
86
|
+
extra_http_headers?: Record<string, string>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type RunJson = {
|
|
90
|
+
success?: boolean
|
|
91
|
+
summary?: string
|
|
92
|
+
goal?: string
|
|
93
|
+
start_url?: string
|
|
94
|
+
final_url?: string
|
|
95
|
+
memory?: { dir?: string; left_out?: string[] }
|
|
96
|
+
browser?: BrowserProfile
|
|
97
|
+
judge?: { verdict?: boolean; reasoning?: string }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type Handoff = {
|
|
101
|
+
goal: string
|
|
102
|
+
success: boolean
|
|
103
|
+
summary: string
|
|
104
|
+
finalUrl: string
|
|
105
|
+
markdown: string
|
|
106
|
+
file: string
|
|
107
|
+
/** Recorded steps and checks, and pages from page memory. */
|
|
108
|
+
steps: number
|
|
109
|
+
pages: number
|
|
110
|
+
checks: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const MAX_PAGES = 4
|
|
114
|
+
const MAX_ELEMENTS = 8
|
|
115
|
+
/** Locators below this confidence (deep XPaths, text that matched other elements) are left out. */
|
|
116
|
+
const MIN_CONFIDENCE = 30
|
|
117
|
+
const MAX_LOCATORS = 4
|
|
118
|
+
/** Page slices in a handoff: at most this many steps get one, of at most this many lines. */
|
|
119
|
+
const MAX_SLICES = 15
|
|
120
|
+
const SLICE_LINES = 14
|
|
121
|
+
|
|
122
|
+
function readJson<T>(file: string): T | null {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(readFileSync(file, "utf8")) as T
|
|
125
|
+
} catch {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function quote(s: string): string {
|
|
131
|
+
return JSON.stringify(s)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const playwrightOf = (l: Locator) => l.playwright || `${l.type}: ${l.value}`
|
|
135
|
+
|
|
136
|
+
/** Locators checked on the live page during the run, the most stable first; XPath only when nothing else was. */
|
|
137
|
+
function verifiedLocators(locs: Locator[] | undefined): Locator[] {
|
|
138
|
+
const ok = (locs || []).filter((l) => l.verified)
|
|
139
|
+
const plain = ok.filter((l) => l.type !== "xpath")
|
|
140
|
+
return (plain.length ? plain : ok).slice(0, MAX_LOCATORS)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function locatorList(step: Step): string[] {
|
|
144
|
+
const verified = verifiedLocators(step.locators)
|
|
145
|
+
if (verified.length) return verified.map((l) => `${l.type}: ${l.value} ✓`)
|
|
146
|
+
if (step.locators?.length) {
|
|
147
|
+
const strong = step.locators.filter((l) => l.confidence == null || l.confidence >= MIN_CONFIDENCE)
|
|
148
|
+
return (strong.length ? strong : step.locators.slice(0, 1))
|
|
149
|
+
.slice(0, MAX_LOCATORS)
|
|
150
|
+
.map((l) => `${l.type}: ${l.value}${l.confidence != null ? ` (${l.confidence}%)` : ""}`)
|
|
151
|
+
}
|
|
152
|
+
return (step.selector_candidates || []).slice(0, MAX_LOCATORS).map((l) => `${l.type}: ${l.value}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function stepLines(step: Step, n: number): string[] {
|
|
156
|
+
const where = step.page || step.page_url || ""
|
|
157
|
+
const target = step.label || Object.values(step.locator || {}).filter(Boolean).join(" ") || "element"
|
|
158
|
+
let head: string
|
|
159
|
+
switch (step.op) {
|
|
160
|
+
case "navigate":
|
|
161
|
+
return [`${n}. navigate ${step.url}`]
|
|
162
|
+
case "click":
|
|
163
|
+
head = `click ${target}`
|
|
164
|
+
break
|
|
165
|
+
case "input":
|
|
166
|
+
head = step.text_env
|
|
167
|
+
? `fill ${target} with process.env.${step.text_env}`
|
|
168
|
+
: `fill ${target} with ${quote(step.text || "")}`
|
|
169
|
+
break
|
|
170
|
+
case "select":
|
|
171
|
+
head = `select ${quote(step.text || "")} in ${target}`
|
|
172
|
+
break
|
|
173
|
+
case "press":
|
|
174
|
+
return [`${n}. press ${step.keys}`]
|
|
175
|
+
case "evaluate":
|
|
176
|
+
return step.scripted
|
|
177
|
+
? [
|
|
178
|
+
`${n}. changed the page from JavaScript, which a test cannot repeat: do what it did through the page's own control (find it in the page snapshot): ${(step.code || "").replace(/\s+/g, " ").trim().slice(0, 400)}`,
|
|
179
|
+
]
|
|
180
|
+
: [`${n}. ran JavaScript on the page: ${(step.code || "").replace(/\s+/g, " ").trim().slice(0, 400)}`]
|
|
181
|
+
case "wait":
|
|
182
|
+
case "scroll":
|
|
183
|
+
case "back":
|
|
184
|
+
return [`${n}. ${step.op}`]
|
|
185
|
+
default:
|
|
186
|
+
return [`${n}. ${step.op || "unknown"}`]
|
|
187
|
+
}
|
|
188
|
+
const overlay = step.overlay ? ` [closes the ${step.overlay.kind || "overlay"}${step.overlay.name ? ` "${step.overlay.name}"` : ""}]` : ""
|
|
189
|
+
const lines = [`${n}. ${head}${where ? ` (on ${where.split("?")[0]})` : ""}${overlay}`]
|
|
190
|
+
if (step.best_locator) lines.push(` best: ${step.best_locator}`)
|
|
191
|
+
const alts = locatorList(step)
|
|
192
|
+
if (alts.length) lines.push(` locators: ${alts.join(" | ")}`)
|
|
193
|
+
return lines
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The steps in order, each group led by the browser agent's note from the step that performed it. */
|
|
197
|
+
function stepsSection(steps: Step[], slices: Map<number, string[]> = new Map()): string[] {
|
|
198
|
+
if (!steps.length) return ["(no recorded steps)"]
|
|
199
|
+
const lines: string[] = []
|
|
200
|
+
let last = ""
|
|
201
|
+
steps.forEach((step, i) => {
|
|
202
|
+
const note = step.note || ""
|
|
203
|
+
if (note && note !== last) lines.push(`> Browser agent: ${note}`)
|
|
204
|
+
last = note
|
|
205
|
+
lines.push(...stepLines(step, i + 1), ...(slices.get(i + 1) || []))
|
|
206
|
+
})
|
|
207
|
+
return lines
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** host + path of a URL or a step's page ("en.wikipedia.org/wiki/X"), to match a step to the replay's page snapshots. */
|
|
211
|
+
function pageKey(url: string | undefined): string {
|
|
212
|
+
if (!url) return ""
|
|
213
|
+
try {
|
|
214
|
+
const u = new URL(/^https?:\/\//.test(url) ? url : `https://${url}`)
|
|
215
|
+
return `${u.host}${u.pathname}`.replace(/\/+$/, "")
|
|
216
|
+
} catch {
|
|
217
|
+
return ""
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The Playwright locators a step can be found by in an aria snapshot: its best, then the recorded ones. */
|
|
222
|
+
function stepLocators(step: Step): string[] {
|
|
223
|
+
const fromType = (l: Locator) => {
|
|
224
|
+
if (l.playwright) return l.playwright
|
|
225
|
+
const [role, name] = String(l.value || "").split("|")
|
|
226
|
+
if (l.type === "role" && role) return `getByRole(${JSON.stringify(role)}${name ? `, { name: ${JSON.stringify(name)} }` : ""})`
|
|
227
|
+
if ((l.type === "label" || l.type === "placeholder" || l.type === "text") && l.value) return `getByText(${JSON.stringify(l.value)})`
|
|
228
|
+
return ""
|
|
229
|
+
}
|
|
230
|
+
return [step.best_locator || "", ...(step.locators || []).map(fromType)].filter(Boolean)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Each element step's part of the page, from the replay's snapshot of that page: its section, its neighbours, and the
|
|
235
|
+
* element marked. A step whose section was just shown points back to it instead of repeating it.
|
|
236
|
+
*/
|
|
237
|
+
function stepSlices(steps: Step[], replay: Replay | null | undefined): Map<number, string[]> {
|
|
238
|
+
const out = new Map<number, string[]>()
|
|
239
|
+
const pass = replay?.passes.find((p) => p.reached && p.pages?.length) || replay?.passes.find((p) => p.pages?.length)
|
|
240
|
+
if (!pass?.pages) return out
|
|
241
|
+
const snaps = new Map<string, string>()
|
|
242
|
+
for (const p of pass.pages) {
|
|
243
|
+
try {
|
|
244
|
+
snaps.set(pageKey(p.url), readFileSync(p.snapshot, "utf8"))
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
const shown = new Map<string, number>()
|
|
248
|
+
steps.forEach((step, i) => {
|
|
249
|
+
if (out.size >= MAX_SLICES || !["click", "input", "select"].includes(step.op || "")) return
|
|
250
|
+
const snap = snaps.get(pageKey(step.page || step.page_url))
|
|
251
|
+
if (!snap) return
|
|
252
|
+
for (const loc of stepLocators(step)) {
|
|
253
|
+
const slice = sliceFor(snap, loc, `step ${i + 1}`, SLICE_LINES)
|
|
254
|
+
if (!slice) continue
|
|
255
|
+
const key = `${pageKey(step.page || step.page_url)}|${slice[0]}`
|
|
256
|
+
const earlier = shown.get(key)
|
|
257
|
+
out.set(i + 1, earlier ? [` on the page: in the same section as step ${earlier}`] : [" on the page:", " ```yaml", ...slice.map((l) => ` ${l}`), " ```"])
|
|
258
|
+
if (!earlier) shown.set(key, i + 1)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
return out
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The overlays the run closed, in order. They often show together (a sign-in prompt over a cookie banner), and a
|
|
267
|
+
* click on one is blocked while another covers it, so the test closes them in this order from one handler.
|
|
268
|
+
*/
|
|
269
|
+
function overlaysSection(steps: Step[]): string[] {
|
|
270
|
+
const closes = steps.filter((s) => s.op === "click" && s.overlay)
|
|
271
|
+
if (!closes.length) return []
|
|
272
|
+
const lines = closes.map((s, i) => {
|
|
273
|
+
const o = s.overlay!
|
|
274
|
+
const [best] = verifiedLocators(s.locators)
|
|
275
|
+
const container = o.id ? ` (container #${o.id})` : ""
|
|
276
|
+
return `${i + 1}. ${o.kind || "overlay"}${o.name ? ` "${o.name}"` : ""}${container}: close with ${best ? playwrightOf(best) : s.best_locator || s.label}`
|
|
277
|
+
})
|
|
278
|
+
return [
|
|
279
|
+
"## Overlays this run closed",
|
|
280
|
+
"",
|
|
281
|
+
"These covered the page and the run closed them in this order (the one on top first); the replay closed them the same way, as plain steps, and passed. In the test, write one helper that closes each of them that is visible, in this order, and call it after every navigation and right before each step that opens something (a date picker, a menu): an overlay can come back after a click. Use plain visibility checks and clicks; locator handlers and combined (or) locators block each other when two overlays show at once.",
|
|
282
|
+
"",
|
|
283
|
+
...lines,
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The Playwright options of the replay pass that reached the run's final page: the test uses the same, so it gets the same page. */
|
|
288
|
+
function launchSection(b: BrowserProfile | undefined, replay: Replay | null | undefined): string[] {
|
|
289
|
+
const pass = replay?.passes.find((p) => p.reached)
|
|
290
|
+
if (!b || !pass) return []
|
|
291
|
+
const { launch, context } = replayOptions(b, pass.setup)
|
|
292
|
+
return [
|
|
293
|
+
"## Browser options that reached the run's page",
|
|
294
|
+
"",
|
|
295
|
+
"The replay reached the run's final page with these Playwright options. Set the same in the repository's browser configuration (launch options, browser context options, run settings) before the first run, so the test gets the same version of each page (language, currency, layout) as the run:",
|
|
296
|
+
"",
|
|
297
|
+
"```json",
|
|
298
|
+
JSON.stringify({ launch, context }, null, 2),
|
|
299
|
+
"```",
|
|
300
|
+
]
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function browserSection(b: BrowserProfile): string[] {
|
|
304
|
+
const headers = Object.keys(b.extra_http_headers || {})
|
|
305
|
+
const lines = [
|
|
306
|
+
b.driver ? `- Driver: ${b.driver}` : "",
|
|
307
|
+
`- Browser: ${[b.version, b.channel ? `channel "${b.channel}"` : "", b.executable].filter(Boolean).join(", ") || "unknown"}`,
|
|
308
|
+
b.headless != null ? `- Mode: ${b.headless ? "headless" : "headed"}` : "",
|
|
309
|
+
b.user_agent ? `- User agent: ${b.user_agent}` : "",
|
|
310
|
+
b.webdriver != null ? `- navigator.webdriver: ${b.webdriver}` : "",
|
|
311
|
+
b.removed_default_args?.length ? `- Default launch switches removed: ${b.removed_default_args.join(" ")}` : "",
|
|
312
|
+
`- Extra HTTP headers: ${headers.length ? headers.join(", ") : "none"}`,
|
|
313
|
+
[b.language && `language ${b.language}`, b.timezone && `time zone ${b.timezone}`, b.viewport?.length === 2 && `viewport ${b.viewport[0]}×${b.viewport[1]}`]
|
|
314
|
+
.filter(Boolean)
|
|
315
|
+
.join(", "),
|
|
316
|
+
]
|
|
317
|
+
return lines.filter(Boolean).map((l) => (l.startsWith("- ") ? l : `- ${l[0].toUpperCase()}${l.slice(1)}`))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function elementRow(e: MemoryElement): string {
|
|
321
|
+
const label = e.name || e.text || e.attributes?.placeholder || e.attributes?.["aria-label"] || ""
|
|
322
|
+
const others = e.locators
|
|
323
|
+
.slice(1)
|
|
324
|
+
.filter((l) => l.confidence >= MIN_CONFIDENCE)
|
|
325
|
+
.slice(0, 2)
|
|
326
|
+
.map((l) => `${l.playwright} (${l.confidence}%)`)
|
|
327
|
+
.join("; ")
|
|
328
|
+
const used = e.interactions ? `used ${e.interactions}×` : ""
|
|
329
|
+
return `| ${e.confidence}% | ${e.tag}${label ? ` "${label.slice(0, 40)}"` : ""} | ${used} | ${e.best || ""} | ${others} |`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A visited page's other strong elements; the ones the steps acted on are listed with the steps. */
|
|
333
|
+
function pageSection(page: MemoryPage, inSteps: Set<string>): string[] {
|
|
334
|
+
const total = Object.keys(page.elements).length
|
|
335
|
+
const elements = Object.values(page.elements)
|
|
336
|
+
.filter((e) => !(e.best && inSteps.has(e.best)) && (e.interactions || e.confidence >= 70))
|
|
337
|
+
.sort((a, b) => (b.interactions || 0) - (a.interactions || 0) || b.confidence - a.confidence)
|
|
338
|
+
const lines = [
|
|
339
|
+
`### ${page.host}${page.path}${page.title ? ` — ${page.title}` : ""}`,
|
|
340
|
+
`${page.visits || 0} visits, last seen ${page.last_seen || "?"}, ${total} elements. File: ${page.file}`,
|
|
341
|
+
]
|
|
342
|
+
const shown = Math.min(elements.length, MAX_ELEMENTS)
|
|
343
|
+
if (shown) lines.push("", "| conf | element | | best locator | other locators |", "|---|---|---|---|---|", ...elements.slice(0, shown).map(elementRow))
|
|
344
|
+
if (total > shown) lines.push("", shown ? `${total - shown} more elements in the file.` : "The steps above use this page's elements; the others are in the file.")
|
|
345
|
+
return lines
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** A check whose element the engine found and verified on the live page: its locator, its container, and whether it is live data. */
|
|
349
|
+
function capturedCheckLine(c: Check, n: number): string | null {
|
|
350
|
+
const [best] = verifiedLocators(c.element?.locators)
|
|
351
|
+
if (c.item) {
|
|
352
|
+
// A check about items in a list: every item, located by what repeats, not by this run's values.
|
|
353
|
+
const inside = c.item.inside ? `; inside each: page.locator(${JSON.stringify(c.item.inside)})` : best ? `; the element: ${playwrightOf(best)}` : ""
|
|
354
|
+
return [
|
|
355
|
+
`${n}. ${c.expected} (the page showed ${quote(c.text)} in one of them)`,
|
|
356
|
+
` Each item: page.locator(${JSON.stringify(c.item.selector)}) (${c.item.count} on the page during the run)${inside}. Assert it for the items the expected result names, not this run's values.`,
|
|
357
|
+
].join("\n")
|
|
358
|
+
}
|
|
359
|
+
if (!best) return null
|
|
360
|
+
const live = c.dynamic ? " Live data: assert what the expected result says holds, not this value." : ""
|
|
361
|
+
const lines = [`${n}. ${c.expected} (the page showed ${quote(c.text)}): ${playwrightOf(best)} ✓${live}`]
|
|
362
|
+
const [box] = verifiedLocators(c.container?.locators)
|
|
363
|
+
if (box) lines.push(` Inside ${c.container?.tag || "its container"}${c.container?.name ? ` "${c.container.name}"` : ""}: ${playwrightOf(box)} ✓ (assert what the section holds inside it)`)
|
|
364
|
+
return lines.join("\n")
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function checkLine(c: Check | CheckResult, n: number): string {
|
|
368
|
+
const captured = capturedCheckLine(c, n)
|
|
369
|
+
if (captured) return captured
|
|
370
|
+
const head = `${n}. ${c.expected} (the page showed ${quote(c.text)})${c.dynamic ? " (live data: assert the rule, not this value)" : ""}`
|
|
371
|
+
if (!("matches" in c)) return head
|
|
372
|
+
if (!c.matches || !c.locator) return `${head}: not found on the replay's last page; find its element in the page snapshot or your live browser.`
|
|
373
|
+
if (c.field) return `${head}: a form field holds it: ${c.locator} (assert its value)`
|
|
374
|
+
const where = c.where?.length ? `; visible: ${c.where.join(", ")}` : ""
|
|
375
|
+
const line = `${head}: ${c.locator} (${c.matches} ${c.matches === 1 ? "match" : "matches"}${where})`
|
|
376
|
+
const s = c.section
|
|
377
|
+
if (!s) return line
|
|
378
|
+
const what = `${s.links} ${s.links === 1 ? "link" : "links"}, ${s.items} list ${s.items === 1 ? "item" : "items"}`
|
|
379
|
+
return `${line}\n Its section (${s.how}, a ${s.tag}): ${s.locator || "no stable locator; reach it from the heading"} (${what})`
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The browser agent's checks, with the locator the replay found for each when it ran. */
|
|
383
|
+
function checksSection(checks: Check[], replay?: Replay | null): string[] {
|
|
384
|
+
if (!checks.length) return []
|
|
385
|
+
const pass = replay?.passes.find((p) => p.reached && p.checks?.length) || replay?.passes.find((p) => p.checks?.length)
|
|
386
|
+
const found = pass?.checks?.length === checks.length ? pass.checks : undefined
|
|
387
|
+
// The element the engine verified on the live page wins over the replay's text match.
|
|
388
|
+
const merged = found ? found.map((r, i) => ({ ...checks[i], ...r, element: checks[i]?.element, container: checks[i]?.container, dynamic: checks[i]?.dynamic, item: checks[i]?.item })) : checks
|
|
389
|
+
const captured = checks.every((c) => c.item || verifiedLocators(c.element?.locators).length)
|
|
390
|
+
return [
|
|
391
|
+
"## Checks the browser agent made",
|
|
392
|
+
"",
|
|
393
|
+
captured
|
|
394
|
+
? "The results the goal asks to check, each with the locator of the element that showed it (✓: found exactly that element on the live page during the run) and the section it sits in. Assert each with that locator, and what a section holds inside the section locator. Add no other assertions."
|
|
395
|
+
: found
|
|
396
|
+
? "The results the goal asks to check, with the text the page showed and the Playwright locator that matched that text on the replay's last page. Assert each of them with that locator (scope it to the container named when there are several matches); where the goal asks about what a section holds (its links, items, or a value in it), assert it inside the section given under the heading. Add no other assertions."
|
|
397
|
+
: "The results the goal asks to check, with the text the page showed. Assert each of them, and add no other assertions.",
|
|
398
|
+
"The text comes from one run: where it is live data (a price, a count, a date that moves), assert what the expected result says holds, not that value.",
|
|
399
|
+
"",
|
|
400
|
+
...merged.map((c, i) => checkLine(c, i + 1)),
|
|
401
|
+
]
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The run found a functional defect: the test asserts the correct behaviour and is marked as an expected failure. */
|
|
405
|
+
export type KnownDefect = { reason: string; bug?: { label: string; url?: string } }
|
|
406
|
+
|
|
407
|
+
export function buildHandoff(runDir: string, memoryDir?: string, opts: { replay?: Replay | null; knownDefect?: KnownDefect } = {}): Handoff {
|
|
408
|
+
const run = readJson<RunJson>(path.join(runDir, "run.json"))
|
|
409
|
+
if (!run) throw new Error(`No run.json in ${runDir}. Point at a ForgeQE run directory.`)
|
|
410
|
+
const workflowFile = path.join(runDir, "workflow.yaml")
|
|
411
|
+
const workflow = existsSync(workflowFile) ? (YAML.parse(readFileSync(workflowFile, "utf8")) as { steps?: Step[]; checks?: Check[] }) : {}
|
|
412
|
+
const steps = workflow?.steps || []
|
|
413
|
+
const checks = (workflow?.checks || []).filter((c) => c && typeof c.text === "string" && c.text.trim())
|
|
414
|
+
const memDir = run.memory?.dir || memoryDir
|
|
415
|
+
const pages = memDir ? readPages(memDir) : []
|
|
416
|
+
const urls = [
|
|
417
|
+
...steps.flatMap((s) => [s.page, s.page_url, s.op === "navigate" ? s.url : undefined]),
|
|
418
|
+
run.final_url,
|
|
419
|
+
].filter((u): u is string => Boolean(u))
|
|
420
|
+
const seen = new Set<string>()
|
|
421
|
+
const visited: MemoryPage[] = []
|
|
422
|
+
for (const url of urls) {
|
|
423
|
+
const page = findPage(pages, url)
|
|
424
|
+
if (page && !seen.has(page.file)) {
|
|
425
|
+
seen.add(page.file)
|
|
426
|
+
visited.push(page)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const goal = run.goal || ""
|
|
431
|
+
const header = [
|
|
432
|
+
`Goal: ${goal}`,
|
|
433
|
+
`Result: ${run.success ? "passed" : opts.knownDefect ? "failed: the application has a functional defect (below); every step was done" : "failed"}`,
|
|
434
|
+
run.summary
|
|
435
|
+
? `What the browser agent reported (values from this one run on live data; check them as rules that hold on every run, not as expected values): ${run.summary}`
|
|
436
|
+
: "",
|
|
437
|
+
run.judge?.reasoning ? `Judge's check of the trace: ${run.judge.reasoning}` : "",
|
|
438
|
+
run.start_url ? `Start URL: ${run.start_url}` : "",
|
|
439
|
+
run.final_url
|
|
440
|
+
? `Final URL (the site built it during this run, with its own tracking and session parameters; reach it through the page, do not open it directly): ${run.final_url}`
|
|
441
|
+
: "",
|
|
442
|
+
`Run artifacts: ${runDir} (run.json, workflow.yaml, trace.md)`,
|
|
443
|
+
]
|
|
444
|
+
const md: string[] = ["# Verified browser run", "", ...header.filter(Boolean), ""]
|
|
445
|
+
if (run.browser) {
|
|
446
|
+
md.push(
|
|
447
|
+
"## Browser ForgeQE used",
|
|
448
|
+
"",
|
|
449
|
+
...browserSection(run.browser),
|
|
450
|
+
"",
|
|
451
|
+
"When the test's browser gets a different page, compare it with this one first.",
|
|
452
|
+
"",
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
const testCase = readJson<TestCase>(path.join(runDir, "testcase.json"))
|
|
456
|
+
if (testCase) {
|
|
457
|
+
md.push(caseMarkdown(testCase), "")
|
|
458
|
+
md.push(
|
|
459
|
+
"## Mapping the test to its test case",
|
|
460
|
+
"",
|
|
461
|
+
`Name the test "${testCase.id} ${testCase.title}" and tag or annotate it with the test case id ${testCase.id}${testCase.url ? ` and its link ${testCase.url}` : ""}, using the framework's own mechanism (Playwright's tag and annotation options, a pytest marker, an NUnit Property or Category, a JUnit Tag), so the test and the manual test case can be found from each other. Give the test's full name, as the framework reports it, in test_name.`,
|
|
462
|
+
"",
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
if (opts.knownDefect) {
|
|
466
|
+
const d = opts.knownDefect
|
|
467
|
+
const bug = d.bug ? `${d.bug.label}${d.bug.url ? ` (${d.bug.url})` : ""}` : testCase ? `the defect found in test case ${testCase.id}` : "the defect"
|
|
468
|
+
md.push(
|
|
469
|
+
"## Known defect: write the test for the correct behaviour",
|
|
470
|
+
"",
|
|
471
|
+
`The browser agent did every step, and the application did not do what the test case expects: ${d.reason}`,
|
|
472
|
+
bug === "the defect" ? "" : `It is tracked as ${bug}.`,
|
|
473
|
+
"",
|
|
474
|
+
"Write the test from the steps below as for any run, and assert the test case's expected results, the correct behaviour, not what the application shows now. Then mark the test as an expected failure with the framework's own mechanism, naming the defect: Playwright `test.fail()` with an annotation of type issue, pytest `@pytest.mark.xfail(strict=True, reason=...)`, JUnit 5 or NUnit with the repository's convention for known failures. It then passes while the defect is there and fails once the defect is fixed, which tells the team to remove the marker. When the framework has no expected-failure mechanism, skip the test with the defect as the reason.",
|
|
475
|
+
"ForgeQE runs it: the run passes when the marked assertion fails for the defect's reason. Do not weaken the assertion to make it pass.",
|
|
476
|
+
"",
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
md.push("## The path the browser agent took, in order", "")
|
|
480
|
+
const leftOut = run.memory?.left_out || []
|
|
481
|
+
if (leftOut.length) {
|
|
482
|
+
md.push(
|
|
483
|
+
`Only actions that changed the page are listed. ${leftOut.length} ${leftOut.length === 1 ? "action" : "actions"} that changed nothing (clicks that missed, scripts that only read the page) ${leftOut.length === 1 ? "was" : "were"} left out; the full trace is in trace.md and steps.json.`,
|
|
484
|
+
"",
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
if (steps.some((s) => verifiedLocators(s.locators).length)) {
|
|
488
|
+
md.push("Locators marked ✓ found exactly the acted-on element (the control itself, not an icon inside it) on the live page while the run was there.", "")
|
|
489
|
+
}
|
|
490
|
+
if (steps.some((s) => s.note)) {
|
|
491
|
+
md.push(
|
|
492
|
+
"Each \"Browser agent\" line is the agent's own note from the step that performed the steps under it: what it saw on the page and what it set out to do. It tells the flow the goal asks for apart from what this run met on the way, such as a pop-up or banner that covered the page and the steps redone after it.",
|
|
493
|
+
"",
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
const slices = stepSlices(steps, opts.replay)
|
|
497
|
+
if (slices.size) {
|
|
498
|
+
md.push(
|
|
499
|
+
"Under a step, \"on the page\" is its element in Playwright's snapshot of that page from ForgeQE's replay (roles and accessible names): the section it sits in and its neighbours, the element marked. Use it to choose locators and assertions instead of opening the page.",
|
|
500
|
+
"",
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
md.push(...stepsSection(steps, slices))
|
|
504
|
+
const overlays = overlaysSection(steps)
|
|
505
|
+
if (overlays.length) md.push("", ...overlays)
|
|
506
|
+
if (opts.replay) md.push("", ...replaySection(opts.replay))
|
|
507
|
+
const launch = launchSection(run.browser, opts.replay)
|
|
508
|
+
if (launch.length) md.push("", ...launch)
|
|
509
|
+
if (checks.length) md.push("", ...checksSection(checks, opts.replay))
|
|
510
|
+
const snapshots = Boolean(opts.replay?.passes.some((p) => p.pages?.length))
|
|
511
|
+
// Page memory is for finding elements the handoff does not give; with every step and check verified it is noise.
|
|
512
|
+
const elementSteps = steps.filter((s) => ["click", "input", "select"].includes(s.op || ""))
|
|
513
|
+
const allVerified =
|
|
514
|
+
elementSteps.every((s) => verifiedLocators(s.locators).length) && checks.every((c) => c.item || verifiedLocators(c.element?.locators).length)
|
|
515
|
+
if (visited.length && !snapshots && !allVerified) {
|
|
516
|
+
md.push(
|
|
517
|
+
"",
|
|
518
|
+
"## Pages and elements from ForgeQE page memory",
|
|
519
|
+
"",
|
|
520
|
+
"Other elements on each page, for assertions (the ones the steps used are listed with the steps). Confidence reflects how uniquely and reliably each locator matched across all runs on that page.",
|
|
521
|
+
"",
|
|
522
|
+
)
|
|
523
|
+
const inSteps = new Set(steps.map((s) => s.best_locator || "").filter(Boolean))
|
|
524
|
+
for (const page of visited.slice(0, MAX_PAGES)) md.push(...pageSection(page, inSteps), "")
|
|
525
|
+
if (memDir) md.push(`All pages: ${path.join(memDir, "pages")}`)
|
|
526
|
+
}
|
|
527
|
+
const markdown = md.filter((l, i, all) => l !== "" || all[i - 1] !== "").join("\n")
|
|
528
|
+
const file = path.join(runDir, "handoff.md")
|
|
529
|
+
writeFileSync(file, `${markdown}\n`)
|
|
530
|
+
return {
|
|
531
|
+
goal,
|
|
532
|
+
success: Boolean(run.success),
|
|
533
|
+
summary: run.summary || "",
|
|
534
|
+
finalUrl: run.final_url || "",
|
|
535
|
+
markdown,
|
|
536
|
+
file,
|
|
537
|
+
steps: steps.length,
|
|
538
|
+
pages: snapshots || allVerified ? 0 : Math.min(visited.length, MAX_PAGES),
|
|
539
|
+
checks: checks.length,
|
|
540
|
+
}
|
|
541
|
+
}
|