@capagents/forgeqe 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
package/src/suite/run.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { goalFor, writeBack as postResults, type CaseResult, type TestCase, type WriteBack } from "../cases/index.ts"
|
|
5
|
+
import { costOf } from "../cases/types.ts"
|
|
6
|
+
import { addUsage, NO_USAGE } from "../usage.ts"
|
|
7
|
+
import type { AgentEvent, CodeResult, ReplayMode, RunResult } from "../types.ts"
|
|
8
|
+
import { codeSkipReason, runCodeAgent } from "../code/index.ts"
|
|
9
|
+
import type { AppConfig } from "../config.ts"
|
|
10
|
+
import { fileBugs } from "../cases/bugs.ts"
|
|
11
|
+
import { classifyFailure, FAILURE_LABEL } from "../cases/failure.ts"
|
|
12
|
+
import { associateAdoAutomation, readMappings } from "../code/traceability.ts"
|
|
13
|
+
import type { ExistingTest } from "../code/prompt.ts"
|
|
14
|
+
import { runTestCommand } from "../code/testrun.ts"
|
|
15
|
+
import { privateEnvNames } from "../childEnv.ts"
|
|
16
|
+
import { readTimeline, reportEnv, type ReportEnv } from "../report/html.ts"
|
|
17
|
+
import { executeRun, runDirectory } from "../run.ts"
|
|
18
|
+
import { writeReports } from "./report.ts"
|
|
19
|
+
|
|
20
|
+
export type SuiteEvent =
|
|
21
|
+
| { type: "case-start"; index: number; total: number; case: TestCase; runDir: string }
|
|
22
|
+
| { type: "case-event"; index: number; case: TestCase; event: AgentEvent }
|
|
23
|
+
| { type: "case-done"; index: number; total: number; result: CaseResult }
|
|
24
|
+
| { type: "code-start"; index: number; case: TestCase }
|
|
25
|
+
| { type: "code-done"; index: number; case: TestCase; result: CaseResult }
|
|
26
|
+
| { type: "writeback"; item: WriteBack }
|
|
27
|
+
|
|
28
|
+
export type SuiteResult = {
|
|
29
|
+
name: string
|
|
30
|
+
dir: string
|
|
31
|
+
startedAt: string
|
|
32
|
+
finishedAt: string
|
|
33
|
+
ms: number
|
|
34
|
+
results: CaseResult[]
|
|
35
|
+
writeBack: WriteBack[]
|
|
36
|
+
files: { junit: string; html: string; json: string }
|
|
37
|
+
stopped: boolean
|
|
38
|
+
env?: ReportEnv
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SuiteOptions = {
|
|
42
|
+
config: AppConfig
|
|
43
|
+
cases: TestCase[]
|
|
44
|
+
/** Repo for the coding agent; unset: browser runs only. */
|
|
45
|
+
repo?: string
|
|
46
|
+
parallel?: number
|
|
47
|
+
outDir?: string
|
|
48
|
+
writeBack?: boolean
|
|
49
|
+
headed?: boolean
|
|
50
|
+
url?: string
|
|
51
|
+
maxSteps?: number
|
|
52
|
+
replay?: ReplayMode
|
|
53
|
+
memory?: boolean
|
|
54
|
+
/** Browser skills for every case (empty: none); unset: the engine picks per case. */
|
|
55
|
+
skills?: string[]
|
|
56
|
+
/** Folder whose .forgeqe/skills count as project skills; default: repo. */
|
|
57
|
+
skillsRepo?: string
|
|
58
|
+
signal?: AbortSignal
|
|
59
|
+
onEvent?: (e: SuiteEvent) => void
|
|
60
|
+
execute?: typeof executeRun
|
|
61
|
+
coder?: typeof runCodeAgent
|
|
62
|
+
/** Replaces runTestCommand for a case's existing test. */
|
|
63
|
+
runTest?: typeof runTestCommand
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const STATUS = { complete: "passed", fail: "failed", stopped: "stopped" } as const
|
|
67
|
+
|
|
68
|
+
function slug(s: string): string {
|
|
69
|
+
return s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "case"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run each test case as one browser goal, `parallel` at a time. With a repo set, a passed case is handed to the coding
|
|
74
|
+
* agent as soon as its browser run ends, and that case's worker waits for its test before starting the next case:
|
|
75
|
+
* explore, hand off, code, then the next case. Coding jobs run one after another so two never edit the repo at once.
|
|
76
|
+
* Results are posted back to their source, then written as JUnit XML, HTML, and JSON.
|
|
77
|
+
*/
|
|
78
|
+
export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
|
|
79
|
+
const { config, cases } = opts
|
|
80
|
+
const execute = opts.execute || executeRun
|
|
81
|
+
const coder = opts.coder || runCodeAgent
|
|
82
|
+
const emit = opts.onEvent || (() => {})
|
|
83
|
+
const dir = runDirectory(config.suite.outDir, opts.outDir)
|
|
84
|
+
mkdirSync(dir, { recursive: true })
|
|
85
|
+
const t0 = Date.now()
|
|
86
|
+
const startedAt = new Date(t0).toISOString()
|
|
87
|
+
const name = `ForgeQE suite ${startedAt.slice(0, 16).replace("T", " ")}`
|
|
88
|
+
const total = cases.length
|
|
89
|
+
const results: (CaseResult | undefined)[] = Array.from({ length: total }, () => undefined)
|
|
90
|
+
const width = String(total).length
|
|
91
|
+
let codeQueue: Promise<void> = Promise.resolve()
|
|
92
|
+
/** Written back during the suite: bugs for functional defects (the suite's own bug pass skips those cases) and tests set as a test case's automation. */
|
|
93
|
+
const earlyBugs: WriteBack[] = []
|
|
94
|
+
|
|
95
|
+
const runOne = async (index: number) => {
|
|
96
|
+
const tc = cases[index]
|
|
97
|
+
const runDir = path.join(dir, `${String(index + 1).padStart(width, "0")}-${slug(tc.id)}`)
|
|
98
|
+
mkdirSync(runDir, { recursive: true })
|
|
99
|
+
writeFileSync(path.join(runDir, "testcase.json"), `${JSON.stringify(tc, null, 2)}\n`)
|
|
100
|
+
emit({ type: "case-start", index, total, case: tc, runDir })
|
|
101
|
+
const start = Date.now()
|
|
102
|
+
const explore = async (): Promise<{ run?: RunResult; error?: string }> => {
|
|
103
|
+
try {
|
|
104
|
+
const { result } = await execute({
|
|
105
|
+
config,
|
|
106
|
+
goal: goalFor(tc),
|
|
107
|
+
url: tc.startUrl || opts.url,
|
|
108
|
+
headed: opts.headed,
|
|
109
|
+
maxSteps: opts.maxSteps,
|
|
110
|
+
replay: opts.replay,
|
|
111
|
+
memory: opts.memory,
|
|
112
|
+
caseSteps: tc.steps.length,
|
|
113
|
+
outDir: runDir,
|
|
114
|
+
skills: opts.skills,
|
|
115
|
+
repo: opts.skillsRepo || opts.repo,
|
|
116
|
+
signal: opts.signal,
|
|
117
|
+
onEvent: (event) => emit({ type: "case-event", index, case: tc, event }),
|
|
118
|
+
})
|
|
119
|
+
return { run: result }
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { error: (err as Error).message || String(err) }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let { run, error } = await explore()
|
|
125
|
+
let failure = opts.signal?.aborted ? undefined : classifyFailure(run, error)
|
|
126
|
+
let spent = run?.usage
|
|
127
|
+
// An environment problem (the site down, a 5xx, a network failure) is often gone a moment later: one more run.
|
|
128
|
+
let retried = false
|
|
129
|
+
if (failure?.type === "environment" && !opts.signal?.aborted) {
|
|
130
|
+
emit({ type: "case-event", index, case: tc, event: { type: "log", level: "warn", text: `${FAILURE_LABEL.environment}: ${failure.reason} Running the case once more.` } })
|
|
131
|
+
retried = true
|
|
132
|
+
;({ run, error } = await explore())
|
|
133
|
+
failure = opts.signal?.aborted ? undefined : classifyFailure(run, error)
|
|
134
|
+
spent = run?.usage ? addUsage(spent || NO_USAGE, run.usage) : spent
|
|
135
|
+
}
|
|
136
|
+
const end = Date.now()
|
|
137
|
+
const result: CaseResult = {
|
|
138
|
+
case: tc,
|
|
139
|
+
// Steps that passed on a page over an accessibility or performance budget: the case fails on the budget.
|
|
140
|
+
status: run ? (run.status === "complete" && run.budgetExceeded?.length ? "failed" : STATUS[run.status]) : opts.signal?.aborted ? "stopped" : "error",
|
|
141
|
+
summary: [run?.summary || error || "", ...(run?.budgetExceeded?.length ? [`Budget exceeded: ${run.budgetExceeded.join("; ")}`] : [])].filter(Boolean).join("\n\n"),
|
|
142
|
+
startedAt: new Date(start).toISOString(),
|
|
143
|
+
finishedAt: new Date(end).toISOString(),
|
|
144
|
+
ms: end - start,
|
|
145
|
+
tokens: spent ? spent.input + spent.output : 0,
|
|
146
|
+
...(spent ? { usage: spent, cost: costOf(spent, config.llm.price) } : {}),
|
|
147
|
+
runDir,
|
|
148
|
+
finalUrl: run?.finalUrl || undefined,
|
|
149
|
+
steps: timelineLines(runDir),
|
|
150
|
+
...(run?.caseReport?.steps.length ? { stepResults: run.caseReport.steps } : {}),
|
|
151
|
+
...(failure ? { failure } : {}),
|
|
152
|
+
...(retried ? { retried } : {}),
|
|
153
|
+
}
|
|
154
|
+
results[index] = result
|
|
155
|
+
emit({ type: "case-done", index, total, result })
|
|
156
|
+
|
|
157
|
+
// A functional defect's bug is filed now, so the test written for it can name the bug it waits on.
|
|
158
|
+
if (failure?.type === "functional_defect" && (opts.writeBack ?? config.sources.writeBack) && config.sources.bugs) {
|
|
159
|
+
for (const item of await fileBugs([result], config.sources, name)) {
|
|
160
|
+
earlyBugs.push(item)
|
|
161
|
+
emit({ type: "writeback", item })
|
|
162
|
+
if (!item.error) result.bug = { label: item.text.split(":")[0], ...(item.url ? { url: item.url } : {}) }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!opts.repo || !config.suite.code || opts.signal?.aborted) return
|
|
167
|
+
const skip = run ? codeSkipReason(config, run, failure) : "the browser run did not finish"
|
|
168
|
+
if (skip) {
|
|
169
|
+
result.codeSkipped = skip
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
// This case goes from its browser run straight to the coding agent, and its worker waits for that before taking
|
|
173
|
+
// the next case. Coding jobs still queue one after another, since they all edit the same repository.
|
|
174
|
+
const job = codeQueue.then(() => codeOne(index, tc, runDir, result))
|
|
175
|
+
codeQueue = job.catch(() => {})
|
|
176
|
+
await job
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const codeOne = async (index: number, tc: TestCase, runDir: string, result: CaseResult) => {
|
|
180
|
+
if (opts.signal?.aborted) return
|
|
181
|
+
// A case that already has a test (from an earlier job): run it first. Passing, it needs no coding job at all;
|
|
182
|
+
// failing, the job updates it instead of writing a new one.
|
|
183
|
+
const mapped = readMappings(opts.repo!).cases[tc.id]
|
|
184
|
+
let existingTest: ExistingTest | undefined
|
|
185
|
+
if (mapped?.testFile && mapped.command && (mapped.status === "automated" || mapped.status === "known_defect") && existsSync(path.join(opts.repo!, mapped.testFile))) {
|
|
186
|
+
emit({ type: "case-event", index, case: tc, event: { type: "code", kind: "info", text: `${tc.id} already has a test, ${mapped.testFile}: ForgeQE runs it first` } })
|
|
187
|
+
const run = await (opts.runTest || runTestCommand)({ command: mapped.command, cwd: opts.repo!, timeoutSeconds: config.code.testTimeoutSeconds, signal: opts.signal, hide: privateEnvNames(config), save: path.join(runDir, "existing-test") })
|
|
188
|
+
const defectNow = result.failure?.type === "functional_defect"
|
|
189
|
+
// Reused as it is: a normal test that passes on a case that passes, or a known-defect test (an expected failure)
|
|
190
|
+
// on a case that still shows the defect.
|
|
191
|
+
const fits = mapped.status === "known_defect" ? defectNow : !defectNow
|
|
192
|
+
if (run.ok && fits) {
|
|
193
|
+
emit({ type: "case-event", index, case: tc, event: { type: "code", kind: "test", ok: true, attempt: 0, text: `${mapped.testFile} passes as it is: no new test needed` } })
|
|
194
|
+
result.code = { status: "passed", testFile: mapped.testFile, command: mapped.command, attempts: 0, existing: "reused", ...(mapped.testName ? { testName: mapped.testName } : {}), ...(mapped.status === "known_defect" ? { knownDefect: true } : {}) }
|
|
195
|
+
emit({ type: "code-done", index, case: tc, result })
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (opts.signal?.aborted) return
|
|
199
|
+
const why =
|
|
200
|
+
mapped.status === "known_defect" && !defectNow
|
|
201
|
+
? `It is marked as an expected failure for a known defect${mapped.bug ? ` (${mapped.bug.label})` : ""}, and the browser run now passes: the defect looks fixed. Remove the expected-failure marker and keep the assertions.`
|
|
202
|
+
: defectNow && run.ok
|
|
203
|
+
? "It passes, although this run found a functional defect (below): its assertions miss it. Assert the expected result the defect breaks, and mark the test as an expected failure."
|
|
204
|
+
: defectNow
|
|
205
|
+
? "It fails because of the functional defect this run found (below): keep its assertions and mark it as an expected failure naming the defect."
|
|
206
|
+
: "It fails now: the application or the page changed since it was written."
|
|
207
|
+
existingTest = { testFile: mapped.testFile, command: mapped.command, output: run.output, why }
|
|
208
|
+
}
|
|
209
|
+
emit({ type: "code-start", index, case: tc })
|
|
210
|
+
let code: CodeResult | undefined
|
|
211
|
+
let error: string | undefined
|
|
212
|
+
try {
|
|
213
|
+
code = await coder({
|
|
214
|
+
config,
|
|
215
|
+
repo: opts.repo!,
|
|
216
|
+
runDir,
|
|
217
|
+
signal: opts.signal,
|
|
218
|
+
onEvent: (event) => emit({ type: "case-event", index, case: tc, event }),
|
|
219
|
+
testCase: tc,
|
|
220
|
+
...(existingTest ? { existingTest } : {}),
|
|
221
|
+
...(result.failure?.type === "functional_defect" ? { knownDefect: { reason: result.failure.reason, ...(result.bug ? { bug: result.bug } : {}) } } : {}),
|
|
222
|
+
})
|
|
223
|
+
} catch (err) {
|
|
224
|
+
error = (err as Error).message || String(err)
|
|
225
|
+
emit({ type: "case-event", index, case: tc, event: { type: "log", level: "error", text: `Coding agent: ${error}` } })
|
|
226
|
+
}
|
|
227
|
+
result.code = code
|
|
228
|
+
? {
|
|
229
|
+
status: code.status,
|
|
230
|
+
testFile: code.testFile,
|
|
231
|
+
command: code.command,
|
|
232
|
+
attempts: code.attempts,
|
|
233
|
+
...(code.handoff ? { handoff: code.handoff } : {}),
|
|
234
|
+
...(code.skills?.length ? { skills: code.skills } : {}),
|
|
235
|
+
...(code.testName ? { testName: code.testName } : {}),
|
|
236
|
+
...(code.knownDefect ? { knownDefect: true } : {}),
|
|
237
|
+
...(code.attention ? { attention: code.attention.dir } : {}),
|
|
238
|
+
...(existingTest ? { existing: "updated" as const } : {}),
|
|
239
|
+
}
|
|
240
|
+
: { status: "failed", attempts: 0, ...(error ? { error } : {}) }
|
|
241
|
+
result.tokens += code ? code.usage.input + code.usage.output : 0
|
|
242
|
+
if (code) {
|
|
243
|
+
result.codeUsage = code.usage
|
|
244
|
+
result.codeCost = code.cost
|
|
245
|
+
}
|
|
246
|
+
// A passing test for an Azure DevOps test case is set as its Associated Automation.
|
|
247
|
+
if (code?.status === "passed" && code.testFile && tc.source === "ado" && (opts.writeBack ?? config.sources.writeBack)) {
|
|
248
|
+
const item = await associateAdoAutomation(config.sources, tc, { name: code.testName || `${tc.id} ${tc.title}`, file: code.testFile, command: code.command })
|
|
249
|
+
earlyBugs.push(item)
|
|
250
|
+
emit({ type: "writeback", item })
|
|
251
|
+
}
|
|
252
|
+
emit({ type: "code-done", index, case: tc, result })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let next = 0
|
|
256
|
+
const workers = Array.from({ length: Math.min(Math.max(1, opts.parallel || config.suite.parallel), total) }, async () => {
|
|
257
|
+
while (next < total && !opts.signal?.aborted) await runOne(next++)
|
|
258
|
+
})
|
|
259
|
+
await Promise.all(workers)
|
|
260
|
+
|
|
261
|
+
const finished = results.map((r, i): CaseResult => {
|
|
262
|
+
if (r) return r
|
|
263
|
+
const now = new Date().toISOString()
|
|
264
|
+
return { case: cases[i], status: "stopped", summary: "Not run: the suite was stopped.", startedAt: now, finishedAt: now, ms: 0, tokens: 0 }
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const posted: WriteBack[] = []
|
|
268
|
+
if (opts.writeBack ?? config.sources.writeBack) {
|
|
269
|
+
const ran = finished.filter((r) => r.runDir)
|
|
270
|
+
const filed = new Set(earlyBugs.filter((b) => b.source === "bugs").map((b) => b.caseId))
|
|
271
|
+
posted.push(...earlyBugs)
|
|
272
|
+
for (const item of [...(await postResults(ran, config.sources, name)), ...(await fileBugs(ran.filter((r) => !filed.has(r.case.id)), config.sources, name))]) {
|
|
273
|
+
posted.push(item)
|
|
274
|
+
emit({ type: "writeback", item })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const finishedAt = new Date().toISOString()
|
|
279
|
+
const suite: Omit<SuiteResult, "files"> = {
|
|
280
|
+
name,
|
|
281
|
+
dir,
|
|
282
|
+
startedAt,
|
|
283
|
+
finishedAt,
|
|
284
|
+
ms: Date.now() - t0,
|
|
285
|
+
results: finished,
|
|
286
|
+
writeBack: posted,
|
|
287
|
+
stopped: Boolean(opts.signal?.aborted),
|
|
288
|
+
env: reportEnv(config, { url: opts.url, headed: opts.headed }),
|
|
289
|
+
}
|
|
290
|
+
return { ...suite, files: writeReports(suite) }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function timelineLines(runDir: string): CaseResult["steps"] | undefined {
|
|
294
|
+
const steps = readTimeline(runDir).flatMap((t) => t.actions.filter((a) => !a.done).map((a) => ({ text: a.error ? `${a.text}: ${a.error}` : a.text, ok: a.ok })))
|
|
295
|
+
return steps.length ? steps : undefined
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 0 when every case passed (and every test written for it passes), 2 when stopped, otherwise 1. */
|
|
299
|
+
export function suiteExitCode(s: Pick<SuiteResult, "results" | "stopped">): number {
|
|
300
|
+
if (s.stopped) return 2
|
|
301
|
+
const ok = s.results.every((r) => r.status === "passed" && (!r.code || r.code.status === "passed"))
|
|
302
|
+
return ok ? 0 : 1
|
|
303
|
+
}
|
package/src/summary.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { qualityLine, type QualityReport } from "./quality/budgets.ts"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import type { CodeResult, CodeStatus, MemoryInfo, RunStatus, Usage } from "./types.ts"
|
|
6
|
+
import { usageLine, type Cost } from "./usage.ts"
|
|
7
|
+
import type { Card } from "./card.ts"
|
|
8
|
+
import { formatDuration, formatTokens } from "./tui/theme.ts"
|
|
9
|
+
|
|
10
|
+
export type RunSummary = {
|
|
11
|
+
status: RunStatus
|
|
12
|
+
/** "Task complete", "Task failed", or "Test case failed" when an expected result did not hold. */
|
|
13
|
+
title: string
|
|
14
|
+
/** Expected results that did not hold: "Expected: … · the page shows: …". */
|
|
15
|
+
failed: string[]
|
|
16
|
+
/** The agent's own account of what it did and found, or the reason it stopped. */
|
|
17
|
+
text: string
|
|
18
|
+
finalUrl: string
|
|
19
|
+
stats: string
|
|
20
|
+
/** What page memory replayed and learned, e.g. "replayed all 4 steps · 1 LLM step · 3 elements on 1 page". */
|
|
21
|
+
memory: string
|
|
22
|
+
savedDir: string
|
|
23
|
+
savedFiles: string[]
|
|
24
|
+
/** Tokens (in, cached, out) and cost, e.g. "52.7K tokens (50.1K in, 31K of it cached · 2.6K out) · US$0.07". */
|
|
25
|
+
usage: string
|
|
26
|
+
/** WCAG rules passed and failed, Core Web Vitals, and whether the budgets held. */
|
|
27
|
+
quality?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const STATUS_TITLE: Record<RunStatus, string> = {
|
|
31
|
+
complete: "Task complete",
|
|
32
|
+
fail: "Task failed",
|
|
33
|
+
stopped: "Run stopped",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const STATUS_MARK: Record<RunStatus, string> = { complete: "✓", fail: "✗", stopped: "■" }
|
|
37
|
+
|
|
38
|
+
/** Host and path without the query string: booking.com/searchresults.en-gb.html */
|
|
39
|
+
export function shortUrl(url: string, max = 70): string {
|
|
40
|
+
if (!url || url === "about:blank") return ""
|
|
41
|
+
try {
|
|
42
|
+
const u = new URL(url)
|
|
43
|
+
const s = `${u.hostname.replace(/^www\./, "")}${u.pathname === "/" ? "" : u.pathname}`
|
|
44
|
+
return s.length <= max ? s : `${s.slice(0, max - 1)}…`
|
|
45
|
+
} catch {
|
|
46
|
+
return url.length <= max ? url : `${url.slice(0, max - 1)}…`
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function runStats(ms: number, tokens: number, steps: number): string {
|
|
51
|
+
return `${steps} ${steps === 1 ? "step" : "steps"} · ${formatDuration(ms)} · ${formatTokens(tokens)} tokens`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
|
|
55
|
+
|
|
56
|
+
export function memoryLine(m?: MemoryInfo): string {
|
|
57
|
+
if (!m) return ""
|
|
58
|
+
const parts: string[] = []
|
|
59
|
+
if (m.replayTotal) {
|
|
60
|
+
parts.push(m.replayed === m.replayTotal ? `replayed all ${m.replayed} steps` : `replayed ${m.replayed} of ${m.replayTotal} steps`)
|
|
61
|
+
parts.push(plural(m.llmSteps, "LLM step"))
|
|
62
|
+
}
|
|
63
|
+
if (m.elements) parts.push(`${plural(m.elements, "element")} on ${plural(m.pages, "page")}${m.newElements ? ` (${m.newElements} new)` : ""}`)
|
|
64
|
+
if (m.flow) parts.push(m.flow === "saved" ? "flow saved for replay" : "flow updated")
|
|
65
|
+
return parts.join(" · ")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildSummary(opts: {
|
|
69
|
+
status: RunStatus
|
|
70
|
+
text: string
|
|
71
|
+
finalUrl: string
|
|
72
|
+
ms: number
|
|
73
|
+
tokens: number
|
|
74
|
+
steps: number
|
|
75
|
+
files: string[]
|
|
76
|
+
memory?: MemoryInfo
|
|
77
|
+
usage?: Usage
|
|
78
|
+
cost?: Cost
|
|
79
|
+
outcome?: "passed" | "assertion_failed" | "error"
|
|
80
|
+
failedChecks?: { expected: string; actual: string }[]
|
|
81
|
+
quality?: QualityReport
|
|
82
|
+
budgetExceeded?: string[]
|
|
83
|
+
}): RunSummary {
|
|
84
|
+
const dir = opts.files.length ? path.relative(process.cwd(), path.dirname(opts.files[0])) || "." : ""
|
|
85
|
+
const assertion = opts.outcome === "assertion_failed"
|
|
86
|
+
// Steps that passed on a page over budget: the run fails on the budget.
|
|
87
|
+
const overBudget = opts.status === "complete" && Boolean(opts.budgetExceeded?.length)
|
|
88
|
+
return {
|
|
89
|
+
status: overBudget ? "fail" : opts.status,
|
|
90
|
+
title: assertion ? "Test case failed: expected result not met" : overBudget ? "Budget exceeded: the steps passed, a page is over budget" : STATUS_TITLE[opts.status],
|
|
91
|
+
failed: [...(assertion ? (opts.failedChecks || []).map((c) => `Expected: ${c.expected} · the page shows: ${c.actual}`) : []), ...(opts.budgetExceeded || []).map((b) => `Budget: ${b}`)],
|
|
92
|
+
text: opts.text.trim() || (opts.status === "complete" ? "Finished without a written result." : "No details were reported."),
|
|
93
|
+
finalUrl: shortUrl(opts.finalUrl),
|
|
94
|
+
stats: runStats(opts.ms, opts.tokens, opts.steps),
|
|
95
|
+
memory: memoryLine(opts.memory),
|
|
96
|
+
savedDir: dir,
|
|
97
|
+
savedFiles: opts.files.map((f) => path.basename(f)),
|
|
98
|
+
usage: opts.usage ? usageLine(opts.usage, opts.cost) : "",
|
|
99
|
+
...(opts.quality && qualityLine(opts.quality) ? { quality: qualityLine(opts.quality) } : {}),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Plain-text block for `forgeqe run`. */
|
|
104
|
+
export function formatSummary(s: RunSummary): string {
|
|
105
|
+
const lines = [`${STATUS_MARK[s.status]} ${s.title} · ${s.stats}`, "", ...(s.failed.length ? [...s.failed.map((f) => `✗ ${f}`), ""] : []), s.text, ""]
|
|
106
|
+
if (s.finalUrl) lines.push(`Ended on ${s.finalUrl}`)
|
|
107
|
+
if (s.memory) lines.push(`Memory ${s.memory}`)
|
|
108
|
+
if (s.quality) lines.push(`Quality ${s.quality}`)
|
|
109
|
+
if (s.usage) lines.push(`Usage ${s.usage}`)
|
|
110
|
+
if (s.savedDir) lines.push(`Saved ${s.savedDir}/ ${s.savedFiles.join(" · ")}`)
|
|
111
|
+
return lines.join("\n")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type CodeSummary = {
|
|
115
|
+
status: CodeStatus
|
|
116
|
+
stats: string
|
|
117
|
+
text: string
|
|
118
|
+
testFile: string
|
|
119
|
+
command: string
|
|
120
|
+
reused: string
|
|
121
|
+
changed: string
|
|
122
|
+
repo: string
|
|
123
|
+
browser: string
|
|
124
|
+
review: string
|
|
125
|
+
usage: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const CODE_TITLE: Record<CodeStatus, string> = {
|
|
129
|
+
passed: "Test passed",
|
|
130
|
+
failed: "Test not passing",
|
|
131
|
+
stopped: "Coding stopped",
|
|
132
|
+
blocked: "Test blocked by the app",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const CODE_MARK: Record<CodeStatus, string> = { passed: "✓", failed: "✗", stopped: "■", blocked: "✗" }
|
|
136
|
+
|
|
137
|
+
export function buildCodeSummary(r: CodeResult, ms: number): CodeSummary {
|
|
138
|
+
const attempts = `${r.attempts}/${r.maxAttempts} ${r.maxAttempts === 1 ? "attempt" : "attempts"}`
|
|
139
|
+
const text =
|
|
140
|
+
r.status === "passed"
|
|
141
|
+
? r.notes || "ForgeQE ran the new test and it passed."
|
|
142
|
+
: r.status === "stopped"
|
|
143
|
+
? "Stopped before the test passed."
|
|
144
|
+
: r.notes || (r.command ? "The test still fails when ForgeQE runs it." : "Scribe did not report a test to run.")
|
|
145
|
+
return {
|
|
146
|
+
status: r.status,
|
|
147
|
+
stats: `${attempts} · ${formatDuration(ms)} · ${formatTokens(r.usage.input + r.usage.output)} tokens · ${r.model || "Scribe"}`,
|
|
148
|
+
text,
|
|
149
|
+
testFile: r.testFile || "",
|
|
150
|
+
command: r.command || "",
|
|
151
|
+
reused: r.reused.join(", "),
|
|
152
|
+
changed: r.changed.join(", "),
|
|
153
|
+
repo: shortPath(r.repo),
|
|
154
|
+
browser: (r.browsers || []).join(", "),
|
|
155
|
+
review: r.review
|
|
156
|
+
? r.review.standardsLeft
|
|
157
|
+
? `approved: it tests the goal; coding standards still to fix: ${r.review.issues.filter((i) => i.kind === "standards").map((i) => i.problem).join(" ")}`
|
|
158
|
+
: `${r.review.approved ? "approved" : `not approved, ${r.review.issues.length} ${r.review.issues.length === 1 ? "issue" : "issues"}`}: ${r.review.summary}`
|
|
159
|
+
: "",
|
|
160
|
+
usage: usageLine(r.usage, r.cost),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A path as the user reads it: relative inside the working directory, otherwise ~/… or absolute. */
|
|
165
|
+
export function shortPath(dir: string): string {
|
|
166
|
+
const rel = path.relative(process.cwd(), dir)
|
|
167
|
+
if (!rel) return "."
|
|
168
|
+
if (!rel.startsWith("..") && !path.isAbsolute(rel)) return rel
|
|
169
|
+
const home = os.homedir()
|
|
170
|
+
return dir === home || dir.startsWith(home + path.sep) ? "~" + dir.slice(home.length) : dir
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function formatCodeSummary(s: CodeSummary): string {
|
|
174
|
+
const lines = [`${CODE_MARK[s.status]} ${CODE_TITLE[s.status]} · ${s.stats}`, "", s.text, ""]
|
|
175
|
+
if (s.testFile) lines.push(`Test ${s.testFile} (in ${s.repo})`)
|
|
176
|
+
if (s.command) lines.push(`Command ${s.command}`)
|
|
177
|
+
if (s.reused) lines.push(`Reused ${s.reused}`)
|
|
178
|
+
if (s.changed) lines.push(`Changed ${s.changed}`)
|
|
179
|
+
if (s.browser) lines.push(`Browser ${s.browser}`)
|
|
180
|
+
if (s.review) lines.push(`Review ${s.review}`)
|
|
181
|
+
if (s.usage) lines.push(`Usage ${s.usage}`)
|
|
182
|
+
return lines.join("\n")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function codeCard(s: CodeSummary): Card {
|
|
186
|
+
const [, , ...body] = formatCodeSummary(s).split("\n")
|
|
187
|
+
return { status: s.status === "passed" ? "ok" : s.status === "stopped" ? "stopped" : "fail", title: CODE_TITLE[s.status], stats: s.stats, body }
|
|
188
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
|
|
7
|
+
export type Log = (text: string) => void
|
|
8
|
+
|
|
9
|
+
export const WINDOWS = process.platform === "win32"
|
|
10
|
+
export const EXE = WINDOWS ? ".exe" : ""
|
|
11
|
+
|
|
12
|
+
/** ForgeQE was called WebPilot: a folder that exists only under the old name is still used, so earlier setups keep working. */
|
|
13
|
+
export function renamed(dir: string, name: string, legacy: string): string {
|
|
14
|
+
const now = path.join(dir, name)
|
|
15
|
+
const old = path.join(dir, legacy)
|
|
16
|
+
return !existsSync(now) && existsSync(old) ? old : now
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function forgeqeHome(): string {
|
|
20
|
+
return process.env.FORGEQE_HOME || renamed(os.homedir(), ".forgeqe", ".webpilot")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A project's ForgeQE folder next to forgeqe.yaml: .forgeqe, or .webpilot from before the rename. */
|
|
24
|
+
export function projectDir(base: string): string {
|
|
25
|
+
return renamed(base, ".forgeqe", ".webpilot")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Where ForgeQE keeps the programs it downloads: <name>-<version>/<binary>. */
|
|
29
|
+
export function toolsDir(): string {
|
|
30
|
+
return path.join(forgeqeHome(), "tools")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toolPath(name: string, version: string, binary: string): string {
|
|
34
|
+
return path.join(toolsDir(), `${name}-${version}`, binary)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isMusl(): boolean {
|
|
38
|
+
if (process.platform !== "linux") return false
|
|
39
|
+
try {
|
|
40
|
+
return readdirSync("/lib").some((f) => f.startsWith("ld-musl-"))
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Windows ships bsdtar (reads zip) in System32; a Git-for-Windows GNU tar earlier on PATH cannot. macOS tar reads zip too. */
|
|
47
|
+
function tarCommand(): string {
|
|
48
|
+
const system = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe")
|
|
49
|
+
return WINDOWS && existsSync(system) ? system : "tar"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findFile(dir: string, name: string): string | null {
|
|
53
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
54
|
+
const full = path.join(dir, entry.name)
|
|
55
|
+
if (entry.isFile() && entry.name === name) return full
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
const found = findFile(full, name)
|
|
58
|
+
if (found) return found
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function fetchBytes(url: string, headers?: Record<string, string>): Promise<Buffer> {
|
|
65
|
+
const res = await fetch(url, { redirect: "follow", headers })
|
|
66
|
+
if (!res.ok) throw new Error(`${url} returned ${res.status}`)
|
|
67
|
+
return Buffer.from(await res.arrayBuffer())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** SHA-256 GitHub records for a release asset, or null when the API does not answer (rate limit, proxy). */
|
|
71
|
+
export async function githubDigest(repo: string, tag: string, asset: string): Promise<string | null> {
|
|
72
|
+
try {
|
|
73
|
+
const body = await fetchBytes(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, { Accept: "application/vnd.github+json" })
|
|
74
|
+
const found = (JSON.parse(body.toString("utf8")).assets || []).find((a: { name: string }) => a.name === asset)
|
|
75
|
+
const digest = String(found?.digest || "")
|
|
76
|
+
return digest.startsWith("sha256:") ? digest.slice(7).toLowerCase() : null
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Download a release archive (zip or tar.gz), check its SHA-256 when one is known, and put `binary` at
|
|
84
|
+
* ~/.forgeqe/tools/<name>-<version>/<binary>. Older versions of the same tool are removed.
|
|
85
|
+
* No admin rights, PATH or shell profile changes.
|
|
86
|
+
*/
|
|
87
|
+
export async function installTool(opts: {
|
|
88
|
+
name: string
|
|
89
|
+
version: string
|
|
90
|
+
asset: string
|
|
91
|
+
url: string
|
|
92
|
+
binary: string
|
|
93
|
+
sha256: string | null
|
|
94
|
+
log: Log
|
|
95
|
+
/** How the logs name the tool; default: name, version, and asset. */
|
|
96
|
+
label?: string
|
|
97
|
+
}): Promise<string> {
|
|
98
|
+
const { name, version, asset, binary, log } = opts
|
|
99
|
+
const label = opts.label || `${name} ${version} (${asset})`
|
|
100
|
+
log(`Downloading ${label} into ${toolsDir()} (one time).`)
|
|
101
|
+
const archive = await fetchBytes(opts.url)
|
|
102
|
+
if (opts.sha256) {
|
|
103
|
+
if (createHash("sha256").update(archive).digest("hex") !== opts.sha256) throw new Error(`checksum mismatch for ${opts.label || asset}`)
|
|
104
|
+
} else {
|
|
105
|
+
log(`No published checksum for ${opts.label || asset} could be fetched; relying on the HTTPS download.`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const work = path.join(toolsDir(), `.${name}-${process.pid}`)
|
|
109
|
+
rmSync(work, { recursive: true, force: true })
|
|
110
|
+
mkdirSync(work, { recursive: true })
|
|
111
|
+
try {
|
|
112
|
+
const file = path.join(work, asset)
|
|
113
|
+
writeFileSync(file, archive)
|
|
114
|
+
const r = spawnSync(tarCommand(), ["-xf", file, "-C", work], { encoding: "utf8" })
|
|
115
|
+
if (r.error || r.status !== 0) throw new Error(`could not unpack ${asset}: ${r.error?.message || r.stderr}`)
|
|
116
|
+
const found = findFile(work, binary)
|
|
117
|
+
if (!found) throw new Error(`${binary} not found in ${asset}`)
|
|
118
|
+
const target = toolPath(name, version, binary)
|
|
119
|
+
rmSync(path.dirname(target), { recursive: true, force: true })
|
|
120
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
121
|
+
renameSync(found, target)
|
|
122
|
+
if (!WINDOWS) chmodSync(target, 0o755)
|
|
123
|
+
for (const old of readdirSync(toolsDir())) {
|
|
124
|
+
if (old.startsWith(`${name}-`) && old !== `${name}-${version}`) rmSync(path.join(toolsDir(), old), { recursive: true, force: true })
|
|
125
|
+
}
|
|
126
|
+
return target
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(work, { recursive: true, force: true })
|
|
129
|
+
}
|
|
130
|
+
}
|