@capagents/forgeqe 0.0.0-stage → 1.0.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 +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +781 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +146 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
package/src/code/find.ts
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { XMLParser } from "fast-xml-parser"
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import type { CodeEvent, Usage } from "../types.ts"
|
|
6
|
+
import type { Card } from "../card.ts"
|
|
7
|
+
import { privateEnvNames } from "../childEnv.ts"
|
|
8
|
+
import type { AppConfig, FlakyConfig } from "../config.ts"
|
|
9
|
+
import { runDirectory } from "../run.ts"
|
|
10
|
+
import { openSession, type AgentSpec, type OpencodeSession } from "./runtime.ts"
|
|
11
|
+
import { runTestCommand, type TestRun } from "./testrun.ts"
|
|
12
|
+
import { CAUSE_TEXT, flakyReport, quarantined, recordResults, RELEASE_AFTER, setQuarantine, type Flakiness } from "./history.ts"
|
|
13
|
+
import { loadSkills } from "../skills/registry.ts"
|
|
14
|
+
import { prompt } from "../prompts.ts"
|
|
15
|
+
import { domainNotes } from "../domain.ts"
|
|
16
|
+
|
|
17
|
+
const LIST_ROUNDS = 3
|
|
18
|
+
const LIST_TIMEOUT_SECONDS = 180
|
|
19
|
+
const OUTPUT_CHARS = 6_000
|
|
20
|
+
|
|
21
|
+
export const findAgentPrompt = () => prompt("find-tests")
|
|
22
|
+
|
|
23
|
+
export const FIND_AGENT: AgentSpec = {
|
|
24
|
+
name: "forgeqe-find",
|
|
25
|
+
description: "Finds the repository's automated tests that match a request, without changing or running anything",
|
|
26
|
+
get prompt() {
|
|
27
|
+
return findAgentPrompt()
|
|
28
|
+
},
|
|
29
|
+
permission: {
|
|
30
|
+
edit: "deny",
|
|
31
|
+
bash: {
|
|
32
|
+
"*": "deny",
|
|
33
|
+
"git log*": "allow",
|
|
34
|
+
"git show*": "allow",
|
|
35
|
+
"git diff*": "allow",
|
|
36
|
+
"git blame*": "allow",
|
|
37
|
+
"git ls-files*": "allow",
|
|
38
|
+
"git rev-parse*": "allow",
|
|
39
|
+
"git status*": "allow",
|
|
40
|
+
"ls*": "allow",
|
|
41
|
+
},
|
|
42
|
+
webfetch: "deny",
|
|
43
|
+
task: "deny",
|
|
44
|
+
},
|
|
45
|
+
skills: "scribe",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const TEST_ITEM = {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
name: { type: "string", description: "The test's full name as the framework reports it, e.g. 'login › signs in with a valid user' or 'tests/test_login.py::test_valid_user'" },
|
|
52
|
+
file: { type: "string", description: "Path relative to the repository root" },
|
|
53
|
+
reason: { type: "string", description: "Why it matches (or, under related, why it was left out), from what the test does" },
|
|
54
|
+
added: { type: "string", description: "For a time range: the date and commit that added it" },
|
|
55
|
+
},
|
|
56
|
+
required: ["name", "reason"],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const FIND_SCHEMA = {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {
|
|
62
|
+
framework: { type: "string", description: "The test framework(s), e.g. 'Playwright (TypeScript)'" },
|
|
63
|
+
tests: { type: "array", items: TEST_ITEM, description: "The tests that match" },
|
|
64
|
+
related: { type: "array", items: TEST_ITEM, description: "Near misses left out, with the reason" },
|
|
65
|
+
list_command: { type: "string", description: "Lists exactly the selected tests without running them; empty when the framework cannot list" },
|
|
66
|
+
run_command: { type: "string", description: "Runs exactly the selected tests and writes JUnit XML or TRX results" },
|
|
67
|
+
results_file: { type: "string", description: "Where run_command writes the results (file or folder), when not the path ForgeQE gave" },
|
|
68
|
+
list_matches: { type: "boolean", description: "After ForgeQE listed the tests: true when the listing is exactly the selection" },
|
|
69
|
+
notes: { type: "string", description: "What was searched, and anything the user should know" },
|
|
70
|
+
},
|
|
71
|
+
required: ["framework", "tests", "run_command", "notes"],
|
|
72
|
+
} as const
|
|
73
|
+
|
|
74
|
+
export type FoundTest = { name: string; file?: string; reason: string; added?: string }
|
|
75
|
+
|
|
76
|
+
type FindReport = {
|
|
77
|
+
framework?: string
|
|
78
|
+
tests?: FoundTest[]
|
|
79
|
+
related?: FoundTest[]
|
|
80
|
+
list_command?: string
|
|
81
|
+
run_command?: string
|
|
82
|
+
results_file?: string
|
|
83
|
+
list_matches?: boolean
|
|
84
|
+
notes?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type TestRange = { since?: string; until?: string; label?: string }
|
|
88
|
+
|
|
89
|
+
export type TestSelection = {
|
|
90
|
+
id: string
|
|
91
|
+
dir: string
|
|
92
|
+
repo: string
|
|
93
|
+
request: string
|
|
94
|
+
range?: TestRange
|
|
95
|
+
framework: string
|
|
96
|
+
tests: FoundTest[]
|
|
97
|
+
related: FoundTest[]
|
|
98
|
+
listCommand: string
|
|
99
|
+
/** ForgeQE ran listCommand and Scribe confirmed it lists exactly the selected tests. */
|
|
100
|
+
listed: boolean
|
|
101
|
+
runCommand: string
|
|
102
|
+
/** Where ForgeQE looks for JUnit XML or TRX after running. */
|
|
103
|
+
results: string[]
|
|
104
|
+
notes: string
|
|
105
|
+
model: string
|
|
106
|
+
usage: Usage
|
|
107
|
+
createdAt: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const clean = (tests: unknown): FoundTest[] =>
|
|
111
|
+
(Array.isArray(tests) ? tests : [])
|
|
112
|
+
.filter((t) => t && typeof t.name === "string" && t.name.trim())
|
|
113
|
+
.map((t) => ({ name: String(t.name).trim(), file: t.file ? String(t.file) : undefined, reason: String(t.reason || ""), added: t.added ? String(t.added) : undefined }))
|
|
114
|
+
|
|
115
|
+
const tailOf = (s: string) => (s.length > OUTPUT_CHARS ? `…${s.slice(-OUTPUT_CHARS)}` : s)
|
|
116
|
+
|
|
117
|
+
function rangeText(r?: TestRange): string {
|
|
118
|
+
if (!r || (!r.since && !r.until)) return ""
|
|
119
|
+
const span = r.since && r.until ? `between ${r.since} and ${r.until}` : r.since ? `since ${r.since}` : `until ${r.until}`
|
|
120
|
+
return `Only tests added ${span}${r.label ? ` (${r.label})` : ""}, both days included.`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function findPrompt(opts: { request: string; range?: TestRange; junit: string; trx: string }): string {
|
|
124
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
125
|
+
const shell = process.platform === "win32" ? "cmd.exe" : "sh"
|
|
126
|
+
return [
|
|
127
|
+
`Find the tests for this request: ${opts.request}`,
|
|
128
|
+
rangeText(opts.range),
|
|
129
|
+
`Today is ${today}. Commands run in ${shell}.`,
|
|
130
|
+
`Write JUnit XML results to ${opts.junit}, or TRX (dotnet test) to ${opts.trx}.`,
|
|
131
|
+
]
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
.join("\n")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function listPrompt(report: FindReport, run: TestRun): string {
|
|
137
|
+
const n = report.tests?.length || 0
|
|
138
|
+
return [
|
|
139
|
+
`ForgeQE ran the list command from the repository root: ${report.list_command}`,
|
|
140
|
+
`${run.exitCode === null ? "It timed out or was stopped" : `Exit code ${run.exitCode}`} after ${Math.round(run.ms / 1000)}s. End of the output:`,
|
|
141
|
+
"```",
|
|
142
|
+
tailOf(run.output) || "(no output)",
|
|
143
|
+
"```",
|
|
144
|
+
`Compare the listing with your ${n} selected test${n === 1 ? "" : "s"}. If it lists exactly them, give the same result with list_matches true.`,
|
|
145
|
+
"If it lists tests you did not select, misses some, or failed, fix the commands (or the selection, when the listing shows you misjudged a test) and give the result with list_matches false; ForgeQE lists again.",
|
|
146
|
+
].join("\n")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sameTests = (a: FoundTest[], b: FoundTest[]) => a.length === b.length && a.every((t, i) => t.name === b[i].name)
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A read-only Scribe session picks the tests that match the request by reading them (and git history for a time range)
|
|
153
|
+
* and gives a list and a run command. ForgeQE runs the list command and Scribe checks the listing is exactly its selection.
|
|
154
|
+
* The selection is saved as selection.json in a new folder under <export.outDir>/tests.
|
|
155
|
+
*/
|
|
156
|
+
export async function findTests(opts: {
|
|
157
|
+
config: AppConfig
|
|
158
|
+
repo: string
|
|
159
|
+
request: string
|
|
160
|
+
range?: TestRange
|
|
161
|
+
signal?: AbortSignal
|
|
162
|
+
onEvent?: (e: CodeEvent) => void
|
|
163
|
+
open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession>
|
|
164
|
+
runTest?: typeof runTestCommand
|
|
165
|
+
}): Promise<TestSelection> {
|
|
166
|
+
const repo = path.resolve(opts.repo)
|
|
167
|
+
if (!existsSync(repo) || !statSync(repo).isDirectory()) throw new Error(`Repo not found: ${repo}`)
|
|
168
|
+
const emit = opts.onEvent || (() => {})
|
|
169
|
+
const runTest = opts.runTest || runTestCommand
|
|
170
|
+
const dir = runDirectory(path.join(opts.config.export.outDir, "tests"))
|
|
171
|
+
const junit = path.join(dir, "results.xml")
|
|
172
|
+
const trx = path.join(dir, "results.trx")
|
|
173
|
+
|
|
174
|
+
const open = (opts.open || openSession) as (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<FindReport>>
|
|
175
|
+
const session = await open({
|
|
176
|
+
repo,
|
|
177
|
+
profile: opts.config.llm,
|
|
178
|
+
domain: domainNotes(opts.config),
|
|
179
|
+
model: opts.config.code.model,
|
|
180
|
+
bin: opts.config.code.opencode,
|
|
181
|
+
readDirs: [repo],
|
|
182
|
+
hide: privateEnvNames(opts.config),
|
|
183
|
+
signal: opts.signal,
|
|
184
|
+
onEvent: emit,
|
|
185
|
+
agent: FIND_AGENT,
|
|
186
|
+
skills: loadSkills(opts.config.skills, repo).skills,
|
|
187
|
+
schema: FIND_SCHEMA as unknown as Record<string, unknown>,
|
|
188
|
+
title: "ForgeQE find tests",
|
|
189
|
+
})
|
|
190
|
+
try {
|
|
191
|
+
emit({ type: "code", kind: "info", text: `Scribe (${session.model}) is looking for tests in ${repo}: ${opts.request}` })
|
|
192
|
+
let reply = await session.prompt(findPrompt({ request: opts.request, range: opts.range, junit, trx }))
|
|
193
|
+
if (!reply.report && !reply.error && !opts.signal?.aborted) reply = await session.prompt("Give the structured result: framework, tests, related, list_command, run_command, and notes.")
|
|
194
|
+
if (!reply.report) throw new Error(`Scribe gave no list of tests${reply.error ? `: ${reply.error}` : "."}`)
|
|
195
|
+
let report = reply.report
|
|
196
|
+
let listed = false
|
|
197
|
+
for (let round = 1; round <= LIST_ROUNDS && clean(report.tests).length && report.list_command?.trim() && !opts.signal?.aborted; round++) {
|
|
198
|
+
emit({ type: "code", kind: "info", text: `ForgeQE lists the selected tests: ${report.list_command}` })
|
|
199
|
+
const run = await runTest({ command: report.list_command!, cwd: repo, timeoutSeconds: LIST_TIMEOUT_SECONDS, signal: opts.signal, hide: privateEnvNames(opts.config) })
|
|
200
|
+
if (opts.signal?.aborted) break
|
|
201
|
+
const next = (await session.prompt(listPrompt(report, run))).report
|
|
202
|
+
if (!next) break
|
|
203
|
+
const unchanged = next.list_command === report.list_command && next.run_command === report.run_command && sameTests(clean(next.tests), clean(report.tests))
|
|
204
|
+
report = { ...report, ...next }
|
|
205
|
+
if (next.list_matches && unchanged) {
|
|
206
|
+
listed = true
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (opts.signal?.aborted) throw new Error("Stopped.")
|
|
211
|
+
const results = [report.results_file ? path.resolve(repo, report.results_file) : "", junit, trx].filter((p, i, all) => p && all.indexOf(p) === i)
|
|
212
|
+
const selection: TestSelection = {
|
|
213
|
+
id: path.basename(dir),
|
|
214
|
+
dir,
|
|
215
|
+
repo,
|
|
216
|
+
request: opts.request,
|
|
217
|
+
range: opts.range,
|
|
218
|
+
framework: String(report.framework || ""),
|
|
219
|
+
tests: clean(report.tests),
|
|
220
|
+
related: clean(report.related),
|
|
221
|
+
listCommand: String(report.list_command || ""),
|
|
222
|
+
listed,
|
|
223
|
+
runCommand: String(report.run_command || ""),
|
|
224
|
+
results,
|
|
225
|
+
notes: String(report.notes || ""),
|
|
226
|
+
model: session.model,
|
|
227
|
+
usage: { ...session.usage },
|
|
228
|
+
createdAt: new Date().toISOString(),
|
|
229
|
+
}
|
|
230
|
+
writeFileSync(path.join(dir, "selection.json"), JSON.stringify(selection, null, 2))
|
|
231
|
+
return selection
|
|
232
|
+
} finally {
|
|
233
|
+
await session.close()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function loadSelection(config: AppConfig, id: string): TestSelection {
|
|
238
|
+
const file = path.join(config.export.outDir, "tests", path.basename(id), "selection.json")
|
|
239
|
+
if (!existsSync(file)) throw new Error(`No test selection "${id}": call find_tests first.`)
|
|
240
|
+
return JSON.parse(readFileSync(file, "utf8")) as TestSelection
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export type TestOutcome = {
|
|
244
|
+
name: string
|
|
245
|
+
status: "passed" | "failed" | "skipped"
|
|
246
|
+
seconds?: number
|
|
247
|
+
message?: string
|
|
248
|
+
file?: string
|
|
249
|
+
/** Failed, then passed when run again in the same run: flaky, not failed. */
|
|
250
|
+
flaky?: boolean
|
|
251
|
+
/** In quarantine: its failure does not fail the run. */
|
|
252
|
+
quarantined?: boolean
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const parser = new XMLParser({
|
|
256
|
+
ignoreAttributes: false,
|
|
257
|
+
attributeNamePrefix: "",
|
|
258
|
+
parseTagValue: false,
|
|
259
|
+
isArray: (name) => ["testsuite", "testcase", "UnitTestResult", "UnitTest", "failure", "error"].includes(name),
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
function text(v: unknown): string {
|
|
263
|
+
if (v == null) return ""
|
|
264
|
+
if (typeof v === "string") return v
|
|
265
|
+
if (Array.isArray(v)) return text(v[0])
|
|
266
|
+
if (typeof v === "object") {
|
|
267
|
+
const o = v as Record<string, unknown>
|
|
268
|
+
return String(o.message || o["#text"] || "")
|
|
269
|
+
}
|
|
270
|
+
return String(v)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function oneLine(s: string): string {
|
|
274
|
+
const t = s.replace(/\s+/g, " ").trim()
|
|
275
|
+
return t.length > 300 ? `${t.slice(0, 300)}…` : t
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function junitCases(node: unknown, out: Record<string, any>[] = []): Record<string, any>[] {
|
|
279
|
+
if (!node || typeof node !== "object") return out
|
|
280
|
+
if (Array.isArray(node)) {
|
|
281
|
+
for (const n of node) junitCases(n, out)
|
|
282
|
+
return out
|
|
283
|
+
}
|
|
284
|
+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
285
|
+
if (key === "testcase") out.push(...(value as Record<string, any>[]))
|
|
286
|
+
else if (typeof value === "object") junitCases(value, out)
|
|
287
|
+
}
|
|
288
|
+
return out
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** hh:mm:ss.fffffff (TRX) in seconds. */
|
|
292
|
+
function trxSeconds(d: unknown): number | undefined {
|
|
293
|
+
const m = typeof d === "string" ? /^(\d+):(\d+):(\d+(?:\.\d+)?)$/.exec(d) : null
|
|
294
|
+
return m ? Math.round((Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3])) * 100) / 100 : undefined
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Per-test outcomes from a JUnit XML or TRX document. */
|
|
298
|
+
export function parseResults(xml: string): TestOutcome[] {
|
|
299
|
+
const doc = parser.parse(xml) as Record<string, any>
|
|
300
|
+
if (doc.TestRun) {
|
|
301
|
+
const results: Record<string, any>[] = doc.TestRun.Results?.UnitTestResult || []
|
|
302
|
+
const files = new Map<string, string>()
|
|
303
|
+
for (const t of doc.TestRun.TestDefinitions?.UnitTest || []) if (t.id && t.storage) files.set(t.id, String(t.storage))
|
|
304
|
+
return results.map((r) => {
|
|
305
|
+
const outcome = String(r.outcome || "")
|
|
306
|
+
const status = outcome === "Passed" ? "passed" : outcome === "Failed" || outcome === "Error" || outcome === "Timeout" || outcome === "Aborted" ? "failed" : "skipped"
|
|
307
|
+
const message = oneLine(text(r.Output?.ErrorInfo?.Message))
|
|
308
|
+
return { name: String(r.testName || ""), status, seconds: trxSeconds(r.duration), ...(message ? { message } : {}), ...(files.get(r.testId) ? { file: files.get(r.testId) } : {}) }
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
return junitCases(doc).map((c) => {
|
|
312
|
+
const failure = c.failure?.[0] ?? c.error?.[0]
|
|
313
|
+
const status = failure !== undefined ? "failed" : c.skipped !== undefined ? "skipped" : "passed"
|
|
314
|
+
const name = c.classname && !String(c.name || "").includes(String(c.classname)) ? `${c.classname} › ${c.name}` : String(c.name || "")
|
|
315
|
+
const message = failure !== undefined ? oneLine(text(failure)) : ""
|
|
316
|
+
const seconds = c.time !== undefined && !Number.isNaN(Number(c.time)) ? Number(c.time) : undefined
|
|
317
|
+
return { name, status, ...(seconds !== undefined ? { seconds } : {}), ...(message ? { message } : {}), ...(c.file ? { file: String(c.file) } : {}) }
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Result files written at or after `since`: the given files, or the XML/TRX files in a given folder (one level down too). */
|
|
322
|
+
function resultFiles(paths: string[], since: number): string[] {
|
|
323
|
+
const fresh = (f: string) => statSync(f).mtimeMs >= since - 1000
|
|
324
|
+
const out: string[] = []
|
|
325
|
+
for (const p of paths) {
|
|
326
|
+
if (!existsSync(p)) continue
|
|
327
|
+
if (statSync(p).isFile()) {
|
|
328
|
+
if (fresh(p)) out.push(p)
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
for (const e of readdirSync(p, { withFileTypes: true })) {
|
|
332
|
+
const f = path.join(p, e.name)
|
|
333
|
+
if (e.isFile() && /\.(xml|trx)$/i.test(e.name) && fresh(f)) out.push(f)
|
|
334
|
+
else if (e.isDirectory()) for (const g of readdirSync(f)) if (/\.(xml|trx)$/i.test(g) && fresh(path.join(f, g))) out.push(path.join(f, g))
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return [...new Set(out)]
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export type TestsRun = {
|
|
341
|
+
status: "passed" | "failed" | "timed out" | "stopped"
|
|
342
|
+
command: string
|
|
343
|
+
exitCode: number | null
|
|
344
|
+
seconds: number
|
|
345
|
+
tests: TestOutcome[]
|
|
346
|
+
counts: { passed: number; failed: number; skipped: number }
|
|
347
|
+
resultFiles: string[]
|
|
348
|
+
browsers: string[]
|
|
349
|
+
output: string
|
|
350
|
+
outputFile: string
|
|
351
|
+
dir: string
|
|
352
|
+
/** Tests that failed, then passed when run again. */
|
|
353
|
+
flaky?: string[]
|
|
354
|
+
/** Quarantined tests that failed: not counted against the run. */
|
|
355
|
+
quarantinedFailures?: string[]
|
|
356
|
+
/** Tests whose history now makes them flaky, and not in quarantine (flaky.quarantine: suggest). */
|
|
357
|
+
suggestQuarantine?: Flakiness[]
|
|
358
|
+
/** Put in quarantine by this run (flaky.quarantine: auto). */
|
|
359
|
+
quarantinedNow?: string[]
|
|
360
|
+
/** In quarantine, and clean for several runs in a row. */
|
|
361
|
+
releasable?: string[]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The command that runs only the tests that just failed, in the framework's own way: Playwright --last-failed,
|
|
366
|
+
* pytest --lf, Jest --onlyFailures. Otherwise the whole command again when the selection is small; null past that.
|
|
367
|
+
*/
|
|
368
|
+
export function retryCommand(command: string, total: number): string | null {
|
|
369
|
+
if (/\bplaywright\s+test\b/.test(command)) return `${command} --last-failed`
|
|
370
|
+
if (/\bpytest\b/.test(command)) return `${command} --lf`
|
|
371
|
+
if (/\bjest\b/.test(command) && !/\bvitest\b/.test(command)) return `${command} --onlyFailures`
|
|
372
|
+
return total <= 20 ? command : null
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Run a saved selection's command and read the per-test results it wrote; results.json lands in the selection's folder. */
|
|
376
|
+
export async function runSelection(opts: {
|
|
377
|
+
selection: TestSelection
|
|
378
|
+
timeoutSeconds: number
|
|
379
|
+
signal?: AbortSignal
|
|
380
|
+
runTest?: typeof runTestCommand
|
|
381
|
+
/** Variables the tests must not see: ForgeQE's own secrets (privateEnvNames). */
|
|
382
|
+
hide?: string[]
|
|
383
|
+
/** Retries and quarantine; default: one retry, quarantine suggested. */
|
|
384
|
+
flaky?: FlakyConfig
|
|
385
|
+
}): Promise<TestsRun> {
|
|
386
|
+
const sel = opts.selection
|
|
387
|
+
const flakyCfg = opts.flaky || { retries: 1, quarantine: "suggest", flakyAfter: 2 }
|
|
388
|
+
if (!sel.runCommand.trim()) throw new Error("The selection has no run command.")
|
|
389
|
+
mkdirSync(sel.dir, { recursive: true })
|
|
390
|
+
for (const p of sel.results) if (p.startsWith(sel.dir) && existsSync(p) && statSync(p).isFile()) rmSync(p)
|
|
391
|
+
const started = Date.now()
|
|
392
|
+
const exec = (command: string) => (opts.runTest || runTestCommand)({ command, cwd: sel.repo, timeoutSeconds: opts.timeoutSeconds, signal: opts.signal, hide: opts.hide })
|
|
393
|
+
const read = (since: number) => {
|
|
394
|
+
const out: TestOutcome[] = []
|
|
395
|
+
for (const f of resultFiles(sel.results, since)) {
|
|
396
|
+
try {
|
|
397
|
+
out.push(...parseResults(readFileSync(f, "utf8")))
|
|
398
|
+
} catch {}
|
|
399
|
+
}
|
|
400
|
+
return out
|
|
401
|
+
}
|
|
402
|
+
let run = await exec(sel.runCommand)
|
|
403
|
+
let files = resultFiles(sel.results, started)
|
|
404
|
+
let tests = read(started)
|
|
405
|
+
let output = run.output
|
|
406
|
+
|
|
407
|
+
// A failed test runs again: passing then means flaky, not failed.
|
|
408
|
+
const failedFirst = tests.filter((t) => t.status === "failed")
|
|
409
|
+
for (let attempt = 1; attempt <= flakyCfg.retries && failedFirst.length && run.exitCode !== null && !opts.signal?.aborted; attempt++) {
|
|
410
|
+
const still = tests.filter((t) => t.status === "failed")
|
|
411
|
+
if (!still.length) break
|
|
412
|
+
const cmd = retryCommand(sel.runCommand, tests.length)
|
|
413
|
+
if (!cmd) break
|
|
414
|
+
const again = Date.now()
|
|
415
|
+
const second = await exec(cmd)
|
|
416
|
+
output += `\n\n--- run again: ${still.length} failed test${still.length === 1 ? "" : "s"} (${cmd}) ---\n${second.output}`
|
|
417
|
+
const rerun = new Map(read(again).map((t) => [t.name, t]))
|
|
418
|
+
tests = tests.map((t) => (t.status === "failed" && rerun.get(t.name)?.status === "passed" ? { ...t, status: "passed", flaky: true } : t))
|
|
419
|
+
if (rerun.size) files = [...new Set([...files, ...resultFiles(sel.results, again)])]
|
|
420
|
+
if (second.exitCode === null) break
|
|
421
|
+
run = { ...second, ok: tests.every((t) => t.status !== "failed") }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// History, and quarantine: a quarantined test's failure does not count.
|
|
425
|
+
const q = quarantined(sel.repo)
|
|
426
|
+
tests = tests.map((t) => (q.has(t.name) ? { ...t, quarantined: true } : t))
|
|
427
|
+
recordResults(sel.repo, tests.map((t) => ({ name: t.name, status: t.status, retried: t.flaky, message: t.message, seconds: t.seconds })))
|
|
428
|
+
const report = new Map(flakyReport(sel.repo, flakyCfg.flakyAfter).map((f) => [f.name, f]))
|
|
429
|
+
const nowFlaky = tests.map((t) => report.get(t.name)).filter((f): f is Flakiness => Boolean(f?.flaky && !f.quarantined))
|
|
430
|
+
let quarantinedNow: string[] = []
|
|
431
|
+
if (flakyCfg.quarantine === "auto" && nowFlaky.length) {
|
|
432
|
+
quarantinedNow = setQuarantine(sel.repo, nowFlaky.map((f) => f.name), "flaky: ForgeQE quarantined it from its history", "auto")
|
|
433
|
+
tests = tests.map((t) => (quarantinedNow.includes(t.name) ? { ...t, quarantined: true } : t))
|
|
434
|
+
}
|
|
435
|
+
const blocking = tests.filter((t) => t.status === "failed" && !t.quarantined)
|
|
436
|
+
const counts = { passed: 0, failed: 0, skipped: 0 }
|
|
437
|
+
for (const t of tests) counts[t.status]++
|
|
438
|
+
const allQuarantined = tests.length > 0 && !blocking.length
|
|
439
|
+
const status = opts.signal?.aborted ? "stopped" : run.exitCode === null ? "timed out" : (run.ok || allQuarantined) && !blocking.length ? "passed" : "failed"
|
|
440
|
+
const outputFile = path.join(sel.dir, "output.log")
|
|
441
|
+
writeFileSync(outputFile, output)
|
|
442
|
+
const flakyNow = tests.filter((t) => t.flaky).map((t) => t.name)
|
|
443
|
+
const qFailed = tests.filter((t) => t.status === "failed" && t.quarantined).map((t) => t.name)
|
|
444
|
+
const releasable = tests.map((t) => report.get(t.name)).filter((f): f is Flakiness => Boolean(f?.releasable)).map((f) => f.name)
|
|
445
|
+
const result: TestsRun = {
|
|
446
|
+
status,
|
|
447
|
+
command: sel.runCommand,
|
|
448
|
+
exitCode: run.exitCode,
|
|
449
|
+
seconds: Math.round((Date.now() - started) / 100) / 10,
|
|
450
|
+
tests,
|
|
451
|
+
counts,
|
|
452
|
+
resultFiles: files,
|
|
453
|
+
browsers: run.browsers,
|
|
454
|
+
output,
|
|
455
|
+
outputFile,
|
|
456
|
+
dir: sel.dir,
|
|
457
|
+
...(flakyNow.length ? { flaky: flakyNow } : {}),
|
|
458
|
+
...(qFailed.length ? { quarantinedFailures: qFailed } : {}),
|
|
459
|
+
...(flakyCfg.quarantine === "suggest" && nowFlaky.length ? { suggestQuarantine: nowFlaky } : {}),
|
|
460
|
+
...(quarantinedNow.length ? { quarantinedNow } : {}),
|
|
461
|
+
...(releasable.length ? { releasable } : {}),
|
|
462
|
+
}
|
|
463
|
+
writeFileSync(path.join(sel.dir, "results.json"), JSON.stringify({ ...result, output: undefined }, null, 2))
|
|
464
|
+
return result
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const rel = (cwd: string, p: string) => {
|
|
468
|
+
const r = path.relative(cwd, p)
|
|
469
|
+
return r && !r.startsWith("..") && !path.isAbsolute(r) ? r : p
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const LIST_MAX = 40
|
|
473
|
+
|
|
474
|
+
export function selectionCard(sel: TestSelection, cwd: string): Card {
|
|
475
|
+
const n = sel.tests.length
|
|
476
|
+
const body: string[] = []
|
|
477
|
+
for (const t of sel.tests.slice(0, LIST_MAX)) {
|
|
478
|
+
body.push(`• ${t.name}`)
|
|
479
|
+
const where = [t.file, t.added ? `added ${t.added}` : ""].filter(Boolean).join(", ")
|
|
480
|
+
body.push(` ${[where, t.reason].filter(Boolean).join(" · ")}`)
|
|
481
|
+
}
|
|
482
|
+
if (n > LIST_MAX) body.push(`and ${n - LIST_MAX} more (selection.json has all of them)`)
|
|
483
|
+
if (sel.related.length) {
|
|
484
|
+
body.push("", "Left out:")
|
|
485
|
+
for (const t of sel.related.slice(0, 10)) body.push(`• ${t.name}${t.reason ? ` · ${t.reason}` : ""}`)
|
|
486
|
+
if (sel.related.length > 10) body.push(`and ${sel.related.length - 10} more`)
|
|
487
|
+
}
|
|
488
|
+
if (n) {
|
|
489
|
+
body.push("")
|
|
490
|
+
if (sel.listCommand) body.push(`List ${sel.listCommand} (${sel.listed ? "ForgeQE ran it: lists exactly these" : "not confirmed"})`)
|
|
491
|
+
body.push(`Run ${sel.runCommand}`)
|
|
492
|
+
}
|
|
493
|
+
if (sel.notes) body.push("", sel.notes)
|
|
494
|
+
body.push(`Saved ${rel(cwd, path.join(sel.dir, "selection.json"))}`)
|
|
495
|
+
return {
|
|
496
|
+
status: "info",
|
|
497
|
+
title: n ? `${n} test${n === 1 ? "" : "s"} found for "${sel.request}"` : `No tests found for "${sel.request}"`,
|
|
498
|
+
stats: [sel.framework, sel.range?.label || [sel.range?.since, sel.range?.until].filter(Boolean).join(" to ")].filter(Boolean).join(" · "),
|
|
499
|
+
body,
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function resultsCard(r: TestsRun, cwd: string): Card {
|
|
504
|
+
const { passed, failed, skipped } = r.counts
|
|
505
|
+
const total = r.tests.length
|
|
506
|
+
const body: string[] = []
|
|
507
|
+
const sorted = [...r.tests.filter((t) => t.status === "failed"), ...r.tests.filter((t) => t.status !== "failed")]
|
|
508
|
+
for (const t of sorted.slice(0, LIST_MAX)) {
|
|
509
|
+
const mark = t.flaky ? "↻" : t.status === "passed" ? "✓" : t.status === "failed" ? "✗" : "–"
|
|
510
|
+
const note = t.flaky ? " flaky: failed, then passed when run again" : t.quarantined && t.status === "failed" ? " quarantined: not counted" : t.quarantined ? " quarantined" : ""
|
|
511
|
+
body.push(`${mark} ${t.name}${t.seconds !== undefined ? ` ${t.seconds}s` : ""}${note}`)
|
|
512
|
+
if (t.message && (t.status === "failed" || t.flaky)) body.push(` ${t.message}`)
|
|
513
|
+
}
|
|
514
|
+
for (const f of r.suggestQuarantine || []) body.push(`⚠ ${f.name} is flaky (${f.trail}): likely ${CAUSE_TEXT[f.cause]}. Quarantine it, or fix it with heal_tests.`)
|
|
515
|
+
if (r.quarantinedNow?.length) body.push(`Quarantined now (flaky): ${r.quarantinedNow.join(", ")}`)
|
|
516
|
+
if (r.releasable?.length) body.push(`Passing again ${RELEASE_AFTER} runs in a row, ready to leave quarantine: ${r.releasable.join(", ")}`)
|
|
517
|
+
if (total > LIST_MAX) body.push(`and ${total - LIST_MAX} more`)
|
|
518
|
+
if (!total) body.push("No per-test results were written; the exit code decides.", ...r.output.split("\n").slice(-8).map((l) => ` ${l}`))
|
|
519
|
+
body.push("", `Command ${r.command}`)
|
|
520
|
+
if (r.resultFiles.length) body.push(`Results ${r.resultFiles.map((f) => rel(cwd, f)).join(", ")}`)
|
|
521
|
+
body.push(`Output ${rel(cwd, r.outputFile)}`)
|
|
522
|
+
const title =
|
|
523
|
+
r.status === "stopped"
|
|
524
|
+
? "Test run stopped"
|
|
525
|
+
: r.status === "timed out"
|
|
526
|
+
? "Test run timed out"
|
|
527
|
+
: total
|
|
528
|
+
? failed - (r.quarantinedFailures?.length || 0) > 0
|
|
529
|
+
? `${failed - (r.quarantinedFailures?.length || 0)} of ${total} tests failed`
|
|
530
|
+
: `${passed} test${passed === 1 ? "" : "s"} passed${r.flaky?.length ? `, ${r.flaky.length} after a retry` : ""}${r.quarantinedFailures?.length ? `; ${r.quarantinedFailures.length} quarantined failed` : ""}`
|
|
531
|
+
: r.status === "passed"
|
|
532
|
+
? "Tests passed"
|
|
533
|
+
: `Tests failed (exit code ${r.exitCode})`
|
|
534
|
+
return {
|
|
535
|
+
status: r.status === "passed" ? "ok" : r.status === "stopped" ? "stopped" : "fail",
|
|
536
|
+
title,
|
|
537
|
+
stats: [`${r.seconds}s`, skipped ? `${skipped} skipped` : "", r.browsers.length ? r.browsers.join(", ") : ""].filter(Boolean).join(" · "),
|
|
538
|
+
body,
|
|
539
|
+
}
|
|
540
|
+
}
|