@capagents/forgeqe 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,122 @@
1
+ import type { SourcesConfig } from "../config.ts"
2
+ import { adfToText, basic, envSecret, http, textToAdf } from "./http.ts"
3
+ import { stepVerdictLines, type CaseResult, type TestCase, type WriteBack } from "./types.ts"
4
+
5
+ export type JiraIssue = {
6
+ id: string
7
+ key: string
8
+ fields: {
9
+ summary?: string
10
+ description?: unknown
11
+ labels?: string[]
12
+ components?: { name?: string }[]
13
+ parent?: { key?: string; fields?: { summary?: string } }
14
+ issuelinks?: { type?: { outward?: string }; outwardIssue?: { key?: string } }[]
15
+ }
16
+ }
17
+
18
+ export type JiraApi = { url: string; cloud: boolean; auth: string; api: string }
19
+
20
+ export function jiraApi(cfg: SourcesConfig, what = "Jira"): JiraApi {
21
+ const j = cfg.jira
22
+ if (!j) throw new Error(`${what} needs sources.jira.url in forgeqe.yaml, or JIRA_URL in the environment.`)
23
+ const token = envSecret(j.tokenEnv, what)
24
+ const auth = j.cloud ? basic(envSecret(j.emailEnv, `${what} (Jira Cloud)`), token) : `Bearer ${token}`
25
+ return { url: j.url, cloud: j.cloud, auth, api: `${j.url}/rest/api/${j.cloud ? 3 : 2}` }
26
+ }
27
+
28
+ const FIELDS = "summary,description,labels,components,parent,issuelinks"
29
+
30
+ /** Issues by key (`SHOP-1,SHOP-2`) or by JQL (`jql=project = SHOP AND labels = e2e`). */
31
+ export async function jiraIssues(ref: string, j: JiraApi): Promise<JiraIssue[]> {
32
+ const jql = ref.match(/^jql=(.+)$/s)?.[1]
33
+ if (!jql) {
34
+ const keys = ref.split(/[,\s]+/).map((k) => k.trim().toUpperCase()).filter(Boolean)
35
+ if (!keys.length) throw new Error(`Jira reference "${ref}": use jira:SHOP-1,SHOP-2 or jira:jql=<query>.`)
36
+ const out: JiraIssue[] = []
37
+ for (const key of keys) out.push((await http<JiraIssue>(`${j.api}/issue/${encodeURIComponent(key)}?fields=${FIELDS}`, { auth: j.auth })).data)
38
+ return out
39
+ }
40
+ const out: JiraIssue[] = []
41
+ if (j.cloud) {
42
+ let next: string | undefined
43
+ do {
44
+ const q = new URLSearchParams({ jql, fields: FIELDS, maxResults: "100", ...(next ? { nextPageToken: next } : {}) })
45
+ const { data } = await http<{ issues?: JiraIssue[]; nextPageToken?: string; isLast?: boolean }>(`${j.api}/search/jql?${q}`, { auth: j.auth })
46
+ out.push(...(data?.issues || []))
47
+ next = data?.isLast === false || data?.nextPageToken ? data?.nextPageToken : undefined
48
+ } while (next)
49
+ } else {
50
+ let startAt = 0
51
+ for (;;) {
52
+ const q = new URLSearchParams({ jql, fields: FIELDS, maxResults: "100", startAt: String(startAt) })
53
+ const { data } = await http<{ issues?: JiraIssue[]; total?: number }>(`${j.api}/search?${q}`, { auth: j.auth })
54
+ const page = data?.issues || []
55
+ out.push(...page)
56
+ startAt += page.length
57
+ if (!page.length || startAt >= Number(data?.total || 0)) break
58
+ }
59
+ }
60
+ if (!out.length) throw new Error(`No Jira issues match: ${jql}`)
61
+ return out
62
+ }
63
+
64
+ /** A Jira issue as a test case: summary is the title, the description holds the steps and expected outcome. */
65
+ function issueGroup(issue: JiraIssue): string | undefined {
66
+ const components = (issue.fields.components || []).map((c) => c.name).filter(Boolean)
67
+ if (components.length) return components.join(", ")
68
+ const p = issue.fields.parent
69
+ return p?.key ? `${p.key}${p.fields?.summary ? ` ${p.fields.summary}` : ""}` : undefined
70
+ }
71
+
72
+ /** Issues this one "tests", through Jira's Test link type (outward "tests"). */
73
+ function testedIssues(issue: JiraIssue): string[] | undefined {
74
+ const keys = (issue.fields.issuelinks || [])
75
+ .filter((l) => l.outwardIssue?.key && /^tests$/i.test((l.type?.outward || "").trim()))
76
+ .map((l) => l.outwardIssue!.key!)
77
+ return keys.length ? keys : undefined
78
+ }
79
+
80
+ export async function jiraCases(ref: string, cfg: SourcesConfig): Promise<TestCase[]> {
81
+ const j = jiraApi(cfg)
82
+ return (await jiraIssues(ref, j)).map((issue) => ({
83
+ id: issue.key,
84
+ title: issue.fields.summary || issue.key,
85
+ source: "jira" as const,
86
+ url: `${j.url}/browse/${issue.key}`,
87
+ description: adfToText(issue.fields.description) || undefined,
88
+ steps: [],
89
+ tags: issue.fields.labels?.length ? issue.fields.labels : undefined,
90
+ group: issueGroup(issue),
91
+ requirements: testedIssues(issue),
92
+ ref: { issue: issue.key },
93
+ }))
94
+ }
95
+
96
+ export async function jiraWriteBack(results: CaseResult[], cfg: SourcesConfig): Promise<WriteBack[]> {
97
+ const j = jiraApi(cfg)
98
+ const out: WriteBack[] = []
99
+ for (const r of results) {
100
+ const key = String(r.case.ref?.issue || r.case.id)
101
+ const text = resultText(r)
102
+ try {
103
+ await http(`${j.api}/issue/${encodeURIComponent(key)}/comment`, { auth: j.auth, body: { body: j.cloud ? textToAdf(text) : text } })
104
+ out.push({ source: "jira", caseId: r.case.id, text: `${key}: result added as a comment`, url: r.case.url })
105
+ } catch (err) {
106
+ out.push({ source: "jira", caseId: r.case.id, text: `${key}: comment not added`, error: (err as Error).message })
107
+ }
108
+ }
109
+ return out
110
+ }
111
+
112
+ export function resultText(r: CaseResult): string {
113
+ const lines = [`ForgeQE: ${r.status.toUpperCase()} in ${Math.round(r.ms / 1000)}s`, "", r.summary.trim()]
114
+ const steps = stepVerdictLines(r)
115
+ if (steps.length) lines.push("", ...steps)
116
+ if (r.finalUrl) lines.push("", `Ended on ${r.finalUrl}`)
117
+ if (r.code?.testFile) {
118
+ lines.push("", `Test ${r.code.status === "passed" ? "written and passing" : r.code.status}: ${r.code.testFile}`)
119
+ if (r.code.command) lines.push(`Run with: ${r.code.command}`)
120
+ }
121
+ return lines.join("\n").trim()
122
+ }
@@ -0,0 +1,108 @@
1
+ import type { SourcesConfig } from "../config.ts"
2
+ import { envSecret, htmlToText, http } from "./http.ts"
3
+ import { resultText } from "./jira.ts"
4
+ import type { CaseResult, CaseStep, TestCase, WriteBack } from "./types.ts"
5
+
6
+ type QCase = {
7
+ id: number
8
+ pid?: string
9
+ name: string
10
+ description?: string | null
11
+ precondition?: string | null
12
+ web_url?: string
13
+ test_steps?: { description?: string | null; expected?: string | null; order?: number }[]
14
+ }
15
+ type QRun = { id: number; pid?: string; name?: string; test_case?: { id: number } }
16
+
17
+ export function qtestApi(cfg: SourcesConfig) {
18
+ const q = cfg.qtest
19
+ if (!q) throw new Error("qTest is not set up. Add sources.qtest.url and project_id to forgeqe.yaml, or set QTEST_URL and QTEST_PROJECT_ID.")
20
+ const token = envSecret(q.tokenEnv, "qTest")
21
+ return { base: q.url, api: `${q.url}/api/v3/projects/${q.projectId}`, auth: /^bearer\s/i.test(token) ? token : `Bearer ${token}`, statuses: q.statuses }
22
+ }
23
+
24
+ function toCase(c: QCase, run?: QRun): TestCase {
25
+ const steps: CaseStep[] = [...(c.test_steps || [])]
26
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
27
+ .map((s) => ({ action: htmlToText(s.description || ""), expected: htmlToText(s.expected || "") || undefined }))
28
+ .filter((s) => s.action)
29
+ return {
30
+ id: c.pid || `TC-${c.id}`,
31
+ title: c.name,
32
+ source: "qtest",
33
+ url: c.web_url,
34
+ description: htmlToText(c.description || "") || undefined,
35
+ preconditions: htmlToText(c.precondition || "") ? [htmlToText(c.precondition || "")] : undefined,
36
+ steps,
37
+ ref: { testCase: c.id, ...(run ? { run: run.id } : {}) },
38
+ }
39
+ }
40
+
41
+ /** `qtest:12345,12346` (test case ids from the URL), `qtest:cycle=678` or `qtest:suite=910` (their test runs; results go to those runs). */
42
+ export async function qtestCases(ref: string, cfg: SourcesConfig): Promise<TestCase[]> {
43
+ const q = qtestApi(cfg)
44
+ const getCase = async (id: number) => (await http<QCase>(`${q.api}/test-cases/${id}?expandSteps=true`, { auth: q.auth })).data
45
+ const parent = ref.match(/^(cycle|suite)=(\d+)$/i)
46
+ if (parent) {
47
+ const type = parent[1].toLowerCase() === "cycle" ? "test-cycle" : "test-suite"
48
+ const runs: QRun[] = []
49
+ for (let page = 1; ; page++) {
50
+ const { data } = await http<QRun[] | { items?: QRun[] }>(`${q.api}/test-runs?parentId=${parent[2]}&parentType=${type}&page=${page}&pageSize=100`, { auth: q.auth })
51
+ const items = Array.isArray(data) ? data : data?.items || []
52
+ runs.push(...items)
53
+ if (items.length < 100) break
54
+ }
55
+ const out: TestCase[] = []
56
+ for (const run of runs) if (run.test_case?.id) out.push(toCase(await getCase(run.test_case.id), run))
57
+ if (!out.length) throw new Error(`No test runs in qTest ${type} ${parent[2]}.`)
58
+ return out
59
+ }
60
+ const ids = ref.split(/[,\s]+/).map((s) => Number(s)).filter((n) => Number.isInteger(n) && n > 0)
61
+ if (!ids.length) throw new Error(`qTest reference "${ref}": use qtest:12345 (the test case id from its URL), qtest:cycle=678, or qtest:suite=910.`)
62
+ const out: TestCase[] = []
63
+ for (const id of ids) out.push(toCase(await getCase(id)))
64
+ return out
65
+ }
66
+
67
+ /** An automation test log per case, on the test run it came from; cases read by id get test runs in a new test cycle. */
68
+ export async function qtestWriteBack(results: CaseResult[], cfg: SourcesConfig, runName: string): Promise<WriteBack[]> {
69
+ const q = qtestApi(cfg)
70
+ const status = { passed: q.statuses.passed, failed: q.statuses.failed, error: q.statuses.error } as const
71
+ const stepStatus = { passed: q.statuses.passed, failed: q.statuses.failed, not_run: q.statuses.error } as const
72
+ const out: WriteBack[] = []
73
+ let cycle: number | undefined
74
+ let logged = 0
75
+ for (const r of results) {
76
+ try {
77
+ let runId = Number(r.case.ref?.run || 0)
78
+ if (!runId) {
79
+ if (!cycle) cycle = (await http<{ id: number }>(`${q.api}/test-cycles?parentId=0&parentType=root`, { auth: q.auth, body: { name: runName } })).data.id
80
+ runId = (await http<{ id: number }>(`${q.api}/test-runs?parentId=${cycle}&parentType=test-cycle`, { auth: q.auth, body: { name: r.case.title, test_case: { id: Number(r.case.ref?.testCase) } } })).data.id
81
+ }
82
+ const verdicts = new Map((r.stepResults || []).map((v) => [v.step, v]))
83
+ const body: Record<string, unknown> = {
84
+ name: r.case.title,
85
+ status: status[r.status as keyof typeof status] ?? q.statuses.error,
86
+ exe_start_date: r.startedAt,
87
+ exe_end_date: r.finishedAt,
88
+ note: resultText(r).slice(0, 4000),
89
+ }
90
+ const withSteps = verdicts.size
91
+ ? {
92
+ ...body,
93
+ test_step_logs: r.case.steps.map((s, i) => {
94
+ const v = verdicts.get(i + 1)
95
+ return { description: s.action, expected_result: s.expected || "", actual_result: v?.actual || "", status: v ? stepStatus[v.status] : q.statuses.error, order: i + 1 }
96
+ }),
97
+ }
98
+ : body
99
+ const url = `${q.api}/test-runs/${runId}/auto-test-logs`
100
+ await http(url, { auth: q.auth, body: withSteps }).catch((err) => (withSteps === body ? Promise.reject(err) : http(url, { auth: q.auth, body })))
101
+ logged++
102
+ } catch (err) {
103
+ out.push({ source: "qtest", caseId: r.case.id, text: `${r.case.id}: result not recorded`, error: (err as Error).message })
104
+ }
105
+ }
106
+ if (logged) out.push({ source: "qtest", text: `qTest: ${logged} test log${logged === 1 ? "" : "s"}${cycle ? ` (new test cycle ${cycle})` : ""}` })
107
+ return out
108
+ }
@@ -0,0 +1,190 @@
1
+ import { findPage, type MemoryPage } from "../memory.ts"
2
+ import type { TestCase } from "./types.ts"
3
+
4
+ /**
5
+ * Test case quality, checked before a case costs a browser run: steps a browser agent cannot follow, or expected
6
+ * results it cannot check, make a run fail for the test case's sake rather than the application's. These checks are
7
+ * rules (no model): what they cannot judge (whether a step is ambiguous for this application) is left to the agent.
8
+ */
9
+
10
+ export type CheckSeverity = "blocker" | "warning" | "info"
11
+
12
+ export type CheckCode =
13
+ | "no_steps"
14
+ | "no_expected_results"
15
+ | "step_without_expected"
16
+ | "vague_expected"
17
+ | "missing_test_data"
18
+ | "placeholder"
19
+ | "credential_in_case"
20
+ | "several_actions"
21
+ | "no_start"
22
+ | "duplicate"
23
+ | "long_case"
24
+ | "not_on_page"
25
+
26
+ export type CaseFinding = { code: CheckCode; severity: CheckSeverity; step?: number; message: string; suggestion: string }
27
+
28
+ /** ready: nothing to fix; review: worth fixing, it runs; not_runnable: a browser agent cannot test it as written. */
29
+ export type CaseVerdict = "ready" | "review" | "not_runnable"
30
+
31
+ export type CaseCheck = { id: string; title: string; url?: string; verdict: CaseVerdict; findings: CaseFinding[] }
32
+
33
+ const VAGUE = /^(it |the (page|app|application|system|form) )?(works?|working|ok|okay|fine|success(ful(ly)?)?|correct(ly)?|proper(ly)?|as expected|no (error|issue)s?|should work|passes|is (displayed|shown|done))\.?$/i
34
+ const VAGUE_WORDS = /\b(as expected|works? (fine|correctly|properly)|correctly|properly|appropriate(ly)?|successfully)\b/i
35
+ const NEEDS_DATA = /\b(valid|invalid|some|any|appropriate|relevant|correct|required|mandatory|test)\s+(details|data|values?|information|info|inputs?|users?|credentials|e-?mail( address)?|password|name|address)\b/i
36
+ const PLACEHOLDER = /\b(TBD|TODO|FIXME|xxx+|lorem ipsum)\b|<[^<>\s]{2,30}>|\{\{[^}]*\}\}|\[(insert|enter|add)[^\]]*\]/i
37
+ const CREDENTIAL = /\b(password|passwd|pwd|secret|api[ -]?key|token)\s*[:=]\s*\S+/i
38
+ const START = /\b(https?:\/\/|www\.|navigate|open|go to|launch|visit|browse to|log ?in|sign ?in)\b/i
39
+
40
+ const words = (s: string) => s.trim().split(/\s+/).filter(Boolean).length
41
+ const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()
42
+
43
+ /** Every rule on one case; duplicates are found across cases by checkCases. */
44
+ export function checkCase(tc: TestCase): CaseFinding[] {
45
+ const out: CaseFinding[] = []
46
+ const steps = tc.steps
47
+ if (!steps.length) {
48
+ out.push(
49
+ tc.description?.trim()
50
+ ? { code: "no_steps", severity: "warning", message: "No numbered steps: the browser agent works from the description alone.", suggestion: "Write the description as numbered steps, each with an expected result." }
51
+ : { code: "no_steps", severity: "blocker", message: "No steps and no description: there is nothing to test.", suggestion: "Add numbered steps, each with an expected result." },
52
+ )
53
+ return out
54
+ }
55
+ const withExpected = steps.filter((s) => s.expected?.trim())
56
+ if (!withExpected.length) {
57
+ out.push({ code: "no_expected_results", severity: "blocker", message: "No step has an expected result, so the run cannot pass or fail on anything.", suggestion: "Give at least the steps that matter an expected result the page shows (a message, a value, a page)." })
58
+ }
59
+ steps.forEach((s, i) => {
60
+ const n = i + 1
61
+ const text = `${s.action} ${s.data || ""} ${s.expected || ""}`
62
+ const last = i === steps.length - 1
63
+ if (withExpected.length && !s.expected?.trim() && last) {
64
+ out.push({ code: "step_without_expected", severity: "warning", step: n, message: "The last step has no expected result, so the case does not say how it ends.", suggestion: "Say what the page shows when the case has passed." })
65
+ }
66
+ if (s.expected?.trim() && (VAGUE.test(s.expected.trim()) || (VAGUE_WORDS.test(s.expected) && words(s.expected) <= 6))) {
67
+ out.push({
68
+ code: "vague_expected",
69
+ severity: "warning",
70
+ step: n,
71
+ message: `"${s.expected.trim()}" does not say what the page shows, so a run can only guess what passing means.`,
72
+ suggestion: "Name what is on the page when it works: a confirmation message, a value, a heading, a URL.",
73
+ })
74
+ }
75
+ if (NEEDS_DATA.test(s.action) && !s.data?.trim() && !/["'“][^"'”]+["'”]|\d/.test(s.action)) {
76
+ out.push({ code: "missing_test_data", severity: "warning", step: n, message: `"${clip(s.action, 80)}" does not give the values to use.`, suggestion: "Give the values (or a data table) the step should use, or say where they come from." })
77
+ }
78
+ if (PLACEHOLDER.test(text)) out.push({ code: "placeholder", severity: "blocker", step: n, message: `An unfilled placeholder: "${clip(PLACEHOLDER.exec(text)![0], 40)}".`, suggestion: "Replace the placeholder with the real value." })
79
+ if (CREDENTIAL.test(text)) {
80
+ out.push({ code: "credential_in_case", severity: "warning", step: n, message: "A password or key is written in the test case.", suggestion: "Keep credentials out of test cases: use a saved login (logins: in forgeqe.yaml) and say \"sign in as <role>\"." })
81
+ }
82
+ if ((s.action.match(/\b(and then|then|and)\b|;/gi) || []).length >= 3) {
83
+ out.push({ code: "several_actions", severity: "info", step: n, message: "Several actions in one step, so a failure cannot say which of them went wrong.", suggestion: "Split it into one action per step." })
84
+ }
85
+ })
86
+ if (!tc.startUrl && !START.test(steps[0].action) && !START.test(tc.description || "") && !(tc.preconditions || []).some((p) => START.test(p))) {
87
+ out.push({ code: "no_start", severity: "warning", step: 1, message: "The case does not say where to start (a URL or page).", suggestion: "Start with \"Open <URL>\", or set the start page in the case." })
88
+ }
89
+ if (steps.length > 25) out.push({ code: "long_case", severity: "info", message: `${steps.length} steps in one case: one failure stops the rest from being checked.`, suggestion: "Split it into cases that each check one behaviour." })
90
+ return out
91
+ }
92
+
93
+ /** Fields and controls a step can name without quoting them: "enter a valid email address". */
94
+ const CONTROL_WORDS = [
95
+ "email", "e-mail", "password", "username", "user name", "first name", "last name", "full name", "phone", "mobile",
96
+ "address", "postcode", "zip", "city", "country", "date of birth", "date", "search", "message", "comment", "subject",
97
+ "company", "quantity", "coupon", "promo code", "captcha", "terms", "upload", "file",
98
+ ]
99
+ const QUOTED = /["“'‘]([^"”'’]{2,40})["”'’]/g
100
+ const BEFORE_CONTROL = /((?:[A-Za-z][\w-]*\s+){0,2}[A-Za-z][\w-]*)\s+(?:field|button|link|checkbox|check box|dropdown|drop-down|radio|tab|menu|textbox|text box|input|option)\b/gi
101
+ const FILLER = new Set(["the", "a", "an", "all", "any", "valid", "invalid", "new", "mandatory", "required", "and", "or", "then", "from", "on", "in", "into", "to", "click", "tick", "untick", "select", "choose", "enter", "type", "press", "open", "fill", "check", "uncheck", "its", "this", "that", "of"])
102
+ const URL = /https?:\/\/[^\s"'<>)]+/i
103
+
104
+ /** What a step names on the page: quoted labels, "X button" / "X field", and common field words. */
105
+ export function namedControls(action: string): string[] {
106
+ const out = new Set<string>()
107
+ const keep = (name: string) => {
108
+ const n = name.trim().toLowerCase()
109
+ if (n && !URL.test(n) && !/^\d+$/.test(n)) out.add(n)
110
+ }
111
+ for (const m of action.matchAll(QUOTED)) keep(m[1])
112
+ const unquoted = action.replace(QUOTED, " ")
113
+ for (const m of unquoted.matchAll(BEFORE_CONTROL)) {
114
+ // The control's name is the last word or two before "button", "field"…, without the verbs and articles around it.
115
+ const words = m[1].split(/\s+/)
116
+ while (words.length && FILLER.has(words[0].toLowerCase())) words.shift()
117
+ if (words.length) keep(words.join(" "))
118
+ }
119
+ const low = action.toLowerCase()
120
+ for (const w of CONTROL_WORDS) if (new RegExp(`\\b${w}\\b`).test(low)) out.add(w)
121
+ // "email address" is the email field, not an address field.
122
+ if (/\bemail address\b/.test(low) && !/\baddress (field|line)\b/.test(low)) out.delete("address")
123
+ return [...out]
124
+ }
125
+
126
+ const label = (e: MemoryPage["elements"][string]) =>
127
+ [e.name, e.text, e.attributes?.placeholder, e.attributes?.["aria-label"], e.attributes?.name, e.attributes?.id, e.attributes?.title, e.attributes?.type]
128
+ .filter(Boolean)
129
+ .join(" ")
130
+ .toLowerCase()
131
+
132
+ /**
133
+ * Steps that name a field or control earlier runs never saw on that page: page memory has every element ForgeQE met
134
+ * on each page. Only pages it knows well (several visits, a fair number of elements) are judged, and a miss is a
135
+ * warning: the control may appear later (after a click), or the page may have changed.
136
+ */
137
+ export function checkAgainstPages(tc: TestCase, pages: MemoryPage[]): CaseFinding[] {
138
+ const out: CaseFinding[] = []
139
+ let url = tc.startUrl || ""
140
+ tc.steps.forEach((s, i) => {
141
+ const found = URL.exec(`${s.action} ${s.data || ""}`)?.[0]
142
+ if (found) {
143
+ url = found.replace(/[.,;]+$/, "")
144
+ return
145
+ }
146
+ if (!url) return
147
+ const page = findPage(pages, url)
148
+ const elements = page ? Object.values(page.elements) : []
149
+ if (!page || elements.length < 8 || (page.visits || 0) < 1) return
150
+ const labels = elements.map(label)
151
+ const missing = namedControls(s.action).filter((name) => !labels.some((l) => l.includes(name.replace(/-/g, "")) || l.includes(name)))
152
+ if (!missing.length) return
153
+ const seen = [...new Set(elements.filter((e) => e.interactions || ["input", "select", "textarea", "button"].includes(e.tag)).map((e) => (e.name || e.attributes?.placeholder || e.text || "").trim()).filter((n) => n && n.length <= 30))].slice(0, 10)
154
+ out.push({
155
+ code: "not_on_page",
156
+ severity: "warning",
157
+ step: i + 1,
158
+ message: `${missing.map((m) => `"${m}"`).join(", ")} ${missing.length === 1 ? "was" : "were"} never seen on ${page.host}${page.path} in ${page.visits} earlier ${page.visits === 1 ? "visit" : "visits"}${seen.length ? `; it has ${seen.join(", ")}` : ""}.`,
159
+ suggestion: "Check the step against the page: the control may have another name, sit on another page, or not exist (then the test case needs updating).",
160
+ })
161
+ })
162
+ return out
163
+ }
164
+
165
+ function verdictOf(findings: CaseFinding[]): CaseVerdict {
166
+ if (findings.some((f) => f.severity === "blocker")) return "not_runnable"
167
+ if (findings.some((f) => f.severity === "warning")) return "review"
168
+ return "ready"
169
+ }
170
+
171
+ /** Check each case (against page memory too, when given), and cases whose steps are the same as an earlier one's. */
172
+ export function checkCases(cases: TestCase[], pages: MemoryPage[] = []): CaseCheck[] {
173
+ const seen = new Map<string, TestCase>()
174
+ return cases.map((tc) => {
175
+ const findings = [...checkCase(tc), ...(pages.length ? checkAgainstPages(tc, pages) : [])]
176
+ const signature = tc.steps.map((s) => `${norm(s.action)}>${norm(s.expected || "")}`).join("|")
177
+ const same = signature && seen.get(signature)
178
+ if (same) findings.push({ code: "duplicate", severity: "warning", message: `Same steps and expected results as ${same.id} "${same.title}".`, suggestion: `Merge it with ${same.id}, or make the difference explicit.` })
179
+ else if (signature) seen.set(signature, tc)
180
+ return { id: tc.id, title: tc.title, ...(tc.url ? { url: tc.url } : {}), verdict: verdictOf(findings), findings }
181
+ })
182
+ }
183
+
184
+ /** "3 ready, 1 to review, 1 not runnable". */
185
+ export function checkSummary(checks: CaseCheck[]): string {
186
+ const n = (v: CaseVerdict) => checks.filter((c) => c.verdict === v).length
187
+ return [`${n("ready")} ready`, n("review") ? `${n("review")} to review` : "", n("not_runnable") ? `${n("not_runnable")} not runnable` : ""].filter(Boolean).join(", ")
188
+ }
189
+
190
+ const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
@@ -0,0 +1,100 @@
1
+ import { spawn } from "node:child_process"
2
+
3
+ import { withoutTelemetry } from "../privacy.ts"
4
+
5
+ /**
6
+ * An optional retrieval service for requirements (a RAG index over Confluence, a wiki, specs, tickets): ForgeQE asks
7
+ * it for the passages relevant to a test case and adds them to the quality check. Plug and play, either kind:
8
+ *
9
+ * rag:
10
+ * url: http://localhost:8080/search # POST {"query": "...", "top_k": 5}
11
+ * token_env: RAG_TOKEN # optional: sent as "Authorization: Bearer <value>"
12
+ * top_k: 5
13
+ *
14
+ * rag:
15
+ * command: ["python", "rag/search.py"] # gets {"query": "...", "top_k": 5} on stdin, prints the answer on stdout
16
+ *
17
+ * The answer is a JSON list of passages, or an object with one under results, passages, documents, or hits. A passage
18
+ * is { text, source, score } (also content / page_content for the text, url / title / metadata.source for the
19
+ * source). A RAG server reachable over MCP (under mcp: in forgeqe.yaml) works too: ForgeQE's agent calls its tools.
20
+ */
21
+
22
+ export type RagConfig = { url?: string; command?: string[]; tokenEnv?: string; topK: number; timeoutMs: number }
23
+ export type Passage = { text: string; source?: string; score?: number }
24
+
25
+ const MAX_TEXT = 1500
26
+
27
+ export function ragConfig(raw: unknown): RagConfig | undefined {
28
+ if (!raw || typeof raw !== "object") return undefined
29
+ const r = raw as Record<string, unknown>
30
+ const url = r.url ? String(r.url) : undefined
31
+ const command = Array.isArray(r.command) ? r.command.map(String) : typeof r.command === "string" ? r.command.split(/\s+/).filter(Boolean) : undefined
32
+ if (!url && !command?.length) return undefined
33
+ return {
34
+ ...(url ? { url } : {}),
35
+ ...(command?.length ? { command } : {}),
36
+ ...(r.token_env ? { tokenEnv: String(r.token_env) } : {}),
37
+ topK: Number(r.top_k) > 0 ? Math.min(20, Number(r.top_k)) : 5,
38
+ timeoutMs: Number(r.timeout_seconds) > 0 ? Number(r.timeout_seconds) * 1000 : 20_000,
39
+ }
40
+ }
41
+
42
+ /** Passages from any of the answer shapes above, best first, text shortened. */
43
+ export function passagesFrom(answer: unknown): Passage[] {
44
+ const list = Array.isArray(answer)
45
+ ? answer
46
+ : answer && typeof answer === "object"
47
+ ? ((answer as Record<string, unknown>).results ?? (answer as Record<string, unknown>).passages ?? (answer as Record<string, unknown>).documents ?? (answer as Record<string, unknown>).hits)
48
+ : undefined
49
+ if (!Array.isArray(list)) return []
50
+ return list
51
+ .map((p): Passage | null => {
52
+ if (typeof p === "string") return { text: p.slice(0, MAX_TEXT) }
53
+ if (!p || typeof p !== "object") return null
54
+ const o = p as Record<string, any>
55
+ const text = String(o.text ?? o.content ?? o.page_content ?? o.chunk ?? "").trim()
56
+ if (!text) return null
57
+ const source = o.source ?? o.url ?? o.title ?? o.metadata?.source ?? o.metadata?.url ?? o.metadata?.title
58
+ const score = typeof o.score === "number" ? o.score : typeof o.similarity === "number" ? o.similarity : undefined
59
+ return { text: text.slice(0, MAX_TEXT), ...(source ? { source: String(source) } : {}), ...(score !== undefined ? { score } : {}) }
60
+ })
61
+ .filter((p): p is Passage => p !== null)
62
+ }
63
+
64
+ /** Ask the configured service; throws with what went wrong, so the check can say why there are no passages. */
65
+ export async function searchRequirements(cfg: RagConfig, query: string): Promise<Passage[]> {
66
+ const body = JSON.stringify({ query, top_k: cfg.topK })
67
+ if (cfg.url) {
68
+ const token = cfg.tokenEnv ? process.env[cfg.tokenEnv] : undefined
69
+ if (cfg.tokenEnv && !token) throw new Error(`rag.token_env names ${cfg.tokenEnv}, which is not set`)
70
+ const res = await fetch(cfg.url, {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json", Accept: "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
73
+ body,
74
+ signal: AbortSignal.timeout(cfg.timeoutMs),
75
+ })
76
+ if (!res.ok) throw new Error(`the RAG service answered ${res.status}`)
77
+ return passagesFrom(await res.json()).slice(0, cfg.topK)
78
+ }
79
+ const [bin, ...args] = cfg.command!
80
+ const out = await new Promise<string>((resolve, reject) => {
81
+ const child = spawn(bin, args, { env: withoutTelemetry(process.env), stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" })
82
+ let stdout = ""
83
+ let stderr = ""
84
+ const timer = setTimeout(() => child.kill(), cfg.timeoutMs)
85
+ child.stdout.on("data", (d) => (stdout += d))
86
+ child.stderr.on("data", (d) => (stderr += d))
87
+ child.on("error", reject)
88
+ child.on("close", (code) => {
89
+ clearTimeout(timer)
90
+ if (code === 0) resolve(stdout)
91
+ else reject(new Error(`the RAG command exited ${code}: ${stderr.trim().slice(-300)}`))
92
+ })
93
+ child.stdin.end(body)
94
+ })
95
+ try {
96
+ return passagesFrom(JSON.parse(out)).slice(0, cfg.topK)
97
+ } catch {
98
+ throw new Error("the RAG command did not print JSON")
99
+ }
100
+ }