@capagents/forgeqe 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
package/src/api/store.ts
ADDED
|
@@ -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
|
+
}
|
package/src/api/types.ts
ADDED
|
@@ -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
|
+
}
|