@capagents/forgeqe 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +40 -3
- package/README.md +1 -1
- package/docs/COST_ARCHITECTURE.md +280 -0
- package/docs/feature-test-prompts.md +95 -0
- package/docs/user-guide.html +54 -7
- package/evals/prompts/brain/route-api-scenarios.yaml +9 -0
- package/evals/prompts/brain/route-e2e.yaml +10 -0
- package/evals/prompts/brain/route-flaky.yaml +9 -0
- package/evals/prompts/brain/route-heal.yaml +9 -0
- package/evals/prompts/brain/route-regression-pack.yaml +9 -0
- package/evals/prompts/brain/route-review-update.yaml +10 -0
- package/evals/prompts/brain/route-run-cases.yaml +9 -0
- package/evals/prompts/brain/route-story-cases.yaml +12 -0
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/README.md +30 -0
- package/prompts/brain.md +34 -49
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +59 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +155 -3
- package/src/agents.ts +52 -0
- package/src/brain/index.ts +8 -2
- package/src/capabilities/registry.ts +48 -6
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/readiness.ts +35 -6
- package/src/cases/save.ts +241 -48
- package/src/cases/types.ts +11 -0
- package/src/cli/help.ts +9 -0
- package/src/cli/index.ts +71 -1
- package/src/code/coverage.ts +22 -3
- package/src/code/decisions.ts +73 -0
- package/src/code/find.ts +3 -1
- package/src/code/graph/graph.ts +33 -2
- package/src/code/loop.ts +17 -1
- package/src/code/notes.ts +48 -1
- package/src/code/prompt.ts +31 -9
- package/src/code/protect.ts +103 -0
- package/src/code/review.ts +2 -0
- package/src/code/runtime.ts +35 -3
- package/src/code/shortlist.ts +309 -0
- package/src/code/testrun.ts +3 -1
- package/src/code/traceability.ts +2 -0
- package/src/commands.ts +6 -1
- package/src/config.ts +46 -0
- package/src/context.ts +75 -0
- package/src/engine/bridge.ts +47 -5
- package/src/mcp/captools.ts +19 -0
- package/src/mcp/casetools.ts +107 -11
- package/src/mcp/commands.ts +74 -20
- package/src/mcp/packtools.ts +187 -0
- package/src/mcp/routing.ts +145 -0
- package/src/mcp/server.ts +7 -3
- package/src/mcp/tools.ts +241 -29
- package/src/pack/collect.ts +251 -0
- package/src/pack/index.ts +134 -0
- package/src/pack/init.ts +45 -0
- package/src/pack/pack.ts +327 -0
- package/src/pack/pipeline.ts +373 -0
- package/src/pack/run.ts +155 -0
- package/src/registry/db.ts +249 -0
- package/src/registry/index.ts +72 -0
- package/src/registry/resolve.ts +57 -0
- package/src/registry/tier0.ts +81 -0
- package/src/report/data.ts +7 -0
- package/src/report/html.ts +14 -0
- package/src/report/template.html +52 -48
- package/src/report/usage.ts +113 -0
- package/src/run.ts +22 -2
- package/src/suite/run.ts +12 -5
- package/src/summary.ts +1 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +89 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/types.ts +9 -2
package/src/cases/save.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { SourcesConfig } from "../config.ts"
|
|
2
|
-
import { adoSettings } from "./ado.ts"
|
|
3
|
-
import { http, textToAdf } from "./http.ts"
|
|
2
|
+
import { adoSettings, parseSteps } from "./ado.ts"
|
|
3
|
+
import { adfToText, http, textToAdf } from "./http.ts"
|
|
4
4
|
import { jiraApi } from "./jira.ts"
|
|
5
|
+
import { stepText } from "./types.ts"
|
|
5
6
|
|
|
6
7
|
/** A test case the agent wrote from requirements, to save in Azure DevOps or Jira. */
|
|
7
8
|
export type NewCase = {
|
|
@@ -11,13 +12,28 @@ export type NewCase = {
|
|
|
11
12
|
description?: string
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
-
export type
|
|
15
|
+
/** A test case already linked to the story, with its steps as its system holds them. */
|
|
16
|
+
export type ExistingCase = { id: string; title: string; url: string; steps: NewCase["steps"] }
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
/** The story and the test cases already linked to it. */
|
|
19
|
+
export type StoryCases = { system: "azure_devops" | "jira"; story: { id: string; title: string; url: string }; cases: ExistingCase[] }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What saving did with one case, never duplicating one already linked to the story:
|
|
23
|
+
* - created: a new test case.
|
|
24
|
+
* - unchanged: a linked case has the same steps (under this title or another): nothing was written.
|
|
25
|
+
* - differs: a linked case has this title but other steps: nothing was written; update it with update_test_case.
|
|
26
|
+
* - similar: a linked case has a close title or most of the same steps: nothing was written; update that case, or
|
|
27
|
+
* save again with this title in force_new when it really is a different case.
|
|
28
|
+
* - failed: the system refused it (error).
|
|
29
|
+
*/
|
|
30
|
+
export type SaveStatus = "created" | "unchanged" | "differs" | "similar" | "failed"
|
|
31
|
+
|
|
32
|
+
export type SavedCase = { title: string; id: string; url: string; created: boolean; status: SaveStatus; existing?: ExistingCase; error?: string }
|
|
33
|
+
|
|
34
|
+
export type SaveResult = Omit<StoryCases, "cases"> & { cases: SavedCase[]; existing: ExistingCase[] }
|
|
18
35
|
|
|
19
36
|
const API = "api-version=7.1"
|
|
20
|
-
const sameTitle = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
|
|
21
37
|
|
|
22
38
|
const escXml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
23
39
|
/** One step string as Azure DevOps stores it: HTML, escaped again inside the steps XML. */
|
|
@@ -43,11 +59,87 @@ function describe(c: NewCase): string[] {
|
|
|
43
59
|
return out
|
|
44
60
|
}
|
|
45
61
|
|
|
62
|
+
/** Steps as ForgeQE writes them in a Jira description, after "Steps:". */
|
|
63
|
+
const stepLines = (steps: NewCase["steps"]) => steps.map((s, i) => `${i + 1}. ${s.action}${s.expected ? `\n Expected: ${s.expected}` : ""}`)
|
|
64
|
+
|
|
65
|
+
/** Numbered steps (`1. …`, with `Expected: …` lines) from a description, as ForgeQE writes them in Jira. */
|
|
66
|
+
export function stepsFromText(text: string): NewCase["steps"] {
|
|
67
|
+
const lines = text.split(/\r?\n/)
|
|
68
|
+
const start = lines.findIndex((l) => /^\s*steps:\s*$/i.test(l))
|
|
69
|
+
const steps: NewCase["steps"] = []
|
|
70
|
+
for (const line of lines.slice(start + 1)) {
|
|
71
|
+
const step = /^\s*\d+[.)]\s+(.+)$/.exec(line)
|
|
72
|
+
const expected = /^\s*expected(?: result)?:\s*(.+)$/i.exec(line)
|
|
73
|
+
if (step) steps.push({ action: step[1].trim() })
|
|
74
|
+
else if (expected && steps.length) steps[steps.length - 1].expected = expected[1].trim()
|
|
75
|
+
}
|
|
76
|
+
return steps
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------------------------
|
|
80
|
+
// Matching a new case to the cases already linked to the story
|
|
81
|
+
// ---------------------------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/** Words that change how a title reads, not what it tests: "Verify that the user can sign in" ≈ "Sign in". */
|
|
84
|
+
const FILLER = new Set("a an and are as be can check checks correct correctly ensure for from if in is it of on should successfully test tests that the to user users validate verify verifies when with".split(" "))
|
|
85
|
+
/** A numbering prefix teams put on titles ("TC-10 ", "AC02 - ", "PBI 2237 - ", "E2E: "), which says nothing about what is tested. */
|
|
86
|
+
const NUMBERING = /^\s*(?:(?:tc|ac|ado|pbi|us|story|test|case|e2e)[-_ ]?\d*[a-z]?|\d+)\s*[-:.)]?\s+/i
|
|
87
|
+
/** Tokens of ids anywhere in a title ("TC_2211_REQ_002", "for PBI 2211"): numbers, and the kinds of id they belong to. */
|
|
88
|
+
const ID_WORD = /\d|^(?:tc|ac|req|pbi|ado)$/
|
|
89
|
+
const titleWords = (t: string) => new Set(stepText(t.replace(NUMBERING, "")).split(" ").filter((w) => w && !FILLER.has(w) && !ID_WORD.test(w)))
|
|
90
|
+
const stepWords = (s: NewCase["steps"][number]) => `${stepText(s.action)}|${stepText(s.expected)}`
|
|
91
|
+
const sameSteps = (a: NewCase["steps"], b: NewCase["steps"]) => a.length === b.length && a.every((s, i) => stepWords(s) === stepWords(b[i]))
|
|
92
|
+
|
|
46
93
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
94
|
+
* How close two titles are: the share of words they have in common (Jaccard), or, when the shorter title has at least
|
|
95
|
+
* three words, the share of the shorter title's words the longer one has ("Start quote journey from landing page" is in
|
|
96
|
+
* "TC-10 Launch quote journey from landing page to step 1").
|
|
49
97
|
*/
|
|
50
|
-
|
|
98
|
+
function titleScore(a: Set<string>, b: Set<string>): number {
|
|
99
|
+
if (!a.size || !b.size) return 0
|
|
100
|
+
let both = 0
|
|
101
|
+
for (const w of a) if (b.has(w)) both++
|
|
102
|
+
const jaccard = both / (a.size + b.size - both)
|
|
103
|
+
const shorter = Math.min(a.size, b.size)
|
|
104
|
+
return shorter >= 3 ? Math.max(jaccard, both / shorter) : jaccard
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Share of `a`'s steps that `b` has too, in any order. */
|
|
108
|
+
function stepOverlap(a: NewCase["steps"], b: NewCase["steps"]): number {
|
|
109
|
+
if (!a.length || !b.length) return 0
|
|
110
|
+
const have = new Set(b.map(stepWords))
|
|
111
|
+
return a.filter((s) => have.has(stepWords(s))).length / a.length
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Titles this close (titleScore, filler words and numbering left out), or this share of the same steps, are alike. */
|
|
115
|
+
const SIMILAR_TITLE = 0.8
|
|
116
|
+
const SIMILAR_STEPS = 0.8
|
|
117
|
+
|
|
118
|
+
/** How a new case stands against the cases already linked to the story; see SaveStatus. */
|
|
119
|
+
export function matchCase(c: NewCase, existing: ExistingCase[], forceNew = false): { status: Exclude<SaveStatus, "created" | "failed">; found: ExistingCase } | undefined {
|
|
120
|
+
const sameTitle = existing.find((e) => stepText(e.title) === stepText(c.title))
|
|
121
|
+
if (sameTitle) return { status: sameSteps(sameTitle.steps, c.steps) ? "unchanged" : "differs", found: sameTitle }
|
|
122
|
+
const reworded = c.steps.length ? existing.find((e) => sameSteps(e.steps, c.steps)) : undefined
|
|
123
|
+
if (reworded) return { status: "unchanged", found: reworded }
|
|
124
|
+
if (forceNew) return undefined
|
|
125
|
+
const words = titleWords(c.title)
|
|
126
|
+
let best: { score: number; found: ExistingCase } | undefined
|
|
127
|
+
for (const e of existing) {
|
|
128
|
+
const t = titleScore(words, titleWords(e.title))
|
|
129
|
+
const s = c.steps.length >= 2 && e.steps.length >= 2 ? Math.min(stepOverlap(c.steps, e.steps), stepOverlap(e.steps, c.steps)) : 0
|
|
130
|
+
const score = t >= SIMILAR_TITLE || s >= SIMILAR_STEPS ? Math.max(t, s) : 0
|
|
131
|
+
if (score && (!best || score > best.score)) best = { score, found: e }
|
|
132
|
+
}
|
|
133
|
+
return best ? { status: "similar", found: best.found } : undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------------------------
|
|
137
|
+
// Azure DevOps
|
|
138
|
+
// ---------------------------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
type AdoStory = StoryCases & { project: string; area: string; iteration: string; base: string; org: string; auth: string }
|
|
141
|
+
|
|
142
|
+
async function adoStory(cfg: SourcesConfig, storyId: number): Promise<AdoStory> {
|
|
51
143
|
const { org, auth } = adoSettings(cfg)
|
|
52
144
|
const { data: story } = await http<{ id: number; fields: Record<string, string>; relations?: { rel: string; url: string }[] }>(
|
|
53
145
|
`${org}/_apis/wit/workitems/${storyId}?$expand=relations&${API}`,
|
|
@@ -60,57 +152,141 @@ export async function saveAdoCases(cfg: SourcesConfig, storyId: number, cases: N
|
|
|
60
152
|
.filter((r) => r.rel === "Microsoft.VSTS.Common.TestedBy-Forward")
|
|
61
153
|
.map((r) => Number(r.url.split("/").pop()))
|
|
62
154
|
.filter(Boolean)
|
|
63
|
-
const
|
|
64
|
-
? ((await http<{ value: { id: number; fields: Record<string, string> }[] }>(`${org}/_apis/wit/workitemsbatch?${API}`, { auth, body: { ids: linked, fields: ["System.Title"], errorPolicy: "omit" } })).data?.value || []).filter(
|
|
155
|
+
const items = linked.length
|
|
156
|
+
? ((await http<{ value: ({ id: number; fields: Record<string, string> } | null)[] }>(`${org}/_apis/wit/workitemsbatch?${API}`, { auth, body: { ids: linked, fields: ["System.Title", "Microsoft.VSTS.TCM.Steps"], errorPolicy: "omit" } })).data?.value || []).filter(
|
|
157
|
+
(w): w is { id: number; fields: Record<string, string> } => Boolean(w),
|
|
158
|
+
)
|
|
65
159
|
: []
|
|
160
|
+
const cases: ExistingCase[] = []
|
|
161
|
+
for (const w of items) {
|
|
162
|
+
const steps = await parseSteps(String(w.fields["Microsoft.VSTS.TCM.Steps"] || ""), base, auth)
|
|
163
|
+
cases.push({ id: String(w.id), title: w.fields["System.Title"] || "", url: link(w.id), steps: steps.map((s) => ({ action: s.action, ...(s.expected ? { expected: s.expected } : {}) })) })
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
system: "azure_devops",
|
|
167
|
+
story: { id: String(storyId), title: story.fields["System.Title"] || "", url: link(storyId) },
|
|
168
|
+
cases,
|
|
169
|
+
project,
|
|
170
|
+
area: story.fields["System.AreaPath"],
|
|
171
|
+
iteration: story.fields["System.IterationPath"],
|
|
172
|
+
base,
|
|
173
|
+
org,
|
|
174
|
+
auth,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
66
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Create each case as an Azure DevOps Test Case linked to the story (Tests / Tested By), in the story's project, area,
|
|
180
|
+
* and iteration. A case matching one already linked to the story (see matchCase) is returned instead of created.
|
|
181
|
+
*/
|
|
182
|
+
export async function saveAdoCases(cfg: SourcesConfig, storyId: number, cases: NewCase[], opts: { forceNew?: string[] } = {}): Promise<SaveResult> {
|
|
183
|
+
const s = await adoStory(cfg, storyId)
|
|
184
|
+
const link = (id: number | string) => `${s.base}/_workitems/edit/${id}`
|
|
185
|
+
const known = [...s.cases]
|
|
186
|
+
const force = new Set((opts.forceNew || []).map(stepText))
|
|
67
187
|
const out: SavedCase[] = []
|
|
68
188
|
for (const c of cases) {
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
out.push({ title: c.title, id:
|
|
189
|
+
const match = matchCase(c, known, force.has(stepText(c.title)))
|
|
190
|
+
if (match) {
|
|
191
|
+
out.push({ title: c.title, id: match.found.id, url: match.found.url, created: false, status: match.status, ...(match.status === "unchanged" ? {} : { existing: match.found }) })
|
|
72
192
|
continue
|
|
73
193
|
}
|
|
74
194
|
const ops: Record<string, unknown>[] = [
|
|
75
195
|
{ op: "add", path: "/fields/System.Title", value: c.title.slice(0, 255) },
|
|
76
196
|
{ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: adoStepsXml(c.steps) },
|
|
77
|
-
{ op: "add", path: "/fields/System.AreaPath", value:
|
|
78
|
-
{ op: "add", path: "/fields/System.IterationPath", value:
|
|
79
|
-
{ op: "add", path: "/relations/-", value: { rel: "Microsoft.VSTS.Common.TestedBy-Reverse", url: `${org}/_apis/wit/workItems/${storyId}` } },
|
|
197
|
+
{ op: "add", path: "/fields/System.AreaPath", value: s.area },
|
|
198
|
+
{ op: "add", path: "/fields/System.IterationPath", value: s.iteration },
|
|
199
|
+
{ op: "add", path: "/relations/-", value: { rel: "Microsoft.VSTS.Common.TestedBy-Reverse", url: `${s.org}/_apis/wit/workItems/${storyId}` } },
|
|
80
200
|
]
|
|
81
201
|
const text = describe(c)
|
|
82
202
|
if (text.length) ops.push({ op: "add", path: "/fields/System.Description", value: html(text) })
|
|
83
203
|
try {
|
|
84
|
-
const { data } = await http<{ id: number }>(`${base}/_apis/wit/workitems/$Test%20Case?${API}`, { auth, body: ops, contentType: "application/json-patch+json" })
|
|
85
|
-
out.push({ title: c.title, id: String(data.id), url: link(data.id), created: true })
|
|
204
|
+
const { data } = await http<{ id: number }>(`${s.base}/_apis/wit/workitems/$Test%20Case?${API}`, { auth: s.auth, body: ops, contentType: "application/json-patch+json" })
|
|
205
|
+
out.push({ title: c.title, id: String(data.id), url: link(data.id), created: true, status: "created" })
|
|
206
|
+
known.push({ id: String(data.id), title: c.title, url: link(data.id), steps: c.steps })
|
|
86
207
|
} catch (err) {
|
|
87
|
-
out.push({ title: c.title, id: "", url: "", created: false, error: (err as Error).message })
|
|
208
|
+
out.push({ title: c.title, id: "", url: "", created: false, status: "failed", error: (err as Error).message })
|
|
88
209
|
}
|
|
89
210
|
}
|
|
90
|
-
return { system: "azure_devops", story:
|
|
211
|
+
return { system: "azure_devops", story: s.story, cases: out, existing: s.cases }
|
|
91
212
|
}
|
|
92
213
|
|
|
93
214
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
215
|
+
* Replace an Azure DevOps Test Case's steps (and title, when given), and say why in its history. Returns its link.
|
|
216
|
+
* The previous steps stay in the work item's history, so the change can be undone there.
|
|
96
217
|
*/
|
|
97
|
-
export async function
|
|
218
|
+
export async function updateAdoCase(cfg: SourcesConfig, id: number, change: { title?: string; steps: NewCase["steps"]; reason: string }): Promise<string> {
|
|
219
|
+
const { org, auth } = adoSettings(cfg)
|
|
220
|
+
const { data: item } = await http<{ fields: Record<string, string> }>(`${org}/_apis/wit/workitems/${id}?fields=System.TeamProject,System.WorkItemType&${API}`, { auth })
|
|
221
|
+
if (item.fields["System.WorkItemType"] && item.fields["System.WorkItemType"] !== "Test Case") throw new Error(`Work item ${id} is a ${item.fields["System.WorkItemType"]}, not a Test Case.`)
|
|
222
|
+
const base = `${org}/${encodeURIComponent(item.fields["System.TeamProject"])}`
|
|
223
|
+
const ops: Record<string, unknown>[] = [
|
|
224
|
+
{ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: adoStepsXml(change.steps) },
|
|
225
|
+
{ op: "add", path: "/fields/System.History", value: html([`Updated by ForgeQE: ${change.reason}`]) },
|
|
226
|
+
]
|
|
227
|
+
if (change.title?.trim()) ops.push({ op: "add", path: "/fields/System.Title", value: change.title.trim() })
|
|
228
|
+
await http(`${base}/_apis/wit/workitems/${id}?${API}`, { auth, method: "PATCH", body: ops, contentType: "application/json-patch+json" })
|
|
229
|
+
return `${base}/_workitems/edit/${id}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------------------------
|
|
233
|
+
// Jira
|
|
234
|
+
// ---------------------------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/** Linked issues of these types are the story's work, not its test cases. */
|
|
237
|
+
const NOT_TESTS = new Set(["bug", "defect", "epic", "story", "feature", "improvement", "sub-task", "subtask"])
|
|
238
|
+
|
|
239
|
+
type JiraIssueFields = { summary?: string; description?: unknown; issuetype?: { name?: string } }
|
|
240
|
+
|
|
241
|
+
/** A Jira issue's title, link, description as text, and the steps in it. */
|
|
242
|
+
export async function jiraCase(cfg: SourcesConfig, key: string): Promise<ExistingCase & { text: string; type?: string }> {
|
|
243
|
+
const j = jiraApi(cfg)
|
|
244
|
+
const { data } = await http<{ key: string; fields: JiraIssueFields }>(`${j.api}/issue/${encodeURIComponent(key)}?fields=summary,description,issuetype`, { auth: j.auth })
|
|
245
|
+
const text = typeof data.fields.description === "string" ? data.fields.description : adfToText(data.fields.description)
|
|
246
|
+
return { id: data.key, title: data.fields.summary || data.key, url: `${j.url}/browse/${data.key}`, steps: stepsFromText(text), text, type: data.fields.issuetype?.name }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function jiraStory(cfg: SourcesConfig, storyKey: string) {
|
|
98
250
|
const j = jiraApi(cfg, "Saving test cases in Jira")
|
|
99
|
-
|
|
251
|
+
type Linked = { key: string; fields?: { summary?: string; issuetype?: { name?: string } } }
|
|
252
|
+
const { data: story } = await http<{ key: string; fields: { summary?: string; project?: { key: string }; issuelinks?: { inwardIssue?: Linked; outwardIssue?: Linked }[] } }>(
|
|
100
253
|
`${j.api}/issue/${encodeURIComponent(storyKey)}?fields=summary,project,issuelinks`,
|
|
101
254
|
{ auth: j.auth },
|
|
102
255
|
)
|
|
103
256
|
const link = (key: string) => `${j.url}/browse/${key}`
|
|
104
|
-
const
|
|
257
|
+
const linked = (story.fields.issuelinks || [])
|
|
258
|
+
.flatMap((l) => [l.inwardIssue, l.outwardIssue])
|
|
259
|
+
.filter((i): i is Linked => Boolean(i?.key))
|
|
260
|
+
.filter((i) => !NOT_TESTS.has((i.fields?.issuetype?.name || "").toLowerCase()))
|
|
261
|
+
const cases: ExistingCase[] = []
|
|
262
|
+
for (const i of linked) {
|
|
263
|
+
// The steps live in the description, which links do not carry; a case that cannot be read still counts by its title.
|
|
264
|
+
try {
|
|
265
|
+
const c = await jiraCase(cfg, i.key)
|
|
266
|
+
if (!NOT_TESTS.has((c.type || "").toLowerCase())) cases.push({ id: c.id, title: c.title, url: c.url, steps: c.steps })
|
|
267
|
+
} catch {
|
|
268
|
+
cases.push({ id: i.key, title: i.fields?.summary || i.key, url: link(i.key), steps: [] })
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { j, story, link, cases }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Create each case as a Jira issue (type `issueType`, default Test) in the story's project with its steps in the
|
|
276
|
+
* description, linked to the story. A case matching an issue already linked to the story (see matchCase) is reused.
|
|
277
|
+
*/
|
|
278
|
+
export async function saveJiraCases(cfg: SourcesConfig, storyKey: string, cases: NewCase[], opts: { issueType?: string; linkType?: string; forceNew?: string[] } = {}): Promise<SaveResult> {
|
|
279
|
+
const { j, story, link, cases: existing } = await jiraStory(cfg, storyKey)
|
|
280
|
+
const known = [...existing]
|
|
281
|
+
const force = new Set((opts.forceNew || []).map(stepText))
|
|
105
282
|
const out: SavedCase[] = []
|
|
106
283
|
for (const c of cases) {
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
out.push({ title: c.title, id: found.
|
|
284
|
+
const match = matchCase(c, known, force.has(stepText(c.title)))
|
|
285
|
+
if (match) {
|
|
286
|
+
out.push({ title: c.title, id: match.found.id, url: match.found.url, created: false, status: match.status, ...(match.status === "unchanged" ? {} : { existing: match.found }) })
|
|
110
287
|
continue
|
|
111
288
|
}
|
|
112
|
-
const
|
|
113
|
-
const text = lines.join("\n")
|
|
289
|
+
const text = [...describe(c), "Steps:", ...stepLines(c.steps)].join("\n")
|
|
114
290
|
try {
|
|
115
291
|
const { data } = await http<{ key: string }>(`${j.api}/issue`, {
|
|
116
292
|
auth: j.auth,
|
|
@@ -122,28 +298,45 @@ export async function saveJiraCases(cfg: SourcesConfig, storyKey: string, cases:
|
|
|
122
298
|
} catch (err) {
|
|
123
299
|
error = `created but not linked to ${story.key}: ${(err as Error).message}`
|
|
124
300
|
}
|
|
125
|
-
out.push({ title: c.title, id: data.key, url: link(data.key), created: true, ...(error ? { error } : {}) })
|
|
301
|
+
out.push({ title: c.title, id: data.key, url: link(data.key), created: true, status: "created", ...(error ? { error } : {}) })
|
|
302
|
+
known.push({ id: data.key, title: c.title, url: link(data.key), steps: c.steps })
|
|
126
303
|
} catch (err) {
|
|
127
|
-
out.push({ title: c.title, id: "", url: "", created: false, error: (err as Error).message })
|
|
304
|
+
out.push({ title: c.title, id: "", url: "", created: false, status: "failed", error: (err as Error).message })
|
|
128
305
|
}
|
|
129
306
|
}
|
|
130
|
-
return { system: "jira", story: { id: story.key, title: story.fields.summary || "", url: link(story.key) }, cases: out }
|
|
307
|
+
return { system: "jira", story: { id: story.key, title: story.fields.summary || "", url: link(story.key) }, cases: out, existing }
|
|
131
308
|
}
|
|
132
309
|
|
|
133
310
|
/**
|
|
134
|
-
* Replace
|
|
135
|
-
*
|
|
311
|
+
* Replace the steps in a Jira test issue's description (and its summary, when given), keeping the text above them,
|
|
312
|
+
* and say why in a comment. Jira keeps the old description in the issue's history. Returns its link.
|
|
136
313
|
*/
|
|
137
|
-
export async function
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (change.title?.trim())
|
|
147
|
-
await http(`${
|
|
148
|
-
|
|
314
|
+
export async function updateJiraCase(cfg: SourcesConfig, key: string, change: { title?: string; steps: NewCase["steps"]; reason: string }): Promise<string> {
|
|
315
|
+
const j = jiraApi(cfg, "Updating test cases in Jira")
|
|
316
|
+
const current = await jiraCase(cfg, key)
|
|
317
|
+
const lines = current.text.split(/\r?\n/)
|
|
318
|
+
let cut = lines.findIndex((l) => /^\s*steps:\s*$/i.test(l))
|
|
319
|
+
if (cut < 0) cut = lines.findIndex((l) => /^\s*1[.)]\s+/.test(l))
|
|
320
|
+
const above = (cut < 0 ? lines : lines.slice(0, cut)).join("\n").trimEnd()
|
|
321
|
+
const text = [...(above ? [above, ""] : []), "Steps:", ...stepLines(change.steps)].join("\n")
|
|
322
|
+
const fields: Record<string, unknown> = { description: j.cloud ? textToAdf(text) : text }
|
|
323
|
+
if (change.title?.trim()) fields.summary = change.title.trim().slice(0, 255)
|
|
324
|
+
await http(`${j.api}/issue/${encodeURIComponent(current.id)}`, { auth: j.auth, method: "PUT", body: { fields } })
|
|
325
|
+
const note = `Updated by ForgeQE: ${change.reason}`
|
|
326
|
+
await http(`${j.api}/issue/${encodeURIComponent(current.id)}/comment`, { auth: j.auth, body: { body: j.cloud ? textToAdf(note) : note } })
|
|
327
|
+
return current.url
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------------------------
|
|
331
|
+
// Either system
|
|
332
|
+
// ---------------------------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/** The story and every test case already linked to it, with their steps: read before writing cases for it. */
|
|
335
|
+
export async function storyCases(cfg: SourcesConfig, system: "azure_devops" | "jira", story: string): Promise<StoryCases> {
|
|
336
|
+
if (system === "jira") {
|
|
337
|
+
const { story: s, link, cases } = await jiraStory(cfg, story)
|
|
338
|
+
return { system, story: { id: s.key, title: s.fields.summary || "", url: link(s.key) }, cases }
|
|
339
|
+
}
|
|
340
|
+
const s = await adoStory(cfg, Number(story.replace(/^(#|ADO-)/i, "")))
|
|
341
|
+
return { system, story: s.story, cases: s.cases }
|
|
149
342
|
}
|
package/src/cases/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
1
3
|
import type { CodeStatus, StepVerdict, Usage } from "../types.ts"
|
|
2
4
|
import type { Cost } from "../usage.ts"
|
|
3
5
|
import type { CaseDefect, Reproduced } from "./defects.ts"
|
|
@@ -5,6 +7,15 @@ import type { Failure } from "./failure.ts"
|
|
|
5
7
|
|
|
6
8
|
export type CaseStep = { action: string; data?: string; expected?: string }
|
|
7
9
|
|
|
10
|
+
/** A step's words, ignoring case, spacing, and punctuation: two wordings of the same step compare equal. */
|
|
11
|
+
export const stepText = (s: string | undefined) => (s || "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim()
|
|
12
|
+
|
|
13
|
+
/** One step as a short hash of its action, data, and expected result; the engine compares these to replay unchanged steps. */
|
|
14
|
+
export const stepKey = (s: CaseStep) => createHash("sha1").update(`${stepText(s.action)}|${stepText(s.data)}|${stepText(s.expected)}`).digest("hex").slice(0, 12)
|
|
15
|
+
|
|
16
|
+
/** A case's steps as one hash: it changes when any step is added, removed, reordered, or reworded. */
|
|
17
|
+
export const stepsHash = (steps: CaseStep[]) => createHash("sha1").update(steps.map(stepKey).join(",")).digest("hex").slice(0, 12)
|
|
18
|
+
|
|
8
19
|
export type CaseSource = "text" | "file" | "ado" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "github"
|
|
9
20
|
|
|
10
21
|
/** A test case from any source, in one shape. It becomes one browser goal and, once it passes, one test. */
|
package/src/cli/help.ts
CHANGED
|
@@ -12,6 +12,7 @@ Usage:
|
|
|
12
12
|
forgeqe setup [--force]
|
|
13
13
|
forgeqe models [--config forgeqe.yaml]
|
|
14
14
|
forgeqe features [--config forgeqe.yaml] what ForgeQE can do, and what each capability still needs here
|
|
15
|
+
forgeqe context what each agent loads on every model call, against its budget (exits 1 over budget)
|
|
15
16
|
forgeqe docs [--guide|--open] [--copy DIR] the guides and the architecture page (--guide: the user guide in the browser; --open: the architecture page)
|
|
16
17
|
forgeqe start [--profile name] [--url URL] [--goal TEXT] [--headed] [--replay MODE] [--no-memory]
|
|
17
18
|
[--repo DIR] [--no-code] [--browser-provider local|browserstack|testmu] [--devices a,b]
|
|
@@ -47,6 +48,14 @@ Usage:
|
|
|
47
48
|
forgeqe pr-tests [pr] [--base BRANCH] [--agent] [--run] [--post]
|
|
48
49
|
the tests a pull request (or the commits since BRANCH) can break; --run runs
|
|
49
50
|
them and exits 1 when one fails (a CI gate), --post puts the results on the PR
|
|
51
|
+
forgeqe pack build [--name N] [--sprint S|--iteration PATH|--area A|--parent ID|--work-items 1,2] [--from REF [--to REF] [--range-repo DIR]]
|
|
52
|
+
[--prs 12,13|--merged-days N] [--pr-repo DIR] [--no-risk] [--no-smoke]
|
|
53
|
+
a regression pack: the tests a release's changes can break, each with why, and its gaps
|
|
54
|
+
forgeqe pack run <name> [--on local|testmu|pipeline] [--pipeline github|azure_devops] [--browsers local|testmu] [--yes]
|
|
55
|
+
run a pack (asks first), compare with its last run; exits 1 when a test fails
|
|
56
|
+
forgeqe pack list | show <name> the saved packs, or one pack's tests and gaps
|
|
57
|
+
forgeqe pack pipeline init [--github] [--azure-devops] [--force]
|
|
58
|
+
write the GitHub Actions workflow and Azure DevOps pipeline that run a pack
|
|
50
59
|
forgeqe quality [run-dir] a run's WCAG rules passed and failed, Core Web Vitals, and budgets
|
|
51
60
|
forgeqe quality trends [page] accessibility and timings per page over time: what got worse or better
|
|
52
61
|
forgeqe defects [--folder DIR] defects over the saved suites: still there, gone, came back, time to fix, by area
|
package/src/cli/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { runSuite, suiteExitCode, suiteParallel } from "../suite/run.ts"
|
|
|
34
34
|
import { packageVersion } from "../tui/theme.ts"
|
|
35
35
|
import { update } from "../update.ts"
|
|
36
36
|
import { meterSnapshot, requestLine } from "../usage.ts"
|
|
37
|
+
import { watchBudgets } from "../tokenomics/budget.ts"
|
|
37
38
|
import { boolFlag, configOpts, maxStepsFlag, memoryFlags, parseArgs, repoFlag, skillFlags, strFlag, type Flags } from "./args.ts"
|
|
38
39
|
import { HELP_TEXT } from "./help.ts"
|
|
39
40
|
import { suiteEventLine, terminalAsk, terminalInput, terminalWait, toolPrinter, writeJobSummary } from "./terminal.ts"
|
|
@@ -80,7 +81,11 @@ function debugRequested(flags: Flags): boolean {
|
|
|
80
81
|
/** Every command that runs something writes a session log; a broken config is reported by the command itself. */
|
|
81
82
|
function openSessionLog(command: string, flags: Flags, argv: string[]): void {
|
|
82
83
|
try {
|
|
83
|
-
const
|
|
84
|
+
const cfg = loadConfig(configOpts(flags))
|
|
85
|
+
const file = startSessionLog(cfg, { command, debug: debugRequested(flags), argv })
|
|
86
|
+
// Budgets only warn, on stderr so a command's output stays clean. The shell (start) says them in the conversation;
|
|
87
|
+
// the MCP server's calls are in the ledger, where the shell or command that started it reads them.
|
|
88
|
+
if (cfg.budget && command !== "start" && command !== "mcp") watchBudgets(cfg.budget, (w) => console.error(w.text))
|
|
84
89
|
if (file && debugRequested(flags) && command !== "start") console.error(`Session log: ${file}`)
|
|
85
90
|
} catch {
|
|
86
91
|
// no config yet
|
|
@@ -166,6 +171,13 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
166
171
|
return cmdModels(flags)
|
|
167
172
|
case "docs":
|
|
168
173
|
return cmdDocs(flags)
|
|
174
|
+
case "context": {
|
|
175
|
+
// What each agent carries on every model call, against its budget in src/agents.ts.
|
|
176
|
+
const { agentContexts, contextReport } = await import("../context.ts")
|
|
177
|
+
const rows = agentContexts()
|
|
178
|
+
console.log(contextReport(rows).join("\n"))
|
|
179
|
+
return rows.some((r) => r.over) ? 1 : 0
|
|
180
|
+
}
|
|
169
181
|
case "features":
|
|
170
182
|
// What ForgeQE can do, and whether each capability works with this config and machine.
|
|
171
183
|
console.log(featuresText(loadConfig(configOpts(flags))))
|
|
@@ -240,6 +252,64 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
240
252
|
await TOOLS.find((t) => t.name === "defect_trends")!.run({ folder: str("folder") }, ctx)
|
|
241
253
|
return 0
|
|
242
254
|
}
|
|
255
|
+
case "pack": {
|
|
256
|
+
// forgeqe pack build [--name N] [--sprint S|--work-items 1,2|--parent ID] [--from REF --to REF --range-repo DIR] [--prs 12,13|--merged-days N]
|
|
257
|
+
// forgeqe pack run <name> [--on local|testmu|pipeline] [--yes]; forgeqe pack list; forgeqe pack show <name>.
|
|
258
|
+
const cfg = loadConfig(configOpts(flags))
|
|
259
|
+
const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
|
|
260
|
+
const str = (k: string) => (typeof flags.get(k) === "string" ? String(flags.get(k)) : undefined)
|
|
261
|
+
const csv = (k: string) => (str(k) || "").split(",").map((x) => x.trim()).filter(Boolean)
|
|
262
|
+
const sub = positionals[1] || "list"
|
|
263
|
+
const ask = sub === "run" && process.stdin.isTTY && !boolFlag(flags, "yes") ? terminalAsk(new AbortController().signal) : undefined
|
|
264
|
+
const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)), ...(ask ? { ask } : {}) }
|
|
265
|
+
const tool = (name: string) => TOOLS.find((t) => t.name === name)!
|
|
266
|
+
if (sub === "build") {
|
|
267
|
+
await tool("build_regression_pack").run(
|
|
268
|
+
{
|
|
269
|
+
name: str("name") || positionals[2],
|
|
270
|
+
repo: str("repo"),
|
|
271
|
+
sprint: str("sprint"),
|
|
272
|
+
iteration: str("iteration"),
|
|
273
|
+
area: str("area"),
|
|
274
|
+
parent: str("parent") ? Number(str("parent")) : undefined,
|
|
275
|
+
work_items: csv("work-items").map(Number).filter(Boolean),
|
|
276
|
+
project: str("project"),
|
|
277
|
+
from: str("from"),
|
|
278
|
+
to: str("to"),
|
|
279
|
+
range_repo: str("range-repo"),
|
|
280
|
+
prs: csv("prs"),
|
|
281
|
+
merged_days: str("merged-days") ? Number(str("merged-days")) : undefined,
|
|
282
|
+
pr_repo: str("pr-repo"),
|
|
283
|
+
risk: !boolFlag(flags, "no-risk"),
|
|
284
|
+
smoke: !boolFlag(flags, "no-smoke"),
|
|
285
|
+
},
|
|
286
|
+
ctx,
|
|
287
|
+
)
|
|
288
|
+
return 0
|
|
289
|
+
}
|
|
290
|
+
if (sub === "run") {
|
|
291
|
+
const out = (await tool("run_regression_pack").run({ name: positionals[2] || str("name"), on: str("on") || "local", pipeline: str("pipeline"), browsers: str("browsers"), repo: str("repo"), confirmed: boolFlag(flags, "yes") }, ctx)) as { ran?: boolean; status?: string }
|
|
292
|
+
return out.ran && out.status !== "passed" ? 1 : 0
|
|
293
|
+
}
|
|
294
|
+
if (sub === "pipeline") {
|
|
295
|
+
// forgeqe pack pipeline init [--github] [--azure-devops] [--force]: the pipeline files that run a pack.
|
|
296
|
+
const { writePipelineFiles } = await import("../pack/init.ts")
|
|
297
|
+
const repo = str("repo") ? path.resolve(str("repo")!) : process.cwd()
|
|
298
|
+
const both = !boolFlag(flags, "github") && !boolFlag(flags, "azure-devops")
|
|
299
|
+
for (const line of writePipelineFiles(cfg, repo, { github: both || boolFlag(flags, "github"), azureDevops: both || boolFlag(flags, "azure-devops"), force: boolFlag(flags, "force") })) console.log(line)
|
|
300
|
+
return 0
|
|
301
|
+
}
|
|
302
|
+
if (sub === "show") {
|
|
303
|
+
const { loadPack } = await import("../pack/pack.ts")
|
|
304
|
+
const { packCard } = await import("../pack/index.ts")
|
|
305
|
+
ctx.emit?.({ type: "card", card: packCard(loadPack(str("repo") ? path.resolve(str("repo")!) : process.cwd(), positionals[2] || str("name") || "")) })
|
|
306
|
+
return 0
|
|
307
|
+
}
|
|
308
|
+
const out = (await tool("list_regression_packs").run({ repo: str("repo") }, ctx)) as { packs: { name: string; built: string; tests: number; not_covered: number; last_run?: { at: string; on?: string; status: string } }[] }
|
|
309
|
+
if (!out.packs.length) console.log("No regression packs yet: forgeqe pack build --sprint current (or --from REF, --prs, --merged-days).")
|
|
310
|
+
for (const p of out.packs) console.log(`${p.name} ${p.built} ${p.tests} tests, ${p.not_covered} not covered${p.last_run ? ` · last run ${p.last_run.at} on ${p.last_run.on}: ${p.last_run.status}` : ""}`)
|
|
311
|
+
return 0
|
|
312
|
+
}
|
|
243
313
|
case "logs":
|
|
244
314
|
return cmdLogs(positionals.slice(1), flags)
|
|
245
315
|
case "skills":
|
package/src/code/coverage.ts
CHANGED
|
@@ -19,9 +19,10 @@ import type { Check, Locator, Step } from "./handoff.ts"
|
|
|
19
19
|
export type CoverageItem = { label: string; kind: "step" | "check"; members: string[] }
|
|
20
20
|
|
|
21
21
|
/** An existing test and which of the run's items (by index) it reaches. */
|
|
22
|
-
export type Candidate = { test: string; file: string; line: number; covered: number[]; missing: number[]; share: number }
|
|
22
|
+
export type Candidate = { test: string; file: string; line: number; covered: number[]; missing: number[]; share: number; lines?: number }
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** sibling: a new test method beside an existing one, in its class, reusing its setup and steps. */
|
|
25
|
+
export type Mode = "new" | "extend" | "reuse" | "update" | "sibling"
|
|
25
26
|
|
|
26
27
|
/** `ask`: the evidence fits more than one way, so the person chooses (the options, each with what it changes). */
|
|
27
28
|
export type Decision = { mode: Mode; ask: boolean; target?: Candidate; why: string }
|
|
@@ -32,6 +33,10 @@ export type Coverage = { items: CoverageItem[]; candidates: Candidate[]; decisio
|
|
|
32
33
|
export const EXTEND_FROM = 0.4
|
|
33
34
|
/** Another test this close to the best one makes the choice the person's. */
|
|
34
35
|
const RIVAL = 0.75
|
|
36
|
+
/** A test this long is not made longer: the new steps go in a test beside it, which reuses its setup and steps. */
|
|
37
|
+
export const MAX_EXTEND_LINES = 80
|
|
38
|
+
/** With no one to ask, a test covering at least this share is extended; one covering less gets a test beside it. */
|
|
39
|
+
export const EXTEND_ALONE_FROM = 0.6
|
|
35
40
|
|
|
36
41
|
type RunJson = { final_url?: string; memory?: { dir?: string } }
|
|
37
42
|
|
|
@@ -84,7 +89,7 @@ export function measureCoverage(runDir: string, graph: CodeGraph, memoryDir?: st
|
|
|
84
89
|
// test in its own way, so it is unknown, not missing.
|
|
85
90
|
const measurable = items.flatMap((it, i) => (it.members.length ? [i] : []))
|
|
86
91
|
const candidates = [...byTest.values()]
|
|
87
|
-
.map(({ at, covered }) => ({ test: qualified(at), file: at.file, line: at.member.line, covered: [...covered].sort((a, b) => a - b), missing: measurable.filter((i) => !covered.has(i)), share: measurable.length ? covered.size / measurable.length : 0 }))
|
|
92
|
+
.map(({ at, covered }) => ({ test: qualified(at), file: at.file, line: at.member.line, lines: at.member.endLine - at.member.line + 1, covered: [...covered].sort((a, b) => a - b), missing: measurable.filter((i) => !covered.has(i)), share: measurable.length ? covered.size / measurable.length : 0 }))
|
|
88
93
|
.sort((a, b) => b.covered.length - a.covered.length || a.missing.length - b.missing.length)
|
|
89
94
|
return { items, candidates, decision: decide(items, candidates) }
|
|
90
95
|
}
|
|
@@ -104,6 +109,20 @@ export function decide(items: CoverageItem[], candidates: Candidate[]): Decision
|
|
|
104
109
|
return { mode: "extend", ask: false, target: best, why: `${best.test} covers ${best.covered.length} of ${n}; only ${best.missing.length} ${best.missing.length === 1 ? "is" : "are"} missing` }
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The decision when it is ForgeQE's to make, not the person's: nobody can be asked (a pipeline, a plain run) or the
|
|
114
|
+
* choice came from ForgeQE's agent. Tests stay short: a long test gets a test beside it instead of more steps. Unclear
|
|
115
|
+
* between close tests, the best is extended when it covers most of the flow, else a test is added beside it.
|
|
116
|
+
*/
|
|
117
|
+
export function settle(d: Decision): Decision {
|
|
118
|
+
const t = d.target
|
|
119
|
+
if (!t || d.mode === "new" || d.mode === "update") return { ...d, ask: false }
|
|
120
|
+
if (d.mode === "reuse") return { ...d, ask: false, why: d.ask ? `${d.why}; with no one to ask, the one covering the most is reused` : d.why }
|
|
121
|
+
if (t.lines && t.lines > MAX_EXTEND_LINES) return { ...d, mode: "sibling", ask: false, why: `${d.why}; ${t.test} is already ${t.lines} lines, so the new steps go in a test beside it` }
|
|
122
|
+
if (d.ask && t.share < EXTEND_ALONE_FROM) return { ...d, mode: "sibling", ask: false, why: `${d.why}; with no one to ask and ${t.test} covering ${Math.round(t.share * 100)}%, a test beside it rather than a longer one` }
|
|
123
|
+
return { ...d, ask: false, why: d.ask ? `${d.why}; with no one to ask, the one covering the most (${Math.round(t.share * 100)}%) is extended` : d.why }
|
|
124
|
+
}
|
|
125
|
+
|
|
107
126
|
/** The evidence as card lines: each close test, what it covers, and what it lacks. */
|
|
108
127
|
export function coverageLines(c: Coverage, max = 3): string[] {
|
|
109
128
|
const n = c.items.filter((i) => i.members.length).length
|