@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,111 @@
|
|
|
1
|
+
import type { SourcesConfig } from "../config.ts"
|
|
2
|
+
import { basic, envSecret, htmlToText, http } from "./http.ts"
|
|
3
|
+
|
|
4
|
+
export type ConfluencePage = { id: string; title: string; url: string; space?: string; text: string }
|
|
5
|
+
|
|
6
|
+
const PAGE_CHARS = 20_000
|
|
7
|
+
const TOTAL_CHARS = 60_000
|
|
8
|
+
|
|
9
|
+
export function confluenceApi(cfg: SourcesConfig) {
|
|
10
|
+
const c = cfg.confluence
|
|
11
|
+
if (!c) throw new Error("Confluence is not set up. Add sources.confluence to forgeqe.yaml (confluence: true on a Jira Cloud site), or set CONFLUENCE_URL.")
|
|
12
|
+
const token = envSecret(c.tokenEnv, "Confluence")
|
|
13
|
+
return { ...c, auth: c.cloud ? basic(envSecret(c.emailEnv, "Confluence Cloud"), token) : `Bearer ${token}` }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Page storage format as text; table cells are kept apart with " | " so requirement tables stay readable. */
|
|
17
|
+
export function storageToText(html: string): string {
|
|
18
|
+
return htmlToText(
|
|
19
|
+
html
|
|
20
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
|
|
21
|
+
.replace(/<\/(td|th)\s*>/gi, " | ")
|
|
22
|
+
.replace(/<ac:parameter[^>]*>[\s\S]*?<\/ac:parameter>/gi, ""),
|
|
23
|
+
)
|
|
24
|
+
.split("\n")
|
|
25
|
+
.map((l) => l.replace(/\s*\|\s*$/, ""))
|
|
26
|
+
.join("\n")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Ref = { id?: string; space?: string; title?: string }
|
|
30
|
+
|
|
31
|
+
/** A page id, a page URL (…/pages/<id>/…, ?pageId=<id>), or a Server/Data Center /display/<SPACE>/<Title> URL. */
|
|
32
|
+
export function pageRef(ref: string): Ref {
|
|
33
|
+
const r = ref.trim()
|
|
34
|
+
if (/^\d+$/.test(r)) return { id: r }
|
|
35
|
+
let u: URL
|
|
36
|
+
try {
|
|
37
|
+
u = new URL(r)
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error(`"${ref}" is not a Confluence page id or URL.`)
|
|
40
|
+
}
|
|
41
|
+
const pageId = u.searchParams.get("pageId")
|
|
42
|
+
if (pageId) return { id: pageId }
|
|
43
|
+
const parts = u.pathname.split("/").filter(Boolean).map(decodeURIComponent)
|
|
44
|
+
const at = parts.indexOf("pages")
|
|
45
|
+
if (at >= 0 && /^\d+$/.test(parts[at + 1] || "")) return { id: parts[at + 1] }
|
|
46
|
+
const display = parts.indexOf("display")
|
|
47
|
+
if (display >= 0 && parts[display + 2]) return { space: parts[display + 1], title: parts[display + 2].replace(/\+/g, " ") }
|
|
48
|
+
throw new Error(`Could not find the page in ${ref}; give the page id or a link to the page.`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type V1Content = { id: string; title: string; space?: { key?: string }; body?: { storage?: { value?: string } }; _links?: { webui?: string; base?: string } }
|
|
52
|
+
|
|
53
|
+
/** Pages by id or link, or by a CQL search, as text for the agent. Cloud uses the v2 pages API; Server/Data Center v1. */
|
|
54
|
+
export async function readConfluence(cfg: SourcesConfig, q: { pages?: string[]; cql?: string; limit?: number }): Promise<{ pages: ConfluencePage[]; note?: string }> {
|
|
55
|
+
const c = confluenceApi(cfg)
|
|
56
|
+
const limit = Math.min(Math.max(1, q.limit || 10), 25)
|
|
57
|
+
const web = (path?: string) => (path ? `${c.url}${path}` : c.url)
|
|
58
|
+
const v1 = async (id: string): Promise<ConfluencePage> => {
|
|
59
|
+
const { data } = await http<V1Content>(`${c.url}/rest/api/content/${id}?expand=body.storage,space`, { auth: c.auth })
|
|
60
|
+
return { id: data.id, title: data.title, url: web(data._links?.webui), space: data.space?.key, text: storageToText(data.body?.storage?.value || "") }
|
|
61
|
+
}
|
|
62
|
+
const byId = async (id: string): Promise<ConfluencePage> => {
|
|
63
|
+
if (!c.cloud) return v1(id)
|
|
64
|
+
const { data } = await http<{ id: string; title: string; spaceId?: string; body?: { storage?: { value?: string } }; _links?: { webui?: string } }>(
|
|
65
|
+
`${c.url}/api/v2/pages/${id}?body-format=storage`,
|
|
66
|
+
{ auth: c.auth },
|
|
67
|
+
)
|
|
68
|
+
return { id: data.id, title: data.title, url: web(data._links?.webui), text: storageToText(data.body?.storage?.value || "") }
|
|
69
|
+
}
|
|
70
|
+
const ids: string[] = []
|
|
71
|
+
for (const p of q.pages || []) {
|
|
72
|
+
const ref = pageRef(p)
|
|
73
|
+
if (ref.id) ids.push(ref.id)
|
|
74
|
+
else {
|
|
75
|
+
const params = new URLSearchParams({ spaceKey: ref.space!, title: ref.title!, limit: "1" })
|
|
76
|
+
const { data } = await http<{ results?: { id: string }[] }>(`${c.url}/rest/api/content?${params}`, { auth: c.auth })
|
|
77
|
+
const hit = data?.results?.[0]
|
|
78
|
+
if (!hit) throw new Error(`No Confluence page "${ref.title}" in space ${ref.space}.`)
|
|
79
|
+
ids.push(hit.id)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (q.cql) {
|
|
83
|
+
const cql = c.space && !/\bspace\b/i.test(q.cql) ? `space = "${c.space}" AND (${q.cql})` : q.cql
|
|
84
|
+
const params = new URLSearchParams({ cql, limit: String(limit) })
|
|
85
|
+
const path = c.cloud ? `/rest/api/search?${params}` : `/rest/api/content/search?${params}`
|
|
86
|
+
const { data } = await http<{ results?: ({ content?: { id: string; type?: string } } & { id?: string; type?: string })[] }>(`${c.url}${path}`, { auth: c.auth })
|
|
87
|
+
for (const r of data?.results || []) {
|
|
88
|
+
const hit = r.content || r
|
|
89
|
+
if (hit?.id && (hit.type || "page") === "page") ids.push(hit.id)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!ids.length) throw new Error(q.cql ? `No Confluence pages match: ${q.cql}` : "Give page ids or links, or a CQL query.")
|
|
93
|
+
const pages: ConfluencePage[] = []
|
|
94
|
+
let total = 0
|
|
95
|
+
let cut = false
|
|
96
|
+
for (const id of [...new Set(ids)].slice(0, limit)) {
|
|
97
|
+
const page = await byId(id)
|
|
98
|
+
const room = Math.min(PAGE_CHARS, TOTAL_CHARS - total)
|
|
99
|
+
if (room <= 0) {
|
|
100
|
+
cut = true
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
if (page.text.length > room) {
|
|
104
|
+
page.text = `${page.text.slice(0, room)}… (cut)`
|
|
105
|
+
cut = true
|
|
106
|
+
}
|
|
107
|
+
total += page.text.length
|
|
108
|
+
pages.push(page)
|
|
109
|
+
}
|
|
110
|
+
return { pages, ...(cut ? { note: "Some text was cut; read fewer pages at a time for the rest." } : {}) }
|
|
111
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { AppConfig } from "../config.ts"
|
|
2
|
+
import { adoSettings } from "./ado.ts"
|
|
3
|
+
import { readConfluence } from "./confluence.ts"
|
|
4
|
+
import { adfToText, htmlToText, http } from "./http.ts"
|
|
5
|
+
import { jiraApi } from "./jira.ts"
|
|
6
|
+
import { searchRequirements, type Passage } from "./rag.ts"
|
|
7
|
+
import type { TestCase } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a test case is for, gathered for the quality check: the requirements above it (the story or PBI it tests,
|
|
11
|
+
* then its feature and epic, with descriptions and acceptance criteria), the Confluence pages they link to, and, when
|
|
12
|
+
* a RAG service is configured, the passages it finds for the case. Requirements and pages are shared between the
|
|
13
|
+
* cases of one check, so each is read once.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type Requirement = {
|
|
17
|
+
id: string
|
|
18
|
+
type: string
|
|
19
|
+
title: string
|
|
20
|
+
url?: string
|
|
21
|
+
description?: string
|
|
22
|
+
acceptance?: string
|
|
23
|
+
parent?: string
|
|
24
|
+
/** Azure DevOps Area Path: where the team files it in the product. */
|
|
25
|
+
area?: string
|
|
26
|
+
/** Azure DevOps test cases that test it ("Tested By"), as ADO-<id>. */
|
|
27
|
+
testedBy?: string[]
|
|
28
|
+
}
|
|
29
|
+
export type Doc = { title: string; url: string; text: string }
|
|
30
|
+
|
|
31
|
+
export type CaseContext = {
|
|
32
|
+
/** Requirement ids, nearest first: the story, then its feature, then its epic. */
|
|
33
|
+
chain: string[]
|
|
34
|
+
docs: string[]
|
|
35
|
+
passages: Passage[]
|
|
36
|
+
/** Acceptance criteria no step seems to cover: hints for the agent, by shared words. */
|
|
37
|
+
uncovered: string[]
|
|
38
|
+
errors: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type Context = { requirements: Map<string, Requirement>; docs: Map<string, Doc>; cases: Map<string, CaseContext> }
|
|
42
|
+
|
|
43
|
+
const MAX_TEXT = 2500
|
|
44
|
+
const MAX_LEVELS = 3
|
|
45
|
+
const clip = (s: string | undefined, n = MAX_TEXT) => {
|
|
46
|
+
const t = (s || "").trim()
|
|
47
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const STOP = new Set("the a an and or of to in on for with is are be should must can will user users when then that this from into able page shown show shows display displayed form forms field fields button buttons click enter valid value values after before".split(" "))
|
|
51
|
+
const keywords = (s: string) => new Set(s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4 && !STOP.has(w)))
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Acceptance criteria (one per line, bullet, or numbered item) that share few of their meaningful words with the
|
|
55
|
+
* case's steps: a cheap hint of a gap. It cannot tell "validates the email format" from "enters an email"; the agent
|
|
56
|
+
* judges coverage from the full criteria.
|
|
57
|
+
*/
|
|
58
|
+
export function uncoveredCriteria(acceptance: string | undefined, tc: TestCase): string[] {
|
|
59
|
+
if (!acceptance?.trim()) return []
|
|
60
|
+
const steps = keywords(tc.steps.map((s) => `${s.action} ${s.data || ""} ${s.expected || ""}`).join(" ") + ` ${tc.title}`)
|
|
61
|
+
return acceptance
|
|
62
|
+
.split(/\n|\s(?=(?:\d+[.)]|[-•*])\s)/)
|
|
63
|
+
.map((c) => c.replace(/^\s*(?:\d+[.)]|[-•*])\s+/, "").trim())
|
|
64
|
+
.filter((c) => c.length >= 12)
|
|
65
|
+
.filter((c) => {
|
|
66
|
+
const words = [...keywords(c)]
|
|
67
|
+
if (!words.length) return false
|
|
68
|
+
return words.filter((w) => steps.has(w)).length / words.length < 0.5
|
|
69
|
+
})
|
|
70
|
+
.slice(0, 10)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Confluence links in a text: the configured site, or any *.atlassian.net/wiki page. */
|
|
74
|
+
function confluenceLinks(text: string, cfg: AppConfig): string[] {
|
|
75
|
+
const host = cfg.sources.confluence ? new URL(cfg.sources.confluence.url).host : ""
|
|
76
|
+
return [...new Set([...(text || "").matchAll(/https?:\/\/[^\s"'<>)]+/g)].map((m) => m[0].replace(/[.,;]+$/, "")))].filter(
|
|
77
|
+
(u) => (host && u.includes(host)) || /atlassian\.net\/wiki\//.test(u) || /\/(display|pages\/viewpage\.action)/.test(u),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type AdoItem = { id: number; fields: Record<string, unknown>; relations?: { rel: string; url: string }[] }
|
|
82
|
+
|
|
83
|
+
const idOf = (url: string) => Number(url.split("/").pop())
|
|
84
|
+
|
|
85
|
+
async function readAdo(cfg: AppConfig, id: number): Promise<AdoItem> {
|
|
86
|
+
const { org, auth } = adoSettings(cfg.sources)
|
|
87
|
+
const { data } = await http<AdoItem>(`${org}/_apis/wit/workitems/${id}?$expand=all&api-version=7.1`, { auth })
|
|
88
|
+
return data
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Azure DevOps requirements from the given work items up through their parents (story, feature, epic), each read
|
|
93
|
+
* once into ctx.requirements. Returns the ids in order, nearest first, and the links (Hyperlink relations and
|
|
94
|
+
* Confluence links in their text) they carry.
|
|
95
|
+
*/
|
|
96
|
+
export async function adoRequirements(cfg: AppConfig, ids: number[], ctx: Pick<Context, "requirements">): Promise<{ chain: string[]; links: string[] }> {
|
|
97
|
+
const { org } = adoSettings(cfg.sources)
|
|
98
|
+
const chain: string[] = []
|
|
99
|
+
const links: string[] = []
|
|
100
|
+
let next = [...new Set(ids)]
|
|
101
|
+
for (let level = 0; level < MAX_LEVELS && next.length; level++) {
|
|
102
|
+
const parents: number[] = []
|
|
103
|
+
for (const id of next.slice(0, 5)) {
|
|
104
|
+
const key = `ADO-${id}`
|
|
105
|
+
let req = ctx.requirements.get(key)
|
|
106
|
+
if (!req) {
|
|
107
|
+
const item = await readAdo(cfg, id)
|
|
108
|
+
const f = item.fields
|
|
109
|
+
const rel = item.relations || []
|
|
110
|
+
const parent = rel.find((r) => r.rel === "System.LinkTypes.Hierarchy-Reverse")
|
|
111
|
+
const testedBy = rel.filter((r) => r.rel === "Microsoft.VSTS.Common.TestedBy-Forward").map((r) => `ADO-${idOf(r.url)}`)
|
|
112
|
+
req = {
|
|
113
|
+
id: key,
|
|
114
|
+
type: String(f["System.WorkItemType"] || "Work item"),
|
|
115
|
+
title: String(f["System.Title"] || ""),
|
|
116
|
+
url: `${org}/${encodeURIComponent(String(f["System.TeamProject"] || ""))}/_workitems/edit/${id}`,
|
|
117
|
+
description: clip(htmlToText(String(f["System.Description"] || "") || String(f["Microsoft.VSTS.TCM.ReproSteps"] || ""))),
|
|
118
|
+
acceptance: clip(htmlToText(String(f["Microsoft.VSTS.Common.AcceptanceCriteria"] || ""))),
|
|
119
|
+
...(parent ? { parent: `ADO-${idOf(parent.url)}` } : {}),
|
|
120
|
+
...(f["System.AreaPath"] ? { area: String(f["System.AreaPath"]) } : {}),
|
|
121
|
+
...(testedBy.length ? { testedBy } : {}),
|
|
122
|
+
}
|
|
123
|
+
ctx.requirements.set(key, req)
|
|
124
|
+
links.push(...rel.filter((r) => r.rel === "Hyperlink").map((r) => r.url))
|
|
125
|
+
links.push(...confluenceLinks(`${f["System.Description"] || ""} ${f["Microsoft.VSTS.Common.AcceptanceCriteria"] || ""}`, cfg))
|
|
126
|
+
}
|
|
127
|
+
if (!chain.includes(key)) chain.push(key)
|
|
128
|
+
if (req.parent) parents.push(Number(req.parent.slice(4)))
|
|
129
|
+
}
|
|
130
|
+
next = [...new Set(parents)].filter((p) => !chain.includes(`ADO-${p}`))
|
|
131
|
+
}
|
|
132
|
+
return { chain, links }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function adoChain(tc: TestCase, cfg: AppConfig, ctx: Context): Promise<{ chain: string[]; links: string[] }> {
|
|
136
|
+
const testCase = await readAdo(cfg, Number(tc.ref?.workItem))
|
|
137
|
+
const links: string[] = (testCase.relations || []).filter((r) => r.rel === "Hyperlink").map((r) => r.url)
|
|
138
|
+
// "Tests": the requirement this test case tests (a story, PBI, bug, or feature), then up through its parents.
|
|
139
|
+
const tested = (testCase.relations || []).filter((r) => r.rel === "Microsoft.VSTS.Common.TestedBy-Reverse").map((r) => idOf(r.url))
|
|
140
|
+
const up = await adoRequirements(cfg, tested.slice(0, 3), ctx)
|
|
141
|
+
return { chain: up.chain, links: [...links, ...up.links] }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function jiraChain(tc: TestCase, cfg: AppConfig, ctx: Context): Promise<{ chain: string[]; links: string[] }> {
|
|
145
|
+
const j = jiraApi(cfg.sources, "Reading requirements from Jira")
|
|
146
|
+
const read = async (key: string) => {
|
|
147
|
+
const { data } = await http<{ key: string; fields: Record<string, any> }>(`${j.api}/issue/${encodeURIComponent(key)}?fields=summary,description,issuetype,parent,issuelinks`, { auth: j.auth })
|
|
148
|
+
return data
|
|
149
|
+
}
|
|
150
|
+
const key = String(tc.ref?.key || tc.id)
|
|
151
|
+
const test = await read(key)
|
|
152
|
+
const text = (d: unknown) => (typeof d === "string" ? d : adfToText(d))
|
|
153
|
+
const links: string[] = confluenceLinks(text(test.fields.description), cfg)
|
|
154
|
+
// The issues the test is linked to as tests / is tested by (or relates to, when nothing says tests), then parents.
|
|
155
|
+
const linked = (test.fields.issuelinks || []) as { type: { name: string; inward: string; outward: string }; inwardIssue?: { key: string }; outwardIssue?: { key: string } }[]
|
|
156
|
+
const testLinks = linked.filter((l) => /test/i.test(`${l.type.name} ${l.type.inward} ${l.type.outward}`))
|
|
157
|
+
let next = (testLinks.length ? testLinks : linked).map((l) => (l.inwardIssue || l.outwardIssue)?.key).filter((k): k is string => Boolean(k))
|
|
158
|
+
if (!next.length && test.fields.parent?.key) next = [test.fields.parent.key]
|
|
159
|
+
const chain: string[] = []
|
|
160
|
+
for (let level = 0; level < MAX_LEVELS && next.length; level++) {
|
|
161
|
+
const parents: string[] = []
|
|
162
|
+
for (const k of next.slice(0, 3)) {
|
|
163
|
+
let req = ctx.requirements.get(k)
|
|
164
|
+
if (!req) {
|
|
165
|
+
const issue = await read(k)
|
|
166
|
+
const description = text(issue.fields.description)
|
|
167
|
+
req = {
|
|
168
|
+
id: k,
|
|
169
|
+
type: String(issue.fields.issuetype?.name || "Issue"),
|
|
170
|
+
title: String(issue.fields.summary || ""),
|
|
171
|
+
url: `${j.url}/browse/${k}`,
|
|
172
|
+
description: clip(description),
|
|
173
|
+
...(issue.fields.parent?.key ? { parent: String(issue.fields.parent.key) } : {}),
|
|
174
|
+
}
|
|
175
|
+
ctx.requirements.set(k, req)
|
|
176
|
+
links.push(...confluenceLinks(description, cfg))
|
|
177
|
+
}
|
|
178
|
+
chain.push(k)
|
|
179
|
+
if (req.parent) parents.push(req.parent)
|
|
180
|
+
}
|
|
181
|
+
next = [...new Set(parents)].filter((p) => !chain.includes(p))
|
|
182
|
+
}
|
|
183
|
+
return { chain, links }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Gather each case's context. Errors (no access to a work item, Confluence not set up, the RAG service down) are kept
|
|
188
|
+
* per case and the rest goes on: a check with less context still runs.
|
|
189
|
+
*/
|
|
190
|
+
export async function gatherContext(cases: TestCase[], cfg: AppConfig): Promise<Context> {
|
|
191
|
+
const ctx: Context = { requirements: new Map(), docs: new Map(), cases: new Map() }
|
|
192
|
+
for (const tc of cases) {
|
|
193
|
+
const c: CaseContext = { chain: [], docs: [], passages: [], uncovered: [], errors: [] }
|
|
194
|
+
ctx.cases.set(tc.id, c)
|
|
195
|
+
let links: string[] = []
|
|
196
|
+
try {
|
|
197
|
+
if (tc.source === "ado" && tc.ref?.workItem && cfg.sources.ado) ({ chain: c.chain, links } = await adoChain(tc, cfg, ctx))
|
|
198
|
+
else if ((tc.source === "jira" || tc.source === "xray") && cfg.sources.jira) ({ chain: c.chain, links } = await jiraChain(tc, cfg, ctx))
|
|
199
|
+
} catch (err) {
|
|
200
|
+
c.errors.push(`requirements: ${(err as Error).message.slice(0, 200)}`)
|
|
201
|
+
}
|
|
202
|
+
const pages = [...new Set(links)].filter((u) => !ctx.docs.has(u)).slice(0, 3)
|
|
203
|
+
if (pages.length && cfg.sources.confluence) {
|
|
204
|
+
try {
|
|
205
|
+
const { pages: read } = await readConfluence(cfg.sources, { pages })
|
|
206
|
+
read.forEach((p, i) => ctx.docs.set(pages[i] || p.url, { title: p.title, url: p.url, text: clip(p.text, 4000) }))
|
|
207
|
+
} catch (err) {
|
|
208
|
+
c.errors.push(`Confluence: ${(err as Error).message.slice(0, 200)}`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
c.docs = [...new Set(links)].filter((u) => ctx.docs.has(u))
|
|
212
|
+
if (cfg.rag) {
|
|
213
|
+
try {
|
|
214
|
+
c.passages = await searchRequirements(cfg.rag, [tc.title, ...tc.steps.map((s) => s.action)].join("\n").slice(0, 1000))
|
|
215
|
+
} catch (err) {
|
|
216
|
+
c.errors.push(`RAG: ${(err as Error).message.slice(0, 200)}`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const story = c.chain[0] ? ctx.requirements.get(c.chain[0]) : undefined
|
|
220
|
+
c.uncovered = uncoveredCriteria(story?.acceptance, tc)
|
|
221
|
+
}
|
|
222
|
+
return ctx
|
|
223
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { SourcesConfig } from "../config.ts"
|
|
2
|
+
import { adoSettings } from "./ado.ts"
|
|
3
|
+
import { http } from "./http.ts"
|
|
4
|
+
|
|
5
|
+
const API = "api-version=7.1"
|
|
6
|
+
const DASHBOARD_API = "api-version=7.1-preview.3"
|
|
7
|
+
/** Shared queries the dashboard's widgets read, kept in one folder so ForgeQE can find and update them. */
|
|
8
|
+
const FOLDER = "ForgeQE"
|
|
9
|
+
const ROOT = "Shared Queries"
|
|
10
|
+
|
|
11
|
+
export type DashboardWidget =
|
|
12
|
+
/** `list`: the query's work items with its columns. `count`: a tile with how many items it returns. */
|
|
13
|
+
| { kind?: "list" | "count"; title: string; wiql: string }
|
|
14
|
+
| { kind: "markdown"; title?: string; text: string }
|
|
15
|
+
|
|
16
|
+
export type DashboardRequest = {
|
|
17
|
+
/** Project the queries and dashboard go in; sources.azure_devops.project when left out. */
|
|
18
|
+
project?: string
|
|
19
|
+
/** A team dashboard when set; otherwise a project dashboard. */
|
|
20
|
+
team?: string
|
|
21
|
+
name: string
|
|
22
|
+
description?: string
|
|
23
|
+
widgets: DashboardWidget[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DashboardResult = {
|
|
27
|
+
id: string
|
|
28
|
+
name: string
|
|
29
|
+
url: string
|
|
30
|
+
project: string
|
|
31
|
+
team?: string
|
|
32
|
+
queries: { name: string; path: string; id: string; created: boolean; url: string }[]
|
|
33
|
+
widgets: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type QueryItem = { id: string; name: string; path: string; isFolder?: boolean }
|
|
37
|
+
|
|
38
|
+
const CONTRIBUTIONS = {
|
|
39
|
+
list: "ms.vss-mywork-web.Microsoft.VisualStudioOnline.MyWork.WitViewWidget",
|
|
40
|
+
count: "ms.vss-dashboards-web.Microsoft.VisualStudioOnline.Dashboards.QueryScalarWidget",
|
|
41
|
+
markdown: "ms.vss-dashboards-web.Microsoft.VisualStudioOnline.Dashboards.MarkdownWidget",
|
|
42
|
+
} as const
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Saves each widget's WIQL as a shared query under Shared Queries/ForgeQE (updating one of the same name), then
|
|
46
|
+
* creates the dashboard with a widget per query: a work item list or a count tile, plus any markdown text.
|
|
47
|
+
* Every query is run once first, so a bad WIQL fails before anything is saved.
|
|
48
|
+
*/
|
|
49
|
+
export async function createAdoDashboard(cfg: SourcesConfig, req: DashboardRequest): Promise<DashboardResult> {
|
|
50
|
+
const { auth, org, project: configured } = adoSettings(cfg)
|
|
51
|
+
const project = (req.project || configured || "").trim()
|
|
52
|
+
if (!project) throw new Error("Name the Azure DevOps project for the dashboard, or set sources.azure_devops.project.")
|
|
53
|
+
const name = req.name.trim()
|
|
54
|
+
if (!name) throw new Error("The dashboard needs a name.")
|
|
55
|
+
if (!req.widgets.length) throw new Error("The dashboard needs at least one widget.")
|
|
56
|
+
const base = `${org}/${encodeURIComponent(project)}`
|
|
57
|
+
|
|
58
|
+
const queryWidgets = req.widgets.filter((w): w is Extract<DashboardWidget, { wiql: string }> => w.kind !== "markdown")
|
|
59
|
+
for (const w of queryWidgets) {
|
|
60
|
+
if (!w.title?.trim()) throw new Error("Each list or count widget needs a title; it also names its saved query.")
|
|
61
|
+
if (!w.wiql?.trim()) throw new Error(`Widget "${w.title}" needs a WIQL query.`)
|
|
62
|
+
try {
|
|
63
|
+
await http(`${base}/_apis/wit/wiql?${API}&$top=1`, { auth, body: { query: w.wiql } })
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(`The query for "${w.title}" was rejected by Azure DevOps, so nothing was saved: ${(err as Error).message}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (queryWidgets.length) await ensureFolder(base, auth)
|
|
70
|
+
const saved = new Map<string, DashboardResult["queries"][number]>()
|
|
71
|
+
for (const w of queryWidgets) {
|
|
72
|
+
const title = w.title.trim()
|
|
73
|
+
if (!saved.has(title)) saved.set(title, await saveQuery(base, auth, title, w.wiql))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tiles = req.widgets.filter((w) => w.kind === "count")
|
|
77
|
+
const panels = req.widgets.filter((w) => w.kind !== "count")
|
|
78
|
+
const tileRows = Math.ceil(tiles.length / 8)
|
|
79
|
+
const widgets = [
|
|
80
|
+
...tiles.map((w, i) => {
|
|
81
|
+
const q = saved.get((w as { title: string }).title.trim())!
|
|
82
|
+
const at = { row: 1 + Math.floor(i / 8), column: 1 + (i % 8) }
|
|
83
|
+
return widget(q.name, CONTRIBUTIONS.count, JSON.stringify({ queryId: q.id, queryName: q.name }), at, { rowSpan: 1, columnSpan: 1 })
|
|
84
|
+
}),
|
|
85
|
+
...panels.map((w, i) => {
|
|
86
|
+
const at = { row: 1 + tileRows + Math.floor(i / 2) * 3, column: i % 2 ? 5 : 1 }
|
|
87
|
+
const size = { rowSpan: 3, columnSpan: 4 }
|
|
88
|
+
if (w.kind === "markdown") return widget(w.title?.trim() || "Notes", CONTRIBUTIONS.markdown, w.text, at, size)
|
|
89
|
+
const q = saved.get(w.title.trim())!
|
|
90
|
+
return widget(q.name, CONTRIBUTIONS.list, JSON.stringify({ query: { queryId: q.id, queryName: q.name } }), at, size)
|
|
91
|
+
}),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
const scope = req.team?.trim() ? `${base}/${encodeURIComponent(req.team.trim())}` : base
|
|
95
|
+
const { data } = await http<{ id: string; name: string }>(`${scope}/_apis/dashboard/dashboards?${DASHBOARD_API}`, {
|
|
96
|
+
auth,
|
|
97
|
+
body: { name, ...(req.description ? { description: req.description } : {}), widgets },
|
|
98
|
+
})
|
|
99
|
+
return {
|
|
100
|
+
id: data.id,
|
|
101
|
+
name: data.name || name,
|
|
102
|
+
url: `${base}/_dashboards/dashboard/${data.id}`,
|
|
103
|
+
project,
|
|
104
|
+
...(req.team?.trim() ? { team: req.team.trim() } : {}),
|
|
105
|
+
queries: [...saved.values()],
|
|
106
|
+
widgets: widgets.map((w) => w.name),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function widget(name: string, contributionId: string, settings: string, position: { row: number; column: number }, size: { rowSpan: number; columnSpan: number }) {
|
|
111
|
+
return { name, position, size, settings, settingsVersion: { major: 1, minor: 0, patch: 0 }, contributionId }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const queryPath = (...parts: string[]) => parts.map(encodeURIComponent).join("/")
|
|
115
|
+
|
|
116
|
+
async function findQuery(base: string, auth: string, ...parts: string[]): Promise<QueryItem | null> {
|
|
117
|
+
try {
|
|
118
|
+
return (await http<QueryItem>(`${base}/_apis/wit/queries/${queryPath(...parts)}?${API}`, { auth })).data
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (/failed: 404\b/.test((err as Error).message)) return null
|
|
121
|
+
throw err
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function ensureFolder(base: string, auth: string): Promise<void> {
|
|
126
|
+
if (await findQuery(base, auth, ROOT, FOLDER)) return
|
|
127
|
+
await http(`${base}/_apis/wit/queries/${queryPath(ROOT)}?${API}`, { auth, body: { name: FOLDER, isFolder: true } })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function saveQuery(base: string, auth: string, name: string, wiql: string): Promise<DashboardResult["queries"][number]> {
|
|
131
|
+
const existing = await findQuery(base, auth, ROOT, FOLDER, name)
|
|
132
|
+
const { data } = existing
|
|
133
|
+
? await http<QueryItem>(`${base}/_apis/wit/queries/${existing.id}?${API}`, { auth, method: "PATCH", body: { wiql } })
|
|
134
|
+
: await http<QueryItem>(`${base}/_apis/wit/queries/${queryPath(ROOT, FOLDER)}?${API}`, { auth, body: { name, wiql } })
|
|
135
|
+
const id = data?.id || existing?.id || ""
|
|
136
|
+
return { name, path: data?.path || `${ROOT}/${FOLDER}/${name}`, id, created: !existing, url: `${base}/_queries/query/${id}/` }
|
|
137
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { RunResult, StepCause } from "../types.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* When a test case's browser run does not pass, what kind of failure it is decides what happens next:
|
|
5
|
+
*
|
|
6
|
+
* - functional_defect: the steps were done and the application behaves wrong. A bug is filed, and a test is still
|
|
7
|
+
* written: it asserts the correct behaviour and is marked as an expected failure linked to the bug.
|
|
8
|
+
* - test_case_issue: the test case does not fit the application (a field or page it names is not there). No bug and no
|
|
9
|
+
* test: the case needs updating, and the report says what the application shows instead.
|
|
10
|
+
* - environment: the application or something it needs was unavailable (an error page, a 5xx, a network failure,
|
|
11
|
+
* sign-in or test data missing, a bot check). The run is repeated once; no bug and no test.
|
|
12
|
+
* - automation: a control that is there could not be operated, or the run ran out of steps. No bug and no test yet:
|
|
13
|
+
* page memory keeps the steps that worked and the next run resumes from them. (A coding job that does not finish,
|
|
14
|
+
* whatever the failure, moves its unfinished test to the needs-attention folder: src/code/attention.ts.)
|
|
15
|
+
*/
|
|
16
|
+
export type FailureType = "functional_defect" | "test_case_issue" | "environment" | "automation"
|
|
17
|
+
|
|
18
|
+
export type Failure = {
|
|
19
|
+
type: FailureType
|
|
20
|
+
/** One sentence: why this type, from the run. */
|
|
21
|
+
reason: string
|
|
22
|
+
/** The first failing test step, when the case has numbered steps. */
|
|
23
|
+
step?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const FAILURE_LABEL: Record<FailureType, string> = {
|
|
27
|
+
functional_defect: "functional defect",
|
|
28
|
+
test_case_issue: "test case needs updating",
|
|
29
|
+
environment: "environment problem",
|
|
30
|
+
automation: "automation problem",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What ForgeQE does about each type, for the agent's reply and the report. */
|
|
34
|
+
export const FAILURE_ACTION: Record<FailureType, string> = {
|
|
35
|
+
functional_defect: "a bug is filed and a test is written that asserts the correct behaviour, marked as an expected failure linked to the bug",
|
|
36
|
+
test_case_issue: "no bug and no test: update the test case to match the application",
|
|
37
|
+
environment: "repeated once; no bug and no test until the environment works",
|
|
38
|
+
automation: "no bug and no test yet: the steps that worked are kept, and the next run resumes from them",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const CAUSE: Record<StepCause, FailureType> = { app: "functional_defect", test_case: "test_case_issue", environment: "environment", automation: "automation" }
|
|
42
|
+
|
|
43
|
+
/** Pages and errors that mean the application or its network was not there to test, whatever the agent concluded. */
|
|
44
|
+
const ENVIRONMENT = [
|
|
45
|
+
/ERR_(NAME_NOT_RESOLVED|CONNECTION_(REFUSED|RESET|TIMED_OUT|CLOSED)|INTERNET_DISCONNECTED|NETWORK_CHANGED|SSL_PROTOCOL_ERROR|CERT_)/i,
|
|
46
|
+
/\b(502 Bad Gateway|503 Service Unavailable|504 Gateway Time-?out|Service Unavailable|site can'?t be reached)\b/i,
|
|
47
|
+
/\b(captcha|verify you are (a )?human|access denied|request blocked|unusual traffic)\b/i,
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
/** The engine gave up for its own reasons: the step budget, a loop, or the agent's model. */
|
|
51
|
+
const AUTOMATION = [/\b(max(imum)? steps|step limit|ran out of steps)\b/i, /Stopped after repeated/i, /\b(rate limit|ProviderAuthError|APIError|context length)\b/i]
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The kind of failure of a case's browser run, or undefined when it passed or was stopped. The browser agent's own
|
|
55
|
+
* cause for the first failing step decides, since it saw the page. Without one, or when it blamed its own automation,
|
|
56
|
+
* clear evidence in the run (an unreachable site, an error page) makes it an environment problem.
|
|
57
|
+
*/
|
|
58
|
+
export function classifyFailure(run: RunResult | undefined, error?: string): Failure | undefined {
|
|
59
|
+
if (!run) {
|
|
60
|
+
const text = error || ""
|
|
61
|
+
if (ENVIRONMENT.some((r) => r.test(text))) return { type: "environment", reason: `The run could not start: ${clip(text)}` }
|
|
62
|
+
return { type: "automation", reason: `The browser run could not finish: ${clip(text) || "the engine stopped"}` }
|
|
63
|
+
}
|
|
64
|
+
if (run.status === "complete" || run.status === "stopped") return undefined
|
|
65
|
+
const text = [run.summary, ...(run.caseReport?.steps || []).map((s) => s.actual || "")].join("\n")
|
|
66
|
+
const failed = (run.caseReport?.steps || []).find((s) => s.status === "failed")
|
|
67
|
+
// The agent's judgement wins where it named the application or the test case: a test about an "Access denied"
|
|
68
|
+
// message must not read as a blocked site. Error pages and network failures override "could not operate it".
|
|
69
|
+
if (failed?.cause === "app" || failed?.cause === "test_case" || failed?.cause === "environment") {
|
|
70
|
+
return { type: CAUSE[failed.cause], reason: `Step ${failed.step}: ${clip(failed.actual || run.summary)}`, step: failed.step }
|
|
71
|
+
}
|
|
72
|
+
if (ENVIRONMENT.some((r) => r.test(text))) return { type: "environment", reason: `The application was not available to test: ${clip(firstMatch(text, ENVIRONMENT))}`, ...(failed ? { step: failed.step } : {}) }
|
|
73
|
+
if (failed?.cause) return { type: CAUSE[failed.cause], reason: `Step ${failed.step}: ${clip(failed.actual || run.summary)}`, step: failed.step }
|
|
74
|
+
if (AUTOMATION.some((r) => r.test(run.summary))) return { type: "automation", reason: clip(run.summary), ...(failed ? { step: failed.step } : {}) }
|
|
75
|
+
// The steps were done and an expected result does not hold: the application, unless the agent said otherwise above.
|
|
76
|
+
if (run.outcome === "assertion_failed") {
|
|
77
|
+
const what = (run.failedChecks || []).map((c) => `expected ${c.expected}, the page shows ${c.actual}`).join("; ")
|
|
78
|
+
return { type: "functional_defect", reason: clip(what || run.summary), ...(failed ? { step: failed.step } : {}) }
|
|
79
|
+
}
|
|
80
|
+
return { type: "automation", reason: clip(run.summary) || "the browser agent could not finish the steps", ...(failed ? { step: failed.step } : {}) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function firstMatch(text: string, patterns: RegExp[]): string {
|
|
84
|
+
for (const r of patterns) {
|
|
85
|
+
const m = r.exec(text)
|
|
86
|
+
if (m) {
|
|
87
|
+
const line = text.split("\n").find((l) => l.includes(m[0])) || m[0]
|
|
88
|
+
return line
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return text
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const clip = (s: string, n = 300) => {
|
|
95
|
+
const t = (s || "").replace(/\s+/g, " ").trim()
|
|
96
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t
|
|
97
|
+
}
|