@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,75 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What Scribe learned about a repository in an earlier job, and the tests earlier jobs wrote there, so the next job
|
|
6
|
+
* starts from them instead of reading the repository again. Kept with page memory, one entry per repository.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const NOTES_FILE = "scribe-notes.json"
|
|
10
|
+
/** Notes older than this are read again from the repository. */
|
|
11
|
+
const MAX_AGE_DAYS = 30
|
|
12
|
+
const MAX_TESTS = 15
|
|
13
|
+
|
|
14
|
+
export type WrittenTest = { goal: string; test_file: string; command: string; changed: string[]; at: string }
|
|
15
|
+
export type RepoNotes = { notes: string; at: string; tests: WrittenTest[] }
|
|
16
|
+
|
|
17
|
+
type NotesFile = { repos: Record<string, RepoNotes> }
|
|
18
|
+
|
|
19
|
+
function load(memoryDir: string): NotesFile {
|
|
20
|
+
try {
|
|
21
|
+
const data = JSON.parse(readFileSync(path.join(memoryDir, NOTES_FILE), "utf8")) as NotesFile
|
|
22
|
+
return data && typeof data.repos === "object" && data.repos ? data : { repos: {} }
|
|
23
|
+
} catch {
|
|
24
|
+
return { repos: {} }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function store(memoryDir: string, data: NotesFile): void {
|
|
29
|
+
try {
|
|
30
|
+
mkdirSync(memoryDir, { recursive: true })
|
|
31
|
+
writeFileSync(path.join(memoryDir, NOTES_FILE), `${JSON.stringify(data, null, 2)}\n`)
|
|
32
|
+
} catch {
|
|
33
|
+
// Notes save a later job some reading; without them it reads the repository.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The repository's notes, unless there are none or they are too old to trust. */
|
|
38
|
+
export function readNotes(memoryDir: string, repo: string, now = Date.now()): RepoNotes | null {
|
|
39
|
+
const n = load(memoryDir).repos[repo]
|
|
40
|
+
if (!n?.notes?.trim()) return null
|
|
41
|
+
const age = (now - Date.parse(n.at)) / 86_400_000
|
|
42
|
+
return Number.isFinite(age) && age <= MAX_AGE_DAYS ? { ...n, tests: n.tests || [] } : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function saveNotes(memoryDir: string, repo: string, notes: string, now = new Date()): void {
|
|
46
|
+
const data = load(memoryDir)
|
|
47
|
+
const text = notes.trim()
|
|
48
|
+
if (!text) return
|
|
49
|
+
data.repos[repo] = { notes: text, at: now.toISOString(), tests: data.repos[repo]?.tests || [] }
|
|
50
|
+
store(memoryDir, data)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function addWrittenTest(memoryDir: string, repo: string, test: Omit<WrittenTest, "at">, now = new Date()): void {
|
|
54
|
+
const data = load(memoryDir)
|
|
55
|
+
const n = data.repos[repo]
|
|
56
|
+
if (!n) return
|
|
57
|
+
const tests = (n.tests || []).filter((t) => t.test_file !== test.test_file || t.goal !== test.goal)
|
|
58
|
+
n.tests = [...tests, { ...test, at: now.toISOString() }].slice(-MAX_TESTS)
|
|
59
|
+
store(memoryDir, data)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The notes as the first message's section. */
|
|
63
|
+
export function notesSection(n: RepoNotes): string {
|
|
64
|
+
const lines = [
|
|
65
|
+
`## What earlier jobs learned about this repository (${n.at.slice(0, 10)})`,
|
|
66
|
+
"",
|
|
67
|
+
n.notes,
|
|
68
|
+
]
|
|
69
|
+
if (n.tests.length) {
|
|
70
|
+
lines.push("", "Tests earlier jobs wrote here (extend them or their page objects when this flow overlaps):")
|
|
71
|
+
for (const t of n.tests) lines.push(`- ${t.test_file} for "${t.goal.replace(/\s+/g, " ").slice(0, 120)}"; command: ${t.command}; files: ${t.changed.join(", ")}`)
|
|
72
|
+
}
|
|
73
|
+
lines.push("", "Start from these notes instead of reading the repository again: read only the files this test uses or changes, and read a file before you edit or reuse it, since it may have changed.")
|
|
74
|
+
return lines.join("\n")
|
|
75
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What ForgeQE reads from a repository before Scribe starts, without a model: the test stack, the settings files
|
|
6
|
+
* and the browser values in them (headless, channel, the test id attribute), the base classes, the page objects and
|
|
7
|
+
* their methods, one page object in full as the style to follow, and how one test is run. Scribe starts from this
|
|
8
|
+
* instead of discovering it with dozens of file reads, and the traps in it (a settings file that forces headless, a
|
|
9
|
+
* redefined test id attribute) are visible before the first run rather than after a failed one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type RepoProfile = {
|
|
13
|
+
stack: string[]
|
|
14
|
+
settings: { file: string; values: Record<string, string> }[]
|
|
15
|
+
testIdAttribute?: { value: string; file: string }
|
|
16
|
+
baseClasses: { name: string; file: string; members: string[] }[]
|
|
17
|
+
pageObjects: { name: string; file: string; members: string[] }[]
|
|
18
|
+
example?: { file: string; content: string }
|
|
19
|
+
oneTest: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "bin", "obj", "dist", "build", "out", ".forgeqe", ".venv", "venv", "__pycache__", "coverage", "artifacts", "test-results", "playwright-report", ".next", "target"])
|
|
23
|
+
const MAX_FILES = 4000
|
|
24
|
+
const MAX_READ = 200_000
|
|
25
|
+
const PAGE_DIRS = /(^|\/)(pages|page-?objects|pom|components|flows|screens)(\/|$)/i
|
|
26
|
+
const MAX_MEMBERS = 14
|
|
27
|
+
const MAX_CLASSES = 25
|
|
28
|
+
|
|
29
|
+
function walk(repo: string): string[] {
|
|
30
|
+
const out: string[] = []
|
|
31
|
+
const visit = (dir: string) => {
|
|
32
|
+
if (out.length >= MAX_FILES) return
|
|
33
|
+
let entries: string[] = []
|
|
34
|
+
try {
|
|
35
|
+
entries = readdirSync(dir)
|
|
36
|
+
} catch {
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
for (const name of entries) {
|
|
40
|
+
if (out.length >= MAX_FILES) return
|
|
41
|
+
const full = path.join(dir, name)
|
|
42
|
+
let st
|
|
43
|
+
try {
|
|
44
|
+
st = statSync(full)
|
|
45
|
+
} catch {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (st.isDirectory()) {
|
|
49
|
+
if (!SKIP_DIRS.has(name) && !name.startsWith(".")) visit(full)
|
|
50
|
+
} else if (st.size <= MAX_READ) out.push(path.relative(repo, full).split(path.sep).join("/"))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
visit(repo)
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const read = (repo: string, rel: string) => {
|
|
58
|
+
try {
|
|
59
|
+
return readFileSync(path.join(repo, rel), "utf8")
|
|
60
|
+
} catch {
|
|
61
|
+
return ""
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Public and protected member signatures of a class body, one line each. */
|
|
66
|
+
function members(source: string, lang: "cs" | "ts" | "py"): string[] {
|
|
67
|
+
const lines = source.split("\n").map((l) => l.trim())
|
|
68
|
+
const pick =
|
|
69
|
+
lang === "cs"
|
|
70
|
+
? lines.filter((l) => /^(public|protected)\s/.test(l) && !/\b(class|record|struct|interface|enum)\b/.test(l))
|
|
71
|
+
: lang === "ts"
|
|
72
|
+
? lines.filter((l) => /^(async\s+)?[a-zA-Z_]\w*\s*\(.*\)\s*(:\s*[^={]+)?\{?$/.test(l) && !/^(if|for|while|switch|return|catch)\b/.test(l))
|
|
73
|
+
: lines.filter((l) => /^(async\s+)?def\s+[a-zA-Z]\w*\(/.test(l) && !/^def\s+_/.test(l))
|
|
74
|
+
return [...new Set(pick.map((l) => l.replace(/\s*(=>.*|\{.*|:\s*$)$/, "").replace(/\s+/g, " ").slice(0, 160)))].slice(0, MAX_MEMBERS)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const XML_KEYS = ["BrowserName", "Headless", "Channel", "TestIdAttribute", "ExpectTimeout", "SlowMo", "Locale", "TimezoneId"]
|
|
78
|
+
const JSON_KEYS = ["Headless", "Channel", "Browser", "BrowserName", "Locale", "TimezoneId", "UserAgent", "ViewportWidth", "ViewportHeight", "BaseUrl", "TestIdAttribute"]
|
|
79
|
+
const TS_KEYS = ["headless", "channel", "locale", "timezoneId", "testIdAttribute", "baseURL", "viewport", "userAgent"]
|
|
80
|
+
|
|
81
|
+
function settingValues(rel: string, text: string): Record<string, string> {
|
|
82
|
+
const values: Record<string, string> = {}
|
|
83
|
+
if (/\.runsettings$/i.test(rel)) {
|
|
84
|
+
for (const k of XML_KEYS) {
|
|
85
|
+
const m = text.match(new RegExp(`<${k}>([^<]*)</${k}>`, "i"))
|
|
86
|
+
if (m) values[k] = m[1].trim()
|
|
87
|
+
}
|
|
88
|
+
} else if (/\.json$/i.test(rel)) {
|
|
89
|
+
for (const k of JSON_KEYS) {
|
|
90
|
+
const m = text.match(new RegExp(`"${k}"\\s*:\\s*("[^"]*"|[\\w.-]+)`))
|
|
91
|
+
if (m) values[k] = m[1].replace(/^"|"$/g, "")
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
for (const k of TS_KEYS) {
|
|
95
|
+
const m = text.match(new RegExp(`\\b${k}\\s*:\\s*([^,\\n}]+)`))
|
|
96
|
+
if (m) values[k] = m[1].trim()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return values
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildProfile(repo: string): RepoProfile | null {
|
|
103
|
+
const files = walk(repo)
|
|
104
|
+
if (!files.length) return null
|
|
105
|
+
const profile: RepoProfile = { stack: [], settings: [], baseClasses: [], pageObjects: [], oneTest: [] }
|
|
106
|
+
|
|
107
|
+
// Stack: test frameworks and their versions.
|
|
108
|
+
const csproj = files.filter((f) => f.endsWith(".csproj"))
|
|
109
|
+
const testProjects: string[] = []
|
|
110
|
+
for (const f of csproj) {
|
|
111
|
+
const text = read(repo, f)
|
|
112
|
+
const pkgs = [...text.matchAll(/<PackageReference\s+Include="([^"]+)"\s+Version="([^"]+)"/g)].map((m) => `${m[1]} ${m[2]}`)
|
|
113
|
+
const interesting = pkgs.filter((p) => /playwright|nunit|xunit|mstest|shouldly|fluentassertions|reqnroll|specflow|selenium/i.test(p) && !/analyzers|adapter|collector/i.test(p))
|
|
114
|
+
if (/Microsoft\.NET\.Test\.Sdk/.test(text)) testProjects.push(f)
|
|
115
|
+
if (interesting.length) profile.stack.push(`${f}: ${interesting.join(", ")}`)
|
|
116
|
+
}
|
|
117
|
+
const pkgJson = files.filter((f) => /(^|\/)package\.json$/.test(f))
|
|
118
|
+
for (const f of pkgJson) {
|
|
119
|
+
try {
|
|
120
|
+
const doc = JSON.parse(read(repo, f)) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string>; scripts?: Record<string, string> }
|
|
121
|
+
const deps = { ...doc.dependencies, ...doc.devDependencies }
|
|
122
|
+
const interesting = Object.entries(deps).filter(([n]) => /playwright|cypress|webdriverio|jest|vitest|mocha/.test(n))
|
|
123
|
+
if (interesting.length) profile.stack.push(`${f}: ${interesting.map(([n, v]) => `${n} ${v}`).join(", ")}`)
|
|
124
|
+
if (doc.scripts?.test) profile.stack.push(`${f} test script: ${doc.scripts.test}`)
|
|
125
|
+
} catch {
|
|
126
|
+
/* not JSON */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const pyDeps = files.filter((f) => /(^|\/)(pyproject\.toml|requirements[^/]*\.txt)$/.test(f))
|
|
130
|
+
for (const f of pyDeps) {
|
|
131
|
+
const hits = read(repo, f).match(/(pytest-playwright|playwright|pytest|selenium)[^\s"',]*/g)
|
|
132
|
+
if (hits?.length) profile.stack.push(`${f}: ${[...new Set(hits)].join(", ")}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Settings files and the browser values in them.
|
|
136
|
+
const settingFiles = files.filter((f) => /\.runsettings$/i.test(f) || /(^|\/)appsettings[^/]*\.json$/i.test(f) || /(^|\/)playwright\.config\.(ts|js|mjs|cjs)$/.test(f))
|
|
137
|
+
for (const f of settingFiles) {
|
|
138
|
+
const values = settingValues(f, read(repo, f))
|
|
139
|
+
if (Object.keys(values).length) profile.settings.push({ file: f, values })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A redefined test id attribute changes what getByTestId finds.
|
|
143
|
+
for (const s of profile.settings) {
|
|
144
|
+
const v = s.values.TestIdAttribute || s.values.testIdAttribute
|
|
145
|
+
if (v) {
|
|
146
|
+
profile.testIdAttribute = { value: v.replace(/^['"]|['"]$/g, ""), file: s.file }
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const sources = files.filter((f) => /\.(cs|ts|js|py)$/.test(f) && !/\.d\.ts$/.test(f))
|
|
151
|
+
if (!profile.testIdAttribute) {
|
|
152
|
+
for (const f of sources) {
|
|
153
|
+
const m = read(repo, f).match(/(?:SetTestIdAttribute|setTestIdAttribute|set_test_id_attribute)\(\s*["']([^"']+)["']/)
|
|
154
|
+
if (m) {
|
|
155
|
+
profile.testIdAttribute = { value: m[1], file: f }
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Base classes and page objects.
|
|
162
|
+
const pages: { name: string; file: string; members: string[]; size: number }[] = []
|
|
163
|
+
for (const f of sources) {
|
|
164
|
+
const lang = f.endsWith(".cs") ? "cs" : f.endsWith(".py") ? "py" : "ts"
|
|
165
|
+
const text = read(repo, f)
|
|
166
|
+
const isBase = lang === "cs" ? /\babstract\s+class\s+\w+|class\s+Base\w*|:\s*(PageTest|ContextTest|BrowserTest)\b/.test(text) : /\bclass\s+Base\w*|test\.extend\(|@pytest\.fixture/.test(text)
|
|
167
|
+
const name = (text.match(/\bclass\s+(\w+)/) || [])[1] || path.basename(f).replace(/\.\w+$/, "")
|
|
168
|
+
const isPage = PAGE_DIRS.test(f) || /(Page|Component|Flow)\.(cs|ts|js|py)$/.test(f) || /\.page\.(ts|js)$/.test(f)
|
|
169
|
+
if (isBase && profile.baseClasses.length < MAX_CLASSES && !/tests?\//i.test(f.replace(/^src\//, ""))) {
|
|
170
|
+
profile.baseClasses.push({ name, file: f, members: members(text, lang) })
|
|
171
|
+
} else if (isPage && !/(test|spec)s?\b/i.test(path.basename(f)) && pages.length < MAX_CLASSES) {
|
|
172
|
+
pages.push({ name, file: f, members: members(text, lang), size: text.length })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
profile.pageObjects = pages.map(({ size: _size, ...p }) => p)
|
|
176
|
+
// The style to follow: a page object of typical size, in full.
|
|
177
|
+
const examples = pages.filter((p) => p.size > 400 && p.size < 6000 && p.members.length >= 2).sort((a, b) => a.size - b.size)
|
|
178
|
+
const example = examples[Math.floor(examples.length / 2)]
|
|
179
|
+
if (example) profile.example = { file: example.file, content: read(repo, example.file) }
|
|
180
|
+
|
|
181
|
+
// How one test is run.
|
|
182
|
+
for (const p of testProjects) {
|
|
183
|
+
const runsettings = profile.settings.filter((s) => s.file.endsWith(".runsettings")).map((s) => s.file)
|
|
184
|
+
profile.oneTest.push(`dotnet test ${p} --filter "FullyQualifiedName~<Namespace>.<Class>.<Method>"${runsettings.length ? ` (settings files: ${runsettings.join(", ")}; add --settings <file> for the one whose browser values the test needs)` : ""}`)
|
|
185
|
+
}
|
|
186
|
+
if (files.some((f) => /(^|\/)playwright\.config\.(ts|js|mjs|cjs)$/.test(f))) profile.oneTest.push('npx playwright test <file> -g "<test title>"')
|
|
187
|
+
if (files.some((f) => /(^|\/)conftest\.py$/.test(f)) || pyDeps.length) profile.oneTest.push("pytest <file>::<test_name>")
|
|
188
|
+
// Only a recognised browser test stack is enough to start from; otherwise Scribe reads the repository itself.
|
|
189
|
+
const browserStack = testProjects.length > 0 || profile.stack.some((s) => /playwright|cypress|selenium|webdriverio/i.test(s))
|
|
190
|
+
return browserStack ? profile : null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The profile as the opening section of Scribe's first prompt. */
|
|
194
|
+
export function profileSection(p: RepoProfile): string {
|
|
195
|
+
const lines = [
|
|
196
|
+
"## This repository, as ForgeQE read it",
|
|
197
|
+
"",
|
|
198
|
+
"ForgeQE read these from the files, without guessing. Start from them: read another file only when the test needs something they do not give.",
|
|
199
|
+
"",
|
|
200
|
+
]
|
|
201
|
+
if (p.stack.length) lines.push("Test stack:", ...p.stack.map((s) => `- ${s}`), "")
|
|
202
|
+
if (p.oneTest.length) lines.push("One test is run with:", ...p.oneTest.map((s) => `- ${s}`), "")
|
|
203
|
+
if (p.settings.length) {
|
|
204
|
+
lines.push("Browser settings in the repository (a settings file the command passes wins over the defaults):")
|
|
205
|
+
for (const s of p.settings) lines.push(`- ${s.file}: ${Object.entries(s.values).map(([k, v]) => `${k}=${v}`).join(", ")}`)
|
|
206
|
+
lines.push("")
|
|
207
|
+
}
|
|
208
|
+
if (p.testIdAttribute) {
|
|
209
|
+
lines.push(
|
|
210
|
+
`Test ids: ${p.testIdAttribute.file} sets the test id attribute to "${p.testIdAttribute.value}", so getByTestId looks for ${p.testIdAttribute.value}="…", not data-testid. Use the handoff's attribute selectors ([data-testid="…"]) as given.`,
|
|
211
|
+
"",
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
if (p.baseClasses.length) {
|
|
215
|
+
lines.push("Base classes and fixtures:")
|
|
216
|
+
for (const c of p.baseClasses) lines.push(`- ${c.name} (${c.file})${c.members.length ? `: ${c.members.join("; ")}` : ""}`)
|
|
217
|
+
lines.push("")
|
|
218
|
+
}
|
|
219
|
+
if (p.pageObjects.length) {
|
|
220
|
+
lines.push("Page objects, components, and flows:")
|
|
221
|
+
for (const c of p.pageObjects) lines.push(`- ${c.name} (${c.file})${c.members.length ? `: ${c.members.join("; ")}` : ""}`)
|
|
222
|
+
lines.push("")
|
|
223
|
+
}
|
|
224
|
+
if (p.example) lines.push(`A page object to follow for style (${p.example.file}):`, "", "```", p.example.content.trimEnd(), "```", "")
|
|
225
|
+
return lines.join("\n").trimEnd()
|
|
226
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { CodeReview } from "../types.ts"
|
|
2
|
+
import type { Handoff } from "./handoff.ts"
|
|
3
|
+
import type { Stall } from "./runtime.ts"
|
|
4
|
+
import { prompt } from "../prompts.ts"
|
|
5
|
+
|
|
6
|
+
/** How every agent with skills uses them; the runtime lists the skills it may load. */
|
|
7
|
+
export const skillRule = () => prompt("skill-rule")
|
|
8
|
+
|
|
9
|
+
/** Rules for any test ForgeQE's agents write or change: the coding agent and the healing agent both follow them. */
|
|
10
|
+
export const testRules = () => prompt("test-rules")
|
|
11
|
+
|
|
12
|
+
/** System prompt of Scribe, the coding agent ForgeQE defines for writing tests. */
|
|
13
|
+
export const testAgentPrompt = () => prompt("test-agent")
|
|
14
|
+
|
|
15
|
+
export const RESULT_SCHEMA = {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
status: {
|
|
19
|
+
type: "string",
|
|
20
|
+
enum: ["passed", "blocked"],
|
|
21
|
+
description:
|
|
22
|
+
"passed: the test is written and ready for ForgeQE to run. blocked (last resort): the application cannot be reached, or still refuses after the test's browser matches ForgeQE's",
|
|
23
|
+
},
|
|
24
|
+
blocked_reason: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Only when blocked: what you compared between the test's browser and ForgeQE's, and what you saw (error or page text, screenshot or trace path)",
|
|
27
|
+
},
|
|
28
|
+
remote_browser: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Only when the repository's tests drive a browser on another machine (Selenium Grid, a cloud browser service, a container): where it runs",
|
|
31
|
+
},
|
|
32
|
+
test_file: { type: "string", description: "Path of the new or updated test, relative to the repository root" },
|
|
33
|
+
test_name: { type: "string", description: "The test's full name as the framework reports it, e.g. 'ADO-2236 Contact form submits' or 'tests/test_login.py::test_valid_user'" },
|
|
34
|
+
command: {
|
|
35
|
+
type: "string",
|
|
36
|
+
description:
|
|
37
|
+
"Shell command, run from the repository root, that runs only this test non-interactively and exits non-zero on failure",
|
|
38
|
+
},
|
|
39
|
+
reused: {
|
|
40
|
+
type: "array",
|
|
41
|
+
items: { type: "string" },
|
|
42
|
+
description: "Existing page objects, fixtures, helpers, or config the test reuses (paths or names)",
|
|
43
|
+
},
|
|
44
|
+
changed: { type: "array", items: { type: "string" }, description: "Files created or modified" },
|
|
45
|
+
notes: { type: "string", description: "Anything the user should know, in one or two sentences" },
|
|
46
|
+
},
|
|
47
|
+
required: ["test_file", "command"],
|
|
48
|
+
} as const
|
|
49
|
+
|
|
50
|
+
export type AgentReport = {
|
|
51
|
+
status?: "passed" | "blocked"
|
|
52
|
+
blocked_reason?: string
|
|
53
|
+
remote_browser?: string
|
|
54
|
+
test_file?: string
|
|
55
|
+
test_name?: string
|
|
56
|
+
command?: string
|
|
57
|
+
reused?: string[]
|
|
58
|
+
changed?: string[]
|
|
59
|
+
notes?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** While ForgeQE replays the steps in Playwright, Scribe learns the repository, without changing anything yet. */
|
|
63
|
+
export function preparePrompt(goal: string, checking = true): string {
|
|
64
|
+
return [
|
|
65
|
+
checking
|
|
66
|
+
? "ForgeQE's browser agent completed a flow in a real browser, and ForgeQE is now checking its steps in Playwright. The flow and that check's result come in the next message."
|
|
67
|
+
: "ForgeQE's browser agent completed a flow in a real browser. The flow comes in the next message.",
|
|
68
|
+
`The flow's goal: ${goal}`,
|
|
69
|
+
"Meanwhile, learn this repository's test setup as your instructions describe: the framework and its config, how one test is run, the page objects, fixtures, and helpers this goal would use, and where the browser is configured. Do not create or change files, and do not build or run tests yet.",
|
|
70
|
+
"Reply with notes a later job on this repository can start from without reading it again, in at most 25 lines: the language, test framework and versions; the exact command that runs one test; where tests, page objects, flows, fixtures, and test data live, and the base classes and helpers tests use; where and how the browser is configured; and the conventions (naming, attributes and categories, assertion library, async style).",
|
|
71
|
+
].join("\n")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A test this case already has, which now fails: the job updates it instead of writing a new one. */
|
|
75
|
+
export type ExistingTest = { testFile: string; command: string; output: string; why: string }
|
|
76
|
+
|
|
77
|
+
function existingSection(e: ExistingTest): string[] {
|
|
78
|
+
return [
|
|
79
|
+
"## The test this case already has",
|
|
80
|
+
"",
|
|
81
|
+
`This test case already has a test: ${e.testFile}, run with: ${e.command}`,
|
|
82
|
+
e.why,
|
|
83
|
+
"ForgeQE ran it before this job. The end of its output:",
|
|
84
|
+
"",
|
|
85
|
+
"```",
|
|
86
|
+
e.output.split("\n").slice(-40).join("\n"),
|
|
87
|
+
"```",
|
|
88
|
+
"",
|
|
89
|
+
"Update that test, and the page objects it uses, to match the verified run below: change only what no longer matches. Do not write a new test file. Report its path as test_file.",
|
|
90
|
+
"",
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; liveOn?: boolean; notes?: string; profile?: string; existing?: ExistingTest } = {}): string {
|
|
95
|
+
const live = opts.liveBrowserDir
|
|
96
|
+
? opts.liveOn
|
|
97
|
+
? `Your live_browser_* tools are on because the replay failed at a step; use them on that step's page. They save snapshots and screenshots in ${opts.liveBrowserDir}.`
|
|
98
|
+
: `Your live_browser_* tools are switched on if a test failure repeats; they save snapshots and screenshots in ${opts.liveBrowserDir}.`
|
|
99
|
+
: ""
|
|
100
|
+
return [
|
|
101
|
+
opts.existing
|
|
102
|
+
? `Update the existing test for the flow below so it passes again. When it is updated, give the structured result; ForgeQE then runs it.`
|
|
103
|
+
: `Write a test in this repository for the flow below, reusing the repository's existing test code. When it is written, give the structured result; ForgeQE then runs it.`,
|
|
104
|
+
"",
|
|
105
|
+
...(opts.existing ? existingSection(opts.existing) : []),
|
|
106
|
+
...(opts.profile ? [opts.profile, ""] : []),
|
|
107
|
+
...(opts.notes ? [opts.notes, ""] : []),
|
|
108
|
+
handoff.markdown,
|
|
109
|
+
"",
|
|
110
|
+
`(The same handoff is saved at ${handoff.file}; you do not need to read it again.)`,
|
|
111
|
+
...(live ? [live] : []),
|
|
112
|
+
].join("\n")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A failed run's output for a prompt, with where its full output and the page at the failure were saved. */
|
|
116
|
+
type FailureFiles = { log?: string; pageSnapshot?: string; context?: string[] }
|
|
117
|
+
|
|
118
|
+
function failureBlock(exitCode: number | null, output: string, files: FailureFiles = {}): string[] {
|
|
119
|
+
const how = exitCode === null ? "timed out" : `exited with code ${exitCode}`
|
|
120
|
+
return [
|
|
121
|
+
`It ${how}. The output (the failure message first; long output shortened):`,
|
|
122
|
+
"",
|
|
123
|
+
"```",
|
|
124
|
+
output,
|
|
125
|
+
"```",
|
|
126
|
+
...(files.log ? [`Full output: ${files.log}`] : []),
|
|
127
|
+
...(files.pageSnapshot ? [`The page at the failure, as Playwright saw it (roles and accessible names; search it for the element you need): ${files.pageSnapshot}`] : []),
|
|
128
|
+
...(files.context?.length ? ["", "Compare these before changing code:", ...files.context] : []),
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const GIVE_RESULT = "Then give the structured result again; ForgeQE runs the test."
|
|
133
|
+
|
|
134
|
+
/** A file the job changed, with its current contents when it is small enough to inline. */
|
|
135
|
+
export type ChangedFile = { path: string; content?: string }
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The first prompt of a fresh session for the next attempt. Each attempt starts a new session so the model is not
|
|
139
|
+
* sent every earlier attempt's tool calls again: this brief carries what matters from them instead.
|
|
140
|
+
*/
|
|
141
|
+
export function nextAttemptPrompt(opts: {
|
|
142
|
+
handoff: Handoff
|
|
143
|
+
notes?: string
|
|
144
|
+
testFile?: string
|
|
145
|
+
command?: string
|
|
146
|
+
changed: ChangedFile[]
|
|
147
|
+
attempts: string[]
|
|
148
|
+
/** What to do now: the fix, review, or no-browser prompt for the latest run. */
|
|
149
|
+
task: string
|
|
150
|
+
liveBrowserDir?: string
|
|
151
|
+
liveOn?: boolean
|
|
152
|
+
}): string {
|
|
153
|
+
const files = opts.changed.map((f) =>
|
|
154
|
+
f.content === undefined ? `- ${f.path} (read it if you need it)` : [`### ${f.path}`, "", "```", f.content.trimEnd(), "```"].join("\n"),
|
|
155
|
+
)
|
|
156
|
+
const live = opts.liveBrowserDir && opts.liveOn ? `Your live_browser_* tools are on; they save snapshots and screenshots in ${opts.liveBrowserDir}.` : ""
|
|
157
|
+
return [
|
|
158
|
+
"You are continuing a test-writing job in a new session. The earlier attempts' conversation is not here; this message carries what matters from it. Do not re-read the repository broadly: the notes, the handoff, and the files below are the state of the job. Read another file only when the fix needs it.",
|
|
159
|
+
"",
|
|
160
|
+
...(opts.notes ? [opts.notes, ""] : []),
|
|
161
|
+
handoff(opts.handoff),
|
|
162
|
+
"",
|
|
163
|
+
"## The job so far",
|
|
164
|
+
"",
|
|
165
|
+
...(opts.testFile ? [`Test file: ${opts.testFile}`] : []),
|
|
166
|
+
...(opts.command ? [`Command ForgeQE runs: ${opts.command}`] : []),
|
|
167
|
+
"",
|
|
168
|
+
"Attempts, in order (what was run and how it ended):",
|
|
169
|
+
...opts.attempts.map((a) => `- ${a}`),
|
|
170
|
+
"",
|
|
171
|
+
"Files this job created or changed, as they are now:",
|
|
172
|
+
"",
|
|
173
|
+
...files,
|
|
174
|
+
"",
|
|
175
|
+
"## Now",
|
|
176
|
+
"",
|
|
177
|
+
opts.task,
|
|
178
|
+
...(live ? ["", live] : []),
|
|
179
|
+
].join("\n")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handoff = (h: Handoff) => [h.markdown, "", `(The same handoff is saved at ${h.file}.)`].join("\n")
|
|
183
|
+
|
|
184
|
+
/** Said with the next prompt when the live browser has just been switched on. */
|
|
185
|
+
export const LIVE_ON_NOTE = "Your live_browser_* tools are now on: look at the step it fails at there before changing code."
|
|
186
|
+
|
|
187
|
+
export function stallPrompt(stall: Stall, liveBrowser: boolean): string {
|
|
188
|
+
return [
|
|
189
|
+
`ForgeQE stopped your turn: you ran this command ${stall.count} times and it failed every time.`,
|
|
190
|
+
"```",
|
|
191
|
+
stall.command,
|
|
192
|
+
"```",
|
|
193
|
+
"Last part of its output:",
|
|
194
|
+
"```",
|
|
195
|
+
stall.output,
|
|
196
|
+
"```",
|
|
197
|
+
liveBrowser
|
|
198
|
+
? "Do not run it again as it is. Open the step it fails at in your live_browser_* tools, see what the page shows and does there (snapshot, screenshot, console, network), and change the approach to fit what you see."
|
|
199
|
+
: "Do not run it again as it is. Find out what the page shows and does at the step it fails at, and change the approach to fit what you see.",
|
|
200
|
+
`If the page cannot do what the handoff did, even though its browser matches ForgeQE's, give status blocked with the step, what you tried, and what the page showed. Otherwise finish the test. ${GIVE_RESULT}`,
|
|
201
|
+
].join("\n")
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* What ForgeQE saw the test's browser do differently from the run's browser (from the processes the test started):
|
|
206
|
+
* headless where the run was headed or the other way round, Chromium where the run used Chrome. Empty when they match.
|
|
207
|
+
*/
|
|
208
|
+
export function browserMismatch(run: { headless?: boolean; channel?: string } | undefined, browsers: string[], command: string): string {
|
|
209
|
+
if (!run || !browsers.length) return ""
|
|
210
|
+
const headless = browsers.every((b) => b.endsWith("(headless)"))
|
|
211
|
+
const headed = browsers.every((b) => !b.endsWith("(headless)"))
|
|
212
|
+
const differences: string[] = []
|
|
213
|
+
if (run.headless === false && headless) differences.push("it ran headless, but the run that passed used a visible (headed) browser")
|
|
214
|
+
if (run.headless === true && headed) differences.push("it ran a visible browser, but the run that passed was headless")
|
|
215
|
+
if (/^chrome/i.test(run.channel || "") && browsers.every((b) => !/chrome/i.test(b))) differences.push(`it launched ${browsers[0]}, but the run that passed used Google Chrome`)
|
|
216
|
+
if (!differences.length) return ""
|
|
217
|
+
const settings = command.match(/--settings\s+(\S+)|--config[= ](\S+)|-c\s+(\S+\.(?:json|ts|js|ya?ml))/)
|
|
218
|
+
const where = settings ? ` Your command passes ${settings[1] || settings[2] || settings[3]}: a value there overrides the repository's other settings.` : ""
|
|
219
|
+
return [
|
|
220
|
+
"",
|
|
221
|
+
`ForgeQE watched the browser your test started (${browsers.join(", ")}): ${differences.join("; ")}. The setting you made is not the one in effect for this command.${where} Make the run's setting the effective one for this test (the file or environment variable that wins for this command), then run it again. Sites serve another version of the page, or a bot challenge, to a browser set up differently.`,
|
|
222
|
+
].join("\n")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function fixPrompt(attempt: number, command: string, exitCode: number | null, output: string, files: FailureFiles = {}): string {
|
|
226
|
+
return [
|
|
227
|
+
`ForgeQE ran your test and it failed (attempt ${attempt}). Command: ${command}`,
|
|
228
|
+
...failureBlock(exitCode, output, files),
|
|
229
|
+
"",
|
|
230
|
+
`Fix what the failure names: the test, a page object, config, or the environment, keeping to your rules. If the test's browser got a different page than ForgeQE's did, follow "When the test's page differs from what ForgeQE saw" first. ${GIVE_RESULT} Status blocked is only for an application that still refuses once the test's browser matches ForgeQE's.`,
|
|
231
|
+
].join("\n")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function repeatedFailurePrompt(times: number, command: string, exitCode: number | null, output: string, files: FailureFiles = {}): string {
|
|
235
|
+
return [
|
|
236
|
+
`ForgeQE ran your test and it failed exactly as the last time (${times} times in a row), apart from numbers such as timings and line numbers: your last change did not change the failure. Command: ${command}`,
|
|
237
|
+
...failureBlock(exitCode, output, files),
|
|
238
|
+
"",
|
|
239
|
+
"Before changing code again, look at the step it fails at: what the page shows there, what is open over it, and the console errors and failed requests while it runs. Then change the approach to fit what you see. If it fails the same way once more, ForgeQE stops the job.",
|
|
240
|
+
`If the page cannot do what the handoff did, even though the test's browser matches ForgeQE's, give status blocked with the step, what you tried, and what the page showed. ${GIVE_RESULT}`,
|
|
241
|
+
].join("\n")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function noBrowserPrompt(command: string): string {
|
|
245
|
+
return [
|
|
246
|
+
`ForgeQE ran your test and it exited 0, but no browser started while it ran. Command: ${command}`,
|
|
247
|
+
"A passing exit code is not enough: the test must drive the application in a browser through the repository's test framework, as the verified run did. Check that the command runs the test you wrote (a filter that matches nothing also exits 0) and that the test opens the browser.",
|
|
248
|
+
`Fix it. ${GIVE_RESULT} If this repository's tests drive a browser on another machine (Selenium Grid, a cloud browser service, a container), say where in remote_browser.`,
|
|
249
|
+
].join("\n")
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function blockedChallengePrompt(handoff: Handoff): string {
|
|
253
|
+
return [
|
|
254
|
+
"ForgeQE's browser completed this flow on the same application from this machine, so a refusal usually comes from how the test's browser is set up.",
|
|
255
|
+
`Before reporting blocked, follow "When the test's page differs from what ForgeQE saw" and compare the test's browser with the one in the handoff (${handoff.file}, section "Browser ForgeQE used"): user agent, navigator.webdriver, headless, channel and version, failed requests and CORS errors, headers the framework adds, console errors. Change the repository's browser configuration to match.`,
|
|
256
|
+
"Then give the structured result with status passed, and ForgeQE runs the test. If the page still refuses after matching every point, give status blocked with what you compared and what you saw.",
|
|
257
|
+
].join("\n")
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export type ReviewIssue = CodeReview["issues"][number]
|
|
261
|
+
|
|
262
|
+
export function reviewFixPrompt(summary: string, issues: ReviewIssue[]): string {
|
|
263
|
+
return [
|
|
264
|
+
"ForgeQE's run of your test passed, but a reviewer checked your change against the goal and the test rules and did not approve it:",
|
|
265
|
+
summary,
|
|
266
|
+
"",
|
|
267
|
+
...issues.map((i, n) => `${n + 1}. ${i.file ? `${i.file}: ` : ""}${i.problem}${i.fix ? ` Fix: ${i.fix}` : ""}`),
|
|
268
|
+
"",
|
|
269
|
+
`Fix each point, keeping to your rules. A point about a file you did not create or change in this job is not yours to fix: leave the file as it is and say so in notes. ${GIVE_RESULT}`,
|
|
270
|
+
].join("\n")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The test works and tests the goal; the reviewer found only coding-standard issues: one round to fix them. */
|
|
274
|
+
export function standardsFixPrompt(issues: ReviewIssue[]): string {
|
|
275
|
+
return [
|
|
276
|
+
"ForgeQE's run of your test passed, and the reviewer agrees it tests what the goal asks. It found these coding-standard issues:",
|
|
277
|
+
"",
|
|
278
|
+
...issues.map((i, n) => `${n + 1}. ${i.file ? `${i.file}: ` : ""}${i.problem}${i.fix ? ` Fix: ${i.fix}` : ""}`),
|
|
279
|
+
"",
|
|
280
|
+
"Fix them without changing what the test does: keep its steps, locators, waits, and assertions. Test data (users, values, expected texts) belongs in the test and goes to page object methods as parameters; credentials come from the environment variables the handoff names. Change only what these points need.",
|
|
281
|
+
`${GIVE_RESULT} If the change breaks the test, ForgeQE keeps the version that passed.`,
|
|
282
|
+
].join("\n")
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const RESUMED_NOTE =
|
|
286
|
+
"ForgeQE stopped before this job finished (it was closed, the machine restarted, or the connection dropped), and this is the same session again. Your earlier messages are above, and the files you wrote are still in the repository: check them before changing anything, and do not start over."
|
|
287
|
+
|
|
288
|
+
/** The first message when Scribe's session is reopened after it had started on the test. */
|
|
289
|
+
export function carryOnPrompt(liveOn: boolean): string {
|
|
290
|
+
return [
|
|
291
|
+
RESUMED_NOTE,
|
|
292
|
+
"Carry on from where you stopped: finish the test and give the structured result with test_file and command; ForgeQE then runs it.",
|
|
293
|
+
...(liveOn ? [LIVE_ON_NOTE] : []),
|
|
294
|
+
].join("\n\n")
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function restoredNote(paths: string[]): string {
|
|
298
|
+
return `ForgeQE put back files that existed before this job, which you removed or reverted: ${paths.join(", ")}. They are the user's work or ForgeQE's; leave them as they are.`
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function missingCommandPrompt(): string {
|
|
302
|
+
return "Your result did not include a test file and a command that runs it. Finish the test and give the structured result with test_file and command; ForgeQE then runs it."
|
|
303
|
+
}
|