@capagents/forgeqe 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,159 @@
1
+ import { readFileSync } from "node:fs"
2
+
3
+ /**
4
+ * Small pieces of Playwright aria snapshots (the page as roles and accessible names, one YAML line per element) around
5
+ * the element a step or a failure is about: its section, its neighbours, and the line itself. Scribe gets these in its
6
+ * prompt instead of whole snapshots or a live browser, which re-send thousands of lines with every later call.
7
+ */
8
+
9
+ /** What a Playwright locator points at, in terms an aria snapshot shows: a role and accessible name, or text. */
10
+ export type AriaTarget = { role?: string; name?: string; text?: string }
11
+
12
+ const STRING = `['"\`]((?:[^'"\`\\\\]|\\\\.)*)['"\`]`
13
+
14
+ /** The role and name, or the text, of a Playwright locator; null for CSS, XPath, and test ids, which a snapshot does not show. */
15
+ export function ariaTarget(locator: string): AriaTarget | null {
16
+ const role = new RegExp(`getByRole\\(\\s*${STRING}(?:\\s*,\\s*\\{([^}]*)\\})?`).exec(locator)
17
+ if (role) {
18
+ const opts = role[2] || ""
19
+ const name = new RegExp(`name:\\s*${STRING}`).exec(opts)?.[1] ?? /name:\s*\/(.*?)\/[a-z]*/.exec(opts)?.[1]
20
+ return { role: role[1], ...(name ? { name: unescape(name) } : {}) }
21
+ }
22
+ const text = new RegExp(`getBy(?:Label|Placeholder|Text|Title|AltText)\\(\\s*${STRING}`).exec(locator)
23
+ if (text) return { text: unescape(text[1]) }
24
+ const has = new RegExp(`hasText:\\s*${STRING}`).exec(locator)
25
+ if (has) return { text: unescape(has[1]) }
26
+ return null
27
+ }
28
+
29
+ const unescape = (s: string) => s.replace(/\\(.)/g, "$1")
30
+ const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase()
31
+ const indentOf = (line: string) => line.length - line.trimStart().length
32
+
33
+ /** The quoted name on an aria line (`- button "Search"`), or its text (`- text: Search`). */
34
+ function lineName(line: string): string {
35
+ const t = line.trim()
36
+ return /^- [\w-]+ "((?:[^"\\]|\\.)*)"/.exec(t)?.[1] ?? /^- [\w-]+: (.+)$/.exec(t)?.[1] ?? ""
37
+ }
38
+
39
+ /** The line the target is on: exact name first, then a name that contains it. -1 when the page does not show it. */
40
+ export function findLine(lines: string[], target: AriaTarget): number {
41
+ const want = norm(target.name ?? target.text ?? "")
42
+ const roleOk = (l: string) => !target.role || l.trim().startsWith(`- ${target.role}`)
43
+ const candidates = lines.map((l, i) => ({ l, i })).filter(({ l }) => l.trim().startsWith("-") && roleOk(l))
44
+ if (!want) return candidates[0]?.i ?? -1
45
+ const exact = candidates.find(({ l }) => norm(lineName(l)) === want)
46
+ if (exact) return exact.i
47
+ const within = candidates.find(({ l }) => norm(lineName(l)).includes(want))
48
+ if (within) return within.i
49
+ // Text locators also match a label's text inside a longer line.
50
+ if (!target.role) return lines.findIndex((l) => norm(l).includes(want))
51
+ return -1
52
+ }
53
+
54
+ /** The line that shares the most words with the target's name: where to look when the element itself is gone. */
55
+ function closestLine(lines: string[], target: AriaTarget): number {
56
+ const words = norm(target.name ?? target.text ?? "").split(" ").filter((w) => w.length >= 4)
57
+ if (!words.length) return -1
58
+ let best = -1
59
+ let score = 0
60
+ lines.forEach((l, i) => {
61
+ const n = norm(l)
62
+ const s = words.filter((w) => n.includes(w)).length
63
+ if (s > score) {
64
+ score = s
65
+ best = i
66
+ }
67
+ })
68
+ return best
69
+ }
70
+
71
+ /**
72
+ * The element's section of the snapshot: from its grandparent (or parent) down, at most `max` lines, kept around the
73
+ * element, with the element's line marked. Long lines are shortened.
74
+ */
75
+ export function sliceAround(lines: string[], at: number, mark: string, max = 18): string[] {
76
+ let top = at
77
+ for (let level = 0; level < 2; level++) {
78
+ const want = indentOf(lines[top])
79
+ let j = top - 1
80
+ while (j >= 0 && indentOf(lines[j]) >= want) j--
81
+ if (j < 0) break
82
+ top = j
83
+ }
84
+ let end = top + 1
85
+ while (end < lines.length && indentOf(lines[end]) > indentOf(lines[top])) end++
86
+ let from = top
87
+ let to = end
88
+ if (to - from > max) {
89
+ from = Math.max(top, at - Math.floor(max / 2))
90
+ to = Math.min(end, from + max)
91
+ from = Math.max(top, to - max)
92
+ }
93
+ const clip = (l: string) => (l.length > 160 ? `${l.slice(0, 159)}…` : l)
94
+ const out = lines.slice(from, to).map((l, k) => (from + k === at ? `${clip(l)} # ← ${mark}` : clip(l)))
95
+ const base = Math.min(...out.map(indentOf))
96
+ const shifted = out.map((l) => l.slice(base))
97
+ if (from > top) shifted.unshift(`${" ".repeat(Math.max(0, indentOf(lines[top]) - base))}${clip(lines[top].trim())}`, "…")
98
+ if (to < end) shifted.push("…")
99
+ return shifted
100
+ }
101
+
102
+ /** The slice for a locator on one snapshot, or null when the snapshot does not show what it points at. */
103
+ export function sliceFor(snapshot: string, locator: string, mark: string, max?: number): string[] | null {
104
+ const target = ariaTarget(locator)
105
+ if (!target) return null
106
+ const lines = snapshot.split("\n")
107
+ const at = findLine(lines, target)
108
+ return at < 0 ? null : sliceAround(lines, at, mark, max)
109
+ }
110
+
111
+ /** The locator a Playwright failure names: "waiting for getByRole(…)" or "Locator: getByText(…)". */
112
+ export function failingLocator(output: string): string | null {
113
+ const m = /(?:waiting for|Locator:)\s+((?:page\.)?(?:getBy\w+|locator)\(.+?\))(?:\s+to\b|\s*$)/m.exec(output)
114
+ return m ? m[1] : null
115
+ }
116
+
117
+ function read(file?: string): string {
118
+ if (!file) return ""
119
+ try {
120
+ return readFileSync(file, "utf8")
121
+ } catch {
122
+ return ""
123
+ }
124
+ }
125
+
126
+ /**
127
+ * For a failed test run: the element the failure names, on the test's page at the failure and on the page ForgeQE's
128
+ * replay of the run saw. Usually enough to fix a locator, a wait, or an overlay without opening a browser.
129
+ */
130
+ export function failureContext(output: string, testSnapshot: string | undefined, runSnapshots: string[]): string[] {
131
+ const locator = failingLocator(output)
132
+ if (!locator) return []
133
+ const target = ariaTarget(locator)
134
+ if (!target) return [`The failing locator: ${locator}. It is not a role, label, or text locator, so the page snapshots cannot show it; use one of the step's role or text locators from the handoff.`]
135
+ const yaml = (lines: string[]) => ["```yaml", ...lines, "```"]
136
+ const out = [`The failing locator: ${locator}`]
137
+ const test = read(testSnapshot).split("\n")
138
+ if (test.length > 1) {
139
+ const at = findLine(test, target)
140
+ if (at >= 0) out.push(`Your test's page at the failure shows it:`, ...yaml(sliceAround(test, at, "the failing locator")))
141
+ else {
142
+ const near = closestLine(test, target)
143
+ out.push(
144
+ near >= 0
145
+ ? "Your test's page at the failure does not show it. The closest part of that page:"
146
+ : "Your test's page at the failure does not show it, or anything with its words: the test is on another page, or something covers it.",
147
+ ...(near >= 0 ? yaml(sliceAround(test, near, "closest match")) : []),
148
+ )
149
+ }
150
+ }
151
+ for (const file of runSnapshots) {
152
+ const run = read(file).split("\n")
153
+ const at = findLine(run, target)
154
+ if (at < 0) continue
155
+ out.push("The same element on ForgeQE's replay of the run:", ...yaml(sliceAround(run, at, "the element")))
156
+ break
157
+ }
158
+ return out
159
+ }
@@ -0,0 +1,187 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { withoutNames } from "../childEnv.ts"
5
+ import { redactSecrets, sessionLoginEnv } from "../sessionLogins.ts"
6
+
7
+ const HEAD_CHARS = 8_000
8
+ const TAIL_CHARS = 4_000
9
+
10
+ /**
11
+ * `browsers`: automation-controlled browsers that ran under the command, e.g. "Google Chrome (headless)".
12
+ * `output` is short enough for a prompt with the failure message kept; `log` and `pageSnapshot` hold what it left out.
13
+ */
14
+ export type TestRun = { ok: boolean; exitCode: number | null; output: string; ms: number; browsers: string[]; log?: string; pageSnapshot?: string }
15
+
16
+ type Proc = { pid: number; ppid: number; args: string }
17
+
18
+ /** Switches Playwright, Puppeteer, Selenium, Cypress, and WebDriver BiDi put on the browsers they launch. */
19
+ const AUTOMATION_SWITCHES = ["--remote-debugging-pipe", "--remote-debugging-port", "-juggler-pipe", "--inspector-pipe", "-marionette", "--marionette"]
20
+
21
+ /**
22
+ * Playwright puts the whole page's accessibility tree into a failed assertion's message ("Aria snapshot:" and a YAML
23
+ * list, often thousands of lines), between the error and the stack trace. Each one is taken out and returned apart.
24
+ */
25
+ export function withoutPageDumps(text: string): { text: string; dumps: string[] } {
26
+ const lines = text.split("\n")
27
+ const out: string[] = []
28
+ const dumps: string[] = []
29
+ for (let i = 0; i < lines.length; i++) {
30
+ if (!/^\s*Aria snapshot:\s*$/.test(lines[i])) {
31
+ out.push(lines[i])
32
+ continue
33
+ }
34
+ let j = i + 1
35
+ while (j < lines.length && /^\s*- /.test(lines[j])) j++
36
+ if (j === i + 1) {
37
+ out.push(lines[i])
38
+ continue
39
+ }
40
+ dumps.push(lines.slice(i + 1, j).join("\n"))
41
+ out.push(`${lines[i].replace(/:\s*$/, "")}: ${j - i - 1} lines of the page, left out here {{snapshot}}`)
42
+ i = j - 1
43
+ }
44
+ return { text: out.join("\n"), dumps }
45
+ }
46
+
47
+ /** The start (where test runners put the first failure) and the end (the summary) of a long output. */
48
+ export function headAndTail(text: string, note = ""): string {
49
+ if (text.length <= HEAD_CHARS + TAIL_CHARS) return text
50
+ const head = text.slice(0, HEAD_CHARS)
51
+ const tail = text.slice(-TAIL_CHARS)
52
+ const cut = text.slice(HEAD_CHARS, -TAIL_CHARS).split("\n").length
53
+ return `${head}\n… ${cut} lines left out${note ? ` (${note})` : ""} …\n${tail}`
54
+ }
55
+
56
+ /** A test run's output for a prompt: page dumps out, then its start and end; the full text goes to `save`.log. */
57
+ export function promptOutput(full: string, save?: string): { output: string; log?: string; pageSnapshot?: string } {
58
+ const { text, dumps } = withoutPageDumps(full)
59
+ let log: string | undefined
60
+ let pageSnapshot: string | undefined
61
+ if (save) {
62
+ try {
63
+ mkdirSync(path.dirname(save), { recursive: true })
64
+ log = `${save}.log`
65
+ writeFileSync(log, full)
66
+ if (dumps.length) {
67
+ pageSnapshot = `${save}-page.aria.yml`
68
+ writeFileSync(pageSnapshot, dumps[0])
69
+ }
70
+ } catch {
71
+ log = pageSnapshot = undefined
72
+ }
73
+ }
74
+ const where = pageSnapshot ? `(saved in ${pageSnapshot})` : ""
75
+ const output = headAndTail(text.replaceAll(" {{snapshot}}", where ? ` ${where}` : ""), log ? `full output in ${log}` : "")
76
+ return { output, ...(log ? { log } : {}), ...(pageSnapshot ? { pageSnapshot } : {}) }
77
+ }
78
+
79
+ async function processTable(): Promise<Proc[]> {
80
+ const windows = process.platform === "win32"
81
+ const argv = windows
82
+ ? [
83
+ "powershell.exe",
84
+ "-NoProfile",
85
+ "-NonInteractive",
86
+ "-Command",
87
+ 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CommandLine)" }',
88
+ ]
89
+ : ["ps", "-A", "-o", "pid=,ppid=,args="]
90
+ try {
91
+ const proc = Bun.spawn(argv, { stdin: "ignore", stdout: "pipe", stderr: "ignore" })
92
+ const text = await new Response(proc.stdout).text()
93
+ await proc.exited
94
+ return text.split(/\r?\n/).flatMap((line) => {
95
+ const m = /^\s*(\d+)\s+(\d+)\s?(.*)$/.exec(line)
96
+ return m ? [{ pid: Number(m[1]), ppid: Number(m[2]), args: m[3] || "" }] : []
97
+ })
98
+ } catch {
99
+ return []
100
+ }
101
+ }
102
+
103
+ /** "Google Chrome (headless)" for an automation-launched browser's main process, else null. */
104
+ export function automatedBrowser(args: string): string | null {
105
+ if (/\s--type=/.test(args) || !AUTOMATION_SWITCHES.some((s) => args.includes(` ${s}`))) return null
106
+ const exe = args.split(/\s-{1,2}[a-z]/i)[0].trim().replace(/^"|"$/g, "")
107
+ const name = path.basename(exe.replace(/\\/g, "/")).replace(/\.exe$/i, "") || "browser"
108
+ return /\s--headless(=|\s|$)|\s-headless(\s|$)/.test(args) ? `${name} (headless)` : name
109
+ }
110
+
111
+ /** Browsers launched for automation at or below `root` in the process tree right now; a shell may exec into its command. */
112
+ async function browsersUnder(root: number): Promise<string[]> {
113
+ const table = await processTable()
114
+ const children = new Map<number, Proc[]>()
115
+ for (const p of table) children.set(p.ppid, [...(children.get(p.ppid) || []), p])
116
+ const found: string[] = []
117
+ const queue = table.filter((p) => p.pid === root)
118
+ const seen = new Set<number>()
119
+ while (queue.length) {
120
+ const p = queue.shift()!
121
+ if (seen.has(p.pid)) continue
122
+ seen.add(p.pid)
123
+ const browser = automatedBrowser(p.args)
124
+ if (browser) found.push(browser)
125
+ queue.push(...(children.get(p.pid) || []))
126
+ }
127
+ return found
128
+ }
129
+
130
+ /**
131
+ * Run the agent's test command from the repo root, non-interactively. The output keeps the failure message and the
132
+ * summary (see promptOutput); with `save`, the full output is kept in `save`.log.
133
+ * While it runs, the process tree is sampled for browsers the test launched: a passing exit code alone
134
+ * does not show the test drove a browser.
135
+ */
136
+ export async function runTestCommand(opts: {
137
+ command: string
138
+ cwd: string
139
+ timeoutSeconds: number
140
+ signal?: AbortSignal
141
+ /** Path without extension for the full output and the first page dump, e.g. <run>/tests/attempt-1. */
142
+ save?: string
143
+ /** Variables the test must not see: ForgeQE's own secrets (privateEnvNames). */
144
+ hide?: string[]
145
+ }): Promise<TestRun> {
146
+ const started = Date.now()
147
+ const windows = process.platform === "win32"
148
+ const argv = windows ? ["cmd.exe", "/d", "/s", "/c", `"${opts.command}"`] : ["sh", "-c", opts.command]
149
+ const proc = Bun.spawn(argv, {
150
+ cwd: opts.cwd,
151
+ windowsVerbatimArguments: windows,
152
+ env: { ...withoutNames(process.env, opts.hide || []), ...sessionLoginEnv(), CI: process.env.CI || "1", PLAYWRIGHT_HTML_OPEN: "never", FORCE_COLOR: "0", NO_COLOR: "1" },
153
+ stdin: "ignore",
154
+ stdout: "pipe",
155
+ stderr: "pipe",
156
+ })
157
+ let timedOut = false
158
+ const kill = () => {
159
+ try {
160
+ proc.kill("SIGTERM")
161
+ } catch {}
162
+ }
163
+ const timer = setTimeout(() => {
164
+ timedOut = true
165
+ kill()
166
+ }, opts.timeoutSeconds * 1000)
167
+ opts.signal?.addEventListener("abort", kill, { once: true })
168
+
169
+ const browsers = new Set<string>()
170
+ let running = true
171
+ const sampling = (async () => {
172
+ const every = windows ? 1500 : 250
173
+ while (running) {
174
+ for (const b of await browsersUnder(proc.pid)) browsers.add(b)
175
+ if (running) await Bun.sleep(every)
176
+ }
177
+ })()
178
+
179
+ const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
180
+ running = false
181
+ await sampling
182
+ clearTimeout(timer)
183
+ opts.signal?.removeEventListener("abort", kill)
184
+ const full = redactSecrets([out, err].filter((s) => s.trim()).join("\n").replace(/\x1b\[[0-9;]*m/g, ""))
185
+ const ok = !timedOut && code === 0
186
+ return { ok, exitCode: timedOut ? null : code, ...promptOutput(full, ok ? undefined : opts.save), ms: Date.now() - started, browsers: [...browsers] }
187
+ }
@@ -0,0 +1,100 @@
1
+ import { createHash } from "node:crypto"
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { projectDir } from "../tools.ts"
6
+
7
+ import { adoSettings } from "../cases/ado.ts"
8
+ import { http } from "../cases/http.ts"
9
+ import type { TestCase, WriteBack } from "../cases/types.ts"
10
+ import type { SourcesConfig } from "../config.ts"
11
+
12
+ /**
13
+ * Which automated test belongs to which manual test case. Every coding job for a test case records its test in
14
+ * .forgeqe/test-cases.json in the repository (committed with the tests, so the team shares it), and a test case
15
+ * from Azure DevOps also gets its Associated Automation fields set, so the test plan shows it as automated.
16
+ */
17
+
18
+ export const MAP_FILE = path.join(".forgeqe", "test-cases.json")
19
+ const mapFile = (repo: string) => path.join(projectDir(repo), path.basename(MAP_FILE))
20
+
21
+ export type MappingStatus = "automated" | "known_defect" | "needs_attention"
22
+
23
+ export type Mapping = {
24
+ caseId: string
25
+ title: string
26
+ source: string
27
+ url?: string
28
+ status: MappingStatus
29
+ testFile?: string
30
+ testName?: string
31
+ command?: string
32
+ /** The bug a known-defect test waits on. */
33
+ bug?: { label: string; url?: string }
34
+ /** The needs-attention folder, for an unfinished test. */
35
+ attention?: string
36
+ updatedAt: string
37
+ }
38
+
39
+ type MapFile = { version: 1; cases: Record<string, Mapping> }
40
+
41
+ export function readMappings(repo: string): MapFile {
42
+ try {
43
+ const data = JSON.parse(readFileSync(mapFile(repo), "utf8")) as MapFile
44
+ return { version: 1, cases: data?.cases && typeof data.cases === "object" ? data.cases : {} }
45
+ } catch {
46
+ return { version: 1, cases: {} }
47
+ }
48
+ }
49
+
50
+ /** Record (or replace) a test case's test in the repository's mapping file. */
51
+ export function recordMapping(repo: string, m: Omit<Mapping, "updatedAt">): void {
52
+ const data = readMappings(repo)
53
+ data.cases[m.caseId] = { ...m, updatedAt: new Date().toISOString() }
54
+ const sorted: MapFile = { version: 1, cases: Object.fromEntries(Object.entries(data.cases).sort(([a], [b]) => a.localeCompare(b))) }
55
+ mkdirSync(projectDir(repo), { recursive: true })
56
+ writeFileSync(mapFile(repo), `${JSON.stringify(sorted, null, 2)}\n`)
57
+ }
58
+
59
+ /** The test framework a command runs, for Azure DevOps' AutomatedTestType. */
60
+ export function frameworkOf(command: string | undefined, testFile: string | undefined): string {
61
+ const c = `${command || ""} ${testFile || ""}`.toLowerCase()
62
+ if (c.includes("playwright")) return "Playwright"
63
+ if (c.includes("cypress")) return "Cypress"
64
+ if (c.includes("pytest") || c.endsWith(".py")) return "pytest"
65
+ if (c.includes("dotnet test") || /\.cs\b/.test(c)) return "NUnit/xUnit (dotnet test)"
66
+ if (c.includes("vitest")) return "Vitest"
67
+ if (c.includes("jest")) return "Jest"
68
+ if (c.includes("mvn") || c.includes("gradle")) return "JUnit"
69
+ return "Automated test"
70
+ }
71
+
72
+ /** A stable GUID for a test, from its name and file: AutomatedTestId must be one, and the same test keeps the same id. */
73
+ export function testGuid(name: string, file: string): string {
74
+ const h = createHash("sha1").update(`${file}::${name}`).digest("hex")
75
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-${((parseInt(h.slice(16, 18), 16) & 0x3f) | 0x80).toString(16)}${h.slice(18, 20)}-${h.slice(20, 32)}`
76
+ }
77
+
78
+ /**
79
+ * Set a Test Case work item's Associated Automation: the test's name, its file, its framework, and a stable id, with
80
+ * Automation status Automated. One write-back line, with an error when Azure DevOps refused it.
81
+ */
82
+ export async function associateAdoAutomation(cfg: SourcesConfig, tc: TestCase, test: { name: string; file: string; command?: string }): Promise<WriteBack> {
83
+ const id = Number(tc.ref?.workItem)
84
+ if (!id) return { source: "ado", caseId: tc.id, text: `${tc.id}: automation not associated`, error: "the test case has no work item id" }
85
+ try {
86
+ const { org, project, base } = adoSettings(cfg)
87
+ const where = project ? base : typeof tc.ref?.project === "string" ? `${org}/${encodeURIComponent(tc.ref.project)}` : org
88
+ const ops = [
89
+ ["Microsoft.VSTS.TCM.AutomatedTestName", test.name],
90
+ ["Microsoft.VSTS.TCM.AutomatedTestStorage", test.file],
91
+ ["Microsoft.VSTS.TCM.AutomatedTestType", frameworkOf(test.command, test.file)],
92
+ ["Microsoft.VSTS.TCM.AutomatedTestId", testGuid(test.name, test.file)],
93
+ ["Microsoft.VSTS.TCM.AutomationStatus", "Automated"],
94
+ ].map(([field, value]) => ({ op: "add", path: `/fields/${field}`, value }))
95
+ await http(`${where}/_apis/wit/workitems/${id}?api-version=7.1`, { auth: adoSettings(cfg).auth, method: "PATCH", body: ops, contentType: "application/json-patch+json" })
96
+ return { source: "ado", caseId: tc.id, text: `${tc.id}: associated with the automated test ${test.name}`, ...(tc.url ? { url: tc.url } : {}) }
97
+ } catch (err) {
98
+ return { source: "ado", caseId: tc.id, text: `${tc.id}: automation not associated`, error: (err as Error).message }
99
+ }
100
+ }
@@ -0,0 +1,153 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { createHash } from "node:crypto"
3
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
4
+ import path from "node:path"
5
+
6
+ import type { ChangedFile } from "./prompt.ts"
7
+
8
+ const KEEP_BYTES = 5_000_000
9
+
10
+ type Entry = { status: string; hash: string; content?: Buffer }
11
+
12
+ /** The repository's uncommitted state before a job: what the user had changed, and ForgeQE's own files. */
13
+ export type WorkTree = { git: boolean; repo: string; own: string[]; entries: Map<string, Entry> }
14
+
15
+ /** ForgeQE's files in the repository, relative to it: config, run folders, and page memory. Never part of a job's change. */
16
+ export function ownPaths(repo: string, dirs: string[]): string[] {
17
+ const inside = dirs.map((d) => path.relative(repo, path.resolve(d))).filter((r) => r && !r.startsWith("..") && !path.isAbsolute(r))
18
+ return [...new Set([".forgeqe", "forgeqe.yaml", ".webpilot", "webpilot.yaml", "llms.json", ...inside].map((p) => p.split(path.sep).join("/")))]
19
+ }
20
+
21
+ /**
22
+ * What the coding agent may not touch, as the agent runtime matches it: edit patterns relative to the git worktree root (or
23
+ * the filesystem root outside git), shell patterns as the agent would write the paths.
24
+ */
25
+ export function protectPatterns(repo: string, own: string[], outside: string[]): { edit: string[]; bash: string[] } {
26
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: repo, encoding: "utf8" })
27
+ const base = top.status === 0 && top.stdout.trim() ? path.resolve(top.stdout.trim()) : path.parse(repo).root
28
+ const abs = [...own.map((p) => path.join(repo, p)), ...outside]
29
+ const posix = (p: string) => p.split(path.sep).join("/")
30
+ return { edit: abs.map((p) => posix(path.relative(base, p))), bash: [...own, ...outside.map(posix)] }
31
+ }
32
+
33
+ export function isOwn(own: string[], rel: string): boolean {
34
+ return own.some((o) => rel === o || rel.startsWith(`${o}/`))
35
+ }
36
+
37
+ function gitStatus(repo: string): Map<string, string> | null {
38
+ const r = spawnSync("git", ["status", "--porcelain=v1", "-z", "-uall", "--no-renames"], { cwd: repo, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })
39
+ if (r.status !== 0) return null
40
+ const out = new Map<string, string>()
41
+ for (const rec of r.stdout.split("\0")) if (rec.length > 3) out.set(rec.slice(3), rec.slice(0, 2))
42
+ return out
43
+ }
44
+
45
+ function read(repo: string, rel: string): Buffer | null {
46
+ try {
47
+ const file = path.join(repo, rel)
48
+ return statSync(file).isFile() ? readFileSync(file) : null
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ const hashOf = (b: Buffer | null) => (b ? createHash("sha1").update(b).digest("hex") : "-")
55
+
56
+ export function snapshot(repo: string, own: string[]): WorkTree {
57
+ const entries = new Map<string, Entry>()
58
+ const status = gitStatus(repo)
59
+ const add = (rel: string, st: string) => {
60
+ const content = read(repo, rel)
61
+ entries.set(rel, { status: st, hash: hashOf(content), content: content && content.length <= KEEP_BYTES ? content : undefined })
62
+ }
63
+ for (const [rel, st] of status || []) if (!isOwn(own, rel)) add(rel, st)
64
+ for (const rel of own) if (!entries.has(rel) && read(repo, rel)) add(rel, "own")
65
+ return { git: status !== null, repo, own, entries }
66
+ }
67
+
68
+ /** Uncommitted paths the user already had before the job, ForgeQE's own files left out. */
69
+ export function preexisting(tree: WorkTree): string[] {
70
+ return [...tree.entries].filter(([rel, e]) => e.status !== "own" && !isOwn(tree.own, rel)).map(([rel]) => rel)
71
+ }
72
+
73
+ /** Paths the job created, changed, reverted, or deleted since the snapshot; null outside git. */
74
+ export function jobChanges(tree: WorkTree): string[] | null {
75
+ const now = tree.git ? gitStatus(tree.repo) : null
76
+ if (!now) return null
77
+ const paths = new Set([...now.keys(), ...preexisting(tree)])
78
+ return [...paths].filter((rel) => {
79
+ if (isOwn(tree.own, rel)) return false
80
+ const was = tree.entries.get(rel)
81
+ return !was || !now.has(rel) || was.hash !== hashOf(read(tree.repo, rel))
82
+ }).sort()
83
+ }
84
+
85
+ /**
86
+ * Put back what the job must not take away: ForgeQE's files (removed or changed: the shell rules that protect them are
87
+ * only guard rails), the user's untracked files, and the user's uncommitted edits reverted to the committed version.
88
+ * Edits the job made on top of the user's are kept.
89
+ */
90
+ export function restoreRemoved(tree: WorkTree, only: "all" | "own" = "all"): string[] {
91
+ const now = tree.git && only === "all" ? gitStatus(tree.repo) : null
92
+ const back: string[] = []
93
+ for (const [rel, e] of tree.entries) {
94
+ if (!e.content) continue
95
+ if (only === "own" && e.status !== "own") continue
96
+ const current = read(tree.repo, rel)
97
+ const gone = current === null
98
+ const ownChanged = e.status === "own" && !gone && hashOf(current) !== e.hash
99
+ const reverted = now !== null && e.status !== "own" && e.status !== "??" && e.status.includes("M") && !now.has(rel)
100
+ if (!gone && !reverted && !ownChanged) continue
101
+ const file = path.join(tree.repo, rel)
102
+ mkdirSync(path.dirname(file), { recursive: true })
103
+ writeFileSync(file, e.content)
104
+ back.push(rel)
105
+ }
106
+ return back
107
+ }
108
+
109
+ const INLINE_FILE_BYTES = 12_000
110
+ const INLINE_TOTAL_BYTES = 60_000
111
+
112
+ /** The job's changed files for a fresh session's brief: contents inline while they are small, paths only past that. */
113
+ export function changedFiles(repo: string, paths: string[]): ChangedFile[] {
114
+ let total = 0
115
+ return paths.map((rel) => {
116
+ try {
117
+ const text = readFileSync(path.join(repo, rel), "utf8")
118
+ if (text.length > INLINE_FILE_BYTES || total + text.length > INLINE_TOTAL_BYTES) return { path: rel }
119
+ total += text.length
120
+ return { path: rel, content: text }
121
+ } catch {
122
+ return { path: rel }
123
+ }
124
+ })
125
+ }
126
+
127
+ /** The current contents of the given files (null for a file that does not exist), to put back later. */
128
+ export function snapshotFiles(repo: string, paths: string[]): Map<string, Buffer | null> {
129
+ return new Map(paths.map((rel) => [rel, read(repo, rel)]))
130
+ }
131
+
132
+ /**
133
+ * Put the files back as `files` had them. A file changed since that is not in it goes back to how it was before the
134
+ * job (the user's version, or the committed one), or is removed when the job created it after the snapshot.
135
+ */
136
+ export function restoreSnapshot(repo: string, tree: WorkTree, files: Map<string, Buffer | null>): void {
137
+ for (const [rel, content] of files) {
138
+ const file = path.join(repo, rel)
139
+ if (content === null) rmSync(file, { force: true })
140
+ else {
141
+ mkdirSync(path.dirname(file), { recursive: true })
142
+ writeFileSync(file, content)
143
+ }
144
+ }
145
+ for (const rel of jobChanges(tree) || []) {
146
+ if (files.has(rel)) continue
147
+ const file = path.join(repo, rel)
148
+ const was = tree.entries.get(rel)
149
+ if (was?.content) writeFileSync(file, was.content)
150
+ else if (spawnSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: repo }).status === 0) spawnSync("git", ["checkout", "--", rel], { cwd: repo })
151
+ else rmSync(file, { force: true })
152
+ }
153
+ }