@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,280 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { AppConfig, LoginConfig } from "../config.ts"
|
|
5
|
+
import type { Card } from "../card.ts"
|
|
6
|
+
import { fileBugs } from "../cases/bugs.ts"
|
|
7
|
+
import { classifyFailure, FAILURE_LABEL, type Failure } from "../cases/failure.ts"
|
|
8
|
+
import { goalFor } from "../cases/goal.ts"
|
|
9
|
+
import type { CaseResult, TestCase, WriteBack } from "../cases/types.ts"
|
|
10
|
+
import { browserErrors, problemLine } from "../report/data.ts"
|
|
11
|
+
import { executeRun } from "../run.ts"
|
|
12
|
+
import type { AgentEvent, RunResult, Usage } from "../types.ts"
|
|
13
|
+
import { addUsage, costOf } from "../usage.ts"
|
|
14
|
+
import type { Charter } from "./charters.ts"
|
|
15
|
+
import type { Story } from "./story.ts"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Exploratory testing of a story: each charter runs in a real browser (as its role when it names one), and each
|
|
19
|
+
* expected result is judged on the live page. A charter that finds a functional defect runs again: a defect is
|
|
20
|
+
* confirmed only when the second run fails the same way. What does not reproduce, the browser errors seen on the way,
|
|
21
|
+
* and what the browser agent noticed beyond the steps are observations, not defects. Charters that ran clean or
|
|
22
|
+
* found a confirmed defect become proposed test cases; bugs are filed for confirmed defects only.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type CharterStatus = "passed" | "defect" | "not_reproduced" | "could_not_explore" | "stopped"
|
|
26
|
+
|
|
27
|
+
export type CharterResult = {
|
|
28
|
+
charter: Charter
|
|
29
|
+
status: CharterStatus
|
|
30
|
+
summary: string
|
|
31
|
+
runDir: string
|
|
32
|
+
reproRunDir?: string
|
|
33
|
+
failure?: Failure
|
|
34
|
+
/** What the browser agent noticed beyond the steps, and the browser errors on the way. */
|
|
35
|
+
observations: string[]
|
|
36
|
+
finalUrl?: string
|
|
37
|
+
usage: Usage
|
|
38
|
+
/** The run as a test case result, for bugs and reports. */
|
|
39
|
+
caseResult: CaseResult
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ProposedCase = { title: string; steps: { action: string; expected: string }[]; preconditions?: string[]; description?: string; from: string; kind: "regression" | "defect" }
|
|
43
|
+
|
|
44
|
+
export type Exploration = {
|
|
45
|
+
dir: string
|
|
46
|
+
story: Story
|
|
47
|
+
results: CharterResult[]
|
|
48
|
+
proposed: ProposedCase[]
|
|
49
|
+
bugs: WriteBack[]
|
|
50
|
+
usage: { plan: Usage; browser: Usage }
|
|
51
|
+
report: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const NOTICED = "Also noticed:"
|
|
55
|
+
|
|
56
|
+
/** The goal for one charter: the charter as a test case, plus the request to report what else looks wrong. */
|
|
57
|
+
export function charterCase(story: Story, c: Charter): TestCase {
|
|
58
|
+
return {
|
|
59
|
+
id: `EXP-${story.id === "story" ? "" : `${story.id}-`}${c.id}`,
|
|
60
|
+
title: `${story.id === "story" ? story.title : story.id} exploratory: ${c.title}`,
|
|
61
|
+
source: "text",
|
|
62
|
+
description: [`Exploratory charter (${c.category.replace("_", " ")}): ${c.mission}`, c.criterion ? `Acceptance criterion: ${c.criterion}` : "", story.url ? `Story: ${story.url}` : ""].filter(Boolean).join("\n"),
|
|
63
|
+
...(c.role ? { preconditions: [`Signed in as the ${c.role} login`] } : {}),
|
|
64
|
+
steps: c.steps,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function charterGoal(story: Story, c: Charter): string {
|
|
69
|
+
return [
|
|
70
|
+
goalFor(charterCase(story, c)),
|
|
71
|
+
"",
|
|
72
|
+
`This is exploratory testing. Do the steps as written, with the data given, even when it is invalid: that is the point. While you do them, watch for anything else that looks wrong (an error message or page, a broken layout, wrong or lost data, a missing validation, a slow page) and list it at the end of your summary after "${NOTICED}", one item per line, or "${NOTICED} nothing".`,
|
|
73
|
+
c.role ? `Sign in as the ${c.role} login before the first step.` : "",
|
|
74
|
+
]
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.join("\n")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The browser agent's "Also noticed:" lines. */
|
|
80
|
+
export function noticed(summary: string): string[] {
|
|
81
|
+
const at = summary.lastIndexOf(NOTICED)
|
|
82
|
+
if (at < 0) return []
|
|
83
|
+
return summary
|
|
84
|
+
.slice(at + NOTICED.length)
|
|
85
|
+
.split("\n")
|
|
86
|
+
.map((l) => l.replace(/^\s*[-*•\d.)]+\s*/, "").trim())
|
|
87
|
+
.filter((l) => l && !/^nothing\.?$/i.test(l) && !/^none\.?$/i.test(l))
|
|
88
|
+
.slice(0, 10)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Only the role's login on its sites: two logins on one site would leave the agent with both sessions. */
|
|
92
|
+
export function loginsFor(logins: LoginConfig[], role?: string): LoginConfig[] {
|
|
93
|
+
if (!role) return logins
|
|
94
|
+
const mine = logins.find((l) => l.name === role)
|
|
95
|
+
if (!mine) return logins
|
|
96
|
+
const hosts = (l: LoginConfig) => l.domains.map((d) => d.replace(/^\w+:\/\//, "").split("/")[0].replace(/^\*\./, ""))
|
|
97
|
+
const taken = new Set(hosts(mine))
|
|
98
|
+
return logins.filter((l) => l === mine || !hosts(l).some((h) => taken.has(h)))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const NO_USAGE: Usage = { input: 0, output: 0 }
|
|
102
|
+
const STATUS = { complete: "passed", fail: "failed", stopped: "stopped" } as const
|
|
103
|
+
|
|
104
|
+
export type ExploreEvents = {
|
|
105
|
+
onRunStart?: (c: Charter, goal: string, repro: boolean) => void
|
|
106
|
+
onRunEvent?: (c: Charter, e: AgentEvent) => void
|
|
107
|
+
onRunDone?: (c: Charter, r: { result?: RunResult; error?: string; runDir: string; ms: number }) => void
|
|
108
|
+
onInfo?: (text: string) => void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function exploreCharters(opts: {
|
|
112
|
+
config: AppConfig
|
|
113
|
+
story: Story
|
|
114
|
+
charters: Charter[]
|
|
115
|
+
dir: string
|
|
116
|
+
url?: string
|
|
117
|
+
headed?: boolean
|
|
118
|
+
maxSteps?: number
|
|
119
|
+
signal?: AbortSignal
|
|
120
|
+
execute?: typeof executeRun
|
|
121
|
+
events?: ExploreEvents
|
|
122
|
+
}): Promise<CharterResult[]> {
|
|
123
|
+
const execute = opts.execute || executeRun
|
|
124
|
+
const ev = opts.events || {}
|
|
125
|
+
const out: CharterResult[] = []
|
|
126
|
+
for (const [i, c] of opts.charters.entries()) {
|
|
127
|
+
if (opts.signal?.aborted) break
|
|
128
|
+
const goal = charterGoal(opts.story, c)
|
|
129
|
+
const config: AppConfig = c.role ? { ...opts.config, logins: loginsFor(opts.config.logins, c.role) } : opts.config
|
|
130
|
+
const go = async (runDir: string, repro: boolean): Promise<{ run?: RunResult; error?: string }> => {
|
|
131
|
+
mkdirSync(runDir, { recursive: true })
|
|
132
|
+
ev.onRunStart?.(c, goal, repro)
|
|
133
|
+
const t0 = Date.now()
|
|
134
|
+
try {
|
|
135
|
+
const { result } = await execute({ config, goal, url: opts.url, headed: opts.headed, maxSteps: opts.maxSteps, caseSteps: c.steps.length, outDir: runDir, signal: opts.signal, onEvent: (e) => ev.onRunEvent?.(c, e) })
|
|
136
|
+
ev.onRunDone?.(c, { result, runDir, ms: Date.now() - t0 })
|
|
137
|
+
return { run: result }
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const error = (err as Error).message || String(err)
|
|
140
|
+
ev.onRunDone?.(c, { error, runDir, ms: Date.now() - t0 })
|
|
141
|
+
return { error }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const runDir = path.join(opts.dir, `${String(i + 1).padStart(2, "0")}-${c.id}`)
|
|
145
|
+
let { run, error } = await go(runDir, false)
|
|
146
|
+
let failure = opts.signal?.aborted ? undefined : classifyFailure(run, error)
|
|
147
|
+
let usage = run?.usage || NO_USAGE
|
|
148
|
+
if (failure?.type === "environment" && !opts.signal?.aborted) {
|
|
149
|
+
ev.onInfo?.(`${c.id}: ${FAILURE_LABEL.environment} (${failure.reason}); running it once more`)
|
|
150
|
+
;({ run, error } = await go(runDir, false))
|
|
151
|
+
failure = opts.signal?.aborted ? undefined : classifyFailure(run, error)
|
|
152
|
+
usage = addUsage(usage, run?.usage)
|
|
153
|
+
}
|
|
154
|
+
let status: CharterStatus = opts.signal?.aborted ? "stopped" : !failure ? (run?.status === "complete" ? "passed" : "stopped") : failure.type === "functional_defect" ? "defect" : "could_not_explore"
|
|
155
|
+
let reproRunDir: string | undefined
|
|
156
|
+
const observations = [...noticed(run?.summary || ""), ...browserErrors(runDir).map(problemLine)]
|
|
157
|
+
if (status === "defect" && !opts.signal?.aborted) {
|
|
158
|
+
// Confirmed only when it happens again: a defect that does not reproduce is an observation.
|
|
159
|
+
ev.onInfo?.(`${c.id}: possible defect (${failure!.reason}); running it again to confirm`)
|
|
160
|
+
reproRunDir = `${runDir}-repro`
|
|
161
|
+
const again = await go(reproRunDir, true)
|
|
162
|
+
usage = addUsage(usage, again.run?.usage)
|
|
163
|
+
const second = classifyFailure(again.run, again.error)
|
|
164
|
+
const same = second?.type === "functional_defect" && (second.step === undefined || failure!.step === undefined || second.step === failure!.step)
|
|
165
|
+
if (!same) {
|
|
166
|
+
status = "not_reproduced"
|
|
167
|
+
observations.unshift(`Not reproduced on a second run: ${failure!.reason}`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const tc = charterCase(opts.story, c)
|
|
171
|
+
const caseResult: CaseResult = {
|
|
172
|
+
case: tc,
|
|
173
|
+
status: run ? STATUS[run.status] : opts.signal?.aborted ? "stopped" : "error",
|
|
174
|
+
summary: run?.summary || error || "",
|
|
175
|
+
startedAt: new Date().toISOString(),
|
|
176
|
+
finishedAt: new Date().toISOString(),
|
|
177
|
+
ms: 0,
|
|
178
|
+
tokens: usage.input + usage.output,
|
|
179
|
+
usage,
|
|
180
|
+
cost: costOf(usage, opts.config.llm.price),
|
|
181
|
+
runDir,
|
|
182
|
+
finalUrl: run?.finalUrl || undefined,
|
|
183
|
+
...(run?.caseReport?.steps.length ? { stepResults: run.caseReport.steps } : {}),
|
|
184
|
+
...(failure ? { failure } : {}),
|
|
185
|
+
}
|
|
186
|
+
out.push({ charter: c, status, summary: run?.summary || error || "", runDir, ...(reproRunDir ? { reproRunDir } : {}), ...(failure ? { failure } : {}), observations: [...new Set(observations)].slice(0, 15), ...(run?.finalUrl ? { finalUrl: run.finalUrl } : {}), usage, caseResult })
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Charters that ran clean become regression cases; confirmed defects become cases that check the correct behaviour. */
|
|
192
|
+
export function proposedCases(results: CharterResult[]): ProposedCase[] {
|
|
193
|
+
return results
|
|
194
|
+
.filter((r) => r.status === "passed" || r.status === "defect")
|
|
195
|
+
.map((r) => ({
|
|
196
|
+
title: r.charter.title,
|
|
197
|
+
steps: r.charter.steps.map((s) => ({ action: s.data ? `${s.action} (data: ${s.data})` : s.action, expected: s.expected })),
|
|
198
|
+
...(r.charter.role ? { preconditions: [`Signed in as ${r.charter.role}`] } : {}),
|
|
199
|
+
description: `${r.charter.mission}${r.status === "defect" ? " Found a defect when explored: this case fails until it is fixed." : ""}`,
|
|
200
|
+
from: r.charter.id,
|
|
201
|
+
kind: r.status === "defect" ? "defect" : "regression",
|
|
202
|
+
}))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** File bugs for confirmed defects only, through the configured tracker (one per charter, commented on when it is still open). */
|
|
206
|
+
export async function fileConfirmed(config: AppConfig, story: Story, results: CharterResult[]): Promise<WriteBack[]> {
|
|
207
|
+
const confirmed = results.filter((r) => r.status === "defect")
|
|
208
|
+
if (!confirmed.length || !config.sources.bugs) return []
|
|
209
|
+
return fileBugs(
|
|
210
|
+
confirmed.map((r) => r.caseResult),
|
|
211
|
+
config.sources,
|
|
212
|
+
`exploratory testing of ${story.id === "story" ? story.title : `${story.id} ${story.title}`}`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const LABEL: Record<CharterStatus, string> = { passed: "✓ as expected", defect: "✗ defect (confirmed)", not_reproduced: "? not reproduced", could_not_explore: "– could not explore", stopped: "■ stopped" }
|
|
217
|
+
|
|
218
|
+
export function explorationMarkdown(x: Omit<Exploration, "report">): string {
|
|
219
|
+
const s = x.story
|
|
220
|
+
const defects = x.results.filter((r) => r.status === "defect")
|
|
221
|
+
const lines = [
|
|
222
|
+
`# Exploratory testing: ${s.id === "story" ? "" : `${s.id} `}${s.title}`,
|
|
223
|
+
"",
|
|
224
|
+
s.url ? `Story: ${s.url}` : "",
|
|
225
|
+
`${x.results.length} charter${x.results.length === 1 ? "" : "s"} · ${defects.length} confirmed defect${defects.length === 1 ? "" : "s"} · ${x.results.filter((r) => r.status === "not_reproduced").length} not reproduced · ${x.proposed.length} proposed test cases`,
|
|
226
|
+
"",
|
|
227
|
+
"## Charters",
|
|
228
|
+
"",
|
|
229
|
+
"| | Charter | Kind | Role | Result |",
|
|
230
|
+
"|---|---|---|---|---|",
|
|
231
|
+
...x.results.map((r) => `| ${r.charter.id} | ${r.charter.title} | ${r.charter.category.replace("_", " ")} | ${r.charter.role || ""} | ${LABEL[r.status]}${r.failure && r.status !== "defect" ? `: ${FAILURE_LABEL[r.failure.type]}` : ""} |`),
|
|
232
|
+
]
|
|
233
|
+
if (defects.length) {
|
|
234
|
+
lines.push("", "## Confirmed defects", "")
|
|
235
|
+
for (const r of defects) {
|
|
236
|
+
lines.push(`### ${r.charter.id}: ${r.charter.title}`, "", r.failure?.reason || r.summary, "", ...r.charter.steps.map((st, i) => `${i + 1}. ${st.action}${st.data ? ` (data: ${st.data})` : ""} → expected: ${st.expected}`), "", `Evidence: ${r.runDir}${r.reproRunDir ? ` and ${r.reproRunDir}` : ""}`)
|
|
237
|
+
const bug = x.bugs.find((b) => b.caseId === r.caseResult.case.id)
|
|
238
|
+
if (bug) lines.push(`Bug: ${bug.text}${bug.url ? ` ${bug.url}` : ""}${bug.error ? ` (${bug.error})` : ""}`)
|
|
239
|
+
lines.push("")
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const obs = x.results.filter((r) => r.observations.length)
|
|
243
|
+
if (obs.length) {
|
|
244
|
+
lines.push("", "## Observations (not confirmed defects)", "")
|
|
245
|
+
for (const r of obs) lines.push(`- ${r.charter.id} ${r.charter.title}:`, ...r.observations.map((o) => ` - ${o}`))
|
|
246
|
+
}
|
|
247
|
+
const blocked = x.results.filter((r) => r.status === "could_not_explore")
|
|
248
|
+
if (blocked.length) lines.push("", "## Could not explore", "", ...blocked.map((r) => `- ${r.charter.id} ${r.charter.title}: ${r.failure ? `${FAILURE_LABEL[r.failure.type]}: ${r.failure.reason}` : r.summary}`))
|
|
249
|
+
if (x.proposed.length) {
|
|
250
|
+
lines.push("", "## Proposed test cases", "")
|
|
251
|
+
for (const p of x.proposed) lines.push(`### ${p.title}${p.kind === "defect" ? " (fails until the defect is fixed)" : ""}`, "", ...(p.preconditions || []).map((c) => `Precondition: ${c}`), ...p.steps.map((st, i) => `${i + 1}. ${st.action} → ${st.expected}`), "")
|
|
252
|
+
}
|
|
253
|
+
return `${lines.filter((l, i, all) => l !== "" || all[i - 1] !== "").join("\n").trim()}\n`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function explorationCard(x: Exploration, cwd: string): Card {
|
|
257
|
+
const defects = x.results.filter((r) => r.status === "defect")
|
|
258
|
+
const body: string[] = x.results.map((r) => `${LABEL[r.status].split(" ")[0]} ${r.charter.id} ${r.charter.title}${r.charter.role ? ` [${r.charter.role}]` : ""}${r.status === "defect" || r.status === "could_not_explore" ? `\n ${r.failure?.reason || r.summary}` : ""}`)
|
|
259
|
+
const observations = x.results.reduce((n, r) => n + r.observations.length, 0)
|
|
260
|
+
if (observations) body.push("", `${observations} observation${observations === 1 ? "" : "s"} (not confirmed): see the report`)
|
|
261
|
+
if (x.bugs.length) body.push("", ...x.bugs.map((b) => `${b.error ? "✗" : "✓"} ${b.text}${b.url ? ` ${b.url}` : ""}${b.error ? `: ${b.error}` : ""}`))
|
|
262
|
+
if (x.proposed.length) body.push("", `${x.proposed.length} proposed test case${x.proposed.length === 1 ? "" : "s"}: ${x.proposed.map((p) => p.title).slice(0, 6).join("; ")}${x.proposed.length > 6 ? "; …" : ""}`)
|
|
263
|
+
const rel = path.relative(cwd, x.report)
|
|
264
|
+
body.push("", `Report ${rel && !rel.startsWith("..") ? rel : x.report}`)
|
|
265
|
+
return {
|
|
266
|
+
status: defects.length ? "fail" : x.results.some((r) => r.status === "passed") ? "ok" : "stopped",
|
|
267
|
+
title: `Explored ${x.story.id === "story" ? x.story.title : x.story.id}: ${defects.length ? `${defects.length} confirmed defect${defects.length === 1 ? "" : "s"}` : "no confirmed defects"}`,
|
|
268
|
+
stats: `${x.results.length} charter${x.results.length === 1 ? "" : "s"}`,
|
|
269
|
+
body,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Save the exploration: exploration.md and exploration.json in its folder. */
|
|
274
|
+
export function saveExploration(x: Omit<Exploration, "report">): string {
|
|
275
|
+
mkdirSync(x.dir, { recursive: true })
|
|
276
|
+
const report = path.join(x.dir, "exploration.md")
|
|
277
|
+
writeFileSync(report, explorationMarkdown(x))
|
|
278
|
+
writeFileSync(path.join(x.dir, "exploration.json"), JSON.stringify({ story: { ...x.story, scripted: x.story.scripted.map((c) => c.id) }, results: x.results.map(({ caseResult, ...r }) => r), proposed: x.proposed, bugs: x.bugs, usage: x.usage }, null, 2))
|
|
279
|
+
return report
|
|
280
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { AppConfig } from "../config.ts"
|
|
2
|
+
import { adoRequirements, type Requirement } from "../cases/context.ts"
|
|
3
|
+
import { adfToText, http } from "../cases/http.ts"
|
|
4
|
+
import { jiraApi } from "../cases/jira.ts"
|
|
5
|
+
import { loadCases } from "../cases/index.ts"
|
|
6
|
+
import { searchRequirements } from "../cases/rag.ts"
|
|
7
|
+
import type { TestCase } from "../cases/types.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The story to explore, with what it needs to be explored well: its description and acceptance criteria, the feature
|
|
11
|
+
* and epic above it, the test cases that already script it (exploration goes beyond them), and passages from the
|
|
12
|
+
* requirements search when one is set up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type Story = {
|
|
16
|
+
/** "ADO-123", "SHOP-12", or "story" for text. */
|
|
17
|
+
id: string
|
|
18
|
+
system: "ado" | "jira" | "text"
|
|
19
|
+
title: string
|
|
20
|
+
url?: string
|
|
21
|
+
description: string
|
|
22
|
+
acceptance: string
|
|
23
|
+
/** The feature and epic above it, nearest first. */
|
|
24
|
+
parents: Requirement[]
|
|
25
|
+
/** Test cases that already test it: their steps are the scripted coverage. */
|
|
26
|
+
scripted: TestCase[]
|
|
27
|
+
passages: string[]
|
|
28
|
+
errors: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MAX_SCRIPTED = 10
|
|
32
|
+
|
|
33
|
+
/** ado:123, #123, an Azure DevOps work item link; jira:SHOP-12, SHOP-12, a Jira browse link; anything else is the story's text. */
|
|
34
|
+
export function storyRef(ref: string): { system: "ado" | "jira" | "text"; key: string } {
|
|
35
|
+
const r = ref.trim()
|
|
36
|
+
const ado = r.match(/^(?:ado|azure):#?(\d+)$/i) || r.match(/^#(\d+)$/) || r.match(/_workitems\/edit\/(\d+)/) || (/^\d+$/.test(r) ? [r, r] : null)
|
|
37
|
+
if (ado) return { system: "ado", key: ado[1] }
|
|
38
|
+
const jira = r.match(/^jira:([A-Z][A-Z0-9_]+-\d+)$/i) || r.match(/^([A-Z][A-Z0-9_]+-\d+)$/) || r.match(/\/browse\/([A-Z][A-Z0-9_]+-\d+)/)
|
|
39
|
+
if (jira) return { system: "jira", key: jira[1].toUpperCase() }
|
|
40
|
+
return { system: "text", key: r }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function adoStory(cfg: AppConfig, id: number, story: Story): Promise<void> {
|
|
44
|
+
const reqs = new Map<string, Requirement>()
|
|
45
|
+
const { chain } = await adoRequirements(cfg, [id], { requirements: reqs })
|
|
46
|
+
const own = reqs.get(`ADO-${id}`)
|
|
47
|
+
if (!own) throw new Error(`Azure DevOps work item ${id} was not found.`)
|
|
48
|
+
Object.assign(story, { id: own.id, title: own.title, url: own.url, description: own.description || "", acceptance: own.acceptance || "" })
|
|
49
|
+
story.parents = chain.slice(1).map((k) => reqs.get(k)!).filter(Boolean)
|
|
50
|
+
const tested = (own.testedBy || []).slice(0, MAX_SCRIPTED).map((k) => k.slice(4))
|
|
51
|
+
if (tested.length) {
|
|
52
|
+
try {
|
|
53
|
+
story.scripted = await loadCases([`ado:${tested.join(",")}`], cfg.sources)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
story.errors.push(`scripted test cases: ${(err as Error).message.slice(0, 200)}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function jiraStory(cfg: AppConfig, key: string, story: Story): Promise<void> {
|
|
61
|
+
const j = jiraApi(cfg.sources, "Reading the story from Jira")
|
|
62
|
+
type Issue = { key: string; fields: Record<string, any> }
|
|
63
|
+
const read = async (k: string) => (await http<Issue>(`${j.api}/issue/${encodeURIComponent(k)}?fields=summary,description,issuetype,parent,issuelinks`, { auth: j.auth })).data
|
|
64
|
+
const issue = await read(key)
|
|
65
|
+
const text = (d: unknown) => (typeof d === "string" ? d : adfToText(d))
|
|
66
|
+
const description = text(issue.fields.description)
|
|
67
|
+
// Acceptance criteria usually sit in the description under a heading; keep them apart when they do.
|
|
68
|
+
const ac = description.split(/\n\s*(?:#+\s*)?acceptance criteria:?\s*\n/i)
|
|
69
|
+
Object.assign(story, { id: key, title: String(issue.fields.summary || ""), url: `${j.url}/browse/${key}`, description: ac[0].trim(), acceptance: (ac[1] || "").trim() })
|
|
70
|
+
let parent = issue.fields.parent?.key as string | undefined
|
|
71
|
+
for (let level = 0; parent && level < 2; level++) {
|
|
72
|
+
const p = await read(parent)
|
|
73
|
+
story.parents.push({ id: p.key, type: String(p.fields.issuetype?.name || "Issue"), title: String(p.fields.summary || ""), url: `${j.url}/browse/${p.key}`, description: text(p.fields.description).slice(0, 2000) })
|
|
74
|
+
parent = p.fields.parent?.key
|
|
75
|
+
}
|
|
76
|
+
const links = (issue.fields.issuelinks || []) as { type: { name: string; inward: string; outward: string }; inwardIssue?: { key: string }; outwardIssue?: { key: string } }[]
|
|
77
|
+
const tests = links.filter((l) => /test/i.test(`${l.type.name} ${l.type.inward} ${l.type.outward}`)).map((l) => (l.inwardIssue || l.outwardIssue)?.key).filter((k): k is string => Boolean(k))
|
|
78
|
+
if (tests.length) {
|
|
79
|
+
try {
|
|
80
|
+
story.scripted = await loadCases(tests.slice(0, MAX_SCRIPTED).map((k) => `jira:${k}`), cfg.sources)
|
|
81
|
+
} catch (err) {
|
|
82
|
+
story.errors.push(`scripted test cases: ${(err as Error).message.slice(0, 200)}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function readStory(cfg: AppConfig, ref: string): Promise<Story> {
|
|
88
|
+
const { system, key } = storyRef(ref)
|
|
89
|
+
const story: Story = { id: "story", system, title: "", description: "", acceptance: "", parents: [], scripted: [], passages: [], errors: [] }
|
|
90
|
+
if (system === "ado") await adoStory(cfg, Number(key), story)
|
|
91
|
+
else if (system === "jira") await jiraStory(cfg, key, story)
|
|
92
|
+
else {
|
|
93
|
+
const [first, ...rest] = key.split("\n")
|
|
94
|
+
const body = rest.join("\n")
|
|
95
|
+
const ac = body.split(/\n?\s*acceptance criteria:?\s*\n/i)
|
|
96
|
+
Object.assign(story, { title: first.trim().slice(0, 200), description: ac[0].trim(), acceptance: (ac[1] || "").trim() })
|
|
97
|
+
}
|
|
98
|
+
if (cfg.rag) {
|
|
99
|
+
try {
|
|
100
|
+
story.passages = (await searchRequirements(cfg.rag, `${story.title}\n${story.acceptance || story.description}`.slice(0, 1000))).map((p) => `${p.source ? `${p.source}: ` : ""}${p.text}`.slice(0, 800)).slice(0, 5)
|
|
101
|
+
} catch (err) {
|
|
102
|
+
story.errors.push(`RAG: ${(err as Error).message.slice(0, 200)}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return story
|
|
106
|
+
}
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function normalizeHttpUrl(raw: string | undefined | null): string | null {
|
|
2
|
+
if (raw == null) return null
|
|
3
|
+
let u = String(raw).trim()
|
|
4
|
+
if (!u) return null
|
|
5
|
+
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(u)) u = `https://${u.replace(/^\/+/, "")}`
|
|
6
|
+
try {
|
|
7
|
+
const parsed = new URL(u)
|
|
8
|
+
if (!parsed.hostname) return null
|
|
9
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null
|
|
10
|
+
return parsed.toString()
|
|
11
|
+
} catch {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function extractUrls(text: string): string[] {
|
|
17
|
+
return text.match(/https?:\/\/[^\s<>"']+/g) ?? []
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hostMatches(hostname: string, pattern: string): boolean {
|
|
21
|
+
const h = hostname.toLowerCase().replace(/\.$/, "")
|
|
22
|
+
const p = pattern.trim().toLowerCase()
|
|
23
|
+
if (!p) return false
|
|
24
|
+
if (p.startsWith("*.")) {
|
|
25
|
+
const suffix = p.slice(1)
|
|
26
|
+
return h.endsWith(suffix) || h === p.slice(2)
|
|
27
|
+
}
|
|
28
|
+
return h === p || h.endsWith(`.${p}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Empty allow-list means every host is allowed. Returns an error string when blocked. */
|
|
32
|
+
export function hostAllowed(url: string, allowed: string[]): string | null {
|
|
33
|
+
if (!allowed.length) return null
|
|
34
|
+
let host = ""
|
|
35
|
+
try {
|
|
36
|
+
host = new URL(url).hostname
|
|
37
|
+
} catch {
|
|
38
|
+
return `Invalid URL: ${url}`
|
|
39
|
+
}
|
|
40
|
+
if (!host) return null
|
|
41
|
+
if (allowed.some((pattern) => hostMatches(host, pattern))) return null
|
|
42
|
+
return `Host not allowed: ${host}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function assertGoalUrlsAllowed(goal: string, allowed: string[]): string | null {
|
|
46
|
+
for (const url of extractUrls(goal)) {
|
|
47
|
+
const err = hostAllowed(url, allowed)
|
|
48
|
+
if (err) return err
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|