@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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import YAML from "yaml"
|
|
5
|
+
|
|
6
|
+
import type { CaseStep, TestCase } from "./types.ts"
|
|
7
|
+
|
|
8
|
+
/** A goal typed or passed as plain text: one test case, the text is its description. */
|
|
9
|
+
export function textCase(text: string, n = 1): TestCase {
|
|
10
|
+
const body = text.trim()
|
|
11
|
+
const [first, ...rest] = body.split("\n")
|
|
12
|
+
return {
|
|
13
|
+
id: `text#${n}`,
|
|
14
|
+
title: clip(first.replace(/^#+\s*/, "").trim(), 100),
|
|
15
|
+
source: "text",
|
|
16
|
+
description: rest.join("\n").trim() || (first.length > 100 ? body : undefined),
|
|
17
|
+
steps: [],
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Test cases from a file: .yaml/.yml/.json, .csv, .feature (Gherkin), or any text file split on `---` lines. */
|
|
22
|
+
export function fileCases(file: string): TestCase[] {
|
|
23
|
+
const text = readFileSync(file, "utf8")
|
|
24
|
+
const base = path.basename(file)
|
|
25
|
+
const ext = path.extname(file).toLowerCase()
|
|
26
|
+
const cases =
|
|
27
|
+
ext === ".yaml" || ext === ".yml" || ext === ".json"
|
|
28
|
+
? structuredCases(ext === ".json" ? JSON.parse(text) : YAML.parse(text), base)
|
|
29
|
+
: ext === ".csv"
|
|
30
|
+
? csvCases(text, base)
|
|
31
|
+
: ext === ".feature"
|
|
32
|
+
? featureCases(text, base)
|
|
33
|
+
: textFileCases(text, base)
|
|
34
|
+
if (!cases.length) throw new Error(`No test cases in ${file}.`)
|
|
35
|
+
return cases
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function textFileCases(text: string, base: string): TestCase[] {
|
|
39
|
+
return text
|
|
40
|
+
.split(/^\s*-{3,}\s*$/m)
|
|
41
|
+
.map((block) => block.trim())
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.map((block, i) => ({ ...textCase(block, i + 1), id: `${base}#${i + 1}`, source: "file" as const }))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// -- YAML / JSON --------------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
type Raw = Record<string, unknown>
|
|
49
|
+
|
|
50
|
+
function structuredCases(doc: unknown, base: string): TestCase[] {
|
|
51
|
+
const items = Array.isArray(doc) ? doc : Array.isArray((doc as Raw)?.cases) ? ((doc as Raw).cases as unknown[]) : doc ? [doc] : []
|
|
52
|
+
return items.map((item, i) => {
|
|
53
|
+
if (typeof item === "string") return { ...textCase(item, i + 1), id: `${base}#${i + 1}`, source: "file" as const }
|
|
54
|
+
const raw = (item || {}) as Raw
|
|
55
|
+
const steps = Array.isArray(raw.steps) ? raw.steps.map(stepFrom).filter((s): s is CaseStep => !!s) : []
|
|
56
|
+
const description = str(raw.description) || str(raw.goal)
|
|
57
|
+
const title = str(raw.title) || str(raw.name) || (description ? clip(description.split("\n")[0], 100) : `Case ${i + 1}`)
|
|
58
|
+
return {
|
|
59
|
+
id: str(raw.id) || `${base}#${i + 1}`,
|
|
60
|
+
title,
|
|
61
|
+
source: "file" as const,
|
|
62
|
+
description: description && description !== title ? description : undefined,
|
|
63
|
+
preconditions: list(raw.preconditions),
|
|
64
|
+
steps,
|
|
65
|
+
startUrl: str(raw.url) || str(raw.start_url),
|
|
66
|
+
tags: list(raw.tags),
|
|
67
|
+
group: str(raw.group) || str(raw.suite) || str(raw.area),
|
|
68
|
+
requirements: list(raw.requirements) || list(raw.refs),
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stepFrom(raw: unknown): CaseStep | null {
|
|
74
|
+
if (typeof raw === "string") return raw.trim() ? { action: raw.trim() } : null
|
|
75
|
+
const r = (raw || {}) as Raw
|
|
76
|
+
const action = str(r.action) || str(r.step)
|
|
77
|
+
if (!action) return null
|
|
78
|
+
return { action, data: str(r.data), expected: str(r.expected) || str(r.result) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// -- CSV ------------------------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Header row, then one row per case or per step: `id, title, url, description, preconditions, tags, action, data,
|
|
85
|
+
* expected`. A row with an empty id, or the same id as the row above, adds a step to that case.
|
|
86
|
+
*/
|
|
87
|
+
function csvCases(text: string, base: string): TestCase[] {
|
|
88
|
+
const rows = parseCsv(text)
|
|
89
|
+
if (rows.length < 2) return []
|
|
90
|
+
const header = rows[0].map((h) => h.trim().toLowerCase().replace(/[\s-]+/g, "_"))
|
|
91
|
+
const col = (row: string[], ...names: string[]) => {
|
|
92
|
+
for (const n of names) {
|
|
93
|
+
const i = header.indexOf(n)
|
|
94
|
+
if (i >= 0 && row[i]?.trim()) return row[i].trim()
|
|
95
|
+
}
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
const cases: TestCase[] = []
|
|
99
|
+
for (const row of rows.slice(1)) {
|
|
100
|
+
if (row.every((c) => !c.trim())) continue
|
|
101
|
+
const id = col(row, "id", "key", "test_id")
|
|
102
|
+
const last = cases[cases.length - 1]
|
|
103
|
+
const step: CaseStep | null = col(row, "action", "step")
|
|
104
|
+
? { action: col(row, "action", "step")!, data: col(row, "data", "test_data"), expected: col(row, "expected", "expected_result", "result") }
|
|
105
|
+
: null
|
|
106
|
+
if (last && (!id || id === last.id)) {
|
|
107
|
+
if (step) last.steps.push(step)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
const description = col(row, "description", "goal", "steps")
|
|
111
|
+
const title = col(row, "title", "name", "summary") || (description ? clip(description.split("\n")[0], 100) : `Case ${cases.length + 1}`)
|
|
112
|
+
cases.push({
|
|
113
|
+
id: id || `${base}#${cases.length + 1}`,
|
|
114
|
+
title,
|
|
115
|
+
source: "file",
|
|
116
|
+
description: description && description !== title ? description : undefined,
|
|
117
|
+
preconditions: splitList(col(row, "preconditions", "precondition")),
|
|
118
|
+
steps: step ? [step] : [],
|
|
119
|
+
startUrl: col(row, "url", "start_url"),
|
|
120
|
+
tags: splitList(col(row, "tags", "labels")),
|
|
121
|
+
group: col(row, "group", "suite", "area"),
|
|
122
|
+
requirements: splitList(col(row, "requirements", "refs")),
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
return cases
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** RFC 4180: quoted fields, doubled quotes, newlines inside quotes. */
|
|
129
|
+
export function parseCsv(text: string): string[][] {
|
|
130
|
+
const rows: string[][] = []
|
|
131
|
+
let row: string[] = []
|
|
132
|
+
let field = ""
|
|
133
|
+
let quoted = false
|
|
134
|
+
const src = text.replace(/^\uFEFF/, "")
|
|
135
|
+
for (let i = 0; i < src.length; i++) {
|
|
136
|
+
const c = src[i]
|
|
137
|
+
if (quoted) {
|
|
138
|
+
if (c === '"' && src[i + 1] === '"') {
|
|
139
|
+
field += '"'
|
|
140
|
+
i++
|
|
141
|
+
} else if (c === '"') quoted = false
|
|
142
|
+
else field += c
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (c === '"') quoted = true
|
|
146
|
+
else if (c === ",") {
|
|
147
|
+
row.push(field)
|
|
148
|
+
field = ""
|
|
149
|
+
} else if (c === "\n" || c === "\r") {
|
|
150
|
+
if (c === "\r" && src[i + 1] === "\n") i++
|
|
151
|
+
row.push(field)
|
|
152
|
+
rows.push(row)
|
|
153
|
+
row = []
|
|
154
|
+
field = ""
|
|
155
|
+
} else field += c
|
|
156
|
+
}
|
|
157
|
+
if (field || row.length) {
|
|
158
|
+
row.push(field)
|
|
159
|
+
rows.push(row)
|
|
160
|
+
}
|
|
161
|
+
return rows
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// -- Gherkin ------------------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const STEP_KEYWORDS = ["Given", "When", "Then", "And", "But", "*"]
|
|
167
|
+
|
|
168
|
+
/** Each Scenario is a case; a Scenario Outline gives one case per Examples row. Then-steps are expected results. */
|
|
169
|
+
function featureCases(text: string, base: string): TestCase[] {
|
|
170
|
+
type Scenario = { name: string; tags: string[]; steps: string[]; examples: string[][] }
|
|
171
|
+
let feature = ""
|
|
172
|
+
let featureTags: string[] = []
|
|
173
|
+
let pendingTags: string[] = []
|
|
174
|
+
let background: string[] = []
|
|
175
|
+
let current: Scenario | null = null
|
|
176
|
+
let inBackground = false
|
|
177
|
+
let inExamples = false
|
|
178
|
+
let docString: string | null = null
|
|
179
|
+
const scenarios: Scenario[] = []
|
|
180
|
+
|
|
181
|
+
const appendToLastStep = (extra: string) => {
|
|
182
|
+
const target = inBackground ? background : current?.steps
|
|
183
|
+
if (target?.length) target[target.length - 1] += `\n${extra}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
187
|
+
const line = rawLine.trim()
|
|
188
|
+
if (docString !== null) {
|
|
189
|
+
if (line === '"""' || line === "```") {
|
|
190
|
+
appendToLastStep(docString.trimEnd())
|
|
191
|
+
docString = null
|
|
192
|
+
} else docString += `${line}\n`
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
if (!line || line.startsWith("#")) continue
|
|
196
|
+
if (line === '"""' || line === "```") {
|
|
197
|
+
docString = ""
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (line.startsWith("@")) {
|
|
201
|
+
pendingTags.push(...line.split(/\s+/).map((t) => t.slice(1)).filter(Boolean))
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
const keyword = line.match(/^(Feature|Background|Scenario Outline|Scenario Template|Scenario|Example|Examples|Scenarios|Rule):\s*(.*)$/)
|
|
205
|
+
if (keyword) {
|
|
206
|
+
const [, kind, name] = keyword
|
|
207
|
+
inBackground = false
|
|
208
|
+
inExamples = false
|
|
209
|
+
if (kind === "Feature") {
|
|
210
|
+
feature = name
|
|
211
|
+
featureTags = pendingTags
|
|
212
|
+
} else if (kind === "Background") inBackground = true
|
|
213
|
+
else if (kind === "Examples" || kind === "Scenarios") inExamples = true
|
|
214
|
+
else if (kind !== "Rule") {
|
|
215
|
+
current = { name, tags: [...featureTags, ...pendingTags], steps: [], examples: [] }
|
|
216
|
+
scenarios.push(current)
|
|
217
|
+
}
|
|
218
|
+
pendingTags = []
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if (line.startsWith("|")) {
|
|
222
|
+
const cells = line.slice(1, line.endsWith("|") ? -1 : undefined).split("|").map((c) => c.trim())
|
|
223
|
+
if (inExamples && current) current.examples.push(cells)
|
|
224
|
+
else appendToLastStep(line)
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
const kw = STEP_KEYWORDS.find((k) => line === k || line.startsWith(`${k} `))
|
|
228
|
+
if (kw) {
|
|
229
|
+
if (inBackground) background.push(line)
|
|
230
|
+
else current?.steps.push(line)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const cases: TestCase[] = []
|
|
235
|
+
for (const sc of scenarios) {
|
|
236
|
+
const header = sc.examples[0]
|
|
237
|
+
const rows = header ? sc.examples.slice(1) : [[]]
|
|
238
|
+
rows.forEach((row, r) => {
|
|
239
|
+
const fill = (s: string) => (header ? header.reduce((acc, h, i) => acc.split(`<${h}>`).join(row[i] ?? ""), s) : s)
|
|
240
|
+
const n = cases.length + 1
|
|
241
|
+
cases.push({
|
|
242
|
+
id: `${base}#${n}`,
|
|
243
|
+
title: fill(sc.name) + (header && !sc.name.includes("<") ? ` (example ${r + 1})` : ""),
|
|
244
|
+
source: "file",
|
|
245
|
+
description: feature ? `Feature: ${feature}` : undefined,
|
|
246
|
+
steps: gherkinSteps([...background, ...sc.steps].map(fill)),
|
|
247
|
+
tags: sc.tags.length ? sc.tags : undefined,
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
return cases
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function gherkinSteps(lines: string[]): CaseStep[] {
|
|
255
|
+
const steps: CaseStep[] = []
|
|
256
|
+
let inThen = false
|
|
257
|
+
for (const line of lines) {
|
|
258
|
+
const kw = STEP_KEYWORDS.find((k) => line === k || line.startsWith(`${k} `))!
|
|
259
|
+
if (kw === "Then") inThen = true
|
|
260
|
+
else if (kw === "Given" || kw === "When") inThen = false
|
|
261
|
+
if (inThen) {
|
|
262
|
+
const last = steps[steps.length - 1]
|
|
263
|
+
if (last) last.expected = last.expected ? `${last.expected}\n${line}` : line
|
|
264
|
+
else steps.push({ action: "Check the page", expected: line })
|
|
265
|
+
} else steps.push({ action: line })
|
|
266
|
+
}
|
|
267
|
+
return steps
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// -- helpers ------------------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
function str(v: unknown): string | undefined {
|
|
273
|
+
if (v === undefined || v === null) return undefined
|
|
274
|
+
const s = String(v).trim()
|
|
275
|
+
return s || undefined
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function list(v: unknown): string[] | undefined {
|
|
279
|
+
if (Array.isArray(v)) {
|
|
280
|
+
const out = v.map((x) => String(x).trim()).filter(Boolean)
|
|
281
|
+
return out.length ? out : undefined
|
|
282
|
+
}
|
|
283
|
+
return splitList(str(v))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function splitList(v?: string): string[] | undefined {
|
|
287
|
+
if (!v) return undefined
|
|
288
|
+
const out = v.split(/[;\n]/).map((s) => s.trim()).filter(Boolean)
|
|
289
|
+
return out.length ? out : undefined
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function clip(s: string, n: number): string {
|
|
293
|
+
const one = s.replace(/\s+/g, " ").trim()
|
|
294
|
+
return one.length <= n ? one : `${one.slice(0, n - 1)}…`
|
|
295
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { TestCase } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
const SOURCE_NAME = { text: "text", file: "file", ado: "Azure DevOps", jira: "Jira", xray: "Xray", testrail: "TestRail", zephyr: "Zephyr Scale", qtest: "qTest" } as const
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The browser goal for a test case: every step with its data and expected result, and an instruction to check each
|
|
7
|
+
* expected result on the page. The agent's own verdict on those checks decides pass or fail.
|
|
8
|
+
*/
|
|
9
|
+
export function goalFor(tc: TestCase): string {
|
|
10
|
+
const lines = [`Test case ${tc.id}: ${tc.title}`]
|
|
11
|
+
if (tc.description?.trim()) lines.push("", tc.description.trim())
|
|
12
|
+
if (tc.preconditions?.length) {
|
|
13
|
+
lines.push("", "Preconditions:")
|
|
14
|
+
for (const p of tc.preconditions) lines.push(`- ${p}`)
|
|
15
|
+
}
|
|
16
|
+
if (tc.steps.length) {
|
|
17
|
+
lines.push("", "Steps:")
|
|
18
|
+
tc.steps.forEach((s, i) => {
|
|
19
|
+
lines.push(`${i + 1}. ${s.action}`)
|
|
20
|
+
if (s.data?.trim()) lines.push(` Data: ${s.data.trim()}`)
|
|
21
|
+
if (s.expected?.trim()) lines.push(` Expected: ${s.expected.trim()}`)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
const checks = tc.steps.some((s) => s.expected?.trim())
|
|
25
|
+
lines.push(
|
|
26
|
+
"",
|
|
27
|
+
checks
|
|
28
|
+
? "Carry out the steps in order and check every expected result on the page. Finish with success only if every expected result holds; otherwise finish with failure and say which step's expected result did not hold and what the page showed instead."
|
|
29
|
+
: "Carry out the test case and check that the outcome it describes holds on the page. Finish with success only if it does; otherwise finish with failure and say what the page showed instead.",
|
|
30
|
+
)
|
|
31
|
+
return lines.join("\n")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Markdown for the coding agent's handoff: where the case came from and what it checks. */
|
|
35
|
+
export function caseMarkdown(tc: TestCase): string {
|
|
36
|
+
const lines = ["## Test case", "", `${tc.id}: ${tc.title}`, `Source: ${SOURCE_NAME[tc.source]}${tc.url ? ` (${tc.url})` : ""}`]
|
|
37
|
+
if (tc.tags?.length) lines.push(`Tags: ${tc.tags.join(", ")}`)
|
|
38
|
+
if (tc.description?.trim()) lines.push("", tc.description.trim())
|
|
39
|
+
if (tc.preconditions?.length) lines.push("", "Preconditions:", ...tc.preconditions.map((p) => `- ${p}`))
|
|
40
|
+
if (tc.steps.length) {
|
|
41
|
+
lines.push("", "| # | Action | Data | Expected |", "|---|--------|------|----------|")
|
|
42
|
+
tc.steps.forEach((s, i) => lines.push(`| ${i + 1} | ${cell(s.action)} | ${cell(s.data)} | ${cell(s.expected)} |`))
|
|
43
|
+
}
|
|
44
|
+
lines.push(
|
|
45
|
+
"",
|
|
46
|
+
`Name the test after the case, e.g. \`${tc.id} ${tc.title}\`, and assert each expected result. Keep the id in the test title so results can be traced back to ${SOURCE_NAME[tc.source]}.`,
|
|
47
|
+
)
|
|
48
|
+
return lines.join("\n")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cell(s?: string): string {
|
|
52
|
+
return (s || "").replace(/\|/g, "\\|").replace(/\s*\n\s*/g, "<br>")
|
|
53
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** JSON over HTTP for the test-management APIs. Errors name the method, URL path, and status, never credentials. */
|
|
2
|
+
export async function http<T = unknown>(
|
|
3
|
+
url: string,
|
|
4
|
+
opts: { method?: string; auth: string; body?: unknown; contentType?: string; headers?: Record<string, string> },
|
|
5
|
+
): Promise<{ data: T; headers: Headers }> {
|
|
6
|
+
const res = await fetch(url, {
|
|
7
|
+
method: opts.method || (opts.body === undefined ? "GET" : "POST"),
|
|
8
|
+
headers: {
|
|
9
|
+
Accept: "application/json",
|
|
10
|
+
...(opts.auth ? { Authorization: opts.auth } : {}),
|
|
11
|
+
...(opts.body === undefined ? {} : { "Content-Type": opts.contentType || "application/json" }),
|
|
12
|
+
...opts.headers,
|
|
13
|
+
},
|
|
14
|
+
body: opts.body === undefined ? undefined : typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body),
|
|
15
|
+
})
|
|
16
|
+
const text = await res.text()
|
|
17
|
+
if (!res.ok) {
|
|
18
|
+
const where = new URL(url)
|
|
19
|
+
const hint = res.status === 401 || res.status === 403 ? " Check the token and its permissions." : ""
|
|
20
|
+
throw new Error(`${opts.method || (opts.body === undefined ? "GET" : "POST")} ${where.host}${where.pathname} failed: ${res.status} ${res.statusText}.${hint} ${brief(text)}`.trim())
|
|
21
|
+
}
|
|
22
|
+
let data: unknown = text
|
|
23
|
+
try {
|
|
24
|
+
data = text ? JSON.parse(text) : null
|
|
25
|
+
} catch {}
|
|
26
|
+
return { data: data as T, headers: res.headers }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function brief(text: string): string {
|
|
30
|
+
const one = text.replace(/\s+/g, " ").trim()
|
|
31
|
+
return one.length > 300 ? `${one.slice(0, 300)}…` : one
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function basic(user: string, secret: string): string {
|
|
35
|
+
return `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function envSecret(name: string, what: string): string {
|
|
39
|
+
const value = process.env[name]
|
|
40
|
+
if (!value) throw new Error(`${what} needs ${name} in the environment (or a .env next to forgeqe.yaml), or a sign-in: run forgeqe auth login, or type /auth in the shell.`)
|
|
41
|
+
return value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Rich text from Azure DevOps (HTML, often entity-encoded) as plain text with line breaks. */
|
|
45
|
+
export function htmlToText(html: string | undefined): string {
|
|
46
|
+
if (!html) return ""
|
|
47
|
+
const decoded = decodeEntities(html)
|
|
48
|
+
return decodeEntities(
|
|
49
|
+
decoded
|
|
50
|
+
.replace(/<\s*br\s*\/?>/gi, "\n")
|
|
51
|
+
.replace(/<\s*\/\s*(p|div|li|tr|h[1-6])\s*>/gi, "\n")
|
|
52
|
+
.replace(/<\s*li[^>]*>/gi, "- ")
|
|
53
|
+
.replace(/<[^>]+>/g, ""),
|
|
54
|
+
)
|
|
55
|
+
.split("\n")
|
|
56
|
+
.map((l) => l.replace(/[ \t\u00a0]+/g, " ").trim())
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join("\n")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function decodeEntities(s: string): string {
|
|
62
|
+
return s
|
|
63
|
+
.replace(/</g, "<")
|
|
64
|
+
.replace(/>/g, ">")
|
|
65
|
+
.replace(/"/g, '"')
|
|
66
|
+
.replace(/'|'/g, "'")
|
|
67
|
+
.replace(/ /g, " ")
|
|
68
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
69
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
|
70
|
+
.replace(/&/g, "&")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type AdfNode = { type?: string; text?: string; content?: AdfNode[]; attrs?: Record<string, unknown> }
|
|
74
|
+
|
|
75
|
+
/** Jira Cloud's Atlassian Document Format as plain text. Strings (Jira Server wiki text) pass through. */
|
|
76
|
+
export function adfToText(doc: unknown): string {
|
|
77
|
+
if (typeof doc === "string") return doc.trim()
|
|
78
|
+
if (!doc || typeof doc !== "object") return ""
|
|
79
|
+
const out: string[] = []
|
|
80
|
+
const walk = (node: AdfNode, prefix = ""): string => {
|
|
81
|
+
if (node.type === "text") return node.text || ""
|
|
82
|
+
if (node.type === "hardBreak") return "\n"
|
|
83
|
+
const inner = (node.content || []).map((c) => walk(c, prefix)).join("")
|
|
84
|
+
switch (node.type) {
|
|
85
|
+
case "paragraph":
|
|
86
|
+
case "heading":
|
|
87
|
+
return `${inner}\n`
|
|
88
|
+
case "listItem":
|
|
89
|
+
return `- ${inner.trim()}\n`
|
|
90
|
+
case "codeBlock":
|
|
91
|
+
return `${inner}\n`
|
|
92
|
+
default:
|
|
93
|
+
return inner
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
out.push(walk(doc as AdfNode))
|
|
97
|
+
return out.join("").split("\n").map((l) => l.trimEnd()).filter((l, i, a) => l || (i > 0 && a[i - 1])).join("\n").trim()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Plain text (paragraphs separated by blank lines) as an ADF document for Jira Cloud comments. */
|
|
101
|
+
export function textToAdf(text: string): Record<string, unknown> {
|
|
102
|
+
const paragraphs = text.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean)
|
|
103
|
+
return {
|
|
104
|
+
type: "doc",
|
|
105
|
+
version: 1,
|
|
106
|
+
content: paragraphs.map((p) => ({
|
|
107
|
+
type: "paragraph",
|
|
108
|
+
content: p.split("\n").flatMap((line, i) => [...(i ? [{ type: "hardBreak" }] : []), { type: "text", text: line }]),
|
|
109
|
+
})),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { SourcesConfig } from "../config.ts"
|
|
5
|
+
import { adoCases, adoWriteBack } from "./ado.ts"
|
|
6
|
+
import { fileCases, textCase } from "./files.ts"
|
|
7
|
+
import { jiraCases, jiraWriteBack } from "./jira.ts"
|
|
8
|
+
import { qtestCases, qtestWriteBack } from "./qtest.ts"
|
|
9
|
+
import { testrailCases, testrailWriteBack } from "./testrail.ts"
|
|
10
|
+
import type { CaseResult, TestCase, WriteBack } from "./types.ts"
|
|
11
|
+
import { xrayCases, xrayWriteBack } from "./xray.ts"
|
|
12
|
+
import { zephyrCases, zephyrWriteBack } from "./zephyr.ts"
|
|
13
|
+
|
|
14
|
+
export { caseMarkdown, goalFor } from "./goal.ts"
|
|
15
|
+
export type { CaseResult, CaseStatus, CaseStep, TestCase, WriteBack } from "./types.ts"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where a reference points: `ado:…`, `jira:…`, `xray:…`, `testrail:…`, `zephyr:…`, `qtest:…`, an existing file, or
|
|
19
|
+
* otherwise plain text that is itself the test case.
|
|
20
|
+
*/
|
|
21
|
+
export function refKind(ref: string): "ado" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "file" | "text" {
|
|
22
|
+
const scheme = ref.match(/^(ado|azure|jira|xray|testrail|zephyr|qtest):/i)?.[1]?.toLowerCase()
|
|
23
|
+
if (scheme === "ado" || scheme === "azure") return "ado"
|
|
24
|
+
if (scheme) return scheme as "jira" | "xray" | "testrail" | "zephyr" | "qtest"
|
|
25
|
+
const file = path.resolve(ref)
|
|
26
|
+
if (ref.length < 1024 && existsSync(file) && statSync(file).isFile()) return "file"
|
|
27
|
+
return "text"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function loadCases(refs: string[], cfg: SourcesConfig): Promise<TestCase[]> {
|
|
31
|
+
const cases: TestCase[] = []
|
|
32
|
+
let texts = 0
|
|
33
|
+
for (const ref of refs.map((r) => r.trim()).filter(Boolean)) {
|
|
34
|
+
const kind = refKind(ref)
|
|
35
|
+
const rest = ref.slice(ref.indexOf(":") + 1).trim()
|
|
36
|
+
if (kind === "ado") cases.push(...(await adoCases(rest, cfg)))
|
|
37
|
+
else if (kind === "jira") cases.push(...(await jiraCases(rest, cfg)))
|
|
38
|
+
else if (kind === "xray") cases.push(...(await xrayCases(rest, cfg)))
|
|
39
|
+
else if (kind === "testrail") cases.push(...(await testrailCases(rest, cfg)))
|
|
40
|
+
else if (kind === "zephyr") cases.push(...(await zephyrCases(rest, cfg)))
|
|
41
|
+
else if (kind === "qtest") cases.push(...(await qtestCases(rest, cfg)))
|
|
42
|
+
else if (kind === "file") cases.push(...fileCases(path.resolve(ref)))
|
|
43
|
+
else cases.push(textCase(ref, ++texts))
|
|
44
|
+
}
|
|
45
|
+
return uniqueIds(cases)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where the agent wants test cases from, as it decided from the user's request. */
|
|
49
|
+
export type CaseSelector = {
|
|
50
|
+
source: "azure_devops" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "file" | "text"
|
|
51
|
+
ids?: string[]
|
|
52
|
+
plan?: string
|
|
53
|
+
suite?: string
|
|
54
|
+
run?: string
|
|
55
|
+
cycle?: string
|
|
56
|
+
folder?: string
|
|
57
|
+
jql?: string
|
|
58
|
+
paths?: string[]
|
|
59
|
+
text?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Test cases for structured selectors; relative paths resolve against `cwd`. */
|
|
63
|
+
export async function casesFrom(selectors: CaseSelector[], cfg: SourcesConfig, cwd = process.cwd()): Promise<TestCase[]> {
|
|
64
|
+
const cases: TestCase[] = []
|
|
65
|
+
let texts = 0
|
|
66
|
+
const ids = (s: CaseSelector, what: string) => {
|
|
67
|
+
const list = (s.ids || []).map((i) => String(i).trim()).filter(Boolean)
|
|
68
|
+
if (!list.length) throw new Error(`${what}: give ids, a plan, or a query.`)
|
|
69
|
+
return list.join(",")
|
|
70
|
+
}
|
|
71
|
+
for (const s of selectors) {
|
|
72
|
+
if (s.source === "azure_devops") {
|
|
73
|
+
cases.push(...(await adoCases(s.plan ? `plan=${s.plan}${s.suite ? `/suite=${s.suite}` : ""}` : ids(s, "Azure DevOps"), cfg)))
|
|
74
|
+
} else if (s.source === "jira") {
|
|
75
|
+
cases.push(...(await jiraCases(s.jql ? `jql=${s.jql}` : ids(s, "Jira"), cfg)))
|
|
76
|
+
} else if (s.source === "xray") {
|
|
77
|
+
cases.push(...(await xrayCases(s.plan ? `plan=${s.plan}` : s.jql ? `jql=${s.jql}` : ids(s, "Xray"), cfg)))
|
|
78
|
+
} else if (s.source === "testrail") {
|
|
79
|
+
const sel = s.run ? `run=${s.run}` : s.plan ? `plan=${s.plan}` : s.suite ? `suite=${s.suite}` : ids(s, "TestRail")
|
|
80
|
+
cases.push(...(await testrailCases(sel, cfg)))
|
|
81
|
+
} else if (s.source === "zephyr") {
|
|
82
|
+
cases.push(...(await zephyrCases(s.cycle ? `cycle=${s.cycle}` : s.folder ? `folder=${s.folder}` : ids(s, "Zephyr Scale"), cfg)))
|
|
83
|
+
} else if (s.source === "qtest") {
|
|
84
|
+
cases.push(...(await qtestCases(s.cycle ? `cycle=${s.cycle}` : s.suite ? `suite=${s.suite}` : ids(s, "qTest"), cfg)))
|
|
85
|
+
} else if (s.source === "file") {
|
|
86
|
+
if (!s.paths?.length) throw new Error("File source: give the file paths.")
|
|
87
|
+
for (const p of s.paths) {
|
|
88
|
+
const file = path.resolve(cwd, p)
|
|
89
|
+
if (!existsSync(file) || !statSync(file).isFile()) throw new Error(`Test case file not found: ${file}`)
|
|
90
|
+
cases.push(...fileCases(file))
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
if (!s.text?.trim()) throw new Error("Text source: give the test case text.")
|
|
94
|
+
cases.push(textCase(s.text, ++texts))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return uniqueIds(cases)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function uniqueIds(cases: TestCase[]): TestCase[] {
|
|
101
|
+
const seen = new Map<string, number>()
|
|
102
|
+
return cases.map((c) => {
|
|
103
|
+
const n = (seen.get(c.id) || 0) + 1
|
|
104
|
+
seen.set(c.id, n)
|
|
105
|
+
return n === 1 ? c : { ...c, id: `${c.id}~${n}` }
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Post each result to the system its case came from. Text and file cases have nowhere to go. */
|
|
110
|
+
export async function writeBack(results: CaseResult[], cfg: SourcesConfig, runName: string): Promise<WriteBack[]> {
|
|
111
|
+
const of = (s: TestCase["source"]) => results.filter((r) => r.case.source === s && r.status !== "stopped")
|
|
112
|
+
const out: WriteBack[] = []
|
|
113
|
+
const tasks: [TestCase["source"], (rs: CaseResult[]) => Promise<WriteBack[]>][] = [
|
|
114
|
+
["ado", (rs) => adoWriteBack(rs, cfg, runName)],
|
|
115
|
+
["jira", (rs) => jiraWriteBack(rs, cfg)],
|
|
116
|
+
["xray", (rs) => xrayWriteBack(rs, cfg, runName)],
|
|
117
|
+
["testrail", (rs) => testrailWriteBack(rs, cfg, runName)],
|
|
118
|
+
["zephyr", (rs) => zephyrWriteBack(rs, cfg, runName)],
|
|
119
|
+
["qtest", (rs) => qtestWriteBack(rs, cfg, runName)],
|
|
120
|
+
]
|
|
121
|
+
for (const [source, post] of tasks) {
|
|
122
|
+
const rs = of(source)
|
|
123
|
+
if (!rs.length) continue
|
|
124
|
+
try {
|
|
125
|
+
out.push(...(await post(rs)))
|
|
126
|
+
} catch (err) {
|
|
127
|
+
out.push({ source, text: `${source} results not recorded`, error: (err as Error).message })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|