@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
package/prompts/test-reviewer.md
CHANGED
|
@@ -24,22 +24,25 @@ A test is approved only when all these conditions are true:
|
|
|
24
24
|
|
|
25
25
|
## Problem types
|
|
26
26
|
- correctness: the test does not do what the goal says, from start to end, or not reliably. Examples are a missing step or check, a weaker assertion, live values in assertions, and ForgeQE files that the test reads.
|
|
27
|
-
- standards: the test passes and does what the goal says, but not in the way of the repository. Examples are test data in a page object, a method that is not generic, a hard wait, and scratch files.
|
|
27
|
+
- standards: the test passes and does what the goal says, but not in the way of the repository. Examples are test data in a page object, a method that is not generic, a hard wait, an XPath or positional locator where a semantic one finds the element, a deprecated API of the installed framework version, and scratch files.
|
|
28
28
|
|
|
29
29
|
## Inputs
|
|
30
30
|
- The message from ForgeQE: the goal, the checks that the browser agent made, and the files that this job changed, with their contents.
|
|
31
|
+
- When the code graph is on: outlines of the classes that the changed code uses, the browser settings of the repository, and the code_* tools (code_outline Class.member gives one member's source).
|
|
31
32
|
- ForgeQE gets those files from a comparison of the working tree with its state before the job.
|
|
32
33
|
- The handoff, and other files of the repository, when a point depends on them.
|
|
33
34
|
|
|
34
35
|
## Rules
|
|
35
36
|
Scope:
|
|
36
37
|
- You MUST review from the changed files. You SHOULD read the handoff or other files only when a point depends on them.
|
|
38
|
+
- You MUST NOT read a file to check a signature, a locator, or a browser setting that the outlines or the settings in the message give. To check a member's body, use code_outline Class.member.
|
|
37
39
|
- You MUST NOT report files that the user changed or added before the job.
|
|
38
40
|
- You MUST NOT report the files of ForgeQE: .forgeqe/, forgeqe.yaml, and llms.json.
|
|
39
41
|
- In a file that the user changed before the job, you MUST review only the edits of this job.
|
|
40
42
|
|
|
41
43
|
Locators and values:
|
|
42
44
|
- You MUST NOT ask to replace locators and texts that the handoff took from its replay script and checks. They matched the live site.
|
|
45
|
+
- When the message lists what the code graph of ForgeQE found, you MUST check each point in the changed files. A new member that locates an element an existing member already locates is a standards problem: the correction is to reuse the existing member. A brittle locator is a standards problem when the handoff gives a ✓ semantic locator for the same element.
|
|
43
46
|
- You MUST accept a value that the test case names, such as a user name. Credentials MUST still come from the environment.
|
|
44
47
|
|
|
45
48
|
Known defects (the handoff has a "Known defect" section):
|
|
@@ -52,7 +55,7 @@ Problems:
|
|
|
52
55
|
- You MUST give all the problems in your first review. The coding agent then corrects them in one round.
|
|
53
56
|
- For each problem, you MUST give the file, what is wrong, the correction, and its type: correctness or standards.
|
|
54
57
|
- You MUST NOT ask for checks, data handling, or behaviour that the test case and the handoff do not ask for.
|
|
55
|
-
- You MUST NOT report style points that the linters and conventions of the repository accept.
|
|
58
|
+
- You MUST NOT report style points that the linters and conventions of the repository accept. Locators, waits, assertions, and deprecated APIs are not style points: judge them by the test rules, also when older tests of the repository do otherwise.
|
|
56
59
|
- You MUST set approved to true when there is no correctness problem, also when there are standards problems. ForgeQE then asks for those corrections in one round.
|
|
57
60
|
- When there is nothing to correct, you MUST approve.
|
|
58
61
|
|
package/prompts/test-rules.md
CHANGED
|
@@ -33,7 +33,11 @@ When the page of the test differs from what ForgeQE saw:
|
|
|
33
33
|
- With blocked, you MUST give what you compared and what you saw, with the path of a screenshot or a trace.
|
|
34
34
|
|
|
35
35
|
## Code standards
|
|
36
|
-
- You MUST follow the conventions of the repository: .editorconfig, analyzers and lint rules, names, folder layout,
|
|
36
|
+
- You MUST follow the conventions of the repository for its structure: .editorconfig, analyzers and lint rules, names, folder layout, base classes, fixtures, the assertion library, and test attributes.
|
|
37
|
+
- You MUST write the code itself with the current best practice of the installed framework version, also when older tests of the repository do not. The test stack in your prompt gives the versions.
|
|
38
|
+
- Locators, in this order of preference: role with accessible name, label, placeholder, text, test id, then CSS. You MUST NOT use XPath, or positions (nth, :nth-child, index) to pick an element, when one of the others finds it. In a framework without role locators, use its nearest semantic one (Testing Library queries, aria/ selectors) or a stable attribute.
|
|
39
|
+
- You MUST assert with the retrying assertions of the framework, and wait for outcomes (an assertion, a URL, a response), never for time. You MUST NOT use sleeps, fixed timeouts, or deprecated APIs of the installed version.
|
|
40
|
+
- You MUST NOT rewrite existing tests to modern style. When the code you reuse is in an older style, say so in notes.
|
|
37
41
|
- The build MUST NOT get more warnings.
|
|
38
42
|
- Page objects, components, and helpers MUST be generic. Each method takes the items that it acts on as parameters, and gives back what the test checks.
|
|
39
43
|
- You MUST NOT put test data, expected values, or URLs with query strings in page objects. You MUST make locators from the parameters.
|
|
@@ -49,11 +53,11 @@ When the page of the test differs from what ForgeQE saw:
|
|
|
49
53
|
## Overlays and page versions
|
|
50
54
|
- The notes of the browser agent tell when a cookie banner, sign-in prompt, promotion, or other overlay came into view, and what the agent did.
|
|
51
55
|
- An overlay can come into view at a different time, or not at all. You MUST handle it where it comes into view.
|
|
52
|
-
- When the handoff lists
|
|
56
|
+
- When the handoff lists one overlay that the run closed, and the framework has an overlay handler (Playwright 1.42 and later: page.addLocatorHandler, Page.AddLocatorHandlerAsync), you MUST register the handler once, in the fixture or the base class setup, with the close control of the handoff.
|
|
57
|
+
- When the handoff lists two or more overlays, or the framework has no overlay handler, you MUST write one helper that closes each visible overlay, in the sequence of the handoff, top one first.
|
|
53
58
|
- For each overlay, the helper MUST check its close control with a plain visibility check, click it, and wait until the overlay is hidden.
|
|
54
59
|
- You MUST call the helper after each navigation, and immediately before each step that opens a thing, such as a date picker or a menu.
|
|
55
|
-
- You MUST NOT use
|
|
56
|
-
- Two overlays at the same time block each other, and strict mode then fails.
|
|
60
|
+
- You MUST NOT use overlay handlers or a combined locator (locator.or) for two or more overlays. They can show at the same time: the click of one handler is blocked by the other overlay, handlers do not run inside a handler, and strict mode fails on a combined locator.
|
|
57
61
|
- You MUST do the flow again, not the tries of the agent.
|
|
58
62
|
- A site can give different versions of a page to different visits (A/B tests). When you see a different version, you MUST write steps that work on each version.
|
|
59
63
|
- You SHOULD find elements by what the versions have in common (role and accessible name). You MUST say in notes which versions you saw.
|
|
@@ -156,7 +156,7 @@ def _browserstack_caps(remote: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
156
156
|
if remote.get("name"):
|
|
157
157
|
caps["name"] = str(remote["name"])
|
|
158
158
|
if remote.get("local") is True:
|
|
159
|
-
caps["browserstack.local"] =
|
|
159
|
+
caps["browserstack.local"] = "true"
|
|
160
160
|
extra = remote.get("extra")
|
|
161
161
|
if isinstance(extra, dict):
|
|
162
162
|
caps.update(extra)
|
|
@@ -26,7 +26,8 @@ agents: [scribe]
|
|
|
26
26
|
- Locators: `Page.GetByRole(AriaRole.Button, new() { Name = "Save" })`, `GetByLabel`, `GetByPlaceholder`, `GetByText`, and `GetByTestId`, then CSS. Avoid XPath and position-based selectors.
|
|
27
27
|
- Assert page state with retrying assertions: `await Expect(locator).ToBeVisibleAsync()`, `ToHaveTextAsync`, `ToHaveValueAsync`, and `await Expect(Page).ToHaveURLAsync(...)`. Use NUnit's `Assert.That` for plain values only, never on `await locator.IsVisibleAsync()`.
|
|
28
28
|
- Wait for outcomes with `Page.WaitForURLAsync`, `Page.RunAndWaitForResponseAsync`, or an assertion.
|
|
29
|
-
-
|
|
29
|
+
- One overlay that appears at random (a cookie banner, a survey): register `Page.AddLocatorHandlerAsync(...)` in `[SetUp]` of the base class (Playwright 1.42 and later) rather than clicking it inside a test.
|
|
30
|
+
- Two or more that can show at once: write one helper that closes each visible one, top first, as the test rules describe. A handler's click is blocked while another overlay covers it.
|
|
30
31
|
|
|
31
32
|
## Data, secrets, attributes
|
|
32
33
|
- Data: `[TestCase(...)]` or `[TestCaseSource(nameof(Cases))]`, or the repository's data files and builders. Compute dates when the test runs.
|
|
@@ -27,7 +27,8 @@ agents: [scribe]
|
|
|
27
27
|
- Wait for outcomes, never time: no `page.waitForTimeout`. Use an assertion, `page.waitForURL`, or a `page.waitForResponse(...)` promise created before the click that triggers the request.
|
|
28
28
|
|
|
29
29
|
## Overlays that come and go
|
|
30
|
-
-
|
|
30
|
+
- One cookie banner, chat prompt, or survey that appears at random: register `page.addLocatorHandler(page.getByRole('button', { name: 'Accept all' }), async (button) => { await button.click() })` in a fixture or `beforeEach` (Playwright 1.42 and later), instead of clicking it inside the test.
|
|
31
|
+
- Two or more that can show at once: write one helper that closes each visible one, top first, as the test rules describe. A handler's click is blocked while another overlay covers it.
|
|
31
32
|
|
|
32
33
|
## Data and secrets
|
|
33
34
|
- Read secrets with `process.env.NAME`, and fail with a clear message when one is missing. If the config loads `dotenv`, add new names to `.env.example` without values.
|
package/src/brain/index.ts
CHANGED
|
@@ -120,7 +120,7 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
|
|
|
120
120
|
const cwd = realpathOr(opts.cwd)
|
|
121
121
|
const context = (): ToolContext => ({ ...opts.context(), config, cwd })
|
|
122
122
|
const tools = localToolServer({ tools: TOOLS, context, instructions: () => environmentNotes(context()), timeoutMs: TOOL_TIMEOUT_MS })
|
|
123
|
-
const external = opts.external || externalMcp(config)
|
|
123
|
+
const external = opts.external || externalMcp(config, Bun.which, process.env, { cwd })
|
|
124
124
|
const notes = [environmentNotes(context()), ...externalNotes(external), ...(opts.notes || [])].filter(Boolean).join("\n")
|
|
125
125
|
const readDirs = [...new Set([config.export.outDir, config.memory.dir, cwd].flatMap((d) => [path.resolve(d), realpathOr(d)]))]
|
|
126
126
|
const onEvent = (e: CodeEvent) => {
|
|
@@ -62,8 +62,12 @@ export const CAPABILITIES: Capability[] = [
|
|
|
62
62
|
title: "Run any goal in a real browser",
|
|
63
63
|
summary:
|
|
64
64
|
"Carries out a plain-language goal in a real browser one step at a time and verifies the outcome, with screenshots, a report, and the steps it took. The browser comes from forgeqe.yaml: local Chrome or Chromium by default, or BrowserStack or TestMu AI/LambdaTest when configured. Page memory replays steps that already worked on a site, so repeat runs are faster and cheaper.",
|
|
65
|
-
tools: ["browser_run", "run_details", "page_memory"],
|
|
66
|
-
examples: [
|
|
65
|
+
tools: ["browser_run", "run_details", "page_memory", "browser_devices"],
|
|
66
|
+
examples: [
|
|
67
|
+
"Go to booking.com and search hotels in Mumbai for next weekend",
|
|
68
|
+
"What does ForgeQE know about the checkout page?",
|
|
69
|
+
"Add Chrome on Windows 11 and Chrome 128 on Windows 10 as TestMu devices, then run SHOP-12 on both",
|
|
70
|
+
],
|
|
67
71
|
requires: [{ kind: "live_model" }],
|
|
68
72
|
limits: ["Web applications only: local Chrome or Chromium, or configured remote web grids such as BrowserStack and TestMu AI/LambdaTest; not native mobile or desktop applications."],
|
|
69
73
|
docs: [
|
|
@@ -258,6 +262,24 @@ export const CAPABILITIES: Capability[] = [
|
|
|
258
262
|
requires: [{ kind: "input", what: "a browser run (from browser_run or run_test_cases); new runs need a live model" }],
|
|
259
263
|
docs: [{ doc: "HOW_TO_USE.md", heading: "Visual comparison" }],
|
|
260
264
|
},
|
|
265
|
+
{
|
|
266
|
+
id: "defect-intelligence",
|
|
267
|
+
area: "Quality",
|
|
268
|
+
title: "Defect intelligence: trends and prediction",
|
|
269
|
+
summary:
|
|
270
|
+
"Groups failures into defects, keeps their history across suites (new, known, gone, back again), and gives defect trends and where a change is likely to cause defects, with the cases to run first.",
|
|
271
|
+
tools: ["defect_trends", "predict_defects"],
|
|
272
|
+
examples: ["Which defects keep coming back?", "What can the changes since main break?"],
|
|
273
|
+
requires: [
|
|
274
|
+
{ kind: "input", what: "suites run into one folder (suite.out_dir)" },
|
|
275
|
+
{ kind: "command", command: "git", optional: true, why: "for predictions from the product's repository" },
|
|
276
|
+
],
|
|
277
|
+
limits: ["Prediction is a heuristic from the saved suites and the change, not a trained model; its accuracy shows once suites have scored it."],
|
|
278
|
+
docs: [
|
|
279
|
+
{ doc: "HOW_TO_USE.md", heading: "Bugs for failures" },
|
|
280
|
+
{ doc: "HOW_TO_USE.md", heading: "Defect trends and prediction" },
|
|
281
|
+
],
|
|
282
|
+
},
|
|
261
283
|
{
|
|
262
284
|
id: "release-readiness",
|
|
263
285
|
area: "Release",
|
|
@@ -318,10 +340,16 @@ export const CAPABILITIES: Capability[] = [
|
|
|
318
340
|
{
|
|
319
341
|
id: "systems",
|
|
320
342
|
area: "Systems",
|
|
321
|
-
title: "Work with Azure DevOps, Jira, and Confluence",
|
|
322
|
-
summary: "Reads anything in the configured systems (work items, issues, queries, boards, pages), makes changes the user asks for, and builds Azure DevOps dashboards and wiki pages.",
|
|
323
|
-
tools: ["query_api", "read_confluence", "create_ado_dashboard", "write_ado_wiki_page"],
|
|
324
|
-
examples: [
|
|
343
|
+
title: "Work with Azure DevOps, Jira, GitHub, and Confluence",
|
|
344
|
+
summary: "Reads anything in the configured systems (work items, issues, queries, boards, pages), makes changes the user asks for, puts run evidence (screenshots, reports) on work items and issues as attachments or comments, and builds Azure DevOps dashboards and wiki pages.",
|
|
345
|
+
tools: ["query_api", "read_confluence", "create_ado_dashboard", "write_ado_wiki_page", "attach_to_work_item"],
|
|
346
|
+
examples: [
|
|
347
|
+
"Show the open bugs in this sprint",
|
|
348
|
+
"Read the checkout requirements from Confluence",
|
|
349
|
+
"Make a dashboard of this sprint's test results",
|
|
350
|
+
"Add the last screenshot of that run to ADO 256 as a comment",
|
|
351
|
+
"Run the GitHub issues labelled login as test cases",
|
|
352
|
+
],
|
|
325
353
|
requires: [{ kind: "system", anyOf: ["azure_devops", "jira", "xray", "confluence"] }],
|
|
326
354
|
docs: [{ doc: "README.md", heading: "Azure DevOps, Jira, and other systems" }],
|
|
327
355
|
},
|
package/src/cases/bugs.ts
CHANGED
|
@@ -6,6 +6,9 @@ import type { BugsConfig, SourcesConfig } from "../config.ts"
|
|
|
6
6
|
import { browserErrors, problemLine } from "../report/data.ts"
|
|
7
7
|
import { ciInfo } from "../report/html.ts"
|
|
8
8
|
import { adoSettings } from "./ado.ts"
|
|
9
|
+
import { DefectBook, type CaseDefect, type DefectCluster } from "./defects.ts"
|
|
10
|
+
import { rcaLines, reproPack, reproText } from "./repro.ts"
|
|
11
|
+
import { githubBugTracker } from "./github.ts"
|
|
9
12
|
import { http, textToAdf } from "./http.ts"
|
|
10
13
|
import { jiraApi, type JiraApi } from "./jira.ts"
|
|
11
14
|
import { stepVerdictLines, type CaseResult, type WriteBack } from "./types.ts"
|
|
@@ -57,35 +60,82 @@ export function bugText(r: CaseResult, runName: string): string {
|
|
|
57
60
|
const failing = (r: CaseResult, b: BugsConfig) =>
|
|
58
61
|
r.failure ? r.failure.type === "functional_defect" : r.status === "failed" || (b.onError && r.status === "error")
|
|
59
62
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
63
|
+
/** A defect from browser evidence holds across runs and cases; a "page" defect (the agent's words) only within a run. */
|
|
64
|
+
const lasting = (d?: CaseDefect): d is CaseDefect => !!d && d.kind !== "page"
|
|
65
|
+
|
|
66
|
+
/** The bug's title: the defect, when cases share it by evidence; else the case. */
|
|
67
|
+
export function defectBugTitle(r: CaseResult, d?: CaseDefect): string {
|
|
68
|
+
return lasting(d) ? `[ForgeQE] ${d.title}`.slice(0, 250) : bugTitle(r)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The bug's description: the defect's evidence first when it has some, then the case that found it, then how to make
|
|
73
|
+
* it happen again (src/cases/repro.ts) when the defect is known.
|
|
74
|
+
*/
|
|
75
|
+
export function defectBugText(r: CaseResult, runName: string, d?: CaseDefect, book?: DefectBook): string {
|
|
76
|
+
const repro = d ? ["", reproText(reproPack(r, d, { env: book?.env, seen: book?.past[d.id]?.seen ?? book?.earlier?.(r.case.id) }))] : []
|
|
77
|
+
if (!lasting(d)) return [bugText(r, runName), ...repro].join("\n")
|
|
78
|
+
return [`Defect: ${d.title}`, ...d.evidence.map((e) => `- ${e}`), "", "Every test case that fails this way is added to this bug as a comment.", "", "First found by:", bugText(r, runName), ...repro].join("\n")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The root-cause analysis of each defect added to its bug as a comment, once the suite has it. Only bugs filed or
|
|
83
|
+
* found in this run, and only analyses with a hypothesis.
|
|
84
|
+
*/
|
|
85
|
+
export async function commentAnalyses(cfg: SourcesConfig, book: DefectBook, clusters: DefectCluster[]): Promise<WriteBack[]> {
|
|
86
|
+
const b = cfg.bugs
|
|
87
|
+
const todo = clusters.filter((c) => c.rca?.hypothesis && book.bugs.has(c.id))
|
|
88
|
+
if (!b || !todo.length) return []
|
|
89
|
+
let tracker: Tracker
|
|
90
|
+
try {
|
|
91
|
+
tracker = b.system === "jira" ? jiraTracker(cfg, b) : b.system === "github" ? githubBugTracker(cfg, b) : adoTracker(cfg, b)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return [{ source: "bugs", text: "Analyses not added to bugs", error: (err as Error).message }]
|
|
94
|
+
}
|
|
95
|
+
const out: WriteBack[] = []
|
|
96
|
+
for (const c of todo) {
|
|
97
|
+
const bug = book.bugs.get(c.id)!
|
|
98
|
+
try {
|
|
99
|
+
await tracker.comment(bug.id, ["Root-cause analysis by ForgeQE: a hypothesis to check, not a verdict.", "", ...rcaLines(c.rca!)].join("\n"))
|
|
100
|
+
out.push({ source: "bugs", caseId: bug.caseId, text: `${bug.label}: root-cause analysis added`, url: bug.url })
|
|
101
|
+
} catch (err) {
|
|
102
|
+
out.push({ source: "bugs", caseId: bug.caseId, text: `${bug.label}: analysis not added`, error: (err as Error).message })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* File a bug for each defect (cases failing the same way share one: src/cases/defects.ts), or for each failing case
|
|
110
|
+
* when bugs.group_by is case. A case joining a defect's bug is added as a comment and tagged with its own fingerprint,
|
|
111
|
+
* so the note when it passes again finds that bug. Notes on the open bug when a case passes again.
|
|
112
|
+
*/
|
|
113
|
+
export async function fileBugs(results: CaseResult[], cfg: SourcesConfig, runName: string, book = new DefectBook()): Promise<WriteBack[]> {
|
|
62
114
|
const b = cfg.bugs
|
|
63
115
|
if (!b) return []
|
|
64
116
|
const relevant = results.filter((r) => failing(r, b) || r.status === "passed")
|
|
65
117
|
if (!relevant.length) return []
|
|
66
118
|
let tracker: Tracker
|
|
67
119
|
try {
|
|
68
|
-
tracker = b.system === "jira" ? jiraTracker(cfg, b) : adoTracker(cfg, b)
|
|
120
|
+
tracker = b.system === "jira" ? jiraTracker(cfg, b) : b.system === "github" ? githubBugTracker(cfg, b) : adoTracker(cfg, b)
|
|
69
121
|
} catch (err) {
|
|
70
122
|
return [{ source: "bugs", text: "Bugs not filed", error: (err as Error).message }]
|
|
71
123
|
}
|
|
72
124
|
const out: WriteBack[] = []
|
|
73
125
|
for (const r of relevant) {
|
|
74
126
|
const fp = bugFingerprint(r)
|
|
127
|
+
// A bug filed before ForgeQE was renamed from WebPilot carries the same hash under the old prefix.
|
|
128
|
+
const byCase = async () => (await tracker.findOpen(fp)) ?? (await tracker.findOpen(fp.replace(/^forgeqe-/, "webpilot-")))
|
|
75
129
|
try {
|
|
76
|
-
// A bug filed before ForgeQE was renamed from WebPilot carries the same hash under the old prefix.
|
|
77
|
-
const open = (await tracker.findOpen(fp)) ?? (await tracker.findOpen(fp.replace(/^forgeqe-/, "webpilot-")))
|
|
78
130
|
if (r.status === "passed") {
|
|
131
|
+
const open = await byCase()
|
|
79
132
|
if (!open) continue
|
|
80
133
|
await tracker.comment(open.id, `ForgeQE: ${r.case.id} passed again in "${runName}".`)
|
|
81
134
|
out.push({ source: "bugs", caseId: r.case.id, text: `${open.label}: noted that ${r.case.id} passes again`, url: open.url })
|
|
82
|
-
|
|
83
|
-
await tracker.comment(open.id, `Still failing.\n\n${bugText(r, runName)}`)
|
|
84
|
-
out.push({ source: "bugs", caseId: r.case.id, text: `${open.label}: still failing, comment added`, url: open.url })
|
|
85
|
-
} else {
|
|
86
|
-
const bug = await tracker.create(r, fp, runName)
|
|
87
|
-
out.push({ source: "bugs", caseId: r.case.id, text: `${bug.label}: bug filed for ${r.case.id}${bug.note ? ` (${bug.note})` : ""}`, url: bug.url })
|
|
135
|
+
continue
|
|
88
136
|
}
|
|
137
|
+
const d = b.groupBy === "case" ? undefined : (r.defect ?? book.assign(r))
|
|
138
|
+
out.push(await book.serial(() => fileOne(tracker, b, r, fp, d, runName, book, byCase)))
|
|
89
139
|
} catch (err) {
|
|
90
140
|
out.push({ source: "bugs", caseId: r.case.id, text: `${r.case.id}: bug not filed`, error: (err as Error).message })
|
|
91
141
|
}
|
|
@@ -93,12 +143,91 @@ export async function fileBugs(results: CaseResult[], cfg: SourcesConfig, runNam
|
|
|
93
143
|
return out
|
|
94
144
|
}
|
|
95
145
|
|
|
96
|
-
|
|
146
|
+
async function fileOne(tracker: Tracker, b: BugsConfig, r: CaseResult, fp: string, d: CaseDefect | undefined, runName: string, book: DefectBook, byCase: () => Promise<BugRef | null>): Promise<WriteBack> {
|
|
147
|
+
const id = r.case.id
|
|
148
|
+
// Another case of this defect filed or found its bug earlier in the run: this case joins it.
|
|
149
|
+
const shared = d && book.bugs.get(d.id)
|
|
150
|
+
if (shared) {
|
|
151
|
+
await tracker.comment(shared.id, `Also fails: ${id}.\n\n${bugText(r, runName)}`)
|
|
152
|
+
const tagged = await tag(tracker, shared.id, fp)
|
|
153
|
+
return { source: "bugs", caseId: id, text: `${shared.label}: same defect as ${shared.caseId}, ${id} added${tagged}`, url: shared.url }
|
|
154
|
+
}
|
|
155
|
+
const byDefect = lasting(d) ? await tracker.findOpen(d.id) : null
|
|
156
|
+
const open = byDefect ?? (await byCase())
|
|
157
|
+
const gone = d && book.past[d.id]?.goneSince
|
|
158
|
+
if (open) {
|
|
159
|
+
const head = gone ? `Failing again after it went away on ${gone.slice(0, 10)}` : "Still failing"
|
|
160
|
+
await tracker.comment(open.id, `${head}${byDefect ? `: ${id}` : ""}.\n\n${bugText(r, runName)}`)
|
|
161
|
+
// Found by one fingerprint, it gets the other, so the next run finds it either way.
|
|
162
|
+
const tagged = await tag(tracker, open.id, byDefect ? fp : lasting(d) ? d.id : "")
|
|
163
|
+
if (d) book.bugs.set(d.id, { ...open, caseId: id })
|
|
164
|
+
return { source: "bugs", caseId: id, text: `${open.label}: still failing, comment added${tagged}`, url: open.url }
|
|
165
|
+
}
|
|
166
|
+
// No open bug: a closed one with either fingerprint means it was fixed and came back.
|
|
167
|
+
const closed = (lasting(d) ? await tracker.findClosed(d.id) : null) ?? (await tracker.findClosed(fp))
|
|
168
|
+
const labels = [...new Set([...b.labels, ...(lasting(d) ? [d.id] : []), fp])]
|
|
169
|
+
if (closed) {
|
|
170
|
+
const when = closed.closedAt ? ` on ${closed.closedAt.slice(0, 10)}` : ""
|
|
171
|
+
const body = [`Regression: this came back. ${closed.label} (${closed.url}) was closed${when}.`, "", defectBugText(r, runName, d, book)].join("\n")
|
|
172
|
+
const bug = await tracker.create(r, { title: `[ForgeQE] Regression: ${defectBugTitle(r, d).replace(/^\[ForgeQE\] /, "")}`.slice(0, 250), body, labels, regressionOf: closed }, runName)
|
|
173
|
+
if (d) {
|
|
174
|
+
book.bugs.set(d.id, { ...bug, caseId: id })
|
|
175
|
+
book.closed.set(d.id, { label: closed.label, url: closed.url, ...(closed.closedAt ? { closedAt: closed.closedAt } : {}) })
|
|
176
|
+
}
|
|
177
|
+
return { source: "bugs", caseId: id, text: `${bug.label}: regression of ${closed.label}, bug filed for ${id}${bug.note ? ` (${bug.note})` : ""}`, url: bug.url }
|
|
178
|
+
}
|
|
179
|
+
// Bugs people filed by hand about the same route or error: named on the new bug, never merged.
|
|
180
|
+
const related = lasting(d) && d.term ? await relatedBugs(tracker, d.term) : []
|
|
181
|
+
const body = defectBugText(r, runName, d, book) + (related.length ? `\n\nPossibly related open bugs (they mention ${d!.term}):\n${related.map((x) => `- ${x.label}: ${x.title} (${x.url})`).join("\n")}` : "")
|
|
182
|
+
const bug = await tracker.create(r, { title: defectBugTitle(r, d), body, labels }, runName)
|
|
183
|
+
if (d) {
|
|
184
|
+
book.bugs.set(d.id, { ...bug, caseId: id })
|
|
185
|
+
if (related.length) book.related.set(d.id, related.map((x) => ({ label: x.label, url: x.url, title: x.title })))
|
|
186
|
+
}
|
|
187
|
+
const also = related.length ? `, ${related.length} possibly related` : ""
|
|
188
|
+
return { source: "bugs", caseId: id, text: `${bug.label}: bug filed for ${id}${bug.note || also ? ` (${[bug.note, also.slice(2)].filter(Boolean).join(", ")})` : ""}`, url: bug.url }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The search for related bugs is a hint: a tracker that cannot run it files the bug without one. */
|
|
192
|
+
async function relatedBugs(tracker: Tracker, term: string): Promise<FoundBug[]> {
|
|
193
|
+
try {
|
|
194
|
+
return (await tracker.related(term, 3)).filter((x) => !/^\[(ForgeQE|WebPilot)\]/.test(x.title))
|
|
195
|
+
} catch {
|
|
196
|
+
return []
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Add a label or tag; a tracker that refuses only loses the note, not the comment. */
|
|
201
|
+
async function tag(tracker: Tracker, id: string, label: string): Promise<string> {
|
|
202
|
+
if (!label) return ""
|
|
203
|
+
try {
|
|
204
|
+
await tracker.tag(id, label)
|
|
205
|
+
return ""
|
|
206
|
+
} catch {
|
|
207
|
+
return ` (not tagged ${label})`
|
|
208
|
+
}
|
|
209
|
+
}
|
|
97
210
|
|
|
98
|
-
type
|
|
211
|
+
export type BugRef = { id: string; label: string; url: string; note?: string }
|
|
212
|
+
|
|
213
|
+
/** A closed bug, and when it was closed when the tracker says. */
|
|
214
|
+
export type ClosedBug = BugRef & { closedAt?: string }
|
|
215
|
+
|
|
216
|
+
/** An open bug found by its text, with its title. */
|
|
217
|
+
export type FoundBug = BugRef & { title: string }
|
|
218
|
+
|
|
219
|
+
/** The bug's title, description, and labels (or tags): its fingerprints among them. `regressionOf`: the closed bug it came back from, linked. */
|
|
220
|
+
export type NewBug = { title: string; body: string; labels: string[]; regressionOf?: ClosedBug }
|
|
221
|
+
|
|
222
|
+
export type Tracker = {
|
|
99
223
|
findOpen(fingerprint: string): Promise<BugRef | null>
|
|
100
|
-
|
|
224
|
+
/** The most recently closed bug with this label or tag. */
|
|
225
|
+
findClosed(fingerprint: string): Promise<ClosedBug | null>
|
|
226
|
+
/** Open bugs whose text mentions `term`, newest first. */
|
|
227
|
+
related(term: string, limit: number): Promise<FoundBug[]>
|
|
228
|
+
create(r: CaseResult, bug: NewBug, runName: string): Promise<BugRef>
|
|
101
229
|
comment(id: string, text: string): Promise<void>
|
|
230
|
+
tag(id: string, label: string): Promise<void>
|
|
102
231
|
}
|
|
103
232
|
|
|
104
233
|
function screenshot(r: CaseResult): { name: string; data: Buffer } | null {
|
|
@@ -120,16 +249,29 @@ function jiraTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
120
249
|
const hit = data?.issues?.[0]
|
|
121
250
|
return hit ? ref(hit.key) : null
|
|
122
251
|
},
|
|
123
|
-
async
|
|
252
|
+
async findClosed(fp) {
|
|
253
|
+
const jql = `project = "${b.project}" AND labels = "${fp}" AND statusCategory = Done ORDER BY updated DESC`
|
|
254
|
+
const q = new URLSearchParams({ jql, fields: "summary,resolutiondate", maxResults: "1" })
|
|
255
|
+
const { data } = await http<{ issues?: { key: string; fields?: { resolutiondate?: string | null } }[] }>(`${j.api}/${j.cloud ? "search/jql" : "search"}?${q}`, { auth: j.auth })
|
|
256
|
+
const hit = data?.issues?.[0]
|
|
257
|
+
return hit ? { ...ref(hit.key), ...(hit.fields?.resolutiondate ? { closedAt: hit.fields.resolutiondate } : {}) } : null
|
|
258
|
+
},
|
|
259
|
+
async related(term, limit) {
|
|
260
|
+
const jql = `project = "${b.project}" AND statusCategory != Done AND text ~ "\\"${term.replace(/["\\]/g, " ")}\\"" ORDER BY updated DESC`
|
|
261
|
+
const q = new URLSearchParams({ jql, fields: "summary", maxResults: String(limit) })
|
|
262
|
+
const { data } = await http<{ issues?: { key: string; fields?: { summary?: string } }[] }>(`${j.api}/${j.cloud ? "search/jql" : "search"}?${q}`, { auth: j.auth })
|
|
263
|
+
return (data?.issues || []).map((i) => ({ ...ref(i.key), title: i.fields?.summary || "" }))
|
|
264
|
+
},
|
|
265
|
+
async create(r, bug) {
|
|
124
266
|
const { data } = await http<{ key: string; id: string }>(`${j.api}/issue`, {
|
|
125
267
|
auth: j.auth,
|
|
126
268
|
body: {
|
|
127
269
|
fields: {
|
|
128
270
|
project: { key: b.project },
|
|
129
271
|
issuetype: { name: b.type },
|
|
130
|
-
summary:
|
|
131
|
-
description: body(
|
|
132
|
-
labels:
|
|
272
|
+
summary: bug.title,
|
|
273
|
+
description: body(bug.body),
|
|
274
|
+
labels: bug.labels,
|
|
133
275
|
},
|
|
134
276
|
},
|
|
135
277
|
})
|
|
@@ -145,6 +287,14 @@ function jiraTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
145
287
|
})
|
|
146
288
|
notes.push(res.ok ? "screenshot attached" : `screenshot not attached: ${res.status}`)
|
|
147
289
|
}
|
|
290
|
+
if (bug.regressionOf) {
|
|
291
|
+
try {
|
|
292
|
+
await http(`${j.api}/issueLink`, { auth: j.auth, body: { type: { name: "Relates" }, inwardIssue: { key: data.key }, outwardIssue: { key: bug.regressionOf.id } } })
|
|
293
|
+
notes.push(`linked to ${bug.regressionOf.label}`)
|
|
294
|
+
} catch {
|
|
295
|
+
notes.push(`not linked to ${bug.regressionOf.label}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
148
298
|
const linked = r.case.source === "jira" || r.case.source === "xray" ? String(r.case.ref?.issue || r.case.ref?.test || r.case.id) : ""
|
|
149
299
|
if (linked) {
|
|
150
300
|
try {
|
|
@@ -159,6 +309,9 @@ function jiraTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
159
309
|
async comment(id, text) {
|
|
160
310
|
await http(`${j.api}/issue/${encodeURIComponent(id)}/comment`, { auth: j.auth, body: { body: body(text) } })
|
|
161
311
|
},
|
|
312
|
+
async tag(id, label) {
|
|
313
|
+
await http(`${j.api}/issue/${encodeURIComponent(id)}`, { method: "PUT", auth: j.auth, body: { update: { labels: [{ add: label }] } } })
|
|
314
|
+
},
|
|
162
315
|
}
|
|
163
316
|
}
|
|
164
317
|
|
|
@@ -181,12 +334,35 @@ function adoTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
181
334
|
const hit = data?.workItems?.[0]
|
|
182
335
|
return hit ? ref(hit.id) : null
|
|
183
336
|
},
|
|
184
|
-
async
|
|
337
|
+
async findClosed(fp) {
|
|
338
|
+
const query =
|
|
339
|
+
`SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = '${b.type.replace(/'/g, "''")}' ` +
|
|
340
|
+
`AND [System.Tags] CONTAINS '${fp}' AND [System.State] IN ('Closed', 'Done', 'Resolved') ORDER BY [System.ChangedDate] DESC`
|
|
341
|
+
const { data } = await http<{ workItems?: { id: number }[] }>(`${base}/_apis/wit/wiql?${api}&$top=1`, { auth, body: { query } })
|
|
342
|
+
const hit = data?.workItems?.[0]
|
|
343
|
+
if (!hit) return null
|
|
344
|
+
// Items created closed (or moved there by a rule) have no ClosedDate: when their state last changed is when they closed.
|
|
345
|
+
const dates = ["Microsoft.VSTS.Common.ClosedDate", "Microsoft.VSTS.Common.ResolvedDate", "Microsoft.VSTS.Common.StateChangeDate"]
|
|
346
|
+
const { data: item } = await http<{ fields?: Record<string, string> }>(`${base}/_apis/wit/workitems/${hit.id}?fields=${dates.join(",")}&${api}`, { auth })
|
|
347
|
+
const at = dates.map((f) => item?.fields?.[f]).find(Boolean)
|
|
348
|
+
return { ...ref(hit.id), ...(at ? { closedAt: at } : {}) }
|
|
349
|
+
},
|
|
350
|
+
async related(term, limit) {
|
|
351
|
+
const query =
|
|
352
|
+
`SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = '${b.type.replace(/'/g, "''")}' ` +
|
|
353
|
+
`AND [System.State] NOT IN ('Closed', 'Done', 'Removed', 'Resolved') AND ([System.Title] CONTAINS '${term.replace(/'/g, "''")}' OR [Microsoft.VSTS.TCM.ReproSteps] CONTAINS WORDS '${term.replace(/'/g, "''")}') ORDER BY [System.ChangedDate] DESC`
|
|
354
|
+
const { data } = await http<{ workItems?: { id: number }[] }>(`${base}/_apis/wit/wiql?${api}&$top=${limit}`, { auth, body: { query } })
|
|
355
|
+
const ids = (data?.workItems || []).slice(0, limit).map((w) => w.id)
|
|
356
|
+
if (!ids.length) return []
|
|
357
|
+
const { data: items } = await http<{ value?: { id: number; fields?: Record<string, string> }[] }>(`${base}/_apis/wit/workitems?ids=${ids.join(",")}&fields=System.Title&${api}`, { auth })
|
|
358
|
+
return (items?.value || []).map((w) => ({ ...ref(w.id), title: w.fields?.["System.Title"] || "" }))
|
|
359
|
+
},
|
|
360
|
+
async create(r, bug) {
|
|
185
361
|
const notes: string[] = []
|
|
186
362
|
const ops: Record<string, unknown>[] = [
|
|
187
|
-
{ op: "add", path: "/fields/System.Title", value:
|
|
188
|
-
{ op: "add", path: "/fields/Microsoft.VSTS.TCM.ReproSteps", value: html(
|
|
189
|
-
{ op: "add", path: "/fields/System.Tags", value:
|
|
363
|
+
{ op: "add", path: "/fields/System.Title", value: bug.title },
|
|
364
|
+
{ op: "add", path: "/fields/Microsoft.VSTS.TCM.ReproSteps", value: html(bug.body) },
|
|
365
|
+
{ op: "add", path: "/fields/System.Tags", value: bug.labels.join("; ") },
|
|
190
366
|
]
|
|
191
367
|
if (b.areaPath) ops.push({ op: "add", path: "/fields/System.AreaPath", value: b.areaPath })
|
|
192
368
|
const testCase = r.case.source === "ado" ? Number(r.case.ref?.workItem) : 0
|
|
@@ -194,6 +370,10 @@ function adoTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
194
370
|
ops.push({ op: "add", path: "/relations/-", value: { rel: "Microsoft.VSTS.Common.TestedBy-Forward", url: `${ado.orgUrl}/_apis/wit/workItems/${testCase}` } })
|
|
195
371
|
notes.push(`linked to test case ${testCase}`)
|
|
196
372
|
}
|
|
373
|
+
if (bug.regressionOf) {
|
|
374
|
+
ops.push({ op: "add", path: "/relations/-", value: { rel: "System.LinkTypes.Related", url: `${ado.orgUrl}/_apis/wit/workItems/${bug.regressionOf.id}`, attributes: { comment: "Regression of" } } })
|
|
375
|
+
notes.push(`linked to ${bug.regressionOf.label}`)
|
|
376
|
+
}
|
|
197
377
|
const shot = screenshot(r)
|
|
198
378
|
if (shot) {
|
|
199
379
|
const res = await fetch(`${base}/_apis/wit/attachments?fileName=${encodeURIComponent(shot.name)}&${api}`, {
|
|
@@ -219,5 +399,12 @@ function adoTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
219
399
|
async comment(id, text) {
|
|
220
400
|
await http(`${base}/_apis/wit/workItems/${id}/comments?api-version=7.1-preview.4`, { auth, body: { text: html(text) } })
|
|
221
401
|
},
|
|
402
|
+
async tag(id, label) {
|
|
403
|
+
// Setting System.Tags replaces them all: read the ones it has first.
|
|
404
|
+
const { data } = await http<{ fields?: Record<string, string> }>(`${base}/_apis/wit/workitems/${id}?fields=System.Tags&${api}`, { auth })
|
|
405
|
+
const tags = (data?.fields?.["System.Tags"] || "").split(";").map((t) => t.trim()).filter(Boolean)
|
|
406
|
+
if (tags.includes(label)) return
|
|
407
|
+
await http(`${base}/_apis/wit/workitems/${id}?${api}`, { method: "PATCH", auth, contentType: "application/json-patch+json", body: [{ op: "add", path: "/fields/System.Tags", value: [...tags, label].join("; ") }] })
|
|
408
|
+
},
|
|
222
409
|
}
|
|
223
410
|
}
|