@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.
- 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 +757 -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 +136 -0
- package/src/update.ts +311 -0
- 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
|
+
}
|