@capagents/forgeqe 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +781 -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 +146 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { callContract, xmlEscape, type RunOptions } from "./run.ts"
|
|
5
|
+
import { getPath } from "./schema.ts"
|
|
6
|
+
import { groupCall, parseJson, type NetworkCall } from "./traffic.ts"
|
|
7
|
+
import type { Contract, ContractSet, JsonSchema, Scenario, ScenarioResult, ScenarioRun, ScenarioStep, StepResult } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* End-to-end API scenarios: calls in order, each later call using values earlier answers gave. They come from a
|
|
11
|
+
* browser run (the calls the application made, with the values that flowed from one answer into a later request found
|
|
12
|
+
* and turned into variables), from an API description (each resource's lifecycle, browsing from a list to an item,
|
|
13
|
+
* the description's links, and negative cases), or from a test case or plain English (planned by the agent: plan.ts).
|
|
14
|
+
* ForgeQE runs them itself: no browser, no model.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const TEMPLATE = /\{\{\s*(\$?\w+)\s*\}\}/g
|
|
18
|
+
const WHOLE = /^\{\{\s*(\$?\w+)\s*\}\}$/
|
|
19
|
+
export const BUILTINS = ["$unique", "$uuid", "$now", "$randomInt"]
|
|
20
|
+
|
|
21
|
+
class MissingVar extends Error {}
|
|
22
|
+
|
|
23
|
+
function lookup(name: string, vars: Record<string, unknown>): unknown {
|
|
24
|
+
if (name in vars) return vars[name]
|
|
25
|
+
throw new MissingVar(`uses {{${name}}}, which no earlier step saved`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A value with {{name}} filled in. A string that is only {{name}} becomes the value itself, keeping its type. */
|
|
29
|
+
export function fill(v: unknown, vars: Record<string, unknown>): unknown {
|
|
30
|
+
if (typeof v === "string") {
|
|
31
|
+
const whole = v.match(WHOLE)
|
|
32
|
+
if (whole) return lookup(whole[1], vars)
|
|
33
|
+
return v.replace(TEMPLATE, (_, n) => String(lookup(n, vars)))
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(v)) return v.map((x) => fill(x, vars))
|
|
36
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fill(x, vars)]))
|
|
37
|
+
return v
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The variables a step uses. */
|
|
41
|
+
export function usedVars(step: Pick<ScenarioStep, "path" | "query" | "headers" | "body">): string[] {
|
|
42
|
+
const text = JSON.stringify([step.path, step.query || {}, step.headers || {}, step.body ?? null])
|
|
43
|
+
return [...new Set([...text.matchAll(TEMPLATE)].map((m) => m[1]))]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What is wrong with a scenario before it runs: variables used before any step saves them. */
|
|
47
|
+
export function scenarioProblems(s: Scenario): string[] {
|
|
48
|
+
const known = new Set(BUILTINS)
|
|
49
|
+
const out: string[] = []
|
|
50
|
+
for (const step of s.steps) {
|
|
51
|
+
for (const v of usedVars(step)) if (!known.has(v)) out.push(`${step.name} uses {{${v}}}, which no earlier step saves`)
|
|
52
|
+
for (const v of Object.keys(step.save || {})) known.add(v)
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function freshVars(): Record<string, unknown> {
|
|
58
|
+
return { $unique: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, $uuid: crypto.randomUUID(), $now: new Date().toISOString(), $randomInt: Math.floor(Math.random() * 1_000_000) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stepContract(step: ScenarioStep, vars: Record<string, unknown>, ops: Map<string, Contract>): Contract {
|
|
62
|
+
const op = step.operation ? ops.get(step.operation) : undefined
|
|
63
|
+
const pathFilled = step.path.replace(TEMPLATE, (_, n) => encodeURIComponent(String(lookup(n, vars))))
|
|
64
|
+
return {
|
|
65
|
+
id: step.name,
|
|
66
|
+
protocol: step.protocol,
|
|
67
|
+
name: step.name,
|
|
68
|
+
method: step.method,
|
|
69
|
+
path: step.path,
|
|
70
|
+
example: {
|
|
71
|
+
path: pathFilled,
|
|
72
|
+
...(step.query ? { query: fill(step.query, vars) as Record<string, string> } : {}),
|
|
73
|
+
...(step.headers ? { headers: fill(step.headers, vars) as Record<string, string> } : {}),
|
|
74
|
+
...(step.body !== undefined ? { body: fill(step.body, vars) } : {}),
|
|
75
|
+
},
|
|
76
|
+
expect: step.expect,
|
|
77
|
+
...(op?.auth ? { auth: op.auth } : {}),
|
|
78
|
+
writes: false,
|
|
79
|
+
source: { kind: "run", ref: "" },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function runSteps(steps: ScenarioStep[], vars: Record<string, unknown>, base: string, headers: Record<string, string>, ops: Map<string, Contract>, opts: RunOptions): Promise<{ results: StepResult[]; ok: boolean; error?: string }> {
|
|
84
|
+
const results: StepResult[] = []
|
|
85
|
+
let failed = false
|
|
86
|
+
let error: string | undefined
|
|
87
|
+
for (const step of steps) {
|
|
88
|
+
if (failed || opts.signal?.aborted) {
|
|
89
|
+
results.push({ id: step.name, name: step.name, status: "skipped", checks: [], reason: opts.signal?.aborted ? "stopped" : "an earlier step failed" })
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const c = stepContract(step, vars, ops)
|
|
94
|
+
const { result, body } = await callContract(c, base, step.noAuth ? {} : headers, opts)
|
|
95
|
+
for (const [name, p] of Object.entries(step.save || {})) {
|
|
96
|
+
const v = getPath(body, p)
|
|
97
|
+
if (v === undefined || v === null) result.checks.push({ name: `save ${name}`, ok: false, detail: `nothing at ${p} in the answer` })
|
|
98
|
+
else vars[name] = v
|
|
99
|
+
}
|
|
100
|
+
if (result.checks.some((x) => !x.ok)) result.status = "failed"
|
|
101
|
+
results.push({ ...result, name: step.name })
|
|
102
|
+
if (result.status !== "passed") failed = true
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const msg = err instanceof Error ? (err.name === "TimeoutError" ? `no answer in ${opts.timeoutSeconds || 30}s` : err.message) : String(err)
|
|
105
|
+
results.push({ id: step.name, name: step.name, status: "error", checks: [], reason: msg })
|
|
106
|
+
failed = true
|
|
107
|
+
error = msg
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { results, ok: !failed, error }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Run each scenario: its steps in order, values passed on, and its cleanup when it fails part-way. */
|
|
114
|
+
export async function runScenarios(set: ContractSet, opts: RunOptions): Promise<ScenarioRun> {
|
|
115
|
+
const base = (opts.baseUrl || set.baseUrl || "").replace(/\/+$/, "")
|
|
116
|
+
const headers = opts.headers || {}
|
|
117
|
+
const ops = new Map(set.contracts.map((c) => [c.id, c]))
|
|
118
|
+
const only = (opts.only || []).map((s) => s.toLowerCase()).filter(Boolean)
|
|
119
|
+
const results: ScenarioResult[] = []
|
|
120
|
+
for (const s of set.scenarios || []) {
|
|
121
|
+
if (only.length && !only.some((o) => `${s.id} ${s.name} ${s.caseId || ""}`.toLowerCase().includes(o))) continue
|
|
122
|
+
if (opts.signal?.aborted) break
|
|
123
|
+
const started = Date.now()
|
|
124
|
+
const problems = scenarioProblems(s)
|
|
125
|
+
let r: ScenarioResult
|
|
126
|
+
if (s.writes && !opts.allowWrites) r = { id: s.id, name: s.name, status: "skipped", steps: [], reason: "changes data: run with allow_writes on a test environment", ms: 0 }
|
|
127
|
+
else if (problems.length) r = { id: s.id, name: s.name, status: "error", steps: [], reason: problems.join("; "), ms: 0 }
|
|
128
|
+
else if (!base && !s.steps.every((x) => /^https?:\/\//.test(x.path))) r = { id: s.id, name: s.name, status: "skipped", steps: [], reason: "no base URL: pass base_url or set api.base_url", ms: 0 }
|
|
129
|
+
else {
|
|
130
|
+
const vars = freshVars()
|
|
131
|
+
const run = await runSteps(s.steps, vars, base, headers, ops, opts)
|
|
132
|
+
let cleanup: StepResult[] | undefined
|
|
133
|
+
if (!run.ok && s.cleanup?.length) {
|
|
134
|
+
// Only the cleanup steps whose values exist: what the scenario did not create, it does not delete.
|
|
135
|
+
const ready = s.cleanup.filter((c) => usedVars(c).every((v) => v in vars))
|
|
136
|
+
if (ready.length) cleanup = (await runSteps(ready, vars, base, headers, ops, { ...opts, signal: undefined })).results
|
|
137
|
+
}
|
|
138
|
+
r = { id: s.id, name: s.name, status: run.error ? "error" : run.ok ? "passed" : "failed", steps: run.results, ...(cleanup ? { cleanup } : {}), ms: Date.now() - started }
|
|
139
|
+
}
|
|
140
|
+
results.push(r)
|
|
141
|
+
}
|
|
142
|
+
const counts = { passed: 0, failed: 0, skipped: 0, error: 0 }
|
|
143
|
+
for (const r of results) counts[r.status]++
|
|
144
|
+
const run: ScenarioRun = { set: set.id, baseUrl: base, at: new Date().toISOString(), results, counts, dir: opts.dir }
|
|
145
|
+
mkdirSync(opts.dir, { recursive: true })
|
|
146
|
+
writeFileSync(path.join(opts.dir, "scenario-results.json"), JSON.stringify(run, null, 2))
|
|
147
|
+
writeFileSync(path.join(opts.dir, "scenarios-junit.xml"), scenarioJunit(run))
|
|
148
|
+
return run
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function scenarioJunit(run: ScenarioRun): string {
|
|
152
|
+
const suites = run.results.map((r) => {
|
|
153
|
+
const cases = r.steps.length
|
|
154
|
+
? r.steps.map((st) => {
|
|
155
|
+
const head = ` <testcase classname="${xmlEscape(r.name)}" name="${xmlEscape(st.name)}" time="${((st.ms || 0) / 1000).toFixed(3)}"`
|
|
156
|
+
if (st.status === "passed") return `${head}/>`
|
|
157
|
+
if (st.status === "skipped") return `${head}><skipped message="${xmlEscape(st.reason || "")}"/></testcase>`
|
|
158
|
+
const msg = st.reason || st.checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail || "failed"}`).join("; ")
|
|
159
|
+
return `${head}><${st.status === "error" ? "error" : "failure"} message="${xmlEscape(msg)}"/></testcase>`
|
|
160
|
+
})
|
|
161
|
+
: [` <testcase classname="${xmlEscape(r.name)}" name="${xmlEscape(r.name)}">${r.status === "skipped" ? `<skipped message="${xmlEscape(r.reason || "")}"/>` : `<error message="${xmlEscape(r.reason || "")}"/>`}</testcase>`]
|
|
162
|
+
const failures = r.steps.filter((s) => s.status === "failed").length
|
|
163
|
+
return ` <testsuite name="${xmlEscape(r.name)}" tests="${cases.length}" failures="${failures}" time="${(r.ms / 1000).toFixed(3)}">\n${cases.join("\n")}\n </testsuite>`
|
|
164
|
+
})
|
|
165
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n${suites.join("\n")}\n</testsuites>\n`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// -- helpers --------------------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export function slug(s: string): string {
|
|
171
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "scenario"
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function uniqueIds(scenarios: Scenario[], taken: string[] = []): Scenario[] {
|
|
175
|
+
const seen = new Set(taken)
|
|
176
|
+
for (const s of scenarios) {
|
|
177
|
+
let id = s.id
|
|
178
|
+
for (let n = 2; seen.has(id); n++) id = `${s.id}-${n}`
|
|
179
|
+
s.id = id
|
|
180
|
+
seen.add(id)
|
|
181
|
+
}
|
|
182
|
+
return scenarios
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const camel = (s: string) => s.replace(/[-_\s]+(\w)/g, (_, c: string) => c.toUpperCase()).replace(/^\w/, (c) => c.toLowerCase())
|
|
186
|
+
|
|
187
|
+
const singular = (w: string) => (/ies$/.test(w) ? w.replace(/ies$/, "y") : /(ss|us)$/.test(w) ? w : w.replace(/s$/, ""))
|
|
188
|
+
|
|
189
|
+
/** The resource a path names: its last segment that is not a parameter or a version. */
|
|
190
|
+
function resourceOf(p: string): string {
|
|
191
|
+
const segs = p.split("/").filter((x) => x && !x.includes("{") && !/^(api|v\d+(\.\d+)?|rest|graphql)$/i.test(x) && !/\d/.test(x))
|
|
192
|
+
return segs[segs.length - 1] || "item"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Unique data for a create: an email or username that would clash on the next run gets {{$unique}} in it. */
|
|
196
|
+
function uniqueData(body: unknown): unknown {
|
|
197
|
+
if (Array.isArray(body)) return body.map(uniqueData)
|
|
198
|
+
if (!body || typeof body !== "object") return body
|
|
199
|
+
return Object.fromEntries(
|
|
200
|
+
Object.entries(body).map(([k, v]) => {
|
|
201
|
+
if (typeof v === "string" && !v.includes("{{")) {
|
|
202
|
+
if (/^e?-?mail(address)?$/i.test(k.replace(/_/g, "")) && v.includes("@")) return [k, v.replace("@", "+{{$unique}}@")]
|
|
203
|
+
if (/^(user_?name|login|handle)$/i.test(k)) return [k, `${v}-{{$unique}}`]
|
|
204
|
+
}
|
|
205
|
+
return [k, uniqueData(v)]
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// -- from a browser run ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
const ID_LEAF = /(^|[_-]|[a-z])(id|Id|ID|key|Key|uuid|Uuid|UUID|token|code|Code|number|Number|no|No|ref|Ref|slug)$/
|
|
213
|
+
const ID_SHAPED = (s: string) =>
|
|
214
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s) || /^[0-9a-f]{16,}$/i.test(s) || (/\d/.test(s) && /[A-Za-z]/.test(s) && /^[\w-]{6,}$/.test(s))
|
|
215
|
+
|
|
216
|
+
type Produced = { step: number; path: string; leaf: string; value: string; name?: string }
|
|
217
|
+
|
|
218
|
+
/** Every scalar in an answer with its path (all array elements, up to 20 each). */
|
|
219
|
+
function scalarsOf(v: unknown, at = "", depth = 0, out: [string, unknown][] = []): [string, unknown][] {
|
|
220
|
+
if (depth > 5 || out.length > 400) return out
|
|
221
|
+
if (Array.isArray(v)) v.slice(0, 20).forEach((x, i) => scalarsOf(x, `${at}[${i}]`, depth + 1, out))
|
|
222
|
+
else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) scalarsOf(x, at ? `${at}.${k}` : k, depth + 1, out)
|
|
223
|
+
else if (at && (typeof v === "string" || typeof v === "number")) out.push([at, v])
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const leafOf = (p: string) => p.split(/[.[]/).pop()!.replace(/]$/, "")
|
|
228
|
+
|
|
229
|
+
function candidate(leaf: string, value: unknown): boolean {
|
|
230
|
+
if (typeof value === "number") return Number.isInteger(value) && value > 1 && ID_LEAF.test(leaf)
|
|
231
|
+
if (typeof value !== "string" || !value || value === "<redacted>" || value.length > 200) return false
|
|
232
|
+
return ID_SHAPED(value) || (ID_LEAF.test(leaf) && /^[\w.:-]{1,80}$/.test(value) && /\d/.test(value))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A request field may take a produced value: an id-like field, the same field name, or a value that can only be an id. */
|
|
236
|
+
const accepts = (leaf: string, p: Produced) => ID_LEAF.test(leaf) || leaf === p.leaf || ID_SHAPED(p.value)
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One scenario from a run's calls, in the order the application made them. A value an answer gave that a later
|
|
240
|
+
* request sends (in its path, query, or body) becomes a variable: saved from that answer, used in that request. The
|
|
241
|
+
* same GET made again (polling, refreshes) is kept once; at most 40 steps.
|
|
242
|
+
*/
|
|
243
|
+
export function scenarioFromRun(calls: NetworkCall[], set: ContractSet, opts: { goal?: string; runId?: string; thirdParty?: boolean } = {}): Scenario | null {
|
|
244
|
+
const base = set.baseUrl
|
|
245
|
+
const ordered = calls
|
|
246
|
+
.filter((c) => !c.failed && c.response?.status && (opts.thirdParty || !c.third_party))
|
|
247
|
+
.sort((a, b) => a.t - b.t)
|
|
248
|
+
const steps: ScenarioStep[] = []
|
|
249
|
+
const producers = new Map<string, Produced>()
|
|
250
|
+
const names = new Map<string, string>()
|
|
251
|
+
const flows: string[] = []
|
|
252
|
+
const seenGets = new Set<string>()
|
|
253
|
+
let dropped = 0
|
|
254
|
+
const varFor = (p: Produced): string => {
|
|
255
|
+
if (p.name) return p.name
|
|
256
|
+
const key = `${p.step}:${p.path}`
|
|
257
|
+
const have = names.get(key)
|
|
258
|
+
if (have) return (p.name = have)
|
|
259
|
+
let name = /^id$/i.test(p.leaf) ? `${camel(singular(resourceOf(steps[p.step].path)))}Id` : camel(p.leaf)
|
|
260
|
+
const taken = new Set(names.values())
|
|
261
|
+
for (let n = 2; taken.has(name); n++) name = `${name.replace(/\d+$/, "")}${n}`
|
|
262
|
+
names.set(key, name)
|
|
263
|
+
steps[p.step].save = { ...(steps[p.step].save || {}), [name]: p.path }
|
|
264
|
+
flows.push(`${name}: from step ${p.step + 1} (${steps[p.step].name})`)
|
|
265
|
+
return (p.name = name)
|
|
266
|
+
}
|
|
267
|
+
const replaceIn = (value: unknown, at: string): unknown => {
|
|
268
|
+
if (Array.isArray(value)) return value.map((x, i) => replaceIn(x, `${at}[${i}]`))
|
|
269
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, x]) => [k, replaceIn(x, at ? `${at}.${k}` : k)]))
|
|
270
|
+
if (typeof value !== "string" && typeof value !== "number") return value
|
|
271
|
+
const p = producers.get(String(value))
|
|
272
|
+
return p && accepts(leafOf(at), p) ? `{{${varFor(p)}}}` : value
|
|
273
|
+
}
|
|
274
|
+
for (const c of ordered) {
|
|
275
|
+
let u: URL
|
|
276
|
+
try {
|
|
277
|
+
u = new URL(c.url)
|
|
278
|
+
} catch {
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
const g = groupCall(c)
|
|
282
|
+
if (!g) continue
|
|
283
|
+
if (c.method === "GET" && seenGets.has(c.url)) continue
|
|
284
|
+
if (steps.length >= 40) {
|
|
285
|
+
dropped++
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
if (c.method === "GET") seenGets.add(c.url)
|
|
289
|
+
const contract = set.contracts.find((x) => x.id === g.key)
|
|
290
|
+
const segs = u.pathname.split("/").map((seg) => {
|
|
291
|
+
const p = producers.get(decodeURIComponent(seg))
|
|
292
|
+
return p && seg ? `{{${varFor(p)}}}` : seg
|
|
293
|
+
})
|
|
294
|
+
const pathname = segs.join("/")
|
|
295
|
+
const query: Record<string, string> = {}
|
|
296
|
+
for (const [k, v] of u.searchParams) query[k] = String(replaceIn(v, k))
|
|
297
|
+
let body: unknown = c.request.body === undefined ? undefined : (parseJson(c.request.body) ?? c.request.body)
|
|
298
|
+
if (typeof body === "string") {
|
|
299
|
+
let text = body
|
|
300
|
+
for (const [value, p] of producers) if (ID_SHAPED(value) && text.includes(value)) text = text.split(value).join(`{{${varFor(p)}}}`)
|
|
301
|
+
body = text
|
|
302
|
+
} else if (body !== undefined) body = replaceIn(body, "")
|
|
303
|
+
if (c.method === "POST" && /regist|sign-?up|users?$|accounts?$|customers?$/i.test(u.pathname)) body = uniqueData(body)
|
|
304
|
+
const headers = contract?.example.headers
|
|
305
|
+
const status = c.response.status
|
|
306
|
+
const expect: Contract["expect"] = contract && contract.expect.status === status ? { ...contract.expect } : { status }
|
|
307
|
+
const origin = u.origin === base ? "" : u.origin
|
|
308
|
+
steps.push({
|
|
309
|
+
name: g.protocol === "rest" ? `${c.method} ${g.path}` : g.key,
|
|
310
|
+
...(contract ? { operation: contract.id } : {}),
|
|
311
|
+
protocol: g.protocol,
|
|
312
|
+
method: c.method,
|
|
313
|
+
path: `${origin}${pathname}`,
|
|
314
|
+
...(Object.keys(query).length ? { query } : {}),
|
|
315
|
+
...(headers ? { headers } : {}),
|
|
316
|
+
...(body !== undefined ? { body } : {}),
|
|
317
|
+
expect,
|
|
318
|
+
})
|
|
319
|
+
const answer = parseJson(c.response.body)
|
|
320
|
+
for (const [p, v] of scalarsOf(answer)) {
|
|
321
|
+
const leaf = leafOf(p)
|
|
322
|
+
// The first answer that gave a value owns it: a later answer that echoes it (a GET of the same cart) is not a new source.
|
|
323
|
+
if (candidate(leaf, v) && !producers.has(String(v))) producers.set(String(v), { step: steps.length - 1, path: p, leaf, value: String(v) })
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!steps.length) return null
|
|
327
|
+
const notes = [...new Set(flows)]
|
|
328
|
+
if (dropped) notes.push(`${dropped} more calls left out: scenarios keep the first 40`)
|
|
329
|
+
if (steps.some((s) => JSON.stringify(s.body ?? "").includes("{{$unique}}"))) notes.push("Emails and usernames sent to sign-up endpoints get {{$unique}}, so each run creates a new one.")
|
|
330
|
+
return {
|
|
331
|
+
id: "flow",
|
|
332
|
+
name: opts.goal ? `API flow: ${opts.goal.slice(0, 80)}` : "API flow of the browser run",
|
|
333
|
+
kind: "flow",
|
|
334
|
+
source: { kind: "run", ref: opts.runId || "" },
|
|
335
|
+
steps,
|
|
336
|
+
writes: steps.some((s) => !["GET", "HEAD", "OPTIONS"].includes(s.method) || (s.operation ? set.contracts.find((x) => x.id === s.operation)?.writes : false)),
|
|
337
|
+
...(notes.length ? { notes } : {}),
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// -- from an API description ----------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
function stepOf(c: Contract, over: Partial<ScenarioStep> = {}): ScenarioStep {
|
|
344
|
+
return {
|
|
345
|
+
name: c.id,
|
|
346
|
+
operation: c.id,
|
|
347
|
+
protocol: c.protocol,
|
|
348
|
+
method: c.method,
|
|
349
|
+
path: c.example.path,
|
|
350
|
+
...(c.example.query ? { query: c.example.query } : {}),
|
|
351
|
+
...(c.example.headers ? { headers: c.example.headers } : {}),
|
|
352
|
+
...(c.example.body !== undefined ? { body: c.example.body } : {}),
|
|
353
|
+
expect: c.expect,
|
|
354
|
+
...over,
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The contract's example path with the segment of {param} in its template replaced by {{param}}. */
|
|
359
|
+
function pathWithVar(c: Contract, param: string, value = `{{${param}}}`): string {
|
|
360
|
+
const tpl = c.path.split("/")
|
|
361
|
+
const concrete = c.example.path.split("/")
|
|
362
|
+
if (tpl.length !== concrete.length) return c.path.replace(`{${param}}`, value)
|
|
363
|
+
return concrete.map((seg, i) => (tpl[i] === `{${param}}` ? value : seg)).join("/")
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const props = (s: JsonSchema | undefined): Record<string, JsonSchema> => s?.properties || s?.allOf?.reduce((a, x) => ({ ...a, ...(x.properties || {}) }), {} as Record<string, JsonSchema>) || {}
|
|
367
|
+
|
|
368
|
+
/** Where an item's id is in an answer: the path parameter's own name, else id, else <resource>Id. */
|
|
369
|
+
function idField(schema: JsonSchema | undefined, param: string, resource: string): string | null {
|
|
370
|
+
const p = props(schema)
|
|
371
|
+
for (const k of [param, "id", `${singular(resource)}Id`, `${singular(resource)}_id`]) if (k in p) return k
|
|
372
|
+
return Object.keys(p).find((k) => /(^|_)id$/i.test(k)) || null
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The path in a list answer to its first item's id: [0].id, or items[0].id when the list is in a field. */
|
|
376
|
+
function firstItemId(schema: JsonSchema | undefined, param: string, resource: string): string | null {
|
|
377
|
+
if (!schema) return null
|
|
378
|
+
if (schema.items) {
|
|
379
|
+
const f = idField(schema.items, param, resource)
|
|
380
|
+
return f ? `[0].${f}` : null
|
|
381
|
+
}
|
|
382
|
+
for (const [k, s] of Object.entries(props(schema))) {
|
|
383
|
+
if (s.items) {
|
|
384
|
+
const f = idField(s.items, param, resource)
|
|
385
|
+
if (f) return `${k}[0].${f}`
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return null
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const bodyPath = (expr: string): string | null => {
|
|
392
|
+
const m = expr.match(/^\$response\.body#\/(.*)$/)
|
|
393
|
+
return m ? m[1].split("/").map((s) => (/^\d+$/.test(s) ? `[${s}]` : s)).join(".").replace(/\.\[/g, "[") : null
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Scenarios from a description's REST operations, with no model: for each resource with an item path
|
|
398
|
+
* (/pets and /pets/{petId}), its lifecycle (create, read, update, read, delete, read gives 404) and browsing (the list,
|
|
399
|
+
* then its first item); the description's links (an answer's value into the next operation); and negative cases: an
|
|
400
|
+
* id that does not exist gives 404, no credentials give 401 or 403, an empty body gives 400 or 422.
|
|
401
|
+
*/
|
|
402
|
+
export function scenariosFromSpec(set: ContractSet, opts: { negative?: boolean } = {}): { scenarios: Scenario[]; notes: string[] } {
|
|
403
|
+
const rest = set.contracts.filter((c) => c.protocol === "rest")
|
|
404
|
+
const notes: string[] = []
|
|
405
|
+
if (set.contracts.length > rest.length) notes.push("Scenarios from a description cover REST operations; GraphQL, SOAP and gRPC scenarios come from a browser run, a test case, or plain English.")
|
|
406
|
+
const by = new Map<string, Map<string, Contract>>()
|
|
407
|
+
for (const c of rest) {
|
|
408
|
+
const m = by.get(c.path) || new Map<string, Contract>()
|
|
409
|
+
m.set(c.method, c)
|
|
410
|
+
by.set(c.path, m)
|
|
411
|
+
}
|
|
412
|
+
const out: Scenario[] = []
|
|
413
|
+
const covered = new Set<string>()
|
|
414
|
+
const src = { kind: set.source.kind, ref: set.source.ref }
|
|
415
|
+
for (const [item, ops] of by) {
|
|
416
|
+
const m = item.match(/^(.*)\/\{(\w+)\}$/)
|
|
417
|
+
if (!m) continue
|
|
418
|
+
const [, collection, param] = m
|
|
419
|
+
const coll = by.get(collection) || new Map<string, Contract>()
|
|
420
|
+
const resource = resourceOf(collection)
|
|
421
|
+
const label = singular(resource)
|
|
422
|
+
const create = coll.get("POST")
|
|
423
|
+
const list = coll.get("GET")
|
|
424
|
+
const read = ops.get("GET")
|
|
425
|
+
const update = ops.get("PUT") || ops.get("PATCH")
|
|
426
|
+
const del = ops.get("DELETE")
|
|
427
|
+
const at = (c: Contract) => pathWithVar(c, param)
|
|
428
|
+
if (create && (read || update || del)) {
|
|
429
|
+
const link = create.links?.map((l) => l.parameters[param]).map((e) => (e ? bodyPath(e) : null)).find(Boolean)
|
|
430
|
+
const idPath = link || idField(create.expect.schema, param, resource)
|
|
431
|
+
if (!idPath) notes.push(`${create.id}: its answer has no id field ForgeQE can tell, so the ${label} lifecycle is not made`)
|
|
432
|
+
else {
|
|
433
|
+
const steps: ScenarioStep[] = [stepOf(create, { name: `Create a ${label}`, body: uniqueData(create.example.body), save: { [param]: idPath } })]
|
|
434
|
+
if (read) steps.push(stepOf(read, { name: `Read the ${label}`, path: at(read) }))
|
|
435
|
+
if (update) {
|
|
436
|
+
const body = update.example.body && typeof update.example.body === "object" && !Array.isArray(update.example.body) ? Object.fromEntries(Object.entries(update.example.body as Record<string, unknown>).map(([k, v]) => [k, k === idPath || k === param ? `{{${param}}}` : v])) : update.example.body
|
|
437
|
+
steps.push(stepOf(update, { name: `Update the ${label}`, path: at(update), ...(body !== undefined ? { body } : {}) }))
|
|
438
|
+
if (read) steps.push(stepOf(read, { name: `Read the ${label} after the update`, path: at(read) }))
|
|
439
|
+
}
|
|
440
|
+
if (del) {
|
|
441
|
+
steps.push(stepOf(del, { name: `Delete the ${label}`, path: at(del) }))
|
|
442
|
+
if (read) steps.push(stepOf(read, { name: `The deleted ${label} is gone`, path: at(read), expect: { status: 404, statusIn: [404, 410] } }))
|
|
443
|
+
}
|
|
444
|
+
const made = create.needsData?.some((n) => n.startsWith("request body")) ? [`The create body is made from the schema: give real values in scenarios in contracts.json if the API rejects it.`] : []
|
|
445
|
+
out.push({ id: `${slug(label)}-lifecycle`, name: `${label} lifecycle: create, read${update ? ", update" : ""}${del ? ", delete" : ""}`, kind: "lifecycle", source: src, steps, ...(del ? { cleanup: [stepOf(del, { name: `Clean up: delete the ${label}`, path: at(del), expect: { status: del.expect.status, statusIn: [del.expect.status, 200, 202, 204, 404] } })] } : {}), writes: true, ...(made.length ? { notes: made } : {}) })
|
|
446
|
+
for (const c of [create, read, update, del]) if (c) covered.add(c.id)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (list && read) {
|
|
450
|
+
const first = firstItemId(list.expect.schema, param, resource)
|
|
451
|
+
if (first)
|
|
452
|
+
out.push({
|
|
453
|
+
id: `${slug(label)}-browse`,
|
|
454
|
+
name: `Browse ${resource}: the list, then its first ${label}`,
|
|
455
|
+
kind: "browse",
|
|
456
|
+
source: src,
|
|
457
|
+
steps: [stepOf(list, { name: `List ${resource}`, save: { [param]: first } }), stepOf(read, { name: `Open the first ${label}`, path: at(read) })],
|
|
458
|
+
writes: false,
|
|
459
|
+
notes: [`Needs at least one ${label} in the list.`, ...(list.needsData?.length ? [`The list needs real values for ${list.needsData.join(", ")}.`] : [])],
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
if (opts.negative !== false) {
|
|
463
|
+
if (read) {
|
|
464
|
+
const sample = read.example.path.split("/")[read.path.split("/").indexOf(`{${param}}`)] || ""
|
|
465
|
+
const missing = /^[0-9a-f-]{36}$/i.test(sample) ? "00000000-0000-4000-8000-000000000000" : /^\d+$/.test(sample) || !sample ? "999999999" : `does-not-exist-{{$unique}}`
|
|
466
|
+
out.push({ id: `${slug(label)}-not-found`, name: `A ${label} that does not exist gives 404`, kind: "negative", source: src, steps: [stepOf(read, { name: `Read a ${label} that does not exist`, path: pathWithVar(read, param, missing), expect: { status: 404, statusIn: [404, 410] } })], writes: false })
|
|
467
|
+
}
|
|
468
|
+
const guarded = [list, read].find((c) => c?.auth && !c.needsData?.length)
|
|
469
|
+
if (guarded)
|
|
470
|
+
out.push({ id: `${slug(label)}-no-auth`, name: `${guarded.id} without credentials is refused`, kind: "negative", source: src, steps: [stepOf(guarded, { name: `${guarded.id} without credentials`, noAuth: true, expect: { status: 401, statusIn: [401, 403] } })], writes: false })
|
|
471
|
+
if (create?.requestRequired?.length)
|
|
472
|
+
out.push({ id: `${slug(label)}-invalid-body`, name: `Creating a ${label} without its required fields is refused`, kind: "negative", source: src, steps: [stepOf(create, { name: `Create a ${label} with an empty body`, body: {}, expect: { status: 400, statusIn: [400, 422] } })], writes: true, notes: [`Required: ${create.requestRequired.join(", ")}. If the API accepts the empty body, this creates a ${label}.`] })
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// The description's links: an answer's value into the next operation.
|
|
476
|
+
const byOpId = new Map(rest.filter((c) => c.operationId).map((c) => [c.operationId!, c]))
|
|
477
|
+
for (const a of rest) {
|
|
478
|
+
for (const l of a.links || []) {
|
|
479
|
+
const b = byOpId.get(l.operationId)
|
|
480
|
+
if (!b || (covered.has(a.id) && covered.has(b.id))) continue
|
|
481
|
+
const save: Record<string, string> = {}
|
|
482
|
+
let p = b.example.path
|
|
483
|
+
const query = { ...(b.example.query || {}) }
|
|
484
|
+
for (const [param, expr] of Object.entries(l.parameters)) {
|
|
485
|
+
const from = bodyPath(expr)
|
|
486
|
+
if (!from) continue
|
|
487
|
+
save[param] = from
|
|
488
|
+
if (b.path.includes(`{${param}}`)) p = pathWithVar({ ...b, example: { ...b.example, path: p } }, param)
|
|
489
|
+
else query[param] = `{{${param}}}`
|
|
490
|
+
}
|
|
491
|
+
if (!Object.keys(save).length) continue
|
|
492
|
+
out.push({ id: `${slug(a.operationId || a.id)}-then-${slug(l.operationId)}`, name: `${a.operationId || a.id}, then ${l.operationId} with its answer`, kind: "linked", source: src, steps: [stepOf(a, { save, ...(a.writes ? { body: uniqueData(a.example.body) } : {}) }), stepOf(b, { path: p, ...(Object.keys(query).length ? { query } : {}) })], writes: a.writes || b.writes })
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return { scenarios: uniqueIds(out), notes }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Add scenarios to a set, with ids unique in it. */
|
|
499
|
+
export function addScenarios(set: ContractSet, scenarios: Scenario[], replaceKinds: string[] = []): ContractSet {
|
|
500
|
+
const kept = (set.scenarios || []).filter((s) => !replaceKinds.includes(s.kind))
|
|
501
|
+
return { ...set, scenarios: [...kept, ...uniqueIds(scenarios, kept.map((s) => s.id))] }
|
|
502
|
+
}
|