@capagents/forgeqe 1.1.4 → 1.1.5
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 +46 -4
- package/README.md +2 -1
- package/browserstack.example.yaml +14 -2
- package/docs/user-guide.html +62 -10
- package/forgeqe.example.yaml +18 -5
- package/package.json +2 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +7 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +12 -2
- package/prompts/test-reviewer.md +5 -2
- package/prompts/test-rules.md +8 -4
- package/py/forgeqe_engine/browser.py +1 -1
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/brain/index.ts +1 -1
- package/src/capabilities/registry.ts +34 -6
- package/src/cases/bugs.ts +209 -22
- package/src/cases/defectHistory.ts +193 -0
- package/src/cases/defects.ts +255 -0
- package/src/cases/github.ts +162 -0
- package/src/cases/goal.ts +1 -1
- package/src/cases/index.ts +23 -4
- package/src/cases/predict.ts +372 -0
- package/src/cases/rca.ts +258 -0
- package/src/cases/readiness.ts +38 -1
- package/src/cases/repro.ts +202 -0
- package/src/cases/types.ts +8 -1
- package/src/cli/help.ts +8 -2
- package/src/cli/index.ts +36 -7
- package/src/code/graph/graph.ts +511 -0
- package/src/code/graph/locators.ts +398 -0
- package/src/code/graph/parse.ts +439 -0
- package/src/code/graph/tools.ts +113 -0
- package/src/code/handoff.ts +93 -18
- package/src/code/loop.ts +30 -5
- package/src/code/profile.ts +14 -3
- package/src/code/replay.ts +1 -1
- package/src/code/review.ts +22 -0
- package/src/config.ts +142 -18
- package/src/mcp/attachtools.ts +223 -0
- package/src/mcp/commands.ts +1 -1
- package/src/mcp/defecttools.ts +106 -0
- package/src/mcp/devicetools.ts +125 -0
- package/src/mcp/external.ts +42 -1
- package/src/mcp/tools.ts +92 -9
- package/src/monitor/run.ts +1 -1
- package/src/report/data.ts +14 -0
- package/src/report/html.ts +15 -3
- package/src/report/template.html +44 -43
- package/src/run.ts +4 -2
- package/src/suite/report.ts +44 -11
- package/src/suite/run.ts +163 -16
- package/src/tui/App.tsx +9 -3
- package/src/usage.ts +1 -1
- package/testmu.example.yaml +11 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { DefectCluster } from "./defects.ts"
|
|
5
|
+
import type { CaseResult, CaseStatus } from "./types.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Defect intelligence, part two: what earlier runs know about each defect. Kept in defect-history.json next to the
|
|
9
|
+
* suite folders (the folder the report reads earlier runs from; keep it between CI runs as a cache to track a
|
|
10
|
+
* pipeline's defects). Only defects found by browser evidence are kept: a defect matched by the agent's words gets
|
|
11
|
+
* new words, and so a new id, every run.
|
|
12
|
+
*
|
|
13
|
+
* - new: never seen before
|
|
14
|
+
* - known: seen in earlier runs, and still there
|
|
15
|
+
* - regression: it went away (a case it broke passed, and it was not seen) and came back; or its bug was closed
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const DEFECT_HISTORY = "defect-history.json"
|
|
19
|
+
const KEEP_SIGHTINGS = 30
|
|
20
|
+
const KEEP_CASES = 50
|
|
21
|
+
/** Earlier suites read to tell an intermittent failure from a steady one. */
|
|
22
|
+
const WINDOW = 20
|
|
23
|
+
|
|
24
|
+
export type Sighting = { at: string; suite: string; cases: string[] }
|
|
25
|
+
|
|
26
|
+
export type PastDefect = {
|
|
27
|
+
title: string
|
|
28
|
+
kind: string
|
|
29
|
+
/** The page it was seen on last, for the area it belongs to. */
|
|
30
|
+
page?: string
|
|
31
|
+
firstSeen: string
|
|
32
|
+
lastSeen: string
|
|
33
|
+
/** Runs it was seen in. */
|
|
34
|
+
seen: number
|
|
35
|
+
sightings: Sighting[]
|
|
36
|
+
/** The cases it has broken, newest last. */
|
|
37
|
+
cases: string[]
|
|
38
|
+
bug?: { label: string; url?: string }
|
|
39
|
+
/** It went away: not seen in a run where a case it broke passed. */
|
|
40
|
+
goneSince?: string
|
|
41
|
+
/** When it came back after going away. */
|
|
42
|
+
returns?: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type DefectHistoryFile = { version: 1; defects: Record<string, PastDefect> }
|
|
46
|
+
|
|
47
|
+
export type DefectHistoryNote = {
|
|
48
|
+
status: "new" | "known" | "regression"
|
|
49
|
+
/** Earlier runs it was seen in. */
|
|
50
|
+
seen: number
|
|
51
|
+
firstSeen?: string
|
|
52
|
+
/** For a regression: since when it was gone, or the closed bug it matches. */
|
|
53
|
+
goneSince?: string
|
|
54
|
+
previousBug?: { label: string; url?: string; closedAt?: string }
|
|
55
|
+
/** Its cases kept changing between passing and failing: failed in `failed` of the last `runs` runs. */
|
|
56
|
+
intermittent?: { failed: number; runs: number }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const defectHistoryFile = (suiteDir: string) => path.join(path.dirname(suiteDir), DEFECT_HISTORY)
|
|
60
|
+
|
|
61
|
+
export function readDefectHistory(file: string): DefectHistoryFile {
|
|
62
|
+
try {
|
|
63
|
+
const d = JSON.parse(readFileSync(file, "utf8")) as DefectHistoryFile
|
|
64
|
+
return { version: 1, defects: d?.defects && typeof d.defects === "object" ? d.defects : {} }
|
|
65
|
+
} catch {
|
|
66
|
+
return { version: 1, defects: {} }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeDefectHistory(file: string, h: DefectHistoryFile): void {
|
|
71
|
+
writeFileSync(file, `${JSON.stringify(h, null, 2)}\n`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type SavedSuite = { at: string; results: { case: { id: string }; status: CaseStatus }[] }
|
|
75
|
+
|
|
76
|
+
/** The suites saved next to this one (this one left out), oldest first. */
|
|
77
|
+
function savedSuites(suiteDir: string): SavedSuite[] {
|
|
78
|
+
const parent = path.dirname(suiteDir)
|
|
79
|
+
const self = path.basename(suiteDir)
|
|
80
|
+
const suites: SavedSuite[] = []
|
|
81
|
+
let names: string[] = []
|
|
82
|
+
try {
|
|
83
|
+
names = readdirSync(parent).filter((n) => n !== self && existsSync(path.join(parent, n, "suite.json")))
|
|
84
|
+
} catch {}
|
|
85
|
+
for (const n of names) {
|
|
86
|
+
try {
|
|
87
|
+
const s = JSON.parse(readFileSync(path.join(parent, n, "suite.json"), "utf8"))
|
|
88
|
+
if (s?.startedAt && Array.isArray(s.results)) suites.push({ at: s.startedAt, results: s.results })
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
return suites.sort((a, b) => a.at.localeCompare(b.at))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Each case's results in the suites saved next to this one, oldest first (this suite left out). */
|
|
95
|
+
export function caseOutcomes(suiteDir: string, window = WINDOW): Map<string, CaseStatus[]> {
|
|
96
|
+
const out = new Map<string, CaseStatus[]>()
|
|
97
|
+
for (const s of savedSuites(suiteDir).slice(-window)) {
|
|
98
|
+
for (const r of s.results) {
|
|
99
|
+
if (r.status === "stopped") continue
|
|
100
|
+
const list = out.get(r.case.id) || []
|
|
101
|
+
list.push(r.status)
|
|
102
|
+
out.set(r.case.id, list)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** When the newest saved suite in which one of these cases passed started; undefined when none did. */
|
|
109
|
+
export function lastPassed(suiteDir: string, caseIds: string[]): string | undefined {
|
|
110
|
+
const ids = new Set(caseIds)
|
|
111
|
+
return [...savedSuites(suiteDir)].reverse().find((s) => s.results.some((r) => ids.has(r.case.id) && r.status === "passed"))?.at
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** How many of the latest runs in a row failed: 0 when the latest passed or there is none. */
|
|
115
|
+
export function failedInARow(statuses: CaseStatus[] = []): number {
|
|
116
|
+
let n = 0
|
|
117
|
+
for (let i = statuses.length - 1; i >= 0 && statuses[i] !== "passed"; i--) n++
|
|
118
|
+
return n
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Failed in some runs and passed in others, changing more than once: pass, fail, pass. */
|
|
122
|
+
function intermittent(statuses: CaseStatus[]): { failed: number; runs: number } | undefined {
|
|
123
|
+
const bad = statuses.map((s) => s !== "passed")
|
|
124
|
+
const flips = bad.slice(1).filter((b, i) => b !== bad[i]).length
|
|
125
|
+
return flips >= 2 ? { failed: bad.filter(Boolean).length, runs: bad.length } : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What is known about each of this run's defects: from the history, the closed bug a regression matched in the tracker,
|
|
130
|
+
* and the earlier results of its cases (with this run's result counted).
|
|
131
|
+
*/
|
|
132
|
+
export function noteHistory(clusters: DefectCluster[], h: DefectHistoryFile, outcomes: Map<string, CaseStatus[]>, closed: Map<string, { label: string; url?: string; closedAt?: string }> = new Map()): void {
|
|
133
|
+
for (const c of clusters) {
|
|
134
|
+
const past = c.kind === "page" ? undefined : h.defects[c.id]
|
|
135
|
+
const previousBug = closed.get(c.id)
|
|
136
|
+
// A defect matched by the agent's words has no history of its own: its cases failing in the runs just before
|
|
137
|
+
// make it known, so it is not run again and analysed again every suite.
|
|
138
|
+
const streak = c.kind === "page" ? Math.min(...c.cases.map((x) => failedInARow(outcomes.get(x.id)))) : 0
|
|
139
|
+
const status = past?.goneSince || previousBug ? "regression" : past || streak > 0 ? "known" : "new"
|
|
140
|
+
// A regression's pass in between was the fix, not chance.
|
|
141
|
+
const flaky = status === "regression" ? undefined : c.cases.map((x) => intermittent([...(outcomes.get(x.id) || []), x.status])).find(Boolean)
|
|
142
|
+
c.history = {
|
|
143
|
+
status,
|
|
144
|
+
seen: past?.seen || streak,
|
|
145
|
+
...(past ? { firstSeen: past.firstSeen } : {}),
|
|
146
|
+
...(past?.goneSince ? { goneSince: past.goneSince } : {}),
|
|
147
|
+
...(previousBug || (past?.goneSince && past.bug) ? { previousBug: previousBug || past!.bug } : {}),
|
|
148
|
+
...(flaky ? { intermittent: flaky } : {}),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Add this run: its defects are seen now; an earlier defect not seen while a case it broke passed has gone away. */
|
|
154
|
+
export function recordRun(h: DefectHistoryFile, clusters: DefectCluster[], results: CaseResult[], suite: string, at: string): void {
|
|
155
|
+
const seen = new Set<string>()
|
|
156
|
+
for (const c of clusters) {
|
|
157
|
+
if (c.kind === "page") continue
|
|
158
|
+
seen.add(c.id)
|
|
159
|
+
const cases = c.cases.map((x) => x.id)
|
|
160
|
+
const p: PastDefect = h.defects[c.id] || { title: c.title, kind: c.kind, firstSeen: at, lastSeen: at, seen: 0, sightings: [], cases: [] }
|
|
161
|
+
if (p.goneSince) {
|
|
162
|
+
p.returns = [...(p.returns || []), at].slice(-KEEP_SIGHTINGS)
|
|
163
|
+
delete p.goneSince
|
|
164
|
+
}
|
|
165
|
+
p.title = c.title
|
|
166
|
+
if (c.page || c.pages[0]) p.page = c.page || c.pages[0]
|
|
167
|
+
p.lastSeen = at
|
|
168
|
+
p.seen += 1
|
|
169
|
+
p.sightings = [...p.sightings, { at, suite, cases }].slice(-KEEP_SIGHTINGS)
|
|
170
|
+
p.cases = [...p.cases.filter((id) => !cases.includes(id)), ...cases].slice(-KEEP_CASES)
|
|
171
|
+
if (c.bug) p.bug = c.bug
|
|
172
|
+
h.defects[c.id] = p
|
|
173
|
+
}
|
|
174
|
+
const passed = new Set(results.filter((r) => r.status === "passed").map((r) => r.case.id))
|
|
175
|
+
for (const [id, p] of Object.entries(h.defects)) {
|
|
176
|
+
if (!seen.has(id) && !p.goneSince && p.cases.some((c) => passed.has(c))) p.goneSince = at
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** "new", "seen in 3 earlier runs since 2026-10-02", "regression of SHOP-12 (closed 2026-10-01)", "intermittent: …". */
|
|
181
|
+
export function historyText(n?: DefectHistoryNote): string {
|
|
182
|
+
if (!n) return ""
|
|
183
|
+
const day = (at?: string) => (at ? at.slice(0, 10) : "")
|
|
184
|
+
const base =
|
|
185
|
+
n.status === "regression"
|
|
186
|
+
? n.previousBug
|
|
187
|
+
? `regression of ${n.previousBug.label}${n.previousBug.closedAt ? ` (closed ${day(n.previousBug.closedAt)})` : n.goneSince ? ` (gone since ${day(n.goneSince)})` : ""}`
|
|
188
|
+
: `regression (gone since ${day(n.goneSince)})`
|
|
189
|
+
: n.status === "known"
|
|
190
|
+
? `seen in ${n.seen} earlier run${n.seen === 1 ? "" : "s"}${n.firstSeen ? ` since ${day(n.firstSeen)}` : ""}`
|
|
191
|
+
: "new"
|
|
192
|
+
return n.intermittent ? `${base}; intermittent: failed ${n.intermittent.failed} of the last ${n.intermittent.runs} runs` : base
|
|
193
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
import { pageKey } from "../quality/history.ts"
|
|
4
|
+
import { problemLine, readTimeline, type BrowserProblem } from "../report/data.ts"
|
|
5
|
+
import { FAILURE_LABEL, type FailureType } from "./failure.ts"
|
|
6
|
+
import type { DefectHistoryNote, PastDefect } from "./defectHistory.ts"
|
|
7
|
+
import type { ReportEnv } from "../report/html.ts"
|
|
8
|
+
import type { RootCause } from "./rca.ts"
|
|
9
|
+
import type { ReproPack } from "./repro.ts"
|
|
10
|
+
import type { CaseResult, WriteBack } from "./types.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Defect intelligence, part one: failures with the same cause are one defect. Each failing case gets a signature from
|
|
14
|
+
* the strongest evidence its run recorded, ids and numbers taken out so the same fault matches across cases, devices
|
|
15
|
+
* and runs:
|
|
16
|
+
*
|
|
17
|
+
* - request: a request that failed (a 5xx before a 4xx), by its route and status
|
|
18
|
+
* - exception: an uncaught error in the page, by its message
|
|
19
|
+
* - console: a console error, by its text
|
|
20
|
+
* - page: no browser evidence; the page and what the agent saw. Two such cases are one defect only on the same page
|
|
21
|
+
* with mostly the same words, and only within a run, since the agent's words change from run to run.
|
|
22
|
+
*
|
|
23
|
+
* Errors that also appear in the suite's passing runs are noise (an analytics call, a missing favicon), not evidence.
|
|
24
|
+
* Evidence is the cause whatever the case's failure was classified as: a 500 that fails one case and stops another
|
|
25
|
+
* from running is one defect.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type SignatureKind = "request" | "exception" | "console" | "page"
|
|
29
|
+
|
|
30
|
+
/** The defect a failing case belongs to; kept on the case result, so suite.json carries it. */
|
|
31
|
+
export type CaseDefect = {
|
|
32
|
+
/** forgeqe-d-<hash> for evidence that holds across runs (also the bug's label or tag); forgeqe-p-<hash> for page. */
|
|
33
|
+
id: string
|
|
34
|
+
kind: SignatureKind
|
|
35
|
+
title: string
|
|
36
|
+
evidence: string[]
|
|
37
|
+
page?: string
|
|
38
|
+
/** Words a person filing this bug by hand would likely use (a route, an error message), to find their bugs. */
|
|
39
|
+
term?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A second run of a defect's first case: whether it failed the same way. */
|
|
43
|
+
export type Reproduced = { runs: number; failed: number; runDir?: string }
|
|
44
|
+
|
|
45
|
+
/** One defect and every case it breaks, for the report and the suite summary. */
|
|
46
|
+
export type DefectCluster = CaseDefect & {
|
|
47
|
+
failure: FailureType | "unclassified"
|
|
48
|
+
failureLabel: string
|
|
49
|
+
cases: { id: string; title: string; status: CaseResult["status"]; device?: string }[]
|
|
50
|
+
pages: string[]
|
|
51
|
+
bug?: { label: string; url?: string }
|
|
52
|
+
/** What earlier runs and the bug tracker know about it (src/cases/defectHistory.ts). */
|
|
53
|
+
history?: DefectHistoryNote
|
|
54
|
+
/** Open bugs not filed for this defect that mention its route or error: possibly the same. */
|
|
55
|
+
related?: { label: string; url?: string; title?: string }[]
|
|
56
|
+
/** How to make it happen again (src/cases/repro.ts). */
|
|
57
|
+
repro?: ReproPack
|
|
58
|
+
/** Why it may happen (src/cases/rca.ts): a hypothesis, never a verdict. */
|
|
59
|
+
rca?: RootCause
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi
|
|
63
|
+
const ID_SEGMENT = /^(\d+|[0-9a-f-]{8,}|(?=.*\d)[A-Za-z0-9_-]{16,})$/i
|
|
64
|
+
|
|
65
|
+
/** A URL as its route: host and path, ids in the path as :id, no query or hash. */
|
|
66
|
+
export function routeOf(url: string): string {
|
|
67
|
+
try {
|
|
68
|
+
const u = new URL(url)
|
|
69
|
+
const segs = u.pathname.split("/").map((s) => (s && /\d/.test(s) && ID_SEGMENT.test(s) ? ":id" : s))
|
|
70
|
+
return `${u.host}${segs.join("/").replace(/\/+$/, "") || "/"}`
|
|
71
|
+
} catch {
|
|
72
|
+
return url.split(/[?#]/)[0]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Text with what changes between runs taken out: URLs as routes, then ids, times and numbers. */
|
|
77
|
+
export function normalize(text: string): string {
|
|
78
|
+
return text
|
|
79
|
+
.replace(/https?:\/\/[^\s"')]+/g, (u) => routeOf(u))
|
|
80
|
+
.replace(UUID, "<id>")
|
|
81
|
+
.replace(/\b[0-9a-f]{12,}\b/gi, "<id>")
|
|
82
|
+
.replace(/\b\d{4}-\d\d-\d\d[T ][\d:.]+Z?/g, "<time>")
|
|
83
|
+
.replace(/\d+(\.\d+)?/g, "#")
|
|
84
|
+
.replace(/\s+/g, " ")
|
|
85
|
+
.trim()
|
|
86
|
+
.slice(0, 200)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Requests the browser cancels itself (a navigation, a blocker) say nothing about the application. */
|
|
90
|
+
const IGNORED = /ERR_ABORTED|ERR_BLOCKED_BY_CLIENT|favicon\.ico/i
|
|
91
|
+
|
|
92
|
+
function problemKey(p: BrowserProblem): string {
|
|
93
|
+
if (p.kind === "network") return `request:${p.status || "failed"} ${routeOf(p.url || p.text)}`
|
|
94
|
+
if (p.kind === "exception") return `exception:${normalize(p.text.split("\n")[0])}`
|
|
95
|
+
return `console:${normalize(p.text)}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Stronger evidence first: a server error, an uncaught exception, any other failed request, a console error. */
|
|
99
|
+
function rank(p: BrowserProblem): number {
|
|
100
|
+
if (p.kind === "network" && (p.status || 0) >= 500) return 0
|
|
101
|
+
if (p.kind === "exception") return 1
|
|
102
|
+
if (p.kind === "network") return 2
|
|
103
|
+
return 3
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function errorsOf(r: CaseResult): { p: BrowserProblem; step: number }[] {
|
|
107
|
+
return readTimeline(r.runDir).flatMap((t, i) => (t.problems || []).filter((p) => p.level === "error" && p.kind !== "slow" && !IGNORED.test(`${p.url || ""} ${p.text}`)).map((p) => ({ p, step: i })))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const clip = (s: string, n: number) => {
|
|
111
|
+
const t = s.replace(/\s+/g, " ").trim()
|
|
112
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The route's path without its ids (/api/cart), or the error's message without its type (price is undefined). */
|
|
116
|
+
function problemTerm(p: BrowserProblem): string | undefined {
|
|
117
|
+
if (p.kind === "network") {
|
|
118
|
+
const route = routeOf(p.url || p.text)
|
|
119
|
+
const segs = route.slice(route.indexOf("/")).split("/").filter((s) => s && s !== ":id")
|
|
120
|
+
return segs.length ? `/${segs.join("/")}` : undefined
|
|
121
|
+
}
|
|
122
|
+
if (p.kind === "exception") {
|
|
123
|
+
const msg = p.text.split("\n")[0].replace(/^[\w.$]*(Error|Exception):\s*/, "").trim()
|
|
124
|
+
return msg.length >= 8 ? clip(msg, 60) : undefined
|
|
125
|
+
}
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function problemTitle(p: BrowserProblem): string {
|
|
130
|
+
if (p.kind === "network") return `${routeOf(p.url || p.text)} ${p.status ? `returns ${p.status}` : "fails"}`
|
|
131
|
+
if (p.kind === "exception") return `Uncaught error: ${clip(p.text.split("\n")[0], 120)}`
|
|
132
|
+
return `Console error: ${clip(p.text, 120)}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What the agent saw go wrong, without the step number the failure reason starts with. */
|
|
136
|
+
function reasonOf(r: CaseResult): string {
|
|
137
|
+
return (r.failure?.reason || r.summary.split("\n")[0] || "").replace(/^Step \d+:\s*/, "").trim()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const STOP = new Set(["the", "and", "but", "not", "was", "were", "with", "that", "this", "from", "for", "page", "shows", "instead", "expected", "step"])
|
|
141
|
+
const words = (s: string) => new Set(normalize(s).toLowerCase().split(/[^a-z#<>]+/).filter((w) => w.length > 2 && !STOP.has(w)))
|
|
142
|
+
|
|
143
|
+
function overlap(a: Set<string>, b: Set<string>): number {
|
|
144
|
+
if (!a.size || !b.size) return 0
|
|
145
|
+
let both = 0
|
|
146
|
+
for (const w of a) if (b.has(w)) both++
|
|
147
|
+
return both / (a.size + b.size - both)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const hash = (s: string) => createHash("sha1").update(s).digest("hex").slice(0, 10)
|
|
151
|
+
|
|
152
|
+
const failureOf = (r: CaseResult): DefectCluster["failure"] => r.failure?.type || "unclassified"
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The defects of one suite: the noise its passing runs show, the defects found so far, and the bug each has in this
|
|
156
|
+
* run. Bug filing runs through `serial`, so two cases of one defect finishing at once file one bug, not two.
|
|
157
|
+
*/
|
|
158
|
+
export class DefectBook {
|
|
159
|
+
private noise = new Set<string>()
|
|
160
|
+
private pages: { id: string; failure: string; page: string; words: Set<string> }[] = []
|
|
161
|
+
private queue: Promise<unknown> = Promise.resolve()
|
|
162
|
+
/** The bug a defect has in this run, and the case that filed or found it. */
|
|
163
|
+
readonly bugs = new Map<string, { id: string; label: string; url: string; caseId: string }>()
|
|
164
|
+
/** The closed bug a defect matched in the tracker: it came back. */
|
|
165
|
+
readonly closed = new Map<string, { label: string; url?: string; closedAt?: string }>()
|
|
166
|
+
/** Open bugs not filed by ForgeQE that mention a defect's route or error. */
|
|
167
|
+
readonly related = new Map<string, { label: string; url?: string; title?: string }[]>()
|
|
168
|
+
/** What earlier runs know about each defect (defect-history.json); empty outside a suite. */
|
|
169
|
+
past: Record<string, PastDefect> = {}
|
|
170
|
+
/** Defects whose first case was run a second time in this suite, to see whether it repeats. */
|
|
171
|
+
readonly reproduced = new Set<string>()
|
|
172
|
+
/** Where the suite runs: the browser, the build, the commit; for the repro steps in a bug. */
|
|
173
|
+
env?: ReportEnv
|
|
174
|
+
/** How many suites in a row before this one a case failed in: its earlier failures count as repeats. */
|
|
175
|
+
earlier?: (caseId: string) => number
|
|
176
|
+
|
|
177
|
+
/** A passing run's errors are noise for every failure after it. */
|
|
178
|
+
notePassed(r: CaseResult): void {
|
|
179
|
+
for (const { p } of errorsOf(r)) this.noise.add(problemKey(p))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The defect a failing case belongs to; undefined for a case that passed or did not run. */
|
|
183
|
+
assign(r: CaseResult): CaseDefect | undefined {
|
|
184
|
+
return this.signature(r, true)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The defect a failing run would belong to, without recording it: to compare a second run with the first. */
|
|
188
|
+
peek(r: CaseResult): CaseDefect | undefined {
|
|
189
|
+
return this.signature(r, false)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private signature(r: CaseResult, record: boolean): CaseDefect | undefined {
|
|
193
|
+
if (r.status !== "failed" && r.status !== "error") return undefined
|
|
194
|
+
const failure = failureOf(r)
|
|
195
|
+
const evidence = errorsOf(r)
|
|
196
|
+
.filter(({ p }) => !this.noise.has(problemKey(p)))
|
|
197
|
+
// The strongest kind; of those, the one nearest the failure (the latest step).
|
|
198
|
+
.sort((a, b) => rank(a.p) - rank(b.p) || b.step - a.step)[0]?.p
|
|
199
|
+
const page = r.finalUrl ? pageKey(r.finalUrl) : undefined
|
|
200
|
+
if (evidence) {
|
|
201
|
+
const key = problemKey(evidence)
|
|
202
|
+
const term = problemTerm(evidence)
|
|
203
|
+
return { id: `forgeqe-d-${hash(key)}`, kind: key.split(":")[0] as SignatureKind, title: problemTitle(evidence), evidence: [problemLine(evidence)], ...(page ? { page } : {}), ...(term ? { term } : {}) }
|
|
204
|
+
}
|
|
205
|
+
const reason = reasonOf(r)
|
|
206
|
+
const own = words(reason)
|
|
207
|
+
const same = this.pages.find((c) => c.failure === failure && c.page === (page || "") && overlap(c.words, own) >= 0.5)
|
|
208
|
+
const id = same?.id || `forgeqe-p-${hash(`${failure}|${page || ""}|${normalize(reason).toLowerCase()}`)}`
|
|
209
|
+
if (!same && record) this.pages.push({ id, failure, page: page || "", words: own })
|
|
210
|
+
const where = page ? routeOf(page) : ""
|
|
211
|
+
return { id, kind: "page", title: clip(`${where ? `${where}: ` : ""}${reason || FAILURE_LABEL[failure as FailureType] || "failed"}`, 160), evidence: reason ? [clip(reason, 300)] : [], ...(page ? { page } : {}) }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Run `fn` after every earlier call has finished. */
|
|
215
|
+
serial<T>(fn: () => Promise<T>): Promise<T> {
|
|
216
|
+
const next = this.queue.then(fn, fn)
|
|
217
|
+
this.queue = next.catch(() => {})
|
|
218
|
+
return next
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The suite's defects, the ones breaking most cases first. Results without a defect (passed, not run) are left out.
|
|
224
|
+
* A defect's bug is the one set on a case, else one posted for it (bugs filed at the end of the suite, or for a case
|
|
225
|
+
* run on several devices, whose results carry the device in their id).
|
|
226
|
+
*/
|
|
227
|
+
export function defectClusters(results: CaseResult[], posted: WriteBack[] = []): DefectCluster[] {
|
|
228
|
+
const postedBug = (id: string) => {
|
|
229
|
+
const w = posted.find((p) => p.source === "bugs" && p.url && !p.error && p.caseId && (p.caseId === id || id.startsWith(`${p.caseId}@`)))
|
|
230
|
+
return w ? { label: w.text.split(":")[0], url: w.url } : undefined
|
|
231
|
+
}
|
|
232
|
+
const byId = new Map<string, DefectCluster>()
|
|
233
|
+
for (const r of results) {
|
|
234
|
+
const d = r.defect
|
|
235
|
+
if (!d || (r.status !== "failed" && r.status !== "error")) continue
|
|
236
|
+
let c = byId.get(d.id)
|
|
237
|
+
if (!c) {
|
|
238
|
+
const failure = failureOf(r)
|
|
239
|
+
c = { ...d, evidence: [...d.evidence], failure, failureLabel: failure === "unclassified" ? "not classified" : FAILURE_LABEL[failure], cases: [], pages: [] }
|
|
240
|
+
byId.set(d.id, c)
|
|
241
|
+
}
|
|
242
|
+
c.cases.push({ id: r.case.id, title: r.case.title, status: r.status, ...(r.device ? { device: r.device } : {}) })
|
|
243
|
+
if (d.page && !c.pages.includes(d.page)) c.pages.push(d.page)
|
|
244
|
+
for (const e of d.evidence) if (c.evidence.length < 5 && !c.evidence.includes(e)) c.evidence.push(e)
|
|
245
|
+
if (!c.bug) c.bug = r.bug || postedBug(r.case.id)
|
|
246
|
+
}
|
|
247
|
+
return [...byId.values()].sort((a, b) => b.cases.length - a.cases.length)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** "7 failures from 3 defects", or "" when nothing failed. */
|
|
251
|
+
export function defectLine(clusters: DefectCluster[]): string {
|
|
252
|
+
const failures = clusters.reduce((n, c) => n + c.cases.length, 0)
|
|
253
|
+
if (!failures) return ""
|
|
254
|
+
return `${failures} failure${failures === 1 ? "" : "s"} from ${clusters.length} defect${clusters.length === 1 ? "" : "s"}`
|
|
255
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
import type { BugsConfig, SourcesConfig } from "../config.ts"
|
|
4
|
+
import type { BugRef, Tracker } from "./bugs.ts"
|
|
5
|
+
import { http } from "./http.ts"
|
|
6
|
+
import { resultText } from "./jira.ts"
|
|
7
|
+
import type { CaseResult, TestCase, WriteBack } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GitHub issues as test cases (github:owner/repo#12, github:#12,#13, github:label=login, github:q=<search>), results
|
|
11
|
+
* posted back as issue comments, and GitHub as a bug tracker. Signs in with GITHUB_TOKEN (sources.github.token_env),
|
|
12
|
+
* else the GitHub CLI's sign-in (gh auth token), so `forgeqe auth login github` is all it needs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type GithubApi = { api: string; web: string; auth: string; repo?: string }
|
|
16
|
+
|
|
17
|
+
let ghCached: string | null | undefined
|
|
18
|
+
|
|
19
|
+
/** The GitHub CLI's token, once per process; never on test machines' saved sign-ins (FORGEQE_SAVED_AUTH=off). */
|
|
20
|
+
export function ghAuthToken(): string | null {
|
|
21
|
+
if (process.env.FORGEQE_SAVED_AUTH === "off") return null
|
|
22
|
+
if (ghCached !== undefined) return ghCached
|
|
23
|
+
if (!Bun.which("gh")) return (ghCached = null)
|
|
24
|
+
const res = spawnSync("gh", ["auth", "token"], { encoding: "utf8", timeout: 10_000 })
|
|
25
|
+
ghCached = res.status === 0 ? res.stdout.trim() || null : null
|
|
26
|
+
return ghCached
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The token GitHub calls use, or null when there is none (no GITHUB_TOKEN and no gh sign-in). */
|
|
30
|
+
export function githubToken(cfg: SourcesConfig): string | null {
|
|
31
|
+
const env = cfg.github?.tokenEnv || "GITHUB_TOKEN"
|
|
32
|
+
return process.env[env]?.trim() || ghAuthToken()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function githubApi(cfg: SourcesConfig, what = "GitHub"): GithubApi {
|
|
36
|
+
const token = githubToken(cfg)
|
|
37
|
+
const env = cfg.github?.tokenEnv || "GITHUB_TOKEN"
|
|
38
|
+
if (!token) throw new Error(`${what} needs ${env} in the environment, or a GitHub CLI sign-in: run forgeqe auth login github.`)
|
|
39
|
+
const api = (cfg.github?.apiUrl || "https://api.github.com").replace(/\/+$/, "")
|
|
40
|
+
const web = api === "https://api.github.com" ? "https://github.com" : api.replace(/\/api\/v3$/, "")
|
|
41
|
+
return { api, web, auth: `Bearer ${token}`, ...(cfg.github?.repo ? { repo: cfg.github.repo } : {}) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
|
|
45
|
+
|
|
46
|
+
type Issue = { number: number; title: string; body?: string | null; html_url: string; labels?: ({ name?: string } | string)[]; milestone?: { title?: string } | null; pull_request?: unknown }
|
|
47
|
+
|
|
48
|
+
/** owner/repo#12,#13 · #12 · 12,13 · label=login,smoke · q=<GitHub search> (each optionally after owner/repo). */
|
|
49
|
+
export function parseGithubRef(ref: string, defaultRepo?: string): { repo: string; numbers: number[]; labels: string[]; query?: string } {
|
|
50
|
+
let rest = ref.trim()
|
|
51
|
+
let repo = defaultRepo || ""
|
|
52
|
+
const lead = rest.match(/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?=#|\s|\?|$|:)/)
|
|
53
|
+
if (lead) {
|
|
54
|
+
repo = lead[1]
|
|
55
|
+
rest = rest.slice(lead[0].length).replace(/^[?:\s]+/, "")
|
|
56
|
+
}
|
|
57
|
+
if (!REPO.test(repo)) throw new Error(`GitHub reference "${ref}": name the repository as owner/repo (or set sources.github.repo).`)
|
|
58
|
+
const q = rest.match(/^q=(.+)$/s)?.[1]
|
|
59
|
+
if (q) return { repo, numbers: [], labels: [], query: q.trim() }
|
|
60
|
+
const labels = rest.match(/^labels?=(.+)$/s)?.[1]
|
|
61
|
+
if (labels) return { repo, numbers: [], labels: labels.split(",").map((l) => l.trim()).filter(Boolean) }
|
|
62
|
+
const numbers = rest.split(/[,\s]+/).map((n) => n.replace(/^#/, "").trim()).filter(Boolean)
|
|
63
|
+
if (!numbers.length || numbers.some((n) => !/^\d+$/.test(n))) throw new Error(`GitHub reference "${ref}": use owner/repo#12,#13, label=<labels>, or q=<search>.`)
|
|
64
|
+
return { repo, numbers: numbers.map(Number), labels: [] }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function issueCase(repo: string, i: Issue): TestCase {
|
|
68
|
+
const labels = (i.labels || []).map((l) => (typeof l === "string" ? l : l.name || "")).filter(Boolean)
|
|
69
|
+
return {
|
|
70
|
+
id: `${repo}#${i.number}`,
|
|
71
|
+
title: i.title || `${repo}#${i.number}`,
|
|
72
|
+
source: "github",
|
|
73
|
+
url: i.html_url,
|
|
74
|
+
description: i.body?.trim() || undefined,
|
|
75
|
+
steps: [],
|
|
76
|
+
...(labels.length ? { tags: labels } : {}),
|
|
77
|
+
...(i.milestone?.title ? { group: i.milestone.title } : {}),
|
|
78
|
+
ref: { repo, issue: i.number },
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function githubCases(ref: string, cfg: SourcesConfig): Promise<TestCase[]> {
|
|
83
|
+
const g = githubApi(cfg)
|
|
84
|
+
const { repo, numbers, labels, query } = parseGithubRef(ref, g.repo)
|
|
85
|
+
if (numbers.length) {
|
|
86
|
+
const out: TestCase[] = []
|
|
87
|
+
for (const n of numbers) {
|
|
88
|
+
const { data } = await http<Issue>(`${g.api}/repos/${repo}/issues/${n}`, { auth: g.auth })
|
|
89
|
+
if (data.pull_request) throw new Error(`${repo}#${n} is a pull request, not an issue: review_pull_request or select_pr_tests cover pull requests.`)
|
|
90
|
+
out.push(issueCase(repo, data))
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
// Pull requests are issues to this API: only issues are test cases.
|
|
95
|
+
const issues: Issue[] = []
|
|
96
|
+
if (query) {
|
|
97
|
+
const q = new URLSearchParams({ q: `repo:${repo} is:issue ${query}`, per_page: "100" })
|
|
98
|
+
issues.push(...((await http<{ items: Issue[] }>(`${g.api}/search/issues?${q}`, { auth: g.auth })).data.items || []))
|
|
99
|
+
} else {
|
|
100
|
+
const q = new URLSearchParams({ labels: labels.join(","), state: "open", per_page: "100" })
|
|
101
|
+
issues.push(...(await http<Issue[]>(`${g.api}/repos/${repo}/issues?${q}`, { auth: g.auth })).data)
|
|
102
|
+
}
|
|
103
|
+
return issues.filter((i) => !i.pull_request).map((i) => issueCase(repo, i))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Each result as a comment on its issue. */
|
|
107
|
+
export async function githubWriteBack(results: CaseResult[], cfg: SourcesConfig): Promise<WriteBack[]> {
|
|
108
|
+
const g = githubApi(cfg)
|
|
109
|
+
const out: WriteBack[] = []
|
|
110
|
+
for (const r of results) {
|
|
111
|
+
const repo = String(r.case.ref?.repo || "")
|
|
112
|
+
const n = Number(r.case.ref?.issue)
|
|
113
|
+
const label = `${repo}#${n}`
|
|
114
|
+
try {
|
|
115
|
+
await http(`${g.api}/repos/${repo}/issues/${n}/comments`, { auth: g.auth, body: { body: resultText(r) } })
|
|
116
|
+
out.push({ source: "github", caseId: r.case.id, text: `${label}: result added as a comment`, url: r.case.url })
|
|
117
|
+
} catch (err) {
|
|
118
|
+
out.push({ source: "github", caseId: r.case.id, text: `${label}: comment not added`, error: (err as Error).message })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** GitHub as sources.bugs: an issue per defect or failing case, found again by its fingerprint labels. */
|
|
125
|
+
export function githubBugTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
126
|
+
const g = githubApi(cfg, "Filing bugs in GitHub")
|
|
127
|
+
const repo = b.project || g.repo
|
|
128
|
+
if (!repo || !REPO.test(repo)) throw new Error("Filing bugs in GitHub needs the repository: sources.bugs.project (owner/repo) or sources.github.repo.")
|
|
129
|
+
const ref = (n: number, url?: string): BugRef => ({ id: String(n), label: `${repo}#${n}`, url: url || `${g.web}/${repo}/issues/${n}` })
|
|
130
|
+
return {
|
|
131
|
+
async findOpen(fp: string): Promise<BugRef | null> {
|
|
132
|
+
const q = new URLSearchParams({ labels: fp, state: "open", per_page: "1" })
|
|
133
|
+
const { data } = await http<Issue[]>(`${g.api}/repos/${repo}/issues?${q}`, { auth: g.auth })
|
|
134
|
+
const hit = data.find((i) => !i.pull_request)
|
|
135
|
+
return hit ? ref(hit.number, hit.html_url) : null
|
|
136
|
+
},
|
|
137
|
+
async findClosed(fp) {
|
|
138
|
+
const q = new URLSearchParams({ labels: fp, state: "closed", sort: "updated", per_page: "5" })
|
|
139
|
+
const { data } = await http<(Issue & { closed_at?: string | null; state_reason?: string | null })[]>(`${g.api}/repos/${repo}/issues?${q}`, { auth: g.auth })
|
|
140
|
+
// Closed as not planned is not a fix: it cannot come back.
|
|
141
|
+
const hit = data.find((i) => !i.pull_request && i.state_reason !== "not_planned")
|
|
142
|
+
return hit ? { ...ref(hit.number, hit.html_url), ...(hit.closed_at ? { closedAt: hit.closed_at } : {}) } : null
|
|
143
|
+
},
|
|
144
|
+
async related(term, limit) {
|
|
145
|
+
const q = new URLSearchParams({ q: `repo:${repo} is:issue is:open "${term.replace(/"/g, " ")}"`, per_page: String(limit), sort: "updated" })
|
|
146
|
+
const { data } = await http<{ items?: Issue[] }>(`${g.api}/search/issues?${q}`, { auth: g.auth })
|
|
147
|
+
return (data?.items || []).filter((i) => !i.pull_request).map((i) => ({ ...ref(i.number, i.html_url), title: i.title }))
|
|
148
|
+
},
|
|
149
|
+
async create(_r, bug) {
|
|
150
|
+
const regression = bug.regressionOf ? `\n\nRegression of #${bug.regressionOf.id}.` : ""
|
|
151
|
+
const body = `${bug.body}${regression}\n\n_GitHub has no API to upload a screenshot to an issue: it is in the run folder above._`
|
|
152
|
+
const { data } = await http<Issue>(`${g.api}/repos/${repo}/issues`, { auth: g.auth, body: { title: bug.title, body, labels: bug.labels } })
|
|
153
|
+
return ref(data.number, data.html_url)
|
|
154
|
+
},
|
|
155
|
+
async comment(id, body) {
|
|
156
|
+
await http(`${g.api}/repos/${repo}/issues/${id}/comments`, { auth: g.auth, body: { body } })
|
|
157
|
+
},
|
|
158
|
+
async tag(id, label) {
|
|
159
|
+
await http(`${g.api}/repos/${repo}/issues/${id}/labels`, { auth: g.auth, body: { labels: [label] } })
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/cases/goal.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TestCase } from "./types.ts"
|
|
2
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
|
|
3
|
+
const SOURCE_NAME = { text: "text", file: "file", ado: "Azure DevOps", jira: "Jira", xray: "Xray", testrail: "TestRail", zephyr: "Zephyr Scale", qtest: "qTest", github: "GitHub" } as const
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The browser goal for a test case: every step with its data and expected result, and an instruction to check each
|