@capagents/forgeqe 0.0.0-stage → 1.0.0

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 +757 -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 +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,772 @@
1
+ import { spawn } from "node:child_process"
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import YAML from "yaml"
6
+
7
+ import { withoutNames } from "../childEnv.ts"
8
+ import { withoutTelemetry } from "../privacy.ts"
9
+ import { redactSecrets, sessionLoginEnv } from "../sessionLogins.ts"
10
+ import { toolsDir, type Log } from "../tools.ts"
11
+ import type { BrowserProfile, Check, CheckResult, Locator, Step } from "./handoff.ts"
12
+
13
+ /**
14
+ * The replay check: before Scribe starts, ForgeQE runs the browser agent's recorded steps as a Playwright script,
15
+ * so Scribe learns which steps work in the library its test uses, and where one fails, what the page showed there.
16
+ */
17
+
18
+ export const PLAYWRIGHT_VERSION = "1.63.0"
19
+ const STEP_TIMEOUT_MS = 10_000
20
+ const NAVIGATION_TIMEOUT_MS = 30_000
21
+ const PASS_TIMEOUT_MS = 180_000
22
+ /** The replay stops after this many failed steps in a row; the steps after them depend on those. */
23
+ const MAX_MISSES = 3
24
+ const MAX_ALTERNATIVES = 4
25
+
26
+ export type Setup = "playwright" | "forgeqe"
27
+
28
+ export type ReplayStep = {
29
+ n: number
30
+ action: string
31
+ ok: boolean
32
+ /** Not replayed (an action with no Playwright equivalent), or not run because the steps before it failed. */
33
+ skipped?: boolean
34
+ error?: string
35
+ ms?: number
36
+ url?: string
37
+ screenshot?: string
38
+ snapshot?: string
39
+ /** On a failure: how many elements each of the step's other recorded locators matched at that moment. */
40
+ alternatives?: { locator: string; matches: number }[]
41
+ /** On a failure: console errors, failed requests, and HTTP errors while the step ran. */
42
+ events?: string[]
43
+ }
44
+
45
+ export type ReplayPass = {
46
+ setup: Setup
47
+ steps: ReplayStep[]
48
+ webdriver: boolean | null
49
+ finalUrl: string
50
+ error: string
51
+ /** Every replayed step worked and the last page is the run's final page. */
52
+ ok: boolean
53
+ /** The last page has the same host and path as the run's final page. */
54
+ reached: boolean
55
+ ms: number
56
+ /** Playwright's page snapshot of each page the replay visited, as the replay left it, in visiting order. */
57
+ pages?: { url: string; snapshot: string }[]
58
+ /** The browser agent's checks, looked up on the page the replay ended on. */
59
+ checks?: CheckResult[]
60
+ }
61
+
62
+ export type Replay = {
63
+ dir: string
64
+ /** The replayed steps as a Playwright script: the reference Scribe ports into the repository's framework. */
65
+ script: string
66
+ steps: number
67
+ passes: ReplayPass[]
68
+ /** The replay could not run at all (no steps, Playwright could not be installed, the browser did not start). */
69
+ error?: string
70
+ }
71
+
72
+ export const SETUP_LABEL: Record<Setup, string> = {
73
+ playwright: "Playwright's default launch",
74
+ forgeqe: "without Playwright's automation switches (--enable-automation left out, --disable-blink-features=AutomationControlled added; navigator.webdriver is false, as in ForgeQE's browser)",
75
+ }
76
+
77
+ const q = (s: unknown) => JSON.stringify(String(s ?? ""))
78
+
79
+ /** A Playwright locator expression for one recorded locator, built from its type and value. */
80
+ export function locatorExpr(l: Locator): string | null {
81
+ const v = l.value || ""
82
+ if (!v) return null
83
+ switch (l.type) {
84
+ case "test_id":
85
+ // An attribute selector, not getByTestId: a repository can point getByTestId at another attribute.
86
+ return `page.locator(${q(`[${l.attr || "data-testid"}=${JSON.stringify(v)}]`)})`
87
+ case "id":
88
+ return `page.locator(${q(l.css || `[id=${JSON.stringify(v)}]`)})`
89
+ case "role": {
90
+ const i = v.indexOf("|")
91
+ const role = i < 0 ? v : v.slice(0, i)
92
+ const name = i < 0 ? "" : v.slice(i + 1)
93
+ return name ? `page.getByRole(${q(role)}, { name: ${q(name)}, exact: true })` : `page.getByRole(${q(role)})`
94
+ }
95
+ case "name":
96
+ case "href":
97
+ return l.css ? `page.locator(${q(l.css)})` : null
98
+ case "label":
99
+ return `page.getByLabel(${q(v)}, { exact: true })`
100
+ case "placeholder":
101
+ return `page.getByPlaceholder(${q(v)}, { exact: true })`
102
+ case "alt":
103
+ return `page.getByAltText(${q(v)}, { exact: true })`
104
+ case "title":
105
+ return `page.getByTitle(${q(v)}, { exact: true })`
106
+ case "text":
107
+ return `page.getByText(${q(v)}, { exact: true })`
108
+ case "css":
109
+ return `page.locator(${q(v)})`
110
+ case "xpath":
111
+ return `page.locator(${q(`xpath=${v}`)})`
112
+ default:
113
+ return null
114
+ }
115
+ }
116
+
117
+ function describe(step: Step): string {
118
+ const target = step.label || step.locator?.name || "element"
119
+ switch (step.op) {
120
+ case "navigate":
121
+ return `navigate ${step.url}`
122
+ case "click":
123
+ return `click ${q(target)}`
124
+ case "input":
125
+ return `fill ${q(target)} with ${step.totp_env ? `a one-time code from ${step.totp_env}` : step.text_env ? `process.env.${step.text_env}` : q(step.text)}`
126
+ case "select":
127
+ return `select ${q(step.text)} in ${q(target)}`
128
+ case "press":
129
+ return `press ${step.keys}`
130
+ case "wait":
131
+ return `wait ${step.seconds || 1}s`
132
+ case "scroll":
133
+ return `scroll ${step.down === false ? "up" : "down"} ${step.pages || 1} page(s)`
134
+ case "back":
135
+ return "go back"
136
+ case "evaluate":
137
+ return "run the agent's JavaScript"
138
+ default:
139
+ return `${step.op || "unknown"}${step.action_keys?.length ? ` (${step.action_keys.join(", ")})` : ""}`
140
+ }
141
+ }
142
+
143
+ /** The body of one step in the script, or null when it has no Playwright equivalent. */
144
+ function stepCode(step: Step): string | null {
145
+ const loc = step.locators?.map(locatorExpr).find(Boolean) || null
146
+ switch (step.op) {
147
+ case "navigate":
148
+ return step.url ? `(page) => page.goto(${q(step.url)})` : null
149
+ case "click":
150
+ return loc ? `(page) => ${loc}.click()` : null
151
+ case "input": {
152
+ if (!loc) return null
153
+ const value = step.totp_env ? `totp(${q(step.totp_env)})` : step.text_env ? `env(${q(step.text_env)})` : q(step.text)
154
+ return [
155
+ "async (page) => {",
156
+ ` const field = ${loc}`,
157
+ ...(step.clear ? [' await field.fill("")'] : []),
158
+ ` await field.pressSequentially(${value}, { delay: 40 })`,
159
+ " }",
160
+ ].join("\n")
161
+ }
162
+ case "select":
163
+ return loc ? `(page) => ${loc}.selectOption(${q(step.text)})` : null
164
+ case "press":
165
+ return step.keys ? `(page) => page.keyboard.press(${q(step.keys)})` : null
166
+ case "wait":
167
+ return `(page) => page.waitForTimeout(${Math.round(Number(step.seconds || 1) * 1000)})`
168
+ case "scroll":
169
+ return `(page) => page.mouse.wheel(0, ${step.down === false ? -1 : 1} * ${Number(step.pages || 1)} * (page.viewportSize()?.height || 800))`
170
+ case "back":
171
+ return "(page) => page.goBack()"
172
+ case "evaluate":
173
+ return step.code ? `(page) => page.evaluate(${q(step.code)})` : null
174
+ default:
175
+ return null
176
+ }
177
+ }
178
+
179
+ /** The replay script (the reference for Scribe) and, apart, each step's other locators for diagnosing a failure. */
180
+ export function replayScript(steps: Step[]): { script: string; alternatives: string } {
181
+ const lines = [
182
+ "// ForgeQE's replay of the browser agent's steps in Playwright: a reference for the test, not the test.",
183
+ "// step(n, action, run) runs one step on the current page; env(NAME) reads a secret, totp(NAME) makes a one-time code.",
184
+ "export default async function flow(step, { env, totp }) {",
185
+ ]
186
+ const alternatives: string[] = ["export default {"]
187
+ steps.forEach((s, i) => {
188
+ const n = i + 1
189
+ const code = stepCode(s)
190
+ lines.push(code ? ` await step(${n}, ${q(describe(s))}, ${code})` : ` await step(${n}, ${q(`not replayed: ${describe(s)}`)}, null)`)
191
+ const others = (s.locators || []).slice(1, 1 + MAX_ALTERNATIVES).flatMap((l) => {
192
+ const expr = locatorExpr(l)
193
+ return expr ? [`[${q(`${l.type}: ${l.value}`)}, (page) => ${expr}]`] : []
194
+ })
195
+ if (others.length) alternatives.push(` ${n}: [${others.join(", ")}],`)
196
+ })
197
+ lines.push("}")
198
+ alternatives.push("}")
199
+ return { script: `${lines.join("\n")}\n`, alternatives: `${alternatives.join("\n")}\n` }
200
+ }
201
+
202
+ /** Launch and context options for one setup, from the run's browser. */
203
+ export function replayOptions(b: BrowserProfile, setup: Setup): { launch: Record<string, unknown>; context: Record<string, unknown> } {
204
+ const launch: Record<string, unknown> = { headless: Boolean(b.headless) }
205
+ if (b.channel) launch.channel = b.channel
206
+ else if (b.executable && existsSync(b.executable)) launch.executablePath = b.executable
207
+ else launch.channel = "chrome"
208
+ if (setup === "forgeqe") {
209
+ launch.ignoreDefaultArgs = ["--enable-automation"]
210
+ launch.args = ["--disable-blink-features=AutomationControlled"]
211
+ }
212
+ const context: Record<string, unknown> = {}
213
+ if (b.viewport?.length === 2) context.viewport = { width: b.viewport[0], height: b.viewport[1] }
214
+ if (b.language) context.locale = b.language
215
+ if (b.timezone) context.timezoneId = b.timezone
216
+ if (b.user_agent) context.userAgent = b.user_agent
217
+ if (b.extra_http_headers && Object.keys(b.extra_http_headers).length) context.extraHTTPHeaders = b.extra_http_headers
218
+ return { launch, context }
219
+ }
220
+
221
+ /** Runs flow.mjs in one browser setup and writes what each step did to the result file. */
222
+ function runnerSource(playwright: string): string {
223
+ return `import { createHmac } from "node:crypto"
224
+ import { readFileSync, writeFileSync } from "node:fs"
225
+ import path from "node:path"
226
+ import { chromium } from ${JSON.stringify(playwright)}
227
+
228
+ const config = JSON.parse(readFileSync(process.argv[2], "utf8"))
229
+ const { default: flow } = await import(config.flow)
230
+ const { default: alternatives } = await import(config.alternatives)
231
+ const out = { steps: [], webdriver: null, finalUrl: "", error: "" }
232
+ const save = () => writeFileSync(config.result, JSON.stringify(out, null, 2))
233
+ const PROGRESS = /^(attempting .* action|retrying .* action|scrolling into view if needed|done scrolling|element is visible, enabled and stable|waiting for element to be visible, enabled and stable|waiting \\d+ms)$/
234
+ const firstLines = (e) => {
235
+ const seen = new Set()
236
+ for (const raw of String(e?.message || e).replace(/\\u001b\\[[0-9;]*m/g, "").split("\\n")) {
237
+ const line = raw.trim().replace(/^\\d+ × /, "").replace(/^- /, "").slice(0, 240)
238
+ if (line && line !== "Call log:" && !PROGRESS.test(line)) seen.add(line)
239
+ }
240
+ return [...seen].slice(0, 6).join(" | ")
241
+ }
242
+
243
+ const env = (name) => {
244
+ const v = process.env[name]
245
+ if (v === undefined || v === "") throw new Error(name + " is not set in the environment")
246
+ return v
247
+ }
248
+ const totp = (name) => {
249
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
250
+ const bits = [...env(name).replace(/[\\s=]/g, "").toUpperCase()].map((c) => alphabet.indexOf(c).toString(2).padStart(5, "0")).join("")
251
+ const key = Buffer.from((bits.match(/.{8}/g) || []).map((b) => parseInt(b, 2)))
252
+ const counter = Buffer.alloc(8)
253
+ counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30000)))
254
+ const h = createHmac("sha1", key).update(counter).digest()
255
+ return String((h.readUInt32BE(h[19] & 15) & 0x7fffffff) % 1000000).padStart(6, "0")
256
+ }
257
+
258
+ const q = JSON.stringify
259
+ const norm = (s) => String(s || "").replace(/\\s+/g, " ").trim().toLowerCase()
260
+ // The nearest container a test can scope to: a test id, a stable id, a label, or a table, list, or section with a class.
261
+ const container = (el) => {
262
+ const wraps = new Set(["TABLE", "SECTION", "ASIDE", "NAV", "FORM", "DIALOG", "HEADER", "FOOTER", "MAIN", "ARTICLE", "UL", "OL"])
263
+ for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {
264
+ const testId = e.getAttribute("data-testid")
265
+ if (testId) return '[data-testid="' + testId + '"]'
266
+ if (e.id && /^[A-Za-z][\\w-]*$/.test(e.id) && !/\\d{3,}/.test(e.id)) return "#" + e.id
267
+ const label = e.getAttribute("aria-label")
268
+ if (label) return e.tagName.toLowerCase() + '[aria-label="' + label + '"]'
269
+ if (wraps.has(e.tagName) && e.classList.length) return e.tagName.toLowerCase() + "." + e.classList[0]
270
+ }
271
+ return ""
272
+ }
273
+ // The part of the page a heading names: an element labelled by it, the section or region it heads, or else the
274
+ // element right after it (or after the wrapper that holds only the heading), with how many links and list items it has.
275
+ // It runs in the page, so it uses nothing from this script.
276
+ const sectionOf = (h) => {
277
+ const q = JSON.stringify
278
+ const stable = (e) => {
279
+ const testId = e.getAttribute("data-testid")
280
+ if (testId) return 'page.locator(' + q('[data-testid=' + q(testId) + ']') + ')'
281
+ if (e.id && /^[A-Za-z][\\w-]*$/.test(e.id) && !/\\d{3,}/.test(e.id)) return 'page.locator(' + q("#" + e.id) + ')'
282
+ const label = e.getAttribute("aria-label")
283
+ if (label) return 'page.locator(' + q(e.tagName.toLowerCase() + '[aria-label="' + label + '"]') + ')'
284
+ return ""
285
+ }
286
+ let s = h.id ? document.querySelector('[aria-labelledby~="' + CSS.escape(h.id) + '"]') : null
287
+ let how = "labelled by the heading"
288
+ if (!s) {
289
+ const box = h.closest("section, article, [role=region]")
290
+ if (box && box.querySelector("h1, h2, h3, h4, h5, h6, [role=heading]") === h) { s = box; how = "the " + box.tagName.toLowerCase() + " it heads" }
291
+ }
292
+ if (!s) {
293
+ let head = h
294
+ while (head.parentElement && head.parentElement !== document.body && head.parentElement.children.length === 1) head = head.parentElement
295
+ s = head.nextElementSibling
296
+ how = "the element right after the heading"
297
+ }
298
+ if (!s) return null
299
+ const visible = (e) => !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length)
300
+ return {
301
+ locator: stable(s),
302
+ how,
303
+ tag: s.tagName.toLowerCase(),
304
+ links: [...s.querySelectorAll("a[href]")].filter(visible).length,
305
+ items: [...s.querySelectorAll("li")].filter(visible).length,
306
+ }
307
+ }
308
+ // A check's text on the page: the first of these locators with a visible match, else a form field holding it.
309
+ const findCheck = async (page, c) => {
310
+ const t = c.text
311
+ const tries = [
312
+ ["page.getByRole(\\"heading\\", { name: " + q(t) + ", exact: true })", () => page.getByRole("heading", { name: t, exact: true })],
313
+ ["page.getByText(" + q(t) + ", { exact: true })", () => page.getByText(t, { exact: true })],
314
+ ["page.getByRole(\\"heading\\", { name: " + q(t) + " })", () => page.getByRole("heading", { name: t })],
315
+ ["page.getByText(" + q(t) + ")", () => page.getByText(t)],
316
+ ]
317
+ for (const [locator, make] of tries) {
318
+ const loc = make()
319
+ const matches = await loc.count().catch(() => 0)
320
+ const where = []
321
+ let shown = null
322
+ for (let i = 0; i < Math.min(matches, 3); i++) {
323
+ const el = loc.nth(i)
324
+ if (!(await el.isVisible().catch(() => false))) continue
325
+ shown = shown || el
326
+ const tag = await el.evaluate((e) => e.tagName.toLowerCase()).catch(() => "")
327
+ const inside = await el.evaluate(container).catch(() => "")
328
+ where.push(tag + (inside ? " in " + inside : ""))
329
+ }
330
+ if (!where.length) continue
331
+ const section = locator.includes("heading") ? await shown.evaluate(sectionOf).catch(() => null) : null
332
+ return { ...c, locator, matches, where, ...(section ? { section } : {}) }
333
+ }
334
+ const fields = page.locator("input:visible, textarea:visible, select:visible")
335
+ const count = Math.min(await fields.count().catch(() => 0), 60)
336
+ for (let i = 0; i < count; i++) {
337
+ const f = fields.nth(i)
338
+ if (!norm(await f.inputValue().catch(() => "")).includes(norm(t))) continue
339
+ const d = await f.evaluate((e) => ({ id: e.id, name: e.getAttribute("name"), label: e.getAttribute("aria-label"), testId: e.getAttribute("data-testid"), placeholder: e.getAttribute("placeholder") })).catch(() => ({}))
340
+ const locator = d.testId ? "page.locator(" + q("[data-testid=" + q(d.testId) + "]") + ")"
341
+ : d.id ? "page.locator(" + q(/^[A-Za-z][\\w-]*$/.test(d.id) ? "#" + d.id : "[id=" + q(d.id) + "]") + ")"
342
+ : d.label ? "page.getByLabel(" + q(d.label) + ")"
343
+ : d.name ? "page.locator(" + q("[name=" + q(d.name) + "]") + ")"
344
+ : d.placeholder ? "page.getByPlaceholder(" + q(d.placeholder) + ")" : ""
345
+ if (locator) return { ...c, locator, matches: 1, field: true }
346
+ }
347
+ return { ...c, matches: 0 }
348
+ }
349
+
350
+ let browser
351
+ try {
352
+ browser = await chromium.launch(config.launch)
353
+ const context = await browser.newContext(config.context)
354
+ context.setDefaultTimeout(config.stepTimeoutMs)
355
+ context.setDefaultNavigationTimeout(config.navigationTimeoutMs)
356
+ const events = []
357
+ const note = (text) => { if (!events.includes(text)) events.push(text) }
358
+ const bare = (u) => { try { const x = new URL(u); return x.origin + x.pathname } catch { return u } }
359
+ const watch = (p) => {
360
+ p.on("console", (m) => { if (m.type() === "error") note("console error: " + m.text().slice(0, 200)) })
361
+ p.on("requestfailed", (r) => note("request failed: " + r.method() + " " + bare(r.url()).slice(0, 120) + " (" + (r.failure()?.errorText || "") + ")"))
362
+ p.on("response", (r) => { if (r.status() >= 400) note("HTTP " + r.status() + ": " + r.request().method() + " " + bare(r.url()).slice(0, 120)) })
363
+ }
364
+ let page = await context.newPage()
365
+ watch(page)
366
+ context.on("page", (p) => { page = p; watch(p) })
367
+ out.webdriver = await page.evaluate(() => navigator.webdriver).catch(() => null)
368
+ const pages = new Map()
369
+ const snap = () => page.locator("body").ariaSnapshot({ timeout: 5000 }).catch(() => "")
370
+ // A page can change its URL at once and re-render a moment later: wait until two snapshots a second apart agree.
371
+ const settled = async () => {
372
+ let last = await snap()
373
+ for (let i = 0; i < 6; i++) {
374
+ await page.waitForTimeout(1000)
375
+ const next = await snap()
376
+ if (next && next === last) break
377
+ last = next
378
+ }
379
+ return last
380
+ }
381
+ const keepPage = async (settle = false) => {
382
+ let key = ""
383
+ try { const u = new URL(page.url()); if (u.protocol.startsWith("http")) key = u.host + u.pathname } catch {}
384
+ if (!key) return
385
+ const aria = settle ? await settled() : await snap()
386
+ if (aria) pages.set(key, aria)
387
+ }
388
+ const writePages = () => {
389
+ out.pages = [...pages].map(([url, aria], i) => {
390
+ const snapshot = path.join(config.outDir, config.setup + "-page-" + (i + 1) + ".aria.yml")
391
+ writeFileSync(snapshot, aria)
392
+ return { url, snapshot }
393
+ })
394
+ }
395
+ let misses = 0
396
+ const step = async (n, action, run) => {
397
+ if (!run) return void out.steps.push({ n, action, ok: true, skipped: true })
398
+ if (misses >= config.maxMisses) return void out.steps.push({ n, action, ok: false, skipped: true, error: "not run: the steps before it failed" })
399
+ const started = Date.now()
400
+ events.length = 0
401
+ const rec = { n, action, ok: true }
402
+ try {
403
+ await run(page)
404
+ await page.waitForLoadState("domcontentloaded", { timeout: 5000 }).catch(() => {})
405
+ misses = 0
406
+ await keepPage()
407
+ } catch (e) {
408
+ misses++
409
+ rec.ok = false
410
+ rec.error = firstLines(e)
411
+ const shot = path.join(config.outDir, config.setup + "-step-" + n + ".png")
412
+ if (await page.screenshot({ path: shot }).then(() => true, () => false)) rec.screenshot = shot
413
+ const aria = await page.locator("body").ariaSnapshot({ timeout: 5000 }).catch(() => "")
414
+ if (aria) {
415
+ rec.snapshot = path.join(config.outDir, config.setup + "-step-" + n + ".aria.yml")
416
+ writeFileSync(rec.snapshot, aria)
417
+ }
418
+ rec.alternatives = []
419
+ for (const [locator, make] of alternatives[n] || []) rec.alternatives.push({ locator, matches: await make(page).count().catch(() => -1) })
420
+ rec.events = events.slice(0, 6)
421
+ }
422
+ rec.ms = Date.now() - started
423
+ rec.url = page.url()
424
+ out.steps.push(rec)
425
+ save()
426
+ }
427
+ await flow(step, { env, totp })
428
+ if (config.finalPage) await page.waitForURL((u) => u.host + u.pathname === config.finalPage, { timeout: config.stepTimeoutMs }).catch(() => {})
429
+ await keepPage(true)
430
+ out.finalUrl = page.url()
431
+ writePages()
432
+ if (config.checks.length) {
433
+ out.checks = []
434
+ for (const c of config.checks) out.checks.push(await findCheck(page, c))
435
+ }
436
+ } catch (e) {
437
+ out.error = firstLines(e)
438
+ } finally {
439
+ save()
440
+ await browser?.close().catch(() => {})
441
+ }
442
+ `
443
+ }
444
+
445
+ function playwrightDir(): string {
446
+ return path.join(toolsDir(), `playwright-core-${PLAYWRIGHT_VERSION}`)
447
+ }
448
+
449
+ /** playwright-core's entry point, installed with Bun into ~/.forgeqe/tools on first use. It drives the installed Chrome. */
450
+ export async function ensurePlaywright(log: Log, bun: string = process.execPath): Promise<string> {
451
+ const dir = playwrightDir()
452
+ const entry = path.join(dir, "node_modules", "playwright-core", "index.mjs")
453
+ if (existsSync(entry)) return entry
454
+ log(`Installing Playwright ${PLAYWRIGHT_VERSION} for the replay check (one time).`)
455
+ mkdirSync(dir, { recursive: true })
456
+ writeFileSync(path.join(dir, "package.json"), `${JSON.stringify({ name: "forgeqe-playwright", private: true })}\n`)
457
+ const r = await new Promise<{ code: number | null; out: string }>((resolve) => {
458
+ const child = spawn(bun, ["add", `playwright-core@${PLAYWRIGHT_VERSION}`], { cwd: dir, env: withoutTelemetry(process.env), stdio: ["ignore", "pipe", "pipe"] })
459
+ let out = ""
460
+ child.stdout.on("data", (b) => (out += b))
461
+ child.stderr.on("data", (b) => (out += b))
462
+ child.on("error", (e) => resolve({ code: -1, out: e.message }))
463
+ child.on("close", (code) => resolve({ code, out }))
464
+ })
465
+ if (r.code !== 0 || !existsSync(entry)) {
466
+ rmSync(dir, { recursive: true, force: true })
467
+ throw new Error(`Could not install Playwright ${PLAYWRIGHT_VERSION}: ${r.out.trim().split("\n").slice(-3).join(" ")}`)
468
+ }
469
+ for (const old of readdirSync(toolsDir())) {
470
+ if (old.startsWith("playwright-core-") && old !== path.basename(dir)) rmSync(path.join(toolsDir(), old), { recursive: true, force: true })
471
+ }
472
+ return entry
473
+ }
474
+
475
+ function hostPath(url: string): string {
476
+ try {
477
+ const u = new URL(url)
478
+ return `${u.host}${u.pathname}`
479
+ } catch {
480
+ return url.split("?")[0]
481
+ }
482
+ }
483
+
484
+ function readJson<T>(file: string): T | null {
485
+ try {
486
+ return JSON.parse(readFileSync(file, "utf8")) as T
487
+ } catch {
488
+ return null
489
+ }
490
+ }
491
+
492
+ async function runPass(opts: {
493
+ dir: string
494
+ runner: string
495
+ setup: Setup
496
+ browser: BrowserProfile
497
+ finalUrl: string
498
+ checks: Check[]
499
+ signal?: AbortSignal
500
+ timeoutMs: number
501
+ hide: string[]
502
+ }): Promise<ReplayPass> {
503
+ const started = Date.now()
504
+ const result = path.join(opts.dir, `${opts.setup}.json`)
505
+ rmSync(result, { force: true })
506
+ const configFile = path.join(opts.dir, `${opts.setup}.config.json`)
507
+ writeFileSync(
508
+ configFile,
509
+ JSON.stringify({
510
+ ...replayOptions(opts.browser, opts.setup),
511
+ setup: opts.setup,
512
+ flow: path.join(opts.dir, "flow.mjs"),
513
+ alternatives: path.join(opts.dir, "alternatives.mjs"),
514
+ result,
515
+ outDir: opts.dir,
516
+ finalPage: opts.finalUrl ? hostPath(opts.finalUrl) : "",
517
+ stepTimeoutMs: STEP_TIMEOUT_MS,
518
+ navigationTimeoutMs: NAVIGATION_TIMEOUT_MS,
519
+ maxMisses: MAX_MISSES,
520
+ checks: opts.checks,
521
+ }),
522
+ )
523
+ const timedOut = await new Promise<boolean>((resolve) => {
524
+ const child = spawn(process.execPath, [opts.runner, configFile], {
525
+ cwd: opts.dir,
526
+ env: { ...withoutNames(process.env, opts.hide), ...sessionLoginEnv(), FORCE_COLOR: "0", NO_COLOR: "1" },
527
+ stdio: "ignore",
528
+ })
529
+ let late = false
530
+ const stop = () => child.kill("SIGKILL")
531
+ const timer = setTimeout(() => {
532
+ late = true
533
+ stop()
534
+ }, opts.timeoutMs)
535
+ opts.signal?.addEventListener("abort", stop, { once: true })
536
+ child.on("error", () => resolve(false))
537
+ child.on("close", () => {
538
+ clearTimeout(timer)
539
+ opts.signal?.removeEventListener("abort", stop)
540
+ resolve(late)
541
+ })
542
+ })
543
+ const raw = readJson<{ steps: ReplayStep[]; webdriver: boolean | null; finalUrl: string; error: string; pages?: ReplayPass["pages"]; checks?: CheckResult[] }>(result)
544
+ const out = raw || { steps: [], webdriver: null, finalUrl: "", error: "the replay wrote no result" }
545
+ if (timedOut) out.error = `stopped after ${Math.round(opts.timeoutMs / 1000)}s`
546
+ const steps = JSON.parse(redactSecrets(JSON.stringify(out.steps))) as ReplayStep[]
547
+ const pages = (out.pages || []).filter((p) => existsSync(p.snapshot))
548
+ for (const file of [...pages.map((p) => p.snapshot), ...steps.flatMap((s) => (s.snapshot && existsSync(s.snapshot) ? [s.snapshot] : []))]) {
549
+ writeFileSync(file, redactSecrets(readFileSync(file, "utf8")))
550
+ }
551
+ const reached = Boolean(out.finalUrl) && (!opts.finalUrl || hostPath(out.finalUrl) === hostPath(opts.finalUrl))
552
+ return {
553
+ setup: opts.setup,
554
+ steps,
555
+ webdriver: out.webdriver,
556
+ finalUrl: out.finalUrl,
557
+ error: redactSecrets(out.error || ""),
558
+ reached,
559
+ ok: !out.error && !timedOut && reached && steps.every((s) => s.ok),
560
+ ms: Date.now() - started,
561
+ ...(pages.length ? { pages } : {}),
562
+ ...(out.checks?.length ? { checks: JSON.parse(redactSecrets(JSON.stringify(out.checks))) as CheckResult[] } : {}),
563
+ }
564
+ }
565
+
566
+ function workflow(runDir: string): { steps?: Step[]; checks?: Check[] } {
567
+ const file = path.join(runDir, "workflow.yaml")
568
+ return existsSync(file) ? (YAML.parse(readFileSync(file, "utf8")) as { steps?: Step[]; checks?: Check[] }) || {} : {}
569
+ }
570
+
571
+ function recordedSteps(runDir: string): Step[] {
572
+ return workflow(runDir).steps || []
573
+ }
574
+
575
+ /** The results the browser agent recorded as checked, with the text the page showed for each. */
576
+ export function recordedChecks(runDir: string): Check[] {
577
+ try {
578
+ return (workflow(runDir).checks || []).filter((c) => c && typeof c.text === "string" && c.text.trim())
579
+ } catch {
580
+ return []
581
+ }
582
+ }
583
+
584
+ /** The run recorded something to replay beyond opening a page, or checks to look up on the page it ended on. */
585
+ export function canReplay(runDir: string): boolean {
586
+ try {
587
+ return recordedSteps(runDir).some((s) => s.op !== "navigate") || recordedChecks(runDir).length > 0
588
+ } catch {
589
+ return false
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Replay the run's steps in Playwright: first with Playwright's default launch, as a plain test gets it; when that
595
+ * does not reach the run's final page, again without Playwright's automation switches, as ForgeQE's browser runs. Files go to
596
+ * `<run>/replay/`: flow.mjs (the reference script), one result file per setup, and a screenshot and page snapshot
597
+ * for each failed step.
598
+ */
599
+ export async function replayRun(opts: {
600
+ runDir: string
601
+ signal?: AbortSignal
602
+ log?: Log
603
+ /** playwright-core's entry point; default: ensurePlaywright. */
604
+ playwright?: string
605
+ timeoutMs?: number
606
+ /** Variables the replay must not see: ForgeQE's own secrets (privateEnvNames). */
607
+ hide?: string[]
608
+ }): Promise<Replay> {
609
+ const log = opts.log || (() => {})
610
+ const dir = path.join(opts.runDir, "replay")
611
+ const script = path.join(dir, "flow.mjs")
612
+ const run = readJson<{ browser?: BrowserProfile; final_url?: string }>(path.join(opts.runDir, "run.json")) || {}
613
+ const steps = recordedSteps(opts.runDir)
614
+ const replay: Replay = { dir, script, steps: steps.length, passes: [] }
615
+ if (!canReplay(opts.runDir)) return { ...replay, error: "the run recorded no steps to replay" }
616
+ let playwright = opts.playwright
617
+ try {
618
+ playwright ||= await ensurePlaywright(log)
619
+ } catch (e) {
620
+ return { ...replay, error: (e as Error).message }
621
+ }
622
+ rmSync(dir, { recursive: true, force: true })
623
+ mkdirSync(dir, { recursive: true })
624
+ const files = replayScript(steps)
625
+ writeFileSync(script, files.script)
626
+ writeFileSync(path.join(dir, "alternatives.mjs"), files.alternatives)
627
+ const runner = path.join(dir, "runner.mjs")
628
+ writeFileSync(runner, runnerSource(playwright))
629
+ for (const setup of ["playwright", "forgeqe"] as const) {
630
+ if (opts.signal?.aborted) break
631
+ const pass = await runPass({
632
+ dir,
633
+ runner,
634
+ setup,
635
+ browser: run.browser || {},
636
+ finalUrl: run.final_url || "",
637
+ checks: recordedChecks(opts.runDir),
638
+ signal: opts.signal,
639
+ timeoutMs: opts.timeoutMs || PASS_TIMEOUT_MS,
640
+ hide: opts.hide || [],
641
+ })
642
+ replay.passes.push(pass)
643
+ log(passLine(pass, steps.length))
644
+ if (pass.reached || (pass.error && !pass.steps.length)) break
645
+ }
646
+ writeFileSync(path.join(dir, "replay.json"), `${JSON.stringify(replay, null, 2)}\n`)
647
+ return replay
648
+ }
649
+
650
+ /** The replay an earlier, cut-off job of this run finished, if any. */
651
+ export function savedReplay(runDir: string): Replay | null {
652
+ const r = readJson<Replay>(path.join(runDir, "replay", "replay.json"))
653
+ return r && Array.isArray(r.passes) ? r : null
654
+ }
655
+
656
+ const failedSteps = (p: ReplayPass) => p.steps.filter((s) => !s.ok && !s.skipped)
657
+
658
+ /** Some steps failed, yet every step after them ran and the script still reached the run's final page. */
659
+ function reachedPastFailures(p: ReplayPass): boolean {
660
+ return !p.ok && !p.error && p.reached && failedSteps(p).length > 0 && !p.steps.some((s) => s.skipped && !s.ok)
661
+ }
662
+
663
+ /** One line for the progress feed, e.g. `Replay in Playwright (default launch): 21 of 21 steps worked in 38s`. */
664
+ export function passLine(p: ReplayPass, total: number): string {
665
+ const worked = p.steps.filter((s) => s.ok && !s.skipped).length
666
+ const where = p.setup === "playwright" ? "default launch" : "without automation switches"
667
+ const secs = Math.round(p.ms / 1000)
668
+ if (p.error && !p.steps.length) return `Replay in Playwright (${where}) did not run: ${p.error}`
669
+ const failed = failedSteps(p).map((s) => s.n)
670
+ const end = !p.reached ? ", did not reach the run's final page" : reachedPastFailures(p) ? ", still reached the run's final page" : ""
671
+ const checks = p.checks?.length ? `; ${p.checks.filter((c) => c.matches > 0).length} of ${p.checks.length} checks found on the last page` : ""
672
+ return `Replay in Playwright (${where}): ${worked} of ${total} steps worked${failed.length ? `, step${failed.length === 1 ? "" : "s"} ${failed.join(", ")} failed` : ""}${end}${checks} in ${secs}s`
673
+ }
674
+
675
+ /** The replay has a failure the coding agent should look at in the live browser. */
676
+ export function replayFailed(r: Replay | null): boolean {
677
+ return Boolean(r && !r.error && r.passes.length && !r.passes[0].ok && !reachedPastFailures(r.passes[0]))
678
+ }
679
+
680
+ /** The dialogs open at that moment, from Playwright's page snapshot: a pop-up is the usual reason a click does not land. */
681
+ function openDialogs(snapshot: string): string[] {
682
+ return snapshot
683
+ .split("\n")
684
+ .map((l) => l.trim().match(/^- (?:'|")?(alertdialog|dialog)(?: "((?:[^"\\]|\\.)*)")?/))
685
+ .flatMap((m) => (m ? [`${m[1]}${m[2] ? ` "${m[2].slice(0, 100)}"` : ""}`] : []))
686
+ }
687
+
688
+ function stepDetail(s: ReplayStep): string[] {
689
+ const lines = [`- Step ${s.n}, ${s.action}: ${s.error}`]
690
+ if (s.url) lines.push(` Page: ${hostPath(s.url)}`)
691
+ if (s.alternatives?.length) lines.push(` This element's other locators at that moment: ${s.alternatives.map((a) => `${a.locator} matched ${a.matches < 0 ? "an error" : a.matches}`).join("; ")}`)
692
+ if (s.events?.length) lines.push(` While it ran: ${s.events.join(" | ")}`)
693
+ const files = [s.screenshot && `screenshot ${s.screenshot}`, s.snapshot && `page snapshot ${s.snapshot}`].filter(Boolean)
694
+ if (files.length) lines.push(` Saved: ${files.join(", ")}`)
695
+ const dialogs = s.snapshot && existsSync(s.snapshot) ? openDialogs(readFileSync(s.snapshot, "utf8")) : []
696
+ if (dialogs.length) lines.push(` Open on the page at that moment: ${dialogs.join(", ")}`)
697
+ return lines
698
+ }
699
+
700
+ function passSummary(p: ReplayPass, total: number): string {
701
+ if (p.error && !p.steps.length) return `it did not run: ${p.error}.`
702
+ const failed = failedSteps(p)
703
+ const notRun = p.steps.filter((s) => s.skipped && !s.ok).length
704
+ const parts = [
705
+ failed.length ? `${failed.length === 1 ? "step" : "steps"} ${failed.map((s) => s.n).join(", ")} failed` : `all ${total} steps worked`,
706
+ notRun ? `${notRun} after them were not run` : "",
707
+ p.error ? `it stopped: ${p.error}` : "",
708
+ p.reached ? `it reached the run's final page (${hostPath(p.finalUrl)})` : `it ended on ${p.finalUrl ? hostPath(p.finalUrl) : "no page"}, not the run's final page`,
709
+ `navigator.webdriver was ${p.webdriver}`,
710
+ ]
711
+ return `${parts.filter(Boolean).join("; ")} (${Math.round(p.ms / 1000)}s).`
712
+ }
713
+
714
+ /** The handoff's section on the replay: what worked in Playwright, and for a failed step, what the page showed. */
715
+ export function replaySection(r: Replay): string[] {
716
+ const lines = ["## Replay in Playwright", ""]
717
+ if (r.error || !r.passes.length) {
718
+ lines.push(`ForgeQE could not replay the steps in Playwright: ${r.error || "no result"}. Check the steps on the live page yourself.`)
719
+ return lines
720
+ }
721
+ const [first, second] = r.passes
722
+ const skipped = first.steps.filter((s) => s.skipped && s.ok)
723
+ lines.push(
724
+ `ForgeQE ran the steps above as a Playwright script (playwright-core ${PLAYWRIGHT_VERSION}, the run's browser channel, headless or headed, user agent, language, time zone, and viewport; ${STEP_TIMEOUT_MS / 1000}s per step). Script: ${r.script}`,
725
+ "",
726
+ `- ${SETUP_LABEL.playwright}: ${passSummary(first, r.steps)}`,
727
+ )
728
+ if (second) lines.push(`- ${SETUP_LABEL.forgeqe}: ${passSummary(second, r.steps)}`)
729
+ if (skipped.length) lines.push(`- Not replayed (no Playwright equivalent recorded): ${skipped.map((s) => `step ${s.n} (${s.action.replace(/^not replayed: /, "")})`).join(", ")}.`)
730
+ const pages = [second, first].find((p) => p?.reached && p.pages?.length)?.pages || first.pages || []
731
+ if (pages.length) {
732
+ lines.push(
733
+ `- Page snapshots (roles, accessible names, and text as Playwright sees them, each page as the replay left it; search them for the element you need): ${pages.map((p) => `${p.url}: ${p.snapshot}`).join("; ")}`,
734
+ )
735
+ }
736
+ lines.push("")
737
+ const port =
738
+ "Port its steps into the repository's framework and page objects with the same locators and waits, then add the goal's assertions (the handoff's checks, when it lists them). Steps that dealt with a pop-up or banner on this run (see the browser agent's notes) still belong in a handler, as the rules say. Steps that run the browser agent's JavaScript were how it looked around or read values, not steps to copy: where the JavaScript clicks or types, do that with a Playwright action on the same element; where it reads values, assert them with locators."
739
+ if (first.ok) {
740
+ lines.push(`The script works as it is. ${port}`)
741
+ return lines
742
+ }
743
+ const failed = failedSteps(first)
744
+ for (const s of failed) lines.push(...stepDetail(s))
745
+ if (reachedPastFailures(first)) {
746
+ const which = `Step${failed.length === 1 ? "" : "s"} ${failed.map((s) => s.n).join(", ")}`
747
+ lines.push(
748
+ "",
749
+ `${which} failed, but every step after ${failed.length === 1 ? "it" : "them"} worked and the script still reached the run's final page, so the rest of the script works as it is. Decide from the step and the browser agent's note whether the goal needs it: if it does, find its element in the page snapshot; if not (it changed nothing on the run), leave it out. ${port}`,
750
+ )
751
+ return lines
752
+ }
753
+ if (second?.ok) {
754
+ lines.push(
755
+ "",
756
+ "Without Playwright's automation switches every step worked, so the difference is the browser setup, not the steps. Put the same launch settings in the repository's browser configuration (Playwright: ignoreDefaultArgs [\"--enable-automation\"] and args [\"--disable-blink-features=AutomationControlled\"]; .NET: BrowserTypeLaunchOptions.IgnoreDefaultArgs and Args), for the whole suite.",
757
+ )
758
+ } else if (second) {
759
+ const again = failedSteps(second).map((s) => s.n)
760
+ lines.push(
761
+ "",
762
+ again.length
763
+ ? `Without the automation switches step${again.length === 1 ? "" : "s"} ${again.join(", ")} failed too, so the launch switches are not the cause.`
764
+ : "Without the automation switches the replay got further; compare the two results.",
765
+ )
766
+ }
767
+ lines.push(
768
+ "",
769
+ "The steps before the first failure work as written. Look at the failing steps first (the saved screenshot and page snapshot, and your live_browser_* tools on that page), change what each needs, and then write the test.",
770
+ )
771
+ return lines
772
+ }