@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,232 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { AppConfig } from "../config.ts"
5
+ import type { Card } from "../card.ts"
6
+ import type { Contract, ContractRun, ContractSet, Scenario, ScenarioRun, ScenarioStep } from "./types.ts"
7
+
8
+ /** Contract sets live in <export dir>/api/<id>/: contracts.json (edit its examples freely), api.md, and each run's results. */
9
+
10
+ export const apiRoot = (config: AppConfig) => path.join(config.export.outDir, "api")
11
+
12
+ export function newSetId(config: AppConfig, hint: string): string {
13
+ const slug = hint.toLowerCase().replace(/^https?:\/\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "api"
14
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..*/, "").replace("T", "-")
15
+ let id = `${stamp}-${slug}`
16
+ for (let n = 2; existsSync(path.join(apiRoot(config), id)); n++) id = `${stamp}-${slug}-${n}`
17
+ return id
18
+ }
19
+
20
+ export function saveSet(config: AppConfig, set: ContractSet): { dir: string; file: string; brief: string } {
21
+ const dir = path.join(apiRoot(config), set.id)
22
+ mkdirSync(dir, { recursive: true })
23
+ const file = path.join(dir, "contracts.json")
24
+ writeFileSync(file, `${JSON.stringify(set, null, 2)}\n`)
25
+ const brief = path.join(dir, "api.md")
26
+ writeFileSync(brief, briefMarkdown(set))
27
+ return { dir, file, brief }
28
+ }
29
+
30
+ /** A saved set by id (or the newest when id is "latest" or empty), or a contracts.json path. */
31
+ export function loadSet(config: AppConfig, ref?: string): { set: ContractSet; dir: string } {
32
+ const r = (ref || "").trim()
33
+ let file: string
34
+ if (r.endsWith(".json") && existsSync(path.resolve(r))) file = path.resolve(r)
35
+ else {
36
+ const id = !r || r === "latest" ? listSets(config)[0]?.id : path.basename(r)
37
+ if (!id) throw new Error("No API contracts yet: make some with api_contracts (from a browser run or an API description).")
38
+ file = path.join(apiRoot(config), id, "contracts.json")
39
+ if (!existsSync(file)) throw new Error(`No API contracts "${r}". Saved sets: ${listSets(config).slice(0, 8).map((s) => s.id).join(", ") || "none"}.`)
40
+ }
41
+ const set = JSON.parse(readFileSync(file, "utf8")) as ContractSet
42
+ if (!Array.isArray(set?.contracts)) throw new Error(`${file} is not a contracts file.`)
43
+ return { set, dir: path.dirname(file) }
44
+ }
45
+
46
+ export function listSets(config: AppConfig): { id: string; name: string; contracts: number; scenarios: number; at: number }[] {
47
+ const root = apiRoot(config)
48
+ if (!existsSync(root)) return []
49
+ return readdirSync(root)
50
+ .map((id) => {
51
+ const file = path.join(root, id, "contracts.json")
52
+ if (!existsSync(file)) return null
53
+ try {
54
+ const s = JSON.parse(readFileSync(file, "utf8")) as ContractSet
55
+ return { id, name: s.name, contracts: s.contracts.length, scenarios: s.scenarios?.length || 0, at: statSync(file).mtimeMs }
56
+ } catch {
57
+ return null
58
+ }
59
+ })
60
+ .filter((x): x is NonNullable<typeof x> => Boolean(x))
61
+ .sort((a, b) => b.at - a.at)
62
+ }
63
+
64
+ const json = (v: unknown, max = 1200) => {
65
+ const t = typeof v === "string" ? v : JSON.stringify(v, null, 1)
66
+ return t.length > max ? `${t.slice(0, max)}\n… (${t.length - max} more characters in contracts.json)` : t
67
+ }
68
+
69
+ /** The schema as short lines: field: type (required marked), nested two levels. Cheaper than the schema itself for the agent. */
70
+ function schemaLines(s: Contract["expect"]["schema"], at = "", depth = 0, out: string[] = []): string[] {
71
+ if (!s || depth > 2 || out.length > 30) return out
72
+ const t = Array.isArray(s.type) ? s.type.join("|") : s.type || (s.anyOf ? "anyOf" : "any")
73
+ if (s.items) return schemaLines(s.items, `${at}[]`, depth, out)
74
+ const req = new Set(s.required || [])
75
+ for (const [k, p] of Object.entries(s.properties || {})) {
76
+ const pt = Array.isArray(p.type) ? p.type.join("|") : p.type || (p.anyOf ? "anyOf" : "any")
77
+ out.push(`${" ".repeat(depth)}- ${at}${at ? "." : ""}${k}: ${pt}${p.items ? `[${(Array.isArray(p.items.type) ? p.items.type.join("|") : p.items.type) || "any"}]` : ""}${req.has(k) ? " (required)" : ""}${p.nullable ? " (nullable)" : ""}${p.enum ? ` one of ${p.enum.slice(0, 6).join(", ")}` : ""}`)
78
+ if (p.properties || p.items?.properties) schemaLines(p.items?.properties ? p.items : p, `${at}${at ? "." : ""}${k}${p.items ? "[]" : ""}`, depth + 1, out)
79
+ }
80
+ if (!out.length && t) out.push(`- (${t})`)
81
+ return out
82
+ }
83
+
84
+ /** The set as markdown: for people to read, and for the coding agent to write tests from. */
85
+ export function briefMarkdown(set: ContractSet): string {
86
+ const lines = [
87
+ `# ${set.name}`,
88
+ "",
89
+ `Source: ${set.source.kind === "run" ? `browser run ${path.basename(set.source.ref)}` : `${set.source.kind} ${set.source.ref}`}`,
90
+ `Base URL: ${set.baseUrl || "(not known: set api.base_url)"}`,
91
+ `${set.contracts.length} operation${set.contracts.length === 1 ? "" : "s"}`,
92
+ "",
93
+ ]
94
+ if (set.skipped?.length) lines.push("Left out:", ...set.skipped.map((s) => `- ${s}`), "")
95
+ for (const c of set.contracts) {
96
+ lines.push(`## ${c.id}`, "")
97
+ if (c.name !== c.id) lines.push(c.name, "")
98
+ if (c.description && c.description !== c.name) lines.push(c.description, "")
99
+ const facts = [
100
+ `Protocol: ${c.protocol}`,
101
+ c.writes ? "Changes data: yes" : "Changes data: no",
102
+ c.auth ? `Auth: ${c.auth.type}${c.auth.name ? ` (${c.auth.name})` : ""}` : "",
103
+ c.source.samples ? `Seen ${c.source.samples} time${c.source.samples === 1 ? "" : "s"}${c.source.steps?.length ? ` in step${c.source.steps.length === 1 ? "" : "s"} ${c.source.steps.join(", ")}` : ""}` : "",
104
+ ].filter(Boolean)
105
+ lines.push(facts.join(" · "), "")
106
+ const q = c.example.query ? `?${new URLSearchParams(c.example.query)}` : ""
107
+ lines.push("Example call:", "", "```", `${c.method} ${c.example.path}${q}`)
108
+ for (const [k, v] of Object.entries(c.example.headers || {})) lines.push(`${k}: ${v}`)
109
+ if (c.example.body !== undefined) lines.push("", json(c.example.body))
110
+ lines.push("```", "")
111
+ if (c.needsData?.length) lines.push(`Needs real values for: ${c.needsData.join(", ")}`, "")
112
+ lines.push(`Expect: status ${c.protocol === "grpc" ? `${c.expect.status} (OK)` : c.expect.status}${c.expect.contentType ? `, ${c.expect.contentType}` : ""}`)
113
+ if (c.expect.elements?.length) lines.push(`Elements: ${c.expect.elements.join(", ")}`)
114
+ if (c.expect.fields?.length) lines.push("Key fields:", ...c.expect.fields.map((f) => `- ${f.path}${f.type ? `: ${f.type}` : ""}${f.equals !== undefined ? ` = ${JSON.stringify(f.equals)}` : ""}`))
115
+ if (c.expect.schema) {
116
+ const s = schemaLines(c.expect.schema)
117
+ if (s.length) lines.push("Body schema:", ...s)
118
+ }
119
+ lines.push("")
120
+ }
121
+ if (set.scenarios?.length) {
122
+ lines.push("# Scenarios", "", "Calls in order; {{name}} is a value saved from an earlier answer. {{$unique}} is new on every run.", "")
123
+ for (const sc of set.scenarios) lines.push(...scenarioMarkdown(sc), "")
124
+ }
125
+ return `${lines.join("\n").trimEnd()}\n`
126
+ }
127
+
128
+ function stepLine(st: ScenarioStep, i: number): string[] {
129
+ const q = st.query ? `?${Object.entries(st.query).map(([k, v]) => `${k}=${v}`).join("&")}` : ""
130
+ const status = st.expect.statusIn?.length ? st.expect.statusIn.join(" or ") : String(st.expect.status)
131
+ const out = [`${i + 1}. ${st.name}: ${st.method} ${st.path}${q} → ${status}${st.noAuth ? " (without credentials)" : ""}`]
132
+ if (st.body !== undefined) out.push(` body: ${json(st.body, 300).replace(/\n\s*/g, " ")}`)
133
+ if (st.save) out.push(` saves: ${Object.entries(st.save).map(([k, p]) => `${k} ← ${p}`).join(", ")}`)
134
+ if (st.expect.fields?.length) out.push(` checks: ${st.expect.fields.map((f) => `${f.path}${f.equals !== undefined ? ` = ${JSON.stringify(f.equals)}` : f.type ? `: ${f.type}` : ""}`).join(", ")}`)
135
+ return out
136
+ }
137
+
138
+ export function scenarioMarkdown(sc: Scenario): string[] {
139
+ const lines = [`## Scenario ${sc.id}: ${sc.name}`, "", [`Kind: ${sc.kind}`, sc.caseId ? `Test case: ${sc.caseId}${sc.caseTitle ? ` ${sc.caseTitle}` : ""}` : "", sc.writes ? "Changes data: yes" : "Changes data: no"].filter(Boolean).join(" · "), ""]
140
+ sc.steps.forEach((st, i) => lines.push(...stepLine(st, i)))
141
+ if (sc.cleanup?.length) {
142
+ lines.push("", "Cleanup when it fails part-way:")
143
+ sc.cleanup.forEach((st, i) => lines.push(...stepLine(st, i)))
144
+ }
145
+ if (sc.notes?.length) lines.push("", ...sc.notes.map((n) => `Note: ${n}`))
146
+ return lines
147
+ }
148
+
149
+ export function scenariosCard(set: ContractSet, made: Scenario[], dir: string, cwd: string, extra: string[] = []): Card {
150
+ const body: string[] = []
151
+ for (const sc of made.slice(0, 12)) {
152
+ body.push(`▸ ${sc.name}${sc.caseId ? ` [${sc.caseId}]` : ""}${sc.writes ? " · changes data" : ""}`)
153
+ sc.steps.slice(0, 10).forEach((st, i) => body.push(` ${stepLine(st, i)[0]}`))
154
+ if (sc.steps.length > 10) body.push(` and ${sc.steps.length - 10} more steps`)
155
+ for (const n of (sc.notes || []).slice(0, 4)) body.push(` note: ${n}`)
156
+ }
157
+ if (made.length > 12) body.push(`and ${made.length - 12} more scenarios`)
158
+ if (extra.length) body.push("", ...extra)
159
+ const r = path.relative(cwd, path.join(dir, "contracts.json"))
160
+ body.push("", `Saved ${r && !r.startsWith("..") ? r : path.join(dir, "contracts.json")} (scenarios) and api.md`)
161
+ const kinds = new Map<string, number>()
162
+ for (const sc of made) kinds.set(sc.kind, (kinds.get(sc.kind) || 0) + 1)
163
+ return { status: made.length ? "info" : "stopped", title: `${plural(made.length, "API scenario")}: ${set.name}`, stats: [...kinds].map(([k, n]) => `${n} ${k}`).join(" · "), body }
164
+ }
165
+
166
+ export function scenarioRunCard(run: ScenarioRun, set: ContractSet, cwd: string): Card {
167
+ const c = run.counts
168
+ const body: string[] = []
169
+ for (const r of run.results) {
170
+ body.push(`${r.status === "passed" ? "✓" : r.status === "skipped" ? "–" : "✗"} ${r.name}${r.ms ? ` ${(r.ms / 1000).toFixed(1)}s` : ""}`)
171
+ if (r.reason) body.push(` ${r.reason}`)
172
+ for (const st of r.steps) {
173
+ if (st.status === "passed" && r.status === "passed") continue
174
+ body.push(` ${st.status === "passed" ? "✓" : st.status === "skipped" ? "–" : "✗"} ${st.name}${st.httpStatus ? ` (${st.httpStatus})` : ""}`)
175
+ if (st.reason && st.status !== "skipped") body.push(` ${st.reason}`)
176
+ for (const k of st.checks.filter((x) => !x.ok)) body.push(` ${k.name}: ${k.detail || "failed"}`)
177
+ }
178
+ if (r.cleanup?.length) body.push(` cleanup: ${r.cleanup.map((x) => `${x.name} ${x.status}`).join(", ")}`)
179
+ }
180
+ const rel = path.relative(cwd, run.dir)
181
+ body.push("", `Results ${rel && !rel.startsWith("..") ? rel : run.dir}/scenario-results.json, scenarios-junit.xml`)
182
+ return {
183
+ status: c.failed || c.error ? "fail" : c.passed ? "ok" : "stopped",
184
+ title: `API scenarios ${c.failed || c.error ? "failed" : c.passed ? "passed" : "not run"}: ${set.name}`,
185
+ stats: [c.passed && `${c.passed} passed`, c.failed && `${c.failed} failed`, c.error && `${c.error} errors`, c.skipped && `${c.skipped} skipped`, run.baseUrl].filter(Boolean).join(" · "),
186
+ body,
187
+ }
188
+ }
189
+
190
+ const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`
191
+
192
+ export function setCard(set: ContractSet, dir: string, cwd: string): Card {
193
+ const rel = (p: string) => {
194
+ const r = path.relative(cwd, p)
195
+ return r && !r.startsWith("..") ? r : p
196
+ }
197
+ const by = new Map<string, number>()
198
+ for (const c of set.contracts) by.set(c.protocol, (by.get(c.protocol) || 0) + 1)
199
+ const body: string[] = []
200
+ for (const c of set.contracts.slice(0, 25)) {
201
+ const notes = [c.writes ? "changes data" : "", c.needsData?.length ? "needs real values" : "", c.source.samples ? `seen ${c.source.samples}×` : "", c.expect.fields?.length ? `${plural(c.expect.fields.length, "key field")}` : ""].filter(Boolean)
202
+ body.push(`• ${c.id} → ${c.protocol === "grpc" ? "OK" : c.expect.status}${notes.length ? ` · ${notes.join(", ")}` : ""}`)
203
+ }
204
+ if (set.contracts.length > 25) body.push(`and ${set.contracts.length - 25} more`)
205
+ if (set.skipped?.length) body.push("", ...set.skipped.map((s) => `Left out: ${s}`))
206
+ body.push("", `Base URL ${set.baseUrl || "not known (set api.base_url)"}`, `Saved ${rel(path.join(dir, "contracts.json"))} and api.md`)
207
+ return {
208
+ status: set.contracts.length ? "info" : "stopped",
209
+ title: `${plural(set.contracts.length, "API contract")}: ${set.name}`,
210
+ stats: [...by].map(([p, n]) => `${n} ${p === "rest" ? "REST" : p === "graphql" ? "GraphQL" : p === "soap" ? "SOAP" : "gRPC"}`).join(" · "),
211
+ body,
212
+ }
213
+ }
214
+
215
+ export function runCard(run: ContractRun, set: ContractSet, cwd: string): Card {
216
+ const c = run.counts
217
+ const body: string[] = []
218
+ for (const r of run.results) {
219
+ const mark = r.status === "passed" ? "✓" : r.status === "skipped" ? "–" : "✗"
220
+ body.push(`${mark} ${r.id}${r.ms !== undefined ? ` ${r.ms} ms` : ""}`)
221
+ if (r.status === "skipped" || r.status === "error") body.push(` ${r.reason}`)
222
+ for (const k of r.checks.filter((x) => !x.ok)) body.push(` ${k.name}: ${k.detail || "failed"}`)
223
+ }
224
+ const rel = path.relative(cwd, run.dir)
225
+ body.push("", `Results ${rel && !rel.startsWith("..") ? rel : run.dir}/results.json, junit.xml`)
226
+ return {
227
+ status: c.failed || c.error ? "fail" : c.passed ? "ok" : "stopped",
228
+ title: `API contracts ${c.failed || c.error ? "failed" : c.passed ? "passed" : "not run"}: ${set.name}`,
229
+ stats: [c.passed && `${c.passed} passed`, c.failed && `${c.failed} failed`, c.error && `${c.error} errors`, c.skipped && `${c.skipped} skipped`, run.baseUrl].filter(Boolean).join(" · "),
230
+ body,
231
+ }
232
+ }
@@ -0,0 +1,281 @@
1
+ import { XMLParser } from "fast-xml-parser"
2
+ import { existsSync, readFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { inferSchema, keyFields } from "./schema.ts"
6
+ import type { Contract, ContractSet, Protocol } from "./types.ts"
7
+
8
+ /**
9
+ * Contracts from the API calls a browser run saw (network.json, written by the engine): the calls are grouped into
10
+ * operations (one per method and path template, GraphQL operation, SOAP action, or gRPC method), and each operation's
11
+ * answers give its status, schema, and key fields. Several calls to one operation make a better contract: a field seen
12
+ * in every answer is required, a value that differs between calls is not pinned.
13
+ */
14
+
15
+ export type NetworkCall = {
16
+ t: number
17
+ method: string
18
+ url: string
19
+ type: string
20
+ page?: string
21
+ third_party?: boolean
22
+ auth?: { type: string; name?: string }
23
+ ms?: number
24
+ failed?: string
25
+ request: { headers?: Record<string, string>; body?: string; truncated?: boolean }
26
+ response: { status: number; mime?: string; headers?: Record<string, string>; body?: string; truncated?: boolean; size?: number }
27
+ }
28
+
29
+ export type NetworkFile = { version: 1; calls: NetworkCall[]; dropped?: number }
30
+
31
+ export function readNetwork(runDir: string): NetworkCall[] {
32
+ const file = path.join(runDir, "network.json")
33
+ if (!existsSync(file)) return []
34
+ try {
35
+ const data = JSON.parse(readFileSync(file, "utf8")) as NetworkFile
36
+ return Array.isArray(data?.calls) ? data.calls : []
37
+ } catch {
38
+ return []
39
+ }
40
+ }
41
+
42
+ /** The browser run's step each call happened in, from the steps' start times in steps.json. */
43
+ export function stepsOf(runDir: string, calls: NetworkCall[]): Map<NetworkCall, number> {
44
+ const out = new Map<NetworkCall, number>()
45
+ let starts: { n: number; t0: number }[] = []
46
+ try {
47
+ starts = (JSON.parse(readFileSync(path.join(runDir, "steps.json"), "utf8")) as { n?: number; t0?: number }[])
48
+ .filter((s) => typeof s.t0 === "number" && typeof s.n === "number")
49
+ .map((s) => ({ n: s.n!, t0: s.t0! }))
50
+ } catch {}
51
+ if (!starts.length) return out
52
+ for (const c of calls) {
53
+ let step = starts[0].n
54
+ for (const s of starts) if (s.t0 <= c.t) step = s.n
55
+ out.set(c, step)
56
+ }
57
+ return out
58
+ }
59
+
60
+ const header = (h: Record<string, string> | undefined, name: string) => Object.entries(h || {}).find(([k]) => k.toLowerCase() === name)?.[1] || ""
61
+
62
+ export function parseJson(text: string | undefined): unknown {
63
+ if (!text) return undefined
64
+ try {
65
+ return JSON.parse(text)
66
+ } catch {
67
+ return undefined
68
+ }
69
+ }
70
+
71
+ const xml = new XMLParser({ ignoreAttributes: true, removeNSPrefix: true })
72
+
73
+ /** The SOAP body's first element (the operation, or its Response), when the text is a SOAP envelope. */
74
+ export function soapOperation(text: string | undefined): string | null {
75
+ if (!text || !/Envelope/.test(text)) return null
76
+ try {
77
+ const env = xml.parse(text)?.Envelope
78
+ const body = env?.Body
79
+ return body && typeof body === "object" ? Object.keys(body)[0] || null : null
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
85
+ /** Every element name in an XML text, for SOAP answer checks. */
86
+ export function xmlElements(text: string, max = 12): string[] {
87
+ const names: string[] = []
88
+ for (const m of text.matchAll(/<(?:[\w.-]+:)?([\w.-]+)[\s>/]/g)) {
89
+ if (!["Envelope", "Header", "Body", "xml"].includes(m[1]) && !names.includes(m[1])) names.push(m[1])
90
+ if (names.length >= max) break
91
+ }
92
+ return names
93
+ }
94
+
95
+ export function protocolOf(c: NetworkCall): Protocol {
96
+ const ct = `${header(c.request.headers, "content-type")} ${c.response.mime || ""}`.toLowerCase()
97
+ if (/grpc/.test(ct)) return "grpc"
98
+ if (header(c.request.headers, "soapaction") || /soap\+xml/.test(ct) || soapOperation(c.request.body)) return "soap"
99
+ const body = parseJson(c.request.body) as { query?: unknown } | undefined
100
+ if ((body && typeof body.query === "string") || /\/graphql\b/i.test(new URL(c.url).pathname) || /graphql/.test(ct)) return "graphql"
101
+ return "rest"
102
+ }
103
+
104
+ const ID_SEGMENT = /^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,}|[A-Za-z0-9_-]{20,}|[A-Z]{2,5}-\d+)$/i
105
+
106
+ /** A path with the segments that are ids turned into {id} (two in a row: {id}, {id2}). */
107
+ export function pathTemplate(pathname: string): string {
108
+ let n = 0
109
+ return pathname
110
+ .split("/")
111
+ .map((seg) => {
112
+ if (!seg || !ID_SEGMENT.test(decodeURIComponent(seg)) || !/\d/.test(seg)) return seg
113
+ n++
114
+ return n === 1 ? "{id}" : `{id${n}}`
115
+ })
116
+ .join("/")
117
+ }
118
+
119
+ /** GraphQL operation: its operationName, else the type and first field of the document. */
120
+ export function graphqlOperation(body: unknown): { kind: "query" | "mutation" | "subscription"; name: string } | null {
121
+ const b = body as { query?: string; operationName?: string } | undefined
122
+ if (!b || typeof b.query !== "string") return null
123
+ const doc = b.query.replace(/#[^\n]*/g, "")
124
+ const m = doc.match(/^\s*(query|mutation|subscription)\b\s*(\w+)?/)
125
+ const kind = (m?.[1] as "query" | "mutation" | "subscription") || "query"
126
+ const field = doc.match(/{\s*(\w+)/)?.[1] || "operation"
127
+ return { kind, name: b.operationName || m?.[2] || field }
128
+ }
129
+
130
+ const READ_NAME = /^(get|list|find|search|query|read|fetch|lookup|check|validate|describe|count|is|has)/i
131
+
132
+ export type Group = { key: string; protocol: Protocol; name: string; method: string; path: string; calls: NetworkCall[]; writes: boolean }
133
+
134
+ export function groupCall(c: NetworkCall): Group | null {
135
+ let u: URL
136
+ try {
137
+ u = new URL(c.url)
138
+ } catch {
139
+ return null
140
+ }
141
+ const protocol = protocolOf(c)
142
+ if (protocol === "graphql") {
143
+ const op = graphqlOperation(parseJson(c.request.body)) || (c.method === "GET" && u.searchParams.get("query") ? graphqlOperation({ query: u.searchParams.get("query"), operationName: u.searchParams.get("operationName") || undefined }) : null)
144
+ if (!op) return null
145
+ return { key: `graphql ${op.kind} ${op.name}`, protocol, name: op.name, method: c.method, path: u.pathname, calls: [c], writes: op.kind === "mutation" }
146
+ }
147
+ if (protocol === "soap") {
148
+ const action = header(c.request.headers, "soapaction").replace(/"/g, "")
149
+ const op = soapOperation(c.request.body) || action.split(/[/#]/).pop() || "operation"
150
+ return { key: `soap ${op}`, protocol, name: op, method: c.method, path: u.pathname, calls: [c], writes: !READ_NAME.test(op) }
151
+ }
152
+ if (protocol === "grpc") {
153
+ const method = u.pathname.replace(/^\//, "")
154
+ return { key: `grpc ${method}`, protocol, name: method, method: "POST", path: u.pathname, calls: [c], writes: !READ_NAME.test(method.split("/").pop() || "") }
155
+ }
156
+ const tpl = pathTemplate(u.pathname)
157
+ return { key: `${c.method} ${tpl}`, protocol, name: `${c.method} ${tpl}`, method: c.method, path: tpl, calls: [c], writes: !["GET", "HEAD", "OPTIONS"].includes(c.method) }
158
+ }
159
+
160
+ export type FromTrafficOptions = {
161
+ /** Calls to other sites than the page's are left out unless this is on. */
162
+ thirdParty?: boolean
163
+ /** Only operations whose id contains one of these (case-insensitive). */
164
+ include?: string[]
165
+ steps?: Map<NetworkCall, number>
166
+ }
167
+
168
+ /** Group a run's calls into operations and write one contract for each. */
169
+ export function contractsFromTraffic(calls: NetworkCall[], opts: FromTrafficOptions = {}): { contracts: Contract[]; skipped: string[]; baseUrl: string } {
170
+ const skipped: string[] = []
171
+ const groups = new Map<string, Group>()
172
+ const origins = new Map<string, number>()
173
+ let third = 0
174
+ let failed = 0
175
+ for (const c of calls) {
176
+ if (c.third_party && !opts.thirdParty) {
177
+ third++
178
+ continue
179
+ }
180
+ if (c.failed || !c.response?.status) {
181
+ failed++
182
+ continue
183
+ }
184
+ const g = groupCall(c)
185
+ if (!g) continue
186
+ const origin = new URL(c.url).origin
187
+ origins.set(origin, (origins.get(origin) || 0) + 1)
188
+ const have = groups.get(`${origin} ${g.key}`)
189
+ if (have) have.calls.push(c)
190
+ else groups.set(`${origin} ${g.key}`, g)
191
+ }
192
+ if (third) skipped.push(`${third} call${third === 1 ? "" : "s"} to other sites (analytics, CDNs, sign-in providers); set api.third_party: true to keep them`)
193
+ if (failed) skipped.push(`${failed} call${failed === 1 ? "" : "s"} that failed or got no answer`)
194
+ const baseUrl = [...origins].sort((a, b) => b[1] - a[1])[0]?.[0] || ""
195
+ const include = (opts.include || []).map((s) => s.toLowerCase()).filter(Boolean)
196
+ const contracts: Contract[] = []
197
+ for (const g of groups.values()) {
198
+ if (include.length && !include.some((i) => g.key.toLowerCase().includes(i))) continue
199
+ const origin = new URL(g.calls[0].url).origin
200
+ contracts.push(contractOf(g, origin === baseUrl ? "" : origin, opts.steps))
201
+ }
202
+ contracts.sort((a, b) => Number(a.writes) - Number(b.writes) || a.id.localeCompare(b.id))
203
+ return { contracts, skipped, baseUrl }
204
+ }
205
+
206
+ function contractOf(g: Group, otherOrigin: string, steps?: Map<NetworkCall, number>): Contract {
207
+ // The example is the first call that succeeded, else the first; the expected status is the one most calls got.
208
+ const statuses = new Map<number, number>()
209
+ for (const c of g.calls) statuses.set(c.response.status, (statuses.get(c.response.status) || 0) + 1)
210
+ const status = [...statuses].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0][0]
211
+ const same = g.calls.filter((c) => c.response.status === status)
212
+ const ex = same[0]
213
+ const u = new URL(ex.url)
214
+ const contentType = (ex.response.mime || header(ex.response.headers, "content-type")).split(";")[0].trim()
215
+ const bodies = same.filter((c) => !c.response.truncated).map((c) => c.response.body)
216
+ const json = bodies.map(parseJson).filter((v) => v !== undefined)
217
+ const reqType = header(ex.request.headers, "content-type")
218
+ const reqBody = ex.request.body === undefined ? undefined : (parseJson(ex.request.body) ?? ex.request.body)
219
+ const headers: Record<string, string> = {}
220
+ for (const name of ["content-type", "soapaction", "accept"]) {
221
+ const v = header(ex.request.headers, name)
222
+ if (v) headers[name === "soapaction" ? "SOAPAction" : name === "content-type" ? "Content-Type" : "Accept"] = v
223
+ }
224
+ const expect: Contract["expect"] = { status, ...(contentType ? { contentType } : {}) }
225
+ if (g.protocol === "soap") {
226
+ const text = bodies.find(Boolean)
227
+ if (text) expect.elements = xmlElements(text, 6)
228
+ } else if (g.protocol === "grpc") {
229
+ const grpcStatus = header(ex.response.headers, "grpc-status")
230
+ if (grpcStatus) expect.fields = [{ path: "grpc-status", equals: Number(grpcStatus) }]
231
+ } else if (json.length) {
232
+ expect.schema = inferSchema(json)
233
+ const fields = keyFields(g.protocol === "graphql" ? json.map((j) => (j as { data?: unknown })?.data) : json)
234
+ if (fields.length) expect.fields = g.protocol === "graphql" ? fields.map((f) => ({ ...f, path: `data.${f.path}` })) : fields
235
+ }
236
+ const stepNumbers = [...new Set(g.calls.map((c) => steps?.get(c)).filter((n): n is number => typeof n === "number"))]
237
+ const query = Object.fromEntries(u.searchParams)
238
+ return {
239
+ id: g.key,
240
+ protocol: g.protocol,
241
+ name: g.name,
242
+ method: g.method,
243
+ path: `${otherOrigin}${g.path}`,
244
+ example: {
245
+ path: `${otherOrigin}${u.pathname}`,
246
+ ...(Object.keys(query).length ? { query } : {}),
247
+ ...(Object.keys(headers).length ? { headers } : {}),
248
+ ...(reqBody !== undefined ? { body: reqBody } : {}),
249
+ },
250
+ expect,
251
+ ...(ex.auth ? { auth: ex.auth } : {}),
252
+ writes: g.writes,
253
+ ...(ex.request.truncated || (reqType && reqBody === undefined && g.writes) ? { needsData: ["request body (not recorded in full)"] } : {}),
254
+ source: { kind: "run", ref: "", samples: g.calls.length, ...(stepNumbers.length ? { steps: stepNumbers } : {}) },
255
+ }
256
+ }
257
+
258
+ /** A contract set from a browser run's folder. */
259
+ export function setFromRun(runDir: string, opts: Omit<FromTrafficOptions, "steps"> & { id: string; name?: string }): ContractSet {
260
+ const calls = readNetwork(runDir)
261
+ if (!calls.length)
262
+ throw new Error(
263
+ `The run ${path.basename(runDir)} recorded no API calls (no network.json). Runs record the application's calls since this version; run the flow again, or check api.capture is not off.`,
264
+ )
265
+ const { contracts, skipped, baseUrl } = contractsFromTraffic(calls, { ...opts, steps: stepsOf(runDir, calls) })
266
+ let goal = ""
267
+ try {
268
+ goal = (JSON.parse(readFileSync(path.join(runDir, "run.json"), "utf8")) as { goal?: string }).goal || ""
269
+ } catch {}
270
+ for (const c of contracts) c.source.ref = path.basename(runDir)
271
+ return {
272
+ version: 1,
273
+ id: opts.id,
274
+ name: opts.name || (goal ? `API calls of: ${goal.slice(0, 80)}` : `API calls of run ${path.basename(runDir)}`),
275
+ baseUrl,
276
+ createdAt: new Date().toISOString(),
277
+ source: { kind: "run", ref: runDir },
278
+ contracts,
279
+ ...(skipped.length ? { skipped } : {}),
280
+ }
281
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * API and contract tests. A contract is one API operation with one example call and what its answer must look like:
3
+ * the status, the shape of the body (a JSON schema), and the fields that matter. Contracts come from an API description
4
+ * (OpenAPI or Swagger, WSDL, a GraphQL schema, .proto files) or from the calls a browser run saw the application make;
5
+ * ForgeQE runs them directly, and the coding agent turns them into tests in the repository's own framework.
6
+ */
7
+
8
+ export type Protocol = "rest" | "graphql" | "soap" | "grpc"
9
+
10
+ /** The JSON Schema subset ForgeQE writes and checks: types, properties, required, items, enum, nullable, anyOf. */
11
+ export type JsonSchema = {
12
+ type?: string | string[]
13
+ properties?: Record<string, JsonSchema>
14
+ required?: string[]
15
+ items?: JsonSchema
16
+ enum?: unknown[]
17
+ format?: string
18
+ nullable?: boolean
19
+ anyOf?: JsonSchema[]
20
+ oneOf?: JsonSchema[]
21
+ allOf?: JsonSchema[]
22
+ additionalProperties?: boolean | JsonSchema
23
+ description?: string
24
+ example?: unknown
25
+ }
26
+
27
+ /** A field the answer must have. `equals` only for values that do not change between calls (a status, a currency). */
28
+ export type KeyField = { path: string; type?: string; equals?: unknown }
29
+
30
+ export type SourceKind = "run" | "openapi" | "swagger" | "wsdl" | "graphql" | "proto"
31
+
32
+ export type Contract = {
33
+ /** "GET /api/orders/{id}", "graphql query GetOrders", "soap GetQuote", "grpc shop.Orders/Get". */
34
+ id: string
35
+ protocol: Protocol
36
+ /** The operation's name or summary. */
37
+ name: string
38
+ method: string
39
+ /** The path template ({id} for values that change). For gRPC, /package.Service/Method. */
40
+ path: string
41
+ /** One call to make: a concrete path, query, headers, and body. */
42
+ example: { path: string; query?: Record<string, string>; headers?: Record<string, string>; body?: unknown }
43
+ expect: {
44
+ status: number
45
+ /** Any of these statuses passes (negative cases: 401 or 403, 400 or 422). */
46
+ statusIn?: number[]
47
+ contentType?: string
48
+ schema?: JsonSchema
49
+ fields?: KeyField[]
50
+ /** SOAP: elements the answer must contain. */
51
+ elements?: string[]
52
+ }
53
+ /** How the application authenticated the call (the credential itself is never kept). */
54
+ auth?: { type: string; name?: string }
55
+ /** Changes data (POST, PUT, PATCH, DELETE, GraphQL mutations, SOAP and gRPC calls not named like reads): not run without allow_writes. */
56
+ writes: boolean
57
+ /** Path parameters or body values the description gives no example for: the call needs real data before it runs. */
58
+ needsData?: string[]
59
+ source: { kind: SourceKind; ref: string; samples?: number; steps?: number[] }
60
+ description?: string
61
+ /** OpenAPI operationId, and the description's links from this operation's answer to other operations. */
62
+ operationId?: string
63
+ links?: { operationId: string; parameters: Record<string, string> }[]
64
+ /** Required fields of the request body, for invalid-body negative cases. */
65
+ requestRequired?: string[]
66
+ }
67
+
68
+ /**
69
+ * A scenario: API calls in order, where later calls use values earlier answers gave ({{orderId}} in a path, query,
70
+ * header, or body). Built-in values: {{$unique}} (one per scenario run, for data that must be new each time),
71
+ * {{$uuid}}, {{$now}}, {{$randomInt}}.
72
+ */
73
+ export type ScenarioStep = {
74
+ name: string
75
+ /** The contract this step calls, when there is one. */
76
+ operation?: string
77
+ protocol: Protocol
78
+ method: string
79
+ path: string
80
+ query?: Record<string, string>
81
+ headers?: Record<string, string>
82
+ body?: unknown
83
+ /** Values to keep from the answer: variable name → path in the body (id, data.items[0].id). */
84
+ save?: Record<string, string>
85
+ expect: Contract["expect"]
86
+ /** Sent without the configured credentials: a negative case. */
87
+ noAuth?: boolean
88
+ }
89
+
90
+ export type ScenarioKind = "flow" | "lifecycle" | "browse" | "linked" | "negative" | "planned"
91
+
92
+ export type Scenario = {
93
+ id: string
94
+ name: string
95
+ kind: ScenarioKind
96
+ source: { kind: SourceKind | "case" | "text"; ref: string }
97
+ /** The test case it automates, for traceability. */
98
+ caseId?: string
99
+ caseTitle?: string
100
+ caseSource?: string
101
+ caseUrl?: string
102
+ /** What the test case's system needs to link the automated test to it (Azure DevOps work item id). */
103
+ caseRef?: Record<string, string | number>
104
+ steps: ScenarioStep[]
105
+ /** Run when the scenario fails part-way, to remove what it created. */
106
+ cleanup?: ScenarioStep[]
107
+ writes: boolean
108
+ notes?: string[]
109
+ }
110
+
111
+ export type StepResult = ContractResult & { name: string }
112
+
113
+ export type ScenarioResult = {
114
+ id: string
115
+ name: string
116
+ status: "passed" | "failed" | "skipped" | "error"
117
+ steps: StepResult[]
118
+ cleanup?: StepResult[]
119
+ reason?: string
120
+ ms: number
121
+ }
122
+
123
+ export type ScenarioRun = {
124
+ set: string
125
+ baseUrl: string
126
+ at: string
127
+ results: ScenarioResult[]
128
+ counts: { passed: number; failed: number; skipped: number; error: number }
129
+ dir: string
130
+ }
131
+
132
+ export type ContractSet = {
133
+ version: 1
134
+ id: string
135
+ name: string
136
+ baseUrl: string
137
+ createdAt: string
138
+ source: { kind: SourceKind; ref: string }
139
+ contracts: Contract[]
140
+ /** What was left out and why (third-party calls, failed calls, unsupported bits of a description). */
141
+ skipped?: string[]
142
+ scenarios?: Scenario[]
143
+ }
144
+
145
+ export type CheckResult = { name: string; ok: boolean; detail?: string }
146
+
147
+ export type ContractResult = {
148
+ id: string
149
+ status: "passed" | "failed" | "skipped" | "error"
150
+ ms?: number
151
+ httpStatus?: number
152
+ checks: CheckResult[]
153
+ reason?: string
154
+ }
155
+
156
+ export type ContractRun = {
157
+ set: string
158
+ baseUrl: string
159
+ at: string
160
+ results: ContractResult[]
161
+ counts: { passed: number; failed: number; skipped: number; error: number }
162
+ dir: string
163
+ }